From 16daf6ea03b0e7fa1d2dccb7de6605192553a1d7 Mon Sep 17 00:00:00 2001 From: Kristofer Pettijohn Date: Mon, 29 Jun 2026 23:06:43 -0500 Subject: [PATCH 001/135] fix(pro): show Edit button on draft emails opened in a new tab --- components/pro/pro-email-tab-body.tsx | 41 +++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/components/pro/pro-email-tab-body.tsx b/components/pro/pro-email-tab-body.tsx index e80e0865..2094963e 100644 --- a/components/pro/pro-email-tab-body.tsx +++ b/components/pro/pro-email-tab-body.tsx @@ -6,6 +6,7 @@ import { EmailViewer } from "@/components/email/email-viewer"; import { ErrorBoundary, EmailViewerErrorFallback } from "@/components/error"; import { useAuthStore } from "@/stores/auth-store"; import { useEmailStore } from "@/stores/email-store"; +import { useIdentityStore } from "@/stores/identity-store"; import { useSettingsStore } from "@/stores/settings-store"; import { toast } from "@/stores/toast-store"; import { useProTabStore, type ProEmailTabData, type ProReplyContext } from "@/stores/pro-tab-store"; @@ -56,6 +57,7 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) { const setEmailKeywordsLocal = useEmailStore((s) => s.setEmailKeywordsLocal); const mailboxes = useEmailStore((s) => s.mailboxes); const settingsKeywords = useSettingsStore((s) => s.emailKeywords); + const identities = useIdentityStore((s) => s.identities); const closeTab = useProTabStore((s) => s.closeTab); const openComposeTab = useProTabStore((s) => s.openComposeTab); @@ -227,6 +229,44 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) { handleReply(body); }, [handleReply]); + const handleEditDraft = useCallback(() => { + if (!email) return; + + const bodyText = email.bodyValues + ? Object.values(email.bodyValues).map((v) => v.value).join('\n') + : ''; + const htmlBody = email.htmlBody?.[0]?.partId && email.bodyValues?.[email.htmlBody[0].partId] + ? email.bodyValues[email.htmlBody[0].partId].value + : undefined; + + // Preserve the identity that matches the draft's From address. + const draftFromEmail = email.from?.[0]?.email; + const matchedIdentity = draftFromEmail + ? identities.find((id) => id.email === draftFromEmail) + : null; + + composerSessionIdRef.current += 1; + openComposeTab({ + sessionId: composerSessionIdRef.current, + mode: 'compose', + title: email.subject || t('email_composer.new_message'), + initialData: { + to: email.to?.map((a) => a.email).filter(Boolean).join(', ') || '', + cc: email.cc?.map((a) => a.email).filter(Boolean).join(', ') || '', + bcc: email.bcc?.map((a) => a.email).filter(Boolean).join(', ') || '', + subject: email.subject || '', + body: htmlBody || bodyText, + showCc: (email.cc?.length || 0) > 0, + showBcc: (email.bcc?.length || 0) > 0, + selectedIdentityId: matchedIdentity?.id ?? null, + subAddressTag: '', + mode: 'compose', + draftId: email.id, + }, + }); + closeTab(tabId); + }, [email, identities, openComposeTab, closeTab, tabId, t]); + return (
@@ -243,6 +283,7 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) { onSetColorTag={handleSetColorTag} onDownloadAttachment={handleDownloadAttachment} onQuickReply={handleQuickReply} + onEditDraft={handleEditDraft} onMoveToMailbox={handleMoveToMailbox} currentUserEmail={client?.getUsername()} currentUserName={client?.getUsername()?.split('@')[0]} From 2a41e73bf9490a672cfaee7500b6b101daaa45e8 Mon Sep 17 00:00:00 2001 From: Kristofer Pettijohn Date: Mon, 29 Jun 2026 23:27:41 -0500 Subject: [PATCH 002/135] fix(pro): prompt to save or discard draft when closing compose tab via tab-bar X --- app/(main)/[locale]/pro/page.tsx | 4 +-- components/email/email-composer.tsx | 19 ++++++++++++++ components/pro/pro-compose-tab-body.tsx | 21 +++++++++++++++- stores/pro-tab-store.ts | 33 +++++++++++++++++++++++++ 4 files changed, 74 insertions(+), 3 deletions(-) diff --git a/app/(main)/[locale]/pro/page.tsx b/app/(main)/[locale]/pro/page.tsx index 1d1005f0..5d4a512c 100644 --- a/app/(main)/[locale]/pro/page.tsx +++ b/app/(main)/[locale]/pro/page.tsx @@ -139,7 +139,7 @@ export default function ProHome() { const focusedPaneId = useProTabStore((s) => s.focusedPaneId); const loadedTabIds = useProTabStore((s) => s.loadedTabIds); const openTab = useProTabStore((s) => s.openTab); - const closeTab = useProTabStore((s) => s.closeTab); + const requestCloseTab = useProTabStore((s) => s.requestCloseTab); const setActiveTab = useProTabStore((s) => s.setActiveTab); const setFocusedPane = useProTabStore((s) => s.setFocusedPane); const moveTabToPane = useProTabStore((s) => s.moveTabToPane); @@ -354,7 +354,7 @@ export default function ProHome() { activeMainTabId={activeMainTabId} activeSplitTabId={activeSplitTabId} onActivate={setActiveTab} - onClose={closeTab} + onClose={requestCloseTab} onDragStateChange={setIsTabDragging} /> diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 23b24ae8..3544eb09 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -133,6 +133,13 @@ interface EmailComposerProps { }) => void | Promise; onScheduledSendCreated?: () => void | Promise; onClose?: () => void; + /** + * When provided, the composer assigns its close handler to `current`. The + * handler shows the unsaved-changes dialog when the draft is dirty, so a + * host (e.g. the Pro tab bar's close button) can route an external close + * request through the same guard instead of discarding silently. + */ + requestCloseRef?: React.MutableRefObject<(() => void) | null>; onDiscardDraft?: (draftId: string) => void; onSaveState?: (data: ComposerDraftData) => void; className?: string; @@ -229,6 +236,7 @@ export function EmailComposer({ onSend, onScheduledSendCreated, onClose, + requestCloseRef, onDiscardDraft, onSaveState, className, @@ -1815,6 +1823,17 @@ export function EmailComposer({ } }; + // Expose the dirty-aware close handler so external hosts (e.g. the Pro tab + // bar) can trigger the same "Save or discard draft?" guard. Re-assigned on + // every render to capture the latest closure over the live refs/state. + useEffect(() => { + if (!requestCloseRef) return; + requestCloseRef.current = handleClose; + return () => { + requestCloseRef.current = null; + }; + }); + const handleComposerKeyDown = (e: React.KeyboardEvent) => { if (e.defaultPrevented) return; diff --git a/components/pro/pro-compose-tab-body.tsx b/components/pro/pro-compose-tab-body.tsx index a175e157..039331bf 100644 --- a/components/pro/pro-compose-tab-body.tsx +++ b/components/pro/pro-compose-tab-body.tsx @@ -7,7 +7,7 @@ import { ErrorBoundary, ComposerErrorFallback } from "@/components/error"; import { useAuthStore } from "@/stores/auth-store"; import { useEmailStore } from "@/stores/email-store"; import { toast } from "@/stores/toast-store"; -import { useProTabStore, type ProComposeTabData } from "@/stores/pro-tab-store"; +import { useProTabStore, registerProTabCloseInterceptor, type ProComposeTabData } from "@/stores/pro-tab-store"; import { debug } from "@/lib/debug"; interface ProComposeTabBodyProps { @@ -38,6 +38,10 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) { const tabIdRef = useRef(tabId); tabIdRef.current = tabId; + // Set by the composer to its dirty-aware close handler. Lets the Pro tab + // bar's "X" route through the same "Save or discard draft?" guard. + const requestCloseRef = useRef<(() => void) | null>(null); + const handleScheduledSendCreated = useCallback(async () => { if (client) { await refreshScheduledMetadata(client); @@ -120,6 +124,20 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) { closeTab(tabIdRef.current); }, [closeTab]); + // Register a close interceptor so closing the tab from the tab bar (the + // "X" button or middle-click) goes through the composer's unsaved-changes + // guard, mirroring the non-Pro inline composer. + useEffect(() => { + const id = tabIdRef.current; + return registerProTabCloseInterceptor(id, () => { + if (requestCloseRef.current) { + requestCloseRef.current(); + } else { + closeTab(id); + } + }); + }, [closeTab]); + const handleDiscardDraft = useCallback(async (draftId: string) => { if (!client) return; try { @@ -160,6 +178,7 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) { onSend={handleSend} onScheduledSendCreated={handleScheduledSendCreated} onClose={handleClose} + requestCloseRef={requestCloseRef} onDiscardDraft={handleDiscardDraft} onSaveState={handleSaveState} className="flex-1" diff --git a/stores/pro-tab-store.ts b/stores/pro-tab-store.ts index 0178f370..a4d239bb 100644 --- a/stores/pro-tab-store.ts +++ b/stores/pro-tab-store.ts @@ -87,6 +87,12 @@ interface ProTabState { openComposeTab: (data: ProComposeTabData) => string; openEmailTab: (data: ProEmailTabData) => string; closeTab: (id: string) => void; + /** + * Request closing a tab, honouring any registered close interceptor (e.g. a + * compose tab with unsaved changes that wants to show the "Save or discard + * draft?" dialog first). Falls back to `closeTab` when none is registered. + */ + requestCloseTab: (id: string) => void; setActiveTab: (id: string) => void; setFocusedPane: (paneId: ProPaneId) => void; @@ -131,6 +137,21 @@ const HOME_TAB: ProTab = { paneId: 'main', }; +/** + * Module-level registry of tab close interceptors. Kept outside the persisted + * Zustand state so functions are never serialised. A compose tab registers a + * handler here so an external close request (tab-bar "X", middle-click) routes + * through the composer's unsaved-changes guard instead of closing instantly. + */ +const closeInterceptors = new Map void>(); + +export function registerProTabCloseInterceptor(id: string, fn: () => void): () => void { + closeInterceptors.set(id, fn); + return () => { + if (closeInterceptors.get(id) === fn) closeInterceptors.delete(id); + }; +} + function makeId(): string { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { return crypto.randomUUID(); @@ -265,11 +286,23 @@ export const useProTabStore = create()( return newTab.id; }, + requestCloseTab: (id) => { + const interceptor = closeInterceptors.get(id); + if (interceptor) { + interceptor(); + return; + } + get().closeTab(id); + }, + closeTab: (id) => { const state = get(); const tab = state.tabs.find((t) => t.id === id); if (!tab || !tab.closeable) return; + // Drop any registered close interceptor for this tab. + closeInterceptors.delete(id); + const removedPane = tab.paneId; const newTabs = state.tabs.filter((t) => t.id !== id); const newLoaded = state.loadedTabIds.filter((tid) => tid !== id); From 6c49427c7cbed3d81bf8af4e13806314d4c006a2 Mon Sep 17 00:00:00 2001 From: Shuki Vaknin Date: Tue, 30 Jun 2026 06:44:39 +0300 Subject: [PATCH 003/135] fix(list): shift-click on the checkbox extends the selection (range) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit selectRangeEmails was only wired to shift-clicking the row, but the checkbox handler called stopPropagation and a plain toggle — so shift- clicking checkboxes (the obvious affordance in selection mode) selected single messages instead of the range. Make all three checkbox handlers (email-list-item, thread single-email, thread header) shift-aware: shift -> selectRangeEmails, otherwise toggle. Adds a regression test. --- .../email/__tests__/email-list-item.test.tsx | 31 +++++++++++++++++++ components/email/email-list-item.tsx | 9 +++++- components/email/thread-list-item.tsx | 10 +++++- 3 files changed, 48 insertions(+), 2 deletions(-) diff --git a/components/email/__tests__/email-list-item.test.tsx b/components/email/__tests__/email-list-item.test.tsx index 2a6101fd..72d41e35 100644 --- a/components/email/__tests__/email-list-item.test.tsx +++ b/components/email/__tests__/email-list-item.test.tsx @@ -121,3 +121,34 @@ describe('EmailListItem tag badge', () => { expect(container.querySelector('p')).toBeNull(); }); }); + +describe('EmailListItem shift-range checkbox', () => { + beforeEach(() => { + useSettingsStore.setState({ emailKeywords: [...DEFAULT_KEYWORDS], showPreview: false, mailLayout: 'split' }); + }); + + it('shift-clicking the checkbox extends the selection from the anchor', () => { + const e1 = makeEmail({ id: 'e1', threadId: 't1' }); + const e2 = makeEmail({ id: 'e2', threadId: 't2' }); + const e3 = makeEmail({ id: 'e3', threadId: 't3' }); + // selection mode active (so the checkbox renders), anchor on e1 + useEmailStore.setState({ + emails: [e1, e2, e3], + selectedEmailIds: new Set(['e1']), + lastSelectedEmailId: 'e1', + selectedMailbox: 'inbox', + }); + + render(); + // 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); + }); +}); diff --git a/components/email/email-list-item.tsx b/components/email/email-list-item.tsx index b6549346..2ee2ca84 100644 --- a/components/email/email-list-item.tsx +++ b/components/email/email-list-item.tsx @@ -87,7 +87,14 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte const handleCheckboxClick = (e: React.MouseEvent) => { e.stopPropagation(); - toggleEmailSelection(email.id); + if (e.shiftKey) { + // Shift-click extends the selection from the anchor to here, like + // shift-clicking the row (the checkbox stops propagation, so the + // row's shift handler never runs — replicate it here). + selectRangeEmails(email.id); + } else { + toggleEmailSelection(email.id); + } }; const handleContextMenu = (e: React.MouseEvent) => { diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx index ebd8ceb0..2e9caac6 100644 --- a/components/email/thread-list-item.tsx +++ b/components/email/thread-list-item.tsx @@ -134,7 +134,11 @@ const SingleEmailItem = React.forwardRef( const handleCheckboxClick = (e: React.MouseEvent) => { e.stopPropagation(); - toggleEmailSelection(email.id); + if (e.shiftKey) { + selectRangeEmails(email.id); + } else { + toggleEmailSelection(email.id); + } }; const handleContextMenu = (e: React.MouseEvent) => { @@ -513,6 +517,10 @@ export const ThreadListItem = React.forwardRef { e.stopPropagation(); + if (e.shiftKey) { + selectRangeEmails(latestEmail.id); + return; + } // Toggle selection for all emails in this thread const allSelected = thread.emails.every(em => selectedEmailIds.has(em.id)); const newSelection = new Set(selectedEmailIds); From b716f95a73697a21a0a01f29a756936af99b4102 Mon Sep 17 00:00:00 2001 From: Patrick Rotter Date: Tue, 30 Jun 2026 14:49:20 +0200 Subject: [PATCH 004/135] feat(compose): preselect identity of the active mailbox for new messages Starting a new message while viewing a specific mailbox/account now defaults the From identity to that mailbox instead of the global primary identity, so composing from info@ sends as info@. Mirrors the existing reply-time identity match and rides the same autoSelectReplyIdentity setting; reply/replyAll/forward keep resolving from the original recipients. Matches exact then +tag-stripped. Extracts findComposeIdentityId into lib/reply-identity.ts with unit tests. --- app/(main)/[locale]/page.tsx | 5 +++++ components/email/email-composer.tsx | 26 +++++++++++++++++++++++++- lib/__tests__/reply-identity.test.ts | 25 ++++++++++++++++++++++++- lib/reply-identity.ts | 28 ++++++++++++++++++++++++++++ 4 files changed, 82 insertions(+), 2 deletions(-) diff --git a/app/(main)/[locale]/page.tsx b/app/(main)/[locale]/page.tsx index 589d5315..77af5192 100644 --- a/app/(main)/[locale]/page.tsx +++ b/app/(main)/[locale]/page.tsx @@ -3084,6 +3084,11 @@ export default function Home() { { if (!autoSelectReplyIdentity) return; if (selectedIdentityId || initialData?.selectedIdentityId) return; + + // New message started from a specific mailbox/account: default the From to + // that mailbox's identity instead of the primary one, so composing while + // viewing info@ sends as info@. Reply/forward fall through to the + // recipient-based resolution below. + if (mode === 'compose') { + const composeIdentityId = findComposeIdentityId(identities, composeFromAccountEmail); + if (composeIdentityId) { + setSelectedIdentityId(composeIdentityId); + } + return; + } + if (mode !== 'reply' && mode !== 'replyAll') return; const resolved = resolveReplyFrom(identities, { @@ -708,6 +731,7 @@ export function EmailComposer({ } }, [ autoSelectReplyIdentity, + composeFromAccountEmail, fromOverrideEnabled, identities, initialData?.selectedIdentityId, diff --git a/lib/__tests__/reply-identity.test.ts b/lib/__tests__/reply-identity.test.ts index 4cb3bdf8..c883d1d1 100644 --- a/lib/__tests__/reply-identity.test.ts +++ b/lib/__tests__/reply-identity.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { findReplyIdentityId, resolveReplyFrom } from '../reply-identity'; +import { findComposeIdentityId, findReplyIdentityId, resolveReplyFrom } from '../reply-identity'; import type { Identity } from '../jmap/types'; const identities: Identity[] = [ @@ -51,6 +51,29 @@ describe('findReplyIdentityId', () => { }); }); +describe('findComposeIdentityId', () => { + it('matches the identity of the active mailbox', () => { + expect(findComposeIdentityId(identities, 'harry@secondary.com')).toBe('secondary'); + }); + + it('matches case-insensitively', () => { + expect(findComposeIdentityId(identities, 'HARRY@PRIMARY.COM')).toBe('primary'); + }); + + it('strips +tag before matching', () => { + expect(findComposeIdentityId(identities, 'harry+news@secondary.com')).toBe('secondary'); + }); + + it('returns null when the active mailbox has no matching identity', () => { + expect(findComposeIdentityId(identities, 'other@example.com')).toBeNull(); + }); + + it('returns null when no active mailbox email is given', () => { + expect(findComposeIdentityId(identities, undefined)).toBeNull(); + expect(findComposeIdentityId(identities, '')).toBeNull(); + }); +}); + describe('resolveReplyFrom', () => { it('returns the matching identity with no override when exact match', () => { expect(resolveReplyFrom(identities, { to: [{ email: 'harry@secondary.com' }] })) diff --git a/lib/reply-identity.ts b/lib/reply-identity.ts index bd5b9dee..1040fbe1 100644 --- a/lib/reply-identity.ts +++ b/lib/reply-identity.ts @@ -67,6 +67,34 @@ export function findReplyIdentityId( return baseIdentity?.id ?? null; } +/** + * Pick the identity to use for a NEW message started while viewing a specific + * mailbox/account. Matches the active mailbox's address to a configured + * identity (exact, then `+tag`-stripped) so composing from info@ defaults its + * From to info@. Returns `null` when no address is given or none matches, so + * the caller keeps the primary identity. + */ +export function findComposeIdentityId( + identities: Identity[], + accountEmail?: string | null, +): string | null { + const email = accountEmail?.trim(); + if (identities.length === 0 || !email) { + return null; + } + + const exact = normalizeEmailAddress(email); + const exactIdentity = identities.find((identity) => normalizeEmailAddress(identity.email) === exact); + if (exactIdentity) { + return exactIdentity.id; + } + + const base = normalizeBaseEmailAddress(email); + const baseIdentity = identities.find((identity) => normalizeBaseEmailAddress(identity.email) === base); + + return baseIdentity?.id ?? null; +} + export interface ReplyFromResolution { /** Identity to use for JMAP `identityId` and the SMTP envelope MAIL FROM. */ identityId: string; From 9da27df2494333d07e73971c5964ee8a597c3417 Mon Sep 17 00:00:00 2001 From: Linus Rath Date: Tue, 30 Jun 2026 18:41:58 +0200 Subject: [PATCH 005/135] Update README.md --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index 717e3629..cc14a888 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,6 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar [![Discord](https://img.shields.io/discord/1482128142939455674?color=7289da&label=discord&logo=discord&logoColor=white)](https://discord.gg/tYCujymGrT) [![Version](https://img.shields.io/badge/version-1.7.6-green.svg?logo=git&logoColor=white)](CHANGELOG.md) [![Docker](https://img.shields.io/badge/docker-ghcr.io%2Fbulwarkmail%2Fwebmail-blue?logo=docker&logoColor=white)](https://ghcr.io/bulwarkmail/webmail) -[![Grafana](https://img.shields.io/badge/grafana-dashboard-orange?logo=grafana&logoColor=white)](https://grafana.external.bulwarkmail.org/) -
--- From 95b5a81924acf533486c6ce2a86f56f4813f828a Mon Sep 17 00:00:00 2001 From: Chris Rowland <78135+rowlc@users.noreply.github.com> Date: Fri, 3 Jul 2026 02:22:21 +0000 Subject: [PATCH 006/135] fix(plugins): preserve settings slot and privileged tier --- app/api/admin/marketplace/route.ts | 1 + components/settings/account-settings.tsx | 3 +++ 2 files changed, 4 insertions(+) diff --git a/app/api/admin/marketplace/route.ts b/app/api/admin/marketplace/route.ts index d44887ce..b60dd6a6 100644 --- a/app/api/admin/marketplace/route.ts +++ b/app/api/admin/marketplace/route.ts @@ -341,6 +341,7 @@ export async function POST(request: NextRequest) { author: (manifest.author as string) || 'Unknown', description: (manifest.description as string) || '', type: (manifest.type as string) || 'hook', + ...(manifest.tier === 'privileged' ? { tier: 'privileged' } : {}), permissions, entrypoint, enabled: existingPlugin?.enabled ?? true, diff --git a/components/settings/account-settings.tsx b/components/settings/account-settings.tsx index 0ac31894..0962c4d8 100644 --- a/components/settings/account-settings.tsx +++ b/components/settings/account-settings.tsx @@ -14,6 +14,7 @@ import { Button } from '@/components/ui/button'; import { useRouter } from '@/i18n/navigation'; import { getMaxAccounts } from '@/lib/account-utils'; import { formatFileSize, cn } from '@/lib/utils'; +import { PluginSlot } from '@/components/plugins/plugin-slot'; function hostnameOf(serverUrl: string): string { try { return new URL(serverUrl).hostname; } catch { return serverUrl; } @@ -180,6 +181,8 @@ export function AccountSettings() { )} + + {/* Logged-in accounts list */} {accounts.length > 0 && ( From f291480565807cd091673ce88e79a1a0ea715cf4 Mon Sep 17 00:00:00 2001 From: Shuki Vaknin Date: Fri, 3 Jul 2026 20:18:30 +0300 Subject: [PATCH 007/135] feat(email): send quick reply with Ctrl/Cmd+Enter The message-viewer quick-reply box only sent via the Send button. Add a keyboard shortcut (Ctrl+Enter / Cmd+Enter) mirroring the composer (#344), so a reply can be fired without reaching for the mouse. preventDefault stops the newline; the shortcut and button share one handleSendQuickReply(). --- components/email/email-viewer.tsx | 34 ++++++++++++++++++------------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 03386e94..ccc7b778 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -752,6 +752,19 @@ export function EmailViewer({ const [quickReplyText, setQuickReplyText] = useState(""); const [isQuickReplyFocused, setIsQuickReplyFocused] = useState(false); const [isSendingQuickReply, setIsSendingQuickReply] = useState(false); + const handleSendQuickReply = async () => { + if (!quickReplyText.trim() || !onQuickReply || isSendingQuickReply) return; + setIsSendingQuickReply(true); + try { + await onQuickReply(quickReplyText); + setQuickReplyText(""); + setIsQuickReplyFocused(false); + } catch (error) { + console.error("Failed to send quick reply:", error); + } finally { + setIsSendingQuickReply(false); + } + }; const [showSourceModal, setShowSourceModal] = useState(false); const [moreMenuOpen, setMoreMenuOpen] = useState(false); const [moreMenuSub, setMoreMenuSub] = useState<'move' | 'tag' | null>(null); @@ -4713,6 +4726,12 @@ export function EmailViewer({ value={quickReplyText} onChange={(e) => setQuickReplyText(e.target.value)} onFocus={() => setIsQuickReplyFocused(true)} + onKeyDown={(e) => { + if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { + e.preventDefault(); + void handleSendQuickReply(); + } + }} placeholder={t('quick_reply_placeholder')} className={cn( "w-full px-3 py-2 text-sm border border-border bg-background text-foreground rounded-lg", @@ -4757,20 +4776,7 @@ export function EmailViewer({ + {isDraggable && ( + + + + )} + ); })} diff --git a/lib/__tests__/account-ordering.test.ts b/lib/__tests__/account-ordering.test.ts new file mode 100644 index 00000000..4c102584 --- /dev/null +++ b/lib/__tests__/account-ordering.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from 'vitest'; +import { sortDefaultFirst, reorderNonDefaultIds, type OrderableAccount } from '../account-utils'; + +const acct = (id: string, isDefault = false): OrderableAccount => ({ id, isDefault }); + +describe('sortDefaultFirst', () => { + it('pins the default account to the front, preserving the rest order', () => { + const accounts = [acct('a'), acct('b', true), acct('c')]; + expect(sortDefaultFirst(accounts).map((a) => a.id)).toEqual(['b', 'a', 'c']); + }); + + it('is a no-op shape when the default is already first', () => { + const accounts = [acct('b', true), acct('a'), acct('c')]; + expect(sortDefaultFirst(accounts).map((a) => a.id)).toEqual(['b', 'a', 'c']); + }); + + it('does not mutate the input array', () => { + const accounts = [acct('a'), acct('b', true)]; + const snapshot = accounts.map((a) => a.id); + sortDefaultFirst(accounts); + expect(accounts.map((a) => a.id)).toEqual(snapshot); + }); +}); + +describe('reorderNonDefaultIds', () => { + // default 'd' stays index 0; non-defaults are a, b, c + const accounts = [acct('d', true), acct('a'), acct('b'), acct('c')]; + + it('moves a non-default onto a later position, keeping default pinned', () => { + expect(reorderNonDefaultIds(accounts, 'a', 'c')).toEqual(['d', 'b', 'c', 'a']); + }); + + it('moves a non-default earlier', () => { + expect(reorderNonDefaultIds(accounts, 'c', 'a')).toEqual(['d', 'c', 'a', 'b']); + }); + + it('returns null for a no-op (same id)', () => { + expect(reorderNonDefaultIds(accounts, 'a', 'a')).toBeNull(); + }); + + it('returns null when the default is dragged or targeted', () => { + expect(reorderNonDefaultIds(accounts, 'd', 'a')).toBeNull(); + expect(reorderNonDefaultIds(accounts, 'a', 'd')).toBeNull(); + }); +}); diff --git a/lib/account-utils.ts b/lib/account-utils.ts index ea63020f..41204e37 100644 --- a/lib/account-utils.ts +++ b/lib/account-utils.ts @@ -102,3 +102,41 @@ export function isHttp2Available(): boolean { export function getMaxAccounts(): number { return isHttp2Available() ? MAX_ACCOUNT_SLOTS : MAX_ACCOUNTS_HTTP1; } + +/** Minimal shape needed to order accounts (structural — avoids importing AccountEntry). */ +export interface OrderableAccount { + id: string; + isDefault: boolean; +} + +/** + * Display order for the account switcher: the default account first, then the + * remaining accounts in their stored order. Pure — does not mutate the input. + */ +export function sortDefaultFirst(accounts: T[]): T[] { + const defaults = accounts.filter((a) => a.isDefault); + const rest = accounts.filter((a) => !a.isDefault); + return [...defaults, ...rest]; +} + +/** + * Compute the new full account-id order after dragging `dragId` onto `overId`. + * Default account(s) stay pinned to the front; only non-default accounts are + * reordered (`dragId` is inserted at `overId`'s position among them). + * Returns null when the move is a no-op or invalid (e.g. a default is involved). + */ +export function reorderNonDefaultIds( + accounts: OrderableAccount[], + dragId: string, + overId: string, +): string[] | null { + if (dragId === overId) return null; + const defaults = accounts.filter((a) => a.isDefault).map((a) => a.id); + const nonDefault = accounts.filter((a) => !a.isDefault).map((a) => a.id); + const from = nonDefault.indexOf(dragId); + const to = nonDefault.indexOf(overId); + if (from < 0 || to < 0) return null; + const [moved] = nonDefault.splice(from, 1); + nonDefault.splice(to, 0, moved); + return [...defaults, ...nonDefault]; +} From e9ac2de6cb1614589309c5ed8ee239ea317d763f Mon Sep 17 00:00:00 2001 From: dealerweb Date: Thu, 2 Jul 2026 13:46:08 +0200 Subject: [PATCH 011/135] Fix: notification sound preview - base-path prefix + longer default beep The sound picker's preview always played the default beep, even for the other choices, on a subpath deployment. playFile() used a raw '/notification/x.mp3' path, which 404s under a deployment base path (e.g. /webmail); audio.play() then rejected and fell back to the beep for every non-default choice. Prefix the file with withBasePath(). The default beep was a 150 ms tone with no envelope - easy to miss on Bluetooth outputs, whose audio path can take 100-200 ms to wake up and route. Lengthen it to ~0.45 s with a fade in/out (also removes click artifacts). --- lib/notification-sound.ts | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/lib/notification-sound.ts b/lib/notification-sound.ts index 13a21a34..36589e31 100644 --- a/lib/notification-sound.ts +++ b/lib/notification-sound.ts @@ -1,4 +1,5 @@ import { debug } from '@/lib/debug'; +import { withBasePath } from '@/lib/browser-navigation'; export type NotificationSoundChoice = 'default' | 'cheerful' | 'involved' | 'swift' | 'relax'; @@ -20,15 +21,29 @@ function playBeep() { oscillator.frequency.value = 800; oscillator.type = 'sine'; - gainNode.gain.value = 0.1; - oscillator.start(); - oscillator.stop(audioContext.currentTime + 0.15); + // Longer, enveloped tone. A 150 ms blip was easy to miss on Bluetooth + // outputs, whose audio path can take 100-200 ms to wake up and route - by + // the time sound reached the headphones the blip was already over. The + // fade in/out also avoids click artifacts. + const now = audioContext.currentTime; + const duration = 0.45; + const peak = 0.12; + gainNode.gain.setValueAtTime(0.0001, now); + gainNode.gain.exponentialRampToValueAtTime(peak, now + 0.04); + gainNode.gain.setValueAtTime(peak, now + duration - 0.08); + gainNode.gain.exponentialRampToValueAtTime(0.0001, now + duration); + + oscillator.start(now); + oscillator.stop(now + duration + 0.02); oscillator.onended = () => audioContext.close(); } function playFile(file: string) { - const audio = new Audio(file); + // Prefix with the deployment base path (e.g. /webmail); a raw "/notification/ + // x.mp3" 404s under a subpath, which made playFile fall back to the beep for + // every choice. + const audio = new Audio(withBasePath(file)); audio.volume = 0.3; audio.play().catch((e) => { debug.log('push', 'Could not play audio file, falling back to beep:', e); From a099ab442adc4ba7f5f2f3b891c3e9ee9c8defb3 Mon Sep 17 00:00:00 2001 From: Patrick Rotter Date: Tue, 30 Jun 2026 13:26:56 +0200 Subject: [PATCH 012/135] fix: route keyword writes to the email's own account in unified view Tags applied to a shared/group-mailbox message did not persist. Custom keywords (:*), and were written via Email/set against the reaching client's primary account instead of the email's owning account, so the server returned notUpdated without an error and the change was lost on the next reload. toggleStar already threaded an accountId through (#281); the keyword methods did not. Add an optional accountId to updateEmailKeywords and setKeyword and resolve it at the call sites from the email's source account (sourceClientAccountId / sourceAccountId), matching the existing delete/archive routing. Personal sources resolve to the account itself, so behavior there is unchanged. --- app/(main)/[locale]/page.tsx | 59 ++++++++++++----- hooks/use-tag-drop.ts | 18 +++++- lib/__tests__/jmap-keyword-account.test.ts | 74 ++++++++++++++++++++++ lib/jmap/client-interface.ts | 4 +- lib/jmap/client.ts | 8 +-- 5 files changed, 138 insertions(+), 25 deletions(-) create mode 100644 lib/__tests__/jmap-keyword-account.test.ts diff --git a/app/(main)/[locale]/page.tsx b/app/(main)/[locale]/page.tsx index 77af5192..038e45f8 100644 --- a/app/(main)/[locale]/page.tsx +++ b/app/(main)/[locale]/page.tsx @@ -1172,18 +1172,22 @@ export default function Home() { return; } - // Mark the original email with $answered or $forwarded keyword - if (originalEmailId && (effectiveMode === 'reply' || effectiveMode === 'replyAll')) { + // Mark the original email with $answered or $forwarded keyword. Route the + // write to the email's own account so the flag lands on shared/group-mailbox + // messages instead of being dropped against the reaching account. (#281) + if (originalEmailId && (effectiveMode === 'reply' || effectiveMode === 'replyAll' || effectiveMode === 'forward')) { + const s = useEmailStore.getState(); + const orig = s.emails.find(e => e.id === originalEmailId); + const kwClientId = s.isUnifiedView ? orig?.sourceClientAccountId : undefined; + const kwAccountId = s.isUnifiedView ? orig?.sourceAccountId : undefined; + const kwClient = kwClientId + ? (useAuthStore.getState().getClientForAccount(kwClientId) ?? client) + : client; + const keyword = effectiveMode === 'forward' ? '$forwarded' : '$answered'; try { - await client.setKeyword(originalEmailId, '$answered'); + await kwClient.setKeyword(originalEmailId, keyword, kwAccountId); } catch (e) { - debug.error('Failed to set $answered keyword:', e); - } - } else if (originalEmailId && effectiveMode === 'forward') { - try { - await client.setKeyword(originalEmailId, '$forwarded'); - } catch (e) { - debug.error('Failed to set $forwarded keyword:', e); + debug.error(`Failed to set ${keyword} keyword:`, e); } } @@ -1663,8 +1667,20 @@ export default function Home() { } } + // In unified view route the write to the email's own account, reached + // through the login it is reachable via (`sourceClientAccountId`) and + // applied to its owning JMAP account (`sourceAccountId`). For personal + // sources these resolve to the account itself, so behavior is unchanged. + // Without this, tags on shared/group-mailbox messages are written to the + // reaching account and silently dropped by the server. (#281) + const tagClientId = isUnifiedView ? email.sourceClientAccountId : undefined; + const tagAccountId = isUnifiedView ? email.sourceAccountId : undefined; + const tagClient = tagClientId + ? (useAuthStore.getState().getClientForAccount(tagClientId) ?? client) + : client; + // Update email keywords via JMAP - await client.updateEmailKeywords(emailId, keywords); + await tagClient.updateEmailKeywords(emailId, keywords, tagAccountId); // Patch the email in place so the list keeps its scroll/pagination state // instead of being reset to the first page by a full refetch. @@ -2271,11 +2287,22 @@ export default function Home() { return; } - // Mark the original email as answered - try { - await client.setKeyword(originalEmailId, '$answered'); - } catch (e) { - debug.error('Failed to set $answered keyword:', e); + // Mark the original email as answered. Route the write to the email's own + // account so the flag lands on shared/group-mailbox messages instead of + // being dropped against the reaching account. (#281) + { + const s = useEmailStore.getState(); + const orig = s.emails.find(e => e.id === originalEmailId); + const kwClientId = s.isUnifiedView ? orig?.sourceClientAccountId : undefined; + const kwAccountId = s.isUnifiedView ? orig?.sourceAccountId : undefined; + const kwClient = kwClientId + ? (useAuthStore.getState().getClientForAccount(kwClientId) ?? client) + : client; + try { + await kwClient.setKeyword(originalEmailId, '$answered', kwAccountId); + } catch (e) { + debug.error('Failed to set $answered keyword:', e); + } } // Refresh emails to show the sent reply diff --git a/hooks/use-tag-drop.ts b/hooks/use-tag-drop.ts index 61e7a391..2c0d2803 100644 --- a/hooks/use-tag-drop.ts +++ b/hooks/use-tag-drop.ts @@ -74,14 +74,26 @@ export function useTagDrop({ tagId, onSuccess, onError }: UseTagDropOptions): Us for (const emailId of emailIds) { // Read fresh state to avoid stale closures - const currentEmails = useEmailStore.getState().emails; - const email = currentEmails.find(em => em.id === emailId); + const emailState = useEmailStore.getState(); + const email = emailState.emails.find(em => em.id === emailId); const keywords = { ...(email?.keywords || {}) }; // Add the tag without removing existing ones keywords[`$label:${tagId}`] = true; - await client.updateEmailKeywords(emailId, keywords); + // 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 = emailState.isUnifiedView ? email?.sourceClientAccountId : undefined; + const tagAccountId = emailState.isUnifiedView ? email?.sourceAccountId : undefined; + const tagClient = tagClientId + ? (useAuthStore.getState().getClientForAccount(tagClientId) ?? client) + : client; + + await tagClient.updateEmailKeywords(emailId, keywords, tagAccountId); } // Refresh the email list diff --git a/lib/__tests__/jmap-keyword-account.test.ts b/lib/__tests__/jmap-keyword-account.test.ts new file mode 100644 index 00000000..7c4585e3 --- /dev/null +++ b/lib/__tests__/jmap-keyword-account.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { JMAPClient } from '../jmap/client'; + +// Regression coverage for the shared-account keyword write path (#281): keyword +// mutations (tags, $answered/$forwarded) on a unified-inbox message must target +// the email's owning account, not the reaching client's primary. Writing to the +// primary account silently no-ops server-side (JMAP returns notUpdated without +// throwing), so the keyword is lost on the next reload. toggleStar already +// threaded accountId through; updateEmailKeywords/setKeyword did not. + +function createClient(): JMAPClient { + const client = new JMAPClient('https://jmap.example.com', 'user@example.com', 'pass'); + Object.assign(client, { + apiUrl: 'https://jmap.example.com/api', + accountId: 'primary-account', + username: 'user@example.com', + }); + return client; +} + +interface JMAPMethodCall { + 0: string; + 1: Record; + 2: string; +} + +function mockEmailSet() { + const captured: JMAPMethodCall[] = []; + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + fetchSpy.mockImplementation(async (_url, init) => { + const body = JSON.parse((init as { body: string }).body) as { methodCalls: JMAPMethodCall[] }; + captured.push(...body.methodCalls); + return new Response(JSON.stringify({ methodResponses: [['Email/set', { updated: {} }, '0']] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }); + return { captured, fetchSpy }; +} + +describe('JMAP keyword writes route to the email account (#281)', () => { + beforeEach(() => vi.restoreAllMocks()); + afterEach(() => vi.restoreAllMocks()); + + it('updateEmailKeywords sends the explicit accountId', async () => { + const client = createClient(); + const { captured } = mockEmailSet(); + await client.updateEmailKeywords('email-x', { '$label:work': true }, 'shared-account'); + expect(captured[0][0]).toBe('Email/set'); + expect(captured[0][1].accountId).toBe('shared-account'); + }); + + it('updateEmailKeywords falls back to the primary account when none is given', async () => { + const client = createClient(); + const { captured } = mockEmailSet(); + await client.updateEmailKeywords('email-x', { '$label:work': true }); + expect(captured[0][1].accountId).toBe('primary-account'); + }); + + it('setKeyword sends the explicit accountId', async () => { + const client = createClient(); + const { captured } = mockEmailSet(); + await client.setKeyword('email-x', '$answered', 'shared-account'); + expect(captured[0][0]).toBe('Email/set'); + expect(captured[0][1].accountId).toBe('shared-account'); + }); + + it('setKeyword falls back to the primary account when none is given', async () => { + const client = createClient(); + const { captured } = mockEmailSet(); + await client.setKeyword('email-x', '$answered'); + expect(captured[0][1].accountId).toBe('primary-account'); + }); +}); diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index 1cbbe0dc..5b332cfa 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -94,8 +94,8 @@ export interface IJMAPClient { markAsRead(emailId: string, read?: boolean, accountId?: string): Promise; batchMarkAsRead(emailIds: string[], read?: boolean, accountId?: string): Promise; toggleStar(emailId: string, starred: boolean, accountId?: string): Promise; - updateEmailKeywords(emailId: string, keywords: Record): Promise; - setKeyword(emailId: string, keyword: string): Promise; + updateEmailKeywords(emailId: string, keywords: Record, accountId?: string): Promise; + setKeyword(emailId: string, keyword: string, accountId?: string): Promise; migrateKeyword(oldKeyword: string, newKeyword: string): Promise; deleteEmail(emailId: string, accountId?: string): Promise; moveToTrash(emailId: string, trashMailboxId: string, accountId?: string, markAsRead?: boolean): Promise; diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 184c5883..7c04ec45 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -1301,10 +1301,10 @@ export class JMAPClient implements IJMAPClient { ]); } - async updateEmailKeywords(emailId: string, keywords: Record): Promise { + async updateEmailKeywords(emailId: string, keywords: Record, accountId?: string): Promise { await this.request([ ["Email/set", { - accountId: this.accountId, + accountId: accountId || this.accountId, update: { [emailId]: { keywords, @@ -1314,10 +1314,10 @@ export class JMAPClient implements IJMAPClient { ]); } - async setKeyword(emailId: string, keyword: string): Promise { + async setKeyword(emailId: string, keyword: string, accountId?: string): Promise { await this.request([ ["Email/set", { - accountId: this.accountId, + accountId: accountId || this.accountId, update: { [emailId]: { [`keywords/${keyword}`]: true, From 14c2807f0ad6f8c49cd309bed4090bd9aba0b77a Mon Sep 17 00:00:00 2001 From: honzup <5564623+honzup@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:16:59 +0200 Subject: [PATCH 013/135] feat: add user-selectable regional date format --- components/settings/language-settings.tsx | 21 ++++++-- lib/utils.ts | 65 ++++++++++++++++++----- locales/cs/common.json | 8 +++ locales/da/common.json | 8 +++ locales/de/common.json | 8 +++ locales/en/common.json | 8 +++ locales/es/common.json | 8 +++ locales/fa/common.json | 8 +++ locales/fr/common.json | 8 +++ locales/hu/common.json | 8 +++ locales/it/common.json | 8 +++ locales/ja/common.json | 8 +++ locales/ko/common.json | 8 +++ locales/lv/common.json | 8 +++ locales/nl/common.json | 8 +++ locales/pl/common.json | 8 +++ locales/pt/common.json | 8 +++ locales/ro/common.json | 8 +++ locales/ru/common.json | 8 +++ locales/tr/common.json | 8 +++ locales/uk/common.json | 8 +++ locales/zh/common.json | 8 +++ stores/settings-store.ts | 11 ++++ 23 files changed, 241 insertions(+), 16 deletions(-) diff --git a/components/settings/language-settings.tsx b/components/settings/language-settings.tsx index 1a5cdf9d..d0faacaf 100644 --- a/components/settings/language-settings.tsx +++ b/components/settings/language-settings.tsx @@ -5,7 +5,7 @@ import { useTranslations } from 'next-intl'; import { LanguageSwitcher } from '@/components/ui/language-switcher'; import { useLocaleStore } from '@/stores/locale-store'; import { useSettingsStore } from '@/stores/settings-store'; -import type { DateFormat, TimeFormat, FirstDayOfWeek } from '@/stores/settings-store'; +import type { DateFormat, DateLocale, TimeFormat, FirstDayOfWeek } from '@/stores/settings-store'; import { formatDate } from '@/lib/utils'; import { SettingsSection, SettingItem, Select, RadioGroup } from './settings-section'; @@ -13,7 +13,7 @@ export function LanguageSettings() { const t = useTranslations('settings.language_region'); const tDays = useTranslations('calendar.days'); - const { dateFormat, timeFormat, firstDayOfWeek, updateSetting } = useSettingsStore(); + const { dateFormat, dateLocale, timeFormat, firstDayOfWeek, updateSetting } = useSettingsStore(); // Subscribe to locale changes so the preview re-renders on language switch // (formatDate reads it via getState() and would otherwise stay stale). @@ -23,7 +23,7 @@ export function LanguageSettings() { // Build sample timestamps for each bucket so users see what their pick // will look like in practice. Use offsets relative to "now" so the // bucketing is stable even though the wall-clock keeps moving. - void locale; void dateFormat; void timeFormat; + void locale; void dateFormat; void dateLocale; void timeFormat; const now = new Date(); const today = new Date(now); today.setHours(15, 31, 0, 0); @@ -38,7 +38,7 @@ export function LanguageSettings() { thisWeek: formatDate(thisWeek), older: formatDate(older), }; - }, [locale, dateFormat, timeFormat]); + }, [locale, dateFormat, dateLocale, timeFormat]); return ( @@ -74,6 +74,19 @@ export function LanguageSettings() { + + setSearchQuery(e.target.value)} - className={cn("pl-9 h-9", searchQuery && "pr-8")} + className={cn("ps-9 h-9", searchQuery && "pe-8")} data-search-input data-tour="search-input" disabled={isUnifiedView || isScheduledView} @@ -2840,7 +2840,7 @@ export default function Home() { @@ -3122,7 +3122,7 @@ export default function Home() { }} className={cn( "absolute z-40 rounded-full shadow-lg", - isMobile ? "bottom-4 right-4 h-14 w-14" : "bottom-4 right-4 h-12 w-12" + isMobile ? "bottom-4 end-4 h-14 w-14" : "bottom-4 end-4 h-12 w-12" )} aria-label={t('sidebar.compose')} title={t('sidebar.compose_hint')} @@ -3250,13 +3250,13 @@ export default function Home() { setShowComposer(true); if (isMobile) setActiveView('viewer'); }} - className="flex items-center gap-3 px-4 py-2.5 bg-primary/10 border-b border-primary/20 hover:bg-primary/15 transition-colors cursor-pointer w-full text-left" + className="flex items-center gap-3 px-4 py-2.5 bg-primary/10 border-b border-primary/20 hover:bg-primary/15 transition-colors cursor-pointer w-full text-start" >
{t('email_composer.continue_draft')} {pendingDraft.subject && ( - {pendingDraft.subject} + {pendingDraft.subject} )}
{t('scoped.back')} - + {t('scoped.managing', { name: managedAccount.name })} @@ -820,7 +820,7 @@ export default function SettingsPage() { value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} placeholder={t('search_placeholder')} - className="pl-9 pr-9 h-10" + className="ps-9 pe-9 h-10" aria-label={t('search_placeholder')} /> {searchQuery && ( @@ -868,7 +868,7 @@ export default function SettingsPage() { @@ -932,7 +932,7 @@ export default function SettingsPage() { <>
router.push('/')} className="w-full justify-start" > - + {t('back_to_mail')}
@@ -960,7 +960,7 @@ export default function SettingsPage() { value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} placeholder={t('search_placeholder')} - className="pl-8 pr-8 h-9 text-sm" + className="ps-8 pe-8 h-9 text-sm" aria-label={t('search_placeholder')} /> {searchQuery && ( @@ -997,7 +997,7 @@ export default function SettingsPage() { diff --git a/app/(main)/admin/_tabs/_jmap-servers-section.tsx b/app/(main)/admin/_tabs/_jmap-servers-section.tsx index 638e9fee..ef4e00ac 100644 --- a/app/(main)/admin/_tabs/_jmap-servers-section.tsx +++ b/app/(main)/admin/_tabs/_jmap-servers-section.tsx @@ -220,7 +220,7 @@ export function JmapServersSection({ value, source, onChange, onRevert }: Props) Per-server OAuth (optional, overrides global) {d.oauthExpanded && ( -
+
- Time - Action - Details - IP + Time + Action + Details + IP diff --git a/app/(main)/admin/_tabs/marketplace.tsx b/app/(main)/admin/_tabs/marketplace.tsx index 64fda763..6bf82c38 100644 --- a/app/(main)/admin/_tabs/marketplace.tsx +++ b/app/(main)/admin/_tabs/marketplace.tsx @@ -171,7 +171,7 @@ export function MarketplaceTab() { placeholder="Search extensions..." value={searchInput} onChange={(e) => setSearchInput(e.target.value)} - className="w-full h-9 pl-9 pr-3 rounded-md border border-input bg-background text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring/20 focus:border-ring" + className="w-full h-9 ps-9 pe-3 rounded-md border border-input bg-background text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring/20 focus:border-ring" />
@@ -210,7 +210,7 @@ export function MarketplaceTab() { {loading && !error && (
- Searching extensions... + Searching extensions...
)} diff --git a/app/(main)/admin/_tabs/plugin-config-panel.tsx b/app/(main)/admin/_tabs/plugin-config-panel.tsx index 70e83cb7..2cc5fe7f 100644 --- a/app/(main)/admin/_tabs/plugin-config-panel.tsx +++ b/app/(main)/admin/_tabs/plugin-config-panel.tsx @@ -155,7 +155,7 @@ export function PluginConfigPanel({ pluginId, onBack }: Props) { if (loading) { return (
- + Loading...
); @@ -217,7 +217,7 @@ export function PluginConfigPanel({ pluginId, onBack }: Props) {
{field.description && (

{field.description}

@@ -250,7 +250,7 @@ export function PluginConfigPanel({ pluginId, onBack }: Props) { value={formValues[key] ?? ''} onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))} placeholder={config[key] ? '•••••••• (unchanged)' : (field.placeholder || '')} - className="w-full h-9 px-3 pr-10 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring font-mono" + className="w-full h-9 px-3 pe-10 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring font-mono" />
{logoUrl ? ( - + ) : ( - + )} Admin Panel
diff --git a/app/(main)/admin/marketplace/[slug]/page.tsx b/app/(main)/admin/marketplace/[slug]/page.tsx index e8095b05..d601e66c 100644 --- a/app/(main)/admin/marketplace/[slug]/page.tsx +++ b/app/(main)/admin/marketplace/[slug]/page.tsx @@ -186,7 +186,7 @@ export default function MarketplacePreviewPage() { if (loading) { return (
- + Loading...
); @@ -512,7 +512,7 @@ export default function MarketplacePreviewPage() {
diff --git a/app/(main)/layout.tsx b/app/(main)/layout.tsx index 65d4bba1..04a86963 100644 --- a/app/(main)/layout.tsx +++ b/app/(main)/layout.tsx @@ -1,4 +1,5 @@ import type { Metadata, Viewport } from "next"; +import { getLocaleDirection } from "@/i18n/direction"; import { Geist, Geist_Mono } from "next/font/google"; import { headers } from "next/headers"; import { getLocale, getTranslations } from "next-intl/server"; @@ -76,7 +77,7 @@ export default async function RootLayout({ const parentOrigin = process.env.NEXT_PUBLIC_PARENT_ORIGIN || ""; return ( - + diff --git a/app/(main)/setup/page.tsx b/app/(main)/setup/page.tsx index 6f3fd608..299715c5 100644 --- a/app/(main)/setup/page.tsx +++ b/app/(main)/setup/page.tsx @@ -780,7 +780,7 @@ function ServerStep({ config, setConfig, onNext }: Pick
-
{showUrlField && ( -
+
{uploadError}

+

{uploadError}

)}
); @@ -1601,7 +1601,7 @@ function SummaryRow({ label, value, mono }: { label: string; value: string; mono return (
{label} - + {value || -}
diff --git a/app/globals.css b/app/globals.css index ce708b75..545a415d 100644 --- a/app/globals.css +++ b/app/globals.css @@ -660,13 +660,13 @@ body { .tiptap ul { list-style-type: disc; - padding-left: 1.5rem; + padding-inline-start: 1.5rem; margin: 0.25rem 0; } .tiptap ol { list-style-type: decimal; - padding-left: 1.5rem; + padding-inline-start: 1.5rem; margin: 0.25rem 0; } @@ -675,8 +675,8 @@ body { } .tiptap blockquote { - border-left: 3px solid var(--color-border); - padding-left: 1rem; + border-inline-start: 3px solid var(--color-border); + padding-inline-start: 1rem; margin: 0.5rem 0; color: var(--color-muted-foreground); } @@ -719,7 +719,7 @@ body { .tiptap p.is-editor-empty:first-child::before { content: attr(data-placeholder); - float: left; + float: inline-start; color: var(--color-muted-foreground); pointer-events: none; height: 0; @@ -808,3 +808,20 @@ body { border-radius: 8px; animation: settings-search-pulse 1.6s ease-in-out forwards; } + +/* RTL: mirror directional icons (chevrons/arrows) so prev/next, back/forward, + and panel-collapse affordances point the correct way in right-to-left layouts. + lucide-react emits a `lucide-` 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); +} diff --git a/components/calendar/calendar-agenda-view.tsx b/components/calendar/calendar-agenda-view.tsx index a31510e8..d757305c 100644 --- a/components/calendar/calendar-agenda-view.tsx +++ b/components/calendar/calendar-agenda-view.tsx @@ -131,7 +131,7 @@ export function CalendarAgendaView({ )}> {formatDateHeader(group.date)} - + {intlFormatter.dateTime(group.date, { month: "short", day: "numeric", year: "numeric" })}
@@ -163,7 +163,7 @@ export function CalendarAgendaView({ onMouseLeave={() => onHoverLeave?.()} onContextMenu={onContextMenuEvent ? (e) => onContextMenuEvent(e, ev) : undefined} className={cn( - "w-full flex items-start px-4 hover:bg-muted/50 transition-colors text-left", + "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)' }} diff --git a/components/calendar/calendar-day-view.tsx b/components/calendar/calendar-day-view.tsx index b06528fc..943480b3 100644 --- a/components/calendar/calendar-day-view.tsx +++ b/components/calendar/calendar-day-view.tsx @@ -218,7 +218,7 @@ export function CalendarDayView({ {HOURS.map((h) => (
{h > 0 && ( @@ -231,7 +231,7 @@ export function CalendarDayView({
handleGridPointerDown(e, dayKey, selectedDate)} @@ -312,7 +312,7 @@ export function CalendarDayView({ style={{ top: (nowMinutes / 60) * HOUR_HEIGHT }} >
-
+
@@ -346,7 +346,7 @@ export function CalendarDayView({ style={{ top: (dropTarget.minutes / 60) * HOUR_HEIGHT }} >
-
+
diff --git a/components/calendar/calendar-month-view.tsx b/components/calendar/calendar-month-view.tsx index c6b26e49..0b230512 100644 --- a/components/calendar/calendar-month-view.tsx +++ b/components/calendar/calendar-month-view.tsx @@ -145,7 +145,7 @@ export function CalendarMonthView({
{dayHeaders.map((d) => (
{isMobile ? t(`days.${d}`).slice(0, 2) : t(`days.${d}`)} @@ -181,7 +181,7 @@ export function CalendarMonthView({ onDragLeave={handleCellDragLeave} onDrop={(e) => handleCellDrop(e, day)} className={cn( - "border-r border-border last:border-r-0 p-1 cursor-pointer transition-colors touch-manipulation", + "border-e border-border last:border-e-0 p-1 cursor-pointer transition-colors touch-manipulation", !inMonth && "bg-muted/30", "hover:bg-muted/50", selected && isMobile && "bg-primary/10", diff --git a/components/calendar/calendar-sidebar-panel.tsx b/components/calendar/calendar-sidebar-panel.tsx index 2fc8dc11..38dc2dcc 100644 --- a/components/calendar/calendar-sidebar-panel.tsx +++ b/components/calendar/calendar-sidebar-panel.tsx @@ -397,7 +397,7 @@ export function CalendarSidebarPanel({ {t('tasks.label')} {pendingTaskCount > 0 && ( - {pendingTaskCount} + {pendingTaskCount} )} {overdueTaskCount > 0 && ( {overdueTaskCount} {t('tasks.filter_overdue').toLowerCase()} @@ -437,7 +437,7 @@ export function CalendarSidebarPanel({ onCreateCalendar(); } }} - className="ml-auto p-0.5 rounded text-muted-foreground/70 opacity-0 group-hover:opacity-100 hover:text-foreground hover:bg-muted transition-colors cursor-pointer" + className="ms-auto p-0.5 rounded text-muted-foreground/70 opacity-0 group-hover:opacity-100 hover:text-foreground hover:bg-muted transition-colors cursor-pointer" title={tMgmt('add_calendar')} > @@ -445,7 +445,7 @@ export function CalendarSidebarPanel({ )} {expanded && ( -
+
{owned.length > 0 && (
diff --git a/components/calendar/calendar-toolbar.tsx b/components/calendar/calendar-toolbar.tsx index c642bd79..ec9480dd 100644 --- a/components/calendar/calendar-toolbar.tsx +++ b/components/calendar/calendar-toolbar.tsx @@ -124,7 +124,7 @@ export function CalendarToolbar({ variant="ghost" size="icon" onClick={onMenuClick} - className="h-8 w-8 -ml-1 mr-1" + className="h-8 w-8 -ms-1 me-1" aria-label={t("nav_open_menu")} > @@ -138,7 +138,7 @@ export function CalendarToolbar({ {onMenuClick && ( -
@@ -284,7 +284,7 @@ export function CalendarToolbar({ {/* ── DESKTOP TOOLBAR ── */} {!isMobile && (
- - + {getDateLabel()}
@@ -328,9 +328,9 @@ export function CalendarToolbar({ {(onImport || onSubscribe) && !isMobile && (
{showImportDropdown && (
@@ -359,7 +359,7 @@ export function CalendarToolbar({ {!isMobile && ( )} diff --git a/components/calendar/calendar-week-view.tsx b/components/calendar/calendar-week-view.tsx index 16e822aa..4d4a4052 100644 --- a/components/calendar/calendar-week-view.tsx +++ b/components/calendar/calendar-week-view.tsx @@ -211,7 +211,7 @@ export function CalendarWeekView({
{hasAllDay && (
{t("events.all_day")} @@ -306,7 +306,7 @@ export function CalendarWeekView({
-
+
{weekDays.map((day) => { const todayCol = isToday(day); const selected = isSameDay(day, selectedDate); @@ -318,7 +318,7 @@ export function CalendarWeekView({ role="columnheader" aria-label={fullLabel} className={cn( - "text-center py-2 text-sm border-r border-border last:border-r-0 transition-colors touch-manipulation", + "text-center py-2 text-sm border-e border-border last:border-e-0 transition-colors touch-manipulation", "hover:bg-muted/50", todayCol && "font-bold", )} @@ -345,7 +345,7 @@ export function CalendarWeekView({ {HOURS.map((h) => (
{h > 0 && ( @@ -357,7 +357,7 @@ export function CalendarWeekView({ ))}
-
+
{weekDays.map((day) => { const key = format(day, "yyyy-MM-dd"); const dayEvents = timedEvents.get(key) || []; @@ -367,7 +367,7 @@ export function CalendarWeekView({ return (
handleGridPointerDown(e, key, day)} @@ -448,7 +448,7 @@ export function CalendarWeekView({ style={{ top: (nowMinutes / 60) * HOUR_HEIGHT }} >
-
+
@@ -482,7 +482,7 @@ export function CalendarWeekView({ style={{ top: (dropTarget.minutes / 60) * HOUR_HEIGHT }} >
-
+
diff --git a/components/calendar/create-calendar-modal.tsx b/components/calendar/create-calendar-modal.tsx index e5b675e7..656dc8d9 100644 --- a/components/calendar/create-calendar-modal.tsx +++ b/components/calendar/create-calendar-modal.tsx @@ -137,7 +137,7 @@ export function CreateCalendarModal({ client, onClose }: CreateCalendarModalProp
{calendar && ( -

+

{calendar.name} {event.status === "tentative" && ( - + {t("detail.tentative")} )} {event.status === "cancelled" && ( - + {t("detail.cancelled")} )} @@ -346,13 +346,13 @@ export function EventDetailPopover({ <>

{formatEventDate(startDate)} - + {formatTime(startDate)}
{formatEventDate(endDate)} - + {formatTime(endDate)}
@@ -367,11 +367,11 @@ export function EventDetailPopover({ {formatEventDate(startDate)} {event.showWithoutTime ? ( - {t("events.all_day")} + {t("events.all_day")} ) : (
{formatTime(startDate)} – {formatTime(endDate)} - ({formatDurationDisplay(durationMinutes)}) + ({formatDurationDisplay(durationMinutes)})
)} @@ -437,7 +437,7 @@ export function EventDetailPopover({ {p.name || p.email} {p.isOrganizer && ( - + ({t("participants.organizer").toLowerCase()}) )} @@ -515,7 +515,7 @@ export function EventDetailPopover({ disabled={!noteText.trim() || isSavingNote} className="h-7 text-xs" > - + {t("detail.save_note")}
@@ -549,7 +549,7 @@ export function EventDetailPopover({ : "text-success border-success/30 hover:bg-success/10" } > - {userCurrentStatus === "accepted" && } + {userCurrentStatus === "accepted" && } {t("participants.accepted")}
@@ -609,7 +609,7 @@ export function EventDetailPopover({ ) : ( <>
diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index d6a16c02..cd65425d 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -666,11 +666,11 @@ export function EventModal({
{formatEventDate(startD)} - {format(startD, timeDisplayFmt)} + {format(startD, timeDisplayFmt)}
{formatEventDate(endD)} - {format(endD, timeDisplayFmt)} + {format(endD, timeDisplayFmt)}
); @@ -679,7 +679,7 @@ export function EventModal({
{formatEventDate(startD)} {!event.showWithoutTime && ( - + {format(startD, timeDisplayFmt)} – {format(endD, timeDisplayFmt)} )} @@ -701,7 +701,7 @@ export function EventModal({ {t("participants.title")}
-
+
{participants.map(p => (
{p.name || p.email} @@ -726,7 +726,7 @@ export function EventModal({ ? "bg-success hover:bg-success/80 text-success-foreground" : "text-success border-success/30 hover:bg-success/10"} > - {userCurrentStatus === "accepted" && } + {userCurrentStatus === "accepted" && } {t("participants.accepted")}
@@ -784,7 +784,7 @@ export function EventModal({

{event.title || t("events.no_title")}

{eventCalendar && ( -

{eventCalendar.name}

+

{eventCalendar.name}

)}
) : ( ) )} {onDuplicate && !showDeleteConfirm && ( )}
{!showDeleteConfirm && ( )} @@ -1320,7 +1320,7 @@ export function EventModal({ onClick={() => setShowDeleteConfirm(true)} className="text-red-600 dark:text-red-400" > - + {t("events.delete")} ) @@ -1332,7 +1332,7 @@ export function EventModal({ onClick={handleDuplicate} aria-label={t("events.duplicate")} > - + {t("events.duplicate")} )} diff --git a/components/calendar/ical-import-modal.tsx b/components/calendar/ical-import-modal.tsx index 36215829..b08f0913 100644 --- a/components/calendar/ical-import-modal.tsx +++ b/components/calendar/ical-import-modal.tsx @@ -444,7 +444,7 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal onClick={handleImport} disabled={selectedIndices.size === 0} > - + {t("import_button")} ({selectedIndices.size}) )} diff --git a/components/calendar/ical-subscription-modal.tsx b/components/calendar/ical-subscription-modal.tsx index 7583ad12..05001fa2 100644 --- a/components/calendar/ical-subscription-modal.tsx +++ b/components/calendar/ical-subscription-modal.tsx @@ -208,7 +208,7 @@ export function ICalSubscriptionModal({ client, onClose, editSubscription, initi )} diff --git a/components/calendar/task-toolbar.tsx b/components/calendar/task-toolbar.tsx index 5111f69a..cdda536e 100644 --- a/components/calendar/task-toolbar.tsx +++ b/components/calendar/task-toolbar.tsx @@ -44,7 +44,7 @@ export function TaskToolbar({ ))}
-
diff --git a/components/contacts/contact-activity.tsx b/components/contacts/contact-activity.tsx index 86b50b98..d5d23e16 100644 --- a/components/contacts/contact-activity.tsx +++ b/components/contacts/contact-activity.tsx @@ -229,7 +229,7 @@ export function ContactActivity({ contact }: ContactActivityProps) { key={email.id} type="button" onClick={() => handleOpenEmail(email)} - className="w-full text-left flex items-start gap-3 px-2 py-2 rounded-md hover:bg-muted/60 transition-colors touch-manipulation" + className="w-full text-start flex items-start gap-3 px-2 py-2 rounded-md hover:bg-muted/60 transition-colors touch-manipulation" >
@@ -277,7 +277,7 @@ export function ContactActivity({ contact }: ContactActivityProps) { key={event.id} type="button" onClick={() => handleOpenEvent(event)} - className="w-full text-left flex items-baseline gap-3 px-2 py-2 rounded-md hover:bg-muted/60 transition-colors touch-manipulation" + className="w-full text-start flex items-baseline gap-3 px-2 py-2 rounded-md hover:bg-muted/60 transition-colors touch-manipulation" > {formatEventTime(event)} diff --git a/components/contacts/contact-detail.tsx b/components/contacts/contact-detail.tsx index 24292071..9cf0d4d5 100644 --- a/components/contacts/contact-detail.tsx +++ b/components/contacts/contact-detail.tsx @@ -201,7 +201,7 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli onClick={onCompose} className="touch-manipulation" > - + {t("detail.compose_email")} )} @@ -210,12 +210,12 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli href={`tel:${phone}`} className="inline-flex items-center justify-center rounded-md font-medium h-9 px-3 text-sm border border-input bg-background hover:bg-accent hover:text-accent-foreground transition-colors touch-manipulation" > - + {t("context_menu.call")} )} @@ -587,7 +587,7 @@ function MoreActionsMenu({ items, label }: { items: MoreItem[]; label: string }) setOpen(false); }} className={cn( - "w-full flex items-center gap-2 px-3 py-1.5 text-sm text-left hover:bg-muted focus:bg-muted focus:outline-none transition-colors", + "w-full flex items-center gap-2 px-3 py-1.5 text-sm text-start hover:bg-muted focus:bg-muted focus:outline-none transition-colors", item.destructive && "text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950 focus:bg-red-50 dark:focus:bg-red-950", )} > diff --git a/components/contacts/contact-form.tsx b/components/contacts/contact-form.tsx index 697e9cc0..a2e898eb 100644 --- a/components/contacts/contact-form.tsx +++ b/components/contacts/contact-form.tsx @@ -72,7 +72,7 @@ function FormSection({ icon: Icon, title, children, collapsible, defaultOpen = t
{emailErrors[i] && ( -

{emailErrors[i]}

+

{emailErrors[i]}

)}
))}
@@ -799,7 +799,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
))}
@@ -855,7 +855,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
))}
@@ -884,7 +884,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
))}
@@ -916,7 +916,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
))}
@@ -957,7 +957,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
))}
@@ -1145,7 +1145,7 @@ function CategoryComboBox({
@@ -134,7 +134,7 @@ export function ContactGroupForm({ type="button" onClick={() => toggleMember(contact.id)} className={cn( - "w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors", + "w-full flex items-center gap-3 px-3 py-2.5 text-start transition-colors", "hover:bg-muted", isSelected && "bg-primary/5" )} diff --git a/components/contacts/contact-group-list.tsx b/components/contacts/contact-group-list.tsx index 0c3127b1..e5193e9d 100644 --- a/components/contacts/contact-group-list.tsx +++ b/components/contacts/contact-group-list.tsx @@ -45,7 +45,7 @@ export function ContactGroupList({
@@ -69,7 +69,7 @@ export function ContactGroupList({ key={group.id} onClick={() => onSelectGroup(group.id)} className={cn( - "w-full flex items-center px-4 text-left transition-colors", + "w-full flex items-center px-4 text-start transition-colors", "hover:bg-muted", group.id === selectedGroupId && "bg-accent text-accent-foreground" )} diff --git a/components/contacts/contact-import-dialog.tsx b/components/contacts/contact-import-dialog.tsx index d62e6dcf..8e8331a1 100644 --- a/components/contacts/contact-import-dialog.tsx +++ b/components/contacts/contact-import-dialog.tsx @@ -187,7 +187,7 @@ export function ContactImportDialog({ type="button" onClick={() => toggleSelect(idx)} className={cn( - "w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors hover:bg-muted", + "w-full flex items-center gap-3 px-3 py-2.5 text-start transition-colors hover:bg-muted", isSelected && "bg-primary/5" )} > diff --git a/components/contacts/contact-list.tsx b/components/contacts/contact-list.tsx index 04eac01b..0d9f23c7 100644 --- a/components/contacts/contact-list.tsx +++ b/components/contacts/contact-list.tsx @@ -310,7 +310,7 @@ export function ContactList({ placeholder={t("search_placeholder")} value={searchQuery} onChange={(e) => onSearchChange(e.target.value)} - className={cn("pl-9 h-9", searchQuery && "pr-8")} + className={cn("ps-9 h-9", searchQuery && "pe-8")} /> {searchQuery && ( )} @@ -538,7 +538,7 @@ export function ContactList({

{t("empty_state_title")}

{t("empty_state_subtitle")}

diff --git a/components/contacts/contacts-sidebar.tsx b/components/contacts/contacts-sidebar.tsx index 1fd36bb8..80fc4b40 100644 --- a/components/contacts/contacts-sidebar.tsx +++ b/components/contacts/contacts-sidebar.tsx @@ -287,14 +287,14 @@ export function ContactsSidebar({ className="absolute right-0 top-full mt-1 w-44 rounded-md border border-border bg-background text-foreground shadow-md z-50 py-1" > {onCreateAddressBook && ( @@ -355,7 +355,7 @@ export function ContactsSidebar({
{expanded && ( -
+
{owned.length > 0 && (
@@ -418,7 +418,7 @@ export function ContactsSidebar({
@@ -505,7 +505,7 @@ export function ContactsSidebar({
@@ -561,7 +561,7 @@ export function ContactsSidebar({
@@ -847,7 +847,7 @@ function AddressBookItem({ onDragLeave={handleDragLeave} onDrop={handleDrop} className={cn( - "w-full flex items-center gap-2 pl-5 pr-3 text-sm transition-colors", + "w-full flex items-center gap-2 ps-5 pe-3 text-sm transition-colors", isActive ? "bg-accent text-accent-foreground font-medium" : "text-foreground/80 hover:bg-muted", @@ -858,11 +858,11 @@ function AddressBookItem({ {book.name} {!book.isShared && Object.keys(book.shareWith || {}).length > 0 && ( - + )} 0) && "ml-auto" + !(!book.isShared && Object.keys(book.shareWith || {}).length > 0) && "ms-auto" )}> {contactCount} diff --git a/components/email/calendar-invitation-banner.tsx b/components/email/calendar-invitation-banner.tsx index 64359948..e3293e2c 100644 --- a/components/email/calendar-invitation-banner.tsx +++ b/components/email/calendar-invitation-banner.tsx @@ -1053,7 +1053,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp setShowCalendarPicker(false); handleImport(cal.id); }} - className="w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2" + className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted flex items-center gap-2" > + )}
)} diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index c2b231f1..5a62c335 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -1990,7 +1990,7 @@ export function EmailComposer({
{/* Right-side composer sidebar slot is rendered after the main content div below. */}
- + {t('send')}
@@ -2445,7 +2445,7 @@ export function EmailComposer({ )} )} @@ -2699,7 +2699,7 @@ export function EmailComposer({ {t('discard')}
@@ -2718,7 +2718,7 @@ export function EmailComposer({
); @@ -2743,7 +2743,7 @@ const AutocompleteDropdown = React.forwardRef { diff --git a/components/email/email-context-menu.tsx b/components/email/email-context-menu.tsx index 9a66f9b6..f8229ca1 100644 --- a/components/email/email-context-menu.tsx +++ b/components/email/email-context-menu.tsx @@ -334,7 +334,7 @@ export function EmailContextMenu({
)} {node.children.length > 0 && ( -
+
{renderNodes(node.children)}
)} @@ -377,7 +377,7 @@ export function EmailContextMenu({ role="menuitem" onClick={() => handleAction(() => onSetColorTag?.(option.value))} className={cn( - "w-full px-3 py-1.5 text-sm text-left flex items-center gap-2 hover:bg-muted cursor-pointer", + "w-full px-3 py-1.5 text-sm text-start flex items-center gap-2 hover:bg-muted cursor-pointer", isActive && "bg-accent font-medium" )} > diff --git a/components/email/email-hover-actions.tsx b/components/email/email-hover-actions.tsx index 7bb4a734..a2691da7 100644 --- a/components/email/email-hover-actions.tsx +++ b/components/email/email-hover-actions.tsx @@ -181,16 +181,17 @@ export function EmailHoverActions({ return (
-
+
{actionButtons}
diff --git a/components/email/email-list-item.tsx b/components/email/email-list-item.tsx index 31e4d8e9..44c7e915 100644 --- a/components/email/email-list-item.tsx +++ b/components/email/email-list-item.tsx @@ -176,7 +176,7 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte {/* Unread indicator */} {isUnread && ( -
+
)} diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index cab95c82..3df7753d 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -417,9 +417,9 @@ export function EmailList({ className="text-destructive border-destructive/30 hover:bg-destructive/10 text-xs" > {isProcessing ? ( - + ) : ( - + )} {t('empty_folder.button')} diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 4f078c5b..7055e928 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -333,7 +333,7 @@ function renderClickableRecipients( return ( - {index > 0 && ,} + {index > 0 && ,} +
{/* Header */}

{t('contact_sidebar.title')}

@@ -607,7 +607,7 @@ function SidebarSection({ icon: Icon, title, children }: { icon: React.Component

{title}

-
{children}
+
{children}
); } @@ -2637,7 +2637,7 @@ export function EmailViewer({

{tDemoWelcome('title')}

{tDemoWelcome('description')}

-
+
{tDemoWelcome('feature_email')} @@ -2679,7 +2679,7 @@ export function EmailViewer({

{t('no_conversation_description')}

{onCompose && ( )} @@ -2704,7 +2704,7 @@ export function EmailViewer({ variant="ghost" size="icon" onClick={onBack} - className="h-9 w-9 flex-shrink-0 -ml-1" + className="h-9 w-9 flex-shrink-0 -ms-1" aria-label={t('back_to_list')} > @@ -2839,7 +2839,7 @@ export function EmailViewer({ {showToolbarLabels && {t('move')}} {moveMenuOpen && ( -
+
{(() => { const renderNodes = (nodes: MailboxNode[], depth = 0) => { return nodes.map((node) => { @@ -2850,7 +2850,7 @@ export function EmailViewer({ {isTarget ? ( {tagMenuOpen && ( -
+
{colorOptions.map((option) => { const isActive = currentColors.includes(option.value); return ( @@ -2918,13 +2918,13 @@ export function EmailViewer({ key={option.value} onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setTagMenuOpen(false); }} className={cn( - "w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2", + "w-full px-3 py-1.5 text-sm text-start hover:bg-muted flex items-center gap-2", isActive && "bg-accent font-medium" )} > {option.name} - {isActive && } + {isActive && } ); })} @@ -2933,7 +2933,7 @@ export function EmailViewer({
{moreMenuOpen && !isMobile && ( -
+
{/* Star toggle */} {moreMenuSub === 'move' && ( -
+
{(() => { const renderMobileNodes = (nodes: MailboxNode[], depth = 0) => { return nodes.map((node) => { @@ -3104,7 +3104,7 @@ export function EmailViewer({ {isTarget ? ( {moreMenuSub === 'tag' && ( -
+
{colorOptions.map((option) => { const isActive = currentColors.includes(option.value); return ( @@ -3153,13 +3153,13 @@ export function EmailViewer({ key={option.value} onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); setMoreMenuSub(null); }} className={cn( - "w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2", + "w-full px-3 py-1.5 text-sm text-start hover:bg-muted flex items-center gap-2", isActive && "bg-accent font-medium" )} > {option.name} - {isActive && } + {isActive && } ); })} @@ -3168,7 +3168,7 @@ export function EmailViewer({
); })} {currentColors.length > 0 && (
{/* Date/time on the right of subject row - hidden on mobile, shown next to sender */} -
+
{formatDateTime(email.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })} @@ -3569,7 +3569,7 @@ export function EmailViewer({ name={sender?.name} email={sender.email} onViewContact={handleViewContactSidebar} - className="font-semibold text-left" + className="font-semibold text-start" /> ) : ( {t('unknown_sender')} @@ -3633,7 +3633,7 @@ export function EmailViewer({ )}
{/* Date/time + size on the right (mobile) */} -
+
{formatDateTime(email.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })} @@ -4006,7 +4006,7 @@ export function EmailViewer({ email={sender?.email || ''} displayLabel={sender?.name && sender?.email ? `${sender.name} <${sender.email}>` : undefined} onViewContact={handleViewContactSidebar} - className="text-sm text-left" + className="text-sm text-start" />
@@ -4534,7 +4534,7 @@ export function EmailViewer({ {getAttachmentDisplayName(attachment.name, attachment.type)} - + {formatFileSize(attachment.size)}
@@ -4672,7 +4672,7 @@ export function EmailViewer({ {getAttachmentDisplayName(attachment.name, attachment.type)} - + {formatFileSize(attachment.size)}
@@ -4815,7 +4815,7 @@ export function EmailViewer({ disabled={isSendingQuickReply} className="text-muted-foreground" > - + {t('more_options')} @@ -488,13 +488,13 @@ function EmailCard({
{/* Card Header - Always visible */} )} @@ -671,7 +671,7 @@ function EmailCard({ }} className="flex-1" > - + {t("email_viewer.reply_all")} )} @@ -685,7 +685,7 @@ function EmailCard({ }} className="flex-1" > - + {t("email_viewer.forward")} )} diff --git a/components/email/thread-email-item.tsx b/components/email/thread-email-item.tsx index 11cfd637..bd35f4c5 100644 --- a/components/email/thread-email-item.tsx +++ b/components/email/thread-email-item.tsx @@ -86,8 +86,8 @@ export function ThreadEmailItem({ {...longPressHandlers} className={cn( "relative cursor-pointer select-none transition-all duration-150", - "pl-12 pr-4", - "border-l-2 border-l-transparent", + "ps-12 pe-4", + "border-s-2 border-l-transparent", selected ? "bg-selection border-l-primary" : "hover:bg-muted/50", @@ -130,7 +130,7 @@ export function ThreadEmailItem({ {/* Unread indicator */} {isUnread && ( -
+
)} diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx index 27a81714..8e44f2a2 100644 --- a/components/email/thread-list-item.tsx +++ b/components/email/thread-list-item.tsx @@ -218,7 +218,7 @@ const SingleEmailItem = React.forwardRef( )} {isUnread && ( -
+
)} @@ -643,7 +643,7 @@ export const ThreadListItem = React.forwardRef +
)} @@ -896,7 +896,7 @@ export const ThreadListItem = React.forwardRef {isLoading ? (
- + {t('loading')}
) : ( diff --git a/components/email/unsubscribe-banner.tsx b/components/email/unsubscribe-banner.tsx index 1f63a0e4..00736e69 100644 --- a/components/email/unsubscribe-banner.tsx +++ b/components/email/unsubscribe-banner.tsx @@ -96,7 +96,7 @@ export function UnsubscribeBanner({ if (success) { return ( - + {t(unsubMethod === 'http' @@ -110,7 +110,7 @@ export function UnsubscribeBanner({ if (error) { return ( - +
@@ -34,13 +34,13 @@ export function PageErrorFallback({ error: _error, resetError, t }: FallbackProp */ export function SidebarErrorFallback({ resetError, t }: FallbackProps) { return ( -
+

{t("sidebar_error")}

@@ -58,7 +58,7 @@ export function EmailListErrorFallback({ resetError, t }: FallbackProps) { {t("email_list_error")}

@@ -81,7 +81,7 @@ export function EmailViewerErrorFallback({ resetError, t }: FallbackProps) { {t("viewer_error_description")}

@@ -119,7 +119,7 @@ export function SettingsErrorFallback({ resetError, t }: FallbackProps) { {t("settings_error_description")}

diff --git a/components/files/file-browser.tsx b/components/files/file-browser.tsx index b712c158..10fbd330 100644 --- a/components/files/file-browser.tsx +++ b/components/files/file-browser.tsx @@ -821,8 +821,8 @@ export function FileBrowser({ const SortIndicator = ({ column }: { column: SortKey }) => { if (sortKey !== column) return null; return sortDir === "asc" - ? - : ; + ? + : ; }; // Keyboard shortcuts @@ -942,7 +942,7 @@ export function FileBrowser({ @@ -1003,7 +1003,7 @@ export function FileBrowser({ className="h-8" onClick={onPaste} > - + {t("paste")} ({clipboard.names.length}) )} @@ -1157,7 +1157,7 @@ export function FileBrowser({ className="h-7 text-destructive hover:text-destructive shrink-0" onClick={onRefresh} > - + {t("retry")}
@@ -1181,12 +1181,12 @@ export function FileBrowser({ {t("uploading")} {uploadProgress.name} {uploadProgress.totalFiles > 1 && ( - + ({uploadProgress.current}/{uploadProgress.totalFiles}) )} - + {uploadProgress.total > 0 ? `${Math.round((uploadProgress.loaded / uploadProgress.total) * 100)}%` : "…"} @@ -1267,7 +1267,7 @@ export function FileBrowser({ )} {/* Favorites & Recent sidebar (when layout is inline) */} {folderLayout === "inline" && (favorites.length > 0 || recentFiles.length > 0) && ( -
+
{favorites.length > 0 && (

@@ -1280,7 +1280,7 @@ export function FileBrowser({ key={fav} onClick={() => onNavigate(fav)} className={cn( - "w-full flex items-center gap-2 px-2 py-1.5 rounded text-sm hover:bg-muted transition-colors text-left", + "w-full flex items-center gap-2 px-2 py-1.5 rounded text-sm hover:bg-muted transition-colors text-start", currentPath === fav && "bg-muted font-medium" )} > @@ -1301,7 +1301,7 @@ export function FileBrowser({ {recentFiles.slice(0, 10).map((recent) => (