Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa31933922 | ||
|
|
d3f77ef9cf | ||
|
|
5ccba83129 | ||
|
|
752e71198c | ||
|
|
2cb74c0186 | ||
|
|
9ab7339320 | ||
|
|
782974ecdb | ||
|
|
c47137fb49 | ||
|
|
38313639ed | ||
|
|
e933800792 | ||
|
|
60a1cc670c | ||
|
|
c0515001f1 | ||
|
|
e10fced28a | ||
|
|
3d36492518 | ||
|
|
94af4725b6 | ||
|
|
22418c17cf | ||
|
|
8904d724bb | ||
|
|
c7d551f185 | ||
|
|
6470fa86f0 | ||
|
|
8647d709ff | ||
|
|
b73d1b55d1 | ||
|
|
7db6fd7e24 | ||
|
|
e066698938 | ||
|
|
c3a97de62f | ||
|
|
29283282d5 | ||
|
|
db2c642d74 | ||
|
|
9110bc388f | ||
|
|
a4dc0b7b4e | ||
|
|
d384c3b553 | ||
|
|
d7a64fd9d6 | ||
|
|
06ddda688d | ||
|
|
6b74615969 | ||
|
|
0d73cb5dfb | ||
|
|
b1b09d54c8 | ||
|
|
9930d19ba4 | ||
|
|
902774eae1 | ||
|
|
1429a6fe1e | ||
|
|
d1da83a5d6 | ||
|
|
babacca482 | ||
|
|
e6aa79ed94 | ||
|
|
4d6b4b5b8e | ||
|
|
d259168bd7 | ||
|
|
97d87c44d0 | ||
|
|
396f5f7d60 | ||
|
|
9aef8bc393 | ||
|
|
e05ab48a45 | ||
|
|
14c2807f0a | ||
|
|
a099ab442a | ||
|
|
e9ac2de6cb | ||
|
|
c4a7f575c5 | ||
|
|
51c3a69be7 | ||
|
|
717b1d8397 | ||
|
|
f291480565 | ||
|
|
95b5a81924 | ||
|
|
9da27df249 | ||
|
|
b716f95a73 | ||
|
|
6c49427c7c | ||
|
|
2a41e73bf9 | ||
|
|
16daf6ea03 | ||
|
|
2704d5dc23 | ||
|
|
2f5b133000 | ||
|
|
1f21a5213f | ||
|
|
d4c066622b | ||
|
|
e141abc849 | ||
|
|
1f4afe082b | ||
|
|
e0747c12ee | ||
|
|
f90cd6abc4 | ||
|
|
63e087f3ef | ||
|
|
f8b8e0b108 | ||
|
|
512adab7e3 | ||
|
|
4cdc15fc3c | ||
|
|
5e67671f57 | ||
|
|
155d99a069 | ||
|
|
d863b1fd4b | ||
|
|
70aaf0aac1 |
@@ -1,5 +1,85 @@
|
||||
# Changelog
|
||||
|
||||
## 1.7.7 (2026-07-09)
|
||||
|
||||
### Features
|
||||
|
||||
- **Plugins**: `ui.rerenderEmail` API and restyled read-receipt banner
|
||||
- **Plugins**: New hooks — `onBeforeBlobUpload`, `onBeforeDraftAutoSave`, `onBeforeEditDraft` (#586)
|
||||
- **Plugins**: `ui.prompt` dialog and first-class settings-section tabs
|
||||
- **Calendar**: Jalali (Persian/Shamsi) calendar support with Saturday as week start (#490)
|
||||
- **i18n**: Hebrew locale with full RTL support
|
||||
- **i18n**: Slovak translation
|
||||
- **i18n**: User-selectable regional date format
|
||||
- **Contacts**: Enable trusted-senders address book sync by default when contacts are available
|
||||
- **Mail**: Pin emails to the top of the folder list
|
||||
- **Mail**: Setting to disable the tag-color row tint in the message list
|
||||
- **Mail**: Click the sender avatar to select a message/thread (Thunderbird-style)
|
||||
- **Accounts**: Pin the default account on top and drag-to-reorder the account switcher
|
||||
- **Composer**: Recipient autocomplete from Sent, with on-demand server search
|
||||
- **Composer**: Preselect the identity of the active mailbox for new messages
|
||||
- **Email**: Send a quick reply with Ctrl/Cmd+Enter
|
||||
- **Headers**: Parse Stalwart spam headers
|
||||
- **Login**: Configurable logo size and hideable heading/subtitle
|
||||
- **PWA**: Apple Touch icons for the iOS home screen
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Mail**: Hide Files when the account lacks the filenode capability (#563)
|
||||
- **Mail**: Keep advanced search filters applied when switching folders (#553)
|
||||
- **Mail**: Keep the email list scrollable when the bottom reading pane is enabled with no conversation selected
|
||||
- **Mail**: Route keyword writes to the email's own account in unified view
|
||||
- **Mail**: Render emails that set `height:100%` on a wrapper element
|
||||
- **Mail**: Hide images that fail to load
|
||||
- **Mail**: Storage quota not shown with Stalwart (#577)
|
||||
- **Spam**: Hide the spam action in Sent, Drafts and Scheduled
|
||||
- **Spam**: Fix stale folder counters and open message after spam actions
|
||||
- **Composer**: Wait for in-flight attachment uploads before sending
|
||||
- **Composer**: Only commit a recipient on Space when the input is a valid email (#571)
|
||||
- **Composer**: Attachment reminder now ignores quoted text on reply/forward (#570)
|
||||
- **Calendar**: Store the event organizer as owner-only to prevent duplicate ORGANIZER/ATTENDEE
|
||||
- **Calendar**: Strike through cancelled events and mute their reminders (#572)
|
||||
- **Calendar**: Use `calendarAddress`/`organizerCalendarAddress` for scheduling, drop retired `sendTo`/`replyTo` (#500)
|
||||
- **Auth**: Keep the session when the auth server is briefly unreachable
|
||||
- **Shortcuts**: Make keyboard shortcuts layout-agnostic and map by physical position
|
||||
- **Shortcuts**: Don't toggle mailbox subfolders on Arrow keys while typing
|
||||
- **Contacts**: Clear the photo on the server by sending `media: null` when removed
|
||||
- **Plugins**: Preserve the settings slot and privileged tier
|
||||
- **Pro**: Prompt to save or discard a draft when closing a compose tab via the tab-bar X
|
||||
- **Pro**: Show the Edit button on draft emails opened in a new tab
|
||||
- **List**: Shift-click on the checkbox extends the selection (range)
|
||||
- **CSP**: Allow external/data fonts so email webfonts render
|
||||
- **Notifications**: Brand push notifications with the configured PWA icon
|
||||
- **Notifications**: Notification sound preview — base-path prefix and longer default beep
|
||||
- **Unsubscribe**: Send `mailto:` unsubscribe ourselves instead of via the OS handler
|
||||
- **Branding**: Apply per-domain favicon override in root metadata (#585)
|
||||
- **Settings**: Load the trusted-senders address book on the settings page so the count isn't 0
|
||||
- **Setup**: Clone source when `setup.sh` runs detached from a checkout (#518)
|
||||
- **Server**: Use a callable `.get` to detect `Headers` in `pickRequestHost`
|
||||
|
||||
## 1.7.6 (2026-06-28)
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- **S/MIME**: The built-in S/MIME implementation has been removed from core and re-delivered through the new generic crypto plugin hooks (privileged same-origin plugin tier). S/MIME signing, encryption, decryption, certificate management, and the related settings UI now live in a plugin rather than the main app. Deployments that relied on built-in S/MIME must install the S/MIME crypto plugin to retain those features.
|
||||
|
||||
### Features
|
||||
|
||||
- **Plugins**: Privileged same-origin plugin tier with a crypto API surface
|
||||
- **Plugins**: Plugin hooks for email details, headers, and source
|
||||
- **Mail**: Option to hide the total message count on folders (#498)
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Mail**: Hide the server scheduled folder when the virtual one is shown (#495)
|
||||
- **Mail**: Stop the unified mailbox from mutating client-returned email objects
|
||||
- **Composer**: HTML-escape sender and subject in the reply/forward quote header (#482)
|
||||
- **Calendar**: Send calendar invites by setting `organizerCalendarAddress`
|
||||
- **Identity**: Sync the default identity (`preferredPrimaryId`) to server settings (#507)
|
||||
- **Auth**: Support MFA login via the structured auth endpoint
|
||||
- **Admin**: Show all built-in themes in the admin theme controls (#496)
|
||||
- **i18n**: Add missing translation keys across 19 locales
|
||||
|
||||
## 1.7.5 (2026-06-24)
|
||||
|
||||
### Features
|
||||
|
||||
@@ -12,10 +12,8 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
|
||||
|
||||
[](LICENSE)
|
||||
[](https://discord.gg/tYCujymGrT)
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](https://ghcr.io/bulwarkmail/webmail)
|
||||
[](https://grafana.external.bulwarkmail.org/)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
@@ -1149,13 +1149,13 @@ export default function CalendarPage() {
|
||||
</div>
|
||||
<div className="px-6 py-4 space-y-3">
|
||||
<Button variant="outline" className="w-full justify-start h-auto py-3" onClick={handleImportWebcal}>
|
||||
<span className="text-left">
|
||||
<span className="text-start">
|
||||
<span className="block font-medium">{tWebcalAction("import_title")}</span>
|
||||
<span className="block text-xs text-muted-foreground mt-0.5">{tWebcalAction("import_description")}</span>
|
||||
</span>
|
||||
</Button>
|
||||
<Button variant="outline" className="w-full justify-start h-auto py-3" onClick={handleSubscribeWebcal}>
|
||||
<span className="text-left">
|
||||
<span className="text-start">
|
||||
<span className="block font-medium">{tWebcalAction("subscribe_title")}</span>
|
||||
<span className="block text-xs text-muted-foreground mt-0.5">{tWebcalAction("subscribe_description")}</span>
|
||||
</span>
|
||||
@@ -1336,7 +1336,7 @@ export default function CalendarPage() {
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"border-r border-border bg-secondary overflow-y-auto flex-shrink-0 p-3",
|
||||
"border-e border-border bg-secondary overflow-y-auto flex-shrink-0 p-3",
|
||||
!isResizing && "transition-[width] duration-300",
|
||||
isNarrow && cn(
|
||||
"absolute inset-y-0 left-0 z-50 w-72 pt-[env(safe-area-inset-top)]",
|
||||
@@ -1475,7 +1475,7 @@ export default function CalendarPage() {
|
||||
|
||||
{/* Desktop event panel */}
|
||||
{!isMobile && showEventModal && (
|
||||
<div className="w-[400px] border-l border-border flex-shrink-0 overflow-hidden">
|
||||
<div className="w-[400px] border-s border-border flex-shrink-0 overflow-hidden">
|
||||
<EventModal
|
||||
key={editEvent?.id ?? 'new'}
|
||||
event={editEvent}
|
||||
@@ -1498,7 +1498,7 @@ export default function CalendarPage() {
|
||||
|
||||
{/* Desktop task panel */}
|
||||
{!isMobile && showTaskModal && (
|
||||
<div className="w-[400px] border-l border-border flex-shrink-0 overflow-hidden">
|
||||
<div className="w-[400px] border-s border-border flex-shrink-0 overflow-hidden">
|
||||
<TaskModal
|
||||
key={editTask?.id ?? 'new-task'}
|
||||
task={editTask}
|
||||
|
||||
@@ -751,7 +751,7 @@ export default function ContactsPage() {
|
||||
<button
|
||||
key={group.id}
|
||||
onClick={() => handleBulkAddToGroupConfirm(group.id)}
|
||||
className="w-full flex items-center gap-3 px-6 py-3 text-left hover:bg-muted transition-colors"
|
||||
className="w-full flex items-center gap-3 px-6 py-3 text-start hover:bg-muted transition-colors"
|
||||
>
|
||||
<div className="w-9 h-9 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||
<Users className="w-4 h-4 text-primary" />
|
||||
@@ -869,7 +869,7 @@ export default function ContactsPage() {
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"border-r border-border flex flex-col flex-shrink-0 bg-background",
|
||||
"border-e border-border flex flex-col flex-shrink-0 bg-background",
|
||||
!isSidebarResizing && "transition-[width] duration-300",
|
||||
isNarrow && cn(
|
||||
"absolute inset-y-0 left-0 z-50 w-72 pt-[env(safe-area-inset-top)]",
|
||||
@@ -937,7 +937,7 @@ export default function ContactsPage() {
|
||||
<div
|
||||
data-tour="contacts-list"
|
||||
className={cn(
|
||||
"border-r border-border bg-background flex flex-col flex-shrink-0",
|
||||
"border-e border-border bg-background flex flex-col flex-shrink-0",
|
||||
isMobile ? "w-full" : "",
|
||||
!isListResizing && !isMobile && "transition-[width] duration-300"
|
||||
)}
|
||||
@@ -991,7 +991,7 @@ export default function ContactsPage() {
|
||||
onClick={mobileBackToList}
|
||||
className="touch-manipulation"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
<ArrowLeft className="w-4 h-4 me-2" />
|
||||
{returnToEmail ? t("back_to_email") : t("back_to_contacts")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -38,11 +38,11 @@ export default function LocaleError({
|
||||
</p>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<Button variant="outline" onClick={() => router.push('/')}>
|
||||
<Home className="w-4 h-4 mr-2" />
|
||||
<Home className="w-4 h-4 me-2" />
|
||||
{t("go_home")}
|
||||
</Button>
|
||||
<Button onClick={reset}>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
<RefreshCw className="w-4 h-4 me-2" />
|
||||
{t("try_again")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -477,7 +477,7 @@ export default function FilesPage() {
|
||||
onClick={() => router.push("/")}
|
||||
className="justify-start"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
<ArrowLeft className="w-4 h-4 me-2" />
|
||||
{t("title")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -611,7 +611,7 @@ export default function FilesPage() {
|
||||
: '0%' }}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground tabular-nums text-right">
|
||||
<p className="mt-2 text-xs text-muted-foreground tabular-nums text-end">
|
||||
{migrationProgress.current} / {migrationProgress.total}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -133,9 +133,16 @@ export default function LoginPage() {
|
||||
const isMobileHandoff = Boolean(mobileRedirectUri);
|
||||
const { login, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore();
|
||||
const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme })));
|
||||
const { appName, jmapServerUrl: configuredServerUrl, oauthEnabled, oauthOnly, oauthClientId: globalOauthClientId, oauthIssuerUrl: globalOauthIssuerUrl, oauthScopes, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode, allowCustomJmapEndpoint, jmapServers, jmapServerAutoPickByDomain } = useConfig();
|
||||
const { appName, jmapServerUrl: configuredServerUrl, oauthEnabled, oauthOnly, oauthClientId: globalOauthClientId, oauthIssuerUrl: globalOauthIssuerUrl, oauthScopes, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, loginLogoMaxHeight, loginLogoMaxWidth, loginShowHeading, loginShowSubtitle, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode, allowCustomJmapEndpoint, jmapServers, jmapServerAutoPickByDomain } = useConfig();
|
||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||
|
||||
// Login logo sizing: when a max height/width is configured, drop the fixed
|
||||
// 64×64 box so the logo (e.g. a wide wordmark) can render at its true size.
|
||||
const hasLogoSize = Boolean(loginLogoMaxHeight || loginLogoMaxWidth);
|
||||
const loginLogoStyle = hasLogoSize
|
||||
? { maxHeight: loginLogoMaxHeight || undefined, maxWidth: loginLogoMaxWidth || undefined }
|
||||
: undefined;
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
username: "",
|
||||
password: "",
|
||||
@@ -718,7 +725,7 @@ export default function LoginPage() {
|
||||
)}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
<span className="flex-1 text-left">{option.label}</span>
|
||||
<span className="flex-1 text-start">{option.label}</span>
|
||||
{isActive && <Check className="w-3.5 h-3.5 text-primary" />}
|
||||
</button>
|
||||
);
|
||||
@@ -867,7 +874,7 @@ export default function LoginPage() {
|
||||
)}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
<span className="flex-1 text-left">{option.label}</span>
|
||||
<span className="flex-1 text-start">{option.label}</span>
|
||||
{isActive && <Check className="w-3.5 h-3.5 text-primary" />}
|
||||
</button>
|
||||
);
|
||||
@@ -881,19 +888,24 @@ export default function LoginPage() {
|
||||
<div className="rounded-2xl border border-border/60 bg-background/80 backdrop-blur-sm shadow-xl shadow-black/5 dark:shadow-black/20 overflow-hidden">
|
||||
{/* Header section with logo */}
|
||||
<div className="px-8 pt-10 pb-6 text-center">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 mb-5">
|
||||
<div className={cn("inline-flex items-center justify-center mb-5", !hasLogoSize && "w-16 h-16")}>
|
||||
<img
|
||||
src={withBasePath(resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl)}
|
||||
alt={appName}
|
||||
className="max-w-16 max-h-16 object-contain"
|
||||
className={cn("object-contain", !hasLogoSize && "max-w-16 max-h-16")}
|
||||
style={loginLogoStyle}
|
||||
/>
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-foreground tracking-tight">
|
||||
{isAddAccountMode ? t("add_account_title") : appName}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1.5">
|
||||
{isAddAccountMode ? t("add_account_subtitle") : (t("title") !== appName ? t("title") : "Sign in to your account")}
|
||||
</p>
|
||||
{loginShowHeading && (
|
||||
<h1 className="text-2xl font-semibold text-foreground tracking-tight">
|
||||
{isAddAccountMode ? t("add_account_title") : appName}
|
||||
</h1>
|
||||
)}
|
||||
{loginShowSubtitle && (
|
||||
<p className="text-sm text-muted-foreground mt-1.5">
|
||||
{isAddAccountMode ? t("add_account_subtitle") : (t("title") !== appName ? t("title") : "Sign in to your account")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Form section */}
|
||||
@@ -1121,7 +1133,7 @@ export default function LoginPage() {
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
className="h-11 px-3.5 pr-11 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200"
|
||||
className="h-11 px-3.5 pe-11 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200"
|
||||
placeholder={t("password_placeholder")}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
@@ -1241,9 +1253,9 @@ export default function LoginPage() {
|
||||
disabled={oauthLoading || isLoading}
|
||||
>
|
||||
{oauthLoading ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||
<Loader2 className="w-4 h-4 animate-spin me-2" />
|
||||
) : (
|
||||
<LogIn className="w-4 h-4 mr-2" />
|
||||
<LogIn className="w-4 h-4 me-2" />
|
||||
)}
|
||||
{t("sign_in_sso")}
|
||||
</Button>
|
||||
|
||||
+137
-33
@@ -75,7 +75,7 @@ import { appLifecycleHooks, uiHooks, routerHooks, toastHooks, emailHooks } from
|
||||
import { emailToReadView } from "@/lib/plugin-projection";
|
||||
import { buildQuoteHeader } from "@/lib/quote-header";
|
||||
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
|
||||
import { useLocaleStore } from "@/stores/locale-store";
|
||||
import { getEffectiveLocale } from '@/i18n/detect-locale';
|
||||
import type { QuoteHeader } from "@/lib/plugin-types";
|
||||
|
||||
const SCHEDULED_MAILBOX_ID = '__scheduled__';
|
||||
@@ -121,7 +121,7 @@ export default function Home() {
|
||||
useIdentitySync();
|
||||
const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook);
|
||||
const sendDelaySeconds = useSettingsStore((state) => state.sendDelaySeconds);
|
||||
const { loadTrustedSendersBook, trustedSendersLoaded } = useContactStore();
|
||||
const { loadTrustedSendersBook, trustedSendersLoaded, loadRecentRecipients } = useContactStore();
|
||||
|
||||
const promptForRescheduleDelayedUntil = useCallback((): string | null => {
|
||||
const value = window.prompt(t('email_viewer.reschedule_prompt'));
|
||||
@@ -342,6 +342,15 @@ export default function Home() {
|
||||
refreshCurrentMailbox,
|
||||
} = useEmailStore();
|
||||
|
||||
// Load recent recipients (from the Sent folder) for compose autocomplete.
|
||||
// Runs once when the Sent mailbox is known; the store guards against reloads.
|
||||
useEffect(() => {
|
||||
const sent = mailboxes.find((m) => m.role === 'sent');
|
||||
if (client && sent) {
|
||||
loadRecentRecipients(client, sent.originalId || sent.id);
|
||||
}
|
||||
}, [client, mailboxes, loadRecentRecipients]);
|
||||
|
||||
// Pro shell: populate per-account mailbox cache so the sidebar can render
|
||||
// every connected account Thunderbird-style.
|
||||
useProMultiAccountMailboxes();
|
||||
@@ -612,6 +621,10 @@ export default function Home() {
|
||||
onToggleSpam: async () => {
|
||||
if (isScheduledView) return;
|
||||
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
|
||||
// Marking your own outgoing mail as spam makes no sense - the toolbar
|
||||
// and menus hide the action in Sent/Drafts/Scheduled, so the shortcut
|
||||
// is a no-op there too.
|
||||
if (['sent', 'drafts', 'scheduled'].includes(currentMailbox?.role || '')) return;
|
||||
const isInJunk = currentMailbox?.role === 'junk';
|
||||
if (selectedEmailIds.size > 0 && client) {
|
||||
const ids = Array.from(selectedEmailIds);
|
||||
@@ -1172,18 +1185,22 @@ export default function Home() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark the original email with $answered or $forwarded keyword
|
||||
if (originalEmailId && (effectiveMode === 'reply' || effectiveMode === 'replyAll')) {
|
||||
// Mark the original email with $answered or $forwarded keyword. Route the
|
||||
// write to the email's own account so the flag lands on shared/group-mailbox
|
||||
// messages instead of being dropped against the reaching account. (#281)
|
||||
if (originalEmailId && (effectiveMode === 'reply' || effectiveMode === 'replyAll' || effectiveMode === 'forward')) {
|
||||
const s = useEmailStore.getState();
|
||||
const orig = s.emails.find(e => e.id === originalEmailId);
|
||||
const kwClientId = s.isUnifiedView ? orig?.sourceClientAccountId : undefined;
|
||||
const kwAccountId = s.isUnifiedView ? orig?.sourceAccountId : undefined;
|
||||
const kwClient = kwClientId
|
||||
? (useAuthStore.getState().getClientForAccount(kwClientId) ?? client)
|
||||
: client;
|
||||
const keyword = effectiveMode === 'forward' ? '$forwarded' : '$answered';
|
||||
try {
|
||||
await client.setKeyword(originalEmailId, '$answered');
|
||||
await kwClient.setKeyword(originalEmailId, keyword, kwAccountId);
|
||||
} catch (e) {
|
||||
debug.error('Failed to set $answered keyword:', e);
|
||||
}
|
||||
} else if (originalEmailId && effectiveMode === 'forward') {
|
||||
try {
|
||||
await client.setKeyword(originalEmailId, '$forwarded');
|
||||
} catch (e) {
|
||||
debug.error('Failed to set $forwarded keyword:', e);
|
||||
debug.error(`Failed to set ${keyword} keyword:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1263,7 +1280,7 @@ export default function Home() {
|
||||
},
|
||||
newTo,
|
||||
newCc,
|
||||
locale: useLocaleStore.getState().locale,
|
||||
locale: getEffectiveLocale(),
|
||||
timeFormat: useSettingsStore.getState().timeFormat,
|
||||
unknownLabel: tCommon('unknown'),
|
||||
labels: {
|
||||
@@ -1327,6 +1344,8 @@ export default function Home() {
|
||||
draft = fullDraft;
|
||||
}
|
||||
|
||||
draft = await emailHooks.onBeforeEditDraft.transform(draft);
|
||||
|
||||
const bodyText = draft.bodyValues
|
||||
? Object.values(draft.bodyValues).map(v => v.value).join('\n')
|
||||
: '';
|
||||
@@ -1635,6 +1654,45 @@ export default function Home() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleTogglePinned = async (emailToPin: Email) => {
|
||||
if (!client) return;
|
||||
|
||||
try {
|
||||
const email = emails.find(e => e.id === emailToPin.id) ?? emailToPin;
|
||||
const isPinned = email.keywords?.['$pinned'] === true;
|
||||
// JMAP keywords are a set of present keys - drop the key to unpin
|
||||
// rather than writing a false value.
|
||||
const keywords = { ...email.keywords };
|
||||
if (isPinned) {
|
||||
delete keywords['$pinned'];
|
||||
} else {
|
||||
keywords['$pinned'] = true;
|
||||
}
|
||||
|
||||
// Same unified-view routing as color tags: write to the email's own
|
||||
// account via the login it is reachable through. (#281)
|
||||
const pinClientId = isUnifiedView ? email.sourceClientAccountId : undefined;
|
||||
const pinAccountId = isUnifiedView ? email.sourceAccountId : undefined;
|
||||
const pinClient = pinClientId
|
||||
? (useAuthStore.getState().getClientForAccount(pinClientId) ?? client)
|
||||
: client;
|
||||
|
||||
await pinClient.updateEmailKeywords(email.id, keywords, pinAccountId);
|
||||
|
||||
// Patch in place so the icon flips immediately, then refetch the first
|
||||
// page so the mail floats/sinks per the server's pinned-first sort.
|
||||
// Skip the refetch where that sort does not apply (unified views) or
|
||||
// where it would replace a tag-filtered list (refreshCurrentMailbox
|
||||
// fetches by folder only).
|
||||
setEmailKeywordsLocal(email.id, keywords);
|
||||
if (!isUnifiedView && !useEmailStore.getState().selectedKeyword) {
|
||||
void refreshCurrentMailbox(client);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle pin:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetColorTag = async (emailId: string, color: string | null) => {
|
||||
if (!client) return;
|
||||
|
||||
@@ -1663,8 +1721,20 @@ export default function Home() {
|
||||
}
|
||||
}
|
||||
|
||||
// In unified view route the write to the email's own account, reached
|
||||
// through the login it is reachable via (`sourceClientAccountId`) and
|
||||
// applied to its owning JMAP account (`sourceAccountId`). For personal
|
||||
// sources these resolve to the account itself, so behavior is unchanged.
|
||||
// Without this, tags on shared/group-mailbox messages are written to the
|
||||
// reaching account and silently dropped by the server. (#281)
|
||||
const tagClientId = isUnifiedView ? email.sourceClientAccountId : undefined;
|
||||
const tagAccountId = isUnifiedView ? email.sourceAccountId : undefined;
|
||||
const tagClient = tagClientId
|
||||
? (useAuthStore.getState().getClientForAccount(tagClientId) ?? client)
|
||||
: client;
|
||||
|
||||
// Update email keywords via JMAP
|
||||
await client.updateEmailKeywords(emailId, keywords);
|
||||
await tagClient.updateEmailKeywords(emailId, keywords, tagAccountId);
|
||||
|
||||
// Patch the email in place so the list keeps its scroll/pagination state
|
||||
// instead of being reset to the first page by a full refetch.
|
||||
@@ -1702,7 +1772,15 @@ export default function Home() {
|
||||
setTabletListVisible(true);
|
||||
}
|
||||
if (viewingClient) {
|
||||
await fetchEmails(viewingClient, mailboxId);
|
||||
// Keep an active search applied when switching folders (#553); the
|
||||
// store actions resolve the viewing account's client internally.
|
||||
if (!isFilterEmpty(searchFilters)) {
|
||||
await advancedSearch(viewingClient);
|
||||
} else if (searchQuery) {
|
||||
await searchEmails(viewingClient, searchQuery);
|
||||
} else {
|
||||
await fetchEmails(viewingClient, mailboxId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1792,8 +1870,13 @@ export default function Home() {
|
||||
}
|
||||
|
||||
if (client) {
|
||||
// If there's an active search, re-run it in the new mailbox
|
||||
if (searchQuery) {
|
||||
// If there's an active search, re-run it in the new mailbox. Advanced
|
||||
// filters must go through advancedSearch (which also includes the text
|
||||
// query) — falling back to fetchEmails would silently drop them while
|
||||
// the UI still shows them as active (#553).
|
||||
if (!isFilterEmpty(searchFilters)) {
|
||||
await advancedSearch(client);
|
||||
} else if (searchQuery) {
|
||||
await searchEmails(client, searchQuery);
|
||||
} else {
|
||||
await fetchEmails(client, mailboxId);
|
||||
@@ -2271,11 +2354,22 @@ export default function Home() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark the original email as answered
|
||||
try {
|
||||
await client.setKeyword(originalEmailId, '$answered');
|
||||
} catch (e) {
|
||||
debug.error('Failed to set $answered keyword:', e);
|
||||
// Mark the original email as answered. Route the write to the email's own
|
||||
// account so the flag lands on shared/group-mailbox messages instead of
|
||||
// being dropped against the reaching account. (#281)
|
||||
{
|
||||
const s = useEmailStore.getState();
|
||||
const orig = s.emails.find(e => e.id === originalEmailId);
|
||||
const kwClientId = s.isUnifiedView ? orig?.sourceClientAccountId : undefined;
|
||||
const kwAccountId = s.isUnifiedView ? orig?.sourceAccountId : undefined;
|
||||
const kwClient = kwClientId
|
||||
? (useAuthStore.getState().getClientForAccount(kwClientId) ?? client)
|
||||
: client;
|
||||
try {
|
||||
await kwClient.setKeyword(originalEmailId, '$answered', kwAccountId);
|
||||
} catch (e) {
|
||||
debug.error('Failed to set $answered keyword:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh emails to show the sent reply
|
||||
@@ -2667,17 +2761,19 @@ export default function Home() {
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex flex-col bg-background",
|
||||
isHorizontalMailLayout ? "md:w-full md:h-auto" : "h-full border-r border-border",
|
||||
isHorizontalMailLayout
|
||||
? (shouldHideHorizontalViewerPane ? "md:w-full md:min-h-0" : "md:w-full md:h-auto")
|
||||
: "h-full border-e border-border",
|
||||
// Mobile: full width, hidden when viewing email
|
||||
"max-md:flex-1 max-md:border-r-0 max-md:border-b-0",
|
||||
"max-md:flex-1 max-md:border-e-0 max-md:border-b-0",
|
||||
isMobile && activeView !== "list" && "max-md:hidden",
|
||||
// Tablet/Desktop: fixed width with collapse animation
|
||||
!isHorizontalMailLayout && (shouldHideViewerPane ? "md:flex-1 md:border-r-0" : "md:flex-shrink-0"),
|
||||
!isHorizontalMailLayout && (shouldHideViewerPane ? "md:flex-1 md:border-e-0" : "md:flex-shrink-0"),
|
||||
isHorizontalMailLayout && (shouldHideHorizontalViewerPane ? "md:flex-1" : "md:flex-shrink-0"),
|
||||
isHorizontalMailLayout && !shouldHideHorizontalViewerPane && "md:shadow-[0_8px_12px_-6px_rgba(0,0,0,0.18)] dark:md:shadow-[0_8px_14px_-6px_rgba(0,0,0,0.55)]",
|
||||
!isHorizontalMailLayout && "md:shadow-sm",
|
||||
!isResizing && "transition-all duration-200 ease-out",
|
||||
shouldCollapseListPane && "md:w-0 md:opacity-0 md:overflow-hidden md:border-r-0"
|
||||
shouldCollapseListPane && "md:w-0 md:opacity-0 md:overflow-hidden md:border-e-0"
|
||||
)}
|
||||
style={
|
||||
isMobile
|
||||
@@ -2730,13 +2826,13 @@ export default function Home() {
|
||||
)}
|
||||
</button>
|
||||
<form onSubmit={(e) => { e.preventDefault(); if (searchQuery.trim()) handleSearch(searchQuery); }} className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Search className="absolute start-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={t("sidebar.search_placeholder_hint")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className={cn("pl-9 h-9", searchQuery && "pr-8")}
|
||||
className={cn("ps-9 h-9", searchQuery && "pe-8")}
|
||||
data-search-input
|
||||
data-tour="search-input"
|
||||
disabled={isUnifiedView || isScheduledView}
|
||||
@@ -2746,7 +2842,7 @@ export default function Home() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearSearch}
|
||||
className="absolute right-2 top-1/2 transform -translate-y-1/2 p-1 rounded-full hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||
className="absolute end-2 top-1/2 transform -translate-y-1/2 p-1 rounded-full hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label={t("sidebar.clear_search")}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
@@ -2803,7 +2899,7 @@ export default function Home() {
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="sm" onClick={() => { clearSearchFilters(); setShowAdvancedFields(false); if (client) advancedSearch(client); }} className="h-7 px-2 text-xs text-muted-foreground">
|
||||
<RotateCcw className="w-3 h-3 mr-1" />
|
||||
<RotateCcw className="w-3 h-3 me-1" />
|
||||
{t("advanced_search.clear")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -2987,6 +3083,9 @@ export default function Home() {
|
||||
await toggleStar(client, email.id);
|
||||
}
|
||||
}}
|
||||
onTogglePinned={async (email) => {
|
||||
await handleTogglePinned(email);
|
||||
}}
|
||||
onDelete={async (email) => {
|
||||
await handleDelete(email);
|
||||
}}
|
||||
@@ -3025,7 +3124,7 @@ export default function Home() {
|
||||
}}
|
||||
className={cn(
|
||||
"absolute z-40 rounded-full shadow-lg",
|
||||
isMobile ? "bottom-4 right-4 h-14 w-14" : "bottom-4 right-4 h-12 w-12"
|
||||
isMobile ? "bottom-4 end-4 h-14 w-14" : "bottom-4 end-4 h-12 w-12"
|
||||
)}
|
||||
aria-label={t('sidebar.compose')}
|
||||
title={t('sidebar.compose_hint')}
|
||||
@@ -3084,6 +3183,11 @@ export default function Home() {
|
||||
<EmailComposer
|
||||
key={composerSessionId}
|
||||
mode={pendingDraft?.mode ?? composerMode}
|
||||
composeFromAccountEmail={
|
||||
useAccountStore
|
||||
.getState()
|
||||
.getAccountById(viewingAccountId ?? activeAccountId ?? '')?.email
|
||||
}
|
||||
replyTo={pendingDraft !== null ? pendingDraft.replyTo : (selectedEmail ? {
|
||||
from: selectedEmail.from,
|
||||
replyToAddresses: selectedEmail.replyTo,
|
||||
@@ -3148,13 +3252,13 @@ export default function Home() {
|
||||
setShowComposer(true);
|
||||
if (isMobile) setActiveView('viewer');
|
||||
}}
|
||||
className="flex items-center gap-3 px-4 py-2.5 bg-primary/10 border-b border-primary/20 hover:bg-primary/15 transition-colors cursor-pointer w-full text-left"
|
||||
className="flex items-center gap-3 px-4 py-2.5 bg-primary/10 border-b border-primary/20 hover:bg-primary/15 transition-colors cursor-pointer w-full text-start"
|
||||
>
|
||||
<PenLine className="w-4 h-4 text-primary shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium text-primary">{t('email_composer.continue_draft')}</span>
|
||||
{pendingDraft.subject && (
|
||||
<span className="text-xs text-muted-foreground ml-2 truncate">{pendingDraft.subject}</span>
|
||||
<span className="text-xs text-muted-foreground ms-2 truncate">{pendingDraft.subject}</span>
|
||||
)}
|
||||
</div>
|
||||
<X
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { useState, useEffect, useRef, useMemo, useSyncExternalStore } from 'react';
|
||||
import { useRouter } from '@/i18n/navigation';
|
||||
import { useTranslations, useMessages } from 'next-intl';
|
||||
import {
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
Tags,
|
||||
HardDrive,
|
||||
BookUser,
|
||||
KeyRound,
|
||||
PanelLeftClose,
|
||||
Bell,
|
||||
Puzzle,
|
||||
@@ -63,11 +62,12 @@ import { AccountSecuritySettings } from '@/components/settings/account-security-
|
||||
import { FilesSettingsComponent } from '@/components/settings/files-settings';
|
||||
import { DownloadsSettings } from '@/components/settings/downloads-settings';
|
||||
import { ContactsSettings } from '@/components/settings/contacts-settings';
|
||||
import { SmimeSettings } from '@/components/settings/smime-settings';
|
||||
import { SidebarAppsSettings } from '@/components/settings/sidebar-apps-settings';
|
||||
import { NotificationSettings } from '@/components/settings/notification-settings';
|
||||
import { ThemesSettings } from '@/components/settings/themes-settings';
|
||||
import { PluginsSettings } from '@/components/settings/plugins-settings';
|
||||
import { PluginIframeSlot } from '@/components/plugins/plugin-iframe-slot';
|
||||
import { offersForSlot as pluginOffersForSlot, subscribe as pluginRegistrySubscribe, get as getActivePlugin } from '@/lib/plugin-sandbox/registry';
|
||||
import { ProtocolHandlerSettings } from '@/components/settings/protocol-handler-settings';
|
||||
import { useAuthStore, redirectToLogin } from '@/stores/auth-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
@@ -102,7 +102,6 @@ type Tab =
|
||||
| 'folders'
|
||||
| 'keywords'
|
||||
| 'security'
|
||||
| 'encryption'
|
||||
| 'content_senders'
|
||||
| 'calendar'
|
||||
| 'contacts'
|
||||
@@ -116,8 +115,14 @@ type Tab =
|
||||
|
||||
type TabGroup = 'general' | 'appearance' | 'mail' | 'privacy' | 'apps' | 'advanced';
|
||||
|
||||
// A plugin that exposes a `settings-section` slot gets its own first-class
|
||||
// Settings entry, keyed `plugin:<id>`, so its UI (e.g. S/MIME key import) is
|
||||
// discoverable as a menu point rather than buried inside another panel.
|
||||
type PluginTabId = `plugin:${string}`;
|
||||
type SettingsTabId = Tab | PluginTabId;
|
||||
|
||||
interface TabDef {
|
||||
id: Tab;
|
||||
id: SettingsTabId;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
group: TabGroup;
|
||||
@@ -139,7 +144,6 @@ const tabIcons: Record<Tab, LucideIcon> = {
|
||||
folders: FolderOpen,
|
||||
keywords: Tags,
|
||||
security: Shield,
|
||||
encryption: KeyRound,
|
||||
content_senders: EyeOff,
|
||||
calendar: Calendar,
|
||||
contacts: BookUser,
|
||||
@@ -217,7 +221,6 @@ const tabSearchPaths: Record<Tab, string[]> = {
|
||||
folders: ['settings.folders'],
|
||||
keywords: ['settings.keywords'],
|
||||
security: ['settings.security'],
|
||||
encryption: ['smime'],
|
||||
content_senders: [
|
||||
'settings.email_behavior.always_light_mode',
|
||||
'settings.email_behavior.external_content',
|
||||
@@ -252,7 +255,6 @@ const tabKeywords: Record<Tab, string> = {
|
||||
folders: 'mailbox subscribe',
|
||||
keywords: 'tags labels colors',
|
||||
security: 'password 2fa two-factor passkey app password mfa',
|
||||
encryption: 's/mime smime certificate pgp gpg',
|
||||
content_senders: 'block sender remote images privacy tracking',
|
||||
calendar: 'event schedule appointment meeting timezone',
|
||||
contacts: 'address book contact',
|
||||
@@ -336,7 +338,7 @@ const LEGACY_TAB_MAP: Record<string, Tab> = {
|
||||
advanced: 'about_data',
|
||||
};
|
||||
|
||||
function readPersistedTab(): Tab {
|
||||
function readPersistedTab(): SettingsTabId {
|
||||
try {
|
||||
// One-shot deep link from the sidebar section gears (Folders / Tags).
|
||||
// Used only as the initial tab and intentionally NOT written to
|
||||
@@ -344,7 +346,7 @@ function readPersistedTab(): Tab {
|
||||
// default that the regular Settings button lands on. Cleared on mount.
|
||||
const deepLink = sessionStorage.getItem('settings-deep-link-tab');
|
||||
if (deepLink) {
|
||||
return (deepLink in LEGACY_TAB_MAP ? LEGACY_TAB_MAP[deepLink] : deepLink) as Tab;
|
||||
return (deepLink in LEGACY_TAB_MAP ? LEGACY_TAB_MAP[deepLink] : deepLink) as SettingsTabId;
|
||||
}
|
||||
const saved = localStorage.getItem('settings-active-tab');
|
||||
if (!saved) return 'appearance';
|
||||
@@ -353,7 +355,7 @@ function readPersistedTab(): Tab {
|
||||
try { localStorage.setItem('settings-active-tab', migrated); } catch { /* ignore */ }
|
||||
return migrated;
|
||||
}
|
||||
return saved as Tab;
|
||||
return saved as SettingsTabId;
|
||||
} catch {
|
||||
return 'appearance';
|
||||
}
|
||||
@@ -370,7 +372,15 @@ export default function SettingsPage() {
|
||||
const { quota, isPushConnected } = useEmailStore();
|
||||
const { stalwartFeaturesEnabled } = useConfig();
|
||||
const { isFeatureEnabled } = usePolicyStore();
|
||||
const [activeTab, setActiveTab] = useState<Tab>(readPersistedTab);
|
||||
const [activeTab, setActiveTab] = useState<SettingsTabId>(readPersistedTab);
|
||||
// Active plugins that expose a `settings-section` slot — each becomes its own
|
||||
// Settings menu entry. Referentially stable per registry mutation, so it is
|
||||
// safe to feed useSyncExternalStore directly.
|
||||
const pluginSettingsOffers = useSyncExternalStore(
|
||||
pluginRegistrySubscribe,
|
||||
() => pluginOffersForSlot('settings-section'),
|
||||
() => pluginOffersForSlot('settings-section'),
|
||||
);
|
||||
// Consume the one-shot deep-link key so a section gear only steers this one
|
||||
// open, never the persisted default for future Settings-button clicks.
|
||||
useEffect(() => {
|
||||
@@ -378,7 +388,7 @@ export default function SettingsPage() {
|
||||
}, []);
|
||||
const [mobileShowContent, setMobileShowContent] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [pendingHighlight, setPendingHighlight] = useState<{ tab: Tab; label: string; pluginId?: string } | null>(null);
|
||||
const [pendingHighlight, setPendingHighlight] = useState<{ tab: SettingsTabId; label: string; pluginId?: string } | null>(null);
|
||||
const isDesktop = useIsDesktop();
|
||||
|
||||
const messages = useMessages() as Record<string, unknown>;
|
||||
@@ -623,7 +633,6 @@ export default function SettingsPage() {
|
||||
|
||||
// Privacy & Security
|
||||
...(stalwartFeaturesEnabled ? [{ id: 'security' as Tab, label: t('tabs.security'), icon: tabIcons.security, group: 'privacy' as TabGroup }] : []),
|
||||
...(isFeatureEnabled('smimeEnabled') ? [{ id: 'encryption' as Tab, label: t('tabs.encryption'), icon: tabIcons.encryption, group: 'privacy' as TabGroup }] : []),
|
||||
{ id: 'content_senders', label: t('tabs.content_senders'), icon: tabIcons.content_senders, group: 'privacy' },
|
||||
|
||||
// Apps
|
||||
@@ -631,6 +640,14 @@ export default function SettingsPage() {
|
||||
...(isFeatureEnabled('contactsEnabled') ? [{ id: 'contacts' as Tab, label: t('tabs.contacts'), icon: tabIcons.contacts, group: 'apps' as TabGroup }] : []),
|
||||
...(supportsFiles && isFeatureEnabled('filesEnabled') ? [{ id: 'files' as Tab, label: t('tabs.files'), icon: tabIcons.files, group: 'apps' as TabGroup }] : []),
|
||||
...(isFeatureEnabled('sidebarAppsEnabled') ? [{ id: 'sidebar_apps' as Tab, label: t('tabs.sidebar_apps'), icon: tabIcons.sidebar_apps, group: 'apps' as TabGroup }] : []),
|
||||
// Plugin-contributed settings pages: one entry per active plugin that
|
||||
// offers a `settings-section` slot (e.g. S/MIME key & certificate manager).
|
||||
...pluginSettingsOffers.map((offer): TabDef => ({
|
||||
id: `plugin:${offer.pluginId}` as PluginTabId,
|
||||
label: getActivePlugin(offer.pluginId)?.plugin.name ?? offer.pluginId,
|
||||
icon: Puzzle,
|
||||
group: 'apps',
|
||||
})),
|
||||
|
||||
// Advanced
|
||||
{ id: 'about_data', label: t('tabs.about_data'), icon: tabIcons.about_data, group: 'advanced' },
|
||||
@@ -651,7 +668,7 @@ export default function SettingsPage() {
|
||||
].filter(Boolean) as Tab[])
|
||||
: [];
|
||||
const visibleTabs = managedAccountId
|
||||
? tabs.filter((tab) => scopedTabIds.includes(tab.id))
|
||||
? tabs.filter((tab) => scopedTabIds.includes(tab.id as Tab))
|
||||
: tabs;
|
||||
|
||||
// Group tabs by category
|
||||
@@ -667,12 +684,12 @@ export default function SettingsPage() {
|
||||
const matchesQuery = (tab: TabDef) => {
|
||||
if (!trimmedQuery) return true;
|
||||
if (tab.label.toLowerCase().includes(trimmedQuery)) return true;
|
||||
return tabSearchHaystacks[tab.id]?.includes(trimmedQuery) ?? false;
|
||||
return tabSearchHaystacks[tab.id as Tab]?.includes(trimmedQuery) ?? false;
|
||||
};
|
||||
|
||||
const subResultsForTab = (tabId: Tab): SubResult[] => {
|
||||
const subResultsForTab = (tabId: SettingsTabId): SubResult[] => {
|
||||
if (!trimmedQuery) return [];
|
||||
const list = tabSubResults[tabId] ?? [];
|
||||
const list = tabSubResults[tabId as Tab] ?? [];
|
||||
return list
|
||||
.filter((r) =>
|
||||
r.label.toLowerCase().includes(trimmedQuery) ||
|
||||
@@ -691,11 +708,11 @@ export default function SettingsPage() {
|
||||
// mode hides it), fall back. In scoped mode fall back to the first scoped tab;
|
||||
// otherwise the usual 'appearance' default.
|
||||
const isActiveVisible = visibleTabs.some((tab) => tab.id === activeTab);
|
||||
const effectiveActiveTab: Tab = isActiveVisible
|
||||
const effectiveActiveTab: SettingsTabId = isActiveVisible
|
||||
? activeTab
|
||||
: (managedAccountId ? (visibleTabs[0]?.id ?? 'appearance') : 'appearance');
|
||||
|
||||
const handleTabSelect = (tabId: Tab) => {
|
||||
const handleTabSelect = (tabId: SettingsTabId) => {
|
||||
setActiveTab(tabId);
|
||||
try { localStorage.setItem('settings-active-tab', tabId); } catch { /* ignore */ }
|
||||
if (!isDesktop) {
|
||||
@@ -703,7 +720,7 @@ export default function SettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubResultSelect = (tabId: Tab, sub: SubResult) => {
|
||||
const handleSubResultSelect = (tabId: SettingsTabId, sub: SubResult) => {
|
||||
handleTabSelect(tabId);
|
||||
setPendingHighlight({ tab: tabId, label: sub.label, pluginId: sub.pluginId });
|
||||
};
|
||||
@@ -719,11 +736,11 @@ export default function SettingsPage() {
|
||||
clearManagedAccount();
|
||||
handleTabSelect('account');
|
||||
}}
|
||||
className="flex items-center gap-2 w-full mb-4 px-3 py-2 rounded-md border border-border bg-muted/40 hover:bg-muted text-left transition-colors"
|
||||
className="flex items-center gap-2 w-full mb-4 px-3 py-2 rounded-md border border-border bg-muted/40 hover:bg-muted text-start transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
<span className="text-sm text-muted-foreground">{t('scoped.back')}</span>
|
||||
<span className="ml-auto text-sm font-medium truncate">
|
||||
<span className="ms-auto text-sm font-medium truncate">
|
||||
{t('scoped.managing', { name: managedAccount.name })}
|
||||
</span>
|
||||
</button>
|
||||
@@ -743,7 +760,6 @@ export default function SettingsPage() {
|
||||
{effectiveActiveTab === 'folders' && <FolderSettings />}
|
||||
{effectiveActiveTab === 'keywords' && <KeywordSettings />}
|
||||
{effectiveActiveTab === 'security' && <AccountSecuritySettings />}
|
||||
{effectiveActiveTab === 'encryption' && <SmimeSettings />}
|
||||
{effectiveActiveTab === 'content_senders' && <ContentSendersSettings />}
|
||||
{effectiveActiveTab === 'calendar' && (
|
||||
managedAccountId
|
||||
@@ -762,6 +778,13 @@ export default function SettingsPage() {
|
||||
{effectiveActiveTab === 'themes' && <ThemesSettings />}
|
||||
{effectiveActiveTab === 'plugins' && <PluginsSettings />}
|
||||
{effectiveActiveTab === 'debug' && <DebugSettings />}
|
||||
{effectiveActiveTab.startsWith('plugin:') && (
|
||||
<PluginIframeSlot
|
||||
key={effectiveActiveTab}
|
||||
pluginId={effectiveActiveTab.slice('plugin:'.length)}
|
||||
slot="settings-section"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -828,7 +851,7 @@ export default function SettingsPage() {
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={t('search_placeholder')}
|
||||
className="pl-9 pr-9 h-10"
|
||||
className="ps-9 pe-9 h-10"
|
||||
aria-label={t('search_placeholder')}
|
||||
/>
|
||||
{searchQuery && (
|
||||
@@ -876,7 +899,7 @@ export default function SettingsPage() {
|
||||
<button
|
||||
key={`${tab.id}:${sub.label}`}
|
||||
onClick={() => handleSubResultSelect(tab.id, sub)}
|
||||
className="w-full flex items-center pl-12 pr-5 py-2 text-xs text-muted-foreground hover:bg-muted hover:text-foreground transition-colors duration-150 text-left"
|
||||
className="w-full flex items-center ps-12 pe-5 py-2 text-xs text-muted-foreground hover:bg-muted hover:text-foreground transition-colors duration-150 text-start"
|
||||
>
|
||||
<span className="truncate">{sub.label}</span>
|
||||
</button>
|
||||
@@ -940,7 +963,7 @@ export default function SettingsPage() {
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"border-r border-border bg-secondary flex flex-col",
|
||||
"border-e border-border bg-secondary flex flex-col",
|
||||
!isResizing && "transition-[width] duration-300"
|
||||
)}
|
||||
style={{ width: `${settingsSidebarWidth}px` }}
|
||||
@@ -953,7 +976,7 @@ export default function SettingsPage() {
|
||||
onClick={() => router.push('/')}
|
||||
className="w-full justify-start"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
<ArrowLeft className="w-4 h-4 me-2" />
|
||||
{t('back_to_mail')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -968,7 +991,7 @@ export default function SettingsPage() {
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={t('search_placeholder')}
|
||||
className="pl-8 pr-8 h-9 text-sm"
|
||||
className="ps-8 pe-8 h-9 text-sm"
|
||||
aria-label={t('search_placeholder')}
|
||||
/>
|
||||
{searchQuery && (
|
||||
@@ -1005,7 +1028,7 @@ export default function SettingsPage() {
|
||||
<button
|
||||
onClick={() => handleTabSelect(tab.id)}
|
||||
className={cn(
|
||||
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
|
||||
'w-full text-start px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
|
||||
effectiveActiveTab === tab.id
|
||||
? 'bg-accent text-accent-foreground font-medium'
|
||||
: 'hover:bg-muted text-foreground'
|
||||
@@ -1021,7 +1044,7 @@ export default function SettingsPage() {
|
||||
<button
|
||||
key={`${tab.id}:${sub.label}`}
|
||||
onClick={() => handleSubResultSelect(tab.id, sub)}
|
||||
className="w-full text-left pl-9 pr-3 py-1.5 rounded-md text-xs text-muted-foreground hover:bg-muted hover:text-foreground transition-colors duration-150"
|
||||
className="w-full text-start ps-9 pe-3 py-1.5 rounded-md text-xs text-muted-foreground hover:bg-muted hover:text-foreground transition-colors duration-150"
|
||||
>
|
||||
<span className="truncate block">{sub.label}</span>
|
||||
</button>
|
||||
|
||||
@@ -220,7 +220,7 @@ export function JmapServersSection({ value, source, onChange, onRevert }: Props)
|
||||
Per-server OAuth (optional, overrides global)
|
||||
</button>
|
||||
{d.oauthExpanded && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2 pl-4 border-l border-border">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2 ps-4 border-s border-border">
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-muted-foreground mb-1">OAuth Client ID</label>
|
||||
<input
|
||||
|
||||
@@ -96,10 +96,10 @@ export function LogsTab() {
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/30">
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground whitespace-nowrap">Time</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground whitespace-nowrap">Action</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Details</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground whitespace-nowrap">IP</th>
|
||||
<th className="text-start px-4 py-2 font-medium text-muted-foreground whitespace-nowrap">Time</th>
|
||||
<th className="text-start px-4 py-2 font-medium text-muted-foreground whitespace-nowrap">Action</th>
|
||||
<th className="text-start px-4 py-2 font-medium text-muted-foreground">Details</th>
|
||||
<th className="text-start px-4 py-2 font-medium text-muted-foreground whitespace-nowrap">IP</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
|
||||
@@ -171,7 +171,7 @@ export function MarketplaceTab() {
|
||||
placeholder="Search extensions..."
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
className="w-full h-9 pl-9 pr-3 rounded-md border border-input bg-background text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring/20 focus:border-ring"
|
||||
className="w-full h-9 ps-9 pe-3 rounded-md border border-input bg-background text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring/20 focus:border-ring"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 rounded-md border border-input bg-background p-0.5 self-start sm:self-auto">
|
||||
@@ -210,7 +210,7 @@ export function MarketplaceTab() {
|
||||
{loading && !error && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
||||
<span className="ml-2 text-sm text-muted-foreground">Searching extensions...</span>
|
||||
<span className="ms-2 text-sm text-muted-foreground">Searching extensions...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -155,7 +155,7 @@ export function PluginConfigPanel({ pluginId, onBack }: Props) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||
<Loader2 className="w-4 h-4 animate-spin me-2" />
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
@@ -217,7 +217,7 @@ export function PluginConfigPanel({ pluginId, onBack }: Props) {
|
||||
<div key={key}>
|
||||
<label className="text-sm font-medium text-foreground block mb-1">
|
||||
{field.label}
|
||||
{field.required && <span className="text-destructive ml-0.5">*</span>}
|
||||
{field.required && <span className="text-destructive ms-0.5">*</span>}
|
||||
</label>
|
||||
{field.description && (
|
||||
<p className="text-xs text-muted-foreground mb-1.5">{field.description}</p>
|
||||
@@ -250,7 +250,7 @@ export function PluginConfigPanel({ pluginId, onBack }: Props) {
|
||||
value={formValues[key] ?? ''}
|
||||
onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))}
|
||||
placeholder={config[key] ? '•••••••• (unchanged)' : (field.placeholder || '')}
|
||||
className="w-full h-9 px-3 pr-10 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring font-mono"
|
||||
className="w-full h-9 px-3 pe-10 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring font-mono"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -117,7 +117,7 @@ export function SettingsTab() {
|
||||
<TextSetting label="JMAP Server URL" configKey="jmapServerUrl" value={currentValue('jmapServerUrl') as string} source={config.jmapServerUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://mail.example.com" />
|
||||
<ToggleSetting label="Allow Custom JMAP Endpoint" description="Show a JMAP server URL field on the login form, allowing users to connect to any JMAP server" configKey="allowCustomJmapEndpoint" value={currentValue('allowCustomJmapEndpoint') as boolean} source={config.allowCustomJmapEndpoint?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
{!!currentValue('allowCustomJmapEndpoint') && (
|
||||
<div className="px-4 py-2.5 bg-amber-50 dark:bg-amber-950/30 border-l-2 border-amber-400 dark:border-amber-600">
|
||||
<div className="px-4 py-2.5 bg-amber-50 dark:bg-amber-950/30 border-s-2 border-amber-400 dark:border-amber-600">
|
||||
<p className="text-xs text-amber-800 dark:text-amber-300 leading-relaxed">
|
||||
<strong>CORS warning:</strong> External JMAP servers must include this domain in their CORS <code className="text-[11px] bg-amber-100 dark:bg-amber-900/50 px-1 py-0.5 rounded">Access-Control-Allow-Origin</code> header, or requests from the browser will be blocked.
|
||||
</p>
|
||||
@@ -145,7 +145,7 @@ export function SettingsTab() {
|
||||
onRevert={() => handleRevert('jmapServers')}
|
||||
/>
|
||||
{Array.isArray(currentValue('jmapServers')) && (currentValue('jmapServers') as JmapServerEntry[]).length > 0 && (
|
||||
<div className="px-4 py-2.5 bg-amber-50 dark:bg-amber-950/30 border-l-2 border-amber-400 dark:border-amber-600">
|
||||
<div className="px-4 py-2.5 bg-amber-50 dark:bg-amber-950/30 border-s-2 border-amber-400 dark:border-amber-600">
|
||||
<p className="text-xs text-amber-800 dark:text-amber-300 leading-relaxed">
|
||||
<strong>CORS warning:</strong> Each JMAP server must allow this webmail's origin in its <code className="text-[11px] bg-amber-100 dark:bg-amber-900/50 px-1 py-0.5 rounded">Access-Control-Allow-Origin</code> header, or browser requests will be blocked.
|
||||
</p>
|
||||
|
||||
@@ -5,12 +5,11 @@ import { Upload, Trash2, Power, PowerOff, Loader2, Palette, Save, Shield, Lock,
|
||||
import type { SettingsPolicy } from '@/lib/admin/types';
|
||||
import { DEFAULT_POLICY, DEFAULT_THEME_POLICY } from '@/lib/admin/types';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import { BUILTIN_THEMES } from '@/lib/builtin-themes';
|
||||
|
||||
const BUILTIN_THEME_OPTIONS = [
|
||||
{ id: 'builtin-nord', name: 'Nord' },
|
||||
{ id: 'builtin-catppuccin', name: 'Catppuccin' },
|
||||
{ id: 'builtin-solarized', name: 'Solarized' },
|
||||
];
|
||||
// Derive from the single source of truth so newly added built-in themes show
|
||||
// up here automatically (was previously a hardcoded subset — see #496).
|
||||
const BUILTIN_THEME_OPTIONS = BUILTIN_THEMES.map(t => ({ id: t.id, name: t.name }));
|
||||
|
||||
interface ThemeEntry {
|
||||
id: string;
|
||||
|
||||
@@ -65,7 +65,7 @@ export default function ChangePasswordPage() {
|
||||
value={currentPassword}
|
||||
onChange={e => setCurrentPassword(e.target.value)}
|
||||
required
|
||||
className="w-full h-9 pl-9 pr-3 rounded-md border border-input bg-background text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
className="w-full h-9 ps-9 pe-3 rounded-md border border-input bg-background text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
+12
-12
@@ -212,7 +212,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
|
||||
'w-full text-start px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
|
||||
active
|
||||
? 'bg-accent text-accent-foreground font-medium'
|
||||
: 'hover:bg-muted text-foreground'
|
||||
@@ -248,7 +248,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
<Link
|
||||
href="/admin/change-password"
|
||||
className={cn(
|
||||
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
|
||||
'w-full text-start px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
|
||||
pathname === '/admin/change-password'
|
||||
? 'bg-accent text-accent-foreground font-medium'
|
||||
: 'hover:bg-muted text-foreground'
|
||||
@@ -263,7 +263,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
)}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5 hover:bg-muted text-foreground"
|
||||
className="w-full text-start px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5 hover:bg-muted text-foreground"
|
||||
>
|
||||
<LogOut className="w-4 h-4 shrink-0 text-muted-foreground" />
|
||||
Sign out
|
||||
@@ -275,7 +275,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
return (
|
||||
<div className="min-h-screen flex bg-background">
|
||||
{/* Slim webmail nav rail (desktop only) */}
|
||||
<nav className="hidden md:flex w-14 bg-secondary flex-col items-center py-3 gap-2 border-r border-border sticky top-0 h-screen shrink-0">
|
||||
<nav className="hidden md:flex w-14 bg-secondary flex-col items-center py-3 gap-2 border-e border-border sticky top-0 h-screen shrink-0">
|
||||
{logoUrl ? (
|
||||
<img src={logoUrl} alt="" className="w-7 h-7 object-contain mb-2" />
|
||||
) : (
|
||||
@@ -326,12 +326,12 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
</nav>
|
||||
|
||||
{/* Admin Sidebar (desktop only) */}
|
||||
<aside className="hidden md:flex w-60 border-r border-border bg-secondary flex-col sticky top-0 h-screen">
|
||||
<aside className="hidden md:flex w-60 border-e border-border bg-secondary flex-col sticky top-0 h-screen">
|
||||
<div className="h-14 flex items-center px-4 border-b border-border shrink-0">
|
||||
{logoUrl ? (
|
||||
<img src={logoUrl} alt="" className="w-5 h-5 object-contain mr-2" />
|
||||
<img src={logoUrl} alt="" className="w-5 h-5 object-contain me-2" />
|
||||
) : (
|
||||
<Shield className="w-5 h-5 text-primary mr-2" />
|
||||
<Shield className="w-5 h-5 text-primary me-2" />
|
||||
)}
|
||||
<span className="font-semibold text-sm text-foreground">Admin Panel</span>
|
||||
</div>
|
||||
@@ -350,7 +350,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
{/* Mobile drawer */}
|
||||
<aside
|
||||
className={cn(
|
||||
'md:hidden fixed inset-y-0 left-0 z-50 w-72 max-w-[85vw] border-r border-border bg-secondary flex flex-col transition-transform duration-200 ease-out',
|
||||
'md:hidden fixed inset-y-0 left-0 z-50 w-72 max-w-[85vw] border-e border-border bg-secondary flex flex-col transition-transform duration-200 ease-out',
|
||||
mobileNavOpen ? 'translate-x-0' : '-translate-x-full'
|
||||
)}
|
||||
aria-label="Admin navigation"
|
||||
@@ -359,9 +359,9 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
<div className="h-14 flex items-center justify-between px-3 border-b border-border shrink-0">
|
||||
<div className="flex items-center min-w-0">
|
||||
{logoUrl ? (
|
||||
<img src={logoUrl} alt="" className="w-5 h-5 object-contain mr-2" />
|
||||
<img src={logoUrl} alt="" className="w-5 h-5 object-contain me-2" />
|
||||
) : (
|
||||
<Shield className="w-5 h-5 text-primary mr-2" />
|
||||
<Shield className="w-5 h-5 text-primary me-2" />
|
||||
)}
|
||||
<span className="font-semibold text-sm text-foreground truncate">Admin Panel</span>
|
||||
</div>
|
||||
@@ -391,9 +391,9 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
</button>
|
||||
<div className="flex items-center min-w-0">
|
||||
{logoUrl ? (
|
||||
<img src={logoUrl} alt="" className="w-5 h-5 object-contain mr-2" />
|
||||
<img src={logoUrl} alt="" className="w-5 h-5 object-contain me-2" />
|
||||
) : (
|
||||
<Shield className="w-5 h-5 text-primary mr-2" />
|
||||
<Shield className="w-5 h-5 text-primary me-2" />
|
||||
)}
|
||||
<span className="font-semibold text-sm text-foreground truncate">Admin Panel</span>
|
||||
</div>
|
||||
|
||||
@@ -186,7 +186,7 @@ export default function MarketplacePreviewPage() {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||
<Loader2 className="w-4 h-4 animate-spin me-2" />
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
@@ -512,7 +512,7 @@ export default function MarketplacePreviewPage() {
|
||||
<section className="border border-border rounded-lg">
|
||||
<button
|
||||
onClick={() => setShowManifest(v => !v)}
|
||||
className="w-full flex items-center justify-between gap-2 px-4 py-3 text-left hover:bg-muted/30 transition-colors"
|
||||
className="w-full flex items-center justify-between gap-2 px-4 py-3 text-start hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileCode className="w-4 h-4 text-muted-foreground" />
|
||||
@@ -532,7 +532,7 @@ export default function MarketplacePreviewPage() {
|
||||
<section className="border border-border rounded-lg">
|
||||
<button
|
||||
onClick={() => setShowSource(v => !v)}
|
||||
className="w-full flex items-center justify-between gap-2 px-4 py-3 text-left hover:bg-muted/30 transition-colors"
|
||||
className="w-full flex items-center justify-between gap-2 px-4 py-3 text-start hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileCode className="w-4 h-4 text-muted-foreground" />
|
||||
|
||||
@@ -44,7 +44,7 @@ export default function GlobalError({
|
||||
onClick={reset}
|
||||
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
<RefreshCw className="w-4 h-4 me-2" />
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
|
||||
+20
-2
@@ -1,9 +1,15 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { getLocaleDirection } from "@/i18n/direction";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { headers } from "next/headers";
|
||||
import { getLocale, getTranslations } from "next-intl/server";
|
||||
import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
|
||||
import { configManager } from "@/lib/admin/config-manager";
|
||||
import {
|
||||
matchDomainBranding,
|
||||
parseDomainBranding,
|
||||
pickRequestHost,
|
||||
} from "@/lib/admin/domain-branding";
|
||||
import { withBasePath } from "@/lib/browser-navigation";
|
||||
import { locales } from "@/i18n/routing";
|
||||
import "../globals.css";
|
||||
@@ -38,7 +44,19 @@ export const viewport: Viewport = {
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
await configManager.ensureLoaded();
|
||||
const faviconUrl = configManager.get<string>("faviconUrl", "/branding/Bulwark_Favicon.svg");
|
||||
// The <head> favicon must honor per-domain branding, exactly like
|
||||
// /api/config, app/manifest.ts, and /api/pwa-icon already do. Resolve the
|
||||
// request host and prefer its override; fall back to the global
|
||||
// admin/env/default value when the host has no favicon override (#585).
|
||||
const host = pickRequestHost(await headers());
|
||||
const domainOverride = matchDomainBranding(
|
||||
host,
|
||||
parseDomainBranding(configManager.get<unknown>("domainBranding", [])),
|
||||
).faviconUrl;
|
||||
const faviconUrl =
|
||||
domainOverride && domainOverride.length > 0
|
||||
? domainOverride
|
||||
: configManager.get<string>("faviconUrl", "/branding/Bulwark_Favicon.svg");
|
||||
// Localize the <head> description to match the UI language; a hardcoded
|
||||
// English description is another signal that makes Chrome offer to
|
||||
// "translate this page". Resolve the locale from the request path, since this
|
||||
@@ -76,7 +94,7 @@ export default async function RootLayout({
|
||||
const parentOrigin = process.env.NEXT_PUBLIC_PARENT_ORIGIN || "";
|
||||
|
||||
return (
|
||||
<html lang={locale} suppressHydrationWarning>
|
||||
<html lang={locale} dir={getLocaleDirection(locale)} suppressHydrationWarning>
|
||||
<head>
|
||||
<meta name="theme-color" content="#ffffff" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
|
||||
@@ -780,7 +780,7 @@ function ServerStep({ config, setConfig, onNext }: Pick<StepProps, 'config' | 's
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<label className="mt-3 ml-[3.25rem] flex items-center gap-2 cursor-pointer">
|
||||
<label className="mt-3 ms-[3.25rem] flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={confirmedNonJmap}
|
||||
@@ -870,7 +870,7 @@ function ServerStep({ config, setConfig, onNext }: Pick<StepProps, 'config' | 's
|
||||
)}
|
||||
|
||||
{hasRowErrors && (
|
||||
<ul className="text-xs text-destructive list-disc pl-5 space-y-0.5">
|
||||
<ul className="text-xs text-destructive list-disc ps-5 space-y-0.5">
|
||||
{rowErrors.map((err, i) => (
|
||||
<li key={i}>{err}</li>
|
||||
))}
|
||||
@@ -1384,7 +1384,7 @@ function BrandingAsset({
|
||||
</div>
|
||||
|
||||
{showUrlField && (
|
||||
<div className="mt-3 pl-[4.75rem]">
|
||||
<div className="mt-3 ps-[4.75rem]">
|
||||
<Input
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
@@ -1394,7 +1394,7 @@ function BrandingAsset({
|
||||
)}
|
||||
|
||||
{uploadError && (
|
||||
<p className="mt-2 pl-[4.75rem] text-xs text-destructive">{uploadError}</p>
|
||||
<p className="mt-2 ps-[4.75rem] text-xs text-destructive">{uploadError}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -1601,7 +1601,7 @@ function SummaryRow({ label, value, mono }: { label: string; value: string; mono
|
||||
return (
|
||||
<div className="flex justify-between items-baseline gap-3 text-sm">
|
||||
<span className="text-muted-foreground shrink-0">{label}</span>
|
||||
<span className={'text-foreground text-right truncate min-w-0 ' + (mono ? 'font-mono text-xs' : '')}>
|
||||
<span className={'text-foreground text-end truncate min-w-0 ' + (mono ? 'font-mono text-xs' : '')}>
|
||||
{value || <span className="text-muted-foreground italic">-</span>}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { SandboxRuntime } from '@/lib/plugin-sandbox/runtime';
|
||||
|
||||
// Privileged-tier sandbox route. Identical runtime to /plugin-sandbox, but the
|
||||
// host loads it into a same-origin (`allow-same-origin`) iframe so the bundle
|
||||
// gets real `crypto.subtle` + IndexedDB. The trust gate (signature + admin
|
||||
// approval) is enforced host-side before this route is ever framed; the page
|
||||
// itself carries no extra privilege.
|
||||
//
|
||||
// Must be dynamic so the per-request CSP nonce from proxy.ts is embedded in
|
||||
// Next's injected hydration/chunk scripts.
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default function PrivilegedPluginSandboxPage() {
|
||||
return <SandboxRuntime />;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -183,6 +183,7 @@ export async function POST(request: NextRequest) {
|
||||
author: manifest.author as string,
|
||||
description: (manifest.description as string) || '',
|
||||
type: manifest.type as string,
|
||||
...(manifest.tier === 'privileged' ? { tier: 'privileged' } : {}),
|
||||
permissions: (manifest.permissions as string[]) || [],
|
||||
entrypoint: manifest.entrypoint as string,
|
||||
enabled: true,
|
||||
|
||||
@@ -82,9 +82,16 @@ export async function PUT(request: NextRequest) {
|
||||
if (!tokenResponse.ok) {
|
||||
const errorText = await tokenResponse.text();
|
||||
logger.error('Token refresh failed', { status: tokenResponse.status, error: errorText });
|
||||
cookieStore.delete(cookieName);
|
||||
cookieStore.delete(refreshTokenServerCookieName(slot));
|
||||
return NextResponse.json({ error: 'Refresh failed' }, { status: 401 });
|
||||
// Drop the refresh token only when the server definitively rejected it
|
||||
// (invalid/expired/revoked grant). A 5xx or 429 is an outage - keeping
|
||||
// the cookie lets the session resume once the server is back.
|
||||
const status = tokenResponse.status;
|
||||
if (status === 400 || status === 401 || status === 403) {
|
||||
cookieStore.delete(cookieName);
|
||||
cookieStore.delete(refreshTokenServerCookieName(slot));
|
||||
return NextResponse.json({ error: 'Refresh failed' }, { status: 401 });
|
||||
}
|
||||
return NextResponse.json({ error: 'Token endpoint unavailable' }, { status: 503 });
|
||||
}
|
||||
|
||||
const tokens = await tokenResponse.json();
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { discoverOAuth } from '@/lib/oauth/discovery';
|
||||
import { getDiscoveryValidator } from '@/lib/oauth/token-exchange';
|
||||
import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
|
||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
import { readFileEnv } from '@/lib/read-file-env';
|
||||
@@ -11,81 +9,173 @@ import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
||||
import { recordLogin } from '@/lib/telemetry/login-tracker';
|
||||
import { parseJmapServers, findServerByUrl, findServerById } from '@/lib/admin/jmap-servers';
|
||||
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
|
||||
import { generateCodeVerifier, generateCodeChallenge } from '@/lib/oauth/pkce';
|
||||
|
||||
/**
|
||||
* Exchange basic auth credentials (with TOTP appended) for OAuth tokens.
|
||||
* Exchange a password + (optional) TOTP code for OAuth tokens.
|
||||
*
|
||||
* This allows 2FA users who log in with basic auth + TOTP to upgrade
|
||||
* to token-based auth, avoiding session expiry when the TOTP rotates.
|
||||
* Stalwart 0.16+ no longer accepts the legacy `password$totp` convention over
|
||||
* HTTP Basic auth: its Basic decoder hardcodes `mfa_token: None` and never
|
||||
* splits the secret on `$`, so any TOTP appended to the password is verified
|
||||
* verbatim against the password hash and fails. The MFA token must instead be
|
||||
* supplied as a distinct field through the structured login endpoint.
|
||||
*
|
||||
* Tries three strategies:
|
||||
* 1. ROPC grant with client_id (if OAUTH_CLIENT_ID is set)
|
||||
* 2. ROPC grant without client_id
|
||||
* 3. ROPC grant authenticated via Basic Auth header (Stalwart-style)
|
||||
* This route drives that flow server-side (avoiding browser CORS against the
|
||||
* mail server, same as OAuth discovery):
|
||||
* 1. POST {serverUrl}/api/auth -> authenticate with a separate `mfaToken`,
|
||||
* receiving a short-lived authorization `clientCode`.
|
||||
* 2. POST {serverUrl}/auth/token (grant_type=authorization_code) -> exchange
|
||||
* the code (with PKCE) for access/refresh tokens.
|
||||
*
|
||||
* Token-based auth also survives TOTP rotation, unlike basic auth which embeds
|
||||
* the (≈30s) code in every request.
|
||||
*/
|
||||
|
||||
async function tryTokenRequest(
|
||||
tokenEndpoint: string,
|
||||
params: URLSearchParams,
|
||||
extraHeaders?: Record<string, string>,
|
||||
): Promise<{ ok: true; tokens: { access_token: string; expires_in?: number; refresh_token?: string } } | { ok: false; status: number; error: string }> {
|
||||
try {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/x-www-form-urlencoded', ...extraHeaders };
|
||||
const response = await fetch(tokenEndpoint, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: params.toString(),
|
||||
});
|
||||
// Fallback OAuth client id used when no client is configured. Stalwart accepts
|
||||
// any client id unless `require_client_registration` is enabled (default off);
|
||||
// when it is enabled the admin must configure `oauthClientId` with this
|
||||
// redirect URI registered.
|
||||
const DEFAULT_CLIENT_ID = 'bulwark-webmail';
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
return { ok: false, status: response.status, error: errorText.substring(0, 500) };
|
||||
}
|
||||
|
||||
const tokens = await response.json();
|
||||
if (!tokens.access_token) {
|
||||
return { ok: false, status: 502, error: 'Response missing access_token' };
|
||||
}
|
||||
|
||||
return { ok: true, tokens };
|
||||
} catch (err) {
|
||||
return { ok: false, status: 0, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
interface LoginResult {
|
||||
type?: string;
|
||||
// The response keeps snake_case: only the LoginResponse variant *tags* are
|
||||
// camelCased server-side, not the struct fields (the request fields are).
|
||||
client_code?: string;
|
||||
}
|
||||
|
||||
async function findTokenEndpoint(serverUrl: string, adminTrusted: boolean): Promise<string | null> {
|
||||
// Admin-trusted callers (matched server entry or configured JMAP server URL)
|
||||
// honor the `oauthAllowPrivateEndpoints` opt-in. User-supplied URLs always
|
||||
// go through the SSRF validator regardless of the setting.
|
||||
const validateEndpoint = adminTrusted ? getDiscoveryValidator() : isPublicHttpUrl;
|
||||
// 1. Try OAuth discovery
|
||||
const metadata = await discoverOAuth(serverUrl, { validateEndpoint });
|
||||
if (metadata?.token_endpoint) return metadata.token_endpoint;
|
||||
function trimUrl(url: string): string {
|
||||
return url.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
// 2. Try common Stalwart token endpoint paths directly
|
||||
const candidates = [
|
||||
`${serverUrl}/auth/token`,
|
||||
`${serverUrl}/api/oauth/token`,
|
||||
];
|
||||
async function attemptLogin(
|
||||
upstreamUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
totp: string | undefined,
|
||||
redirectUri: string,
|
||||
slot: number,
|
||||
serverId: string | null,
|
||||
): Promise<NextResponse> {
|
||||
const base = trimUrl(upstreamUrl);
|
||||
|
||||
for (const url of candidates) {
|
||||
try {
|
||||
// A POST with no body should return 400 (bad request) rather than 404 if the endpoint exists
|
||||
const probe = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: 'grant_type=probe' });
|
||||
if (probe.status !== 404 && probe.status !== 405) {
|
||||
return url;
|
||||
}
|
||||
} catch {
|
||||
// Network error - endpoint not reachable
|
||||
// Per-server OAuth credentials override the global ones when the requested
|
||||
// server entry has its own oauth block configured.
|
||||
const serverList = parseJmapServers(configManager.get<unknown>('jmapServers', []));
|
||||
const entry = findServerById(serverList, serverId);
|
||||
const clientId = entry?.oauth?.clientId
|
||||
|| configManager.get<string>('oauthClientId', '')
|
||||
|| process.env.OAUTH_CLIENT_ID
|
||||
|| DEFAULT_CLIENT_ID;
|
||||
const clientSecret = entry?.oauth?.clientSecret
|
||||
|| configManager.get<string>('oauthClientSecret', '')
|
||||
|| process.env.OAUTH_CLIENT_SECRET
|
||||
|| readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE)
|
||||
|| '';
|
||||
|
||||
// PKCE proves the token exchange originates from the same client that
|
||||
// initiated the login, so no client secret is required for public clients.
|
||||
const verifier = generateCodeVerifier();
|
||||
const challenge = await generateCodeChallenge(verifier);
|
||||
|
||||
// Step 1: structured login with a separate MFA token.
|
||||
let login: LoginResult;
|
||||
try {
|
||||
const loginResponse = await fetch(`${base}/api/auth`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type: 'authCode',
|
||||
accountName: username,
|
||||
accountSecret: password,
|
||||
...(totp ? { mfaToken: totp } : {}),
|
||||
clientId,
|
||||
redirectUri,
|
||||
codeChallenge: challenge,
|
||||
codeChallengeMethod: 'S256',
|
||||
}),
|
||||
});
|
||||
|
||||
if (!loginResponse.ok) {
|
||||
const detail = (await loginResponse.text()).substring(0, 500);
|
||||
logger.warn('TOTP login: /api/auth rejected request', { status: loginResponse.status });
|
||||
// A 404 means the server predates the structured login endpoint; let the
|
||||
// caller fall back to the legacy basic-auth path.
|
||||
return NextResponse.json(
|
||||
{ error: loginResponse.status === 404 ? 'login_endpoint_missing' : 'login_failed', detail },
|
||||
{ status: loginResponse.status === 404 ? 404 : 502 },
|
||||
);
|
||||
}
|
||||
|
||||
login = await loginResponse.json();
|
||||
} catch (err) {
|
||||
logger.warn('TOTP login: /api/auth request failed', { error: err instanceof Error ? err.message : String(err) });
|
||||
return NextResponse.json({ error: 'login_unreachable' }, { status: 502 });
|
||||
}
|
||||
|
||||
return null;
|
||||
switch (login.type) {
|
||||
case 'authenticated':
|
||||
break;
|
||||
case 'mfaRequired':
|
||||
return NextResponse.json({ error: 'totp_required' }, { status: 401 });
|
||||
case 'failure':
|
||||
default:
|
||||
return NextResponse.json({ error: 'invalid_credentials' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!login.client_code) {
|
||||
logger.warn('TOTP login: authenticated response missing client_code');
|
||||
return NextResponse.json({ error: 'login_failed' }, { status: 502 });
|
||||
}
|
||||
|
||||
// Step 2: exchange the authorization code for tokens.
|
||||
const tokenParams = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code: login.client_code,
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
code_verifier: verifier,
|
||||
});
|
||||
// Confidential clients still send their secret; harmless for public clients.
|
||||
if (clientSecret) tokenParams.set('client_secret', clientSecret);
|
||||
|
||||
let tokens: { access_token?: string; expires_in?: number; refresh_token?: string };
|
||||
try {
|
||||
const tokenResponse = await fetch(`${base}/auth/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: tokenParams.toString(),
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const detail = (await tokenResponse.text()).substring(0, 500);
|
||||
logger.warn('TOTP login: token exchange failed', { status: tokenResponse.status, detail });
|
||||
return NextResponse.json({ error: 'token_exchange_failed', detail }, { status: 502 });
|
||||
}
|
||||
|
||||
tokens = await tokenResponse.json();
|
||||
} catch (err) {
|
||||
logger.warn('TOTP login: token endpoint failed', { error: err instanceof Error ? err.message : String(err) });
|
||||
return NextResponse.json({ error: 'token_exchange_failed' }, { status: 502 });
|
||||
}
|
||||
|
||||
if (!tokens.access_token) {
|
||||
return NextResponse.json({ error: 'token_exchange_failed', detail: 'Response missing access_token' }, { status: 502 });
|
||||
}
|
||||
|
||||
logger.info('TOTP login succeeded');
|
||||
void recordLogin(username, base);
|
||||
return await storeAndRespond(
|
||||
{ access_token: tokens.access_token, expires_in: tokens.expires_in, refresh_token: tokens.refresh_token },
|
||||
slot,
|
||||
serverId,
|
||||
);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { serverUrl, username, password, slot: bodySlot, server_id: bodyServerId } = await request.json();
|
||||
const { serverUrl, username, password, totp, slot: bodySlot, server_id: bodyServerId, redirectUri: bodyRedirectUri } =
|
||||
await request.json();
|
||||
|
||||
if (!serverUrl || !username || !password) {
|
||||
return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 });
|
||||
@@ -93,6 +183,7 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot < MAX_ACCOUNT_SLOTS ? bodySlot : 0;
|
||||
const requestedServerId = typeof bodyServerId === 'string' && bodyServerId ? bodyServerId : null;
|
||||
const totpCode = typeof totp === 'string' && totp ? totp : undefined;
|
||||
|
||||
// Pin the upstream URL to a configured JMAP server. The list of allowed
|
||||
// servers is `jmapServerUrl` plus any entry from `jmapServers`. Only when
|
||||
@@ -110,20 +201,17 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
let upstreamUrl: string;
|
||||
let resolvedServerId: string | null = null;
|
||||
let adminTrusted = false;
|
||||
const requestedEntry = findServerById(serverList, requestedServerId);
|
||||
const matchedEntry = requestedEntry || findServerByUrl(serverList, serverUrl);
|
||||
|
||||
if (matchedEntry) {
|
||||
upstreamUrl = matchedEntry.url;
|
||||
resolvedServerId = matchedEntry.id;
|
||||
adminTrusted = true;
|
||||
} else if (configuredServerUrl) {
|
||||
upstreamUrl = configuredServerUrl;
|
||||
adminTrusted = true;
|
||||
} else if (allowCustomEndpoint) {
|
||||
if (!(await isPublicHttpUrl(serverUrl))) {
|
||||
logger.warn('TOTP token exchange: rejected non-public server URL');
|
||||
logger.warn('TOTP login: rejected non-public server URL');
|
||||
return NextResponse.json({ error: 'invalid_server_url' }, { status: 400 });
|
||||
}
|
||||
upstreamUrl = serverUrl;
|
||||
@@ -131,100 +219,22 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'jmap_server_not_configured' }, { status: 500 });
|
||||
}
|
||||
|
||||
const tokenEndpoint = await findTokenEndpoint(upstreamUrl, adminTrusted);
|
||||
if (!tokenEndpoint) {
|
||||
logger.warn('TOTP token exchange: no token endpoint found');
|
||||
return NextResponse.json({ error: 'no_token_endpoint', detail: 'Could not discover OAuth token endpoint on the mail server' }, { status: 404 });
|
||||
}
|
||||
// The redirect URI must be identical in the login and token-exchange steps,
|
||||
// and (when require_client_registration is on) registered for the client.
|
||||
// Prefer the browser-supplied callback URL the OAuth client already uses;
|
||||
// fall back to the upstream URL so the two steps still agree.
|
||||
const redirectUri =
|
||||
typeof bodyRedirectUri === 'string' && /^https?:\/\//.test(bodyRedirectUri)
|
||||
? bodyRedirectUri
|
||||
: trimUrl(upstreamUrl);
|
||||
|
||||
return await attemptAllStrategies(tokenEndpoint, upstreamUrl, username, password, slot, resolvedServerId);
|
||||
return await attemptLogin(upstreamUrl, username, password, totpCode, redirectUri, slot, resolvedServerId);
|
||||
} catch (error) {
|
||||
logger.error('TOTP token exchange error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
logger.error('TOTP login error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
async function attemptAllStrategies(
|
||||
tokenEndpoint: string,
|
||||
serverUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
slot: number,
|
||||
serverId: string | null,
|
||||
): Promise<NextResponse> {
|
||||
logger.info('TOTP token exchange: found token endpoint', { tokenEndpoint });
|
||||
|
||||
// Per-server OAuth credentials override the global ones when the requested
|
||||
// server entry has its own oauth block configured.
|
||||
const serverList = parseJmapServers(configManager.get<unknown>('jmapServers', []));
|
||||
const entry = findServerById(serverList, serverId);
|
||||
const clientId = entry?.oauth?.clientId
|
||||
|| configManager.get<string>('oauthClientId', '')
|
||||
|| process.env.OAUTH_CLIENT_ID;
|
||||
const clientSecret = entry?.oauth?.clientSecret
|
||||
|| configManager.get<string>('oauthClientSecret', '')
|
||||
|| process.env.OAUTH_CLIENT_SECRET
|
||||
|| readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE);
|
||||
const basicAuth = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
||||
const attempts: Array<{ strategy: string; error: string }> = [];
|
||||
|
||||
// Strategy 1: ROPC with client_id (if configured)
|
||||
if (clientId) {
|
||||
const params = new URLSearchParams({ grant_type: 'password', username, password, client_id: clientId });
|
||||
if (clientSecret) params.set('client_secret', clientSecret);
|
||||
const result = await tryTokenRequest(tokenEndpoint, params);
|
||||
if (result.ok) {
|
||||
logger.info('TOTP token exchange succeeded (ROPC with client_id)');
|
||||
void recordLogin(username, serverUrl);
|
||||
return await storeAndRespond(result.tokens, slot, serverId);
|
||||
}
|
||||
attempts.push({ strategy: 'ROPC with client_id', error: result.error });
|
||||
}
|
||||
|
||||
// Strategy 2: ROPC without client_id
|
||||
{
|
||||
const params = new URLSearchParams({ grant_type: 'password', username, password });
|
||||
const result = await tryTokenRequest(tokenEndpoint, params);
|
||||
if (result.ok) {
|
||||
logger.info('TOTP token exchange succeeded (ROPC without client_id)');
|
||||
void recordLogin(username, serverUrl);
|
||||
return await storeAndRespond(result.tokens, slot, serverId);
|
||||
}
|
||||
attempts.push({ strategy: 'ROPC without client_id', error: result.error });
|
||||
}
|
||||
|
||||
// Strategy 3: Basic Auth header on token endpoint (some servers accept this)
|
||||
{
|
||||
const params = new URLSearchParams({ grant_type: 'password' });
|
||||
const result = await tryTokenRequest(tokenEndpoint, params, { 'Authorization': basicAuth });
|
||||
if (result.ok) {
|
||||
logger.info('TOTP token exchange succeeded (Basic Auth header)');
|
||||
void recordLogin(username, serverUrl);
|
||||
return await storeAndRespond(result.tokens, slot, serverId);
|
||||
}
|
||||
attempts.push({ strategy: 'Basic Auth header', error: result.error });
|
||||
}
|
||||
|
||||
// Strategy 4: client_credentials with Basic Auth (last resort)
|
||||
{
|
||||
const params = new URLSearchParams({ grant_type: 'client_credentials' });
|
||||
const result = await tryTokenRequest(tokenEndpoint, params, { 'Authorization': basicAuth });
|
||||
if (result.ok) {
|
||||
logger.info('TOTP token exchange succeeded (client_credentials + Basic Auth)');
|
||||
void recordLogin(username, serverUrl);
|
||||
return await storeAndRespond(result.tokens, slot, serverId);
|
||||
}
|
||||
attempts.push({ strategy: 'client_credentials + Basic Auth', error: result.error });
|
||||
}
|
||||
|
||||
logger.warn('TOTP token exchange: all strategies failed', { attempts });
|
||||
return NextResponse.json({
|
||||
error: 'token_exchange_failed',
|
||||
detail: 'All token exchange strategies failed',
|
||||
attempts,
|
||||
}, { status: 502 });
|
||||
}
|
||||
|
||||
async function storeAndRespond(
|
||||
tokens: { access_token: string; expires_in?: number; refresh_token?: string },
|
||||
slot: number,
|
||||
|
||||
@@ -74,6 +74,10 @@ export async function GET(request: NextRequest) {
|
||||
loginImprintUrl: branded<string>('loginImprintUrl', ''),
|
||||
loginPrivacyPolicyUrl: branded<string>('loginPrivacyPolicyUrl', ''),
|
||||
loginWebsiteUrl: branded<string>('loginWebsiteUrl', ''),
|
||||
loginLogoMaxHeight: configManager.get<string>('loginLogoMaxHeight', ''),
|
||||
loginLogoMaxWidth: configManager.get<string>('loginLogoMaxWidth', ''),
|
||||
loginShowHeading: configManager.get<boolean>('loginShowHeading', true),
|
||||
loginShowSubtitle: configManager.get<boolean>('loginShowSubtitle', true),
|
||||
demoMode: configManager.get<boolean>('demoMode', false),
|
||||
allowCustomJmapEndpoint: configManager.get<boolean>('allowCustomJmapEndpoint', false),
|
||||
jmapServers: redactJmapServers(parseJmapServers(configManager.get<unknown>('jmapServers', []))),
|
||||
|
||||
@@ -1679,7 +1679,8 @@ function handleEmailSubmissionGet(args: MethodArgs, callId: string): MethodResul
|
||||
}
|
||||
|
||||
function handleQuotaGet(_args: MethodArgs, callId: string): MethodResult {
|
||||
return ['Quota/get', { accountId: ACCOUNT_ID, state: nextState(), list: [{ resourceType: 'mail', scope: 'mail', used: 52428800, hardLimit: 1073741824 }], notFound: [] }, callId];
|
||||
// mirroring Stalwart: resourceType "octets", scope "account"
|
||||
return ['Quota/get', { accountId: ACCOUNT_ID, state: nextState(), list: [{ id: 'quota-1', resourceType: 'octets', scope: 'account', types: ['Email', 'SieveScript'], used: 52428800, hardLimit: 1073741824 }], notFound: [] }, callId];
|
||||
}
|
||||
|
||||
function handleVacationResponseGet(_args: MethodArgs, callId: string): MethodResult {
|
||||
|
||||
@@ -37,6 +37,9 @@ export async function GET() {
|
||||
author: p.author,
|
||||
description: p.description,
|
||||
type: p.type,
|
||||
// Requested execution tier; clients gate the same-origin privileged
|
||||
// sandbox on this (plus signature + approval + consent).
|
||||
tier: p.tier,
|
||||
permissions: p.permissions,
|
||||
entrypoint: p.entrypoint,
|
||||
// Policy is the canonical source for force-enable. The per-plugin field
|
||||
|
||||
@@ -59,10 +59,12 @@ export async function GET(
|
||||
domainOverrides.pwaIconUrl ||
|
||||
domainOverrides.faviconUrl ||
|
||||
(sources.pwaIconUrl?.source !== 'default' ? (sources.pwaIconUrl?.value as string) : '') ||
|
||||
(sources.faviconUrl?.source !== 'default' ? (sources.faviconUrl?.value as string) : '');
|
||||
if (!iconUrl) {
|
||||
return new NextResponse('No PWA icon configured', { status: 404 });
|
||||
}
|
||||
(sources.faviconUrl?.source !== 'default' ? (sources.faviconUrl?.value as string) : '') ||
|
||||
// Fall back to the built-in default so this endpoint ALWAYS returns an app
|
||||
// icon (custom if configured, else the bundled default). This lets callers
|
||||
// that can't run the custom-vs-default check themselves - notably the
|
||||
// service worker's notifications - use a single stable URL.
|
||||
`/icon-${size}x${size}.png`;
|
||||
|
||||
const pngHeaders = {
|
||||
'Content-Type': 'image/png',
|
||||
|
||||
+22
-5
@@ -660,13 +660,13 @@ body {
|
||||
|
||||
.tiptap ul {
|
||||
list-style-type: disc;
|
||||
padding-left: 1.5rem;
|
||||
padding-inline-start: 1.5rem;
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.tiptap ol {
|
||||
list-style-type: decimal;
|
||||
padding-left: 1.5rem;
|
||||
padding-inline-start: 1.5rem;
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
@@ -675,8 +675,8 @@ body {
|
||||
}
|
||||
|
||||
.tiptap blockquote {
|
||||
border-left: 3px solid var(--color-border);
|
||||
padding-left: 1rem;
|
||||
border-inline-start: 3px solid var(--color-border);
|
||||
padding-inline-start: 1rem;
|
||||
margin: 0.5rem 0;
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
@@ -719,7 +719,7 @@ body {
|
||||
|
||||
.tiptap p.is-editor-empty:first-child::before {
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
float: inline-start;
|
||||
color: var(--color-muted-foreground);
|
||||
pointer-events: none;
|
||||
height: 0;
|
||||
@@ -808,3 +808,20 @@ body {
|
||||
border-radius: 8px;
|
||||
animation: settings-search-pulse 1.6s ease-in-out forwards;
|
||||
}
|
||||
|
||||
/* RTL: mirror directional icons (chevrons/arrows) so prev/next, back/forward,
|
||||
and panel-collapse affordances point the correct way in right-to-left layouts.
|
||||
lucide-react emits a `lucide-<name>` class per icon, so we target the
|
||||
directional ones only — vertical chevrons (up/down) are intentionally left. */
|
||||
[dir="rtl"] .lucide-chevron-left,
|
||||
[dir="rtl"] .lucide-chevron-right,
|
||||
[dir="rtl"] .lucide-chevrons-left,
|
||||
[dir="rtl"] .lucide-chevrons-right,
|
||||
[dir="rtl"] .lucide-arrow-left,
|
||||
[dir="rtl"] .lucide-arrow-right,
|
||||
[dir="rtl"] .lucide-arrow-big-left,
|
||||
[dir="rtl"] .lucide-arrow-big-right,
|
||||
[dir="rtl"] .lucide-panel-left,
|
||||
[dir="rtl"] .lucide-panel-right {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
@@ -131,7 +131,7 @@ export function CalendarAgendaView({
|
||||
)}>
|
||||
{formatDateHeader(group.date)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
<span className="text-xs text-muted-foreground ms-2">
|
||||
{intlFormatter.dateTime(group.date, { month: "short", day: "numeric", year: "numeric" })}
|
||||
</span>
|
||||
</div>
|
||||
@@ -148,6 +148,9 @@ export function CalendarAgendaView({
|
||||
const color = getEventColor(ev, calendar);
|
||||
const start = getEventStartDate(ev);
|
||||
const end = getEventEndDate(ev);
|
||||
// iTIP CANCEL marks the attendee's copy with status "cancelled"
|
||||
// instead of deleting it (#572).
|
||||
const isCancelled = ev.status === "cancelled";
|
||||
const locationName = ev.locations
|
||||
? Object.values(ev.locations)[0]?.name
|
||||
: null;
|
||||
@@ -159,7 +162,10 @@ export function CalendarAgendaView({
|
||||
onMouseEnter={(e) => onHoverEvent?.(ev, e.currentTarget.getBoundingClientRect())}
|
||||
onMouseLeave={() => onHoverLeave?.()}
|
||||
onContextMenu={onContextMenuEvent ? (e) => onContextMenuEvent(e, ev) : undefined}
|
||||
className="w-full flex items-start px-4 hover:bg-muted/50 transition-colors text-left"
|
||||
className={cn(
|
||||
"w-full flex items-start px-4 hover:bg-muted/50 transition-colors text-start",
|
||||
isCancelled && "opacity-60"
|
||||
)}
|
||||
style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
|
||||
>
|
||||
<div className="flex flex-col items-center pt-0.5 min-w-[60px]">
|
||||
@@ -181,7 +187,7 @@ export function CalendarAgendaView({
|
||||
/>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">
|
||||
<div className={cn("text-sm font-medium truncate", isCancelled && "line-through")}>
|
||||
{ev.title || t("events.no_title")}
|
||||
</div>
|
||||
{locationName && (
|
||||
|
||||
@@ -218,7 +218,7 @@ export function CalendarDayView({
|
||||
{HOURS.map((h) => (
|
||||
<div
|
||||
key={h}
|
||||
className="relative text-muted-foreground text-right pr-2"
|
||||
className="relative text-muted-foreground text-end pe-2"
|
||||
style={{ height: HOUR_HEIGHT }}
|
||||
>
|
||||
{h > 0 && (
|
||||
@@ -231,7 +231,7 @@ export function CalendarDayView({
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex-1 relative border-l border-border"
|
||||
className="flex-1 relative border-s border-border"
|
||||
role="row"
|
||||
aria-label={t("views.day")}
|
||||
onPointerDown={(e) => handleGridPointerDown(e, dayKey, selectedDate)}
|
||||
@@ -312,7 +312,7 @@ export function CalendarDayView({
|
||||
style={{ top: (nowMinutes / 60) * HOUR_HEIGHT }}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-destructive -ml-1" />
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-destructive -ms-1" />
|
||||
<div className="flex-1 h-px bg-destructive" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -346,7 +346,7 @@ export function CalendarDayView({
|
||||
style={{ top: (dropTarget.minutes / 60) * HOUR_HEIGHT }}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-primary -ml-1" />
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-primary -ms-1" />
|
||||
<div className="flex-1 h-0.5 bg-primary rounded-full" />
|
||||
</div>
|
||||
<div className="absolute -top-4 left-2 text-[10px] font-medium text-primary bg-background/90 px-1 rounded shadow-sm">
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState, useCallback, type DragEvent } from "react";
|
||||
import { useTranslations, useFormatter } from "next-intl";
|
||||
import {
|
||||
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
|
||||
eachDayOfInterval, isSameDay, isSameMonth, isToday, format, parseISO,
|
||||
} from "date-fns";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { format, parseISO } from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { EventCard } from "./event-card";
|
||||
import { buildWeekSegments, getEventDayBounds, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
||||
@@ -14,6 +11,7 @@ import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import type { PendingEventPreview } from "./event-modal";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { useCalendarLocale } from "@/hooks/use-calendar-locale";
|
||||
|
||||
interface CalendarMonthViewProps {
|
||||
selectedDate: Date;
|
||||
@@ -47,16 +45,21 @@ export function CalendarMonthView({
|
||||
pendingPreview,
|
||||
}: CalendarMonthViewProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const intlFormatter = useFormatter();
|
||||
const weekStart = (firstDayOfWeek === 0 ? 0 : 1) as 0 | 1;
|
||||
const {
|
||||
weekStartsOn,
|
||||
dayHeaderKeys,
|
||||
getMonthGridDays,
|
||||
checkIsToday,
|
||||
checkIsSameMonth,
|
||||
checkIsSameDay,
|
||||
formatDayNumber,
|
||||
formatFullDate,
|
||||
} = useCalendarLocale();
|
||||
|
||||
const days = useMemo(() => {
|
||||
const monthStart = startOfMonth(selectedDate);
|
||||
const monthEnd = endOfMonth(selectedDate);
|
||||
const gridStart = startOfWeek(monthStart, { weekStartsOn: weekStart });
|
||||
const gridEnd = endOfWeek(monthEnd, { weekStartsOn: weekStart });
|
||||
return eachDayOfInterval({ start: gridStart, end: gridEnd });
|
||||
}, [selectedDate, weekStart]);
|
||||
const days = useMemo(
|
||||
() => getMonthGridDays(selectedDate),
|
||||
[selectedDate, getMonthGridDays],
|
||||
);
|
||||
|
||||
const calendarMap = useMemo(() => {
|
||||
const map = new Map<string, Calendar>();
|
||||
@@ -83,10 +86,6 @@ export function CalendarMonthView({
|
||||
return map;
|
||||
}, [events]);
|
||||
|
||||
const dayHeaders = firstDayOfWeek === 0
|
||||
? ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const
|
||||
: ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] as const;
|
||||
|
||||
const weeks = useMemo(() => {
|
||||
const result: Date[][] = [];
|
||||
for (let i = 0; i < days.length; i += 7) {
|
||||
@@ -141,11 +140,11 @@ export function CalendarMonthView({
|
||||
}, [t]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={intlFormatter.dateTime(selectedDate, { month: "long", year: "numeric" })}>
|
||||
<div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={formatFullDate(selectedDate)}>
|
||||
<div className="grid grid-cols-7 border-b border-border" role="row">
|
||||
{dayHeaders.map((d) => (
|
||||
{dayHeaderKeys.map((d) => (
|
||||
<div key={d} role="columnheader" className={cn(
|
||||
"text-center text-xs font-medium text-muted-foreground py-2 border-r border-border last:border-r-0",
|
||||
"text-center text-xs font-medium text-muted-foreground py-2 border-e border-border last:border-e-0",
|
||||
isMobile && "py-1.5 text-[11px]"
|
||||
)}>
|
||||
{isMobile ? t(`days.${d}`).slice(0, 2) : t(`days.${d}`)}
|
||||
@@ -161,12 +160,12 @@ export function CalendarMonthView({
|
||||
)} role="row" style={isMobile ? undefined : { minHeight: Math.max(100, 34 + rowCount * 22 + 8) }}>
|
||||
<div className="grid grid-cols-7 h-full">
|
||||
{week.map((day) => {
|
||||
const inMonth = isSameMonth(day, selectedDate);
|
||||
const selected = isSameDay(day, selectedDate);
|
||||
const today = isToday(day);
|
||||
const inMonth = checkIsSameMonth(day, selectedDate);
|
||||
const selected = checkIsSameDay(day, selectedDate);
|
||||
const today = checkIsToday(day);
|
||||
const key = format(day, "yyyy-MM-dd");
|
||||
const dayEvents = eventsByDate.get(key) || [];
|
||||
const fullDateLabel = intlFormatter.dateTime(day, { weekday: "long", month: "long", day: "numeric", year: "numeric" });
|
||||
const fullDateLabel = formatFullDate(day);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -181,7 +180,7 @@ export function CalendarMonthView({
|
||||
onDragLeave={handleCellDragLeave}
|
||||
onDrop={(e) => handleCellDrop(e, day)}
|
||||
className={cn(
|
||||
"border-r border-border last:border-r-0 p-1 cursor-pointer transition-colors touch-manipulation",
|
||||
"border-e border-border last:border-e-0 p-1 cursor-pointer transition-colors touch-manipulation",
|
||||
!inMonth && "bg-muted/30",
|
||||
"hover:bg-muted/50",
|
||||
selected && isMobile && "bg-primary/10",
|
||||
@@ -199,7 +198,7 @@ export function CalendarMonthView({
|
||||
inMonth && !selected && !today && "font-medium"
|
||||
)}
|
||||
>
|
||||
{format(day, "d")}
|
||||
{formatDayNumber(day)}
|
||||
</span>
|
||||
</div>
|
||||
{isMobile ? (
|
||||
@@ -219,7 +218,7 @@ export function CalendarMonthView({
|
||||
{dayEvents.length > 3 && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/40" />
|
||||
)}
|
||||
{pendingPreview && isSameDay(pendingPreview.start, day) && (
|
||||
{pendingPreview && checkIsSameDay(pendingPreview.start, day) && (
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-full border border-dashed"
|
||||
style={{ borderColor: calendarMap.get(pendingPreview.calendarId)?.color || "#3b82f6" }}
|
||||
@@ -233,7 +232,7 @@ export function CalendarMonthView({
|
||||
</div>
|
||||
|
||||
{!isMobile && pendingPreview && (() => {
|
||||
const previewDayIdx = week.findIndex(d => isSameDay(d, pendingPreview.start));
|
||||
const previewDayIdx = week.findIndex(d => checkIsSameDay(d, pendingPreview.start));
|
||||
if (previewDayIdx === -1) return null;
|
||||
const previewRow = rowCount;
|
||||
const cal = calendarMap.get(pendingPreview.calendarId);
|
||||
|
||||
@@ -397,7 +397,7 @@ export function CalendarSidebarPanel({
|
||||
<ListTodo className="w-4 h-4 text-muted-foreground" />
|
||||
<span>{t('tasks.label')}</span>
|
||||
{pendingTaskCount > 0 && (
|
||||
<span className="ml-auto text-xs text-muted-foreground">{pendingTaskCount}</span>
|
||||
<span className="ms-auto text-xs text-muted-foreground">{pendingTaskCount}</span>
|
||||
)}
|
||||
{overdueTaskCount > 0 && (
|
||||
<span className="text-xs text-destructive font-medium">{overdueTaskCount} {t('tasks.filter_overdue').toLowerCase()}</span>
|
||||
@@ -437,7 +437,7 @@ export function CalendarSidebarPanel({
|
||||
onCreateCalendar();
|
||||
}
|
||||
}}
|
||||
className="ml-auto p-0.5 rounded text-muted-foreground/70 opacity-0 group-hover:opacity-100 hover:text-foreground hover:bg-muted transition-colors cursor-pointer"
|
||||
className="ms-auto p-0.5 rounded text-muted-foreground/70 opacity-0 group-hover:opacity-100 hover:text-foreground hover:bg-muted transition-colors cursor-pointer"
|
||||
title={tMgmt('add_calendar')}
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
@@ -445,7 +445,7 @@ export function CalendarSidebarPanel({
|
||||
)}
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="mt-1 pl-3">
|
||||
<div className="mt-1 ps-3">
|
||||
{owned.length > 0 && (
|
||||
<div>
|
||||
<div className="px-1 mb-1 text-[10px] font-medium text-muted-foreground/80 uppercase tracking-wider">
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useTranslations, useFormatter } from "next-intl";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays, Globe, ChevronDown, ArrowLeft, Menu } from "lucide-react";
|
||||
import { addDays, startOfWeek } from "date-fns";
|
||||
import { startOfWeek } from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CalendarViewMode } from "@/stores/calendar-store";
|
||||
import type { Calendar } from "@/lib/jmap/types";
|
||||
import { useCalendarLocale } from "@/hooks/use-calendar-locale";
|
||||
|
||||
interface CalendarToolbarProps {
|
||||
selectedDate: Date;
|
||||
@@ -50,7 +51,14 @@ export function CalendarToolbar({
|
||||
onMenuClick,
|
||||
}: CalendarToolbarProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const formatter = useFormatter();
|
||||
const {
|
||||
weekStartsOn,
|
||||
formatMonthYear,
|
||||
formatMonthYearShort,
|
||||
formatWeekRange,
|
||||
formatWeekRangeShort,
|
||||
formatFullDate,
|
||||
} = useCalendarLocale();
|
||||
const views: CalendarViewMode[] = enableCalendarTasks
|
||||
? ["month", "week", "day", "agenda", "tasks"]
|
||||
: ["month", "week", "day", "agenda"];
|
||||
@@ -72,28 +80,22 @@ export function CalendarToolbar({
|
||||
switch (viewMode) {
|
||||
case "month":
|
||||
return isMobile
|
||||
? formatter.dateTime(selectedDate, { month: "short", year: "numeric" })
|
||||
: formatter.dateTime(selectedDate, { month: "long", year: "numeric" });
|
||||
? formatMonthYearShort(selectedDate)
|
||||
: formatMonthYear(selectedDate);
|
||||
case "week": {
|
||||
const ws = startOfWeek(selectedDate, { weekStartsOn: firstDayOfWeek as 0 | 1 });
|
||||
const we = addDays(ws, 6);
|
||||
if (isMobile) {
|
||||
return `${formatter.dateTime(ws, { month: "short", day: "numeric" })} – ${formatter.dateTime(we, { day: "numeric" })}`;
|
||||
}
|
||||
const sameMonth = ws.getMonth() === we.getMonth();
|
||||
if (sameMonth) {
|
||||
return `${formatter.dateTime(ws, { month: "short", day: "numeric" })} – ${formatter.dateTime(we, { day: "numeric" })}, ${we.getFullYear()}`;
|
||||
}
|
||||
return `${formatter.dateTime(ws, { month: "short", day: "numeric" })} – ${formatter.dateTime(we, { month: "short", day: "numeric" })}, ${we.getFullYear()}`;
|
||||
const ws = startOfWeek(selectedDate, { weekStartsOn });
|
||||
return isMobile
|
||||
? formatWeekRangeShort(ws)
|
||||
: formatWeekRange(ws);
|
||||
}
|
||||
case "day":
|
||||
return isMobile
|
||||
? formatter.dateTime(selectedDate, { weekday: "short", month: "short", day: "numeric" })
|
||||
: formatter.dateTime(selectedDate, { weekday: "long", month: "long", day: "numeric", year: "numeric" });
|
||||
? formatFullDate(selectedDate)
|
||||
: formatFullDate(selectedDate);
|
||||
case "agenda":
|
||||
return isMobile
|
||||
? formatter.dateTime(selectedDate, { month: "short", year: "numeric" })
|
||||
: formatter.dateTime(selectedDate, { month: "long", year: "numeric" });
|
||||
? formatMonthYearShort(selectedDate)
|
||||
: formatMonthYear(selectedDate);
|
||||
case "tasks":
|
||||
return t("views.tasks");
|
||||
}
|
||||
@@ -124,7 +126,7 @@ export function CalendarToolbar({
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onMenuClick}
|
||||
className="h-8 w-8 -ml-1 mr-1"
|
||||
className="h-8 w-8 -ms-1 me-1"
|
||||
aria-label={t("nav_open_menu")}
|
||||
>
|
||||
<Menu className="w-4 h-4" />
|
||||
@@ -138,7 +140,7 @@ export function CalendarToolbar({
|
||||
{onMenuClick && (
|
||||
<button
|
||||
onClick={onMenuClick}
|
||||
className="p-1.5 -ml-1 rounded-md hover:bg-muted transition-colors touch-manipulation"
|
||||
className="p-1.5 -ms-1 rounded-md hover:bg-muted transition-colors touch-manipulation"
|
||||
aria-label={t("nav_open_menu")}
|
||||
>
|
||||
<Menu className="w-4 h-4" />
|
||||
@@ -147,7 +149,7 @@ export function CalendarToolbar({
|
||||
{onNavigateBack && (
|
||||
<button
|
||||
onClick={onNavigateBack}
|
||||
className="p-1.5 -ml-1 rounded-md hover:bg-muted transition-colors touch-manipulation"
|
||||
className="p-1.5 -ms-1 rounded-md hover:bg-muted transition-colors touch-manipulation"
|
||||
aria-label={t("back_to_month")}
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
@@ -162,7 +164,7 @@ export function CalendarToolbar({
|
||||
<button onClick={onNext} className="p-1.5 rounded-md hover:bg-muted transition-colors touch-manipulation" aria-label={t("nav_next")}>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
<Button variant="ghost" size="sm" onClick={onToday} className="touch-manipulation text-xs h-7 px-2 ml-0.5">
|
||||
<Button variant="ghost" size="sm" onClick={onToday} className="touch-manipulation text-xs h-7 px-2 ms-0.5">
|
||||
{t("views.today")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -284,7 +286,7 @@ export function CalendarToolbar({
|
||||
{/* ── DESKTOP TOOLBAR ── */}
|
||||
{!isMobile && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="outline" size="sm" onClick={onToday} className="h-8 mr-1">
|
||||
<Button variant="outline" size="sm" onClick={onToday} className="h-8 me-1">
|
||||
{t("views.today")}
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onPrev} aria-label={t("nav_prev")}>
|
||||
@@ -293,7 +295,7 @@ export function CalendarToolbar({
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onNext} aria-label={t("nav_next")}>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
<span className="text-base font-semibold ml-2 select-none">
|
||||
<span className="text-base font-semibold ms-2 select-none">
|
||||
{getDateLabel()}
|
||||
</span>
|
||||
</div>
|
||||
@@ -328,9 +330,9 @@ export function CalendarToolbar({
|
||||
{(onImport || onSubscribe) && !isMobile && (
|
||||
<div className="relative" ref={importDropdownRef}>
|
||||
<Button variant="outline" size="sm" className="h-8" onClick={() => setShowImportDropdown((v) => !v)}>
|
||||
<Upload className="w-4 h-4 mr-1" />
|
||||
<Upload className="w-4 h-4 me-1" />
|
||||
{t("import.title")}
|
||||
<ChevronDown className="w-3 h-3 ml-1" />
|
||||
<ChevronDown className="w-3 h-3 ms-1" />
|
||||
</Button>
|
||||
{showImportDropdown && (
|
||||
<div className="absolute top-full right-0 mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-1 min-w-[180px]">
|
||||
@@ -359,7 +361,7 @@ export function CalendarToolbar({
|
||||
|
||||
{!isMobile && (
|
||||
<Button size="sm" className="h-8" onClick={onCreateEvent} data-tour="create-event-button">
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
<Plus className="w-4 h-4 me-1" />
|
||||
{t("events.create")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -58,7 +58,7 @@ export function CalendarWeekView({
|
||||
const intlFormatter = useFormatter();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const weekStart = (firstDayOfWeek === 0 ? 0 : 1) as 0 | 1;
|
||||
const weekStart = (firstDayOfWeek === 0 ? 0 : firstDayOfWeek === 6 ? 6 : 1) as 0 | 1 | 6;
|
||||
|
||||
const weekDays = useMemo(() => {
|
||||
const start = startOfWeek(selectedDate, { weekStartsOn: weekStart });
|
||||
@@ -211,7 +211,7 @@ export function CalendarWeekView({
|
||||
<div className={cn("flex min-h-0 flex-col flex-1", isMobile && "min-w-[880px]")}> {hasAllDay && (
|
||||
<div className="flex border-b border-border">
|
||||
<div
|
||||
className={cn("flex-shrink-0 text-[10px] text-muted-foreground p-1 text-right", isMobile ? "w-10 sticky left-0 z-10 bg-background" : "w-14")}
|
||||
className={cn("flex-shrink-0 text-[10px] text-muted-foreground p-1 text-end", isMobile ? "w-10 sticky left-0 z-10 bg-background" : "w-14")}
|
||||
style={{ minHeight: Math.max(28, (allDayRowCount + taskRowCount) * 24 + 4) }}
|
||||
>
|
||||
{t("events.all_day")}
|
||||
@@ -306,7 +306,7 @@ export function CalendarWeekView({
|
||||
|
||||
<div className="flex border-b border-border" role="row">
|
||||
<div className={cn("flex-shrink-0", isMobile ? "w-10 sticky left-0 z-10 bg-background" : "w-14")} />
|
||||
<div className="flex-1 border-l border-border grid grid-cols-7">
|
||||
<div className="flex-1 border-s border-border grid grid-cols-7">
|
||||
{weekDays.map((day) => {
|
||||
const todayCol = isToday(day);
|
||||
const selected = isSameDay(day, selectedDate);
|
||||
@@ -318,7 +318,7 @@ export function CalendarWeekView({
|
||||
role="columnheader"
|
||||
aria-label={fullLabel}
|
||||
className={cn(
|
||||
"text-center py-2 text-sm border-r border-border last:border-r-0 transition-colors touch-manipulation",
|
||||
"text-center py-2 text-sm border-e border-border last:border-e-0 transition-colors touch-manipulation",
|
||||
"hover:bg-muted/50",
|
||||
todayCol && "font-bold",
|
||||
)}
|
||||
@@ -345,7 +345,7 @@ export function CalendarWeekView({
|
||||
{HOURS.map((h) => (
|
||||
<div
|
||||
key={h}
|
||||
className="relative text-muted-foreground text-right pr-2"
|
||||
className="relative text-muted-foreground text-end pe-2"
|
||||
style={{ height: HOUR_HEIGHT }}
|
||||
>
|
||||
{h > 0 && (
|
||||
@@ -357,7 +357,7 @@ export function CalendarWeekView({
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 border-l border-border relative grid grid-cols-7">
|
||||
<div className="flex-1 border-s border-border relative grid grid-cols-7">
|
||||
{weekDays.map((day) => {
|
||||
const key = format(day, "yyyy-MM-dd");
|
||||
const dayEvents = timedEvents.get(key) || [];
|
||||
@@ -367,7 +367,7 @@ export function CalendarWeekView({
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="relative border-r border-border last:border-r-0"
|
||||
className="relative border-e border-border last:border-e-0"
|
||||
role="row"
|
||||
aria-label={intlFormatter.dateTime(day, { weekday: "long", month: "long", day: "numeric" })}
|
||||
onPointerDown={(e) => handleGridPointerDown(e, key, day)}
|
||||
@@ -448,7 +448,7 @@ export function CalendarWeekView({
|
||||
style={{ top: (nowMinutes / 60) * HOUR_HEIGHT }}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className="w-2 h-2 rounded-full bg-destructive -ml-1" />
|
||||
<div className="w-2 h-2 rounded-full bg-destructive -ms-1" />
|
||||
<div className="flex-1 h-px bg-destructive" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -482,7 +482,7 @@ export function CalendarWeekView({
|
||||
style={{ top: (dropTarget.minutes / 60) * HOUR_HEIGHT }}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className="w-2 h-2 rounded-full bg-primary -ml-1" />
|
||||
<div className="w-2 h-2 rounded-full bg-primary -ms-1" />
|
||||
<div className="flex-1 h-0.5 bg-primary rounded-full" />
|
||||
</div>
|
||||
<div className="absolute -top-4 left-2 text-[10px] font-medium text-primary bg-background/90 px-1 rounded shadow-sm">
|
||||
|
||||
@@ -137,7 +137,7 @@ export function CreateCalendarModal({ client, onClose }: CreateCalendarModalProp
|
||||
<Button onClick={handleSubmit} disabled={!isValid || isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||
<Loader2 className="w-4 h-4 animate-spin me-2" />
|
||||
{tCommon("loading")}
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -88,7 +88,10 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
||||
try { return format(d, fmt); } catch { return "--:--"; }
|
||||
};
|
||||
const timeString = `${safeFormat(startDate, timeFmt)} – ${safeFormat(endTime, timeFmt)}`;
|
||||
const ariaLabel = `${event.title || t("events.no_title")}, ${timeString}${calendarName ? `, ${calendarName}` : ""}`;
|
||||
// iTIP CANCEL marks the attendee's copy with status "cancelled" instead of
|
||||
// deleting it (#572) - render it struck through and dimmed.
|
||||
const isCancelled = event.status === "cancelled";
|
||||
const ariaLabel = `${event.title || t("events.no_title")}, ${timeString}${calendarName ? `, ${calendarName}` : ""}${isCancelled ? `, ${t("detail.cancelled")}` : ""}`;
|
||||
|
||||
const handleDragStart = useCallback((e: DragEvent) => {
|
||||
e.stopPropagation();
|
||||
@@ -131,11 +134,12 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
||||
aria-label={ariaLabel}
|
||||
{...dragProps}
|
||||
className={cn(
|
||||
"flex items-center gap-1 w-full text-left text-xs px-1 py-0.5 rounded truncate",
|
||||
"flex items-center gap-1 w-full text-start text-xs px-1 py-0.5 rounded truncate",
|
||||
"min-h-[44px] sm:min-h-0",
|
||||
"hover:opacity-80 transition-opacity",
|
||||
isSelected && "ring-2 ring-primary",
|
||||
isBeingDragged && "opacity-50",
|
||||
isCancelled && !isBeingDragged && "opacity-60",
|
||||
className
|
||||
)}
|
||||
style={{ backgroundColor: `${color}20`, color, ...style }}
|
||||
@@ -144,7 +148,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
||||
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
<span className="truncate">{event.title || t("events.no_title")}</span>
|
||||
<span className={cn("truncate", isCancelled && "line-through")}>{event.title || t("events.no_title")}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -159,12 +163,13 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
||||
aria-label={ariaLabel}
|
||||
{...dragProps}
|
||||
className={cn(
|
||||
"w-full h-full text-left rounded-r px-1.5 py-0.5 text-xs overflow-hidden",
|
||||
"w-full h-full text-start rounded-r px-1.5 py-0.5 text-xs overflow-hidden",
|
||||
"hover:opacity-90 transition-opacity cursor-pointer",
|
||||
continuesAfter && "rounded-r-sm",
|
||||
continuesAfter && "pr-2",
|
||||
continuesAfter && "pe-2",
|
||||
isSelected && "ring-2 ring-primary",
|
||||
isBeingDragged && "opacity-50",
|
||||
isCancelled && !isBeingDragged && "opacity-60",
|
||||
className
|
||||
)}
|
||||
style={{ backgroundColor: `${color}24`, borderLeft: `3px solid ${color}`, color, ...style }}
|
||||
@@ -173,7 +178,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
||||
{showTimeInMonthView && !event.showWithoutTime && (
|
||||
<span className="flex-shrink-0 opacity-80">{format(startDate, timeFmt)}</span>
|
||||
)}
|
||||
<span className="truncate font-medium">{event.title || t("events.no_title")}</span>
|
||||
<span className={cn("truncate font-medium", isCancelled && "line-through")}>{event.title || t("events.no_title")}</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
@@ -189,15 +194,16 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
||||
{...dragProps}
|
||||
data-calendar-event
|
||||
className={cn(
|
||||
"w-full h-full text-left rounded-r px-1.5 py-0.5 text-xs overflow-hidden",
|
||||
"w-full h-full text-start rounded-r px-1.5 py-0.5 text-xs overflow-hidden",
|
||||
"hover:opacity-90 transition-opacity cursor-pointer",
|
||||
isSelected && "ring-2 ring-primary",
|
||||
isBeingDragged && "opacity-50",
|
||||
isCancelled && !isBeingDragged && "opacity-60",
|
||||
className
|
||||
)}
|
||||
style={{ backgroundColor: `${color}30`, borderLeft: `3px solid ${color}`, color, ...style }}
|
||||
>
|
||||
<div className="font-medium truncate">{event.title || t("events.no_title")}</div>
|
||||
<div className={cn("font-medium truncate", isCancelled && "line-through")}>{event.title || t("events.no_title")}</div>
|
||||
{!event.showWithoutTime && (
|
||||
<div className="opacity-80 text-[10px]">
|
||||
{timeString}
|
||||
|
||||
@@ -290,20 +290,23 @@ export function EventDetailPopover({
|
||||
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
<h3 className="text-base font-semibold truncate text-foreground">
|
||||
<h3 className={cn(
|
||||
"text-base font-semibold truncate text-foreground",
|
||||
event.status === "cancelled" && "line-through text-muted-foreground"
|
||||
)}>
|
||||
{event.title || t("events.no_title")}
|
||||
</h3>
|
||||
</div>
|
||||
{calendar && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5 pl-[18px]">
|
||||
<p className="text-xs text-muted-foreground mt-0.5 ps-[18px]">
|
||||
{calendar.name}
|
||||
{event.status === "tentative" && (
|
||||
<span className="ml-2 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-warning/15 text-warning">
|
||||
<span className="ms-2 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-warning/15 text-warning">
|
||||
{t("detail.tentative")}
|
||||
</span>
|
||||
)}
|
||||
{event.status === "cancelled" && (
|
||||
<span className="ml-2 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400 line-through">
|
||||
<span className="ms-2 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400 line-through">
|
||||
{t("detail.cancelled")}
|
||||
</span>
|
||||
)}
|
||||
@@ -343,13 +346,13 @@ export function EventDetailPopover({
|
||||
<>
|
||||
<div className="font-medium text-foreground">
|
||||
{formatEventDate(startDate)}
|
||||
<span className="ml-1.5 font-normal text-muted-foreground">
|
||||
<span className="ms-1.5 font-normal text-muted-foreground">
|
||||
{formatTime(startDate)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="font-medium text-foreground">
|
||||
{formatEventDate(endDate)}
|
||||
<span className="ml-1.5 font-normal text-muted-foreground">
|
||||
<span className="ms-1.5 font-normal text-muted-foreground">
|
||||
{formatTime(endDate)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -364,11 +367,11 @@ export function EventDetailPopover({
|
||||
{formatEventDate(startDate)}
|
||||
</span>
|
||||
{event.showWithoutTime ? (
|
||||
<span className="text-muted-foreground ml-1.5">{t("events.all_day")}</span>
|
||||
<span className="text-muted-foreground ms-1.5">{t("events.all_day")}</span>
|
||||
) : (
|
||||
<div className="text-muted-foreground">
|
||||
{formatTime(startDate)} – {formatTime(endDate)}
|
||||
<span className="ml-1.5 text-xs">({formatDurationDisplay(durationMinutes)})</span>
|
||||
<span className="ms-1.5 text-xs">({formatDurationDisplay(durationMinutes)})</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@@ -434,7 +437,7 @@ export function EventDetailPopover({
|
||||
<span className="truncate text-foreground">
|
||||
{p.name || p.email}
|
||||
{p.isOrganizer && (
|
||||
<span className="text-muted-foreground ml-1">
|
||||
<span className="text-muted-foreground ms-1">
|
||||
({t("participants.organizer").toLowerCase()})
|
||||
</span>
|
||||
)}
|
||||
@@ -512,7 +515,7 @@ export function EventDetailPopover({
|
||||
disabled={!noteText.trim() || isSavingNote}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
<Send className="w-3 h-3 mr-1" />
|
||||
<Send className="w-3 h-3 me-1" />
|
||||
{t("detail.save_note")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -546,7 +549,7 @@ export function EventDetailPopover({
|
||||
: "text-success border-success/30 hover:bg-success/10"
|
||||
}
|
||||
>
|
||||
{userCurrentStatus === "accepted" && <Check className="w-3.5 h-3.5 mr-1" />}
|
||||
{userCurrentStatus === "accepted" && <Check className="w-3.5 h-3.5 me-1" />}
|
||||
{t("participants.accepted")}
|
||||
</Button>
|
||||
<Button
|
||||
@@ -559,7 +562,7 @@ export function EventDetailPopover({
|
||||
: "border border-warning/30 text-warning hover:bg-warning/10"
|
||||
}
|
||||
>
|
||||
{userCurrentStatus === "tentative" && <Check className="w-3.5 h-3.5 mr-1" />}
|
||||
{userCurrentStatus === "tentative" && <Check className="w-3.5 h-3.5 me-1" />}
|
||||
{t("participants.tentative")}
|
||||
</Button>
|
||||
<Button
|
||||
@@ -572,7 +575,7 @@ export function EventDetailPopover({
|
||||
: "text-destructive hover:bg-destructive/10"
|
||||
}
|
||||
>
|
||||
{userCurrentStatus === "declined" && <Check className="w-3.5 h-3.5 mr-1" />}
|
||||
{userCurrentStatus === "declined" && <Check className="w-3.5 h-3.5 me-1" />}
|
||||
{t("participants.declined")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -606,7 +609,7 @@ export function EventDetailPopover({
|
||||
) : (
|
||||
<>
|
||||
<Button variant="default" size="sm" onClick={onEdit} className="h-7 text-xs">
|
||||
<Pencil className="w-3.5 h-3.5 mr-1" />
|
||||
<Pencil className="w-3.5 h-3.5 me-1" />
|
||||
{t("events.edit")}
|
||||
</Button>
|
||||
<Button
|
||||
@@ -616,7 +619,7 @@ export function EventDetailPopover({
|
||||
className="h-7 text-xs"
|
||||
title={t("events.duplicate")}
|
||||
>
|
||||
<Copy className="w-3.5 h-3.5 mr-1" />
|
||||
<Copy className="w-3.5 h-3.5 me-1" />
|
||||
{t("events.duplicate")}
|
||||
</Button>
|
||||
<div className="flex-1" />
|
||||
|
||||
@@ -522,10 +522,16 @@ export function EventModal({
|
||||
{ name: organizerName, email: organizerEmail },
|
||||
effectiveAttendees
|
||||
) as Record<string, CalendarParticipant>;
|
||||
data.replyTo = { imip: `mailto:${organizerEmail}` };
|
||||
// Stalwart (calcard) derives the iCalendar ORGANIZER property solely from
|
||||
// organizerCalendarAddress; without it no ORGANIZER is emitted and iTIP
|
||||
// scheduling is silently skipped (NoSchedulingInfo), so no invites are sent.
|
||||
// The RFC 8984 replyTo property is retired in jscalendarbis and ignored.
|
||||
data.organizerCalendarAddress = `mailto:${organizerEmail}`;
|
||||
} else if (effectiveAttendees.length === 0 && event?.participants) {
|
||||
data.participants = null;
|
||||
// Also clear the retired replyTo that older releases (<= 1.7.6) wrote.
|
||||
data.replyTo = null;
|
||||
data.organizerCalendarAddress = null;
|
||||
}
|
||||
|
||||
const shouldSendScheduling = effectiveAttendees.length > 0 && sendInvitations;
|
||||
@@ -660,11 +666,11 @@ export function EventModal({
|
||||
<div className="text-sm">
|
||||
<div>
|
||||
<span className="font-medium">{formatEventDate(startD)}</span>
|
||||
<span className="text-muted-foreground ml-2">{format(startD, timeDisplayFmt)}</span>
|
||||
<span className="text-muted-foreground ms-2">{format(startD, timeDisplayFmt)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">{formatEventDate(endD)}</span>
|
||||
<span className="text-muted-foreground ml-2">{format(endD, timeDisplayFmt)}</span>
|
||||
<span className="text-muted-foreground ms-2">{format(endD, timeDisplayFmt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -673,7 +679,7 @@ export function EventModal({
|
||||
<div className="text-sm">
|
||||
<span className="font-medium">{formatEventDate(startD)}</span>
|
||||
{!event.showWithoutTime && (
|
||||
<span className="text-muted-foreground ml-2">
|
||||
<span className="text-muted-foreground ms-2">
|
||||
{format(startD, timeDisplayFmt)} – {format(endD, timeDisplayFmt)}
|
||||
</span>
|
||||
)}
|
||||
@@ -695,7 +701,7 @@ export function EventModal({
|
||||
<Users className="w-4 h-4" />
|
||||
{t("participants.title")}
|
||||
</div>
|
||||
<div className="space-y-1 pl-5">
|
||||
<div className="space-y-1 ps-5">
|
||||
{participants.map(p => (
|
||||
<div key={p.id} className="flex items-center justify-between text-sm">
|
||||
<span className="truncate">{p.name || p.email}</span>
|
||||
@@ -720,7 +726,7 @@ export function EventModal({
|
||||
? "bg-success hover:bg-success/80 text-success-foreground"
|
||||
: "text-success border-success/30 hover:bg-success/10"}
|
||||
>
|
||||
{userCurrentStatus === "accepted" && <Check className="w-4 h-4 mr-1" />}
|
||||
{userCurrentStatus === "accepted" && <Check className="w-4 h-4 me-1" />}
|
||||
{t("participants.accepted")}
|
||||
</Button>
|
||||
<Button
|
||||
@@ -731,7 +737,7 @@ export function EventModal({
|
||||
? "bg-warning hover:bg-warning/80 text-warning-foreground"
|
||||
: "border border-warning/30 text-warning hover:bg-warning/10"}
|
||||
>
|
||||
{userCurrentStatus === "tentative" && <Check className="w-4 h-4 mr-1" />}
|
||||
{userCurrentStatus === "tentative" && <Check className="w-4 h-4 me-1" />}
|
||||
{t("participants.tentative")}
|
||||
</Button>
|
||||
<Button
|
||||
@@ -742,7 +748,7 @@ export function EventModal({
|
||||
? "bg-destructive hover:bg-destructive/80 text-destructive-foreground"
|
||||
: "text-destructive hover:bg-destructive/10"}
|
||||
>
|
||||
{userCurrentStatus === "declined" && <Check className="w-4 h-4 mr-1" />}
|
||||
{userCurrentStatus === "declined" && <Check className="w-4 h-4 me-1" />}
|
||||
{t("participants.declined")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -778,7 +784,7 @@ export function EventModal({
|
||||
<h2 className="text-lg font-semibold truncate">{event.title || t("events.no_title")}</h2>
|
||||
</div>
|
||||
{eventCalendar && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5 pl-[18px]">{eventCalendar.name}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 ps-[18px]">{eventCalendar.name}</p>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 flex-shrink-0 mt-0.5 text-muted-foreground hover:text-foreground" aria-label={t("form.cancel")}>
|
||||
@@ -810,13 +816,13 @@ export function EventModal({
|
||||
<>
|
||||
<div className="font-medium text-foreground">
|
||||
{formatEventDate(startD)}
|
||||
<span className="ml-1.5 font-normal text-muted-foreground">
|
||||
<span className="ms-1.5 font-normal text-muted-foreground">
|
||||
{format(startD, timeDisplayFmt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="font-medium text-foreground">
|
||||
{formatEventDate(endD)}
|
||||
<span className="ml-1.5 font-normal text-muted-foreground">
|
||||
<span className="ms-1.5 font-normal text-muted-foreground">
|
||||
{format(endD, timeDisplayFmt)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -832,11 +838,11 @@ export function EventModal({
|
||||
{formatEventDate(startD)}
|
||||
</span>
|
||||
{event.showWithoutTime ? (
|
||||
<span className="text-muted-foreground ml-1.5">{t("events.all_day")}</span>
|
||||
<span className="text-muted-foreground ms-1.5">{t("events.all_day")}</span>
|
||||
) : (
|
||||
<div className="text-muted-foreground">
|
||||
{format(startD, timeDisplayFmt)} – {format(endD, timeDisplayFmt)}
|
||||
<span className="ml-1.5 text-xs">({formatDurationDisplay(durMin)})</span>
|
||||
<span className="ms-1.5 text-xs">({formatDurationDisplay(durMin)})</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@@ -883,7 +889,7 @@ export function EventModal({
|
||||
<span className="truncate text-foreground">
|
||||
{p.name || p.email}
|
||||
{p.isOrganizer && (
|
||||
<span className="text-muted-foreground ml-1">({t("participants.organizer").toLowerCase()})</span>
|
||||
<span className="text-muted-foreground ms-1">({t("participants.organizer").toLowerCase()})</span>
|
||||
)}
|
||||
</span>
|
||||
<StatusBadge status={p.status} isOrganizer={p.isOrganizer} t={t} />
|
||||
@@ -936,21 +942,21 @@ export function EventModal({
|
||||
</div>
|
||||
) : (
|
||||
<Button variant="ghost" size="sm" onClick={() => setShowDeleteConfirm(true)} className="text-destructive">
|
||||
<Trash2 className="w-4 h-4 mr-1" />
|
||||
<Trash2 className="w-4 h-4 me-1" />
|
||||
{t("events.delete")}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
{onDuplicate && !showDeleteConfirm && (
|
||||
<Button variant="ghost" size="sm" onClick={handleDuplicate} aria-label={t("events.duplicate")}>
|
||||
<Copy className="w-4 h-4 mr-1" />
|
||||
<Copy className="w-4 h-4 me-1" />
|
||||
{t("events.duplicate")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{!showDeleteConfirm && (
|
||||
<Button onClick={() => setMode("edit")}>
|
||||
<Pencil className="w-4 h-4 mr-1" />
|
||||
<Pencil className="w-4 h-4 me-1" />
|
||||
{t("events.edit")}
|
||||
</Button>
|
||||
)}
|
||||
@@ -1314,7 +1320,7 @@ export function EventModal({
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="text-red-600 dark:text-red-400"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-1" />
|
||||
<Trash2 className="w-4 h-4 me-1" />
|
||||
{t("events.delete")}
|
||||
</Button>
|
||||
)
|
||||
@@ -1326,7 +1332,7 @@ export function EventModal({
|
||||
onClick={handleDuplicate}
|
||||
aria-label={t("events.duplicate")}
|
||||
>
|
||||
<Copy className="w-4 h-4 mr-1" />
|
||||
<Copy className="w-4 h-4 me-1" />
|
||||
{t("events.duplicate")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -444,7 +444,7 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal
|
||||
onClick={handleImport}
|
||||
disabled={selectedIndices.size === 0}
|
||||
>
|
||||
<Check className="w-4 h-4 mr-1" />
|
||||
<Check className="w-4 h-4 me-1" />
|
||||
{t("import_button")} ({selectedIndices.size})
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -208,7 +208,7 @@ export function ICalSubscriptionModal({ client, onClose, editSubscription, initi
|
||||
<Button onClick={handleSubmit} disabled={!isValid || isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||
<Loader2 className="w-4 h-4 animate-spin me-2" />
|
||||
{isEdit ? t("saving") : t("subscribing")}
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -1,25 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, Fragment } from "react";
|
||||
import { useTranslations, useFormatter } from "next-intl";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ChevronLeft, ChevronRight, ChevronDown } from "lucide-react";
|
||||
import {
|
||||
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
|
||||
addMonths, subMonths, addYears, subYears, setMonth, setYear,
|
||||
eachDayOfInterval, getMonth, getYear, getISOWeek, getWeek,
|
||||
isSameDay, isSameMonth, isToday, format,
|
||||
getISOWeek, getWeek, format,
|
||||
} from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getEventDayBounds } from "@/lib/calendar-utils";
|
||||
import type { CalendarEvent } from "@/lib/jmap/types";
|
||||
import { useCalendarLocale } from "@/hooks/use-calendar-locale";
|
||||
|
||||
type PickerView = "days" | "months" | "years";
|
||||
|
||||
const MONTH_LABELS = [
|
||||
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||||
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
||||
];
|
||||
|
||||
interface MiniCalendarProps {
|
||||
selectedDate: Date;
|
||||
displayMonth: Date;
|
||||
@@ -40,17 +34,25 @@ export function MiniCalendar({
|
||||
showWeekNumbers = false,
|
||||
}: MiniCalendarProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const intlFormatter = useFormatter();
|
||||
const weekStart = (firstDayOfWeek === 0 ? 0 : 1) as 0 | 1;
|
||||
const {
|
||||
weekStartsOn,
|
||||
dayHeaderKeys,
|
||||
getMonthGridDays,
|
||||
checkIsToday,
|
||||
checkIsSameMonth,
|
||||
checkIsSameDay,
|
||||
formatDayNumber,
|
||||
formatMonthYear,
|
||||
getMonth,
|
||||
getYear,
|
||||
monthLabelKeys,
|
||||
} = useCalendarLocale();
|
||||
const [pickerView, setPickerView] = useState<PickerView>("days");
|
||||
|
||||
const days = useMemo(() => {
|
||||
const monthStart = startOfMonth(displayMonth);
|
||||
const monthEnd = endOfMonth(displayMonth);
|
||||
const gridStart = startOfWeek(monthStart, { weekStartsOn: weekStart });
|
||||
const gridEnd = endOfWeek(monthEnd, { weekStartsOn: weekStart });
|
||||
return eachDayOfInterval({ start: gridStart, end: gridEnd });
|
||||
}, [displayMonth, weekStart]);
|
||||
const days = useMemo(
|
||||
() => getMonthGridDays(displayMonth),
|
||||
[displayMonth, getMonthGridDays],
|
||||
);
|
||||
|
||||
const eventDates = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
@@ -67,20 +69,16 @@ export function MiniCalendar({
|
||||
return set;
|
||||
}, [events]);
|
||||
|
||||
const dayHeaders = firstDayOfWeek === 0
|
||||
? ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const
|
||||
: ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] as const;
|
||||
|
||||
// Compute week numbers for each row (one per 7-day chunk)
|
||||
const weekNumbers = useMemo(() => {
|
||||
if (!showWeekNumbers) return [];
|
||||
const nums: number[] = [];
|
||||
for (let i = 0; i < days.length; i += 7) {
|
||||
// Use the first day of each row to determine the week number
|
||||
nums.push(weekStart === 1 ? getISOWeek(days[i]) : getWeek(days[i], { weekStartsOn: 0 }));
|
||||
nums.push(weekStartsOn === 1 ? getISOWeek(days[i]) : getWeek(days[i], { weekStartsOn: 0 }));
|
||||
}
|
||||
return nums;
|
||||
}, [days, showWeekNumbers, weekStart]);
|
||||
}, [days, showWeekNumbers, weekStartsOn]);
|
||||
|
||||
const currentYear = getYear(displayMonth);
|
||||
const currentMonth = getMonth(displayMonth);
|
||||
@@ -116,7 +114,7 @@ export function MiniCalendar({
|
||||
|
||||
const headerLabel =
|
||||
pickerView === "days"
|
||||
? intlFormatter.dateTime(displayMonth, { month: "long", year: "numeric" })
|
||||
? formatMonthYear(displayMonth)
|
||||
: pickerView === "months"
|
||||
? String(currentYear)
|
||||
: `${decadeStart}\u2013${decadeStart + 9}`;
|
||||
@@ -160,15 +158,15 @@ export function MiniCalendar({
|
||||
{showWeekNumbers && (
|
||||
<div className="text-center text-[10px] font-medium text-muted-foreground py-1 w-5" />
|
||||
)}
|
||||
{dayHeaders.map((d) => (
|
||||
{dayHeaderKeys.map((d) => (
|
||||
<div key={d} className="text-center text-[10px] font-medium text-muted-foreground py-1">
|
||||
{t(`days.${d}`)}
|
||||
</div>
|
||||
))}
|
||||
{days.map((day, index) => {
|
||||
const inMonth = isSameMonth(day, displayMonth);
|
||||
const selected = isSameDay(day, selectedDate);
|
||||
const today = isToday(day);
|
||||
const inMonth = checkIsSameMonth(day, displayMonth);
|
||||
const selected = checkIsSameDay(day, selectedDate);
|
||||
const today = checkIsToday(day);
|
||||
const hasEvent = eventDates.has(format(day, "yyyy-MM-dd"));
|
||||
const isFirstDayOfRow = index % 7 === 0;
|
||||
|
||||
@@ -193,7 +191,7 @@ export function MiniCalendar({
|
||||
selected && "bg-primary text-primary-foreground"
|
||||
)}
|
||||
>
|
||||
{format(day, "d")}
|
||||
{formatDayNumber(day)}
|
||||
{hasEvent && !selected && (
|
||||
<span className="absolute bottom-0.5 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-primary" />
|
||||
)}
|
||||
@@ -206,7 +204,7 @@ export function MiniCalendar({
|
||||
|
||||
{pickerView === "months" && (
|
||||
<div className="grid grid-cols-3 gap-1 py-1">
|
||||
{MONTH_LABELS.map((label, i) => {
|
||||
{monthLabelKeys.map((labelKey, i) => {
|
||||
const isCurrentMonth = i === currentMonth && currentYear === getYear(new Date());
|
||||
const isSelected = i === getMonth(selectedDate) && currentYear === getYear(selectedDate);
|
||||
return (
|
||||
@@ -220,7 +218,7 @@ export function MiniCalendar({
|
||||
!isSelected && !isCurrentMonth && "hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
{t(`months.${labelKey}`)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -302,7 +302,7 @@ export function TaskModal({
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => onDelete(task.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-1" />
|
||||
<Trash2 className="h-4 w-4 me-1" />
|
||||
{t("tasks.delete")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -44,7 +44,7 @@ export function TaskToolbar({
|
||||
))}
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer select-none ml-2">
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer select-none ms-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showCompleted}
|
||||
@@ -57,7 +57,7 @@ export function TaskToolbar({
|
||||
<div className="flex-1" />
|
||||
|
||||
<Button size="sm" onClick={onCreateTask}>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
<Plus className="w-4 h-4 me-1" />
|
||||
{t("tasks.create")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -62,6 +62,27 @@ describe('ContactForm', () => {
|
||||
expect(phoneAfter.length).toBe(phoneBefore.length + 1);
|
||||
});
|
||||
|
||||
it('sends media: null when an existing photo is removed', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
const contactWithPhoto: ContactCard = {
|
||||
...existingContact,
|
||||
media: {
|
||||
photo: { kind: 'photo', uri: 'data:image/png;base64,AAAA', mediaType: 'image/png' },
|
||||
},
|
||||
};
|
||||
render(<ContactForm contact={contactWithPhoto} onSave={onSave} onCancel={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByText('remove_photo'));
|
||||
fireEvent.submit(screen.getByText('save').closest('form')!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSave).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
const savedData = onSave.mock.calls[0][0];
|
||||
expect(savedData.media).toBeNull();
|
||||
});
|
||||
|
||||
it('submits form data correctly', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<ContactForm onSave={onSave} onCancel={vi.fn()} />);
|
||||
|
||||
@@ -229,7 +229,7 @@ export function ContactActivity({ contact }: ContactActivityProps) {
|
||||
key={email.id}
|
||||
type="button"
|
||||
onClick={() => handleOpenEmail(email)}
|
||||
className="w-full text-left flex items-start gap-3 px-2 py-2 rounded-md hover:bg-muted/60 transition-colors touch-manipulation"
|
||||
className="w-full text-start flex items-start gap-3 px-2 py-2 rounded-md hover:bg-muted/60 transition-colors touch-manipulation"
|
||||
>
|
||||
<Avatar name={sender.name} email={sender.address} size="sm" />
|
||||
<div className="flex-1 min-w-0">
|
||||
@@ -277,7 +277,7 @@ export function ContactActivity({ contact }: ContactActivityProps) {
|
||||
key={event.id}
|
||||
type="button"
|
||||
onClick={() => handleOpenEvent(event)}
|
||||
className="w-full text-left flex items-baseline gap-3 px-2 py-2 rounded-md hover:bg-muted/60 transition-colors touch-manipulation"
|
||||
className="w-full text-start flex items-baseline gap-3 px-2 py-2 rounded-md hover:bg-muted/60 transition-colors touch-manipulation"
|
||||
>
|
||||
<span className="text-xs text-muted-foreground tabular-nums w-20 flex-shrink-0">
|
||||
{formatEventTime(event)}
|
||||
|
||||
@@ -2,16 +2,13 @@
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Copy, Send, Globe, Cake, KeyRound, Users, Briefcase, Heart, Languages, Calendar, UserCircle, ShieldCheck, ShieldAlert, Download, MoreHorizontal, Printer } from "lucide-react";
|
||||
import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Copy, Send, Globe, Cake, KeyRound, Users, Briefcase, Heart, Languages, Calendar, UserCircle, Download, MoreHorizontal, Printer } from "lucide-react";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ContactCard, AnniversaryDate, PartialDate } from "@/lib/jmap/types";
|
||||
import { getContactDisplayName, getContactPrimaryEmail, getContactPhotoUri } from "@/stores/contact-store";
|
||||
import { ContactActivity } from "./contact-activity";
|
||||
import { useSmimeStore } from "@/stores/smime-store";
|
||||
import { parseCertificatePemOrDer, extractCertificateInfo } from "@/lib/smime/certificate-utils";
|
||||
import type { CertificateInfo } from "@/lib/smime/types";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { exportContact } from "./contact-export";
|
||||
import { printContact } from "./contact-print";
|
||||
@@ -119,49 +116,9 @@ function formatDate(dateInput: AnniversaryDate): string {
|
||||
|
||||
export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDuplicate, onCompose, isMobile, className }: ContactDetailProps) {
|
||||
const t = useTranslations("contacts");
|
||||
const smimeStore = useSmimeStore();
|
||||
const [parsedCerts, setParsedCerts] = useState<Map<number, CertificateInfo>>(new Map());
|
||||
|
||||
const cryptoKeys = contact?.cryptoKeys ? Object.values(contact.cryptoKeys) : [];
|
||||
|
||||
useEffect(() => {
|
||||
if (!contact) return;
|
||||
let cancelled = false;
|
||||
const parseCerts = async () => {
|
||||
const results = new Map<number, CertificateInfo>();
|
||||
for (let i = 0; i < cryptoKeys.length; i++) {
|
||||
const key = cryptoKeys[i];
|
||||
if (typeof key.uri !== 'string') continue;
|
||||
try {
|
||||
let derBytes: ArrayBuffer | string | null = null;
|
||||
if (key.uri.startsWith('data:')) {
|
||||
const commaIdx = key.uri.indexOf(',');
|
||||
if (commaIdx === -1) continue;
|
||||
const b64 = key.uri.substring(commaIdx + 1);
|
||||
const binary = atob(b64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let j = 0; j < binary.length; j++) bytes[j] = binary.charCodeAt(j);
|
||||
derBytes = bytes.buffer;
|
||||
} else if (key.uri.startsWith('-----BEGIN')) {
|
||||
derBytes = key.uri;
|
||||
}
|
||||
if (!derBytes) continue;
|
||||
const cert = parseCertificatePemOrDer(derBytes);
|
||||
const der = typeof derBytes === 'string' ? cert.toSchema(true).toBER(false) : derBytes;
|
||||
const info = await extractCertificateInfo(cert, der);
|
||||
if (!cancelled) results.set(i, info);
|
||||
} catch { /* skip unparseable keys */ }
|
||||
}
|
||||
if (!cancelled) setParsedCerts(results);
|
||||
};
|
||||
if (cryptoKeys.length > 0) {
|
||||
parseCerts();
|
||||
} else {
|
||||
setParsedCerts(new Map());
|
||||
}
|
||||
return () => { cancelled = true; };
|
||||
}, [contact?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (!contact) {
|
||||
return (
|
||||
<div className={cn("flex flex-col items-center justify-center h-full text-muted-foreground", className)}>
|
||||
@@ -207,30 +164,6 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
|
||||
const anniversaries = contact.anniversaries ? Object.values(contact.anniversaries) : [];
|
||||
const keywords = contact.keywords ? Object.keys(contact.keywords).filter(k => contact.keywords![k]) : [];
|
||||
|
||||
const handleImportContactCert = async (keyIndex: number) => {
|
||||
const key = cryptoKeys[keyIndex];
|
||||
if (!key?.uri || typeof key.uri !== 'string') return;
|
||||
try {
|
||||
let derBytes: ArrayBuffer | string;
|
||||
if (key.uri.startsWith('data:')) {
|
||||
const commaIdx = key.uri.indexOf(',');
|
||||
if (commaIdx === -1) return;
|
||||
const b64 = key.uri.substring(commaIdx + 1);
|
||||
const binary = atob(b64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let j = 0; j < binary.length; j++) bytes[j] = binary.charCodeAt(j);
|
||||
derBytes = bytes.buffer;
|
||||
} else if (key.uri.startsWith('-----BEGIN')) {
|
||||
derBytes = key.uri;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
await smimeStore.importPublicCert(derBytes, 'contact', contact.id);
|
||||
toast.success(t("detail.cert_imported"));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t("detail.cert_import_failed"));
|
||||
}
|
||||
};
|
||||
const relatedTo = contact.relatedTo ? Object.entries(contact.relatedTo) : [];
|
||||
const preferredLanguages = contact.preferredLanguages ? Object.values(contact.preferredLanguages) : [];
|
||||
const personalInfo = contact.personalInfo ? Object.values(contact.personalInfo) : [];
|
||||
@@ -268,7 +201,7 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
|
||||
onClick={onCompose}
|
||||
className="touch-manipulation"
|
||||
>
|
||||
<Send className="w-4 h-4 mr-1" />
|
||||
<Send className="w-4 h-4 me-1" />
|
||||
{t("detail.compose_email")}
|
||||
</Button>
|
||||
)}
|
||||
@@ -277,12 +210,12 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
|
||||
href={`tel:${phone}`}
|
||||
className="inline-flex items-center justify-center rounded-md font-medium h-9 px-3 text-sm border border-input bg-background hover:bg-accent hover:text-accent-foreground transition-colors touch-manipulation"
|
||||
>
|
||||
<Phone className="w-4 h-4 mr-1" />
|
||||
<Phone className="w-4 h-4 me-1" />
|
||||
{t("context_menu.call")}
|
||||
</a>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={onEdit} className="touch-manipulation">
|
||||
<Pencil className="w-4 h-4 mr-1" />
|
||||
<Pencil className="w-4 h-4 me-1" />
|
||||
{t("form.edit_title")}
|
||||
</Button>
|
||||
<MoreActionsMenu items={moreItems} label={t("detail.more_actions")} />
|
||||
@@ -493,59 +426,20 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
|
||||
{cryptoKeys.length > 0 && (
|
||||
<Section title={t("detail.crypto_keys")}>
|
||||
<div className="space-y-3">
|
||||
{cryptoKeys.map((key, i) => {
|
||||
const certInfo = parsedCerts.get(i);
|
||||
const isExpired = certInfo ? new Date(certInfo.notAfter) < new Date() : false;
|
||||
const alreadyImported = certInfo?.emailAddresses?.[0]
|
||||
? !!smimeStore.getPublicCertForEmail(certInfo.emailAddresses[0])
|
||||
: false;
|
||||
|
||||
return (
|
||||
<div key={i} className="rounded-md border border-border/60 bg-muted/30 p-3 space-y-1">
|
||||
{certInfo ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
{isExpired ? (
|
||||
<ShieldAlert className="w-4 h-4 text-destructive flex-shrink-0" />
|
||||
) : (
|
||||
<ShieldCheck className="w-4 h-4 text-primary flex-shrink-0" />
|
||||
)}
|
||||
<span className="text-sm font-medium truncate">{certInfo.subject}</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground space-y-0.5 pl-6">
|
||||
<p>{t("detail.cert_issuer")}: {certInfo.issuer}</p>
|
||||
<p>
|
||||
{t("detail.cert_expires")}: {new Date(certInfo.notAfter).toLocaleDateString()}
|
||||
{isExpired && <span className="text-destructive ml-1">({t("detail.cert_expired")})</span>}
|
||||
</p>
|
||||
<p>{t("detail.cert_fingerprint")}: {certInfo.fingerprint.substring(0, 20)}...</p>
|
||||
{certInfo.algorithm && <p>{t("detail.cert_algorithm")}: {certInfo.algorithm}</p>}
|
||||
</div>
|
||||
{!alreadyImported && (
|
||||
<Button variant="ghost" size="sm" className="ml-4 mt-1" onClick={() => handleImportContactCert(i)}>
|
||||
<Download className="w-3 h-3 mr-1" />
|
||||
{t("detail.import_to_smime")}
|
||||
</Button>
|
||||
)}
|
||||
{alreadyImported && (
|
||||
<p className="text-xs text-green-600 pl-6 mt-1">{t("detail.cert_already_imported")}</p>
|
||||
)}
|
||||
</>
|
||||
{cryptoKeys.map((key, i) => (
|
||||
<div key={i} className="rounded-md border border-border/60 bg-muted/30 p-3 space-y-1">
|
||||
<div className="flex items-start gap-2 text-sm break-all">
|
||||
<KeyRound className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
{typeof key.uri === 'string' && key.uri.startsWith("http") ? (
|
||||
<a href={key.uri} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
|
||||
{key.uri}
|
||||
</a>
|
||||
) : (
|
||||
<div className="flex items-start gap-2 text-sm break-all">
|
||||
<KeyRound className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
{typeof key.uri === 'string' && key.uri.startsWith("http") ? (
|
||||
<a href={key.uri} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
|
||||
{key.uri}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-muted-foreground">{typeof key.uri === 'string' ? `${key.uri.substring(0, 80)}${key.uri.length > 80 ? "…" : ""}` : String(key.uri ?? '')}</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-muted-foreground">{typeof key.uri === 'string' ? `${key.uri.substring(0, 80)}${key.uri.length > 80 ? "…" : ""}` : String(key.uri ?? '')}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
@@ -693,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",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -72,7 +72,7 @@ function FormSection({ icon: Icon, title, children, collapsible, defaultOpen = t
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full text-left",
|
||||
"flex items-center gap-2 w-full text-start",
|
||||
collapsible ? "cursor-pointer" : "cursor-default"
|
||||
)}
|
||||
onClick={() => collapsible && setOpen(!open)}
|
||||
@@ -525,6 +525,11 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
mediaMap[photoKey] = { kind: "photo", uri: photoUri, mediaType: photoMediaType };
|
||||
}
|
||||
|
||||
// Set mediaVluae to null if we are removing media, so the server removes it
|
||||
const hadMedia = !!contact?.media && Object.keys(contact.media).length > 0;
|
||||
const mediaValue: Record<string, ContactMedia> | null | undefined =
|
||||
Object.keys(mediaMap).length > 0 ? mediaMap : (hadMedia ? null : undefined);
|
||||
|
||||
const data: Partial<ContactCard> = {
|
||||
name: { components: nameComponents, isOrdered: true },
|
||||
nicknames: nickname.trim() ? { n0: { name: nickname.trim() } } : undefined,
|
||||
@@ -551,7 +556,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
calendarUri: calendarUri.trim() || undefined,
|
||||
schedulingUri: schedulingUri.trim() || undefined,
|
||||
freeBusyUri: freeBusyUri.trim() || undefined,
|
||||
media: Object.keys(mediaMap).length > 0 ? mediaMap : undefined,
|
||||
media: mediaValue as Record<string, ContactMedia> | undefined,
|
||||
...(selectedBookId ? { addressBookIds: { [selectedBookId]: true } } : {}),
|
||||
};
|
||||
|
||||
@@ -731,12 +736,12 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
)}
|
||||
</div>
|
||||
{emailErrors[i] && (
|
||||
<p className="text-xs text-red-600 dark:text-red-400 mt-1 ml-1">{emailErrors[i]}</p>
|
||||
<p className="text-xs text-red-600 dark:text-red-400 mt-1 ms-1">{emailErrors[i]}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setEmails([...emails, { address: "", context: "" }])} className="text-xs">
|
||||
<Plus className="w-3 h-3 mr-1" />
|
||||
<Plus className="w-3 h-3 me-1" />
|
||||
{t("add_email")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -794,7 +799,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
</div>
|
||||
))}
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setPhones([...phones, { number: "", context: "", feature: "" }])} className="text-xs">
|
||||
<Plus className="w-3 h-3 mr-1" />
|
||||
<Plus className="w-3 h-3 me-1" />
|
||||
{t("add_phone")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -850,7 +855,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
</div>
|
||||
))}
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setAddresses([...addresses, { street: "", locality: "", region: "", postcode: "", country: "", context: "" }])} className="text-xs">
|
||||
<Plus className="w-3 h-3 mr-1" />
|
||||
<Plus className="w-3 h-3 me-1" />
|
||||
{t("add_address")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -879,7 +884,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
</div>
|
||||
))}
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setOnlineServices([...onlineServices, { uri: "", service: "", label: "" }])} className="text-xs">
|
||||
<Plus className="w-3 h-3 mr-1" />
|
||||
<Plus className="w-3 h-3 me-1" />
|
||||
{t("add_online_service")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -911,7 +916,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
</div>
|
||||
))}
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setAnniversaries([...anniversaries, { date: "", kind: "birth" }])} className="text-xs">
|
||||
<Plus className="w-3 h-3 mr-1" />
|
||||
<Plus className="w-3 h-3 me-1" />
|
||||
{t("add_anniversary")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -952,7 +957,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
</div>
|
||||
))}
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setPersonalInfoEntries([...personalInfoEntries, { value: "", kind: "hobby", level: "" }])} className="text-xs">
|
||||
<Plus className="w-3 h-3 mr-1" />
|
||||
<Plus className="w-3 h-3 me-1" />
|
||||
{t("add_personal_info")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -1140,7 +1145,7 @@ function CategoryComboBox({
|
||||
<button
|
||||
key={kw}
|
||||
type="button"
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-left"
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-start"
|
||||
onClick={() => { addKeyword(kw); inputRef.current?.focus(); }}
|
||||
>
|
||||
<Tag className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
||||
@@ -1150,7 +1155,7 @@ function CategoryComboBox({
|
||||
{canAddNew && (
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-left text-primary"
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-start text-primary"
|
||||
onClick={() => { addKeyword(inputValue); inputRef.current?.focus(); }}
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
|
||||
@@ -53,7 +53,7 @@ export function ContactGroupDetail({
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={onEdit} className="touch-manipulation">
|
||||
<Pencil className="w-4 h-4 mr-1" />
|
||||
<Pencil className="w-4 h-4 me-1" />
|
||||
{t("form.edit_title")}
|
||||
</Button>
|
||||
<Button
|
||||
@@ -106,7 +106,7 @@ export function ContactGroupDetail({
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-md hover:bg-muted group transition-colors"
|
||||
>
|
||||
<button
|
||||
className="flex items-center gap-3 flex-1 min-w-0 text-left"
|
||||
className="flex items-center gap-3 flex-1 min-w-0 text-start"
|
||||
onClick={() => onSelectMember(member.id)}
|
||||
>
|
||||
<Avatar name={mName} email={mEmail} size="sm" />
|
||||
|
||||
@@ -114,7 +114,7 @@ export function ContactGroupForm({
|
||||
placeholder={t("groups.search_members")}
|
||||
value={memberSearch}
|
||||
onChange={(e) => setMemberSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
className="ps-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -134,7 +134,7 @@ export function ContactGroupForm({
|
||||
type="button"
|
||||
onClick={() => toggleMember(contact.id)}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors",
|
||||
"w-full flex items-center gap-3 px-3 py-2.5 text-start transition-colors",
|
||||
"hover:bg-muted",
|
||||
isSelected && "bg-primary/5"
|
||||
)}
|
||||
|
||||
@@ -45,7 +45,7 @@ export function ContactGroupList({
|
||||
<div className={cn("flex flex-col", className)}>
|
||||
<div className="px-4 py-2 border-b border-border">
|
||||
<Button size="sm" variant="outline" onClick={onCreateGroup} className="w-full">
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
<Plus className="w-4 h-4 me-1" />
|
||||
{t("groups.create")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -69,7 +69,7 @@ export function ContactGroupList({
|
||||
key={group.id}
|
||||
onClick={() => onSelectGroup(group.id)}
|
||||
className={cn(
|
||||
"w-full flex items-center px-4 text-left transition-colors",
|
||||
"w-full flex items-center px-4 text-start transition-colors",
|
||||
"hover:bg-muted",
|
||||
group.id === selectedGroupId && "bg-accent text-accent-foreground"
|
||||
)}
|
||||
|
||||
@@ -187,7 +187,7 @@ export function ContactImportDialog({
|
||||
type="button"
|
||||
onClick={() => toggleSelect(idx)}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors hover:bg-muted",
|
||||
"w-full flex items-center gap-3 px-3 py-2.5 text-start transition-colors hover:bg-muted",
|
||||
isSelected && "bg-primary/5"
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -310,7 +310,7 @@ export function ContactList({
|
||||
placeholder={t("search_placeholder")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className={cn("pl-9 h-9", searchQuery && "pr-8")}
|
||||
className={cn("ps-9 h-9", searchQuery && "pe-8")}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
@@ -359,7 +359,7 @@ export function ContactList({
|
||||
onClick={() => setFilters(EMPTY_FILTERS)}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
<RotateCcw className="w-3 h-3 mr-1" />
|
||||
<RotateCcw className="w-3 h-3 me-1" />
|
||||
{t("filters.clear")}
|
||||
</Button>
|
||||
<Button
|
||||
@@ -479,11 +479,11 @@ export function ContactList({
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<Button variant="ghost" size="sm" onClick={onBulkAddToGroup} className="h-7 text-xs">
|
||||
<Users className="w-3.5 h-3.5 mr-1" />
|
||||
<Users className="w-3.5 h-3.5 me-1" />
|
||||
{t("bulk.add_to_group")}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={onBulkExport} className="h-7 text-xs">
|
||||
<Download className="w-3.5 h-3.5 mr-1" />
|
||||
<Download className="w-3.5 h-3.5 me-1" />
|
||||
{t("bulk.export")}
|
||||
</Button>
|
||||
<Button
|
||||
@@ -492,7 +492,7 @@ export function ContactList({
|
||||
onClick={onBulkDelete}
|
||||
className="h-7 text-xs text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5 mr-1" />
|
||||
<Trash2 className="w-3.5 h-3.5 me-1" />
|
||||
{t("bulk.delete")}
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={onClearSelection} className="h-7 w-7">
|
||||
@@ -526,7 +526,7 @@ export function ContactList({
|
||||
)}
|
||||
{activeFilters > 0 && (
|
||||
<Button variant="outline" size="sm" onClick={() => setFilters(EMPTY_FILTERS)}>
|
||||
<RotateCcw className="w-3.5 h-3.5 mr-1" />
|
||||
<RotateCcw className="w-3.5 h-3.5 me-1" />
|
||||
{t("filters.clear")}
|
||||
</Button>
|
||||
)}
|
||||
@@ -538,7 +538,7 @@ export function ContactList({
|
||||
<p className="text-sm font-medium text-foreground">{t("empty_state_title")}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">{t("empty_state_subtitle")}</p>
|
||||
<Button size="sm" className="mt-3" onClick={onCreateNew}>
|
||||
<UserPlus className="w-4 h-4 mr-1.5" />
|
||||
<UserPlus className="w-4 h-4 me-1.5" />
|
||||
{t("create_new")}
|
||||
</Button>
|
||||
</>
|
||||
|
||||
@@ -287,14 +287,14 @@ export function ContactsSidebar({
|
||||
className="absolute right-0 top-full mt-1 w-44 rounded-md border border-border bg-background text-foreground shadow-md z-50 py-1"
|
||||
>
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-left"
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-start"
|
||||
onClick={() => { setShowMenu(false); onCreateContact(); }}
|
||||
>
|
||||
<UserPlus className="w-4 h-4" />
|
||||
{t("create_new")}
|
||||
</button>
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-left"
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-start"
|
||||
onClick={() => { setShowMenu(false); onCreateGroup(); }}
|
||||
>
|
||||
<UsersRound className="w-4 h-4" />
|
||||
@@ -302,7 +302,7 @@ export function ContactsSidebar({
|
||||
</button>
|
||||
{onCreateAddressBook && (
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-left"
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-start"
|
||||
onClick={() => { setShowMenu(false); onCreateAddressBook(); }}
|
||||
>
|
||||
<BookPlus className="w-4 h-4" />
|
||||
@@ -311,7 +311,7 @@ export function ContactsSidebar({
|
||||
)}
|
||||
{onImport && (
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-left"
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-start"
|
||||
onClick={() => { setShowMenu(false); onImport(); }}
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
@@ -338,7 +338,7 @@ export function ContactsSidebar({
|
||||
>
|
||||
<BookUser className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="truncate">{t("tabs.all")}</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
|
||||
<span className="ms-auto text-xs text-muted-foreground tabular-nums">
|
||||
{individuals.length}
|
||||
</span>
|
||||
</button>
|
||||
@@ -355,7 +355,7 @@ export function ContactsSidebar({
|
||||
<div className="flex items-center px-3 py-1 group">
|
||||
<button
|
||||
onClick={() => toggleSection(sectionKey)}
|
||||
className="flex items-center gap-1 flex-1 min-w-0 text-left"
|
||||
className="flex items-center gap-1 flex-1 min-w-0 text-start"
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown className="w-3 h-3 text-muted-foreground" />
|
||||
@@ -369,7 +369,7 @@ export function ContactsSidebar({
|
||||
</button>
|
||||
</div>
|
||||
{expanded && (
|
||||
<div className="pl-2">
|
||||
<div className="ps-2">
|
||||
{owned.length > 0 && (
|
||||
<div className="mt-1">
|
||||
<div className="px-3 py-0.5 text-[10px] font-medium text-muted-foreground/80 uppercase tracking-wider">
|
||||
@@ -418,7 +418,7 @@ export function ContactsSidebar({
|
||||
<div className="flex items-center px-3 py-1 group">
|
||||
<button
|
||||
onClick={() => toggleSection("addressBooks")}
|
||||
className="flex items-center gap-1 flex-1 text-left"
|
||||
className="flex items-center gap-1 flex-1 text-start"
|
||||
>
|
||||
{collapsed.addressBooks ? (
|
||||
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||
@@ -461,7 +461,7 @@ export function ContactsSidebar({
|
||||
<div className="mt-2">
|
||||
<button
|
||||
onClick={() => toggleSection("groups")}
|
||||
className="flex items-center gap-1 px-3 py-1 w-full text-left group"
|
||||
className="flex items-center gap-1 px-3 py-1 w-full text-start group"
|
||||
>
|
||||
{collapsed.groups ? (
|
||||
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||
@@ -483,7 +483,7 @@ export function ContactsSidebar({
|
||||
onClick={() => onSelectCategory({ groupId: group.id })}
|
||||
onContextMenu={(e) => openGroupContextMenu(e, group)}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 pl-5 pr-3 text-sm transition-colors",
|
||||
"w-full flex items-center gap-2 ps-5 pe-3 text-sm transition-colors",
|
||||
isActive
|
||||
? "bg-accent text-accent-foreground font-medium"
|
||||
: "text-foreground/80 hover:bg-muted"
|
||||
@@ -492,7 +492,7 @@ export function ContactsSidebar({
|
||||
>
|
||||
<Users className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="truncate">{getContactDisplayName(group)}</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
|
||||
<span className="ms-auto text-xs text-muted-foreground tabular-nums">
|
||||
{memberCount}
|
||||
</span>
|
||||
</button>
|
||||
@@ -505,7 +505,7 @@ export function ContactsSidebar({
|
||||
<div className="mt-2">
|
||||
<button
|
||||
onClick={() => toggleSection("categories")}
|
||||
className="flex items-center gap-1 px-3 py-1 w-full text-left group"
|
||||
className="flex items-center gap-1 px-3 py-1 w-full text-start group"
|
||||
>
|
||||
{collapsed.categories ? (
|
||||
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||
@@ -523,7 +523,7 @@ export function ContactsSidebar({
|
||||
<button
|
||||
onClick={() => onSelectCategory("uncategorized")}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 pl-5 pr-3 text-sm transition-colors",
|
||||
"w-full flex items-center gap-2 ps-5 pe-3 text-sm transition-colors",
|
||||
activeCategory === "uncategorized"
|
||||
? "bg-accent text-accent-foreground font-medium"
|
||||
: "text-foreground/80 hover:bg-muted"
|
||||
@@ -532,7 +532,7 @@ export function ContactsSidebar({
|
||||
>
|
||||
<Tag className="w-3.5 h-3.5 flex-shrink-0 opacity-50" />
|
||||
<span className="truncate italic">{t("no_category")}</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
|
||||
<span className="ms-auto text-xs text-muted-foreground tabular-nums">
|
||||
{uncategorizedCount}
|
||||
</span>
|
||||
</button>
|
||||
@@ -561,7 +561,7 @@ export function ContactsSidebar({
|
||||
<div className="flex items-center px-3 py-1 group">
|
||||
<button
|
||||
onClick={() => toggleSection(`shared-${group.accountId}`)}
|
||||
className="flex items-center gap-1 flex-1 min-w-0 text-left"
|
||||
className="flex items-center gap-1 flex-1 min-w-0 text-start"
|
||||
>
|
||||
{collapsed[`shared-${group.accountId}`] ? (
|
||||
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||
@@ -779,7 +779,7 @@ function CategoryItem({
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 pl-5 pr-3 text-sm transition-colors",
|
||||
"w-full flex items-center gap-2 ps-5 pe-3 text-sm transition-colors",
|
||||
isActive
|
||||
? "bg-accent text-accent-foreground font-medium"
|
||||
: "text-foreground/80 hover:bg-muted",
|
||||
@@ -789,7 +789,7 @@ function CategoryItem({
|
||||
>
|
||||
<Tag className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span className="truncate">{keyword}</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
|
||||
<span className="ms-auto text-xs text-muted-foreground tabular-nums">
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
@@ -847,7 +847,7 @@ function AddressBookItem({
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 pl-5 pr-3 text-sm transition-colors",
|
||||
"w-full flex items-center gap-2 ps-5 pe-3 text-sm transition-colors",
|
||||
isActive
|
||||
? "bg-accent text-accent-foreground font-medium"
|
||||
: "text-foreground/80 hover:bg-muted",
|
||||
@@ -858,11 +858,11 @@ function AddressBookItem({
|
||||
<Book className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="truncate">{book.name}</span>
|
||||
{!book.isShared && Object.keys(book.shareWith || {}).length > 0 && (
|
||||
<Users className="w-3 h-3 text-muted-foreground flex-shrink-0 ml-auto" />
|
||||
<Users className="w-3 h-3 text-muted-foreground flex-shrink-0 ms-auto" />
|
||||
)}
|
||||
<span className={cn(
|
||||
"text-xs text-muted-foreground tabular-nums",
|
||||
!(!book.isShared && Object.keys(book.shareWith || {}).length > 0) && "ml-auto"
|
||||
!(!book.isShared && Object.keys(book.shareWith || {}).length > 0) && "ms-auto"
|
||||
)}>
|
||||
{contactCount}
|
||||
</span>
|
||||
|
||||
@@ -540,7 +540,9 @@ describe('CalendarInvitationBanner', () => {
|
||||
mocks.clientMock,
|
||||
'event-8',
|
||||
expect.objectContaining({
|
||||
replyTo: { imip: 'mailto:organizer@example.com' },
|
||||
// The stored event lacks an ORGANIZER, so the RSVP repair writes
|
||||
// organizerCalendarAddress (replyTo is retired in jscalendarbis).
|
||||
organizerCalendarAddress: 'mailto:organizer@example.com',
|
||||
participants: expect.objectContaining({
|
||||
attendee: expect.objectContaining({
|
||||
participationStatus: 'accepted',
|
||||
|
||||
@@ -121,3 +121,34 @@ describe('EmailListItem tag badge', () => {
|
||||
expect(container.querySelector('p')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('EmailListItem shift-range checkbox', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ emailKeywords: [...DEFAULT_KEYWORDS], showPreview: false, mailLayout: 'split' });
|
||||
});
|
||||
|
||||
it('shift-clicking the checkbox extends the selection from the anchor', () => {
|
||||
const e1 = makeEmail({ id: 'e1', threadId: 't1' });
|
||||
const e2 = makeEmail({ id: 'e2', threadId: 't2' });
|
||||
const e3 = makeEmail({ id: 'e3', threadId: 't3' });
|
||||
// selection mode active (so the checkbox renders), anchor on e1
|
||||
useEmailStore.setState({
|
||||
emails: [e1, e2, e3],
|
||||
selectedEmailIds: new Set(['e1']),
|
||||
lastSelectedEmailId: 'e1',
|
||||
selectedMailbox: 'inbox',
|
||||
});
|
||||
|
||||
render(<EmailListItem email={e3} />);
|
||||
// the checkbox is the first button in the row (shown in selection mode)
|
||||
const checkbox = screen.getAllByRole('button')[0];
|
||||
act(() => {
|
||||
checkbox.dispatchEvent(new MouseEvent('click', { bubbles: true, shiftKey: true }));
|
||||
});
|
||||
|
||||
const sel = useEmailStore.getState().selectedEmailIds;
|
||||
expect(sel.has('e1')).toBe(true);
|
||||
expect(sel.has('e2')).toBe(true); // the in-between row got filled in
|
||||
expect(sel.has('e3')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -67,21 +67,6 @@ vi.mock('@/stores/account-store', () => {
|
||||
return { useAccountStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/smime-store', () => {
|
||||
const state = {
|
||||
certs: [],
|
||||
signingEnabled: false,
|
||||
encryptionEnabled: false,
|
||||
defaultSigningCertId: null,
|
||||
defaultEncryptionCertId: null,
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useSmimeStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/email-store', () => {
|
||||
const state = {
|
||||
draftSaveEnabled: false,
|
||||
@@ -172,12 +157,6 @@ vi.mock('@/lib/signature-utils', () => ({
|
||||
getPlainTextSignature: () => '',
|
||||
}));
|
||||
vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' }));
|
||||
vi.mock('@/lib/smime/smime-sign', () => ({ smimeSign: async () => null }));
|
||||
vi.mock('@/lib/smime/smime-encrypt', () => ({ smimeEncrypt: async () => null }));
|
||||
vi.mock('@/lib/smime/mime-builder', () => ({
|
||||
buildMimeMessage: () => null,
|
||||
wrapCmsAsSmimeMessage: () => null,
|
||||
}));
|
||||
vi.mock('@/lib/debug', () => ({ debug: () => {} }));
|
||||
vi.mock('@/components/email/quoted-html', () => ({
|
||||
buildQuotedHtmlBlock: () => '',
|
||||
|
||||
@@ -66,21 +66,6 @@ vi.mock('@/stores/account-store', () => {
|
||||
return { useAccountStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/smime-store', () => {
|
||||
const state = {
|
||||
certs: [],
|
||||
signingEnabled: false,
|
||||
encryptionEnabled: false,
|
||||
defaultSigningCertId: null,
|
||||
defaultEncryptionCertId: null,
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useSmimeStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/email-store', () => {
|
||||
const state = {
|
||||
draftSaveEnabled: false,
|
||||
@@ -171,12 +156,6 @@ vi.mock('@/lib/signature-utils', () => ({
|
||||
getPlainTextSignature: () => '',
|
||||
}));
|
||||
vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' }));
|
||||
vi.mock('@/lib/smime/smime-sign', () => ({ smimeSign: async () => null }));
|
||||
vi.mock('@/lib/smime/smime-encrypt', () => ({ smimeEncrypt: async () => null }));
|
||||
vi.mock('@/lib/smime/mime-builder', () => ({
|
||||
buildMimeMessage: () => null,
|
||||
wrapCmsAsSmimeMessage: () => null,
|
||||
}));
|
||||
vi.mock('@/lib/debug', () => ({ debug: () => {} }));
|
||||
vi.mock('@/components/email/quoted-html', () => ({
|
||||
buildQuotedHtmlBlock: () => '',
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { SelectableAvatar } from '../selectable-avatar';
|
||||
|
||||
// Isolate from the real Avatar (image fetching, libravatar hashing) — we only
|
||||
// care about the selection wrapper behaviour here.
|
||||
vi.mock('@/components/ui/avatar', () => ({
|
||||
Avatar: (props: { name?: string }) => <span data-testid="avatar">{props.name}</span>,
|
||||
}));
|
||||
|
||||
describe('SelectableAvatar', () => {
|
||||
it('renders the wrapped avatar', () => {
|
||||
render(<SelectableAvatar name="Marta" checked={false} onToggle={() => {}} selectLabel="Select" />);
|
||||
expect(screen.getByTestId('avatar')).toHaveTextContent('Marta');
|
||||
});
|
||||
|
||||
it('fires onToggle and stops propagation when the avatar is clicked', () => {
|
||||
const onToggle = vi.fn();
|
||||
const onRowClick = vi.fn();
|
||||
render(
|
||||
<div onClick={onRowClick}>
|
||||
<SelectableAvatar name="Marta" checked={false} onToggle={onToggle} selectLabel="Select" />
|
||||
</div>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole('checkbox'));
|
||||
expect(onToggle).toHaveBeenCalledTimes(1);
|
||||
// Clicking the avatar must not bubble up to open/select the row.
|
||||
expect(onRowClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reflects the checked state via aria-checked', () => {
|
||||
const { rerender } = render(
|
||||
<SelectableAvatar name="Marta" checked={false} onToggle={() => {}} selectLabel="Select" />,
|
||||
);
|
||||
expect(screen.getByRole('checkbox')).toHaveAttribute('aria-checked', 'false');
|
||||
rerender(<SelectableAvatar name="Marta" checked onToggle={() => {}} selectLabel="Select" />);
|
||||
expect(screen.getByRole('checkbox')).toHaveAttribute('aria-checked', 'true');
|
||||
});
|
||||
});
|
||||
@@ -582,7 +582,12 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
} else {
|
||||
await updateEvent(client, eventForRsvp.id, {
|
||||
participants: repairedParticipants,
|
||||
replyTo: replyToForRsvp ?? undefined,
|
||||
// Stalwart routes the iTIP REPLY via the stored ORGANIZER
|
||||
// (organizerCalendarAddress; the RFC 8984 replyTo is retired).
|
||||
// Only repair a missing organizer - attendees may not modify it.
|
||||
...(replyToForRsvp?.imip && !eventForRsvp.organizerCalendarAddress
|
||||
? { organizerCalendarAddress: replyToForRsvp.imip }
|
||||
: {}),
|
||||
}, true);
|
||||
setRsvpStatus(status);
|
||||
setActionNotice(t('rsvp_sent'));
|
||||
@@ -1048,7 +1053,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
setShowCalendarPicker(false);
|
||||
handleImport(cal.id);
|
||||
}}
|
||||
className="w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2"
|
||||
className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted flex items-center gap-2"
|
||||
>
|
||||
<span
|
||||
className="w-3 h-3 rounded-full flex-shrink-0"
|
||||
@@ -1090,7 +1095,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
)}
|
||||
|
||||
{isProcessing && (
|
||||
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground ml-auto" />
|
||||
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground ms-auto" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
+292
-301
@@ -5,33 +5,27 @@ import { useFocusTrap } from "@/hooks/use-focus-trap";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, ShieldCheck, Lock, CalendarClock, ChevronDown, MailCheck } from "lucide-react";
|
||||
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, CalendarClock, ChevronDown, MailCheck, Search } from "lucide-react";
|
||||
import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils";
|
||||
import { debug } from "@/lib/debug";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
|
||||
import { sanitizeSignatureHtml, sanitizeEmailHtml } from "@/lib/email-sanitization";
|
||||
import { sanitizeSignatureHtml, sanitizeEmailHtml, escapeHtml } from "@/lib/email-sanitization";
|
||||
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
|
||||
import { isFilePreviewable } from "@/lib/file-preview";
|
||||
import { buildQuotedHtmlBlock, serializeEditorContent } from "@/components/email/quoted-html";
|
||||
import { buildSignatureBlock } from "@/components/email/signature-block";
|
||||
import { emailHooks, contactHooks } from "@/lib/plugin-hooks";
|
||||
import type { OutgoingEmail, RecipientSuggestion } from "@/lib/plugin-types";
|
||||
import type { AlmostSavedDraft, OutgoingEmail, RecipientSuggestion } from "@/lib/plugin-types";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useIdentityStore } from "@/stores/identity-store";
|
||||
import { useProMultiAccountIdentities, stripCrossAccountIdentityPrefix } from "@/hooks/use-pro-multi-account-identities";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { useSmimeStore } from "@/stores/smime-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { buildMimeMessage, wrapCmsAsSmimeMessage } from "@/lib/smime/mime-builder";
|
||||
import type { MimeAttachment } from "@/lib/smime/mime-builder";
|
||||
import { smimeSign } from "@/lib/smime/smime-sign";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
||||
import { smimeEncrypt } from "@/lib/smime/smime-encrypt";
|
||||
import { useContactStore } from "@/stores/contact-store";
|
||||
import { useTemplateStore } from "@/stores/template-store";
|
||||
import { SubAddressHelper } from "@/components/identity/sub-address-helper";
|
||||
@@ -41,7 +35,7 @@ import { TemplatePicker } from "@/components/templates/template-picker";
|
||||
import { TemplateForm } from "@/components/templates/template-form";
|
||||
import type { EmailTemplate } from "@/lib/template-types";
|
||||
import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils";
|
||||
import { resolveReplyFrom } from "@/lib/reply-identity";
|
||||
import { findComposeIdentityId, resolveReplyFrom } from "@/lib/reply-identity";
|
||||
import { computeReplyThreadingHeaders } from "@/lib/email-threading";
|
||||
import {
|
||||
rewriteCidImagesForEditor,
|
||||
@@ -51,11 +45,15 @@ import {
|
||||
parseRecipientList,
|
||||
formatRecipientList,
|
||||
splitPastedRecipients,
|
||||
waitForPendingUploads,
|
||||
extractUserAuthoredText,
|
||||
type Recipient,
|
||||
} from "@/lib/email-composer-utils";
|
||||
import { isValidEmail } from "@/lib/validation";
|
||||
import { RichTextEditor } from "@/components/email/rich-text-editor";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { htmlToPlainText as htmlToPlainTextShared } from "@/lib/html-to-text";
|
||||
import { fileStorage } from "@/lib/plugin-storage";
|
||||
|
||||
/**
|
||||
* Derives the text/plain alternative from the composer's HTML body, preserving
|
||||
@@ -139,12 +137,28 @@ interface EmailComposerProps {
|
||||
}) => void | Promise<void>;
|
||||
onScheduledSendCreated?: () => void | Promise<void>;
|
||||
onClose?: () => void;
|
||||
/**
|
||||
* When provided, the composer assigns its close handler to `current`. The
|
||||
* handler shows the unsaved-changes dialog when the draft is dirty, so a
|
||||
* host (e.g. the Pro tab bar's close button) can route an external close
|
||||
* request through the same guard instead of discarding silently.
|
||||
*/
|
||||
requestCloseRef?: React.MutableRefObject<(() => void) | null>;
|
||||
onDiscardDraft?: (draftId: string) => void;
|
||||
onSaveState?: (data: ComposerDraftData) => void;
|
||||
className?: string;
|
||||
initialDraftText?: string;
|
||||
initialData?: ComposerDraftData | null;
|
||||
mode?: 'compose' | 'reply' | 'replyAll' | 'forward';
|
||||
/**
|
||||
* Email of the mailbox/account the user is viewing when they start a new
|
||||
* message. When set (and `autoSelectReplyIdentity` is on), a fresh compose
|
||||
* preselects the identity matching this address instead of the primary
|
||||
* identity, so "New message" from info@ defaults its From to info@. Mirrors
|
||||
* the reply-time identity match; ignored for reply/replyAll/forward (those
|
||||
* resolve from the original recipients).
|
||||
*/
|
||||
composeFromAccountEmail?: string;
|
||||
replyTo?: {
|
||||
from?: { email?: string; name?: string }[];
|
||||
replyToAddresses?: { email?: string; name?: string }[];
|
||||
@@ -235,12 +249,14 @@ export function EmailComposer({
|
||||
onSend,
|
||||
onScheduledSendCreated,
|
||||
onClose,
|
||||
requestCloseRef,
|
||||
onDiscardDraft,
|
||||
onSaveState,
|
||||
className,
|
||||
initialDraftText,
|
||||
initialData,
|
||||
mode = 'compose',
|
||||
composeFromAccountEmail,
|
||||
replyTo
|
||||
}: EmailComposerProps) {
|
||||
const t = useTranslations('email_composer');
|
||||
@@ -347,9 +363,8 @@ export function EmailComposer({
|
||||
|
||||
const date = replyTo.receivedAt ? formatDateTime(replyTo.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : "";
|
||||
const from = replyTo.from?.[0];
|
||||
const fromStr = from ? `${from.name || from.email}` : tCommon('unknown');
|
||||
// Forward "From:" shows the full sender incl. address; reply line keeps
|
||||
// the bare name (reads naturally in the localized "On … wrote:" line).
|
||||
// Forward "From:" and the reply "On … wrote:" line both show the full
|
||||
// sender incl. address ("Name <email>"), like Gmail/Outlook (#482).
|
||||
const fromStrFull = from
|
||||
? (from.name && from.email && from.name !== from.email
|
||||
? `${from.name} <${from.email}>`
|
||||
@@ -377,7 +392,7 @@ export function EmailComposer({
|
||||
if (mode === 'forward') {
|
||||
return `${prefix}${signatureBlock}\n\n${tQuote('forwarded_separator')}\n${tQuote('from_label')}: ${fromStrFull}\n${tQuote('date_label')}: ${date}\n${tQuote('subject_label')}: ${replyTo.subject || ''}\n\n${originalText}`;
|
||||
} else if (mode === 'reply' || mode === 'replyAll') {
|
||||
return `${prefix}${signatureBlock}\n\n${tQuote('reply_line', { date, from: fromStr })}\n${quotedText}`;
|
||||
return `${prefix}${signatureBlock}\n\n${tQuote('reply_line', { date, from: fromStrFull })}\n${quotedText}`;
|
||||
}
|
||||
return prefix;
|
||||
}
|
||||
@@ -399,7 +414,7 @@ export function EmailComposer({
|
||||
|
||||
const date = replyTo.receivedAt ? formatDateTime(replyTo.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : "";
|
||||
const from = replyTo.from?.[0];
|
||||
const fromStr = from ? `${from.name || from.email}` : tCommon('unknown');
|
||||
// Forward and reply quote lines both show the full "Name <email>" sender (#482).
|
||||
const fromStrFull = from
|
||||
? (from.name && from.email && from.name !== from.email
|
||||
? `${from.name} <${from.email}>`
|
||||
@@ -434,9 +449,11 @@ export function EmailComposer({
|
||||
|
||||
// Build quoted content as HTML
|
||||
if (replyTo.htmlBody && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
|
||||
// HTML-escape user-controlled values: an unescaped sender "Name <email>"
|
||||
// has its "<email>" eaten as a bogus HTML tag by the rich-text editor (#482).
|
||||
const quoteHeader = mode === 'forward'
|
||||
? `${tQuote('forwarded_separator')}<br>${tQuote('from_label')}: ${fromStrFull}<br>${tQuote('date_label')}: ${date}<br>${tQuote('subject_label')}: ${replyTo.subject || ''}<br><br>`
|
||||
: `${tQuote('reply_line', { date, from: fromStr })}<br>`;
|
||||
? `${tQuote('forwarded_separator')}<br>${tQuote('from_label')}: ${escapeHtml(fromStrFull)}<br>${tQuote('date_label')}: ${escapeHtml(date)}<br>${tQuote('subject_label')}: ${escapeHtml(replyTo.subject || '')}<br><br>`
|
||||
: `${tQuote('reply_line', { date: escapeHtml(date), from: escapeHtml(fromStrFull) })}<br>`;
|
||||
// Embed the original as a QuotedHtml island (verbatim, schema-free) so
|
||||
// its layout survives the editor round-trip. Sanitize first to strip
|
||||
// scripts/styles/head; cid rewrite afterwards so data-cid markers
|
||||
@@ -450,9 +467,9 @@ export function EmailComposer({
|
||||
if (replyTo.body) {
|
||||
const escapedOriginal = replyTo.body.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>');
|
||||
if (mode === 'forward') {
|
||||
return `${prefix}${signatureBlock}<br><br>${tQuote('forwarded_separator')}<br>${tQuote('from_label')}: ${fromStrFull}<br>${tQuote('date_label')}: ${date}<br>${tQuote('subject_label')}: ${replyTo.subject || ''}<br><br>${escapedOriginal}`;
|
||||
return `${prefix}${signatureBlock}<br><br>${tQuote('forwarded_separator')}<br>${tQuote('from_label')}: ${escapeHtml(fromStrFull)}<br>${tQuote('date_label')}: ${escapeHtml(date)}<br>${tQuote('subject_label')}: ${escapeHtml(replyTo.subject || '')}<br><br>${escapedOriginal}`;
|
||||
} else if (mode === 'reply' || mode === 'replyAll') {
|
||||
return `${prefix}${signatureBlock}<br><br>${tQuote('reply_line', { date, from: fromStr })}<br><blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${escapedOriginal}</blockquote>`;
|
||||
return `${prefix}${signatureBlock}<br><br>${tQuote('reply_line', { date: escapeHtml(date), from: escapeHtml(fromStrFull) })}<br><blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${escapedOriginal}</blockquote>`;
|
||||
}
|
||||
}
|
||||
return prefix;
|
||||
@@ -521,11 +538,6 @@ export function EmailComposer({
|
||||
const [showCloseDialog, setShowCloseDialog] = useState(false);
|
||||
const [showAllAttachments, setShowAllAttachments] = useState(false);
|
||||
const [previewAttachment, setPreviewAttachment] = useState<ComposerAttachment | null>(null);
|
||||
const [smimeSign_, setSmimeSign] = useState(false);
|
||||
const [smimeEncrypt_, setSmimeEncrypt] = useState(false);
|
||||
const [smimePassphrasePrompt, setSmimePassphrasePrompt] = useState<{ keyId: string; resolve: (passphrase: string) => void; reject: () => void } | null>(null);
|
||||
const [smimePassphraseInput, setSmimePassphraseInput] = useState('');
|
||||
const [smimePassphraseError, setSmimePassphraseError] = useState('');
|
||||
const [showAttachmentWarning, setShowAttachmentWarning] = useState(false);
|
||||
const [attachmentWarningKeyword, setAttachmentWarningKeyword] = useState('');
|
||||
const [attachmentWarningDelayedUntil, setAttachmentWarningDelayedUntil] = useState<string | undefined>();
|
||||
@@ -677,6 +689,19 @@ export function EmailComposer({
|
||||
useEffect(() => {
|
||||
if (!autoSelectReplyIdentity) return;
|
||||
if (selectedIdentityId || initialData?.selectedIdentityId) return;
|
||||
|
||||
// New message started from a specific mailbox/account: default the From to
|
||||
// that mailbox's identity instead of the primary one, so composing while
|
||||
// viewing info@ sends as info@. Reply/forward fall through to the
|
||||
// recipient-based resolution below.
|
||||
if (mode === 'compose') {
|
||||
const composeIdentityId = findComposeIdentityId(identities, composeFromAccountEmail);
|
||||
if (composeIdentityId) {
|
||||
setSelectedIdentityId(composeIdentityId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode !== 'reply' && mode !== 'replyAll') return;
|
||||
|
||||
const resolved = resolveReplyFrom(identities, {
|
||||
@@ -710,6 +735,7 @@ export function EmailComposer({
|
||||
}
|
||||
}, [
|
||||
autoSelectReplyIdentity,
|
||||
composeFromAccountEmail,
|
||||
fromOverrideEnabled,
|
||||
identities,
|
||||
initialData?.selectedIdentityId,
|
||||
@@ -797,38 +823,17 @@ export function EmailComposer({
|
||||
? `<div>${getPlainTextSignature(signatureIdentity).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')}</div>`
|
||||
: '';
|
||||
const getAutocomplete = useContactStore((s) => s.getAutocomplete);
|
||||
const searchRecipients = useContactStore((s) => s.searchRecipients);
|
||||
// Whether a Sent mailbox is known so the on-demand server search is worth
|
||||
// offering (falls back to hiding the "search the server" row otherwise).
|
||||
const canSearchServer = useContactStore((s) => s.sentMailboxId != null);
|
||||
const addToTrustedSendersBook = useContactStore((s) => s.addToTrustedSendersBook);
|
||||
const addTrustedSender = useSettingsStore((s) => s.addTrustedSender);
|
||||
const trustedSendersAddressBook = useSettingsStore((s) => s.trustedSendersAddressBook);
|
||||
const addTemplate = useTemplateStore((s) => s.addTemplate);
|
||||
const sendRawEmail = useEmailStore((s) => s.sendRawEmail);
|
||||
const smimeStore = useSmimeStore();
|
||||
|
||||
// Determine S/MIME availability for the selected identity
|
||||
const currentSmimeIdentityId = selectedIdentityId || primaryIdentity?.id;
|
||||
const smimeKeyRecord = currentSmimeIdentityId ? smimeStore.getKeyRecordForIdentity(currentSmimeIdentityId) : undefined;
|
||||
const canSmimeSign = !!smimeKeyRecord;
|
||||
const canSmimeEncrypt = (() => {
|
||||
if (!smimeKeyRecord) return false;
|
||||
const allRecipients = [
|
||||
...withInput(to, toInput),
|
||||
...withInput(cc, ccInput),
|
||||
...withInput(bcc, bccInput),
|
||||
].map(r => r.email);
|
||||
if (allRecipients.length === 0) return false;
|
||||
const { missing } = smimeStore.getRecipientCerts(allRecipients);
|
||||
return missing.length === 0;
|
||||
})();
|
||||
|
||||
// Initialize S/MIME defaults from store when identity changes
|
||||
useEffect(() => {
|
||||
if (currentSmimeIdentityId) {
|
||||
setSmimeSign(!!smimeStore.defaultSignIdentity[currentSmimeIdentityId] && canSmimeSign);
|
||||
}
|
||||
setSmimeEncrypt(smimeStore.defaultEncrypt && canSmimeEncrypt);
|
||||
// Only run when identity changes, not on every recipient edit
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentSmimeIdentityId]);
|
||||
// Sign/encrypt is provided by crypto plugins (S/MIME, PGP) via the
|
||||
// composer-toolbar slot + the onComposeSend hook — the host stays
|
||||
// crypto-agnostic.
|
||||
|
||||
// Serialized recipient strings for ComposerDraftData (string-shaped) and for
|
||||
// by-value dirty comparison. Folds in any uncommitted typed text.
|
||||
@@ -897,6 +902,10 @@ export function EmailComposer({
|
||||
const [autocompleteResults, setAutocompleteResults] = useState<Array<{ name: string; email: string }>>([]);
|
||||
const [activeAutoField, setActiveAutoField] = useState<'to' | 'cc' | 'bcc' | null>(null);
|
||||
const [autoSelectedIndex, setAutoSelectedIndex] = useState(-1);
|
||||
// Current trimmed query behind the open dropdown, plus the in-flight flag for
|
||||
// the on-demand Sent-folder lookup ("search the server" row).
|
||||
const [autoQuery, setAutoQuery] = useState('');
|
||||
const [isSearchingServer, setIsSearchingServer] = useState(false);
|
||||
const autocompleteTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const toInputRef = useRef<HTMLInputElement>(null);
|
||||
const ccInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -944,8 +953,10 @@ export function EmailComposer({
|
||||
setAutocompleteResults([]);
|
||||
setActiveAutoField(null);
|
||||
setAutoSelectedIndex(-1);
|
||||
setAutoQuery('');
|
||||
return;
|
||||
}
|
||||
setAutoQuery(query);
|
||||
|
||||
autocompleteTimeoutRef.current = setTimeout(async () => {
|
||||
const localResults = getAutocomplete(query);
|
||||
@@ -953,10 +964,39 @@ export function EmailComposer({
|
||||
const initial: RecipientSuggestion[] = localResults.map(r => ({ name: r.name, email: r.email }));
|
||||
const merged = await contactHooks.onProvideRecipientSuggestions.transform(initial, { query });
|
||||
setAutocompleteResults(merged.map(s => ({ name: s.name, email: s.email })));
|
||||
setActiveAutoField(merged.length > 0 ? field : null);
|
||||
// Keep the dropdown open even without local hits when a server search is
|
||||
// available, so the "search the server" row stays reachable (OWA-style).
|
||||
setActiveAutoField(merged.length > 0 || canSearchServer ? field : null);
|
||||
setAutoSelectedIndex(-1);
|
||||
}, 200);
|
||||
}, [getAutocomplete]);
|
||||
}, [getAutocomplete, canSearchServer]);
|
||||
|
||||
// On-demand: search the Sent folder server-side for recipients matching the
|
||||
// current query and merge fresh hits into the open dropdown (deduped by email).
|
||||
const handleServerSearch = useCallback(async () => {
|
||||
const query = autoQuery.trim();
|
||||
if (!composerClient || !query || isSearchingServer) return;
|
||||
setIsSearchingServer(true);
|
||||
try {
|
||||
const serverResults = await searchRecipients(composerClient, query);
|
||||
setAutocompleteResults((prev) => {
|
||||
const seen = new Set(prev.map((r) => r.email.toLowerCase()));
|
||||
const merged = [...prev];
|
||||
for (const r of serverResults) {
|
||||
const key = r.email.toLowerCase();
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
merged.push(r);
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
});
|
||||
} catch {
|
||||
// Best-effort: a failed lookup just leaves the local suggestions in place.
|
||||
} finally {
|
||||
setIsSearchingServer(false);
|
||||
}
|
||||
}, [autoQuery, composerClient, isSearchingServer, searchRecipients]);
|
||||
|
||||
const insertAutocomplete = (suggestion: { name: string; email: string }, field: 'to' | 'cc' | 'bcc') => {
|
||||
const setter = field === 'to' ? setTo : field === 'cc' ? setCc : setBcc;
|
||||
@@ -1090,7 +1130,16 @@ export function EmailComposer({
|
||||
const controller = newAttachments[i].abortController;
|
||||
try {
|
||||
if (controller?.signal.aborted) continue;
|
||||
const { blobId } = await client.uploadBlob(file);
|
||||
|
||||
const fileId = generateUUID();
|
||||
await fileStorage.saveFile(fileId, file);
|
||||
|
||||
const newFileId = await emailHooks.onBeforeBlobUpload.transform(fileId);
|
||||
|
||||
const newFile = await fileStorage.getFile(newFileId) || file;
|
||||
await fileStorage.deleteFile(newFileId);
|
||||
|
||||
const { blobId } = await client.uploadBlob(newFile);
|
||||
|
||||
if (controller?.signal.aborted) continue;
|
||||
setAttachments(prev =>
|
||||
@@ -1298,21 +1347,36 @@ export function EmailComposer({
|
||||
|
||||
try {
|
||||
const previousDraftId = draftIdRef.current;
|
||||
let savedDraft : AlmostSavedDraft = {
|
||||
to: toAddresses,
|
||||
subject: subject || t('no_subject'),
|
||||
body: plainTextMode ? body : htmlToPlainText(body),
|
||||
cc: ccAddresses,
|
||||
bcc: bccAddresses,
|
||||
identityId: currentIdentityRawId,
|
||||
fromEmail,
|
||||
draftId: previousDraftId || undefined,
|
||||
attachments: uploadedAttachments,
|
||||
fromName,
|
||||
htmlBody: plainTextMode ? undefined : body
|
||||
}
|
||||
savedDraft = await emailHooks.onBeforeDraftAutoSave.transform(savedDraft);
|
||||
|
||||
// Use the JMAP client and raw identity id for the *owning* account
|
||||
// - falls back to active client for single-account / same-account
|
||||
// identities. See `composerClient` derivation above.
|
||||
const savedDraftId = await composerClient.createDraft(
|
||||
toAddresses,
|
||||
subject || t('no_subject'),
|
||||
plainTextMode ? body : htmlToPlainText(body),
|
||||
ccAddresses,
|
||||
bccAddresses,
|
||||
currentIdentityRawId,
|
||||
fromEmail,
|
||||
previousDraftId || undefined,
|
||||
uploadedAttachments,
|
||||
fromName,
|
||||
plainTextMode ? undefined : body
|
||||
savedDraft.to,
|
||||
savedDraft.subject,
|
||||
savedDraft.body,
|
||||
savedDraft.cc,
|
||||
savedDraft.bcc,
|
||||
savedDraft.identityId,
|
||||
savedDraft.fromEmail,
|
||||
savedDraft.draftId,
|
||||
savedDraft.attachments,
|
||||
savedDraft.fromName,
|
||||
savedDraft.htmlBody
|
||||
);
|
||||
|
||||
// Update the ref synchronously so a queued save sees the new id and
|
||||
@@ -1416,6 +1480,7 @@ export function EmailComposer({
|
||||
const canSend = toAddresses.length > 0 && !!subject && hasContent;
|
||||
|
||||
const getSendTooltip = (): string | undefined => {
|
||||
if (isWaitingForUploads) return t('validation.attachments_uploading');
|
||||
if (canSend) return undefined;
|
||||
if (toAddresses.length === 0) return t('validation.recipient_required');
|
||||
if (!subject) return t('validation.subject_required');
|
||||
@@ -1501,8 +1566,43 @@ export function EmailComposer({
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const isSendingRef = useRef(false);
|
||||
|
||||
// Attachments still uploading when Send is clicked used to be silently
|
||||
// dropped from the outgoing message (the filters below exclude anything
|
||||
// with uploading:true). attachmentsRef gives handleSend a way to read the
|
||||
// freshest attachment state after waiting on in-flight uploads, since the
|
||||
// `attachments` closure captured at click time won't reflect uploads that
|
||||
// finish during that wait.
|
||||
const attachmentsRef = useRef<ComposerAttachment[]>(attachments);
|
||||
useEffect(() => {
|
||||
attachmentsRef.current = attachments;
|
||||
}, [attachments]);
|
||||
const [isWaitingForUploads, setIsWaitingForUploads] = useState(false);
|
||||
const sendCancelledRef = useRef(false);
|
||||
|
||||
const handleSend = async (skipAttachmentCheck = false, delayedUntil?: string) => {
|
||||
if (isSendingRef.current) return;
|
||||
|
||||
if (attachmentsRef.current.some(att => att.uploading)) {
|
||||
isSendingRef.current = true;
|
||||
setIsSending(true);
|
||||
setIsWaitingForUploads(true);
|
||||
const uploadResult = await waitForPendingUploads(
|
||||
() => attachmentsRef.current,
|
||||
() => sendCancelledRef.current
|
||||
);
|
||||
setIsWaitingForUploads(false);
|
||||
isSendingRef.current = false;
|
||||
setIsSending(false);
|
||||
if (uploadResult === 'cancelled') return;
|
||||
if (uploadResult === 'failed') {
|
||||
// An upload broke while we were waiting - the user may not be
|
||||
// looking at the composer, so auto-sending would silently drop
|
||||
// the failed attachment. Abort and let them decide.
|
||||
toast.error(t('validation.attachment_upload_failed'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const ccAddresses = withInput(cc, ccInput);
|
||||
const bccAddresses = withInput(bcc, bccInput);
|
||||
|
||||
@@ -1523,9 +1623,15 @@ export function EmailComposer({
|
||||
|
||||
// Attachment reminder check
|
||||
if (!skipAttachmentCheck && attachmentReminderEnabled) {
|
||||
const hasAttachments = attachments.some(att => att.blobId && !att.uploading && !att.error);
|
||||
const hasAttachments = attachmentsRef.current.some(att => att.blobId && !att.uploading && !att.error);
|
||||
if (!hasAttachments) {
|
||||
const bodyText = htmlToPlainText(body);
|
||||
// Scan only the user-authored text: the quoted original of a
|
||||
// reply/forward often mentions an attachment itself, which used to fire
|
||||
// the reminder even when the user typed no keyword and added nothing (#570).
|
||||
const bodyText = extractUserAuthoredText(body, {
|
||||
plainTextMode,
|
||||
forwardedSeparator: tQuote('forwarded_separator'),
|
||||
});
|
||||
const searchText = `${subject} ${bodyText}`.toLowerCase();
|
||||
const matched = attachmentReminderKeywords.find(kw => searchText.includes(kw.toLowerCase()));
|
||||
if (matched) {
|
||||
@@ -1639,7 +1745,7 @@ export function EmailComposer({
|
||||
textBody: finalBody,
|
||||
identityId: currentIdentity?.id || '',
|
||||
fromEmail,
|
||||
attachments: attachments
|
||||
attachments: attachmentsRef.current
|
||||
.filter(att => att.blobId && !att.uploading && !att.error)
|
||||
.map(a => ({ name: a.name, type: a.type || 'application/octet-stream', size: a.size })),
|
||||
inReplyTo: threadingHeaders?.inReplyTo?.[0],
|
||||
@@ -1647,149 +1753,43 @@ export function EmailComposer({
|
||||
const sendAllowed = await emailHooks.onBeforeEmailSend.intercept(sendablePreview);
|
||||
if (!sendAllowed) return;
|
||||
|
||||
// S/MIME send pipeline: build raw MIME → sign → encrypt → sendRawEmail
|
||||
if ((smimeSign_ || smimeEncrypt_) && client && currentIdentity?.id) {
|
||||
// S/MIME keys are scoped to one JMAP account's identity - sending
|
||||
// from a cross-account identity via S/MIME would mix accounts'
|
||||
// certs/clients. Refuse upfront and tell the user to switch.
|
||||
const crossAccount = stripCrossAccountIdentityPrefix(currentIdentity.id);
|
||||
if (crossAccount.localAccountId) {
|
||||
throw new Error('S/MIME sending from another account’s identity is not supported. Switch to that account first.');
|
||||
}
|
||||
// 1. Resolve S/MIME key
|
||||
if (smimeSign_ && !smimeKeyRecord) {
|
||||
throw new Error('No S/MIME key bound to this identity');
|
||||
}
|
||||
// S/MIME binds to the identity's key; sending from an override address
|
||||
// would produce a signature whose Subject differs from the visible
|
||||
// From, which most clients reject or flag. Refuse up front.
|
||||
if (overrideActive) {
|
||||
throw new Error('Cannot use From override with S/MIME - disable one to send.');
|
||||
}
|
||||
|
||||
// 2. Ensure key is unlocked for signing
|
||||
if (smimeSign_ && smimeKeyRecord && !smimeStore.isKeyUnlocked(smimeKeyRecord.id)) {
|
||||
const passphrase = await new Promise<string>((resolve, reject) => {
|
||||
setSmimePassphrasePrompt({ keyId: smimeKeyRecord.id, resolve, reject });
|
||||
});
|
||||
try {
|
||||
await smimeStore.unlockKey(smimeKeyRecord.id, passphrase);
|
||||
} finally {
|
||||
setSmimePassphrasePrompt(null);
|
||||
setSmimePassphraseInput('');
|
||||
setSmimePassphraseError('');
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Resolve attachments as ArrayBuffers
|
||||
const mimeAttachments: MimeAttachment[] = [];
|
||||
for (const att of attachments) {
|
||||
if (att.error || att.uploading) continue;
|
||||
let content: ArrayBuffer;
|
||||
if (att.file && att.file.size > 0) {
|
||||
content = await att.file.arrayBuffer();
|
||||
} else if (att.blobId && client) {
|
||||
content = await client.fetchBlobArrayBuffer(att.blobId, att.name, att.type);
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
mimeAttachments.push({
|
||||
filename: att.name,
|
||||
contentType: att.type || 'application/octet-stream',
|
||||
content,
|
||||
// Hand off to a crypto plugin (S/MIME, PGP, …) if one wants to take over
|
||||
// the send: it builds raw MIME, signs/encrypts, and submits via
|
||||
// api.jmap.sendRaw. A handler returning false means "I sent it" — the
|
||||
// host then skips its own plaintext submission but still cleans up the
|
||||
// draft (and fires the scheduled-send callback for a delayed send).
|
||||
const composeSendRequest = {
|
||||
to: toAddresses.map(r => formatRecipient(r.name, r.email)),
|
||||
cc: ccAddresses.map(r => formatRecipient(r.name, r.email)),
|
||||
bcc: bccAddresses.map(r => formatRecipient(r.name, r.email)),
|
||||
subject,
|
||||
htmlBody: finalHtmlBody || '',
|
||||
textBody: finalBody,
|
||||
identityId: currentIdentity?.id || '',
|
||||
fromEmail,
|
||||
fromName,
|
||||
inReplyTo: threadingHeaders?.inReplyTo?.[0],
|
||||
references: threadingHeaders?.references,
|
||||
delayedUntil: effectiveDelayedUntil,
|
||||
attachments: [
|
||||
...attachmentsRef.current
|
||||
.filter(att => att.blobId && !att.uploading && !att.error)
|
||||
.map(a => ({ name: a.name, type: a.type || 'application/octet-stream', size: a.size, blobId: a.blobId })),
|
||||
...inlineAttachments.map(a => ({ name: a.name, type: a.type, size: a.size, blobId: a.blobId, cid: a.cid })),
|
||||
],
|
||||
};
|
||||
const sendHandledByPlugin = (await emailHooks.onComposeSend.intercept(composeSendRequest)) === false;
|
||||
if (sendHandledByPlugin) {
|
||||
if (finalDraftId) {
|
||||
client?.deleteEmail(finalDraftId).catch((err) => {
|
||||
debug.warn('email', 'Plugin handled the send, but draft cleanup failed:', err);
|
||||
});
|
||||
}
|
||||
for (const inline of inlineAttachments) {
|
||||
if (!client) break;
|
||||
const content = await client.fetchBlobArrayBuffer(inline.blobId, inline.name, inline.type);
|
||||
mimeAttachments.push({
|
||||
filename: inline.name,
|
||||
contentType: inline.type,
|
||||
content,
|
||||
cid: inline.cid,
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Build canonical MIME
|
||||
// mime-builder takes inReplyTo as a single ref-form msg-id (with brackets);
|
||||
// references stays an array. threadingHeaders contains bare msg-ids.
|
||||
const mimeInReplyTo = threadingHeaders?.inReplyTo[0]
|
||||
? `<${threadingHeaders.inReplyTo[0]}>`
|
||||
: undefined;
|
||||
const mimeReferences = threadingHeaders?.references.length
|
||||
? threadingHeaders.references.map(id => `<${id}>`)
|
||||
: undefined;
|
||||
const mimeBytes = buildMimeMessage({
|
||||
from: { name: currentIdentity.name || undefined, email: fromEmail || currentIdentity.email },
|
||||
to: toAddresses,
|
||||
cc: ccAddresses.length > 0 ? ccAddresses : undefined,
|
||||
bcc: bccAddresses.length > 0 ? bccAddresses : undefined,
|
||||
subject,
|
||||
inReplyTo: mimeInReplyTo,
|
||||
references: mimeReferences,
|
||||
textBody: finalBody,
|
||||
htmlBody: finalHtmlBody,
|
||||
attachments: mimeAttachments.length > 0 ? mimeAttachments : undefined,
|
||||
});
|
||||
|
||||
let payload: Blob = new Blob([mimeBytes.buffer as ArrayBuffer], { type: 'message/rfc822' });
|
||||
|
||||
const smimeHeaders = {
|
||||
from: { name: currentIdentity.name || undefined, email: fromEmail || currentIdentity.email },
|
||||
to: toAddresses,
|
||||
cc: ccAddresses.length > 0 ? ccAddresses : undefined,
|
||||
subject,
|
||||
inReplyTo: mimeInReplyTo,
|
||||
references: mimeReferences,
|
||||
};
|
||||
|
||||
// 5. Sign if enabled
|
||||
if (smimeSign_ && smimeKeyRecord) {
|
||||
const privateKey = smimeStore.getUnlockedKey(smimeKeyRecord.id);
|
||||
if (!privateKey) throw new Error('S/MIME key is not unlocked');
|
||||
const cmsBlob = await smimeSign(
|
||||
mimeBytes,
|
||||
privateKey,
|
||||
smimeKeyRecord.certificate,
|
||||
smimeKeyRecord.certificateChain || [],
|
||||
);
|
||||
const cmsBytes = new Uint8Array(await cmsBlob.arrayBuffer());
|
||||
payload = wrapCmsAsSmimeMessage(cmsBytes, { ...smimeHeaders, smimeType: 'signed-data' });
|
||||
}
|
||||
|
||||
// 6. Encrypt if enabled
|
||||
if (smimeEncrypt_ && smimeKeyRecord) {
|
||||
const allRecipients = [...toAddresses, ...ccAddresses, ...bccAddresses].map(r => r.email);
|
||||
const { found, missing } = smimeStore.getRecipientCerts(allRecipients);
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`Missing certificates for: ${missing.join(', ')}`);
|
||||
}
|
||||
const recipientCertsDer = found.map(c => c.certificate instanceof ArrayBuffer ? c.certificate : new Uint8Array(c.certificate as ArrayBuffer).buffer);
|
||||
const payloadBytes = new Uint8Array(await payload.arrayBuffer());
|
||||
const cmsBlob = await smimeEncrypt(
|
||||
payloadBytes,
|
||||
recipientCertsDer,
|
||||
smimeKeyRecord.certificate,
|
||||
);
|
||||
const cmsBytes = new Uint8Array(await cmsBlob.arrayBuffer());
|
||||
payload = wrapCmsAsSmimeMessage(cmsBytes, { ...smimeHeaders, smimeType: 'enveloped-data' });
|
||||
}
|
||||
|
||||
// 7. Send via raw email path
|
||||
const result = await sendRawEmail(client, payload, currentIdentity.id, effectiveDelayedUntil, [...toAddresses, ...ccAddresses, ...bccAddresses].map(r => r.email));
|
||||
if (effectiveDelayedUntil && finalDraftId) {
|
||||
client.deleteEmail(finalDraftId).catch(err => {
|
||||
debug.warn('email', 'Scheduled S/MIME send created, but plaintext draft cleanup failed:', err);
|
||||
toast.warning(t('schedule_send_cleanup_warning'));
|
||||
});
|
||||
}
|
||||
if (result.scheduled) {
|
||||
await onScheduledSendCreated?.();
|
||||
}
|
||||
if (effectiveDelayedUntil) await onScheduledSendCreated?.();
|
||||
} else {
|
||||
// Standard JMAP send path
|
||||
// Collect uploaded attachment blobIds for the send request
|
||||
const uploadedAttachments: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }> = attachments
|
||||
const uploadedAttachments: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }> = attachmentsRef.current
|
||||
.filter(att => att.blobId && !att.uploading && !att.error)
|
||||
.map(att => ({ blobId: att.blobId!, name: att.name, type: att.type || 'application/octet-stream', size: att.size }));
|
||||
uploadedAttachments.push(...inlineAttachments);
|
||||
@@ -1916,6 +1916,7 @@ export function EmailComposer({
|
||||
}, []);
|
||||
|
||||
const cleanClose = () => {
|
||||
sendCancelledRef.current = true;
|
||||
explicitCloseRef.current = true;
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current);
|
||||
@@ -1925,6 +1926,7 @@ export function EmailComposer({
|
||||
};
|
||||
|
||||
const handleSaveDraftAndClose = async () => {
|
||||
sendCancelledRef.current = true;
|
||||
explicitCloseRef.current = true;
|
||||
setShowCloseDialog(false);
|
||||
if (saveTimeoutRef.current) {
|
||||
@@ -1936,6 +1938,7 @@ export function EmailComposer({
|
||||
};
|
||||
|
||||
const handleDiscardAndClose = () => {
|
||||
sendCancelledRef.current = true;
|
||||
explicitCloseRef.current = true;
|
||||
setShowCloseDialog(false);
|
||||
if (saveTimeoutRef.current) {
|
||||
@@ -1956,6 +1959,17 @@ export function EmailComposer({
|
||||
}
|
||||
};
|
||||
|
||||
// Expose the dirty-aware close handler so external hosts (e.g. the Pro tab
|
||||
// bar) can trigger the same "Save or discard draft?" guard. Re-assigned on
|
||||
// every render to capture the latest closure over the live refs/state.
|
||||
useEffect(() => {
|
||||
if (!requestCloseRef) return;
|
||||
requestCloseRef.current = handleClose;
|
||||
return () => {
|
||||
requestCloseRef.current = null;
|
||||
};
|
||||
});
|
||||
|
||||
const handleComposerKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.defaultPrevented) return;
|
||||
|
||||
@@ -1969,7 +1983,6 @@ export function EmailComposer({
|
||||
showTemplatePicker ||
|
||||
showSaveAsTemplate ||
|
||||
showScheduleDialog ||
|
||||
smimePassphrasePrompt ||
|
||||
showAttachmentWarning ||
|
||||
showCloseDialog
|
||||
) return;
|
||||
@@ -2002,7 +2015,7 @@ export function EmailComposer({
|
||||
<div ref={composerRootRef} className={cn("flex h-full bg-background", className)}>
|
||||
<PluginSlot
|
||||
name="composer-sidebar"
|
||||
className="hidden md:flex shrink-0 h-full overflow-hidden border-r border-border"
|
||||
className="hidden md:flex shrink-0 h-full overflow-hidden border-e border-border"
|
||||
/>
|
||||
{/* Right-side composer sidebar slot is rendered after the main content div below. */}
|
||||
<div
|
||||
@@ -2059,7 +2072,7 @@ export function EmailComposer({
|
||||
size="sm"
|
||||
className="md:hidden h-9 px-4"
|
||||
>
|
||||
<Send className="w-4 h-4 mr-1.5" />
|
||||
<Send className="w-4 h-4 me-1.5" />
|
||||
{t('send')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -2206,6 +2219,10 @@ export function EmailComposer({
|
||||
autoSelectedIndex={autoSelectedIndex}
|
||||
dropdownRef={toDropdownRef}
|
||||
onInsertAutocomplete={insertAutocomplete}
|
||||
canSearchServer={canSearchServer}
|
||||
onServerSearch={handleServerSearch}
|
||||
isSearchingServer={isSearchingServer}
|
||||
serverSearchQuery={autoQuery}
|
||||
validationError={validationErrors.to}
|
||||
validationMessage={t('validation.recipient_required')}
|
||||
onTab={focusSubject}
|
||||
@@ -2283,6 +2300,10 @@ export function EmailComposer({
|
||||
autoSelectedIndex={autoSelectedIndex}
|
||||
dropdownRef={ccDropdownRef}
|
||||
onInsertAutocomplete={insertAutocomplete}
|
||||
canSearchServer={canSearchServer}
|
||||
onServerSearch={handleServerSearch}
|
||||
isSearchingServer={isSearchingServer}
|
||||
serverSearchQuery={autoQuery}
|
||||
onMoveChip={handleMoveChip}
|
||||
/>
|
||||
</div>
|
||||
@@ -2308,6 +2329,10 @@ export function EmailComposer({
|
||||
autoSelectedIndex={autoSelectedIndex}
|
||||
dropdownRef={bccDropdownRef}
|
||||
onInsertAutocomplete={insertAutocomplete}
|
||||
canSearchServer={canSearchServer}
|
||||
onServerSearch={handleServerSearch}
|
||||
isSearchingServer={isSearchingServer}
|
||||
serverSearchQuery={autoQuery}
|
||||
onMoveChip={handleMoveChip}
|
||||
/>
|
||||
</div>
|
||||
@@ -2445,7 +2470,7 @@ export function EmailComposer({
|
||||
)}
|
||||
<button
|
||||
onClick={() => removeAttachment(index)}
|
||||
className="ml-1 hover:text-red-500 min-w-[20px] min-h-[20px] flex items-center justify-center"
|
||||
className="ms-1 hover:text-red-500 min-w-[20px] min-h-[20px] flex items-center justify-center"
|
||||
title={att.uploading ? t('upload_cancel') : undefined}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
@@ -2505,31 +2530,8 @@ export function EmailComposer({
|
||||
>
|
||||
<BookmarkPlus className="w-4 h-4" />
|
||||
</Button>
|
||||
{/* S/MIME toggles */}
|
||||
{canSmimeSign && (
|
||||
<>
|
||||
<div className="w-px h-5 bg-border mx-1" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setSmimeSign(v => !v)}
|
||||
className={cn("h-9 w-9", smimeSign_ && "bg-primary/10 text-primary")}
|
||||
title={smimeSign_ ? t('smime_sign_on') : t('smime_sign_off')}
|
||||
>
|
||||
<ShieldCheck className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setSmimeEncrypt(v => !v)}
|
||||
disabled={!canSmimeEncrypt}
|
||||
className={cn("h-9 w-9", smimeEncrypt_ && "bg-primary/10 text-primary")}
|
||||
title={smimeEncrypt_ ? t('smime_encrypt_on') : canSmimeEncrypt ? t('smime_encrypt_off') : t('smime_encrypt_unavailable')}
|
||||
>
|
||||
<Lock className="w-4 h-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{/* Sign/encrypt controls are contributed by crypto plugins via the
|
||||
composer-toolbar slot (rendered below). */}
|
||||
|
||||
{/* Read-receipt request toggle */}
|
||||
<Button
|
||||
@@ -2563,9 +2565,9 @@ export function EmailComposer({
|
||||
onClick={() => handleSend()}
|
||||
disabled={!canSend || isSending}
|
||||
title={getSendTooltip()}
|
||||
className="rounded-r-none border-r border-primary-foreground/20"
|
||||
className="rounded-e-none border-e border-primary-foreground/20"
|
||||
>
|
||||
<Send className="w-4 h-4 mr-2" />
|
||||
<Send className="w-4 h-4 me-2" />
|
||||
{t('send')}
|
||||
</Button>
|
||||
<Button
|
||||
@@ -2573,7 +2575,7 @@ export function EmailComposer({
|
||||
onClick={() => setShowSendMenu((open) => !open)}
|
||||
disabled={!canSend || isSending}
|
||||
title={t('schedule_send')}
|
||||
className="rounded-l-none px-2"
|
||||
className="rounded-s-none px-2"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={showSendMenu}
|
||||
>
|
||||
@@ -2588,7 +2590,7 @@ export function EmailComposer({
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={openScheduleDialog}
|
||||
className="flex w-full items-center gap-2 rounded-sm px-3 py-2 text-left text-sm hover:bg-accent hover:text-accent-foreground"
|
||||
className="flex w-full items-center gap-2 rounded-sm px-3 py-2 text-start text-sm hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<CalendarClock className="w-4 h-4" />
|
||||
{t('schedule_send')}
|
||||
@@ -2603,7 +2605,7 @@ export function EmailComposer({
|
||||
title={getSendTooltip()}
|
||||
className="hidden md:inline-flex"
|
||||
>
|
||||
<Send className="w-4 h-4 mr-2" />
|
||||
<Send className="w-4 h-4 me-2" />
|
||||
{t('send')}
|
||||
</Button>
|
||||
)}
|
||||
@@ -2668,60 +2670,6 @@ export function EmailComposer({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* S/MIME passphrase prompt */}
|
||||
{smimePassphrasePrompt && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-[60] p-4 animate-in fade-in duration-150"
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="bg-background border border-border rounded-lg shadow-xl w-full max-w-sm animate-in zoom-in-95 duration-200"
|
||||
>
|
||||
<div className="p-6">
|
||||
<h2 className="text-lg font-semibold text-foreground">{t('smime_unlock_title')}</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">{t('smime_unlock_message')}</p>
|
||||
<input
|
||||
type="password"
|
||||
autoFocus
|
||||
value={smimePassphraseInput}
|
||||
onChange={(e) => {
|
||||
setSmimePassphraseInput(e.target.value);
|
||||
setSmimePassphraseError('');
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && smimePassphraseInput) {
|
||||
smimePassphrasePrompt.resolve(smimePassphraseInput);
|
||||
}
|
||||
}}
|
||||
placeholder={t('smime_passphrase_placeholder')}
|
||||
className="mt-3 w-full px-3 py-2 border border-border rounded-md text-sm bg-background text-foreground outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
{smimePassphraseError && (
|
||||
<p className="mt-1 text-xs text-red-500">{smimePassphraseError}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-3 px-6 pb-6">
|
||||
<Button variant="outline" onClick={() => {
|
||||
smimePassphrasePrompt.reject();
|
||||
setSmimePassphrasePrompt(null);
|
||||
setSmimePassphraseInput('');
|
||||
setSmimePassphraseError('');
|
||||
}}>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!smimePassphraseInput}
|
||||
onClick={() => smimePassphrasePrompt.resolve(smimePassphraseInput)}
|
||||
>
|
||||
{t('smime_unlock_button')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAttachmentWarning && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-[60] p-4 animate-in fade-in duration-150"
|
||||
@@ -2776,7 +2724,7 @@ export function EmailComposer({
|
||||
{t('discard')}
|
||||
</Button>
|
||||
<Button onClick={handleSaveDraftAndClose}>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
<Save className="w-4 h-4 me-2" />
|
||||
{t('save_draft')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -2795,7 +2743,7 @@ export function EmailComposer({
|
||||
</div>
|
||||
<PluginSlot
|
||||
name="composer-sidebar-right"
|
||||
className="hidden md:flex shrink-0 h-full overflow-hidden border-l border-border"
|
||||
className="hidden md:flex shrink-0 h-full overflow-hidden border-s border-border"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -2806,7 +2754,10 @@ const AutocompleteDropdown = React.forwardRef<HTMLDivElement, {
|
||||
results: Array<{ name: string; email: string }>;
|
||||
selectedIndex: number;
|
||||
onSelect: (suggestion: { name: string; email: string }) => void;
|
||||
}>(function AutocompleteDropdown({ id, results, selectedIndex, onSelect }, ref) {
|
||||
onSearchServer?: () => void;
|
||||
isSearchingServer?: boolean;
|
||||
}>(function AutocompleteDropdown({ id, results, selectedIndex, onSelect, onSearchServer, isSearchingServer }, ref) {
|
||||
const t = useTranslations('email_composer');
|
||||
return (
|
||||
<div ref={ref} id={id} role="listbox" className="absolute top-full left-0 right-0 z-50 mt-1 bg-background border border-border rounded-md shadow-lg max-h-48 overflow-y-auto">
|
||||
{results.map((r, i) => (
|
||||
@@ -2817,7 +2768,7 @@ const AutocompleteDropdown = React.forwardRef<HTMLDivElement, {
|
||||
role="option"
|
||||
aria-selected={i === selectedIndex}
|
||||
className={cn(
|
||||
"w-full px-3 py-2 text-left text-sm flex items-center gap-2",
|
||||
"w-full px-3 py-2 text-start text-sm flex items-center gap-2",
|
||||
i === selectedIndex ? "bg-accent text-accent-foreground" : "hover:bg-muted"
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
@@ -2832,6 +2783,27 @@ const AutocompleteDropdown = React.forwardRef<HTMLDivElement, {
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{onSearchServer && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isSearchingServer}
|
||||
className={cn(
|
||||
"w-full px-3 py-2 text-left text-sm flex items-center gap-2 text-muted-foreground hover:bg-muted disabled:opacity-60 disabled:cursor-default",
|
||||
results.length > 0 && "border-t border-border"
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
if (!isSearchingServer) onSearchServer();
|
||||
}}
|
||||
>
|
||||
{isSearchingServer
|
||||
? <Loader2 className="w-4 h-4 shrink-0 animate-spin" />
|
||||
: <Search className="w-4 h-4 shrink-0" />}
|
||||
<span className="truncate">
|
||||
{isSearchingServer ? t('autocomplete_searching') : t('autocomplete_search_server')}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -2852,6 +2824,10 @@ function RecipientChipInput({
|
||||
autoSelectedIndex,
|
||||
dropdownRef,
|
||||
onInsertAutocomplete,
|
||||
canSearchServer,
|
||||
onServerSearch,
|
||||
isSearchingServer,
|
||||
serverSearchQuery,
|
||||
validationError,
|
||||
validationMessage,
|
||||
onTab,
|
||||
@@ -2872,6 +2848,10 @@ function RecipientChipInput({
|
||||
autoSelectedIndex: number;
|
||||
dropdownRef: React.RefObject<HTMLDivElement | null>;
|
||||
onInsertAutocomplete: (suggestion: { name: string; email: string }, field: 'to' | 'cc' | 'bcc') => void;
|
||||
canSearchServer: boolean;
|
||||
onServerSearch: () => void;
|
||||
isSearchingServer: boolean;
|
||||
serverSearchQuery: string;
|
||||
validationError?: boolean;
|
||||
validationMessage?: string;
|
||||
onTab?: () => void;
|
||||
@@ -2968,7 +2948,15 @@ function RecipientChipInput({
|
||||
}
|
||||
}
|
||||
|
||||
if ((e.key === ' ' || e.key === 'Enter' || e.key === 'Tab') && inputText.trim()) {
|
||||
// Enter / Tab commit whatever is typed. Space only commits when the input
|
||||
// is already a complete email address; otherwise Space is a normal
|
||||
// character so a name search like "John Doe" can continue past the space
|
||||
// instead of committing "John" as a bogus recipient (#571).
|
||||
const trimmedInput = inputText.trim();
|
||||
const commitOnKey =
|
||||
((e.key === 'Enter' || e.key === 'Tab') && trimmedInput) ||
|
||||
(e.key === ' ' && isValidEmail(trimmedInput));
|
||||
if (commitOnKey) {
|
||||
if (e.key !== 'Tab') e.preventDefault();
|
||||
commitCurrentInput();
|
||||
if (e.key === 'Tab' && onTab) {
|
||||
@@ -3173,13 +3161,16 @@ function RecipientChipInput({
|
||||
{validationError && validationMessage && (
|
||||
<p className="text-xs text-red-600 dark:text-red-400 mt-0.5">{validationMessage}</p>
|
||||
)}
|
||||
{activeAutoField === field && autocompleteResults.length > 0 && (
|
||||
{activeAutoField === field &&
|
||||
(autocompleteResults.length > 0 || (canSearchServer && serverSearchQuery.length > 0)) && (
|
||||
<AutocompleteDropdown
|
||||
ref={dropdownRef}
|
||||
id={`autocomplete-${field}`}
|
||||
results={autocompleteResults}
|
||||
selectedIndex={autoSelectedIndex}
|
||||
onSelect={(suggestion) => onInsertAutocomplete(suggestion, field)}
|
||||
onSearchServer={canSearchServer && serverSearchQuery.length > 0 ? onServerSearch : undefined}
|
||||
isSearchingServer={isSearchingServer}
|
||||
/>
|
||||
)}
|
||||
<ContextMenu
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
Mail,
|
||||
MailOpen,
|
||||
Star,
|
||||
Pin,
|
||||
PinOff,
|
||||
Trash2,
|
||||
Archive,
|
||||
FolderInput,
|
||||
@@ -59,6 +61,7 @@ interface EmailContextMenuProps {
|
||||
onForward?: () => void;
|
||||
onMarkAsRead?: (read: boolean) => void;
|
||||
onToggleStar?: () => void;
|
||||
onTogglePinned?: () => void;
|
||||
onDelete?: () => void;
|
||||
onArchive?: () => void;
|
||||
onSetColorTag?: (color: string | null) => void;
|
||||
@@ -126,6 +129,7 @@ export function EmailContextMenu({
|
||||
onForward,
|
||||
onMarkAsRead,
|
||||
onToggleStar,
|
||||
onTogglePinned,
|
||||
onDelete,
|
||||
onArchive,
|
||||
onSetColorTag,
|
||||
@@ -149,10 +153,14 @@ export function EmailContextMenu({
|
||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||
const isUnread = !email.keywords?.$seen;
|
||||
const isStarred = email.keywords?.$flagged;
|
||||
const isPinned = email.keywords?.['$pinned'] === true;
|
||||
const isDraft = email.keywords?.['$draft'] === true;
|
||||
const currentColors = getCurrentColors(email.keywords);
|
||||
const showBatchActions = isMultiSelect && selectedCount > 1;
|
||||
const isInJunkFolder = currentMailboxRole === 'junk';
|
||||
// Marking your own outgoing mail as spam makes no sense - hide the action
|
||||
// in Sent, Drafts and Scheduled.
|
||||
const spamApplicable = !['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || '');
|
||||
const isScheduled = email.isScheduled === true;
|
||||
const canCancelScheduled = isScheduled && email.scheduledUndoStatus === 'pending';
|
||||
|
||||
@@ -326,7 +334,7 @@ export function EmailContextMenu({
|
||||
</div>
|
||||
)}
|
||||
{node.children.length > 0 && (
|
||||
<div className="pl-4">
|
||||
<div className="ps-4">
|
||||
{renderNodes(node.children)}
|
||||
</div>
|
||||
)}
|
||||
@@ -349,6 +357,15 @@ export function EmailContextMenu({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Pin/Unpin - only for single email; pinned mails float to the top of the list */}
|
||||
{!showBatchActions && onTogglePinned && (
|
||||
<ContextMenuItem
|
||||
icon={isPinned ? PinOff : Pin}
|
||||
label={isPinned ? t("unpin") : t("pin")}
|
||||
onClick={() => handleAction(onTogglePinned)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Set tag submenu - only for single email */}
|
||||
{!showBatchActions && (
|
||||
<ContextMenuSubMenu icon={Tag} label={t("color_tag")}>
|
||||
@@ -360,7 +377,7 @@ export function EmailContextMenu({
|
||||
role="menuitem"
|
||||
onClick={() => handleAction(() => onSetColorTag?.(option.value))}
|
||||
className={cn(
|
||||
"w-full px-3 py-1.5 text-sm text-left flex items-center gap-2 hover:bg-muted cursor-pointer",
|
||||
"w-full px-3 py-1.5 text-sm text-start flex items-center gap-2 hover:bg-muted cursor-pointer",
|
||||
isActive && "bg-accent font-medium"
|
||||
)}
|
||||
>
|
||||
@@ -385,22 +402,26 @@ export function EmailContextMenu({
|
||||
</ContextMenuSubMenu>
|
||||
)}
|
||||
|
||||
<ContextMenuSeparator />
|
||||
{/* Spam - contextual based on folder; pointless on own outgoing mail */}
|
||||
{spamApplicable && (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
|
||||
{/* Spam - contextual based on folder */}
|
||||
<ContextMenuItem
|
||||
icon={isInJunkFolder ? ShieldCheck : ShieldAlert}
|
||||
label={isInJunkFolder ? t("not_spam") : t("mark_as_spam")}
|
||||
onClick={() =>
|
||||
handleAction(
|
||||
showBatchActions
|
||||
? (isInJunkFolder ? onBatchUndoSpam! : onBatchMarkAsSpam!)
|
||||
: (isInJunkFolder ? onUndoSpam! : onMarkAsSpam!)
|
||||
)
|
||||
}
|
||||
disabled={showBatchActions ? (isInJunkFolder ? !onBatchUndoSpam : !onBatchMarkAsSpam) : (isInJunkFolder ? !onUndoSpam : !onMarkAsSpam)}
|
||||
destructive={!isInJunkFolder}
|
||||
/>
|
||||
<ContextMenuItem
|
||||
icon={isInJunkFolder ? ShieldCheck : ShieldAlert}
|
||||
label={isInJunkFolder ? t("not_spam") : t("mark_as_spam")}
|
||||
onClick={() =>
|
||||
handleAction(
|
||||
showBatchActions
|
||||
? (isInJunkFolder ? onBatchUndoSpam! : onBatchMarkAsSpam!)
|
||||
: (isInJunkFolder ? onUndoSpam! : onMarkAsSpam!)
|
||||
)
|
||||
}
|
||||
disabled={showBatchActions ? (isInJunkFolder ? !onBatchUndoSpam : !onBatchMarkAsSpam) : (isInJunkFolder ? !onUndoSpam : !onMarkAsSpam)}
|
||||
destructive={!isInJunkFolder}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ContextMenuSeparator />
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ interface EmailHoverActionsProps {
|
||||
// the spam quick-action flips to "not spam".
|
||||
isInJunk?: boolean;
|
||||
onUndoSpam?: () => void;
|
||||
// Hidden where marking spam is meaningless for self-authored mail (Drafts, Sent).
|
||||
spamApplicable?: boolean;
|
||||
}
|
||||
|
||||
const ACTION_CONFIG: Record<HoverAction, {
|
||||
@@ -78,6 +80,7 @@ export function EmailHoverActions({
|
||||
onMarkAsSpam,
|
||||
isInJunk = false,
|
||||
onUndoSpam,
|
||||
spamApplicable = true,
|
||||
}: EmailHoverActionsProps) {
|
||||
const hoverActions = useSettingsStore((state) => state.hoverActions);
|
||||
const hoverActionsMode = useSettingsStore((state) => state.hoverActionsMode);
|
||||
@@ -121,6 +124,7 @@ export function EmailHoverActions({
|
||||
const actionButtons = hoverActions.map((actionId) => {
|
||||
const config = ACTION_CONFIG[actionId];
|
||||
if (!config) return null;
|
||||
if (actionId === "spam" && !spamApplicable) return null;
|
||||
const Icon = config.icon;
|
||||
|
||||
// In a junk context the spam action becomes "not spam".
|
||||
@@ -177,16 +181,17 @@ export function EmailHoverActions({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute right-0 top-0 bottom-0 z-10 hidden group-hover:flex items-center"
|
||||
className="absolute end-0 top-0 bottom-0 z-10 hidden group-hover:flex items-center"
|
||||
>
|
||||
<div
|
||||
className={cn("w-8 h-full", hoverBackgroundClassName)}
|
||||
style={{
|
||||
WebkitMaskImage: "linear-gradient(to right, transparent, black)",
|
||||
maskImage: "linear-gradient(to right, transparent, black)",
|
||||
}}
|
||||
className={cn(
|
||||
"w-8 h-full",
|
||||
"[mask-image:linear-gradient(to_right,transparent,black)] [-webkit-mask-image:linear-gradient(to_right,transparent,black)]",
|
||||
"rtl:[mask-image:linear-gradient(to_left,transparent,black)] rtl:[-webkit-mask-image:linear-gradient(to_left,transparent,black)]",
|
||||
hoverBackgroundClassName,
|
||||
)}
|
||||
/>
|
||||
<div className={cn("flex items-center gap-0.5 h-full pr-3 pl-0.5", hoverBackgroundClassName)}>
|
||||
<div className={cn("flex items-center gap-0.5 h-full pe-3 ps-0.5", hoverBackgroundClassName)}>
|
||||
{actionButtons}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,8 +5,8 @@ import { useCallback } from "react";
|
||||
import { formatDate, stripInvisibleLeading } from "@/lib/utils";
|
||||
import { Email } from "@/lib/jmap/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { Paperclip, Star, Circle, CheckSquare, Square, Reply, Forward } from "lucide-react";
|
||||
import { SelectableAvatar } from "@/components/email/selectable-avatar";
|
||||
import { Paperclip, Star, Pin, Circle, CheckSquare, Square, Reply, Forward } from "lucide-react";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
@@ -34,16 +34,19 @@ interface EmailListItemProps {
|
||||
|
||||
export function EmailListItem({ email, selected, onClick, onDoubleClick, onContextMenu, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam, onUndoSpam }: EmailListItemProps) {
|
||||
const t = useTranslations('email_viewer');
|
||||
const tBatch = useTranslations('email_list.batch_actions');
|
||||
const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, mailboxes, clearSelection, isUnifiedView, unifiedRole } = useEmailStore();
|
||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||
const density = useSettingsStore((state) => state.density);
|
||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||
const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag);
|
||||
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
|
||||
const { identities } = useAuthStore();
|
||||
const isChecked = selectedEmailIds.has(email.id);
|
||||
const isUnread = !email.keywords?.$seen;
|
||||
const isStarred = email.keywords?.$flagged;
|
||||
const isPinned = email.keywords?.['$pinned'] === true;
|
||||
const isImportant = email.keywords?.["$important"];
|
||||
const isAnswered = email.keywords?.$answered;
|
||||
const isForwarded = email.keywords?.$forwarded;
|
||||
@@ -66,7 +69,7 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
|
||||
const keywordDefs = colorTagIds.map(id => emailKeywords.find(k => k.id === id) ?? { id, label: id, color: 'gray' });
|
||||
// Use first tag for background coloring
|
||||
const keywordDef = keywordDefs[0] ?? null;
|
||||
const colorTag = keywordDef ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null;
|
||||
const colorTag = (tintListRowsByTag && keywordDef) ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null;
|
||||
|
||||
// Drag and drop functionality
|
||||
const { dragHandlers, isDragging } = useEmailDrag({
|
||||
@@ -87,7 +90,14 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
|
||||
|
||||
const handleCheckboxClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
toggleEmailSelection(email.id);
|
||||
if (e.shiftKey) {
|
||||
// Shift-click extends the selection from the anchor to here, like
|
||||
// shift-clicking the row (the checkbox stops propagation, so the
|
||||
// row's shift handler never runs — replicate it here).
|
||||
selectRangeEmails(email.id);
|
||||
} else {
|
||||
toggleEmailSelection(email.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent) => {
|
||||
@@ -166,19 +176,22 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
|
||||
|
||||
{/* Unread indicator */}
|
||||
{isUnread && (
|
||||
<div className="absolute left-0.5 top-1/2 -translate-y-1/2">
|
||||
<div className="absolute start-0.5 top-1/2 -translate-y-1/2">
|
||||
<Circle className="w-2 h-2 fill-unread text-unread" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Avatar */}
|
||||
{density !== 'extra-compact' && (
|
||||
<Avatar
|
||||
<SelectableAvatar
|
||||
name={sender?.name}
|
||||
email={sender?.email}
|
||||
size={isFocusedMailLayout ? "sm" : "md"}
|
||||
className="flex-shrink-0 shadow-sm"
|
||||
disableImages={hideJunkAvatarImages}
|
||||
checked={isChecked}
|
||||
onToggle={() => toggleEmailSelection(email.id)}
|
||||
selectLabel={tBatch('select')}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -206,6 +219,7 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5 shrink-0">
|
||||
{isPinned && <Pin className="w-3.5 h-3.5 text-primary" />}
|
||||
{isStarred && <Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />}
|
||||
{isImportant && <span className="h-2 w-2 rounded-full bg-warning" />}
|
||||
{isAnswered && !isForwarded && <Reply className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||
@@ -242,6 +256,9 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
|
||||
{sender?.name || sender?.email || "Unknown"}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isPinned && (
|
||||
<Pin className="w-3.5 h-3.5 text-primary" />
|
||||
)}
|
||||
{isStarred && (
|
||||
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
|
||||
)}
|
||||
@@ -327,6 +344,7 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
|
||||
onMarkAsSpam={onMarkAsSpam}
|
||||
onUndoSpam={onUndoSpam}
|
||||
isInJunk={currentMailboxRole === 'junk'}
|
||||
spamApplicable={!['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || '')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -35,6 +35,7 @@ interface EmailListProps {
|
||||
onForward?: (email: Email) => void;
|
||||
onMarkAsRead?: (email: Email, read: boolean) => void;
|
||||
onToggleStar?: (email: Email) => void;
|
||||
onTogglePinned?: (email: Email) => void;
|
||||
onDelete?: (email: Email) => void;
|
||||
onArchive?: (email: Email) => void;
|
||||
onSetColorTag?: (emailId: string, color: string | null) => void;
|
||||
@@ -64,6 +65,7 @@ export function EmailList({
|
||||
onForward,
|
||||
onMarkAsRead,
|
||||
onToggleStar,
|
||||
onTogglePinned,
|
||||
onDelete,
|
||||
onArchive,
|
||||
onSetColorTag,
|
||||
@@ -415,9 +417,9 @@ export function EmailList({
|
||||
className="text-destructive border-destructive/30 hover:bg-destructive/10 text-xs"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin mr-1" />
|
||||
<Loader2 className="w-3 h-3 animate-spin me-1" />
|
||||
) : (
|
||||
<Trash2 className="w-3 h-3 mr-1" />
|
||||
<Trash2 className="w-3 h-3 me-1" />
|
||||
)}
|
||||
{t('empty_folder.button')}
|
||||
</Button>
|
||||
@@ -551,6 +553,7 @@ export function EmailList({
|
||||
onForward={() => onForward?.(contextMenu.data!)}
|
||||
onMarkAsRead={(read) => onMarkAsRead?.(contextMenu.data!, read)}
|
||||
onToggleStar={() => onToggleStar?.(contextMenu.data!)}
|
||||
onTogglePinned={onTogglePinned ? () => onTogglePinned(contextMenu.data!) : undefined}
|
||||
onDelete={() => onDelete?.(contextMenu.data!)}
|
||||
onArchive={() => onArchive?.(contextMenu.data!)}
|
||||
onSetColorTag={(color) => onSetColorTag?.(contextMenu.data!.id, color)}
|
||||
|
||||
+290
-1140
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { MailCheck, Loader2, CheckCircle } from 'lucide-react';
|
||||
import { MailCheck, Loader2, CheckCircle, X } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
interface ReadReceiptBannerProps {
|
||||
@@ -17,43 +17,61 @@ export function ReadReceiptBanner({ requestedBy, onSend, onIgnore }: ReadReceipt
|
||||
const t = useTranslations('email_viewer.read_receipt');
|
||||
const [state, setState] = useState<'idle' | 'sending' | 'sent'>('idle');
|
||||
|
||||
// Matches the host's "External Content" banner row: a round tinted icon chip,
|
||||
// an uppercase eyebrow, a foreground message, and neutral bordered actions.
|
||||
if (state === 'sent') {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-md border border-border bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
|
||||
<CheckCircle className="w-4 h-4 text-green-600 dark:text-green-400 shrink-0" />
|
||||
<span>{t('sent')}</span>
|
||||
<div className="flex items-center gap-3 py-1">
|
||||
<div className="w-10 h-10 rounded-full bg-success/15 text-success flex items-center justify-center flex-shrink-0 shadow-sm">
|
||||
<CheckCircle className="w-5 h-5" />
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">{t('sent')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-md border border-amber-300/60 bg-amber-50 px-3 py-2 text-sm dark:border-amber-700/50 dark:bg-amber-950/30">
|
||||
<MailCheck className="w-4 h-4 shrink-0 text-amber-600 dark:text-amber-400" />
|
||||
<span className="text-foreground">{t('prompt')}</span>
|
||||
<span className="break-all text-muted-foreground">{requestedBy}</span>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<button
|
||||
onClick={async () => {
|
||||
setState('sending');
|
||||
try {
|
||||
await onSend();
|
||||
setState('sent');
|
||||
} catch {
|
||||
setState('idle');
|
||||
}
|
||||
}}
|
||||
disabled={state === 'sending'}
|
||||
className="inline-flex items-center gap-1.5 rounded-md bg-green-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{state === 'sending' && <Loader2 className="w-3 h-3 animate-spin" />}
|
||||
{t('send')}
|
||||
</button>
|
||||
<button
|
||||
onClick={onIgnore}
|
||||
className="rounded-md bg-red-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-red-700"
|
||||
>
|
||||
{t('ignore')}
|
||||
</button>
|
||||
<div className="flex items-start gap-3 py-1">
|
||||
<div className="w-10 h-10 rounded-full bg-info/15 text-info flex items-center justify-center flex-shrink-0 shadow-sm">
|
||||
<MailCheck className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 space-y-2">
|
||||
<div>
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Read receipt
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground break-words">
|
||||
{t('prompt')}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground break-all">
|
||||
Requested by <span className="text-foreground/80">{requestedBy}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
|
||||
<button
|
||||
onClick={async () => {
|
||||
setState('sending');
|
||||
try {
|
||||
await onSend();
|
||||
setState('sent');
|
||||
} catch {
|
||||
setState('idle');
|
||||
}
|
||||
}}
|
||||
disabled={state === 'sending'}
|
||||
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground px-3 py-1.5 rounded-md border border-border hover:bg-muted transition-colors min-h-[36px] disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{state === 'sending' ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <MailCheck className="w-3.5 h-3.5" />}
|
||||
{t('send')}
|
||||
</button>
|
||||
<button
|
||||
onClick={onIgnore}
|
||||
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground px-3 py-1.5 rounded-md border border-border hover:bg-muted transition-colors min-h-[36px]"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
{t('ignore')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -221,7 +221,7 @@ export function RecipientPopover({ name, email, displayLabel, onViewContact, cla
|
||||
{onViewContact && (
|
||||
<button
|
||||
onClick={handleViewContact}
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground px-2 py-1.5 rounded hover:bg-muted transition-colors ml-auto"
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground px-2 py-1.5 rounded hover:bg-muted transition-colors ms-auto"
|
||||
title={contact ? "View contact" : "View details"}
|
||||
>
|
||||
{contact ? <ExternalLink className="w-3.5 h-3.5" /> : <UserPlus className="w-3.5 h-3.5" />}
|
||||
|
||||
@@ -8,6 +8,7 @@ import Heading from "@tiptap/extension-heading";
|
||||
import Underline from "@tiptap/extension-underline";
|
||||
import Link from "@tiptap/extension-link";
|
||||
import TextAlign from "@tiptap/extension-text-align";
|
||||
import { TextDirection } from "@/components/email/text-direction";
|
||||
import { TextStyle } from "@tiptap/extension-text-style";
|
||||
import Color from "@tiptap/extension-color";
|
||||
import { ResizableImage } from "@/components/email/resizable-image";
|
||||
@@ -19,6 +20,7 @@ import { TableCell } from "@tiptap/extension-table-cell";
|
||||
import { QuotedHtml, serializeEditorContent } from "@/components/email/quoted-html";
|
||||
import { SignatureBlock } from "@/components/email/signature-block";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import {
|
||||
Bold,
|
||||
Italic,
|
||||
@@ -29,6 +31,7 @@ import {
|
||||
AlignLeft,
|
||||
AlignCenter,
|
||||
AlignRight,
|
||||
ArrowLeftRight,
|
||||
Link as LinkIcon,
|
||||
Undo,
|
||||
Redo,
|
||||
@@ -183,6 +186,7 @@ export function RichTextEditor({
|
||||
hasError,
|
||||
onEditorReady,
|
||||
}: RichTextEditorProps) {
|
||||
const rtlEditingSupport = useSettingsStore((st) => st.rtlEditingSupport);
|
||||
const onImageUploadRef = React.useRef(onImageUpload);
|
||||
onImageUploadRef.current = onImageUpload;
|
||||
const onEditorReadyRef = React.useRef(onEditorReady);
|
||||
@@ -240,6 +244,7 @@ export function RichTextEditor({
|
||||
// rich/branded signatures keep their inline styling in the editor and
|
||||
// in the sent mail (see signature-block.ts).
|
||||
SignatureBlock,
|
||||
TextDirection,
|
||||
],
|
||||
content,
|
||||
editorProps: {
|
||||
@@ -454,6 +459,22 @@ export function RichTextEditor({
|
||||
<AlignRight className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
|
||||
{rtlEditingSupport && (
|
||||
<ToolbarButton
|
||||
active={
|
||||
(editor.getAttributes("paragraph").dir || editor.getAttributes("heading").dir) === "rtl"
|
||||
}
|
||||
onClick={() => {
|
||||
const cur =
|
||||
editor.getAttributes("paragraph").dir || editor.getAttributes("heading").dir;
|
||||
editor.chain().focus().setTextDirection(cur === "rtl" ? "ltr" : "rtl").run();
|
||||
}}
|
||||
title="Text direction (RTL/LTR)"
|
||||
>
|
||||
<ArrowLeftRight className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
)}
|
||||
|
||||
<ToolbarSeparator />
|
||||
|
||||
<ToolbarButton
|
||||
@@ -478,28 +499,28 @@ export function RichTextEditor({
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
|
||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-start"
|
||||
onClick={() => { editor.chain().focus().addRowBefore().run(); setTableMenuOpen(false); }}
|
||||
>
|
||||
<Rows3 className="w-4 h-4" /> Add row above
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
|
||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-start"
|
||||
onClick={() => { editor.chain().focus().addRowAfter().run(); setTableMenuOpen(false); }}
|
||||
>
|
||||
<Rows3 className="w-4 h-4" /> Add row below
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
|
||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-start"
|
||||
onClick={() => { editor.chain().focus().addColumnBefore().run(); setTableMenuOpen(false); }}
|
||||
>
|
||||
<Columns3 className="w-4 h-4" /> Add column before
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
|
||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-start"
|
||||
onClick={() => { editor.chain().focus().addColumnAfter().run(); setTableMenuOpen(false); }}
|
||||
>
|
||||
<Columns3 className="w-4 h-4" /> Add column after
|
||||
@@ -507,21 +528,21 @@ export function RichTextEditor({
|
||||
<div className="h-px bg-border my-1" />
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
|
||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-start"
|
||||
onClick={() => { editor.chain().focus().deleteRow().run(); setTableMenuOpen(false); }}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" /> Delete row
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
|
||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-start"
|
||||
onClick={() => { editor.chain().focus().deleteColumn().run(); setTableMenuOpen(false); }}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" /> Delete column
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
|
||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-start"
|
||||
onClick={() => { editor.chain().focus().toggleHeaderRow().run(); setTableMenuOpen(false); }}
|
||||
>
|
||||
<Rows3 className="w-4 h-4" /> Toggle header row
|
||||
@@ -529,7 +550,7 @@ export function RichTextEditor({
|
||||
<div className="h-px bg-border my-1" />
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left text-red-600 dark:text-red-400"
|
||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-start text-red-600 dark:text-red-400"
|
||||
onClick={() => { editor.chain().focus().deleteTable().run(); setTableMenuOpen(false); }}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" /> Delete table
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { ShieldCheck, ShieldAlert, ShieldX, Lock, LockOpen, AlertTriangle, Info } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { SmimeStatus } from "@/lib/smime/types";
|
||||
|
||||
interface SmimeStatusBannerProps {
|
||||
status: SmimeStatus;
|
||||
onUnlockKey?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
type SmimeVariant = 'success' | 'warning' | 'error' | 'info';
|
||||
|
||||
const variantTone: Record<SmimeVariant, string> = {
|
||||
success: 'bg-success/15 text-success',
|
||||
warning: 'bg-warning/15 text-warning',
|
||||
error: 'bg-destructive/15 text-destructive',
|
||||
info: 'bg-info/15 text-info',
|
||||
};
|
||||
|
||||
export function SmimeStatusBanner({ status, onUnlockKey, className }: SmimeStatusBannerProps) {
|
||||
const t = useTranslations('smime');
|
||||
|
||||
const items: Array<{
|
||||
icon: React.ReactNode;
|
||||
text: string;
|
||||
variant: SmimeVariant;
|
||||
}> = [];
|
||||
|
||||
// Encryption status
|
||||
if (status.isEncrypted) {
|
||||
if (status.decryptionError) {
|
||||
if (status.decryptionError === 'locked') {
|
||||
items.push({
|
||||
icon: <Lock className="w-5 h-5" />,
|
||||
text: t('unlock_key_desc'),
|
||||
variant: 'warning',
|
||||
});
|
||||
} else if (status.decryptionError === 'no-key') {
|
||||
items.push({
|
||||
icon: <Lock className="w-5 h-5" />,
|
||||
text: t('status_encrypted_no_key'),
|
||||
variant: 'warning',
|
||||
});
|
||||
} else {
|
||||
items.push({
|
||||
icon: <ShieldX className="w-5 h-5" />,
|
||||
text: t('status_encrypted_failed'),
|
||||
variant: 'error',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
items.push({
|
||||
icon: <LockOpen className="w-5 h-5" />,
|
||||
text: t('status_encrypted_ok'),
|
||||
variant: 'success',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Signature status
|
||||
if (status.isSigned) {
|
||||
if (status.signatureValid === true) {
|
||||
if (status.selfSigned) {
|
||||
items.push({
|
||||
icon: <AlertTriangle className="w-5 h-5" />,
|
||||
text: t('status_signed_self_signed'),
|
||||
variant: 'warning',
|
||||
});
|
||||
} else if (status.signerEmailMatch === false) {
|
||||
items.push({
|
||||
icon: <AlertTriangle className="w-5 h-5" />,
|
||||
text: t('status_signed_mismatch'),
|
||||
variant: 'warning',
|
||||
});
|
||||
} else {
|
||||
items.push({
|
||||
icon: <ShieldCheck className="w-5 h-5" />,
|
||||
text: t('status_signed_valid'),
|
||||
variant: 'success',
|
||||
});
|
||||
}
|
||||
} else if (status.signatureValid === false) {
|
||||
items.push({
|
||||
icon: <ShieldAlert className="w-5 h-5" />,
|
||||
text: status.signatureError || t('status_signed_invalid'),
|
||||
variant: 'error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Unsupported S/MIME
|
||||
if (status.unsupportedReason) {
|
||||
items.push({
|
||||
icon: <Info className="w-5 h-5" />,
|
||||
text: t('status_unsupported'),
|
||||
variant: 'info',
|
||||
});
|
||||
}
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-3 py-1", className)}>
|
||||
{items.map((item, i) => (
|
||||
<div key={i} className="flex items-start gap-3">
|
||||
<div className={cn(
|
||||
"w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 shadow-sm",
|
||||
variantTone[item.variant],
|
||||
)}>
|
||||
{item.icon}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 flex items-center justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
S/MIME
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground break-words">
|
||||
{item.text}
|
||||
</div>
|
||||
</div>
|
||||
{item.variant === 'warning' && status.decryptionError === 'locked' && onUnlockKey && (
|
||||
<button
|
||||
onClick={onUnlockKey}
|
||||
className="text-xs font-medium underline hover:no-underline flex-shrink-0"
|
||||
>
|
||||
{t('unlock_key')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Extension } from "@tiptap/core";
|
||||
|
||||
export type TextDir = "ltr" | "rtl";
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
textDirection: {
|
||||
setTextDirection: (dir: TextDir) => ReturnType;
|
||||
unsetTextDirection: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a `dir` attribute to block nodes so the composer can mark individual
|
||||
* paragraphs/headings as LTR or RTL (Gmail-style right-to-left editing). The
|
||||
* attribute round-trips to HTML so the direction is preserved in the sent mail.
|
||||
*/
|
||||
export const TextDirection = Extension.create({
|
||||
name: "textDirection",
|
||||
|
||||
addOptions() {
|
||||
return { types: ["paragraph", "heading", "blockquote", "listItem"] };
|
||||
},
|
||||
|
||||
addGlobalAttributes() {
|
||||
return [
|
||||
{
|
||||
types: this.options.types,
|
||||
attributes: {
|
||||
dir: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("dir") || null,
|
||||
renderHTML: (attributes) =>
|
||||
attributes.dir ? { dir: attributes.dir } : {},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setTextDirection:
|
||||
(dir) =>
|
||||
({ commands }) =>
|
||||
this.options.types.every((type: string) =>
|
||||
commands.updateAttributes(type, { dir }),
|
||||
),
|
||||
unsetTextDirection:
|
||||
() =>
|
||||
({ commands }) =>
|
||||
this.options.types.every((type: string) =>
|
||||
commands.resetAttributes(type, "dir"),
|
||||
),
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -150,7 +150,7 @@ export function ThreadConversationView({
|
||||
<div className="flex items-center px-4 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 sticky top-0 z-10" style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-header-py)' }}>
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="p-2 -ml-2 rounded-full hover:bg-muted transition-colors"
|
||||
className="p-2 -ms-2 rounded-full hover:bg-muted transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</button>
|
||||
@@ -488,13 +488,13 @@ function EmailCard({
|
||||
<div className={cn(
|
||||
"rounded-lg border border-border overflow-hidden transition-all duration-200",
|
||||
isExpanded ? "bg-background shadow-sm" : "bg-muted/30",
|
||||
isUnread && !isExpanded && "border-l-2 border-l-primary"
|
||||
isUnread && !isExpanded && "border-s-2 border-l-primary"
|
||||
)}>
|
||||
{/* Card Header - Always visible */}
|
||||
<button
|
||||
onClick={onToggleExpanded}
|
||||
className={cn(
|
||||
"w-full flex items-start text-left transition-colors",
|
||||
"w-full flex items-start text-start transition-colors",
|
||||
!isExpanded && "hover:bg-muted/50"
|
||||
)}
|
||||
style={{ gap: 'var(--density-item-gap)', padding: 'var(--density-card-p)' }}
|
||||
@@ -657,7 +657,7 @@ function EmailCard({
|
||||
}}
|
||||
className="flex-1"
|
||||
>
|
||||
<Reply className="w-4 h-4 mr-2" />
|
||||
<Reply className="w-4 h-4 me-2" />
|
||||
{t("email_viewer.reply")}
|
||||
</Button>
|
||||
)}
|
||||
@@ -671,7 +671,7 @@ function EmailCard({
|
||||
}}
|
||||
className="flex-1"
|
||||
>
|
||||
<ReplyAll className="w-4 h-4 mr-2" />
|
||||
<ReplyAll className="w-4 h-4 me-2" />
|
||||
{t("email_viewer.reply_all")}
|
||||
</Button>
|
||||
)}
|
||||
@@ -685,7 +685,7 @@ function EmailCard({
|
||||
}}
|
||||
className="flex-1"
|
||||
>
|
||||
<Forward className="w-4 h-4 mr-2" />
|
||||
<Forward className="w-4 h-4 me-2" />
|
||||
{t("email_viewer.forward")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -86,8 +86,8 @@ export function ThreadEmailItem({
|
||||
{...longPressHandlers}
|
||||
className={cn(
|
||||
"relative cursor-pointer select-none transition-all duration-150",
|
||||
"pl-12 pr-4",
|
||||
"border-l-2 border-l-transparent",
|
||||
"ps-12 pe-4",
|
||||
"border-s-2 border-l-transparent",
|
||||
selected
|
||||
? "bg-selection border-l-primary"
|
||||
: "hover:bg-muted/50",
|
||||
@@ -130,7 +130,7 @@ export function ThreadEmailItem({
|
||||
|
||||
{/* Unread indicator */}
|
||||
{isUnread && (
|
||||
<div className="absolute left-7 top-1/2 -translate-y-1/2">
|
||||
<div className="absolute start-7 top-1/2 -translate-y-1/2">
|
||||
<Circle className="w-1.5 h-1.5 fill-unread text-unread" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -4,8 +4,8 @@ import React, { useCallback } from "react";
|
||||
import { formatDate, formatDateTime, stripInvisibleLeading } from "@/lib/utils";
|
||||
import { Email, ThreadGroup, ALL_MAIL_MAILBOX_ID } from "@/lib/jmap/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { Paperclip, Star, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square, Reply, Forward, CalendarClock, Folder } from "lucide-react";
|
||||
import { SelectableAvatar } from "@/components/email/selectable-avatar";
|
||||
import { Paperclip, Star, Pin, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square, Reply, Forward, CalendarClock, Folder } from "lucide-react";
|
||||
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
@@ -75,8 +75,10 @@ interface SingleEmailItemProps {
|
||||
const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
function SingleEmailItem({ email, selected, onClick, onDoubleClick, onContextMenu, showPreview, colorTag, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam, onUndoSpam }, ref) {
|
||||
const t = useTranslations('email_viewer');
|
||||
const tBatch = useTranslations('email_list.batch_actions');
|
||||
const isUnread = !email.keywords?.$seen;
|
||||
const isStarred = email.keywords?.$flagged;
|
||||
const isPinned = email.keywords?.['$pinned'] === true;
|
||||
const isAnswered = email.keywords?.$answered;
|
||||
const isForwarded = email.keywords?.$forwarded;
|
||||
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView, unifiedRole } = useEmailStore();
|
||||
@@ -88,6 +90,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
|
||||
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
|
||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||
const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag);
|
||||
const density = useSettingsStore((state) => state.density);
|
||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||
@@ -111,7 +114,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
const tagIds = getEmailColorTags(email.keywords);
|
||||
const resolvedKeywordDefs = tagIds.map(id => emailKeywords.find(k => k.id === id) ?? { id, label: id, color: 'gray' });
|
||||
const resolvedKeywordDef = resolvedKeywordDefs[0] ?? null;
|
||||
const resolvedColorTag = (() => {
|
||||
const resolvedColorTag = !tintListRowsByTag ? null : (() => {
|
||||
if (colorTag) return colorTag;
|
||||
return resolvedKeywordDef ? KEYWORD_PALETTE[resolvedKeywordDef.color]?.bg ?? null : null;
|
||||
})();
|
||||
@@ -134,7 +137,11 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
|
||||
const handleCheckboxClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
toggleEmailSelection(email.id);
|
||||
if (e.shiftKey) {
|
||||
selectRangeEmails(email.id);
|
||||
} else {
|
||||
toggleEmailSelection(email.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent) => {
|
||||
@@ -211,18 +218,21 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
)}
|
||||
|
||||
{isUnread && (
|
||||
<div className="absolute left-0.5 top-1/2 -translate-y-1/2">
|
||||
<div className="absolute start-0.5 top-1/2 -translate-y-1/2">
|
||||
<Circle className="w-2 h-2 fill-unread text-unread" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{density !== 'extra-compact' && (
|
||||
<Avatar
|
||||
<SelectableAvatar
|
||||
name={sender?.name}
|
||||
email={sender?.email}
|
||||
size={isFocusedMailLayout ? "sm" : "md"}
|
||||
className="flex-shrink-0 shadow-sm"
|
||||
disableImages={hideJunkAvatarImages}
|
||||
checked={isChecked}
|
||||
onToggle={() => toggleEmailSelection(email.id)}
|
||||
selectLabel={tBatch('select')}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -256,6 +266,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5 shrink-0">
|
||||
{isPinned && <Pin className="w-3.5 h-3.5 text-primary" />}
|
||||
{isStarred && <Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />}
|
||||
{isAnswered && !isForwarded && <Reply className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||
{isForwarded && !isAnswered && <Forward className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||
@@ -308,6 +319,9 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
{sender?.name || sender?.email || "Unknown"}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isPinned && (
|
||||
<Pin className="w-3.5 h-3.5 text-primary" />
|
||||
)}
|
||||
{isStarred && (
|
||||
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
|
||||
)}
|
||||
@@ -397,6 +411,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
onMarkAsSpam={onMarkAsSpam}
|
||||
onUndoSpam={onUndoSpam}
|
||||
isInJunk={currentMailboxRole === 'junk'}
|
||||
spamApplicable={!['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || '')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -427,13 +442,14 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
}, ref) {
|
||||
const t = useTranslations('threads');
|
||||
const tEmailViewer = useTranslations('email_viewer');
|
||||
const tBatch = useTranslations('email_list.batch_actions');
|
||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||
const density = useSettingsStore((state) => state.density);
|
||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread;
|
||||
const { latestEmail, participantNames, hasUnread, hasStarred, hasPinned, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread;
|
||||
// The horizontal one-line "focus" layout doesn't fit on narrow screens; fall back to multi-line on mobile.
|
||||
const isFocusedMailLayout = mailLayout === 'focus' && !isMobile;
|
||||
const trimmedPreview = stripInvisibleLeading(latestEmail.preview ?? '');
|
||||
@@ -479,8 +495,9 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
|
||||
const threadColor = getThreadColorTag(thread.emails);
|
||||
const emailKeywordDefs = useSettingsStore((state) => state.emailKeywords);
|
||||
const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag);
|
||||
const keywordDef = threadColor ? (emailKeywordDefs.find(k => k.id === threadColor) ?? { id: threadColor, label: threadColor, color: 'gray' }) : null;
|
||||
const colorTag = keywordDef ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null;
|
||||
const colorTag = (tintListRowsByTag && keywordDef) ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null;
|
||||
|
||||
const isSelected = selectedEmailId === latestEmail.id ||
|
||||
thread.emails.some(e => e.id === selectedEmailId);
|
||||
@@ -511,9 +528,8 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
|
||||
const emailsToShow = expandedEmails || thread.emails;
|
||||
|
||||
const handleThreadCheckboxClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
// Toggle selection for all emails in this thread
|
||||
// Toggle selection for all emails in this thread.
|
||||
const toggleThreadSelection = () => {
|
||||
const allSelected = thread.emails.every(em => selectedEmailIds.has(em.id));
|
||||
const newSelection = new Set(selectedEmailIds);
|
||||
thread.emails.forEach(em => {
|
||||
@@ -526,6 +542,15 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
useEmailStore.setState({ selectedEmailIds: newSelection, lastSelectedEmailId: latestEmail.id });
|
||||
};
|
||||
|
||||
const handleThreadCheckboxClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (e.shiftKey) {
|
||||
selectRangeEmails(latestEmail.id);
|
||||
return;
|
||||
}
|
||||
toggleThreadSelection();
|
||||
};
|
||||
|
||||
const handleHeaderClick = (e: React.MouseEvent) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault();
|
||||
@@ -618,19 +643,22 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
)}
|
||||
|
||||
{hasUnread && (
|
||||
<div className="absolute left-0.5 top-1/2 -translate-y-1/2">
|
||||
<div className="absolute start-0.5 top-1/2 -translate-y-1/2">
|
||||
<Circle className="w-2 h-2 fill-unread text-unread" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{density !== 'extra-compact' && (
|
||||
<div className="relative flex-shrink-0">
|
||||
<Avatar
|
||||
<SelectableAvatar
|
||||
name={avatarPerson?.name}
|
||||
email={avatarPerson?.email}
|
||||
size={isFocusedMailLayout ? "sm" : "md"}
|
||||
className="shadow-sm"
|
||||
disableImages={hideJunkAvatarImages}
|
||||
checked={isChecked}
|
||||
onToggle={toggleThreadSelection}
|
||||
selectLabel={tBatch('select')}
|
||||
/>
|
||||
{!isMobile && !isFocusedMailLayout && (
|
||||
<button
|
||||
@@ -702,6 +730,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5 shrink-0">
|
||||
{hasPinned && <Pin className="w-3.5 h-3.5 text-primary" />}
|
||||
{hasStarred && <Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />}
|
||||
{hasAnswered && !hasForwarded && <Reply className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||
{hasForwarded && !hasAnswered && <Forward className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||
@@ -766,6 +795,9 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
{emailCount}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{hasPinned && (
|
||||
<Pin className="w-3.5 h-3.5 text-primary" />
|
||||
)}
|
||||
{hasStarred && (
|
||||
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
|
||||
)}
|
||||
@@ -855,6 +887,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined}
|
||||
onUndoSpam={onUndoSpam ? () => onUndoSpam(latestEmail) : undefined}
|
||||
isInJunk={currentMailboxRole === 'junk'}
|
||||
spamApplicable={!['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || '')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -863,7 +896,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
<div className="bg-muted/20 animate-in slide-in-from-top-2 duration-200">
|
||||
{isLoading ? (
|
||||
<div className="py-4 flex items-center justify-center text-sm text-muted-foreground">
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||
<Loader2 className="w-4 h-4 animate-spin me-2" />
|
||||
{t('loading')}
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Loader2, CheckCircle, AlertCircle } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { isValidUnsubscribeUrl } from '@/lib/validation';
|
||||
import { isValidUnsubscribeUrl, parseMailtoUrl } from '@/lib/validation';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { useIsDesktop } from '@/hooks/use-media-query';
|
||||
|
||||
@@ -14,12 +14,17 @@ interface UnsubscribeBannerProps {
|
||||
preferred?: 'http' | 'mailto';
|
||||
};
|
||||
senderEmail: string;
|
||||
// Sends the unsubscribe message through the app's own account. This is a
|
||||
// webmail client - handing a mailto: URL to the OS mail handler goes
|
||||
// nowhere for most users.
|
||||
onSendMailtoUnsubscribe: (fields: { to: string[]; subject?: string; body?: string }) => Promise<void>;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
export function UnsubscribeBanner({
|
||||
listUnsubscribe,
|
||||
senderEmail: _senderEmail,
|
||||
onSendMailtoUnsubscribe,
|
||||
onDismiss
|
||||
}: UnsubscribeBannerProps) {
|
||||
const t = useTranslations();
|
||||
@@ -74,12 +79,18 @@ export function UnsubscribeBanner({
|
||||
setShowConfirm(false);
|
||||
setTimeout(onDismiss, 3000);
|
||||
} else {
|
||||
const link = document.createElement('a');
|
||||
link.href = unsubUrl;
|
||||
link.style.display = 'none';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
// Send the unsubscribe message ourselves and only report success
|
||||
// once the server accepted it. The previous hidden-link click handed
|
||||
// the mailto: to the OS mail handler and claimed success even though
|
||||
// nothing was ever sent.
|
||||
const fields = parseMailtoUrl(unsubUrl);
|
||||
if (!fields) {
|
||||
setError(true);
|
||||
setProcessing(false);
|
||||
setShowConfirm(false);
|
||||
return;
|
||||
}
|
||||
await onSendMailtoUnsubscribe(fields);
|
||||
|
||||
setSuccess(true);
|
||||
setProcessing(false);
|
||||
@@ -96,7 +107,7 @@ export function UnsubscribeBanner({
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 ml-1">
|
||||
<span className="inline-flex items-center gap-1 ms-1">
|
||||
<CheckCircle className="w-3 h-3 text-green-600 dark:text-green-400" />
|
||||
<span className="text-xs text-green-600 dark:text-green-400">
|
||||
{t(unsubMethod === 'http'
|
||||
@@ -110,7 +121,7 @@ export function UnsubscribeBanner({
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 ml-1">
|
||||
<span className="inline-flex items-center gap-1 ms-1">
|
||||
<AlertCircle className="w-3 h-3 text-red-500 dark:text-red-400" />
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
@@ -171,8 +182,8 @@ export function UnsubscribeBanner({
|
||||
}}
|
||||
title={t('email_viewer.unsubscribe_banner.confirm_title')}
|
||||
message={t(unsubMethod === 'http'
|
||||
? 'email_viewer.unsubscribe_banner.success_http'
|
||||
: 'email_viewer.unsubscribe_banner.success_mailto'
|
||||
? 'email_viewer.unsubscribe_banner.confirm_message_http'
|
||||
: 'email_viewer.unsubscribe_banner.confirm_message_mailto'
|
||||
)}
|
||||
confirmText={t('email_viewer.unsubscribe_banner.confirm_button')}
|
||||
cancelText={t('email_viewer.unsubscribe_banner.cancel')}
|
||||
|
||||
@@ -21,7 +21,7 @@ export function PageErrorFallback({ error: _error, resetError, t }: FallbackProp
|
||||
{t("page_error_description")}
|
||||
</p>
|
||||
<Button onClick={resetError}>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
<RefreshCw className="w-4 h-4 me-2" />
|
||||
{t("try_again")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -34,13 +34,13 @@ export function PageErrorFallback({ error: _error, resetError, t }: FallbackProp
|
||||
*/
|
||||
export function SidebarErrorFallback({ resetError, t }: FallbackProps) {
|
||||
return (
|
||||
<div className="w-64 h-full border-r border-border bg-secondary flex flex-col items-center justify-center p-4">
|
||||
<div className="w-64 h-full border-e border-border bg-secondary flex flex-col items-center justify-center p-4">
|
||||
<FolderOpen className="w-10 h-10 text-muted-foreground mb-3" />
|
||||
<p className="text-sm text-muted-foreground text-center mb-4">
|
||||
{t("sidebar_error")}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={resetError}>
|
||||
<RefreshCw className="w-3 h-3 mr-1" />
|
||||
<RefreshCw className="w-3 h-3 me-1" />
|
||||
{t("reload")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -58,7 +58,7 @@ export function EmailListErrorFallback({ resetError, t }: FallbackProps) {
|
||||
{t("email_list_error")}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={resetError}>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
<RefreshCw className="w-4 h-4 me-2" />
|
||||
{t("reload_emails")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -81,7 +81,7 @@ export function EmailViewerErrorFallback({ resetError, t }: FallbackProps) {
|
||||
{t("viewer_error_description")}
|
||||
</p>
|
||||
<Button onClick={resetError}>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
<RefreshCw className="w-4 h-4 me-2" />
|
||||
{t("try_again")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -119,7 +119,7 @@ export function SettingsErrorFallback({ resetError, t }: FallbackProps) {
|
||||
{t("settings_error_description")}
|
||||
</p>
|
||||
<Button onClick={resetError}>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
<RefreshCw className="w-4 h-4 me-2" />
|
||||
{t("reload_settings")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -821,8 +821,8 @@ export function FileBrowser({
|
||||
const SortIndicator = ({ column }: { column: SortKey }) => {
|
||||
if (sortKey !== column) return null;
|
||||
return sortDir === "asc"
|
||||
? <ArrowUp className="w-3 h-3 inline ml-1" />
|
||||
: <ArrowDown className="w-3 h-3 inline ml-1" />;
|
||||
? <ArrowUp className="w-3 h-3 inline ms-1" />
|
||||
: <ArrowDown className="w-3 h-3 inline ms-1" />;
|
||||
};
|
||||
|
||||
// Keyboard shortcuts
|
||||
@@ -942,7 +942,7 @@ export function FileBrowser({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 -ml-2"
|
||||
className="h-8 w-8 -ms-2"
|
||||
onClick={() => setNarrowSidebarOpen((v) => !v)}
|
||||
aria-label={t("open_folder_tree")}
|
||||
>
|
||||
@@ -982,7 +982,7 @@ export function FileBrowser({
|
||||
className="h-8"
|
||||
onClick={() => onBatchDownload([...selectedResources].filter(n => !resources.find(r => r.name === n)?.isDirectory))}
|
||||
>
|
||||
<Download className="w-4 h-4 mr-1" />
|
||||
<Download className="w-4 h-4 me-1" />
|
||||
{t("download")} ({[...selectedResources].filter(n => !resources.find(r => r.name === n)?.isDirectory).length})
|
||||
</Button>
|
||||
<Button
|
||||
@@ -991,7 +991,7 @@ export function FileBrowser({
|
||||
className="h-8 text-destructive hover:text-destructive"
|
||||
onClick={() => onBatchDelete([...selectedResources])}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-1" />
|
||||
<Trash2 className="w-4 h-4 me-1" />
|
||||
{t("delete")} ({selectedResources.size})
|
||||
</Button>
|
||||
</>
|
||||
@@ -1003,7 +1003,7 @@ export function FileBrowser({
|
||||
className="h-8"
|
||||
onClick={onPaste}
|
||||
>
|
||||
<Clipboard className="w-4 h-4 mr-1" />
|
||||
<Clipboard className="w-4 h-4 me-1" />
|
||||
{t("paste")} ({clipboard.names.length})
|
||||
</Button>
|
||||
)}
|
||||
@@ -1157,7 +1157,7 @@ export function FileBrowser({
|
||||
className="h-7 text-destructive hover:text-destructive shrink-0"
|
||||
onClick={onRefresh}
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5 mr-1" />
|
||||
<RefreshCw className="w-3.5 h-3.5 me-1" />
|
||||
{t("retry")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -1181,12 +1181,12 @@ export function FileBrowser({
|
||||
<span className="truncate">
|
||||
{t("uploading")} {uploadProgress.name}
|
||||
{uploadProgress.totalFiles > 1 && (
|
||||
<span className="text-muted-foreground ml-1">
|
||||
<span className="text-muted-foreground ms-1">
|
||||
({uploadProgress.current}/{uploadProgress.totalFiles})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="ml-auto tabular-nums shrink-0 flex items-center gap-2">
|
||||
<span className="ms-auto tabular-nums shrink-0 flex items-center gap-2">
|
||||
{uploadProgress.total > 0
|
||||
? `${Math.round((uploadProgress.loaded / uploadProgress.total) * 100)}%`
|
||||
: "…"}
|
||||
@@ -1267,7 +1267,7 @@ export function FileBrowser({
|
||||
)}
|
||||
{/* Favorites & Recent sidebar (when layout is inline) */}
|
||||
{folderLayout === "inline" && (favorites.length > 0 || recentFiles.length > 0) && (
|
||||
<div className="w-48 border-r border-border bg-background overflow-y-auto shrink-0 hidden lg:block">
|
||||
<div className="w-48 border-e border-border bg-background overflow-y-auto shrink-0 hidden lg:block">
|
||||
{favorites.length > 0 && (
|
||||
<div className="p-3">
|
||||
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 flex items-center gap-1">
|
||||
@@ -1280,7 +1280,7 @@ export function FileBrowser({
|
||||
key={fav}
|
||||
onClick={() => onNavigate(fav)}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 px-2 py-1.5 rounded text-sm hover:bg-muted transition-colors text-left",
|
||||
"w-full flex items-center gap-2 px-2 py-1.5 rounded text-sm hover:bg-muted transition-colors text-start",
|
||||
currentPath === fav && "bg-muted font-medium"
|
||||
)}
|
||||
>
|
||||
@@ -1301,7 +1301,7 @@ export function FileBrowser({
|
||||
{recentFiles.slice(0, 10).map((recent) => (
|
||||
<button
|
||||
key={recent.id}
|
||||
className="w-full flex items-center gap-2 px-2 py-1.5 rounded text-sm hover:bg-muted transition-colors text-left"
|
||||
className="w-full flex items-center gap-2 px-2 py-1.5 rounded text-sm hover:bg-muted transition-colors text-start"
|
||||
title={recent.name}
|
||||
>
|
||||
<File className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
||||
@@ -1322,14 +1322,14 @@ export function FileBrowser({
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50 sticky top-0 z-10">
|
||||
<tr className="border-b border-border">
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">
|
||||
<th className="text-start px-4 py-2 font-medium text-muted-foreground">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-4 h-4" />
|
||||
{t("name")}
|
||||
</div>
|
||||
</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground hidden md:table-cell w-24">{t("size")}</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground hidden lg:table-cell w-44">{t("modified")}</th>
|
||||
<th className="text-start px-4 py-2 font-medium text-muted-foreground hidden md:table-cell w-24">{t("size")}</th>
|
||||
<th className="text-start px-4 py-2 font-medium text-muted-foreground hidden lg:table-cell w-44">{t("modified")}</th>
|
||||
<th className="w-10 px-2 py-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -1354,7 +1354,7 @@ export function FileBrowser({
|
||||
key={`__account__:${acc.accountId}`}
|
||||
onClick={() => onSelectAccount(acc.accountId)}
|
||||
title={acc.email}
|
||||
className="flex items-center gap-3 p-3 rounded-lg border border-border hover:bg-muted/50 transition-colors text-left min-w-0"
|
||||
className="flex items-center gap-3 p-3 rounded-lg border border-border hover:bg-muted/50 transition-colors text-start min-w-0"
|
||||
>
|
||||
<Avatar
|
||||
name={acc.label}
|
||||
@@ -1514,7 +1514,7 @@ export function FileBrowser({
|
||||
>
|
||||
<thead className="bg-muted/50 sticky top-0 z-10">
|
||||
<tr className="border-b border-border">
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">
|
||||
<th className="text-start px-4 py-2 font-medium text-muted-foreground">
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -1530,13 +1530,13 @@ export function FileBrowser({
|
||||
</button>
|
||||
</div>
|
||||
</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground hidden md:table-cell w-24">
|
||||
<th className="text-start px-4 py-2 font-medium text-muted-foreground hidden md:table-cell w-24">
|
||||
<button onClick={() => handleSortClick("size")} className="hover:text-foreground transition-colors">
|
||||
{t("size")}
|
||||
<SortIndicator column="size" />
|
||||
</button>
|
||||
</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground hidden lg:table-cell w-44">
|
||||
<th className="text-start px-4 py-2 font-medium text-muted-foreground hidden lg:table-cell w-44">
|
||||
<button onClick={() => handleSortClick("modified")} className="hover:text-foreground transition-colors">
|
||||
{t("modified")}
|
||||
<SortIndicator column="modified" />
|
||||
@@ -1681,7 +1681,7 @@ export function FileBrowser({
|
||||
>
|
||||
{!resources.find(r => r.name === contextMenu.name)?.isDirectory && isPreviewable(contextMenu.name) && (
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-left"
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
|
||||
onClick={() => {
|
||||
if (isImageFile(contextMenu.name)) {
|
||||
onPreviewImage(contextMenu.name);
|
||||
@@ -1697,7 +1697,7 @@ export function FileBrowser({
|
||||
)}
|
||||
{!resources.find(r => r.name === contextMenu.name)?.isDirectory && (
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-left"
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
|
||||
onClick={() => {
|
||||
onDownload(contextMenu.name);
|
||||
setContextMenu(null);
|
||||
@@ -1708,7 +1708,7 @@ export function FileBrowser({
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-left"
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
|
||||
onClick={() => {
|
||||
onCut([contextMenu.name]);
|
||||
setContextMenu(null);
|
||||
@@ -1718,7 +1718,7 @@ export function FileBrowser({
|
||||
{t("cut")}
|
||||
</button>
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-left"
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
|
||||
onClick={() => {
|
||||
onCopy([contextMenu.name]);
|
||||
setContextMenu(null);
|
||||
@@ -1729,7 +1729,7 @@ export function FileBrowser({
|
||||
</button>
|
||||
{clipboard && (
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-left"
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
|
||||
onClick={() => {
|
||||
onPaste();
|
||||
setContextMenu(null);
|
||||
@@ -1741,7 +1741,7 @@ export function FileBrowser({
|
||||
)}
|
||||
{!resources.find(r => r.name === contextMenu.name)?.isDirectory && (
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-left"
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
|
||||
onClick={() => {
|
||||
onDuplicate(contextMenu.name);
|
||||
setContextMenu(null);
|
||||
@@ -1753,7 +1753,7 @@ export function FileBrowser({
|
||||
)}
|
||||
{canShare(resources.find(r => r.name === contextMenu.name)) && (
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-left"
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
|
||||
onClick={() => {
|
||||
const r = resources.find(res => res.name === contextMenu.name);
|
||||
if (r) setShareTargetId(r.id);
|
||||
@@ -1766,7 +1766,7 @@ export function FileBrowser({
|
||||
)}
|
||||
<div className="h-px bg-border my-1" />
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-left"
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
|
||||
onClick={() => {
|
||||
onShowDetails(contextMenu.name);
|
||||
setContextMenu(null);
|
||||
@@ -1776,7 +1776,7 @@ export function FileBrowser({
|
||||
{t("details")}
|
||||
</button>
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-left"
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
|
||||
onClick={() => {
|
||||
setRenameTarget(contextMenu.name);
|
||||
setContextMenu(null);
|
||||
@@ -1786,7 +1786,7 @@ export function FileBrowser({
|
||||
{t("rename")}
|
||||
</button>
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted text-destructive transition-colors text-left"
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted text-destructive transition-colors text-start"
|
||||
onClick={() => {
|
||||
onDelete(contextMenu.name);
|
||||
setContextMenu(null);
|
||||
@@ -1808,7 +1808,7 @@ export function FileBrowser({
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-left"
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
|
||||
onClick={() => {
|
||||
setShowNewFolder(true);
|
||||
setEmptyContextMenu(null);
|
||||
@@ -1818,7 +1818,7 @@ export function FileBrowser({
|
||||
{t("new_folder")}
|
||||
</button>
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-left"
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
|
||||
onClick={() => {
|
||||
setShowNewTextFile(true);
|
||||
setEmptyContextMenu(null);
|
||||
@@ -1828,7 +1828,7 @@ export function FileBrowser({
|
||||
{t("new_text_file")}
|
||||
</button>
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-left"
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
|
||||
onClick={() => {
|
||||
fileInputRef.current?.click();
|
||||
setEmptyContextMenu(null);
|
||||
@@ -1838,7 +1838,7 @@ export function FileBrowser({
|
||||
{t("upload")}
|
||||
</button>
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-left"
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
|
||||
onClick={() => {
|
||||
folderInputRef.current?.click();
|
||||
setEmptyContextMenu(null);
|
||||
@@ -1851,7 +1851,7 @@ export function FileBrowser({
|
||||
<>
|
||||
<div className="h-px bg-border my-1" />
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-left"
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
|
||||
onClick={() => {
|
||||
onPaste();
|
||||
setEmptyContextMenu(null);
|
||||
@@ -1864,7 +1864,7 @@ export function FileBrowser({
|
||||
)}
|
||||
<div className="h-px bg-border my-1" />
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-left"
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
|
||||
onClick={() => {
|
||||
onRefresh();
|
||||
setEmptyContextMenu(null);
|
||||
@@ -1895,7 +1895,7 @@ export function FileBrowser({
|
||||
return (
|
||||
<button
|
||||
key={folder.id}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-left"
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
|
||||
onClick={() => {
|
||||
onNavigate(folderPath, folder.id);
|
||||
setBreadcrumbDropdown(null);
|
||||
@@ -1926,7 +1926,7 @@ export function FileBrowser({
|
||||
|
||||
{/* Details sidebar */}
|
||||
{showDetails && detailResource && (
|
||||
<div role="complementary" aria-label={t("details")} className="w-64 border-l border-border bg-background p-4 overflow-y-auto shrink-0 hidden md:block">
|
||||
<div role="complementary" aria-label={t("details")} className="w-64 border-s border-border bg-background p-4 overflow-y-auto shrink-0 hidden md:block">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-medium">{t("details")}</h3>
|
||||
<button onClick={onToggleDetails} className="text-muted-foreground hover:text-foreground">
|
||||
|
||||
@@ -68,11 +68,11 @@ function SimpleMarkdown({ content }: { content: string }) {
|
||||
} else if (line.startsWith("---") || line.startsWith("***")) {
|
||||
elements.push(<hr key={i} className="my-4 border-border" />);
|
||||
} else if (line.startsWith("- ") || line.startsWith("* ")) {
|
||||
elements.push(<li key={i} className="ml-4 list-disc">{processInline(line.slice(2))}</li>);
|
||||
elements.push(<li key={i} className="ms-4 list-disc">{processInline(line.slice(2))}</li>);
|
||||
} else if (/^\d+\. /.test(line)) {
|
||||
elements.push(<li key={i} className="ml-4 list-decimal">{processInline(line.replace(/^\d+\. /, ""))}</li>);
|
||||
elements.push(<li key={i} className="ms-4 list-decimal">{processInline(line.replace(/^\d+\. /, ""))}</li>);
|
||||
} else if (line.startsWith("> ")) {
|
||||
elements.push(<blockquote key={i} className="border-l-4 border-border pl-4 italic text-muted-foreground my-2">{processInline(line.slice(2))}</blockquote>);
|
||||
elements.push(<blockquote key={i} className="border-s-4 border-border ps-4 italic text-muted-foreground my-2">{processInline(line.slice(2))}</blockquote>);
|
||||
} else if (line.startsWith("```")) {
|
||||
// Code block - collect until closing ```
|
||||
const codeLines: string[] = [];
|
||||
|
||||
@@ -68,7 +68,7 @@ export function FileUploadArea({ onUpload, onUploadFolder, onCreateFolder, onCre
|
||||
size="sm"
|
||||
onClick={onCreateFolder}
|
||||
>
|
||||
<FolderPlus className="w-4 h-4 mr-2" />
|
||||
<FolderPlus className="w-4 h-4 me-2" />
|
||||
{t("new_folder")}
|
||||
</Button>
|
||||
{onCreateTextFile && (
|
||||
@@ -77,7 +77,7 @@ export function FileUploadArea({ onUpload, onUploadFolder, onCreateFolder, onCre
|
||||
size="sm"
|
||||
onClick={onCreateTextFile}
|
||||
>
|
||||
<FilePlus className="w-4 h-4 mr-2" />
|
||||
<FilePlus className="w-4 h-4 me-2" />
|
||||
{t("new_text_file")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -135,7 +135,7 @@ export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, wid
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"border-r border-border bg-secondary overflow-hidden shrink-0 flex flex-col h-full",
|
||||
"border-e border-border bg-secondary overflow-hidden shrink-0 flex flex-col h-full",
|
||||
!isResizing && "transition-[width] duration-300"
|
||||
)}
|
||||
style={{ width: `${width}px` }}
|
||||
@@ -154,10 +154,10 @@ export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, wid
|
||||
>
|
||||
<button
|
||||
onClick={() => handleFolderClick("/", null)}
|
||||
className="flex items-center px-1 rounded transition-colors duration-150 flex-1 text-left"
|
||||
className="flex items-center px-1 rounded transition-colors duration-150 flex-1 text-start"
|
||||
style={{ paddingBlock: "var(--density-sidebar-py)", paddingLeft: "24px" }}
|
||||
>
|
||||
<Home className={cn("w-4 h-4 flex-shrink-0 mr-2 transition-colors")} />
|
||||
<Home className={cn("w-4 h-4 flex-shrink-0 me-2 transition-colors")} />
|
||||
<span className="truncate">{t("breadcrumb_root")}</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -207,11 +207,11 @@ export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, wid
|
||||
>
|
||||
<button
|
||||
onClick={() => handleFolderClick(path, r.id)}
|
||||
className="flex items-center px-1 rounded transition-colors duration-150 flex-1 text-left min-w-0"
|
||||
className="flex items-center px-1 rounded transition-colors duration-150 flex-1 text-start min-w-0"
|
||||
style={{ paddingBlock: "var(--density-sidebar-py)", paddingLeft: "24px" }}
|
||||
title={r.ownerName ? t("shared_by", { name: r.ownerName }) : r.name}
|
||||
>
|
||||
<Folder className="w-4 h-4 flex-shrink-0 mr-2 text-primary" />
|
||||
<Folder className="w-4 h-4 flex-shrink-0 me-2 text-primary" />
|
||||
<span className="truncate">{r.name}</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -281,7 +281,7 @@ function FolderTreeItem({
|
||||
onToggleExpand(node.id, node.path);
|
||||
}}
|
||||
className={cn(
|
||||
"p-0.5 rounded mr-1 transition-all duration-200",
|
||||
"p-0.5 rounded me-1 transition-all duration-200",
|
||||
"hover:bg-muted active:bg-accent"
|
||||
)}
|
||||
style={{ marginLeft: `${indentPx}px` }}
|
||||
@@ -297,14 +297,14 @@ function FolderTreeItem({
|
||||
{/* Folder name */}
|
||||
<button
|
||||
onClick={() => onFolderClick(node.path, node.id)}
|
||||
className="flex items-center px-1 rounded transition-colors duration-150 flex-1 text-left"
|
||||
className="flex items-center px-1 rounded transition-colors duration-150 flex-1 text-start"
|
||||
style={{
|
||||
paddingBlock: "var(--density-sidebar-py)",
|
||||
paddingLeft: hasChildren ? "4px" : `${indentPx + 24}px`,
|
||||
}}
|
||||
>
|
||||
<Icon className={cn(
|
||||
"w-4 h-4 flex-shrink-0 mr-2 transition-colors",
|
||||
"w-4 h-4 flex-shrink-0 me-2 transition-colors",
|
||||
isExpanded && hasChildren && "text-primary",
|
||||
!hasChildren && depth > 0 && "text-muted-foreground"
|
||||
)} />
|
||||
|
||||
@@ -212,7 +212,7 @@ export function PdfMobileViewer({ url }: { url: string }) {
|
||||
size="sm"
|
||||
onClick={() => window.open(url, "_blank", "noopener,noreferrer")}
|
||||
>
|
||||
<ExternalLink className="w-4 h-4 mr-2" />
|
||||
<ExternalLink className="w-4 h-4 me-2" />
|
||||
{t("open_in_new_tab")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -100,7 +100,7 @@ export function SieveEditorModal({
|
||||
|
||||
<div className="flex-1 min-h-0 flex border border-border rounded-md overflow-hidden">
|
||||
<div
|
||||
className="w-10 flex-shrink-0 bg-muted border-r border-border py-2 text-right pr-2 select-none overflow-hidden"
|
||||
className="w-10 flex-shrink-0 bg-muted border-e border-border py-2 text-end pe-2 select-none overflow-hidden"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{Array.from({ length: lineCount }, (_, i) => (
|
||||
@@ -172,7 +172,7 @@ export function SieveEditorModal({
|
||||
>
|
||||
{isValidating ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
<Loader2 className="w-4 h-4 me-2 animate-spin" />
|
||||
{t("validating")}
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -352,7 +352,7 @@ function SignatureByteCounter({ id, value }: { id: string; value: string }) {
|
||||
aria-live="polite"
|
||||
>
|
||||
{t('signature_byte_counter', { bytes, max: SIGNATURE_MAX_BYTES })}
|
||||
{atLimit && <span className="ml-1">{t('signature_byte_limit_reached')}</span>}
|
||||
{atLimit && <span className="ms-1">{t('signature_byte_limit_reached')}</span>}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { IdentityForm } from './identity-form';
|
||||
import { useIdentityStore } from '@/stores/identity-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
|
||||
function useSyncIdentities() {
|
||||
const syncIdentities = useAuthStore((state) => state.syncIdentities);
|
||||
@@ -206,6 +207,17 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
|
||||
|
||||
const handleSetPrimary = useCallback((identity: Identity) => {
|
||||
setPreferredPrimary(identity.id);
|
||||
// Persist to the synced settings (keyed by username, matching how
|
||||
// loadIdentities reads it back) so the choice survives a new browser /
|
||||
// cleared site data and reaches other devices (#507).
|
||||
const username = useAuthStore.getState().username || '';
|
||||
if (username) {
|
||||
const current = useSettingsStore.getState().preferredIdentityIds;
|
||||
useSettingsStore.getState().updateSetting('preferredIdentityIds', {
|
||||
...current,
|
||||
[username]: identity.id,
|
||||
});
|
||||
}
|
||||
// Re-sort: move the preferred identity to the front
|
||||
const reordered = [identity, ...identities.filter((id) => id.id !== identity.id)];
|
||||
useIdentityStore.getState().setIdentities(reordered);
|
||||
@@ -263,7 +275,7 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
|
||||
onClick={() => setIsCreating(true)}
|
||||
className="mb-6 w-full sm:w-auto"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
<Plus className="w-4 h-4 me-2" />
|
||||
{t('create_new')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -128,7 +128,7 @@ export function SubAddressHelper({
|
||||
title={t('button_tooltip')}
|
||||
className="h-8 px-2"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
<Plus className="w-4 h-4 me-1" />
|
||||
<Tag className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
|
||||
@@ -174,7 +174,7 @@ function ShortcutRow({
|
||||
return (
|
||||
<div className="flex items-center justify-between py-1.5">
|
||||
<span className="text-sm text-muted-foreground">{description}</span>
|
||||
<div className="flex items-center gap-1.5 ml-4">
|
||||
<div className="flex items-center gap-1.5 ms-4">
|
||||
{keys.map((key, index) => (
|
||||
<span key={index}>
|
||||
{index > 0 && <span className="text-muted-foreground/50 mx-1 text-xs">or</span>}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Check, Plus, LogOut, Star, ChevronDown, AlertCircle } from "lucide-react";
|
||||
import { Check, Plus, LogOut, Star, ChevronDown, AlertCircle, GripVertical } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useAccountStore, type AccountEntry } from "@/stores/account-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { getMaxAccounts } from "@/lib/account-utils";
|
||||
import { getMaxAccounts, sortDefaultFirst, reorderNonDefaultIds } from "@/lib/account-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
@@ -40,6 +40,7 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
|
||||
|
||||
const accounts = useAccountStore((s) => s.accounts);
|
||||
const setDefaultAccount = useAccountStore((s) => s.setDefaultAccount);
|
||||
const reorderAccounts = useAccountStore((s) => s.reorderAccounts);
|
||||
// Read activeAccountId from authStore so the selector matches the actually-loaded
|
||||
// session (primaryIdentity, JMAP client). accountStore.activeAccountId is a separate
|
||||
// persisted copy that can drift out of sync across hydration / partial persist writes.
|
||||
@@ -113,6 +114,32 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
|
||||
setDefaultAccount(accountId);
|
||||
};
|
||||
|
||||
// Display order: default account pinned to the top, the rest reorderable.
|
||||
const displayAccounts = useMemo(() => sortDefaultFirst(accounts), [accounts]);
|
||||
|
||||
// Drag-to-rearrange (non-default accounts only; the default stays pinned).
|
||||
const [dragId, setDragId] = useState<string | null>(null);
|
||||
const [dragOverId, setDragOverId] = useState<string | null>(null);
|
||||
const resetDrag = () => { setDragId(null); setDragOverId(null); };
|
||||
|
||||
const handleDragStart = (e: React.DragEvent, id: string) => {
|
||||
setDragId(id);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
};
|
||||
const handleDragOver = (e: React.DragEvent, overId: string) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
if (overId !== dragOverId) setDragOverId(overId);
|
||||
};
|
||||
const handleDrop = (e: React.DragEvent, overId: string) => {
|
||||
e.preventDefault();
|
||||
if (dragId) {
|
||||
const next = reorderNonDefaultIds(accounts, dragId, overId);
|
||||
if (next) reorderAccounts(next);
|
||||
}
|
||||
resetDrag();
|
||||
};
|
||||
|
||||
// Show the account's own identity, not the preferred sending identity -
|
||||
// primaryIdentity can be an alias (e.g. info@korazo.net) that differs from
|
||||
// the actually logged-in account (info@linusrath.de).
|
||||
@@ -128,7 +155,7 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
|
||||
"flex items-center gap-2 rounded-md transition-colors",
|
||||
variant === "rail"
|
||||
? "justify-center w-10 h-10 hover:bg-muted"
|
||||
: "w-full px-2 py-1.5 hover:bg-muted text-left min-w-0",
|
||||
: "w-full px-2 py-1.5 hover:bg-muted text-start min-w-0",
|
||||
className
|
||||
)}
|
||||
title={variant === "rail" ? (displayName || displayEmail) : undefined}
|
||||
@@ -167,15 +194,29 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
|
||||
>
|
||||
{/* Account List */}
|
||||
<div className="py-1 max-h-64 overflow-y-auto">
|
||||
{accounts.map((account) => {
|
||||
{displayAccounts.map((account) => {
|
||||
const isActive = account.id === activeAccountId;
|
||||
const isDraggable = !account.isDefault && accounts.length > 2;
|
||||
return (
|
||||
<button
|
||||
<div
|
||||
key={account.id}
|
||||
draggable={isDraggable}
|
||||
onDragStart={isDraggable ? (e) => handleDragStart(e, account.id) : undefined}
|
||||
onDragOver={isDraggable ? (e) => handleDragOver(e, account.id) : undefined}
|
||||
onDrop={isDraggable ? (e) => handleDrop(e, account.id) : undefined}
|
||||
onDragEnd={isDraggable ? resetDrag : undefined}
|
||||
className={cn(
|
||||
"group/acct relative",
|
||||
dragId === account.id && "opacity-50",
|
||||
dragOverId === account.id && dragId !== account.id && "border-t-2 border-primary"
|
||||
)}
|
||||
>
|
||||
<button
|
||||
onClick={() => handleSwitch(account.id)}
|
||||
className={cn(
|
||||
"w-full flex items-start gap-3 px-3 py-2.5 text-left transition-colors",
|
||||
isActive ? "bg-accent/50" : "hover:bg-muted"
|
||||
"w-full flex items-start gap-3 px-3 py-2.5 text-start transition-colors",
|
||||
isActive ? "bg-accent/50" : "hover:bg-muted",
|
||||
isDraggable && "pe-7"
|
||||
)}
|
||||
role="menuitem"
|
||||
disabled={isActive}
|
||||
@@ -215,6 +256,15 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{isDraggable && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute end-2 top-1/2 -translate-y-1/2 text-muted-foreground/50 opacity-0 transition-opacity group-hover/acct:opacity-100"
|
||||
>
|
||||
<GripVertical className="w-4 h-4" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -95,7 +95,7 @@ export function IconPicker({ value, onChange, className }: IconPickerProps) {
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t('search_icons')}
|
||||
className="pl-8 h-8 text-xs"
|
||||
className="ps-8 h-8 text-xs"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
|
||||
@@ -91,7 +91,8 @@ function StorageQuotaCircle({ quota, usagePercent }: { quota: { used: number; to
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, [open, updatePosition]);
|
||||
|
||||
const free = quota.total - quota.used;
|
||||
// Usage can legitimately exceed the quota (e.g. limit lowered after the fact)
|
||||
const free = Math.max(0, quota.total - quota.used);
|
||||
const strokeColor = usagePercent > 90
|
||||
? "stroke-destructive"
|
||||
: usagePercent > 70
|
||||
|
||||
@@ -154,7 +154,7 @@ function SidebarAppForm({
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
{t('icon_label')} <span className="text-destructive">*</span>
|
||||
{SelectedIcon && (
|
||||
<span className="inline-flex items-center gap-1.5 ml-2 text-muted-foreground font-normal">
|
||||
<span className="inline-flex items-center gap-1.5 ms-2 text-muted-foreground font-normal">
|
||||
- <SelectedIcon className="w-4 h-4" /> {formData.icon}
|
||||
</span>
|
||||
)}
|
||||
@@ -275,7 +275,7 @@ export function SidebarAppsModal({ isOpen, onClose }: SidebarAppsModalProps) {
|
||||
onClick={() => setIsCreating(true)}
|
||||
className="mb-6 w-full sm:w-auto"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
<Plus className="w-4 h-4 me-2" />
|
||||
{t('add_new')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -184,8 +184,9 @@ function SidebarRowCounts({
|
||||
isSelected: boolean;
|
||||
onUnreadClick?: () => void;
|
||||
}) {
|
||||
const showFolderTotalCount = useSettingsStore(s => s.showFolderTotalCount);
|
||||
const unreadCount = unread ?? 0;
|
||||
const totalCount = total ?? 0;
|
||||
const totalCount = showFolderTotalCount ? (total ?? 0) : 0;
|
||||
|
||||
if (unreadCount === 0 && totalCount === 0) return null;
|
||||
|
||||
@@ -219,7 +220,8 @@ function SidebarRowCounts({
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<span className="ml-2 flex-shrink-0 flex items-baseline gap-1" title={`${unreadCount} unread / ${totalCount} total`}>
|
||||
<span className="ms-2 flex-shrink-0 flex items-baseline gap-1" title={totalCount > 0 ? `${unreadCount} unread / ${totalCount} total` : `${unreadCount} unread`}>
|
||||
|
||||
{unreadNode}
|
||||
{unreadCount > 0 && totalCount > 0 && (
|
||||
<span className="text-xs text-muted-foreground/60">/</span>
|
||||
@@ -278,12 +280,12 @@ function SidebarRow({
|
||||
style={{ paddingBlock: 'var(--density-sidebar-py)' }}
|
||||
className={cn(
|
||||
"group w-full flex items-center max-lg:min-h-[44px] text-sm transition-colors duration-150",
|
||||
isCollapsed ? "justify-center px-1" : "pr-2",
|
||||
isCollapsed ? "justify-center px-1" : "pe-2",
|
||||
isVirtual
|
||||
? "text-muted-foreground"
|
||||
: isSelected
|
||||
? "bg-accent text-accent-foreground font-semibold border-l-2 border-primary"
|
||||
: "hover:bg-muted/50 text-foreground border-l-2 border-transparent",
|
||||
? "bg-accent text-accent-foreground font-semibold border-s-2 border-primary"
|
||||
: "hover:bg-muted/50 text-foreground border-s-2 border-transparent",
|
||||
isValidDropTarget && "bg-primary/20 ring-2 ring-primary ring-inset",
|
||||
isInvalidDropTarget && "bg-destructive/10 ring-2 ring-destructive/30 ring-inset opacity-50"
|
||||
)}
|
||||
@@ -320,7 +322,7 @@ function SidebarRow({
|
||||
disabled={isVirtual}
|
||||
className={cn(
|
||||
"flex items-center gap-2 min-w-0 transition-colors",
|
||||
isCollapsed ? "justify-center" : "flex-1 text-left",
|
||||
isCollapsed ? "justify-center" : "flex-1 text-start",
|
||||
isVirtual && "cursor-default select-none"
|
||||
)}
|
||||
title={isCollapsed ? label : undefined}
|
||||
@@ -389,8 +391,8 @@ function SidebarSectionHeader({
|
||||
) : (
|
||||
<ChevronRight className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
{icon && <span className="ml-1.5 flex-shrink-0">{icon}</span>}
|
||||
<span className={cn(textClass, icon ? "ml-1.5" : "ml-1.5")}>
|
||||
{icon && <span className="ms-1.5 flex-shrink-0">{icon}</span>}
|
||||
<span className={cn(textClass, icon ? "ms-1.5" : "ms-1.5")}>
|
||||
{label}
|
||||
</span>
|
||||
{onSettings && (
|
||||
@@ -408,7 +410,7 @@ function SidebarSectionHeader({
|
||||
onSettings();
|
||||
}
|
||||
}}
|
||||
className="ml-auto p-1 rounded text-muted-foreground/70 hover:text-foreground hover:bg-muted transition-colors cursor-pointer"
|
||||
className="ms-auto p-1 rounded text-muted-foreground/70 hover:text-foreground hover:bg-muted transition-colors cursor-pointer"
|
||||
title={settingsTitle}
|
||||
>
|
||||
<Settings className="w-3.5 h-3.5" />
|
||||
@@ -664,7 +666,7 @@ function VacationBanner() {
|
||||
>
|
||||
<Palmtree className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span className="truncate font-medium">{t("vacation_active")}</span>
|
||||
<Settings className="w-3 h-3 ml-auto flex-shrink-0 opacity-60" />
|
||||
<Settings className="w-3 h-3 ms-auto flex-shrink-0 opacity-60" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -810,8 +812,14 @@ export function Sidebar({
|
||||
});
|
||||
};
|
||||
|
||||
// When the app renders its own virtual "Scheduled" folder (for delayed
|
||||
// sends, driven by EmailSubmission), hide the server-provided scheduled
|
||||
// mailbox (e.g. Stalwart's auto-created Scheduled folder, role === 'scheduled')
|
||||
// so it does not appear twice. (#495)
|
||||
const isServerScheduledNode = (n: MailboxNode) => showScheduledMailbox && n.role === 'scheduled';
|
||||
|
||||
const mailboxTree = buildMailboxTree(mailboxes);
|
||||
const ownTree = mailboxTree.filter(n => !n.id.startsWith('shared-account-'));
|
||||
const ownTree = mailboxTree.filter(n => !n.id.startsWith('shared-account-') && !isServerScheduledNode(n));
|
||||
const sharedAccounts = mailboxTree.filter(n => n.id.startsWith('shared-account-'));
|
||||
|
||||
// Multi-account mode (Pro shell): render every connected account as its
|
||||
@@ -826,7 +834,7 @@ export function Sidebar({
|
||||
? mailboxes
|
||||
: (accountMailboxes?.[account.id] ?? []);
|
||||
const tree = buildMailboxTree(accountMailboxList).filter(
|
||||
(n) => !n.id.startsWith('shared-account-')
|
||||
(n) => !n.id.startsWith('shared-account-') && !(isActive && isServerScheduledNode(n))
|
||||
);
|
||||
return { account, isActive, tree };
|
||||
})
|
||||
@@ -846,6 +854,20 @@ export function Sidebar({
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Don't hijack Arrow keys while the user is typing. This is a global
|
||||
// window listener, so without this guard typing in a new email (the
|
||||
// contentEditable composer, the subject field, search, etc.) toggled the
|
||||
// selected mailbox's subfolders open/closed on ArrowLeft/ArrowRight.
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (
|
||||
target &&
|
||||
(target.tagName === 'INPUT' ||
|
||||
target.tagName === 'TEXTAREA' ||
|
||||
target.tagName === 'SELECT' ||
|
||||
target.isContentEditable)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (!selectedMailbox || isCollapsed) return;
|
||||
|
||||
const findNode = (nodes: MailboxNode[]): MailboxNode | null => {
|
||||
@@ -949,7 +971,7 @@ export function Sidebar({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex flex-col h-full border-r transition-all duration-300 overflow-hidden",
|
||||
"relative flex flex-col h-full border-e transition-all duration-300 overflow-hidden",
|
||||
"bg-secondary border-border",
|
||||
"max-lg:w-full",
|
||||
isCollapsed ? "lg:w-12" : "lg:w-full",
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
'use client';
|
||||
|
||||
// Host-rendered modal for plugin-requested confirm/alert dialogs.
|
||||
// Host-rendered modal for plugin-requested confirm/alert/prompt dialogs.
|
||||
// Subscribes to the host-dialog queue and renders the head request, one at
|
||||
// a time. Closing the modal advances the queue.
|
||||
// a time. Closing the modal advances the queue. Prompts collect one or more
|
||||
// (optionally masked) fields so plugins never fall back to `window.prompt`,
|
||||
// which the sandbox blocks.
|
||||
|
||||
import React, { useEffect, useSyncExternalStore } from 'react';
|
||||
import React, { useEffect, useMemo, useState, useSyncExternalStore } from 'react';
|
||||
import { head, resolveHead, subscribe } from '@/lib/plugin-sandbox/host-dialog';
|
||||
|
||||
// Lightweight **bold** support in plugin dialog messages. Everything else is
|
||||
@@ -23,26 +25,50 @@ function renderMessage(message?: string): React.ReactNode {
|
||||
export function PluginDialogHost(): React.JSX.Element | null {
|
||||
const current = useSyncExternalStore(subscribe, head, () => null);
|
||||
|
||||
const isPrompt = current?.kind === 'prompt';
|
||||
const fields = useMemo(() => (isPrompt ? current?.fields ?? [] : []), [isPrompt, current]);
|
||||
|
||||
// Field values for a prompt, re-initialised whenever a new dialog surfaces.
|
||||
const [values, setValues] = useState<Record<string, string>>({});
|
||||
useEffect(() => {
|
||||
if (!current) return;
|
||||
const init: Record<string, string> = {};
|
||||
for (const f of current.fields ?? []) init[f.name] = '';
|
||||
setValues(init);
|
||||
}, [current]);
|
||||
|
||||
const canSubmit = fields.every((f) => !f.required || (values[f.name] ?? '').length > 0);
|
||||
|
||||
const cancel = () => resolveHead(current?.kind === 'prompt' ? null : false);
|
||||
const submitPrompt = () => { if (canSubmit) resolveHead(values); };
|
||||
|
||||
useEffect(() => {
|
||||
if (!current) return;
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
resolveHead(false);
|
||||
} else if (e.key === 'Enter') {
|
||||
cancel();
|
||||
} else if (e.key === 'Enter' && !isPrompt) {
|
||||
// For prompts, Enter is handled by the form (so it respects required
|
||||
// validation and works from within an input); non-prompt dialogs accept.
|
||||
e.preventDefault();
|
||||
resolveHead(true);
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', onKey, true);
|
||||
return () => document.removeEventListener('keydown', onKey, true);
|
||||
}, [current]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [current, isPrompt, canSubmit, values]);
|
||||
|
||||
if (!current) return null;
|
||||
|
||||
const confirmLabel = current.confirmLabel ?? (current.kind === 'alert' ? 'OK' : 'Confirm');
|
||||
const confirmLabel = current.confirmLabel ?? (current.kind === 'alert' ? 'OK' : current.kind === 'prompt' ? 'Submit' : 'Confirm');
|
||||
const cancelLabel = current.cancelLabel ?? 'Cancel';
|
||||
|
||||
const btnBase: React.CSSProperties = { padding: '8px 16px', borderRadius: 8, fontSize: 13, fontWeight: 500, cursor: 'pointer' };
|
||||
const secondaryBtn: React.CSSProperties = { ...btnBase, border: '1px solid var(--color-border, #e2e8f0)', background: 'transparent', color: 'inherit' };
|
||||
const primaryBtn: React.CSSProperties = { ...btnBase, border: '1px solid transparent', background: 'var(--color-primary, #3b82f6)', color: 'var(--color-primary-foreground, #fff)' };
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
@@ -58,7 +84,7 @@ export function PluginDialogHost(): React.JSX.Element | null {
|
||||
zIndex: 100000,
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) resolveHead(false);
|
||||
if (e.target === e.currentTarget) cancel();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
@@ -76,47 +102,73 @@ export function PluginDialogHost(): React.JSX.Element | null {
|
||||
<h2 id="plugin-dialog-title" style={{ fontSize: 16, fontWeight: 600, margin: '0 0 10px 0' }}>
|
||||
{current.title}
|
||||
</h2>
|
||||
<p style={{ fontSize: 13, lineHeight: 1.5, margin: '0 0 16px 0', color: 'var(--color-muted-foreground, #64748b)', whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }}>
|
||||
{renderMessage(current.message)}
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
{current.kind === 'confirm' && (
|
||||
{current.message && (
|
||||
<p style={{ fontSize: 13, lineHeight: 1.5, margin: '0 0 16px 0', color: 'var(--color-muted-foreground, #64748b)', whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }}>
|
||||
{renderMessage(current.message)}
|
||||
</p>
|
||||
)}
|
||||
{isPrompt ? (
|
||||
<form onSubmit={(e) => { e.preventDefault(); submitPrompt(); }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginBottom: 16 }}>
|
||||
{fields.map((f, i) => (
|
||||
<label key={f.name} style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<span style={{ fontSize: 12, fontWeight: 500 }}>
|
||||
{f.label}{f.required ? ' *' : ''}
|
||||
</span>
|
||||
<input
|
||||
type={f.type === 'password' ? 'password' : 'text'}
|
||||
value={values[f.name] ?? ''}
|
||||
placeholder={f.placeholder}
|
||||
autoFocus={i === 0}
|
||||
autoComplete={f.type === 'password' ? 'off' : undefined}
|
||||
onChange={(e) => setValues((v) => ({ ...v, [f.name]: e.target.value }))}
|
||||
style={{
|
||||
fontSize: 13,
|
||||
padding: '8px 10px',
|
||||
borderRadius: 8,
|
||||
border: '1px solid var(--color-input, var(--color-border, #cbd5e1))',
|
||||
background: 'var(--color-background, #fff)',
|
||||
color: 'inherit',
|
||||
outline: 'none',
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
<button type="button" onClick={cancel} style={secondaryBtn}>{cancelLabel}</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
style={{ ...primaryBtn, opacity: canSubmit ? 1 : 0.5, cursor: canSubmit ? 'pointer' : 'not-allowed' }}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
{current.kind === 'confirm' && (
|
||||
<button type="button" autoFocus={!!current.danger} onClick={cancel} style={secondaryBtn}>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
autoFocus={!!current.danger}
|
||||
onClick={() => resolveHead(false)}
|
||||
autoFocus={current.kind === 'alert' || !current.danger}
|
||||
onClick={() => resolveHead(true)}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
border: '1px solid var(--color-border, #e2e8f0)',
|
||||
background: 'transparent',
|
||||
color: 'inherit',
|
||||
...primaryBtn,
|
||||
background: current.danger ? 'var(--color-destructive, #dc2626)' : 'var(--color-primary, #3b82f6)',
|
||||
color: current.danger ? 'var(--color-destructive-foreground, #fff)' : 'var(--color-primary-foreground, #fff)',
|
||||
}}
|
||||
>
|
||||
{cancelLabel}
|
||||
{confirmLabel}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
autoFocus={current.kind === 'alert' || !current.danger}
|
||||
onClick={() => resolveHead(true)}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
border: '1px solid transparent',
|
||||
background: current.danger ? 'var(--color-destructive, #dc2626)' : 'var(--color-primary, #3b82f6)',
|
||||
color: current.danger ? 'var(--color-destructive-foreground, #fff)' : 'var(--color-primary-foreground, #fff)',
|
||||
}}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ marginTop: 12, fontSize: 11, color: 'var(--color-muted-foreground, #94a3b8)', textAlign: 'right' }}>
|
||||
From plugin: {current.pluginId}
|
||||
</div>
|
||||
|
||||
@@ -52,6 +52,7 @@ export function PluginIframeSlot({ pluginId, slot, extraProps }: Props) {
|
||||
slot,
|
||||
code: active.code,
|
||||
locale,
|
||||
tier: active.tier,
|
||||
extraProps: extraProps ?? {},
|
||||
hostContainer: wrapperRef.current,
|
||||
onResize: (h) => setHeight(h),
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -6,6 +6,7 @@ import { EmailViewer } from "@/components/email/email-viewer";
|
||||
import { ErrorBoundary, EmailViewerErrorFallback } from "@/components/error";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useIdentityStore } from "@/stores/identity-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { useProTabStore, type ProEmailTabData, type ProReplyContext } from "@/stores/pro-tab-store";
|
||||
@@ -56,6 +57,7 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
|
||||
const setEmailKeywordsLocal = useEmailStore((s) => s.setEmailKeywordsLocal);
|
||||
const mailboxes = useEmailStore((s) => s.mailboxes);
|
||||
const settingsKeywords = useSettingsStore((s) => s.emailKeywords);
|
||||
const identities = useIdentityStore((s) => s.identities);
|
||||
|
||||
const closeTab = useProTabStore((s) => s.closeTab);
|
||||
const openComposeTab = useProTabStore((s) => s.openComposeTab);
|
||||
@@ -227,6 +229,44 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
|
||||
handleReply(body);
|
||||
}, [handleReply]);
|
||||
|
||||
const handleEditDraft = useCallback(() => {
|
||||
if (!email) return;
|
||||
|
||||
const bodyText = email.bodyValues
|
||||
? Object.values(email.bodyValues).map((v) => v.value).join('\n')
|
||||
: '';
|
||||
const htmlBody = email.htmlBody?.[0]?.partId && email.bodyValues?.[email.htmlBody[0].partId]
|
||||
? email.bodyValues[email.htmlBody[0].partId].value
|
||||
: undefined;
|
||||
|
||||
// Preserve the identity that matches the draft's From address.
|
||||
const draftFromEmail = email.from?.[0]?.email;
|
||||
const matchedIdentity = draftFromEmail
|
||||
? identities.find((id) => id.email === draftFromEmail)
|
||||
: null;
|
||||
|
||||
composerSessionIdRef.current += 1;
|
||||
openComposeTab({
|
||||
sessionId: composerSessionIdRef.current,
|
||||
mode: 'compose',
|
||||
title: email.subject || t('email_composer.new_message'),
|
||||
initialData: {
|
||||
to: email.to?.map((a) => a.email).filter(Boolean).join(', ') || '',
|
||||
cc: email.cc?.map((a) => a.email).filter(Boolean).join(', ') || '',
|
||||
bcc: email.bcc?.map((a) => a.email).filter(Boolean).join(', ') || '',
|
||||
subject: email.subject || '',
|
||||
body: htmlBody || bodyText,
|
||||
showCc: (email.cc?.length || 0) > 0,
|
||||
showBcc: (email.bcc?.length || 0) > 0,
|
||||
selectedIdentityId: matchedIdentity?.id ?? null,
|
||||
subAddressTag: '',
|
||||
mode: 'compose',
|
||||
draftId: email.id,
|
||||
},
|
||||
});
|
||||
closeTab(tabId);
|
||||
}, [email, identities, openComposeTab, closeTab, tabId, t]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col bg-background">
|
||||
<ErrorBoundary fallback={EmailViewerErrorFallback}>
|
||||
@@ -243,6 +283,7 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
|
||||
onSetColorTag={handleSetColorTag}
|
||||
onDownloadAttachment={handleDownloadAttachment}
|
||||
onQuickReply={handleQuickReply}
|
||||
onEditDraft={handleEditDraft}
|
||||
onMoveToMailbox={handleMoveToMailbox}
|
||||
currentUserEmail={client?.getUsername()}
|
||||
currentUserName={client?.getUsername()?.split('@')[0]}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user