Merge dev into main - version 1.4.8

This commit is contained in:
Linus Rath
2026-03-23 17:08:19 +01:00
47 changed files with 1506 additions and 211 deletions
+2 -1
View File
@@ -448,7 +448,8 @@ export function CalendarWeekView({
{pendingPreview && !pendingPreview.allDay && isSameDay(pendingPreview.start, day) && (
(() => {
const startMin = pendingPreview.start.getHours() * 60 + pendingPreview.start.getMinutes();
const endMin = pendingPreview.end.getHours() * 60 + pendingPreview.end.getMinutes();
let endMin = pendingPreview.end.getHours() * 60 + pendingPreview.end.getMinutes();
if (endMin <= startMin) endMin = 1440;
const durationMin = Math.max(15, endMin - startMin);
const cal = calendars.find(c => c.id === pendingPreview.calendarId);
const color = cal?.color || "hsl(var(--primary))";
+14 -8
View File
@@ -59,10 +59,12 @@ function buildDuration(startDate: Date, endDate: Date): string {
const minutes = totalMinutes % 60;
let dur = "P";
if (days > 0) dur += `${days}D`;
dur += "T";
if (hours > 0) dur += `${hours}H`;
if (minutes > 0) dur += `${minutes}M`;
if (dur === "PT") dur = "PT0M";
if (hours > 0 || minutes > 0) {
dur += "T";
if (hours > 0) dur += `${hours}H`;
if (minutes > 0) dur += `${minutes}M`;
}
if (dur === "P") dur = "PT0M";
return dur;
}
@@ -83,9 +85,9 @@ function getAlertLabel(event: CalendarEvent, t: ReturnType<typeof useTranslation
if (!first || first.trigger["@type"] !== "OffsetTrigger") return null;
const offset = first.trigger.offset;
if (offset === "PT0S") return t("alerts.at_time");
const minMatch = offset.match(/-?PT?(\d+)M$/);
const minMatch = offset.match(/-?PT(\d+)M$/);
if (minMatch) return t("alerts.minutes_before", { count: parseInt(minMatch[1]) });
const hourMatch = offset.match(/-?PT?(\d+)H$/);
const hourMatch = offset.match(/-?PT(\d+)H$/);
if (hourMatch) return t("alerts.hours_before", { count: parseInt(hourMatch[1]) });
const dayMatch = offset.match(/-?P(\d+)D/);
if (dayMatch) return t("alerts.days_before", { count: parseInt(dayMatch[1]) });
@@ -209,9 +211,9 @@ export function EventModal({
if (first.trigger["@type"] === "OffsetTrigger") {
const offset = first.trigger.offset;
if (offset === "PT0S") return "at_time";
const minMatch = offset.match(/-?PT?(\d+)M$/);
const minMatch = offset.match(/-?PT(\d+)M$/);
if (minMatch) return minMatch[1] as AlertOption;
const hourMatch = offset.match(/-?PT?(\d+)H$/);
const hourMatch = offset.match(/-?PT(\d+)H$/);
if (hourMatch) return String(parseInt(hourMatch[1]) * 60) as AlertOption;
const dayMatch = offset.match(/-?P(\d+)D/);
if (dayMatch) return String(parseInt(dayMatch[1]) * 1440) as AlertOption;
@@ -384,7 +386,11 @@ export function EventModal({
if (!event || !onDuplicate) return;
const start = parseISO(event.start);
const newStart = addDays(start, 1);
const newUid = typeof crypto !== 'undefined' && crypto.randomUUID
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
const data: Partial<CalendarEvent> = {
uid: newUid,
title: event.title,
description: event.description,
start: format(newStart, "yyyy-MM-dd'T'HH:mm:ss"),
+10 -5
View File
@@ -943,11 +943,16 @@ export function EmailComposer({
onChange={(e) => setSelectedIdentityId(e.target.value)}
className="flex-1 bg-transparent text-sm text-foreground outline-none cursor-pointer hover:text-muted-foreground transition-colors min-w-0 truncate"
>
{identities.map((identity) => (
<option key={identity.id} value={identity.id}>
{identity.name ? `${identity.name} <${identity.email}>` : identity.email}
</option>
))}
{identities.map((identity) => {
const displayEmail = subAddressTag
? generateSubAddress(identity.email, subAddressTag)
: identity.email;
return (
<option key={identity.id} value={identity.id}>
{identity.name ? `${identity.name} <${displayEmail}>` : displayEmail}
</option>
);
})}
</select>
) : (
<span className="text-sm text-foreground flex-1 truncate">
+15 -1
View File
@@ -6,7 +6,7 @@ import { formatDate } 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, Tag } from "lucide-react";
import { Paperclip, Star, Circle, CheckSquare, Square, Tag, 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";
@@ -41,6 +41,8 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
const isImportant = email.keywords?.["$important"];
const isAnswered = email.keywords?.$answered;
const isForwarded = email.keywords?.$forwarded;
const sender = email.from?.[0];
// Resolve color tag using keyword definitions from settings
@@ -175,6 +177,18 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
</span>
)}
<EmailIdentityBadge email={email} identities={identities} compact={true} />
{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" />
)}
{isAnswered && isForwarded && (
<>
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
</>
)}
{email.hasAttachment && (
<Paperclip className="w-3.5 h-3.5 text-muted-foreground" />
)}
+15 -1
View File
@@ -5,7 +5,7 @@ import { formatDate } 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 } from "lucide-react";
import { Paperclip, Star, Circle, CheckSquare, Square, Reply, Forward } from "lucide-react";
import { useEmailDrag } from "@/hooks/use-email-drag";
import { useLongPress } from "@/hooks/use-long-press";
import { useEmailStore } from "@/stores/email-store";
@@ -29,6 +29,8 @@ export function ThreadEmailItem({
}: ThreadEmailItemProps) {
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
const isAnswered = email.keywords?.$answered;
const isForwarded = email.keywords?.$forwarded;
const sender = email.from?.[0];
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
const density = useSettingsStore((state) => state.density);
@@ -151,6 +153,18 @@ export function ThreadEmailItem({
{isStarred && (
<Star className="w-3 h-3 fill-amber-400 text-amber-400" />
)}
{isAnswered && !isForwarded && (
<Reply className="w-3 h-3 text-muted-foreground" />
)}
{isForwarded && !isAnswered && (
<Forward className="w-3 h-3 text-muted-foreground" />
)}
{isAnswered && isForwarded && (
<>
<Reply className="w-3 h-3 text-muted-foreground" />
<Forward className="w-3 h-3 text-muted-foreground" />
</>
)}
{email.hasAttachment && (
<Paperclip className="w-3 h-3 text-muted-foreground" />
)}
+28 -2
View File
@@ -5,7 +5,7 @@ import { formatDate } from "@/lib/utils";
import { Email, ThreadGroup } 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 } from "lucide-react";
import { Paperclip, Star, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square, Reply, Forward } from "lucide-react";
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
import { useUIStore } from "@/stores/ui-store";
import { useEmailStore } from "@/stores/email-store";
@@ -53,6 +53,8 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
function SingleEmailItem({ email, selected, onClick, onContextMenu, showPreview, colorTag, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam }, ref) {
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
const isAnswered = email.keywords?.$answered;
const isForwarded = email.keywords?.$forwarded;
const sender = email.from?.[0];
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
@@ -182,6 +184,18 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
{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" />
)}
{isAnswered && isForwarded && (
<>
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
</>
)}
{email.hasAttachment && (
<Paperclip className="w-3.5 h-3.5 text-muted-foreground" />
)}
@@ -267,7 +281,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
const showPreview = useSettingsStore((state) => state.showPreview);
const density = useSettingsStore((state) => state.density);
const isMobile = useUIStore((state) => state.isMobile);
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, emailCount } = thread;
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread;
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
@@ -482,6 +496,18 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
{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" />
)}
{hasAnswered && hasForwarded && (
<>
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
</>
)}
{hasAttachment && (
<Paperclip className="w-3.5 h-3.5 text-muted-foreground" />
)}
@@ -2,6 +2,7 @@
import { useEffect } from "react";
import { isEmbedded, listenFromParent } from "@/lib/iframe-bridge";
import { getPathPrefix, getLocaleFromPath } from "@/lib/browser-navigation";
import { useAuthStore } from "@/stores/auth-store";
import { useConfig } from "@/hooks/use-config";
@@ -16,9 +17,9 @@ export function EmbeddedBridgeProvider({ children }: { children: React.ReactNode
switch (msg.type) {
case "sso:trigger-login": {
// Navigate to login page to start SSO flow
const segments = window.location.pathname.split("/").filter(Boolean);
const locale = segments[0] || "en";
window.location.href = `/${locale}/login`;
const prefix = getPathPrefix();
const locale = getLocaleFromPath();
window.location.href = `${prefix}/${locale}/login`;
break;
}
case "sso:trigger-logout":
+24 -9
View File
@@ -3,29 +3,44 @@
import { useTranslations } from 'next-intl';
import { useAuthStore } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store';
import { useAccountStore } from '@/stores/account-store';
import { SettingsSection, SettingItem } from './settings-section';
import { formatFileSize } from '@/lib/utils';
export function AccountSettings() {
const t = useTranslations('settings.account');
const { username, serverUrl, isDemoMode, primaryIdentity } = useAuthStore();
const { username, serverUrl, isDemoMode, primaryIdentity, authMode, activeAccountId } = useAuthStore();
const { quota } = useEmailStore();
const account = useAccountStore((s) => activeAccountId ? s.getAccountById(activeAccountId) : undefined);
const quotaPercentage = quota ? Math.round((quota.used / quota.total) * 100) : 0;
const displayName = primaryIdentity?.name || (isDemoMode ? 'Demo User' : undefined);
const displayName = primaryIdentity?.name || account?.displayName || (isDemoMode ? 'Demo User' : undefined);
const email = primaryIdentity?.email || account?.email || username;
return (
<SettingsSection title={t('title')} description={t('description')}>
{/* Display Name (show in demo mode or when identity has a name) */}
{displayName && (
<SettingItem label={t('name_label')}>
<span className="text-sm text-foreground">{displayName}</span>
</SettingItem>
)}
{/* Display Name */}
<SettingItem label={t('name_label')}>
<span className="text-sm text-foreground">{displayName || t('../../common.unknown')}</span>
</SettingItem>
{/* Email Address */}
<SettingItem label={t('email.label')}>
<span className="text-sm text-foreground">{username || t('../../common.unknown')}</span>
<span className="text-sm text-foreground">{email || t('../../common.unknown')}</span>
</SettingItem>
{/* Username / Login (show when it differs from email) */}
{username && username !== email && (
<SettingItem label={t('username_label')}>
<span className="text-sm text-foreground">{username}</span>
</SettingItem>
)}
{/* Authentication Method */}
<SettingItem label={t('auth_method_label')}>
<span className="text-sm text-foreground">
{authMode === 'oauth' ? t('auth_method_oauth') : t('auth_method_basic')}
</span>
</SettingItem>
{/* Server */}
-34
View File
@@ -16,9 +16,6 @@ export function CalendarSettings() {
firstDayOfWeek,
showTimeInMonthView,
showWeekNumbers,
calendarNotificationsEnabled,
calendarNotificationSound,
calendarInvitationParsingEnabled,
enableCalendarTasks,
showTasksOnCalendar,
updateSetting,
@@ -103,37 +100,6 @@ export function CalendarSettings() {
</SettingItem>
)}
<SettingItem
label={t('notifications_enabled')}
description={t('notifications_enabled_desc')}
>
<ToggleSwitch
checked={calendarNotificationsEnabled}
onChange={(checked) => updateSetting('calendarNotificationsEnabled', checked)}
/>
</SettingItem>
<SettingItem
label={t('notification_sound')}
description={t('notification_sound_desc')}
>
<ToggleSwitch
checked={calendarNotificationSound}
onChange={(checked) => updateSetting('calendarNotificationSound', checked)}
disabled={!calendarNotificationsEnabled}
/>
</SettingItem>
<SettingItem
label={t('invitation_parsing')}
description={t('invitation_parsing_desc')}
>
<ToggleSwitch
checked={calendarInvitationParsingEnabled}
onChange={(checked) => updateSetting('calendarInvitationParsingEnabled', checked)}
/>
</SettingItem>
</SettingsSection>
);
}
+35 -2
View File
@@ -1,7 +1,8 @@
"use client";
import { useState } from 'react';
import { useState, useCallback } from 'react';
import { useTranslations } from 'next-intl';
import { useConfig } from '@/hooks/use-config';
import { useSettingsStore } from '@/stores/settings-store';
import type { ArchiveMode, HoverAction } from '@/stores/settings-store';
import { ALL_HOVER_ACTIONS } from '@/stores/settings-store';
@@ -10,13 +11,26 @@ import { useEmailStore } from '@/stores/email-store';
import { cn } from '@/lib/utils';
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
import { TrustedSendersModal } from '@/components/trusted-senders-modal';
import { ChevronRight, AlertTriangle, FolderSync, Loader2 } from 'lucide-react';
import { ChevronRight, AlertTriangle, FolderSync, Loader2, Mail } from 'lucide-react';
export function EmailSettings() {
const t = useTranslations('settings.email_behavior');
const { appName } = useConfig();
const [showTrustedModal, setShowTrustedModal] = useState(false);
const [isReorganizing, setIsReorganizing] = useState(false);
const [reorganizeResult, setReorganizeResult] = useState<string | null>(null);
const [defaultMailStatus, setDefaultMailStatus] = useState<'idle' | 'success' | 'error'>('idle');
const handleSetDefaultMailProgram = useCallback(() => {
try {
if (typeof navigator !== 'undefined' && navigator.registerProtocolHandler) {
navigator.registerProtocolHandler('mailto', `${window.location.origin}/compose?mailto=%s`);
setDefaultMailStatus('success');
}
} catch {
setDefaultMailStatus('error');
}
}, []);
const {
markAsReadDelay,
@@ -280,6 +294,25 @@ export function EmailSettings() {
/>
</SettingItem>
{/* Default Mail Program */}
<SettingItem label={t('default_mail_program.label')} description={t('default_mail_program.description', { appName: appName || 'Bulwark' })}>
<div className="flex flex-col items-end gap-1">
<button
onClick={handleSetDefaultMailProgram}
className="flex items-center gap-2 px-3 py-1.5 bg-muted hover:bg-accent rounded-md transition-colors"
>
<Mail className="w-4 h-4" />
<span className="text-sm text-foreground">{t('default_mail_program.button')}</span>
</button>
{defaultMailStatus === 'success' && (
<p className="text-xs text-green-600 dark:text-green-400">{t('default_mail_program.success')}</p>
)}
{defaultMailStatus === 'error' && (
<p className="text-xs text-destructive">{t('default_mail_program.error')}</p>
)}
</div>
</SettingItem>
{/* Trusted Senders */}
<SettingItem label={t('trusted_senders.label')} description={t('trusted_senders.description')}>
<button
@@ -0,0 +1,115 @@
"use client";
import { useTranslations } from 'next-intl';
import { useSettingsStore } from '@/stores/settings-store';
import { SettingsSection, SettingItem, ToggleSwitch, Select } from './settings-section';
import { playNotificationSound, NOTIFICATION_SOUNDS } from '@/lib/notification-sound';
import type { NotificationSoundChoice } from '@/lib/notification-sound';
import { Button } from '@/components/ui/button';
import { Volume2 } from 'lucide-react';
export function NotificationSettings() {
const t = useTranslations('settings.notifications');
const {
emailNotificationsEnabled,
emailNotificationSound,
notificationSoundChoice,
calendarNotificationsEnabled,
calendarNotificationSound,
calendarInvitationParsingEnabled,
updateSetting,
} = useSettingsStore();
const soundOptions = NOTIFICATION_SOUNDS.map((s) => ({
value: s.id,
label: t(`sounds.${s.id}`),
}));
return (
<div className="space-y-8">
<SettingsSection title={t('sound_selection.title')} description={t('sound_selection.description')}>
<SettingItem
label={t('sound_selection.choose')}
description={t('sound_selection.choose_desc')}
>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => playNotificationSound(notificationSoundChoice)}
title={t('test_sound')}
>
<Volume2 className="w-4 h-4" />
</Button>
<Select
value={notificationSoundChoice}
onChange={(value) => {
const choice = value as NotificationSoundChoice;
updateSetting('notificationSoundChoice', choice);
playNotificationSound(choice);
}}
options={soundOptions}
/>
</div>
</SettingItem>
</SettingsSection>
<SettingsSection title={t('email.title')} description={t('email.description')}>
<SettingItem
label={t('email.enabled')}
description={t('email.enabled_desc')}
>
<ToggleSwitch
checked={emailNotificationsEnabled}
onChange={(checked) => updateSetting('emailNotificationsEnabled', checked)}
/>
</SettingItem>
<SettingItem
label={t('email.sound')}
description={t('email.sound_desc')}
>
<ToggleSwitch
checked={emailNotificationSound}
onChange={(checked) => updateSetting('emailNotificationSound', checked)}
disabled={!emailNotificationsEnabled}
/>
</SettingItem>
</SettingsSection>
<SettingsSection title={t('calendar.title')} description={t('calendar.description')}>
<SettingItem
label={t('calendar.enabled')}
description={t('calendar.enabled_desc')}
>
<ToggleSwitch
checked={calendarNotificationsEnabled}
onChange={(checked) => updateSetting('calendarNotificationsEnabled', checked)}
/>
</SettingItem>
<SettingItem
label={t('calendar.sound')}
description={t('calendar.sound_desc')}
>
<ToggleSwitch
checked={calendarNotificationSound}
onChange={(checked) => updateSetting('calendarNotificationSound', checked)}
disabled={!calendarNotificationsEnabled}
/>
</SettingItem>
<SettingItem
label={t('calendar.invitation_parsing')}
description={t('calendar.invitation_parsing_desc')}
>
<ToggleSwitch
checked={calendarInvitationParsingEnabled}
onChange={(checked) => updateSetting('calendarInvitationParsingEnabled', checked)}
/>
</SettingItem>
</SettingsSection>
</div>
);
}