Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa31933922 | ||
|
|
d3f77ef9cf | ||
|
|
5ccba83129 | ||
|
|
752e71198c | ||
|
|
2cb74c0186 | ||
|
|
9ab7339320 | ||
|
|
782974ecdb | ||
|
|
c47137fb49 | ||
|
|
38313639ed | ||
|
|
e933800792 | ||
|
|
60a1cc670c | ||
|
|
c0515001f1 | ||
|
|
e10fced28a | ||
|
|
3d36492518 | ||
|
|
94af4725b6 | ||
|
|
22418c17cf | ||
|
|
8904d724bb | ||
|
|
c7d551f185 | ||
|
|
6470fa86f0 | ||
|
|
8647d709ff | ||
|
|
b73d1b55d1 | ||
|
|
7db6fd7e24 | ||
|
|
e066698938 | ||
|
|
c3a97de62f | ||
|
|
29283282d5 | ||
|
|
db2c642d74 | ||
|
|
9110bc388f | ||
|
|
a4dc0b7b4e | ||
|
|
d384c3b553 | ||
|
|
d7a64fd9d6 | ||
|
|
06ddda688d | ||
|
|
6b74615969 | ||
|
|
0d73cb5dfb | ||
|
|
b1b09d54c8 | ||
|
|
9930d19ba4 | ||
|
|
902774eae1 | ||
|
|
1429a6fe1e | ||
|
|
d1da83a5d6 | ||
|
|
babacca482 | ||
|
|
e6aa79ed94 | ||
|
|
4d6b4b5b8e | ||
|
|
d259168bd7 | ||
|
|
97d87c44d0 | ||
|
|
396f5f7d60 | ||
|
|
9aef8bc393 | ||
|
|
e05ab48a45 | ||
|
|
14c2807f0a | ||
|
|
a099ab442a | ||
|
|
e9ac2de6cb | ||
|
|
c4a7f575c5 | ||
|
|
51c3a69be7 | ||
|
|
717b1d8397 | ||
|
|
f291480565 | ||
|
|
95b5a81924 | ||
|
|
9da27df249 | ||
|
|
b716f95a73 | ||
|
|
6c49427c7c | ||
|
|
2a41e73bf9 | ||
|
|
16daf6ea03 | ||
|
|
2704d5dc23 | ||
|
|
2f5b133000 | ||
|
|
1f21a5213f | ||
|
|
d4c066622b | ||
|
|
e141abc849 | ||
|
|
1f4afe082b | ||
|
|
e0747c12ee | ||
|
|
f90cd6abc4 | ||
|
|
63e087f3ef | ||
|
|
f8b8e0b108 | ||
|
|
512adab7e3 | ||
|
|
4cdc15fc3c | ||
|
|
5e67671f57 | ||
|
|
155d99a069 | ||
|
|
d863b1fd4b | ||
|
|
70aaf0aac1 | ||
|
|
de56229ef2 | ||
|
|
1ff23790ae | ||
|
|
198407ebf5 | ||
|
|
de68d68fb7 | ||
|
|
f285a2bd64 | ||
|
|
84b6d0fd8b | ||
|
|
f972068143 | ||
|
|
7882b254a0 | ||
|
|
ae5d397512 | ||
|
|
7a022596c3 | ||
|
|
8c575e8ed8 | ||
|
|
85fbab9eb4 | ||
|
|
1119d8ed73 | ||
|
|
32f0a67dbc | ||
|
|
baf094d026 | ||
|
|
f1b9f4ba50 | ||
|
|
cc20d29636 | ||
|
|
5f713a033b | ||
|
|
079ec57204 | ||
|
|
cd247e9c47 | ||
|
|
8af6694152 | ||
|
|
751f3c1685 | ||
|
|
5c2f206c74 | ||
|
|
acc61db6f2 | ||
|
|
d671c606a1 | ||
|
|
fa3c57467b | ||
|
|
befee332d2 | ||
|
|
a29c33b50a | ||
|
|
3dd596ba50 | ||
|
|
e50fe0d4db | ||
|
|
3d3ad8f0ef | ||
|
|
d0ed4b4dfe | ||
|
|
5306f7c548 | ||
|
|
ddb596affc | ||
|
|
bfc8ba851a | ||
|
|
3f9e60843d | ||
|
|
2fac6ebfb8 | ||
|
|
dda9fd1433 | ||
|
|
3516d3c727 | ||
|
|
0b7203df0f | ||
|
|
344795a8d9 | ||
|
|
638fc7db4e | ||
|
|
ab3e0e717a | ||
|
|
c9eae3b3a1 | ||
|
|
6f615f4c32 | ||
|
|
52eacf87b9 | ||
|
|
c4f1cc23d7 | ||
|
|
4f1390fdeb | ||
|
|
c0ca6d3102 | ||
|
|
0872d3dc8d | ||
|
|
1f889b0965 | ||
|
|
0b9fe5451f | ||
|
|
fd700f412e | ||
|
|
f7d4f9d53c | ||
|
|
84aced7b4e | ||
|
|
94b1f5aa48 | ||
|
|
c51c3655d5 | ||
|
|
404a1e847c | ||
|
|
dcea4fdd5e | ||
|
|
701f96adb3 | ||
|
|
1ebfb286ad | ||
|
|
b5f15dfdb7 | ||
|
|
aee4bd78db | ||
|
|
798a33495e | ||
|
|
4c6c1aab60 | ||
|
|
848ed9774d | ||
|
|
b32e102ff9 | ||
|
|
e8feb11983 | ||
|
|
3e12fca517 | ||
|
|
8df483d8c7 | ||
|
|
1e63e2469a | ||
|
|
e1c28e767a | ||
|
|
fe5645c818 | ||
|
|
38570b1723 | ||
|
|
a4f476945d | ||
|
|
20fd9ff4de | ||
|
|
569dde9985 | ||
|
|
08f344403b | ||
|
|
be58cee989 | ||
|
|
7c11e2b3c9 | ||
|
|
f2913a7c7d | ||
|
|
a27a5be3dd | ||
|
|
964136b540 | ||
|
|
569f688fbf | ||
|
|
1d050f8469 | ||
|
|
2e4c0f9eea | ||
|
|
fd0b866339 | ||
|
|
1518ba04dd | ||
|
|
c5672c64fb | ||
|
|
5997579f54 | ||
|
|
f77d2e9103 | ||
|
|
6350e9dabc | ||
|
|
7723c134ff | ||
|
|
d11ed904a9 | ||
|
|
9db7b6b55f | ||
|
|
1460706e60 | ||
|
|
27d624758a | ||
|
|
5288821605 | ||
|
|
58e3ecc97a | ||
|
|
38c0694a08 | ||
|
|
71d25bb62f | ||
|
|
ef825b801a | ||
|
|
50cbd66bfd | ||
|
|
d564c874a3 | ||
|
|
568abb0e33 | ||
|
|
44781f002c | ||
|
|
941fa15251 | ||
|
|
60c7bd713e | ||
|
|
6f193e0c24 | ||
|
|
6bb85d746c | ||
|
|
bbd43b5948 | ||
|
|
180331805f | ||
|
|
9e96b1c24e | ||
|
|
40b4b26074 | ||
|
|
9863b9d88e | ||
|
|
75602b6a00 | ||
|
|
22da11514b | ||
|
|
9953557af0 | ||
|
|
8f7066d194 | ||
|
|
75c02f443f | ||
|
|
31100b8f87 | ||
|
|
152ec99262 | ||
|
|
ce401c0f59 | ||
|
|
3035fb046f | ||
|
|
4659b81538 | ||
|
|
c78dbee60b | ||
|
|
4b4c801148 | ||
|
|
2e4d3d4bc6 | ||
|
|
26ccf9e3b4 | ||
|
|
bc322a1e69 | ||
|
|
6ee3849463 | ||
|
|
abb249e5df | ||
|
|
0352312f25 | ||
|
|
ae66f8d89d | ||
|
|
55be19ede7 | ||
|
|
b508551d02 | ||
|
|
7ee329e046 | ||
|
|
1512b9afc0 | ||
|
|
ad48f4394a | ||
|
|
8d79145dba | ||
|
|
b821f8cc27 | ||
|
|
bebb394f54 | ||
|
|
2ba0003e16 | ||
|
|
4c1d0931a1 | ||
|
|
5a70cf95e0 | ||
|
|
66c5f0f52c | ||
|
|
8353b28b33 | ||
|
|
229992853b | ||
|
|
f0d87d594a | ||
|
|
196e51e91b | ||
|
|
0879030dc8 | ||
|
|
05e2837f6b | ||
|
|
241544cd08 | ||
|
|
7341e47a1b | ||
|
|
f238ca5898 |
+13
-4
@@ -49,6 +49,11 @@ JMAP_SERVER_URL=https://your-jmap-server.com
|
|||||||
# OpenID Connect issuer URL for discovery
|
# OpenID Connect issuer URL for discovery
|
||||||
# OAUTH_ISSUER_URL=https://your-idp.example.com
|
# OAUTH_ISSUER_URL=https://your-idp.example.com
|
||||||
|
|
||||||
|
# Overrides only the user-facing authorize endpoint (e.g. a per-brand login
|
||||||
|
# host). Discovery, token exchange and refresh keep using OAUTH_ISSUER_URL.
|
||||||
|
# Leave unset to use the authorization_endpoint from discovery.
|
||||||
|
# OAUTH_AUTHORIZE_URL=https://login.your-brand.example.com/application/o/authorize/
|
||||||
|
|
||||||
# Allow OAuth discovery to resolve to private (RFC-1918 / loopback) addresses.
|
# Allow OAuth discovery to resolve to private (RFC-1918 / loopback) addresses.
|
||||||
# Off by default as an SSRF guard. Enable for split-DNS deployments where the
|
# Off by default as an SSRF guard. Enable for split-DNS deployments where the
|
||||||
# OAuth issuer's public hostname resolves to an internal IP from this server.
|
# OAuth issuer's public hostname resolves to an internal IP from this server.
|
||||||
@@ -110,12 +115,16 @@ JMAP_SERVER_URL=https://your-jmap-server.com
|
|||||||
# Anonymous Telemetry
|
# Anonymous Telemetry
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
# Anonymous instance telemetry is enabled by default. Heartbeats contain no PII:
|
# Anonymous instance telemetry is OPT-IN and disabled by default. Enabling it
|
||||||
# version, platform, bucketed account counts, and feature toggles only. See
|
# helps us understand how Bulwark is used so we can make the product better.
|
||||||
|
# Heartbeats contain no PII: version, platform, bucketed account counts, and
|
||||||
|
# feature toggles only - never email addresses, hostnames, or IPs. See
|
||||||
# https://bulwarkmail.org/docs/legal/privacy/telemetry for the full schema.
|
# https://bulwarkmail.org/docs/legal/privacy/telemetry for the full schema.
|
||||||
#
|
#
|
||||||
# Disable telemetry entirely (overrides the admin UI):
|
# Enable telemetry (also toggleable in the admin UI):
|
||||||
# BULWARK_TELEMETRY=off
|
# BULWARK_TELEMETRY=on
|
||||||
|
#
|
||||||
|
# Setting this (on or off) locks the choice and disables the admin UI toggle.
|
||||||
|
|
||||||
# Directory for telemetry state: instance id, consent, login HMACs
|
# Directory for telemetry state: instance id, consent, login HMACs
|
||||||
# (default: ./data/telemetry). For Docker, the default resolves to
|
# (default: ./data/telemetry). For Docker, the default resolves to
|
||||||
|
|||||||
+206
@@ -1,5 +1,211 @@
|
|||||||
# Changelog
|
# 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
|
||||||
|
|
||||||
|
- **S/MIME**: The built-in S/MIME implementation has been removed from core and re-delivered through the new generic crypto plugin hooks (privileged same-origin plugin tier). S/MIME signing, encryption, decryption, certificate management, and the related settings UI now live in a plugin rather than the main app. Deployments that relied on built-in S/MIME must install the S/MIME crypto plugin to retain those features.
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Plugins**: Privileged same-origin plugin tier with a crypto API surface
|
||||||
|
- **Plugins**: Plugin hooks for email details, headers, and source
|
||||||
|
- **Mail**: Option to hide the total message count on folders (#498)
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Mail**: Hide the server scheduled folder when the virtual one is shown (#495)
|
||||||
|
- **Mail**: Stop the unified mailbox from mutating client-returned email objects
|
||||||
|
- **Composer**: HTML-escape sender and subject in the reply/forward quote header (#482)
|
||||||
|
- **Calendar**: Send calendar invites by setting `organizerCalendarAddress`
|
||||||
|
- **Identity**: Sync the default identity (`preferredPrimaryId`) to server settings (#507)
|
||||||
|
- **Auth**: Support MFA login via the structured auth endpoint
|
||||||
|
- **Admin**: Show all built-in themes in the admin theme controls (#496)
|
||||||
|
- **i18n**: Add missing translation keys across 19 locales
|
||||||
|
|
||||||
|
## 1.7.5 (2026-06-24)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Mail**: Cross-account "All accounts" views with full group/shared-account support
|
||||||
|
- **Mail**: Per-account "All Mail" folder selection
|
||||||
|
- **Mail**: "Download all" button to bundle attachments into a zip (#466)
|
||||||
|
- **Mail**: Return to the list after deleting or marking the open message unread — configurable (default on)
|
||||||
|
- **Mail**: Collapse-all-threads action in thread-list selection
|
||||||
|
- **Calendar**: Option to disable the calendar
|
||||||
|
- **Composer**: Send-now button on scheduled/delayed messages
|
||||||
|
- **Composer**: Email a contact or group via the in-app composer
|
||||||
|
- **Composer**: Split a pasted address list into recipient chips
|
||||||
|
- **Contacts**: "New address book" creation UI (#415)
|
||||||
|
- **OAuth**: `OAUTH_AUTHORIZE_URL` to override the authorize endpoint
|
||||||
|
- **i18n**: Farsi (fa) locale — complete (2654 strings)
|
||||||
|
- **i18n**: Romanian (ro) locale
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Composer**: Keep HTML signature styling in the editor and on send
|
||||||
|
- **Composer**: Guard Send against double-submit
|
||||||
|
- **Composer**: Strip display names from the `EmailSubmission` envelope addresses
|
||||||
|
- **Calendar**: Disable iMIP scheduling on calendar import (#411)
|
||||||
|
- **Mail**: Localize special-folder names by JMAP role (#404)
|
||||||
|
- **Mail**: Block remaining email tracking vectors (#457)
|
||||||
|
- **Mail**: Route counter and unread updates to the email's own account in aggregate views
|
||||||
|
- **Mail**: Fix blank space in plain-text emails
|
||||||
|
- **Mail**: Fix toolbar re-render when opening emails
|
||||||
|
- **Mail**: Truncate long subjects so they don't overlap the timestamp
|
||||||
|
- **Mail**: Strip reply/forward prefixes followed by a full-width colon
|
||||||
|
- **Mail**: Add breathing room between the unread dot and the avatar
|
||||||
|
- **Mail**: Isolate per-account state snapshots from leakage and mutation
|
||||||
|
- **Mail**: Cap filename tokens at the full 200-char limit
|
||||||
|
- **Spam**: Fetch mailboxes with `accountId` in `markAsSpam`
|
||||||
|
- **Filters**: Load mailboxes when opened directly (#485)
|
||||||
|
- **Settings**: Surface server errors on password change and TOTP toggle
|
||||||
|
- **Send now**: Gate the toolbar label and translate `send_now` across locales
|
||||||
|
- **Directory**: Fix fetching display names
|
||||||
|
- **Push**: Reap only relay-confirmed-dead leftover subscriptions
|
||||||
|
- **i18n**: Add the missing fa locale to the client `IntlProvider` messages map
|
||||||
|
- **i18n**: Add missing translation keys across 19 locales
|
||||||
|
|
||||||
|
## 1.7.4 (2026-06-15)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Mail**: New "All Mail" view across folders and accounts
|
||||||
|
- **Mail**: Edit contact directly from the email viewer contact sidebar
|
||||||
|
- **Calendar**: Recurrence editor, set-default calendar, and timezone-aware calendar queries
|
||||||
|
- **Calendar**: Agenda plugin sidecar
|
||||||
|
- **Composer**: Email display name support
|
||||||
|
- **Composer**: Drag-and-drop recipient chips between To/CC/BCC fields, with the address shown in the drag preview
|
||||||
|
- **Composer**: Avatars in recipient autocomplete suggestions, including directory users
|
||||||
|
- **Files**: JMAP file/folder sharing in the Files app (#408)
|
||||||
|
- **Auth**: QR-code SSO login and device pairing between webmail and the mobile app
|
||||||
|
- **Auth**: Require re-authentication for device pairing and SSO
|
||||||
|
- **Accounts**: Manage shared/group account settings from the Accounts page
|
||||||
|
- **Setup**: Opt-in telemetry in the web setup wizard
|
||||||
|
- **Mail**: Persist the email detail sidebar state
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Mail**: Preserve line breaks in the generated `text/plain` alternative (#421)
|
||||||
|
- **Mail**: Fix inconsistent threading of email messages in the inbox and folders
|
||||||
|
- **Mail**: Stop draft emails from being marked as unread
|
||||||
|
- **Mail**: Prevent wide email tables from rendering with rotated headers (#409)
|
||||||
|
- **Mail**: Preserve the folder list when a mailbox refetch hits the concurrent-request limit
|
||||||
|
- **Mail**: Correct dark-mode background-image inversion and height clipping in the email viewer
|
||||||
|
- **Calendar**: Dedupe scheduling emails and use Stalwart-compatible calendar filters
|
||||||
|
- **Calendar**: Redesign the custom recurrence editor to match the modal UI
|
||||||
|
- **Files**: Don't send the connected-account key as the JMAP `accountId` when sharing files (#408)
|
||||||
|
- **Routing**: Strip the build-time `basePath` from `router.push` redirects after login (#390)
|
||||||
|
- **Nav**: Open recent contact emails at `/` instead of 404ing on `/mail`
|
||||||
|
- **Nav**: Hide the Add App button when `sidebarAppsEnabled` is false
|
||||||
|
- **Settings**: Move the "Plain Text Only" setting from Reading to Composing (#422)
|
||||||
|
- **Privacy**: Make telemetry opt-in
|
||||||
|
- **UI**: Fix the context menu being invisible on first right-click after page load
|
||||||
|
- **Admin**: Remove the JMAP status from the admin dashboard
|
||||||
|
- **i18n**: Add missing translation keys across 17 locales
|
||||||
|
|
||||||
|
## 1.7.3 (2026-06-04)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Mail**: Inline attachment preview — reliable MIME detection with inline PDF on desktop and mobile
|
||||||
|
- **Mail**: Preview composer attachments inline (click to open)
|
||||||
|
- **Mail**: Preview `.eml` (`message/rfc822`) attachments like an email
|
||||||
|
- **Mail**: Read receipts (MDN, RFC 8098)
|
||||||
|
- **Mail**: Editable, layout-preserving quote island when replying
|
||||||
|
- **Mail**: Surface the most severe SPF result and hide the "via" badge on spoofed mail
|
||||||
|
- **Calendar**: Per-viewer colors for shared calendars (#345)
|
||||||
|
- **Filters**: Extended filter rules — attachment field and multi-value conditions
|
||||||
|
- **Settings**: New built-in themes — Aurora Glass and Elastic
|
||||||
|
- **Settings**: Theme cards render as a mini mailbox mockup from theme colors, with light/dark variant chips
|
||||||
|
- **Plugins**: Localizable sandboxed plugins (manifest locales + `api.i18n.t`)
|
||||||
|
- **Plugins**: `/api/translate` proxy and email body exposed to plugins
|
||||||
|
- **Admin**: Toggle for search-engine indexing (robots)
|
||||||
|
- **Admin**: `passwordHashFile` in `admin.json`
|
||||||
|
- **Admin**: `sessionSecretFile` and `oauthClientSecretFile` for file-based secrets in JSON config
|
||||||
|
- **PWA**: Configurable install screenshots (per-domain)
|
||||||
|
- **i18n**: Hungarian locale support
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Files**: Store Files as real `FileNode` hierarchy, migrate legacy flat-named files on load, and list folders via `FileNode/get` so they are visible (#379)
|
||||||
|
- **Files**: Treat a blob-less `FileNode` as the only folder signal and migrate legacy dir-markers
|
||||||
|
- **Mail**: Empty Trash for shared and group folders (#387)
|
||||||
|
- **Mail**: Move mail from a shared group inbox to a personal inbox (#375)
|
||||||
|
- **Mail**: Preserve the HTML signature when sending a quick reply
|
||||||
|
- **Mail**: Stop body clipping under the fold when the email sets `html`/`body` `height: 100%`
|
||||||
|
- **Mail**: Drop single-letter `R:`/`I:` subject prefix tokens and deduplicate localized reply/forward prefixes
|
||||||
|
- **Mail**: No more 404 console spam for missing sender favicons
|
||||||
|
- **Auth**: Discover OIDC metadata server-side to avoid CORS failures (#382)
|
||||||
|
- **Send**: Route the Sent copy to the shared-mailbox account on per-identity send
|
||||||
|
- **Routing**: Honour `basePath` in the plugin sandbox, `http.post` proxy, and branding
|
||||||
|
- **i18n**: Localize the PWA install prompt, reply/forward quote header (incl. sender address), `<html lang>`, and per-locale `<head>` description; add missing `settings.folders.role_memos` key
|
||||||
|
- **Themes**: Plugin slot iframes inherit host font and color tokens
|
||||||
|
- **Theme**: Gate preview "open in new tab" on inline-safe MIME types
|
||||||
|
- **Appearance**: Move Themes settings into the Appearance category with a distinct tab icon; clicking the active theme is a no-op
|
||||||
|
- **UI**: Fix invisible dark-mode borders (border token collided with secondary)
|
||||||
|
- **UI**: Remove the 16px empty strip beside the collapsed sidebar
|
||||||
|
- **UI**: Align top bars to a uniform `h-14` height and the account selector header to the search/reply toolbars
|
||||||
|
- **UI**: Close pane gaps by centering the resize handle on the seam
|
||||||
|
- **Settings**: Fix section gears permanently hijacking the active tab
|
||||||
|
|
||||||
## 1.7.2 (2026-05-28)
|
## 1.7.2 (2026-05-28)
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
+21
-10
@@ -4,10 +4,15 @@
|
|||||||
|
|
||||||
- Read, compose, reply, reply-all, and forward with a Tiptap rich text editor (inline images, drag-and-drop embedding, tables)
|
- Read, compose, reply, reply-all, and forward with a Tiptap rich text editor (inline images, drag-and-drop embedding, tables)
|
||||||
- Gmail-style threading with inline expansion and an optional conversation toggle
|
- Gmail-style threading with inline expansion and an optional conversation toggle
|
||||||
- Unified mailbox view across all connected accounts
|
- Unified mailbox view across all connected accounts – combined Inbox, Sent, Drafts, Junk, Archive, and Trash, with group/shared accounts optionally merged in
|
||||||
|
- Cross-account "All accounts" views – All unread, All starred, and All mail spanning every account (including shared/group folders); each aggregate list labels the source folder of every message
|
||||||
|
- "All Mail" view that merges an account's folders (with a configurable folder selection) into a single list
|
||||||
- Three selectable mail layouts: split (three-pane), focused list, and reading pane at bottom
|
- Three selectable mail layouts: split (three-pane), focused list, and reading pane at bottom
|
||||||
- Draft auto-save with identity preservation, persisted HTML body, and proper `In-Reply-To` / `References` headers on replies
|
- Draft auto-save with identity preservation, persisted HTML body, and proper `In-Reply-To` / `References` headers on replies
|
||||||
- Attachment upload, download, drag-out to local file system, and inline preview; image thumbnails and forgotten-attachment warning
|
- Attachment upload, download, drag-out to local file system, and inline preview – images, inline PDF on desktop and mobile, composer attachments (click to open), and `.eml` (`message/rfc822`) attachments rendered like an email; image thumbnails and forgotten-attachment warning
|
||||||
|
- Scheduled send and configurable send delay
|
||||||
|
- Read receipts (MDN, RFC 8098)
|
||||||
|
- Editable, layout-preserving quote island when replying
|
||||||
- Full-text search with JMAP filter panel, search chips, wildcards, OR conditions, and cross-mailbox queries
|
- Full-text search with JMAP filter panel, search chips, wildcards, OR conditions, and cross-mailbox queries
|
||||||
- Batch operations – multi-select, archive, delete, move, tag
|
- Batch operations – multi-select, archive, delete, move, tag
|
||||||
- Archive modes – direct, by year, or by month
|
- Archive modes – direct, by year, or by month
|
||||||
@@ -36,7 +41,7 @@
|
|||||||
- Auto-generated birthday calendar from contacts
|
- Auto-generated birthday calendar from contacts
|
||||||
- Virtual locations (video conference URLs) as first-class event fields
|
- Virtual locations (video conference URLs) as first-class event fields
|
||||||
- Task management with due dates, priority, and completion status
|
- Task management with due dates, priority, and completion status
|
||||||
- Shared calendars with CalDAV discovery and multi-account home resolution
|
- Shared calendars with CalDAV discovery, multi-account home resolution, and per-viewer colors
|
||||||
- Week numbers, event hover preview, notifications with sound picker
|
- Week numbers, event hover preview, notifications with sound picker
|
||||||
- Real-time sync via JMAP push
|
- Real-time sync via JMAP push
|
||||||
|
|
||||||
@@ -52,7 +57,7 @@
|
|||||||
## Filters & Templates
|
## Filters & Templates
|
||||||
|
|
||||||
- Server-side filters via JMAP Sieve Scripts (RFC 9661)
|
- Server-side filters via JMAP Sieve Scripts (RFC 9661)
|
||||||
- Visual rule builder with expanded view; conditions (From, To, Subject, Size, Body…) and actions (Move, Forward, Star, Discard…)
|
- Visual rule builder with expanded view; conditions (From, To, Subject, Size, Body, Attachment…) with multi-value matching and actions (Move, Forward, Star, Discard…)
|
||||||
- Preserves rules authored in other clients
|
- Preserves rules authored in other clients
|
||||||
- Raw Sieve editor with syntax validation
|
- Raw Sieve editor with syntax validation
|
||||||
- Vacation responder with date range scheduling
|
- Vacation responder with date range scheduling
|
||||||
@@ -60,19 +65,20 @@
|
|||||||
|
|
||||||
## Files
|
## Files
|
||||||
|
|
||||||
- JMAP FileNode browser (Stalwart native cloud storage)
|
- JMAP FileNode browser (Stalwart native cloud storage) with a real folder hierarchy; legacy flat-named files are migrated into nested `FileNode` folders automatically on load
|
||||||
- Streamed WebDAV PUT upload and folder upload with progress tracking
|
- Streamed WebDAV PUT upload and folder upload with progress tracking
|
||||||
- Dynamic upload limits based on server configuration
|
- Dynamic upload limits based on server configuration
|
||||||
- Grid and list views with sorting by name, size, or date
|
- Grid and list views with sorting by name, size, or date
|
||||||
- Previews for images, text, audio, and video
|
- Previews for images, text, audio, and video
|
||||||
- Clipboard operations (cut, copy, paste, duplicate), favorites, and recent files
|
- Clipboard operations (cut, copy, paste, duplicate), favorites, and recent files
|
||||||
|
- JMAP sharing (RFC 9670) for files and folders – share with users or groups at read, read/write, or manager levels via a principal picker, with share indicators and a "Shared with me" sidebar section for folders other principals have shared with you
|
||||||
|
|
||||||
## Security & Privacy
|
## Security & Privacy
|
||||||
|
|
||||||
- External content blocked by default, with a trusted senders list
|
- External content blocked by default, with a trusted senders list
|
||||||
- HTML sanitization via DOMPurify
|
- HTML sanitization via DOMPurify
|
||||||
- S/MIME – manage certificates, sign, encrypt, decrypt, and verify; legacy 3DES / PBE support; per-account key isolation
|
- S/MIME – manage certificates, sign, encrypt, decrypt, and verify; legacy 3DES / PBE support; per-account key isolation
|
||||||
- SPF / DKIM / DMARC status indicators
|
- SPF / DKIM / DMARC status indicators – surfaces the most severe SPF result and hides the "via" badge on spoofed mail
|
||||||
- OAuth2 / OIDC with PKCE (Keycloak, Authentik, or built-in), OAuth-only mode, OAuth app passwords, and non-interactive SSO for embedded deployments
|
- OAuth2 / OIDC with PKCE (Keycloak, Authentik, or built-in), OAuth-only mode, OAuth app passwords, and non-interactive SSO for embedded deployments
|
||||||
- TOTP two-factor authentication
|
- TOTP two-factor authentication
|
||||||
- Account security panel for password and 2FA management via the Stalwart admin API
|
- Account security panel for password and 2FA management via the Stalwart admin API
|
||||||
@@ -85,6 +91,7 @@
|
|||||||
|
|
||||||
- Selectable mail layouts (split three-pane, focused list, reading pane at bottom) with resizable columns
|
- Selectable mail layouts (split three-pane, focused list, reading pane at bottom) with resizable columns
|
||||||
- Dark and light themes with intelligent email color transformation
|
- Dark and light themes with intelligent email color transformation
|
||||||
|
- Bundled color themes including Aurora Glass and Elastic; theme cards render as a mini mailbox mockup built from the theme's own colors, with light/dark variant chips
|
||||||
- Responsive desktop, tablet, and mobile layouts
|
- Responsive desktop, tablet, and mobile layouts
|
||||||
- Full keyboard navigation
|
- Full keyboard navigation
|
||||||
- Drag-and-drop email organization and tag assignment
|
- Drag-and-drop email organization and tag assignment
|
||||||
@@ -98,7 +105,7 @@
|
|||||||
|
|
||||||
## Internationalization
|
## Internationalization
|
||||||
|
|
||||||
17 languages: Česky · Dansk · Deutsch · English · Español · Français · Italiano · Latviešu · Nederlands · Polski · Português · Türkçe · Русский · Українська · 한국어 · 日本語 · 简体中文
|
19 languages: Česky · Dansk · Deutsch · English · Español · Français · Italiano · Latviešu · Magyar · Nederlands · Polski · Português · Română · Türkçe · Русский · Українська · 한국어 · 日本語 · 简体中文
|
||||||
|
|
||||||
Automatic browser detection with persistent preference. Configurable locale URL prefix via `NEXT_PUBLIC_LOCALE_PREFIX`.
|
Automatic browser detection with persistent preference. Configurable locale URL prefix via `NEXT_PUBLIC_LOCALE_PREFIX`.
|
||||||
|
|
||||||
@@ -110,6 +117,7 @@ Automatic browser detection with persistent preference. Configurable locale URL
|
|||||||
- Configurable signature position (above or below quoted text)
|
- Configurable signature position (above or below quoted text)
|
||||||
- Sub-addressing (`user+tag@domain.com`) with configurable delimiter and contextual tag suggestions
|
- Sub-addressing (`user+tag@domain.com`) with configurable delimiter and contextual tag suggestions
|
||||||
- Shared folders across accounts
|
- Shared folders across accounts
|
||||||
|
- Shared / group (delegated) accounts: their folders appear alongside your own and can be merged into the unified and "All accounts" views ("Include group inboxes"); their messages are fully actionable there – open, mark read, spam / not-spam, move, delete, and archive – with folder unread counts kept in sync
|
||||||
- Multiple JMAP servers per deployment with optional auto-pick by email domain
|
- Multiple JMAP servers per deployment with optional auto-pick by email domain
|
||||||
- Optional custom JMAP endpoints on the login form (`ALLOW_CUSTOM_JMAP_ENDPOINT`)
|
- Optional custom JMAP endpoints on the login form (`ALLOW_CUSTOM_JMAP_ENDPOINT`)
|
||||||
|
|
||||||
@@ -117,8 +125,11 @@ Automatic browser detection with persistent preference. Configurable locale URL
|
|||||||
|
|
||||||
- Web setup wizard for first launch – guides through JMAP server(s), OAuth/OIDC, session secret, logging, branding (with file upload), and admin password; persists to the admin config dir, no `.env.local` editing required
|
- Web setup wizard for first launch – guides through JMAP server(s), OAuth/OIDC, session secret, logging, branding (with file upload), and admin password; persists to the admin config dir, no `.env.local` editing required
|
||||||
- Stalwart admin dashboard with dedicated policy sections, collapsed into a single tabbed page
|
- Stalwart admin dashboard with dedicated policy sections, collapsed into a single tabbed page
|
||||||
|
- Admin policy gates for the aggregate mail views – enable or disable the "All Mail" and the cross-account "All unread / starred / all" entries org-wide; each gated view still respects the user's own toggle
|
||||||
- Split admin storage: `ADMIN_CONFIG_DIR` (operator-authored, mountable read-only after setup) and `ADMIN_STATE_DIR` (runtime audit log and login timestamps)
|
- Split admin storage: `ADMIN_CONFIG_DIR` (operator-authored, mountable read-only after setup) and `ADMIN_STATE_DIR` (runtime audit log and login timestamps)
|
||||||
- Plugin system – schema-driven config UI, render and intercept hooks, `onAvatarResolve`, `onBeforeEmailSend`, composer-sidebar and email-banner slots, calendar event slots, i18n APIs, and managed policy enforcement
|
- File-based secrets for JSON config: `passwordHashFile` (admin password), `sessionSecretFile`, and `oauthClientSecretFile` for Docker/Kubernetes secret mounts
|
||||||
|
- Admin toggle for search-engine indexing (`robots.txt` / `noindex`)
|
||||||
|
- Plugin system – schema-driven config UI, render and intercept hooks, `onAvatarResolve`, `onBeforeEmailSend`, composer-sidebar and email-banner slots, calendar event slots, i18n APIs (localizable sandboxed plugins via manifest locales and `api.i18n.t`), an `/api/translate` proxy, email-body access, and managed policy enforcement
|
||||||
- Plugin hot-reload and dev-folder loading, on-demand `src/` bundling via esbuild, and `http:fetch` permission with `httpOrigins`
|
- Plugin hot-reload and dev-folder loading, on-demand `src/` bundling via esbuild, and `http:fetch` permission with `httpOrigins`
|
||||||
- Themes – upload, enforce, and manage admin-controlled themes as ZIP bundles
|
- Themes – upload, enforce, and manage admin-controlled themes as ZIP bundles
|
||||||
- Extension marketplace – browse and install plugins and themes from a configurable directory (`EXTENSION_DIRECTORY_URL`); install/uninstall restricted to the admin dashboard
|
- Extension marketplace – browse and install plugins and themes from a configurable directory (`EXTENSION_DIRECTORY_URL`); install/uninstall restricted to the admin dashboard
|
||||||
@@ -126,10 +137,10 @@ Automatic browser detection with persistent preference. Configurable locale URL
|
|||||||
|
|
||||||
## Operations
|
## Operations
|
||||||
|
|
||||||
- Progressive Web App with service worker, install prompt, web push notifications for inbox mail, and dynamic manifest
|
- Progressive Web App with service worker, install prompt, web push notifications for inbox mail, dynamic manifest, and configurable (per-domain) install screenshots
|
||||||
- Automatic update check with server-side logging of new releases and a non-dismissible update notice
|
- Automatic update check with server-side logging of new releases and a non-dismissible update notice
|
||||||
- Structured logging (`text` or `json`) with category-based levels
|
- Structured logging (`text` or `json`) with category-based levels
|
||||||
- Anonymous instance telemetry (opt-out via admin UI or `BULWARK_TELEMETRY=off`) – version, platform, bucketed account counts, feature toggles only
|
- Anonymous instance telemetry (opt-in via admin UI, the installer, or `BULWARK_TELEMETRY=on`; off by default) – version, platform, bucketed account counts, feature toggles only
|
||||||
- Release (`main`) and development (`dev`) Docker images on GHCR
|
- Release (`main`) and development (`dev`) Docker images on GHCR
|
||||||
- Subpath deployment via `NEXT_PUBLIC_BASE_PATH` for mounting behind a reverse proxy
|
- Subpath deployment via `NEXT_PUBLIC_BASE_PATH` for mounting behind a reverse proxy
|
||||||
- Demo mode with fixture data – no mail server required
|
- Demo mode with fixture data – no mail server required
|
||||||
|
|||||||
@@ -12,10 +12,8 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
|
|||||||
|
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://discord.gg/tYCujymGrT)
|
[](https://discord.gg/tYCujymGrT)
|
||||||
[](CHANGELOG.md)
|
[](CHANGELOG.md)
|
||||||
[](https://ghcr.io/bulwarkmail/webmail)
|
[](https://ghcr.io/bulwarkmail/webmail)
|
||||||
[](https://grafana.external.bulwarkmail.org/)
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -81,12 +79,12 @@ The wizard writes to `ADMIN_CONFIG_DIR` (`./data/admin` by default). Setting `JM
|
|||||||
|
|
||||||
Bulwark is a full webmail suite, not just an inbox. It bundles the four apps most self-hosters end up wanting on the same login:
|
Bulwark is a full webmail suite, not just an inbox. It bundles the four apps most self-hosters end up wanting on the same login:
|
||||||
|
|
||||||
- **Mail** – threading, unified inbox, full-text search, Sieve filters, S/MIME, templates
|
- **Mail** – threading, unified inbox, cross-account "All accounts" views, full-text search, Sieve filters, S/MIME, templates
|
||||||
- **Calendar** – month/week/day/agenda, recurring events, iMIP invitations, CalDAV subscriptions
|
- **Calendar** – month/week/day/agenda, recurring events, iMIP invitations, CalDAV subscriptions
|
||||||
- **Contacts** – multiple address books, groups, vCard import/export
|
- **Contacts** – multiple address books, groups, vCard import/export
|
||||||
- **Files** – Stalwart's JMAP FileNode storage with previews and folder upload
|
- **Files** – Stalwart's JMAP FileNode storage with previews and folder upload
|
||||||
|
|
||||||
Plus the infrastructure around them: a web setup wizard, OAuth2 / OIDC SSO, TOTP 2FA, multi-account with HTTP/2 connection pooling, 15 languages, PWA install, dark/light themes, a plugin system with an extension marketplace, and an admin dashboard.
|
Plus the infrastructure around them: a web setup wizard, OAuth2 / OIDC SSO, TOTP 2FA, multi-account with HTTP/2 connection pooling, 18 languages, PWA install, dark/light themes, a plugin system with an extension marketplace, and an admin dashboard.
|
||||||
|
|
||||||
Full feature list: **[FEATURES.md](FEATURES.md)**.
|
Full feature list: **[FEATURES.md](FEATURES.md)**.
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Suspense, useEffect, useState } from "react";
|
|||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { apiFetch, getPathPrefix } from "@/lib/browser-navigation";
|
import { apiFetch, getPathPrefix, toRouterPath } from "@/lib/browser-navigation";
|
||||||
import { Loader2, AlertCircle } from "lucide-react";
|
import { Loader2, AlertCircle } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
@@ -32,6 +32,42 @@ function OAuthCallbackInner() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Step-up re-auth for device pairing: the QR generator sent the user here
|
||||||
|
// via prompt=login. Don't create a login session — just confirm the fresh
|
||||||
|
// auth (sets the short-lived pairing proof cookie) and bounce back to the
|
||||||
|
// Security settings, where the QR generation auto-resumes.
|
||||||
|
let pairReauthResume = false;
|
||||||
|
try {
|
||||||
|
pairReauthResume = sessionStorage.getItem("pair_reauth_resume") === "1";
|
||||||
|
} catch { /* sessionStorage unavailable */ }
|
||||||
|
if (pairReauthResume && state) {
|
||||||
|
try { sessionStorage.removeItem("pair_reauth_resume"); } catch { /* ignore */ }
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const res = await apiFetch("/api/auth/reauth/sso/complete", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ code, state }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
setError("token_exchange_failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem("pair_reauth_done", "1");
|
||||||
|
// Land back on the Security tab (readPersistedTab reads this key).
|
||||||
|
sessionStorage.setItem("settings-deep-link-tab", "security");
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
const prefix = getPathPrefix(params.locale as string);
|
||||||
|
router.push(toRouterPath(`${prefix}/${params.locale}/settings`));
|
||||||
|
} catch {
|
||||||
|
setError("token_exchange_failed");
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const savedState = sessionStorage.getItem("oauth_state");
|
const savedState = sessionStorage.getItem("oauth_state");
|
||||||
|
|
||||||
if (savedState) {
|
if (savedState) {
|
||||||
@@ -69,7 +105,7 @@ function OAuthCallbackInner() {
|
|||||||
redirectTo = saved;
|
redirectTo = saved;
|
||||||
}
|
}
|
||||||
} catch { /* sessionStorage may be unavailable */ }
|
} catch { /* sessionStorage may be unavailable */ }
|
||||||
router.push(redirectTo);
|
router.push(toRouterPath(redirectTo));
|
||||||
} else {
|
} else {
|
||||||
setError("token_exchange_failed");
|
setError("token_exchange_failed");
|
||||||
}
|
}
|
||||||
@@ -153,7 +189,7 @@ function OAuthCallbackInner() {
|
|||||||
redirectTo = saved;
|
redirectTo = saved;
|
||||||
}
|
}
|
||||||
} catch { /* sessionStorage may be unavailable */ }
|
} catch { /* sessionStorage may be unavailable */ }
|
||||||
router.push(redirectTo);
|
router.push(toRouterPath(redirectTo));
|
||||||
} else {
|
} else {
|
||||||
setError("token_exchange_failed");
|
setError("token_exchange_failed");
|
||||||
}
|
}
|
||||||
@@ -181,7 +217,7 @@ function OAuthCallbackInner() {
|
|||||||
</p>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => router.push(`${getPathPrefix(params.locale as string)}/${params.locale}/login`)}
|
onClick={() => router.push(toRouterPath(`${getPathPrefix(params.locale as string)}/${params.locale}/login`))}
|
||||||
>
|
>
|
||||||
{t("oauth_error.back_to_login")}
|
{t("oauth_error.back_to_login")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { useEmailStore } from "@/stores/email-store";
|
|||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
import { useIdentityStore } from "@/stores/identity-store";
|
import { useIdentityStore } from "@/stores/identity-store";
|
||||||
import { useAccountStore } from "@/stores/account-store";
|
import { useAccountStore } from "@/stores/account-store";
|
||||||
|
import { usePolicyStore } from "@/stores/policy-store";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
import { useIsDesktop, useIsMobile } from "@/hooks/use-media-query";
|
import { useIsDesktop, useIsMobile } from "@/hooks/use-media-query";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -60,6 +61,7 @@ import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
|||||||
import { CreateCalendarModal } from "@/components/calendar/create-calendar-modal";
|
import { CreateCalendarModal } from "@/components/calendar/create-calendar-modal";
|
||||||
import { getUserParticipantId } from "@/lib/calendar-participants";
|
import { getUserParticipantId } from "@/lib/calendar-participants";
|
||||||
import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
|
import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
|
||||||
|
import { sharedCalendarColorKey, pickUnusedCalendarColor } from "@/lib/shared-calendar-colors";
|
||||||
import { debug } from "@/lib/debug";
|
import { debug } from "@/lib/debug";
|
||||||
import { consumePendingWebcal, hasPendingWebcal, subscribeToPendingWebcal } from "@/lib/protocol-handlers/session";
|
import { consumePendingWebcal, hasPendingWebcal, subscribeToPendingWebcal } from "@/lib/protocol-handlers/session";
|
||||||
import type { ParsedWebcal } from "@/lib/protocol-handlers/webcal";
|
import type { ParsedWebcal } from "@/lib/protocol-handlers/webcal";
|
||||||
@@ -96,7 +98,11 @@ export default function CalendarPage() {
|
|||||||
removeCalendar, clearCalendarEvents,
|
removeCalendar, clearCalendarEvents,
|
||||||
refreshAllSubscriptions, icalSubscriptions,
|
refreshAllSubscriptions, icalSubscriptions,
|
||||||
} = useCalendarStore();
|
} = useCalendarStore();
|
||||||
|
const calendarEnabled = usePolicyStore((s) => s.isFeatureEnabled('calendarEnabled'));
|
||||||
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar, calendarHoverPreview, showBirthdayCalendar, birthdayCalendarColor, updateSetting } = useSettingsStore();
|
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar, calendarHoverPreview, showBirthdayCalendar, birthdayCalendarColor, updateSetting } = useSettingsStore();
|
||||||
|
const sharedCalendarColors = useSettingsStore((s) => s.sharedCalendarColors);
|
||||||
|
const setSharedCalendarColor = useSettingsStore((s) => s.setSharedCalendarColor);
|
||||||
|
const removeSharedCalendarColor = useSettingsStore((s) => s.removeSharedCalendarColor);
|
||||||
const taskStore = useTaskStore();
|
const taskStore = useTaskStore();
|
||||||
const fetchTasksFn = useTaskStore(state => state.fetchTasks);
|
const fetchTasksFn = useTaskStore(state => state.fetchTasks);
|
||||||
const { identities } = useIdentityStore();
|
const { identities } = useIdentityStore();
|
||||||
@@ -175,10 +181,13 @@ export default function CalendarPage() {
|
|||||||
if (initialCheckDone && !isAuthenticated && !authLoading) {
|
if (initialCheckDone && !isAuthenticated && !authLoading) {
|
||||||
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
|
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
|
||||||
redirectToLogin();
|
redirectToLogin();
|
||||||
|
} else if (client && !calendarEnabled) {
|
||||||
|
// Calendar disabled by admin policy - send the user back to mail.
|
||||||
|
router.push("/");
|
||||||
} else if (client && !supportsCalendar && !pendingWebcalAccountChoice && !isProtocolAccountSwitching && !pendingSubscription && !showWebcalActionChoice && !hasPendingWebcal()) {
|
} else if (client && !supportsCalendar && !pendingWebcalAccountChoice && !isProtocolAccountSwitching && !pendingSubscription && !showWebcalActionChoice && !hasPendingWebcal()) {
|
||||||
router.push("/");
|
router.push("/");
|
||||||
}
|
}
|
||||||
}, [initialCheckDone, isAuthenticated, authLoading, client, supportsCalendar, pendingWebcalAccountChoice, isProtocolAccountSwitching, pendingSubscription, showWebcalActionChoice, router]);
|
}, [initialCheckDone, isAuthenticated, authLoading, client, calendarEnabled, supportsCalendar, pendingWebcalAccountChoice, isProtocolAccountSwitching, pendingSubscription, showWebcalActionChoice, router]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -1030,10 +1039,47 @@ export default function CalendarPage() {
|
|||||||
try { return t('birthday_calendar'); } catch { return 'Birthdays'; }
|
try { return t('birthday_calendar'); } catch { return 'Birthdays'; }
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
// Apply each shared calendar's local color override (per-viewer recolor,
|
||||||
|
// #345). The override replaces the calendar's color and wins over per-event
|
||||||
|
// colors via the `colorIsLocalOverride` flag (see getEventColor). Personal
|
||||||
|
// calendars are passed through untouched.
|
||||||
|
const displayCalendars = useMemo(() => {
|
||||||
|
return calendars.map((cal) => {
|
||||||
|
if (!cal.isShared) return cal;
|
||||||
|
const override = sharedCalendarColors[sharedCalendarColorKey(cal)];
|
||||||
|
if (!override) return cal;
|
||||||
|
return { ...cal, color: override, colorIsLocalOverride: true };
|
||||||
|
});
|
||||||
|
}, [calendars, sharedCalendarColors]);
|
||||||
|
|
||||||
|
// Auto-assign a random, not-yet-used palette color to any freshly shared
|
||||||
|
// calendar so multiple shared calendars don't collide on one color. Runs
|
||||||
|
// once per calendar (guarded by the presence of an existing key), and the
|
||||||
|
// user can still overwrite it from the sidebar.
|
||||||
|
useEffect(() => {
|
||||||
|
const shared = calendars.filter((c) => c.isShared);
|
||||||
|
const missing = shared.filter((c) => !sharedCalendarColors[sharedCalendarColorKey(c)]);
|
||||||
|
if (missing.length === 0) return;
|
||||||
|
// Seed "used" with personal calendar colors plus already-assigned shared
|
||||||
|
// overrides so the picks stay distinct from what's already on screen.
|
||||||
|
const used = new Set<string>();
|
||||||
|
for (const c of calendars) {
|
||||||
|
if (!c.isShared && c.color) used.add(c.color.toLowerCase());
|
||||||
|
}
|
||||||
|
for (const color of Object.values(sharedCalendarColors)) {
|
||||||
|
if (color) used.add(color.toLowerCase());
|
||||||
|
}
|
||||||
|
for (const cal of missing) {
|
||||||
|
const color = pickUnusedCalendarColor(used);
|
||||||
|
used.add(color.toLowerCase());
|
||||||
|
setSharedCalendarColor(sharedCalendarColorKey(cal), color);
|
||||||
|
}
|
||||||
|
}, [calendars, sharedCalendarColors, setSharedCalendarColor]);
|
||||||
|
|
||||||
const allCalendars = useMemo(() => {
|
const allCalendars = useMemo(() => {
|
||||||
if (!showBirthdayCalendar) return calendars;
|
if (!showBirthdayCalendar) return displayCalendars;
|
||||||
return [...calendars, createBirthdayCalendar(birthdayCalendarName, birthdayCalendarColor)];
|
return [...displayCalendars, createBirthdayCalendar(birthdayCalendarName, birthdayCalendarColor)];
|
||||||
}, [calendars, showBirthdayCalendar, birthdayCalendarName, birthdayCalendarColor]);
|
}, [displayCalendars, showBirthdayCalendar, birthdayCalendarName, birthdayCalendarColor]);
|
||||||
|
|
||||||
const visibleEvents = useMemo(() => {
|
const visibleEvents = useMemo(() => {
|
||||||
const filtered = events.filter((e) => {
|
const filtered = events.filter((e) => {
|
||||||
@@ -1103,13 +1149,13 @@ export default function CalendarPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="px-6 py-4 space-y-3">
|
<div className="px-6 py-4 space-y-3">
|
||||||
<Button variant="outline" className="w-full justify-start h-auto py-3" onClick={handleImportWebcal}>
|
<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 font-medium">{tWebcalAction("import_title")}</span>
|
||||||
<span className="block text-xs text-muted-foreground mt-0.5">{tWebcalAction("import_description")}</span>
|
<span className="block text-xs text-muted-foreground mt-0.5">{tWebcalAction("import_description")}</span>
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" className="w-full justify-start h-auto py-3" onClick={handleSubscribeWebcal}>
|
<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 font-medium">{tWebcalAction("subscribe_title")}</span>
|
||||||
<span className="block text-xs text-muted-foreground mt-0.5">{tWebcalAction("subscribe_description")}</span>
|
<span className="block text-xs text-muted-foreground mt-0.5">{tWebcalAction("subscribe_description")}</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -1123,6 +1169,7 @@ export default function CalendarPage() {
|
|||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
if (!isAuthenticated) return null;
|
if (!isAuthenticated) return null;
|
||||||
|
if (!calendarEnabled) return null;
|
||||||
if (!supportsCalendar) return renderWebcalAccountPicker();
|
if (!supportsCalendar) return renderWebcalAccountPicker();
|
||||||
|
|
||||||
const renderView = () => {
|
const renderView = () => {
|
||||||
@@ -1219,7 +1266,7 @@ export default function CalendarPage() {
|
|||||||
/>
|
/>
|
||||||
<TaskListView
|
<TaskListView
|
||||||
tasks={taskStore.tasks}
|
tasks={taskStore.tasks}
|
||||||
calendars={calendars}
|
calendars={displayCalendars}
|
||||||
selectedCalendarIds={selectedCalendarIds}
|
selectedCalendarIds={selectedCalendarIds}
|
||||||
filter={taskStore.filter}
|
filter={taskStore.filter}
|
||||||
showCompleted={taskStore.showCompleted}
|
showCompleted={taskStore.showCompleted}
|
||||||
@@ -1289,7 +1336,7 @@ export default function CalendarPage() {
|
|||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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",
|
!isResizing && "transition-[width] duration-300",
|
||||||
isNarrow && cn(
|
isNarrow && cn(
|
||||||
"absolute inset-y-0 left-0 z-50 w-72 pt-[env(safe-area-inset-top)]",
|
"absolute inset-y-0 left-0 z-50 w-72 pt-[env(safe-area-inset-top)]",
|
||||||
@@ -1317,8 +1364,21 @@ export default function CalendarPage() {
|
|||||||
updateSetting('birthdayCalendarColor', color);
|
updateSetting('birthdayCalendarColor', color);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Shared calendars: recolor locally only (the viewer usually
|
||||||
|
// can't write the owner's calendar, and it'd recolor it for
|
||||||
|
// everyone). Personal calendars write through to the server.
|
||||||
|
const cal = allCalendars.find((c) => c.id === calendarId);
|
||||||
|
if (cal?.isShared) {
|
||||||
|
setSharedCalendarColor(sharedCalendarColorKey(cal), color);
|
||||||
|
return;
|
||||||
|
}
|
||||||
updateCalendar(client, calendarId, { color });
|
updateCalendar(client, calendarId, { color });
|
||||||
} : undefined}
|
} : undefined}
|
||||||
|
onResetColor={(cal) => {
|
||||||
|
// Drop the local override; the auto-assign effect picks a
|
||||||
|
// fresh unused color (so it never reverts to a collision).
|
||||||
|
removeSharedCalendarColor(sharedCalendarColorKey(cal));
|
||||||
|
}}
|
||||||
onShareCalendar={client ? (cal) => setSharingCalendarId(cal.id) : undefined}
|
onShareCalendar={client ? (cal) => setSharingCalendarId(cal.id) : undefined}
|
||||||
onCreateEvent={(cal: Calendar) => {
|
onCreateEvent={(cal: Calendar) => {
|
||||||
setDefaultCalendarIdForCreate(cal.id);
|
setDefaultCalendarIdForCreate(cal.id);
|
||||||
@@ -1389,7 +1449,7 @@ export default function CalendarPage() {
|
|||||||
onSubscribe={() => setShowSubscriptionModal(true)}
|
onSubscribe={() => setShowSubscriptionModal(true)}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
onNavigateBack={isMobile && mobileReturnToMonth && normalizedViewMode === "day" ? navigateBackToMonth : undefined}
|
onNavigateBack={isMobile && mobileReturnToMonth && normalizedViewMode === "day" ? navigateBackToMonth : undefined}
|
||||||
calendars={calendars}
|
calendars={displayCalendars}
|
||||||
selectedCalendarIds={selectedCalendarIds}
|
selectedCalendarIds={selectedCalendarIds}
|
||||||
onToggleVisibility={toggleCalendarVisibility}
|
onToggleVisibility={toggleCalendarVisibility}
|
||||||
enableCalendarTasks={enableCalendarTasks}
|
enableCalendarTasks={enableCalendarTasks}
|
||||||
@@ -1415,11 +1475,11 @@ export default function CalendarPage() {
|
|||||||
|
|
||||||
{/* Desktop event panel */}
|
{/* Desktop event panel */}
|
||||||
{!isMobile && showEventModal && (
|
{!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
|
<EventModal
|
||||||
key={editEvent?.id ?? 'new'}
|
key={editEvent?.id ?? 'new'}
|
||||||
event={editEvent}
|
event={editEvent}
|
||||||
calendars={calendars}
|
calendars={displayCalendars}
|
||||||
defaultDate={defaultModalDate}
|
defaultDate={defaultModalDate}
|
||||||
defaultEndDate={defaultModalEndDate}
|
defaultEndDate={defaultModalEndDate}
|
||||||
defaultAllDay={defaultModalAllDay}
|
defaultAllDay={defaultModalAllDay}
|
||||||
@@ -1438,11 +1498,11 @@ export default function CalendarPage() {
|
|||||||
|
|
||||||
{/* Desktop task panel */}
|
{/* Desktop task panel */}
|
||||||
{!isMobile && showTaskModal && (
|
{!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
|
<TaskModal
|
||||||
key={editTask?.id ?? 'new-task'}
|
key={editTask?.id ?? 'new-task'}
|
||||||
task={editTask}
|
task={editTask}
|
||||||
calendars={calendars}
|
calendars={displayCalendars}
|
||||||
onSave={handleSaveTask}
|
onSave={handleSaveTask}
|
||||||
onDelete={handleDeleteTask}
|
onDelete={handleDeleteTask}
|
||||||
onClose={() => { setShowTaskModal(false); setEditTask(null); }}
|
onClose={() => { setShowTaskModal(false); setEditTask(null); }}
|
||||||
@@ -1529,7 +1589,7 @@ export default function CalendarPage() {
|
|||||||
{detailEvent && detailAnchorRect && (
|
{detailEvent && detailAnchorRect && (
|
||||||
<EventDetailPopover
|
<EventDetailPopover
|
||||||
event={detailEvent}
|
event={detailEvent}
|
||||||
calendar={calendars.find(c => detailEvent.calendarIds[c.id])}
|
calendar={displayCalendars.find(c => detailEvent.calendarIds[c.id])}
|
||||||
anchorRect={detailAnchorRect}
|
anchorRect={detailAnchorRect}
|
||||||
onEdit={handleEditFromDetail}
|
onEdit={handleEditFromDetail}
|
||||||
onDelete={handleDeleteFromDetail}
|
onDelete={handleDeleteFromDetail}
|
||||||
@@ -1549,7 +1609,7 @@ export default function CalendarPage() {
|
|||||||
<EventModal
|
<EventModal
|
||||||
key={editEvent?.id ?? 'new'}
|
key={editEvent?.id ?? 'new'}
|
||||||
event={editEvent}
|
event={editEvent}
|
||||||
calendars={calendars}
|
calendars={displayCalendars}
|
||||||
defaultDate={defaultModalDate}
|
defaultDate={defaultModalDate}
|
||||||
defaultEndDate={defaultModalEndDate}
|
defaultEndDate={defaultModalEndDate}
|
||||||
defaultAllDay={defaultModalAllDay}
|
defaultAllDay={defaultModalAllDay}
|
||||||
@@ -1566,7 +1626,7 @@ export default function CalendarPage() {
|
|||||||
|
|
||||||
{showImportModal && client && (
|
{showImportModal && client && (
|
||||||
<ICalImportModal
|
<ICalImportModal
|
||||||
calendars={calendars}
|
calendars={displayCalendars}
|
||||||
client={client}
|
client={client}
|
||||||
initialUrl={pendingSubscription?.url}
|
initialUrl={pendingSubscription?.url}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ import { ContactImportDialog } from "@/components/contacts/contact-import-dialog
|
|||||||
import { RenameDialog } from "@/components/files/rename-dialog";
|
import { RenameDialog } from "@/components/files/rename-dialog";
|
||||||
import { exportContacts } from "@/components/contacts/contact-export";
|
import { exportContacts } from "@/components/contacts/contact-export";
|
||||||
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
|
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
|
||||||
import { useContactStore, getContactDisplayName } from "@/stores/contact-store";
|
import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
|
||||||
|
import { savePendingMailto } from "@/lib/protocol-handlers/session";
|
||||||
|
import { formatRecipient } from "@/lib/email-composer-utils";
|
||||||
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
||||||
import { useEmailStore } from "@/stores/email-store";
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
import { usePolicyStore } from "@/stores/policy-store";
|
import { usePolicyStore } from "@/stores/policy-store";
|
||||||
@@ -82,6 +84,7 @@ export default function ContactsPage() {
|
|||||||
bulkDeleteContacts,
|
bulkDeleteContacts,
|
||||||
bulkAddToGroup,
|
bulkAddToGroup,
|
||||||
moveContactToAddressBook,
|
moveContactToAddressBook,
|
||||||
|
createAddressBook,
|
||||||
renameAddressBook,
|
renameAddressBook,
|
||||||
removeAddressBook,
|
removeAddressBook,
|
||||||
shareAddressBook,
|
shareAddressBook,
|
||||||
@@ -93,6 +96,7 @@ export default function ContactsPage() {
|
|||||||
const [activeCategory, setActiveCategory] = useState<ContactCategory>("all");
|
const [activeCategory, setActiveCategory] = useState<ContactCategory>("all");
|
||||||
const [showImportDialog, setShowImportDialog] = useState(false);
|
const [showImportDialog, setShowImportDialog] = useState(false);
|
||||||
const [renamingAddressBook, setRenamingAddressBook] = useState<AddressBook | null>(null);
|
const [renamingAddressBook, setRenamingAddressBook] = useState<AddressBook | null>(null);
|
||||||
|
const [creatingAddressBook, setCreatingAddressBook] = useState(false);
|
||||||
const [sharingAddressBookId, setSharingAddressBookId] = useState<string | null>(null);
|
const [sharingAddressBookId, setSharingAddressBookId] = useState<string | null>(null);
|
||||||
const [defaultBookIdForCreate, setDefaultBookIdForCreate] = useState<string | undefined>(undefined);
|
const [defaultBookIdForCreate, setDefaultBookIdForCreate] = useState<string | undefined>(undefined);
|
||||||
const [createPrefill, setCreatePrefill] = useState<{ email?: string; name?: string } | undefined>(undefined);
|
const [createPrefill, setCreatePrefill] = useState<{ email?: string; name?: string } | undefined>(undefined);
|
||||||
@@ -173,12 +177,13 @@ export default function ContactsPage() {
|
|||||||
const addEmail = searchParams.get('addEmail');
|
const addEmail = searchParams.get('addEmail');
|
||||||
const addName = searchParams.get('addName');
|
const addName = searchParams.get('addName');
|
||||||
const from = searchParams.get('from');
|
const from = searchParams.get('from');
|
||||||
|
const viewParam = searchParams.get('view');
|
||||||
if (!contactId && !addEmail && !from) return;
|
if (!contactId && !addEmail && !from) return;
|
||||||
intentAppliedRef.current = true;
|
intentAppliedRef.current = true;
|
||||||
if (from === 'email') setReturnToEmail(true);
|
if (from === 'email') setReturnToEmail(true);
|
||||||
if (contactId) {
|
if (contactId) {
|
||||||
setSelectedContact(contactId);
|
setSelectedContact(contactId);
|
||||||
setView('detail');
|
setView(viewParam === 'edit' ? 'edit' : 'detail');
|
||||||
} else if (addEmail) {
|
} else if (addEmail) {
|
||||||
setCreatePrefill({ email: addEmail, name: addName ?? undefined });
|
setCreatePrefill({ email: addEmail, name: addName ?? undefined });
|
||||||
setSelectedContact(null);
|
setSelectedContact(null);
|
||||||
@@ -297,6 +302,34 @@ export default function ContactsPage() {
|
|||||||
}
|
}
|
||||||
}, [client, supportsSync, contacts, updateContact, updateLocalContact, t]);
|
}, [client, supportsSync, contacts, updateContact, updateLocalContact, t]);
|
||||||
|
|
||||||
|
// Refresh address books (and contacts) after a structural change, staying
|
||||||
|
// multi-account aware so a freshly created book lands in the sidebar.
|
||||||
|
const refreshAddressBooks = useCallback(async () => {
|
||||||
|
if (!client) return;
|
||||||
|
if (multiAccountEnabled && accountClients.length > 0) {
|
||||||
|
const activeId = useAuthStore.getState().activeAccountId;
|
||||||
|
if (activeId) {
|
||||||
|
const { fetchAllAccountsAddressBooks } = useContactStore.getState();
|
||||||
|
await fetchAllAccountsAddressBooks(accountClients, activeId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await useContactStore.getState().fetchAddressBooks(client);
|
||||||
|
}, [client, multiAccountEnabled, accountClients]);
|
||||||
|
|
||||||
|
const handleCreateAddressBook = useCallback(async (name: string) => {
|
||||||
|
if (!client) return;
|
||||||
|
try {
|
||||||
|
await createAddressBook(client, name);
|
||||||
|
await refreshAddressBooks();
|
||||||
|
toast.success(t("address_books.created"));
|
||||||
|
setCreatingAddressBook(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create address book:', error);
|
||||||
|
toast.error(t("address_books.create_failed"));
|
||||||
|
}
|
||||||
|
}, [client, createAddressBook, refreshAddressBooks, t]);
|
||||||
|
|
||||||
const handleImportContacts = useCallback(async (importedContacts: ContactCard[]) => {
|
const handleImportContacts = useCallback(async (importedContacts: ContactCard[]) => {
|
||||||
return importContacts(
|
return importContacts(
|
||||||
supportsSync && client ? client : null,
|
supportsSync && client ? client : null,
|
||||||
@@ -460,6 +493,51 @@ export default function ContactsPage() {
|
|||||||
setView("group-edit");
|
setView("group-edit");
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Open the in-app composer in the current session rather than routing through
|
||||||
|
// a mailto: URL. `window.location='mailto:'` hands off to the OS handler
|
||||||
|
// (which may open a different mail app), and the mailto protocol round-trip
|
||||||
|
// reloads the app - dropping the in-memory per-account JMAP clients of a
|
||||||
|
// multi-account session, which reads as a logout. Stashing the recipients and
|
||||||
|
// doing a client-side router.push keeps the session and the active account
|
||||||
|
// intact; the main route consumes the pending compose and opens the composer
|
||||||
|
// (see consumePendingMailto in page.tsx).
|
||||||
|
const openComposeInApp = useCallback((recipients: string[], field: "to" | "cc" | "bcc") => {
|
||||||
|
savePendingMailto({
|
||||||
|
to: field === "to" ? recipients : [],
|
||||||
|
cc: field === "cc" ? recipients : [],
|
||||||
|
bcc: field === "bcc" ? recipients : [],
|
||||||
|
subject: "",
|
||||||
|
body: "",
|
||||||
|
});
|
||||||
|
router.push("/");
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
const handleComposeGroupFromSidebar = useCallback((groupId: string, field: "to" | "cc" | "bcc") => {
|
||||||
|
// Format each member as "Name <email>" so the composer keeps the display
|
||||||
|
// name (round-trips via formatRecipient -> parseRecipientList). Dedupe by
|
||||||
|
// email, case-insensitively; members without an email are skipped.
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const recipients: string[] = [];
|
||||||
|
for (const member of getGroupMembers(groupId)) {
|
||||||
|
const email = getContactPrimaryEmail(member).trim();
|
||||||
|
const key = email.toLowerCase();
|
||||||
|
if (!email || seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
recipients.push(formatRecipient(getContactDisplayName(member), email));
|
||||||
|
}
|
||||||
|
if (recipients.length === 0) {
|
||||||
|
toast.error(t("groups.no_member_emails"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openComposeInApp(recipients, field);
|
||||||
|
}, [getGroupMembers, t, openComposeInApp]);
|
||||||
|
|
||||||
|
const handleComposeContact = useCallback((contact: ContactCard) => {
|
||||||
|
const email = getContactPrimaryEmail(contact).trim();
|
||||||
|
if (!email) return;
|
||||||
|
openComposeInApp([formatRecipient(getContactDisplayName(contact), email)], "to");
|
||||||
|
}, [openComposeInApp]);
|
||||||
|
|
||||||
const handleDeleteGroupFromSidebar = useCallback(async (groupId: string) => {
|
const handleDeleteGroupFromSidebar = useCallback(async (groupId: string) => {
|
||||||
const confirmed = await confirmDialog({
|
const confirmed = await confirmDialog({
|
||||||
title: t("groups.delete_confirm_title"),
|
title: t("groups.delete_confirm_title"),
|
||||||
@@ -624,6 +702,7 @@ export default function ContactsPage() {
|
|||||||
onEdit={handleEditGroup}
|
onEdit={handleEditGroup}
|
||||||
onDelete={handleDeleteGroup}
|
onDelete={handleDeleteGroup}
|
||||||
onRemoveMember={handleRemoveGroupMember}
|
onRemoveMember={handleRemoveGroupMember}
|
||||||
|
onComposeGroup={(field) => handleComposeGroupFromSidebar(selectedGroup.id, field)}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
onSelectMember={(id) => {
|
onSelectMember={(id) => {
|
||||||
setSelectedContact(id);
|
setSelectedContact(id);
|
||||||
@@ -672,7 +751,7 @@ export default function ContactsPage() {
|
|||||||
<button
|
<button
|
||||||
key={group.id}
|
key={group.id}
|
||||||
onClick={() => handleBulkAddToGroupConfirm(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">
|
<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" />
|
<Users className="w-4 h-4 text-primary" />
|
||||||
@@ -701,6 +780,11 @@ export default function ContactsPage() {
|
|||||||
contact={selectedContact}
|
contact={selectedContact}
|
||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
|
onCompose={
|
||||||
|
selectedContact
|
||||||
|
? () => handleComposeContact(selectedContact)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
onAddToGroup={
|
onAddToGroup={
|
||||||
selectedContact
|
selectedContact
|
||||||
? () => handleAddContactToGroup(selectedContact.id)
|
? () => handleAddContactToGroup(selectedContact.id)
|
||||||
@@ -785,7 +869,7 @@ export default function ContactsPage() {
|
|||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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",
|
!isSidebarResizing && "transition-[width] duration-300",
|
||||||
isNarrow && cn(
|
isNarrow && cn(
|
||||||
"absolute inset-y-0 left-0 z-50 w-72 pt-[env(safe-area-inset-top)]",
|
"absolute inset-y-0 left-0 z-50 w-72 pt-[env(safe-area-inset-top)]",
|
||||||
@@ -803,9 +887,11 @@ export default function ContactsPage() {
|
|||||||
onSelectCategory={handleSelectCategory}
|
onSelectCategory={handleSelectCategory}
|
||||||
onCreateGroup={handleCreateGroup}
|
onCreateGroup={handleCreateGroup}
|
||||||
onCreateContact={handleCreateNew}
|
onCreateContact={handleCreateNew}
|
||||||
|
onCreateAddressBook={client ? () => setCreatingAddressBook(true) : undefined}
|
||||||
onImport={() => setShowImportDialog(true)}
|
onImport={() => setShowImportDialog(true)}
|
||||||
onEditGroup={handleEditGroupFromSidebar}
|
onEditGroup={handleEditGroupFromSidebar}
|
||||||
onDeleteGroup={handleDeleteGroupFromSidebar}
|
onDeleteGroup={handleDeleteGroupFromSidebar}
|
||||||
|
onComposeGroup={handleComposeGroupFromSidebar}
|
||||||
onDropContacts={handleDropContacts}
|
onDropContacts={handleDropContacts}
|
||||||
onDropContactsToCategory={handleDropContactsToCategory}
|
onDropContactsToCategory={handleDropContactsToCategory}
|
||||||
onRenameAddressBook={client ? (book) => setRenamingAddressBook(book) : undefined}
|
onRenameAddressBook={client ? (book) => setRenamingAddressBook(book) : undefined}
|
||||||
@@ -851,7 +937,7 @@ export default function ContactsPage() {
|
|||||||
<div
|
<div
|
||||||
data-tour="contacts-list"
|
data-tour="contacts-list"
|
||||||
className={cn(
|
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" : "",
|
isMobile ? "w-full" : "",
|
||||||
!isListResizing && !isMobile && "transition-[width] duration-300"
|
!isListResizing && !isMobile && "transition-[width] duration-300"
|
||||||
)}
|
)}
|
||||||
@@ -905,7 +991,7 @@ export default function ContactsPage() {
|
|||||||
onClick={mobileBackToList}
|
onClick={mobileBackToList}
|
||||||
className="touch-manipulation"
|
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")}
|
{returnToEmail ? t("back_to_email") : t("back_to_contacts")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -951,6 +1037,15 @@ export default function ContactsPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{creatingAddressBook && (
|
||||||
|
<RenameDialog
|
||||||
|
currentName=""
|
||||||
|
title={t("address_books.create")}
|
||||||
|
label={t("address_books.name_label")}
|
||||||
|
onCancel={() => setCreatingAddressBook(false)}
|
||||||
|
onConfirm={handleCreateAddressBook}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{renamingAddressBook && (
|
{renamingAddressBook && (
|
||||||
<RenameDialog
|
<RenameDialog
|
||||||
currentName={renamingAddressBook.name}
|
currentName={renamingAddressBook.name}
|
||||||
|
|||||||
@@ -38,11 +38,11 @@ export default function LocaleError({
|
|||||||
</p>
|
</p>
|
||||||
<div className="flex gap-3 justify-center">
|
<div className="flex gap-3 justify-center">
|
||||||
<Button variant="outline" onClick={() => router.push('/')}>
|
<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")}
|
{t("go_home")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={reset}>
|
<Button onClick={reset}>
|
||||||
<RefreshCw className="w-4 h-4 mr-2" />
|
<RefreshCw className="w-4 h-4 me-2" />
|
||||||
{t("try_again")}
|
{t("try_again")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,12 +22,13 @@ import { useIsMobile } from "@/hooks/use-media-query";
|
|||||||
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
|
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
|
||||||
import { usePolicyStore } from "@/stores/policy-store";
|
import { usePolicyStore } from "@/stores/policy-store";
|
||||||
import { FileBrowser } from "@/components/files/file-browser";
|
import { FileBrowser } from "@/components/files/file-browser";
|
||||||
|
import type { FileNodeRights } from "@/lib/jmap/types";
|
||||||
import { ImagePreviewModal } from "@/components/files/image-preview-modal";
|
import { ImagePreviewModal } from "@/components/files/image-preview-modal";
|
||||||
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
||||||
import { loadFilesSettings } from "@/components/files/files-settings-dialog";
|
import { loadFilesSettings } from "@/components/files/files-settings-dialog";
|
||||||
import type { FolderLayout } from "@/components/files/files-settings-dialog";
|
import type { FolderLayout } from "@/components/files/files-settings-dialog";
|
||||||
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
|
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
|
||||||
import { AlertTriangle } from "lucide-react";
|
import { AlertTriangle, Loader2 } from "lucide-react";
|
||||||
|
|
||||||
export default function FilesPage() {
|
export default function FilesPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -48,9 +49,11 @@ export default function FilesPage() {
|
|||||||
supportsFiles,
|
supportsFiles,
|
||||||
selectedResources,
|
selectedResources,
|
||||||
uploadProgress,
|
uploadProgress,
|
||||||
|
migrationProgress,
|
||||||
clipboard,
|
clipboard,
|
||||||
initClient,
|
initClient,
|
||||||
checkSupport,
|
checkSupport,
|
||||||
|
migrateLegacyFlatNodes,
|
||||||
navigate,
|
navigate,
|
||||||
navigateByPath,
|
navigateByPath,
|
||||||
refresh,
|
refresh,
|
||||||
@@ -86,6 +89,7 @@ export default function FilesPage() {
|
|||||||
cancelUpload,
|
cancelUpload,
|
||||||
undoLastAction,
|
undoLastAction,
|
||||||
lastAction,
|
lastAction,
|
||||||
|
shareResource,
|
||||||
} = useFileStore();
|
} = useFileStore();
|
||||||
|
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
@@ -160,13 +164,16 @@ export default function FilesPage() {
|
|||||||
const storeClient = useFileStore(s => s.client);
|
const storeClient = useFileStore(s => s.client);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (storeClient && supportsFiles === null) {
|
if (storeClient && supportsFiles === null) {
|
||||||
checkSupport().then((supported) => {
|
checkSupport().then(async (supported) => {
|
||||||
if (supported) {
|
if (supported) {
|
||||||
|
// Upgrade any files created by older builds (flat path-encoded names)
|
||||||
|
// into the real FileNode hierarchy before the first listing.
|
||||||
|
await migrateLegacyFlatNodes();
|
||||||
navigate(null);
|
navigate(null);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [storeClient, supportsFiles, checkSupport, navigate]);
|
}, [storeClient, supportsFiles, checkSupport, migrateLegacyFlatNodes, navigate]);
|
||||||
|
|
||||||
const handleNavigate = useCallback((path: string, resourceId?: string | null) => {
|
const handleNavigate = useCallback((path: string, resourceId?: string | null) => {
|
||||||
// Pro shell only: the Account breadcrumb segment signals "go to this
|
// Pro shell only: the Account breadcrumb segment signals "go to this
|
||||||
@@ -396,6 +403,14 @@ export default function FilesPage() {
|
|||||||
|
|
||||||
const currentFilesAccountId = useFileStore((s) => s.currentAccountId);
|
const currentFilesAccountId = useFileStore((s) => s.currentAccountId);
|
||||||
|
|
||||||
|
// Sharing: the browsing client (store-attached) drives the principal picker
|
||||||
|
// and share mutations. supportsPrincipals() gates the whole Share affordance.
|
||||||
|
const sharingEnabled = !!storeClient?.supportsPrincipals();
|
||||||
|
const filesAccountId = storeClient?.getFilesAccountId() ?? null;
|
||||||
|
const handleShare = useCallback(async (id: string, principalId: string, rights: FileNodeRights | null) => {
|
||||||
|
await shareResource(id, principalId, rights);
|
||||||
|
}, [shareResource]);
|
||||||
|
|
||||||
// Pro shell only: all connected accounts are equal top-level entries at
|
// Pro shell only: all connected accounts are equal top-level entries at
|
||||||
// the root. The root path "/" itself is a cross-account picker - no
|
// the root. The root path "/" itself is a cross-account picker - no
|
||||||
// account's files are shown until the user enters one.
|
// account's files are shown until the user enters one.
|
||||||
@@ -462,7 +477,7 @@ export default function FilesPage() {
|
|||||||
onClick={() => router.push("/")}
|
onClick={() => router.push("/")}
|
||||||
className="justify-start"
|
className="justify-start"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
<ArrowLeft className="w-4 h-4 me-2" />
|
||||||
{t("title")}
|
{t("title")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -535,6 +550,10 @@ export default function FilesPage() {
|
|||||||
onSelectAccount={handleSelectAccount}
|
onSelectAccount={handleSelectAccount}
|
||||||
accountPickerMode={isAccountPicker}
|
accountPickerMode={isAccountPicker}
|
||||||
accountLabel={currentAccountLabel}
|
accountLabel={currentAccountLabel}
|
||||||
|
client={storeClient}
|
||||||
|
ownAccountId={filesAccountId}
|
||||||
|
sharingEnabled={sharingEnabled}
|
||||||
|
onShare={handleShare}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -573,6 +592,32 @@ export default function FilesPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Legacy file migration progress (issue #379) */}
|
||||||
|
{migrationProgress && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||||
|
<div className="w-[22rem] max-w-[90vw] rounded-lg border border-border bg-background p-6 shadow-xl">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Loader2 className="w-5 h-5 text-primary animate-spin shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">{t("migration_title")}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{t("migration_description")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 h-1.5 bg-primary/20 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-primary rounded-full transition-all duration-300"
|
||||||
|
style={{ width: migrationProgress.total > 0
|
||||||
|
? `${(migrationProgress.current / migrationProgress.total) * 100}%`
|
||||||
|
: '0%' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-muted-foreground tabular-nums text-end">
|
||||||
|
{migrationProgress.current} / {migrationProgress.total}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||||
<ConfirmDialog {...confirmDialogProps} />
|
<ConfirmDialog {...confirmDialogProps} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { ProtocolLaunchHandlerProvider } from "@/components/protocol/protocol-la
|
|||||||
import { ProInterfaceRedirect } from "@/components/pro/pro-interface-redirect";
|
import { ProInterfaceRedirect } from "@/components/pro/pro-interface-redirect";
|
||||||
import { PluginDialogHost } from "@/components/plugins/plugin-dialog-host";
|
import { PluginDialogHost } from "@/components/plugins/plugin-dialog-host";
|
||||||
import { PluginConsentDialog } from "@/components/plugins/plugin-consent-dialog";
|
import { PluginConsentDialog } from "@/components/plugins/plugin-consent-dialog";
|
||||||
|
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
|
||||||
import { locales } from "@/i18n/routing";
|
import { locales } from "@/i18n/routing";
|
||||||
|
|
||||||
export default async function LocaleLayout({
|
export default async function LocaleLayout({
|
||||||
@@ -41,6 +42,7 @@ export default async function LocaleLayout({
|
|||||||
{children}
|
{children}
|
||||||
<PluginDialogHost />
|
<PluginDialogHost />
|
||||||
<PluginConsentDialog />
|
<PluginConsentDialog />
|
||||||
|
<PWAInstallPrompt />
|
||||||
</ProtocolLaunchHandlerProvider>
|
</ProtocolLaunchHandlerProvider>
|
||||||
</TourProvider>
|
</TourProvider>
|
||||||
</EmbeddedBridgeProvider>
|
</EmbeddedBridgeProvider>
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ import { useAccountStore } from "@/stores/account-store";
|
|||||||
import { useThemeStore } from "@/stores/theme-store";
|
import { useThemeStore } from "@/stores/theme-store";
|
||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import { useConfig } from "@/hooks/use-config";
|
import { useConfig } from "@/hooks/use-config";
|
||||||
import { apiFetch, getPathPrefix, withBasePath } from "@/lib/browser-navigation";
|
import { apiFetch, getPathPrefix, toRouterPath, withBasePath } from "@/lib/browser-navigation";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play, Copy } from "lucide-react";
|
import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play, Copy } from "lucide-react";
|
||||||
import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
|
import { type OAuthMetadata } from "@/lib/oauth/discovery";
|
||||||
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
|
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
|
||||||
import { useUpdateStore, selectBanner } from "@/stores/update-store";
|
import { useUpdateStore, selectBanner } from "@/stores/update-store";
|
||||||
import type { PublicJmapServerEntry } from "@/lib/admin/jmap-servers";
|
import type { PublicJmapServerEntry } from "@/lib/admin/jmap-servers";
|
||||||
@@ -133,9 +133,16 @@ export default function LoginPage() {
|
|||||||
const isMobileHandoff = Boolean(mobileRedirectUri);
|
const isMobileHandoff = Boolean(mobileRedirectUri);
|
||||||
const { login, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore();
|
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 { 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);
|
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({
|
const [formData, setFormData] = useState({
|
||||||
username: "",
|
username: "",
|
||||||
password: "",
|
password: "",
|
||||||
@@ -279,7 +286,7 @@ export default function LoginPage() {
|
|||||||
redirectTo = saved;
|
redirectTo = saved;
|
||||||
}
|
}
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
router.push(redirectTo);
|
router.push(toRouterPath(redirectTo));
|
||||||
}
|
}
|
||||||
}, [isAuthenticated, router, isAddAccountMode, isMobileHandoff, mobileRedirectUri, mobileState]);
|
}, [isAuthenticated, router, isAddAccountMode, isMobileHandoff, mobileRedirectUri, mobileState]);
|
||||||
|
|
||||||
@@ -329,16 +336,27 @@ export default function LoginPage() {
|
|||||||
if (!oauthEnabled || !serverUrl) return;
|
if (!oauthEnabled || !serverUrl) return;
|
||||||
setOauthDiscoveryDone(false);
|
setOauthDiscoveryDone(false);
|
||||||
setOauthMetadata(null);
|
setOauthMetadata(null);
|
||||||
discoverOAuth(effectiveOauthIssuerUrl || serverUrl)
|
const controller = new AbortController();
|
||||||
|
// Discover via our own origin rather than fetching the IdP's /.well-known/*
|
||||||
|
// documents directly from the browser. A direct cross-origin discovery
|
||||||
|
// fetch is subject to CORS, and providers like Authentik serve those
|
||||||
|
// documents without Access-Control-Allow-Origin, so the browser blocks the
|
||||||
|
// response and login breaks (issue #382). The proxy runs discovery server
|
||||||
|
// side where CORS does not apply.
|
||||||
|
const query = selectedServer?.id ? `?server_id=${encodeURIComponent(selectedServer.id)}` : "";
|
||||||
|
apiFetch(`/api/auth/oauth/metadata${query}`, { signal: controller.signal })
|
||||||
|
.then(async (res) => (res.ok ? ((await res.json()) as OAuthMetadata) : null))
|
||||||
.then((metadata) => {
|
.then((metadata) => {
|
||||||
setOauthMetadata(metadata);
|
setOauthMetadata(metadata);
|
||||||
setOauthDiscoveryDone(true);
|
setOauthDiscoveryDone(true);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch((err) => {
|
||||||
|
if (err?.name === "AbortError") return;
|
||||||
setOauthMetadata(null);
|
setOauthMetadata(null);
|
||||||
setOauthDiscoveryDone(true);
|
setOauthDiscoveryDone(true);
|
||||||
});
|
});
|
||||||
}, [oauthEnabled, serverUrl, effectiveOauthIssuerUrl]);
|
return () => controller.abort();
|
||||||
|
}, [oauthEnabled, serverUrl, effectiveOauthIssuerUrl, selectedServer?.id]);
|
||||||
|
|
||||||
// Auto-SSO: when enabled with OAUTH_ONLY, skip the login page entirely
|
// Auto-SSO: when enabled with OAUTH_ONLY, skip the login page entirely
|
||||||
const ssoError = searchParams.get("sso_error");
|
const ssoError = searchParams.get("sso_error");
|
||||||
@@ -644,7 +662,7 @@ export default function LoginPage() {
|
|||||||
redirectTo = saved;
|
redirectTo = saved;
|
||||||
}
|
}
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
router.push(redirectTo);
|
router.push(toRouterPath(redirectTo));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -707,7 +725,7 @@ export default function LoginPage() {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className="w-4 h-4" />
|
<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" />}
|
{isActive && <Check className="w-3.5 h-3.5 text-primary" />}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -856,7 +874,7 @@ export default function LoginPage() {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className="w-4 h-4" />
|
<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" />}
|
{isActive && <Check className="w-3.5 h-3.5 text-primary" />}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -870,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">
|
<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 */}
|
{/* Header section with logo */}
|
||||||
<div className="px-8 pt-10 pb-6 text-center">
|
<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
|
<img
|
||||||
src={withBasePath(resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl)}
|
src={withBasePath(resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl)}
|
||||||
alt={appName}
|
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>
|
</div>
|
||||||
<h1 className="text-2xl font-semibold text-foreground tracking-tight">
|
{loginShowHeading && (
|
||||||
{isAddAccountMode ? t("add_account_title") : appName}
|
<h1 className="text-2xl font-semibold text-foreground tracking-tight">
|
||||||
</h1>
|
{isAddAccountMode ? t("add_account_title") : appName}
|
||||||
<p className="text-sm text-muted-foreground mt-1.5">
|
</h1>
|
||||||
{isAddAccountMode ? t("add_account_subtitle") : (t("title") !== appName ? t("title") : "Sign in to your account")}
|
)}
|
||||||
</p>
|
{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>
|
</div>
|
||||||
|
|
||||||
{/* Form section */}
|
{/* Form section */}
|
||||||
@@ -1110,7 +1133,7 @@ export default function LoginPage() {
|
|||||||
type={showPassword ? "text" : "password"}
|
type={showPassword ? "text" : "password"}
|
||||||
value={formData.password}
|
value={formData.password}
|
||||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
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")}
|
placeholder={t("password_placeholder")}
|
||||||
required
|
required
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
@@ -1230,9 +1253,9 @@ export default function LoginPage() {
|
|||||||
disabled={oauthLoading || isLoading}
|
disabled={oauthLoading || isLoading}
|
||||||
>
|
>
|
||||||
{oauthLoading ? (
|
{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")}
|
{t("sign_in_sso")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
+411
-95
@@ -11,8 +11,9 @@ import type { ComposerDraftData } from "@/components/email/email-composer";
|
|||||||
import { ProtocolAccountPicker } from "@/components/protocol/protocol-account-picker";
|
import { ProtocolAccountPicker } from "@/components/protocol/protocol-account-picker";
|
||||||
import { ThreadConversationView } from "@/components/email/thread-conversation-view";
|
import { ThreadConversationView } from "@/components/email/thread-conversation-view";
|
||||||
import { MobileHeader } from "@/components/layout/mobile-header";
|
import { MobileHeader } from "@/components/layout/mobile-header";
|
||||||
import { ThreadGroup, Email, isUnifiedMailboxId, UNIFIED_ROLE_BY_ID } from "@/lib/jmap/types";
|
import { ThreadGroup, Email, Mailbox, isUnifiedMailboxId, UNIFIED_ROLE_BY_ID, ALL_MAIL_MAILBOX_ID, CROSS_VIEW_BY_ID, isCrossViewId } from "@/lib/jmap/types";
|
||||||
import { useAccountStore } from "@/stores/account-store";
|
import { useAccountStore } from "@/stores/account-store";
|
||||||
|
import { usePolicyStore } from "@/stores/policy-store";
|
||||||
import type { UnifiedAccountClient } from "@/lib/unified-mailbox";
|
import type { UnifiedAccountClient } from "@/lib/unified-mailbox";
|
||||||
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
|
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
|
||||||
import { useEmailStore, buildUnifiedAccountClients } from "@/stores/email-store";
|
import { useEmailStore, buildUnifiedAccountClients } from "@/stores/email-store";
|
||||||
@@ -31,6 +32,7 @@ import { useBrowserNavigation, type NavSnapshot } from "@/hooks/use-browser-navi
|
|||||||
import { debug } from "@/lib/debug";
|
import { debug } from "@/lib/debug";
|
||||||
import { playNotificationSound } from "@/lib/notification-sound";
|
import { playNotificationSound } from "@/lib/notification-sound";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { localizeMailboxName } from "@/lib/mailbox-label";
|
||||||
import {
|
import {
|
||||||
ErrorBoundary,
|
ErrorBoundary,
|
||||||
SidebarErrorFallback,
|
SidebarErrorFallback,
|
||||||
@@ -55,7 +57,7 @@ import { useProMultiAccountMailboxes } from "@/hooks/use-pro-multi-account-mailb
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
||||||
import { isFilePreviewable } from "@/lib/file-preview";
|
import { isFilePreviewable } from "@/lib/file-preview";
|
||||||
import { appendPlainTextSignature } from "@/lib/signature-utils";
|
import { appendHtmlSignature, appendPlainTextSignature } from "@/lib/signature-utils";
|
||||||
import { computeReplyThreadingHeaders } from "@/lib/email-threading";
|
import { computeReplyThreadingHeaders } from "@/lib/email-threading";
|
||||||
import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import";
|
import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import";
|
||||||
import { resolveReplyFrom } from "@/lib/reply-identity";
|
import { resolveReplyFrom } from "@/lib/reply-identity";
|
||||||
@@ -72,7 +74,8 @@ import { plainTextToComposerBody } from "@/lib/email-composer-utils";
|
|||||||
import { appLifecycleHooks, uiHooks, routerHooks, toastHooks, emailHooks } from "@/lib/plugin-hooks";
|
import { appLifecycleHooks, uiHooks, routerHooks, toastHooks, emailHooks } from "@/lib/plugin-hooks";
|
||||||
import { emailToReadView } from "@/lib/plugin-projection";
|
import { emailToReadView } from "@/lib/plugin-projection";
|
||||||
import { buildQuoteHeader } from "@/lib/quote-header";
|
import { buildQuoteHeader } from "@/lib/quote-header";
|
||||||
import { useLocaleStore } from "@/stores/locale-store";
|
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
|
||||||
|
import { getEffectiveLocale } from '@/i18n/detect-locale';
|
||||||
import type { QuoteHeader } from "@/lib/plugin-types";
|
import type { QuoteHeader } from "@/lib/plugin-types";
|
||||||
|
|
||||||
const SCHEDULED_MAILBOX_ID = '__scheduled__';
|
const SCHEDULED_MAILBOX_ID = '__scheduled__';
|
||||||
@@ -81,6 +84,7 @@ const SCHEDULED_MAILBOX_ID = '__scheduled__';
|
|||||||
export default function Home() {
|
export default function Home() {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const tCommon = useTranslations('common');
|
const tCommon = useTranslations('common');
|
||||||
|
const tQuote = useTranslations('quote_header');
|
||||||
const { appName } = useConfig();
|
const { appName } = useConfig();
|
||||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||||
const [showComposer, setShowComposer] = useState(false);
|
const [showComposer, setShowComposer] = useState(false);
|
||||||
@@ -117,7 +121,7 @@ export default function Home() {
|
|||||||
useIdentitySync();
|
useIdentitySync();
|
||||||
const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook);
|
const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook);
|
||||||
const sendDelaySeconds = useSettingsStore((state) => state.sendDelaySeconds);
|
const sendDelaySeconds = useSettingsStore((state) => state.sendDelaySeconds);
|
||||||
const { loadTrustedSendersBook, trustedSendersLoaded } = useContactStore();
|
const { loadTrustedSendersBook, trustedSendersLoaded, loadRecentRecipients } = useContactStore();
|
||||||
|
|
||||||
const promptForRescheduleDelayedUntil = useCallback((): string | null => {
|
const promptForRescheduleDelayedUntil = useCallback((): string | null => {
|
||||||
const value = window.prompt(t('email_viewer.reschedule_prompt'));
|
const value = window.prompt(t('email_viewer.reschedule_prompt'));
|
||||||
@@ -299,6 +303,7 @@ export default function Home() {
|
|||||||
fetchTagCounts,
|
fetchTagCounts,
|
||||||
fetchEmailContent,
|
fetchEmailContent,
|
||||||
isUnifiedView,
|
isUnifiedView,
|
||||||
|
unifiedRole,
|
||||||
scheduledEmails,
|
scheduledEmails,
|
||||||
scheduledTotal,
|
scheduledTotal,
|
||||||
scheduledHasMore,
|
scheduledHasMore,
|
||||||
@@ -315,7 +320,10 @@ export default function Home() {
|
|||||||
clearPendingUndoSend,
|
clearPendingUndoSend,
|
||||||
pendingUndoSend,
|
pendingUndoSend,
|
||||||
fetchUnifiedEmails: fetchUnifiedEmailsAction,
|
fetchUnifiedEmails: fetchUnifiedEmailsAction,
|
||||||
|
fetchCrossView: fetchCrossViewAction,
|
||||||
refreshUnifiedCounts,
|
refreshUnifiedCounts,
|
||||||
|
refreshCrossCounts,
|
||||||
|
crossUnreadCount,
|
||||||
exitUnifiedView,
|
exitUnifiedView,
|
||||||
emptyMailbox,
|
emptyMailbox,
|
||||||
markMailboxAsRead,
|
markMailboxAsRead,
|
||||||
@@ -331,14 +339,40 @@ export default function Home() {
|
|||||||
viewingAccountId,
|
viewingAccountId,
|
||||||
selectAccountMailbox,
|
selectAccountMailbox,
|
||||||
setViewingAccount,
|
setViewingAccount,
|
||||||
|
refreshCurrentMailbox,
|
||||||
} = useEmailStore();
|
} = 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
|
// Pro shell: populate per-account mailbox cache so the sidebar can render
|
||||||
// every connected account Thunderbird-style.
|
// every connected account Thunderbird-style.
|
||||||
useProMultiAccountMailboxes();
|
useProMultiAccountMailboxes();
|
||||||
|
|
||||||
const enableUnifiedMailbox = useSettingsStore((s) => s.enableUnifiedMailbox);
|
const enableUnifiedMailbox = useSettingsStore((s) => s.enableUnifiedMailbox);
|
||||||
|
const enableAllMailView = useSettingsStore((s) => s.enableAllMailView);
|
||||||
const delayedSendSupported = client?.hasDelayedSend() ?? true;
|
const delayedSendSupported = client?.hasDelayedSend() ?? true;
|
||||||
|
const allMailViewEnabled = usePolicyStore((s) => s.isFeatureEnabled('allMailViewEnabled'));
|
||||||
|
const showAllMailMailbox = allMailViewEnabled && enableAllMailView;
|
||||||
|
|
||||||
|
// Cross-account "All accounts" views: a sub-feature of the unified mailbox, so
|
||||||
|
// they require Unified Mailbox to be enabled, plus the admin gate and the
|
||||||
|
// per-user setting. Hooks are called unconditionally.
|
||||||
|
const crossUnreadGate = usePolicyStore((s) => s.isFeatureEnabled('crossUnreadViewEnabled'));
|
||||||
|
const crossStarredGate = usePolicyStore((s) => s.isFeatureEnabled('crossStarredViewEnabled'));
|
||||||
|
const crossAllGate = usePolicyStore((s) => s.isFeatureEnabled('crossAllViewEnabled'));
|
||||||
|
const enableCrossUnreadView = useSettingsStore((s) => s.enableCrossUnreadView);
|
||||||
|
const enableCrossStarredView = useSettingsStore((s) => s.enableCrossStarredView);
|
||||||
|
const enableCrossAllView = useSettingsStore((s) => s.enableCrossAllView);
|
||||||
|
const showCrossUnread = enableUnifiedMailbox && crossUnreadGate && enableCrossUnreadView;
|
||||||
|
const showCrossStarred = enableUnifiedMailbox && crossStarredGate && enableCrossStarredView;
|
||||||
|
const showCrossAll = enableUnifiedMailbox && crossAllGate && enableCrossAllView;
|
||||||
const activeEmails = isScheduledView ? scheduledEmails : emails;
|
const activeEmails = isScheduledView ? scheduledEmails : emails;
|
||||||
const activeHasMore = isScheduledView ? scheduledHasMore : hasMoreEmails;
|
const activeHasMore = isScheduledView ? scheduledHasMore : hasMoreEmails;
|
||||||
const activeIsLoading = isScheduledView ? isLoadingScheduled : isLoading;
|
const activeIsLoading = isScheduledView ? isLoadingScheduled : isLoading;
|
||||||
@@ -587,6 +621,10 @@ export default function Home() {
|
|||||||
onToggleSpam: async () => {
|
onToggleSpam: async () => {
|
||||||
if (isScheduledView) return;
|
if (isScheduledView) return;
|
||||||
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
|
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';
|
const isInJunk = currentMailbox?.role === 'junk';
|
||||||
if (selectedEmailIds.size > 0 && client) {
|
if (selectedEmailIds.size > 0 && client) {
|
||||||
const ids = Array.from(selectedEmailIds);
|
const ids = Array.from(selectedEmailIds);
|
||||||
@@ -710,7 +748,7 @@ export default function Home() {
|
|||||||
// Mailbox view
|
// Mailbox view
|
||||||
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
|
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
|
||||||
if (mailbox) {
|
if (mailbox) {
|
||||||
const mailboxName = mailbox.name;
|
const mailboxName = localizeMailboxName(mailbox.role, mailbox.name, (k) => t(`sidebar.mailboxes.${k}`));
|
||||||
const unreadCount = mailbox.unreadEmails || 0;
|
const unreadCount = mailbox.unreadEmails || 0;
|
||||||
title = unreadCount > 0
|
title = unreadCount > 0
|
||||||
? `${mailboxName} (${unreadCount}) - ${appName}`
|
? `${mailboxName} (${unreadCount}) - ${appName}`
|
||||||
@@ -751,9 +789,9 @@ export default function Home() {
|
|||||||
let title = t('email_composer.new_message');
|
let title = t('email_composer.new_message');
|
||||||
if (baseSubject) {
|
if (baseSubject) {
|
||||||
if (effectiveMode === 'reply' || effectiveMode === 'replyAll') {
|
if (effectiveMode === 'reply' || effectiveMode === 'replyAll') {
|
||||||
title = baseSubject.startsWith('Re:') ? baseSubject : `Re: ${baseSubject}`;
|
title = buildReplySubject(baseSubject, t('email_composer.prefix.reply'));
|
||||||
} else if (effectiveMode === 'forward') {
|
} else if (effectiveMode === 'forward') {
|
||||||
title = baseSubject.startsWith('Fwd:') ? baseSubject : `Fwd: ${baseSubject}`;
|
title = buildForwardSubject(baseSubject, t('email_composer.prefix.forward'));
|
||||||
} else {
|
} else {
|
||||||
title = baseSubject;
|
title = baseSubject;
|
||||||
}
|
}
|
||||||
@@ -978,14 +1016,16 @@ export default function Home() {
|
|||||||
// recounting happened"). The Pro shell always renders the unified mailbox
|
// recounting happened"). The Pro shell always renders the unified mailbox
|
||||||
// regardless of the user setting, so refresh when embedded too.
|
// regardless of the user setting, so refresh when embedded too.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enableUnifiedMailbox && !isEmbedded) return;
|
const anyCross = showCrossUnread || showCrossStarred || showCrossAll;
|
||||||
|
if (!enableUnifiedMailbox && !isEmbedded && !anyCross) return;
|
||||||
if (!isAuthenticated || !client) return;
|
if (!isAuthenticated || !client) return;
|
||||||
buildPopulatedUnifiedAccounts().then((built) => {
|
buildPopulatedUnifiedAccounts().then((built) => {
|
||||||
const hasGroupEntry = built.some((b) => b.isShared);
|
const hasGroupEntry = built.some((b) => b.isShared);
|
||||||
|
if (anyCross) refreshCrossCounts(built);
|
||||||
if (built.length < 2 && !hasGroupEntry && !isEmbedded) return;
|
if (built.length < 2 && !hasGroupEntry && !isEmbedded) return;
|
||||||
refreshUnifiedCounts(built);
|
refreshUnifiedCounts(built);
|
||||||
});
|
});
|
||||||
}, [enableUnifiedMailbox, includeGroupInUnified, isEmbedded, isAuthenticated, client, mailboxes, connectedAccountsSignature, buildPopulatedUnifiedAccounts, refreshUnifiedCounts]);
|
}, [enableUnifiedMailbox, includeGroupInUnified, isEmbedded, isAuthenticated, client, mailboxes, connectedAccountsSignature, buildPopulatedUnifiedAccounts, refreshUnifiedCounts, refreshCrossCounts, showCrossUnread, showCrossStarred, showCrossAll]);
|
||||||
|
|
||||||
// System-notification click handler. The push SW navigates the user back
|
// System-notification click handler. The push SW navigates the user back
|
||||||
// here with `?email=<id>` (specific email it built the toast from) or
|
// here with `?email=<id>` (specific email it built the toast from) or
|
||||||
@@ -1031,8 +1071,8 @@ export default function Home() {
|
|||||||
// Skip when handleEmailSelect already started a fetch (it sets isLoadingEmail before
|
// Skip when handleEmailSelect already started a fetch (it sets isLoadingEmail before
|
||||||
// calling selectEmail on the stub), to avoid a duplicate request.
|
// calling selectEmail on the stub), to avoid a duplicate request.
|
||||||
if (!selectedEmail.bodyValues && !isLoadingEmail) {
|
if (!selectedEmail.bodyValues && !isLoadingEmail) {
|
||||||
const perAccountClient = isUnifiedView && selectedEmail.accountId
|
const perAccountClient = isUnifiedView && selectedEmail.sourceClientAccountId
|
||||||
? useAuthStore.getState().getClientForAccount(selectedEmail.accountId)
|
? useAuthStore.getState().getClientForAccount(selectedEmail.sourceClientAccountId)
|
||||||
: undefined;
|
: undefined;
|
||||||
const fetchClient = perAccountClient ?? client;
|
const fetchClient = perAccountClient ?? client;
|
||||||
setLoadingEmail(true);
|
setLoadingEmail(true);
|
||||||
@@ -1129,6 +1169,7 @@ export default function Home() {
|
|||||||
inReplyTo?: string[];
|
inReplyTo?: string[];
|
||||||
references?: string[];
|
references?: string[];
|
||||||
delayedUntil?: string;
|
delayedUntil?: string;
|
||||||
|
requestReadReceipt?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
|
|
||||||
@@ -1136,7 +1177,7 @@ export default function Home() {
|
|||||||
const effectiveMode = pendingDraft?.mode ?? composerMode;
|
const effectiveMode = pendingDraft?.mode ?? composerMode;
|
||||||
const originalEmailId = selectedEmail?.id;
|
const originalEmailId = selectedEmail?.id;
|
||||||
|
|
||||||
const result = await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments, data.inReplyTo, data.references, data.delayedUntil, data.envelopeMailFrom);
|
const result = await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments, data.inReplyTo, data.references, data.delayedUntil, data.envelopeMailFrom, { requestReadReceipt: data.requestReadReceipt });
|
||||||
setShowComposer(false);
|
setShowComposer(false);
|
||||||
if (result.scheduled) {
|
if (result.scheduled) {
|
||||||
await refreshScheduledMetadata(client);
|
await refreshScheduledMetadata(client);
|
||||||
@@ -1144,23 +1185,53 @@ export default function Home() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark the original email with $answered or $forwarded keyword
|
// Mark the original email with $answered or $forwarded keyword. Route the
|
||||||
if (originalEmailId && (effectiveMode === 'reply' || effectiveMode === 'replyAll')) {
|
// 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 {
|
try {
|
||||||
await client.setKeyword(originalEmailId, '$answered');
|
await kwClient.setKeyword(originalEmailId, keyword, kwAccountId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debug.error('Failed to set $answered keyword:', e);
|
debug.error(`Failed to set ${keyword} keyword:`, e);
|
||||||
}
|
|
||||||
} else if (originalEmailId && effectiveMode === 'forward') {
|
|
||||||
try {
|
|
||||||
await client.setKeyword(originalEmailId, '$forwarded');
|
|
||||||
} catch (e) {
|
|
||||||
debug.error('Failed to set $forwarded keyword:', e);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh the current mailbox to update the UI
|
// Refresh the current mailbox to update the UI
|
||||||
if (!isScheduledView) await fetchEmails(client, selectedMailbox);
|
if (!isScheduledView) {
|
||||||
|
await refreshCurrentMailbox(client);
|
||||||
|
// Re-fetch the replied thread's cross-folder data so the expanded
|
||||||
|
// view shows the newly sent reply without collapsing.
|
||||||
|
if (originalEmailId) {
|
||||||
|
const emailState = useEmailStore.getState();
|
||||||
|
const repliedEmail = emailState.emails.find(e => e.id === originalEmailId);
|
||||||
|
if (repliedEmail?.threadId && emailState.expandedThreadIds.has(repliedEmail.threadId)) {
|
||||||
|
// Route to the email's own account so shared/group threads refresh
|
||||||
|
// from the right server, not the active one. (#281)
|
||||||
|
const threadClient = emailState.isUnifiedView && repliedEmail.sourceClientAccountId
|
||||||
|
? (useAuthStore.getState().getClientForAccount(repliedEmail.sourceClientAccountId) ?? client)
|
||||||
|
: client;
|
||||||
|
const accountId = emailState.isUnifiedView && repliedEmail.sourceAccountId
|
||||||
|
? repliedEmail.sourceAccountId
|
||||||
|
: client.getAccountId();
|
||||||
|
const fullEmails = await threadClient.getThreadEmails(repliedEmail.threadId, accountId);
|
||||||
|
if (fullEmails.length > 0) {
|
||||||
|
useEmailStore.setState((state) => {
|
||||||
|
const c = new Map(state.threadEmailsCache);
|
||||||
|
c.set(repliedEmail.threadId!, fullEmails);
|
||||||
|
return { threadEmailsCache: c };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to send email:", error);
|
console.error("Failed to send email:", error);
|
||||||
}
|
}
|
||||||
@@ -1209,16 +1280,23 @@ export default function Home() {
|
|||||||
},
|
},
|
||||||
newTo,
|
newTo,
|
||||||
newCc,
|
newCc,
|
||||||
locale: useLocaleStore.getState().locale,
|
locale: getEffectiveLocale(),
|
||||||
timeFormat: useSettingsStore.getState().timeFormat,
|
timeFormat: useSettingsStore.getState().timeFormat,
|
||||||
unknownLabel: tCommon('unknown'),
|
unknownLabel: tCommon('unknown'),
|
||||||
|
labels: {
|
||||||
|
formatReplyLine: (vars) => tQuote('reply_line', vars),
|
||||||
|
forwardedSeparator: tQuote('forwarded_separator'),
|
||||||
|
fromLabel: tQuote('from_label'),
|
||||||
|
dateLabel: tQuote('date_label'),
|
||||||
|
subjectLabel: tQuote('subject_label'),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
setComposerQuoteHeader(header);
|
setComposerQuoteHeader(header);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[quote-header] plugin transform failed; using default', err);
|
console.warn('[quote-header] plugin transform failed; using default', err);
|
||||||
setComposerQuoteHeader(null);
|
setComposerQuoteHeader(null);
|
||||||
}
|
}
|
||||||
}, [tCommon]);
|
}, [tCommon, tQuote]);
|
||||||
|
|
||||||
// Force a clean composer remount on every fresh entry point so prior
|
// Force a clean composer remount on every fresh entry point so prior
|
||||||
// compose state can't bleed into the new session (#329 C). The composer is
|
// compose state can't bleed into the new session (#329 C). The composer is
|
||||||
@@ -1266,6 +1344,8 @@ export default function Home() {
|
|||||||
draft = fullDraft;
|
draft = fullDraft;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
draft = await emailHooks.onBeforeEditDraft.transform(draft);
|
||||||
|
|
||||||
const bodyText = draft.bodyValues
|
const bodyText = draft.bodyValues
|
||||||
? Object.values(draft.bodyValues).map(v => v.value).join('\n')
|
? Object.values(draft.bodyValues).map(v => v.value).join('\n')
|
||||||
: '';
|
: '';
|
||||||
@@ -1378,8 +1458,20 @@ export default function Home() {
|
|||||||
const handleDelete = async (emailToDelete: Email | null = selectedEmail) => {
|
const handleDelete = async (emailToDelete: Email | null = selectedEmail) => {
|
||||||
if (!client || !emailToDelete) return;
|
if (!client || !emailToDelete) return;
|
||||||
|
|
||||||
// Check if we're currently in the trash or junk folder
|
// In unified view the trash destination and current-folder check must come
|
||||||
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
|
// from the email's own account, not the active one. The owning account's
|
||||||
|
// mailbox list is cached under its JMAP id (`sourceAccountId`). (#281)
|
||||||
|
const actionMailboxes =
|
||||||
|
isUnifiedView && emailToDelete.sourceAccountId
|
||||||
|
? (accountMailboxes[emailToDelete.sourceAccountId] ?? mailboxes)
|
||||||
|
: mailboxes;
|
||||||
|
|
||||||
|
// Check if we're currently in the trash or junk folder. In unified view the
|
||||||
|
// "current folder" is the unified role within the email's account.
|
||||||
|
const currentMailbox = isUnifiedView
|
||||||
|
? (actionMailboxes.find(m => m.role === unifiedRole && emailToDelete.mailboxIds?.[m.id])
|
||||||
|
?? actionMailboxes.find(m => m.role === unifiedRole))
|
||||||
|
: mailboxes.find(m => m.id === selectedMailbox);
|
||||||
const isInTrash = currentMailbox?.role === 'trash';
|
const isInTrash = currentMailbox?.role === 'trash';
|
||||||
const isInJunk = currentMailbox?.role === 'junk';
|
const isInJunk = currentMailbox?.role === 'junk';
|
||||||
const permanentlyDeleteJunk = useSettingsStore.getState().permanentlyDeleteJunk;
|
const permanentlyDeleteJunk = useSettingsStore.getState().permanentlyDeleteJunk;
|
||||||
@@ -1400,11 +1492,17 @@ export default function Home() {
|
|||||||
console.error("Failed to permanently delete email:", error);
|
console.error("Failed to permanently delete email:", error);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Not in trash: always move to trash
|
// Not in trash: always move to trash (in the email's own account). Scope
|
||||||
|
// the trash lookup to the email's account: for a shared/group source every
|
||||||
|
// mailbox in the list is `isShared`, so we match by accountId instead of
|
||||||
|
// excluding shared (otherwise no trash is found and the delete fails). (#281)
|
||||||
|
const sourceAccountId = isUnifiedView ? emailToDelete.sourceAccountId : undefined;
|
||||||
|
const matchesScope = (m: Mailbox) =>
|
||||||
|
sourceAccountId ? m.accountId === sourceAccountId : !m.isShared;
|
||||||
const trashMailbox =
|
const trashMailbox =
|
||||||
mailboxes.find(m => m.role === 'trash' && !m.isShared) ??
|
actionMailboxes.find(m => m.role === 'trash' && matchesScope(m)) ??
|
||||||
mailboxes.find(m => {
|
actionMailboxes.find(m => {
|
||||||
if (m.isShared) return false;
|
if (!matchesScope(m)) return false;
|
||||||
const lower = m.name.toLowerCase();
|
const lower = m.name.toLowerCase();
|
||||||
return lower.includes('trash') || lower.includes('deleted');
|
return lower.includes('trash') || lower.includes('deleted');
|
||||||
});
|
});
|
||||||
@@ -1426,9 +1524,30 @@ export default function Home() {
|
|||||||
const handleArchive = async (emailToArchive: Email | null = selectedEmail) => {
|
const handleArchive = async (emailToArchive: Email | null = selectedEmail) => {
|
||||||
if (!client || !emailToArchive) return;
|
if (!client || !emailToArchive) return;
|
||||||
|
|
||||||
|
// In unified view the archive folder (and any year/month subfolders we
|
||||||
|
// create) must live in the email's own account, reached through the login it
|
||||||
|
// is reachable via (`sourceClientAccountId`) and routed to its owning JMAP
|
||||||
|
// account (`sourceAccountId`). For personal sources these resolve to the
|
||||||
|
// account itself, so behavior is unchanged. (#281)
|
||||||
|
const archiveClientId = isUnifiedView ? emailToArchive.sourceClientAccountId : undefined;
|
||||||
|
const archiveAccountId = isUnifiedView ? emailToArchive.sourceAccountId : undefined;
|
||||||
|
const archiveClient = archiveClientId
|
||||||
|
? (useAuthStore.getState().getClientForAccount(archiveClientId) ?? client)
|
||||||
|
: client;
|
||||||
// Read fresh mailboxes from the store – batch archive calls this in a loop,
|
// Read fresh mailboxes from the store – batch archive calls this in a loop,
|
||||||
// and each iteration needs to see folders created by prior iterations.
|
// and each iteration needs to see folders created by prior iterations.
|
||||||
const currentMailboxes = useEmailStore.getState().mailboxes;
|
const readMailboxes = () => {
|
||||||
|
const s = useEmailStore.getState();
|
||||||
|
return archiveAccountId
|
||||||
|
? (s.accountMailboxes[archiveAccountId] ?? s.mailboxes)
|
||||||
|
: s.mailboxes;
|
||||||
|
};
|
||||||
|
const refreshMailboxes = () =>
|
||||||
|
archiveAccountId
|
||||||
|
? useEmailStore.getState().fetchAccountMailboxes(archiveClient, archiveAccountId)
|
||||||
|
: fetchMailboxes(client);
|
||||||
|
|
||||||
|
const currentMailboxes = readMailboxes();
|
||||||
|
|
||||||
const archiveMailbox = currentMailboxes.find(m => m.role === "archive" || m.name.toLowerCase() === "archive");
|
const archiveMailbox = currentMailboxes.find(m => m.role === "archive" || m.name.toLowerCase() === "archive");
|
||||||
if (!archiveMailbox) return;
|
if (!archiveMailbox) return;
|
||||||
@@ -1448,21 +1567,21 @@ export default function Home() {
|
|||||||
m => m.name === year && m.parentId === archiveId
|
m => m.name === year && m.parentId === archiveId
|
||||||
);
|
);
|
||||||
if (!yearMailbox) {
|
if (!yearMailbox) {
|
||||||
yearMailbox = await client.createMailbox(year, archiveId);
|
yearMailbox = await archiveClient.createMailbox(year, archiveId, archiveAccountId);
|
||||||
await fetchMailboxes(client);
|
await refreshMailboxes();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (archiveMode === 'year') {
|
if (archiveMode === 'year') {
|
||||||
await moveThreadToMailbox(client, emailToArchive.id, yearMailbox.id);
|
await moveThreadToMailbox(client, emailToArchive.id, yearMailbox.id);
|
||||||
} else {
|
} else {
|
||||||
const yearId = yearMailbox.originalId || yearMailbox.id;
|
const yearId = yearMailbox.originalId || yearMailbox.id;
|
||||||
const afterYear = useEmailStore.getState().mailboxes;
|
const afterYear = readMailboxes();
|
||||||
let monthMailbox = afterYear.find(
|
let monthMailbox = afterYear.find(
|
||||||
m => m.name === month && m.parentId === yearId
|
m => m.name === month && m.parentId === yearId
|
||||||
);
|
);
|
||||||
if (!monthMailbox) {
|
if (!monthMailbox) {
|
||||||
monthMailbox = await client.createMailbox(month, yearId);
|
monthMailbox = await archiveClient.createMailbox(month, yearId, archiveAccountId);
|
||||||
await fetchMailboxes(client);
|
await refreshMailboxes();
|
||||||
}
|
}
|
||||||
await moveThreadToMailbox(client, emailToArchive.id, monthMailbox.id);
|
await moveThreadToMailbox(client, emailToArchive.id, monthMailbox.id);
|
||||||
}
|
}
|
||||||
@@ -1473,7 +1592,7 @@ export default function Home() {
|
|||||||
setConversationEmails([]);
|
setConversationEmails([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
void fetchMailboxes(client);
|
void refreshMailboxes();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to archive email:", error);
|
console.error("Failed to archive email:", error);
|
||||||
}
|
}
|
||||||
@@ -1535,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) => {
|
const handleSetColorTag = async (emailId: string, color: string | null) => {
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
|
|
||||||
@@ -1554,7 +1712,7 @@ export default function Home() {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const jmapKey = `$label:${color}`;
|
const jmapKey = `$label:${color}`;
|
||||||
if (keywords[jmapKey] === true) {
|
if (keywords[jmapKey]) {
|
||||||
// Toggle off if already active
|
// Toggle off if already active
|
||||||
keywords[jmapKey] = false;
|
keywords[jmapKey] = false;
|
||||||
} else {
|
} else {
|
||||||
@@ -1563,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
|
// 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
|
// 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.
|
// instead of being reset to the first page by a full refetch.
|
||||||
@@ -1602,7 +1772,15 @@ export default function Home() {
|
|||||||
setTabletListVisible(true);
|
setTabletListVisible(true);
|
||||||
}
|
}
|
||||||
if (viewingClient) {
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1650,6 +1828,28 @@ export default function Home() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isCrossViewId(mailboxId)) {
|
||||||
|
setScheduledView(false);
|
||||||
|
const view = CROSS_VIEW_BY_ID[mailboxId];
|
||||||
|
if (!view) return;
|
||||||
|
|
||||||
|
selectMailbox(mailboxId);
|
||||||
|
selectEmail(null);
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
setSidebarOpen(false);
|
||||||
|
setActiveView("list");
|
||||||
|
}
|
||||||
|
if (isTablet) {
|
||||||
|
setTabletListVisible(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const populated = await buildPopulatedUnifiedAccounts();
|
||||||
|
await fetchCrossViewAction(populated, view);
|
||||||
|
refreshCrossCounts(populated);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (isUnifiedView) {
|
if (isUnifiedView) {
|
||||||
exitUnifiedView();
|
exitUnifiedView();
|
||||||
}
|
}
|
||||||
@@ -1670,8 +1870,13 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (client) {
|
if (client) {
|
||||||
// If there's an active search, re-run it in the new mailbox
|
// If there's an active search, re-run it in the new mailbox. Advanced
|
||||||
if (searchQuery) {
|
// 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);
|
await searchEmails(client, searchQuery);
|
||||||
} else {
|
} else {
|
||||||
await fetchEmails(client, mailboxId);
|
await fetchEmails(client, mailboxId);
|
||||||
@@ -2090,9 +2295,21 @@ export default function Home() {
|
|||||||
|
|
||||||
// Append signature from the sending identity (fall back to primary
|
// Append signature from the sending identity (fall back to primary
|
||||||
// when the reply-from lives on the same identity but a different alias).
|
// when the reply-from lives on the same identity but a different alias).
|
||||||
const finalBody = appendPlainTextSignature(body, sendingIdentity, {
|
const separator = useSettingsStore.getState().signatureSeparatorEnabled;
|
||||||
separator: useSettingsStore.getState().signatureSeparatorEnabled,
|
const finalBody = appendPlainTextSignature(body, sendingIdentity, { separator });
|
||||||
});
|
|
||||||
|
// When the identity has an HTML signature, send a matching HTML body so the
|
||||||
|
// signature keeps its formatting; appendPlainTextSignature would otherwise
|
||||||
|
// flatten it to plain text. Text-only identities keep the plain-text-only
|
||||||
|
// behavior (htmlBody stays undefined).
|
||||||
|
const escapedBody = body
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/\n/g, '<br>');
|
||||||
|
const finalHtmlBody = sendingIdentity?.htmlSignature?.trim()
|
||||||
|
? appendHtmlSignature(`<div>${escapedBody}</div>`, sendingIdentity, { separator })
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const originalEmailId = selectedEmail.id;
|
const originalEmailId = selectedEmail.id;
|
||||||
const sendDelaySeconds = useSettingsStore.getState().sendDelaySeconds;
|
const sendDelaySeconds = useSettingsStore.getState().sendDelaySeconds;
|
||||||
@@ -2116,7 +2333,7 @@ export default function Home() {
|
|||||||
const result = await sendEmail(
|
const result = await sendEmail(
|
||||||
client,
|
client,
|
||||||
[sender.email],
|
[sender.email],
|
||||||
`Re: ${selectedEmail.subject || "(no subject)"}`,
|
buildReplySubject(selectedEmail.subject || "(no subject)", t('email_composer.prefix.reply')),
|
||||||
finalBody,
|
finalBody,
|
||||||
undefined,
|
undefined,
|
||||||
undefined,
|
undefined,
|
||||||
@@ -2124,7 +2341,7 @@ export default function Home() {
|
|||||||
headerFromEmail,
|
headerFromEmail,
|
||||||
undefined,
|
undefined,
|
||||||
headerFromName,
|
headerFromName,
|
||||||
undefined,
|
finalHtmlBody,
|
||||||
undefined,
|
undefined,
|
||||||
threading?.inReplyTo,
|
threading?.inReplyTo,
|
||||||
threading?.references,
|
threading?.references,
|
||||||
@@ -2137,15 +2354,48 @@ export default function Home() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark the original email as answered
|
// Mark the original email as answered. Route the write to the email's own
|
||||||
try {
|
// account so the flag lands on shared/group-mailbox messages instead of
|
||||||
await client.setKeyword(originalEmailId, '$answered');
|
// being dropped against the reaching account. (#281)
|
||||||
} catch (e) {
|
{
|
||||||
debug.error('Failed to set $answered keyword:', e);
|
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
|
// Refresh emails to show the sent reply
|
||||||
await fetchEmails(client, selectedMailbox);
|
await refreshCurrentMailbox(client);
|
||||||
|
// Re-fetch the replied thread's cross-folder data so the expanded
|
||||||
|
// view shows the newly sent reply without collapsing.
|
||||||
|
const emailState = useEmailStore.getState();
|
||||||
|
const repliedEmail = emailState.emails.find(e => e.id === originalEmailId);
|
||||||
|
if (repliedEmail?.threadId && emailState.expandedThreadIds.has(repliedEmail.threadId)) {
|
||||||
|
// Route to the email's own account so shared/group threads refresh from
|
||||||
|
// the right server, not the active one. (#281)
|
||||||
|
const threadClient = emailState.isUnifiedView && repliedEmail.sourceClientAccountId
|
||||||
|
? (useAuthStore.getState().getClientForAccount(repliedEmail.sourceClientAccountId) ?? client)
|
||||||
|
: client;
|
||||||
|
const accountId = emailState.isUnifiedView && repliedEmail.sourceAccountId
|
||||||
|
? repliedEmail.sourceAccountId
|
||||||
|
: client.getAccountId();
|
||||||
|
const fullEmails = await threadClient.getThreadEmails(repliedEmail.threadId, accountId);
|
||||||
|
if (fullEmails.length > 0) {
|
||||||
|
useEmailStore.setState((state) => {
|
||||||
|
const c = new Map(state.threadEmailsCache);
|
||||||
|
c.set(repliedEmail.threadId!, fullEmails);
|
||||||
|
return { threadEmailsCache: c };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Show loading state while checking auth
|
// Show loading state while checking auth
|
||||||
@@ -2161,7 +2411,16 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get current mailbox name for mobile header
|
// Get current mailbox name for mobile header
|
||||||
const currentMailboxName = isScheduledView ? t('sidebar.scheduled') : mailboxes.find(m => m.id === selectedMailbox)?.name || "Inbox";
|
const currentMailboxName = isScheduledView
|
||||||
|
? t('sidebar.scheduled')
|
||||||
|
: selectedMailbox === ALL_MAIL_MAILBOX_ID
|
||||||
|
? t('sidebar.mailboxes.all_mail')
|
||||||
|
: (() => {
|
||||||
|
const mb = mailboxes.find(m => m.id === selectedMailbox);
|
||||||
|
return mb
|
||||||
|
? localizeMailboxName(mb.role, mb.name, (k) => t(`sidebar.mailboxes.${k}`))
|
||||||
|
: "Inbox";
|
||||||
|
})();
|
||||||
const isFocusedMailLayout = mailLayout === 'focus';
|
const isFocusedMailLayout = mailLayout === 'focus';
|
||||||
const isHorizontalMailLayout = mailLayout === 'horizontal' && !isMobile && !isTablet;
|
const isHorizontalMailLayout = mailLayout === 'horizontal' && !isMobile && !isTablet;
|
||||||
const hasViewerContent = showComposer || Boolean(conversationThread) || Boolean(selectedEmail);
|
const hasViewerContent = showComposer || Boolean(conversationThread) || Boolean(selectedEmail);
|
||||||
@@ -2178,11 +2437,10 @@ export default function Home() {
|
|||||||
setShowComposer(false);
|
setShowComposer(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// waiting for the body fetch - avoids the loading flicker.
|
// Find the list-level email for metadata (accountId, scheduled flags, etc.)
|
||||||
|
// but don't select it yet — wait for the full fetch to avoid a toolbar flash
|
||||||
|
// caused by rendering with the stub (no bodyValues) then re-rendering with the full email.
|
||||||
const listEmail = activeEmails.find(e => e.id === email.id);
|
const listEmail = activeEmails.find(e => e.id === email.id);
|
||||||
if (listEmail) {
|
|
||||||
selectEmail(listEmail);
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoadingEmail(true);
|
setLoadingEmail(true);
|
||||||
|
|
||||||
@@ -2198,21 +2456,25 @@ export default function Home() {
|
|||||||
|
|
||||||
// Fetch the full content
|
// Fetch the full content
|
||||||
try {
|
try {
|
||||||
// In unified view each email carries its own accountId. Use that
|
// In unified view each email carries its source reference: the login it is
|
||||||
// account's client so we fetch from the server that actually owns it.
|
// reachable through (`sourceClientAccountId`) and its owning JMAP account
|
||||||
const emailAccountId = isUnifiedView ? listEmail?.accountId : undefined;
|
// (`sourceAccountId`). Resolve both so we fetch from the server that actually
|
||||||
const perAccountClient = emailAccountId
|
// owns it — works uniformly for personal and shared/group sources, since for
|
||||||
? useAuthStore.getState().getClientForAccount(emailAccountId)
|
// personal the owning account equals the client's primary (no-op). (#281)
|
||||||
|
const sourceClientId = isUnifiedView ? listEmail?.sourceClientAccountId : undefined;
|
||||||
|
const perAccountClient = sourceClientId
|
||||||
|
? useAuthStore.getState().getClientForAccount(sourceClientId)
|
||||||
: undefined;
|
: undefined;
|
||||||
const fetchClient = perAccountClient ?? client;
|
const fetchClient = perAccountClient ?? client;
|
||||||
|
|
||||||
// For shared folders on the primary client, we still need to pass the
|
const accountId = isUnifiedView
|
||||||
// shared account's id. In unified view we use the per-account client
|
? listEmail?.sourceAccountId
|
||||||
// directly, so no explicit accountId is needed.
|
: (() => {
|
||||||
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
|
// Non-unified: shared folders on the active client still need their
|
||||||
const accountId = perAccountClient
|
// owner accountId passed explicitly.
|
||||||
? undefined
|
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
|
||||||
: mailbox?.isShared ? mailbox.accountId : undefined;
|
return mailbox?.isShared ? mailbox.accountId : undefined;
|
||||||
|
})();
|
||||||
|
|
||||||
const fullEmail = await fetchClient.getEmail(email.id, accountId);
|
const fullEmail = await fetchClient.getEmail(email.id, accountId);
|
||||||
if (fullEmail) {
|
if (fullEmail) {
|
||||||
@@ -2224,9 +2486,13 @@ export default function Home() {
|
|||||||
fullEmail.isScheduled = true;
|
fullEmail.isScheduled = true;
|
||||||
fullEmail.isSmimeScheduled = listEmail.isSmimeScheduled;
|
fullEmail.isSmimeScheduled = listEmail.isSmimeScheduled;
|
||||||
}
|
}
|
||||||
if (emailAccountId) {
|
// Re-stamp the source reference so later actions on the open email
|
||||||
fullEmail.accountId = emailAccountId;
|
// resolve to the right account (the fetched object lacks these).
|
||||||
fullEmail.accountLabel = listEmail?.accountLabel;
|
if (isUnifiedView && listEmail) {
|
||||||
|
fullEmail.accountId = listEmail.accountId;
|
||||||
|
fullEmail.accountLabel = listEmail.accountLabel;
|
||||||
|
fullEmail.sourceClientAccountId = listEmail.sourceClientAccountId;
|
||||||
|
fullEmail.sourceAccountId = listEmail.sourceAccountId;
|
||||||
}
|
}
|
||||||
selectEmail(fullEmail);
|
selectEmail(fullEmail);
|
||||||
// Mark-as-read logic is now handled by useEffect
|
// Mark-as-read logic is now handled by useEffect
|
||||||
@@ -2275,8 +2541,27 @@ export default function Home() {
|
|||||||
setActiveView("viewer");
|
setActiveView("viewer");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch complete thread emails
|
// In unified/aggregate views the thread may belong to another (possibly
|
||||||
const emails = await client.getThreadEmails(thread.threadId);
|
// shared/group) account. Route the fetch to the login it's reachable
|
||||||
|
// through (`sourceClientAccountId`) and pass its owning JMAP account
|
||||||
|
// (`sourceAccountId`) so the thread loads from the right server instead of
|
||||||
|
// the active one (which doesn't have it → empty/body-less). (#281)
|
||||||
|
const ref = thread.emails?.[0];
|
||||||
|
const threadClient = isUnifiedView && ref?.sourceClientAccountId
|
||||||
|
? (useAuthStore.getState().getClientForAccount(ref.sourceClientAccountId) ?? client)
|
||||||
|
: client;
|
||||||
|
const threadAccountId = isUnifiedView ? ref?.sourceAccountId : undefined;
|
||||||
|
const emails = await threadClient.getThreadEmails(thread.threadId, threadAccountId);
|
||||||
|
// Re-stamp the source reference so conversation actions (reply/move/…)
|
||||||
|
// resolve to the right account; the fetched objects don't carry it.
|
||||||
|
if (isUnifiedView && ref) {
|
||||||
|
for (const e of emails) {
|
||||||
|
e.accountId = ref.accountId;
|
||||||
|
e.accountLabel = ref.accountLabel;
|
||||||
|
e.sourceClientAccountId = ref.sourceClientAccountId;
|
||||||
|
e.sourceAccountId = ref.sourceAccountId;
|
||||||
|
}
|
||||||
|
}
|
||||||
setConversationEmails(emails);
|
setConversationEmails(emails);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch thread emails:', error);
|
console.error('Failed to fetch thread emails:', error);
|
||||||
@@ -2414,7 +2699,7 @@ export default function Home() {
|
|||||||
),
|
),
|
||||||
inlineApp && "hidden"
|
inlineApp && "hidden"
|
||||||
)}
|
)}
|
||||||
style={!isMobile && !isTablet ? { width: sidebarCollapsed ? 64 : sidebarWidth } : undefined}
|
style={!isMobile && !isTablet ? { width: sidebarCollapsed ? 48 : sidebarWidth } : undefined}
|
||||||
>
|
>
|
||||||
<ErrorBoundary fallback={SidebarErrorFallback}>
|
<ErrorBoundary fallback={SidebarErrorFallback}>
|
||||||
<Sidebar
|
<Sidebar
|
||||||
@@ -2423,6 +2708,11 @@ export default function Home() {
|
|||||||
selectedKeyword={selectedKeyword}
|
selectedKeyword={selectedKeyword}
|
||||||
scheduledTotal={scheduledTotal}
|
scheduledTotal={scheduledTotal}
|
||||||
showScheduledMailbox={delayedSendSupported}
|
showScheduledMailbox={delayedSendSupported}
|
||||||
|
showAllMailMailbox={showAllMailMailbox}
|
||||||
|
showCrossUnread={showCrossUnread}
|
||||||
|
showCrossStarred={showCrossStarred}
|
||||||
|
showCrossAll={showCrossAll}
|
||||||
|
crossUnreadCount={crossUnreadCount}
|
||||||
onMailboxSelect={handleMailboxSelect}
|
onMailboxSelect={handleMailboxSelect}
|
||||||
onTagSelect={handleTagSelect}
|
onTagSelect={handleTagSelect}
|
||||||
onUnreadFilterClick={handleUnreadFilterClick}
|
onUnreadFilterClick={handleUnreadFilterClick}
|
||||||
@@ -2471,17 +2761,19 @@ export default function Home() {
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex flex-col bg-background",
|
"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
|
// 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",
|
isMobile && activeView !== "list" && "max-md:hidden",
|
||||||
// Tablet/Desktop: fixed width with collapse animation
|
// 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: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 && !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",
|
!isHorizontalMailLayout && "md:shadow-sm",
|
||||||
!isResizing && "transition-all duration-200 ease-out",
|
!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={
|
style={
|
||||||
isMobile
|
isMobile
|
||||||
@@ -2498,8 +2790,8 @@ export default function Home() {
|
|||||||
|
|
||||||
{/* Search Bar + Inline Advanced Filters */}
|
{/* Search Bar + Inline Advanced Filters */}
|
||||||
<div className="border-b border-border bg-background">
|
<div className="border-b border-border bg-background">
|
||||||
<div className="px-3 py-3">
|
<div className="px-3 h-14 flex items-center">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5 w-full">
|
||||||
{/* Select / Select All toggle */}
|
{/* Select / Select All toggle */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -2534,13 +2826,13 @@ export default function Home() {
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<form onSubmit={(e) => { e.preventDefault(); if (searchQuery.trim()) handleSearch(searchQuery); }} className="relative flex-1">
|
<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
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={t("sidebar.search_placeholder_hint")}
|
placeholder={t("sidebar.search_placeholder_hint")}
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
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-search-input
|
||||||
data-tour="search-input"
|
data-tour="search-input"
|
||||||
disabled={isUnifiedView || isScheduledView}
|
disabled={isUnifiedView || isScheduledView}
|
||||||
@@ -2550,7 +2842,7 @@ export default function Home() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleClearSearch}
|
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")}
|
aria-label={t("sidebar.clear_search")}
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
@@ -2590,24 +2882,24 @@ export default function Home() {
|
|||||||
icon={<Paperclip className="w-3.5 h-3.5" />}
|
icon={<Paperclip className="w-3.5 h-3.5" />}
|
||||||
label={t("advanced_search.has_attachment")}
|
label={t("advanced_search.has_attachment")}
|
||||||
value={searchFilters.hasAttachment}
|
value={searchFilters.hasAttachment}
|
||||||
onClick={() => { const next = searchFilters.hasAttachment === null ? true : searchFilters.hasAttachment === true ? false : null; setSearchFilters({ hasAttachment: next }); handleAdvancedSearch(); }}
|
onClick={() => { const next = searchFilters.hasAttachment === null ? true : searchFilters.hasAttachment ? false : null; setSearchFilters({ hasAttachment: next }); handleAdvancedSearch(); }}
|
||||||
/>
|
/>
|
||||||
<ToggleChip
|
<ToggleChip
|
||||||
icon={<Star className="w-3.5 h-3.5" />}
|
icon={<Star className="w-3.5 h-3.5" />}
|
||||||
label={t("advanced_search.starred")}
|
label={t("advanced_search.starred")}
|
||||||
value={searchFilters.isStarred}
|
value={searchFilters.isStarred}
|
||||||
onClick={() => { const next = searchFilters.isStarred === null ? true : searchFilters.isStarred === true ? false : null; setSearchFilters({ isStarred: next }); handleAdvancedSearch(); }}
|
onClick={() => { const next = searchFilters.isStarred === null ? true : searchFilters.isStarred ? false : null; setSearchFilters({ isStarred: next }); handleAdvancedSearch(); }}
|
||||||
/>
|
/>
|
||||||
<ToggleChip
|
<ToggleChip
|
||||||
icon={searchFilters.isUnread === false ? <MailOpen className="w-3.5 h-3.5" /> : <Mail className="w-3.5 h-3.5" />}
|
icon={searchFilters.isUnread === false ? <MailOpen className="w-3.5 h-3.5" /> : <Mail className="w-3.5 h-3.5" />}
|
||||||
label={searchFilters.isUnread === false ? t("advanced_search.read") : t("advanced_search.unread")}
|
label={searchFilters.isUnread === false ? t("advanced_search.read") : t("advanced_search.unread")}
|
||||||
value={searchFilters.isUnread}
|
value={searchFilters.isUnread}
|
||||||
onClick={() => { const next = searchFilters.isUnread === null ? true : searchFilters.isUnread === true ? false : null; setSearchFilters({ isUnread: next }); handleAdvancedSearch(); }}
|
onClick={() => { const next = searchFilters.isUnread === null ? true : searchFilters.isUnread ? false : null; setSearchFilters({ isUnread: next }); handleAdvancedSearch(); }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<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">
|
<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")}
|
{t("advanced_search.clear")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -2791,6 +3083,9 @@ export default function Home() {
|
|||||||
await toggleStar(client, email.id);
|
await toggleStar(client, email.id);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onTogglePinned={async (email) => {
|
||||||
|
await handleTogglePinned(email);
|
||||||
|
}}
|
||||||
onDelete={async (email) => {
|
onDelete={async (email) => {
|
||||||
await handleDelete(email);
|
await handleDelete(email);
|
||||||
}}
|
}}
|
||||||
@@ -2829,7 +3124,7 @@ export default function Home() {
|
|||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute z-40 rounded-full shadow-lg",
|
"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')}
|
aria-label={t('sidebar.compose')}
|
||||||
title={t('sidebar.compose_hint')}
|
title={t('sidebar.compose_hint')}
|
||||||
@@ -2888,6 +3183,11 @@ export default function Home() {
|
|||||||
<EmailComposer
|
<EmailComposer
|
||||||
key={composerSessionId}
|
key={composerSessionId}
|
||||||
mode={pendingDraft?.mode ?? composerMode}
|
mode={pendingDraft?.mode ?? composerMode}
|
||||||
|
composeFromAccountEmail={
|
||||||
|
useAccountStore
|
||||||
|
.getState()
|
||||||
|
.getAccountById(viewingAccountId ?? activeAccountId ?? '')?.email
|
||||||
|
}
|
||||||
replyTo={pendingDraft !== null ? pendingDraft.replyTo : (selectedEmail ? {
|
replyTo={pendingDraft !== null ? pendingDraft.replyTo : (selectedEmail ? {
|
||||||
from: selectedEmail.from,
|
from: selectedEmail.from,
|
||||||
replyToAddresses: selectedEmail.replyTo,
|
replyToAddresses: selectedEmail.replyTo,
|
||||||
@@ -2952,13 +3252,13 @@ export default function Home() {
|
|||||||
setShowComposer(true);
|
setShowComposer(true);
|
||||||
if (isMobile) setActiveView('viewer');
|
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" />
|
<PenLine className="w-4 h-4 text-primary shrink-0" />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<span className="text-sm font-medium text-primary">{t('email_composer.continue_draft')}</span>
|
<span className="text-sm font-medium text-primary">{t('email_composer.continue_draft')}</span>
|
||||||
{pendingDraft.subject && (
|
{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>
|
</div>
|
||||||
<X
|
<X
|
||||||
@@ -3004,7 +3304,17 @@ export default function Home() {
|
|||||||
onReply={handleReply}
|
onReply={handleReply}
|
||||||
onReplyAll={handleReplyAll}
|
onReplyAll={handleReplyAll}
|
||||||
onForward={handleForward}
|
onForward={handleForward}
|
||||||
onDelete={() => handleDelete()}
|
onDelete={() => {
|
||||||
|
// Deleting the open message returns to the list (Gmail-style),
|
||||||
|
// not the next email — unless the user turned the setting off.
|
||||||
|
// Deselect first so the store's remove-and-advance sees no
|
||||||
|
// selection and doesn't auto-open the next message.
|
||||||
|
const target = selectedEmail;
|
||||||
|
if (useSettingsStore.getState().returnToListAfterAction) {
|
||||||
|
handleMobileBack();
|
||||||
|
}
|
||||||
|
handleDelete(target);
|
||||||
|
}}
|
||||||
onArchive={() => handleArchive()}
|
onArchive={() => handleArchive()}
|
||||||
onToggleStar={handleToggleStar}
|
onToggleStar={handleToggleStar}
|
||||||
onSetColorTag={handleSetColorTag}
|
onSetColorTag={handleSetColorTag}
|
||||||
@@ -3013,6 +3323,12 @@ export default function Home() {
|
|||||||
onMarkAsRead={async (emailId, read) => {
|
onMarkAsRead={async (emailId, read) => {
|
||||||
if (client) {
|
if (client) {
|
||||||
await markAsRead(client, emailId, read);
|
await markAsRead(client, emailId, read);
|
||||||
|
// Marking the open message unread returns to the list
|
||||||
|
// (Gmail-style, gated on returnToListAfterAction). Staying
|
||||||
|
// in the reading pane would just re-mark it read on view.
|
||||||
|
if (!read && useSettingsStore.getState().returnToListAfterAction) {
|
||||||
|
handleMobileBack();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onDownloadAttachment={handleDownloadAttachment}
|
onDownloadAttachment={handleDownloadAttachment}
|
||||||
@@ -3047,7 +3363,7 @@ export default function Home() {
|
|||||||
}}
|
}}
|
||||||
currentUserEmail={client?.getUsername()}
|
currentUserEmail={client?.getUsername()}
|
||||||
currentUserName={client?.getUsername()?.split("@")[0]}
|
currentUserName={client?.getUsername()?.split("@")[0]}
|
||||||
currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role}
|
currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role ?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined)}
|
||||||
mailboxes={mailboxes}
|
mailboxes={mailboxes}
|
||||||
selectedMailbox={selectedMailbox}
|
selectedMailbox={selectedMailbox}
|
||||||
onMoveToMailbox={async (mailboxId) => {
|
onMoveToMailbox={async (mailboxId) => {
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ export default function ProHome() {
|
|||||||
const focusedPaneId = useProTabStore((s) => s.focusedPaneId);
|
const focusedPaneId = useProTabStore((s) => s.focusedPaneId);
|
||||||
const loadedTabIds = useProTabStore((s) => s.loadedTabIds);
|
const loadedTabIds = useProTabStore((s) => s.loadedTabIds);
|
||||||
const openTab = useProTabStore((s) => s.openTab);
|
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 setActiveTab = useProTabStore((s) => s.setActiveTab);
|
||||||
const setFocusedPane = useProTabStore((s) => s.setFocusedPane);
|
const setFocusedPane = useProTabStore((s) => s.setFocusedPane);
|
||||||
const moveTabToPane = useProTabStore((s) => s.moveTabToPane);
|
const moveTabToPane = useProTabStore((s) => s.moveTabToPane);
|
||||||
@@ -354,7 +354,7 @@ export default function ProHome() {
|
|||||||
activeMainTabId={activeMainTabId}
|
activeMainTabId={activeMainTabId}
|
||||||
activeSplitTabId={activeSplitTabId}
|
activeSplitTabId={activeSplitTabId}
|
||||||
onActivate={setActiveTab}
|
onActivate={setActiveTab}
|
||||||
onClose={closeTab}
|
onClose={requestCloseTab}
|
||||||
onDragStateChange={setIsTabDragging}
|
onDragStateChange={setIsTabDragging}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useRef, useMemo } from 'react';
|
import { useState, useEffect, useRef, useMemo, useSyncExternalStore } from 'react';
|
||||||
import { useRouter } from '@/i18n/navigation';
|
import { useRouter } from '@/i18n/navigation';
|
||||||
import { useTranslations, useMessages } from 'next-intl';
|
import { useTranslations, useMessages } from 'next-intl';
|
||||||
import {
|
import {
|
||||||
@@ -21,7 +21,6 @@ import {
|
|||||||
Tags,
|
Tags,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
BookUser,
|
BookUser,
|
||||||
KeyRound,
|
|
||||||
PanelLeftClose,
|
PanelLeftClose,
|
||||||
Bell,
|
Bell,
|
||||||
Puzzle,
|
Puzzle,
|
||||||
@@ -33,6 +32,7 @@ import {
|
|||||||
Languages,
|
Languages,
|
||||||
Info,
|
Info,
|
||||||
Bug,
|
Bug,
|
||||||
|
SwatchBook,
|
||||||
Download,
|
Download,
|
||||||
X,
|
X,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
@@ -62,17 +62,19 @@ import { AccountSecuritySettings } from '@/components/settings/account-security-
|
|||||||
import { FilesSettingsComponent } from '@/components/settings/files-settings';
|
import { FilesSettingsComponent } from '@/components/settings/files-settings';
|
||||||
import { DownloadsSettings } from '@/components/settings/downloads-settings';
|
import { DownloadsSettings } from '@/components/settings/downloads-settings';
|
||||||
import { ContactsSettings } from '@/components/settings/contacts-settings';
|
import { ContactsSettings } from '@/components/settings/contacts-settings';
|
||||||
import { SmimeSettings } from '@/components/settings/smime-settings';
|
|
||||||
import { SidebarAppsSettings } from '@/components/settings/sidebar-apps-settings';
|
import { SidebarAppsSettings } from '@/components/settings/sidebar-apps-settings';
|
||||||
import { NotificationSettings } from '@/components/settings/notification-settings';
|
import { NotificationSettings } from '@/components/settings/notification-settings';
|
||||||
import { ThemesSettings } from '@/components/settings/themes-settings';
|
import { ThemesSettings } from '@/components/settings/themes-settings';
|
||||||
import { PluginsSettings } from '@/components/settings/plugins-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 { ProtocolHandlerSettings } from '@/components/settings/protocol-handler-settings';
|
||||||
import { useAuthStore, redirectToLogin } from '@/stores/auth-store';
|
import { useAuthStore, redirectToLogin } from '@/stores/auth-store';
|
||||||
import { useEmailStore } from '@/stores/email-store';
|
import { useEmailStore } from '@/stores/email-store';
|
||||||
import { usePluginStore } from '@/stores/plugin-store';
|
import { usePluginStore } from '@/stores/plugin-store';
|
||||||
import { useThemeStore } from '@/stores/theme-store';
|
import { useThemeStore } from '@/stores/theme-store';
|
||||||
import { useSettingsStore } from '@/stores/settings-store';
|
import { useSettingsStore } from '@/stores/settings-store';
|
||||||
|
import { useManagedAccountStore } from '@/stores/managed-account-store';
|
||||||
import { useIsDesktop } from '@/hooks/use-media-query';
|
import { useIsDesktop } from '@/hooks/use-media-query';
|
||||||
import { NavigationRail } from '@/components/layout/navigation-rail';
|
import { NavigationRail } from '@/components/layout/navigation-rail';
|
||||||
import { SidebarAppsModal } from '@/components/layout/sidebar-apps-modal';
|
import { SidebarAppsModal } from '@/components/layout/sidebar-apps-modal';
|
||||||
@@ -100,7 +102,6 @@ type Tab =
|
|||||||
| 'folders'
|
| 'folders'
|
||||||
| 'keywords'
|
| 'keywords'
|
||||||
| 'security'
|
| 'security'
|
||||||
| 'encryption'
|
|
||||||
| 'content_senders'
|
| 'content_senders'
|
||||||
| 'calendar'
|
| 'calendar'
|
||||||
| 'contacts'
|
| 'contacts'
|
||||||
@@ -114,8 +115,14 @@ type Tab =
|
|||||||
|
|
||||||
type TabGroup = 'general' | 'appearance' | 'mail' | 'privacy' | 'apps' | 'advanced';
|
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 {
|
interface TabDef {
|
||||||
id: Tab;
|
id: SettingsTabId;
|
||||||
label: string;
|
label: string;
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
group: TabGroup;
|
group: TabGroup;
|
||||||
@@ -137,7 +144,6 @@ const tabIcons: Record<Tab, LucideIcon> = {
|
|||||||
folders: FolderOpen,
|
folders: FolderOpen,
|
||||||
keywords: Tags,
|
keywords: Tags,
|
||||||
security: Shield,
|
security: Shield,
|
||||||
encryption: KeyRound,
|
|
||||||
content_senders: EyeOff,
|
content_senders: EyeOff,
|
||||||
calendar: Calendar,
|
calendar: Calendar,
|
||||||
contacts: BookUser,
|
contacts: BookUser,
|
||||||
@@ -145,7 +151,7 @@ const tabIcons: Record<Tab, LucideIcon> = {
|
|||||||
protocol_handlers: LinkIcon,
|
protocol_handlers: LinkIcon,
|
||||||
sidebar_apps: PanelLeftClose,
|
sidebar_apps: PanelLeftClose,
|
||||||
about_data: Info,
|
about_data: Info,
|
||||||
themes: Palette,
|
themes: SwatchBook,
|
||||||
plugins: Puzzle,
|
plugins: Puzzle,
|
||||||
debug: Bug,
|
debug: Bug,
|
||||||
};
|
};
|
||||||
@@ -181,6 +187,7 @@ const tabSearchPaths: Record<Tab, string[]> = {
|
|||||||
'settings.appearance.hide_account_switcher',
|
'settings.appearance.hide_account_switcher',
|
||||||
'settings.appearance.show_rail_account_list',
|
'settings.appearance.show_rail_account_list',
|
||||||
'settings.appearance.unified_mailbox',
|
'settings.appearance.unified_mailbox',
|
||||||
|
'settings.appearance.all_mail',
|
||||||
'settings.appearance.colorful_sidebar_icons',
|
'settings.appearance.colorful_sidebar_icons',
|
||||||
'settings.email_behavior.mail_layout',
|
'settings.email_behavior.mail_layout',
|
||||||
],
|
],
|
||||||
@@ -197,11 +204,11 @@ const tabSearchPaths: Record<Tab, string[]> = {
|
|||||||
'settings.email_behavior.hover_actions',
|
'settings.email_behavior.hover_actions',
|
||||||
'settings.email_behavior.permanently_delete_junk',
|
'settings.email_behavior.permanently_delete_junk',
|
||||||
'settings.email_behavior.show_preview',
|
'settings.email_behavior.show_preview',
|
||||||
'settings.email_behavior.plain_text_mode',
|
|
||||||
],
|
],
|
||||||
composing: [
|
composing: [
|
||||||
'settings.email_behavior.attachment_reminder',
|
'settings.email_behavior.attachment_reminder',
|
||||||
'settings.email_behavior.auto_select_reply_identity',
|
'settings.email_behavior.auto_select_reply_identity',
|
||||||
|
'settings.email_behavior.plain_text_mode',
|
||||||
'settings.email_behavior.default_mail_program',
|
'settings.email_behavior.default_mail_program',
|
||||||
'settings.email_behavior.signature_position',
|
'settings.email_behavior.signature_position',
|
||||||
'settings.email_behavior.sub_address_delimiter',
|
'settings.email_behavior.sub_address_delimiter',
|
||||||
@@ -214,7 +221,6 @@ const tabSearchPaths: Record<Tab, string[]> = {
|
|||||||
folders: ['settings.folders'],
|
folders: ['settings.folders'],
|
||||||
keywords: ['settings.keywords'],
|
keywords: ['settings.keywords'],
|
||||||
security: ['settings.security'],
|
security: ['settings.security'],
|
||||||
encryption: ['smime'],
|
|
||||||
content_senders: [
|
content_senders: [
|
||||||
'settings.email_behavior.always_light_mode',
|
'settings.email_behavior.always_light_mode',
|
||||||
'settings.email_behavior.external_content',
|
'settings.email_behavior.external_content',
|
||||||
@@ -249,7 +255,6 @@ const tabKeywords: Record<Tab, string> = {
|
|||||||
folders: 'mailbox subscribe',
|
folders: 'mailbox subscribe',
|
||||||
keywords: 'tags labels colors',
|
keywords: 'tags labels colors',
|
||||||
security: 'password 2fa two-factor passkey app password mfa',
|
security: 'password 2fa two-factor passkey app password mfa',
|
||||||
encryption: 's/mime smime certificate pgp gpg',
|
|
||||||
content_senders: 'block sender remote images privacy tracking',
|
content_senders: 'block sender remote images privacy tracking',
|
||||||
calendar: 'event schedule appointment meeting timezone',
|
calendar: 'event schedule appointment meeting timezone',
|
||||||
contacts: 'address book contact',
|
contacts: 'address book contact',
|
||||||
@@ -333,8 +338,16 @@ const LEGACY_TAB_MAP: Record<string, Tab> = {
|
|||||||
advanced: 'about_data',
|
advanced: 'about_data',
|
||||||
};
|
};
|
||||||
|
|
||||||
function readPersistedTab(): Tab {
|
function readPersistedTab(): SettingsTabId {
|
||||||
try {
|
try {
|
||||||
|
// One-shot deep link from the sidebar section gears (Folders / Tags).
|
||||||
|
// Used only as the initial tab and intentionally NOT written to
|
||||||
|
// 'settings-active-tab', so a gear click never becomes the persisted
|
||||||
|
// 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 SettingsTabId;
|
||||||
|
}
|
||||||
const saved = localStorage.getItem('settings-active-tab');
|
const saved = localStorage.getItem('settings-active-tab');
|
||||||
if (!saved) return 'appearance';
|
if (!saved) return 'appearance';
|
||||||
if (saved in LEGACY_TAB_MAP) {
|
if (saved in LEGACY_TAB_MAP) {
|
||||||
@@ -342,7 +355,7 @@ function readPersistedTab(): Tab {
|
|||||||
try { localStorage.setItem('settings-active-tab', migrated); } catch { /* ignore */ }
|
try { localStorage.setItem('settings-active-tab', migrated); } catch { /* ignore */ }
|
||||||
return migrated;
|
return migrated;
|
||||||
}
|
}
|
||||||
return saved as Tab;
|
return saved as SettingsTabId;
|
||||||
} catch {
|
} catch {
|
||||||
return 'appearance';
|
return 'appearance';
|
||||||
}
|
}
|
||||||
@@ -359,10 +372,23 @@ export default function SettingsPage() {
|
|||||||
const { quota, isPushConnected } = useEmailStore();
|
const { quota, isPushConnected } = useEmailStore();
|
||||||
const { stalwartFeaturesEnabled } = useConfig();
|
const { stalwartFeaturesEnabled } = useConfig();
|
||||||
const { isFeatureEnabled } = usePolicyStore();
|
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(() => {
|
||||||
|
try { sessionStorage.removeItem('settings-deep-link-tab'); } catch { /* ignore */ }
|
||||||
|
}, []);
|
||||||
const [mobileShowContent, setMobileShowContent] = useState(false);
|
const [mobileShowContent, setMobileShowContent] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
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 isDesktop = useIsDesktop();
|
||||||
|
|
||||||
const messages = useMessages() as Record<string, unknown>;
|
const messages = useMessages() as Record<string, unknown>;
|
||||||
@@ -371,6 +397,12 @@ export default function SettingsPage() {
|
|||||||
const sidebarAppsList = useSettingsStore((s) => s.sidebarApps);
|
const sidebarAppsList = useSettingsStore((s) => s.sidebarApps);
|
||||||
const proInterface = useSettingsStore((s) => s.proInterface);
|
const proInterface = useSettingsStore((s) => s.proInterface);
|
||||||
|
|
||||||
|
// When set, the settings panel is scoped to a shared/group account: a reduced
|
||||||
|
// tab list and a "Managing: <name>" header. null = the user's own account.
|
||||||
|
const managedAccountId = useManagedAccountStore((s) => s.managedAccountId);
|
||||||
|
const managedAccount = useManagedAccountStore((s) => s.managedAccount);
|
||||||
|
const clearManagedAccount = useManagedAccountStore((s) => s.clear);
|
||||||
|
|
||||||
// Build a per-tab haystack for fulltext search and a list of sub-results
|
// Build a per-tab haystack for fulltext search and a list of sub-results
|
||||||
// (individual settings) per tab. Sub-results come from translation entries
|
// (individual settings) per tab. Sub-results come from translation entries
|
||||||
// that have a `label`/`title` field, plus dynamic content (installed
|
// that have a `label`/`title` field, plus dynamic content (installed
|
||||||
@@ -474,6 +506,10 @@ export default function SettingsPage() {
|
|||||||
return () => window.removeEventListener('settings-tab-change', handler);
|
return () => window.removeEventListener('settings-tab-change', handler);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Leaving the settings panel drops any shared-account scope so it never
|
||||||
|
// leaks into the next visit or another session.
|
||||||
|
useEffect(() => () => clearManagedAccount(), [clearManagedAccount]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialCheckDone && !isAuthenticated && !authLoading) {
|
if (initialCheckDone && !isAuthenticated && !authLoading) {
|
||||||
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
|
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
|
||||||
@@ -582,6 +618,7 @@ export default function SettingsPage() {
|
|||||||
// Appearance
|
// Appearance
|
||||||
{ id: 'appearance', label: t('tabs.appearance'), icon: tabIcons.appearance, group: 'appearance' },
|
{ id: 'appearance', label: t('tabs.appearance'), icon: tabIcons.appearance, group: 'appearance' },
|
||||||
{ id: 'layout', label: t('tabs.layout'), icon: tabIcons.layout, group: 'appearance' },
|
{ id: 'layout', label: t('tabs.layout'), icon: tabIcons.layout, group: 'appearance' },
|
||||||
|
...(isFeatureEnabled('themesEnabled') ? [{ id: 'themes' as Tab, label: 'Themes', icon: tabIcons.themes, group: 'appearance' as TabGroup }] : []),
|
||||||
|
|
||||||
// Mail
|
// Mail
|
||||||
{ id: 'reading', label: t('tabs.reading'), icon: tabIcons.reading, group: 'mail' },
|
{ id: 'reading', label: t('tabs.reading'), icon: tabIcons.reading, group: 'mail' },
|
||||||
@@ -596,28 +633,50 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
// Privacy & Security
|
// Privacy & Security
|
||||||
...(stalwartFeaturesEnabled ? [{ id: 'security' as Tab, label: t('tabs.security'), icon: tabIcons.security, group: 'privacy' as TabGroup }] : []),
|
...(stalwartFeaturesEnabled ? [{ id: 'security' as Tab, label: t('tabs.security'), icon: tabIcons.security, group: 'privacy' as TabGroup }] : []),
|
||||||
...(isFeatureEnabled('smimeEnabled') ? [{ id: 'encryption' as Tab, label: t('tabs.encryption'), icon: tabIcons.encryption, group: 'privacy' as TabGroup }] : []),
|
|
||||||
{ id: 'content_senders', label: t('tabs.content_senders'), icon: tabIcons.content_senders, group: 'privacy' },
|
{ id: 'content_senders', label: t('tabs.content_senders'), icon: tabIcons.content_senders, group: 'privacy' },
|
||||||
|
|
||||||
// Apps
|
// Apps
|
||||||
...(supportsCalendar ? [{ id: 'calendar' as Tab, label: t('tabs.calendar'), icon: tabIcons.calendar, group: 'apps' as TabGroup }] : []),
|
...(supportsCalendar && isFeatureEnabled('calendarEnabled') ? [{ id: 'calendar' as Tab, label: t('tabs.calendar'), icon: tabIcons.calendar, group: 'apps' as TabGroup }] : []),
|
||||||
...(isFeatureEnabled('contactsEnabled') ? [{ id: 'contacts' as Tab, label: t('tabs.contacts'), icon: tabIcons.contacts, group: 'apps' as TabGroup }] : []),
|
...(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 }] : []),
|
...(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 }] : []),
|
...(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
|
// Advanced
|
||||||
{ id: 'about_data', label: t('tabs.about_data'), icon: tabIcons.about_data, group: 'advanced' },
|
{ id: 'about_data', label: t('tabs.about_data'), icon: tabIcons.about_data, group: 'advanced' },
|
||||||
...(isFeatureEnabled('themesEnabled') ? [{ id: 'themes' as Tab, label: 'Themes', icon: tabIcons.themes, group: 'advanced' as TabGroup }] : []),
|
|
||||||
...(isFeatureEnabled('pluginsEnabled') ? [{ id: 'plugins' as Tab, label: 'Plugins', icon: tabIcons.plugins, group: 'advanced' as TabGroup }] : []),
|
...(isFeatureEnabled('pluginsEnabled') ? [{ id: 'plugins' as Tab, label: 'Plugins', icon: tabIcons.plugins, group: 'advanced' as TabGroup }] : []),
|
||||||
...(isFeatureEnabled('debugModeEnabled') ? [{ id: 'debug' as Tab, label: t('tabs.debug'), icon: tabIcons.debug, group: 'advanced' as TabGroup }] : []),
|
...(isFeatureEnabled('debugModeEnabled') ? [{ id: 'debug' as Tab, label: t('tabs.debug'), icon: tabIcons.debug, group: 'advanced' as TabGroup }] : []),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// In scoped (shared-account) mode, restrict to the account-relevant tabs the
|
||||||
|
// account actually advertises. Folders is intentionally excluded (mailbox CRUD
|
||||||
|
// is hardwired to the active account). Gated on both the per-account
|
||||||
|
// capability and the session-level support/feature flags.
|
||||||
|
const scopedTabIds: Tab[] = managedAccount
|
||||||
|
? ([
|
||||||
|
managedAccount.capabilities.sieve && supportsSieve ? 'filters' : null,
|
||||||
|
managedAccount.capabilities.mail && supportsVacation ? 'vacation' : null,
|
||||||
|
managedAccount.capabilities.calendars && supportsCalendar && isFeatureEnabled('calendarEnabled') ? 'calendar' : null,
|
||||||
|
managedAccount.capabilities.contacts && isFeatureEnabled('contactsEnabled') ? 'contacts' : null,
|
||||||
|
].filter(Boolean) as Tab[])
|
||||||
|
: [];
|
||||||
|
const visibleTabs = managedAccountId
|
||||||
|
? tabs.filter((tab) => scopedTabIds.includes(tab.id as Tab))
|
||||||
|
: tabs;
|
||||||
|
|
||||||
// Group tabs by category
|
// Group tabs by category
|
||||||
const groupedTabs = tabGroupOrder
|
const groupedTabs = tabGroupOrder
|
||||||
.map((group) => ({
|
.map((group) => ({
|
||||||
group,
|
group,
|
||||||
label: t(`tab_groups.${group}`),
|
label: t(`tab_groups.${group}`),
|
||||||
items: tabs.filter((tab) => tab.group === group),
|
items: visibleTabs.filter((tab) => tab.group === group),
|
||||||
}))
|
}))
|
||||||
.filter((g) => g.items.length > 0);
|
.filter((g) => g.items.length > 0);
|
||||||
|
|
||||||
@@ -625,12 +684,12 @@ export default function SettingsPage() {
|
|||||||
const matchesQuery = (tab: TabDef) => {
|
const matchesQuery = (tab: TabDef) => {
|
||||||
if (!trimmedQuery) return true;
|
if (!trimmedQuery) return true;
|
||||||
if (tab.label.toLowerCase().includes(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 [];
|
if (!trimmedQuery) return [];
|
||||||
const list = tabSubResults[tabId] ?? [];
|
const list = tabSubResults[tabId as Tab] ?? [];
|
||||||
return list
|
return list
|
||||||
.filter((r) =>
|
.filter((r) =>
|
||||||
r.label.toLowerCase().includes(trimmedQuery) ||
|
r.label.toLowerCase().includes(trimmedQuery) ||
|
||||||
@@ -645,11 +704,15 @@ export default function SettingsPage() {
|
|||||||
.filter((g) => g.items.length > 0)
|
.filter((g) => g.items.length > 0)
|
||||||
: groupedTabs;
|
: groupedTabs;
|
||||||
|
|
||||||
// If active tab is not in the visible list (e.g., feature disabled), fall back.
|
// If active tab is not in the visible list (e.g., feature disabled, or scoped
|
||||||
const isActiveVisible = tabs.some((tab) => tab.id === activeTab);
|
// mode hides it), fall back. In scoped mode fall back to the first scoped tab;
|
||||||
const effectiveActiveTab: Tab = isActiveVisible ? activeTab : 'appearance';
|
// otherwise the usual 'appearance' default.
|
||||||
|
const isActiveVisible = visibleTabs.some((tab) => tab.id === activeTab);
|
||||||
|
const effectiveActiveTab: SettingsTabId = isActiveVisible
|
||||||
|
? activeTab
|
||||||
|
: (managedAccountId ? (visibleTabs[0]?.id ?? 'appearance') : 'appearance');
|
||||||
|
|
||||||
const handleTabSelect = (tabId: Tab) => {
|
const handleTabSelect = (tabId: SettingsTabId) => {
|
||||||
setActiveTab(tabId);
|
setActiveTab(tabId);
|
||||||
try { localStorage.setItem('settings-active-tab', tabId); } catch { /* ignore */ }
|
try { localStorage.setItem('settings-active-tab', tabId); } catch { /* ignore */ }
|
||||||
if (!isDesktop) {
|
if (!isDesktop) {
|
||||||
@@ -657,15 +720,31 @@ export default function SettingsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubResultSelect = (tabId: Tab, sub: SubResult) => {
|
const handleSubResultSelect = (tabId: SettingsTabId, sub: SubResult) => {
|
||||||
handleTabSelect(tabId);
|
handleTabSelect(tabId);
|
||||||
setPendingHighlight({ tab: tabId, label: sub.label, pluginId: sub.pluginId });
|
setPendingHighlight({ tab: tabId, label: sub.label, pluginId: sub.pluginId });
|
||||||
};
|
};
|
||||||
|
|
||||||
const activeTabLabel = tabs.find((tab) => tab.id === effectiveActiveTab)?.label ?? '';
|
const activeTabLabel = visibleTabs.find((tab) => tab.id === effectiveActiveTab)?.label ?? '';
|
||||||
|
|
||||||
const renderTabContent = () => (
|
const renderTabContent = () => (
|
||||||
<>
|
<>
|
||||||
|
{managedAccountId && managedAccount && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
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-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="ms-auto text-sm font-medium truncate">
|
||||||
|
{t('scoped.managing', { name: managedAccount.name })}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{effectiveActiveTab === 'account' && <AccountSettings />}
|
{effectiveActiveTab === 'account' && <AccountSettings />}
|
||||||
{effectiveActiveTab === 'language' && <LanguageSettings />}
|
{effectiveActiveTab === 'language' && <LanguageSettings />}
|
||||||
{effectiveActiveTab === 'notifications' && <NotificationSettings />}
|
{effectiveActiveTab === 'notifications' && <NotificationSettings />}
|
||||||
@@ -681,10 +760,17 @@ export default function SettingsPage() {
|
|||||||
{effectiveActiveTab === 'folders' && <FolderSettings />}
|
{effectiveActiveTab === 'folders' && <FolderSettings />}
|
||||||
{effectiveActiveTab === 'keywords' && <KeywordSettings />}
|
{effectiveActiveTab === 'keywords' && <KeywordSettings />}
|
||||||
{effectiveActiveTab === 'security' && <AccountSecuritySettings />}
|
{effectiveActiveTab === 'security' && <AccountSecuritySettings />}
|
||||||
{effectiveActiveTab === 'encryption' && <SmimeSettings />}
|
|
||||||
{effectiveActiveTab === 'content_senders' && <ContentSendersSettings />}
|
{effectiveActiveTab === 'content_senders' && <ContentSendersSettings />}
|
||||||
{effectiveActiveTab === 'calendar' && <><CalendarSettings /><div className="mt-8"><CalendarManagementSettings /></div></>}
|
{effectiveActiveTab === 'calendar' && (
|
||||||
{effectiveActiveTab === 'contacts' && <><ContactsSettings /><div className="mt-8"><AddressBookManagementSettings /></div></>}
|
managedAccountId
|
||||||
|
? <CalendarManagementSettings />
|
||||||
|
: <><CalendarSettings /><div className="mt-8"><CalendarManagementSettings /></div></>
|
||||||
|
)}
|
||||||
|
{effectiveActiveTab === 'contacts' && (
|
||||||
|
managedAccountId
|
||||||
|
? <AddressBookManagementSettings />
|
||||||
|
: <><ContactsSettings /><div className="mt-8"><AddressBookManagementSettings /></div></>
|
||||||
|
)}
|
||||||
{effectiveActiveTab === 'files' && <FilesSettingsComponent />}
|
{effectiveActiveTab === 'files' && <FilesSettingsComponent />}
|
||||||
{effectiveActiveTab === 'protocol_handlers' && <ProtocolHandlerSettings supportsCalendar={supportsCalendar} />}
|
{effectiveActiveTab === 'protocol_handlers' && <ProtocolHandlerSettings supportsCalendar={supportsCalendar} />}
|
||||||
{effectiveActiveTab === 'sidebar_apps' && <SidebarAppsSettings />}
|
{effectiveActiveTab === 'sidebar_apps' && <SidebarAppsSettings />}
|
||||||
@@ -692,6 +778,13 @@ export default function SettingsPage() {
|
|||||||
{effectiveActiveTab === 'themes' && <ThemesSettings />}
|
{effectiveActiveTab === 'themes' && <ThemesSettings />}
|
||||||
{effectiveActiveTab === 'plugins' && <PluginsSettings />}
|
{effectiveActiveTab === 'plugins' && <PluginsSettings />}
|
||||||
{effectiveActiveTab === 'debug' && <DebugSettings />}
|
{effectiveActiveTab === 'debug' && <DebugSettings />}
|
||||||
|
{effectiveActiveTab.startsWith('plugin:') && (
|
||||||
|
<PluginIframeSlot
|
||||||
|
key={effectiveActiveTab}
|
||||||
|
pluginId={effectiveActiveTab.slice('plugin:'.length)}
|
||||||
|
slot="settings-section"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -758,7 +851,7 @@ export default function SettingsPage() {
|
|||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
placeholder={t('search_placeholder')}
|
placeholder={t('search_placeholder')}
|
||||||
className="pl-9 pr-9 h-10"
|
className="ps-9 pe-9 h-10"
|
||||||
aria-label={t('search_placeholder')}
|
aria-label={t('search_placeholder')}
|
||||||
/>
|
/>
|
||||||
{searchQuery && (
|
{searchQuery && (
|
||||||
@@ -806,7 +899,7 @@ export default function SettingsPage() {
|
|||||||
<button
|
<button
|
||||||
key={`${tab.id}:${sub.label}`}
|
key={`${tab.id}:${sub.label}`}
|
||||||
onClick={() => handleSubResultSelect(tab.id, sub)}
|
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>
|
<span className="truncate">{sub.label}</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -870,7 +963,7 @@ export default function SettingsPage() {
|
|||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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"
|
!isResizing && "transition-[width] duration-300"
|
||||||
)}
|
)}
|
||||||
style={{ width: `${settingsSidebarWidth}px` }}
|
style={{ width: `${settingsSidebarWidth}px` }}
|
||||||
@@ -883,7 +976,7 @@ export default function SettingsPage() {
|
|||||||
onClick={() => router.push('/')}
|
onClick={() => router.push('/')}
|
||||||
className="w-full justify-start"
|
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')}
|
{t('back_to_mail')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -898,7 +991,7 @@ export default function SettingsPage() {
|
|||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
placeholder={t('search_placeholder')}
|
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')}
|
aria-label={t('search_placeholder')}
|
||||||
/>
|
/>
|
||||||
{searchQuery && (
|
{searchQuery && (
|
||||||
@@ -933,9 +1026,9 @@ export default function SettingsPage() {
|
|||||||
return (
|
return (
|
||||||
<div key={tab.id}>
|
<div key={tab.id}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab(tab.id)}
|
onClick={() => handleTabSelect(tab.id)}
|
||||||
className={cn(
|
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
|
effectiveActiveTab === tab.id
|
||||||
? 'bg-accent text-accent-foreground font-medium'
|
? 'bg-accent text-accent-foreground font-medium'
|
||||||
: 'hover:bg-muted text-foreground'
|
: 'hover:bg-muted text-foreground'
|
||||||
@@ -951,7 +1044,7 @@ export default function SettingsPage() {
|
|||||||
<button
|
<button
|
||||||
key={`${tab.id}:${sub.label}`}
|
key={`${tab.id}:${sub.label}`}
|
||||||
onClick={() => handleSubResultSelect(tab.id, sub)}
|
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>
|
<span className="truncate block">{sub.label}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -220,7 +220,7 @@ export function JmapServersSection({ value, source, onChange, onRevert }: Props)
|
|||||||
Per-server OAuth (optional, overrides global)
|
Per-server OAuth (optional, overrides global)
|
||||||
</button>
|
</button>
|
||||||
{d.oauthExpanded && (
|
{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>
|
<div>
|
||||||
<label className="block text-[11px] font-medium text-muted-foreground mb-1">OAuth Client ID</label>
|
<label className="block text-[11px] font-medium text-muted-foreground mb-1">OAuth Client ID</label>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2, Globe, Plus, X } from 'lucide-react';
|
import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2, Globe, Plus, X } from 'lucide-react';
|
||||||
import { apiFetch } from '@/lib/browser-navigation';
|
import { apiFetch, withBasePath } from '@/lib/browser-navigation';
|
||||||
import {
|
import {
|
||||||
BRANDING_OVERRIDE_KEYS,
|
BRANDING_OVERRIDE_KEYS,
|
||||||
parseDomainBranding,
|
parseDomainBranding,
|
||||||
@@ -33,6 +33,8 @@ const TEXT_FIELDS = [
|
|||||||
|
|
||||||
const PWA_IMAGE_FIELDS = [
|
const PWA_IMAGE_FIELDS = [
|
||||||
{ key: 'pwaIconUrl', label: 'PWA Icon', accept: '.svg,.png,.jpg,.webp' },
|
{ key: 'pwaIconUrl', label: 'PWA Icon', accept: '.svg,.png,.jpg,.webp' },
|
||||||
|
{ key: 'pwaScreenshotMobileUrl', label: 'PWA Screenshot (Mobile)', accept: '.png,.jpg,.webp' },
|
||||||
|
{ key: 'pwaScreenshotDesktopUrl', label: 'PWA Screenshot (Desktop)', accept: '.png,.jpg,.webp' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const PWA_TEXT_FIELDS = [
|
const PWA_TEXT_FIELDS = [
|
||||||
@@ -528,7 +530,7 @@ export function BrandingTab() {
|
|||||||
<ImageIcon className="w-3.5 h-3.5 text-muted-foreground" />
|
<ImageIcon className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
<div className="h-8 w-auto bg-muted rounded flex items-center justify-center px-2">
|
<div className="h-8 w-auto bg-muted rounded flex items-center justify-center px-2">
|
||||||
<img
|
<img
|
||||||
src={currentValue(field.key)}
|
src={withBasePath(currentValue(field.key))}
|
||||||
alt={field.label}
|
alt={field.label}
|
||||||
className="max-h-6 max-w-[200px] object-contain"
|
className="max-h-6 max-w-[200px] object-contain"
|
||||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||||
@@ -606,7 +608,7 @@ export function BrandingTab() {
|
|||||||
<ImageIcon className="w-3.5 h-3.5 text-muted-foreground" />
|
<ImageIcon className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
<div className="h-8 w-auto bg-muted rounded flex items-center justify-center px-2">
|
<div className="h-8 w-auto bg-muted rounded flex items-center justify-center px-2">
|
||||||
<img
|
<img
|
||||||
src={currentValue(field.key)}
|
src={withBasePath(currentValue(field.key))}
|
||||||
alt={field.label}
|
alt={field.label}
|
||||||
className="max-h-6 max-w-[200px] object-contain"
|
className="max-h-6 max-w-[200px] object-contain"
|
||||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ export function DashboardTab() {
|
|||||||
const [themeCount, setThemeCount] = useState(0);
|
const [themeCount, setThemeCount] = useState(0);
|
||||||
const [policyRuleCount, setPolicyRuleCount] = useState(0);
|
const [policyRuleCount, setPolicyRuleCount] = useState(0);
|
||||||
const [accountCounts, setAccountCounts] = useState<{ total: number; active7d: number } | null>(null);
|
const [accountCounts, setAccountCounts] = useState<{ total: number; active7d: number } | null>(null);
|
||||||
const [jmapHealth, setJmapHealth] = useState<'unknown' | 'ok' | 'error'>('unknown');
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchDashboardData();
|
fetchDashboardData();
|
||||||
@@ -82,15 +81,6 @@ export function DashboardTab() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (configData?.jmapServerUrl) {
|
|
||||||
try {
|
|
||||||
const jmapRes = await apiFetch('/api/config');
|
|
||||||
setJmapHealth(jmapRes.ok ? 'ok' : 'error');
|
|
||||||
} catch {
|
|
||||||
setJmapHealth('error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const w: string[] = [];
|
const w: string[] = [];
|
||||||
if (adminConfigRes.ok) {
|
if (adminConfigRes.ok) {
|
||||||
const sources = await adminConfigRes.json();
|
const sources = await adminConfigRes.json();
|
||||||
@@ -128,16 +118,6 @@ export function DashboardTab() {
|
|||||||
<SettingItem label="JMAP Server" description={jmapUrl !== '-' ? jmapUrl : undefined}>
|
<SettingItem label="JMAP Server" description={jmapUrl !== '-' ? jmapUrl : undefined}>
|
||||||
<span className="text-sm text-foreground">{jmapHostname}</span>
|
<span className="text-sm text-foreground">{jmapHostname}</span>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
<SettingItem label="JMAP Connection">
|
|
||||||
<span className={`inline-flex items-center gap-1.5 text-sm font-medium ${
|
|
||||||
jmapHealth === 'ok' ? 'text-green-600 dark:text-green-400' : jmapHealth === 'error' ? 'text-red-600 dark:text-red-400' : 'text-muted-foreground'
|
|
||||||
}`}>
|
|
||||||
<span className={`w-2 h-2 rounded-full ${
|
|
||||||
jmapHealth === 'ok' ? 'bg-green-500' : jmapHealth === 'error' ? 'bg-red-500' : 'bg-muted-foreground/40'
|
|
||||||
}`} />
|
|
||||||
{jmapHealth === 'ok' ? 'Connected' : jmapHealth === 'error' ? 'Error' : 'Unknown'}
|
|
||||||
</span>
|
|
||||||
</SettingItem>
|
|
||||||
<SettingItem label="Last Login">
|
<SettingItem label="Last Login">
|
||||||
<span className="text-sm text-foreground">
|
<span className="text-sm text-foreground">
|
||||||
{status?.lastLogin ? new Date(status.lastLogin).toLocaleString() : 'Never'}
|
{status?.lastLogin ? new Date(status.lastLogin).toLocaleString() : 'Never'}
|
||||||
|
|||||||
@@ -96,10 +96,10 @@ export function LogsTab() {
|
|||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-border bg-muted/30">
|
<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-start 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-start 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-start 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">IP</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-border">
|
<tbody className="divide-y divide-border">
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ export function MarketplaceTab() {
|
|||||||
placeholder="Search extensions..."
|
placeholder="Search extensions..."
|
||||||
value={searchInput}
|
value={searchInput}
|
||||||
onChange={(e) => setSearchInput(e.target.value)}
|
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>
|
||||||
<div className="flex items-center gap-1 rounded-md border border-input bg-background p-0.5 self-start sm:self-auto">
|
<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 && (
|
{loading && !error && (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ export function PluginConfigPanel({ pluginId, onBack }: Props) {
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
|
<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...
|
Loading...
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -217,7 +217,7 @@ export function PluginConfigPanel({ pluginId, onBack }: Props) {
|
|||||||
<div key={key}>
|
<div key={key}>
|
||||||
<label className="text-sm font-medium text-foreground block mb-1">
|
<label className="text-sm font-medium text-foreground block mb-1">
|
||||||
{field.label}
|
{field.label}
|
||||||
{field.required && <span className="text-destructive ml-0.5">*</span>}
|
{field.required && <span className="text-destructive ms-0.5">*</span>}
|
||||||
</label>
|
</label>
|
||||||
{field.description && (
|
{field.description && (
|
||||||
<p className="text-xs text-muted-foreground mb-1.5">{field.description}</p>
|
<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] ?? ''}
|
value={formValues[key] ?? ''}
|
||||||
onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))}
|
onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))}
|
||||||
placeholder={config[key] ? '•••••••• (unchanged)' : (field.placeholder || '')}
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; d
|
|||||||
settingsExportEnabled: { label: 'Settings Export/Import', description: 'Allow users to export and import settings JSON' },
|
settingsExportEnabled: { label: 'Settings Export/Import', description: 'Allow users to export and import settings JSON' },
|
||||||
customKeywordsEnabled: { label: 'Custom Keywords', description: 'Allow user-created labels and tags' },
|
customKeywordsEnabled: { label: 'Custom Keywords', description: 'Allow user-created labels and tags' },
|
||||||
templatesEnabled: { label: 'Email Templates', description: 'Allow email template creation and library' },
|
templatesEnabled: { label: 'Email Templates', description: 'Allow email template creation and library' },
|
||||||
|
calendarEnabled: { label: 'Calendar', description: 'Enable calendar features and views' },
|
||||||
calendarTasksEnabled: { label: 'Calendar Tasks', description: 'Show task panel in calendar view' },
|
calendarTasksEnabled: { label: 'Calendar Tasks', description: 'Show task panel in calendar view' },
|
||||||
contactsEnabled: { label: 'Contacts', description: 'Enable contacts/address book features' },
|
contactsEnabled: { label: 'Contacts', description: 'Enable contacts/address book features' },
|
||||||
smimeEnabled: { label: 'S/MIME', description: 'Enable certificate management and email signing' },
|
smimeEnabled: { label: 'S/MIME', description: 'Enable certificate management and email signing' },
|
||||||
@@ -21,6 +22,10 @@ const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; d
|
|||||||
folderIconsEnabled: { label: 'Folder Icons', description: 'Allow custom folder icon picker' },
|
folderIconsEnabled: { label: 'Folder Icons', description: 'Allow custom folder icon picker' },
|
||||||
hoverActionsConfigEnabled: { label: 'Hover Actions Config', description: 'Allow users to customize email hover actions' },
|
hoverActionsConfigEnabled: { label: 'Hover Actions Config', description: 'Allow users to customize email hover actions' },
|
||||||
filesEnabled: { label: 'Files (WebDAV)', description: 'Enable file storage via WebDAV. WARNING: Large uploads can cause Stalwart/RocksDB instability. Not recommended for production.' },
|
filesEnabled: { label: 'Files (WebDAV)', description: 'Enable file storage via WebDAV. WARNING: Large uploads can cause Stalwart/RocksDB instability. Not recommended for production.' },
|
||||||
|
allMailViewEnabled: { label: 'All Mail View', description: 'Show a virtual "All Mail" folder that merges messages from across an account’s folders into one list. Users choose which folders are included. Requires the per-user toggle in Settings → Appearance.' },
|
||||||
|
crossUnreadViewEnabled: { label: 'All Accounts: Unread', description: 'Allow an "All unread" entry in the All accounts section that lists unread mail across every account (incl. shared folders), spanning all folders except junk, sent, archive, trash and drafts. Requires the matching per-user toggle in Settings → Appearance.' },
|
||||||
|
crossStarredViewEnabled: { label: 'All Accounts: Starred', description: 'Allow an "All starred" entry in the All accounts section that lists flagged/starred mail across every account (incl. shared folders), spanning all folders except junk, sent, archive, trash and drafts. Requires the matching per-user toggle in Settings → Appearance.' },
|
||||||
|
crossAllViewEnabled: { label: 'All Accounts: All Mail', description: 'Allow an "All mail" entry in the All accounts section that lists all mail across every account (incl. shared folders), spanning all folders except junk, sent, archive, trash and drafts. Requires the matching per-user toggle in Settings → Appearance.' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const RESTRICTABLE_SETTINGS = [
|
const RESTRICTABLE_SETTINGS = [
|
||||||
|
|||||||
@@ -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" />
|
<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} />
|
<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') && (
|
{!!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">
|
<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.
|
<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>
|
</p>
|
||||||
@@ -125,6 +125,7 @@ export function SettingsTab() {
|
|||||||
)}
|
)}
|
||||||
<ToggleSetting label="Stalwart Features" description="Enable Stalwart Mail Server-specific features" configKey="stalwartFeaturesEnabled" value={currentValue('stalwartFeaturesEnabled') as boolean} source={config.stalwartFeaturesEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
|
<ToggleSetting label="Stalwart Features" description="Enable Stalwart Mail Server-specific features" configKey="stalwartFeaturesEnabled" value={currentValue('stalwartFeaturesEnabled') as boolean} source={config.stalwartFeaturesEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||||
<ToggleSetting label="Demo Mode" description="Enable demo mode with sample data" configKey="demoMode" value={currentValue('demoMode') as boolean} source={config.demoMode?.source} onChange={handleChange} onRevert={handleRevert} />
|
<ToggleSetting label="Demo Mode" description="Enable demo mode with sample data" configKey="demoMode" value={currentValue('demoMode') as boolean} source={config.demoMode?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||||
|
<ToggleSetting label="Search Engine Indexing" description="Allow search engines to index this webmail. Off (the default) sends noindex/nofollow in the page head, recommended for private deployments." configKey="searchEngineIndexing" value={currentValue('searchEngineIndexing') as boolean} source={config.searchEngineIndexing?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
<SettingsSection title="JMAP Servers (multi-server)">
|
<SettingsSection title="JMAP Servers (multi-server)">
|
||||||
@@ -144,7 +145,7 @@ export function SettingsTab() {
|
|||||||
onRevert={() => handleRevert('jmapServers')}
|
onRevert={() => handleRevert('jmapServers')}
|
||||||
/>
|
/>
|
||||||
{Array.isArray(currentValue('jmapServers')) && (currentValue('jmapServers') as JmapServerEntry[]).length > 0 && (
|
{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">
|
<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.
|
<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>
|
</p>
|
||||||
|
|||||||
@@ -118,9 +118,10 @@ export function TelemetryTab() {
|
|||||||
<header className="space-y-2">
|
<header className="space-y-2">
|
||||||
<h1 className="text-2xl font-semibold">Anonymous Usage Stats</h1>
|
<h1 className="text-2xl font-semibold">Anonymous Usage Stats</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Bulwark sends one anonymous heartbeat per day so we can see how many instances are
|
Bulwark can send one anonymous heartbeat per day so we can see how many instances are
|
||||||
running, on what platforms, and which features they use. <strong>Enabled by default</strong>;
|
running, on what platforms, and which features they use. It's <strong>off by
|
||||||
one click below disables it. No email addresses, no hostnames, no IPs are sent.{' '}
|
default</strong>; one click below enables it and helps us make the product better. No
|
||||||
|
email addresses, no hostnames, no IPs are sent.{' '}
|
||||||
<a
|
<a
|
||||||
href="https://bulwarkmail.org/docs/legal/privacy/telemetry"
|
href="https://bulwarkmail.org/docs/legal/privacy/telemetry"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -138,8 +139,8 @@ export function TelemetryTab() {
|
|||||||
<div className="font-medium">Status</div>
|
<div className="font-medium">Status</div>
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
{status.consent === 'pending' && 'Initialising - no heartbeats sent yet.'}
|
{status.consent === 'pending' && 'Initialising - no heartbeats sent yet.'}
|
||||||
{status.consent === 'on' && 'Heartbeats are enabled (default).'}
|
{status.consent === 'on' && 'Heartbeats are enabled. Thanks for helping us improve!'}
|
||||||
{status.consent === 'off' && 'Heartbeats are off.'}
|
{status.consent === 'off' && 'Heartbeats are off (default).'}
|
||||||
{envOverridden && (
|
{envOverridden && (
|
||||||
<> Locked by <code>BULWARK_TELEMETRY</code> env var.</>
|
<> Locked by <code>BULWARK_TELEMETRY</code> env var.</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,12 +5,11 @@ import { Upload, Trash2, Power, PowerOff, Loader2, Palette, Save, Shield, Lock,
|
|||||||
import type { SettingsPolicy } from '@/lib/admin/types';
|
import type { SettingsPolicy } from '@/lib/admin/types';
|
||||||
import { DEFAULT_POLICY, DEFAULT_THEME_POLICY } from '@/lib/admin/types';
|
import { DEFAULT_POLICY, DEFAULT_THEME_POLICY } from '@/lib/admin/types';
|
||||||
import { apiFetch } from '@/lib/browser-navigation';
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
import { BUILTIN_THEMES } from '@/lib/builtin-themes';
|
||||||
|
|
||||||
const BUILTIN_THEME_OPTIONS = [
|
// Derive from the single source of truth so newly added built-in themes show
|
||||||
{ id: 'builtin-nord', name: 'Nord' },
|
// up here automatically (was previously a hardcoded subset — see #496).
|
||||||
{ id: 'builtin-catppuccin', name: 'Catppuccin' },
|
const BUILTIN_THEME_OPTIONS = BUILTIN_THEMES.map(t => ({ id: t.id, name: t.name }));
|
||||||
{ id: 'builtin-solarized', name: 'Solarized' },
|
|
||||||
];
|
|
||||||
|
|
||||||
interface ThemeEntry {
|
interface ThemeEntry {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export default function ChangePasswordPage() {
|
|||||||
value={currentPassword}
|
value={currentPassword}
|
||||||
onChange={e => setCurrentPassword(e.target.value)}
|
onChange={e => setCurrentPassword(e.target.value)}
|
||||||
required
|
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"
|
autoComplete="current-password"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+12
-12
@@ -212,7 +212,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
className={cn(
|
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
|
active
|
||||||
? 'bg-accent text-accent-foreground font-medium'
|
? 'bg-accent text-accent-foreground font-medium'
|
||||||
: 'hover:bg-muted text-foreground'
|
: 'hover:bg-muted text-foreground'
|
||||||
@@ -248,7 +248,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
<Link
|
<Link
|
||||||
href="/admin/change-password"
|
href="/admin/change-password"
|
||||||
className={cn(
|
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'
|
pathname === '/admin/change-password'
|
||||||
? 'bg-accent text-accent-foreground font-medium'
|
? 'bg-accent text-accent-foreground font-medium'
|
||||||
: 'hover:bg-muted text-foreground'
|
: 'hover:bg-muted text-foreground'
|
||||||
@@ -263,7 +263,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={handleLogout}
|
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" />
|
<LogOut className="w-4 h-4 shrink-0 text-muted-foreground" />
|
||||||
Sign out
|
Sign out
|
||||||
@@ -275,7 +275,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex bg-background">
|
<div className="min-h-screen flex bg-background">
|
||||||
{/* Slim webmail nav rail (desktop only) */}
|
{/* 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 ? (
|
{logoUrl ? (
|
||||||
<img src={logoUrl} alt="" className="w-7 h-7 object-contain mb-2" />
|
<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>
|
</nav>
|
||||||
|
|
||||||
{/* Admin Sidebar (desktop only) */}
|
{/* 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">
|
<div className="h-14 flex items-center px-4 border-b border-border shrink-0">
|
||||||
{logoUrl ? (
|
{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>
|
<span className="font-semibold text-sm text-foreground">Admin Panel</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -350,7 +350,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
{/* Mobile drawer */}
|
{/* Mobile drawer */}
|
||||||
<aside
|
<aside
|
||||||
className={cn(
|
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'
|
mobileNavOpen ? 'translate-x-0' : '-translate-x-full'
|
||||||
)}
|
)}
|
||||||
aria-label="Admin navigation"
|
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="h-14 flex items-center justify-between px-3 border-b border-border shrink-0">
|
||||||
<div className="flex items-center min-w-0">
|
<div className="flex items-center min-w-0">
|
||||||
{logoUrl ? (
|
{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>
|
<span className="font-semibold text-sm text-foreground truncate">Admin Panel</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -391,9 +391,9 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
</button>
|
</button>
|
||||||
<div className="flex items-center min-w-0">
|
<div className="flex items-center min-w-0">
|
||||||
{logoUrl ? (
|
{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>
|
<span className="font-semibold text-sm text-foreground truncate">Admin Panel</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ export default function MarketplacePreviewPage() {
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
|
<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...
|
Loading...
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -512,7 +512,7 @@ export default function MarketplacePreviewPage() {
|
|||||||
<section className="border border-border rounded-lg">
|
<section className="border border-border rounded-lg">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowManifest(v => !v)}
|
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">
|
<div className="flex items-center gap-2">
|
||||||
<FileCode className="w-4 h-4 text-muted-foreground" />
|
<FileCode className="w-4 h-4 text-muted-foreground" />
|
||||||
@@ -532,7 +532,7 @@ export default function MarketplacePreviewPage() {
|
|||||||
<section className="border border-border rounded-lg">
|
<section className="border border-border rounded-lg">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowSource(v => !v)}
|
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">
|
<div className="flex items-center gap-2">
|
||||||
<FileCode className="w-4 h-4 text-muted-foreground" />
|
<FileCode className="w-4 h-4 text-muted-foreground" />
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export default function GlobalError({
|
|||||||
onClick={reset}
|
onClick={reset}
|
||||||
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
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
|
Try again
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+47
-7
@@ -1,13 +1,31 @@
|
|||||||
import type { Metadata, Viewport } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
|
import { getLocaleDirection } from "@/i18n/direction";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { getLocale } from "next-intl/server";
|
import { getLocale, getTranslations } from "next-intl/server";
|
||||||
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
|
|
||||||
import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
|
import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
|
||||||
import { configManager } from "@/lib/admin/config-manager";
|
import { configManager } from "@/lib/admin/config-manager";
|
||||||
|
import {
|
||||||
|
matchDomainBranding,
|
||||||
|
parseDomainBranding,
|
||||||
|
pickRequestHost,
|
||||||
|
} from "@/lib/admin/domain-branding";
|
||||||
import { withBasePath } from "@/lib/browser-navigation";
|
import { withBasePath } from "@/lib/browser-navigation";
|
||||||
|
import { locales } from "@/i18n/routing";
|
||||||
import "../globals.css";
|
import "../globals.css";
|
||||||
|
|
||||||
|
// This layout renders <html> and sits ABOVE the [locale] segment, so
|
||||||
|
// next-intl's getLocale() returns the default locale here - emitting
|
||||||
|
// <html lang="en"> on e.g. /de pages, which makes browsers offer to
|
||||||
|
// "translate this page". Recover the active locale from the request pathname
|
||||||
|
// (exposed by proxy.ts as x-pathname), falling back to getLocale() (cookie /
|
||||||
|
// Accept-Language) when the path carries no locale segment.
|
||||||
|
async function resolveRequestLocale(): Promise<string> {
|
||||||
|
const pathname = (await headers()).get("x-pathname") || "";
|
||||||
|
const seg = pathname.split("/").find((s) => (locales as readonly string[]).includes(s));
|
||||||
|
return seg ?? (await getLocale());
|
||||||
|
}
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-geist-sans",
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
@@ -26,11 +44,34 @@ export const viewport: Viewport = {
|
|||||||
|
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
await configManager.ensureLoaded();
|
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
|
||||||
|
// layout is above the [locale] segment (see resolveRequestLocale).
|
||||||
|
const locale = await resolveRequestLocale();
|
||||||
|
const t = await getTranslations({ locale });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail",
|
title: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail",
|
||||||
description: "Minimalist webmail client using JMAP protocol",
|
description: t("meta_description"),
|
||||||
|
// A private webmail should not be indexed by search engines. This is opt-in
|
||||||
|
// via Settings -> General; the default (false) emits noindex/nofollow.
|
||||||
|
robots: configManager.get<boolean>("searchEngineIndexing", false)
|
||||||
|
? { index: true, follow: true }
|
||||||
|
: { index: false, follow: false },
|
||||||
appleWebApp: {
|
appleWebApp: {
|
||||||
capable: true,
|
capable: true,
|
||||||
statusBarStyle: "black-translucent",
|
statusBarStyle: "black-translucent",
|
||||||
@@ -48,12 +89,12 @@ export default async function RootLayout({
|
|||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const locale = await getLocale();
|
const locale = await resolveRequestLocale();
|
||||||
const nonce = (await headers()).get("x-nonce") ?? "";
|
const nonce = (await headers()).get("x-nonce") ?? "";
|
||||||
const parentOrigin = process.env.NEXT_PUBLIC_PARENT_ORIGIN || "";
|
const parentOrigin = process.env.NEXT_PUBLIC_PARENT_ORIGIN || "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang={locale} suppressHydrationWarning>
|
<html lang={locale} dir={getLocaleDirection(locale)} suppressHydrationWarning>
|
||||||
<head>
|
<head>
|
||||||
<meta name="theme-color" content="#ffffff" />
|
<meta name="theme-color" content="#ffffff" />
|
||||||
<meta name="mobile-web-app-capable" content="yes" />
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
@@ -92,7 +133,6 @@ export default async function RootLayout({
|
|||||||
>
|
>
|
||||||
<ServiceWorkerRegistration />
|
<ServiceWorkerRegistration />
|
||||||
{children}
|
{children}
|
||||||
<PWAInstallPrompt />
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ interface WizardConfig {
|
|||||||
// Security
|
// Security
|
||||||
sessionSecret: string;
|
sessionSecret: string;
|
||||||
settingsSyncEnabled: boolean;
|
settingsSyncEnabled: boolean;
|
||||||
|
telemetryEnabled: boolean;
|
||||||
// Logging
|
// Logging
|
||||||
logFormat: 'text' | 'json';
|
logFormat: 'text' | 'json';
|
||||||
logLevel: 'error' | 'warn' | 'info' | 'debug';
|
logLevel: 'error' | 'warn' | 'info' | 'debug';
|
||||||
@@ -66,6 +67,7 @@ const EMPTY_CONFIG: WizardConfig = {
|
|||||||
oauthIssuerUrl: '',
|
oauthIssuerUrl: '',
|
||||||
sessionSecret: '',
|
sessionSecret: '',
|
||||||
settingsSyncEnabled: true,
|
settingsSyncEnabled: true,
|
||||||
|
telemetryEnabled: false,
|
||||||
logFormat: 'text',
|
logFormat: 'text',
|
||||||
logLevel: 'info',
|
logLevel: 'info',
|
||||||
faviconUrl: '',
|
faviconUrl: '',
|
||||||
@@ -778,7 +780,7 @@ function ServerStep({ config, setConfig, onNext }: Pick<StepProps, 'config' | 's
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={confirmedNonJmap}
|
checked={confirmedNonJmap}
|
||||||
@@ -868,7 +870,7 @@ function ServerStep({ config, setConfig, onNext }: Pick<StepProps, 'config' | 's
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{hasRowErrors && (
|
{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) => (
|
{rowErrors.map((err, i) => (
|
||||||
<li key={i}>{err}</li>
|
<li key={i}>{err}</li>
|
||||||
))}
|
))}
|
||||||
@@ -1002,8 +1004,11 @@ function SecurityStep({ config, setConfig, onNext, onBack }: Pick<StepProps, 'co
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const values: Partial<WizardConfig> = {
|
// telemetryConsent is persisted to the telemetry state file by the API,
|
||||||
|
// not to admin config - see app/api/setup/step/route.ts.
|
||||||
|
const values: Record<string, unknown> = {
|
||||||
settingsSyncEnabled: config.settingsSyncEnabled,
|
settingsSyncEnabled: config.settingsSyncEnabled,
|
||||||
|
telemetryConsent: config.telemetryEnabled ? 'on' : 'off',
|
||||||
};
|
};
|
||||||
if (config.sessionSecret) values.sessionSecret = config.sessionSecret;
|
if (config.sessionSecret) values.sessionSecret = config.sessionSecret;
|
||||||
await onNext('security', values);
|
await onNext('security', values);
|
||||||
@@ -1068,6 +1073,15 @@ function SecurityStep({ config, setConfig, onNext, onBack }: Pick<StepProps, 'co
|
|||||||
hint="Stores user preferences server-side, encrypted with the session secret."
|
hint="Stores user preferences server-side, encrypted with the session secret."
|
||||||
disabled={!config.sessionSecret}
|
disabled={!config.sessionSecret}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className="rounded-md border border-border bg-muted/20 p-3">
|
||||||
|
<Toggle
|
||||||
|
checked={config.telemetryEnabled}
|
||||||
|
onChange={(v) => setConfig({ ...config, telemetryEnabled: v })}
|
||||||
|
label="Send anonymous usage stats to help improve Bulwark"
|
||||||
|
hint="Off by default. One anonymous heartbeat per day with version, platform, and which features are enabled - never email addresses, hostnames, or IPs. You can change this anytime in admin settings."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<Footer>
|
<Footer>
|
||||||
<SecondaryButton onClick={onBack}>Back</SecondaryButton>
|
<SecondaryButton onClick={onBack}>Back</SecondaryButton>
|
||||||
<PrimaryButton type="submit" disabled={submitting}>
|
<PrimaryButton type="submit" disabled={submitting}>
|
||||||
@@ -1370,7 +1384,7 @@ function BrandingAsset({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showUrlField && (
|
{showUrlField && (
|
||||||
<div className="mt-3 pl-[4.75rem]">
|
<div className="mt-3 ps-[4.75rem]">
|
||||||
<Input
|
<Input
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
@@ -1380,7 +1394,7 @@ function BrandingAsset({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{uploadError && (
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -1480,6 +1494,7 @@ function ReviewStep({ config, onBack, onFinish }: { config: WizardConfig; onBack
|
|||||||
: 'Off'
|
: 'Off'
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<SummaryRow label="Anonymous telemetry" value={config.telemetryEnabled ? 'On' : 'Off'} />
|
||||||
</SummaryGroup>
|
</SummaryGroup>
|
||||||
|
|
||||||
<SummaryGroup icon={<FileText className="w-4 h-4" />} title="Logging">
|
<SummaryGroup icon={<FileText className="w-4 h-4" />} title="Logging">
|
||||||
@@ -1586,7 +1601,7 @@ function SummaryRow({ label, value, mono }: { label: string; value: string; mono
|
|||||||
return (
|
return (
|
||||||
<div className="flex justify-between items-baseline gap-3 text-sm">
|
<div className="flex justify-between items-baseline gap-3 text-sm">
|
||||||
<span className="text-muted-foreground shrink-0">{label}</span>
|
<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>}
|
{value || <span className="text-muted-foreground italic">-</span>}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,17 +1,14 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { Geist, Geist_Mono } from 'next/font/google';
|
|
||||||
import '../globals.css';
|
|
||||||
|
|
||||||
const geistSans = Geist({
|
// The plugin sandbox iframe runs with an opaque origin (the `sandbox`
|
||||||
variable: '--font-geist-sans',
|
// attribute in production excludes `allow-same-origin` for isolation). Any
|
||||||
subsets: ['latin'],
|
// asset request from this layout - bundled fonts, globals.css, etc. - is then
|
||||||
});
|
// cross-origin from the "null" origin to the host origin and gets blocked
|
||||||
|
// (fonts in particular require CORS). So this layout is intentionally minimal:
|
||||||
const geistMono = Geist_Mono({
|
// no font imports, no CSS imports. Plugins ship their own styles, and both the
|
||||||
variable: '--font-geist-mono',
|
// plugin bundle and all host API calls travel over the postMessage RPC bridge,
|
||||||
subsets: ['latin'],
|
// so the sandbox never fetches same-origin assets itself.
|
||||||
});
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Plugin sandbox',
|
title: 'Plugin sandbox',
|
||||||
@@ -21,10 +18,7 @@ export const metadata: Metadata = {
|
|||||||
export default function PluginSandboxLayout({ children }: { children: ReactNode }) {
|
export default function PluginSandboxLayout({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body
|
<body style={{ margin: 0, padding: 0, background: 'transparent' }}>
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
|
||||||
style={{ margin: 0, padding: 0, background: 'transparent' }}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { SandboxRuntime } from '@/lib/plugin-sandbox/runtime';
|
||||||
|
|
||||||
|
// Privileged-tier sandbox route. Identical runtime to /plugin-sandbox, but the
|
||||||
|
// host loads it into a same-origin (`allow-same-origin`) iframe so the bundle
|
||||||
|
// gets real `crypto.subtle` + IndexedDB. The trust gate (signature + admin
|
||||||
|
// approval) is enforced host-side before this route is ever framed; the page
|
||||||
|
// itself carries no extra privilege.
|
||||||
|
//
|
||||||
|
// Must be dynamic so the per-request CSP nonce from proxy.ts is embedded in
|
||||||
|
// Next's injected hydration/chunk scripts.
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export default function PrivilegedPluginSandboxPage() {
|
||||||
|
return <SandboxRuntime />;
|
||||||
|
}
|
||||||
@@ -26,14 +26,18 @@ const ALLOWED_MIME_TYPES = new Set([
|
|||||||
'image/vnd.microsoft.icon',
|
'image/vnd.microsoft.icon',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
type UploadSlot = BrandingOverrideKey;
|
||||||
|
|
||||||
/** Slots that correspond to branding config keys */
|
/** Slots that correspond to branding config keys */
|
||||||
const VALID_SLOTS = new Set<BrandingOverrideKey>([
|
const VALID_SLOTS = new Set<UploadSlot>([
|
||||||
'faviconUrl',
|
'faviconUrl',
|
||||||
'pwaIconUrl',
|
'pwaIconUrl',
|
||||||
'appLogoLightUrl',
|
'appLogoLightUrl',
|
||||||
'appLogoDarkUrl',
|
'appLogoDarkUrl',
|
||||||
'loginLogoLightUrl',
|
'loginLogoLightUrl',
|
||||||
'loginLogoDarkUrl',
|
'loginLogoDarkUrl',
|
||||||
|
'pwaScreenshotMobileUrl',
|
||||||
|
'pwaScreenshotDesktopUrl',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const EXT_BY_MIME: Record<string, string> = {
|
const EXT_BY_MIME: Record<string, string> = {
|
||||||
@@ -131,7 +135,7 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Missing file or slot' }, { status: 400 });
|
return NextResponse.json({ error: 'Missing file or slot' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!VALID_SLOTS.has(slot as BrandingOverrideKey)) {
|
if (!VALID_SLOTS.has(slot as UploadSlot)) {
|
||||||
return NextResponse.json({ error: `Invalid slot: ${slot}` }, { status: 400 });
|
return NextResponse.json({ error: `Invalid slot: ${slot}` }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,7 +230,7 @@ export async function DELETE(request: NextRequest) {
|
|||||||
const slot = body.slot;
|
const slot = body.slot;
|
||||||
const rawHost = body.host ?? '';
|
const rawHost = body.host ?? '';
|
||||||
|
|
||||||
if (!slot || !VALID_SLOTS.has(slot as BrandingOverrideKey)) {
|
if (!slot || !VALID_SLOTS.has(slot as UploadSlot)) {
|
||||||
return NextResponse.json({ error: 'Invalid or missing slot' }, { status: 400 });
|
return NextResponse.json({ error: 'Invalid or missing slot' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
invalidateFrameOriginsCache,
|
invalidateFrameOriginsCache,
|
||||||
} from '@/lib/admin/csp-frame-origins';
|
} from '@/lib/admin/csp-frame-origins';
|
||||||
import JSZip from 'jszip';
|
import JSZip from 'jszip';
|
||||||
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE, ALL_PERMISSIONS, ALLOWED_PLUGIN_FILES } from '@/lib/plugin-types';
|
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE, ALL_PERMISSIONS } from '@/lib/plugin-types';
|
||||||
import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
|
import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
|
||||||
import { configManager } from '@/lib/admin/config-manager';
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
|
|
||||||
@@ -341,6 +341,7 @@ export async function POST(request: NextRequest) {
|
|||||||
author: (manifest.author as string) || 'Unknown',
|
author: (manifest.author as string) || 'Unknown',
|
||||||
description: (manifest.description as string) || '',
|
description: (manifest.description as string) || '',
|
||||||
type: (manifest.type as string) || 'hook',
|
type: (manifest.type as string) || 'hook',
|
||||||
|
...(manifest.tier === 'privileged' ? { tier: 'privileged' } : {}),
|
||||||
permissions,
|
permissions,
|
||||||
entrypoint,
|
entrypoint,
|
||||||
enabled: existingPlugin?.enabled ?? true,
|
enabled: existingPlugin?.enabled ?? true,
|
||||||
|
|||||||
@@ -183,6 +183,7 @@ export async function POST(request: NextRequest) {
|
|||||||
author: manifest.author as string,
|
author: manifest.author as string,
|
||||||
description: (manifest.description as string) || '',
|
description: (manifest.description as string) || '',
|
||||||
type: manifest.type as string,
|
type: manifest.type as string,
|
||||||
|
...(manifest.tier === 'privileged' ? { tier: 'privileged' } : {}),
|
||||||
permissions: (manifest.permissions as string[]) || [],
|
permissions: (manifest.permissions as string[]) || [],
|
||||||
entrypoint: manifest.entrypoint as string,
|
entrypoint: manifest.entrypoint as string,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -192,6 +193,9 @@ export async function POST(request: NextRequest) {
|
|||||||
...(manifest.settingsSchema && typeof manifest.settingsSchema === 'object'
|
...(manifest.settingsSchema && typeof manifest.settingsSchema === 'object'
|
||||||
? { settingsSchema: manifest.settingsSchema as ServerPlugin['settingsSchema'] }
|
? { settingsSchema: manifest.settingsSchema as ServerPlugin['settingsSchema'] }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(manifest.locales && typeof manifest.locales === 'object'
|
||||||
|
? { locales: manifest.locales as ServerPlugin['locales'] }
|
||||||
|
: {}),
|
||||||
...(declaredFrameOrigins.length > 0
|
...(declaredFrameOrigins.length > 0
|
||||||
? { frameOrigins: declaredFrameOrigins }
|
? { frameOrigins: declaredFrameOrigins }
|
||||||
: {}),
|
: {}),
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
|
import { getMetadata, getRequiredConfig } from '@/lib/oauth/token-exchange';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same-origin OAuth metadata (discovery) proxy.
|
||||||
|
*
|
||||||
|
* The login page needs the authorization_endpoint to build the PKCE authorize
|
||||||
|
* URL in the browser. Discovering it directly from the browser means a
|
||||||
|
* cross-origin fetch to the IdP's /.well-known/* documents, which is subject
|
||||||
|
* to CORS: providers like Authentik serve those documents without an
|
||||||
|
* Access-Control-Allow-Origin header, so the browser blocks the response and
|
||||||
|
* discovery fails (issue #382). Performing discovery here - server to server,
|
||||||
|
* where CORS does not apply - and handing the result back as a same-origin
|
||||||
|
* response sidesteps the problem entirely.
|
||||||
|
*
|
||||||
|
* The discovery URL is resolved from admin config (via server_id), never from
|
||||||
|
* client input, so this cannot be abused as an open SSRF proxy. Endpoint URLs
|
||||||
|
* in the discovered document are still gated by the SSRF validator inside
|
||||||
|
* discoverOAuth. The returned fields are public well-known metadata.
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
await configManager.ensureLoaded();
|
||||||
|
const serverId = request.nextUrl.searchParams.get('server_id');
|
||||||
|
|
||||||
|
let discoveryUrl: string;
|
||||||
|
try {
|
||||||
|
({ discoveryUrl } = getRequiredConfig(serverId));
|
||||||
|
} catch {
|
||||||
|
// OAuth not configured for this server - surface as "no metadata" rather
|
||||||
|
// than a 500 so the login page just hides the SSO button.
|
||||||
|
return NextResponse.json({ error: 'OAuth not configured' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const metadata = await getMetadata(serverId);
|
||||||
|
if (!metadata?.authorization_endpoint || !metadata.token_endpoint) {
|
||||||
|
logger.warn('OAuth metadata discovery returned no usable endpoints', { discoveryUrl });
|
||||||
|
return NextResponse.json({ error: 'OAuth discovery failed' }, { status: 502 });
|
||||||
|
}
|
||||||
|
return NextResponse.json(metadata, {
|
||||||
|
// Mirror the in-process discovery cache TTL so repeated login-page loads
|
||||||
|
// hit the CDN/browser cache instead of re-running discovery.
|
||||||
|
headers: { 'Cache-Control': 'private, max-age=600' },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('OAuth metadata discovery error', {
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
});
|
||||||
|
return NextResponse.json({ error: 'OAuth discovery failed' }, { status: 502 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
|
||||||
|
import { buildOAuthParams, getRequiredConfig, getTokenEndpoint } from '@/lib/oauth/token-exchange';
|
||||||
|
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||||
|
import { createPairing } from '@/lib/auth/pairing-store';
|
||||||
|
import { hasValidPairReauth } from '@/lib/auth/pair-reauth';
|
||||||
|
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
|
||||||
|
|
||||||
|
// Desktop side of the cross-device QR login. The caller must be a signed-in
|
||||||
|
// webmail session (its refresh token lives in the httpOnly jmap_rt cookie). We
|
||||||
|
// refresh that token to (a) prove the session is live and (b) obtain a fresh
|
||||||
|
// access token to hand the phone, then stash the bundle under a one-time
|
||||||
|
// pairing code. The desktop renders the returned code as a QR; the phone
|
||||||
|
// redeems it at /api/auth/pair/redeem.
|
||||||
|
//
|
||||||
|
// Token sharing note: the phone receives the SAME refresh token as the desktop.
|
||||||
|
// That is correct for OAuth servers (such as Stalwart in its default config)
|
||||||
|
// that do not rotate refresh tokens on use. If the server rotates refresh
|
||||||
|
// tokens, the two devices would fight over the latest token — such deployments
|
||||||
|
// should disable rotation for this client or use a token-exchange grant.
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
try {
|
||||||
|
// Step-up gate: minting a pairing code grants new-device access, so it
|
||||||
|
// requires a recent fresh IdP re-authentication (see the reauth SSO flow).
|
||||||
|
// The client turns this 401 into a re-auth redirect, then retries.
|
||||||
|
if (!(await hasValidPairReauth())) {
|
||||||
|
return NextResponse.json({ error: 'reauth_required' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json().catch(() => ({}));
|
||||||
|
const slot =
|
||||||
|
typeof body.slot === 'number' && body.slot >= 0 && body.slot < MAX_ACCOUNT_SLOTS
|
||||||
|
? body.slot
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const cookieName = refreshTokenCookieName(slot);
|
||||||
|
const refreshToken = cookieStore.get(cookieName)?.value;
|
||||||
|
if (!refreshToken) {
|
||||||
|
return NextResponse.json({ error: 'Not signed in' }, { status: 401 });
|
||||||
|
}
|
||||||
|
const serverId = cookieStore.get(refreshTokenServerCookieName(slot))?.value || null;
|
||||||
|
|
||||||
|
const tokenEndpoint = await getTokenEndpoint(serverId);
|
||||||
|
const params = buildOAuthParams({ grant_type: 'refresh_token', refresh_token: refreshToken }, serverId);
|
||||||
|
|
||||||
|
const tokenResponse = await fetch(tokenEndpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: params.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!tokenResponse.ok) {
|
||||||
|
const errorText = await tokenResponse.text();
|
||||||
|
logger.warn('Pair create: refresh failed', { status: tokenResponse.status, error: errorText });
|
||||||
|
// Stale session — clear the dead cookie so the user is prompted to log
|
||||||
|
// back in, mirroring the token route's behaviour.
|
||||||
|
cookieStore.delete(cookieName);
|
||||||
|
cookieStore.delete(refreshTokenServerCookieName(slot));
|
||||||
|
return NextResponse.json({ error: 'Session expired' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = await tokenResponse.json();
|
||||||
|
if (!tokens.access_token) {
|
||||||
|
logger.error('Pair create: refresh response missing access_token');
|
||||||
|
return NextResponse.json({ error: 'Invalid token response' }, { status: 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the server rotated the refresh token, persist the new one back to the
|
||||||
|
// desktop's cookie so this very session keeps working. The phone will get
|
||||||
|
// the same (new) token below.
|
||||||
|
const effectiveRefreshToken = tokens.refresh_token || refreshToken;
|
||||||
|
if (tokens.refresh_token) {
|
||||||
|
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
|
||||||
|
}
|
||||||
|
|
||||||
|
const { clientId, serverUrl } = getRequiredConfig(serverId);
|
||||||
|
|
||||||
|
const { code, expiresIn } = createPairing({
|
||||||
|
accessToken: tokens.access_token,
|
||||||
|
refreshToken: effectiveRefreshToken,
|
||||||
|
expiresIn: tokens.expires_in,
|
||||||
|
tokenEndpoint,
|
||||||
|
clientId,
|
||||||
|
serverUrl,
|
||||||
|
serverId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ pairing_code: code, server_url: serverUrl, expires_in: expiresIn });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Pair create error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
import { consumePairing } from '@/lib/auth/pairing-store';
|
||||||
|
|
||||||
|
// Phone side of the cross-device QR login. The app POSTs the pairing code it
|
||||||
|
// scanned; we hand back the OAuth token bundle the desktop stashed at
|
||||||
|
// /api/auth/pair/create. The code is the only credential required — it is
|
||||||
|
// high-entropy, single-use, and expires within ~2 minutes — so this route is
|
||||||
|
// intentionally unauthenticated (the scanning device has no webmail cookies).
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { pairing_code: pairingCode } = await request.json().catch(() => ({}));
|
||||||
|
if (!pairingCode || typeof pairingCode !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'Missing pairing code' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = consumePairing(pairingCode);
|
||||||
|
if (!tokens) {
|
||||||
|
// Unknown, expired, or already redeemed — do not distinguish.
|
||||||
|
return NextResponse.json({ error: 'Invalid or expired pairing code' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
flow: 'oauth',
|
||||||
|
server_url: tokens.serverUrl,
|
||||||
|
access_token: tokens.accessToken,
|
||||||
|
...(tokens.refreshToken ? { refresh_token: tokens.refreshToken } : {}),
|
||||||
|
...(typeof tokens.expiresIn === 'number' ? { expires_in: tokens.expiresIn } : {}),
|
||||||
|
token_endpoint: tokens.tokenEndpoint,
|
||||||
|
client_id: tokens.clientId,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Pair redeem error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
import { decryptPayload } from '@/lib/auth/crypto';
|
||||||
|
import { exchangeCodeForTokens } from '@/lib/oauth/token-exchange';
|
||||||
|
import { setPairReauth } from '@/lib/auth/pair-reauth';
|
||||||
|
|
||||||
|
// Completes the step-up re-authentication for device pairing. The user was sent
|
||||||
|
// to the IdP with prompt=login (see /api/auth/sso/start with purpose=reauth);
|
||||||
|
// here we verify the returned code against the pending state and exchange it to
|
||||||
|
// confirm a fresh login actually happened, then set the short-lived pairing
|
||||||
|
// re-auth proof cookie. We deliberately do NOT issue a login session or write
|
||||||
|
// any refresh-token cookies — the user is already signed in; this only proves
|
||||||
|
// recency for the pairing action.
|
||||||
|
|
||||||
|
const SSO_PENDING_COOKIE = 'sso_pending';
|
||||||
|
const SSO_PENDING_MAX_AGE_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
try {
|
||||||
|
const { code, state } = await request.json();
|
||||||
|
if (!code || !state) {
|
||||||
|
return NextResponse.json({ error: 'Missing code or state' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingCookie = cookieStore.get(SSO_PENDING_COOKIE)?.value;
|
||||||
|
if (!pendingCookie) {
|
||||||
|
return NextResponse.json({ error: 'No pending re-auth session' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = decryptPayload(pendingCookie);
|
||||||
|
cookieStore.delete(SSO_PENDING_COOKIE);
|
||||||
|
if (!pending) {
|
||||||
|
return NextResponse.json({ error: 'Invalid re-auth session' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only honor pending sessions that were started for the reauth purpose, so
|
||||||
|
// a normal login code can't be redirected into setting a pairing proof.
|
||||||
|
if (pending.purpose !== 'reauth') {
|
||||||
|
return NextResponse.json({ error: 'Not a re-auth session' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (pending.state !== state) {
|
||||||
|
return NextResponse.json({ error: 'State mismatch' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const createdAt = pending.created_at as number;
|
||||||
|
if (!createdAt || Date.now() - createdAt > SSO_PENDING_MAX_AGE_MS) {
|
||||||
|
return NextResponse.json({ error: 'Re-auth session expired' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const codeVerifier = pending.code_verifier as string;
|
||||||
|
const redirectUri = pending.redirect_uri as string;
|
||||||
|
const pendingServerId = typeof pending.server_id === 'string' ? pending.server_id : null;
|
||||||
|
if (!codeVerifier || !redirectUri) {
|
||||||
|
return NextResponse.json({ error: 'Invalid re-auth session data' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// A successful exchange proves the user just authenticated at the IdP (the
|
||||||
|
// freshness is enforced by prompt=login on the authorize request). We don't
|
||||||
|
// keep the resulting tokens.
|
||||||
|
await exchangeCodeForTokens(code, codeVerifier, redirectUri, pendingServerId);
|
||||||
|
|
||||||
|
await setPairReauth();
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
cookieStore.delete(SSO_PENDING_COOKIE);
|
||||||
|
logger.error('Reauth complete error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Re-authentication failed' }, { status: 401 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { discoverOAuth } from '@/lib/oauth/discovery';
|
|||||||
import { getOauthScopes } from '@/lib/oauth/tokens';
|
import { getOauthScopes } from '@/lib/oauth/tokens';
|
||||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||||
import { hasSessionSecret } from '@/lib/auth/session-secret';
|
import { hasSessionSecret } from '@/lib/auth/session-secret';
|
||||||
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
|
|
||||||
const SSO_PENDING_COOKIE = 'sso_pending';
|
const SSO_PENDING_COOKIE = 'sso_pending';
|
||||||
const SSO_PENDING_MAX_AGE = 300; // 5 minutes
|
const SSO_PENDING_MAX_AGE = 300; // 5 minutes
|
||||||
@@ -30,8 +31,14 @@ export async function POST(request: NextRequest) {
|
|||||||
server_id: bodyServerId,
|
server_id: bodyServerId,
|
||||||
mobile_redirect_uri: rawMobileRedirectUri,
|
mobile_redirect_uri: rawMobileRedirectUri,
|
||||||
mobile_state: rawMobileState,
|
mobile_state: rawMobileState,
|
||||||
|
purpose: rawPurpose,
|
||||||
} = await request.json();
|
} = await request.json();
|
||||||
|
|
||||||
|
// `reauth` drives the step-up flow for device pairing: it forces a fresh
|
||||||
|
// IdP login (prompt=login) and the /reauth/sso/complete handler sets the
|
||||||
|
// short-lived pairing re-auth proof instead of logging the user in again.
|
||||||
|
const isReauth = rawPurpose === 'reauth';
|
||||||
|
|
||||||
if (!redirect_uri || typeof redirect_uri !== 'string') {
|
if (!redirect_uri || typeof redirect_uri !== 'string') {
|
||||||
return NextResponse.json({ error: 'Missing redirect_uri' }, { status: 400 });
|
return NextResponse.json({ error: 'Missing redirect_uri' }, { status: 400 });
|
||||||
}
|
}
|
||||||
@@ -85,6 +92,7 @@ export async function POST(request: NextRequest) {
|
|||||||
...(serverId ? { server_id: serverId } : {}),
|
...(serverId ? { server_id: serverId } : {}),
|
||||||
...(mobileRedirectUri ? { mobile_redirect_uri: mobileRedirectUri } : {}),
|
...(mobileRedirectUri ? { mobile_redirect_uri: mobileRedirectUri } : {}),
|
||||||
...(mobileState ? { mobile_state: mobileState } : {}),
|
...(mobileState ? { mobile_state: mobileState } : {}),
|
||||||
|
...(isReauth ? { purpose: 'reauth' } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const encrypted = encryptPayload(pendingData);
|
const encrypted = encryptPayload(pendingData);
|
||||||
@@ -95,8 +103,12 @@ export async function POST(request: NextRequest) {
|
|||||||
maxAge: SSO_PENDING_MAX_AGE,
|
maxAge: SSO_PENDING_MAX_AGE,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Build authorize URL
|
// Build authorize URL. OAUTH_AUTHORIZE_URL, when set, overrides only the
|
||||||
const authUrl = new URL(metadata.authorization_endpoint);
|
// user-facing authorize endpoint (e.g. a per-brand login host). Discovery,
|
||||||
|
// token exchange and refresh keep using the canonical discovered endpoints.
|
||||||
|
const authorizeOverride =
|
||||||
|
configManager.get<string>('oauthAuthorizeUrl', '') || process.env.OAUTH_AUTHORIZE_URL;
|
||||||
|
const authUrl = new URL(authorizeOverride?.trim() || metadata.authorization_endpoint);
|
||||||
authUrl.searchParams.set('response_type', 'code');
|
authUrl.searchParams.set('response_type', 'code');
|
||||||
authUrl.searchParams.set('client_id', clientId);
|
authUrl.searchParams.set('client_id', clientId);
|
||||||
authUrl.searchParams.set('redirect_uri', redirect_uri);
|
authUrl.searchParams.set('redirect_uri', redirect_uri);
|
||||||
@@ -109,6 +121,14 @@ export async function POST(request: NextRequest) {
|
|||||||
authUrl.searchParams.set('ui_locales', locale);
|
authUrl.searchParams.set('ui_locales', locale);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Force a fresh credential entry for step-up re-auth. prompt=login and
|
||||||
|
// max_age=0 both ask the IdP to re-authenticate even if it has an active
|
||||||
|
// session; honoring them depends on the IdP supporting these OIDC params.
|
||||||
|
if (isReauth) {
|
||||||
|
authUrl.searchParams.set('prompt', 'login');
|
||||||
|
authUrl.searchParams.set('max_age', '0');
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
authorize_url: authUrl.toString(),
|
authorize_url: authUrl.toString(),
|
||||||
state,
|
state,
|
||||||
|
|||||||
@@ -82,9 +82,16 @@ export async function PUT(request: NextRequest) {
|
|||||||
if (!tokenResponse.ok) {
|
if (!tokenResponse.ok) {
|
||||||
const errorText = await tokenResponse.text();
|
const errorText = await tokenResponse.text();
|
||||||
logger.error('Token refresh failed', { status: tokenResponse.status, error: errorText });
|
logger.error('Token refresh failed', { status: tokenResponse.status, error: errorText });
|
||||||
cookieStore.delete(cookieName);
|
// Drop the refresh token only when the server definitively rejected it
|
||||||
cookieStore.delete(refreshTokenServerCookieName(slot));
|
// (invalid/expired/revoked grant). A 5xx or 429 is an outage - keeping
|
||||||
return NextResponse.json({ error: 'Refresh failed' }, { status: 401 });
|
// 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();
|
const tokens = await tokenResponse.json();
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { discoverOAuth } from '@/lib/oauth/discovery';
|
|
||||||
import { getDiscoveryValidator } from '@/lib/oauth/token-exchange';
|
|
||||||
import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
|
import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
|
||||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||||
import { readFileEnv } from '@/lib/read-file-env';
|
import { readFileEnv } from '@/lib/read-file-env';
|
||||||
@@ -11,81 +9,173 @@ import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
|||||||
import { recordLogin } from '@/lib/telemetry/login-tracker';
|
import { recordLogin } from '@/lib/telemetry/login-tracker';
|
||||||
import { parseJmapServers, findServerByUrl, findServerById } from '@/lib/admin/jmap-servers';
|
import { parseJmapServers, findServerByUrl, findServerById } from '@/lib/admin/jmap-servers';
|
||||||
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
|
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
|
||||||
|
import { generateCodeVerifier, generateCodeChallenge } from '@/lib/oauth/pkce';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exchange basic auth credentials (with TOTP appended) for OAuth tokens.
|
* Exchange a password + (optional) TOTP code for OAuth tokens.
|
||||||
*
|
*
|
||||||
* This allows 2FA users who log in with basic auth + TOTP to upgrade
|
* Stalwart 0.16+ no longer accepts the legacy `password$totp` convention over
|
||||||
* to token-based auth, avoiding session expiry when the TOTP rotates.
|
* HTTP Basic auth: its Basic decoder hardcodes `mfa_token: None` and never
|
||||||
|
* splits the secret on `$`, so any TOTP appended to the password is verified
|
||||||
|
* verbatim against the password hash and fails. The MFA token must instead be
|
||||||
|
* supplied as a distinct field through the structured login endpoint.
|
||||||
*
|
*
|
||||||
* Tries three strategies:
|
* This route drives that flow server-side (avoiding browser CORS against the
|
||||||
* 1. ROPC grant with client_id (if OAUTH_CLIENT_ID is set)
|
* mail server, same as OAuth discovery):
|
||||||
* 2. ROPC grant without client_id
|
* 1. POST {serverUrl}/api/auth -> authenticate with a separate `mfaToken`,
|
||||||
* 3. ROPC grant authenticated via Basic Auth header (Stalwart-style)
|
* receiving a short-lived authorization `clientCode`.
|
||||||
|
* 2. POST {serverUrl}/auth/token (grant_type=authorization_code) -> exchange
|
||||||
|
* the code (with PKCE) for access/refresh tokens.
|
||||||
|
*
|
||||||
|
* Token-based auth also survives TOTP rotation, unlike basic auth which embeds
|
||||||
|
* the (≈30s) code in every request.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
async function tryTokenRequest(
|
// Fallback OAuth client id used when no client is configured. Stalwart accepts
|
||||||
tokenEndpoint: string,
|
// any client id unless `require_client_registration` is enabled (default off);
|
||||||
params: URLSearchParams,
|
// when it is enabled the admin must configure `oauthClientId` with this
|
||||||
extraHeaders?: Record<string, string>,
|
// redirect URI registered.
|
||||||
): Promise<{ ok: true; tokens: { access_token: string; expires_in?: number; refresh_token?: string } } | { ok: false; status: number; error: string }> {
|
const DEFAULT_CLIENT_ID = 'bulwark-webmail';
|
||||||
try {
|
|
||||||
const headers: Record<string, string> = { 'Content-Type': 'application/x-www-form-urlencoded', ...extraHeaders };
|
|
||||||
const response = await fetch(tokenEndpoint, {
|
|
||||||
method: 'POST',
|
|
||||||
headers,
|
|
||||||
body: params.toString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
interface LoginResult {
|
||||||
const errorText = await response.text();
|
type?: string;
|
||||||
return { ok: false, status: response.status, error: errorText.substring(0, 500) };
|
// The response keeps snake_case: only the LoginResponse variant *tags* are
|
||||||
}
|
// camelCased server-side, not the struct fields (the request fields are).
|
||||||
|
client_code?: string;
|
||||||
const tokens = await response.json();
|
|
||||||
if (!tokens.access_token) {
|
|
||||||
return { ok: false, status: 502, error: 'Response missing access_token' };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ok: true, tokens };
|
|
||||||
} catch (err) {
|
|
||||||
return { ok: false, status: 0, error: err instanceof Error ? err.message : String(err) };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function findTokenEndpoint(serverUrl: string, adminTrusted: boolean): Promise<string | null> {
|
function trimUrl(url: string): string {
|
||||||
// Admin-trusted callers (matched server entry or configured JMAP server URL)
|
return url.replace(/\/+$/, '');
|
||||||
// honor the `oauthAllowPrivateEndpoints` opt-in. User-supplied URLs always
|
}
|
||||||
// go through the SSRF validator regardless of the setting.
|
|
||||||
const validateEndpoint = adminTrusted ? getDiscoveryValidator() : isPublicHttpUrl;
|
|
||||||
// 1. Try OAuth discovery
|
|
||||||
const metadata = await discoverOAuth(serverUrl, { validateEndpoint });
|
|
||||||
if (metadata?.token_endpoint) return metadata.token_endpoint;
|
|
||||||
|
|
||||||
// 2. Try common Stalwart token endpoint paths directly
|
async function attemptLogin(
|
||||||
const candidates = [
|
upstreamUrl: string,
|
||||||
`${serverUrl}/auth/token`,
|
username: string,
|
||||||
`${serverUrl}/api/oauth/token`,
|
password: string,
|
||||||
];
|
totp: string | undefined,
|
||||||
|
redirectUri: string,
|
||||||
|
slot: number,
|
||||||
|
serverId: string | null,
|
||||||
|
): Promise<NextResponse> {
|
||||||
|
const base = trimUrl(upstreamUrl);
|
||||||
|
|
||||||
for (const url of candidates) {
|
// Per-server OAuth credentials override the global ones when the requested
|
||||||
try {
|
// server entry has its own oauth block configured.
|
||||||
// A POST with no body should return 400 (bad request) rather than 404 if the endpoint exists
|
const serverList = parseJmapServers(configManager.get<unknown>('jmapServers', []));
|
||||||
const probe = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: 'grant_type=probe' });
|
const entry = findServerById(serverList, serverId);
|
||||||
if (probe.status !== 404 && probe.status !== 405) {
|
const clientId = entry?.oauth?.clientId
|
||||||
return url;
|
|| configManager.get<string>('oauthClientId', '')
|
||||||
}
|
|| process.env.OAUTH_CLIENT_ID
|
||||||
} catch {
|
|| DEFAULT_CLIENT_ID;
|
||||||
// Network error - endpoint not reachable
|
const clientSecret = entry?.oauth?.clientSecret
|
||||||
|
|| configManager.get<string>('oauthClientSecret', '')
|
||||||
|
|| process.env.OAUTH_CLIENT_SECRET
|
||||||
|
|| readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE)
|
||||||
|
|| '';
|
||||||
|
|
||||||
|
// PKCE proves the token exchange originates from the same client that
|
||||||
|
// initiated the login, so no client secret is required for public clients.
|
||||||
|
const verifier = generateCodeVerifier();
|
||||||
|
const challenge = await generateCodeChallenge(verifier);
|
||||||
|
|
||||||
|
// Step 1: structured login with a separate MFA token.
|
||||||
|
let login: LoginResult;
|
||||||
|
try {
|
||||||
|
const loginResponse = await fetch(`${base}/api/auth`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
type: 'authCode',
|
||||||
|
accountName: username,
|
||||||
|
accountSecret: password,
|
||||||
|
...(totp ? { mfaToken: totp } : {}),
|
||||||
|
clientId,
|
||||||
|
redirectUri,
|
||||||
|
codeChallenge: challenge,
|
||||||
|
codeChallengeMethod: 'S256',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!loginResponse.ok) {
|
||||||
|
const detail = (await loginResponse.text()).substring(0, 500);
|
||||||
|
logger.warn('TOTP login: /api/auth rejected request', { status: loginResponse.status });
|
||||||
|
// A 404 means the server predates the structured login endpoint; let the
|
||||||
|
// caller fall back to the legacy basic-auth path.
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: loginResponse.status === 404 ? 'login_endpoint_missing' : 'login_failed', detail },
|
||||||
|
{ status: loginResponse.status === 404 ? 404 : 502 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
login = await loginResponse.json();
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('TOTP login: /api/auth request failed', { error: err instanceof Error ? err.message : String(err) });
|
||||||
|
return NextResponse.json({ error: 'login_unreachable' }, { status: 502 });
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
switch (login.type) {
|
||||||
|
case 'authenticated':
|
||||||
|
break;
|
||||||
|
case 'mfaRequired':
|
||||||
|
return NextResponse.json({ error: 'totp_required' }, { status: 401 });
|
||||||
|
case 'failure':
|
||||||
|
default:
|
||||||
|
return NextResponse.json({ error: 'invalid_credentials' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!login.client_code) {
|
||||||
|
logger.warn('TOTP login: authenticated response missing client_code');
|
||||||
|
return NextResponse.json({ error: 'login_failed' }, { status: 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: exchange the authorization code for tokens.
|
||||||
|
const tokenParams = new URLSearchParams({
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
code: login.client_code,
|
||||||
|
client_id: clientId,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
code_verifier: verifier,
|
||||||
|
});
|
||||||
|
// Confidential clients still send their secret; harmless for public clients.
|
||||||
|
if (clientSecret) tokenParams.set('client_secret', clientSecret);
|
||||||
|
|
||||||
|
let tokens: { access_token?: string; expires_in?: number; refresh_token?: string };
|
||||||
|
try {
|
||||||
|
const tokenResponse = await fetch(`${base}/auth/token`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: tokenParams.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!tokenResponse.ok) {
|
||||||
|
const detail = (await tokenResponse.text()).substring(0, 500);
|
||||||
|
logger.warn('TOTP login: token exchange failed', { status: tokenResponse.status, detail });
|
||||||
|
return NextResponse.json({ error: 'token_exchange_failed', detail }, { status: 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens = await tokenResponse.json();
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('TOTP login: token endpoint failed', { error: err instanceof Error ? err.message : String(err) });
|
||||||
|
return NextResponse.json({ error: 'token_exchange_failed' }, { status: 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tokens.access_token) {
|
||||||
|
return NextResponse.json({ error: 'token_exchange_failed', detail: 'Response missing access_token' }, { status: 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('TOTP login succeeded');
|
||||||
|
void recordLogin(username, base);
|
||||||
|
return await storeAndRespond(
|
||||||
|
{ access_token: tokens.access_token, expires_in: tokens.expires_in, refresh_token: tokens.refresh_token },
|
||||||
|
slot,
|
||||||
|
serverId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { serverUrl, username, password, slot: bodySlot, server_id: bodyServerId } = await request.json();
|
const { serverUrl, username, password, totp, slot: bodySlot, server_id: bodyServerId, redirectUri: bodyRedirectUri } =
|
||||||
|
await request.json();
|
||||||
|
|
||||||
if (!serverUrl || !username || !password) {
|
if (!serverUrl || !username || !password) {
|
||||||
return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 });
|
return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 });
|
||||||
@@ -93,6 +183,7 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot < MAX_ACCOUNT_SLOTS ? bodySlot : 0;
|
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot < MAX_ACCOUNT_SLOTS ? bodySlot : 0;
|
||||||
const requestedServerId = typeof bodyServerId === 'string' && bodyServerId ? bodyServerId : null;
|
const requestedServerId = typeof bodyServerId === 'string' && bodyServerId ? bodyServerId : null;
|
||||||
|
const totpCode = typeof totp === 'string' && totp ? totp : undefined;
|
||||||
|
|
||||||
// Pin the upstream URL to a configured JMAP server. The list of allowed
|
// Pin the upstream URL to a configured JMAP server. The list of allowed
|
||||||
// servers is `jmapServerUrl` plus any entry from `jmapServers`. Only when
|
// servers is `jmapServerUrl` plus any entry from `jmapServers`. Only when
|
||||||
@@ -110,20 +201,17 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
let upstreamUrl: string;
|
let upstreamUrl: string;
|
||||||
let resolvedServerId: string | null = null;
|
let resolvedServerId: string | null = null;
|
||||||
let adminTrusted = false;
|
|
||||||
const requestedEntry = findServerById(serverList, requestedServerId);
|
const requestedEntry = findServerById(serverList, requestedServerId);
|
||||||
const matchedEntry = requestedEntry || findServerByUrl(serverList, serverUrl);
|
const matchedEntry = requestedEntry || findServerByUrl(serverList, serverUrl);
|
||||||
|
|
||||||
if (matchedEntry) {
|
if (matchedEntry) {
|
||||||
upstreamUrl = matchedEntry.url;
|
upstreamUrl = matchedEntry.url;
|
||||||
resolvedServerId = matchedEntry.id;
|
resolvedServerId = matchedEntry.id;
|
||||||
adminTrusted = true;
|
|
||||||
} else if (configuredServerUrl) {
|
} else if (configuredServerUrl) {
|
||||||
upstreamUrl = configuredServerUrl;
|
upstreamUrl = configuredServerUrl;
|
||||||
adminTrusted = true;
|
|
||||||
} else if (allowCustomEndpoint) {
|
} else if (allowCustomEndpoint) {
|
||||||
if (!(await isPublicHttpUrl(serverUrl))) {
|
if (!(await isPublicHttpUrl(serverUrl))) {
|
||||||
logger.warn('TOTP token exchange: rejected non-public server URL');
|
logger.warn('TOTP login: rejected non-public server URL');
|
||||||
return NextResponse.json({ error: 'invalid_server_url' }, { status: 400 });
|
return NextResponse.json({ error: 'invalid_server_url' }, { status: 400 });
|
||||||
}
|
}
|
||||||
upstreamUrl = serverUrl;
|
upstreamUrl = serverUrl;
|
||||||
@@ -131,100 +219,22 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'jmap_server_not_configured' }, { status: 500 });
|
return NextResponse.json({ error: 'jmap_server_not_configured' }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokenEndpoint = await findTokenEndpoint(upstreamUrl, adminTrusted);
|
// The redirect URI must be identical in the login and token-exchange steps,
|
||||||
if (!tokenEndpoint) {
|
// and (when require_client_registration is on) registered for the client.
|
||||||
logger.warn('TOTP token exchange: no token endpoint found');
|
// Prefer the browser-supplied callback URL the OAuth client already uses;
|
||||||
return NextResponse.json({ error: 'no_token_endpoint', detail: 'Could not discover OAuth token endpoint on the mail server' }, { status: 404 });
|
// fall back to the upstream URL so the two steps still agree.
|
||||||
}
|
const redirectUri =
|
||||||
|
typeof bodyRedirectUri === 'string' && /^https?:\/\//.test(bodyRedirectUri)
|
||||||
|
? bodyRedirectUri
|
||||||
|
: trimUrl(upstreamUrl);
|
||||||
|
|
||||||
return await attemptAllStrategies(tokenEndpoint, upstreamUrl, username, password, slot, resolvedServerId);
|
return await attemptLogin(upstreamUrl, username, password, totpCode, redirectUri, slot, resolvedServerId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('TOTP token exchange error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
logger.error('TOTP login error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function attemptAllStrategies(
|
|
||||||
tokenEndpoint: string,
|
|
||||||
serverUrl: string,
|
|
||||||
username: string,
|
|
||||||
password: string,
|
|
||||||
slot: number,
|
|
||||||
serverId: string | null,
|
|
||||||
): Promise<NextResponse> {
|
|
||||||
logger.info('TOTP token exchange: found token endpoint', { tokenEndpoint });
|
|
||||||
|
|
||||||
// Per-server OAuth credentials override the global ones when the requested
|
|
||||||
// server entry has its own oauth block configured.
|
|
||||||
const serverList = parseJmapServers(configManager.get<unknown>('jmapServers', []));
|
|
||||||
const entry = findServerById(serverList, serverId);
|
|
||||||
const clientId = entry?.oauth?.clientId
|
|
||||||
|| configManager.get<string>('oauthClientId', '')
|
|
||||||
|| process.env.OAUTH_CLIENT_ID;
|
|
||||||
const clientSecret = entry?.oauth?.clientSecret
|
|
||||||
|| configManager.get<string>('oauthClientSecret', '')
|
|
||||||
|| process.env.OAUTH_CLIENT_SECRET
|
|
||||||
|| readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE);
|
|
||||||
const basicAuth = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
|
||||||
const attempts: Array<{ strategy: string; error: string }> = [];
|
|
||||||
|
|
||||||
// Strategy 1: ROPC with client_id (if configured)
|
|
||||||
if (clientId) {
|
|
||||||
const params = new URLSearchParams({ grant_type: 'password', username, password, client_id: clientId });
|
|
||||||
if (clientSecret) params.set('client_secret', clientSecret);
|
|
||||||
const result = await tryTokenRequest(tokenEndpoint, params);
|
|
||||||
if (result.ok) {
|
|
||||||
logger.info('TOTP token exchange succeeded (ROPC with client_id)');
|
|
||||||
void recordLogin(username, serverUrl);
|
|
||||||
return await storeAndRespond(result.tokens, slot, serverId);
|
|
||||||
}
|
|
||||||
attempts.push({ strategy: 'ROPC with client_id', error: result.error });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Strategy 2: ROPC without client_id
|
|
||||||
{
|
|
||||||
const params = new URLSearchParams({ grant_type: 'password', username, password });
|
|
||||||
const result = await tryTokenRequest(tokenEndpoint, params);
|
|
||||||
if (result.ok) {
|
|
||||||
logger.info('TOTP token exchange succeeded (ROPC without client_id)');
|
|
||||||
void recordLogin(username, serverUrl);
|
|
||||||
return await storeAndRespond(result.tokens, slot, serverId);
|
|
||||||
}
|
|
||||||
attempts.push({ strategy: 'ROPC without client_id', error: result.error });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Strategy 3: Basic Auth header on token endpoint (some servers accept this)
|
|
||||||
{
|
|
||||||
const params = new URLSearchParams({ grant_type: 'password' });
|
|
||||||
const result = await tryTokenRequest(tokenEndpoint, params, { 'Authorization': basicAuth });
|
|
||||||
if (result.ok) {
|
|
||||||
logger.info('TOTP token exchange succeeded (Basic Auth header)');
|
|
||||||
void recordLogin(username, serverUrl);
|
|
||||||
return await storeAndRespond(result.tokens, slot, serverId);
|
|
||||||
}
|
|
||||||
attempts.push({ strategy: 'Basic Auth header', error: result.error });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Strategy 4: client_credentials with Basic Auth (last resort)
|
|
||||||
{
|
|
||||||
const params = new URLSearchParams({ grant_type: 'client_credentials' });
|
|
||||||
const result = await tryTokenRequest(tokenEndpoint, params, { 'Authorization': basicAuth });
|
|
||||||
if (result.ok) {
|
|
||||||
logger.info('TOTP token exchange succeeded (client_credentials + Basic Auth)');
|
|
||||||
void recordLogin(username, serverUrl);
|
|
||||||
return await storeAndRespond(result.tokens, slot, serverId);
|
|
||||||
}
|
|
||||||
attempts.push({ strategy: 'client_credentials + Basic Auth', error: result.error });
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.warn('TOTP token exchange: all strategies failed', { attempts });
|
|
||||||
return NextResponse.json({
|
|
||||||
error: 'token_exchange_failed',
|
|
||||||
detail: 'All token exchange strategies failed',
|
|
||||||
attempts,
|
|
||||||
}, { status: 502 });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function storeAndRespond(
|
async function storeAndRespond(
|
||||||
tokens: { access_token: string; expires_in?: number; refresh_token?: string },
|
tokens: { access_token: string; expires_in?: number; refresh_token?: string },
|
||||||
slot: number,
|
slot: number,
|
||||||
|
|||||||
@@ -0,0 +1,334 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||||
|
import { normalizeCalendarEventLike } from '@/lib/calendar-event-normalization';
|
||||||
|
import { expandRecurringEvents } from '@/lib/recurrence-expansion';
|
||||||
|
import { parseISO } from 'date-fns';
|
||||||
|
import type { CalendarEvent } from '@/lib/jmap/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/calendar-agenda
|
||||||
|
*
|
||||||
|
* Sidecar for the "Calendar Agenda" plugin. Resolves the caller's calendar
|
||||||
|
* account from the stored Stalwart auth context, queries upcoming
|
||||||
|
* CalendarEvents over JMAP, expands recurring series server-side, and returns
|
||||||
|
* a slim, structured-cloneable agenda the plugin can render directly.
|
||||||
|
*
|
||||||
|
* Credentials never leave the server — the sandboxed plugin only ever sees
|
||||||
|
* the resulting agenda DTOs.
|
||||||
|
*
|
||||||
|
* Body: { days?: number (1-90, default 7), limit?: number (1-200, default 50) }
|
||||||
|
*/
|
||||||
|
|
||||||
|
const CALENDAR_CAP = 'urn:ietf:params:jmap:calendars';
|
||||||
|
const PRINCIPALS_CAP = 'urn:ietf:params:jmap:principals';
|
||||||
|
|
||||||
|
// Mirror of lib/jmap/client.ts CALENDAR_EVENT_PROPERTIES, trimmed to what the
|
||||||
|
// agenda actually needs (start/recurrence/display fields).
|
||||||
|
const EVENT_PROPERTIES = [
|
||||||
|
'id', '@type', 'uid', 'calendarIds', 'title', 'start', 'duration', 'timeZone',
|
||||||
|
'showWithoutTime', 'utcStart', 'utcEnd', 'status', 'freeBusyStatus', 'color',
|
||||||
|
'locations', 'recurrenceId', 'recurrenceIdTimeZone', 'recurrenceRule',
|
||||||
|
'recurrenceOverrides', 'excludedRecurrenceRule',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
interface JmapSession {
|
||||||
|
apiUrl?: string;
|
||||||
|
primaryAccounts?: Record<string, string>;
|
||||||
|
capabilities?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AgendaEvent {
|
||||||
|
id: string;
|
||||||
|
uid: string | null;
|
||||||
|
title: string;
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
allDay: boolean;
|
||||||
|
status: string | null;
|
||||||
|
color: string | null;
|
||||||
|
location: string | null;
|
||||||
|
calendarId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pure, server-safe equivalents of lib/calendar-utils' getEventStartDate /
|
||||||
|
// getEventEndDate. We can't import that module here because it transitively
|
||||||
|
// pulls in a "use client" calendar component (which throws at load on the
|
||||||
|
// server). Logic mirrors the originals.
|
||||||
|
function parseDurationMinutes(duration: string | undefined): number {
|
||||||
|
if (!duration) return 0;
|
||||||
|
let total = 0;
|
||||||
|
const week = duration.match(/(\d+)W/);
|
||||||
|
const day = duration.match(/(\d+)D/);
|
||||||
|
const hour = duration.match(/(\d+)H/);
|
||||||
|
const min = duration.match(/(\d+)M/);
|
||||||
|
if (week) total += parseInt(week[1], 10) * 7 * 24 * 60;
|
||||||
|
if (day) total += parseInt(day[1], 10) * 24 * 60;
|
||||||
|
if (hour) total += parseInt(hour[1], 10) * 60;
|
||||||
|
if (min) total += parseInt(min[1], 10);
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventStart(event: Partial<CalendarEvent>): Date {
|
||||||
|
if (!event.showWithoutTime && event.utcStart) {
|
||||||
|
const utc = parseISO(event.utcStart);
|
||||||
|
if (!isNaN(utc.getTime())) return utc;
|
||||||
|
}
|
||||||
|
return parseISO(event.start as string);
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventEnd(event: Partial<CalendarEvent>): Date {
|
||||||
|
if (!event.showWithoutTime && event.utcEnd) {
|
||||||
|
const utc = parseISO(event.utcEnd);
|
||||||
|
if (!isNaN(utc.getTime())) return utc;
|
||||||
|
}
|
||||||
|
const start = eventStart(event);
|
||||||
|
if (!event.duration) return start;
|
||||||
|
return new Date(start.getTime() + parseDurationMinutes(event.duration) * 60000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstLocationName(event: Partial<CalendarEvent>): string | null {
|
||||||
|
const locations = event.locations;
|
||||||
|
if (!locations || typeof locations !== 'object') return null;
|
||||||
|
for (const loc of Object.values(locations)) {
|
||||||
|
const name = (loc as { name?: unknown })?.name;
|
||||||
|
if (typeof name === 'string' && name.trim()) return name.trim();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstCalendarId(event: Partial<CalendarEvent>): string | null {
|
||||||
|
const ids = event.calendarIds;
|
||||||
|
if (!ids || typeof ids !== 'object') return null;
|
||||||
|
const keys = Object.keys(ids);
|
||||||
|
return keys.length > 0 ? keys[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const creds = await getStalwartCredentials(request);
|
||||||
|
if (!creds) {
|
||||||
|
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: { days?: unknown; limit?: unknown } = {};
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
/* empty body is fine */
|
||||||
|
}
|
||||||
|
const days = clampInt(body.days, 1, 90, 7);
|
||||||
|
const limit = clampInt(body.limit, 1, 200, 50);
|
||||||
|
|
||||||
|
// ── Resolve the calendar account from the JMAP session ──
|
||||||
|
// Hit Stalwart's canonical session endpoint on the SAME host as serverUrl.
|
||||||
|
// We deliberately avoid /.well-known/jmap: it 301s to the server's
|
||||||
|
// configured public hostname, which the host process may not be able to
|
||||||
|
// resolve (the browser client rewrites those URLs back to the origin for
|
||||||
|
// the same reason). Fall back to /.well-known/jmap for non-Stalwart servers.
|
||||||
|
const session = await fetchJmapSession(creds.serverUrl, creds.authHeader);
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'JMAP session fetch failed' }, { status: 502 });
|
||||||
|
}
|
||||||
|
const accountId = session.primaryAccounts?.[CALENDAR_CAP];
|
||||||
|
if (!accountId) {
|
||||||
|
// No calendar account for this user — return an empty agenda, not an error.
|
||||||
|
return NextResponse.json({ events: [], generatedAt: new Date().toISOString() });
|
||||||
|
}
|
||||||
|
|
||||||
|
const using = ['urn:ietf:params:jmap:core', CALENDAR_CAP];
|
||||||
|
if (session.capabilities && PRINCIPALS_CAP in session.capabilities) {
|
||||||
|
using.push('urn:ietf:params:jmap:principals:owner');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send method calls to the same-origin JMAP endpoint the app's passthrough
|
||||||
|
// uses — never to session.apiUrl's (possibly unreachable) public host.
|
||||||
|
const apiUrl = `${creds.serverUrl}/jmap/`;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const horizon = new Date(now.getTime() + days * 24 * 60 * 60 * 1000);
|
||||||
|
// Expand from the start of today so all-day / already-running events still
|
||||||
|
// show in the agenda.
|
||||||
|
const windowStart = new Date(now);
|
||||||
|
windowStart.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
// ── 1) Query event IDs in range + load calendars (colours) ──
|
||||||
|
const queryReq = {
|
||||||
|
using,
|
||||||
|
methodCalls: [
|
||||||
|
[
|
||||||
|
'CalendarEvent/query',
|
||||||
|
{
|
||||||
|
accountId,
|
||||||
|
// Mirror the app's calendar store: an { after, before } window lets
|
||||||
|
// Stalwart evaluate recurrence so masters with occurrences in range
|
||||||
|
// are returned (a `before`-only filter can drop unbounded series).
|
||||||
|
filter: { after: windowStart.toISOString(), before: horizon.toISOString() },
|
||||||
|
limit: 1000,
|
||||||
|
},
|
||||||
|
'0',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Calendar/get',
|
||||||
|
{ accountId, ids: null, properties: ['id', 'name', 'color'] },
|
||||||
|
'c',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const queryRes = await jmapPost(apiUrl, creds.authHeader, queryReq);
|
||||||
|
const queryResp = findResponse(queryRes, 'CalendarEvent/query', '0');
|
||||||
|
if (!queryResp) {
|
||||||
|
const err = findResponse(queryRes, 'error', '0');
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: (err?.description as string) || 'CalendarEvent/query failed' },
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const ids = (queryResp.ids as string[]) || [];
|
||||||
|
|
||||||
|
const calColors = new Map<string, { name: string; color: string | null }>();
|
||||||
|
const calResp = findResponse(queryRes, 'Calendar/get', 'c');
|
||||||
|
for (const cal of ((calResp?.list as Array<Record<string, unknown>>) || [])) {
|
||||||
|
if (typeof cal.id === 'string') {
|
||||||
|
calColors.set(cal.id, {
|
||||||
|
name: typeof cal.name === 'string' ? cal.name : '',
|
||||||
|
color: typeof cal.color === 'string' ? cal.color : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ids.length === 0) {
|
||||||
|
return NextResponse.json({ events: [], generatedAt: now.toISOString() });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2) Fetch full event objects (batched) ──
|
||||||
|
const raw: Array<Record<string, unknown>> = [];
|
||||||
|
const BATCH = 100;
|
||||||
|
for (let i = 0; i < ids.length; i += BATCH) {
|
||||||
|
const batch = ids.slice(i, i + BATCH);
|
||||||
|
const getRes = await jmapPost(apiUrl, creds.authHeader, {
|
||||||
|
using,
|
||||||
|
methodCalls: [
|
||||||
|
['CalendarEvent/get', { accountId, ids: batch, properties: EVENT_PROPERTIES }, '0'],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const getResp = findResponse(getRes, 'CalendarEvent/get', '0');
|
||||||
|
if (getResp?.list) raw.push(...(getResp.list as Array<Record<string, unknown>>));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 3) Normalize + expand recurrences server-side ──
|
||||||
|
const normalized = raw
|
||||||
|
.map((e) => normalizeCalendarEventLike(e as Partial<CalendarEvent>))
|
||||||
|
.filter((e) => (e['@type'] ?? 'Event') === 'Event')
|
||||||
|
// Drop malformed events without a parseable start (would crash format()/
|
||||||
|
// parseISO downstream) — mirrors the calendar store guard (#316).
|
||||||
|
.filter((e) => typeof e.start === 'string' && e.start && !isNaN(parseISO(e.start).getTime())) as CalendarEvent[];
|
||||||
|
|
||||||
|
const expanded = expandRecurringEvents(
|
||||||
|
normalized,
|
||||||
|
windowStart.toISOString(),
|
||||||
|
horizon.toISOString(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── 4) Keep ongoing/upcoming, sort, slice, map to DTOs ──
|
||||||
|
const agenda: AgendaEvent[] = expanded
|
||||||
|
.filter((e) => eventEnd(e).getTime() >= now.getTime())
|
||||||
|
.sort((a, b) => eventStart(a).getTime() - eventStart(b).getTime())
|
||||||
|
.slice(0, limit)
|
||||||
|
.map((e) => {
|
||||||
|
const calId = firstCalendarId(e);
|
||||||
|
const cal = calId ? calColors.get(calId) : undefined;
|
||||||
|
return {
|
||||||
|
id: String(e.id ?? ''),
|
||||||
|
uid: e.uid ?? null,
|
||||||
|
title: (e.title ?? '').trim() || '(no title)',
|
||||||
|
start: eventStart(e).toISOString(),
|
||||||
|
end: eventEnd(e).toISOString(),
|
||||||
|
allDay: !!e.showWithoutTime,
|
||||||
|
status: e.status ?? null,
|
||||||
|
color: e.color || cal?.color || null,
|
||||||
|
location: firstLocationName(e),
|
||||||
|
calendarId: calId,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ events: agenda, generatedAt: now.toISOString() });
|
||||||
|
} catch (error) {
|
||||||
|
// `fetch failed` from undici is too generic to debug — the real reason
|
||||||
|
// (ENOTFOUND, ECONNREFUSED, self-signed TLS, …) lives on `error.cause`.
|
||||||
|
const err = error as Error & { cause?: { code?: string; message?: string } };
|
||||||
|
logger.error('Calendar agenda error', {
|
||||||
|
error: err?.message ?? 'Unknown',
|
||||||
|
causeCode: err?.cause?.code,
|
||||||
|
causeMessage: err?.cause?.message,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampInt(value: unknown, min: number, max: number, fallback: number): number {
|
||||||
|
const n = typeof value === 'number' ? value : Number(value);
|
||||||
|
if (!Number.isFinite(n)) return fallback;
|
||||||
|
return Math.min(max, Math.max(min, Math.round(n)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the JMAP session from the same host as `serverUrl`. Tries Stalwart's
|
||||||
|
* canonical /jmap/session first (no redirect), then /.well-known/jmap as a
|
||||||
|
* fallback for other servers. Returns null if neither yields a usable session.
|
||||||
|
*/
|
||||||
|
async function fetchJmapSession(
|
||||||
|
serverUrl: string,
|
||||||
|
authHeader: string,
|
||||||
|
): Promise<JmapSession | null> {
|
||||||
|
const candidates = [`${serverUrl}/jmap/session`, `${serverUrl}/.well-known/jmap`];
|
||||||
|
for (const url of candidates) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { Authorization: authHeader },
|
||||||
|
redirect: 'follow',
|
||||||
|
});
|
||||||
|
if (!res.ok) continue;
|
||||||
|
const session = (await res.json()) as JmapSession;
|
||||||
|
if (session && typeof session === 'object' && session.primaryAccounts) {
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Try the next candidate (e.g. canonical path 404s on a non-Stalwart server).
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function jmapPost(
|
||||||
|
apiUrl: string,
|
||||||
|
authHeader: string,
|
||||||
|
payload: unknown,
|
||||||
|
): Promise<unknown> {
|
||||||
|
const res = await fetch(apiUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: authHeader, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`JMAP request failed (${res.status})`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function findResponse(
|
||||||
|
res: unknown,
|
||||||
|
name: string,
|
||||||
|
callId: string,
|
||||||
|
): Record<string, unknown> | null {
|
||||||
|
const responses = (res as { methodResponses?: unknown[] })?.methodResponses;
|
||||||
|
if (!Array.isArray(responses)) return null;
|
||||||
|
for (const entry of responses) {
|
||||||
|
if (Array.isArray(entry) && entry[0] === name && entry[2] === callId) {
|
||||||
|
return entry[1] as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -74,6 +74,10 @@ export async function GET(request: NextRequest) {
|
|||||||
loginImprintUrl: branded<string>('loginImprintUrl', ''),
|
loginImprintUrl: branded<string>('loginImprintUrl', ''),
|
||||||
loginPrivacyPolicyUrl: branded<string>('loginPrivacyPolicyUrl', ''),
|
loginPrivacyPolicyUrl: branded<string>('loginPrivacyPolicyUrl', ''),
|
||||||
loginWebsiteUrl: branded<string>('loginWebsiteUrl', ''),
|
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),
|
demoMode: configManager.get<boolean>('demoMode', false),
|
||||||
allowCustomJmapEndpoint: configManager.get<boolean>('allowCustomJmapEndpoint', false),
|
allowCustomJmapEndpoint: configManager.get<boolean>('allowCustomJmapEndpoint', false),
|
||||||
jmapServers: redactJmapServers(parseJmapServers(configManager.get<unknown>('jmapServers', []))),
|
jmapServers: redactJmapServers(parseJmapServers(configManager.get<unknown>('jmapServers', []))),
|
||||||
|
|||||||
@@ -121,9 +121,9 @@ const emails: MockEmail[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'email-002', threadId: 'thread-002', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true, $flagged: true, '$label:blue': true }, size: 5100, receivedAt: daysAgo(1),
|
id: 'email-002', threadId: 'thread-002', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true, $flagged: true, '$label:blue': true }, size: 5100, receivedAt: daysAgo(1),
|
||||||
from: [{ name: 'Pierre Dubois', email: 'pierre@dubois.example' }],
|
from: [{ name: 'Dubois, Pierre', email: 'pierre@dubois.example' }],
|
||||||
to: [{ name: 'Dev User', email: 'dev@localhost' }],
|
to: [{ name: 'Dev User', email: 'dev@localhost' }],
|
||||||
cc: [{ name: 'Karel de Vries', email: 'karel@devries.example' }],
|
cc: [{ name: 'de Vries, Karel', email: 'karel@devries.example' }],
|
||||||
subject: 'Project Update - Q1 Review',
|
subject: 'Project Update - Q1 Review',
|
||||||
preview: 'Salut team, I wanted to share the latest project numbers. We are on track to meet our targets for Q1.',
|
preview: 'Salut team, I wanted to share the latest project numbers. We are on track to meet our targets for Q1.',
|
||||||
hasAttachment: true,
|
hasAttachment: true,
|
||||||
@@ -723,7 +723,18 @@ const emails: MockEmail[] = [
|
|||||||
// Identities
|
// Identities
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const IDENTITIES = [
|
type MockIdentity = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
replyTo: Array<{ name?: string; email: string }> | null;
|
||||||
|
bcc: Array<{ name?: string; email: string }> | null;
|
||||||
|
textSignature: string | null;
|
||||||
|
htmlSignature: string | null;
|
||||||
|
mayDelete: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const IDENTITIES: MockIdentity[] = [
|
||||||
{
|
{
|
||||||
id: 'identity-001',
|
id: 'identity-001',
|
||||||
name: 'Dev User',
|
name: 'Dev User',
|
||||||
@@ -1562,13 +1573,55 @@ function handleIdentityGet(_args: MethodArgs, callId: string): MethodResult {
|
|||||||
|
|
||||||
function handleIdentitySet(args: MethodArgs, callId: string): MethodResult {
|
function handleIdentitySet(args: MethodArgs, callId: string): MethodResult {
|
||||||
const created: Record<string, { id: string }> = {};
|
const created: Record<string, { id: string }> = {};
|
||||||
const create = args.create as Record<string, unknown> | undefined;
|
const updated: Record<string, null> = {};
|
||||||
|
const destroyed: string[] = [];
|
||||||
|
|
||||||
|
const create = args.create as Record<string, Record<string, unknown>> | undefined;
|
||||||
if (create) {
|
if (create) {
|
||||||
for (const key of Object.keys(create)) {
|
for (const [key, data] of Object.entries(create)) {
|
||||||
created[key] = { id: `identity-new-${Date.now()}-${key}` };
|
const newId = `identity-${Date.now()}-${key}`;
|
||||||
|
IDENTITIES.push({
|
||||||
|
id: newId,
|
||||||
|
name: (data.name as string) || '',
|
||||||
|
email: (data.email as string) || '',
|
||||||
|
replyTo: (data.replyTo as MockIdentity['replyTo']) ?? null,
|
||||||
|
bcc: (data.bcc as MockIdentity['bcc']) ?? null,
|
||||||
|
textSignature: (data.textSignature as string | null) ?? null,
|
||||||
|
htmlSignature: (data.htmlSignature as string | null) ?? null,
|
||||||
|
mayDelete: true,
|
||||||
|
});
|
||||||
|
created[key] = { id: newId };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ['Identity/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), created, updated: null, destroyed: null }, callId];
|
|
||||||
|
const update = args.update as Record<string, Record<string, unknown>> | undefined;
|
||||||
|
if (update) {
|
||||||
|
for (const [id, changes] of Object.entries(update)) {
|
||||||
|
const identity = IDENTITIES.find((i) => i.id === id);
|
||||||
|
if (identity) {
|
||||||
|
// Email is immutable per the identity form, so it's never in `changes`.
|
||||||
|
if (changes.name !== undefined) identity.name = changes.name as string;
|
||||||
|
if (changes.replyTo !== undefined) identity.replyTo = changes.replyTo as MockIdentity['replyTo'];
|
||||||
|
if (changes.bcc !== undefined) identity.bcc = changes.bcc as MockIdentity['bcc'];
|
||||||
|
if (changes.textSignature !== undefined) identity.textSignature = changes.textSignature as string | null;
|
||||||
|
if (changes.htmlSignature !== undefined) identity.htmlSignature = changes.htmlSignature as string | null;
|
||||||
|
updated[id] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const destroy = args.destroy as string[] | undefined;
|
||||||
|
if (destroy) {
|
||||||
|
for (const id of destroy) {
|
||||||
|
const idx = IDENTITIES.findIndex((i) => i.id === id);
|
||||||
|
if (idx !== -1) {
|
||||||
|
IDENTITIES.splice(idx, 1);
|
||||||
|
destroyed.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['Identity/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), created, updated, destroyed, notCreated: null, notUpdated: null, notDestroyed: null }, callId];
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleThreadGet(args: MethodArgs, callId: string): MethodResult {
|
function handleThreadGet(args: MethodArgs, callId: string): MethodResult {
|
||||||
@@ -1626,7 +1679,8 @@ function handleEmailSubmissionGet(args: MethodArgs, callId: string): MethodResul
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleQuotaGet(_args: MethodArgs, callId: string): MethodResult {
|
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 {
|
function handleVacationResponseGet(_args: MethodArgs, callId: string): MethodResult {
|
||||||
|
|||||||
+17
-12
@@ -19,6 +19,20 @@ const negativeCache = new Map<string, NegativeCacheEntry>();
|
|||||||
const NEGATIVE_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 1 day
|
const NEGATIVE_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 1 day
|
||||||
const NEGATIVE_CACHE_MAX_SIZE = 2000;
|
const NEGATIVE_CACHE_MAX_SIZE = 2000;
|
||||||
|
|
||||||
|
// 1x1 transparent PNG. Returned with HTTP 200 (instead of 404) when no
|
||||||
|
// favicon exists for a domain, so the browser's <img> tag loads it cleanly
|
||||||
|
// without spamming the DevTools console with red 404 errors. Avatar.tsx
|
||||||
|
// checks `naturalWidth <= 1` in onLoad and falls back to initials.
|
||||||
|
const TRANSPARENT_PNG = Buffer.from(
|
||||||
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNgAAIAAAUAAen63NgAAAAASUVORK5CYII=',
|
||||||
|
'base64',
|
||||||
|
);
|
||||||
|
const MISSING_FAVICON_HEADERS = {
|
||||||
|
'Content-Type': 'image/png',
|
||||||
|
'Cache-Control': 'public, max-age=86400', // 1 day
|
||||||
|
'X-Bulwark-Favicon': 'missing',
|
||||||
|
};
|
||||||
|
|
||||||
// Strict domain validation to prevent SSRF
|
// Strict domain validation to prevent SSRF
|
||||||
const DOMAIN_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i;
|
const DOMAIN_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i;
|
||||||
|
|
||||||
@@ -434,10 +448,7 @@ export async function GET(request: NextRequest) {
|
|||||||
// Check negative cache (domains known to have no favicon)
|
// Check negative cache (domains known to have no favicon)
|
||||||
const neg = negativeCache.get(normalizedDomain);
|
const neg = negativeCache.get(normalizedDomain);
|
||||||
if (neg && Date.now() - neg.fetchedAt < NEGATIVE_CACHE_TTL_MS) {
|
if (neg && Date.now() - neg.fetchedAt < NEGATIVE_CACHE_TTL_MS) {
|
||||||
return new NextResponse(null, {
|
return new NextResponse(TRANSPARENT_PNG, { headers: MISSING_FAVICON_HEADERS });
|
||||||
status: 404,
|
|
||||||
headers: { 'Cache-Control': 'public, max-age=86400' }, // 1 day
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check cache
|
// Check cache
|
||||||
@@ -460,10 +471,7 @@ export async function GET(request: NextRequest) {
|
|||||||
if (!upstream.ok) {
|
if (!upstream.ok) {
|
||||||
evictNegativeOldest();
|
evictNegativeOldest();
|
||||||
negativeCache.set(normalizedDomain, { fetchedAt: Date.now() });
|
negativeCache.set(normalizedDomain, { fetchedAt: Date.now() });
|
||||||
return new NextResponse(null, {
|
return new NextResponse(TRANSPARENT_PNG, { headers: MISSING_FAVICON_HEADERS });
|
||||||
status: 404,
|
|
||||||
headers: { 'Cache-Control': 'public, max-age=86400' },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const contentType = upstream.headers.get('content-type') || 'image/x-icon';
|
const contentType = upstream.headers.get('content-type') || 'image/x-icon';
|
||||||
@@ -473,10 +481,7 @@ export async function GET(request: NextRequest) {
|
|||||||
if (data.byteLength < 10) {
|
if (data.byteLength < 10) {
|
||||||
evictNegativeOldest();
|
evictNegativeOldest();
|
||||||
negativeCache.set(normalizedDomain, { fetchedAt: Date.now() });
|
negativeCache.set(normalizedDomain, { fetchedAt: Date.now() });
|
||||||
return new NextResponse(null, {
|
return new NextResponse(TRANSPARENT_PNG, { headers: MISSING_FAVICON_HEADERS });
|
||||||
status: 404,
|
|
||||||
headers: { 'Cache-Control': 'public, max-age=86400' },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache the result
|
// Cache the result
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ export async function GET() {
|
|||||||
author: p.author,
|
author: p.author,
|
||||||
description: p.description,
|
description: p.description,
|
||||||
type: p.type,
|
type: p.type,
|
||||||
|
// Requested execution tier; clients gate the same-origin privileged
|
||||||
|
// sandbox on this (plus signature + approval + consent).
|
||||||
|
tier: p.tier,
|
||||||
permissions: p.permissions,
|
permissions: p.permissions,
|
||||||
entrypoint: p.entrypoint,
|
entrypoint: p.entrypoint,
|
||||||
// Policy is the canonical source for force-enable. The per-plugin field
|
// Policy is the canonical source for force-enable. The per-plugin field
|
||||||
@@ -57,6 +60,9 @@ export async function GET() {
|
|||||||
// Per-user settings schema, captured from the manifest at upload/load
|
// Per-user settings schema, captured from the manifest at upload/load
|
||||||
// time so the client can render the settings UI without re-parsing.
|
// time so the client can render the settings UI without re-parsing.
|
||||||
settingsSchema: p.settingsSchema,
|
settingsSchema: p.settingsSchema,
|
||||||
|
// Plugin-declared i18n tables, so the sandbox can localize plugin
|
||||||
|
// strings via api.i18n.t().
|
||||||
|
locales: p.locales,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Only serve enabled themes
|
// Only serve enabled themes
|
||||||
|
|||||||
@@ -59,10 +59,12 @@ export async function GET(
|
|||||||
domainOverrides.pwaIconUrl ||
|
domainOverrides.pwaIconUrl ||
|
||||||
domainOverrides.faviconUrl ||
|
domainOverrides.faviconUrl ||
|
||||||
(sources.pwaIconUrl?.source !== 'default' ? (sources.pwaIconUrl?.value as string) : '') ||
|
(sources.pwaIconUrl?.source !== 'default' ? (sources.pwaIconUrl?.value as string) : '') ||
|
||||||
(sources.faviconUrl?.source !== 'default' ? (sources.faviconUrl?.value as string) : '');
|
(sources.faviconUrl?.source !== 'default' ? (sources.faviconUrl?.value as string) : '') ||
|
||||||
if (!iconUrl) {
|
// Fall back to the built-in default so this endpoint ALWAYS returns an app
|
||||||
return new NextResponse('No PWA icon configured', { status: 404 });
|
// 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 = {
|
const pngHeaders = {
|
||||||
'Content-Type': 'image/png',
|
'Content-Type': 'image/png',
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
|
import { getConfigDir } from '@/lib/admin/paths';
|
||||||
|
import {
|
||||||
|
matchDomainBranding,
|
||||||
|
parseDomainBranding,
|
||||||
|
pickRequestHost,
|
||||||
|
} from '@/lib/admin/domain-branding';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Variant → target output size + admin config key.
|
||||||
|
* Matches the sizes declared in app/manifest.ts so the rendered PNG fits
|
||||||
|
* the slot the manifest tells the browser about.
|
||||||
|
*/
|
||||||
|
const VARIANTS = {
|
||||||
|
mobile: { width: 540, height: 720, configKey: 'pwaScreenshotMobileUrl' as const },
|
||||||
|
desktop: { width: 1280, height: 720, configKey: 'pwaScreenshotDesktopUrl' as const },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type Variant = keyof typeof VARIANTS;
|
||||||
|
|
||||||
|
// Cache resized images keyed by (variant, source URL).
|
||||||
|
const cache = new Map<string, Blob>();
|
||||||
|
|
||||||
|
async function fetchSourceImage(iconUrl: string): Promise<Buffer> {
|
||||||
|
if (iconUrl.startsWith('http://') || iconUrl.startsWith('https://')) {
|
||||||
|
const res = await fetch(iconUrl);
|
||||||
|
if (!res.ok) throw new Error(`Failed to fetch PWA screenshot: ${res.status}`);
|
||||||
|
return Buffer.from(await res.arrayBuffer());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin-uploaded branding asset: served from /api/admin/branding/<file>
|
||||||
|
// but stored on disk under getConfigDir()/branding/.
|
||||||
|
const ADMIN_BRANDING_PREFIX = '/api/admin/branding/';
|
||||||
|
if (iconUrl.startsWith(ADMIN_BRANDING_PREFIX)) {
|
||||||
|
const filename = path.basename(iconUrl.slice(ADMIN_BRANDING_PREFIX.length));
|
||||||
|
return readFile(path.join(getConfigDir(), 'branding', filename));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path relative to public/ directory
|
||||||
|
const publicPath = path.join(process.cwd(), 'public', iconUrl.replace(/^\//, ''));
|
||||||
|
return readFile(publicPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ variant: string }> },
|
||||||
|
) {
|
||||||
|
const { variant: variantParam } = await params;
|
||||||
|
if (!(variantParam in VARIANTS)) {
|
||||||
|
return new NextResponse('Invalid variant. Allowed: mobile, desktop', { status: 400 });
|
||||||
|
}
|
||||||
|
const { width, height, configKey } = VARIANTS[variantParam as Variant];
|
||||||
|
|
||||||
|
await configManager.ensureLoaded();
|
||||||
|
const host = pickRequestHost(req);
|
||||||
|
const domainOverrides = matchDomainBranding(
|
||||||
|
host,
|
||||||
|
parseDomainBranding(configManager.get<unknown>('domainBranding', [])),
|
||||||
|
);
|
||||||
|
const sources = configManager.getAllWithSources();
|
||||||
|
const sourceEntry = sources[configKey];
|
||||||
|
const screenshotUrl =
|
||||||
|
domainOverrides[configKey] ||
|
||||||
|
(sourceEntry?.source !== 'default' ? (sourceEntry?.value as string | undefined) : undefined);
|
||||||
|
if (!screenshotUrl) {
|
||||||
|
return new NextResponse('No PWA screenshot configured', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const pngHeaders = {
|
||||||
|
'Content-Type': 'image/png',
|
||||||
|
'Cache-Control': 'public, max-age=86400',
|
||||||
|
Vary: 'Host, X-Forwarded-Host',
|
||||||
|
};
|
||||||
|
const cacheKey = `${variantParam}|${screenshotUrl}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (cache.has(cacheKey)) {
|
||||||
|
return new NextResponse(cache.get(cacheKey)!, { headers: pngHeaders });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceBuffer = await fetchSourceImage(screenshotUrl);
|
||||||
|
// 'cover' fills the target box without letterboxing - screenshots benefit
|
||||||
|
// more from cropping than from a transparent frame around them. Users get
|
||||||
|
// a hint about the recommended aspect ratio in the admin UI.
|
||||||
|
const resized = await sharp(sourceBuffer)
|
||||||
|
.resize(width, height, { fit: 'cover', position: 'center' })
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
const ab = new ArrayBuffer(resized.byteLength);
|
||||||
|
new Uint8Array(ab).set(resized);
|
||||||
|
const blob = new Blob([ab], { type: 'image/png' });
|
||||||
|
cache.set(cacheKey, blob);
|
||||||
|
|
||||||
|
return new NextResponse(blob, { headers: pngHeaders });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to generate PWA screenshot:', err);
|
||||||
|
return new NextResponse('Failed to generate screenshot', { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,10 +23,13 @@ const ALLOWED_MIME_TYPES = new Set([
|
|||||||
|
|
||||||
const VALID_SLOTS = new Set([
|
const VALID_SLOTS = new Set([
|
||||||
'faviconUrl',
|
'faviconUrl',
|
||||||
|
'pwaIconUrl',
|
||||||
'appLogoLightUrl',
|
'appLogoLightUrl',
|
||||||
'appLogoDarkUrl',
|
'appLogoDarkUrl',
|
||||||
'loginLogoLightUrl',
|
'loginLogoLightUrl',
|
||||||
'loginLogoDarkUrl',
|
'loginLogoDarkUrl',
|
||||||
|
'pwaScreenshotMobileUrl',
|
||||||
|
'pwaScreenshotDesktopUrl',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const EXT_BY_MIME: Record<string, string> = {
|
const EXT_BY_MIME: Record<string, string> = {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { authenticateWizardRequest } from '@/lib/setup/session';
|
|||||||
import { configManager } from '@/lib/admin/config-manager';
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
import { CONFIG_ENV_MAP } from '@/lib/admin/types';
|
import { CONFIG_ENV_MAP } from '@/lib/admin/types';
|
||||||
import { parseJmapServers } from '@/lib/admin/jmap-servers';
|
import { parseJmapServers } from '@/lib/admin/jmap-servers';
|
||||||
|
import { effectiveConsent, loadState, saveState, reschedule } from '@/lib/telemetry';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
@@ -76,8 +77,32 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'values must be an object' }, { status: 400 });
|
return NextResponse.json({ error: 'values must be an object' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const valuesObj = { ...(values as Record<string, unknown>) };
|
||||||
|
|
||||||
|
// Telemetry consent lives in the telemetry state file, not admin config, so
|
||||||
|
// it has no CONFIG_ENV_MAP entry. Pull it out of the security step and
|
||||||
|
// persist it directly, mirroring POST /api/admin/telemetry (set-consent).
|
||||||
|
if (step === 'security' && 'telemetryConsent' in valuesObj) {
|
||||||
|
const consent = valuesObj.telemetryConsent;
|
||||||
|
delete valuesObj.telemetryConsent;
|
||||||
|
if (consent !== 'on' && consent !== 'off') {
|
||||||
|
return NextResponse.json({ error: 'telemetryConsent must be "on" or "off"' }, { status: 400 });
|
||||||
|
}
|
||||||
|
// A BULWARK_TELEMETRY env var hard-locks the choice; don't fight it.
|
||||||
|
const { source } = await effectiveConsent();
|
||||||
|
if (source !== 'env') {
|
||||||
|
const tstate = await loadState();
|
||||||
|
tstate.consent = consent;
|
||||||
|
if (consent === 'on' && !tstate.consentedAt) {
|
||||||
|
tstate.consentedAt = new Date().toISOString();
|
||||||
|
}
|
||||||
|
await saveState(tstate);
|
||||||
|
await reschedule();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const updates: Record<string, unknown> = {};
|
const updates: Record<string, unknown> = {};
|
||||||
for (const [key, value] of Object.entries(values as Record<string, unknown>)) {
|
for (const [key, value] of Object.entries(valuesObj)) {
|
||||||
if (!allowedKeys.includes(key)) {
|
if (!allowedKeys.includes(key)) {
|
||||||
return NextResponse.json({ error: `Key not allowed in step ${step}: ${key}` }, { status: 400 });
|
return NextResponse.json({ error: `Key not allowed in step ${step}: ${key}` }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,298 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
// Host-side proxy backing the "Translate" plugin (manifest apiPostPaths:
|
||||||
|
// ["/api/translate"]). The plugin slot iframe POSTs { text, target, source,
|
||||||
|
// provider } here via api.http.post; we forward to a free translation backend
|
||||||
|
// and return { translatedText, detectedSource } in a stable shape.
|
||||||
|
//
|
||||||
|
// Two providers:
|
||||||
|
// - "mymemory" — public MyMemory API, no configuration required. Its
|
||||||
|
// langpair needs an explicit source language, so when the
|
||||||
|
// plugin asks for "auto" we detect it locally first.
|
||||||
|
// - "libretranslate" — only available when the host sets LIBRETRANSLATE_URL
|
||||||
|
// (and optionally LIBRETRANSLATE_API_KEY). Supports native
|
||||||
|
// source auto-detection.
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
const MAX_CHARS = 5000;
|
||||||
|
// Long bodies are split into ~480-char chunks for MyMemory and translated
|
||||||
|
// sequentially, so allow enough headroom for ~10 round-trips.
|
||||||
|
const TIMEOUT_MS = 25000;
|
||||||
|
|
||||||
|
type Provider = 'mymemory' | 'libretranslate';
|
||||||
|
|
||||||
|
interface TranslateBody {
|
||||||
|
text?: unknown;
|
||||||
|
target?: unknown;
|
||||||
|
source?: unknown;
|
||||||
|
provider?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TranslateResult {
|
||||||
|
translatedText: string;
|
||||||
|
detectedSource?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Lightweight language detection ───────────────────────────
|
||||||
|
//
|
||||||
|
// MyMemory has no auto-detect, so we infer a source language from the text.
|
||||||
|
// Non-Latin scripts are decided by Unicode range; Latin-script European
|
||||||
|
// languages are scored by stop-word frequency. Detection only needs to be good
|
||||||
|
// enough to (a) pick a sensible langpair and (b) let the plugin skip messages
|
||||||
|
// already in the target language.
|
||||||
|
|
||||||
|
const SCRIPT_RANGES: ReadonlyArray<[RegExp, string]> = [
|
||||||
|
[/[-ヿ]/, 'ja'], // Hiragana / Katakana
|
||||||
|
[/[가-]/, 'ko'], // Hangul
|
||||||
|
[/[一-鿿]/, 'zh'], // CJK ideographs (after JP/KR checks)
|
||||||
|
[/[Ѐ-ӿ]/, 'ru'], // Cyrillic (ru vs uk refined below)
|
||||||
|
[/[Ͱ-Ͽ]/, 'el'], // Greek
|
||||||
|
[/[-ۿ]/, 'ar'], // Arabic
|
||||||
|
[/[-]/, 'he'], // Hebrew
|
||||||
|
[/[ऀ-ॿ]/, 'hi'], // Devanagari
|
||||||
|
];
|
||||||
|
|
||||||
|
// Distinctive stop words per Latin-script language from the manifest's option
|
||||||
|
// list. Kept small and high-signal to avoid cross-language collisions.
|
||||||
|
const LATIN_STOPWORDS: Record<string, readonly string[]> = {
|
||||||
|
en: ['the', 'and', 'you', 'that', 'with', 'for', 'this', 'have', 'are'],
|
||||||
|
de: ['der', 'die', 'und', 'das', 'ist', 'nicht', 'mit', 'sie', 'ein', 'auch'],
|
||||||
|
fr: ['les', 'des', 'une', 'est', 'pour', 'que', 'vous', 'dans', 'avec', 'pas'],
|
||||||
|
es: ['que', 'los', 'una', 'por', 'con', 'para', 'como', 'pero', 'más', 'esta'],
|
||||||
|
it: ['che', 'non', 'per', 'una', 'sono', 'con', 'come', 'questo', 'anche', 'della'],
|
||||||
|
pt: ['que', 'não', 'uma', 'com', 'para', 'como', 'mais', 'você', 'está', 'isso'],
|
||||||
|
nl: ['het', 'een', 'van', 'dat', 'niet', 'met', 'voor', 'aan', 'zijn', 'maar'],
|
||||||
|
pl: ['nie', 'jest', 'się', 'ale', 'oraz', 'tego', 'jak', 'tym', 'przez', 'dla'],
|
||||||
|
sv: ['och', 'att', 'det', 'som', 'för', 'med', 'inte', 'den', 'till', 'har'],
|
||||||
|
no: ['og', 'det', 'som', 'for', 'med', 'ikke', 'har', 'til', 'denne', 'jeg'],
|
||||||
|
da: ['og', 'det', 'som', 'for', 'med', 'ikke', 'har', 'til', 'denne', 'jeg'],
|
||||||
|
fi: ['että', 'olen', 'tämä', 'kanssa', 'mutta', 'sekä', 'ei', 'on', 'ja', 'jotta'],
|
||||||
|
cs: ['není', 'jsem', 'pro', 'ale', 'jako', 'tento', 'také', 'přes', 'jsou', 'své'],
|
||||||
|
ro: ['este', 'pentru', 'care', 'dar', 'sunt', 'această', 'mai', 'din', 'sau', 'nu'],
|
||||||
|
hu: ['hogy', 'nem', 'egy', 'van', 'ezt', 'vagy', 'mint', 'csak', 'ezzel', 'így'],
|
||||||
|
tr: ['bir', 'için', 'değil', 'bu', 'çok', 'daha', 'ama', 'gibi', 've', 'ile'],
|
||||||
|
};
|
||||||
|
|
||||||
|
function detectLanguage(text: string): string {
|
||||||
|
const sample = text.slice(0, 1000);
|
||||||
|
for (const [range, lang] of SCRIPT_RANGES) {
|
||||||
|
if (range.test(sample)) {
|
||||||
|
// Ukrainian shares Cyrillic with Russian; its unique glyphs decide it.
|
||||||
|
if (lang === 'ru' && /[єіїґ]/.test(sample)) return 'uk';
|
||||||
|
return lang;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const words = sample.toLowerCase().match(/[a-zà-ÿčśžłńęąółżźć]+/gi) || [];
|
||||||
|
if (words.length === 0) return 'en';
|
||||||
|
const counts: Record<string, number> = {};
|
||||||
|
const wordSet = new Set(words);
|
||||||
|
for (const [lang, stops] of Object.entries(LATIN_STOPWORDS)) {
|
||||||
|
let score = 0;
|
||||||
|
for (const stop of stops) if (wordSet.has(stop)) score += 1;
|
||||||
|
counts[lang] = score;
|
||||||
|
}
|
||||||
|
let best = 'en';
|
||||||
|
let bestScore = -1;
|
||||||
|
for (const [lang, score] of Object.entries(counts)) {
|
||||||
|
if (score > bestScore) {
|
||||||
|
best = lang;
|
||||||
|
bestScore = score;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bestScore > 0 ? best : 'en';
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseLang(code: string): string {
|
||||||
|
return String(code || '').toLowerCase().split('-')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Chunking ─────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// MyMemory's free endpoint caps each request's `q` at 500 characters and
|
||||||
|
// silently returns only the translated prefix beyond that — which is why long
|
||||||
|
// emails came back truncated ("…about Sel…"). We split the text into
|
||||||
|
// line-aware chunks under the limit, translate each, then rejoin so the whole
|
||||||
|
// body is covered.
|
||||||
|
|
||||||
|
const MYMEMORY_CHUNK = 480;
|
||||||
|
|
||||||
|
function chunkText(text: string, max: number): string[] {
|
||||||
|
const chunks: string[] = [];
|
||||||
|
let cur = '';
|
||||||
|
const flush = () => {
|
||||||
|
if (cur) {
|
||||||
|
chunks.push(cur);
|
||||||
|
cur = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (const line of text.split('\n')) {
|
||||||
|
if (line.length > max) {
|
||||||
|
flush();
|
||||||
|
// A single over-long line (e.g. a long URL list): split on words, and
|
||||||
|
// hard-cut any word that is itself longer than the limit.
|
||||||
|
let seg = '';
|
||||||
|
for (const word of line.split(' ')) {
|
||||||
|
const piece = seg ? seg + ' ' + word : word;
|
||||||
|
if (piece.length > max) {
|
||||||
|
if (seg) {
|
||||||
|
chunks.push(seg);
|
||||||
|
seg = '';
|
||||||
|
}
|
||||||
|
if (word.length > max) {
|
||||||
|
for (let i = 0; i < word.length; i += max) chunks.push(word.slice(i, i + max));
|
||||||
|
} else {
|
||||||
|
seg = word;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
seg = piece;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (seg) chunks.push(seg);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (cur && cur.length + 1 + line.length > max) flush();
|
||||||
|
cur = cur ? cur + '\n' + line : line;
|
||||||
|
}
|
||||||
|
flush();
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Providers ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function mymemoryRequest(
|
||||||
|
q: string,
|
||||||
|
langpair: string,
|
||||||
|
signal: AbortSignal,
|
||||||
|
): Promise<string> {
|
||||||
|
const url = new URL('https://api.mymemory.translated.net/get');
|
||||||
|
url.searchParams.set('q', q);
|
||||||
|
url.searchParams.set('langpair', langpair);
|
||||||
|
|
||||||
|
const res = await fetch(url.toString(), {
|
||||||
|
signal,
|
||||||
|
headers: { 'User-Agent': 'JMAP-Webmail/1.0 Translate-Plugin' },
|
||||||
|
});
|
||||||
|
const data = (await res.json().catch(() => null)) as
|
||||||
|
| { responseStatus?: number | string; responseData?: { translatedText?: string }; responseDetails?: string }
|
||||||
|
| null;
|
||||||
|
if (!res.ok || !data) {
|
||||||
|
throw new Error(`MyMemory returned ${res.status}`);
|
||||||
|
}
|
||||||
|
const status = Number(data.responseStatus);
|
||||||
|
if (status && status !== 200) {
|
||||||
|
throw new Error(data.responseDetails || `MyMemory error ${status}`);
|
||||||
|
}
|
||||||
|
const translatedText = data.responseData?.translatedText || '';
|
||||||
|
if (!translatedText) {
|
||||||
|
throw new Error('MyMemory returned no translation');
|
||||||
|
}
|
||||||
|
return translatedText;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function translateMyMemory(
|
||||||
|
text: string,
|
||||||
|
source: string,
|
||||||
|
target: string,
|
||||||
|
signal: AbortSignal,
|
||||||
|
): Promise<TranslateResult> {
|
||||||
|
const detected = source === 'auto' || !source ? detectLanguage(text) : baseLang(source);
|
||||||
|
const tgt = baseLang(target);
|
||||||
|
// Nothing to do if already in the target language; the plugin skips display.
|
||||||
|
if (detected === tgt) {
|
||||||
|
return { translatedText: text, detectedSource: detected };
|
||||||
|
}
|
||||||
|
const langpair = `${detected}|${tgt}`;
|
||||||
|
const chunks = chunkText(text, MYMEMORY_CHUNK);
|
||||||
|
// Sequential to stay friendly to MyMemory's free-tier rate limits; emails are
|
||||||
|
// usually one or two chunks.
|
||||||
|
const translated: string[] = [];
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
translated.push(await mymemoryRequest(chunk, langpair, signal));
|
||||||
|
}
|
||||||
|
return { translatedText: translated.join('\n'), detectedSource: detected };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function translateLibre(
|
||||||
|
text: string,
|
||||||
|
source: string,
|
||||||
|
target: string,
|
||||||
|
signal: AbortSignal,
|
||||||
|
): Promise<TranslateResult> {
|
||||||
|
const endpoint = process.env.LIBRETRANSLATE_URL;
|
||||||
|
if (!endpoint) {
|
||||||
|
throw new Error('LibreTranslate is not configured on this server');
|
||||||
|
}
|
||||||
|
const apiKey = process.env.LIBRETRANSLATE_API_KEY;
|
||||||
|
const url = endpoint.replace(/\/+$/, '') + '/translate';
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
signal,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
q: text,
|
||||||
|
source: source || 'auto',
|
||||||
|
target: baseLang(target),
|
||||||
|
format: 'text',
|
||||||
|
...(apiKey ? { api_key: apiKey } : {}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = (await res.json().catch(() => null)) as
|
||||||
|
| { translatedText?: string; detectedLanguage?: { language?: string }; error?: string }
|
||||||
|
| null;
|
||||||
|
if (!res.ok || !data) {
|
||||||
|
throw new Error(data?.error || `LibreTranslate returned ${res.status}`);
|
||||||
|
}
|
||||||
|
if (!data.translatedText) {
|
||||||
|
throw new Error(data.error || 'LibreTranslate returned no translation');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
translatedText: data.translatedText,
|
||||||
|
detectedSource: data.detectedLanguage?.language || (source !== 'auto' ? baseLang(source) : undefined),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Route ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
let body: TranslateBody;
|
||||||
|
try {
|
||||||
|
body = (await request.json()) as TranslateBody;
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = typeof body.text === 'string' ? body.text.trim() : '';
|
||||||
|
const target = typeof body.target === 'string' && body.target.trim() ? body.target.trim() : 'en';
|
||||||
|
const source = typeof body.source === 'string' && body.source.trim() ? body.source.trim() : 'auto';
|
||||||
|
const provider: Provider = body.provider === 'libretranslate' ? 'libretranslate' : 'mymemory';
|
||||||
|
|
||||||
|
if (!text) {
|
||||||
|
return NextResponse.json({ error: 'No text to translate' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (text.length > MAX_CHARS) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Text exceeds the ${MAX_CHARS}-character limit` },
|
||||||
|
{ status: 413 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||||
|
try {
|
||||||
|
const result =
|
||||||
|
provider === 'libretranslate'
|
||||||
|
? await translateLibre(text, source, target, controller.signal)
|
||||||
|
: await translateMyMemory(text, source, target, controller.signal);
|
||||||
|
return NextResponse.json(result, { status: 200 });
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error instanceof Error && error.name === 'AbortError') {
|
||||||
|
return NextResponse.json({ error: 'Translation timed out' }, { status: 504 });
|
||||||
|
}
|
||||||
|
const message = error instanceof Error ? error.message : 'Translation failed';
|
||||||
|
return NextResponse.json({ error: message }, { status: 502 });
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
+23
-6
@@ -70,7 +70,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--color-border: #262626;
|
--color-border: rgba(128, 128, 128, 0.3);
|
||||||
--color-input: #262626;
|
--color-input: #262626;
|
||||||
--color-ring: #d4d4d4;
|
--color-ring: #d4d4d4;
|
||||||
--color-background: #0a0a0a;
|
--color-background: #0a0a0a;
|
||||||
@@ -660,13 +660,13 @@ body {
|
|||||||
|
|
||||||
.tiptap ul {
|
.tiptap ul {
|
||||||
list-style-type: disc;
|
list-style-type: disc;
|
||||||
padding-left: 1.5rem;
|
padding-inline-start: 1.5rem;
|
||||||
margin: 0.25rem 0;
|
margin: 0.25rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tiptap ol {
|
.tiptap ol {
|
||||||
list-style-type: decimal;
|
list-style-type: decimal;
|
||||||
padding-left: 1.5rem;
|
padding-inline-start: 1.5rem;
|
||||||
margin: 0.25rem 0;
|
margin: 0.25rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -675,8 +675,8 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tiptap blockquote {
|
.tiptap blockquote {
|
||||||
border-left: 3px solid var(--color-border);
|
border-inline-start: 3px solid var(--color-border);
|
||||||
padding-left: 1rem;
|
padding-inline-start: 1rem;
|
||||||
margin: 0.5rem 0;
|
margin: 0.5rem 0;
|
||||||
color: var(--color-muted-foreground);
|
color: var(--color-muted-foreground);
|
||||||
}
|
}
|
||||||
@@ -719,7 +719,7 @@ body {
|
|||||||
|
|
||||||
.tiptap p.is-editor-empty:first-child::before {
|
.tiptap p.is-editor-empty:first-child::before {
|
||||||
content: attr(data-placeholder);
|
content: attr(data-placeholder);
|
||||||
float: left;
|
float: inline-start;
|
||||||
color: var(--color-muted-foreground);
|
color: var(--color-muted-foreground);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
height: 0;
|
height: 0;
|
||||||
@@ -808,3 +808,20 @@ body {
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
animation: settings-search-pulse 1.6s ease-in-out forwards;
|
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);
|
||||||
|
}
|
||||||
|
|||||||
+19
-4
@@ -95,10 +95,25 @@ export default async function manifest(): Promise<ExtendedManifest> {
|
|||||||
background_color: backgroundColor,
|
background_color: backgroundColor,
|
||||||
icons,
|
icons,
|
||||||
categories: ["productivity"],
|
categories: ["productivity"],
|
||||||
screenshots: [
|
// Use admin-uploaded screenshots when configured (per-domain override,
|
||||||
{ src: withBase("/screenshot-540x720.png"), sizes: "540x720", type: "image/png" },
|
// admin/env global; resized on the fly via /api/pwa-screenshot/[variant]);
|
||||||
{ src: withBase("/screenshot-1280x720.png"), sizes: "1280x720", type: "image/png" },
|
// otherwise fall back to the built-in Bulwark screenshots from public/.
|
||||||
],
|
screenshots: (() => {
|
||||||
|
const hasMobile =
|
||||||
|
!!domainOverrides.pwaScreenshotMobileUrl ||
|
||||||
|
sources.pwaScreenshotMobileUrl?.source !== "default";
|
||||||
|
const hasDesktop =
|
||||||
|
!!domainOverrides.pwaScreenshotDesktopUrl ||
|
||||||
|
sources.pwaScreenshotDesktopUrl?.source !== "default";
|
||||||
|
return [
|
||||||
|
hasMobile
|
||||||
|
? { src: withBase("/api/pwa-screenshot/mobile"), sizes: "540x720", type: "image/png" }
|
||||||
|
: { src: withBase("/screenshot-540x720.png"), sizes: "540x720", type: "image/png" },
|
||||||
|
hasDesktop
|
||||||
|
? { src: withBase("/api/pwa-screenshot/desktop"), sizes: "1280x720", type: "image/png" }
|
||||||
|
: { src: withBase("/screenshot-1280x720.png"), sizes: "1280x720", type: "image/png" },
|
||||||
|
];
|
||||||
|
})(),
|
||||||
protocol_handlers: [
|
protocol_handlers: [
|
||||||
{ protocol: "mailto", url: withBase("/protocol/mailto?url=%s") },
|
{ protocol: "mailto", url: withBase("/protocol/mailto?url=%s") },
|
||||||
{ protocol: "webcal", url: withBase("/protocol/webcal?url=%s") },
|
{ protocol: "webcal", url: withBase("/protocol/webcal?url=%s") },
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useTranslations, useFormatter } from "next-intl";
|
|||||||
import { format, isToday, isTomorrow, startOfDay } from "date-fns";
|
import { format, isToday, isTomorrow, startOfDay } from "date-fns";
|
||||||
import { MapPin, Users } from "lucide-react";
|
import { MapPin, Users } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { parseDuration, getEventColor } from "./event-card";
|
import { getEventColor } from "./event-card";
|
||||||
import { getEventDayBounds, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
import { getEventDayBounds, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
||||||
import { getParticipantCount } from "@/lib/calendar-participants";
|
import { getParticipantCount } from "@/lib/calendar-participants";
|
||||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||||
@@ -131,7 +131,7 @@ export function CalendarAgendaView({
|
|||||||
)}>
|
)}>
|
||||||
{formatDateHeader(group.date)}
|
{formatDateHeader(group.date)}
|
||||||
</span>
|
</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" })}
|
{intlFormatter.dateTime(group.date, { month: "short", day: "numeric", year: "numeric" })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -147,8 +147,10 @@ export function CalendarAgendaView({
|
|||||||
const calendar = calId ? calendarMap.get(calId) : undefined;
|
const calendar = calId ? calendarMap.get(calId) : undefined;
|
||||||
const color = getEventColor(ev, calendar);
|
const color = getEventColor(ev, calendar);
|
||||||
const start = getEventStartDate(ev);
|
const start = getEventStartDate(ev);
|
||||||
const durMin = parseDuration(ev.duration);
|
|
||||||
const end = getEventEndDate(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
|
const locationName = ev.locations
|
||||||
? Object.values(ev.locations)[0]?.name
|
? Object.values(ev.locations)[0]?.name
|
||||||
: null;
|
: null;
|
||||||
@@ -160,7 +162,10 @@ export function CalendarAgendaView({
|
|||||||
onMouseEnter={(e) => onHoverEvent?.(ev, e.currentTarget.getBoundingClientRect())}
|
onMouseEnter={(e) => onHoverEvent?.(ev, e.currentTarget.getBoundingClientRect())}
|
||||||
onMouseLeave={() => onHoverLeave?.()}
|
onMouseLeave={() => onHoverLeave?.()}
|
||||||
onContextMenu={onContextMenuEvent ? (e) => onContextMenuEvent(e, ev) : undefined}
|
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)' }}
|
style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col items-center pt-0.5 min-w-[60px]">
|
<div className="flex flex-col items-center pt-0.5 min-w-[60px]">
|
||||||
@@ -182,7 +187,7 @@ export function CalendarAgendaView({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<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")}
|
{ev.title || t("events.no_title")}
|
||||||
</div>
|
</div>
|
||||||
{locationName && (
|
{locationName && (
|
||||||
|
|||||||
@@ -218,7 +218,7 @@ export function CalendarDayView({
|
|||||||
{HOURS.map((h) => (
|
{HOURS.map((h) => (
|
||||||
<div
|
<div
|
||||||
key={h}
|
key={h}
|
||||||
className="relative text-muted-foreground text-right pr-2"
|
className="relative text-muted-foreground text-end pe-2"
|
||||||
style={{ height: HOUR_HEIGHT }}
|
style={{ height: HOUR_HEIGHT }}
|
||||||
>
|
>
|
||||||
{h > 0 && (
|
{h > 0 && (
|
||||||
@@ -231,7 +231,7 @@ export function CalendarDayView({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="flex-1 relative border-l border-border"
|
className="flex-1 relative border-s border-border"
|
||||||
role="row"
|
role="row"
|
||||||
aria-label={t("views.day")}
|
aria-label={t("views.day")}
|
||||||
onPointerDown={(e) => handleGridPointerDown(e, dayKey, selectedDate)}
|
onPointerDown={(e) => handleGridPointerDown(e, dayKey, selectedDate)}
|
||||||
@@ -312,7 +312,7 @@ export function CalendarDayView({
|
|||||||
style={{ top: (nowMinutes / 60) * HOUR_HEIGHT }}
|
style={{ top: (nowMinutes / 60) * HOUR_HEIGHT }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center">
|
<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 className="flex-1 h-px bg-destructive" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -346,7 +346,7 @@ export function CalendarDayView({
|
|||||||
style={{ top: (dropTarget.minutes / 60) * HOUR_HEIGHT }}
|
style={{ top: (dropTarget.minutes / 60) * HOUR_HEIGHT }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center">
|
<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 className="flex-1 h-0.5 bg-primary rounded-full" />
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute -top-4 left-2 text-[10px] font-medium text-primary bg-background/90 px-1 rounded shadow-sm">
|
<div className="absolute -top-4 left-2 text-[10px] font-medium text-primary bg-background/90 px-1 rounded shadow-sm">
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState, useCallback, type DragEvent } from "react";
|
import { useMemo, useState, useCallback, type DragEvent } from "react";
|
||||||
import { useTranslations, useFormatter } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import {
|
import { format, parseISO } from "date-fns";
|
||||||
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
|
|
||||||
eachDayOfInterval, isSameDay, isSameMonth, isToday, format, parseISO,
|
|
||||||
} from "date-fns";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { EventCard } from "./event-card";
|
import { EventCard } from "./event-card";
|
||||||
import { buildWeekSegments, getEventDayBounds, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
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 { useCalendarStore } from "@/stores/calendar-store";
|
||||||
import type { PendingEventPreview } from "./event-modal";
|
import type { PendingEventPreview } from "./event-modal";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
|
import { useCalendarLocale } from "@/hooks/use-calendar-locale";
|
||||||
|
|
||||||
interface CalendarMonthViewProps {
|
interface CalendarMonthViewProps {
|
||||||
selectedDate: Date;
|
selectedDate: Date;
|
||||||
@@ -47,16 +45,21 @@ export function CalendarMonthView({
|
|||||||
pendingPreview,
|
pendingPreview,
|
||||||
}: CalendarMonthViewProps) {
|
}: CalendarMonthViewProps) {
|
||||||
const t = useTranslations("calendar");
|
const t = useTranslations("calendar");
|
||||||
const intlFormatter = useFormatter();
|
const {
|
||||||
const weekStart = (firstDayOfWeek === 0 ? 0 : 1) as 0 | 1;
|
weekStartsOn,
|
||||||
|
dayHeaderKeys,
|
||||||
|
getMonthGridDays,
|
||||||
|
checkIsToday,
|
||||||
|
checkIsSameMonth,
|
||||||
|
checkIsSameDay,
|
||||||
|
formatDayNumber,
|
||||||
|
formatFullDate,
|
||||||
|
} = useCalendarLocale();
|
||||||
|
|
||||||
const days = useMemo(() => {
|
const days = useMemo(
|
||||||
const monthStart = startOfMonth(selectedDate);
|
() => getMonthGridDays(selectedDate),
|
||||||
const monthEnd = endOfMonth(selectedDate);
|
[selectedDate, getMonthGridDays],
|
||||||
const gridStart = startOfWeek(monthStart, { weekStartsOn: weekStart });
|
);
|
||||||
const gridEnd = endOfWeek(monthEnd, { weekStartsOn: weekStart });
|
|
||||||
return eachDayOfInterval({ start: gridStart, end: gridEnd });
|
|
||||||
}, [selectedDate, weekStart]);
|
|
||||||
|
|
||||||
const calendarMap = useMemo(() => {
|
const calendarMap = useMemo(() => {
|
||||||
const map = new Map<string, Calendar>();
|
const map = new Map<string, Calendar>();
|
||||||
@@ -83,10 +86,6 @@ export function CalendarMonthView({
|
|||||||
return map;
|
return map;
|
||||||
}, [events]);
|
}, [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 weeks = useMemo(() => {
|
||||||
const result: Date[][] = [];
|
const result: Date[][] = [];
|
||||||
for (let i = 0; i < days.length; i += 7) {
|
for (let i = 0; i < days.length; i += 7) {
|
||||||
@@ -141,11 +140,11 @@ export function CalendarMonthView({
|
|||||||
}, [t]);
|
}, [t]);
|
||||||
|
|
||||||
return (
|
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">
|
<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(
|
<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 && "py-1.5 text-[11px]"
|
||||||
)}>
|
)}>
|
||||||
{isMobile ? t(`days.${d}`).slice(0, 2) : t(`days.${d}`)}
|
{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) }}>
|
)} role="row" style={isMobile ? undefined : { minHeight: Math.max(100, 34 + rowCount * 22 + 8) }}>
|
||||||
<div className="grid grid-cols-7 h-full">
|
<div className="grid grid-cols-7 h-full">
|
||||||
{week.map((day) => {
|
{week.map((day) => {
|
||||||
const inMonth = isSameMonth(day, selectedDate);
|
const inMonth = checkIsSameMonth(day, selectedDate);
|
||||||
const selected = isSameDay(day, selectedDate);
|
const selected = checkIsSameDay(day, selectedDate);
|
||||||
const today = isToday(day);
|
const today = checkIsToday(day);
|
||||||
const key = format(day, "yyyy-MM-dd");
|
const key = format(day, "yyyy-MM-dd");
|
||||||
const dayEvents = eventsByDate.get(key) || [];
|
const dayEvents = eventsByDate.get(key) || [];
|
||||||
const fullDateLabel = intlFormatter.dateTime(day, { weekday: "long", month: "long", day: "numeric", year: "numeric" });
|
const fullDateLabel = formatFullDate(day);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -181,7 +180,7 @@ export function CalendarMonthView({
|
|||||||
onDragLeave={handleCellDragLeave}
|
onDragLeave={handleCellDragLeave}
|
||||||
onDrop={(e) => handleCellDrop(e, day)}
|
onDrop={(e) => handleCellDrop(e, day)}
|
||||||
className={cn(
|
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",
|
!inMonth && "bg-muted/30",
|
||||||
"hover:bg-muted/50",
|
"hover:bg-muted/50",
|
||||||
selected && isMobile && "bg-primary/10",
|
selected && isMobile && "bg-primary/10",
|
||||||
@@ -199,7 +198,7 @@ export function CalendarMonthView({
|
|||||||
inMonth && !selected && !today && "font-medium"
|
inMonth && !selected && !today && "font-medium"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{format(day, "d")}
|
{formatDayNumber(day)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
@@ -219,7 +218,7 @@ export function CalendarMonthView({
|
|||||||
{dayEvents.length > 3 && (
|
{dayEvents.length > 3 && (
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/40" />
|
<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
|
<span
|
||||||
className="w-1.5 h-1.5 rounded-full border border-dashed"
|
className="w-1.5 h-1.5 rounded-full border border-dashed"
|
||||||
style={{ borderColor: calendarMap.get(pendingPreview.calendarId)?.color || "#3b82f6" }}
|
style={{ borderColor: calendarMap.get(pendingPreview.calendarId)?.color || "#3b82f6" }}
|
||||||
@@ -233,7 +232,7 @@ export function CalendarMonthView({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isMobile && pendingPreview && (() => {
|
{!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;
|
if (previewDayIdx === -1) return null;
|
||||||
const previewRow = rowCount;
|
const previewRow = rowCount;
|
||||||
const cal = calendarMap.get(pendingPreview.calendarId);
|
const cal = calendarMap.get(pendingPreview.calendarId);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { ChevronDown, ChevronRight, Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2, Cake, User, Users, Plus, Eraser, Palette } from "lucide-react";
|
import { ChevronDown, ChevronRight, Globe, ListTodo, Pencil, RefreshCw, Share2, Star, Trash2, Cake, User, Users, Plus, Eraser, Palette, Shuffle } from "lucide-react";
|
||||||
import { cn, formatDateTime } from "@/lib/utils";
|
import { cn, formatDateTime } from "@/lib/utils";
|
||||||
import type { Calendar } from "@/lib/jmap/types";
|
import type { Calendar } from "@/lib/jmap/types";
|
||||||
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
|
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
|
||||||
@@ -11,6 +11,7 @@ import { useSettingsStore } from "@/stores/settings-store";
|
|||||||
import { useTaskStore } from "@/stores/task-store";
|
import { useTaskStore } from "@/stores/task-store";
|
||||||
import { useAccountStore } from "@/stores/account-store";
|
import { useAccountStore } from "@/stores/account-store";
|
||||||
import { BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
|
import { BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
|
||||||
|
import { sharedCalendarColorKey } from "@/lib/shared-calendar-colors";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from "@/components/ui/context-menu";
|
import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from "@/components/ui/context-menu";
|
||||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||||
@@ -50,6 +51,7 @@ interface CalendarSidebarPanelProps {
|
|||||||
selectedCalendarIds: string[];
|
selectedCalendarIds: string[];
|
||||||
onToggleVisibility: (id: string) => void;
|
onToggleVisibility: (id: string) => void;
|
||||||
onColorChange?: (calendarId: string, color: string) => void;
|
onColorChange?: (calendarId: string, color: string) => void;
|
||||||
|
onResetColor?: (calendar: Calendar) => void;
|
||||||
onShareCalendar?: (calendar: Calendar) => void;
|
onShareCalendar?: (calendar: Calendar) => void;
|
||||||
onCreateEvent?: (calendar: Calendar) => void;
|
onCreateEvent?: (calendar: Calendar) => void;
|
||||||
onClearCalendar?: (calendar: Calendar) => void;
|
onClearCalendar?: (calendar: Calendar) => void;
|
||||||
@@ -71,6 +73,7 @@ export function CalendarSidebarPanel({
|
|||||||
selectedCalendarIds,
|
selectedCalendarIds,
|
||||||
onToggleVisibility,
|
onToggleVisibility,
|
||||||
onColorChange,
|
onColorChange,
|
||||||
|
onResetColor,
|
||||||
onShareCalendar,
|
onShareCalendar,
|
||||||
onCreateEvent,
|
onCreateEvent,
|
||||||
onClearCalendar,
|
onClearCalendar,
|
||||||
@@ -93,7 +96,9 @@ export function CalendarSidebarPanel({
|
|||||||
);
|
);
|
||||||
const refreshICalSubscription = useCalendarStore((s) => s.refreshICalSubscription);
|
const refreshICalSubscription = useCalendarStore((s) => s.refreshICalSubscription);
|
||||||
const removeICalSubscription = useCalendarStore((s) => s.removeICalSubscription);
|
const removeICalSubscription = useCalendarStore((s) => s.removeICalSubscription);
|
||||||
|
const setDefaultCalendar = useCalendarStore((s) => s.setDefaultCalendar);
|
||||||
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
||||||
|
const sharedCalendarColors = useSettingsStore((s) => s.sharedCalendarColors);
|
||||||
const enableCalendarTasks = useSettingsStore((s) => s.enableCalendarTasks);
|
const enableCalendarTasks = useSettingsStore((s) => s.enableCalendarTasks);
|
||||||
const tasks = useTaskStore((s) => s.tasks);
|
const tasks = useTaskStore((s) => s.tasks);
|
||||||
const setViewMode = useCalendarStore((s) => s.setViewMode);
|
const setViewMode = useCalendarStore((s) => s.setViewMode);
|
||||||
@@ -215,6 +220,16 @@ export function CalendarSidebarPanel({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSetDefault = async (calendarId: string) => {
|
||||||
|
if (!client) return;
|
||||||
|
try {
|
||||||
|
await setDefaultCalendar(client, calendarId);
|
||||||
|
toast.success(tMgmt('default_updated'));
|
||||||
|
} catch {
|
||||||
|
toast.error(tMgmt('error_default'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (calendars.length === 0 && !onSubscribe) return null;
|
if (calendars.length === 0 && !onSubscribe) return null;
|
||||||
|
|
||||||
const renderCalendarItem = (cal: Calendar) => {
|
const renderCalendarItem = (cal: Calendar) => {
|
||||||
@@ -302,10 +317,13 @@ export function CalendarSidebarPanel({
|
|||||||
const isBirthday = cal.id === BIRTHDAY_CALENDAR_ID;
|
const isBirthday = cal.id === BIRTHDAY_CALENDAR_ID;
|
||||||
const canCreate = onCreateEvent && !isBirthday && cal.myRights?.mayWriteOwn !== false;
|
const canCreate = onCreateEvent && !isBirthday && cal.myRights?.mayWriteOwn !== false;
|
||||||
const canShare = onShareCalendar && cal.myRights?.mayShare && !cal.isShared;
|
const canShare = onShareCalendar && cal.myRights?.mayShare && !cal.isShared;
|
||||||
|
const canSetDefault = !!client && !isBirthday && !cal.isShared && !cal.isDefault;
|
||||||
const canChangeColor = !!onColorChange;
|
const canChangeColor = !!onColorChange;
|
||||||
|
const hasColorOverride = !!cal.isShared && !!sharedCalendarColors[sharedCalendarColorKey(cal)];
|
||||||
|
const canResetColor = !!onResetColor && hasColorOverride;
|
||||||
const canClear = onClearCalendar && !isBirthday && cal.myRights?.mayDelete !== false;
|
const canClear = onClearCalendar && !isBirthday && cal.myRights?.mayDelete !== false;
|
||||||
const canDelete = onDeleteCalendar && !isBirthday && !cal.isDefault && !cal.isShared;
|
const canDelete = onDeleteCalendar && !isBirthday && !cal.isDefault && !cal.isShared;
|
||||||
const showSeparator = (canCreate || canShare || canChangeColor) && (canClear || canDelete);
|
const showSeparator = (canCreate || canShare || canSetDefault || canChangeColor || canResetColor) && (canClear || canDelete);
|
||||||
const color = cal.color || "#3b82f6";
|
const color = cal.color || "#3b82f6";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -324,6 +342,13 @@ export function CalendarSidebarPanel({
|
|||||||
onClick={() => { closeContextMenu(); onShareCalendar(cal); }}
|
onClick={() => { closeContextMenu(); onShareCalendar(cal); }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{canSetDefault && (
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Star}
|
||||||
|
label={tMgmt('set_default')}
|
||||||
|
onClick={() => { closeContextMenu(); handleSetDefault(cal.id); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{canChangeColor && (
|
{canChangeColor && (
|
||||||
<ContextMenuSubMenu icon={Palette} label={tMgmt('change_color')}>
|
<ContextMenuSubMenu icon={Palette} label={tMgmt('change_color')}>
|
||||||
<div className="px-2 py-1.5 w-[200px]">
|
<div className="px-2 py-1.5 w-[200px]">
|
||||||
@@ -335,6 +360,13 @@ export function CalendarSidebarPanel({
|
|||||||
</div>
|
</div>
|
||||||
</ContextMenuSubMenu>
|
</ContextMenuSubMenu>
|
||||||
)}
|
)}
|
||||||
|
{canResetColor && (
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Shuffle}
|
||||||
|
label={tMgmt('random_color')}
|
||||||
|
onClick={() => { closeContextMenu(); onResetColor!(cal); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{showSeparator && <ContextMenuSeparator />}
|
{showSeparator && <ContextMenuSeparator />}
|
||||||
{canClear && (
|
{canClear && (
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
@@ -365,7 +397,7 @@ export function CalendarSidebarPanel({
|
|||||||
<ListTodo className="w-4 h-4 text-muted-foreground" />
|
<ListTodo className="w-4 h-4 text-muted-foreground" />
|
||||||
<span>{t('tasks.label')}</span>
|
<span>{t('tasks.label')}</span>
|
||||||
{pendingTaskCount > 0 && (
|
{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 && (
|
{overdueTaskCount > 0 && (
|
||||||
<span className="text-xs text-destructive font-medium">{overdueTaskCount} {t('tasks.filter_overdue').toLowerCase()}</span>
|
<span className="text-xs text-destructive font-medium">{overdueTaskCount} {t('tasks.filter_overdue').toLowerCase()}</span>
|
||||||
@@ -405,7 +437,7 @@ export function CalendarSidebarPanel({
|
|||||||
onCreateCalendar();
|
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')}
|
title={tMgmt('add_calendar')}
|
||||||
>
|
>
|
||||||
<Plus className="w-3 h-3" />
|
<Plus className="w-3 h-3" />
|
||||||
@@ -413,7 +445,7 @@ export function CalendarSidebarPanel({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<div className="mt-1 pl-3">
|
<div className="mt-1 ps-3">
|
||||||
{owned.length > 0 && (
|
{owned.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<div className="px-1 mb-1 text-[10px] font-medium text-muted-foreground/80 uppercase tracking-wider">
|
<div className="px-1 mb-1 text-[10px] font-medium text-muted-foreground/80 uppercase tracking-wider">
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
import { useTranslations, useFormatter } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays, Globe, ChevronDown, ArrowLeft, Menu } from "lucide-react";
|
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 { cn } from "@/lib/utils";
|
||||||
import type { CalendarViewMode } from "@/stores/calendar-store";
|
import type { CalendarViewMode } from "@/stores/calendar-store";
|
||||||
import type { Calendar } from "@/lib/jmap/types";
|
import type { Calendar } from "@/lib/jmap/types";
|
||||||
|
import { useCalendarLocale } from "@/hooks/use-calendar-locale";
|
||||||
|
|
||||||
interface CalendarToolbarProps {
|
interface CalendarToolbarProps {
|
||||||
selectedDate: Date;
|
selectedDate: Date;
|
||||||
@@ -50,7 +51,14 @@ export function CalendarToolbar({
|
|||||||
onMenuClick,
|
onMenuClick,
|
||||||
}: CalendarToolbarProps) {
|
}: CalendarToolbarProps) {
|
||||||
const t = useTranslations("calendar");
|
const t = useTranslations("calendar");
|
||||||
const formatter = useFormatter();
|
const {
|
||||||
|
weekStartsOn,
|
||||||
|
formatMonthYear,
|
||||||
|
formatMonthYearShort,
|
||||||
|
formatWeekRange,
|
||||||
|
formatWeekRangeShort,
|
||||||
|
formatFullDate,
|
||||||
|
} = useCalendarLocale();
|
||||||
const views: CalendarViewMode[] = enableCalendarTasks
|
const views: CalendarViewMode[] = enableCalendarTasks
|
||||||
? ["month", "week", "day", "agenda", "tasks"]
|
? ["month", "week", "day", "agenda", "tasks"]
|
||||||
: ["month", "week", "day", "agenda"];
|
: ["month", "week", "day", "agenda"];
|
||||||
@@ -72,28 +80,22 @@ export function CalendarToolbar({
|
|||||||
switch (viewMode) {
|
switch (viewMode) {
|
||||||
case "month":
|
case "month":
|
||||||
return isMobile
|
return isMobile
|
||||||
? formatter.dateTime(selectedDate, { month: "short", year: "numeric" })
|
? formatMonthYearShort(selectedDate)
|
||||||
: formatter.dateTime(selectedDate, { month: "long", year: "numeric" });
|
: formatMonthYear(selectedDate);
|
||||||
case "week": {
|
case "week": {
|
||||||
const ws = startOfWeek(selectedDate, { weekStartsOn: firstDayOfWeek as 0 | 1 });
|
const ws = startOfWeek(selectedDate, { weekStartsOn });
|
||||||
const we = addDays(ws, 6);
|
return isMobile
|
||||||
if (isMobile) {
|
? formatWeekRangeShort(ws)
|
||||||
return `${formatter.dateTime(ws, { month: "short", day: "numeric" })} – ${formatter.dateTime(we, { day: "numeric" })}`;
|
: formatWeekRange(ws);
|
||||||
}
|
|
||||||
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()}`;
|
|
||||||
}
|
}
|
||||||
case "day":
|
case "day":
|
||||||
return isMobile
|
return isMobile
|
||||||
? formatter.dateTime(selectedDate, { weekday: "short", month: "short", day: "numeric" })
|
? formatFullDate(selectedDate)
|
||||||
: formatter.dateTime(selectedDate, { weekday: "long", month: "long", day: "numeric", year: "numeric" });
|
: formatFullDate(selectedDate);
|
||||||
case "agenda":
|
case "agenda":
|
||||||
return isMobile
|
return isMobile
|
||||||
? formatter.dateTime(selectedDate, { month: "short", year: "numeric" })
|
? formatMonthYearShort(selectedDate)
|
||||||
: formatter.dateTime(selectedDate, { month: "long", year: "numeric" });
|
: formatMonthYear(selectedDate);
|
||||||
case "tasks":
|
case "tasks":
|
||||||
return t("views.tasks");
|
return t("views.tasks");
|
||||||
}
|
}
|
||||||
@@ -124,7 +126,7 @@ export function CalendarToolbar({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={onMenuClick}
|
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")}
|
aria-label={t("nav_open_menu")}
|
||||||
>
|
>
|
||||||
<Menu className="w-4 h-4" />
|
<Menu className="w-4 h-4" />
|
||||||
@@ -138,7 +140,7 @@ export function CalendarToolbar({
|
|||||||
{onMenuClick && (
|
{onMenuClick && (
|
||||||
<button
|
<button
|
||||||
onClick={onMenuClick}
|
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")}
|
aria-label={t("nav_open_menu")}
|
||||||
>
|
>
|
||||||
<Menu className="w-4 h-4" />
|
<Menu className="w-4 h-4" />
|
||||||
@@ -147,7 +149,7 @@ export function CalendarToolbar({
|
|||||||
{onNavigateBack && (
|
{onNavigateBack && (
|
||||||
<button
|
<button
|
||||||
onClick={onNavigateBack}
|
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")}
|
aria-label={t("back_to_month")}
|
||||||
>
|
>
|
||||||
<ArrowLeft className="w-4 h-4" />
|
<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")}>
|
<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" />
|
<ChevronRight className="w-4 h-4" />
|
||||||
</button>
|
</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")}
|
{t("views.today")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -284,7 +286,7 @@ export function CalendarToolbar({
|
|||||||
{/* ── DESKTOP TOOLBAR ── */}
|
{/* ── DESKTOP TOOLBAR ── */}
|
||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
<div className="flex items-center gap-1">
|
<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")}
|
{t("views.today")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onPrev} aria-label={t("nav_prev")}>
|
<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")}>
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onNext} aria-label={t("nav_next")}>
|
||||||
<ChevronRight className="w-4 h-4" />
|
<ChevronRight className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<span className="text-base font-semibold ml-2 select-none">
|
<span className="text-base font-semibold ms-2 select-none">
|
||||||
{getDateLabel()}
|
{getDateLabel()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -328,9 +330,9 @@ export function CalendarToolbar({
|
|||||||
{(onImport || onSubscribe) && !isMobile && (
|
{(onImport || onSubscribe) && !isMobile && (
|
||||||
<div className="relative" ref={importDropdownRef}>
|
<div className="relative" ref={importDropdownRef}>
|
||||||
<Button variant="outline" size="sm" className="h-8" onClick={() => setShowImportDropdown((v) => !v)}>
|
<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")}
|
{t("import.title")}
|
||||||
<ChevronDown className="w-3 h-3 ml-1" />
|
<ChevronDown className="w-3 h-3 ms-1" />
|
||||||
</Button>
|
</Button>
|
||||||
{showImportDropdown && (
|
{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]">
|
<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 && (
|
{!isMobile && (
|
||||||
<Button size="sm" className="h-8" onClick={onCreateEvent} data-tour="create-event-button">
|
<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")}
|
{t("events.create")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export function CalendarWeekView({
|
|||||||
const intlFormatter = useFormatter();
|
const intlFormatter = useFormatter();
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const rootRef = 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 weekDays = useMemo(() => {
|
||||||
const start = startOfWeek(selectedDate, { weekStartsOn: weekStart });
|
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={cn("flex min-h-0 flex-col flex-1", isMobile && "min-w-[880px]")}> {hasAllDay && (
|
||||||
<div className="flex border-b border-border">
|
<div className="flex border-b border-border">
|
||||||
<div
|
<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) }}
|
style={{ minHeight: Math.max(28, (allDayRowCount + taskRowCount) * 24 + 4) }}
|
||||||
>
|
>
|
||||||
{t("events.all_day")}
|
{t("events.all_day")}
|
||||||
@@ -306,7 +306,7 @@ export function CalendarWeekView({
|
|||||||
|
|
||||||
<div className="flex border-b border-border" role="row">
|
<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={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) => {
|
{weekDays.map((day) => {
|
||||||
const todayCol = isToday(day);
|
const todayCol = isToday(day);
|
||||||
const selected = isSameDay(day, selectedDate);
|
const selected = isSameDay(day, selectedDate);
|
||||||
@@ -318,7 +318,7 @@ export function CalendarWeekView({
|
|||||||
role="columnheader"
|
role="columnheader"
|
||||||
aria-label={fullLabel}
|
aria-label={fullLabel}
|
||||||
className={cn(
|
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",
|
"hover:bg-muted/50",
|
||||||
todayCol && "font-bold",
|
todayCol && "font-bold",
|
||||||
)}
|
)}
|
||||||
@@ -345,7 +345,7 @@ export function CalendarWeekView({
|
|||||||
{HOURS.map((h) => (
|
{HOURS.map((h) => (
|
||||||
<div
|
<div
|
||||||
key={h}
|
key={h}
|
||||||
className="relative text-muted-foreground text-right pr-2"
|
className="relative text-muted-foreground text-end pe-2"
|
||||||
style={{ height: HOUR_HEIGHT }}
|
style={{ height: HOUR_HEIGHT }}
|
||||||
>
|
>
|
||||||
{h > 0 && (
|
{h > 0 && (
|
||||||
@@ -357,7 +357,7 @@ export function CalendarWeekView({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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) => {
|
{weekDays.map((day) => {
|
||||||
const key = format(day, "yyyy-MM-dd");
|
const key = format(day, "yyyy-MM-dd");
|
||||||
const dayEvents = timedEvents.get(key) || [];
|
const dayEvents = timedEvents.get(key) || [];
|
||||||
@@ -367,7 +367,7 @@ export function CalendarWeekView({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={key}
|
key={key}
|
||||||
className="relative border-r border-border last:border-r-0"
|
className="relative border-e border-border last:border-e-0"
|
||||||
role="row"
|
role="row"
|
||||||
aria-label={intlFormatter.dateTime(day, { weekday: "long", month: "long", day: "numeric" })}
|
aria-label={intlFormatter.dateTime(day, { weekday: "long", month: "long", day: "numeric" })}
|
||||||
onPointerDown={(e) => handleGridPointerDown(e, key, day)}
|
onPointerDown={(e) => handleGridPointerDown(e, key, day)}
|
||||||
@@ -448,7 +448,7 @@ export function CalendarWeekView({
|
|||||||
style={{ top: (nowMinutes / 60) * HOUR_HEIGHT }}
|
style={{ top: (nowMinutes / 60) * HOUR_HEIGHT }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center">
|
<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 className="flex-1 h-px bg-destructive" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -482,7 +482,7 @@ export function CalendarWeekView({
|
|||||||
style={{ top: (dropTarget.minutes / 60) * HOUR_HEIGHT }}
|
style={{ top: (dropTarget.minutes / 60) * HOUR_HEIGHT }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center">
|
<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 className="flex-1 h-0.5 bg-primary rounded-full" />
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute -top-4 left-2 text-[10px] font-medium text-primary bg-background/90 px-1 rounded shadow-sm">
|
<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}>
|
<Button onClick={handleSubmit} disabled={!isValid || isSubmitting}>
|
||||||
{isSubmitting ? (
|
{isSubmitting ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
<Loader2 className="w-4 h-4 animate-spin me-2" />
|
||||||
{tCommon("loading")}
|
{tCommon("loading")}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -34,6 +34,11 @@ function sanitizeColor(color: string | null | undefined, fallback = "#3b82f6"):
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getEventColor(event: CalendarEvent, calendar?: Calendar): string {
|
function getEventColor(event: CalendarEvent, calendar?: Calendar): string {
|
||||||
|
// A local color override on a shared calendar wins over per-event colors,
|
||||||
|
// so the whole shared calendar paints uniformly in the viewer's chosen hue.
|
||||||
|
if (calendar?.colorIsLocalOverride && calendar.color) {
|
||||||
|
return sanitizeColor(calendar.color);
|
||||||
|
}
|
||||||
return sanitizeColor(event.color, sanitizeColor(calendar?.color));
|
return sanitizeColor(event.color, sanitizeColor(calendar?.color));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,7 +71,7 @@ function createEventDragPreview(title: string, timeRange: string, color: string)
|
|||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onMouseLeave, onContextMenu, isSelected, draggable: isDraggable, continuesBefore = false, continuesAfter = false, className, style }: EventCardProps) {
|
export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onMouseLeave, onContextMenu, isSelected, draggable: isDraggable, continuesAfter = false, className, style }: EventCardProps) {
|
||||||
const t = useTranslations("calendar");
|
const t = useTranslations("calendar");
|
||||||
const [isBeingDragged, setIsBeingDragged] = useState(false);
|
const [isBeingDragged, setIsBeingDragged] = useState(false);
|
||||||
const color = getEventColor(event, calendar);
|
const color = getEventColor(event, calendar);
|
||||||
@@ -83,7 +88,10 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
|||||||
try { return format(d, fmt); } catch { return "--:--"; }
|
try { return format(d, fmt); } catch { return "--:--"; }
|
||||||
};
|
};
|
||||||
const timeString = `${safeFormat(startDate, timeFmt)} – ${safeFormat(endTime, timeFmt)}`;
|
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) => {
|
const handleDragStart = useCallback((e: DragEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -126,11 +134,12 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
|||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
{...dragProps}
|
{...dragProps}
|
||||||
className={cn(
|
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",
|
"min-h-[44px] sm:min-h-0",
|
||||||
"hover:opacity-80 transition-opacity",
|
"hover:opacity-80 transition-opacity",
|
||||||
isSelected && "ring-2 ring-primary",
|
isSelected && "ring-2 ring-primary",
|
||||||
isBeingDragged && "opacity-50",
|
isBeingDragged && "opacity-50",
|
||||||
|
isCancelled && !isBeingDragged && "opacity-60",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
style={{ backgroundColor: `${color}20`, color, ...style }}
|
style={{ backgroundColor: `${color}20`, color, ...style }}
|
||||||
@@ -139,7 +148,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
|||||||
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
|
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
|
||||||
style={{ backgroundColor: color }}
|
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>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -154,12 +163,13 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
|||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
{...dragProps}
|
{...dragProps}
|
||||||
className={cn(
|
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",
|
"hover:opacity-90 transition-opacity cursor-pointer",
|
||||||
continuesAfter && "rounded-r-sm",
|
continuesAfter && "rounded-r-sm",
|
||||||
continuesAfter && "pr-2",
|
continuesAfter && "pe-2",
|
||||||
isSelected && "ring-2 ring-primary",
|
isSelected && "ring-2 ring-primary",
|
||||||
isBeingDragged && "opacity-50",
|
isBeingDragged && "opacity-50",
|
||||||
|
isCancelled && !isBeingDragged && "opacity-60",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
style={{ backgroundColor: `${color}24`, borderLeft: `3px solid ${color}`, color, ...style }}
|
style={{ backgroundColor: `${color}24`, borderLeft: `3px solid ${color}`, color, ...style }}
|
||||||
@@ -168,7 +178,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
|||||||
{showTimeInMonthView && !event.showWithoutTime && (
|
{showTimeInMonthView && !event.showWithoutTime && (
|
||||||
<span className="flex-shrink-0 opacity-80">{format(startDate, timeFmt)}</span>
|
<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>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -184,15 +194,16 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
|||||||
{...dragProps}
|
{...dragProps}
|
||||||
data-calendar-event
|
data-calendar-event
|
||||||
className={cn(
|
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",
|
"hover:opacity-90 transition-opacity cursor-pointer",
|
||||||
isSelected && "ring-2 ring-primary",
|
isSelected && "ring-2 ring-primary",
|
||||||
isBeingDragged && "opacity-50",
|
isBeingDragged && "opacity-50",
|
||||||
|
isCancelled && !isBeingDragged && "opacity-60",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
style={{ backgroundColor: `${color}30`, borderLeft: `3px solid ${color}`, color, ...style }}
|
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 && (
|
{!event.showWithoutTime && (
|
||||||
<div className="opacity-80 text-[10px]">
|
<div className="opacity-80 text-[10px]">
|
||||||
{timeString}
|
{timeString}
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useRef, useMemo, useCallback, useLayoutEffect } from "react";
|
import { useState, useEffect, useRef, useMemo, useCallback, useLayoutEffect } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations, useLocale } from "next-intl";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
X, Clock, MapPin, Video, Users, Repeat, Bell, AlignLeft,
|
X, Clock, MapPin, Video, Users, Repeat, Bell, AlignLeft,
|
||||||
Pencil, Trash2, Copy, Send, Check,
|
Pencil, Trash2, Copy, Send, Check,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { format, isSameDay, parseISO } from "date-fns";
|
import { format, isSameDay } from "date-fns";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
|
||||||
import { parseDuration, getEventColor } from "./event-card";
|
import { parseDuration, getEventColor } from "./event-card";
|
||||||
import { getEventDisplayEndDate, getEventEndDate, getEventStartDate } from "@/lib/calendar-utils";
|
import { getEventDisplayEndDate, getEventEndDate, getEventStartDate } from "@/lib/calendar-utils";
|
||||||
|
import { buildRecurrenceSummary } from "./recurrence-editor";
|
||||||
import {
|
import {
|
||||||
isOrganizer,
|
isOrganizer,
|
||||||
getUserParticipantId,
|
getUserParticipantId,
|
||||||
@@ -101,16 +102,9 @@ function getAlertLabel(event: CalendarEvent, t: ReturnType<typeof useTranslation
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRecurrenceLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>): string | null {
|
function getRecurrenceLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>, locale: string): string | null {
|
||||||
if (!event.recurrenceRules?.length) return null;
|
if (!event.recurrenceRules?.length) return null;
|
||||||
const freq = event.recurrenceRules[0].frequency;
|
return buildRecurrenceSummary(event.recurrenceRules[0], t, locale);
|
||||||
const labels: Record<string, string> = {
|
|
||||||
daily: t("recurrence.daily"),
|
|
||||||
weekly: t("recurrence.weekly"),
|
|
||||||
monthly: t("recurrence.monthly"),
|
|
||||||
yearly: t("recurrence.yearly"),
|
|
||||||
};
|
|
||||||
return labels[freq] || null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EventDetailPopover({
|
export function EventDetailPopover({
|
||||||
@@ -130,6 +124,7 @@ export function EventDetailPopover({
|
|||||||
isMobile,
|
isMobile,
|
||||||
}: EventDetailPopoverProps) {
|
}: EventDetailPopoverProps) {
|
||||||
const t = useTranslations("calendar");
|
const t = useTranslations("calendar");
|
||||||
|
const locale = useLocale();
|
||||||
const popoverRef = useRef<HTMLDivElement>(null);
|
const popoverRef = useRef<HTMLDivElement>(null);
|
||||||
const noteInputRef = useRef<HTMLTextAreaElement>(null);
|
const noteInputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const [position, setPosition] = useState<{ top: number; left: number } | null>(null);
|
const [position, setPosition] = useState<{ top: number; left: number } | null>(null);
|
||||||
@@ -158,7 +153,7 @@ export function EventDetailPopover({
|
|||||||
}, [event.virtualLocations]);
|
}, [event.virtualLocations]);
|
||||||
|
|
||||||
const participants = useMemo(() => getParticipantList(event), [event]);
|
const participants = useMemo(() => getParticipantList(event), [event]);
|
||||||
const recurrenceLabel = useMemo(() => getRecurrenceLabel(event, t), [event, t]);
|
const recurrenceLabel = useMemo(() => getRecurrenceLabel(event, t, locale), [event, t, locale]);
|
||||||
const alertLabel = useMemo(() => getAlertLabel(event, t), [event, t]);
|
const alertLabel = useMemo(() => getAlertLabel(event, t), [event, t]);
|
||||||
|
|
||||||
const userIsOrganizer = useMemo(() => {
|
const userIsOrganizer = useMemo(() => {
|
||||||
@@ -295,20 +290,23 @@ export function EventDetailPopover({
|
|||||||
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
|
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
|
||||||
style={{ backgroundColor: color }}
|
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")}
|
{event.title || t("events.no_title")}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
{calendar && (
|
{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}
|
{calendar.name}
|
||||||
{event.status === "tentative" && (
|
{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")}
|
{t("detail.tentative")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{event.status === "cancelled" && (
|
{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")}
|
{t("detail.cancelled")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -348,13 +346,13 @@ export function EventDetailPopover({
|
|||||||
<>
|
<>
|
||||||
<div className="font-medium text-foreground">
|
<div className="font-medium text-foreground">
|
||||||
{formatEventDate(startDate)}
|
{formatEventDate(startDate)}
|
||||||
<span className="ml-1.5 font-normal text-muted-foreground">
|
<span className="ms-1.5 font-normal text-muted-foreground">
|
||||||
{formatTime(startDate)}
|
{formatTime(startDate)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="font-medium text-foreground">
|
<div className="font-medium text-foreground">
|
||||||
{formatEventDate(endDate)}
|
{formatEventDate(endDate)}
|
||||||
<span className="ml-1.5 font-normal text-muted-foreground">
|
<span className="ms-1.5 font-normal text-muted-foreground">
|
||||||
{formatTime(endDate)}
|
{formatTime(endDate)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -369,11 +367,11 @@ export function EventDetailPopover({
|
|||||||
{formatEventDate(startDate)}
|
{formatEventDate(startDate)}
|
||||||
</span>
|
</span>
|
||||||
{event.showWithoutTime ? (
|
{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">
|
<div className="text-muted-foreground">
|
||||||
{formatTime(startDate)} – {formatTime(endDate)}
|
{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -439,7 +437,7 @@ export function EventDetailPopover({
|
|||||||
<span className="truncate text-foreground">
|
<span className="truncate text-foreground">
|
||||||
{p.name || p.email}
|
{p.name || p.email}
|
||||||
{p.isOrganizer && (
|
{p.isOrganizer && (
|
||||||
<span className="text-muted-foreground ml-1">
|
<span className="text-muted-foreground ms-1">
|
||||||
({t("participants.organizer").toLowerCase()})
|
({t("participants.organizer").toLowerCase()})
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -517,7 +515,7 @@ export function EventDetailPopover({
|
|||||||
disabled={!noteText.trim() || isSavingNote}
|
disabled={!noteText.trim() || isSavingNote}
|
||||||
className="h-7 text-xs"
|
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")}
|
{t("detail.save_note")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -551,7 +549,7 @@ export function EventDetailPopover({
|
|||||||
: "text-success border-success/30 hover:bg-success/10"
|
: "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")}
|
{t("participants.accepted")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -564,7 +562,7 @@ export function EventDetailPopover({
|
|||||||
: "border border-warning/30 text-warning hover:bg-warning/10"
|
: "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")}
|
{t("participants.tentative")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -577,7 +575,7 @@ export function EventDetailPopover({
|
|||||||
: "text-destructive hover:bg-destructive/10"
|
: "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")}
|
{t("participants.declined")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -611,7 +609,7 @@ export function EventDetailPopover({
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Button variant="default" size="sm" onClick={onEdit} className="h-7 text-xs">
|
<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")}
|
{t("events.edit")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -621,7 +619,7 @@ export function EventDetailPopover({
|
|||||||
className="h-7 text-xs"
|
className="h-7 text-xs"
|
||||||
title={t("events.duplicate")}
|
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")}
|
{t("events.duplicate")}
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations, useLocale } from "next-intl";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, Plus } from "lucide-react";
|
import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, Plus } from "lucide-react";
|
||||||
import { format, parseISO, addHours, addDays, isSameDay } from "date-fns";
|
import { format, parseISO, addHours, addDays, isSameDay } from "date-fns";
|
||||||
import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert, CalendarRecurrenceRule } from "@/lib/jmap/types";
|
||||||
|
import { RecurrenceEditor, buildRecurrenceSummary, isSimpleRecurrenceRule } from "./recurrence-editor";
|
||||||
import { parseDuration, getEventColor } from "./event-card";
|
import { parseDuration, getEventColor } from "./event-card";
|
||||||
import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
||||||
import { ParticipantInput, type ParticipantInputHandle } from "./participant-input";
|
import { ParticipantInput, type ParticipantInputHandle } from "./participant-input";
|
||||||
@@ -75,7 +76,7 @@ function buildDuration(startDate: Date, endDate: Date): string {
|
|||||||
return dur;
|
return dur;
|
||||||
}
|
}
|
||||||
|
|
||||||
type RecurrenceOption = "none" | "daily" | "weekly" | "monthly" | "yearly";
|
type RecurrenceOption = "none" | "daily" | "weekly" | "monthly" | "yearly" | "custom";
|
||||||
|
|
||||||
type AlertUnit = "at_time" | "minutes" | "hours" | "days" | "weeks";
|
type AlertUnit = "at_time" | "minutes" | "hours" | "days" | "weeks";
|
||||||
|
|
||||||
@@ -157,16 +158,9 @@ function getAlertLabel(event: CalendarEvent, t: ReturnType<typeof useTranslation
|
|||||||
return labels.join(", ");
|
return labels.join(", ");
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRecurrenceLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>): string | null {
|
function getRecurrenceLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>, locale: string): string | null {
|
||||||
if (!event.recurrenceRules?.length) return null;
|
if (!event.recurrenceRules?.length) return null;
|
||||||
const freq = event.recurrenceRules[0].frequency;
|
return buildRecurrenceSummary(event.recurrenceRules[0], t, locale);
|
||||||
const labels: Record<string, string> = {
|
|
||||||
daily: t("recurrence.daily"),
|
|
||||||
weekly: t("recurrence.weekly"),
|
|
||||||
monthly: t("recurrence.monthly"),
|
|
||||||
yearly: t("recurrence.yearly"),
|
|
||||||
};
|
|
||||||
return labels[freq] || null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EventModal({
|
export function EventModal({
|
||||||
@@ -186,6 +180,7 @@ export function EventModal({
|
|||||||
isMobile = false,
|
isMobile = false,
|
||||||
}: EventModalProps) {
|
}: EventModalProps) {
|
||||||
const t = useTranslations("calendar");
|
const t = useTranslations("calendar");
|
||||||
|
const locale = useLocale();
|
||||||
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
||||||
const timeDisplayFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
|
const timeDisplayFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
|
||||||
const isEdit = !!event;
|
const isEdit = !!event;
|
||||||
@@ -270,8 +265,35 @@ export function EventModal({
|
|||||||
});
|
});
|
||||||
const [recurrence, setRecurrence] = useState<RecurrenceOption>(() => {
|
const [recurrence, setRecurrence] = useState<RecurrenceOption>(() => {
|
||||||
if (!event?.recurrenceRules?.length) return "none";
|
if (!event?.recurrenceRules?.length) return "none";
|
||||||
return event.recurrenceRules[0].frequency as RecurrenceOption;
|
const rule = event.recurrenceRules[0];
|
||||||
|
return isSimpleRecurrenceRule(rule) ? (rule.frequency as RecurrenceOption) : "custom";
|
||||||
});
|
});
|
||||||
|
const [customRule, setCustomRule] = useState<CalendarRecurrenceRule | null>(() => {
|
||||||
|
if (!event?.recurrenceRules?.length) return null;
|
||||||
|
const rule = event.recurrenceRules[0];
|
||||||
|
return isSimpleRecurrenceRule(rule) ? null : rule;
|
||||||
|
});
|
||||||
|
const [showRecurrenceEditor, setShowRecurrenceEditor] = useState(false);
|
||||||
|
// Dropdown value to restore when the custom editor is cancelled without a saved rule.
|
||||||
|
const recurrenceBeforeCustomRef = useRef<RecurrenceOption>("none");
|
||||||
|
|
||||||
|
const handleRecurrenceEditorSave = useCallback((rule: CalendarRecurrenceRule) => {
|
||||||
|
setCustomRule(rule);
|
||||||
|
setRecurrence("custom");
|
||||||
|
setShowRecurrenceEditor(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRecurrenceEditorCancel = useCallback(() => {
|
||||||
|
setShowRecurrenceEditor(false);
|
||||||
|
if (!customRule) {
|
||||||
|
setRecurrence(recurrenceBeforeCustomRef.current);
|
||||||
|
}
|
||||||
|
}, [customRule]);
|
||||||
|
|
||||||
|
const customRuleSummary = useMemo(
|
||||||
|
() => (customRule ? buildRecurrenceSummary(customRule, t, locale) : null),
|
||||||
|
[customRule, t, locale]
|
||||||
|
);
|
||||||
const preservedAlertsRef = useRef<Record<string, CalendarEventAlert>>({});
|
const preservedAlertsRef = useRef<Record<string, CalendarEventAlert>>({});
|
||||||
const [alertRows, setAlertRows] = useState<AlertRow[]>(() => {
|
const [alertRows, setAlertRows] = useState<AlertRow[]>(() => {
|
||||||
if (!event?.alerts) return [];
|
if (!event?.alerts) return [];
|
||||||
@@ -444,7 +466,9 @@ export function EventModal({
|
|||||||
data.virtualLocations = null;
|
data.virtualLocations = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (recurrence !== "none") {
|
if (recurrence === "custom" && customRule) {
|
||||||
|
data.recurrenceRules = [customRule];
|
||||||
|
} else if (recurrence !== "none" && recurrence !== "custom") {
|
||||||
data.recurrenceRules = [{
|
data.recurrenceRules = [{
|
||||||
"@type": "RecurrenceRule",
|
"@type": "RecurrenceRule",
|
||||||
frequency: recurrence,
|
frequency: recurrence,
|
||||||
@@ -498,10 +522,16 @@ export function EventModal({
|
|||||||
{ name: organizerName, email: organizerEmail },
|
{ name: organizerName, email: organizerEmail },
|
||||||
effectiveAttendees
|
effectiveAttendees
|
||||||
) as Record<string, CalendarParticipant>;
|
) 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) {
|
} else if (effectiveAttendees.length === 0 && event?.participants) {
|
||||||
data.participants = null;
|
data.participants = null;
|
||||||
|
// Also clear the retired replyTo that older releases (<= 1.7.6) wrote.
|
||||||
data.replyTo = null;
|
data.replyTo = null;
|
||||||
|
data.organizerCalendarAddress = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const shouldSendScheduling = effectiveAttendees.length > 0 && sendInvitations;
|
const shouldSendScheduling = effectiveAttendees.length > 0 && sendInvitations;
|
||||||
@@ -511,7 +541,7 @@ export function EventModal({
|
|||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
}, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, alertRows, attendees, sendInvitations, currentUserEmails, existingParticipants, event, onSave, isSaving]);
|
}, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, customRule, alertRows, attendees, sendInvitations, currentUserEmails, existingParticipants, event, onSave, isSaving]);
|
||||||
|
|
||||||
const handleRsvp = useCallback((status: CalendarParticipant['participationStatus']) => {
|
const handleRsvp = useCallback((status: CalendarParticipant['participationStatus']) => {
|
||||||
if (!event || !userParticipantId || !onRsvp) return;
|
if (!event || !userParticipantId || !onRsvp) return;
|
||||||
@@ -593,7 +623,6 @@ export function EventModal({
|
|||||||
|
|
||||||
if (isAttendeeMode && event) {
|
if (isAttendeeMode && event) {
|
||||||
const startD = getEventStartDate(event);
|
const startD = getEventStartDate(event);
|
||||||
const durMin = parseDuration(event.duration);
|
|
||||||
const endD = getEventEndDate(event);
|
const endD = getEventEndDate(event);
|
||||||
const locationName = event.locations ? Object.values(event.locations)[0]?.name : null;
|
const locationName = event.locations ? Object.values(event.locations)[0]?.name : null;
|
||||||
const participants = getParticipantList(event);
|
const participants = getParticipantList(event);
|
||||||
@@ -637,11 +666,11 @@ export function EventModal({
|
|||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium">{formatEventDate(startD)}</span>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium">{formatEventDate(endD)}</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -650,7 +679,7 @@ export function EventModal({
|
|||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
<span className="font-medium">{formatEventDate(startD)}</span>
|
<span className="font-medium">{formatEventDate(startD)}</span>
|
||||||
{!event.showWithoutTime && (
|
{!event.showWithoutTime && (
|
||||||
<span className="text-muted-foreground ml-2">
|
<span className="text-muted-foreground ms-2">
|
||||||
{format(startD, timeDisplayFmt)} – {format(endD, timeDisplayFmt)}
|
{format(startD, timeDisplayFmt)} – {format(endD, timeDisplayFmt)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -672,7 +701,7 @@ export function EventModal({
|
|||||||
<Users className="w-4 h-4" />
|
<Users className="w-4 h-4" />
|
||||||
{t("participants.title")}
|
{t("participants.title")}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1 pl-5">
|
<div className="space-y-1 ps-5">
|
||||||
{participants.map(p => (
|
{participants.map(p => (
|
||||||
<div key={p.id} className="flex items-center justify-between text-sm">
|
<div key={p.id} className="flex items-center justify-between text-sm">
|
||||||
<span className="truncate">{p.name || p.email}</span>
|
<span className="truncate">{p.name || p.email}</span>
|
||||||
@@ -697,7 +726,7 @@ export function EventModal({
|
|||||||
? "bg-success hover:bg-success/80 text-success-foreground"
|
? "bg-success hover:bg-success/80 text-success-foreground"
|
||||||
: "text-success border-success/30 hover:bg-success/10"}
|
: "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")}
|
{t("participants.accepted")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -708,7 +737,7 @@ export function EventModal({
|
|||||||
? "bg-warning hover:bg-warning/80 text-warning-foreground"
|
? "bg-warning hover:bg-warning/80 text-warning-foreground"
|
||||||
: "border border-warning/30 text-warning hover:bg-warning/10"}
|
: "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")}
|
{t("participants.tentative")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -719,7 +748,7 @@ export function EventModal({
|
|||||||
? "bg-destructive hover:bg-destructive/80 text-destructive-foreground"
|
? "bg-destructive hover:bg-destructive/80 text-destructive-foreground"
|
||||||
: "text-destructive hover:bg-destructive/10"}
|
: "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")}
|
{t("participants.declined")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -737,7 +766,7 @@ export function EventModal({
|
|||||||
const locationName = event.locations ? Object.values(event.locations)[0]?.name || null : null;
|
const locationName = event.locations ? Object.values(event.locations)[0]?.name || null : null;
|
||||||
const virtualLoc = event.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri || null : null;
|
const virtualLoc = event.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri || null : null;
|
||||||
const viewParticipants = getParticipantList(event);
|
const viewParticipants = getParticipantList(event);
|
||||||
const recurrenceLabel = getRecurrenceLabel(event, t);
|
const recurrenceLabel = getRecurrenceLabel(event, t, locale);
|
||||||
const alertLabel = getAlertLabel(event, t);
|
const alertLabel = getAlertLabel(event, t);
|
||||||
const eventCalendar = calendars.find(c => event.calendarIds[c.id]);
|
const eventCalendar = calendars.find(c => event.calendarIds[c.id]);
|
||||||
const color = getEventColor(event, eventCalendar);
|
const color = getEventColor(event, eventCalendar);
|
||||||
@@ -755,7 +784,7 @@ export function EventModal({
|
|||||||
<h2 className="text-lg font-semibold truncate">{event.title || t("events.no_title")}</h2>
|
<h2 className="text-lg font-semibold truncate">{event.title || t("events.no_title")}</h2>
|
||||||
</div>
|
</div>
|
||||||
{eventCalendar && (
|
{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>
|
</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")}>
|
<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")}>
|
||||||
@@ -787,13 +816,13 @@ export function EventModal({
|
|||||||
<>
|
<>
|
||||||
<div className="font-medium text-foreground">
|
<div className="font-medium text-foreground">
|
||||||
{formatEventDate(startD)}
|
{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)}
|
{format(startD, timeDisplayFmt)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="font-medium text-foreground">
|
<div className="font-medium text-foreground">
|
||||||
{formatEventDate(endD)}
|
{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)}
|
{format(endD, timeDisplayFmt)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -809,11 +838,11 @@ export function EventModal({
|
|||||||
{formatEventDate(startD)}
|
{formatEventDate(startD)}
|
||||||
</span>
|
</span>
|
||||||
{event.showWithoutTime ? (
|
{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">
|
<div className="text-muted-foreground">
|
||||||
{format(startD, timeDisplayFmt)} – {format(endD, timeDisplayFmt)}
|
{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -860,7 +889,7 @@ export function EventModal({
|
|||||||
<span className="truncate text-foreground">
|
<span className="truncate text-foreground">
|
||||||
{p.name || p.email}
|
{p.name || p.email}
|
||||||
{p.isOrganizer && (
|
{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>
|
</span>
|
||||||
<StatusBadge status={p.status} isOrganizer={p.isOrganizer} t={t} />
|
<StatusBadge status={p.status} isOrganizer={p.isOrganizer} t={t} />
|
||||||
@@ -913,21 +942,21 @@ export function EventModal({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Button variant="ghost" size="sm" onClick={() => setShowDeleteConfirm(true)} className="text-destructive">
|
<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")}
|
{t("events.delete")}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
{onDuplicate && !showDeleteConfirm && (
|
{onDuplicate && !showDeleteConfirm && (
|
||||||
<Button variant="ghost" size="sm" onClick={handleDuplicate} aria-label={t("events.duplicate")}>
|
<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")}
|
{t("events.duplicate")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!showDeleteConfirm && (
|
{!showDeleteConfirm && (
|
||||||
<Button onClick={() => setMode("edit")}>
|
<Button onClick={() => setMode("edit")}>
|
||||||
<Pencil className="w-4 h-4 mr-1" />
|
<Pencil className="w-4 h-4 me-1" />
|
||||||
{t("events.edit")}
|
{t("events.edit")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -1131,17 +1160,51 @@ export function EventModal({
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium mb-1 block">{t("recurrence.title")}</label>
|
<label className="text-sm font-medium mb-1 block">{t("recurrence.title")}</label>
|
||||||
<select
|
<div className="flex items-center gap-2">
|
||||||
value={recurrence}
|
<select
|
||||||
onChange={(e) => setRecurrence(e.target.value as RecurrenceOption)}
|
value={recurrence}
|
||||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
onChange={(e) => {
|
||||||
>
|
const value = e.target.value as RecurrenceOption;
|
||||||
<option value="none">{t("recurrence.none")}</option>
|
if (value === "custom") {
|
||||||
<option value="daily">{t("recurrence.daily")}</option>
|
recurrenceBeforeCustomRef.current = recurrence;
|
||||||
<option value="weekly">{t("recurrence.weekly")}</option>
|
setRecurrence("custom");
|
||||||
<option value="monthly">{t("recurrence.monthly")}</option>
|
setShowRecurrenceEditor(true);
|
||||||
<option value="yearly">{t("recurrence.yearly")}</option>
|
} else {
|
||||||
</select>
|
setRecurrence(value);
|
||||||
|
setShowRecurrenceEditor(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="flex-1 min-w-0 rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
>
|
||||||
|
<option value="none">{t("recurrence.none")}</option>
|
||||||
|
<option value="daily">{t("recurrence.daily")}</option>
|
||||||
|
<option value="weekly">{t("recurrence.weekly")}</option>
|
||||||
|
<option value="monthly">{t("recurrence.monthly")}</option>
|
||||||
|
<option value="yearly">{t("recurrence.yearly")}</option>
|
||||||
|
<option value="custom">{customRuleSummary || t("recurrence.custom")}</option>
|
||||||
|
</select>
|
||||||
|
{recurrence === "custom" && !showRecurrenceEditor && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowRecurrenceEditor(true)}
|
||||||
|
className="p-2 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||||
|
aria-label={t("recurrence.edit_custom")}
|
||||||
|
>
|
||||||
|
<Pencil className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{showRecurrenceEditor && (
|
||||||
|
<RecurrenceEditor
|
||||||
|
rule={customRule}
|
||||||
|
eventStart={(() => {
|
||||||
|
const d = new Date(`${startDate}T${allDay ? "00:00" : (startTime || "00:00")}:00`);
|
||||||
|
return isNaN(d.getTime()) ? new Date() : d;
|
||||||
|
})()}
|
||||||
|
onSave={handleRecurrenceEditorSave}
|
||||||
|
onCancel={handleRecurrenceEditorCancel}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -1257,7 +1320,7 @@ export function EventModal({
|
|||||||
onClick={() => setShowDeleteConfirm(true)}
|
onClick={() => setShowDeleteConfirm(true)}
|
||||||
className="text-red-600 dark:text-red-400"
|
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")}
|
{t("events.delete")}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
@@ -1269,7 +1332,7 @@ export function EventModal({
|
|||||||
onClick={handleDuplicate}
|
onClick={handleDuplicate}
|
||||||
aria-label={t("events.duplicate")}
|
aria-label={t("events.duplicate")}
|
||||||
>
|
>
|
||||||
<Copy className="w-4 h-4 mr-1" />
|
<Copy className="w-4 h-4 me-1" />
|
||||||
{t("events.duplicate")}
|
{t("events.duplicate")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useState, useCallback, useRef, useEffect } from "react";
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { X, Upload, Check, Loader2, RefreshCw, Globe } from "lucide-react";
|
import { X, Upload, Check, Loader2, RefreshCw, Globe } from "lucide-react";
|
||||||
import { format, parseISO } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||||
import { getEventStartDate } from "@/lib/calendar-utils";
|
import { getEventStartDate } from "@/lib/calendar-utils";
|
||||||
@@ -444,7 +444,7 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal
|
|||||||
onClick={handleImport}
|
onClick={handleImport}
|
||||||
disabled={selectedIndices.size === 0}
|
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})
|
{t("import_button")} ({selectedIndices.size})
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -208,7 +208,7 @@ export function ICalSubscriptionModal({ client, onClose, editSubscription, initi
|
|||||||
<Button onClick={handleSubmit} disabled={!isValid || isSubmitting}>
|
<Button onClick={handleSubmit} disabled={!isValid || isSubmitting}>
|
||||||
{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")}
|
{isEdit ? t("saving") : t("subscribing")}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,25 +1,19 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useMemo, Fragment } from "react";
|
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 { ChevronLeft, ChevronRight, ChevronDown } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
|
|
||||||
addMonths, subMonths, addYears, subYears, setMonth, setYear,
|
addMonths, subMonths, addYears, subYears, setMonth, setYear,
|
||||||
eachDayOfInterval, getMonth, getYear, getISOWeek, getWeek,
|
getISOWeek, getWeek, format,
|
||||||
isSameDay, isSameMonth, isToday, format,
|
|
||||||
} from "date-fns";
|
} from "date-fns";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { getEventDayBounds } from "@/lib/calendar-utils";
|
import { getEventDayBounds } from "@/lib/calendar-utils";
|
||||||
import type { CalendarEvent } from "@/lib/jmap/types";
|
import type { CalendarEvent } from "@/lib/jmap/types";
|
||||||
|
import { useCalendarLocale } from "@/hooks/use-calendar-locale";
|
||||||
|
|
||||||
type PickerView = "days" | "months" | "years";
|
type PickerView = "days" | "months" | "years";
|
||||||
|
|
||||||
const MONTH_LABELS = [
|
|
||||||
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
|
||||||
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
|
||||||
];
|
|
||||||
|
|
||||||
interface MiniCalendarProps {
|
interface MiniCalendarProps {
|
||||||
selectedDate: Date;
|
selectedDate: Date;
|
||||||
displayMonth: Date;
|
displayMonth: Date;
|
||||||
@@ -40,17 +34,25 @@ export function MiniCalendar({
|
|||||||
showWeekNumbers = false,
|
showWeekNumbers = false,
|
||||||
}: MiniCalendarProps) {
|
}: MiniCalendarProps) {
|
||||||
const t = useTranslations("calendar");
|
const t = useTranslations("calendar");
|
||||||
const intlFormatter = useFormatter();
|
const {
|
||||||
const weekStart = (firstDayOfWeek === 0 ? 0 : 1) as 0 | 1;
|
weekStartsOn,
|
||||||
|
dayHeaderKeys,
|
||||||
|
getMonthGridDays,
|
||||||
|
checkIsToday,
|
||||||
|
checkIsSameMonth,
|
||||||
|
checkIsSameDay,
|
||||||
|
formatDayNumber,
|
||||||
|
formatMonthYear,
|
||||||
|
getMonth,
|
||||||
|
getYear,
|
||||||
|
monthLabelKeys,
|
||||||
|
} = useCalendarLocale();
|
||||||
const [pickerView, setPickerView] = useState<PickerView>("days");
|
const [pickerView, setPickerView] = useState<PickerView>("days");
|
||||||
|
|
||||||
const days = useMemo(() => {
|
const days = useMemo(
|
||||||
const monthStart = startOfMonth(displayMonth);
|
() => getMonthGridDays(displayMonth),
|
||||||
const monthEnd = endOfMonth(displayMonth);
|
[displayMonth, getMonthGridDays],
|
||||||
const gridStart = startOfWeek(monthStart, { weekStartsOn: weekStart });
|
);
|
||||||
const gridEnd = endOfWeek(monthEnd, { weekStartsOn: weekStart });
|
|
||||||
return eachDayOfInterval({ start: gridStart, end: gridEnd });
|
|
||||||
}, [displayMonth, weekStart]);
|
|
||||||
|
|
||||||
const eventDates = useMemo(() => {
|
const eventDates = useMemo(() => {
|
||||||
const set = new Set<string>();
|
const set = new Set<string>();
|
||||||
@@ -67,20 +69,16 @@ export function MiniCalendar({
|
|||||||
return set;
|
return set;
|
||||||
}, [events]);
|
}, [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)
|
// Compute week numbers for each row (one per 7-day chunk)
|
||||||
const weekNumbers = useMemo(() => {
|
const weekNumbers = useMemo(() => {
|
||||||
if (!showWeekNumbers) return [];
|
if (!showWeekNumbers) return [];
|
||||||
const nums: number[] = [];
|
const nums: number[] = [];
|
||||||
for (let i = 0; i < days.length; i += 7) {
|
for (let i = 0; i < days.length; i += 7) {
|
||||||
// Use the first day of each row to determine the week number
|
// 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;
|
return nums;
|
||||||
}, [days, showWeekNumbers, weekStart]);
|
}, [days, showWeekNumbers, weekStartsOn]);
|
||||||
|
|
||||||
const currentYear = getYear(displayMonth);
|
const currentYear = getYear(displayMonth);
|
||||||
const currentMonth = getMonth(displayMonth);
|
const currentMonth = getMonth(displayMonth);
|
||||||
@@ -116,7 +114,7 @@ export function MiniCalendar({
|
|||||||
|
|
||||||
const headerLabel =
|
const headerLabel =
|
||||||
pickerView === "days"
|
pickerView === "days"
|
||||||
? intlFormatter.dateTime(displayMonth, { month: "long", year: "numeric" })
|
? formatMonthYear(displayMonth)
|
||||||
: pickerView === "months"
|
: pickerView === "months"
|
||||||
? String(currentYear)
|
? String(currentYear)
|
||||||
: `${decadeStart}\u2013${decadeStart + 9}`;
|
: `${decadeStart}\u2013${decadeStart + 9}`;
|
||||||
@@ -160,15 +158,15 @@ export function MiniCalendar({
|
|||||||
{showWeekNumbers && (
|
{showWeekNumbers && (
|
||||||
<div className="text-center text-[10px] font-medium text-muted-foreground py-1 w-5" />
|
<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">
|
<div key={d} className="text-center text-[10px] font-medium text-muted-foreground py-1">
|
||||||
{t(`days.${d}`)}
|
{t(`days.${d}`)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{days.map((day, index) => {
|
{days.map((day, index) => {
|
||||||
const inMonth = isSameMonth(day, displayMonth);
|
const inMonth = checkIsSameMonth(day, displayMonth);
|
||||||
const selected = isSameDay(day, selectedDate);
|
const selected = checkIsSameDay(day, selectedDate);
|
||||||
const today = isToday(day);
|
const today = checkIsToday(day);
|
||||||
const hasEvent = eventDates.has(format(day, "yyyy-MM-dd"));
|
const hasEvent = eventDates.has(format(day, "yyyy-MM-dd"));
|
||||||
const isFirstDayOfRow = index % 7 === 0;
|
const isFirstDayOfRow = index % 7 === 0;
|
||||||
|
|
||||||
@@ -193,7 +191,7 @@ export function MiniCalendar({
|
|||||||
selected && "bg-primary text-primary-foreground"
|
selected && "bg-primary text-primary-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{format(day, "d")}
|
{formatDayNumber(day)}
|
||||||
{hasEvent && !selected && (
|
{hasEvent && !selected && (
|
||||||
<span className="absolute bottom-0.5 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-primary" />
|
<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" && (
|
{pickerView === "months" && (
|
||||||
<div className="grid grid-cols-3 gap-1 py-1">
|
<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 isCurrentMonth = i === currentMonth && currentYear === getYear(new Date());
|
||||||
const isSelected = i === getMonth(selectedDate) && currentYear === getYear(selectedDate);
|
const isSelected = i === getMonth(selectedDate) && currentYear === getYear(selectedDate);
|
||||||
return (
|
return (
|
||||||
@@ -220,7 +218,7 @@ export function MiniCalendar({
|
|||||||
!isSelected && !isCurrentMonth && "hover:bg-muted"
|
!isSelected && !isCurrentMonth && "hover:bg-muted"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{label}
|
{t(`months.${labelKey}`)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState, useRef, useCallback, useEffect, forwardRef, useImperativeHand
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Avatar } from "@/components/ui/avatar";
|
||||||
import { useContactStore } from "@/stores/contact-store";
|
import { useContactStore } from "@/stores/contact-store";
|
||||||
|
|
||||||
interface Participant {
|
interface Participant {
|
||||||
@@ -144,14 +145,17 @@ export const ParticipantInput = forwardRef<ParticipantInputHandle, ParticipantIn
|
|||||||
role="option"
|
role="option"
|
||||||
aria-selected={i === activeIndex}
|
aria-selected={i === activeIndex}
|
||||||
onMouseDown={(e) => { e.preventDefault(); addParticipant(s); }}
|
onMouseDown={(e) => { e.preventDefault(); addParticipant(s); }}
|
||||||
className={`px-3 py-2 text-sm cursor-pointer transition-colors ${
|
className={`px-3 py-2 text-sm cursor-pointer transition-colors flex items-center gap-2 ${
|
||||||
i === activeIndex ? "bg-accent text-accent-foreground" : "hover:bg-muted"
|
i === activeIndex ? "bg-accent text-accent-foreground" : "hover:bg-muted"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="font-medium truncate">{s.name || s.email}</div>
|
<Avatar name={s.name} email={s.email} size="sm" className="shrink-0 w-7 h-7 text-[10px]" />
|
||||||
{s.name && (
|
<div className="min-w-0">
|
||||||
<div className="text-xs text-muted-foreground truncate">{s.email}</div>
|
<div className="font-medium truncate">{s.name || s.email}</div>
|
||||||
)}
|
{s.name && (
|
||||||
|
<div className="text-xs text-muted-foreground truncate">{s.email}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -0,0 +1,427 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useLocale, useTranslations } from "next-intl";
|
||||||
|
import { addYears, format } from "date-fns";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import type { CalendarRecurrenceRule } from "@/lib/jmap/types";
|
||||||
|
|
||||||
|
type EditorFrequency = "daily" | "weekly" | "monthly" | "yearly";
|
||||||
|
type MonthlyMode = "day" | "nth";
|
||||||
|
type EndsMode = "never" | "on" | "after";
|
||||||
|
|
||||||
|
const EDITOR_FREQUENCIES: EditorFrequency[] = ["daily", "weekly", "monthly", "yearly"];
|
||||||
|
|
||||||
|
// 2024-01-01 is a Monday - used to render localized weekday names via Intl.
|
||||||
|
const WEEKDAYS: string[] = ["mo", "tu", "we", "th", "fr", "sa", "su"];
|
||||||
|
const DAY_TO_REF_DATE: Record<string, number> = { mo: 1, tu: 2, we: 3, th: 4, fr: 5, sa: 6, su: 7 };
|
||||||
|
const INDEX_TO_DAY = ["su", "mo", "tu", "we", "th", "fr", "sa"];
|
||||||
|
|
||||||
|
const UNIT_LABEL_KEYS: Record<EditorFrequency, string> = {
|
||||||
|
daily: "recurrence.editor_unit_days",
|
||||||
|
weekly: "recurrence.editor_unit_weeks",
|
||||||
|
monthly: "recurrence.editor_unit_months",
|
||||||
|
yearly: "recurrence.editor_unit_years",
|
||||||
|
};
|
||||||
|
|
||||||
|
type CalendarT = ReturnType<typeof useTranslations>;
|
||||||
|
|
||||||
|
function weekdayName(day: string, locale: string, style: "long" | "short" = "long"): string {
|
||||||
|
const ref = new Date(2024, 0, DAY_TO_REF_DATE[day] ?? 1);
|
||||||
|
return new Intl.DateTimeFormat(locale, { weekday: style }).format(ref);
|
||||||
|
}
|
||||||
|
|
||||||
|
function monthName(month: number, locale: string): string {
|
||||||
|
return new Intl.DateTimeFormat(locale, { month: "long" }).format(new Date(2024, month - 1, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
function nthLabel(nth: number, t: CalendarT): string {
|
||||||
|
if (nth === -1) return t("recurrence.nth_last");
|
||||||
|
if (nth >= 1 && nth <= 4) return t(`recurrence.nth_${nth}`);
|
||||||
|
return String(nth);
|
||||||
|
}
|
||||||
|
|
||||||
|
function capitalize(s: string): string {
|
||||||
|
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract an "nth weekday" pattern from a rule, accepting both the
|
||||||
|
* byDay+nthOfPeriod encoding and the byDay+bySetPosition encoding.
|
||||||
|
*/
|
||||||
|
function getNthDay(rule: CalendarRecurrenceRule): { day: string; nth: number } | null {
|
||||||
|
if (rule.byDay?.length === 1) {
|
||||||
|
const nd = rule.byDay[0];
|
||||||
|
if (nd.nthOfPeriod) return { day: nd.day, nth: nd.nthOfPeriod };
|
||||||
|
if (rule.bySetPosition?.length === 1) return { day: nd.day, nth: rule.bySetPosition[0] };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when the rule is exactly what the plain Daily/Weekly/Monthly/Yearly
|
||||||
|
* dropdown presets produce, i.e. it needs no custom editor to represent.
|
||||||
|
*/
|
||||||
|
export function isSimpleRecurrenceRule(rule: CalendarRecurrenceRule): boolean {
|
||||||
|
return (
|
||||||
|
(EDITOR_FREQUENCIES as string[]).includes(rule.frequency) &&
|
||||||
|
(!rule.interval || rule.interval === 1) &&
|
||||||
|
!rule.byDay?.length &&
|
||||||
|
!rule.byMonthDay?.length &&
|
||||||
|
!rule.byMonth?.length &&
|
||||||
|
!rule.byYearDay?.length &&
|
||||||
|
!rule.byWeekNo?.length &&
|
||||||
|
!rule.bySetPosition?.length &&
|
||||||
|
!rule.count &&
|
||||||
|
!rule.until
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Human-readable summary of a recurrence rule, e.g.
|
||||||
|
* "Every 2 months on the third Thursday · 12 occurrences".
|
||||||
|
* Returns null for frequencies the UI cannot describe (hourly etc.).
|
||||||
|
*/
|
||||||
|
export function buildRecurrenceSummary(
|
||||||
|
rule: CalendarRecurrenceRule,
|
||||||
|
t: CalendarT,
|
||||||
|
locale: string,
|
||||||
|
): string | null {
|
||||||
|
const interval = rule.interval || 1;
|
||||||
|
let base: string;
|
||||||
|
switch (rule.frequency) {
|
||||||
|
case "daily":
|
||||||
|
base = interval > 1 ? t("recurrence.every_n_days", { count: interval }) : t("recurrence.daily");
|
||||||
|
break;
|
||||||
|
case "weekly":
|
||||||
|
base = interval > 1 ? t("recurrence.every_n_weeks", { count: interval }) : t("recurrence.weekly");
|
||||||
|
break;
|
||||||
|
case "monthly":
|
||||||
|
base = interval > 1 ? t("recurrence.every_n_months", { count: interval }) : t("recurrence.monthly");
|
||||||
|
break;
|
||||||
|
case "yearly":
|
||||||
|
base = interval > 1 ? t("recurrence.every_n_years", { count: interval }) : t("recurrence.yearly");
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = [base];
|
||||||
|
|
||||||
|
if (rule.frequency === "weekly" && rule.byDay?.length) {
|
||||||
|
const days = rule.byDay
|
||||||
|
.filter((d) => WEEKDAYS.includes(d.day))
|
||||||
|
.sort((a, b) => WEEKDAYS.indexOf(a.day) - WEEKDAYS.indexOf(b.day))
|
||||||
|
.map((d) => weekdayName(d.day, locale, "short"))
|
||||||
|
.join(", ");
|
||||||
|
if (days) parts.push(t("recurrence.on_days", { days }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rule.frequency === "monthly" || rule.frequency === "yearly") {
|
||||||
|
if (rule.frequency === "yearly" && rule.byMonth?.length) {
|
||||||
|
const m = parseInt(rule.byMonth[0], 10);
|
||||||
|
if (m >= 1 && m <= 12) parts.push(t("recurrence.in_month", { month: monthName(m, locale) }));
|
||||||
|
}
|
||||||
|
const nthDay = getNthDay(rule);
|
||||||
|
if (nthDay) {
|
||||||
|
parts.push(t("recurrence.on_the_nth", {
|
||||||
|
nth: nthLabel(nthDay.nth, t),
|
||||||
|
day: weekdayName(nthDay.day, locale),
|
||||||
|
}));
|
||||||
|
} else if (rule.byMonthDay?.length) {
|
||||||
|
parts.push(t("recurrence.on_day_n", { day: rule.byMonthDay[0] }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let summary = parts.join(" ");
|
||||||
|
if (rule.count) {
|
||||||
|
summary += ` · ${t("recurrence.occurrences", { count: rule.count })}`;
|
||||||
|
} else if (rule.until) {
|
||||||
|
const d = new Date(rule.until);
|
||||||
|
if (!isNaN(d.getTime())) {
|
||||||
|
summary += ` · ${t("recurrence.until")} ${new Intl.DateTimeFormat(locale, { dateStyle: "medium" }).format(d)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RecurrenceEditorProps {
|
||||||
|
rule: CalendarRecurrenceRule | null;
|
||||||
|
eventStart: Date;
|
||||||
|
onSave: (rule: CalendarRecurrenceRule) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RecurrenceEditor({ rule, eventStart, onSave, onCancel }: RecurrenceEditorProps) {
|
||||||
|
const t = useTranslations("calendar");
|
||||||
|
const locale = useLocale();
|
||||||
|
|
||||||
|
const startDay = INDEX_TO_DAY[eventStart.getDay()];
|
||||||
|
const initialNthDay = rule ? getNthDay(rule) : null;
|
||||||
|
|
||||||
|
const [frequency, setFrequency] = useState<EditorFrequency>(() =>
|
||||||
|
rule && (EDITOR_FREQUENCIES as string[]).includes(rule.frequency)
|
||||||
|
? (rule.frequency as EditorFrequency)
|
||||||
|
: "weekly"
|
||||||
|
);
|
||||||
|
const [interval, setIntervalValue] = useState<number>(rule?.interval || 1);
|
||||||
|
const [weekDays, setWeekDays] = useState<string[]>(() => {
|
||||||
|
if (rule?.frequency === "weekly" && rule.byDay?.length) {
|
||||||
|
const days = rule.byDay.map((d) => d.day).filter((d) => WEEKDAYS.includes(d));
|
||||||
|
if (days.length) return days;
|
||||||
|
}
|
||||||
|
return [startDay];
|
||||||
|
});
|
||||||
|
const [monthlyMode, setMonthlyMode] = useState<MonthlyMode>(initialNthDay ? "nth" : "day");
|
||||||
|
const [monthDay, setMonthDay] = useState<number>(() => {
|
||||||
|
const md = rule?.byMonthDay?.[0];
|
||||||
|
return md && md >= 1 && md <= 31 ? md : eventStart.getDate();
|
||||||
|
});
|
||||||
|
const [nth, setNth] = useState<number>(() => {
|
||||||
|
if (initialNthDay && (initialNthDay.nth === -1 || (initialNthDay.nth >= 1 && initialNthDay.nth <= 4))) {
|
||||||
|
return initialNthDay.nth;
|
||||||
|
}
|
||||||
|
return Math.min(4, Math.floor((eventStart.getDate() - 1) / 7) + 1);
|
||||||
|
});
|
||||||
|
const [nthDay, setNthDay] = useState<string>(() =>
|
||||||
|
initialNthDay && WEEKDAYS.includes(initialNthDay.day) ? initialNthDay.day : startDay
|
||||||
|
);
|
||||||
|
const [month, setMonth] = useState<number>(() => {
|
||||||
|
const m = rule?.byMonth?.length ? parseInt(rule.byMonth[0], 10) : NaN;
|
||||||
|
return m >= 1 && m <= 12 ? m : eventStart.getMonth() + 1;
|
||||||
|
});
|
||||||
|
const [endsMode, setEndsMode] = useState<EndsMode>(rule?.count ? "after" : rule?.until ? "on" : "never");
|
||||||
|
const [untilDate, setUntilDate] = useState<string>(() => {
|
||||||
|
if (rule?.until) {
|
||||||
|
const d = new Date(rule.until);
|
||||||
|
if (!isNaN(d.getTime())) return format(d, "yyyy-MM-dd");
|
||||||
|
}
|
||||||
|
return format(addYears(eventStart, 1), "yyyy-MM-dd");
|
||||||
|
});
|
||||||
|
const [count, setCount] = useState<number>(rule?.count ?? 12);
|
||||||
|
|
||||||
|
const toggleWeekDay = (day: string) => {
|
||||||
|
setWeekDays((prev) =>
|
||||||
|
prev.includes(day)
|
||||||
|
? prev.length > 1 ? prev.filter((d) => d !== day) : prev
|
||||||
|
: [...prev, day]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildRule = (): CalendarRecurrenceRule => {
|
||||||
|
const built: CalendarRecurrenceRule = {
|
||||||
|
"@type": "RecurrenceRule",
|
||||||
|
frequency,
|
||||||
|
interval: Math.max(1, interval),
|
||||||
|
rscale: "gregorian",
|
||||||
|
skip: "omit",
|
||||||
|
firstDayOfWeek: "mo",
|
||||||
|
byDay: null,
|
||||||
|
byMonthDay: null,
|
||||||
|
byMonth: null,
|
||||||
|
byYearDay: null,
|
||||||
|
byWeekNo: null,
|
||||||
|
byHour: null,
|
||||||
|
byMinute: null,
|
||||||
|
bySecond: null,
|
||||||
|
bySetPosition: null,
|
||||||
|
count: endsMode === "after" ? Math.max(1, count) : null,
|
||||||
|
until: endsMode === "on" && untilDate ? `${untilDate}T23:59:59` : null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (frequency === "weekly") {
|
||||||
|
const days = weekDays.length ? weekDays : [startDay];
|
||||||
|
built.byDay = WEEKDAYS.filter((d) => days.includes(d)).map((day) => ({ day }));
|
||||||
|
} else if (frequency === "monthly" || frequency === "yearly") {
|
||||||
|
if (monthlyMode === "nth") {
|
||||||
|
built.byDay = [{ day: nthDay, nthOfPeriod: nth }];
|
||||||
|
} else {
|
||||||
|
built.byMonthDay = [Math.min(31, Math.max(1, monthDay))];
|
||||||
|
}
|
||||||
|
if (frequency === "yearly") {
|
||||||
|
built.byMonth = [String(month)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return built;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => onSave(buildRule());
|
||||||
|
|
||||||
|
const summary = buildRecurrenceSummary(buildRule(), t, locale);
|
||||||
|
|
||||||
|
const selectCls = "rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-2 rounded-md border border-border bg-muted/20 p-3 space-y-3">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<span className="shrink-0">{t("recurrence.editor_every")}</span>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={999}
|
||||||
|
value={interval}
|
||||||
|
onChange={(e) => {
|
||||||
|
const n = parseInt(e.target.value, 10);
|
||||||
|
setIntervalValue(Number.isFinite(n) ? Math.max(1, n) : 1);
|
||||||
|
}}
|
||||||
|
className="w-16 shrink-0"
|
||||||
|
aria-label={t("recurrence.editor_every")}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={frequency}
|
||||||
|
onChange={(e) => setFrequency(e.target.value as EditorFrequency)}
|
||||||
|
className={`${selectCls} flex-1 min-w-0`}
|
||||||
|
aria-label={t("recurrence.title")}
|
||||||
|
>
|
||||||
|
{EDITOR_FREQUENCIES.map((f) => (
|
||||||
|
<option key={f} value={f}>{t(UNIT_LABEL_KEYS[f])}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{frequency === "weekly" && (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{WEEKDAYS.map((day) => (
|
||||||
|
<button
|
||||||
|
key={day}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleWeekDay(day)}
|
||||||
|
title={weekdayName(day, locale)}
|
||||||
|
aria-pressed={weekDays.includes(day)}
|
||||||
|
className={
|
||||||
|
weekDays.includes(day)
|
||||||
|
? "flex-1 min-w-0 px-1 py-1.5 text-xs font-medium rounded-md border border-primary text-primary bg-primary/10 transition-colors"
|
||||||
|
: "flex-1 min-w-0 px-1 py-1.5 text-xs font-medium rounded-md border border-input text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{weekdayName(day, locale, "short")}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{frequency === "yearly" && (
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<span className="shrink-0">{capitalize(t("recurrence.editor_in"))}</span>
|
||||||
|
<select
|
||||||
|
value={month}
|
||||||
|
onChange={(e) => setMonth(parseInt(e.target.value, 10))}
|
||||||
|
className={`${selectCls} flex-1 min-w-0`}
|
||||||
|
aria-label={t("recurrence.editor_in")}
|
||||||
|
>
|
||||||
|
{Array.from({ length: 12 }, (_, i) => i + 1).map((m) => (
|
||||||
|
<option key={m} value={m}>{capitalize(monthName(m, locale))}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(frequency === "monthly" || frequency === "yearly") && (
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<select
|
||||||
|
value={monthlyMode}
|
||||||
|
onChange={(e) => setMonthlyMode(e.target.value as MonthlyMode)}
|
||||||
|
className={`${selectCls} shrink-0`}
|
||||||
|
aria-label={t("recurrence.editor_repeats_on")}
|
||||||
|
>
|
||||||
|
<option value="day">{capitalize(t("recurrence.editor_on_day"))}</option>
|
||||||
|
<option value="nth">{capitalize(t("recurrence.editor_on_the"))}</option>
|
||||||
|
</select>
|
||||||
|
{monthlyMode === "day" ? (
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={31}
|
||||||
|
value={monthDay}
|
||||||
|
onChange={(e) => {
|
||||||
|
const n = parseInt(e.target.value, 10);
|
||||||
|
setMonthDay(Number.isFinite(n) ? Math.min(31, Math.max(1, n)) : 1);
|
||||||
|
}}
|
||||||
|
className="w-16 shrink-0"
|
||||||
|
aria-label={t("recurrence.editor_on_day")}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<select
|
||||||
|
value={nth}
|
||||||
|
onChange={(e) => setNth(parseInt(e.target.value, 10))}
|
||||||
|
className={`${selectCls} flex-1 min-w-0`}
|
||||||
|
aria-label={t("recurrence.editor_on_the")}
|
||||||
|
>
|
||||||
|
{[1, 2, 3, 4, -1].map((n) => (
|
||||||
|
<option key={n} value={n}>{capitalize(nthLabel(n, t))}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={nthDay}
|
||||||
|
onChange={(e) => setNthDay(e.target.value)}
|
||||||
|
className={`${selectCls} flex-1 min-w-0`}
|
||||||
|
aria-label={t("recurrence.editor_on_the")}
|
||||||
|
>
|
||||||
|
{WEEKDAYS.map((d) => (
|
||||||
|
<option key={d} value={d}>{capitalize(weekdayName(d, locale))}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<span className="shrink-0">{t("recurrence.editor_ends")}</span>
|
||||||
|
<select
|
||||||
|
value={endsMode}
|
||||||
|
onChange={(e) => setEndsMode(e.target.value as EndsMode)}
|
||||||
|
className={`${selectCls} ${endsMode === "never" ? "flex-1" : "shrink-0"} min-w-0`}
|
||||||
|
aria-label={t("recurrence.editor_ends")}
|
||||||
|
>
|
||||||
|
<option value="never">{t("recurrence.editor_never")}</option>
|
||||||
|
<option value="on">{t("recurrence.until")}</option>
|
||||||
|
<option value="after">{t("recurrence.editor_ends_after")}</option>
|
||||||
|
</select>
|
||||||
|
{endsMode === "on" && (
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={untilDate}
|
||||||
|
onChange={(e) => setUntilDate(e.target.value)}
|
||||||
|
className={`${selectCls} flex-1 min-w-0`}
|
||||||
|
aria-label={t("recurrence.editor_ends_on")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{endsMode === "after" && (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={999}
|
||||||
|
value={count}
|
||||||
|
onChange={(e) => {
|
||||||
|
const n = parseInt(e.target.value, 10);
|
||||||
|
setCount(Number.isFinite(n) ? Math.max(1, n) : 1);
|
||||||
|
}}
|
||||||
|
className="w-16 shrink-0"
|
||||||
|
aria-label={t("recurrence.editor_ends_after")}
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground truncate">{t("recurrence.editor_occurrences")}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-3 border-t border-border pt-3">
|
||||||
|
<p className="text-xs text-muted-foreground truncate min-w-0" title={summary ?? undefined}>
|
||||||
|
{summary}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2 shrink-0">
|
||||||
|
<Button variant="outline" size="sm" onClick={onCancel}>
|
||||||
|
{t("form.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={handleSave}>
|
||||||
|
{t("form.save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -302,7 +302,7 @@ export function TaskModal({
|
|||||||
className="text-destructive hover:text-destructive"
|
className="text-destructive hover:text-destructive"
|
||||||
onClick={() => onDelete(task.id)}
|
onClick={() => onDelete(task.id)}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4 mr-1" />
|
<Trash2 className="h-4 w-4 me-1" />
|
||||||
{t("tasks.delete")}
|
{t("tasks.delete")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export function TaskToolbar({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={showCompleted}
|
checked={showCompleted}
|
||||||
@@ -57,7 +57,7 @@ export function TaskToolbar({
|
|||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|
||||||
<Button size="sm" onClick={onCreateTask}>
|
<Button size="sm" onClick={onCreateTask}>
|
||||||
<Plus className="w-4 h-4 mr-1" />
|
<Plus className="w-4 h-4 me-1" />
|
||||||
{t("tasks.create")}
|
{t("tasks.create")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -62,6 +62,27 @@ describe('ContactForm', () => {
|
|||||||
expect(phoneAfter.length).toBe(phoneBefore.length + 1);
|
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 () => {
|
it('submits form data correctly', async () => {
|
||||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||||
render(<ContactForm onSave={onSave} onCancel={vi.fn()} />);
|
render(<ContactForm onSave={onSave} onCancel={vi.fn()} />);
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { fireEvent } from '@testing-library/dom';
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { ContactsSidebar } from '../contacts-sidebar';
|
||||||
|
import type { ContactCard } from '@/lib/jmap/types';
|
||||||
|
|
||||||
|
// next-intl + next/navigation are mocked globally in vitest.setup (t returns the key).
|
||||||
|
vi.mock('@/stores/account-store', () => {
|
||||||
|
const state = { accounts: [], activeAccountId: null };
|
||||||
|
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||||
|
typeof sel === 'function' ? sel(state) : state;
|
||||||
|
hook.getState = () => state;
|
||||||
|
return { useAccountStore: hook };
|
||||||
|
});
|
||||||
|
|
||||||
|
const group = {
|
||||||
|
id: 'g1',
|
||||||
|
kind: 'group',
|
||||||
|
name: { full: 'Team' },
|
||||||
|
members: { '1': true },
|
||||||
|
} as unknown as ContactCard;
|
||||||
|
|
||||||
|
function renderSidebar(onComposeGroup = vi.fn()) {
|
||||||
|
render(
|
||||||
|
<ContactsSidebar
|
||||||
|
groups={[group]}
|
||||||
|
individuals={[]}
|
||||||
|
addressBooks={[]}
|
||||||
|
activeCategory="all"
|
||||||
|
onSelectCategory={vi.fn()}
|
||||||
|
onCreateGroup={vi.fn()}
|
||||||
|
onCreateContact={vi.fn()}
|
||||||
|
onEditGroup={vi.fn()}
|
||||||
|
onDeleteGroup={vi.fn()}
|
||||||
|
onComposeGroup={onComposeGroup}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
return onComposeGroup;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ContactsSidebar — compose to group', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
it('shows a "Send email to group" submenu in the group context menu', () => {
|
||||||
|
renderSidebar();
|
||||||
|
fireEvent.contextMenu(screen.getByText('Team'));
|
||||||
|
expect(screen.getByText('groups.send_email')).toBeInTheDocument();
|
||||||
|
// and the existing Edit/Delete entries still render
|
||||||
|
expect(screen.getByText('groups.edit')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('form.delete')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onComposeGroup(groupId, field) when a To/Cc/Bcc item is clicked', () => {
|
||||||
|
const onComposeGroup = renderSidebar();
|
||||||
|
fireEvent.contextMenu(screen.getByText('Team'));
|
||||||
|
|
||||||
|
// Open the submenu (hover) then click "Cc".
|
||||||
|
const trigger = screen.getByText('groups.send_email').closest('.relative')!;
|
||||||
|
fireEvent.mouseOver(trigger);
|
||||||
|
fireEvent.mouseEnter(trigger);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('groups.send_email_cc'));
|
||||||
|
expect(onComposeGroup).toHaveBeenCalledWith('g1', 'cc');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -172,7 +172,7 @@ export function ContactActivity({ contact }: ContactActivityProps) {
|
|||||||
|
|
||||||
const handleOpenEmail = (email: Email) => {
|
const handleOpenEmail = (email: Email) => {
|
||||||
selectEmail(email);
|
selectEmail(email);
|
||||||
router.push("/mail");
|
router.push("/");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenEvent = (event: CalendarEvent) => {
|
const handleOpenEvent = (event: CalendarEvent) => {
|
||||||
@@ -229,7 +229,7 @@ export function ContactActivity({ contact }: ContactActivityProps) {
|
|||||||
key={email.id}
|
key={email.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleOpenEmail(email)}
|
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" />
|
<Avatar name={sender.name} email={sender.address} size="sm" />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -277,7 +277,7 @@ export function ContactActivity({ contact }: ContactActivityProps) {
|
|||||||
key={event.id}
|
key={event.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleOpenEvent(event)}
|
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">
|
<span className="text-xs text-muted-foreground tabular-nums w-20 flex-shrink-0">
|
||||||
{formatEventTime(event)}
|
{formatEventTime(event)}
|
||||||
|
|||||||
@@ -2,16 +2,13 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Copy, Send, Globe, Cake, KeyRound, Users, Briefcase, Heart, Languages, Calendar, UserCircle, ShieldCheck, ShieldAlert, Download, MoreHorizontal, Printer } from "lucide-react";
|
import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Copy, Send, Globe, Cake, KeyRound, Users, Briefcase, Heart, Languages, Calendar, UserCircle, Download, MoreHorizontal, Printer } from "lucide-react";
|
||||||
import { Avatar } from "@/components/ui/avatar";
|
import { Avatar } from "@/components/ui/avatar";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { ContactCard, AnniversaryDate, PartialDate } from "@/lib/jmap/types";
|
import type { ContactCard, AnniversaryDate, PartialDate } from "@/lib/jmap/types";
|
||||||
import { getContactDisplayName, getContactPrimaryEmail, getContactPhotoUri } from "@/stores/contact-store";
|
import { getContactDisplayName, getContactPrimaryEmail, getContactPhotoUri } from "@/stores/contact-store";
|
||||||
import { ContactActivity } from "./contact-activity";
|
import { ContactActivity } from "./contact-activity";
|
||||||
import { useSmimeStore } from "@/stores/smime-store";
|
|
||||||
import { parseCertificatePemOrDer, extractCertificateInfo } from "@/lib/smime/certificate-utils";
|
|
||||||
import type { CertificateInfo } from "@/lib/smime/types";
|
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
import { exportContact } from "./contact-export";
|
import { exportContact } from "./contact-export";
|
||||||
import { printContact } from "./contact-print";
|
import { printContact } from "./contact-print";
|
||||||
@@ -32,6 +29,8 @@ interface ContactDetailProps {
|
|||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
onAddToGroup?: () => void;
|
onAddToGroup?: () => void;
|
||||||
onDuplicate?: () => void;
|
onDuplicate?: () => void;
|
||||||
|
/** Compose an email to this contact in the app (no OS mailto handoff). */
|
||||||
|
onCompose?: () => void;
|
||||||
isMobile?: boolean;
|
isMobile?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
@@ -115,51 +114,11 @@ function formatDate(dateInput: AnniversaryDate): string {
|
|||||||
return dateStr;
|
return dateStr;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDuplicate, isMobile, className }: ContactDetailProps) {
|
export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDuplicate, onCompose, isMobile, className }: ContactDetailProps) {
|
||||||
const t = useTranslations("contacts");
|
const t = useTranslations("contacts");
|
||||||
const smimeStore = useSmimeStore();
|
|
||||||
const [parsedCerts, setParsedCerts] = useState<Map<number, CertificateInfo>>(new Map());
|
|
||||||
|
|
||||||
const cryptoKeys = contact?.cryptoKeys ? Object.values(contact.cryptoKeys) : [];
|
const cryptoKeys = contact?.cryptoKeys ? Object.values(contact.cryptoKeys) : [];
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!contact) return;
|
|
||||||
let cancelled = false;
|
|
||||||
const parseCerts = async () => {
|
|
||||||
const results = new Map<number, CertificateInfo>();
|
|
||||||
for (let i = 0; i < cryptoKeys.length; i++) {
|
|
||||||
const key = cryptoKeys[i];
|
|
||||||
if (typeof key.uri !== 'string') continue;
|
|
||||||
try {
|
|
||||||
let derBytes: ArrayBuffer | string | null = null;
|
|
||||||
if (key.uri.startsWith('data:')) {
|
|
||||||
const commaIdx = key.uri.indexOf(',');
|
|
||||||
if (commaIdx === -1) continue;
|
|
||||||
const b64 = key.uri.substring(commaIdx + 1);
|
|
||||||
const binary = atob(b64);
|
|
||||||
const bytes = new Uint8Array(binary.length);
|
|
||||||
for (let j = 0; j < binary.length; j++) bytes[j] = binary.charCodeAt(j);
|
|
||||||
derBytes = bytes.buffer;
|
|
||||||
} else if (key.uri.startsWith('-----BEGIN')) {
|
|
||||||
derBytes = key.uri;
|
|
||||||
}
|
|
||||||
if (!derBytes) continue;
|
|
||||||
const cert = parseCertificatePemOrDer(derBytes);
|
|
||||||
const der = typeof derBytes === 'string' ? cert.toSchema(true).toBER(false) : derBytes;
|
|
||||||
const info = await extractCertificateInfo(cert, der);
|
|
||||||
if (!cancelled) results.set(i, info);
|
|
||||||
} catch { /* skip unparseable keys */ }
|
|
||||||
}
|
|
||||||
if (!cancelled) setParsedCerts(results);
|
|
||||||
};
|
|
||||||
if (cryptoKeys.length > 0) {
|
|
||||||
parseCerts();
|
|
||||||
} else {
|
|
||||||
setParsedCerts(new Map());
|
|
||||||
}
|
|
||||||
return () => { cancelled = true; };
|
|
||||||
}, [contact?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
||||||
|
|
||||||
if (!contact) {
|
if (!contact) {
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex flex-col items-center justify-center h-full text-muted-foreground", className)}>
|
<div className={cn("flex flex-col items-center justify-center h-full text-muted-foreground", className)}>
|
||||||
@@ -201,35 +160,10 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
|
|||||||
const notes = contact.notes ? Object.values(contact.notes) : [];
|
const notes = contact.notes ? Object.values(contact.notes) : [];
|
||||||
const titles = contact.titles ? Object.values(contact.titles) : [];
|
const titles = contact.titles ? Object.values(contact.titles) : [];
|
||||||
const jobTitles = titles.filter(t => t.kind !== "role");
|
const jobTitles = titles.filter(t => t.kind !== "role");
|
||||||
const roles = titles.filter(t => t.kind === "role");
|
|
||||||
const onlineServices = contact.onlineServices ? Object.values(contact.onlineServices) : [];
|
const onlineServices = contact.onlineServices ? Object.values(contact.onlineServices) : [];
|
||||||
const anniversaries = contact.anniversaries ? Object.values(contact.anniversaries) : [];
|
const anniversaries = contact.anniversaries ? Object.values(contact.anniversaries) : [];
|
||||||
const keywords = contact.keywords ? Object.keys(contact.keywords).filter(k => contact.keywords![k]) : [];
|
const keywords = contact.keywords ? Object.keys(contact.keywords).filter(k => contact.keywords![k]) : [];
|
||||||
|
|
||||||
const handleImportContactCert = async (keyIndex: number) => {
|
|
||||||
const key = cryptoKeys[keyIndex];
|
|
||||||
if (!key?.uri || typeof key.uri !== 'string') return;
|
|
||||||
try {
|
|
||||||
let derBytes: ArrayBuffer | string;
|
|
||||||
if (key.uri.startsWith('data:')) {
|
|
||||||
const commaIdx = key.uri.indexOf(',');
|
|
||||||
if (commaIdx === -1) return;
|
|
||||||
const b64 = key.uri.substring(commaIdx + 1);
|
|
||||||
const binary = atob(b64);
|
|
||||||
const bytes = new Uint8Array(binary.length);
|
|
||||||
for (let j = 0; j < binary.length; j++) bytes[j] = binary.charCodeAt(j);
|
|
||||||
derBytes = bytes.buffer;
|
|
||||||
} else if (key.uri.startsWith('-----BEGIN')) {
|
|
||||||
derBytes = key.uri;
|
|
||||||
} else {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await smimeStore.importPublicCert(derBytes, 'contact', contact.id);
|
|
||||||
toast.success(t("detail.cert_imported"));
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(err instanceof Error ? err.message : t("detail.cert_import_failed"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const relatedTo = contact.relatedTo ? Object.entries(contact.relatedTo) : [];
|
const relatedTo = contact.relatedTo ? Object.entries(contact.relatedTo) : [];
|
||||||
const preferredLanguages = contact.preferredLanguages ? Object.values(contact.preferredLanguages) : [];
|
const preferredLanguages = contact.preferredLanguages ? Object.values(contact.preferredLanguages) : [];
|
||||||
const personalInfo = contact.personalInfo ? Object.values(contact.personalInfo) : [];
|
const personalInfo = contact.personalInfo ? Object.values(contact.personalInfo) : [];
|
||||||
@@ -260,26 +194,28 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 flex-shrink-0 flex-wrap">
|
<div className="flex gap-2 flex-shrink-0 flex-wrap">
|
||||||
{email && (
|
{email && onCompose && (
|
||||||
<a
|
<Button
|
||||||
href={`mailto:${email}`}
|
variant="outline"
|
||||||
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"
|
size="sm"
|
||||||
|
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")}
|
{t("detail.compose_email")}
|
||||||
</a>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{phone && (
|
{phone && (
|
||||||
<a
|
<a
|
||||||
href={`tel:${phone}`}
|
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"
|
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")}
|
{t("context_menu.call")}
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
<Button variant="outline" size="sm" onClick={onEdit} className="touch-manipulation">
|
<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")}
|
{t("form.edit_title")}
|
||||||
</Button>
|
</Button>
|
||||||
<MoreActionsMenu items={moreItems} label={t("detail.more_actions")} />
|
<MoreActionsMenu items={moreItems} label={t("detail.more_actions")} />
|
||||||
@@ -490,59 +426,20 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
|
|||||||
{cryptoKeys.length > 0 && (
|
{cryptoKeys.length > 0 && (
|
||||||
<Section title={t("detail.crypto_keys")}>
|
<Section title={t("detail.crypto_keys")}>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{cryptoKeys.map((key, i) => {
|
{cryptoKeys.map((key, i) => (
|
||||||
const certInfo = parsedCerts.get(i);
|
<div key={i} className="rounded-md border border-border/60 bg-muted/30 p-3 space-y-1">
|
||||||
const isExpired = certInfo ? new Date(certInfo.notAfter) < new Date() : false;
|
<div className="flex items-start gap-2 text-sm break-all">
|
||||||
const alreadyImported = certInfo?.emailAddresses?.[0]
|
<KeyRound className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||||
? !!smimeStore.getPublicCertForEmail(certInfo.emailAddresses[0])
|
{typeof key.uri === 'string' && key.uri.startsWith("http") ? (
|
||||||
: false;
|
<a href={key.uri} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
|
||||||
|
{key.uri}
|
||||||
return (
|
</a>
|
||||||
<div key={i} className="rounded-md border border-border/60 bg-muted/30 p-3 space-y-1">
|
|
||||||
{certInfo ? (
|
|
||||||
<>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{isExpired ? (
|
|
||||||
<ShieldAlert className="w-4 h-4 text-destructive flex-shrink-0" />
|
|
||||||
) : (
|
|
||||||
<ShieldCheck className="w-4 h-4 text-primary flex-shrink-0" />
|
|
||||||
)}
|
|
||||||
<span className="text-sm font-medium truncate">{certInfo.subject}</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted-foreground space-y-0.5 pl-6">
|
|
||||||
<p>{t("detail.cert_issuer")}: {certInfo.issuer}</p>
|
|
||||||
<p>
|
|
||||||
{t("detail.cert_expires")}: {new Date(certInfo.notAfter).toLocaleDateString()}
|
|
||||||
{isExpired && <span className="text-destructive ml-1">({t("detail.cert_expired")})</span>}
|
|
||||||
</p>
|
|
||||||
<p>{t("detail.cert_fingerprint")}: {certInfo.fingerprint.substring(0, 20)}...</p>
|
|
||||||
{certInfo.algorithm && <p>{t("detail.cert_algorithm")}: {certInfo.algorithm}</p>}
|
|
||||||
</div>
|
|
||||||
{!alreadyImported && (
|
|
||||||
<Button variant="ghost" size="sm" className="ml-4 mt-1" onClick={() => handleImportContactCert(i)}>
|
|
||||||
<Download className="w-3 h-3 mr-1" />
|
|
||||||
{t("detail.import_to_smime")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{alreadyImported && (
|
|
||||||
<p className="text-xs text-green-600 pl-6 mt-1">{t("detail.cert_already_imported")}</p>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-start gap-2 text-sm break-all">
|
<span className="text-muted-foreground">{typeof key.uri === 'string' ? `${key.uri.substring(0, 80)}${key.uri.length > 80 ? "…" : ""}` : String(key.uri ?? '')}</span>
|
||||||
<KeyRound className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
|
||||||
{typeof key.uri === 'string' && key.uri.startsWith("http") ? (
|
|
||||||
<a href={key.uri} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
|
|
||||||
{key.uri}
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<span className="text-muted-foreground">{typeof key.uri === 'string' ? `${key.uri.substring(0, 80)}${key.uri.length > 80 ? "…" : ""}` : String(key.uri ?? '')}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
})}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
)}
|
)}
|
||||||
@@ -690,7 +587,7 @@ function MoreActionsMenu({ items, label }: { items: MoreItem[]; label: string })
|
|||||||
setOpen(false);
|
setOpen(false);
|
||||||
}}
|
}}
|
||||||
className={cn(
|
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",
|
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",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useMemo, useCallback, useEffect, useRef } from "react";
|
import { useState, useMemo, useCallback, useRef } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { X, Plus, ChevronDown, ChevronRight, User, Building, MapPin, Globe, Cake, Heart, Tag, StickyNote, Mail, Phone, Calendar, UserCircle, Book, Camera, Trash2 } from "lucide-react";
|
import { X, Plus, ChevronDown, ChevronRight, User, Building, MapPin, Globe, Cake, Heart, Tag, StickyNote, Mail, Phone, Calendar, UserCircle, Book, Camera, Trash2 } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -72,7 +72,7 @@ function FormSection({ icon: Icon, title, children, collapsible, defaultOpen = t
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
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"
|
collapsible ? "cursor-pointer" : "cursor-default"
|
||||||
)}
|
)}
|
||||||
onClick={() => collapsible && setOpen(!open)}
|
onClick={() => collapsible && setOpen(!open)}
|
||||||
@@ -525,6 +525,11 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
|||||||
mediaMap[photoKey] = { kind: "photo", uri: photoUri, mediaType: photoMediaType };
|
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> = {
|
const data: Partial<ContactCard> = {
|
||||||
name: { components: nameComponents, isOrdered: true },
|
name: { components: nameComponents, isOrdered: true },
|
||||||
nicknames: nickname.trim() ? { n0: { name: nickname.trim() } } : undefined,
|
nicknames: nickname.trim() ? { n0: { name: nickname.trim() } } : undefined,
|
||||||
@@ -551,7 +556,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
|||||||
calendarUri: calendarUri.trim() || undefined,
|
calendarUri: calendarUri.trim() || undefined,
|
||||||
schedulingUri: schedulingUri.trim() || undefined,
|
schedulingUri: schedulingUri.trim() || undefined,
|
||||||
freeBusyUri: freeBusyUri.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 } } : {}),
|
...(selectedBookId ? { addressBookIds: { [selectedBookId]: true } } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -731,12 +736,12 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{emailErrors[i] && (
|
{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>
|
</div>
|
||||||
))}
|
))}
|
||||||
<Button type="button" variant="ghost" size="sm" onClick={() => setEmails([...emails, { address: "", context: "" }])} className="text-xs">
|
<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")}
|
{t("add_email")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -794,7 +799,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<Button type="button" variant="ghost" size="sm" onClick={() => setPhones([...phones, { number: "", context: "", feature: "" }])} className="text-xs">
|
<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")}
|
{t("add_phone")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -850,7 +855,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<Button type="button" variant="ghost" size="sm" onClick={() => setAddresses([...addresses, { street: "", locality: "", region: "", postcode: "", country: "", context: "" }])} className="text-xs">
|
<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")}
|
{t("add_address")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -879,7 +884,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<Button type="button" variant="ghost" size="sm" onClick={() => setOnlineServices([...onlineServices, { uri: "", service: "", label: "" }])} className="text-xs">
|
<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")}
|
{t("add_online_service")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -911,7 +916,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<Button type="button" variant="ghost" size="sm" onClick={() => setAnniversaries([...anniversaries, { date: "", kind: "birth" }])} className="text-xs">
|
<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")}
|
{t("add_anniversary")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -952,7 +957,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<Button type="button" variant="ghost" size="sm" onClick={() => setPersonalInfoEntries([...personalInfoEntries, { value: "", kind: "hobby", level: "" }])} className="text-xs">
|
<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")}
|
{t("add_personal_info")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1140,7 +1145,7 @@ function CategoryComboBox({
|
|||||||
<button
|
<button
|
||||||
key={kw}
|
key={kw}
|
||||||
type="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"
|
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(); }}
|
onClick={() => { addKeyword(kw); inputRef.current?.focus(); }}
|
||||||
>
|
>
|
||||||
<Tag className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
<Tag className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
||||||
@@ -1150,7 +1155,7 @@ function CategoryComboBox({
|
|||||||
{canAddNew && (
|
{canAddNew && (
|
||||||
<button
|
<button
|
||||||
type="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(); }}
|
onClick={() => { addKeyword(inputValue); inputRef.current?.focus(); }}
|
||||||
>
|
>
|
||||||
<Plus className="w-3.5 h-3.5 flex-shrink-0" />
|
<Plus className="w-3.5 h-3.5 flex-shrink-0" />
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Users, Pencil, Trash2, UserMinus } from "lucide-react";
|
import { Users, Pencil, Trash2, UserMinus, Mail } from "lucide-react";
|
||||||
import { Avatar } from "@/components/ui/avatar";
|
import { Avatar } from "@/components/ui/avatar";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -15,6 +15,8 @@ interface ContactGroupDetailProps {
|
|||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
onRemoveMember: (memberId: string) => void;
|
onRemoveMember: (memberId: string) => void;
|
||||||
onSelectMember: (id: string) => void;
|
onSelectMember: (id: string) => void;
|
||||||
|
/** Compose an email to every member with the recipients placed in `field`. */
|
||||||
|
onComposeGroup?: (field: "to" | "cc" | "bcc") => void;
|
||||||
isMobile?: boolean;
|
isMobile?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
@@ -26,11 +28,13 @@ export function ContactGroupDetail({
|
|||||||
onDelete,
|
onDelete,
|
||||||
onRemoveMember,
|
onRemoveMember,
|
||||||
onSelectMember,
|
onSelectMember,
|
||||||
|
onComposeGroup,
|
||||||
isMobile,
|
isMobile,
|
||||||
className,
|
className,
|
||||||
}: ContactGroupDetailProps) {
|
}: ContactGroupDetailProps) {
|
||||||
const t = useTranslations("contacts");
|
const t = useTranslations("contacts");
|
||||||
const groupName = getContactDisplayName(group);
|
const groupName = getContactDisplayName(group);
|
||||||
|
const hasEmailMembers = members.some((m) => getContactPrimaryEmail(m).trim());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex flex-col h-full overflow-y-auto", className)}>
|
<div className={cn("flex flex-col h-full overflow-y-auto", className)}>
|
||||||
@@ -49,7 +53,7 @@ export function ContactGroupDetail({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button variant="outline" size="sm" onClick={onEdit} className="touch-manipulation">
|
<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")}
|
{t("form.edit_title")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -62,6 +66,25 @@ export function ContactGroupDetail({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{onComposeGroup && hasEmailMembers && (
|
||||||
|
<div className="flex flex-wrap items-center gap-2 mt-4">
|
||||||
|
<Mail className="w-4 h-4 text-muted-foreground" aria-hidden />
|
||||||
|
<span className="text-sm text-muted-foreground">{t("groups.send_email")}</span>
|
||||||
|
<div className="inline-flex gap-1">
|
||||||
|
{(["to", "cc", "bcc"] as const).map((field) => (
|
||||||
|
<Button
|
||||||
|
key={field}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onComposeGroup(field)}
|
||||||
|
className="touch-manipulation"
|
||||||
|
>
|
||||||
|
{t(`groups.send_email_${field}`)}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-6 py-4">
|
<div className="px-6 py-4">
|
||||||
@@ -83,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"
|
className="flex items-center gap-3 px-3 py-2.5 rounded-md hover:bg-muted group transition-colors"
|
||||||
>
|
>
|
||||||
<button
|
<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)}
|
onClick={() => onSelectMember(member.id)}
|
||||||
>
|
>
|
||||||
<Avatar name={mName} email={mEmail} size="sm" />
|
<Avatar name={mName} email={mEmail} size="sm" />
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ export function ContactGroupForm({
|
|||||||
placeholder={t("groups.search_members")}
|
placeholder={t("groups.search_members")}
|
||||||
value={memberSearch}
|
value={memberSearch}
|
||||||
onChange={(e) => setMemberSearch(e.target.value)}
|
onChange={(e) => setMemberSearch(e.target.value)}
|
||||||
className="pl-9"
|
className="ps-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -134,7 +134,7 @@ export function ContactGroupForm({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggleMember(contact.id)}
|
onClick={() => toggleMember(contact.id)}
|
||||||
className={cn(
|
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",
|
"hover:bg-muted",
|
||||||
isSelected && "bg-primary/5"
|
isSelected && "bg-primary/5"
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export function ContactGroupList({
|
|||||||
<div className={cn("flex flex-col", className)}>
|
<div className={cn("flex flex-col", className)}>
|
||||||
<div className="px-4 py-2 border-b border-border">
|
<div className="px-4 py-2 border-b border-border">
|
||||||
<Button size="sm" variant="outline" onClick={onCreateGroup} className="w-full">
|
<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")}
|
{t("groups.create")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -69,7 +69,7 @@ export function ContactGroupList({
|
|||||||
key={group.id}
|
key={group.id}
|
||||||
onClick={() => onSelectGroup(group.id)}
|
onClick={() => onSelectGroup(group.id)}
|
||||||
className={cn(
|
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",
|
"hover:bg-muted",
|
||||||
group.id === selectedGroupId && "bg-accent text-accent-foreground"
|
group.id === selectedGroupId && "bg-accent text-accent-foreground"
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ export function ContactImportDialog({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggleSelect(idx)}
|
onClick={() => toggleSelect(idx)}
|
||||||
className={cn(
|
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"
|
isSelected && "bg-primary/5"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -310,7 +310,7 @@ export function ContactList({
|
|||||||
placeholder={t("search_placeholder")}
|
placeholder={t("search_placeholder")}
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => onSearchChange(e.target.value)}
|
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 && (
|
{searchQuery && (
|
||||||
<button
|
<button
|
||||||
@@ -359,7 +359,7 @@ export function ContactList({
|
|||||||
onClick={() => setFilters(EMPTY_FILTERS)}
|
onClick={() => setFilters(EMPTY_FILTERS)}
|
||||||
className="h-7 px-2 text-xs"
|
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")}
|
{t("filters.clear")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -479,11 +479,11 @@ export function ContactList({
|
|||||||
</span>
|
</span>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<Button variant="ghost" size="sm" onClick={onBulkAddToGroup} className="h-7 text-xs">
|
<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")}
|
{t("bulk.add_to_group")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" onClick={onBulkExport} className="h-7 text-xs">
|
<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")}
|
{t("bulk.export")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -492,7 +492,7 @@ export function ContactList({
|
|||||||
onClick={onBulkDelete}
|
onClick={onBulkDelete}
|
||||||
className="h-7 text-xs text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300"
|
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")}
|
{t("bulk.delete")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="icon" onClick={onClearSelection} className="h-7 w-7">
|
<Button variant="ghost" size="icon" onClick={onClearSelection} className="h-7 w-7">
|
||||||
@@ -526,7 +526,7 @@ export function ContactList({
|
|||||||
)}
|
)}
|
||||||
{activeFilters > 0 && (
|
{activeFilters > 0 && (
|
||||||
<Button variant="outline" size="sm" onClick={() => setFilters(EMPTY_FILTERS)}>
|
<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")}
|
{t("filters.clear")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -538,7 +538,7 @@ export function ContactList({
|
|||||||
<p className="text-sm font-medium text-foreground">{t("empty_state_title")}</p>
|
<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>
|
<p className="text-xs text-muted-foreground mt-1">{t("empty_state_subtitle")}</p>
|
||||||
<Button size="sm" className="mt-3" onClick={onCreateNew}>
|
<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")}
|
{t("create_new")}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
import { useMemo, useState, useCallback, useEffect, useRef, type DragEvent } from "react";
|
import { useMemo, useState, useCallback, useEffect, useRef, type DragEvent } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { BookUser, User, Users, Plus, Share2, Book, ChevronRight, ChevronDown, UserPlus, UsersRound, Upload, Tag, Pencil, Trash2, Settings } from "lucide-react";
|
import { BookUser, User, Users, Plus, Share2, Book, BookPlus, ChevronRight, ChevronDown, UserPlus, UsersRound, Upload, Tag, Pencil, Trash2, Settings, Mail } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
|
import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from "@/components/ui/context-menu";
|
||||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { ContactCard, AddressBook } from "@/lib/jmap/types";
|
import type { ContactCard, AddressBook } from "@/lib/jmap/types";
|
||||||
@@ -22,9 +22,11 @@ interface ContactsSidebarProps {
|
|||||||
onSelectCategory: (category: ContactCategory) => void;
|
onSelectCategory: (category: ContactCategory) => void;
|
||||||
onCreateGroup: () => void;
|
onCreateGroup: () => void;
|
||||||
onCreateContact: () => void;
|
onCreateContact: () => void;
|
||||||
|
onCreateAddressBook?: () => void;
|
||||||
onImport?: () => void;
|
onImport?: () => void;
|
||||||
onEditGroup?: (groupId: string) => void;
|
onEditGroup?: (groupId: string) => void;
|
||||||
onDeleteGroup?: (groupId: string) => void;
|
onDeleteGroup?: (groupId: string) => void;
|
||||||
|
onComposeGroup?: (groupId: string, field: "to" | "cc" | "bcc") => void;
|
||||||
onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void;
|
onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void;
|
||||||
onDropContactsToCategory?: (contactIds: string[], keyword: string) => void;
|
onDropContactsToCategory?: (contactIds: string[], keyword: string) => void;
|
||||||
onRenameAddressBook?: (addressBook: AddressBook) => void;
|
onRenameAddressBook?: (addressBook: AddressBook) => void;
|
||||||
@@ -90,9 +92,11 @@ export function ContactsSidebar({
|
|||||||
onSelectCategory,
|
onSelectCategory,
|
||||||
onCreateGroup,
|
onCreateGroup,
|
||||||
onCreateContact,
|
onCreateContact,
|
||||||
|
onCreateAddressBook,
|
||||||
onImport,
|
onImport,
|
||||||
onEditGroup,
|
onEditGroup,
|
||||||
onDeleteGroup,
|
onDeleteGroup,
|
||||||
|
onComposeGroup,
|
||||||
onDropContacts,
|
onDropContacts,
|
||||||
onDropContactsToCategory,
|
onDropContactsToCategory,
|
||||||
onRenameAddressBook,
|
onRenameAddressBook,
|
||||||
@@ -283,22 +287,31 @@ 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"
|
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
|
<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(); }}
|
onClick={() => { setShowMenu(false); onCreateContact(); }}
|
||||||
>
|
>
|
||||||
<UserPlus className="w-4 h-4" />
|
<UserPlus className="w-4 h-4" />
|
||||||
{t("create_new")}
|
{t("create_new")}
|
||||||
</button>
|
</button>
|
||||||
<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(); }}
|
onClick={() => { setShowMenu(false); onCreateGroup(); }}
|
||||||
>
|
>
|
||||||
<UsersRound className="w-4 h-4" />
|
<UsersRound className="w-4 h-4" />
|
||||||
{t("groups.create")}
|
{t("groups.create")}
|
||||||
</button>
|
</button>
|
||||||
|
{onCreateAddressBook && (
|
||||||
|
<button
|
||||||
|
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" />
|
||||||
|
{t("address_books.create")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{onImport && (
|
{onImport && (
|
||||||
<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); onImport(); }}
|
onClick={() => { setShowMenu(false); onImport(); }}
|
||||||
>
|
>
|
||||||
<Upload className="w-4 h-4" />
|
<Upload className="w-4 h-4" />
|
||||||
@@ -325,7 +338,7 @@ export function ContactsSidebar({
|
|||||||
>
|
>
|
||||||
<BookUser className="w-4 h-4 flex-shrink-0" />
|
<BookUser className="w-4 h-4 flex-shrink-0" />
|
||||||
<span className="truncate">{t("tabs.all")}</span>
|
<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}
|
{individuals.length}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -342,7 +355,7 @@ export function ContactsSidebar({
|
|||||||
<div className="flex items-center px-3 py-1 group">
|
<div className="flex items-center px-3 py-1 group">
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleSection(sectionKey)}
|
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 ? (
|
{expanded ? (
|
||||||
<ChevronDown className="w-3 h-3 text-muted-foreground" />
|
<ChevronDown className="w-3 h-3 text-muted-foreground" />
|
||||||
@@ -356,7 +369,7 @@ export function ContactsSidebar({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<div className="pl-2">
|
<div className="ps-2">
|
||||||
{owned.length > 0 && (
|
{owned.length > 0 && (
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
<div className="px-3 py-0.5 text-[10px] font-medium text-muted-foreground/80 uppercase tracking-wider">
|
<div className="px-3 py-0.5 text-[10px] font-medium text-muted-foreground/80 uppercase tracking-wider">
|
||||||
@@ -405,7 +418,7 @@ export function ContactsSidebar({
|
|||||||
<div className="flex items-center px-3 py-1 group">
|
<div className="flex items-center px-3 py-1 group">
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleSection("addressBooks")}
|
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 ? (
|
{collapsed.addressBooks ? (
|
||||||
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||||
@@ -448,7 +461,7 @@ export function ContactsSidebar({
|
|||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleSection("groups")}
|
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 ? (
|
{collapsed.groups ? (
|
||||||
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||||
@@ -470,7 +483,7 @@ export function ContactsSidebar({
|
|||||||
onClick={() => onSelectCategory({ groupId: group.id })}
|
onClick={() => onSelectCategory({ groupId: group.id })}
|
||||||
onContextMenu={(e) => openGroupContextMenu(e, group)}
|
onContextMenu={(e) => openGroupContextMenu(e, group)}
|
||||||
className={cn(
|
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
|
isActive
|
||||||
? "bg-accent text-accent-foreground font-medium"
|
? "bg-accent text-accent-foreground font-medium"
|
||||||
: "text-foreground/80 hover:bg-muted"
|
: "text-foreground/80 hover:bg-muted"
|
||||||
@@ -479,7 +492,7 @@ export function ContactsSidebar({
|
|||||||
>
|
>
|
||||||
<Users className="w-4 h-4 flex-shrink-0" />
|
<Users className="w-4 h-4 flex-shrink-0" />
|
||||||
<span className="truncate">{getContactDisplayName(group)}</span>
|
<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}
|
{memberCount}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -492,7 +505,7 @@ export function ContactsSidebar({
|
|||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleSection("categories")}
|
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 ? (
|
{collapsed.categories ? (
|
||||||
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||||
@@ -510,7 +523,7 @@ export function ContactsSidebar({
|
|||||||
<button
|
<button
|
||||||
onClick={() => onSelectCategory("uncategorized")}
|
onClick={() => onSelectCategory("uncategorized")}
|
||||||
className={cn(
|
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"
|
activeCategory === "uncategorized"
|
||||||
? "bg-accent text-accent-foreground font-medium"
|
? "bg-accent text-accent-foreground font-medium"
|
||||||
: "text-foreground/80 hover:bg-muted"
|
: "text-foreground/80 hover:bg-muted"
|
||||||
@@ -519,7 +532,7 @@ export function ContactsSidebar({
|
|||||||
>
|
>
|
||||||
<Tag className="w-3.5 h-3.5 flex-shrink-0 opacity-50" />
|
<Tag className="w-3.5 h-3.5 flex-shrink-0 opacity-50" />
|
||||||
<span className="truncate italic">{t("no_category")}</span>
|
<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}
|
{uncategorizedCount}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -548,7 +561,7 @@ export function ContactsSidebar({
|
|||||||
<div className="flex items-center px-3 py-1 group">
|
<div className="flex items-center px-3 py-1 group">
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleSection(`shared-${group.accountId}`)}
|
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}`] ? (
|
{collapsed[`shared-${group.accountId}`] ? (
|
||||||
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||||
@@ -684,6 +697,21 @@ export function ContactsSidebar({
|
|||||||
onEditGroup?.(groupContextMenu.data!.id);
|
onEditGroup?.(groupContextMenu.data!.id);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{onComposeGroup && (
|
||||||
|
<ContextMenuSubMenu icon={Mail} label={t("groups.send_email")}>
|
||||||
|
{(["to", "cc", "bcc"] as const).map((field) => (
|
||||||
|
<ContextMenuItem
|
||||||
|
key={field}
|
||||||
|
label={t(`groups.send_email_${field}`)}
|
||||||
|
onClick={() => {
|
||||||
|
const groupId = groupContextMenu.data!.id;
|
||||||
|
closeGroupContextMenu();
|
||||||
|
onComposeGroup(groupId, field);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ContextMenuSubMenu>
|
||||||
|
)}
|
||||||
<ContextMenuSeparator />
|
<ContextMenuSeparator />
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
icon={Trash2}
|
icon={Trash2}
|
||||||
@@ -751,7 +779,7 @@ function CategoryItem({
|
|||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
className={cn(
|
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
|
isActive
|
||||||
? "bg-accent text-accent-foreground font-medium"
|
? "bg-accent text-accent-foreground font-medium"
|
||||||
: "text-foreground/80 hover:bg-muted",
|
: "text-foreground/80 hover:bg-muted",
|
||||||
@@ -761,7 +789,7 @@ function CategoryItem({
|
|||||||
>
|
>
|
||||||
<Tag className="w-3.5 h-3.5 flex-shrink-0" />
|
<Tag className="w-3.5 h-3.5 flex-shrink-0" />
|
||||||
<span className="truncate">{keyword}</span>
|
<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}
|
{count}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -819,7 +847,7 @@ function AddressBookItem({
|
|||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
className={cn(
|
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
|
isActive
|
||||||
? "bg-accent text-accent-foreground font-medium"
|
? "bg-accent text-accent-foreground font-medium"
|
||||||
: "text-foreground/80 hover:bg-muted",
|
: "text-foreground/80 hover:bg-muted",
|
||||||
@@ -830,11 +858,11 @@ function AddressBookItem({
|
|||||||
<Book className="w-4 h-4 flex-shrink-0" />
|
<Book className="w-4 h-4 flex-shrink-0" />
|
||||||
<span className="truncate">{book.name}</span>
|
<span className="truncate">{book.name}</span>
|
||||||
{!book.isShared && Object.keys(book.shareWith || {}).length > 0 && (
|
{!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(
|
<span className={cn(
|
||||||
"text-xs text-muted-foreground tabular-nums",
|
"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}
|
{contactCount}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -540,7 +540,9 @@ describe('CalendarInvitationBanner', () => {
|
|||||||
mocks.clientMock,
|
mocks.clientMock,
|
||||||
'event-8',
|
'event-8',
|
||||||
expect.objectContaining({
|
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({
|
participants: expect.objectContaining({
|
||||||
attendee: expect.objectContaining({
|
attendee: expect.objectContaining({
|
||||||
participationStatus: 'accepted',
|
participationStatus: 'accepted',
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { ContactSidebarPanel } from '../email-viewer';
|
||||||
|
import type { ContactCard } from '@/lib/jmap/types';
|
||||||
|
|
||||||
|
const contact: ContactCard = {
|
||||||
|
id: 'c1',
|
||||||
|
addressBookIds: {},
|
||||||
|
name: {
|
||||||
|
components: [
|
||||||
|
{ kind: 'given', value: 'Alice' },
|
||||||
|
{ kind: 'surname', value: 'Smith' },
|
||||||
|
],
|
||||||
|
isOrdered: true,
|
||||||
|
},
|
||||||
|
emails: { e0: { address: 'alice@example.com' } },
|
||||||
|
};
|
||||||
|
|
||||||
|
const unknownEmail = 'unknown@example.com';
|
||||||
|
|
||||||
|
describe('ContactSidebarPanel', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
Object.assign(navigator, {
|
||||||
|
clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows Edit button when contact is known and onEditContact is provided', () => {
|
||||||
|
render(
|
||||||
|
<ContactSidebarPanel
|
||||||
|
email="alice@example.com"
|
||||||
|
contact={contact}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
onEditContact={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
// useTranslations mock returns the key, so we look for the common.edit key
|
||||||
|
expect(screen.getByTitle('contact_sidebar.action_edit_title')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('edit')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onEditContact when Edit button is clicked', () => {
|
||||||
|
const onEditContact = vi.fn();
|
||||||
|
render(
|
||||||
|
<ContactSidebarPanel
|
||||||
|
email="alice@example.com"
|
||||||
|
contact={contact}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
onEditContact={onEditContact}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByTitle('contact_sidebar.action_edit_title'));
|
||||||
|
expect(onEditContact).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show Edit button when contact is null', () => {
|
||||||
|
render(
|
||||||
|
<ContactSidebarPanel
|
||||||
|
email={unknownEmail}
|
||||||
|
contact={null}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
onEditContact={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.queryByTitle('contact_sidebar.action_edit_title')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show Edit button when onEditContact is not provided', () => {
|
||||||
|
render(
|
||||||
|
<ContactSidebarPanel
|
||||||
|
email="alice@example.com"
|
||||||
|
contact={contact}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.queryByTitle('contact_sidebar.action_edit_title')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "not in contacts" message and Add button for unknown email', () => {
|
||||||
|
const onAddToContacts = vi.fn();
|
||||||
|
render(
|
||||||
|
<ContactSidebarPanel
|
||||||
|
email={unknownEmail}
|
||||||
|
contact={null}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
onAddToContacts={onAddToContacts}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('contact_sidebar.not_in_contacts')).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByText('contact_sidebar.add_to_contacts'));
|
||||||
|
expect(onAddToContacts).toHaveBeenCalledWith(unknownEmail, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onClose when the close button is clicked', () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(
|
||||||
|
<ContactSidebarPanel
|
||||||
|
email="alice@example.com"
|
||||||
|
contact={contact}
|
||||||
|
onClose={onClose}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByLabelText('contact_sidebar.close'));
|
||||||
|
expect(onClose).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -68,10 +68,12 @@ describe('EmailListItem tag badge', () => {
|
|||||||
expect(screen.getByText('Blue')).toBeInTheDocument();
|
expect(screen.getByText('Blue')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not show badge when keyword id not in settings', () => {
|
it('shows a gray fallback badge when keyword id is not in settings', () => {
|
||||||
const email = makeEmail({ keywords: { $seen: true, '$label:unknown-tag': true } });
|
const email = makeEmail({ keywords: { $seen: true, '$label:unknown-tag': true } });
|
||||||
render(<EmailListItem email={email} />);
|
render(<EmailListItem email={email} />);
|
||||||
expect(screen.queryByText('unknown-tag')).not.toBeInTheDocument();
|
// Unknown tags fall back to the raw id as label with a gray dot
|
||||||
|
// (see email-list-item.tsx: keywordDefs fallback).
|
||||||
|
expect(screen.getByText('unknown-tag')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows custom keyword label', () => {
|
it('shows custom keyword label', () => {
|
||||||
@@ -119,3 +121,34 @@ describe('EmailListItem tag badge', () => {
|
|||||||
expect(container.querySelector('p')).toBeNull();
|
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,343 @@
|
|||||||
|
import { render, screen, act } from '@testing-library/react';
|
||||||
|
import { fireEvent } from '@testing-library/dom';
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import React from 'react';
|
||||||
|
import { EmailComposer } from '../email-composer';
|
||||||
|
|
||||||
|
// ─── Heavy component mocks ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
vi.mock('@/components/email/rich-text-editor', () => ({
|
||||||
|
RichTextEditor: ({ onChange }: { onChange?: (html: string) => void }) => (
|
||||||
|
React.createElement('div', { 'data-testid': 'rich-text-editor', onClick: () => onChange?.('') })
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||||
|
vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
|
||||||
|
vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null }));
|
||||||
|
vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
|
||||||
|
vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
|
||||||
|
vi.mock('@/hooks/use-focus-trap', () => ({
|
||||||
|
useFocusTrap: () => ({ ref: { current: null } }),
|
||||||
|
}));
|
||||||
|
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({
|
||||||
|
useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }),
|
||||||
|
stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ─── Store mocks ──────────────────────────────────────────────────────────────
|
||||||
|
// vi.mock factories are hoisted, so all values must be defined inline.
|
||||||
|
|
||||||
|
vi.mock('@/stores/auth-store', () => {
|
||||||
|
const state = {
|
||||||
|
client: null,
|
||||||
|
identities: [],
|
||||||
|
primaryIdentity: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
isDemoMode: false,
|
||||||
|
activeAccountId: null,
|
||||||
|
connectionLost: false,
|
||||||
|
getClientForAccount: () => undefined,
|
||||||
|
getAllConnectedClients: () => new Map(),
|
||||||
|
syncIdentities: () => {},
|
||||||
|
refreshIdentities: async () => {},
|
||||||
|
};
|
||||||
|
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||||
|
typeof sel === 'function' ? sel(state) : state;
|
||||||
|
hook.getState = () => state;
|
||||||
|
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||||
|
return { useAuthStore: hook };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('@/stores/identity-store', () => {
|
||||||
|
const state = { identities: [], defaultIdentityId: null };
|
||||||
|
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||||
|
typeof sel === 'function' ? sel(state) : state;
|
||||||
|
hook.getState = () => state;
|
||||||
|
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||||
|
return { useIdentityStore: hook };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('@/stores/account-store', () => {
|
||||||
|
const state = { accounts: [], getAccountById: () => undefined };
|
||||||
|
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||||
|
typeof sel === 'function' ? sel(state) : state;
|
||||||
|
hook.getState = () => state;
|
||||||
|
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||||
|
return { useAccountStore: hook };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('@/stores/email-store', () => {
|
||||||
|
const state = {
|
||||||
|
draftSaveEnabled: false,
|
||||||
|
sendRawEmail: async () => ({ sent: true }),
|
||||||
|
};
|
||||||
|
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||||
|
typeof sel === 'function' ? sel(state) : state;
|
||||||
|
hook.getState = () => state;
|
||||||
|
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||||
|
return { useEmailStore: hook };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('@/stores/settings-store', () => {
|
||||||
|
const state = {
|
||||||
|
timeFormat: '24h',
|
||||||
|
plainTextMode: false,
|
||||||
|
subAddressDelimiter: '+',
|
||||||
|
autoSelectReplyIdentity: true,
|
||||||
|
attachmentReminderEnabled: false,
|
||||||
|
attachmentReminderKeywords: [],
|
||||||
|
sendDelaySeconds: 0,
|
||||||
|
signaturePosition: 'above_quote',
|
||||||
|
signatureSeparatorEnabled: false,
|
||||||
|
requestReadReceiptDefault: false,
|
||||||
|
addTrustedSender: () => {},
|
||||||
|
trustedSendersAddressBook: null,
|
||||||
|
};
|
||||||
|
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||||
|
typeof sel === 'function' ? sel(state) : state;
|
||||||
|
hook.getState = () => state;
|
||||||
|
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||||
|
return { useSettingsStore: hook };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('@/stores/contact-store', () => {
|
||||||
|
const state = {
|
||||||
|
contacts: [],
|
||||||
|
getAutocomplete: async () => [],
|
||||||
|
addToTrustedSendersBook: async () => {},
|
||||||
|
};
|
||||||
|
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||||
|
typeof sel === 'function' ? sel(state) : state;
|
||||||
|
hook.getState = () => state;
|
||||||
|
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||||
|
return { useContactStore: hook };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('@/stores/template-store', () => {
|
||||||
|
const state = { templates: [], addTemplate: async () => {} };
|
||||||
|
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||||
|
typeof sel === 'function' ? sel(state) : state;
|
||||||
|
hook.getState = () => state;
|
||||||
|
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||||
|
return { useTemplateStore: hook };
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Misc dependency mocks ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
vi.mock('@/stores/toast-store', () => ({
|
||||||
|
toast: { info: () => {}, error: () => {}, success: () => {} },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/plugin-hooks', () => ({
|
||||||
|
emailHooks: {
|
||||||
|
onComposerOpen: { call: async () => [] },
|
||||||
|
onRecipientChange: { call: async () => [] },
|
||||||
|
getRecipientSuggestions: { call: async () => [] },
|
||||||
|
onSend: { call: async () => [] },
|
||||||
|
beforeSend: { call: async () => [] },
|
||||||
|
},
|
||||||
|
contactHooks: {
|
||||||
|
search: { call: async () => [] },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/email-sanitization', () => ({
|
||||||
|
sanitizeSignatureHtml: (v: string) => v,
|
||||||
|
sanitizeEmailHtml: (v: string) => v,
|
||||||
|
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/reply-identity', () => ({ resolveReplyFrom: () => null }));
|
||||||
|
vi.mock('@/lib/email-threading', () => ({
|
||||||
|
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
|
||||||
|
}));
|
||||||
|
vi.mock('@/lib/signature-utils', () => ({
|
||||||
|
appendPlainTextSignature: (body: string) => body,
|
||||||
|
getPlainTextSignature: () => '',
|
||||||
|
}));
|
||||||
|
vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' }));
|
||||||
|
vi.mock('@/lib/debug', () => ({ debug: () => {} }));
|
||||||
|
vi.mock('@/components/email/quoted-html', () => ({
|
||||||
|
buildQuotedHtmlBlock: () => '',
|
||||||
|
serializeEditorContent: () => '',
|
||||||
|
}));
|
||||||
|
vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s }));
|
||||||
|
|
||||||
|
// ─── DataTransfer polyfill ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** jsdom's built-in DataTransfer doesn't fully support setData/getData in synthetic drag events. */
|
||||||
|
class MockDataTransfer {
|
||||||
|
private _data: Record<string, string> = {};
|
||||||
|
types: string[] = [];
|
||||||
|
effectAllowed = '';
|
||||||
|
dropEffect = '';
|
||||||
|
|
||||||
|
setData(type: string, data: string) {
|
||||||
|
this._data[type] = data;
|
||||||
|
if (!this.types.includes(type)) this.types.push(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
getData(type: string): string {
|
||||||
|
return this._data[type] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
setDragImage(_image: Element, _x: number, _y: number) {
|
||||||
|
// no-op: jsdom has no rendering, but the chip drag handler calls this.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Shared test data ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const BASE_DATA = {
|
||||||
|
to: 'alice@example.com, ',
|
||||||
|
cc: '',
|
||||||
|
bcc: '',
|
||||||
|
subject: '',
|
||||||
|
body: '',
|
||||||
|
showCc: true,
|
||||||
|
showBcc: true,
|
||||||
|
selectedIdentityId: null,
|
||||||
|
subAddressTag: '',
|
||||||
|
mode: 'compose' as const,
|
||||||
|
draftId: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('RecipientChipInput drag and drop', () => {
|
||||||
|
beforeEach(() => { vi.clearAllMocks(); });
|
||||||
|
|
||||||
|
it('renders recipient chips with draggable="true"', async () => {
|
||||||
|
render(<EmailComposer initialData={BASE_DATA} />);
|
||||||
|
|
||||||
|
const chipText = await screen.findByText('alice@example.com');
|
||||||
|
const chipSpan = chipText.closest('[draggable]');
|
||||||
|
expect(chipSpan).not.toBeNull();
|
||||||
|
expect(chipSpan).toHaveAttribute('draggable', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('onDragStart encodes the recipient and source field into dataTransfer', async () => {
|
||||||
|
render(<EmailComposer initialData={BASE_DATA} />);
|
||||||
|
|
||||||
|
const chipText = await screen.findByText('alice@example.com');
|
||||||
|
const chipSpan = chipText.closest('[draggable]') as HTMLElement;
|
||||||
|
|
||||||
|
const dt = new MockDataTransfer();
|
||||||
|
fireEvent.dragStart(chipSpan, { dataTransfer: dt });
|
||||||
|
|
||||||
|
const payload = JSON.parse(dt.getData('application/x-recipient-chip'));
|
||||||
|
expect(payload).toEqual({ recipient: { email: 'alice@example.com' }, fromField: 'to' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a display name with a comma in a single chip (array model)', async () => {
|
||||||
|
render(<EmailComposer initialData={{ ...BASE_DATA, to: '"Doo, John" <john@doo.org>, ' }} />);
|
||||||
|
|
||||||
|
// One chip, displayed as "Doo, John (john@doo.org)" — not split on the comma.
|
||||||
|
const chip = await screen.findByText('Doo, John (john@doo.org)');
|
||||||
|
const chipSpan = chip.closest('[draggable]') as HTMLElement;
|
||||||
|
expect(chipSpan).not.toBeNull();
|
||||||
|
|
||||||
|
const dt = new MockDataTransfer();
|
||||||
|
fireEvent.dragStart(chipSpan, { dataTransfer: dt });
|
||||||
|
const payload = JSON.parse(dt.getData('application/x-recipient-chip'));
|
||||||
|
expect(payload).toEqual({ recipient: { name: 'Doo, John', email: 'john@doo.org' }, fromField: 'to' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('onDragEnd clears the opacity class on the chip', async () => {
|
||||||
|
render(<EmailComposer initialData={BASE_DATA} />);
|
||||||
|
|
||||||
|
const chipText = await screen.findByText('alice@example.com');
|
||||||
|
const chipSpan = chipText.closest('[draggable]') as HTMLElement;
|
||||||
|
|
||||||
|
fireEvent.dragStart(chipSpan, { dataTransfer: new MockDataTransfer() });
|
||||||
|
expect(chipSpan.className).toContain('opacity-50');
|
||||||
|
|
||||||
|
fireEvent.dragEnd(chipSpan);
|
||||||
|
expect(chipSpan.className).not.toContain('opacity-50');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dragOver on a different field container adds ring indicator; dragLeave removes it', async () => {
|
||||||
|
render(<EmailComposer initialData={BASE_DATA} />);
|
||||||
|
|
||||||
|
await screen.findByText('alice@example.com');
|
||||||
|
|
||||||
|
// The flex-wrap containers are the actual drop zones
|
||||||
|
const allContainers = Array.from(document.querySelectorAll('[class*="flex-wrap"]'));
|
||||||
|
// To-container has the draggable chip; cc-container doesn't
|
||||||
|
const toContainer = allContainers.find(el => el.querySelector('[draggable]')) as HTMLElement;
|
||||||
|
const ccContainer = allContainers.find(el => el !== toContainer) as HTMLElement;
|
||||||
|
|
||||||
|
if (!ccContainer) return;
|
||||||
|
|
||||||
|
const dt = new MockDataTransfer();
|
||||||
|
dt.setData('application/x-recipient-chip', JSON.stringify({ recipient: { email: 'alice@example.com' }, fromField: 'to' }));
|
||||||
|
|
||||||
|
fireEvent.dragOver(ccContainer, { dataTransfer: dt });
|
||||||
|
expect(ccContainer.className).toContain('ring-primary');
|
||||||
|
|
||||||
|
fireEvent.dragLeave(ccContainer, { relatedTarget: null });
|
||||||
|
expect(ccContainer.className).not.toContain('ring-primary');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drop on a different field container moves the chip', async () => {
|
||||||
|
render(<EmailComposer initialData={BASE_DATA} />);
|
||||||
|
await screen.findByText('alice@example.com');
|
||||||
|
|
||||||
|
const allContainers = Array.from(document.querySelectorAll('[class*="flex-wrap"]'));
|
||||||
|
const toContainer = allContainers.find(el => el.querySelector('[draggable]')) as HTMLElement;
|
||||||
|
const ccContainer = allContainers.find(el => el !== toContainer) as HTMLElement;
|
||||||
|
|
||||||
|
if (!toContainer || !ccContainer) return;
|
||||||
|
|
||||||
|
const dt = new MockDataTransfer();
|
||||||
|
dt.setData('application/x-recipient-chip', JSON.stringify({ recipient: { email: 'alice@example.com' }, fromField: 'to' }));
|
||||||
|
fireEvent.dragOver(ccContainer, { dataTransfer: dt });
|
||||||
|
act(() => {
|
||||||
|
fireEvent.drop(ccContainer, { dataTransfer: dt });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Chip should still appear exactly once (moved, not duplicated or lost)
|
||||||
|
await screen.findByText('alice@example.com');
|
||||||
|
expect(screen.getAllByText('alice@example.com')).toHaveLength(1);
|
||||||
|
|
||||||
|
// The To container must now be empty
|
||||||
|
expect(toContainer.querySelectorAll('[draggable]')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drop on the same field container is a no-op', async () => {
|
||||||
|
render(<EmailComposer initialData={BASE_DATA} />);
|
||||||
|
await screen.findByText('alice@example.com');
|
||||||
|
|
||||||
|
const allContainers = Array.from(document.querySelectorAll('[class*="flex-wrap"]'));
|
||||||
|
const toContainer = allContainers.find(el => el.querySelector('[draggable]')) as HTMLElement;
|
||||||
|
|
||||||
|
const dt = new MockDataTransfer();
|
||||||
|
dt.setData('application/x-recipient-chip', JSON.stringify({ recipient: { email: 'alice@example.com' }, fromField: 'to' }));
|
||||||
|
fireEvent.dragOver(toContainer, { dataTransfer: dt });
|
||||||
|
act(() => {
|
||||||
|
fireEvent.drop(toContainer, { dataTransfer: dt });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Chip stays present exactly once
|
||||||
|
expect(screen.getAllByText('alice@example.com')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dropping a chip onto the hidden Cc button shows the CC field', async () => {
|
||||||
|
render(<EmailComposer initialData={{ ...BASE_DATA, showCc: false, showBcc: false }} />);
|
||||||
|
await screen.findByText('alice@example.com');
|
||||||
|
|
||||||
|
const ccButton = screen.getByRole('button', { name: 'Cc' });
|
||||||
|
|
||||||
|
const dt = new MockDataTransfer();
|
||||||
|
dt.setData('application/x-recipient-chip', JSON.stringify({ recipient: { email: 'alice@example.com' }, fromField: 'to' }));
|
||||||
|
fireEvent.dragOver(ccButton, { dataTransfer: dt });
|
||||||
|
act(() => {
|
||||||
|
fireEvent.drop(ccButton, { dataTransfer: dt });
|
||||||
|
});
|
||||||
|
|
||||||
|
// cc_label is rendered by the mock translation as its key string
|
||||||
|
const ccLabel = await screen.findByText('cc_label');
|
||||||
|
expect(ccLabel).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { fireEvent } from '@testing-library/dom';
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import React from 'react';
|
||||||
|
import { EmailComposer } from '../email-composer';
|
||||||
|
|
||||||
|
// ─── Heavy component mocks (mirrors recipient-chip-drag.test.tsx) ──────────────
|
||||||
|
|
||||||
|
vi.mock('@/components/email/rich-text-editor', () => ({
|
||||||
|
RichTextEditor: ({ onChange }: { onChange?: (html: string) => void }) => (
|
||||||
|
React.createElement('div', { 'data-testid': 'rich-text-editor', onClick: () => onChange?.('') })
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||||
|
vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
|
||||||
|
vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null }));
|
||||||
|
vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
|
||||||
|
vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
|
||||||
|
vi.mock('@/hooks/use-focus-trap', () => ({
|
||||||
|
useFocusTrap: () => ({ ref: { current: null } }),
|
||||||
|
}));
|
||||||
|
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({
|
||||||
|
useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }),
|
||||||
|
stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ─── Store mocks ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
vi.mock('@/stores/auth-store', () => {
|
||||||
|
const state = {
|
||||||
|
client: null,
|
||||||
|
identities: [],
|
||||||
|
primaryIdentity: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
isDemoMode: false,
|
||||||
|
activeAccountId: null,
|
||||||
|
connectionLost: false,
|
||||||
|
getClientForAccount: () => undefined,
|
||||||
|
getAllConnectedClients: () => new Map(),
|
||||||
|
syncIdentities: () => {},
|
||||||
|
refreshIdentities: async () => {},
|
||||||
|
};
|
||||||
|
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||||
|
typeof sel === 'function' ? sel(state) : state;
|
||||||
|
hook.getState = () => state;
|
||||||
|
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||||
|
return { useAuthStore: hook };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('@/stores/identity-store', () => {
|
||||||
|
const state = { identities: [], defaultIdentityId: null };
|
||||||
|
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||||
|
typeof sel === 'function' ? sel(state) : state;
|
||||||
|
hook.getState = () => state;
|
||||||
|
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||||
|
return { useIdentityStore: hook };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('@/stores/account-store', () => {
|
||||||
|
const state = { accounts: [], getAccountById: () => undefined };
|
||||||
|
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||||
|
typeof sel === 'function' ? sel(state) : state;
|
||||||
|
hook.getState = () => state;
|
||||||
|
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||||
|
return { useAccountStore: hook };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('@/stores/email-store', () => {
|
||||||
|
const state = {
|
||||||
|
draftSaveEnabled: false,
|
||||||
|
sendRawEmail: async () => ({ sent: true }),
|
||||||
|
};
|
||||||
|
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||||
|
typeof sel === 'function' ? sel(state) : state;
|
||||||
|
hook.getState = () => state;
|
||||||
|
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||||
|
return { useEmailStore: hook };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('@/stores/settings-store', () => {
|
||||||
|
const state = {
|
||||||
|
timeFormat: '24h',
|
||||||
|
plainTextMode: false,
|
||||||
|
subAddressDelimiter: '+',
|
||||||
|
autoSelectReplyIdentity: true,
|
||||||
|
attachmentReminderEnabled: false,
|
||||||
|
attachmentReminderKeywords: [],
|
||||||
|
sendDelaySeconds: 0,
|
||||||
|
signaturePosition: 'above_quote',
|
||||||
|
signatureSeparatorEnabled: false,
|
||||||
|
requestReadReceiptDefault: false,
|
||||||
|
addTrustedSender: () => {},
|
||||||
|
trustedSendersAddressBook: null,
|
||||||
|
};
|
||||||
|
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||||
|
typeof sel === 'function' ? sel(state) : state;
|
||||||
|
hook.getState = () => state;
|
||||||
|
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||||
|
return { useSettingsStore: hook };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('@/stores/contact-store', () => {
|
||||||
|
const state = {
|
||||||
|
contacts: [],
|
||||||
|
getAutocomplete: async () => [],
|
||||||
|
addToTrustedSendersBook: async () => {},
|
||||||
|
};
|
||||||
|
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||||
|
typeof sel === 'function' ? sel(state) : state;
|
||||||
|
hook.getState = () => state;
|
||||||
|
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||||
|
return { useContactStore: hook };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('@/stores/template-store', () => {
|
||||||
|
const state = { templates: [], addTemplate: async () => {} };
|
||||||
|
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||||
|
typeof sel === 'function' ? sel(state) : state;
|
||||||
|
hook.getState = () => state;
|
||||||
|
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||||
|
return { useTemplateStore: hook };
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Misc dependency mocks ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
vi.mock('@/stores/toast-store', () => ({
|
||||||
|
toast: { info: () => {}, error: () => {}, success: () => {} },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/plugin-hooks', () => ({
|
||||||
|
emailHooks: {
|
||||||
|
onComposerOpen: { call: async () => [] },
|
||||||
|
onRecipientChange: { call: async () => [] },
|
||||||
|
getRecipientSuggestions: { call: async () => [] },
|
||||||
|
onSend: { call: async () => [] },
|
||||||
|
beforeSend: { call: async () => [] },
|
||||||
|
},
|
||||||
|
contactHooks: {
|
||||||
|
search: { call: async () => [] },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/email-sanitization', () => ({
|
||||||
|
sanitizeSignatureHtml: (v: string) => v,
|
||||||
|
sanitizeEmailHtml: (v: string) => v,
|
||||||
|
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/reply-identity', () => ({ resolveReplyFrom: () => null }));
|
||||||
|
vi.mock('@/lib/email-threading', () => ({
|
||||||
|
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
|
||||||
|
}));
|
||||||
|
vi.mock('@/lib/signature-utils', () => ({
|
||||||
|
appendPlainTextSignature: (body: string) => body,
|
||||||
|
getPlainTextSignature: () => '',
|
||||||
|
}));
|
||||||
|
vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' }));
|
||||||
|
vi.mock('@/lib/debug', () => ({ debug: () => {} }));
|
||||||
|
vi.mock('@/components/email/quoted-html', () => ({
|
||||||
|
buildQuotedHtmlBlock: () => '',
|
||||||
|
serializeEditorContent: () => '',
|
||||||
|
}));
|
||||||
|
vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s }));
|
||||||
|
|
||||||
|
// ─── Shared test data ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const EMPTY_DATA = {
|
||||||
|
to: '',
|
||||||
|
cc: '',
|
||||||
|
bcc: '',
|
||||||
|
subject: '',
|
||||||
|
body: '',
|
||||||
|
showCc: true,
|
||||||
|
showBcc: true,
|
||||||
|
selectedIdentityId: null,
|
||||||
|
subAddressTag: '',
|
||||||
|
mode: 'compose' as const,
|
||||||
|
draftId: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** next-intl is mocked to return the key, so the To placeholder is "to_placeholder". */
|
||||||
|
const toInput = () => screen.getByPlaceholderText('to_placeholder') as HTMLInputElement;
|
||||||
|
const ccInput = () => screen.getByPlaceholderText('cc_placeholder') as HTMLInputElement;
|
||||||
|
|
||||||
|
const paste = (input: HTMLElement, text: string) =>
|
||||||
|
fireEvent.paste(input, { clipboardData: { getData: () => text } });
|
||||||
|
|
||||||
|
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('RecipientChipInput paste', () => {
|
||||||
|
beforeEach(() => { vi.clearAllMocks(); });
|
||||||
|
|
||||||
|
it('splits a pasted list (comma / semicolon / whitespace) into one chip per address', () => {
|
||||||
|
render(<EmailComposer initialData={EMPTY_DATA} />);
|
||||||
|
const input = toInput(); // capture once — placeholder disappears once chips exist
|
||||||
|
paste(input, 'a@x.com b@y.com; c@z.com');
|
||||||
|
|
||||||
|
expect(screen.getByText('a@x.com')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('b@y.com')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('c@z.com')).toBeInTheDocument();
|
||||||
|
// all consumed → input cleared
|
||||||
|
expect(input.value).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('chips the valid addresses and leaves invalid text in the input', () => {
|
||||||
|
render(<EmailComposer initialData={EMPTY_DATA} />);
|
||||||
|
const input = toInput();
|
||||||
|
paste(input, 'foo bar a@x.com');
|
||||||
|
|
||||||
|
expect(screen.getByText('a@x.com')).toBeInTheDocument();
|
||||||
|
expect(input.value).toBe('foo bar');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not pre-empt a single-address paste (no delimiter → normal editing)', () => {
|
||||||
|
render(<EmailComposer initialData={EMPTY_DATA} />);
|
||||||
|
paste(toInput(), 'single@x.com');
|
||||||
|
|
||||||
|
// The handler bailed out, so no chip was created from the single token.
|
||||||
|
expect(screen.queryByText('single@x.com')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps display names from a fully-quoted "Name <email>" list', () => {
|
||||||
|
render(<EmailComposer initialData={EMPTY_DATA} />);
|
||||||
|
const input = toInput();
|
||||||
|
paste(input, '"Alice Smith <alice@x.com>", "Alex Smith <alex@x.com>"');
|
||||||
|
|
||||||
|
// Chips render as "Name (email)" when a display name is present.
|
||||||
|
expect(screen.getByText('Alice Smith (alice@x.com)')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Alex Smith (alex@x.com)')).toBeInTheDocument();
|
||||||
|
expect(input.value).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a `Name <email>` pair as a single named chip', () => {
|
||||||
|
render(<EmailComposer initialData={EMPTY_DATA} />);
|
||||||
|
const input = toInput();
|
||||||
|
paste(input, 'John Doe <j@x.com>, jane@y.com');
|
||||||
|
|
||||||
|
expect(screen.getByText('John Doe (j@x.com)')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('jane@y.com')).toBeInTheDocument();
|
||||||
|
expect(input.value).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a comma inside a quoted display name intact', () => {
|
||||||
|
render(<EmailComposer initialData={EMPTY_DATA} />);
|
||||||
|
const input = toInput();
|
||||||
|
paste(input, '"Doe, John" <j@x.com>; bob@z.com');
|
||||||
|
|
||||||
|
expect(screen.getByText('Doe, John (j@x.com)')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('bob@z.com')).toBeInTheDocument();
|
||||||
|
expect(input.value).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('splits a newline-separated block (e.g. a spreadsheet column)', () => {
|
||||||
|
render(<EmailComposer initialData={EMPTY_DATA} />);
|
||||||
|
const input = toInput();
|
||||||
|
paste(input, 'a@x.com\nb@y.com\nc@z.com');
|
||||||
|
|
||||||
|
expect(screen.getByText('a@x.com')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('b@y.com')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('c@z.com')).toBeInTheDocument();
|
||||||
|
expect(input.value).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dedupes case-insensitively within the paste and against existing chips', () => {
|
||||||
|
render(<EmailComposer initialData={EMPTY_DATA} />);
|
||||||
|
const input = toInput(); // DOM node persists across pastes (placeholder just clears)
|
||||||
|
paste(input, 'a@x.com, A@X.com, b@y.com'); // within-paste dup collapses
|
||||||
|
paste(input, 'A@X.COM, c@z.com'); // dup of an existing chip is dropped
|
||||||
|
|
||||||
|
expect(screen.getByText('b@y.com')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('c@z.com')).toBeInTheDocument();
|
||||||
|
// a@x.com appears exactly once despite three case variants across two pastes.
|
||||||
|
expect(screen.getAllByText('a@x.com')).toHaveLength(1);
|
||||||
|
expect(screen.queryByText('A@X.COM')).not.toBeInTheDocument();
|
||||||
|
expect(input.value).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('chips the valid (named) entries and leaves a non-address token behind', () => {
|
||||||
|
render(<EmailComposer initialData={EMPTY_DATA} />);
|
||||||
|
const input = toInput();
|
||||||
|
paste(input, '"VIP" <vip@x.com>, not-an-email, b@y.com');
|
||||||
|
|
||||||
|
expect(screen.getByText('VIP (vip@x.com)')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('b@y.com')).toBeInTheDocument();
|
||||||
|
expect(input.value).toBe('not-an-email');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('works on the Cc field (shared handler)', () => {
|
||||||
|
render(<EmailComposer initialData={EMPTY_DATA} />);
|
||||||
|
paste(ccInput(), 'x@a.com; y@b.com');
|
||||||
|
|
||||||
|
expect(screen.getByText('x@a.com')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('y@b.com')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { Editor } from '@tiptap/core';
|
||||||
|
import StarterKit from '@tiptap/starter-kit';
|
||||||
|
|
||||||
|
import { SignatureBlock, buildSignatureBlock, SIGNATURE_BLOCK_MARKER } from '../signature-block';
|
||||||
|
import { serializeEditorContent } from '../quoted-html';
|
||||||
|
|
||||||
|
describe('signature-block', () => {
|
||||||
|
it('buildSignatureBlock wraps html in the marker div', () => {
|
||||||
|
expect(buildSignatureBlock('<b>x</b>')).toBe(`<div ${SIGNATURE_BLOCK_MARKER}><b>x</b></div>`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves an inline-styled signature through parse + serialize (no schema flattening)', () => {
|
||||||
|
const styled =
|
||||||
|
'<table style="background:#0a0e16;border-radius:8px"><tbody><tr>' +
|
||||||
|
'<td style="color:#c6f24e;font-family:\'Courier New\'">MV</td>' +
|
||||||
|
'</tr></tbody></table>';
|
||||||
|
const editor = new Editor({
|
||||||
|
element: document.createElement('div'),
|
||||||
|
extensions: [StarterKit, SignatureBlock],
|
||||||
|
content: `<p>Hello</p>${buildSignatureBlock(styled)}`,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const out = serializeEditorContent(editor);
|
||||||
|
// Original inline styling survives - it is NOT re-parsed into the schema.
|
||||||
|
expect(out).toContain('background:#0a0e16');
|
||||||
|
expect(out).toContain('border-radius:8px');
|
||||||
|
expect(out).toContain('color:#c6f24e');
|
||||||
|
expect(out).toContain(SIGNATURE_BLOCK_MARKER);
|
||||||
|
// Surrounding body is preserved.
|
||||||
|
expect(out).toContain('Hello');
|
||||||
|
// The signature did not get the editor's generic table styling.
|
||||||
|
expect(out).not.toContain('rgb(204, 204, 204)');
|
||||||
|
} finally {
|
||||||
|
editor.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -553,40 +553,10 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
const replyToForRsvp = parsedEvent.replyTo
|
const replyToForRsvp = parsedEvent.replyTo
|
||||||
|| (parsedEvent.organizerCalendarAddress ? { imip: parsedEvent.organizerCalendarAddress } : null);
|
|| (parsedEvent.organizerCalendarAddress ? { imip: parsedEvent.organizerCalendarAddress } : null);
|
||||||
|
|
||||||
const imipStatus = status.toUpperCase() as 'ACCEPTED' | 'TENTATIVE' | 'DECLINED';
|
// The iMIP REPLY to the organizer is sent by the server: rsvpEvent /
|
||||||
const organizerEmail = parsedEvent?.replyTo?.imip?.replace('mailto:', '')
|
// updateEvent below pass sendSchedulingMessages=true to CalendarEvent/set
|
||||||
|| parsedEvent?.organizerCalendarAddress?.replace('mailto:', '')
|
// and Stalwart queues the iTIP REPLY itself. A manual client-side reply
|
||||||
|| summary?.organizerEmail
|
// here produced duplicate emails.
|
||||||
|| null;
|
|
||||||
|
|
||||||
// Send the iMIP REPLY email to the organizer (client-side scheduling).
|
|
||||||
// Called after updating the local calendar event. Best-effort - if it
|
|
||||||
// fails we still report the RSVP as sent since the calendar was updated.
|
|
||||||
const sendImipReply = async () => {
|
|
||||||
if (!organizerEmail || !parsedEvent?.uid || !currentUserEmail) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await client.sendImipReply({
|
|
||||||
organizerEmail,
|
|
||||||
organizerName: summary?.organizer || undefined,
|
|
||||||
attendeeEmail: currentUserEmail,
|
|
||||||
attendeeName: myParticipant?.participant.name || undefined,
|
|
||||||
uid: parsedEvent.uid,
|
|
||||||
summary: parsedEvent.title,
|
|
||||||
dtStart: parsedEvent.start || undefined,
|
|
||||||
dtEnd: summary?.end || undefined,
|
|
||||||
timeZone: parsedEvent.timeZone || undefined,
|
|
||||||
isAllDay: parsedEvent.showWithoutTime || false,
|
|
||||||
sequence: parsedEvent.sequence,
|
|
||||||
status: imipStatus,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// Best-effort: don't block the RSVP success notification
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const eventForRsvp = existingEvent
|
const eventForRsvp = existingEvent
|
||||||
? await resolveExistingEventForRsvp()
|
? await resolveExistingEventForRsvp()
|
||||||
@@ -601,7 +571,6 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
);
|
);
|
||||||
if (eventForRsvp && existingEventParticipant) {
|
if (eventForRsvp && existingEventParticipant) {
|
||||||
await rsvpEvent(client, eventForRsvp.id, existingEventParticipant.id, status, replyToForRsvp);
|
await rsvpEvent(client, eventForRsvp.id, existingEventParticipant.id, status, replyToForRsvp);
|
||||||
await sendImipReply();
|
|
||||||
setRsvpStatus(status);
|
setRsvpStatus(status);
|
||||||
setActionNotice(t('rsvp_sent'));
|
setActionNotice(t('rsvp_sent'));
|
||||||
setState('parsed');
|
setState('parsed');
|
||||||
@@ -613,9 +582,13 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
} else {
|
} else {
|
||||||
await updateEvent(client, eventForRsvp.id, {
|
await updateEvent(client, eventForRsvp.id, {
|
||||||
participants: repairedParticipants,
|
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);
|
}, true);
|
||||||
await sendImipReply();
|
|
||||||
setRsvpStatus(status);
|
setRsvpStatus(status);
|
||||||
setActionNotice(t('rsvp_sent'));
|
setActionNotice(t('rsvp_sent'));
|
||||||
setState('parsed');
|
setState('parsed');
|
||||||
@@ -632,7 +605,6 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
|| (newEvent && currentUserEmail ? findParticipantByEmail(newEvent, currentUserEmail) : null);
|
|| (newEvent && currentUserEmail ? findParticipantByEmail(newEvent, currentUserEmail) : null);
|
||||||
if (newEvent && participant) {
|
if (newEvent && participant) {
|
||||||
await rsvpEvent(client, newEvent.id, participant.id, status, replyToForRsvp);
|
await rsvpEvent(client, newEvent.id, participant.id, status, replyToForRsvp);
|
||||||
await sendImipReply();
|
|
||||||
setRsvpStatus(status);
|
setRsvpStatus(status);
|
||||||
setActionNotice(t('rsvp_sent'));
|
setActionNotice(t('rsvp_sent'));
|
||||||
} else {
|
} else {
|
||||||
@@ -1081,7 +1053,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
setShowCalendarPicker(false);
|
setShowCalendarPicker(false);
|
||||||
handleImport(cal.id);
|
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
|
<span
|
||||||
className="w-3 h-3 rounded-full flex-shrink-0"
|
className="w-3 h-3 rounded-full flex-shrink-0"
|
||||||
@@ -1123,7 +1095,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{isProcessing && (
|
{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+941
-490
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,8 @@ import {
|
|||||||
Mail,
|
Mail,
|
||||||
MailOpen,
|
MailOpen,
|
||||||
Star,
|
Star,
|
||||||
|
Pin,
|
||||||
|
PinOff,
|
||||||
Trash2,
|
Trash2,
|
||||||
Archive,
|
Archive,
|
||||||
FolderInput,
|
FolderInput,
|
||||||
@@ -34,6 +36,7 @@ import {
|
|||||||
XCircle,
|
XCircle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
|
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
|
||||||
|
import { localizeMailboxName } from "@/lib/mailbox-label";
|
||||||
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
||||||
|
|
||||||
interface Position {
|
interface Position {
|
||||||
@@ -58,6 +61,7 @@ interface EmailContextMenuProps {
|
|||||||
onForward?: () => void;
|
onForward?: () => void;
|
||||||
onMarkAsRead?: (read: boolean) => void;
|
onMarkAsRead?: (read: boolean) => void;
|
||||||
onToggleStar?: () => void;
|
onToggleStar?: () => void;
|
||||||
|
onTogglePinned?: () => void;
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
onArchive?: () => void;
|
onArchive?: () => void;
|
||||||
onSetColorTag?: (color: string | null) => void;
|
onSetColorTag?: (color: string | null) => void;
|
||||||
@@ -125,6 +129,7 @@ export function EmailContextMenu({
|
|||||||
onForward,
|
onForward,
|
||||||
onMarkAsRead,
|
onMarkAsRead,
|
||||||
onToggleStar,
|
onToggleStar,
|
||||||
|
onTogglePinned,
|
||||||
onDelete,
|
onDelete,
|
||||||
onArchive,
|
onArchive,
|
||||||
onSetColorTag,
|
onSetColorTag,
|
||||||
@@ -143,14 +148,19 @@ export function EmailContextMenu({
|
|||||||
onRescheduleScheduled,
|
onRescheduleScheduled,
|
||||||
}: EmailContextMenuProps) {
|
}: EmailContextMenuProps) {
|
||||||
const t = useTranslations("context_menu");
|
const t = useTranslations("context_menu");
|
||||||
|
const tSidebar = useTranslations("sidebar");
|
||||||
const _tColor = useTranslations("email_viewer.color_tag");
|
const _tColor = useTranslations("email_viewer.color_tag");
|
||||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||||
const isUnread = !email.keywords?.$seen;
|
const isUnread = !email.keywords?.$seen;
|
||||||
const isStarred = email.keywords?.$flagged;
|
const isStarred = email.keywords?.$flagged;
|
||||||
|
const isPinned = email.keywords?.['$pinned'] === true;
|
||||||
const isDraft = email.keywords?.['$draft'] === true;
|
const isDraft = email.keywords?.['$draft'] === true;
|
||||||
const currentColors = getCurrentColors(email.keywords);
|
const currentColors = getCurrentColors(email.keywords);
|
||||||
const showBatchActions = isMultiSelect && selectedCount > 1;
|
const showBatchActions = isMultiSelect && selectedCount > 1;
|
||||||
const isInJunkFolder = currentMailboxRole === 'junk';
|
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 isScheduled = email.isScheduled === true;
|
||||||
const canCancelScheduled = isScheduled && email.scheduledUndoStatus === 'pending';
|
const canCancelScheduled = isScheduled && email.scheduledUndoStatus === 'pending';
|
||||||
|
|
||||||
@@ -302,12 +312,13 @@ export function EmailContextMenu({
|
|||||||
return nodes.map((node) => {
|
return nodes.map((node) => {
|
||||||
const Icon = getMailboxIcon(node.role);
|
const Icon = getMailboxIcon(node.role);
|
||||||
const isTarget = moveTargetIds.has(node.id);
|
const isTarget = moveTargetIds.has(node.id);
|
||||||
|
const nodeLabel = localizeMailboxName(node.role, node.name, (k) => tSidebar(`mailboxes.${k}`));
|
||||||
return (
|
return (
|
||||||
<div key={node.id}>
|
<div key={node.id}>
|
||||||
{isTarget ? (
|
{isTarget ? (
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
icon={Icon}
|
icon={Icon}
|
||||||
label={node.name}
|
label={nodeLabel}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
handleAction(() =>
|
handleAction(() =>
|
||||||
showBatchActions
|
showBatchActions
|
||||||
@@ -319,11 +330,11 @@ export function EmailContextMenu({
|
|||||||
) : (
|
) : (
|
||||||
<div className="px-3 py-1.5 text-sm flex items-center gap-2 text-muted-foreground">
|
<div className="px-3 py-1.5 text-sm flex items-center gap-2 text-muted-foreground">
|
||||||
<Icon className="w-4 h-4 flex-shrink-0" />
|
<Icon className="w-4 h-4 flex-shrink-0" />
|
||||||
<span>{node.name}</span>
|
<span>{nodeLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{node.children.length > 0 && (
|
{node.children.length > 0 && (
|
||||||
<div className="pl-4">
|
<div className="ps-4">
|
||||||
{renderNodes(node.children)}
|
{renderNodes(node.children)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -346,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 */}
|
{/* Set tag submenu - only for single email */}
|
||||||
{!showBatchActions && (
|
{!showBatchActions && (
|
||||||
<ContextMenuSubMenu icon={Tag} label={t("color_tag")}>
|
<ContextMenuSubMenu icon={Tag} label={t("color_tag")}>
|
||||||
@@ -357,7 +377,7 @@ export function EmailContextMenu({
|
|||||||
role="menuitem"
|
role="menuitem"
|
||||||
onClick={() => handleAction(() => onSetColorTag?.(option.value))}
|
onClick={() => handleAction(() => onSetColorTag?.(option.value))}
|
||||||
className={cn(
|
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"
|
isActive && "bg-accent font-medium"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -382,22 +402,26 @@ export function EmailContextMenu({
|
|||||||
</ContextMenuSubMenu>
|
</ContextMenuSubMenu>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ContextMenuSeparator />
|
{/* Spam - contextual based on folder; pointless on own outgoing mail */}
|
||||||
|
{spamApplicable && (
|
||||||
|
<>
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
|
||||||
{/* Spam - contextual based on folder */}
|
<ContextMenuItem
|
||||||
<ContextMenuItem
|
icon={isInJunkFolder ? ShieldCheck : ShieldAlert}
|
||||||
icon={isInJunkFolder ? ShieldCheck : ShieldAlert}
|
label={isInJunkFolder ? t("not_spam") : t("mark_as_spam")}
|
||||||
label={isInJunkFolder ? t("not_spam") : t("mark_as_spam")}
|
onClick={() =>
|
||||||
onClick={() =>
|
handleAction(
|
||||||
handleAction(
|
showBatchActions
|
||||||
showBatchActions
|
? (isInJunkFolder ? onBatchUndoSpam! : onBatchMarkAsSpam!)
|
||||||
? (isInJunkFolder ? onBatchUndoSpam! : onBatchMarkAsSpam!)
|
: (isInJunkFolder ? onUndoSpam! : onMarkAsSpam!)
|
||||||
: (isInJunkFolder ? onUndoSpam! : onMarkAsSpam!)
|
)
|
||||||
)
|
}
|
||||||
}
|
disabled={showBatchActions ? (isInJunkFolder ? !onBatchUndoSpam : !onBatchMarkAsSpam) : (isInJunkFolder ? !onUndoSpam : !onMarkAsSpam)}
|
||||||
disabled={showBatchActions ? (isInJunkFolder ? !onBatchUndoSpam : !onBatchMarkAsSpam) : (isInJunkFolder ? !onUndoSpam : !onMarkAsSpam)}
|
destructive={!isInJunkFolder}
|
||||||
destructive={!isInJunkFolder}
|
/>
|
||||||
/>
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<ContextMenuSeparator />
|
<ContextMenuSeparator />
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Email } from "@/lib/jmap/types";
|
|||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
import type { HoverAction } from "@/stores/settings-store";
|
import type { HoverAction } from "@/stores/settings-store";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Trash2, Star, Mail, MailOpen, Archive, Tag, ShieldAlert } from "lucide-react";
|
import { Trash2, Star, Mail, MailOpen, Archive, Tag, ShieldAlert, ShieldCheck } from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useIsMobile } from "@/hooks/use-media-query";
|
import { useIsMobile } from "@/hooks/use-media-query";
|
||||||
|
|
||||||
@@ -17,6 +17,12 @@ interface EmailHoverActionsProps {
|
|||||||
onArchive?: () => void;
|
onArchive?: () => void;
|
||||||
onSetColorTag?: (color: string | null) => void;
|
onSetColorTag?: (color: string | null) => void;
|
||||||
onMarkAsSpam?: () => void;
|
onMarkAsSpam?: () => void;
|
||||||
|
// When the email lives in a junk folder (incl. the aggregate "All Junk" view)
|
||||||
|
// 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, {
|
const ACTION_CONFIG: Record<HoverAction, {
|
||||||
@@ -72,6 +78,9 @@ export function EmailHoverActions({
|
|||||||
onArchive,
|
onArchive,
|
||||||
onSetColorTag,
|
onSetColorTag,
|
||||||
onMarkAsSpam,
|
onMarkAsSpam,
|
||||||
|
isInJunk = false,
|
||||||
|
onUndoSpam,
|
||||||
|
spamApplicable = true,
|
||||||
}: EmailHoverActionsProps) {
|
}: EmailHoverActionsProps) {
|
||||||
const hoverActions = useSettingsStore((state) => state.hoverActions);
|
const hoverActions = useSettingsStore((state) => state.hoverActions);
|
||||||
const hoverActionsMode = useSettingsStore((state) => state.hoverActionsMode);
|
const hoverActionsMode = useSettingsStore((state) => state.hoverActionsMode);
|
||||||
@@ -106,7 +115,8 @@ export function EmailHoverActions({
|
|||||||
onSetColorTag?.(null);
|
onSetColorTag?.(null);
|
||||||
break;
|
break;
|
||||||
case "spam":
|
case "spam":
|
||||||
onMarkAsSpam?.();
|
if (isInJunk) onUndoSpam?.();
|
||||||
|
else onMarkAsSpam?.();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -114,22 +124,31 @@ export function EmailHoverActions({
|
|||||||
const actionButtons = hoverActions.map((actionId) => {
|
const actionButtons = hoverActions.map((actionId) => {
|
||||||
const config = ACTION_CONFIG[actionId];
|
const config = ACTION_CONFIG[actionId];
|
||||||
if (!config) return null;
|
if (!config) return null;
|
||||||
|
if (actionId === "spam" && !spamApplicable) return null;
|
||||||
const Icon = config.icon;
|
const Icon = config.icon;
|
||||||
|
|
||||||
|
// In a junk context the spam action becomes "not spam".
|
||||||
|
const isNotSpam = actionId === "spam" && isInJunk;
|
||||||
const DisplayIcon = actionId === "markRead"
|
const DisplayIcon = actionId === "markRead"
|
||||||
? (isUnread ? MailOpen : Mail)
|
? (isUnread ? MailOpen : Mail)
|
||||||
: actionId === "star" && isStarred
|
: actionId === "star" && isStarred
|
||||||
? Star
|
? Star
|
||||||
: Icon;
|
: isNotSpam
|
||||||
|
? ShieldCheck
|
||||||
|
: Icon;
|
||||||
|
const title = isNotSpam ? t("not_spam") : t(config.titleKey);
|
||||||
|
const className = isNotSpam
|
||||||
|
? "hover:text-green-600 dark:hover:text-green-400"
|
||||||
|
: config.className;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={actionId}
|
key={actionId}
|
||||||
onClick={(e) => handleAction(e, actionId)}
|
onClick={(e) => handleAction(e, actionId)}
|
||||||
title={t(config.titleKey)}
|
title={title}
|
||||||
className={cn(
|
className={cn(
|
||||||
"p-1.5 rounded-md transition-colors duration-100 text-muted-foreground hover:bg-black/5 dark:hover:bg-white/10",
|
"p-1.5 rounded-md transition-colors duration-100 text-muted-foreground hover:bg-black/5 dark:hover:bg-white/10",
|
||||||
config.className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<DisplayIcon
|
<DisplayIcon
|
||||||
@@ -162,16 +181,17 @@ export function EmailHoverActions({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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
|
<div
|
||||||
className={cn("w-8 h-full", hoverBackgroundClassName)}
|
className={cn(
|
||||||
style={{
|
"w-8 h-full",
|
||||||
WebkitMaskImage: "linear-gradient(to right, transparent, black)",
|
"[mask-image:linear-gradient(to_right,transparent,black)] [-webkit-mask-image:linear-gradient(to_right,transparent,black)]",
|
||||||
maskImage: "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}
|
{actionButtons}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Mail, Tag } from 'lucide-react';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { Email, Identity } from '@/lib/jmap/types';
|
import type { Email, Identity } from '@/lib/jmap/types';
|
||||||
import { parseSubAddress } from '@/lib/sub-addressing';
|
import { parseSubAddress } from '@/lib/sub-addressing';
|
||||||
|
import { isAuthenticationSpoofed } from '@/lib/email-headers';
|
||||||
import { useSettingsStore } from '@/stores/settings-store';
|
import { useSettingsStore } from '@/stores/settings-store';
|
||||||
|
|
||||||
interface EmailIdentityBadgeProps {
|
interface EmailIdentityBadgeProps {
|
||||||
@@ -29,10 +30,17 @@ export function EmailIdentityBadge({
|
|||||||
// Parse the from address to check for sub-addressing
|
// Parse the from address to check for sub-addressing
|
||||||
const parsedFrom = parseSubAddress(fromAddress, subAddressDelimiter);
|
const parsedFrom = parseSubAddress(fromAddress, subAddressDelimiter);
|
||||||
|
|
||||||
// Find matching identity (email sent BY the user)
|
// Find matching identity (email sent BY the user). When the message is
|
||||||
const matchingIdentity = identities.find(
|
// likely spoofed, the From address can't be trusted, so we ignore any
|
||||||
(identity) => identity.email === fromAddress || identity.email === `${parsedFrom.baseUser}@${parsedFrom.domain}`
|
// identity match — otherwise a forged From matching one of the user's own
|
||||||
);
|
// addresses would render a misleading "via <identity>" badge that implies
|
||||||
|
// legitimacy.
|
||||||
|
const spoofed = isAuthenticationSpoofed(email.authenticationResults);
|
||||||
|
const matchingIdentity = spoofed
|
||||||
|
? undefined
|
||||||
|
: identities.find(
|
||||||
|
(identity) => identity.email === fromAddress || identity.email === `${parsedFrom.baseUser}@${parsedFrom.domain}`
|
||||||
|
);
|
||||||
|
|
||||||
// Check if email was sent TO a sub-address (received email)
|
// Check if email was sent TO a sub-address (received email)
|
||||||
let receivedToTag: string | null = null;
|
let receivedToTag: string | null = null;
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { useCallback } from "react";
|
|||||||
import { formatDate, stripInvisibleLeading } from "@/lib/utils";
|
import { formatDate, stripInvisibleLeading } from "@/lib/utils";
|
||||||
import { Email } from "@/lib/jmap/types";
|
import { Email } from "@/lib/jmap/types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Avatar } from "@/components/ui/avatar";
|
import { SelectableAvatar } from "@/components/email/selectable-avatar";
|
||||||
import { Paperclip, Star, Circle, CheckSquare, Square, Reply, Forward } from "lucide-react";
|
import { Paperclip, Star, Pin, Circle, CheckSquare, Square, Reply, Forward } from "lucide-react";
|
||||||
import { useEmailStore } from "@/stores/email-store";
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
@@ -29,25 +29,32 @@ interface EmailListItemProps {
|
|||||||
onArchive?: () => void;
|
onArchive?: () => void;
|
||||||
onSetColorTag?: (color: string | null) => void;
|
onSetColorTag?: (color: string | null) => void;
|
||||||
onMarkAsSpam?: () => void;
|
onMarkAsSpam?: () => void;
|
||||||
|
onUndoSpam?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EmailListItem({ email, selected, onClick, onDoubleClick, onContextMenu, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam }: EmailListItemProps) {
|
export function EmailListItem({ email, selected, onClick, onDoubleClick, onContextMenu, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam, onUndoSpam }: EmailListItemProps) {
|
||||||
const t = useTranslations('email_viewer');
|
const t = useTranslations('email_viewer');
|
||||||
const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, mailboxes, clearSelection } = useEmailStore();
|
const tBatch = useTranslations('email_list.batch_actions');
|
||||||
|
const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, mailboxes, clearSelection, isUnifiedView, unifiedRole } = useEmailStore();
|
||||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||||
const density = useSettingsStore((state) => state.density);
|
const density = useSettingsStore((state) => state.density);
|
||||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||||
|
const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag);
|
||||||
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
|
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
|
||||||
const { identities } = useAuthStore();
|
const { identities } = useAuthStore();
|
||||||
const isChecked = selectedEmailIds.has(email.id);
|
const isChecked = selectedEmailIds.has(email.id);
|
||||||
const isUnread = !email.keywords?.$seen;
|
const isUnread = !email.keywords?.$seen;
|
||||||
const isStarred = email.keywords?.$flagged;
|
const isStarred = email.keywords?.$flagged;
|
||||||
|
const isPinned = email.keywords?.['$pinned'] === true;
|
||||||
const isImportant = email.keywords?.["$important"];
|
const isImportant = email.keywords?.["$important"];
|
||||||
const isAnswered = email.keywords?.$answered;
|
const isAnswered = email.keywords?.$answered;
|
||||||
const isForwarded = email.keywords?.$forwarded;
|
const isForwarded = email.keywords?.$forwarded;
|
||||||
// In Sent/Drafts folders, show recipient instead of sender (which is always "me")
|
// In Sent/Drafts folders, show recipient instead of sender (which is always "me").
|
||||||
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role;
|
// In aggregate role-views the selected mailbox is virtual → fall back to the
|
||||||
|
// unified role so junk-contextual UI (spam ↔ not-spam) and avatar hiding work.
|
||||||
|
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role
|
||||||
|
?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined);
|
||||||
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
|
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
|
||||||
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
|
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
|
||||||
const isMobile = useUIStore((state) => state.isMobile);
|
const isMobile = useUIStore((state) => state.isMobile);
|
||||||
@@ -62,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' });
|
const keywordDefs = colorTagIds.map(id => emailKeywords.find(k => k.id === id) ?? { id, label: id, color: 'gray' });
|
||||||
// Use first tag for background coloring
|
// Use first tag for background coloring
|
||||||
const keywordDef = keywordDefs[0] ?? null;
|
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
|
// Drag and drop functionality
|
||||||
const { dragHandlers, isDragging } = useEmailDrag({
|
const { dragHandlers, isDragging } = useEmailDrag({
|
||||||
@@ -83,7 +90,14 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
|
|||||||
|
|
||||||
const handleCheckboxClick = (e: React.MouseEvent) => {
|
const handleCheckboxClick = (e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
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) => {
|
const handleContextMenu = (e: React.MouseEvent) => {
|
||||||
@@ -162,19 +176,22 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
|
|||||||
|
|
||||||
{/* Unread indicator */}
|
{/* Unread indicator */}
|
||||||
{isUnread && (
|
{isUnread && (
|
||||||
<div className="absolute left-1 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" />
|
<Circle className="w-2 h-2 fill-unread text-unread" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Avatar */}
|
{/* Avatar */}
|
||||||
{density !== 'extra-compact' && (
|
{density !== 'extra-compact' && (
|
||||||
<Avatar
|
<SelectableAvatar
|
||||||
name={sender?.name}
|
name={sender?.name}
|
||||||
email={sender?.email}
|
email={sender?.email}
|
||||||
size={isFocusedMailLayout ? "sm" : "md"}
|
size={isFocusedMailLayout ? "sm" : "md"}
|
||||||
className="flex-shrink-0 shadow-sm"
|
className="flex-shrink-0 shadow-sm"
|
||||||
disableImages={hideJunkAvatarImages}
|
disableImages={hideJunkAvatarImages}
|
||||||
|
checked={isChecked}
|
||||||
|
onToggle={() => toggleEmailSelection(email.id)}
|
||||||
|
selectLabel={tBatch('select')}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -191,17 +208,18 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
|
|||||||
</span>
|
</span>
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
|
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
'shrink-0 truncate',
|
'min-w-0 truncate',
|
||||||
isUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
|
isUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
|
||||||
)}>
|
)}>
|
||||||
{email.subject || t('no_subject')}
|
{email.subject || t('no_subject')}
|
||||||
</span>
|
</span>
|
||||||
{inlinePreview && (
|
{inlinePreview && (
|
||||||
<span className="min-w-0 truncate text-muted-foreground">{inlinePreview}</span>
|
<span className="min-w-0 shrink-[9999] truncate text-muted-foreground">{inlinePreview}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2.5 shrink-0">
|
<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" />}
|
{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" />}
|
{isImportant && <span className="h-2 w-2 rounded-full bg-warning" />}
|
||||||
{isAnswered && !isForwarded && <Reply className="w-3.5 h-3.5 text-muted-foreground" />}
|
{isAnswered && !isForwarded && <Reply className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||||
@@ -238,6 +256,9 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
|
|||||||
{sender?.name || sender?.email || "Unknown"}
|
{sender?.name || sender?.email || "Unknown"}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
|
{isPinned && (
|
||||||
|
<Pin className="w-3.5 h-3.5 text-primary" />
|
||||||
|
)}
|
||||||
{isStarred && (
|
{isStarred && (
|
||||||
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
|
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
|
||||||
)}
|
)}
|
||||||
@@ -321,6 +342,9 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
|
|||||||
onArchive={onArchive}
|
onArchive={onArchive}
|
||||||
onSetColorTag={onSetColorTag}
|
onSetColorTag={onSetColorTag}
|
||||||
onMarkAsSpam={onMarkAsSpam}
|
onMarkAsSpam={onMarkAsSpam}
|
||||||
|
onUndoSpam={onUndoSpam}
|
||||||
|
isInJunk={currentMailboxRole === 'junk'}
|
||||||
|
spamApplicable={!['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || '')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ interface EmailListProps {
|
|||||||
onForward?: (email: Email) => void;
|
onForward?: (email: Email) => void;
|
||||||
onMarkAsRead?: (email: Email, read: boolean) => void;
|
onMarkAsRead?: (email: Email, read: boolean) => void;
|
||||||
onToggleStar?: (email: Email) => void;
|
onToggleStar?: (email: Email) => void;
|
||||||
|
onTogglePinned?: (email: Email) => void;
|
||||||
onDelete?: (email: Email) => void;
|
onDelete?: (email: Email) => void;
|
||||||
onArchive?: (email: Email) => void;
|
onArchive?: (email: Email) => void;
|
||||||
onSetColorTag?: (emailId: string, color: string | null) => void;
|
onSetColorTag?: (emailId: string, color: string | null) => void;
|
||||||
@@ -64,6 +65,7 @@ export function EmailList({
|
|||||||
onForward,
|
onForward,
|
||||||
onMarkAsRead,
|
onMarkAsRead,
|
||||||
onToggleStar,
|
onToggleStar,
|
||||||
|
onTogglePinned,
|
||||||
onDelete,
|
onDelete,
|
||||||
onArchive,
|
onArchive,
|
||||||
onSetColorTag,
|
onSetColorTag,
|
||||||
@@ -100,19 +102,32 @@ export function EmailList({
|
|||||||
isLoadingThread,
|
isLoadingThread,
|
||||||
toggleThreadExpansion,
|
toggleThreadExpansion,
|
||||||
fetchThreadEmails,
|
fetchThreadEmails,
|
||||||
|
markThreadAsRead,
|
||||||
|
collapseAllThreads,
|
||||||
|
threadEmailCounts,
|
||||||
searchFilters,
|
searchFilters,
|
||||||
setSearchFilters,
|
setSearchFilters,
|
||||||
clearSearchFilters,
|
clearSearchFilters,
|
||||||
advancedSearch,
|
advancedSearch,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
|
isUnifiedView,
|
||||||
|
unifiedRole,
|
||||||
} = useEmailStore();
|
} = useEmailStore();
|
||||||
|
|
||||||
|
// In aggregate role-views (e.g. "All Junk") the selected mailbox is virtual, so
|
||||||
|
// there is no concrete mailbox to read the role from. Fall back to the unified
|
||||||
|
// role so contextual actions (e.g. mark-as-spam ↔ not-spam) behave as if inside
|
||||||
|
// that role's folder.
|
||||||
|
const effectiveMailboxRole =
|
||||||
|
mailboxes.find(m => m.id === selectedMailbox)?.role
|
||||||
|
?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined);
|
||||||
|
|
||||||
const disableThreading = useSettingsStore((state) => state.disableThreading);
|
const disableThreading = useSettingsStore((state) => state.disableThreading);
|
||||||
|
|
||||||
const threadGroups = useMemo(() => {
|
const threadGroups = useMemo(() => {
|
||||||
const groups = groupEmailsByThread(emails, disableThreading || isScheduledView);
|
const groups = groupEmailsByThread(emails, disableThreading || isScheduledView, threadEmailCounts);
|
||||||
return sortThreadGroups(groups);
|
return sortThreadGroups(groups);
|
||||||
}, [emails, disableThreading, isScheduledView]);
|
}, [emails, disableThreading, isScheduledView, threadEmailCounts]);
|
||||||
|
|
||||||
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Email>();
|
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Email>();
|
||||||
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||||
@@ -250,10 +265,12 @@ export function EmailList({
|
|||||||
if (!isExpanded && client) {
|
if (!isExpanded && client) {
|
||||||
toggleThreadExpansion(threadId);
|
toggleThreadExpansion(threadId);
|
||||||
await fetchThreadEmails(client, threadId);
|
await fetchThreadEmails(client, threadId);
|
||||||
|
// Mark all unread emails in this thread as read
|
||||||
|
void markThreadAsRead(client, threadId);
|
||||||
} else {
|
} else {
|
||||||
toggleThreadExpansion(threadId);
|
toggleThreadExpansion(threadId);
|
||||||
}
|
}
|
||||||
}, [client, expandedThreadIds, toggleThreadExpansion, fetchThreadEmails]);
|
}, [client, expandedThreadIds, toggleThreadExpansion, fetchThreadEmails, markThreadAsRead]);
|
||||||
|
|
||||||
// Range-based load more: trigger when last visible item is near the end.
|
// Range-based load more: trigger when last visible item is near the end.
|
||||||
// Debounce to prevent rapid cascade when thread grouping reduces item
|
// Debounce to prevent rapid cascade when thread grouping reduces item
|
||||||
@@ -400,9 +417,9 @@ export function EmailList({
|
|||||||
className="text-destructive border-destructive/30 hover:bg-destructive/10 text-xs"
|
className="text-destructive border-destructive/30 hover:bg-destructive/10 text-xs"
|
||||||
>
|
>
|
||||||
{isProcessing ? (
|
{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')}
|
{t('empty_folder.button')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -473,7 +490,18 @@ export function EmailList({
|
|||||||
isLoading={isLoadingThread === thread.threadId}
|
isLoading={isLoadingThread === thread.threadId}
|
||||||
expandedEmails={threadEmailsCache.get(thread.threadId)}
|
expandedEmails={threadEmailsCache.get(thread.threadId)}
|
||||||
onToggleExpand={() => handleToggleThreadExpansion(thread.threadId)}
|
onToggleExpand={() => handleToggleThreadExpansion(thread.threadId)}
|
||||||
onEmailSelect={(email) => onEmailSelect?.(email)}
|
onCollapseAllThreads={collapseAllThreads}
|
||||||
|
onEmailSelect={(email) => {
|
||||||
|
// Collapse expanded threads when selecting an email outside the expanded thread
|
||||||
|
const currentExpanded = useEmailStore.getState().expandedThreadIds;
|
||||||
|
if (currentExpanded.size > 0) {
|
||||||
|
// Check if the selected email belongs to any expanded thread via its threadId
|
||||||
|
if (!email.threadId || !currentExpanded.has(email.threadId)) {
|
||||||
|
collapseAllThreads();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onEmailSelect?.(email);
|
||||||
|
}}
|
||||||
onEmailDoubleClick={onEmailDoubleClick ? (email) => onEmailDoubleClick(email) : undefined}
|
onEmailDoubleClick={onEmailDoubleClick ? (email) => onEmailDoubleClick(email) : undefined}
|
||||||
onContextMenu={openContextMenu}
|
onContextMenu={openContextMenu}
|
||||||
onOpenConversation={onOpenConversation}
|
onOpenConversation={onOpenConversation}
|
||||||
@@ -483,6 +511,7 @@ export function EmailList({
|
|||||||
onArchive={onArchive ? (email) => onArchive(email) : undefined}
|
onArchive={onArchive ? (email) => onArchive(email) : undefined}
|
||||||
onSetColorTag={onSetColorTag}
|
onSetColorTag={onSetColorTag}
|
||||||
onMarkAsSpam={onMarkAsSpam ? (email) => onMarkAsSpam(email) : undefined}
|
onMarkAsSpam={onMarkAsSpam ? (email) => onMarkAsSpam(email) : undefined}
|
||||||
|
onUndoSpam={onUndoSpam ? (email) => onUndoSpam(email) : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -516,7 +545,7 @@ export function EmailList({
|
|||||||
menuRef={menuRef}
|
menuRef={menuRef}
|
||||||
mailboxes={mailboxes}
|
mailboxes={mailboxes}
|
||||||
selectedMailbox={selectedMailbox}
|
selectedMailbox={selectedMailbox}
|
||||||
currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role}
|
currentMailboxRole={effectiveMailboxRole}
|
||||||
isMultiSelect={selectedEmailIds.has(contextMenu.data.id)}
|
isMultiSelect={selectedEmailIds.has(contextMenu.data.id)}
|
||||||
selectedCount={selectedEmailIds.size}
|
selectedCount={selectedEmailIds.size}
|
||||||
onReply={() => onReply?.(contextMenu.data!)}
|
onReply={() => onReply?.(contextMenu.data!)}
|
||||||
@@ -524,6 +553,7 @@ export function EmailList({
|
|||||||
onForward={() => onForward?.(contextMenu.data!)}
|
onForward={() => onForward?.(contextMenu.data!)}
|
||||||
onMarkAsRead={(read) => onMarkAsRead?.(contextMenu.data!, read)}
|
onMarkAsRead={(read) => onMarkAsRead?.(contextMenu.data!, read)}
|
||||||
onToggleStar={() => onToggleStar?.(contextMenu.data!)}
|
onToggleStar={() => onToggleStar?.(contextMenu.data!)}
|
||||||
|
onTogglePinned={onTogglePinned ? () => onTogglePinned(contextMenu.data!) : undefined}
|
||||||
onDelete={() => onDelete?.(contextMenu.data!)}
|
onDelete={() => onDelete?.(contextMenu.data!)}
|
||||||
onArchive={() => onArchive?.(contextMenu.data!)}
|
onArchive={() => onArchive?.(contextMenu.data!)}
|
||||||
onSetColorTag={(color) => onSetColorTag?.(contextMenu.data!.id, color)}
|
onSetColorTag={(color) => onSetColorTag?.(contextMenu.data!.id, color)}
|
||||||
|
|||||||
+709
-1309
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,196 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Node as TiptapNode, mergeAttributes } from "@tiptap/core";
|
||||||
|
import { DOMSerializer } from "@tiptap/pm/model";
|
||||||
|
import type { Editor } from "@tiptap/react";
|
||||||
|
|
||||||
|
import { buildSignatureBlock } from "@/components/email/signature-block";
|
||||||
|
|
||||||
|
// Marker attribute that identifies the quoted-original wrapper in serialized
|
||||||
|
// HTML, so parseHTML can recognise it on the way back in.
|
||||||
|
export const QUOTED_HTML_MARKER = "data-quoted-html";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* QuotedHtml — an atomic block node that carries the *verbatim* HTML of a
|
||||||
|
* quoted/forwarded original email. The HTML is stored in the `html` attribute
|
||||||
|
* and is NEVER parsed into the ProseMirror schema, so layout-heavy emails
|
||||||
|
* (nested tables, MJML, Outlook divs) survive a reply/forward 1:1.
|
||||||
|
*
|
||||||
|
* Behaviour:
|
||||||
|
* - To ProseMirror it's a single atomic block: Backspace at the boundary or
|
||||||
|
* Ctrl+A + Delete removes the whole quote in one go ("wie es sich gehört").
|
||||||
|
* - Its NodeView renders an inner `contentEditable` region so the user can
|
||||||
|
* still redact text inside the quote. Inner edits are synced back into the
|
||||||
|
* `html` attribute (without polluting the undo history).
|
||||||
|
*
|
||||||
|
* Serialization: ProseMirror's DOM serializer can't emit an atom's inner raw
|
||||||
|
* HTML, so use `serializeEditorContent(editor)` (below) instead of
|
||||||
|
* `editor.getHTML()` to read the composer body for sending/draft-saving.
|
||||||
|
*/
|
||||||
|
export const QuotedHtml = TiptapNode.create({
|
||||||
|
name: "quotedHtml",
|
||||||
|
group: "block",
|
||||||
|
atom: true,
|
||||||
|
selectable: true,
|
||||||
|
draggable: false,
|
||||||
|
// Isolating keeps selection/gapcursor behaviour sane at the boundary.
|
||||||
|
isolating: true,
|
||||||
|
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
html: {
|
||||||
|
default: "",
|
||||||
|
// Capture the verbatim inner HTML when parsing. Because the node is
|
||||||
|
// an atom, ProseMirror does NOT descend into the children, so they
|
||||||
|
// never hit the schema.
|
||||||
|
parseHTML: (el) => el.innerHTML,
|
||||||
|
// Not rendered as an attribute - the real content round-trips via the
|
||||||
|
// custom serializer. renderHTML below only needs the wrapper.
|
||||||
|
renderHTML: () => ({}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [{ tag: `div[${QUOTED_HTML_MARKER}]` }];
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
// Only used for ProseMirror's internal/clipboard round-trip. The send /
|
||||||
|
// draft path uses serializeEditorContent() which inlines attrs.html.
|
||||||
|
return ["div", mergeAttributes(HTMLAttributes, { [QUOTED_HTML_MARKER]: "" })];
|
||||||
|
},
|
||||||
|
|
||||||
|
addNodeView() {
|
||||||
|
return ({ node, editor, getPos }) => {
|
||||||
|
// Host element ProseMirror manages. A subtle left border echoes the
|
||||||
|
// classic email quote bar without touching the quoted content's styling.
|
||||||
|
const dom = document.createElement("div");
|
||||||
|
dom.setAttribute(QUOTED_HTML_MARKER, "");
|
||||||
|
dom.className = "quoted-html-island";
|
||||||
|
dom.style.cssText =
|
||||||
|
"border-left:2px solid #c5c5c5;padding-left:12px;margin-top:8px;";
|
||||||
|
|
||||||
|
// CRITICAL: render the quoted email inside a Shadow Root. The app's
|
||||||
|
// global CSS (Tailwind preflight, .tiptap table/td rules, box-sizing
|
||||||
|
// resets) would otherwise cascade INTO the quote and destroy its layout
|
||||||
|
// - even though the verbatim HTML serializes/sends perfectly. Shadow DOM
|
||||||
|
// isolates both directions: only the browser's UA defaults + the email's
|
||||||
|
// own inline styles apply, so the in-editor rendering matches the sent
|
||||||
|
// mail 1:1.
|
||||||
|
const shadow = dom.attachShadow({ mode: "open" });
|
||||||
|
const inner = document.createElement("div");
|
||||||
|
inner.contentEditable = "true";
|
||||||
|
inner.style.cssText = "outline:none;";
|
||||||
|
inner.innerHTML = node.attrs.html || "";
|
||||||
|
shadow.appendChild(inner);
|
||||||
|
|
||||||
|
// Track focus via focusin/focusout: inside a shadow root,
|
||||||
|
// document.activeElement is retargeted to the host, so we can't rely on
|
||||||
|
// it to detect "is the user editing in here".
|
||||||
|
let focused = false;
|
||||||
|
inner.addEventListener("focusin", () => {
|
||||||
|
focused = true;
|
||||||
|
});
|
||||||
|
inner.addEventListener("focusout", () => {
|
||||||
|
focused = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sync inner edits back into the node attribute. Coalesced via rAF so a
|
||||||
|
// burst of keystrokes is one transaction; addToHistory:false keeps
|
||||||
|
// redaction edits out of the editor's undo stack. The `input` event is
|
||||||
|
// composed and crosses the shadow boundary, so this listener fires.
|
||||||
|
let frame = 0;
|
||||||
|
const syncBack = () => {
|
||||||
|
cancelAnimationFrame(frame);
|
||||||
|
frame = requestAnimationFrame(() => {
|
||||||
|
if (typeof getPos !== "function") return;
|
||||||
|
const pos = getPos();
|
||||||
|
if (pos == null) return;
|
||||||
|
const current = inner.innerHTML;
|
||||||
|
if (current === node.attrs.html) return;
|
||||||
|
editor.view.dispatch(
|
||||||
|
editor.view.state.tr
|
||||||
|
.setNodeAttribute(pos, "html", current)
|
||||||
|
.setMeta("addToHistory", false)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
inner.addEventListener("input", syncBack);
|
||||||
|
|
||||||
|
return {
|
||||||
|
dom,
|
||||||
|
// ProseMirror must not try to reconcile the foreign shadow content.
|
||||||
|
ignoreMutation: () => true,
|
||||||
|
// Events originating inside the island are retargeted to the host
|
||||||
|
// (`dom`) once they cross the shadow boundary, so dom.contains(target)
|
||||||
|
// is true for them → let the native shadow contentEditable handle
|
||||||
|
// them. Events from the surrounding doc (boundary Backspace,
|
||||||
|
// Ctrl+A+Delete) target other elements → fall through to ProseMirror
|
||||||
|
// so whole-block deletion still works.
|
||||||
|
stopEvent: (event) => {
|
||||||
|
const target = event.target as Node | null;
|
||||||
|
return !!target && dom.contains(target);
|
||||||
|
},
|
||||||
|
update: (updatedNode) => {
|
||||||
|
if (updatedNode.type.name !== "quotedHtml") return false;
|
||||||
|
// Don't clobber the caret while the user is redacting inside.
|
||||||
|
if (!focused && inner.innerHTML !== updatedNode.attrs.html) {
|
||||||
|
inner.innerHTML = updatedNode.attrs.html || "";
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
destroy: () => {
|
||||||
|
cancelAnimationFrame(frame);
|
||||||
|
inner.removeEventListener("input", syncBack);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize the composer document to HTML for sending / draft-saving.
|
||||||
|
*
|
||||||
|
* Use this INSTEAD of `editor.getHTML()`: ProseMirror's DOM serializer cannot
|
||||||
|
* emit the raw inner HTML of an atom node, so it would drop the quoted body.
|
||||||
|
* Here we walk the top-level nodes, inline the quote node's verbatim `html`,
|
||||||
|
* and serialize everything else normally.
|
||||||
|
*/
|
||||||
|
export function serializeEditorContent(editor: Editor): string {
|
||||||
|
const serializer = DOMSerializer.fromSchema(editor.schema);
|
||||||
|
const parts: string[] = [];
|
||||||
|
editor.state.doc.forEach((node) => {
|
||||||
|
if (node.type.name === "quotedHtml") {
|
||||||
|
// Emit the SAME wrapper buildQuotedHtmlBlock produces, so a saved draft
|
||||||
|
// round-trips: re-opening parses this back into a QuotedHtml node
|
||||||
|
// instead of letting the schema mangle the raw table layout again.
|
||||||
|
parts.push(buildQuotedHtmlBlock((node.attrs.html as string) || ""));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (node.type.name === "signatureBlock") {
|
||||||
|
// Same rationale as quotedHtml: inline the verbatim signature HTML so the
|
||||||
|
// styled signature reaches the recipient (and a saved draft round-trips)
|
||||||
|
// instead of the schema-flattened version.
|
||||||
|
parts.push(buildSignatureBlock((node.attrs.html as string) || ""));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fragment = serializer.serializeNode(node);
|
||||||
|
const tmp = document.createElement("div");
|
||||||
|
tmp.appendChild(fragment);
|
||||||
|
parts.push(tmp.innerHTML);
|
||||||
|
});
|
||||||
|
return parts.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the editor-content wrapper that the composer prepends/appends so the
|
||||||
|
* quoted original becomes a single QuotedHtml node. The inner HTML must be
|
||||||
|
* pre-sanitized (scripts/styles/head stripped, cid: images rewritten).
|
||||||
|
*
|
||||||
|
* The `data-quoted-html` marker is what parseHTML keys on, so this exact form
|
||||||
|
* must be what serializeEditorContent emits too (round-trip consistency).
|
||||||
|
*/
|
||||||
|
export function buildQuotedHtmlBlock(sanitizedInnerHtml: string): string {
|
||||||
|
return `<div ${QUOTED_HTML_MARKER}>${sanitizedInnerHtml}</div>`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { MailCheck, Loader2, CheckCircle, X } from 'lucide-react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
|
interface ReadReceiptBannerProps {
|
||||||
|
/** Address that requested the receipt (Disposition-Notification-To). */
|
||||||
|
requestedBy: string;
|
||||||
|
/** Sends the MDN. Should resolve when the receipt has been submitted. */
|
||||||
|
onSend: () => Promise<void>;
|
||||||
|
/** Suppresses the request without sending (sets $MDNSent server-side). */
|
||||||
|
onIgnore: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReadReceiptBanner({ requestedBy, onSend, onIgnore }: ReadReceiptBannerProps) {
|
||||||
|
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-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 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user