Merge upstream/main to resolve conflicts
Both sides added adjacent LOGIN_* config entries (upstream: loginShowHeading/loginShowSubtitle/logo sizing; this branch: loginShowTotp/loginShowVersion) — resolution keeps both. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LrR2CVfvcPWxr9ub299VwW
This commit is contained in:
@@ -9,3 +9,8 @@ scripts/
|
|||||||
TODO.md
|
TODO.md
|
||||||
*.md
|
*.md
|
||||||
!README.md
|
!README.md
|
||||||
|
# Sibling projects / test harness - not part of the webmail image
|
||||||
|
examples/
|
||||||
|
integration/
|
||||||
|
e2e/
|
||||||
|
**/node_modules
|
||||||
|
|||||||
@@ -118,3 +118,114 @@ jobs:
|
|||||||
- name: Inspect image
|
- name: Inspect image
|
||||||
run: |
|
run: |
|
||||||
docker buildx imagetools inspect ${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
|
docker buildx imagetools inspect ${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
|
||||||
|
|
||||||
|
build-always:
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- platform: linux/amd64
|
||||||
|
runner: ubuntu-latest
|
||||||
|
- platform: linux/arm64
|
||||||
|
runner: ubuntu-24.04-arm
|
||||||
|
runs-on: ${{ matrix.runner }}
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to GHCR
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.IMAGE_NAME }}
|
||||||
|
|
||||||
|
- name: Build and push by digest
|
||||||
|
id: build
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: ${{ matrix.platform }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
build-args: |
|
||||||
|
GIT_COMMIT=${{ github.sha }}
|
||||||
|
NEXT_PUBLIC_LOCALE_PREFIX=always
|
||||||
|
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
||||||
|
cache-from: type=gha,scope=always-${{ matrix.platform }}
|
||||||
|
cache-to: type=gha,mode=max,scope=always-${{ matrix.platform }}
|
||||||
|
|
||||||
|
- name: Export digest
|
||||||
|
run: |
|
||||||
|
mkdir -p /tmp/digests-always
|
||||||
|
digest="${{ steps.build.outputs.digest }}"
|
||||||
|
touch "/tmp/digests-always/${digest#sha256:}"
|
||||||
|
|
||||||
|
- name: Upload digest
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: digests-always-${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }}
|
||||||
|
path: /tmp/digests-always/*
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
merge-always:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: build-always
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
steps:
|
||||||
|
- name: Download digests
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: /tmp/digests-always
|
||||||
|
pattern: digests-always-*
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to GHCR
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.IMAGE_NAME }}
|
||||||
|
flavor: |
|
||||||
|
suffix=-always,onlatest=true
|
||||||
|
tags: |
|
||||||
|
type=raw,value=latest
|
||||||
|
type=semver,pattern=v{{version}}
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern=v{{major}}.{{minor}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
type=semver,pattern=v{{major}}
|
||||||
|
type=semver,pattern={{major}}
|
||||||
|
|
||||||
|
- name: Create manifest list and push
|
||||||
|
working-directory: /tmp/digests-always
|
||||||
|
run: |
|
||||||
|
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||||
|
$(printf '${{ env.IMAGE_NAME }}@sha256:%s ' *)
|
||||||
|
|
||||||
|
- name: Inspect image
|
||||||
|
run: |
|
||||||
|
docker buildx imagetools inspect ${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
|
||||||
|
|||||||
@@ -1,5 +1,62 @@
|
|||||||
# 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)
|
## 1.7.6 (2026-06-28)
|
||||||
|
|
||||||
### Breaking Changes
|
### Breaking Changes
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ ENV NEXT_TELEMETRY_DISABLED=1
|
|||||||
# at build time, so it cannot be changed without rebuilding.
|
# at build time, so it cannot be changed without rebuilding.
|
||||||
ARG NEXT_PUBLIC_BASE_PATH=
|
ARG NEXT_PUBLIC_BASE_PATH=
|
||||||
ENV NEXT_PUBLIC_BASE_PATH=$NEXT_PUBLIC_BASE_PATH
|
ENV NEXT_PUBLIC_BASE_PATH=$NEXT_PUBLIC_BASE_PATH
|
||||||
|
# Optional: avoid next-intl rewrite loops when served under a subpath.
|
||||||
|
# Baked in at build time.
|
||||||
|
ARG NEXT_PUBLIC_LOCALE_PREFIX=
|
||||||
|
ENV NEXT_PUBLIC_LOCALE_PREFIX=$NEXT_PUBLIC_LOCALE_PREFIX
|
||||||
# Optional: fallback UI locale (e.g. tr, de, fr) used when the visitor's
|
# Optional: fallback UI locale (e.g. tr, de, fr) used when the visitor's
|
||||||
# Accept-Language header does not match any supported locale. Baked in at
|
# Accept-Language header does not match any supported locale. Baked in at
|
||||||
# build time because next-intl wires it into client-side routing too.
|
# build time because next-intl wires it into client-side routing too.
|
||||||
|
|||||||
+5
-5
@@ -4,9 +4,9 @@
|
|||||||
|
|
||||||
- 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 – combined Inbox, Sent, Drafts, Junk, Archive, and Trash, with group/shared accounts optionally merged in
|
- Unified Mailbox – combined Inbox, Sent, Drafts, Junk, Archive, and Trash, scoped by default to the active account and its shared/group folders, with an optional admin-gated cross-account mode that spans every connected account
|
||||||
- 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
|
- Aggregated All mail / Unread / Starred entries in the Unified Mailbox – scoped by the same account boundary (or all accounts in cross-account mode) and narrowed by a per-account folder selection; each 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
|
- Search inside the Unified Mailbox – text search across every unified view (the per-role mailboxes and the folder-selected All mail / Unread / Starred lists); advanced filters are additionally available in the per-role unified mailboxes
|
||||||
- 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 – 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
|
- 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
|
||||||
@@ -117,7 +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
|
- Shared / group (delegated) accounts: their folders appear alongside your own and can be merged into the Unified Mailbox ("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`)
|
||||||
|
|
||||||
@@ -125,7 +125,7 @@ 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
|
- Admin policy gates for the Unified Mailbox – enable or disable the All mail / Unread / Starred entries org-wide, plus a cross-account capability gate (off by default; auto-enabled on upgrade for instances that already used the cross-account views); 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)
|
||||||
- File-based secrets for JSON config: `passwordHashFile` (admin password), `sessionSecretFile`, and `oauthClientSecretFile` for Docker/Kubernetes secret mounts
|
- 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`)
|
- Admin toggle for search-engine indexing (`robots.txt` / `noindex`)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -1149,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>
|
||||||
@@ -1336,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)]",
|
||||||
@@ -1475,7 +1475,7 @@ 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}
|
||||||
@@ -1498,7 +1498,7 @@ 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}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ 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, getContactPrimaryEmail } from "@/stores/contact-store";
|
import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
|
||||||
import { savePendingMailto } from "@/lib/protocol-handlers/session";
|
import { savePendingMailto } from "@/lib/protocol-handlers/session";
|
||||||
import { formatRecipient } from "@/lib/email-composer-utils";
|
import { formatRecipient, formatRecipientEntry, type Recipient } 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";
|
||||||
@@ -513,23 +513,31 @@ export default function ContactsPage() {
|
|||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
const handleComposeGroupFromSidebar = useCallback((groupId: string, field: "to" | "cc" | "bcc") => {
|
const handleComposeGroupFromSidebar = useCallback((groupId: string, field: "to" | "cc" | "bcc") => {
|
||||||
// Format each member as "Name <email>" so the composer keeps the display
|
// Hand the composer a single group chip (RFC 5322 group syntax survives
|
||||||
// name (round-trips via formatRecipient -> parseRecipientList). Dedupe by
|
// the string hand-off) instead of one entry per member - the chip expands
|
||||||
// email, case-insensitively; members without an email are skipped.
|
// into the members when the message is sent. Dedupe by email,
|
||||||
|
// case-insensitively; members without an email are skipped.
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const recipients: string[] = [];
|
const members: Array<{ name?: string; email: string }> = [];
|
||||||
for (const member of getGroupMembers(groupId)) {
|
for (const member of getGroupMembers(groupId)) {
|
||||||
const email = getContactPrimaryEmail(member).trim();
|
const email = getContactPrimaryEmail(member).trim();
|
||||||
const key = email.toLowerCase();
|
const key = email.toLowerCase();
|
||||||
if (!email || seen.has(key)) continue;
|
if (!email || seen.has(key)) continue;
|
||||||
seen.add(key);
|
seen.add(key);
|
||||||
recipients.push(formatRecipient(getContactDisplayName(member), email));
|
const name = getContactDisplayName(member);
|
||||||
|
members.push({ name: name && name !== email ? name : undefined, email });
|
||||||
}
|
}
|
||||||
if (recipients.length === 0) {
|
if (members.length === 0) {
|
||||||
toast.error(t("groups.no_member_emails"));
|
toast.error(t("groups.no_member_emails"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
openComposeInApp(recipients, field);
|
const group = useContactStore.getState().contacts.find((c) => c.id === groupId);
|
||||||
|
const chip: Recipient = {
|
||||||
|
name: (group && getContactDisplayName(group)) || "Group",
|
||||||
|
email: "",
|
||||||
|
group: { members },
|
||||||
|
};
|
||||||
|
openComposeInApp([formatRecipientEntry(chip)], field);
|
||||||
}, [getGroupMembers, t, openComposeInApp]);
|
}, [getGroupMembers, t, openComposeInApp]);
|
||||||
|
|
||||||
const handleComposeContact = useCallback((contact: ContactCard) => {
|
const handleComposeContact = useCallback((contact: ContactCard) => {
|
||||||
@@ -751,7 +759,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" />
|
||||||
@@ -869,7 +877,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)]",
|
||||||
@@ -937,7 +945,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"
|
||||||
)}
|
)}
|
||||||
@@ -991,7 +999,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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -477,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>
|
||||||
@@ -611,7 +611,7 @@ export default function FilesPage() {
|
|||||||
: '0%' }}
|
: '0%' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-xs text-muted-foreground tabular-nums text-right">
|
<p className="mt-2 text-xs text-muted-foreground tabular-nums text-end">
|
||||||
{migrationProgress.current} / {migrationProgress.total}
|
{migrationProgress.current} / {migrationProgress.total}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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, loginShowTotp, loginShowVersion, 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, loginShowTotp, loginShowVersion, 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: "",
|
||||||
@@ -718,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>
|
||||||
);
|
);
|
||||||
@@ -867,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>
|
||||||
);
|
);
|
||||||
@@ -881,19 +888,24 @@ export default function LoginPage() {
|
|||||||
<div className="rounded-2xl border border-border/60 bg-background/80 backdrop-blur-sm shadow-xl shadow-black/5 dark:shadow-black/20 overflow-hidden">
|
<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 */}
|
||||||
@@ -1121,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"
|
||||||
@@ -1246,9 +1258,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>
|
||||||
|
|||||||
+304
-92
@@ -11,7 +11,7 @@ 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, Mailbox, isUnifiedMailboxId, UNIFIED_ROLE_BY_ID, ALL_MAIL_MAILBOX_ID, CROSS_VIEW_BY_ID, isCrossViewId } from "@/lib/jmap/types";
|
import { ThreadGroup, Email, Mailbox, isUnifiedMailboxId, UNIFIED_ROLE_BY_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 { usePolicyStore } from "@/stores/policy-store";
|
||||||
import type { UnifiedAccountClient } from "@/lib/unified-mailbox";
|
import type { UnifiedAccountClient } from "@/lib/unified-mailbox";
|
||||||
@@ -70,12 +70,12 @@ import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
|
|||||||
import { useThemeStore } from "@/stores/theme-store";
|
import { useThemeStore } from "@/stores/theme-store";
|
||||||
import { consumePendingMailto, subscribeToPendingMailto } from "@/lib/protocol-handlers/session";
|
import { consumePendingMailto, subscribeToPendingMailto } from "@/lib/protocol-handlers/session";
|
||||||
import type { ParsedMailto } from "@/lib/protocol-handlers/mailto";
|
import type { ParsedMailto } from "@/lib/protocol-handlers/mailto";
|
||||||
import { plainTextToComposerBody } from "@/lib/email-composer-utils";
|
import { plainTextToComposerBody, getQuoteBodies } 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 { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
|
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
|
||||||
import { useLocaleStore } from "@/stores/locale-store";
|
import { getEffectiveLocale } from '@/i18n/detect-locale';
|
||||||
import type { QuoteHeader } from "@/lib/plugin-types";
|
import type { QuoteHeader } from "@/lib/plugin-types";
|
||||||
|
|
||||||
const SCHEDULED_MAILBOX_ID = '__scheduled__';
|
const SCHEDULED_MAILBOX_ID = '__scheduled__';
|
||||||
@@ -110,7 +110,7 @@ export default function Home() {
|
|||||||
const [conversationEmails, setConversationEmails] = useState<Email[]>([]);
|
const [conversationEmails, setConversationEmails] = useState<Email[]>([]);
|
||||||
const [isLoadingConversation, setIsLoadingConversation] = useState(false);
|
const [isLoadingConversation, setIsLoadingConversation] = useState(false);
|
||||||
const [rateLimitSecondsLeft, setRateLimitSecondsLeft] = useState<number | null>(null);
|
const [rateLimitSecondsLeft, setRateLimitSecondsLeft] = useState<number | null>(null);
|
||||||
const [previewAttachment, setPreviewAttachment] = useState<{ blobId: string; name: string; type?: string } | null>(null);
|
const [previewAttachment, setPreviewAttachment] = useState<{ blobId: string; name: string; type?: string; accountId?: string; clientAccountId?: string } | null>(null);
|
||||||
const [pendingMailtoAccountChoice, setPendingMailtoAccountChoice] = useState<ParsedMailto | null>(null);
|
const [pendingMailtoAccountChoice, setPendingMailtoAccountChoice] = useState<ParsedMailto | null>(null);
|
||||||
const [isProtocolAccountSwitching, setIsProtocolAccountSwitching] = useState(false);
|
const [isProtocolAccountSwitching, setIsProtocolAccountSwitching] = useState(false);
|
||||||
const markAsReadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const markAsReadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
@@ -121,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'));
|
||||||
@@ -342,15 +342,21 @@ export default function Home() {
|
|||||||
refreshCurrentMailbox,
|
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
|
// 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
|
// they require Unified Mailbox to be enabled, plus the admin gate and the
|
||||||
@@ -368,18 +374,36 @@ export default function Home() {
|
|||||||
const activeHasMore = isScheduledView ? scheduledHasMore : hasMoreEmails;
|
const activeHasMore = isScheduledView ? scheduledHasMore : hasMoreEmails;
|
||||||
const activeIsLoading = isScheduledView ? isLoadingScheduled : isLoading;
|
const activeIsLoading = isScheduledView ? isLoadingScheduled : isLoading;
|
||||||
const includeGroupInUnified = useSettingsStore((s) => s.includeGroupInUnified);
|
const includeGroupInUnified = useSettingsStore((s) => s.includeGroupInUnified);
|
||||||
|
const unifiedCrossAccount = useSettingsStore((s) => s.unifiedCrossAccount);
|
||||||
|
const unifiedCrossAccountGate = usePolicyStore((s) => s.isFeatureEnabled('unifiedCrossAccountEnabled'));
|
||||||
const accounts = useAccountStore((s) => s.accounts);
|
const accounts = useAccountStore((s) => s.accounts);
|
||||||
const connectedAccountsSignature = useMemo(
|
const connectedAccountsSignature = useMemo(
|
||||||
() => accounts.filter((a) => a.isConnected).map((a) => a.id).sort().join(","),
|
() => accounts.filter((a) => a.isConnected).map((a) => a.id).sort().join(","),
|
||||||
[accounts],
|
[accounts],
|
||||||
);
|
);
|
||||||
|
// Cross-account is "active" when the user opted in, the admin allows it, and
|
||||||
|
// more than one account is connected. Drives the sidebar header label: the
|
||||||
|
// old "All accounts" when spanning accounts, else "Unified Mailbox".
|
||||||
|
const crossAccountActive =
|
||||||
|
unifiedCrossAccount &&
|
||||||
|
unifiedCrossAccountGate &&
|
||||||
|
accounts.filter((a) => a.isConnected).length > 1;
|
||||||
|
|
||||||
// Builds the populated UnifiedAccountClient[] used by the unified-view
|
// Builds the populated UnifiedAccountClient[] used by the unified-view
|
||||||
// effects and one-shot actions in this page. Reads the includeGroup
|
// effects and one-shot actions in this page. Reads the settings at call time
|
||||||
// setting at call time so the latest toggle value is always honored.
|
// so the latest toggle values are always honored. When the cross-account
|
||||||
|
// sub-option is off, the unified mailbox stays within the active account
|
||||||
|
// boundary (its own + shared folders); when on, it spans every login account.
|
||||||
const buildPopulatedUnifiedAccounts = useCallback(async (): Promise<UnifiedAccountClient[]> => {
|
const buildPopulatedUnifiedAccounts = useCallback(async (): Promise<UnifiedAccountClient[]> => {
|
||||||
|
// Cross-account scope requires both the per-user opt-in and the admin
|
||||||
|
// capability gate; otherwise stay within the active account boundary.
|
||||||
|
const crossAccount = useSettingsStore.getState().unifiedCrossAccount
|
||||||
|
&& usePolicyStore.getState().isFeatureEnabled('unifiedCrossAccountEnabled');
|
||||||
return buildUnifiedAccountClients({
|
return buildUnifiedAccountClients({
|
||||||
includeGroup: useSettingsStore.getState().includeGroupInUnified,
|
includeGroup: useSettingsStore.getState().includeGroupInUnified,
|
||||||
|
scopeToClientAccountId: crossAccount
|
||||||
|
? undefined
|
||||||
|
: (useAccountStore.getState().activeAccountId ?? undefined),
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -612,6 +636,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);
|
||||||
@@ -759,8 +787,7 @@ export default function Home() {
|
|||||||
cc: selectedEmail.cc,
|
cc: selectedEmail.cc,
|
||||||
bcc: selectedEmail.bcc,
|
bcc: selectedEmail.bcc,
|
||||||
subject: selectedEmail.subject,
|
subject: selectedEmail.subject,
|
||||||
body: selectedEmail.bodyValues?.[selectedEmail.textBody?.[0]?.partId || '']?.value || selectedEmail.preview || '',
|
...getQuoteBodies(selectedEmail),
|
||||||
htmlBody: selectedEmail.bodyValues?.[selectedEmail.htmlBody?.[0]?.partId || '']?.value || undefined,
|
|
||||||
receivedAt: selectedEmail.receivedAt,
|
receivedAt: selectedEmail.receivedAt,
|
||||||
attachments: selectedEmail.attachments,
|
attachments: selectedEmail.attachments,
|
||||||
messageId: selectedEmail.messageId,
|
messageId: selectedEmail.messageId,
|
||||||
@@ -973,29 +1000,52 @@ export default function Home() {
|
|||||||
};
|
};
|
||||||
}, [isAuthenticated, client, fetchMailboxes, fetchEmails, fetchQuota, fetchTagCounts, refreshScheduledMetadata]);
|
}, [isAuthenticated, client, fetchMailboxes, fetchEmails, fetchQuota, fetchTagCounts, refreshScheduledMetadata]);
|
||||||
|
|
||||||
// Push notifications: set up once per client and tear down when the client
|
// Push notifications: set up once per CONNECTED client and tear down when the
|
||||||
// goes away (logout or account switch). Kept separate from the fetch effect
|
// clients go away (logout or account switch). Kept separate from the fetch
|
||||||
// above so it still runs when data was prefetched at login time.
|
// effect above so it still runs when data was prefetched at login time.
|
||||||
|
//
|
||||||
|
// We bind every connected login, not just the active one: background accounts
|
||||||
|
// must drive the unified-section counters too. The active client keeps the
|
||||||
|
// full handler (current list / scheduled / calendar / filters); background
|
||||||
|
// logins only re-project the unified counts by rebuilding the unified scope
|
||||||
|
// (which refreshes every account's cached mailbox list), since their changes
|
||||||
|
// never touch the active `mailboxes`. (#281 background push)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAuthenticated || !client) return;
|
if (!isAuthenticated || !client) return;
|
||||||
|
|
||||||
try {
|
const clients = useAuthStore.getState().getAllConnectedClients();
|
||||||
client.onStateChange((change) => handleStateChange(change, client));
|
const cleanups: Array<() => void> = [];
|
||||||
const pushEnabled = client.setupPushNotifications();
|
|
||||||
if (pushEnabled) {
|
for (const [accId, c] of clients) {
|
||||||
setPushConnected(true);
|
try {
|
||||||
debug.log('push', '[Push] Push notifications successfully enabled');
|
if (accId === activeAccountId) {
|
||||||
} else {
|
c.onStateChange((change) => handleStateChange(change, c));
|
||||||
debug.log('push', '[Push] Push notifications not available on this server');
|
} else {
|
||||||
|
c.onStateChange(() => {
|
||||||
|
buildPopulatedUnifiedAccounts()
|
||||||
|
.then((built) => {
|
||||||
|
refreshCrossCounts(built);
|
||||||
|
refreshUnifiedCounts(built);
|
||||||
|
})
|
||||||
|
.catch(() => { /* per-account fetch failures surface elsewhere */ });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
c.setupPushNotifications();
|
||||||
|
cleanups.push(() => c.closePushNotifications());
|
||||||
|
} catch (error) {
|
||||||
|
debug.log('push', '[Push] Failed to setup push notifications for account:', accId, error);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
}
|
||||||
debug.log('push', '[Push] Failed to setup push notifications:', error);
|
|
||||||
|
if (cleanups.length > 0) {
|
||||||
|
setPushConnected(true);
|
||||||
|
debug.log('push', `[Push] Push notifications enabled for ${cleanups.length} account(s)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
client.closePushNotifications();
|
cleanups.forEach((fn) => fn());
|
||||||
};
|
};
|
||||||
}, [isAuthenticated, client, handleStateChange, setPushConnected]);
|
}, [isAuthenticated, client, activeAccountId, connectedAccountsSignature, handleStateChange, setPushConnected, buildPopulatedUnifiedAccounts, refreshCrossCounts, refreshUnifiedCounts]);
|
||||||
|
|
||||||
// Keep unified mailbox counts in sync when the feature is enabled and more
|
// Keep unified mailbox counts in sync when the feature is enabled and more
|
||||||
// than one account is connected. Runs whenever the set of connected accounts
|
// than one account is connected. Runs whenever the set of connected accounts
|
||||||
@@ -1012,7 +1062,7 @@ export default function Home() {
|
|||||||
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, refreshCrossCounts, showCrossUnread, showCrossStarred, showCrossAll]);
|
}, [enableUnifiedMailbox, includeGroupInUnified, unifiedCrossAccount, activeAccountId, 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
|
||||||
@@ -1172,18 +1222,22 @@ 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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1263,7 +1317,7 @@ 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: {
|
labels: {
|
||||||
@@ -1327,11 +1381,18 @@ 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')
|
||||||
: '';
|
: '';
|
||||||
const htmlBody = draft.htmlBody?.[0]?.partId && draft.bodyValues?.[draft.htmlBody[0].partId]
|
// A plain-text-only draft lists its text/plain part under htmlBody
|
||||||
? draft.bodyValues[draft.htmlBody[0].partId].value
|
// (RFC 8621 § 4.1.4 fallback) - only treat it as HTML when it really is.
|
||||||
|
const draftHtmlPart = draft.htmlBody?.[0];
|
||||||
|
const htmlBody = draftHtmlPart?.partId
|
||||||
|
&& (!draftHtmlPart.type || draftHtmlPart.type.toLowerCase() === 'text/html')
|
||||||
|
&& draft.bodyValues?.[draftHtmlPart.partId]
|
||||||
|
? draft.bodyValues[draftHtmlPart.partId].value
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
// Try to find the identity that matches the draft's from address to preserve it
|
// Try to find the identity that matches the draft's from address to preserve it
|
||||||
@@ -1371,6 +1432,27 @@ export default function Home() {
|
|||||||
|
|
||||||
toast.success(t('email_viewer.scheduled_send_created'), {
|
toast.success(t('email_viewer.scheduled_send_created'), {
|
||||||
duration: undoDurationMs,
|
duration: undoDurationMs,
|
||||||
|
secondaryAction: (pending.emailId && pending.identityId)
|
||||||
|
? {
|
||||||
|
label: t('email_viewer.send_now'),
|
||||||
|
onClick: () => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
await client.rescheduleEmailSubmission(
|
||||||
|
pending.submissionId,
|
||||||
|
pending.emailId!,
|
||||||
|
pending.identityId!,
|
||||||
|
new Date(Date.now() + 1000).toISOString(),
|
||||||
|
);
|
||||||
|
clearPendingUndoSend();
|
||||||
|
if (isScheduledView) await fetchScheduledEmails(client);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send now:', error);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
action: {
|
action: {
|
||||||
label: t('email_viewer.undo_send'),
|
label: t('email_viewer.undo_send'),
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
@@ -1635,6 +1717,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;
|
||||||
|
|
||||||
@@ -1663,8 +1784,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.
|
||||||
@@ -1702,7 +1835,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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1745,7 +1886,18 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const populated = await buildPopulatedUnifiedAccounts();
|
const populated = await buildPopulatedUnifiedAccounts();
|
||||||
await fetchUnifiedEmailsAction(populated, role);
|
// Keep an active search across the switch and re-run it in this view
|
||||||
|
// (mirrors normal mailboxes), preserving advanced filters; otherwise browse.
|
||||||
|
if (client && (!isFilterEmpty(searchFilters) || searchQuery)) {
|
||||||
|
useEmailStore.setState({ isUnifiedView: true, unifiedRole: role, crossView: null });
|
||||||
|
if (!isFilterEmpty(searchFilters)) {
|
||||||
|
await advancedSearch(client);
|
||||||
|
} else {
|
||||||
|
await searchEmails(client, searchQuery);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await fetchUnifiedEmailsAction(populated, role);
|
||||||
|
}
|
||||||
refreshUnifiedCounts(populated);
|
refreshUnifiedCounts(populated);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1767,7 +1919,18 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const populated = await buildPopulatedUnifiedAccounts();
|
const populated = await buildPopulatedUnifiedAccounts();
|
||||||
await fetchCrossViewAction(populated, view);
|
// Keep an active search across the switch and re-run it in this view
|
||||||
|
// (mirrors normal mailboxes), preserving advanced filters; otherwise browse.
|
||||||
|
if (client && (!isFilterEmpty(searchFilters) || searchQuery)) {
|
||||||
|
useEmailStore.setState({ isUnifiedView: true, crossView: view, unifiedRole: null });
|
||||||
|
if (!isFilterEmpty(searchFilters)) {
|
||||||
|
await advancedSearch(client);
|
||||||
|
} else {
|
||||||
|
await searchEmails(client, searchQuery);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await fetchCrossViewAction(populated, view);
|
||||||
|
}
|
||||||
refreshCrossCounts(populated);
|
refreshCrossCounts(populated);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1792,8 +1955,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);
|
||||||
@@ -2108,13 +2276,16 @@ export default function Home() {
|
|||||||
setSearchQuery("");
|
setSearchQuery("");
|
||||||
clearSearchFilters();
|
clearSearchFilters();
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
// In unified view the active "mailbox" is a virtual role, so refresh via
|
// In unified view the active "mailbox" is a virtual role or cross view, so
|
||||||
// the unified fan-out instead of fetchEmails.
|
// refresh via the unified fan-out instead of fetchEmails.
|
||||||
if (isUnifiedView) {
|
if (isUnifiedView) {
|
||||||
|
const populated = await buildPopulatedUnifiedAccounts();
|
||||||
const role = useEmailStore.getState().unifiedRole;
|
const role = useEmailStore.getState().unifiedRole;
|
||||||
|
const cross = useEmailStore.getState().crossView;
|
||||||
if (role) {
|
if (role) {
|
||||||
const populated = await buildPopulatedUnifiedAccounts();
|
|
||||||
await fetchUnifiedEmailsAction(populated, role);
|
await fetchUnifiedEmailsAction(populated, role);
|
||||||
|
} else if (cross) {
|
||||||
|
await fetchCrossViewAction(populated, cross);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2146,41 +2317,64 @@ export default function Home() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Blobs are scoped per JMAP account. In the unified/All-Mail view the open
|
||||||
|
// message may belong to another login (route to its client) or to a delegated
|
||||||
|
// shared account (same client, but the owner's accountId in the download URL).
|
||||||
|
// Resolve both from the email's source so attachments on cross-account
|
||||||
|
// messages can be viewed/downloaded instead of 404ing against the active
|
||||||
|
// account.
|
||||||
|
const resolveBlobSource = useCallback((email: typeof selectedEmail) => {
|
||||||
|
const clientAccountId = isUnifiedView ? email?.sourceClientAccountId : undefined;
|
||||||
|
const blobClient = clientAccountId
|
||||||
|
? (useAuthStore.getState().getClientForAccount(clientAccountId) ?? client)
|
||||||
|
: client;
|
||||||
|
const accountId = isUnifiedView ? email?.sourceAccountId : undefined;
|
||||||
|
return { blobClient, accountId, clientAccountId };
|
||||||
|
}, [isUnifiedView, client]);
|
||||||
|
|
||||||
const handleDownloadAttachment = async (blobId: string, name: string, type?: string, forceDownload?: boolean) => {
|
const handleDownloadAttachment = async (blobId: string, name: string, type?: string, forceDownload?: boolean) => {
|
||||||
if (!client) return;
|
const { blobClient, accountId, clientAccountId } = resolveBlobSource(selectedEmail);
|
||||||
|
if (!blobClient) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { mailAttachmentAction } = useSettingsStore.getState();
|
const { mailAttachmentAction } = useSettingsStore.getState();
|
||||||
|
|
||||||
if (!forceDownload && mailAttachmentAction === 'preview' && isFilePreviewable(name, type)) {
|
if (!forceDownload && mailAttachmentAction === 'preview' && isFilePreviewable(name, type)) {
|
||||||
setPreviewAttachment({ blobId, name, type });
|
setPreviewAttachment({ blobId, name, type, accountId, clientAccountId });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await client.downloadBlob(blobId, name, type);
|
await blobClient.downloadBlob(blobId, name, type, accountId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to download attachment:", error);
|
console.error("Failed to download attachment:", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePreviewAttachmentDownload = useCallback(async () => {
|
const previewBlobClient = useCallback(() => {
|
||||||
if (!client || !previewAttachment) return;
|
const id = previewAttachment?.clientAccountId;
|
||||||
|
return id ? (useAuthStore.getState().getClientForAccount(id) ?? client) : client;
|
||||||
|
}, [previewAttachment, client]);
|
||||||
|
|
||||||
await client.downloadBlob(previewAttachment.blobId, previewAttachment.name, previewAttachment.type);
|
const handlePreviewAttachmentDownload = useCallback(async () => {
|
||||||
}, [client, previewAttachment]);
|
const c = previewBlobClient();
|
||||||
|
if (!c || !previewAttachment) return;
|
||||||
|
|
||||||
|
await c.downloadBlob(previewAttachment.blobId, previewAttachment.name, previewAttachment.type, previewAttachment.accountId);
|
||||||
|
}, [previewBlobClient, previewAttachment]);
|
||||||
|
|
||||||
const getPreviewAttachmentContent = useCallback(async () => {
|
const getPreviewAttachmentContent = useCallback(async () => {
|
||||||
if (!client || !previewAttachment) {
|
const c = previewBlobClient();
|
||||||
|
if (!c || !previewAttachment) {
|
||||||
throw new Error('No attachment selected');
|
throw new Error('No attachment selected');
|
||||||
}
|
}
|
||||||
|
|
||||||
const blob = await client.fetchBlob(previewAttachment.blobId, previewAttachment.name, previewAttachment.type);
|
const blob = await c.fetchBlob(previewAttachment.blobId, previewAttachment.name, previewAttachment.type, previewAttachment.accountId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
blob,
|
blob,
|
||||||
contentType: previewAttachment.type || blob.type || 'application/octet-stream',
|
contentType: previewAttachment.type || blob.type || 'application/octet-stream',
|
||||||
};
|
};
|
||||||
}, [client, previewAttachment]);
|
}, [previewBlobClient, previewAttachment]);
|
||||||
|
|
||||||
const handleQuickReply = async (body: string) => {
|
const handleQuickReply = async (body: string) => {
|
||||||
if (!client || !selectedEmail) return;
|
if (!client || !selectedEmail) return;
|
||||||
@@ -2271,11 +2465,22 @@ 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
|
||||||
@@ -2319,14 +2524,12 @@ export default function Home() {
|
|||||||
// Get current mailbox name for mobile header
|
// Get current mailbox name for mobile header
|
||||||
const currentMailboxName = isScheduledView
|
const currentMailboxName = isScheduledView
|
||||||
? t('sidebar.scheduled')
|
? t('sidebar.scheduled')
|
||||||
: selectedMailbox === ALL_MAIL_MAILBOX_ID
|
: (() => {
|
||||||
? t('sidebar.mailboxes.all_mail')
|
const mb = mailboxes.find(m => m.id === selectedMailbox);
|
||||||
: (() => {
|
return mb
|
||||||
const mb = mailboxes.find(m => m.id === selectedMailbox);
|
? localizeMailboxName(mb.role, mb.name, (k) => t(`sidebar.mailboxes.${k}`))
|
||||||
return mb
|
: "Inbox";
|
||||||
? 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);
|
||||||
@@ -2614,7 +2817,7 @@ export default function Home() {
|
|||||||
selectedKeyword={selectedKeyword}
|
selectedKeyword={selectedKeyword}
|
||||||
scheduledTotal={scheduledTotal}
|
scheduledTotal={scheduledTotal}
|
||||||
showScheduledMailbox={delayedSendSupported}
|
showScheduledMailbox={delayedSendSupported}
|
||||||
showAllMailMailbox={showAllMailMailbox}
|
crossAccountActive={crossAccountActive}
|
||||||
showCrossUnread={showCrossUnread}
|
showCrossUnread={showCrossUnread}
|
||||||
showCrossStarred={showCrossStarred}
|
showCrossStarred={showCrossStarred}
|
||||||
showCrossAll={showCrossAll}
|
showCrossAll={showCrossAll}
|
||||||
@@ -2667,17 +2870,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
|
||||||
@@ -2730,23 +2935,23 @@ 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={isScheduledView}
|
||||||
title={isUnifiedView ? t("unified_mailbox.search_unavailable") : isScheduledView ? t('email_viewer.scheduled_actions_only') : undefined}
|
title={isScheduledView ? t('email_viewer.scheduled_actions_only') : undefined}
|
||||||
/>
|
/>
|
||||||
{searchQuery && (
|
{searchQuery && (
|
||||||
<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" />
|
||||||
@@ -2756,15 +2961,15 @@ export default function Home() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={toggleAdvancedSearch}
|
onClick={toggleAdvancedSearch}
|
||||||
disabled={isUnifiedView || isScheduledView}
|
disabled={isScheduledView}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex-shrink-0 p-2 rounded-md transition-colors",
|
"relative flex-shrink-0 p-2 rounded-md transition-colors",
|
||||||
(isUnifiedView || isScheduledView) && "opacity-50 cursor-not-allowed",
|
isScheduledView && "opacity-50 cursor-not-allowed",
|
||||||
isAdvancedSearchOpen || activeFilterCount(searchFilters) > 0
|
isAdvancedSearchOpen || activeFilterCount(searchFilters) > 0
|
||||||
? "bg-primary/10 text-primary"
|
? "bg-primary/10 text-primary"
|
||||||
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||||
)}
|
)}
|
||||||
title={isUnifiedView ? t("unified_mailbox.search_unavailable") : isScheduledView ? t('email_viewer.scheduled_actions_only') : t("advanced_search.toggle_filters")}
|
title={isScheduledView ? t('email_viewer.scheduled_actions_only') : t("advanced_search.toggle_filters")}
|
||||||
>
|
>
|
||||||
<Filter className="w-4 h-4" />
|
<Filter className="w-4 h-4" />
|
||||||
{!isAdvancedSearchOpen && activeFilterCount(searchFilters) > 0 && (
|
{!isAdvancedSearchOpen && activeFilterCount(searchFilters) > 0 && (
|
||||||
@@ -2803,7 +3008,7 @@ export default function Home() {
|
|||||||
</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>
|
||||||
@@ -2987,6 +3192,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);
|
||||||
}}
|
}}
|
||||||
@@ -3025,7 +3233,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')}
|
||||||
@@ -3084,6 +3292,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,
|
||||||
@@ -3091,8 +3304,7 @@ export default function Home() {
|
|||||||
cc: selectedEmail.cc,
|
cc: selectedEmail.cc,
|
||||||
bcc: selectedEmail.bcc,
|
bcc: selectedEmail.bcc,
|
||||||
subject: selectedEmail.subject,
|
subject: selectedEmail.subject,
|
||||||
body: selectedEmail.bodyValues?.[selectedEmail.textBody?.[0]?.partId || '']?.value || selectedEmail.preview || '',
|
...getQuoteBodies(selectedEmail),
|
||||||
htmlBody: selectedEmail.bodyValues?.[selectedEmail.htmlBody?.[0]?.partId || '']?.value || undefined,
|
|
||||||
receivedAt: selectedEmail.receivedAt,
|
receivedAt: selectedEmail.receivedAt,
|
||||||
attachments: selectedEmail.attachments,
|
attachments: selectedEmail.attachments,
|
||||||
messageId: selectedEmail.messageId,
|
messageId: selectedEmail.messageId,
|
||||||
@@ -3148,13 +3360,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
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -66,6 +66,8 @@ 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';
|
||||||
@@ -113,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;
|
||||||
@@ -330,7 +338,7 @@ 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).
|
// One-shot deep link from the sidebar section gears (Folders / Tags).
|
||||||
// Used only as the initial tab and intentionally NOT written to
|
// Used only as the initial tab and intentionally NOT written to
|
||||||
@@ -338,7 +346,7 @@ function readPersistedTab(): Tab {
|
|||||||
// default that the regular Settings button lands on. Cleared on mount.
|
// default that the regular Settings button lands on. Cleared on mount.
|
||||||
const deepLink = sessionStorage.getItem('settings-deep-link-tab');
|
const deepLink = sessionStorage.getItem('settings-deep-link-tab');
|
||||||
if (deepLink) {
|
if (deepLink) {
|
||||||
return (deepLink in LEGACY_TAB_MAP ? LEGACY_TAB_MAP[deepLink] : deepLink) as Tab;
|
return (deepLink in LEGACY_TAB_MAP ? LEGACY_TAB_MAP[deepLink] : deepLink) as SettingsTabId;
|
||||||
}
|
}
|
||||||
const saved = localStorage.getItem('settings-active-tab');
|
const saved = localStorage.getItem('settings-active-tab');
|
||||||
if (!saved) return 'appearance';
|
if (!saved) return 'appearance';
|
||||||
@@ -347,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';
|
||||||
}
|
}
|
||||||
@@ -364,7 +372,15 @@ 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
|
// 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.
|
// open, never the persisted default for future Settings-button clicks.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -372,7 +388,7 @@ export default function SettingsPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
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>;
|
||||||
@@ -624,6 +640,14 @@ export default function SettingsPage() {
|
|||||||
...(isFeatureEnabled('contactsEnabled') ? [{ id: 'contacts' as Tab, label: t('tabs.contacts'), icon: tabIcons.contacts, group: 'apps' as TabGroup }] : []),
|
...(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' },
|
||||||
@@ -644,7 +668,7 @@ export default function SettingsPage() {
|
|||||||
].filter(Boolean) as Tab[])
|
].filter(Boolean) as Tab[])
|
||||||
: [];
|
: [];
|
||||||
const visibleTabs = managedAccountId
|
const visibleTabs = managedAccountId
|
||||||
? tabs.filter((tab) => scopedTabIds.includes(tab.id))
|
? tabs.filter((tab) => scopedTabIds.includes(tab.id as Tab))
|
||||||
: tabs;
|
: tabs;
|
||||||
|
|
||||||
// Group tabs by category
|
// Group tabs by category
|
||||||
@@ -660,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) ||
|
||||||
@@ -684,11 +708,11 @@ export default function SettingsPage() {
|
|||||||
// mode hides it), fall back. In scoped mode fall back to the first scoped tab;
|
// mode hides it), fall back. In scoped mode fall back to the first scoped tab;
|
||||||
// otherwise the usual 'appearance' default.
|
// otherwise the usual 'appearance' default.
|
||||||
const isActiveVisible = visibleTabs.some((tab) => tab.id === activeTab);
|
const isActiveVisible = visibleTabs.some((tab) => tab.id === activeTab);
|
||||||
const effectiveActiveTab: Tab = isActiveVisible
|
const effectiveActiveTab: SettingsTabId = isActiveVisible
|
||||||
? activeTab
|
? activeTab
|
||||||
: (managedAccountId ? (visibleTabs[0]?.id ?? 'appearance') : 'appearance');
|
: (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) {
|
||||||
@@ -696,7 +720,7 @@ 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 });
|
||||||
};
|
};
|
||||||
@@ -712,11 +736,11 @@ export default function SettingsPage() {
|
|||||||
clearManagedAccount();
|
clearManagedAccount();
|
||||||
handleTabSelect('account');
|
handleTabSelect('account');
|
||||||
}}
|
}}
|
||||||
className="flex items-center gap-2 w-full mb-4 px-3 py-2 rounded-md border border-border bg-muted/40 hover:bg-muted text-left transition-colors"
|
className="flex items-center gap-2 w-full mb-4 px-3 py-2 rounded-md border border-border bg-muted/40 hover:bg-muted text-start transition-colors"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
<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="text-sm text-muted-foreground">{t('scoped.back')}</span>
|
||||||
<span className="ml-auto text-sm font-medium truncate">
|
<span className="ms-auto text-sm font-medium truncate">
|
||||||
{t('scoped.managing', { name: managedAccount.name })}
|
{t('scoped.managing', { name: managedAccount.name })}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -754,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"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -820,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 && (
|
||||||
@@ -868,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>
|
||||||
@@ -932,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` }}
|
||||||
@@ -945,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>
|
||||||
@@ -960,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 && (
|
||||||
@@ -997,7 +1028,7 @@ export default function SettingsPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleTabSelect(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'
|
||||||
@@ -1013,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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import type { SettingsPolicy, FeatureGates } from '@/lib/admin/types';
|
|||||||
import { DEFAULT_FEATURE_GATES, DEFAULT_POLICY } from '@/lib/admin/types';
|
import { DEFAULT_FEATURE_GATES, DEFAULT_POLICY } from '@/lib/admin/types';
|
||||||
import { apiFetch } from '@/lib/browser-navigation';
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
const EXCLUDED_FEATURE_GATES: (keyof FeatureGates)[] = ['pluginsEnabled', 'pluginsUploadEnabled', 'themesEnabled', 'userThemesEnabled'];
|
// `allMailViewEnabled` is deprecated (folded into `crossAllViewEnabled`, normalized
|
||||||
|
// forward on policy load), so it is hidden from the admin UI.
|
||||||
|
const EXCLUDED_FEATURE_GATES: (keyof FeatureGates)[] = ['pluginsEnabled', 'pluginsUploadEnabled', 'themesEnabled', 'userThemesEnabled', 'allMailViewEnabled'];
|
||||||
|
|
||||||
const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; description: string }>> = {
|
const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; description: string }>> = {
|
||||||
sidebarAppsEnabled: { label: 'Sidebar Apps', description: 'Allow custom web apps in navigation rail' },
|
sidebarAppsEnabled: { label: 'Sidebar Apps', description: 'Allow custom web apps in navigation rail' },
|
||||||
@@ -22,10 +24,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: 'Unified Mailbox: Unread', description: 'Allow an "Unread" entry in the Unified Mailbox section that lists unread mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. Requires the matching 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: 'Unified Mailbox: Starred', description: 'Allow a "Starred" entry in the Unified Mailbox section that lists flagged/starred mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. 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: 'Unified Mailbox: All Mail', description: 'Allow an "All mail" entry in the Unified Mailbox section that lists all mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. 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.' },
|
unifiedCrossAccountEnabled: { label: 'Unified Mailbox: Cross-account', description: 'Allow users to expand the Unified Mailbox beyond the active account boundary so its lists merge across every logged-in account. When off, the Unified Mailbox stays within the active account and its shared folders.' },
|
||||||
};
|
};
|
||||||
|
|
||||||
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>
|
||||||
@@ -145,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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
+22
-2
@@ -1,9 +1,16 @@
|
|||||||
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, getTranslations } from "next-intl/server";
|
import { getLocale, getTranslations } from "next-intl/server";
|
||||||
import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
|
import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
|
||||||
|
import { FaviconBadge } from "@/components/favicon-badge";
|
||||||
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 { locales } from "@/i18n/routing";
|
||||||
import "../globals.css";
|
import "../globals.css";
|
||||||
@@ -38,7 +45,19 @@ 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
|
// Localize the <head> description to match the UI language; a hardcoded
|
||||||
// English description is another signal that makes Chrome offer to
|
// English description is another signal that makes Chrome offer to
|
||||||
// "translate this page". Resolve the locale from the request path, since this
|
// "translate this page". Resolve the locale from the request path, since this
|
||||||
@@ -76,7 +95,7 @@ export default async function RootLayout({
|
|||||||
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" />
|
||||||
@@ -114,6 +133,7 @@ export default async function RootLayout({
|
|||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||||
>
|
>
|
||||||
<ServiceWorkerRegistration />
|
<ServiceWorkerRegistration />
|
||||||
|
<FaviconBadge />
|
||||||
{children}
|
{children}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -780,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}
|
||||||
@@ -870,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>
|
||||||
))}
|
))}
|
||||||
@@ -1384,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}
|
||||||
@@ -1394,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>
|
||||||
);
|
);
|
||||||
@@ -1601,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,6 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||||
|
import { JmapRedirectError, fetchJmapSession, postJmap, rebaseApiUrl } from '@/lib/stalwart/jmap-api';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/account/stalwart/jmap
|
* POST /api/account/stalwart/jmap
|
||||||
@@ -23,14 +24,26 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const body = await request.text();
|
const body = await request.text();
|
||||||
|
|
||||||
const response = await fetch(`${creds.serverUrl}/jmap/`, {
|
const directUrl = `${creds.serverUrl}/jmap/`;
|
||||||
method: 'POST',
|
let response = await postJmap(directUrl, creds.authHeader, body);
|
||||||
headers: {
|
|
||||||
'Authorization': creds.authHeader,
|
if (response.status === 404) {
|
||||||
'Content-Type': 'application/json',
|
// `${serverUrl}/jmap/` is not the API endpoint on this deployment
|
||||||
},
|
// (path prefix, non-Stalwart URL layout). Resolve the session's
|
||||||
body,
|
// advertised apiUrl on the same host and retry once.
|
||||||
});
|
const session = await fetchJmapSession(creds.serverUrl, creds.authHeader);
|
||||||
|
const apiUrl = rebaseApiUrl(session, creds.serverUrl);
|
||||||
|
if (apiUrl && apiUrl !== directUrl) {
|
||||||
|
response = await postJmap(apiUrl, creds.authHeader, body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
logger.warn('Stalwart JMAP passthrough upstream error', {
|
||||||
|
status: response.status,
|
||||||
|
serverUrl: creds.serverUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const responseText = await response.text();
|
const responseText = await response.text();
|
||||||
return new NextResponse(responseText, {
|
return new NextResponse(responseText, {
|
||||||
@@ -38,6 +51,10 @@ export async function POST(request: NextRequest) {
|
|||||||
headers: { 'Content-Type': response.headers.get('Content-Type') || 'application/json' },
|
headers: { 'Content-Type': response.headers.get('Content-Type') || 'application/json' },
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof JmapRedirectError) {
|
||||||
|
logger.error('Stalwart JMAP passthrough redirect error', { error: error.message });
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 502 });
|
||||||
|
}
|
||||||
logger.error('Stalwart JMAP passthrough error', {
|
logger.error('Stalwart JMAP passthrough error', {
|
||||||
error: error instanceof Error ? error.message : 'Unknown',
|
error: error instanceof Error ? error.message : 'Unknown',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,6 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||||
|
import { fetchJmapSession, postJmap, rebaseApiUrl } from '@/lib/stalwart/jmap-api';
|
||||||
import { normalizeCalendarEventLike } from '@/lib/calendar-event-normalization';
|
import { normalizeCalendarEventLike } from '@/lib/calendar-event-normalization';
|
||||||
import { expandRecurringEvents } from '@/lib/recurrence-expansion';
|
import { expandRecurringEvents } from '@/lib/recurrence-expansion';
|
||||||
import { parseISO } from 'date-fns';
|
import { parseISO } from 'date-fns';
|
||||||
@@ -32,12 +33,6 @@ const EVENT_PROPERTIES = [
|
|||||||
'recurrenceOverrides', 'excludedRecurrenceRule',
|
'recurrenceOverrides', 'excludedRecurrenceRule',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
interface JmapSession {
|
|
||||||
apiUrl?: string;
|
|
||||||
primaryAccounts?: Record<string, string>;
|
|
||||||
capabilities?: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AgendaEvent {
|
interface AgendaEvent {
|
||||||
id: string;
|
id: string;
|
||||||
uid: string | null;
|
uid: string | null;
|
||||||
@@ -141,9 +136,9 @@ export async function POST(request: NextRequest) {
|
|||||||
using.push('urn:ietf:params:jmap:principals:owner');
|
using.push('urn:ietf:params:jmap:principals:owner');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send method calls to the same-origin JMAP endpoint the app's passthrough
|
// Send method calls to the session's apiUrl rebased onto serverUrl's host
|
||||||
// uses — never to session.apiUrl's (possibly unreachable) public host.
|
// — never to session.apiUrl's (possibly unreachable) public host.
|
||||||
const apiUrl = `${creds.serverUrl}/jmap/`;
|
const apiUrl = rebaseApiUrl(session, creds.serverUrl) ?? `${creds.serverUrl}/jmap/`;
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const horizon = new Date(now.getTime() + days * 24 * 60 * 60 * 1000);
|
const horizon = new Date(now.getTime() + days * 24 * 60 * 60 * 1000);
|
||||||
@@ -273,45 +268,12 @@ function clampInt(value: unknown, min: number, max: number, fallback: number): n
|
|||||||
return Math.min(max, Math.max(min, Math.round(n)));
|
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(
|
async function jmapPost(
|
||||||
apiUrl: string,
|
apiUrl: string,
|
||||||
authHeader: string,
|
authHeader: string,
|
||||||
payload: unknown,
|
payload: unknown,
|
||||||
): Promise<unknown> {
|
): Promise<unknown> {
|
||||||
const res = await fetch(apiUrl, {
|
const res = await postJmap(apiUrl, authHeader, JSON.stringify(payload));
|
||||||
method: 'POST',
|
|
||||||
headers: { Authorization: authHeader, 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(`JMAP request failed (${res.status})`);
|
throw new Error(`JMAP request failed (${res.status})`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
loginShowTotp: configManager.get<boolean>('loginShowTotp', true),
|
loginShowTotp: configManager.get<boolean>('loginShowTotp', true),
|
||||||
loginShowVersion: configManager.get<boolean>('loginShowVersion', true),
|
loginShowVersion: configManager.get<boolean>('loginShowVersion', true),
|
||||||
demoMode: configManager.get<boolean>('demoMode', false),
|
demoMode: configManager.get<boolean>('demoMode', false),
|
||||||
|
|||||||
@@ -1679,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 {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
+22
-5
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import { render } from '@testing-library/react';
|
||||||
|
import { FaviconBadge } from '@/components/favicon-badge';
|
||||||
|
import { useFaviconBadge } from '@/hooks/use-favicon-badge';
|
||||||
|
import { useEmailStore } from '@/stores/email-store';
|
||||||
|
import { useSettingsStore } from '@/stores/settings-store';
|
||||||
|
import type { Mailbox } from '@/lib/jmap/types';
|
||||||
|
|
||||||
|
vi.mock('@/hooks/use-favicon-badge', () => ({
|
||||||
|
useFaviconBadge: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const useFaviconBadgeMock = vi.mocked(useFaviconBadge);
|
||||||
|
|
||||||
|
function mailbox(patch: Partial<Mailbox> & { id: string }): Mailbox {
|
||||||
|
return {
|
||||||
|
name: patch.id,
|
||||||
|
sortOrder: 0,
|
||||||
|
totalEmails: 0,
|
||||||
|
unreadEmails: 0,
|
||||||
|
totalThreads: 0,
|
||||||
|
unreadThreads: 0,
|
||||||
|
isSubscribed: true,
|
||||||
|
myRights: {
|
||||||
|
mayReadItems: true,
|
||||||
|
mayAddItems: true,
|
||||||
|
mayRemoveItems: true,
|
||||||
|
maySetSeen: true,
|
||||||
|
maySetKeywords: true,
|
||||||
|
mayCreateChild: true,
|
||||||
|
mayRename: true,
|
||||||
|
mayDelete: true,
|
||||||
|
maySubmit: true,
|
||||||
|
},
|
||||||
|
...patch,
|
||||||
|
} as Mailbox;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialMailboxes = useEmailStore.getState().mailboxes;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
useEmailStore.setState({ mailboxes: initialMailboxes });
|
||||||
|
useSettingsStore.setState({ faviconUnreadBadge: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
useEmailStore.setState({ mailboxes: initialMailboxes });
|
||||||
|
useSettingsStore.setState({ faviconUnreadBadge: true });
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('FaviconBadge', () => {
|
||||||
|
it('badges the unread count of the primary inbox', () => {
|
||||||
|
useEmailStore.setState({
|
||||||
|
mailboxes: [mailbox({ id: 'inbox', role: 'inbox', unreadEmails: 7 })],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { container } = render(<FaviconBadge />);
|
||||||
|
|
||||||
|
expect(useFaviconBadgeMock).toHaveBeenCalledWith(7, true);
|
||||||
|
expect(container.firstChild).toBeNull(); // renders no markup
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables the badge when the setting is off', () => {
|
||||||
|
useSettingsStore.setState({ faviconUnreadBadge: false });
|
||||||
|
useEmailStore.setState({
|
||||||
|
mailboxes: [mailbox({ id: 'inbox', role: 'inbox', unreadEmails: 7 })],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<FaviconBadge />);
|
||||||
|
|
||||||
|
expect(useFaviconBadgeMock).toHaveBeenCalledWith(7, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a shared inbox, even when it sorts first', () => {
|
||||||
|
// Shared and group inboxes ship in the same `mailboxes` array. A plain
|
||||||
|
// `role === 'inbox'` lookup would badge somebody else's inbox on a
|
||||||
|
// delegated setup, so the store's canonical `!isShared` filter is required.
|
||||||
|
useEmailStore.setState({
|
||||||
|
mailboxes: [
|
||||||
|
mailbox({ id: 'shared', role: 'inbox', isShared: true, unreadEmails: 99 }),
|
||||||
|
mailbox({ id: 'mine', role: 'inbox', unreadEmails: 4 }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<FaviconBadge />);
|
||||||
|
|
||||||
|
expect(useFaviconBadgeMock).toHaveBeenCalledWith(4, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('badges zero when there is no inbox yet', () => {
|
||||||
|
useEmailStore.setState({ mailboxes: [] });
|
||||||
|
|
||||||
|
render(<FaviconBadge />);
|
||||||
|
|
||||||
|
expect(useFaviconBadgeMock).toHaveBeenCalledWith(0, true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
@@ -148,6 +148,9 @@ export function CalendarAgendaView({
|
|||||||
const color = getEventColor(ev, calendar);
|
const color = getEventColor(ev, calendar);
|
||||||
const start = getEventStartDate(ev);
|
const start = getEventStartDate(ev);
|
||||||
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;
|
||||||
@@ -159,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]">
|
||||||
@@ -181,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);
|
||||||
|
|||||||
@@ -397,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>
|
||||||
@@ -437,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" />
|
||||||
@@ -445,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>
|
||||||
@@ -199,7 +201,7 @@ export function CalendarToolbar({
|
|||||||
<CalendarDays className="w-4 h-4" />
|
<CalendarDays className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
{showCalendarDropdown && (
|
{showCalendarDropdown && (
|
||||||
<div className="absolute top-full right-0 mt-1 z-50 bg-popover border border-border rounded-lg shadow-lg p-2 min-w-[180px]">
|
<div className="absolute top-full end-0 mt-1 z-50 bg-popover border border-border rounded-lg shadow-lg p-2 min-w-[180px]">
|
||||||
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 px-1">
|
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 px-1">
|
||||||
{t("my_calendars")}
|
{t("my_calendars")}
|
||||||
</h3>
|
</h3>
|
||||||
@@ -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,12 +330,12 @@ 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 end-0 mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-1 min-w-[180px]">
|
||||||
{onImport && (
|
{onImport && (
|
||||||
<button
|
<button
|
||||||
onClick={() => { onImport(); setShowImportDropdown(false); }}
|
onClick={() => { onImport(); setShowImportDropdown(false); }}
|
||||||
@@ -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")}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -88,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();
|
||||||
@@ -131,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 }}
|
||||||
@@ -144,7 +148,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
|||||||
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -159,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 }}
|
||||||
@@ -173,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>
|
||||||
);
|
);
|
||||||
@@ -189,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}
|
||||||
|
|||||||
@@ -290,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>
|
||||||
)}
|
)}
|
||||||
@@ -343,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>
|
||||||
@@ -364,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>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -434,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>
|
||||||
)}
|
)}
|
||||||
@@ -512,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>
|
||||||
@@ -546,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
|
||||||
@@ -559,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
|
||||||
@@ -572,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>
|
||||||
@@ -606,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
|
||||||
@@ -616,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" />
|
||||||
|
|||||||
@@ -522,13 +522,14 @@ 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
|
// Stalwart (calcard) derives the iCalendar ORGANIZER property solely from
|
||||||
// organizerCalendarAddress; without it no ORGANIZER is emitted and iTIP
|
// organizerCalendarAddress; without it no ORGANIZER is emitted and iTIP
|
||||||
// scheduling is silently skipped (NoSchedulingInfo), so no invites are sent.
|
// 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}`;
|
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;
|
data.organizerCalendarAddress = null;
|
||||||
}
|
}
|
||||||
@@ -665,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>
|
||||||
);
|
);
|
||||||
@@ -678,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>
|
||||||
)}
|
)}
|
||||||
@@ -700,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>
|
||||||
@@ -725,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
|
||||||
@@ -736,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
|
||||||
@@ -747,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>
|
||||||
@@ -783,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")}>
|
||||||
@@ -815,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>
|
||||||
@@ -837,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>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -888,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} />
|
||||||
@@ -941,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>
|
||||||
)}
|
)}
|
||||||
@@ -1319,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>
|
||||||
)
|
)
|
||||||
@@ -1331,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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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()} />);
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
|
|||||||
onClick={onCompose}
|
onClick={onCompose}
|
||||||
className="touch-manipulation"
|
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")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -210,12 +210,12 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
|
|||||||
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")} />
|
||||||
@@ -571,7 +571,7 @@ function MoreActionsMenu({ items, label }: { items: MoreItem[]; label: string })
|
|||||||
{open && (
|
{open && (
|
||||||
<div
|
<div
|
||||||
role="menu"
|
role="menu"
|
||||||
className="absolute right-0 top-full mt-1 z-30 min-w-[200px] rounded-md border border-border bg-popover text-popover-foreground shadow-lg py-1 animate-in fade-in-0 zoom-in-95 duration-100"
|
className="absolute end-0 top-full mt-1 z-30 min-w-[200px] rounded-md border border-border bg-popover text-popover-foreground shadow-lg py-1 animate-in fade-in-0 zoom-in-95 duration-100"
|
||||||
>
|
>
|
||||||
{items.map((item, i) => {
|
{items.map((item, i) => {
|
||||||
if (item.separator) {
|
if (item.separator) {
|
||||||
@@ -587,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",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -53,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
|
||||||
@@ -106,7 +106,7 @@ export function ContactGroupDetail({
|
|||||||
className="flex items-center gap-3 px-3 py-2.5 rounded-md hover:bg-muted group transition-colors"
|
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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -284,17 +284,17 @@ export function ContactsSidebar({
|
|||||||
{showMenu && (
|
{showMenu && (
|
||||||
<div
|
<div
|
||||||
ref={menuRef}
|
ref={menuRef}
|
||||||
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 end-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" />
|
||||||
@@ -302,7 +302,7 @@ export function ContactsSidebar({
|
|||||||
</button>
|
</button>
|
||||||
{onCreateAddressBook && (
|
{onCreateAddressBook && (
|
||||||
<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); onCreateAddressBook(); }}
|
onClick={() => { setShowMenu(false); onCreateAddressBook(); }}
|
||||||
>
|
>
|
||||||
<BookPlus className="w-4 h-4" />
|
<BookPlus className="w-4 h-4" />
|
||||||
@@ -311,7 +311,7 @@ export function ContactsSidebar({
|
|||||||
)}
|
)}
|
||||||
{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" />
|
||||||
@@ -338,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>
|
||||||
@@ -355,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" />
|
||||||
@@ -369,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">
|
||||||
@@ -418,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" />
|
||||||
@@ -461,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" />
|
||||||
@@ -483,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"
|
||||||
@@ -492,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>
|
||||||
@@ -505,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" />
|
||||||
@@ -523,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"
|
||||||
@@ -532,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>
|
||||||
@@ -561,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" />
|
||||||
@@ -779,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",
|
||||||
@@ -789,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>
|
||||||
@@ -847,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",
|
||||||
@@ -858,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',
|
||||||
|
|||||||
@@ -121,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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -148,7 +148,10 @@ vi.mock('@/lib/email-sanitization', () => ({
|
|||||||
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
|
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@/lib/reply-identity', () => ({ resolveReplyFrom: () => null }));
|
vi.mock('@/lib/reply-identity', () => ({
|
||||||
|
resolveReplyFrom: () => null,
|
||||||
|
findComposeIdentityId: () => null,
|
||||||
|
}));
|
||||||
vi.mock('@/lib/email-threading', () => ({
|
vi.mock('@/lib/email-threading', () => ({
|
||||||
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
|
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
|
||||||
}));
|
}));
|
||||||
@@ -227,7 +230,7 @@ describe('RecipientChipInput drag and drop', () => {
|
|||||||
fireEvent.dragStart(chipSpan, { dataTransfer: dt });
|
fireEvent.dragStart(chipSpan, { dataTransfer: dt });
|
||||||
|
|
||||||
const payload = JSON.parse(dt.getData('application/x-recipient-chip'));
|
const payload = JSON.parse(dt.getData('application/x-recipient-chip'));
|
||||||
expect(payload).toEqual({ recipient: { email: 'alice@example.com' }, fromField: 'to' });
|
expect(payload).toEqual({ recipient: { email: 'alice@example.com' }, fromField: 'to', fromIndex: 0 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps a display name with a comma in a single chip (array model)', async () => {
|
it('keeps a display name with a comma in a single chip (array model)', async () => {
|
||||||
@@ -241,7 +244,7 @@ describe('RecipientChipInput drag and drop', () => {
|
|||||||
const dt = new MockDataTransfer();
|
const dt = new MockDataTransfer();
|
||||||
fireEvent.dragStart(chipSpan, { dataTransfer: dt });
|
fireEvent.dragStart(chipSpan, { dataTransfer: dt });
|
||||||
const payload = JSON.parse(dt.getData('application/x-recipient-chip'));
|
const payload = JSON.parse(dt.getData('application/x-recipient-chip'));
|
||||||
expect(payload).toEqual({ recipient: { name: 'Doo, John', email: 'john@doo.org' }, fromField: 'to' });
|
expect(payload).toEqual({ recipient: { name: 'Doo, John', email: 'john@doo.org' }, fromField: 'to', fromIndex: 0 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('onDragEnd clears the opacity class on the chip', async () => {
|
it('onDragEnd clears the opacity class on the chip', async () => {
|
||||||
@@ -340,4 +343,121 @@ describe('RecipientChipInput drag and drop', () => {
|
|||||||
const ccLabel = await screen.findByText('cc_label');
|
const ccLabel = await screen.findByText('cc_label');
|
||||||
expect(ccLabel).toBeInTheDocument();
|
expect(ccLabel).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Reordering within / across fields (#593) ─────────────────────────────────
|
||||||
|
// jsdom ignores `clientX` in fireEvent's init for drag events (it's a
|
||||||
|
// read-only MouseEvent getter) and gives every element a zero-size rect at
|
||||||
|
// (0,0). So we dispatch events with `clientX` forced via defineProperty; with
|
||||||
|
// the rect midpoint at 0, clientX>0 lands AFTER the hovered chip, <0 BEFORE.
|
||||||
|
|
||||||
|
const THREE = { ...BASE_DATA, to: 'alice@example.com, bob@example.com, carol@example.com, ' };
|
||||||
|
|
||||||
|
const chipByText = async (text: string) =>
|
||||||
|
(await screen.findByText(text)).closest('[draggable]') as HTMLElement;
|
||||||
|
|
||||||
|
/** Dispatch a drag event with a real clientX (fireEvent init drops it). */
|
||||||
|
const fireDnd = (type: 'dragover' | 'drop', el: HTMLElement, dt: MockDataTransfer, clientX: number) => {
|
||||||
|
const e = new Event(type, { bubbles: true, cancelable: true });
|
||||||
|
Object.defineProperty(e, 'clientX', { value: clientX });
|
||||||
|
Object.defineProperty(e, 'dataTransfer', { value: dt });
|
||||||
|
act(() => { fireEvent(el, e); });
|
||||||
|
};
|
||||||
|
const BEFORE = -100;
|
||||||
|
const AFTER = 100;
|
||||||
|
|
||||||
|
/** Ordered chip labels of the field-container that holds `anchorText`. */
|
||||||
|
const orderIn = (anchorText: string) => {
|
||||||
|
const containers = Array.from(document.querySelectorAll('[class*="flex-wrap"]'));
|
||||||
|
const c = containers.find(el =>
|
||||||
|
Array.from(el.querySelectorAll('[draggable]')).some(d => d.textContent?.includes(anchorText))
|
||||||
|
) as HTMLElement;
|
||||||
|
return Array.from(c.querySelectorAll('[draggable]')).map(el => el.textContent?.trim() ?? '');
|
||||||
|
};
|
||||||
|
|
||||||
|
/** All draggable chips (across fields) whose label contains `text`. */
|
||||||
|
const draggableChipsWith = (text: string) =>
|
||||||
|
Array.from(document.querySelectorAll('[draggable]')).filter(el => el.textContent?.includes(text));
|
||||||
|
|
||||||
|
it('reorders a chip to the end of the same field (drop after the last chip)', async () => {
|
||||||
|
render(<EmailComposer initialData={THREE} />);
|
||||||
|
await screen.findByText('alice@example.com');
|
||||||
|
const alice = await chipByText('alice@example.com');
|
||||||
|
const carol = await chipByText('carol@example.com');
|
||||||
|
|
||||||
|
const dt = new MockDataTransfer();
|
||||||
|
fireEvent.dragStart(alice, { dataTransfer: dt }); // fromIndex 0
|
||||||
|
fireDnd('dragover', carol, dt, AFTER); // after carol -> index 3
|
||||||
|
fireDnd('drop', carol, dt, AFTER);
|
||||||
|
|
||||||
|
expect(orderIn('bob@example.com')).toEqual([
|
||||||
|
'bob@example.com', 'carol@example.com', 'alice@example.com',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reorders a chip to the front of the same field (drop before the first chip)', async () => {
|
||||||
|
render(<EmailComposer initialData={THREE} />);
|
||||||
|
await screen.findByText('carol@example.com');
|
||||||
|
const carol = await chipByText('carol@example.com');
|
||||||
|
const alice = await chipByText('alice@example.com');
|
||||||
|
|
||||||
|
const dt = new MockDataTransfer();
|
||||||
|
fireEvent.dragStart(carol, { dataTransfer: dt }); // fromIndex 2
|
||||||
|
fireDnd('dragover', alice, dt, BEFORE); // before alice -> index 0
|
||||||
|
fireDnd('drop', alice, dt, BEFORE);
|
||||||
|
|
||||||
|
expect(orderIn('alice@example.com')).toEqual([
|
||||||
|
'carol@example.com', 'alice@example.com', 'bob@example.com',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dropping a chip onto its own position leaves the order unchanged', async () => {
|
||||||
|
render(<EmailComposer initialData={THREE} />);
|
||||||
|
await screen.findByText('bob@example.com');
|
||||||
|
const bob = await chipByText('bob@example.com');
|
||||||
|
|
||||||
|
const dt = new MockDataTransfer();
|
||||||
|
fireEvent.dragStart(bob, { dataTransfer: dt }); // fromIndex 1
|
||||||
|
fireDnd('dragover', bob, dt, BEFORE); // before itself -> index 1 (no-op)
|
||||||
|
fireDnd('drop', bob, dt, BEFORE);
|
||||||
|
|
||||||
|
expect(orderIn('bob@example.com')).toEqual([
|
||||||
|
'alice@example.com', 'bob@example.com', 'carol@example.com',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves a chip into another field at the drop position (cross-field reorder)', async () => {
|
||||||
|
render(<EmailComposer initialData={{ ...BASE_DATA, to: 'alice@example.com, ', cc: 'x@example.com, y@example.com, ' }} />);
|
||||||
|
await screen.findByText('alice@example.com');
|
||||||
|
const alice = await chipByText('alice@example.com'); // To
|
||||||
|
const y = await chipByText('y@example.com'); // Cc
|
||||||
|
|
||||||
|
const dt = new MockDataTransfer();
|
||||||
|
fireEvent.dragStart(alice, { dataTransfer: dt });
|
||||||
|
fireDnd('dragover', y, dt, BEFORE); // before y -> index 1 in Cc
|
||||||
|
fireDnd('drop', y, dt, BEFORE);
|
||||||
|
|
||||||
|
// alice lands between x and y; To no longer holds it (count only real chips,
|
||||||
|
// not the leftover jsdom drag-preview element)
|
||||||
|
expect(orderIn('x@example.com')).toEqual([
|
||||||
|
'x@example.com', 'alice@example.com', 'y@example.com',
|
||||||
|
]);
|
||||||
|
expect(draggableChipsWith('alice@example.com')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a drop caret only while a chip is dragged over the field', async () => {
|
||||||
|
render(<EmailComposer initialData={THREE} />);
|
||||||
|
await screen.findByText('alice@example.com');
|
||||||
|
const alice = await chipByText('alice@example.com');
|
||||||
|
const bob = await chipByText('bob@example.com');
|
||||||
|
|
||||||
|
const dt = new MockDataTransfer();
|
||||||
|
fireEvent.dragStart(alice, { dataTransfer: dt });
|
||||||
|
expect(document.querySelector('[data-testid="recipient-drop-caret"]')).toBeNull();
|
||||||
|
|
||||||
|
fireDnd('dragover', bob, dt, BEFORE);
|
||||||
|
expect(document.querySelector('[data-testid="recipient-drop-caret"]')).not.toBeNull();
|
||||||
|
|
||||||
|
fireEvent.dragEnd(alice);
|
||||||
|
expect(document.querySelector('[data-testid="recipient-drop-caret"]')).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -147,7 +147,10 @@ vi.mock('@/lib/email-sanitization', () => ({
|
|||||||
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
|
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@/lib/reply-identity', () => ({ resolveReplyFrom: () => null }));
|
vi.mock('@/lib/reply-identity', () => ({
|
||||||
|
resolveReplyFrom: () => null,
|
||||||
|
findComposeIdentityId: () => null,
|
||||||
|
}));
|
||||||
vi.mock('@/lib/email-threading', () => ({
|
vi.mock('@/lib/email-threading', () => ({
|
||||||
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
|
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useTranslations, useFormatter } from 'next-intl';
|
import { useTranslations, useFormatter } from 'next-intl';
|
||||||
import { useRouter } from '@/i18n/navigation';
|
import { useRouter } from '@/i18n/navigation';
|
||||||
|
import { isDocumentRTL } from '@/i18n/direction';
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
import { useCalendarStore } from '@/stores/calendar-store';
|
import { useCalendarStore } from '@/stores/calendar-store';
|
||||||
import { useSettingsStore } from '@/stores/settings-store';
|
import { useSettingsStore } from '@/stores/settings-store';
|
||||||
@@ -374,7 +375,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
const [actionError, setActionError] = useState<string | null>(null);
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
const [showCalendarPicker, setShowCalendarPicker] = useState(false);
|
const [showCalendarPicker, setShowCalendarPicker] = useState(false);
|
||||||
const [pickerPosition, setPickerPosition] = useState<{ top: number; left: number } | null>(null);
|
const [pickerPosition, setPickerPosition] = useState<{ top: number; left?: number; right?: number } | null>(null);
|
||||||
const pickerTriggerRef = useRef<HTMLButtonElement>(null);
|
const pickerTriggerRef = useRef<HTMLButtonElement>(null);
|
||||||
const [selectedCalendarId, setSelectedCalendarId] = useState<string>('');
|
const [selectedCalendarId, setSelectedCalendarId] = useState<string>('');
|
||||||
const [rawIcsMethod, setRawIcsMethod] = useState<InvitationMethod>('unknown');
|
const [rawIcsMethod, setRawIcsMethod] = useState<InvitationMethod>('unknown');
|
||||||
@@ -582,7 +583,12 @@ 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);
|
||||||
setRsvpStatus(status);
|
setRsvpStatus(status);
|
||||||
setActionNotice(t('rsvp_sent'));
|
setActionNotice(t('rsvp_sent'));
|
||||||
@@ -1021,7 +1027,11 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
}
|
}
|
||||||
if (pickerTriggerRef.current) {
|
if (pickerTriggerRef.current) {
|
||||||
const rect = pickerTriggerRef.current.getBoundingClientRect();
|
const rect = pickerTriggerRef.current.getBoundingClientRect();
|
||||||
setPickerPosition({ top: rect.bottom + 4, left: rect.left });
|
setPickerPosition(
|
||||||
|
isDocumentRTL()
|
||||||
|
? { top: rect.bottom + 4, right: window.innerWidth - rect.right }
|
||||||
|
: { top: rect.bottom + 4, left: rect.left }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
setShowCalendarPicker(true);
|
setShowCalendarPicker(true);
|
||||||
}}
|
}}
|
||||||
@@ -1036,7 +1046,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
{showCalendarPicker && calendars.length > 1 && pickerPosition && typeof document !== 'undefined' && createPortal(
|
{showCalendarPicker && calendars.length > 1 && pickerPosition && typeof document !== 'undefined' && createPortal(
|
||||||
<div
|
<div
|
||||||
className="fixed w-52 bg-background rounded-lg shadow-lg border border-border z-50 py-1"
|
className="fixed w-52 bg-background rounded-lg shadow-lg border border-border z-50 py-1"
|
||||||
style={{ top: pickerPosition.top, left: pickerPosition.left }}
|
style={{ top: pickerPosition.top, left: pickerPosition.left, right: pickerPosition.right }}
|
||||||
>
|
>
|
||||||
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
|
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
|
||||||
{t('select_calendar')}
|
{t('select_calendar')}
|
||||||
@@ -1048,7 +1058,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"
|
||||||
@@ -1090,7 +1100,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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+527
-115
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,
|
||||||
@@ -59,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;
|
||||||
@@ -126,6 +129,7 @@ export function EmailContextMenu({
|
|||||||
onForward,
|
onForward,
|
||||||
onMarkAsRead,
|
onMarkAsRead,
|
||||||
onToggleStar,
|
onToggleStar,
|
||||||
|
onTogglePinned,
|
||||||
onDelete,
|
onDelete,
|
||||||
onArchive,
|
onArchive,
|
||||||
onSetColorTag,
|
onSetColorTag,
|
||||||
@@ -149,10 +153,14 @@ export function EmailContextMenu({
|
|||||||
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';
|
||||||
|
|
||||||
@@ -287,6 +295,7 @@ export function EmailContextMenu({
|
|||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
icon={Trash2}
|
icon={Trash2}
|
||||||
label={t("delete")}
|
label={t("delete")}
|
||||||
|
testId="ctx-delete"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
handleAction(showBatchActions ? onBatchDelete! : onDelete!)
|
handleAction(showBatchActions ? onBatchDelete! : onDelete!)
|
||||||
}
|
}
|
||||||
@@ -298,7 +307,7 @@ export function EmailContextMenu({
|
|||||||
|
|
||||||
{/* Move to submenu */}
|
{/* Move to submenu */}
|
||||||
{moveTree.length > 0 && (
|
{moveTree.length > 0 && (
|
||||||
<ContextMenuSubMenu icon={FolderInput} label={t("move_to")}>
|
<ContextMenuSubMenu icon={FolderInput} label={t("move_to")} testId="ctx-move-to">
|
||||||
{(() => {
|
{(() => {
|
||||||
const renderNodes = (nodes: MailboxNode[]) => {
|
const renderNodes = (nodes: MailboxNode[]) => {
|
||||||
return nodes.map((node) => {
|
return nodes.map((node) => {
|
||||||
@@ -311,6 +320,7 @@ export function EmailContextMenu({
|
|||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
icon={Icon}
|
icon={Icon}
|
||||||
label={nodeLabel}
|
label={nodeLabel}
|
||||||
|
testId={`move-to:${node.id}`}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
handleAction(() =>
|
handleAction(() =>
|
||||||
showBatchActions
|
showBatchActions
|
||||||
@@ -326,7 +336,7 @@ export function EmailContextMenu({
|
|||||||
</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>
|
||||||
)}
|
)}
|
||||||
@@ -349,6 +359,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")}>
|
||||||
@@ -360,7 +379,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"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -385,22 +404,27 @@ 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")}
|
testId={isInJunkFolder ? "ctx-not-spam" : "ctx-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 />
|
||||||
|
|
||||||
@@ -408,6 +432,7 @@ export function EmailContextMenu({
|
|||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
icon={isUnread ? MailOpen : Mail}
|
icon={isUnread ? MailOpen : Mail}
|
||||||
label={isUnread ? t("mark_read") : t("mark_unread")}
|
label={isUnread ? t("mark_read") : t("mark_unread")}
|
||||||
|
testId={isUnread ? "ctx-mark-read" : "ctx-mark-unread"}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
handleAction(() =>
|
handleAction(() =>
|
||||||
showBatchActions
|
showBatchActions
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ interface EmailHoverActionsProps {
|
|||||||
// the spam quick-action flips to "not spam".
|
// the spam quick-action flips to "not spam".
|
||||||
isInJunk?: boolean;
|
isInJunk?: boolean;
|
||||||
onUndoSpam?: () => void;
|
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, {
|
||||||
@@ -78,6 +80,7 @@ export function EmailHoverActions({
|
|||||||
onMarkAsSpam,
|
onMarkAsSpam,
|
||||||
isInJunk = false,
|
isInJunk = false,
|
||||||
onUndoSpam,
|
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);
|
||||||
@@ -121,6 +124,7 @@ 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".
|
// In a junk context the spam action becomes "not spam".
|
||||||
@@ -177,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,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";
|
||||||
@@ -34,16 +34,19 @@ interface EmailListItemProps {
|
|||||||
|
|
||||||
export function EmailListItem({ email, selected, onClick, onDoubleClick, onContextMenu, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam, onUndoSpam }: 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 tBatch = useTranslations('email_list.batch_actions');
|
||||||
const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, mailboxes, clearSelection, isUnifiedView, unifiedRole } = useEmailStore();
|
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;
|
||||||
@@ -66,7 +69,7 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
|
|||||||
const keywordDefs = colorTagIds.map(id => emailKeywords.find(k => k.id === id) ?? { id, label: id, color: 'gray' });
|
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({
|
||||||
@@ -87,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) => {
|
||||||
@@ -166,19 +176,22 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
|
|||||||
|
|
||||||
{/* Unread indicator */}
|
{/* Unread indicator */}
|
||||||
{isUnread && (
|
{isUnread && (
|
||||||
<div className="absolute left-0.5 top-1/2 -translate-y-1/2">
|
<div className="absolute start-0.5 top-1/2 -translate-y-1/2">
|
||||||
<Circle className="w-2 h-2 fill-unread text-unread" />
|
<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')}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -206,6 +219,7 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
|
|||||||
</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" />}
|
||||||
@@ -242,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" />
|
||||||
)}
|
)}
|
||||||
@@ -327,6 +344,7 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
|
|||||||
onMarkAsSpam={onMarkAsSpam}
|
onMarkAsSpam={onMarkAsSpam}
|
||||||
onUndoSpam={onUndoSpam}
|
onUndoSpam={onUndoSpam}
|
||||||
isInJunk={currentMailboxRole === 'junk'}
|
isInJunk={currentMailboxRole === 'junk'}
|
||||||
|
spamApplicable={!['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || '')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Email, ThreadGroup } from "@/lib/jmap/types";
|
|||||||
import { ThreadListItem } from "./thread-list-item";
|
import { ThreadListItem } from "./thread-list-item";
|
||||||
import { EmailContextMenu } from "./email-context-menu";
|
import { EmailContextMenu } from "./email-context-menu";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Trash2, Mail, MailX, MailOpen, Loader2, SearchX, AlertTriangle, CalendarClock } from "lucide-react";
|
import { Trash2, Mail, MailX, MailOpen, Loader2, SearchX, AlertTriangle, CalendarClock, ShieldCheck } from "lucide-react";
|
||||||
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||||
@@ -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,
|
||||||
@@ -189,6 +191,22 @@ export function EmailList({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleBatchUndoSpam = async () => {
|
||||||
|
if (!client || isProcessing) return;
|
||||||
|
setIsProcessing(true);
|
||||||
|
try {
|
||||||
|
const emailIds = Array.from(selectedEmailIds);
|
||||||
|
await batchUndoSpam(client, emailIds);
|
||||||
|
const { toast } = await import('sonner');
|
||||||
|
toast.success(t('../email_viewer.spam.toast_not_spam_batch', { count: emailIds.length }));
|
||||||
|
} catch {
|
||||||
|
const { toast } = await import('sonner');
|
||||||
|
toast.error(t('../email_viewer.spam.error_not_spam'));
|
||||||
|
} finally {
|
||||||
|
setTimeout(() => setIsProcessing(false), 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleBatchDelete = async () => {
|
const handleBatchDelete = async () => {
|
||||||
if (!client || isProcessing) return;
|
if (!client || isProcessing) return;
|
||||||
|
|
||||||
@@ -355,6 +373,22 @@ export function EmailList({
|
|||||||
<Mail className="w-4 h-4" />
|
<Mail className="w-4 h-4" />
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
{effectiveMailboxRole === 'junk' && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleBatchUndoSpam}
|
||||||
|
title={t('../context_menu.not_spam')}
|
||||||
|
disabled={isProcessing}
|
||||||
|
className="text-emerald-600 dark:text-emerald-400 hover:bg-emerald-100/50 dark:hover:bg-emerald-950/30 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isProcessing ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<ShieldCheck className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -415,9 +449,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>
|
||||||
@@ -551,6 +585,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)}
|
||||||
|
|||||||
+284
-132
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,10 @@ import { buildSignatureBlock } from "@/components/email/signature-block";
|
|||||||
// HTML, so parseHTML can recognise it on the way back in.
|
// HTML, so parseHTML can recognise it on the way back in.
|
||||||
export const QUOTED_HTML_MARKER = "data-quoted-html";
|
export const QUOTED_HTML_MARKER = "data-quoted-html";
|
||||||
|
|
||||||
|
// Reusable style for the quote bar when quoting email text (like in a reply).
|
||||||
|
const QUOTE_BAR_STYLE =
|
||||||
|
"border-left:2px solid #c5c5c5;padding-left:12px;margin-top:8px;";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* QuotedHtml — an atomic block node that carries the *verbatim* HTML of a
|
* QuotedHtml — an atomic block node that carries the *verbatim* HTML of a
|
||||||
* quoted/forwarded original email. The HTML is stored in the `html` attribute
|
* quoted/forwarded original email. The HTML is stored in the `html` attribute
|
||||||
@@ -68,8 +72,7 @@ export const QuotedHtml = TiptapNode.create({
|
|||||||
const dom = document.createElement("div");
|
const dom = document.createElement("div");
|
||||||
dom.setAttribute(QUOTED_HTML_MARKER, "");
|
dom.setAttribute(QUOTED_HTML_MARKER, "");
|
||||||
dom.className = "quoted-html-island";
|
dom.className = "quoted-html-island";
|
||||||
dom.style.cssText =
|
dom.style.cssText = QUOTE_BAR_STYLE;
|
||||||
"border-left:2px solid #c5c5c5;padding-left:12px;margin-top:8px;";
|
|
||||||
|
|
||||||
// CRITICAL: render the quoted email inside a Shadow Root. The app's
|
// CRITICAL: render the quoted email inside a Shadow Root. The app's
|
||||||
// global CSS (Tailwind preflight, .tiptap table/td rules, box-sizing
|
// global CSS (Tailwind preflight, .tiptap table/td rules, box-sizing
|
||||||
@@ -192,5 +195,5 @@ export function serializeEditorContent(editor: Editor): string {
|
|||||||
* must be what serializeEditorContent emits too (round-trip consistency).
|
* must be what serializeEditorContent emits too (round-trip consistency).
|
||||||
*/
|
*/
|
||||||
export function buildQuotedHtmlBlock(sanitizedInnerHtml: string): string {
|
export function buildQuotedHtmlBlock(sanitizedInnerHtml: string): string {
|
||||||
return `<div ${QUOTED_HTML_MARKER}>${sanitizedInnerHtml}</div>`;
|
return `<div ${QUOTED_HTML_MARKER} style="${QUOTE_BAR_STYLE}">${sanitizedInnerHtml}</div>`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { MailCheck, Loader2, CheckCircle } from 'lucide-react';
|
import { MailCheck, Loader2, CheckCircle, X } from 'lucide-react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
interface ReadReceiptBannerProps {
|
interface ReadReceiptBannerProps {
|
||||||
@@ -17,43 +17,61 @@ export function ReadReceiptBanner({ requestedBy, onSend, onIgnore }: ReadReceipt
|
|||||||
const t = useTranslations('email_viewer.read_receipt');
|
const t = useTranslations('email_viewer.read_receipt');
|
||||||
const [state, setState] = useState<'idle' | 'sending' | 'sent'>('idle');
|
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') {
|
if (state === 'sent') {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 rounded-md border border-border bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
|
<div className="flex items-center gap-3 py-1">
|
||||||
<CheckCircle className="w-4 h-4 text-green-600 dark:text-green-400 shrink-0" />
|
<div className="w-10 h-10 rounded-full bg-success/15 text-success flex items-center justify-center flex-shrink-0 shadow-sm">
|
||||||
<span>{t('sent')}</span>
|
<CheckCircle className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-muted-foreground">{t('sent')}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-center gap-2 rounded-md border border-amber-300/60 bg-amber-50 px-3 py-2 text-sm dark:border-amber-700/50 dark:bg-amber-950/30">
|
<div className="flex items-start gap-3 py-1">
|
||||||
<MailCheck className="w-4 h-4 shrink-0 text-amber-600 dark:text-amber-400" />
|
<div className="w-10 h-10 rounded-full bg-info/15 text-info flex items-center justify-center flex-shrink-0 shadow-sm">
|
||||||
<span className="text-foreground">{t('prompt')}</span>
|
<MailCheck className="w-5 h-5" />
|
||||||
<span className="break-all text-muted-foreground">{requestedBy}</span>
|
</div>
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="flex-1 min-w-0 space-y-2">
|
||||||
<button
|
<div>
|
||||||
onClick={async () => {
|
<div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
setState('sending');
|
Read receipt
|
||||||
try {
|
</div>
|
||||||
await onSend();
|
<div className="text-sm font-medium text-foreground break-words">
|
||||||
setState('sent');
|
{t('prompt')}
|
||||||
} catch {
|
</div>
|
||||||
setState('idle');
|
<div className="text-xs text-muted-foreground break-all">
|
||||||
}
|
Requested by <span className="text-foreground/80">{requestedBy}</span>
|
||||||
}}
|
</div>
|
||||||
disabled={state === 'sending'}
|
</div>
|
||||||
className="inline-flex items-center gap-1.5 rounded-md bg-green-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
|
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
|
||||||
>
|
<button
|
||||||
{state === 'sending' && <Loader2 className="w-3 h-3 animate-spin" />}
|
onClick={async () => {
|
||||||
{t('send')}
|
setState('sending');
|
||||||
</button>
|
try {
|
||||||
<button
|
await onSend();
|
||||||
onClick={onIgnore}
|
setState('sent');
|
||||||
className="rounded-md bg-red-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-red-700"
|
} catch {
|
||||||
>
|
setState('idle');
|
||||||
{t('ignore')}
|
}
|
||||||
</button>
|
}}
|
||||||
|
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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ export function RecipientPopover({ name, email, displayLabel, onViewContact, cla
|
|||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{displayLabel || name || email}
|
<bdi>{displayLabel || name || email}</bdi>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isOpen &&
|
{isOpen &&
|
||||||
@@ -221,7 +221,7 @@ export function RecipientPopover({ name, email, displayLabel, onViewContact, cla
|
|||||||
{onViewContact && (
|
{onViewContact && (
|
||||||
<button
|
<button
|
||||||
onClick={handleViewContact}
|
onClick={handleViewContact}
|
||||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground px-2 py-1.5 rounded hover:bg-muted transition-colors ml-auto"
|
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground px-2 py-1.5 rounded hover:bg-muted transition-colors ms-auto"
|
||||||
title={contact ? "View contact" : "View details"}
|
title={contact ? "View contact" : "View details"}
|
||||||
>
|
>
|
||||||
{contact ? <ExternalLink className="w-3.5 h-3.5" /> : <UserPlus className="w-3.5 h-3.5" />}
|
{contact ? <ExternalLink className="w-3.5 h-3.5" /> : <UserPlus className="w-3.5 h-3.5" />}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import Heading from "@tiptap/extension-heading";
|
|||||||
import Underline from "@tiptap/extension-underline";
|
import Underline from "@tiptap/extension-underline";
|
||||||
import Link from "@tiptap/extension-link";
|
import Link from "@tiptap/extension-link";
|
||||||
import TextAlign from "@tiptap/extension-text-align";
|
import TextAlign from "@tiptap/extension-text-align";
|
||||||
|
import { TextDirection } from "@/components/email/text-direction";
|
||||||
import { TextStyle } from "@tiptap/extension-text-style";
|
import { TextStyle } from "@tiptap/extension-text-style";
|
||||||
import Color from "@tiptap/extension-color";
|
import Color from "@tiptap/extension-color";
|
||||||
import { ResizableImage } from "@/components/email/resizable-image";
|
import { ResizableImage } from "@/components/email/resizable-image";
|
||||||
@@ -19,6 +20,7 @@ import { TableCell } from "@tiptap/extension-table-cell";
|
|||||||
import { QuotedHtml, serializeEditorContent } from "@/components/email/quoted-html";
|
import { QuotedHtml, serializeEditorContent } from "@/components/email/quoted-html";
|
||||||
import { SignatureBlock } from "@/components/email/signature-block";
|
import { SignatureBlock } from "@/components/email/signature-block";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
import {
|
import {
|
||||||
Bold,
|
Bold,
|
||||||
Italic,
|
Italic,
|
||||||
@@ -29,6 +31,7 @@ import {
|
|||||||
AlignLeft,
|
AlignLeft,
|
||||||
AlignCenter,
|
AlignCenter,
|
||||||
AlignRight,
|
AlignRight,
|
||||||
|
ArrowLeftRight,
|
||||||
Link as LinkIcon,
|
Link as LinkIcon,
|
||||||
Undo,
|
Undo,
|
||||||
Redo,
|
Redo,
|
||||||
@@ -38,6 +41,7 @@ import {
|
|||||||
Heading1,
|
Heading1,
|
||||||
Heading2,
|
Heading2,
|
||||||
Table as TableIcon,
|
Table as TableIcon,
|
||||||
|
Baseline,
|
||||||
Trash2,
|
Trash2,
|
||||||
Rows3,
|
Rows3,
|
||||||
Columns3,
|
Columns3,
|
||||||
@@ -140,6 +144,14 @@ function ToolbarSeparator() {
|
|||||||
const TABLE_PICKER_ROWS = 6;
|
const TABLE_PICKER_ROWS = 6;
|
||||||
const TABLE_PICKER_COLS = 8;
|
const TABLE_PICKER_COLS = 8;
|
||||||
|
|
||||||
|
// Preset text colours (2 x 8). Inline `style="color: …"` survives email
|
||||||
|
// round-trips; the TextStyle/Color extensions are already registered to
|
||||||
|
// preserve pasted colours - this palette just adds a UI to set them.
|
||||||
|
const TEXT_COLORS = [
|
||||||
|
"#000000", "#5f6368", "#9aa0a6", "#c5221f", "#e8710a", "#f9ab00", "#188038", "#1967d2",
|
||||||
|
"#7627bb", "#c2185b", "#795548", "#fa5252", "#fd7e14", "#40c057", "#4dabf7", "#e64980",
|
||||||
|
];
|
||||||
|
|
||||||
function TableSizePicker({ onPick }: { onPick: (rows: number, cols: number) => void }) {
|
function TableSizePicker({ onPick }: { onPick: (rows: number, cols: number) => void }) {
|
||||||
const [hover, setHover] = useState<{ r: number; c: number } | null>(null);
|
const [hover, setHover] = useState<{ r: number; c: number } | null>(null);
|
||||||
return (
|
return (
|
||||||
@@ -183,6 +195,7 @@ export function RichTextEditor({
|
|||||||
hasError,
|
hasError,
|
||||||
onEditorReady,
|
onEditorReady,
|
||||||
}: RichTextEditorProps) {
|
}: RichTextEditorProps) {
|
||||||
|
const rtlEditingSupport = useSettingsStore((st) => st.rtlEditingSupport);
|
||||||
const onImageUploadRef = React.useRef(onImageUpload);
|
const onImageUploadRef = React.useRef(onImageUpload);
|
||||||
onImageUploadRef.current = onImageUpload;
|
onImageUploadRef.current = onImageUpload;
|
||||||
const onEditorReadyRef = React.useRef(onEditorReady);
|
const onEditorReadyRef = React.useRef(onEditorReady);
|
||||||
@@ -240,6 +253,7 @@ export function RichTextEditor({
|
|||||||
// rich/branded signatures keep their inline styling in the editor and
|
// rich/branded signatures keep their inline styling in the editor and
|
||||||
// in the sent mail (see signature-block.ts).
|
// in the sent mail (see signature-block.ts).
|
||||||
SignatureBlock,
|
SignatureBlock,
|
||||||
|
TextDirection,
|
||||||
],
|
],
|
||||||
content,
|
content,
|
||||||
editorProps: {
|
editorProps: {
|
||||||
@@ -331,6 +345,19 @@ export function RichTextEditor({
|
|||||||
|
|
||||||
const [tableMenuOpen, setTableMenuOpen] = useState(false);
|
const [tableMenuOpen, setTableMenuOpen] = useState(false);
|
||||||
const tableWrapperRef = useRef<HTMLDivElement>(null);
|
const tableWrapperRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [colorMenuOpen, setColorMenuOpen] = useState(false);
|
||||||
|
const colorWrapperRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!colorMenuOpen) return;
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (colorWrapperRef.current && !colorWrapperRef.current.contains(e.target as Node)) {
|
||||||
|
setColorMenuOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", handler);
|
||||||
|
return () => document.removeEventListener("mousedown", handler);
|
||||||
|
}, [colorMenuOpen]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!tableMenuOpen) return;
|
if (!tableMenuOpen) return;
|
||||||
@@ -381,6 +408,49 @@ export function RichTextEditor({
|
|||||||
>
|
>
|
||||||
<Strikethrough className="w-4 h-4" />
|
<Strikethrough className="w-4 h-4" />
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
|
<div ref={colorWrapperRef} className="relative">
|
||||||
|
<ToolbarButton
|
||||||
|
active={!!editor.getAttributes("textStyle").color}
|
||||||
|
onClick={() => setColorMenuOpen((v) => !v)}
|
||||||
|
title="Text color"
|
||||||
|
>
|
||||||
|
{/* The icon itself previews the active colour - no layout shift. */}
|
||||||
|
<Baseline className="w-4 h-4" style={{ color: editor.getAttributes("textStyle").color || undefined }} />
|
||||||
|
</ToolbarButton>
|
||||||
|
{colorMenuOpen && (
|
||||||
|
<div className="absolute z-50 top-full start-0 mt-1 bg-popover border border-border rounded-md shadow-md p-2">
|
||||||
|
<div className="grid gap-0.5" style={{ gridTemplateColumns: "repeat(8, 1fr)" }}>
|
||||||
|
{TEXT_COLORS.map((color) => (
|
||||||
|
<button
|
||||||
|
key={color}
|
||||||
|
type="button"
|
||||||
|
title={color}
|
||||||
|
onClick={() => {
|
||||||
|
editor.chain().focus().setColor(color).run();
|
||||||
|
setColorMenuOpen(false);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"w-4 h-4 border border-border/60 rounded-[2px] transition-transform hover:scale-110",
|
||||||
|
editor.getAttributes("textStyle").color === color && "ring-1 ring-ring ring-offset-1"
|
||||||
|
)}
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="h-px bg-border my-1.5" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-2 px-2 py-1 text-sm rounded hover:bg-accent text-start w-full"
|
||||||
|
onClick={() => {
|
||||||
|
editor.chain().focus().unsetColor().run();
|
||||||
|
setColorMenuOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RemoveFormatting className="w-4 h-4" /> Remove color
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<ToolbarSeparator />
|
<ToolbarSeparator />
|
||||||
|
|
||||||
@@ -454,6 +524,22 @@ export function RichTextEditor({
|
|||||||
<AlignRight className="w-4 h-4" />
|
<AlignRight className="w-4 h-4" />
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
|
|
||||||
|
{rtlEditingSupport && (
|
||||||
|
<ToolbarButton
|
||||||
|
active={
|
||||||
|
(editor.getAttributes("paragraph").dir || editor.getAttributes("heading").dir) === "rtl"
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
const cur =
|
||||||
|
editor.getAttributes("paragraph").dir || editor.getAttributes("heading").dir;
|
||||||
|
editor.chain().focus().setTextDirection(cur === "rtl" ? "ltr" : "rtl").run();
|
||||||
|
}}
|
||||||
|
title="Text direction (RTL/LTR)"
|
||||||
|
>
|
||||||
|
<ArrowLeftRight className="w-4 h-4" />
|
||||||
|
</ToolbarButton>
|
||||||
|
)}
|
||||||
|
|
||||||
<ToolbarSeparator />
|
<ToolbarSeparator />
|
||||||
|
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
@@ -473,33 +559,33 @@ export function RichTextEditor({
|
|||||||
<TableIcon className="w-4 h-4" />
|
<TableIcon className="w-4 h-4" />
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
{tableMenuOpen && (
|
{tableMenuOpen && (
|
||||||
<div className="absolute z-50 top-full left-0 mt-1 bg-popover border border-border rounded-md shadow-md p-2 min-w-[200px]">
|
<div className="absolute z-50 top-full start-0 mt-1 bg-popover border border-border rounded-md shadow-md p-2 min-w-[200px]">
|
||||||
{editor.isActive("table") ? (
|
{editor.isActive("table") ? (
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
|
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-start"
|
||||||
onClick={() => { editor.chain().focus().addRowBefore().run(); setTableMenuOpen(false); }}
|
onClick={() => { editor.chain().focus().addRowBefore().run(); setTableMenuOpen(false); }}
|
||||||
>
|
>
|
||||||
<Rows3 className="w-4 h-4" /> Add row above
|
<Rows3 className="w-4 h-4" /> Add row above
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
|
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-start"
|
||||||
onClick={() => { editor.chain().focus().addRowAfter().run(); setTableMenuOpen(false); }}
|
onClick={() => { editor.chain().focus().addRowAfter().run(); setTableMenuOpen(false); }}
|
||||||
>
|
>
|
||||||
<Rows3 className="w-4 h-4" /> Add row below
|
<Rows3 className="w-4 h-4" /> Add row below
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
|
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-start"
|
||||||
onClick={() => { editor.chain().focus().addColumnBefore().run(); setTableMenuOpen(false); }}
|
onClick={() => { editor.chain().focus().addColumnBefore().run(); setTableMenuOpen(false); }}
|
||||||
>
|
>
|
||||||
<Columns3 className="w-4 h-4" /> Add column before
|
<Columns3 className="w-4 h-4" /> Add column before
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
|
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-start"
|
||||||
onClick={() => { editor.chain().focus().addColumnAfter().run(); setTableMenuOpen(false); }}
|
onClick={() => { editor.chain().focus().addColumnAfter().run(); setTableMenuOpen(false); }}
|
||||||
>
|
>
|
||||||
<Columns3 className="w-4 h-4" /> Add column after
|
<Columns3 className="w-4 h-4" /> Add column after
|
||||||
@@ -507,21 +593,21 @@ export function RichTextEditor({
|
|||||||
<div className="h-px bg-border my-1" />
|
<div className="h-px bg-border my-1" />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
|
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-start"
|
||||||
onClick={() => { editor.chain().focus().deleteRow().run(); setTableMenuOpen(false); }}
|
onClick={() => { editor.chain().focus().deleteRow().run(); setTableMenuOpen(false); }}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" /> Delete row
|
<Trash2 className="w-4 h-4" /> Delete row
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
|
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-start"
|
||||||
onClick={() => { editor.chain().focus().deleteColumn().run(); setTableMenuOpen(false); }}
|
onClick={() => { editor.chain().focus().deleteColumn().run(); setTableMenuOpen(false); }}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" /> Delete column
|
<Trash2 className="w-4 h-4" /> Delete column
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
|
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-start"
|
||||||
onClick={() => { editor.chain().focus().toggleHeaderRow().run(); setTableMenuOpen(false); }}
|
onClick={() => { editor.chain().focus().toggleHeaderRow().run(); setTableMenuOpen(false); }}
|
||||||
>
|
>
|
||||||
<Rows3 className="w-4 h-4" /> Toggle header row
|
<Rows3 className="w-4 h-4" /> Toggle header row
|
||||||
@@ -529,7 +615,7 @@ export function RichTextEditor({
|
|||||||
<div className="h-px bg-border my-1" />
|
<div className="h-px bg-border my-1" />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left text-red-600 dark:text-red-400"
|
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-start text-red-600 dark:text-red-400"
|
||||||
onClick={() => { editor.chain().focus().deleteTable().run(); setTableMenuOpen(false); }}
|
onClick={() => { editor.chain().focus().deleteTable().run(); setTableMenuOpen(false); }}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" /> Delete table
|
<Trash2 className="w-4 h-4" /> Delete table
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ComponentProps } from "react";
|
||||||
|
import { Check } from "lucide-react";
|
||||||
|
import { Avatar } from "@/components/ui/avatar";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type SelectableAvatarProps = ComponentProps<typeof Avatar> & {
|
||||||
|
/** Whether the underlying message/thread is currently selected. */
|
||||||
|
checked: boolean;
|
||||||
|
/** Toggle selection. The wrapper stops propagation so the row is not opened. */
|
||||||
|
onToggle: () => void;
|
||||||
|
/** Accessible label for the selection control. */
|
||||||
|
selectLabel?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Avatar that doubles as a selection control, Thunderbird-style: clicking the
|
||||||
|
* avatar toggles the message/thread into the current selection instead of
|
||||||
|
* opening it. A check overlay appears on hover (hinting it is clickable) and
|
||||||
|
* stays visible while the row is selected.
|
||||||
|
*/
|
||||||
|
export function SelectableAvatar({
|
||||||
|
checked,
|
||||||
|
onToggle,
|
||||||
|
selectLabel,
|
||||||
|
className,
|
||||||
|
...avatarProps
|
||||||
|
}: SelectableAvatarProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="checkbox"
|
||||||
|
aria-checked={checked}
|
||||||
|
aria-label={selectLabel}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onToggle();
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"group/select relative shrink-0 rounded-full outline-none",
|
||||||
|
"focus-visible:ring-2 focus-visible:ring-primary/60",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Avatar {...avatarProps} />
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-0 flex items-center justify-center rounded-full",
|
||||||
|
"bg-primary text-primary-foreground transition-opacity duration-150",
|
||||||
|
checked ? "opacity-100" : "opacity-0 group-hover/select:opacity-100",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,23 @@ import { Node as TiptapNode, mergeAttributes } from "@tiptap/core";
|
|||||||
// so parseHTML can recognise it on the way back in (initial content, drafts).
|
// so parseHTML can recognise it on the way back in (initial content, drafts).
|
||||||
export const SIGNATURE_BLOCK_MARKER = "data-signature-block-node";
|
export const SIGNATURE_BLOCK_MARKER = "data-signature-block-node";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force every link in the rendered signature to open in a new tab.
|
||||||
|
*
|
||||||
|
* Applied to the NodeView's DOM only, never to `attrs.html` — that attribute is
|
||||||
|
* what serializeEditorContent emits into the sent message, and the recipient's
|
||||||
|
* copy should stay exactly as the user wrote it. Without this the composer's
|
||||||
|
* signature is a set of live, target-less anchors in the main document (the
|
||||||
|
* message body gets a sandboxed iframe; this does not), so one stray click
|
||||||
|
* navigates the whole app away and takes the unsent draft with it.
|
||||||
|
*/
|
||||||
|
function forceLinksToNewTab(root: HTMLElement): void {
|
||||||
|
root.querySelectorAll("a[href]").forEach((a) => {
|
||||||
|
a.setAttribute("target", "_blank");
|
||||||
|
a.setAttribute("rel", "noopener noreferrer");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SignatureBlock — an atomic, NON-editable block node that carries the
|
* SignatureBlock — an atomic, NON-editable block node that carries the
|
||||||
* *verbatim* HTML of the user's identity signature in its `html` attribute.
|
* *verbatim* HTML of the user's identity signature in its `html` attribute.
|
||||||
@@ -61,6 +78,7 @@ export const SignatureBlock = TiptapNode.create({
|
|||||||
dom.setAttribute(SIGNATURE_BLOCK_MARKER, "");
|
dom.setAttribute(SIGNATURE_BLOCK_MARKER, "");
|
||||||
dom.className = "signature-block-island";
|
dom.className = "signature-block-island";
|
||||||
|
|
||||||
|
|
||||||
// CRITICAL: render the signature inside a Shadow Root. The app's global
|
// CRITICAL: render the signature inside a Shadow Root. The app's global
|
||||||
// CSS (Tailwind preflight, .tiptap table/td rules, box-sizing resets)
|
// CSS (Tailwind preflight, .tiptap table/td rules, box-sizing resets)
|
||||||
// would otherwise cascade INTO the signature and destroy its layout -
|
// would otherwise cascade INTO the signature and destroy its layout -
|
||||||
@@ -71,7 +89,12 @@ export const SignatureBlock = TiptapNode.create({
|
|||||||
const inner = document.createElement("div");
|
const inner = document.createElement("div");
|
||||||
// Read-only: a signature is inserted/removed as a unit, not edited inline.
|
// Read-only: a signature is inserted/removed as a unit, not edited inline.
|
||||||
inner.contentEditable = "false";
|
inner.contentEditable = "false";
|
||||||
inner.innerHTML = node.attrs.html || "";
|
// Track what we were given, not what's in the DOM: forceLinksToNewTab
|
||||||
|
// rewrites the markup, so inner.innerHTML no longer round-trips against
|
||||||
|
// attrs.html and comparing the two would rewrite on every transaction.
|
||||||
|
let appliedHtml = node.attrs.html || "";
|
||||||
|
inner.innerHTML = appliedHtml;
|
||||||
|
forceLinksToNewTab(inner);
|
||||||
shadow.appendChild(inner);
|
shadow.appendChild(inner);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -83,8 +106,11 @@ export const SignatureBlock = TiptapNode.create({
|
|||||||
stopEvent: () => false,
|
stopEvent: () => false,
|
||||||
update: (updatedNode) => {
|
update: (updatedNode) => {
|
||||||
if (updatedNode.type.name !== "signatureBlock") return false;
|
if (updatedNode.type.name !== "signatureBlock") return false;
|
||||||
if (inner.innerHTML !== (updatedNode.attrs.html || "")) {
|
const nextHtml = updatedNode.attrs.html || "";
|
||||||
inner.innerHTML = updatedNode.attrs.html || "";
|
if (nextHtml !== appliedHtml) {
|
||||||
|
appliedHtml = nextHtml;
|
||||||
|
inner.innerHTML = nextHtml;
|
||||||
|
forceLinksToNewTab(inner);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { Extension } from "@tiptap/core";
|
||||||
|
|
||||||
|
export type TextDir = "ltr" | "rtl";
|
||||||
|
|
||||||
|
declare module "@tiptap/core" {
|
||||||
|
interface Commands<ReturnType> {
|
||||||
|
textDirection: {
|
||||||
|
setTextDirection: (dir: TextDir) => ReturnType;
|
||||||
|
unsetTextDirection: () => ReturnType;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a `dir` attribute to block nodes so the composer can mark individual
|
||||||
|
* paragraphs/headings as LTR or RTL (Gmail-style right-to-left editing).
|
||||||
|
*
|
||||||
|
* The default is `"auto"`: each block detects its own direction from its first
|
||||||
|
* strong character, so a paragraph typed in English renders LTR and one typed
|
||||||
|
* in Hebrew renders RTL, per block, as you type. The toolbar toggle still pins
|
||||||
|
* an explicit `ltr`/`rtl` when you want to override the auto-detection, and the
|
||||||
|
* attribute round-trips to HTML so the direction is preserved in the sent mail.
|
||||||
|
*/
|
||||||
|
export const TextDirection = Extension.create({
|
||||||
|
name: "textDirection",
|
||||||
|
|
||||||
|
addOptions() {
|
||||||
|
return { types: ["paragraph", "heading", "blockquote", "listItem"] };
|
||||||
|
},
|
||||||
|
|
||||||
|
addGlobalAttributes() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
types: this.options.types,
|
||||||
|
attributes: {
|
||||||
|
dir: {
|
||||||
|
default: "auto",
|
||||||
|
parseHTML: (element) => element.getAttribute("dir") || "auto",
|
||||||
|
renderHTML: (attributes) =>
|
||||||
|
attributes.dir ? { dir: attributes.dir } : { dir: "auto" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
addCommands() {
|
||||||
|
return {
|
||||||
|
setTextDirection:
|
||||||
|
(dir) =>
|
||||||
|
({ commands }) =>
|
||||||
|
this.options.types.every((type: string) =>
|
||||||
|
commands.updateAttributes(type, { dir }),
|
||||||
|
),
|
||||||
|
unsetTextDirection:
|
||||||
|
() =>
|
||||||
|
({ commands }) =>
|
||||||
|
this.options.types.every((type: string) =>
|
||||||
|
commands.resetAttributes(type, "dir"),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -150,7 +150,7 @@ export function ThreadConversationView({
|
|||||||
<div className="flex items-center px-4 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 sticky top-0 z-10" style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-header-py)' }}>
|
<div className="flex items-center px-4 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 sticky top-0 z-10" style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-header-py)' }}>
|
||||||
<button
|
<button
|
||||||
onClick={onBack}
|
onClick={onBack}
|
||||||
className="p-2 -ml-2 rounded-full hover:bg-muted transition-colors"
|
className="p-2 -ms-2 rounded-full hover:bg-muted transition-colors"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="w-5 h-5" />
|
<ArrowLeft className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -488,13 +488,13 @@ function EmailCard({
|
|||||||
<div className={cn(
|
<div className={cn(
|
||||||
"rounded-lg border border-border overflow-hidden transition-all duration-200",
|
"rounded-lg border border-border overflow-hidden transition-all duration-200",
|
||||||
isExpanded ? "bg-background shadow-sm" : "bg-muted/30",
|
isExpanded ? "bg-background shadow-sm" : "bg-muted/30",
|
||||||
isUnread && !isExpanded && "border-l-2 border-l-primary"
|
isUnread && !isExpanded && "border-s-2 border-l-primary"
|
||||||
)}>
|
)}>
|
||||||
{/* Card Header - Always visible */}
|
{/* Card Header - Always visible */}
|
||||||
<button
|
<button
|
||||||
onClick={onToggleExpanded}
|
onClick={onToggleExpanded}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full flex items-start text-left transition-colors",
|
"w-full flex items-start text-start transition-colors",
|
||||||
!isExpanded && "hover:bg-muted/50"
|
!isExpanded && "hover:bg-muted/50"
|
||||||
)}
|
)}
|
||||||
style={{ gap: 'var(--density-item-gap)', padding: 'var(--density-card-p)' }}
|
style={{ gap: 'var(--density-item-gap)', padding: 'var(--density-card-p)' }}
|
||||||
@@ -657,7 +657,7 @@ function EmailCard({
|
|||||||
}}
|
}}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
>
|
>
|
||||||
<Reply className="w-4 h-4 mr-2" />
|
<Reply className="w-4 h-4 me-2" />
|
||||||
{t("email_viewer.reply")}
|
{t("email_viewer.reply")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -671,7 +671,7 @@ function EmailCard({
|
|||||||
}}
|
}}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
>
|
>
|
||||||
<ReplyAll className="w-4 h-4 mr-2" />
|
<ReplyAll className="w-4 h-4 me-2" />
|
||||||
{t("email_viewer.reply_all")}
|
{t("email_viewer.reply_all")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -685,7 +685,7 @@ function EmailCard({
|
|||||||
}}
|
}}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
>
|
>
|
||||||
<Forward className="w-4 h-4 mr-2" />
|
<Forward className="w-4 h-4 me-2" />
|
||||||
{t("email_viewer.forward")}
|
{t("email_viewer.forward")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -86,8 +86,8 @@ export function ThreadEmailItem({
|
|||||||
{...longPressHandlers}
|
{...longPressHandlers}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative cursor-pointer select-none transition-all duration-150",
|
"relative cursor-pointer select-none transition-all duration-150",
|
||||||
"pl-12 pr-4",
|
"ps-12 pe-4",
|
||||||
"border-l-2 border-l-transparent",
|
"border-s-2 border-l-transparent",
|
||||||
selected
|
selected
|
||||||
? "bg-selection border-l-primary"
|
? "bg-selection border-l-primary"
|
||||||
: "hover:bg-muted/50",
|
: "hover:bg-muted/50",
|
||||||
@@ -130,7 +130,7 @@ export function ThreadEmailItem({
|
|||||||
|
|
||||||
{/* Unread indicator */}
|
{/* Unread indicator */}
|
||||||
{isUnread && (
|
{isUnread && (
|
||||||
<div className="absolute left-7 top-1/2 -translate-y-1/2">
|
<div className="absolute start-7 top-1/2 -translate-y-1/2">
|
||||||
<Circle className="w-1.5 h-1.5 fill-unread text-unread" />
|
<Circle className="w-1.5 h-1.5 fill-unread text-unread" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
import React, { useCallback } from "react";
|
import React, { useCallback } from "react";
|
||||||
import { formatDate, formatDateTime, stripInvisibleLeading } from "@/lib/utils";
|
import { formatDate, formatDateTime, stripInvisibleLeading } from "@/lib/utils";
|
||||||
import { Email, ThreadGroup, ALL_MAIL_MAILBOX_ID } from "@/lib/jmap/types";
|
import { Email, ThreadGroup } 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, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square, Reply, Forward, CalendarClock, Folder } from "lucide-react";
|
import { Paperclip, Star, Pin, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square, Reply, Forward, CalendarClock, Folder } from "lucide-react";
|
||||||
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
||||||
import { useUIStore } from "@/stores/ui-store";
|
import { useUIStore } from "@/stores/ui-store";
|
||||||
import { useEmailStore } from "@/stores/email-store";
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
@@ -75,8 +75,10 @@ interface SingleEmailItemProps {
|
|||||||
const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||||
function SingleEmailItem({ email, selected, onClick, onDoubleClick, onContextMenu, showPreview, colorTag, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam, onUndoSpam }, ref) {
|
function SingleEmailItem({ email, selected, onClick, onDoubleClick, onContextMenu, showPreview, colorTag, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam, onUndoSpam }, ref) {
|
||||||
const t = useTranslations('email_viewer');
|
const t = useTranslations('email_viewer');
|
||||||
|
const tBatch = useTranslations('email_list.batch_actions');
|
||||||
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 isAnswered = email.keywords?.$answered;
|
const isAnswered = email.keywords?.$answered;
|
||||||
const isForwarded = email.keywords?.$forwarded;
|
const isForwarded = email.keywords?.$forwarded;
|
||||||
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView, unifiedRole } = useEmailStore();
|
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView, unifiedRole } = useEmailStore();
|
||||||
@@ -88,13 +90,14 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
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 emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||||
|
const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag);
|
||||||
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 timeFormat = useSettingsStore((state) => state.timeFormat);
|
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||||
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
|
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
|
||||||
const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk;
|
const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk;
|
||||||
// Show the originating folder in the aggregate "All …" views.
|
// Show the originating folder in the aggregate "All …" views.
|
||||||
const showSourceFolder = (isUnifiedView || selectedMailbox === ALL_MAIL_MAILBOX_ID) && !!email.sourceFolder;
|
const showSourceFolder = isUnifiedView && !!email.sourceFolder;
|
||||||
const getAccountById = useAccountStore((state) => state.getAccountById);
|
const getAccountById = useAccountStore((state) => state.getAccountById);
|
||||||
const accountColor = email.accountId ? getAccountById(email.accountId)?.avatarColor : undefined;
|
const accountColor = email.accountId ? getAccountById(email.accountId)?.avatarColor : undefined;
|
||||||
const isChecked = selectedEmailIds.has(email.id);
|
const isChecked = selectedEmailIds.has(email.id);
|
||||||
@@ -111,7 +114,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
const tagIds = getEmailColorTags(email.keywords);
|
const tagIds = getEmailColorTags(email.keywords);
|
||||||
const resolvedKeywordDefs = tagIds.map(id => emailKeywords.find(k => k.id === id) ?? { id, label: id, color: 'gray' });
|
const resolvedKeywordDefs = tagIds.map(id => emailKeywords.find(k => k.id === id) ?? { id, label: id, color: 'gray' });
|
||||||
const resolvedKeywordDef = resolvedKeywordDefs[0] ?? null;
|
const resolvedKeywordDef = resolvedKeywordDefs[0] ?? null;
|
||||||
const resolvedColorTag = (() => {
|
const resolvedColorTag = !tintListRowsByTag ? null : (() => {
|
||||||
if (colorTag) return colorTag;
|
if (colorTag) return colorTag;
|
||||||
return resolvedKeywordDef ? KEYWORD_PALETTE[resolvedKeywordDef.color]?.bg ?? null : null;
|
return resolvedKeywordDef ? KEYWORD_PALETTE[resolvedKeywordDef.color]?.bg ?? null : null;
|
||||||
})();
|
})();
|
||||||
@@ -134,7 +137,11 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
|
|
||||||
const handleCheckboxClick = (e: React.MouseEvent) => {
|
const handleCheckboxClick = (e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
toggleEmailSelection(email.id);
|
if (e.shiftKey) {
|
||||||
|
selectRangeEmails(email.id);
|
||||||
|
} else {
|
||||||
|
toggleEmailSelection(email.id);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleContextMenu = (e: React.MouseEvent) => {
|
const handleContextMenu = (e: React.MouseEvent) => {
|
||||||
@@ -159,6 +166,10 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
{...dragHandlers}
|
{...dragHandlers}
|
||||||
{...longPressHandlers}
|
{...longPressHandlers}
|
||||||
|
data-testid="email-list-item"
|
||||||
|
data-email-id={email.id}
|
||||||
|
data-subject={email.subject || ''}
|
||||||
|
data-unread={isUnread ? 'true' : 'false'}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative group cursor-pointer select-none transition-shadow duration-200 border-b border-border overflow-hidden",
|
"relative group cursor-pointer select-none transition-shadow duration-200 border-b border-border overflow-hidden",
|
||||||
resolvedColorTag ? resolvedColorTag : (
|
resolvedColorTag ? resolvedColorTag : (
|
||||||
@@ -211,18 +222,21 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{isUnread && (
|
{isUnread && (
|
||||||
<div className="absolute left-0.5 top-1/2 -translate-y-1/2">
|
<div className="absolute start-0.5 top-1/2 -translate-y-1/2">
|
||||||
<Circle className="w-2 h-2 fill-unread text-unread" />
|
<Circle className="w-2 h-2 fill-unread text-unread" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{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')}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -256,6 +270,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
</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" />}
|
||||||
{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" />}
|
||||||
{isForwarded && !isAnswered && <Forward className="w-3.5 h-3.5 text-muted-foreground" />}
|
{isForwarded && !isAnswered && <Forward className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||||
@@ -308,6 +323,9 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
{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" />
|
||||||
)}
|
)}
|
||||||
@@ -397,6 +415,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
onMarkAsSpam={onMarkAsSpam}
|
onMarkAsSpam={onMarkAsSpam}
|
||||||
onUndoSpam={onUndoSpam}
|
onUndoSpam={onUndoSpam}
|
||||||
isInJunk={currentMailboxRole === 'junk'}
|
isInJunk={currentMailboxRole === 'junk'}
|
||||||
|
spamApplicable={!['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || '')}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -427,13 +446,14 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
}, ref) {
|
}, ref) {
|
||||||
const t = useTranslations('threads');
|
const t = useTranslations('threads');
|
||||||
const tEmailViewer = useTranslations('email_viewer');
|
const tEmailViewer = useTranslations('email_viewer');
|
||||||
|
const tBatch = useTranslations('email_list.batch_actions');
|
||||||
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 timeFormat = useSettingsStore((state) => state.timeFormat);
|
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||||
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
|
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
|
||||||
const isMobile = useUIStore((state) => state.isMobile);
|
const isMobile = useUIStore((state) => state.isMobile);
|
||||||
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread;
|
const { latestEmail, participantNames, hasUnread, hasStarred, hasPinned, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread;
|
||||||
// The horizontal one-line "focus" layout doesn't fit on narrow screens; fall back to multi-line on mobile.
|
// The horizontal one-line "focus" layout doesn't fit on narrow screens; fall back to multi-line on mobile.
|
||||||
const isFocusedMailLayout = mailLayout === 'focus' && !isMobile;
|
const isFocusedMailLayout = mailLayout === 'focus' && !isMobile;
|
||||||
const trimmedPreview = stripInvisibleLeading(latestEmail.preview ?? '');
|
const trimmedPreview = stripInvisibleLeading(latestEmail.preview ?? '');
|
||||||
@@ -443,7 +463,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView, unifiedRole } = useEmailStore();
|
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView, unifiedRole } = useEmailStore();
|
||||||
const showSourceFolder = (isUnifiedView || selectedMailbox === ALL_MAIL_MAILBOX_ID) && !!latestEmail.sourceFolder;
|
const showSourceFolder = isUnifiedView && !!latestEmail.sourceFolder;
|
||||||
const getAccountById = useAccountStore((state) => state.getAccountById);
|
const getAccountById = useAccountStore((state) => state.getAccountById);
|
||||||
const threadAccountColor = latestEmail.accountId ? getAccountById(latestEmail.accountId)?.avatarColor : undefined;
|
const threadAccountColor = latestEmail.accountId ? getAccountById(latestEmail.accountId)?.avatarColor : undefined;
|
||||||
// In Sent/Drafts folders, show recipient instead of sender (which is always
|
// In Sent/Drafts folders, show recipient instead of sender (which is always
|
||||||
@@ -479,8 +499,9 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
|
|
||||||
const threadColor = getThreadColorTag(thread.emails);
|
const threadColor = getThreadColorTag(thread.emails);
|
||||||
const emailKeywordDefs = useSettingsStore((state) => state.emailKeywords);
|
const emailKeywordDefs = useSettingsStore((state) => state.emailKeywords);
|
||||||
|
const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag);
|
||||||
const keywordDef = threadColor ? (emailKeywordDefs.find(k => k.id === threadColor) ?? { id: threadColor, label: threadColor, color: 'gray' }) : null;
|
const keywordDef = threadColor ? (emailKeywordDefs.find(k => k.id === threadColor) ?? { id: threadColor, label: threadColor, color: 'gray' }) : null;
|
||||||
const colorTag = keywordDef ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null;
|
const colorTag = (tintListRowsByTag && keywordDef) ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null;
|
||||||
|
|
||||||
const isSelected = selectedEmailId === latestEmail.id ||
|
const isSelected = selectedEmailId === latestEmail.id ||
|
||||||
thread.emails.some(e => e.id === selectedEmailId);
|
thread.emails.some(e => e.id === selectedEmailId);
|
||||||
@@ -511,9 +532,8 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
|
|
||||||
const emailsToShow = expandedEmails || thread.emails;
|
const emailsToShow = expandedEmails || thread.emails;
|
||||||
|
|
||||||
const handleThreadCheckboxClick = (e: React.MouseEvent) => {
|
// Toggle selection for all emails in this thread.
|
||||||
e.stopPropagation();
|
const toggleThreadSelection = () => {
|
||||||
// Toggle selection for all emails in this thread
|
|
||||||
const allSelected = thread.emails.every(em => selectedEmailIds.has(em.id));
|
const allSelected = thread.emails.every(em => selectedEmailIds.has(em.id));
|
||||||
const newSelection = new Set(selectedEmailIds);
|
const newSelection = new Set(selectedEmailIds);
|
||||||
thread.emails.forEach(em => {
|
thread.emails.forEach(em => {
|
||||||
@@ -526,6 +546,15 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
useEmailStore.setState({ selectedEmailIds: newSelection, lastSelectedEmailId: latestEmail.id });
|
useEmailStore.setState({ selectedEmailIds: newSelection, lastSelectedEmailId: latestEmail.id });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleThreadCheckboxClick = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.shiftKey) {
|
||||||
|
selectRangeEmails(latestEmail.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toggleThreadSelection();
|
||||||
|
};
|
||||||
|
|
||||||
const handleHeaderClick = (e: React.MouseEvent) => {
|
const handleHeaderClick = (e: React.MouseEvent) => {
|
||||||
if (e.ctrlKey || e.metaKey) {
|
if (e.ctrlKey || e.metaKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -618,21 +647,24 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{hasUnread && (
|
{hasUnread && (
|
||||||
<div className="absolute left-0.5 top-1/2 -translate-y-1/2">
|
<div className="absolute start-0.5 top-1/2 -translate-y-1/2">
|
||||||
<Circle className="w-2 h-2 fill-unread text-unread" />
|
<Circle className="w-2 h-2 fill-unread text-unread" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{density !== 'extra-compact' && (
|
{density !== 'extra-compact' && (
|
||||||
<div className="relative flex-shrink-0">
|
<div className="relative flex-shrink-0">
|
||||||
<Avatar
|
<SelectableAvatar
|
||||||
name={avatarPerson?.name}
|
name={avatarPerson?.name}
|
||||||
email={avatarPerson?.email}
|
email={avatarPerson?.email}
|
||||||
size={isFocusedMailLayout ? "sm" : "md"}
|
size={isFocusedMailLayout ? "sm" : "md"}
|
||||||
className="shadow-sm"
|
className="shadow-sm"
|
||||||
disableImages={hideJunkAvatarImages}
|
disableImages={hideJunkAvatarImages}
|
||||||
|
checked={isChecked}
|
||||||
|
onToggle={toggleThreadSelection}
|
||||||
|
selectLabel={tBatch('select')}
|
||||||
/>
|
/>
|
||||||
{!isMobile && !isFocusedMailLayout && (
|
{!isMobile && (
|
||||||
<button
|
<button
|
||||||
data-expand-toggle
|
data-expand-toggle
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -702,6 +734,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2.5 shrink-0">
|
<div className="flex items-center gap-2.5 shrink-0">
|
||||||
|
{hasPinned && <Pin className="w-3.5 h-3.5 text-primary" />}
|
||||||
{hasStarred && <Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />}
|
{hasStarred && <Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />}
|
||||||
{hasAnswered && !hasForwarded && <Reply className="w-3.5 h-3.5 text-muted-foreground" />}
|
{hasAnswered && !hasForwarded && <Reply className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||||
{hasForwarded && !hasAnswered && <Forward className="w-3.5 h-3.5 text-muted-foreground" />}
|
{hasForwarded && !hasAnswered && <Forward className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||||
@@ -766,6 +799,9 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
{emailCount}
|
{emailCount}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
|
{hasPinned && (
|
||||||
|
<Pin className="w-3.5 h-3.5 text-primary" />
|
||||||
|
)}
|
||||||
{hasStarred && (
|
{hasStarred && (
|
||||||
<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" />
|
||||||
)}
|
)}
|
||||||
@@ -855,15 +891,16 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined}
|
onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined}
|
||||||
onUndoSpam={onUndoSpam ? () => onUndoSpam(latestEmail) : undefined}
|
onUndoSpam={onUndoSpam ? () => onUndoSpam(latestEmail) : undefined}
|
||||||
isInJunk={currentMailboxRole === 'junk'}
|
isInJunk={currentMailboxRole === 'junk'}
|
||||||
|
spamApplicable={!['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || '')}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isExpanded && !isMobile && !isFocusedMailLayout && (
|
{isExpanded && !isMobile && (
|
||||||
<div className="bg-muted/20 animate-in slide-in-from-top-2 duration-200">
|
<div className="bg-muted/20 animate-in slide-in-from-top-2 duration-200">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="py-4 flex items-center justify-center text-sm text-muted-foreground">
|
<div className="py-4 flex items-center justify-center text-sm text-muted-foreground">
|
||||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
<Loader2 className="w-4 h-4 animate-spin me-2" />
|
||||||
{t('loading')}
|
{t('loading')}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { Loader2, CheckCircle, AlertCircle } from 'lucide-react';
|
import { Loader2, CheckCircle, AlertCircle } from 'lucide-react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { isValidUnsubscribeUrl } from '@/lib/validation';
|
import { isValidUnsubscribeUrl, parseMailtoUrl } from '@/lib/validation';
|
||||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||||
import { useIsDesktop } from '@/hooks/use-media-query';
|
import { useIsDesktop } from '@/hooks/use-media-query';
|
||||||
|
|
||||||
@@ -14,12 +14,17 @@ interface UnsubscribeBannerProps {
|
|||||||
preferred?: 'http' | 'mailto';
|
preferred?: 'http' | 'mailto';
|
||||||
};
|
};
|
||||||
senderEmail: string;
|
senderEmail: string;
|
||||||
|
// Sends the unsubscribe message through the app's own account. This is a
|
||||||
|
// webmail client - handing a mailto: URL to the OS mail handler goes
|
||||||
|
// nowhere for most users.
|
||||||
|
onSendMailtoUnsubscribe: (fields: { to: string[]; subject?: string; body?: string }) => Promise<void>;
|
||||||
onDismiss: () => void;
|
onDismiss: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UnsubscribeBanner({
|
export function UnsubscribeBanner({
|
||||||
listUnsubscribe,
|
listUnsubscribe,
|
||||||
senderEmail: _senderEmail,
|
senderEmail: _senderEmail,
|
||||||
|
onSendMailtoUnsubscribe,
|
||||||
onDismiss
|
onDismiss
|
||||||
}: UnsubscribeBannerProps) {
|
}: UnsubscribeBannerProps) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
@@ -74,12 +79,18 @@ export function UnsubscribeBanner({
|
|||||||
setShowConfirm(false);
|
setShowConfirm(false);
|
||||||
setTimeout(onDismiss, 3000);
|
setTimeout(onDismiss, 3000);
|
||||||
} else {
|
} else {
|
||||||
const link = document.createElement('a');
|
// Send the unsubscribe message ourselves and only report success
|
||||||
link.href = unsubUrl;
|
// once the server accepted it. The previous hidden-link click handed
|
||||||
link.style.display = 'none';
|
// the mailto: to the OS mail handler and claimed success even though
|
||||||
document.body.appendChild(link);
|
// nothing was ever sent.
|
||||||
link.click();
|
const fields = parseMailtoUrl(unsubUrl);
|
||||||
document.body.removeChild(link);
|
if (!fields) {
|
||||||
|
setError(true);
|
||||||
|
setProcessing(false);
|
||||||
|
setShowConfirm(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await onSendMailtoUnsubscribe(fields);
|
||||||
|
|
||||||
setSuccess(true);
|
setSuccess(true);
|
||||||
setProcessing(false);
|
setProcessing(false);
|
||||||
@@ -96,7 +107,7 @@ export function UnsubscribeBanner({
|
|||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center gap-1 ml-1">
|
<span className="inline-flex items-center gap-1 ms-1">
|
||||||
<CheckCircle className="w-3 h-3 text-green-600 dark:text-green-400" />
|
<CheckCircle className="w-3 h-3 text-green-600 dark:text-green-400" />
|
||||||
<span className="text-xs text-green-600 dark:text-green-400">
|
<span className="text-xs text-green-600 dark:text-green-400">
|
||||||
{t(unsubMethod === 'http'
|
{t(unsubMethod === 'http'
|
||||||
@@ -110,7 +121,7 @@ export function UnsubscribeBanner({
|
|||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center gap-1 ml-1">
|
<span className="inline-flex items-center gap-1 ms-1">
|
||||||
<AlertCircle className="w-3 h-3 text-red-500 dark:text-red-400" />
|
<AlertCircle className="w-3 h-3 text-red-500 dark:text-red-400" />
|
||||||
<button
|
<button
|
||||||
onClick={onDismiss}
|
onClick={onDismiss}
|
||||||
@@ -136,7 +147,7 @@ export function UnsubscribeBanner({
|
|||||||
{showConfirm && isDesktop && (
|
{showConfirm && isDesktop && (
|
||||||
<div
|
<div
|
||||||
ref={popoverRef}
|
ref={popoverRef}
|
||||||
className="absolute top-full left-0 mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-3 min-w-[220px]"
|
className="absolute top-full start-0 mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-3 min-w-[220px]"
|
||||||
>
|
>
|
||||||
<p className="text-sm text-foreground mb-2">
|
<p className="text-sm text-foreground mb-2">
|
||||||
{t('email_viewer.unsubscribe_banner.confirm_title')}
|
{t('email_viewer.unsubscribe_banner.confirm_title')}
|
||||||
@@ -171,8 +182,8 @@ export function UnsubscribeBanner({
|
|||||||
}}
|
}}
|
||||||
title={t('email_viewer.unsubscribe_banner.confirm_title')}
|
title={t('email_viewer.unsubscribe_banner.confirm_title')}
|
||||||
message={t(unsubMethod === 'http'
|
message={t(unsubMethod === 'http'
|
||||||
? 'email_viewer.unsubscribe_banner.success_http'
|
? 'email_viewer.unsubscribe_banner.confirm_message_http'
|
||||||
: 'email_viewer.unsubscribe_banner.success_mailto'
|
: 'email_viewer.unsubscribe_banner.confirm_message_mailto'
|
||||||
)}
|
)}
|
||||||
confirmText={t('email_viewer.unsubscribe_banner.confirm_button')}
|
confirmText={t('email_viewer.unsubscribe_banner.confirm_button')}
|
||||||
cancelText={t('email_viewer.unsubscribe_banner.cancel')}
|
cancelText={t('email_viewer.unsubscribe_banner.cancel')}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export function PageErrorFallback({ error: _error, resetError, t }: FallbackProp
|
|||||||
{t("page_error_description")}
|
{t("page_error_description")}
|
||||||
</p>
|
</p>
|
||||||
<Button onClick={resetError}>
|
<Button onClick={resetError}>
|
||||||
<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>
|
||||||
@@ -34,13 +34,13 @@ export function PageErrorFallback({ error: _error, resetError, t }: FallbackProp
|
|||||||
*/
|
*/
|
||||||
export function SidebarErrorFallback({ resetError, t }: FallbackProps) {
|
export function SidebarErrorFallback({ resetError, t }: FallbackProps) {
|
||||||
return (
|
return (
|
||||||
<div className="w-64 h-full border-r border-border bg-secondary flex flex-col items-center justify-center p-4">
|
<div className="w-64 h-full border-e border-border bg-secondary flex flex-col items-center justify-center p-4">
|
||||||
<FolderOpen className="w-10 h-10 text-muted-foreground mb-3" />
|
<FolderOpen className="w-10 h-10 text-muted-foreground mb-3" />
|
||||||
<p className="text-sm text-muted-foreground text-center mb-4">
|
<p className="text-sm text-muted-foreground text-center mb-4">
|
||||||
{t("sidebar_error")}
|
{t("sidebar_error")}
|
||||||
</p>
|
</p>
|
||||||
<Button variant="outline" size="sm" onClick={resetError}>
|
<Button variant="outline" size="sm" onClick={resetError}>
|
||||||
<RefreshCw className="w-3 h-3 mr-1" />
|
<RefreshCw className="w-3 h-3 me-1" />
|
||||||
{t("reload")}
|
{t("reload")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -58,7 +58,7 @@ export function EmailListErrorFallback({ resetError, t }: FallbackProps) {
|
|||||||
{t("email_list_error")}
|
{t("email_list_error")}
|
||||||
</p>
|
</p>
|
||||||
<Button variant="outline" size="sm" onClick={resetError}>
|
<Button variant="outline" size="sm" onClick={resetError}>
|
||||||
<RefreshCw className="w-4 h-4 mr-2" />
|
<RefreshCw className="w-4 h-4 me-2" />
|
||||||
{t("reload_emails")}
|
{t("reload_emails")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -81,7 +81,7 @@ export function EmailViewerErrorFallback({ resetError, t }: FallbackProps) {
|
|||||||
{t("viewer_error_description")}
|
{t("viewer_error_description")}
|
||||||
</p>
|
</p>
|
||||||
<Button onClick={resetError}>
|
<Button onClick={resetError}>
|
||||||
<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>
|
||||||
@@ -119,7 +119,7 @@ export function SettingsErrorFallback({ resetError, t }: FallbackProps) {
|
|||||||
{t("settings_error_description")}
|
{t("settings_error_description")}
|
||||||
</p>
|
</p>
|
||||||
<Button onClick={resetError}>
|
<Button onClick={resetError}>
|
||||||
<RefreshCw className="w-4 h-4 mr-2" />
|
<RefreshCw className="w-4 h-4 me-2" />
|
||||||
{t("reload_settings")}
|
{t("reload_settings")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
|
import { useFaviconBadge } from "@/hooks/use-favicon-badge";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Badges the browser-tab favicon with the inbox unread count, so new mail is
|
||||||
|
* visible without focusing the tab. See issue #560.
|
||||||
|
*
|
||||||
|
* Opt-out via the `faviconUnreadBadge` setting (Settings -> Appearance); on by
|
||||||
|
* default.
|
||||||
|
*
|
||||||
|
* Mounted in the root layout rather than on the mail route: the badge belongs
|
||||||
|
* to the tab, not to a page. Mounting it on the mail page unmounted it — and so
|
||||||
|
* cleared the badge, and flickered the icon — on every hop to /settings,
|
||||||
|
* /calendar or /contacts.
|
||||||
|
*
|
||||||
|
* Renders nothing.
|
||||||
|
*/
|
||||||
|
export function FaviconBadge() {
|
||||||
|
// The store's canonical inbox selector. `role === 'inbox'` alone is not
|
||||||
|
// enough: shared and group inboxes ship in the same `mailboxes` array, so on
|
||||||
|
// a delegated setup the first match can be somebody else's inbox.
|
||||||
|
const inboxUnread = useEmailStore(
|
||||||
|
(s) => s.mailboxes.find((m) => m.role === "inbox" && !m.isShared)?.unreadEmails ?? 0,
|
||||||
|
);
|
||||||
|
const enabled = useSettingsStore((s) => s.faviconUnreadBadge);
|
||||||
|
|
||||||
|
useFaviconBadge(inboxUnread, enabled);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -68,10 +68,10 @@ export function EmlPreview({ message }: { message: ParsedEml }) {
|
|||||||
<h2 className="text-lg font-semibold text-foreground break-words">{message.subject || ""}</h2>
|
<h2 className="text-lg font-semibold text-foreground break-words">{message.subject || ""}</h2>
|
||||||
<div className="mt-2 space-y-0.5 text-sm text-muted-foreground border-b border-border pb-3">
|
<div className="mt-2 space-y-0.5 text-sm text-muted-foreground border-b border-border pb-3">
|
||||||
{message.from && (
|
{message.from && (
|
||||||
<div><span className="font-medium text-foreground">{t("from")}: </span>{formatAddress(message.from)}</div>
|
<div><span className="font-medium text-foreground">{t("from")}: </span><bdi>{formatAddress(message.from)}</bdi></div>
|
||||||
)}
|
)}
|
||||||
{message.to && message.to.length > 0 && (
|
{message.to && message.to.length > 0 && (
|
||||||
<div><span className="font-medium text-foreground">{t("to")}: </span>{message.to.map(formatAddress).join(", ")}</div>
|
<div><span className="font-medium text-foreground">{t("to")}: </span><bdi>{message.to.map(formatAddress).join(", ")}</bdi></div>
|
||||||
)}
|
)}
|
||||||
{message.date && (
|
{message.date && (
|
||||||
<div><span className="font-medium text-foreground">{t("date")}: </span>{new Date(message.date).toLocaleString()}</div>
|
<div><span className="font-medium text-foreground">{t("date")}: </span>{new Date(message.date).toLocaleString()}</div>
|
||||||
|
|||||||
@@ -821,8 +821,8 @@ export function FileBrowser({
|
|||||||
const SortIndicator = ({ column }: { column: SortKey }) => {
|
const SortIndicator = ({ column }: { column: SortKey }) => {
|
||||||
if (sortKey !== column) return null;
|
if (sortKey !== column) return null;
|
||||||
return sortDir === "asc"
|
return sortDir === "asc"
|
||||||
? <ArrowUp className="w-3 h-3 inline ml-1" />
|
? <ArrowUp className="w-3 h-3 inline ms-1" />
|
||||||
: <ArrowDown className="w-3 h-3 inline ml-1" />;
|
: <ArrowDown className="w-3 h-3 inline ms-1" />;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Keyboard shortcuts
|
// Keyboard shortcuts
|
||||||
@@ -942,7 +942,7 @@ export function FileBrowser({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8 -ml-2"
|
className="h-8 w-8 -ms-2"
|
||||||
onClick={() => setNarrowSidebarOpen((v) => !v)}
|
onClick={() => setNarrowSidebarOpen((v) => !v)}
|
||||||
aria-label={t("open_folder_tree")}
|
aria-label={t("open_folder_tree")}
|
||||||
>
|
>
|
||||||
@@ -982,7 +982,7 @@ export function FileBrowser({
|
|||||||
className="h-8"
|
className="h-8"
|
||||||
onClick={() => onBatchDownload([...selectedResources].filter(n => !resources.find(r => r.name === n)?.isDirectory))}
|
onClick={() => onBatchDownload([...selectedResources].filter(n => !resources.find(r => r.name === n)?.isDirectory))}
|
||||||
>
|
>
|
||||||
<Download className="w-4 h-4 mr-1" />
|
<Download className="w-4 h-4 me-1" />
|
||||||
{t("download")} ({[...selectedResources].filter(n => !resources.find(r => r.name === n)?.isDirectory).length})
|
{t("download")} ({[...selectedResources].filter(n => !resources.find(r => r.name === n)?.isDirectory).length})
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -991,7 +991,7 @@ export function FileBrowser({
|
|||||||
className="h-8 text-destructive hover:text-destructive"
|
className="h-8 text-destructive hover:text-destructive"
|
||||||
onClick={() => onBatchDelete([...selectedResources])}
|
onClick={() => onBatchDelete([...selectedResources])}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4 mr-1" />
|
<Trash2 className="w-4 h-4 me-1" />
|
||||||
{t("delete")} ({selectedResources.size})
|
{t("delete")} ({selectedResources.size})
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
@@ -1003,7 +1003,7 @@ export function FileBrowser({
|
|||||||
className="h-8"
|
className="h-8"
|
||||||
onClick={onPaste}
|
onClick={onPaste}
|
||||||
>
|
>
|
||||||
<Clipboard className="w-4 h-4 mr-1" />
|
<Clipboard className="w-4 h-4 me-1" />
|
||||||
{t("paste")} ({clipboard.names.length})
|
{t("paste")} ({clipboard.names.length})
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -1157,7 +1157,7 @@ export function FileBrowser({
|
|||||||
className="h-7 text-destructive hover:text-destructive shrink-0"
|
className="h-7 text-destructive hover:text-destructive shrink-0"
|
||||||
onClick={onRefresh}
|
onClick={onRefresh}
|
||||||
>
|
>
|
||||||
<RefreshCw className="w-3.5 h-3.5 mr-1" />
|
<RefreshCw className="w-3.5 h-3.5 me-1" />
|
||||||
{t("retry")}
|
{t("retry")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1181,12 +1181,12 @@ export function FileBrowser({
|
|||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
{t("uploading")} {uploadProgress.name}
|
{t("uploading")} {uploadProgress.name}
|
||||||
{uploadProgress.totalFiles > 1 && (
|
{uploadProgress.totalFiles > 1 && (
|
||||||
<span className="text-muted-foreground ml-1">
|
<span className="text-muted-foreground ms-1">
|
||||||
({uploadProgress.current}/{uploadProgress.totalFiles})
|
({uploadProgress.current}/{uploadProgress.totalFiles})
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="ml-auto tabular-nums shrink-0 flex items-center gap-2">
|
<span className="ms-auto tabular-nums shrink-0 flex items-center gap-2">
|
||||||
{uploadProgress.total > 0
|
{uploadProgress.total > 0
|
||||||
? `${Math.round((uploadProgress.loaded / uploadProgress.total) * 100)}%`
|
? `${Math.round((uploadProgress.loaded / uploadProgress.total) * 100)}%`
|
||||||
: "…"}
|
: "…"}
|
||||||
@@ -1267,7 +1267,7 @@ export function FileBrowser({
|
|||||||
)}
|
)}
|
||||||
{/* Favorites & Recent sidebar (when layout is inline) */}
|
{/* Favorites & Recent sidebar (when layout is inline) */}
|
||||||
{folderLayout === "inline" && (favorites.length > 0 || recentFiles.length > 0) && (
|
{folderLayout === "inline" && (favorites.length > 0 || recentFiles.length > 0) && (
|
||||||
<div className="w-48 border-r border-border bg-background overflow-y-auto shrink-0 hidden lg:block">
|
<div className="w-48 border-e border-border bg-background overflow-y-auto shrink-0 hidden lg:block">
|
||||||
{favorites.length > 0 && (
|
{favorites.length > 0 && (
|
||||||
<div className="p-3">
|
<div className="p-3">
|
||||||
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 flex items-center gap-1">
|
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 flex items-center gap-1">
|
||||||
@@ -1280,7 +1280,7 @@ export function FileBrowser({
|
|||||||
key={fav}
|
key={fav}
|
||||||
onClick={() => onNavigate(fav)}
|
onClick={() => onNavigate(fav)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full flex items-center gap-2 px-2 py-1.5 rounded text-sm hover:bg-muted transition-colors text-left",
|
"w-full flex items-center gap-2 px-2 py-1.5 rounded text-sm hover:bg-muted transition-colors text-start",
|
||||||
currentPath === fav && "bg-muted font-medium"
|
currentPath === fav && "bg-muted font-medium"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -1301,7 +1301,7 @@ export function FileBrowser({
|
|||||||
{recentFiles.slice(0, 10).map((recent) => (
|
{recentFiles.slice(0, 10).map((recent) => (
|
||||||
<button
|
<button
|
||||||
key={recent.id}
|
key={recent.id}
|
||||||
className="w-full flex items-center gap-2 px-2 py-1.5 rounded text-sm hover:bg-muted transition-colors text-left"
|
className="w-full flex items-center gap-2 px-2 py-1.5 rounded text-sm hover:bg-muted transition-colors text-start"
|
||||||
title={recent.name}
|
title={recent.name}
|
||||||
>
|
>
|
||||||
<File className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
<File className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
||||||
@@ -1322,14 +1322,14 @@ export function FileBrowser({
|
|||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="bg-muted/50 sticky top-0 z-10">
|
<thead className="bg-muted/50 sticky top-0 z-10">
|
||||||
<tr className="border-b border-border">
|
<tr className="border-b border-border">
|
||||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">
|
<th className="text-start px-4 py-2 font-medium text-muted-foreground">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-4 h-4" />
|
<div className="w-4 h-4" />
|
||||||
{t("name")}
|
{t("name")}
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground hidden md:table-cell w-24">{t("size")}</th>
|
<th className="text-start px-4 py-2 font-medium text-muted-foreground hidden md:table-cell w-24">{t("size")}</th>
|
||||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground hidden lg:table-cell w-44">{t("modified")}</th>
|
<th className="text-start px-4 py-2 font-medium text-muted-foreground hidden lg:table-cell w-44">{t("modified")}</th>
|
||||||
<th className="w-10 px-2 py-2" />
|
<th className="w-10 px-2 py-2" />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -1354,7 +1354,7 @@ export function FileBrowser({
|
|||||||
key={`__account__:${acc.accountId}`}
|
key={`__account__:${acc.accountId}`}
|
||||||
onClick={() => onSelectAccount(acc.accountId)}
|
onClick={() => onSelectAccount(acc.accountId)}
|
||||||
title={acc.email}
|
title={acc.email}
|
||||||
className="flex items-center gap-3 p-3 rounded-lg border border-border hover:bg-muted/50 transition-colors text-left min-w-0"
|
className="flex items-center gap-3 p-3 rounded-lg border border-border hover:bg-muted/50 transition-colors text-start min-w-0"
|
||||||
>
|
>
|
||||||
<Avatar
|
<Avatar
|
||||||
name={acc.label}
|
name={acc.label}
|
||||||
@@ -1514,7 +1514,7 @@ export function FileBrowser({
|
|||||||
>
|
>
|
||||||
<thead className="bg-muted/50 sticky top-0 z-10">
|
<thead className="bg-muted/50 sticky top-0 z-10">
|
||||||
<tr className="border-b border-border">
|
<tr className="border-b border-border">
|
||||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">
|
<th className="text-start px-4 py-2 font-medium text-muted-foreground">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -1530,13 +1530,13 @@ export function FileBrowser({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground hidden md:table-cell w-24">
|
<th className="text-start px-4 py-2 font-medium text-muted-foreground hidden md:table-cell w-24">
|
||||||
<button onClick={() => handleSortClick("size")} className="hover:text-foreground transition-colors">
|
<button onClick={() => handleSortClick("size")} className="hover:text-foreground transition-colors">
|
||||||
{t("size")}
|
{t("size")}
|
||||||
<SortIndicator column="size" />
|
<SortIndicator column="size" />
|
||||||
</button>
|
</button>
|
||||||
</th>
|
</th>
|
||||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground hidden lg:table-cell w-44">
|
<th className="text-start px-4 py-2 font-medium text-muted-foreground hidden lg:table-cell w-44">
|
||||||
<button onClick={() => handleSortClick("modified")} className="hover:text-foreground transition-colors">
|
<button onClick={() => handleSortClick("modified")} className="hover:text-foreground transition-colors">
|
||||||
{t("modified")}
|
{t("modified")}
|
||||||
<SortIndicator column="modified" />
|
<SortIndicator column="modified" />
|
||||||
@@ -1681,7 +1681,7 @@ export function FileBrowser({
|
|||||||
>
|
>
|
||||||
{!resources.find(r => r.name === contextMenu.name)?.isDirectory && isPreviewable(contextMenu.name) && (
|
{!resources.find(r => r.name === contextMenu.name)?.isDirectory && isPreviewable(contextMenu.name) && (
|
||||||
<button
|
<button
|
||||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-left"
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (isImageFile(contextMenu.name)) {
|
if (isImageFile(contextMenu.name)) {
|
||||||
onPreviewImage(contextMenu.name);
|
onPreviewImage(contextMenu.name);
|
||||||
@@ -1697,7 +1697,7 @@ export function FileBrowser({
|
|||||||
)}
|
)}
|
||||||
{!resources.find(r => r.name === contextMenu.name)?.isDirectory && (
|
{!resources.find(r => r.name === contextMenu.name)?.isDirectory && (
|
||||||
<button
|
<button
|
||||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-left"
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onDownload(contextMenu.name);
|
onDownload(contextMenu.name);
|
||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
@@ -1708,7 +1708,7 @@ export function FileBrowser({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-left"
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onCut([contextMenu.name]);
|
onCut([contextMenu.name]);
|
||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
@@ -1718,7 +1718,7 @@ export function FileBrowser({
|
|||||||
{t("cut")}
|
{t("cut")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-left"
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onCopy([contextMenu.name]);
|
onCopy([contextMenu.name]);
|
||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
@@ -1729,7 +1729,7 @@ export function FileBrowser({
|
|||||||
</button>
|
</button>
|
||||||
{clipboard && (
|
{clipboard && (
|
||||||
<button
|
<button
|
||||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-left"
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onPaste();
|
onPaste();
|
||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
@@ -1741,7 +1741,7 @@ export function FileBrowser({
|
|||||||
)}
|
)}
|
||||||
{!resources.find(r => r.name === contextMenu.name)?.isDirectory && (
|
{!resources.find(r => r.name === contextMenu.name)?.isDirectory && (
|
||||||
<button
|
<button
|
||||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-left"
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onDuplicate(contextMenu.name);
|
onDuplicate(contextMenu.name);
|
||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
@@ -1753,7 +1753,7 @@ export function FileBrowser({
|
|||||||
)}
|
)}
|
||||||
{canShare(resources.find(r => r.name === contextMenu.name)) && (
|
{canShare(resources.find(r => r.name === contextMenu.name)) && (
|
||||||
<button
|
<button
|
||||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-left"
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const r = resources.find(res => res.name === contextMenu.name);
|
const r = resources.find(res => res.name === contextMenu.name);
|
||||||
if (r) setShareTargetId(r.id);
|
if (r) setShareTargetId(r.id);
|
||||||
@@ -1766,7 +1766,7 @@ export function FileBrowser({
|
|||||||
)}
|
)}
|
||||||
<div className="h-px bg-border my-1" />
|
<div className="h-px bg-border my-1" />
|
||||||
<button
|
<button
|
||||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-left"
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onShowDetails(contextMenu.name);
|
onShowDetails(contextMenu.name);
|
||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
@@ -1776,7 +1776,7 @@ export function FileBrowser({
|
|||||||
{t("details")}
|
{t("details")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-left"
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setRenameTarget(contextMenu.name);
|
setRenameTarget(contextMenu.name);
|
||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
@@ -1786,7 +1786,7 @@ export function FileBrowser({
|
|||||||
{t("rename")}
|
{t("rename")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted text-destructive transition-colors text-left"
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted text-destructive transition-colors text-start"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onDelete(contextMenu.name);
|
onDelete(contextMenu.name);
|
||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
@@ -1808,7 +1808,7 @@ export function FileBrowser({
|
|||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-left"
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowNewFolder(true);
|
setShowNewFolder(true);
|
||||||
setEmptyContextMenu(null);
|
setEmptyContextMenu(null);
|
||||||
@@ -1818,7 +1818,7 @@ export function FileBrowser({
|
|||||||
{t("new_folder")}
|
{t("new_folder")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-left"
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowNewTextFile(true);
|
setShowNewTextFile(true);
|
||||||
setEmptyContextMenu(null);
|
setEmptyContextMenu(null);
|
||||||
@@ -1828,7 +1828,7 @@ export function FileBrowser({
|
|||||||
{t("new_text_file")}
|
{t("new_text_file")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-left"
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
fileInputRef.current?.click();
|
fileInputRef.current?.click();
|
||||||
setEmptyContextMenu(null);
|
setEmptyContextMenu(null);
|
||||||
@@ -1838,7 +1838,7 @@ export function FileBrowser({
|
|||||||
{t("upload")}
|
{t("upload")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-left"
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
folderInputRef.current?.click();
|
folderInputRef.current?.click();
|
||||||
setEmptyContextMenu(null);
|
setEmptyContextMenu(null);
|
||||||
@@ -1851,7 +1851,7 @@ export function FileBrowser({
|
|||||||
<>
|
<>
|
||||||
<div className="h-px bg-border my-1" />
|
<div className="h-px bg-border my-1" />
|
||||||
<button
|
<button
|
||||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-left"
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onPaste();
|
onPaste();
|
||||||
setEmptyContextMenu(null);
|
setEmptyContextMenu(null);
|
||||||
@@ -1864,7 +1864,7 @@ export function FileBrowser({
|
|||||||
)}
|
)}
|
||||||
<div className="h-px bg-border my-1" />
|
<div className="h-px bg-border my-1" />
|
||||||
<button
|
<button
|
||||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-left"
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onRefresh();
|
onRefresh();
|
||||||
setEmptyContextMenu(null);
|
setEmptyContextMenu(null);
|
||||||
@@ -1895,7 +1895,7 @@ export function FileBrowser({
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={folder.id}
|
key={folder.id}
|
||||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-left"
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onNavigate(folderPath, folder.id);
|
onNavigate(folderPath, folder.id);
|
||||||
setBreadcrumbDropdown(null);
|
setBreadcrumbDropdown(null);
|
||||||
@@ -1926,7 +1926,7 @@ export function FileBrowser({
|
|||||||
|
|
||||||
{/* Details sidebar */}
|
{/* Details sidebar */}
|
||||||
{showDetails && detailResource && (
|
{showDetails && detailResource && (
|
||||||
<div role="complementary" aria-label={t("details")} className="w-64 border-l border-border bg-background p-4 overflow-y-auto shrink-0 hidden md:block">
|
<div role="complementary" aria-label={t("details")} className="w-64 border-s border-border bg-background p-4 overflow-y-auto shrink-0 hidden md:block">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h3 className="text-sm font-medium">{t("details")}</h3>
|
<h3 className="text-sm font-medium">{t("details")}</h3>
|
||||||
<button onClick={onToggleDetails} className="text-muted-foreground hover:text-foreground">
|
<button onClick={onToggleDetails} className="text-muted-foreground hover:text-foreground">
|
||||||
|
|||||||
@@ -68,11 +68,11 @@ function SimpleMarkdown({ content }: { content: string }) {
|
|||||||
} else if (line.startsWith("---") || line.startsWith("***")) {
|
} else if (line.startsWith("---") || line.startsWith("***")) {
|
||||||
elements.push(<hr key={i} className="my-4 border-border" />);
|
elements.push(<hr key={i} className="my-4 border-border" />);
|
||||||
} else if (line.startsWith("- ") || line.startsWith("* ")) {
|
} else if (line.startsWith("- ") || line.startsWith("* ")) {
|
||||||
elements.push(<li key={i} className="ml-4 list-disc">{processInline(line.slice(2))}</li>);
|
elements.push(<li key={i} className="ms-4 list-disc">{processInline(line.slice(2))}</li>);
|
||||||
} else if (/^\d+\. /.test(line)) {
|
} else if (/^\d+\. /.test(line)) {
|
||||||
elements.push(<li key={i} className="ml-4 list-decimal">{processInline(line.replace(/^\d+\. /, ""))}</li>);
|
elements.push(<li key={i} className="ms-4 list-decimal">{processInline(line.replace(/^\d+\. /, ""))}</li>);
|
||||||
} else if (line.startsWith("> ")) {
|
} else if (line.startsWith("> ")) {
|
||||||
elements.push(<blockquote key={i} className="border-l-4 border-border pl-4 italic text-muted-foreground my-2">{processInline(line.slice(2))}</blockquote>);
|
elements.push(<blockquote key={i} className="border-s-4 border-border ps-4 italic text-muted-foreground my-2">{processInline(line.slice(2))}</blockquote>);
|
||||||
} else if (line.startsWith("```")) {
|
} else if (line.startsWith("```")) {
|
||||||
// Code block - collect until closing ```
|
// Code block - collect until closing ```
|
||||||
const codeLines: string[] = [];
|
const codeLines: string[] = [];
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export function FileUploadArea({ onUpload, onUploadFolder, onCreateFolder, onCre
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={onCreateFolder}
|
onClick={onCreateFolder}
|
||||||
>
|
>
|
||||||
<FolderPlus className="w-4 h-4 mr-2" />
|
<FolderPlus className="w-4 h-4 me-2" />
|
||||||
{t("new_folder")}
|
{t("new_folder")}
|
||||||
</Button>
|
</Button>
|
||||||
{onCreateTextFile && (
|
{onCreateTextFile && (
|
||||||
@@ -77,7 +77,7 @@ export function FileUploadArea({ onUpload, onUploadFolder, onCreateFolder, onCre
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={onCreateTextFile}
|
onClick={onCreateTextFile}
|
||||||
>
|
>
|
||||||
<FilePlus className="w-4 h-4 mr-2" />
|
<FilePlus className="w-4 h-4 me-2" />
|
||||||
{t("new_text_file")}
|
{t("new_text_file")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, wid
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-r border-border bg-secondary overflow-hidden shrink-0 flex flex-col h-full",
|
"border-e border-border bg-secondary overflow-hidden shrink-0 flex flex-col h-full",
|
||||||
!isResizing && "transition-[width] duration-300"
|
!isResizing && "transition-[width] duration-300"
|
||||||
)}
|
)}
|
||||||
style={{ width: `${width}px` }}
|
style={{ width: `${width}px` }}
|
||||||
@@ -154,10 +154,10 @@ export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, wid
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleFolderClick("/", null)}
|
onClick={() => handleFolderClick("/", null)}
|
||||||
className="flex items-center px-1 rounded transition-colors duration-150 flex-1 text-left"
|
className="flex items-center px-1 rounded transition-colors duration-150 flex-1 text-start"
|
||||||
style={{ paddingBlock: "var(--density-sidebar-py)", paddingLeft: "24px" }}
|
style={{ paddingBlock: "var(--density-sidebar-py)", paddingLeft: "24px" }}
|
||||||
>
|
>
|
||||||
<Home className={cn("w-4 h-4 flex-shrink-0 mr-2 transition-colors")} />
|
<Home className={cn("w-4 h-4 flex-shrink-0 me-2 transition-colors")} />
|
||||||
<span className="truncate">{t("breadcrumb_root")}</span>
|
<span className="truncate">{t("breadcrumb_root")}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -207,11 +207,11 @@ export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, wid
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleFolderClick(path, r.id)}
|
onClick={() => handleFolderClick(path, r.id)}
|
||||||
className="flex items-center px-1 rounded transition-colors duration-150 flex-1 text-left min-w-0"
|
className="flex items-center px-1 rounded transition-colors duration-150 flex-1 text-start min-w-0"
|
||||||
style={{ paddingBlock: "var(--density-sidebar-py)", paddingLeft: "24px" }}
|
style={{ paddingBlock: "var(--density-sidebar-py)", paddingLeft: "24px" }}
|
||||||
title={r.ownerName ? t("shared_by", { name: r.ownerName }) : r.name}
|
title={r.ownerName ? t("shared_by", { name: r.ownerName }) : r.name}
|
||||||
>
|
>
|
||||||
<Folder className="w-4 h-4 flex-shrink-0 mr-2 text-primary" />
|
<Folder className="w-4 h-4 flex-shrink-0 me-2 text-primary" />
|
||||||
<span className="truncate">{r.name}</span>
|
<span className="truncate">{r.name}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -281,7 +281,7 @@ function FolderTreeItem({
|
|||||||
onToggleExpand(node.id, node.path);
|
onToggleExpand(node.id, node.path);
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"p-0.5 rounded mr-1 transition-all duration-200",
|
"p-0.5 rounded me-1 transition-all duration-200",
|
||||||
"hover:bg-muted active:bg-accent"
|
"hover:bg-muted active:bg-accent"
|
||||||
)}
|
)}
|
||||||
style={{ marginLeft: `${indentPx}px` }}
|
style={{ marginLeft: `${indentPx}px` }}
|
||||||
@@ -297,14 +297,14 @@ function FolderTreeItem({
|
|||||||
{/* Folder name */}
|
{/* Folder name */}
|
||||||
<button
|
<button
|
||||||
onClick={() => onFolderClick(node.path, node.id)}
|
onClick={() => onFolderClick(node.path, node.id)}
|
||||||
className="flex items-center px-1 rounded transition-colors duration-150 flex-1 text-left"
|
className="flex items-center px-1 rounded transition-colors duration-150 flex-1 text-start"
|
||||||
style={{
|
style={{
|
||||||
paddingBlock: "var(--density-sidebar-py)",
|
paddingBlock: "var(--density-sidebar-py)",
|
||||||
paddingLeft: hasChildren ? "4px" : `${indentPx + 24}px`,
|
paddingLeft: hasChildren ? "4px" : `${indentPx + 24}px`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Icon className={cn(
|
<Icon className={cn(
|
||||||
"w-4 h-4 flex-shrink-0 mr-2 transition-colors",
|
"w-4 h-4 flex-shrink-0 me-2 transition-colors",
|
||||||
isExpanded && hasChildren && "text-primary",
|
isExpanded && hasChildren && "text-primary",
|
||||||
!hasChildren && depth > 0 && "text-muted-foreground"
|
!hasChildren && depth > 0 && "text-muted-foreground"
|
||||||
)} />
|
)} />
|
||||||
|
|||||||
@@ -212,7 +212,7 @@ export function PdfMobileViewer({ url }: { url: string }) {
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => window.open(url, "_blank", "noopener,noreferrer")}
|
onClick={() => window.open(url, "_blank", "noopener,noreferrer")}
|
||||||
>
|
>
|
||||||
<ExternalLink className="w-4 h-4 mr-2" />
|
<ExternalLink className="w-4 h-4 me-2" />
|
||||||
{t("open_in_new_tab")}
|
{t("open_in_new_tab")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ export function SieveEditorModal({
|
|||||||
|
|
||||||
<div className="flex-1 min-h-0 flex border border-border rounded-md overflow-hidden">
|
<div className="flex-1 min-h-0 flex border border-border rounded-md overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className="w-10 flex-shrink-0 bg-muted border-r border-border py-2 text-right pr-2 select-none overflow-hidden"
|
className="w-10 flex-shrink-0 bg-muted border-e border-border py-2 text-end pe-2 select-none overflow-hidden"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
{Array.from({ length: lineCount }, (_, i) => (
|
{Array.from({ length: lineCount }, (_, i) => (
|
||||||
@@ -172,7 +172,7 @@ export function SieveEditorModal({
|
|||||||
>
|
>
|
||||||
{isValidating ? (
|
{isValidating ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
<Loader2 className="w-4 h-4 me-2 animate-spin" />
|
||||||
{t("validating")}
|
{t("validating")}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useTranslations } 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 type { Identity, EmailAddress } from '@/lib/jmap/types';
|
import type { Identity, EmailAddress } from '@/lib/jmap/types';
|
||||||
import { sanitizeSignatureHtml } from '@/lib/email-sanitization';
|
import { sanitizeSignatureHtml, sanitizeSignatureHtmlForDisplay } from '@/lib/email-sanitization';
|
||||||
import { getEmailValidationError, validateEmailList } from '@/lib/validation';
|
import { getEmailValidationError, validateEmailList } from '@/lib/validation';
|
||||||
|
|
||||||
// Stalwarts JMAP Identity/set caps signature fields at 2047 UTF-8 bytes
|
// Stalwarts JMAP Identity/set caps signature fields at 2047 UTF-8 bytes
|
||||||
@@ -305,7 +305,7 @@ export function IdentityForm({ identity, onSave, onCancel }: IdentityFormProps)
|
|||||||
<div className="text-xs text-muted-foreground mb-1">{tDisplay('preview')}</div>
|
<div className="text-xs text-muted-foreground mb-1">{tDisplay('preview')}</div>
|
||||||
<div
|
<div
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: sanitizeSignatureHtml(formData.htmlSignature)
|
__html: sanitizeSignatureHtmlForDisplay(formData.htmlSignature)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -352,7 +352,7 @@ function SignatureByteCounter({ id, value }: { id: string; value: string }) {
|
|||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
>
|
>
|
||||||
{t('signature_byte_counter', { bytes, max: SIGNATURE_MAX_BYTES })}
|
{t('signature_byte_counter', { bytes, max: SIGNATURE_MAX_BYTES })}
|
||||||
{atLimit && <span className="ml-1">{t('signature_byte_limit_reached')}</span>}
|
{atLimit && <span className="ms-1">{t('signature_byte_limit_reached')}</span>}
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
|||||||
import { IdentityForm } from './identity-form';
|
import { IdentityForm } from './identity-form';
|
||||||
import { useIdentityStore } from '@/stores/identity-store';
|
import { useIdentityStore } from '@/stores/identity-store';
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
import { useAccountStore } from '@/stores/account-store';
|
||||||
import { useSettingsStore } from '@/stores/settings-store';
|
import { useSettingsStore } from '@/stores/settings-store';
|
||||||
|
|
||||||
function useSyncIdentities() {
|
function useSyncIdentities() {
|
||||||
@@ -207,15 +208,16 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
|
|||||||
|
|
||||||
const handleSetPrimary = useCallback((identity: Identity) => {
|
const handleSetPrimary = useCallback((identity: Identity) => {
|
||||||
setPreferredPrimary(identity.id);
|
setPreferredPrimary(identity.id);
|
||||||
// Persist to the synced settings (keyed by username, matching how
|
// Persist the choice per account in the synced settings store so it
|
||||||
// loadIdentities reads it back) so the choice survives a new browser /
|
// survives clearing site data, follows the user across devices, and shows
|
||||||
// cleared site data and reaches other devices (#507).
|
// up in exported settings (issue #507). JMAP identity ids are account-
|
||||||
const username = useAuthStore.getState().username || '';
|
// scoped, so the default is keyed by the active account.
|
||||||
if (username) {
|
const activeAccountId = useAccountStore.getState().activeAccountId;
|
||||||
|
if (activeAccountId) {
|
||||||
const current = useSettingsStore.getState().preferredIdentityIds;
|
const current = useSettingsStore.getState().preferredIdentityIds;
|
||||||
useSettingsStore.getState().updateSetting('preferredIdentityIds', {
|
useSettingsStore.getState().updateSetting('preferredIdentityIds', {
|
||||||
...current,
|
...current,
|
||||||
[username]: identity.id,
|
[activeAccountId]: identity.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Re-sort: move the preferred identity to the front
|
// Re-sort: move the preferred identity to the front
|
||||||
@@ -275,7 +277,7 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
|
|||||||
onClick={() => setIsCreating(true)}
|
onClick={() => setIsCreating(true)}
|
||||||
className="mb-6 w-full sm:w-auto"
|
className="mb-6 w-full sm:w-auto"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
<Plus className="w-4 h-4 me-2" />
|
||||||
{t('create_new')}
|
{t('create_new')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ export function SubAddressHelper({
|
|||||||
title={t('button_tooltip')}
|
title={t('button_tooltip')}
|
||||||
className="h-8 px-2"
|
className="h-8 px-2"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4 mr-1" />
|
<Plus className="w-4 h-4 me-1" />
|
||||||
<Tag className="w-4 h-4" />
|
<Tag className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
@@ -137,7 +137,7 @@ export function SubAddressHelper({
|
|||||||
<div
|
<div
|
||||||
ref={popoverRef}
|
ref={popoverRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute top-full right-0 mt-1 z-50',
|
'absolute top-full end-0 mt-1 z-50',
|
||||||
'bg-background border border-border rounded-lg shadow-lg',
|
'bg-background border border-border rounded-lg shadow-lg',
|
||||||
'w-80 p-4 animate-in fade-in zoom-in-95 duration-150'
|
'w-80 p-4 animate-in fade-in zoom-in-95 duration-150'
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ function ShortcutRow({
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between py-1.5">
|
<div className="flex items-center justify-between py-1.5">
|
||||||
<span className="text-sm text-muted-foreground">{description}</span>
|
<span className="text-sm text-muted-foreground">{description}</span>
|
||||||
<div className="flex items-center gap-1.5 ml-4">
|
<div className="flex items-center gap-1.5 ms-4">
|
||||||
{keys.map((key, index) => (
|
{keys.map((key, index) => (
|
||||||
<span key={index}>
|
<span key={index}>
|
||||||
{index > 0 && <span className="text-muted-foreground/50 mx-1 text-xs">or</span>}
|
{index > 0 && <span className="text-muted-foreground/50 mx-1 text-xs">or</span>}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useRef, useEffect, useCallback } from "react";
|
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { Check, Plus, LogOut, Star, ChevronDown, AlertCircle } from "lucide-react";
|
import { Check, Plus, LogOut, Star, ChevronDown, AlertCircle, GripVertical, X } from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useAccountStore, type AccountEntry } from "@/stores/account-store";
|
import { useAccountStore, type AccountEntry } from "@/stores/account-store";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { getMaxAccounts } from "@/lib/account-utils";
|
import { getMaxAccounts, sortDefaultFirst, reorderNonDefaultIds } from "@/lib/account-utils";
|
||||||
|
import { isDocumentRTL } from "@/i18n/direction";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useRouter } from "@/i18n/navigation";
|
import { useRouter } from "@/i18n/navigation";
|
||||||
import { Avatar } from "@/components/ui/avatar";
|
import { Avatar } from "@/components/ui/avatar";
|
||||||
@@ -40,6 +41,7 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
|
|||||||
|
|
||||||
const accounts = useAccountStore((s) => s.accounts);
|
const accounts = useAccountStore((s) => s.accounts);
|
||||||
const setDefaultAccount = useAccountStore((s) => s.setDefaultAccount);
|
const setDefaultAccount = useAccountStore((s) => s.setDefaultAccount);
|
||||||
|
const reorderAccounts = useAccountStore((s) => s.reorderAccounts);
|
||||||
// Read activeAccountId from authStore so the selector matches the actually-loaded
|
// Read activeAccountId from authStore so the selector matches the actually-loaded
|
||||||
// session (primaryIdentity, JMAP client). accountStore.activeAccountId is a separate
|
// session (primaryIdentity, JMAP client). accountStore.activeAccountId is a separate
|
||||||
// persisted copy that can drift out of sync across hydration / partial persist writes.
|
// persisted copy that can drift out of sync across hydration / partial persist writes.
|
||||||
@@ -47,23 +49,41 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
|
|||||||
const activeAccount = accounts.find((a) => a.id === activeAccountId);
|
const activeAccount = accounts.find((a) => a.id === activeAccountId);
|
||||||
const switchAccount = useAuthStore((s) => s.switchAccount);
|
const switchAccount = useAuthStore((s) => s.switchAccount);
|
||||||
const logout = useAuthStore((s) => s.logout);
|
const logout = useAuthStore((s) => s.logout);
|
||||||
|
const removeAccount = useAuthStore((s) => s.removeAccount);
|
||||||
const logoutAll = useAuthStore((s) => s.logoutAll);
|
const logoutAll = useAuthStore((s) => s.logoutAll);
|
||||||
|
|
||||||
const updatePosition = useCallback(() => {
|
const updatePosition = useCallback(() => {
|
||||||
if (!buttonRef.current) return;
|
if (!buttonRef.current) return;
|
||||||
const rect = buttonRef.current.getBoundingClientRect();
|
const rect = buttonRef.current.getBoundingClientRect();
|
||||||
|
const rtl = isDocumentRTL();
|
||||||
if (variant === "rail") {
|
if (variant === "rail") {
|
||||||
setPopoverStyle({
|
setPopoverStyle(
|
||||||
position: "fixed",
|
rtl
|
||||||
left: rect.right + 8,
|
? {
|
||||||
bottom: Math.max(8, window.innerHeight - rect.bottom),
|
position: "fixed",
|
||||||
});
|
right: window.innerWidth - rect.left + 8,
|
||||||
|
bottom: Math.max(8, window.innerHeight - rect.bottom),
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
position: "fixed",
|
||||||
|
left: rect.right + 8,
|
||||||
|
bottom: Math.max(8, window.innerHeight - rect.bottom),
|
||||||
|
}
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
setPopoverStyle({
|
setPopoverStyle(
|
||||||
position: "fixed",
|
rtl
|
||||||
left: rect.left,
|
? {
|
||||||
top: rect.bottom + 4,
|
position: "fixed",
|
||||||
});
|
right: window.innerWidth - rect.right,
|
||||||
|
top: rect.bottom + 4,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
position: "fixed",
|
||||||
|
left: rect.left,
|
||||||
|
top: rect.bottom + 4,
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, [variant]);
|
}, [variant]);
|
||||||
|
|
||||||
@@ -99,6 +119,13 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
|
|||||||
router.push(`/login?mode=add-account` as never);
|
router.push(`/login?mode=add-account` as never);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRemove = (e: React.MouseEvent, account: AccountEntry) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const label = account.email || account.username;
|
||||||
|
if (!window.confirm(t("remove_account_confirm", { account: label }))) return;
|
||||||
|
removeAccount(account.id);
|
||||||
|
};
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
logout();
|
logout();
|
||||||
@@ -113,6 +140,32 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
|
|||||||
setDefaultAccount(accountId);
|
setDefaultAccount(accountId);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Display order: default account pinned to the top, the rest reorderable.
|
||||||
|
const displayAccounts = useMemo(() => sortDefaultFirst(accounts), [accounts]);
|
||||||
|
|
||||||
|
// Drag-to-rearrange (non-default accounts only; the default stays pinned).
|
||||||
|
const [dragId, setDragId] = useState<string | null>(null);
|
||||||
|
const [dragOverId, setDragOverId] = useState<string | null>(null);
|
||||||
|
const resetDrag = () => { setDragId(null); setDragOverId(null); };
|
||||||
|
|
||||||
|
const handleDragStart = (e: React.DragEvent, id: string) => {
|
||||||
|
setDragId(id);
|
||||||
|
e.dataTransfer.effectAllowed = "move";
|
||||||
|
};
|
||||||
|
const handleDragOver = (e: React.DragEvent, overId: string) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = "move";
|
||||||
|
if (overId !== dragOverId) setDragOverId(overId);
|
||||||
|
};
|
||||||
|
const handleDrop = (e: React.DragEvent, overId: string) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (dragId) {
|
||||||
|
const next = reorderNonDefaultIds(accounts, dragId, overId);
|
||||||
|
if (next) reorderAccounts(next);
|
||||||
|
}
|
||||||
|
resetDrag();
|
||||||
|
};
|
||||||
|
|
||||||
// Show the account's own identity, not the preferred sending identity -
|
// Show the account's own identity, not the preferred sending identity -
|
||||||
// primaryIdentity can be an alias (e.g. info@korazo.net) that differs from
|
// primaryIdentity can be an alias (e.g. info@korazo.net) that differs from
|
||||||
// the actually logged-in account (info@linusrath.de).
|
// the actually logged-in account (info@linusrath.de).
|
||||||
@@ -124,11 +177,13 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
|
|||||||
<button
|
<button
|
||||||
ref={buttonRef}
|
ref={buttonRef}
|
||||||
onClick={() => setOpen(!open)}
|
onClick={() => setOpen(!open)}
|
||||||
|
data-testid="account-switcher"
|
||||||
|
data-active-account-id={activeAccountId ?? undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 rounded-md transition-colors",
|
"flex items-center gap-2 rounded-md transition-colors",
|
||||||
variant === "rail"
|
variant === "rail"
|
||||||
? "justify-center w-10 h-10 hover:bg-muted"
|
? "justify-center w-10 h-10 hover:bg-muted"
|
||||||
: "w-full px-2 py-1.5 hover:bg-muted text-left min-w-0",
|
: "w-full px-2 py-1.5 hover:bg-muted text-start min-w-0",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
title={variant === "rail" ? (displayName || displayEmail) : undefined}
|
title={variant === "rail" ? (displayName || displayEmail) : undefined}
|
||||||
@@ -167,15 +222,32 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
|
|||||||
>
|
>
|
||||||
{/* Account List */}
|
{/* Account List */}
|
||||||
<div className="py-1 max-h-64 overflow-y-auto">
|
<div className="py-1 max-h-64 overflow-y-auto">
|
||||||
{accounts.map((account) => {
|
{displayAccounts.map((account) => {
|
||||||
const isActive = account.id === activeAccountId;
|
const isActive = account.id === activeAccountId;
|
||||||
|
const isDraggable = !account.isDefault && accounts.length > 2;
|
||||||
return (
|
return (
|
||||||
<button
|
<div
|
||||||
key={account.id}
|
key={account.id}
|
||||||
onClick={() => handleSwitch(account.id)}
|
draggable={isDraggable}
|
||||||
|
onDragStart={isDraggable ? (e) => handleDragStart(e, account.id) : undefined}
|
||||||
|
onDragOver={isDraggable ? (e) => handleDragOver(e, account.id) : undefined}
|
||||||
|
onDrop={isDraggable ? (e) => handleDrop(e, account.id) : undefined}
|
||||||
|
onDragEnd={isDraggable ? resetDrag : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full flex items-start gap-3 px-3 py-2.5 text-left transition-colors",
|
"group/acct relative",
|
||||||
isActive ? "bg-accent/50" : "hover:bg-muted"
|
dragId === account.id && "opacity-50",
|
||||||
|
dragOverId === account.id && dragId !== account.id && "border-t-2 border-primary"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSwitch(account.id)}
|
||||||
|
data-testid="account-option"
|
||||||
|
data-account-id={account.id}
|
||||||
|
data-account-email={account.email || account.username}
|
||||||
|
className={cn(
|
||||||
|
"w-full flex items-start gap-3 px-3 py-2.5 text-start transition-colors",
|
||||||
|
isActive ? "bg-accent/50" : "hover:bg-muted",
|
||||||
|
(!isActive && !account.isDefault) ? (isDraggable ? "pe-14" : "pe-8") : (isDraggable && "pe-7")
|
||||||
)}
|
)}
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
disabled={isActive}
|
disabled={isActive}
|
||||||
@@ -215,6 +287,26 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
{isDraggable && (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute end-7 top-1/2 -translate-y-1/2 text-muted-foreground/50 opacity-0 transition-opacity group-hover/acct:opacity-100"
|
||||||
|
>
|
||||||
|
<GripVertical className="w-4 h-4" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!isActive && !account.isDefault && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => handleRemove(e, account)}
|
||||||
|
aria-label={t("remove_account")}
|
||||||
|
title={t("remove_account")}
|
||||||
|
className="absolute end-1.5 top-1/2 -translate-y-1/2 p-1 rounded-md text-muted-foreground/60 opacity-0 transition-opacity group-hover/acct:opacity-100 hover:bg-destructive/10 hover:text-destructive focus:opacity-100 focus:outline-none focus:ring-1 focus:ring-destructive"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -224,6 +316,7 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
|
|||||||
<div className="border-t border-border">
|
<div className="border-t border-border">
|
||||||
<button
|
<button
|
||||||
onClick={handleAddAccount}
|
onClick={handleAddAccount}
|
||||||
|
data-testid="add-account"
|
||||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-foreground hover:bg-muted transition-colors"
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-foreground hover:bg-muted transition-colors"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export function IconPicker({ value, onChange, className }: IconPickerProps) {
|
|||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
placeholder={t('search_icons')}
|
placeholder={t('search_icons')}
|
||||||
className="pl-8 h-8 text-xs"
|
className="ps-8 h-8 text-xs"
|
||||||
/>
|
/>
|
||||||
{search && (
|
{search && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { useAccountStore } from "@/stores/account-store";
|
|||||||
import { useUpdateStore, selectHasUpdate } from "@/stores/update-store";
|
import { useUpdateStore, selectHasUpdate } from "@/stores/update-store";
|
||||||
import { getActiveAccountSlotHeaders } from "@/lib/auth/active-account-slot";
|
import { getActiveAccountSlotHeaders } from "@/lib/auth/active-account-slot";
|
||||||
import { getMaxAccounts } from "@/lib/account-utils";
|
import { getMaxAccounts } from "@/lib/account-utils";
|
||||||
|
import { isDocumentRTL } from "@/i18n/direction";
|
||||||
import { cn, formatFileSize } from "@/lib/utils";
|
import { cn, formatFileSize } from "@/lib/utils";
|
||||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||||
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
|
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
|
||||||
@@ -70,11 +71,19 @@ function StorageQuotaCircle({ quota, usagePercent }: { quota: { used: number; to
|
|||||||
const updatePosition = useCallback(() => {
|
const updatePosition = useCallback(() => {
|
||||||
if (!buttonRef.current) return;
|
if (!buttonRef.current) return;
|
||||||
const rect = buttonRef.current.getBoundingClientRect();
|
const rect = buttonRef.current.getBoundingClientRect();
|
||||||
setPopoverStyle({
|
setPopoverStyle(
|
||||||
position: "fixed",
|
isDocumentRTL()
|
||||||
left: rect.right + 8,
|
? {
|
||||||
bottom: window.innerHeight - rect.bottom,
|
position: "fixed",
|
||||||
});
|
right: window.innerWidth - rect.left + 8,
|
||||||
|
bottom: window.innerHeight - rect.bottom,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
position: "fixed",
|
||||||
|
left: rect.right + 8,
|
||||||
|
bottom: window.innerHeight - rect.bottom,
|
||||||
|
}
|
||||||
|
);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -91,7 +100,8 @@ function StorageQuotaCircle({ quota, usagePercent }: { quota: { used: number; to
|
|||||||
return () => document.removeEventListener("mousedown", handleClick);
|
return () => document.removeEventListener("mousedown", handleClick);
|
||||||
}, [open, updatePosition]);
|
}, [open, updatePosition]);
|
||||||
|
|
||||||
const free = quota.total - quota.used;
|
// Usage can legitimately exceed the quota (e.g. limit lowered after the fact)
|
||||||
|
const free = Math.max(0, quota.total - quota.used);
|
||||||
const strokeColor = usagePercent > 90
|
const strokeColor = usagePercent > 90
|
||||||
? "stroke-destructive"
|
? "stroke-destructive"
|
||||||
: usagePercent > 70
|
: usagePercent > 70
|
||||||
@@ -217,11 +227,19 @@ export function NavigationRail({
|
|||||||
const updateLogoutPosition = useCallback(() => {
|
const updateLogoutPosition = useCallback(() => {
|
||||||
if (!logoutBtnRef.current) return;
|
if (!logoutBtnRef.current) return;
|
||||||
const rect = logoutBtnRef.current.getBoundingClientRect();
|
const rect = logoutBtnRef.current.getBoundingClientRect();
|
||||||
setLogoutPopoverStyle({
|
setLogoutPopoverStyle(
|
||||||
position: "fixed",
|
isDocumentRTL()
|
||||||
left: rect.right + 8,
|
? {
|
||||||
bottom: Math.max(8, window.innerHeight - rect.bottom),
|
position: "fixed",
|
||||||
});
|
right: window.innerWidth - rect.left + 8,
|
||||||
|
bottom: Math.max(8, window.innerHeight - rect.bottom),
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
position: "fixed",
|
||||||
|
left: rect.right + 8,
|
||||||
|
bottom: Math.max(8, window.innerHeight - rect.bottom),
|
||||||
|
}
|
||||||
|
);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user