Merge dev into main - version 1.4.5
This commit is contained in:
@@ -1,5 +1,30 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 1.4.5 (2026-03-20)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Calendar**: Add prev/next navigation buttons and date label to desktop calendar toolbar
|
||||||
|
- **Calendar**: Add pending event preview functionality to calendar views and event modal
|
||||||
|
- **Calendar**: Add setting to show event start time in month view
|
||||||
|
- **Contacts**: Implement pagination for fetching contacts with maxObjectsInGet capability
|
||||||
|
- **Email**: Add attachment position setting in email settings
|
||||||
|
- **Layout**: Add mobile visibility toggle for sidebar apps
|
||||||
|
- **Error**: Add NotFound component to handle 404 errors and redirect unauthenticated users
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Auth**: Enhance account switching logic and clear stores on account change
|
||||||
|
- **Auth**: Improve account restoration logic and handle stale accounts
|
||||||
|
- **Auth**: Improve draft handling in email composer and enhance session cookie verification
|
||||||
|
- **Calendar**: Expand recurring events in CalendarEvent/query so individual occurrences are returned (#65)
|
||||||
|
- **Calendar**: Validate event start field when fetching calendar events
|
||||||
|
- **Calendar**: Auto-scroll agenda view to today's events and include today's date in groups
|
||||||
|
- **Calendar**: Correct JSX syntax in CalendarToolbar component
|
||||||
|
- **Dependencies**: Update flatted to 3.4.2
|
||||||
|
- **DevOps**: Use native ARM runners instead of QEMU for Docker builds
|
||||||
|
- **DevOps**: Enhance health check with detailed memory diagnostics and stable liveness probe
|
||||||
|
|
||||||
## 1.4.4 (2026-03-19)
|
## 1.4.4 (2026-03-19)
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import { CalendarDayView } from "@/components/calendar/calendar-day-view";
|
|||||||
import { CalendarAgendaView } from "@/components/calendar/calendar-agenda-view";
|
import { CalendarAgendaView } from "@/components/calendar/calendar-agenda-view";
|
||||||
import { MiniCalendar } from "@/components/calendar/mini-calendar";
|
import { MiniCalendar } from "@/components/calendar/mini-calendar";
|
||||||
import { CalendarSidebarPanel } from "@/components/calendar/calendar-sidebar-panel";
|
import { CalendarSidebarPanel } from "@/components/calendar/calendar-sidebar-panel";
|
||||||
import { EventModal } from "@/components/calendar/event-modal";
|
import { EventModal, type PendingEventPreview } from "@/components/calendar/event-modal";
|
||||||
import { EventDetailPopover } from "@/components/calendar/event-detail-popover";
|
import { EventDetailPopover } from "@/components/calendar/event-detail-popover";
|
||||||
import { ICalImportModal } from "@/components/calendar/ical-import-modal";
|
import { ICalImportModal } from "@/components/calendar/ical-import-modal";
|
||||||
import { ICalSubscriptionModal } from "@/components/calendar/ical-subscription-modal";
|
import { ICalSubscriptionModal } from "@/components/calendar/ical-subscription-modal";
|
||||||
@@ -82,6 +82,7 @@ export default function CalendarPage() {
|
|||||||
const [pendingScopeAction, setPendingScopeAction] = useState<PendingScopeAction | null>(null);
|
const [pendingScopeAction, setPendingScopeAction] = useState<PendingScopeAction | null>(null);
|
||||||
const [detailEvent, setDetailEvent] = useState<CalendarEvent | null>(null);
|
const [detailEvent, setDetailEvent] = useState<CalendarEvent | null>(null);
|
||||||
const [detailAnchorRect, setDetailAnchorRect] = useState<DOMRect | null>(null);
|
const [detailAnchorRect, setDetailAnchorRect] = useState<DOMRect | null>(null);
|
||||||
|
const [pendingPreview, setPendingPreview] = useState<PendingEventPreview | null>(null);
|
||||||
const hasFetched = useRef(false);
|
const hasFetched = useRef(false);
|
||||||
|
|
||||||
// Sidebar resize state
|
// Sidebar resize state
|
||||||
@@ -268,12 +269,13 @@ export default function CalendarPage() {
|
|||||||
}, [closeDetail, openEditModal]);
|
}, [closeDetail, openEditModal]);
|
||||||
|
|
||||||
const handleHoverEvent = useCallback((event: CalendarEvent, anchorRect: DOMRect) => {
|
const handleHoverEvent = useCallback((event: CalendarEvent, anchorRect: DOMRect) => {
|
||||||
|
if (isMobile) return;
|
||||||
if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; }
|
if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; }
|
||||||
// Don't show hover popover if the sidebar is already open for this event
|
// Don't show hover popover if the sidebar is already open for this event
|
||||||
if (showEventModal && editEvent?.id === event.id) return;
|
if (showEventModal && editEvent?.id === event.id) return;
|
||||||
setDetailEvent(event);
|
setDetailEvent(event);
|
||||||
setDetailAnchorRect(anchorRect);
|
setDetailAnchorRect(anchorRect);
|
||||||
}, [showEventModal, editEvent]);
|
}, [isMobile, showEventModal, editEvent]);
|
||||||
|
|
||||||
const handleHoverLeave = useCallback(() => {
|
const handleHoverLeave = useCallback(() => {
|
||||||
hoverTimerRef.current = setTimeout(() => {
|
hoverTimerRef.current = setTimeout(() => {
|
||||||
@@ -615,7 +617,7 @@ export default function CalendarPage() {
|
|||||||
|
|
||||||
const visibleEvents = useMemo(() =>
|
const visibleEvents = useMemo(() =>
|
||||||
events.filter((e) => {
|
events.filter((e) => {
|
||||||
if (!e.calendarIds) return false;
|
if (!e.start || !e.calendarIds) return false;
|
||||||
const calIds = Object.keys(e.calendarIds);
|
const calIds = Object.keys(e.calendarIds);
|
||||||
return calIds.some((id) => selectedCalendarIds.includes(id));
|
return calIds.some((id) => selectedCalendarIds.includes(id));
|
||||||
}),
|
}),
|
||||||
@@ -648,6 +650,7 @@ export default function CalendarPage() {
|
|||||||
onCreateAtTime={openCreateModal}
|
onCreateAtTime={openCreateModal}
|
||||||
firstDayOfWeek={firstDayOfWeek}
|
firstDayOfWeek={firstDayOfWeek}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
|
pendingPreview={pendingPreview}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "week":
|
case "week":
|
||||||
@@ -664,6 +667,7 @@ export default function CalendarPage() {
|
|||||||
firstDayOfWeek={firstDayOfWeek}
|
firstDayOfWeek={firstDayOfWeek}
|
||||||
timeFormat={timeFormat}
|
timeFormat={timeFormat}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
|
pendingPreview={pendingPreview}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "day":
|
case "day":
|
||||||
@@ -678,6 +682,7 @@ export default function CalendarPage() {
|
|||||||
onCreateAtTime={openCreateModal}
|
onCreateAtTime={openCreateModal}
|
||||||
timeFormat={timeFormat}
|
timeFormat={timeFormat}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
|
pendingPreview={pendingPreview}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "agenda":
|
case "agenda":
|
||||||
@@ -708,7 +713,7 @@ export default function CalendarPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-dvh bg-background overflow-hidden">
|
<div className={cn("flex h-dvh bg-background overflow-hidden", isMobile && "flex-col")}>
|
||||||
{/* Left Navigation Rail */}
|
{/* Left Navigation Rail */}
|
||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
|
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
|
||||||
@@ -771,7 +776,7 @@ export default function CalendarPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!inlineApp && (
|
{!inlineApp && (
|
||||||
<div className="flex flex-col flex-1 min-w-0">
|
<div className="flex flex-col flex-1 min-w-0 min-h-0">
|
||||||
<CalendarToolbar
|
<CalendarToolbar
|
||||||
selectedDate={selectedDate}
|
selectedDate={selectedDate}
|
||||||
viewMode={normalizedViewMode}
|
viewMode={normalizedViewMode}
|
||||||
@@ -808,7 +813,8 @@ export default function CalendarPage() {
|
|||||||
onDelete={handleDeleteEvent}
|
onDelete={handleDeleteEvent}
|
||||||
onDuplicate={handleDuplicateEvent}
|
onDuplicate={handleDuplicateEvent}
|
||||||
onRsvp={handleRsvp}
|
onRsvp={handleRsvp}
|
||||||
onClose={() => { setShowEventModal(false); setEditEvent(null); }}
|
onClose={() => { setShowEventModal(false); setEditEvent(null); setPendingPreview(null); }}
|
||||||
|
onPreviewChange={setPendingPreview}
|
||||||
currentUserEmails={currentUserEmails}
|
currentUserEmails={currentUserEmails}
|
||||||
isMobile={false}
|
isMobile={false}
|
||||||
/>
|
/>
|
||||||
@@ -831,13 +837,15 @@ export default function CalendarPage() {
|
|||||||
|
|
||||||
{/* Mobile Bottom Navigation */}
|
{/* Mobile Bottom Navigation */}
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
<NavigationRail
|
<div className="shrink-0">
|
||||||
orientation="horizontal"
|
<NavigationRail
|
||||||
onManageApps={handleManageApps}
|
orientation="horizontal"
|
||||||
onInlineApp={handleInlineApp}
|
onManageApps={handleManageApps}
|
||||||
onCloseInlineApp={closeInlineApp}
|
onInlineApp={handleInlineApp}
|
||||||
activeAppId={inlineApp?.id ?? null}
|
onCloseInlineApp={closeInlineApp}
|
||||||
/>
|
activeAppId={inlineApp?.id ?? null}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{detailEvent && detailAnchorRect && (
|
{detailEvent && detailAnchorRect && (
|
||||||
|
|||||||
@@ -544,7 +544,7 @@ export default function ContactsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-dvh bg-background overflow-hidden">
|
<div className={cn("flex h-dvh bg-background overflow-hidden", isMobile && "flex-col")}>
|
||||||
{/* Navigation Rail - desktop only */}
|
{/* Navigation Rail - desktop only */}
|
||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
|
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { decryptSession } from '@/lib/auth/crypto';
|
import { decryptSession } from '@/lib/auth/crypto';
|
||||||
import { SESSION_COOKIE } from '@/lib/auth/session-cookie';
|
import { sessionCookieName } from '@/lib/auth/session-cookie';
|
||||||
import { saveUserSettings, loadUserSettings, deleteUserSettings } from '@/lib/settings-sync';
|
import { saveUserSettings, loadUserSettings, deleteUserSettings } from '@/lib/settings-sync';
|
||||||
|
|
||||||
function isEnabled(): boolean {
|
function isEnabled(): boolean {
|
||||||
@@ -10,19 +10,30 @@ function isEnabled(): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verify identity against the session cookie if available.
|
* Verify identity against session cookies across all account slots.
|
||||||
* Returns true if no session cookie exists (can't verify) or if identity matches.
|
* With multi-account, the requesting account may be on any slot (0-4).
|
||||||
* Returns false if session cookie exists but identity doesn't match.
|
* Returns true if any slot matches OR if no session cookies exist at all.
|
||||||
*/
|
*/
|
||||||
async function verifyIdentity(username: string, serverUrl: string): Promise<boolean> {
|
async function verifyIdentity(username: string, serverUrl: string): Promise<boolean> {
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const sessionToken = cookieStore.get(SESSION_COOKIE)?.value;
|
let hasAnyCookie = false;
|
||||||
if (!sessionToken) return true; // No session cookie, can't verify (same-origin protection applies)
|
|
||||||
|
|
||||||
const session = decryptSession(sessionToken);
|
for (let slot = 0; slot <= 4; slot++) {
|
||||||
if (!session) return true; // Invalid session cookie, skip verification
|
const token = cookieStore.get(sessionCookieName(slot))?.value;
|
||||||
|
if (!token) continue;
|
||||||
|
hasAnyCookie = true;
|
||||||
|
|
||||||
return session.username === username && session.serverUrl === serverUrl;
|
const session = decryptSession(token);
|
||||||
|
if (session && session.username === username && session.serverUrl === serverUrl) {
|
||||||
|
return true; // Found a matching slot
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No cookies at all → can't verify, allow (same-origin protection applies)
|
||||||
|
if (!hasAnyCookie) return true;
|
||||||
|
|
||||||
|
// Cookies exist but none matched → identity mismatch
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
|
|
||||||
|
export default function NotFound() {
|
||||||
|
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
window.location.href = "/login";
|
||||||
|
}
|
||||||
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||||
|
<div className="text-center max-w-md px-4">
|
||||||
|
<h1 className="text-4xl font-bold text-foreground mb-2">404</h1>
|
||||||
|
<p className="text-muted-foreground mb-6">This page could not be found.</p>
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
className="inline-flex items-center px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity"
|
||||||
|
>
|
||||||
|
Go home
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,13 +2,14 @@
|
|||||||
|
|
||||||
import { useMemo, useEffect, useRef, useState } from "react";
|
import { useMemo, useEffect, useRef, useState } from "react";
|
||||||
import { useTranslations, useFormatter } from "next-intl";
|
import { useTranslations, useFormatter } from "next-intl";
|
||||||
import { format, isToday, parseISO } from "date-fns";
|
import { format, isSameDay, isToday, parseISO } from "date-fns";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { EventCard, parseDuration } from "./event-card";
|
import { EventCard, parseDuration } from "./event-card";
|
||||||
import { QuickEventInput } from "./quick-event-input";
|
import { QuickEventInput } from "./quick-event-input";
|
||||||
import { formatSnapTime, getEventDayBounds, getPrimaryCalendarId, layoutOverlappingEvents } from "@/lib/calendar-utils";
|
import { formatSnapTime, getEventDayBounds, getPrimaryCalendarId, layoutOverlappingEvents } from "@/lib/calendar-utils";
|
||||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||||
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
|
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
|
||||||
|
import type { PendingEventPreview } from "./event-modal";
|
||||||
|
|
||||||
interface CalendarDayViewProps {
|
interface CalendarDayViewProps {
|
||||||
selectedDate: Date;
|
selectedDate: Date;
|
||||||
@@ -20,6 +21,7 @@ interface CalendarDayViewProps {
|
|||||||
onCreateAtTime: (date: Date, endDate?: Date) => void;
|
onCreateAtTime: (date: Date, endDate?: Date) => void;
|
||||||
timeFormat?: "12h" | "24h";
|
timeFormat?: "12h" | "24h";
|
||||||
isMobile?: boolean;
|
isMobile?: boolean;
|
||||||
|
pendingPreview?: PendingEventPreview | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const HOUR_HEIGHT = 64;
|
const HOUR_HEIGHT = 64;
|
||||||
@@ -35,6 +37,7 @@ export function CalendarDayView({
|
|||||||
onCreateAtTime,
|
onCreateAtTime,
|
||||||
timeFormat = "24h",
|
timeFormat = "24h",
|
||||||
isMobile,
|
isMobile,
|
||||||
|
pendingPreview,
|
||||||
}: CalendarDayViewProps) {
|
}: CalendarDayViewProps) {
|
||||||
const t = useTranslations("calendar");
|
const t = useTranslations("calendar");
|
||||||
const intlFormatter = useFormatter();
|
const intlFormatter = useFormatter();
|
||||||
@@ -274,6 +277,34 @@ export function CalendarDayView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{pendingPreview && !pendingPreview.allDay && isSameDay(pendingPreview.start, selectedDate) && (
|
||||||
|
(() => {
|
||||||
|
const startMin = pendingPreview.start.getHours() * 60 + pendingPreview.start.getMinutes();
|
||||||
|
const endMin = pendingPreview.end.getHours() * 60 + pendingPreview.end.getMinutes();
|
||||||
|
const durationMin = Math.max(15, endMin - startMin);
|
||||||
|
const cal = calendars.find(c => c.id === pendingPreview.calendarId);
|
||||||
|
const color = cal?.color || "hsl(var(--primary))";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="absolute left-2 right-2 z-10 rounded-md pointer-events-none border-2 border-dashed overflow-hidden"
|
||||||
|
style={{
|
||||||
|
top: (startMin / 60) * HOUR_HEIGHT,
|
||||||
|
height: Math.max(24, (durationMin / 60) * HOUR_HEIGHT),
|
||||||
|
borderColor: color,
|
||||||
|
backgroundColor: `${color}10`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="text-[10px] font-medium px-1.5 py-0.5 truncate" style={{ color }}>
|
||||||
|
{pendingPreview.title}
|
||||||
|
</div>
|
||||||
|
<div className="text-[9px] px-1.5 opacity-70" style={{ color }}>
|
||||||
|
{formatSnapTime(startMin, timeFormat)} – {formatSnapTime(startMin + durationMin, timeFormat)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { buildWeekSegments, getEventDayBounds, getPrimaryCalendarId } from "@/li
|
|||||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { useCalendarStore } from "@/stores/calendar-store";
|
import { useCalendarStore } from "@/stores/calendar-store";
|
||||||
|
import type { PendingEventPreview } from "./event-modal";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
|
|
||||||
interface CalendarMonthViewProps {
|
interface CalendarMonthViewProps {
|
||||||
@@ -25,6 +26,7 @@ interface CalendarMonthViewProps {
|
|||||||
onCreateAtTime?: (date: Date) => void;
|
onCreateAtTime?: (date: Date) => void;
|
||||||
firstDayOfWeek?: number;
|
firstDayOfWeek?: number;
|
||||||
isMobile?: boolean;
|
isMobile?: boolean;
|
||||||
|
pendingPreview?: PendingEventPreview | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CalendarMonthView({
|
export function CalendarMonthView({
|
||||||
@@ -38,6 +40,7 @@ export function CalendarMonthView({
|
|||||||
onCreateAtTime,
|
onCreateAtTime,
|
||||||
firstDayOfWeek = 1,
|
firstDayOfWeek = 1,
|
||||||
isMobile,
|
isMobile,
|
||||||
|
pendingPreview,
|
||||||
}: CalendarMonthViewProps) {
|
}: CalendarMonthViewProps) {
|
||||||
const t = useTranslations("calendar");
|
const t = useTranslations("calendar");
|
||||||
const intlFormatter = useFormatter();
|
const intlFormatter = useFormatter();
|
||||||
@@ -194,31 +197,63 @@ export function CalendarMonthView({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
dayEvents.length > 0 && (
|
<div className="flex items-center justify-center gap-0.5 flex-wrap">
|
||||||
<div className="flex items-center justify-center gap-0.5 flex-wrap">
|
{dayEvents.slice(0, 3).map((ev) => {
|
||||||
{dayEvents.slice(0, 3).map((ev) => {
|
const calId = getPrimaryCalendarId(ev);
|
||||||
const calId = getPrimaryCalendarId(ev);
|
const cal = calId ? calendarMap.get(calId) : undefined;
|
||||||
const cal = calId ? calendarMap.get(calId) : undefined;
|
const evColor = ev.color || cal?.color || "#3b82f6";
|
||||||
const evColor = ev.color || cal?.color || "#3b82f6";
|
return (
|
||||||
return (
|
<span
|
||||||
<span
|
key={ev.id}
|
||||||
key={ev.id}
|
className="w-1.5 h-1.5 rounded-full"
|
||||||
className="w-1.5 h-1.5 rounded-full"
|
style={{ backgroundColor: evColor }}
|
||||||
style={{ backgroundColor: evColor }}
|
/>
|
||||||
/>
|
);
|
||||||
);
|
})}
|
||||||
})}
|
{dayEvents.length > 3 && (
|
||||||
{dayEvents.length > 3 && (
|
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/40" />
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/40" />
|
)}
|
||||||
)}
|
{pendingPreview && isSameDay(pendingPreview.start, day) && (
|
||||||
</div>
|
<span
|
||||||
)
|
className="w-1.5 h-1.5 rounded-full border border-dashed"
|
||||||
|
style={{ borderColor: calendarMap.get(pendingPreview.calendarId)?.color || "#3b82f6" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!isMobile && pendingPreview && (() => {
|
||||||
|
const previewDayIdx = week.findIndex(d => isSameDay(d, pendingPreview.start));
|
||||||
|
if (previewDayIdx === -1) return null;
|
||||||
|
const previewRow = rowCount;
|
||||||
|
const cal = calendarMap.get(pendingPreview.calendarId);
|
||||||
|
const color = cal?.color || "#3b82f6";
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-x-0 pointer-events-none" style={{ top: 30 }}>
|
||||||
|
<div
|
||||||
|
className="absolute px-0.5"
|
||||||
|
style={{
|
||||||
|
left: `calc(${(previewDayIdx / 7) * 100}% + 1px)`,
|
||||||
|
width: `calc(${(1 / 7) * 100}% - 2px)`,
|
||||||
|
top: previewRow * 22,
|
||||||
|
height: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="h-full rounded text-[10px] leading-[20px] font-medium px-1.5 truncate border-2 border-dashed"
|
||||||
|
style={{ borderColor: color, color, backgroundColor: `${color}10` }}
|
||||||
|
>
|
||||||
|
{pendingPreview.title}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{!isMobile && segments.length > 0 && (
|
{!isMobile && segments.length > 0 && (
|
||||||
<div className="absolute inset-x-0 pointer-events-none" style={{ top: 30 }}>
|
<div className="absolute inset-x-0 pointer-events-none" style={{ top: 30 }}>
|
||||||
{segments.map((segment) => {
|
{segments.map((segment) => {
|
||||||
|
|||||||
@@ -136,6 +136,20 @@ export function CalendarToolbar({
|
|||||||
{t("views.today")}
|
{t("views.today")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{!isMobile && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onPrev} aria-label={t("nav_prev")}>
|
||||||
|
<ChevronLeft className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<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">
|
||||||
|
{getDateLabel()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{isMobile && calendars && selectedCalendarIds && onToggleVisibility && (
|
{isMobile && calendars && selectedCalendarIds && onToggleVisibility && (
|
||||||
<div className="relative" ref={dropdownRef}>
|
<div className="relative" ref={dropdownRef}>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { QuickEventInput } from "./quick-event-input";
|
|||||||
import { buildWeekSegments, formatSnapTime, getEventDayBounds, getPrimaryCalendarId, layoutOverlappingEvents } from "@/lib/calendar-utils";
|
import { buildWeekSegments, formatSnapTime, getEventDayBounds, getPrimaryCalendarId, layoutOverlappingEvents } from "@/lib/calendar-utils";
|
||||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||||
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
|
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
|
||||||
|
import type { PendingEventPreview } from "./event-modal";
|
||||||
|
|
||||||
interface CalendarWeekViewProps {
|
interface CalendarWeekViewProps {
|
||||||
selectedDate: Date;
|
selectedDate: Date;
|
||||||
@@ -24,6 +25,7 @@ interface CalendarWeekViewProps {
|
|||||||
firstDayOfWeek?: number;
|
firstDayOfWeek?: number;
|
||||||
timeFormat?: "12h" | "24h";
|
timeFormat?: "12h" | "24h";
|
||||||
isMobile?: boolean;
|
isMobile?: boolean;
|
||||||
|
pendingPreview?: PendingEventPreview | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const HOUR_HEIGHT = 60;
|
const HOUR_HEIGHT = 60;
|
||||||
@@ -41,6 +43,7 @@ export function CalendarWeekView({
|
|||||||
firstDayOfWeek = 1,
|
firstDayOfWeek = 1,
|
||||||
timeFormat = "24h",
|
timeFormat = "24h",
|
||||||
isMobile,
|
isMobile,
|
||||||
|
pendingPreview,
|
||||||
}: CalendarWeekViewProps) {
|
}: CalendarWeekViewProps) {
|
||||||
const t = useTranslations("calendar");
|
const t = useTranslations("calendar");
|
||||||
const intlFormatter = useFormatter();
|
const intlFormatter = useFormatter();
|
||||||
@@ -366,6 +369,34 @@ export function CalendarWeekView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{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();
|
||||||
|
const durationMin = Math.max(15, endMin - startMin);
|
||||||
|
const cal = calendars.find(c => c.id === pendingPreview.calendarId);
|
||||||
|
const color = cal?.color || "hsl(var(--primary))";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="absolute left-1 right-1 z-10 rounded-md pointer-events-none border-2 border-dashed overflow-hidden"
|
||||||
|
style={{
|
||||||
|
top: (startMin / 60) * HOUR_HEIGHT,
|
||||||
|
height: Math.max(20, (durationMin / 60) * HOUR_HEIGHT),
|
||||||
|
borderColor: color,
|
||||||
|
backgroundColor: `${color}10`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="text-[10px] font-medium px-1.5 py-0.5 truncate" style={{ color }}>
|
||||||
|
{pendingPreview.title}
|
||||||
|
</div>
|
||||||
|
<div className="text-[9px] px-1.5 opacity-70" style={{ color }}>
|
||||||
|
{formatSnapTime(startMin, timeFormat)} – {formatSnapTime(startMin + durationMin, timeFormat)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
|||||||
const color = getEventColor(event, calendar);
|
const color = getEventColor(event, calendar);
|
||||||
const startDate = parseISO(event.start);
|
const startDate = parseISO(event.start);
|
||||||
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||||
|
const showTimeInMonthView = useSettingsStore((state) => state.showTimeInMonthView);
|
||||||
const timeFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
|
const timeFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
|
||||||
|
|
||||||
const calendarName = calendar?.name || "";
|
const calendarName = calendar?.name || "";
|
||||||
@@ -156,6 +157,9 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
|||||||
style={{ backgroundColor: `${color}24`, borderLeft: `3px solid ${color}`, color, ...style }}
|
style={{ backgroundColor: `${color}24`, borderLeft: `3px solid ${color}`, color, ...style }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1 min-w-0">
|
<div className="flex items-center gap-1 min-w-0">
|
||||||
|
{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="truncate font-medium">{event.title || t("events.no_title")}</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -20,6 +20,14 @@ import {
|
|||||||
} from "@/lib/calendar-participants";
|
} from "@/lib/calendar-participants";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
|
|
||||||
|
export interface PendingEventPreview {
|
||||||
|
start: Date;
|
||||||
|
end: Date;
|
||||||
|
title: string;
|
||||||
|
allDay: boolean;
|
||||||
|
calendarId: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface EventModalProps {
|
interface EventModalProps {
|
||||||
event?: CalendarEvent | null;
|
event?: CalendarEvent | null;
|
||||||
calendars: Calendar[];
|
calendars: Calendar[];
|
||||||
@@ -30,6 +38,7 @@ interface EventModalProps {
|
|||||||
onDuplicate?: (data: Partial<CalendarEvent>) => void;
|
onDuplicate?: (data: Partial<CalendarEvent>) => void;
|
||||||
onRsvp?: (eventId: string, participantId: string, status: CalendarParticipant['participationStatus']) => void;
|
onRsvp?: (eventId: string, participantId: string, status: CalendarParticipant['participationStatus']) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
onPreviewChange?: (preview: PendingEventPreview | null) => void;
|
||||||
currentUserEmails?: string[];
|
currentUserEmails?: string[];
|
||||||
isMobile?: boolean;
|
isMobile?: boolean;
|
||||||
}
|
}
|
||||||
@@ -105,6 +114,7 @@ export function EventModal({
|
|||||||
onDuplicate,
|
onDuplicate,
|
||||||
onRsvp,
|
onRsvp,
|
||||||
onClose,
|
onClose,
|
||||||
|
onPreviewChange,
|
||||||
currentUserEmails = [],
|
currentUserEmails = [],
|
||||||
isMobile = false,
|
isMobile = false,
|
||||||
}: EventModalProps) {
|
}: EventModalProps) {
|
||||||
@@ -219,6 +229,18 @@ export function EventModal({
|
|||||||
});
|
});
|
||||||
const [sendInvitations, setSendInvitations] = useState(true);
|
const [sendInvitations, setSendInvitations] = useState(true);
|
||||||
|
|
||||||
|
// Report live preview to parent for grid outline
|
||||||
|
useEffect(() => {
|
||||||
|
if (!onPreviewChange || isEdit) return;
|
||||||
|
const startStr = allDay ? `${startDate}T00:00:00` : `${startDate}T${startTime}:00`;
|
||||||
|
const endStr = allDay ? `${endDate}T23:59:59` : `${endDate}T${endTime}:00`;
|
||||||
|
const s = new Date(startStr);
|
||||||
|
const e = new Date(endStr);
|
||||||
|
if (isNaN(s.getTime()) || isNaN(e.getTime())) return;
|
||||||
|
onPreviewChange({ start: s, end: e, title: title || "(No title)", allDay, calendarId });
|
||||||
|
return () => onPreviewChange(null);
|
||||||
|
}, [startDate, startTime, endDate, endTime, allDay, title, calendarId, isEdit, onPreviewChange]);
|
||||||
|
|
||||||
const statusCounts = useMemo(() => {
|
const statusCounts = useMemo(() => {
|
||||||
if (!event?.participants) return null;
|
if (!event?.participants) return null;
|
||||||
return getStatusCounts(event);
|
return getStatusCounts(event);
|
||||||
|
|||||||
@@ -217,8 +217,8 @@ export function NavigationRail({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Custom sidebar apps */}
|
{/* Custom sidebar apps (per-app mobile visibility) */}
|
||||||
{sidebarApps.map((app) => {
|
{sidebarApps.filter((app) => app.showOnMobile).map((app) => {
|
||||||
const AppIcon = lucideIcons[app.icon as keyof typeof lucideIcons] as LucideIcon | undefined;
|
const AppIcon = lucideIcons[app.icon as keyof typeof lucideIcons] as LucideIcon | undefined;
|
||||||
const isActive = activeAppId === app.id;
|
const isActive = activeAppId === app.id;
|
||||||
return (
|
return (
|
||||||
@@ -252,16 +252,27 @@ export function NavigationRail({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Manage apps button */}
|
{/* Settings */}
|
||||||
{onManageApps && (
|
<Link
|
||||||
<button
|
href="/settings"
|
||||||
onClick={onManageApps}
|
onClick={activeAppId ? () => onCloseInlineApp?.() : undefined}
|
||||||
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
|
className={cn(
|
||||||
>
|
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px]",
|
||||||
<Plus className="w-5 h-5" />
|
"transition-colors duration-150",
|
||||||
<span className="text-[10px] font-medium leading-tight">{t("add_app")}</span>
|
isSettingsActive
|
||||||
</button>
|
? "text-primary"
|
||||||
)}
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
)}
|
||||||
|
aria-current={isSettingsActive ? "page" : undefined}
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
<Settings className="w-5 h-5" />
|
||||||
|
{isSettingsActive && (
|
||||||
|
<span className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-4 h-0.5 rounded-full bg-primary" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] font-medium leading-tight">{t("settings")}</span>
|
||||||
|
</Link>
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export function CalendarSettings() {
|
|||||||
const {
|
const {
|
||||||
timeFormat,
|
timeFormat,
|
||||||
firstDayOfWeek,
|
firstDayOfWeek,
|
||||||
|
showTimeInMonthView,
|
||||||
calendarNotificationsEnabled,
|
calendarNotificationsEnabled,
|
||||||
calendarNotificationSound,
|
calendarNotificationSound,
|
||||||
calendarInvitationParsingEnabled,
|
calendarInvitationParsingEnabled,
|
||||||
@@ -57,6 +58,16 @@ export function CalendarSettings() {
|
|||||||
/>
|
/>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
|
||||||
|
<SettingItem
|
||||||
|
label={t('show_time_in_month_view')}
|
||||||
|
description={t('show_time_in_month_view_desc')}
|
||||||
|
>
|
||||||
|
<ToggleSwitch
|
||||||
|
checked={showTimeInMonthView}
|
||||||
|
onChange={(checked) => updateSetting('showTimeInMonthView', checked)}
|
||||||
|
/>
|
||||||
|
</SettingItem>
|
||||||
|
|
||||||
<SettingItem
|
<SettingItem
|
||||||
label={t('notifications_enabled')}
|
label={t('notifications_enabled')}
|
||||||
description={t('notifications_enabled_desc')}
|
description={t('notifications_enabled_desc')}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ interface SidebarAppFormData {
|
|||||||
url: string;
|
url: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
openMode: "tab" | "inline";
|
openMode: "tab" | "inline";
|
||||||
|
showOnMobile: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function AppForm({
|
function AppForm({
|
||||||
@@ -37,6 +38,7 @@ function AppForm({
|
|||||||
url: app?.url || "",
|
url: app?.url || "",
|
||||||
icon: app?.icon || "Globe",
|
icon: app?.icon || "Globe",
|
||||||
openMode: app?.openMode || "tab",
|
openMode: app?.openMode || "tab",
|
||||||
|
showOnMobile: app?.showOnMobile ?? false,
|
||||||
});
|
});
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
@@ -144,6 +146,25 @@ function AppForm({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-sm font-medium">{t("show_on_mobile")}</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFormData({ ...formData, showOnMobile: !formData.showOnMobile })}
|
||||||
|
className={cn(
|
||||||
|
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
|
||||||
|
formData.showOnMobile ? "bg-primary" : "bg-muted-foreground/30"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
|
||||||
|
formData.showOnMobile ? "translate-x-4.5" : "translate-x-0.5"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 justify-end">
|
<div className="flex gap-2 justify-end">
|
||||||
<Button type="button" variant="ghost" size="sm" onClick={onCancel}>
|
<Button type="button" variant="ghost" size="sm" onClick={onCancel}>
|
||||||
{t("cancel")}
|
{t("cancel")}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useEmailStore } from '@/stores/email-store';
|
|||||||
import { useContactStore } from '@/stores/contact-store';
|
import { useContactStore } from '@/stores/contact-store';
|
||||||
import { useCalendarStore } from '@/stores/calendar-store';
|
import { useCalendarStore } from '@/stores/calendar-store';
|
||||||
import { useFilterStore } from '@/stores/filter-store';
|
import { useFilterStore } from '@/stores/filter-store';
|
||||||
|
import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
|
||||||
import { useIdentityStore } from '@/stores/identity-store';
|
import { useIdentityStore } from '@/stores/identity-store';
|
||||||
import { useVacationStore } from '@/stores/vacation-store';
|
import { useVacationStore } from '@/stores/vacation-store';
|
||||||
|
|
||||||
@@ -97,6 +98,19 @@ export function clearAllStores(): void {
|
|||||||
error: null,
|
error: null,
|
||||||
searchQuery: '',
|
searchQuery: '',
|
||||||
quota: null,
|
quota: null,
|
||||||
|
isPushConnected: false,
|
||||||
|
lastPushUpdate: null,
|
||||||
|
newEmailNotification: null,
|
||||||
|
selectedEmailIds: new Set<string>(),
|
||||||
|
hasMoreEmails: false,
|
||||||
|
totalEmails: 0,
|
||||||
|
expandedThreadIds: new Set<string>(),
|
||||||
|
threadEmailsCache: new Map(),
|
||||||
|
isLoadingThread: null,
|
||||||
|
selectedKeyword: null,
|
||||||
|
tagCounts: {},
|
||||||
|
searchFilters: { ...DEFAULT_SEARCH_FILTERS },
|
||||||
|
isAdvancedSearchOpen: false,
|
||||||
});
|
});
|
||||||
useIdentityStore.getState().clearIdentities();
|
useIdentityStore.getState().clearIdentities();
|
||||||
useContactStore.getState().clearContacts();
|
useContactStore.getState().clearContacts();
|
||||||
|
|||||||
+72
-37
@@ -2066,6 +2066,11 @@ export class JMAPClient {
|
|||||||
return coreCapability?.maxCallsInRequest || 50;
|
return coreCapability?.maxCallsInRequest || 50;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getMaxObjectsInGet(): number {
|
||||||
|
const coreCapability = this.capabilities["urn:ietf:params:jmap:core"] as { maxObjectsInGet?: number } | undefined;
|
||||||
|
return coreCapability?.maxObjectsInGet || 500;
|
||||||
|
}
|
||||||
|
|
||||||
getEventSourceUrl(): string | null {
|
getEventSourceUrl(): string | null {
|
||||||
if (!this.session) return null;
|
if (!this.session) return null;
|
||||||
|
|
||||||
@@ -2428,26 +2433,62 @@ export class JMAPClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getContacts(addressBookId?: string): Promise<ContactCard[]> {
|
private async fetchPaginatedContacts(
|
||||||
try {
|
accountId: string,
|
||||||
const accountId = this.getContactsAccountId();
|
filter?: Record<string, unknown>,
|
||||||
const queryArgs: Record<string, unknown> = { accountId, limit: 1000 };
|
): Promise<ContactCard[]> {
|
||||||
if (addressBookId) {
|
const batchSize = this.getMaxObjectsInGet();
|
||||||
queryArgs.filter = { inAddressBook: addressBookId };
|
const allIds: string[] = [];
|
||||||
|
let position = 0;
|
||||||
|
|
||||||
|
// Paginate ContactCard/query to collect all IDs
|
||||||
|
for (;;) {
|
||||||
|
const queryArgs: Record<string, unknown> = { accountId, position, limit: batchSize };
|
||||||
|
if (filter) {
|
||||||
|
queryArgs.filter = filter;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await this.request([
|
const response = await this.request([
|
||||||
["ContactCard/query", queryArgs, "0"],
|
["ContactCard/query", queryArgs, "q"],
|
||||||
["ContactCard/get", {
|
|
||||||
accountId,
|
|
||||||
"#ids": { resultOf: "0", name: "ContactCard/query", path: "/ids" },
|
|
||||||
}, "1"],
|
|
||||||
], this.contactUsing());
|
], this.contactUsing());
|
||||||
|
|
||||||
if (response.methodResponses?.[1]?.[0] === "ContactCard/get") {
|
const queryResult = response.methodResponses?.[0];
|
||||||
return (response.methodResponses[1][1].list || []) as ContactCard[];
|
if (queryResult?.[0] !== "ContactCard/query") break;
|
||||||
|
|
||||||
|
const ids: string[] = queryResult[1].ids || [];
|
||||||
|
allIds.push(...ids);
|
||||||
|
|
||||||
|
const total: number = queryResult[1].total ?? -1;
|
||||||
|
if (ids.length < batchSize || (total > 0 && allIds.length >= total)) {
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
return [];
|
position += ids.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allIds.length === 0) return [];
|
||||||
|
|
||||||
|
// Batch ContactCard/get to respect maxObjectsInGet
|
||||||
|
const allContacts: ContactCard[] = [];
|
||||||
|
for (let i = 0; i < allIds.length; i += batchSize) {
|
||||||
|
const chunk = allIds.slice(i, i + batchSize);
|
||||||
|
const response = await this.request([
|
||||||
|
["ContactCard/get", { accountId, ids: chunk }, "g"],
|
||||||
|
], this.contactUsing());
|
||||||
|
|
||||||
|
if (response.methodResponses?.[0]?.[0] === "ContactCard/get") {
|
||||||
|
const list = (response.methodResponses[0][1].list || []) as ContactCard[];
|
||||||
|
allContacts.push(...list);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return allContacts;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getContacts(addressBookId?: string): Promise<ContactCard[]> {
|
||||||
|
try {
|
||||||
|
const accountId = this.getContactsAccountId();
|
||||||
|
const filter = addressBookId ? { inAddressBook: addressBookId } : undefined;
|
||||||
|
return await this.fetchPaginatedContacts(accountId, filter);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to get contacts:', error);
|
console.error('Failed to get contacts:', error);
|
||||||
return [];
|
return [];
|
||||||
@@ -2465,29 +2506,19 @@ export class JMAPClient {
|
|||||||
const account = this.accounts[accountId];
|
const account = this.accounts[accountId];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await this.request([
|
const rawContacts = await this.fetchPaginatedContacts(accountId);
|
||||||
["ContactCard/query", { accountId, limit: 1000 }, "0"],
|
const contacts = rawContacts.map((contact) => ({
|
||||||
["ContactCard/get", {
|
...contact,
|
||||||
accountId,
|
id: isPrimary ? contact.id : `${accountId}:${contact.id}`,
|
||||||
"#ids": { resultOf: "0", name: "ContactCard/query", path: "/ids" },
|
originalId: contact.id,
|
||||||
}, "1"],
|
addressBookIds: isPrimary ? contact.addressBookIds : (contact.addressBookIds ? Object.fromEntries(
|
||||||
], this.contactUsing());
|
Object.entries(contact.addressBookIds).map(([bookId, v]) => [`${accountId}:${bookId}`, v])
|
||||||
|
) : contact.addressBookIds),
|
||||||
if (response.methodResponses?.[1]?.[0] === "ContactCard/get") {
|
accountId,
|
||||||
const rawContacts = (response.methodResponses[1][1].list || []) as ContactCard[];
|
accountName: account?.name || (isPrimary ? this.username : accountId),
|
||||||
const contacts = rawContacts.map((contact) => ({
|
isShared: !isPrimary,
|
||||||
...contact,
|
}));
|
||||||
id: isPrimary ? contact.id : `${accountId}:${contact.id}`,
|
allContacts.push(...contacts);
|
||||||
originalId: contact.id,
|
|
||||||
addressBookIds: isPrimary ? contact.addressBookIds : (contact.addressBookIds ? Object.fromEntries(
|
|
||||||
Object.entries(contact.addressBookIds).map(([bookId, v]) => [`${accountId}:${bookId}`, v])
|
|
||||||
) : contact.addressBookIds),
|
|
||||||
accountId,
|
|
||||||
accountName: account?.name || (isPrimary ? this.username : accountId),
|
|
||||||
isShared: !isPrimary,
|
|
||||||
}));
|
|
||||||
allContacts.push(...contacts);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to fetch contacts for account ${accountId}:`, error);
|
console.error(`Failed to fetch contacts for account ${accountId}:`, error);
|
||||||
}
|
}
|
||||||
@@ -2888,6 +2919,10 @@ export class JMAPClient {
|
|||||||
filter,
|
filter,
|
||||||
limit: limit || 1000,
|
limit: limit || 1000,
|
||||||
};
|
};
|
||||||
|
// Expand recurring events into individual occurrences when a date range is provided
|
||||||
|
if (filter.after || filter.before) {
|
||||||
|
queryArgs.expandRecurrences = true;
|
||||||
|
}
|
||||||
if (sort) {
|
if (sort) {
|
||||||
queryArgs.sort = sort;
|
queryArgs.sort = sort;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "bulwark-webmail",
|
"name": "bulwark-webmail",
|
||||||
"version": "1.4.4",
|
"version": "1.4.5",
|
||||||
"description": "Bulwark Webmail — a modern webmail client built for Stalwart Mail Server",
|
"description": "Bulwark Webmail — a modern webmail client built for Stalwart Mail Server",
|
||||||
"author": "Bulwark Webmail <bulwark@rbm.systems>",
|
"author": "Bulwark Webmail <bulwark@rbm.systems>",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
|
|||||||
+98
-19
@@ -34,7 +34,7 @@ interface AuthState {
|
|||||||
login: (serverUrl: string, username: string, password: string, totp?: string, rememberMe?: boolean) => Promise<boolean>;
|
login: (serverUrl: string, username: string, password: string, totp?: string, rememberMe?: boolean) => Promise<boolean>;
|
||||||
loginWithOAuth: (serverUrl: string, code: string, codeVerifier: string, redirectUri: string) => Promise<boolean>;
|
loginWithOAuth: (serverUrl: string, code: string, codeVerifier: string, redirectUri: string) => Promise<boolean>;
|
||||||
refreshAccessToken: () => Promise<string | null>;
|
refreshAccessToken: () => Promise<string | null>;
|
||||||
logout: () => void;
|
logout: () => Promise<void>;
|
||||||
logoutAll: () => void;
|
logoutAll: () => void;
|
||||||
switchAccount: (accountId: string) => Promise<void>;
|
switchAccount: (accountId: string) => Promise<void>;
|
||||||
checkAuth: () => Promise<void>;
|
checkAuth: () => Promise<void>;
|
||||||
@@ -264,10 +264,12 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
? (accountStore.getAccountById(accountId)?.cookieSlot ?? accountStore.getNextCookieSlot())
|
? (accountStore.getAccountById(accountId)?.cookieSlot ?? accountStore.getNextCookieSlot())
|
||||||
: accountStore.getNextCookieSlot();
|
: accountStore.getNextCookieSlot();
|
||||||
|
|
||||||
// Snapshot current account if switching away
|
// Snapshot current account if switching away and clear stores so
|
||||||
|
// the new account starts with a clean email/contact/calendar state.
|
||||||
const prevAccountId = get().activeAccountId;
|
const prevAccountId = get().activeAccountId;
|
||||||
if (prevAccountId && prevAccountId !== accountId) {
|
if (prevAccountId && prevAccountId !== accountId) {
|
||||||
snapshotAccount(prevAccountId);
|
snapshotAccount(prevAccountId);
|
||||||
|
clearAllStores();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store client in multi-account map
|
// Store client in multi-account map
|
||||||
@@ -390,10 +392,12 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
// Register in account store
|
// Register in account store
|
||||||
const accountId = generateAccountId(username, serverUrl);
|
const accountId = generateAccountId(username, serverUrl);
|
||||||
|
|
||||||
// Snapshot current account if switching away
|
// Snapshot current account if switching away and clear stores so
|
||||||
|
// the new account starts with a clean email/contact/calendar state.
|
||||||
const prevAccountId = get().activeAccountId;
|
const prevAccountId = get().activeAccountId;
|
||||||
if (prevAccountId && prevAccountId !== accountId) {
|
if (prevAccountId && prevAccountId !== accountId) {
|
||||||
snapshotAccount(prevAccountId);
|
snapshotAccount(prevAccountId);
|
||||||
|
clearAllStores();
|
||||||
}
|
}
|
||||||
|
|
||||||
clients.set(accountId, client);
|
clients.set(accountId, client);
|
||||||
@@ -506,7 +510,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
return promise;
|
return promise;
|
||||||
},
|
},
|
||||||
|
|
||||||
logout: () => {
|
logout: async () => {
|
||||||
const state = get();
|
const state = get();
|
||||||
const wasOAuth = state.authMode === 'oauth';
|
const wasOAuth = state.authMode === 'oauth';
|
||||||
const accountId = state.activeAccountId;
|
const accountId = state.activeAccountId;
|
||||||
@@ -515,6 +519,11 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
const slot = account?.cookieSlot ?? 0;
|
const slot = account?.cookieSlot ?? 0;
|
||||||
|
|
||||||
clearRefreshTimer(accountId ?? undefined);
|
clearRefreshTimer(accountId ?? undefined);
|
||||||
|
|
||||||
|
// Null out the client BEFORE disconnecting so the page doesn't fire
|
||||||
|
// data-loading effects with the stale disconnected client while
|
||||||
|
// stores are being cleared.
|
||||||
|
set({ client: null });
|
||||||
state.client?.disconnect();
|
state.client?.disconnect();
|
||||||
|
|
||||||
// Remove client from multi-account map
|
// Remove client from multi-account map
|
||||||
@@ -536,11 +545,56 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
clearAllStores();
|
clearAllStores();
|
||||||
|
|
||||||
// Restore next account
|
// Restore next account
|
||||||
const nextClient = clients.get(nextAccount.id);
|
let nextClient = clients.get(nextAccount.id);
|
||||||
|
|
||||||
|
// If the client isn't in memory, try to restore it from the session
|
||||||
|
if (!nextClient) {
|
||||||
|
try {
|
||||||
|
if (nextAccount.authMode === 'oauth') {
|
||||||
|
const res = await fetch(`/api/auth/token?slot=${nextAccount.cookieSlot}`, { method: 'PUT' });
|
||||||
|
if (res.ok) {
|
||||||
|
const { access_token, expires_in } = await res.json();
|
||||||
|
const refreshFn = get().refreshAccessToken;
|
||||||
|
nextClient = JMAPClient.withBearer(nextAccount.serverUrl, access_token, nextAccount.username, () => refreshFn());
|
||||||
|
nextClient.onConnectionChange((connected) => {
|
||||||
|
if (get().activeAccountId === nextAccount.id) {
|
||||||
|
set({ connectionLost: !connected });
|
||||||
|
}
|
||||||
|
accountStore.updateAccount(nextAccount.id, { isConnected: connected });
|
||||||
|
});
|
||||||
|
await nextClient.connect();
|
||||||
|
clients.set(nextAccount.id, nextClient);
|
||||||
|
scheduleRefresh(expires_in, get().refreshAccessToken, nextAccount.id);
|
||||||
|
}
|
||||||
|
} else if (nextAccount.authMode === 'basic' && nextAccount.rememberMe) {
|
||||||
|
const res = await fetch(`/api/auth/session?slot=${nextAccount.cookieSlot}`);
|
||||||
|
if (res.ok) {
|
||||||
|
const { serverUrl: sUrl, username: uName, password: pwd } = await res.json();
|
||||||
|
nextClient = new JMAPClient(sUrl, uName, pwd);
|
||||||
|
nextClient.onConnectionChange((connected) => {
|
||||||
|
if (get().activeAccountId === nextAccount.id) {
|
||||||
|
set({ connectionLost: !connected });
|
||||||
|
}
|
||||||
|
accountStore.updateAccount(nextAccount.id, { isConnected: connected });
|
||||||
|
});
|
||||||
|
await nextClient.connect();
|
||||||
|
clients.set(nextAccount.id, nextClient);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
debug.error(`Failed to restore next account ${nextAccount.id} during logout:`, err);
|
||||||
|
nextClient = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (nextClient) {
|
if (nextClient) {
|
||||||
const restored = restoreAccount(nextAccount.id);
|
const restored = restoreAccount(nextAccount.id);
|
||||||
accountStore.setActiveAccount(nextAccount.id);
|
accountStore.setActiveAccount(nextAccount.id);
|
||||||
|
|
||||||
|
// Build identity state up front so the name updates atomically
|
||||||
|
const restoredIdentities = restored ? useIdentityStore.getState().identities : [];
|
||||||
|
const restoredPrimary = restoredIdentities[0] ?? null;
|
||||||
|
|
||||||
set({
|
set({
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@@ -552,6 +606,8 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
connectionLost: false,
|
connectionLost: false,
|
||||||
error: null,
|
error: null,
|
||||||
activeAccountId: nextAccount.id,
|
activeAccountId: nextAccount.id,
|
||||||
|
identities: restoredIdentities,
|
||||||
|
primaryIdentity: restoredPrimary,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!restored) {
|
if (!restored) {
|
||||||
@@ -560,13 +616,32 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
const { identities, primaryIdentity } = loadIdentities(rawIds, nextAccount.username);
|
const { identities, primaryIdentity } = loadIdentities(rawIds, nextAccount.username);
|
||||||
set({ identities, primaryIdentity });
|
set({ identities, primaryIdentity });
|
||||||
}).catch((err) => debug.error('Failed to load identities after switch:', err));
|
}).catch((err) => debug.error('Failed to load identities after switch:', err));
|
||||||
} else {
|
|
||||||
const identityState = useIdentityStore.getState();
|
|
||||||
set({
|
|
||||||
identities: identityState.identities,
|
|
||||||
primaryIdentity: identityState.identities[0] ?? null,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Could not restore the next account — remove it and do a full logout
|
||||||
|
debug.error(`Cannot restore next account ${nextAccount.id}, performing full logout`);
|
||||||
|
evictAccount(nextAccount.id);
|
||||||
|
accountStore.removeAccount(nextAccount.id);
|
||||||
|
|
||||||
|
set({
|
||||||
|
isAuthenticated: false,
|
||||||
|
serverUrl: null,
|
||||||
|
username: null,
|
||||||
|
client: null,
|
||||||
|
identities: [],
|
||||||
|
primaryIdentity: null,
|
||||||
|
authMode: 'basic',
|
||||||
|
rememberMe: false,
|
||||||
|
accessToken: null,
|
||||||
|
tokenExpiresAt: null,
|
||||||
|
connectionLost: false,
|
||||||
|
error: null,
|
||||||
|
activeAccountId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
localStorage.removeItem('auth-storage');
|
||||||
|
clearAllStores();
|
||||||
|
redirectToLogin();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// No accounts remaining — full logout
|
// No accounts remaining — full logout
|
||||||
@@ -691,7 +766,9 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
const targetAccount = accountStore.getAccountById(accountId);
|
const targetAccount = accountStore.getAccountById(accountId);
|
||||||
if (!targetAccount) return;
|
if (!targetAccount) return;
|
||||||
|
|
||||||
set({ isLoading: true });
|
// Null out the client immediately so the page doesn't fire data-loading
|
||||||
|
// effects with the old client while stores are being cleared.
|
||||||
|
set({ isLoading: true, client: null });
|
||||||
|
|
||||||
// Snapshot current account
|
// Snapshot current account
|
||||||
if (state.activeAccountId) {
|
if (state.activeAccountId) {
|
||||||
@@ -782,6 +859,10 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
accountStore.setActiveAccount(accountId);
|
accountStore.setActiveAccount(accountId);
|
||||||
accountStore.updateAccount(accountId, { isConnected: true, hasError: false, errorMessage: undefined });
|
accountStore.updateAccount(accountId, { isConnected: true, hasError: false, errorMessage: undefined });
|
||||||
|
|
||||||
|
// Build identity state up front so the name updates atomically
|
||||||
|
const restoredIdentities = restored ? useIdentityStore.getState().identities : [];
|
||||||
|
const restoredPrimary = restoredIdentities[0] ?? null;
|
||||||
|
|
||||||
set({
|
set({
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@@ -793,6 +874,8 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
connectionLost: false,
|
connectionLost: false,
|
||||||
error: null,
|
error: null,
|
||||||
activeAccountId: accountId,
|
activeAccountId: accountId,
|
||||||
|
identities: restoredIdentities,
|
||||||
|
primaryIdentity: restoredPrimary,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!restored) {
|
if (!restored) {
|
||||||
@@ -804,12 +887,6 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
debug.error(`Failed to load data for ${accountId}:`, err);
|
debug.error(`Failed to load data for ${accountId}:`, err);
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
const identityState = useIdentityStore.getState();
|
|
||||||
set({
|
|
||||||
identities: identityState.identities,
|
|
||||||
primaryIdentity: identityState.identities[0] ?? null,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sync settings
|
// Sync settings
|
||||||
@@ -827,7 +904,9 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
|
|
||||||
// Multi-account restoration: restore all registered accounts
|
// Multi-account restoration: restore all registered accounts
|
||||||
if (accounts.length > 0) {
|
if (accounts.length > 0) {
|
||||||
set({ isLoading: true });
|
// Null out client so the page doesn't fire data-loading effects
|
||||||
|
// with a stale client reference while we're restoring accounts.
|
||||||
|
set({ isLoading: true, client: null });
|
||||||
|
|
||||||
// Determine which account to activate first
|
// Determine which account to activate first
|
||||||
const defaultAccount = accountStore.getDefaultAccount();
|
const defaultAccount = accountStore.getDefaultAccount();
|
||||||
|
|||||||
@@ -110,10 +110,12 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
fetchEvents: async (client, start, end) => {
|
fetchEvents: async (client, start, end) => {
|
||||||
set({ isLoadingEvents: true, error: null });
|
set({ isLoadingEvents: true, error: null });
|
||||||
try {
|
try {
|
||||||
const events = await client.queryAllCalendarEvents({
|
const rawEvents = await client.queryAllCalendarEvents({
|
||||||
after: start,
|
after: start,
|
||||||
before: end,
|
before: end,
|
||||||
});
|
});
|
||||||
|
// Filter out malformed events missing required 'start' field
|
||||||
|
const events = rawEvents.filter(e => typeof e.start === 'string' && e.start);
|
||||||
set({ events, isLoadingEvents: false, dateRange: { start, end } });
|
set({ events, isLoadingEvents: false, dateRange: { start, end } });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
debug.error('Failed to fetch events:', error);
|
debug.error('Failed to fetch events:', error);
|
||||||
|
|||||||
@@ -239,15 +239,17 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
try {
|
try {
|
||||||
const mailboxes = await client.getAllMailboxes();
|
const mailboxes = await client.getAllMailboxes();
|
||||||
|
|
||||||
// Auto-select inbox if no mailbox is currently selected
|
// Auto-select inbox if no mailbox is selected or the current selection
|
||||||
|
// doesn't exist in the fetched list (e.g. after an account switch)
|
||||||
const currentSelectedMailbox = get().selectedMailbox;
|
const currentSelectedMailbox = get().selectedMailbox;
|
||||||
if (!currentSelectedMailbox) {
|
const selectionValid = currentSelectedMailbox && mailboxes.some(m => m.id === currentSelectedMailbox);
|
||||||
|
if (!selectionValid) {
|
||||||
// Find inbox from PRIMARY account (not shared accounts)
|
// Find inbox from PRIMARY account (not shared accounts)
|
||||||
const inboxMailbox = mailboxes.find(m => m.role === 'inbox' && !m.isShared);
|
const inboxMailbox = mailboxes.find(m => m.role === 'inbox' && !m.isShared);
|
||||||
if (inboxMailbox) {
|
if (inboxMailbox) {
|
||||||
set({ mailboxes, selectedMailbox: inboxMailbox.id, isLoading: false });
|
set({ mailboxes, selectedMailbox: inboxMailbox.id, isLoading: false });
|
||||||
} else {
|
} else {
|
||||||
set({ mailboxes, isLoading: false });
|
set({ mailboxes, selectedMailbox: '', isLoading: false });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
set({ mailboxes, isLoading: false });
|
set({ mailboxes, isLoading: false });
|
||||||
|
|||||||
Reference in New Issue
Block a user