diff --git a/app/(main)/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx
index ebd3dc38a..7c3a7151b 100644
--- a/app/(main)/[locale]/layout.tsx
+++ b/app/(main)/[locale]/layout.tsx
@@ -31,6 +31,7 @@ import { OfflineHost } from "@/components/offline/offline-host";
import { DeviceWipeHost } from "@/components/offline/device-wipe-host";
import { NotificationActionsHost } from "@/components/providers/notification-actions-host";
import { DesktopOsHost } from "@/components/desktop/desktop-os-host";
+import { FileDropHost } from "@/components/file-drop/file-drop-host";
import { EditedFilesHost } from "@/components/desktop/edited-files-host";
import { DevicePolicyHost } from "@/components/device/device-policy-host";
import { PushNotificationPrompt } from "@/components/push-notification-prompt";
@@ -103,6 +104,7 @@ export default async function LocaleLayout({
{children}
+
diff --git a/app/globals.css b/app/globals.css
index 191548f9c..6ecc0f095 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -212,6 +212,12 @@
* {
border-color: var(--color-border);
}
+
+ /* A placeholder or value too long for a narrow field ends in "…" instead of
+ stopping mid-word ("Termine durchs"), so it's clear there's more. */
+ input {
+ text-overflow: ellipsis;
+ }
}
body {
@@ -632,6 +638,11 @@ html[data-window-controls] [data-own-window-close] {
display: none;
}
+/* Toasts that step out of the focus's way go to the top end corner: below the controls there. */
+html[data-window-controls] [data-toast-container][data-dodging] {
+ top: calc(var(--titlebar-row-height) + 0.75rem) !important;
+}
+
/* A top row is at least as tall as the controls, so they don't hang over what's below it. */
html[data-window-controls] [data-window-row] {
min-height: var(--titlebar-row-height);
@@ -655,6 +666,21 @@ html[data-window-controls] [data-window-row-left] {
padding-left: calc(var(--titlebar-left-inset) + 0.5rem) !important;
}
+/*
+ * The desktop app's menu bar while it takes its row (components/layout/window-menu-bar.tsx).
+ * The app moves down under it, a page that fills the window starts below it, and the
+ * window's controls sit in the bar's row, so no column below makes room for them.
+ */
+html[data-menu-bar] {
+ --menu-bar-height: 2rem;
+ --app-top-inset: calc(env(safe-area-inset-top, 0px) + var(--menu-bar-height));
+ --titlebar-inset: var(--menu-bar-height);
+}
+
+html[data-menu-bar][data-window-controls] [data-window-end-column] {
+ padding-top: 0;
+}
+
/*
* The empty parts of the app's top rows are what the window is dragged by, and a
* double-click there maximises. Anything to click stays clickable, and so does
@@ -1550,3 +1576,14 @@ html[data-window-controls-left] [data-window-rail] {
width: var(--mv-rail-peek-w);
}
}
+
+/* A list row while its right-click menu is open (components/ui/context-menu.tsx). */
+[data-menu-row][data-menu-opener] {
+ outline: 2px solid var(--color-ring);
+ outline-offset: -2px;
+}
+
+/* A menu opened under the pointer: the item there isn't hovered until the pointer moves. */
+[data-pointer-unarmed] :is([role="menuitem"], [role="menuitemradio"], [role="menuitemcheckbox"]):hover {
+ background-color: transparent;
+}
diff --git a/app/print.css b/app/print.css
index 9cf992ad5..2a754311b 100644
--- a/app/print.css
+++ b/app/print.css
@@ -738,3 +738,24 @@
margin: 0;
overflow-wrap: anywhere;
}
+
+/* An invitation's card: when and where, in a box above the body. */
+.pr-invitation {
+ margin: 4mm 0 0;
+ padding: 2.5mm 3mm;
+ border: 0.3mm solid var(--pr-rule, #bbb);
+ border-radius: 1.5mm;
+ break-inside: avoid;
+}
+
+.pr-invitation-heading {
+ margin: 0;
+ font-size: 8pt;
+ font-weight: 600;
+ color: var(--pr-muted);
+}
+
+.pr-invitation .pr-message-meta {
+ font-size: 10pt;
+ color: var(--pr-ink);
+}
diff --git a/components/admin/org/sections/offline-section.tsx b/components/admin/org/sections/offline-section.tsx
index 9a806ccbc..18f63f935 100644
--- a/components/admin/org/sections/offline-section.tsx
+++ b/components/admin/org/sections/offline-section.tsx
@@ -117,8 +117,10 @@ export function OfflineSection({ draft }: { draft: OrgPolicyDraft }) {
What a device keeps: mail in every folder from the window the person picks (30 days, 90 days, a year or everything),
- with calendar, contacts and files, encrypted on the device. That protects against someone copying its files, not
- against someone using the unlocked browser or app. The limits above can only lower what people pick: where several
+ with calendar, contacts and files, encrypted on the device. In a browser the key sits in the same profile, so this doesn't stop someone
+ who copies its files: use full-disk encryption. In the desktop app the key also takes that person's login on
+ that computer. Neither stops someone using the unlocked browser or app. An app lock adds a PIN, which someone with
+ a copy can guess at their own speed, so ask for a long one. The limits above can only lower what people pick: where several
scopes apply, the lowest value counts, so a scope can't raise what another one lowered. The app holds itself to
them when it fetches the policy; the server can't check what is on a device. A sign-out someone asks for deletes everything.
One that nobody asked for hides it, as the switch above says. With several accounts signed in, each account follows its own settings.
diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx
index 7d84f6e06..35f12e35f 100644
--- a/components/calendar/calendar-app.tsx
+++ b/components/calendar/calendar-app.tsx
@@ -125,6 +125,8 @@ import { calendarShortcutAction } from "@/lib/calendar-shortcuts";
import { isAppShortcutBlocked, isArrowKeyWidgetTarget } from "@/lib/keyboard";
import { isContextMenuOpen } from "@/components/ui/context-menu";
import { useRegisterCommands } from "@/lib/command-registry";
+import { useAppCommands } from "@/lib/app-commands";
+import { useDesktopCommands } from "@/lib/desktop/commands";
import { useConfig } from "@/hooks/use-config";
import { CalendarPrint, type CalendarPrintContent } from "@/components/calendar/calendar-print";
import { usePrintListener, usePrinting } from "@/components/print/print-root";
@@ -1667,6 +1669,28 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
{ mode: "agenda", key: "a" },
{ mode: "tasks", key: "k" },
];
+ // File > New event and New task in the desktop app's menus, from any app.
+ useAppCommands({
+ "new-event": () => openCreateModal(),
+ "new-task": () => { if (enableCalendarTasks) openCreateTaskModal(); },
+ }, true);
+ // Its View menu's Day, Week, Month and Agenda, and Edit > Search events.
+ const viewFromMenu = (mode: CalendarViewMode) => () => { setMobileReturnToMonth(false); leaveSearch(); setViewMode(mode); };
+ useDesktopCommands({
+ "view.calendar.day": viewFromMenu("day"),
+ "view.calendar.week": viewFromMenu("week"),
+ "view.calendar.month": viewFromMenu("month"),
+ "view.calendar.agenda": viewFromMenu("agenda"),
+ "edit.search": openSearch,
+ }, {
+ "view.calendar.day": { enabled: true, checked: normalizedViewMode === "day" },
+ "view.calendar.week": { enabled: true, checked: normalizedViewMode === "week" },
+ "view.calendar.month": { enabled: true, checked: normalizedViewMode === "month" },
+ "view.calendar.agenda": { enabled: true, checked: normalizedViewMode === "agenda" },
+ "edit.search": normalizedViewMode !== "tasks",
+ "file.newTask": enableCalendarTasks,
+ });
+
useRegisterCommands(tSidebar("calendar"), [
{ id: "new-event", label: t("events.new_event"), icon: Plus, shortcut: "n", run: () => openCreateModal() },
{ id: "new-task", label: t("events.new_task"), icon: ListTodo, run: openCreateTaskModal, when: () => enableCalendarTasks },
diff --git a/components/calendar/calendar-toolbar.tsx b/components/calendar/calendar-toolbar.tsx
index d1957b882..fe6471667 100644
--- a/components/calendar/calendar-toolbar.tsx
+++ b/components/calendar/calendar-toolbar.tsx
@@ -6,7 +6,7 @@ import { Button } from "@/components/ui/button";
import { IconButton } from "@/components/ui/icon-button";
import { ToolbarButton } from "@/components/ui/toolbar";
import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays, Globe, ChevronDown, ArrowLeft, Menu, MoreVertical, RefreshCw, Search, Printer } from "@/components/icons";
-import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
+import { ContextMenu, ContextMenuItem, ContextMenuSeparator, MenuShortcut } from "@/components/ui/context-menu";
import { useContextMenu } from "@/hooks/use-context-menu";
import { startOfWeek } from "date-fns";
import { PhoneHeaderActions } from "@/components/layout/phone-header-actions";
@@ -482,9 +482,9 @@ export function CalendarToolbar({
v === viewMode ? "bg-accent text-accent-foreground font-medium" : "hover:bg-muted text-foreground",
)}
>
- {t(`views.${v}`)}
+ {t(`views.${v}`)}
{/* The key, on devices with a keyboard (R8). */}
- {VIEW_SHORTCUTS[v]}
+
))}
diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx
index 94438b553..4532896ac 100644
--- a/components/calendar/event-modal.tsx
+++ b/components/calendar/event-modal.tsx
@@ -1,6 +1,7 @@
"use client";
import { useState, useEffect, useCallback, useRef, useMemo, useId } from "react";
+import { useCloseBesideControls } from "@/hooks/use-close-beside-controls";
import { useTranslations, useLocale } from "next-intl";
import { toast } from "@/stores/toast-store";
import { Button } from "@/components/ui/button";
@@ -278,6 +279,9 @@ export function EventModal({
const labelIdBase = useId();
const labelFor = (key: string) => `${labelIdBase}-${key}`;
const tCommon = useTranslations("common");
+ // The header's close goes to its start when the window's own close is at its end.
+ const [header, setHeader] = useState(null);
+ const closeFirst = useCloseBesideControls(header);
const locale = useLocale();
const timeFormat = useSettingsStore((s) => s.timeFormat);
const timeDisplayFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
@@ -1031,13 +1035,20 @@ export function EventModal({
const eventCalendar = calendars.find(c => event.calendarIds[c.id]);
const color = paint(getEventColor(event, eventCalendar));
+ const viewClose = (
+
+
+
+ );
+
return (
{/* Color accent bar */}
{/* Header */}
-
+
+ {closeFirst && viewClose}
@@ -1052,9 +1063,7 @@ export function EventModal({
)}
-
-
-
+ {!closeFirst && viewClose}
{/* Content */}
@@ -1245,15 +1254,20 @@ export function EventModal({
);
}
+ const editClose = (
+
+
+
+ );
+
return (
-
-
+
+ {closeFirst && editClose}
+
{isEdit ? t("events.edit") : t("events.create")}
-
-
-
+ {!closeFirst && editClose}
diff --git a/components/calendar/task-modal.tsx b/components/calendar/task-modal.tsx
index 75ea6439a..0c2d5a45c 100644
--- a/components/calendar/task-modal.tsx
+++ b/components/calendar/task-modal.tsx
@@ -1,6 +1,7 @@
"use client";
import { useState, useEffect, useCallback, useRef, useMemo, useId } from "react";
+import { useCloseBesideControls } from "@/hooks/use-close-beside-controls";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -61,6 +62,9 @@ export function TaskModal({
}: TaskModalProps) {
const t = useTranslations("calendar");
const tCommon = useTranslations("common");
+ // The header's close goes to its start when the window's own close is at its end.
+ const [header, setHeader] = useState
(null);
+ const closeFirst = useCloseBesideControls(header);
const id = useId();
const isEdit = !!task;
const titleRef = useRef(null);
@@ -212,6 +216,12 @@ export function TaskModal({
}
}, [requestClose, handleSave]);
+ const close = (
+
+
+
+ );
+
return (
// The desktop side panel is a dialog named by its title, like the event
// editor; on a phone the full-screen wrapper around it is.
@@ -222,13 +232,12 @@ export function TaskModal({
onKeyDown={handleKeyDown}
>
{/* Header */}
-
-
+
+ {closeFirst && close}
+
{isEdit ? t("tasks.edit") : t("tasks.create")}
-
-
-
+ {!closeFirst && close}
{/* Body */}
diff --git a/components/command-palette/__tests__/command-palette.test.tsx b/components/command-palette/__tests__/command-palette.test.tsx
index 2bd4fae1e..927e2b174 100644
--- a/components/command-palette/__tests__/command-palette.test.tsx
+++ b/components/command-palette/__tests__/command-palette.test.tsx
@@ -119,6 +119,41 @@ describe('command palette', () => {
expect(hint('search')).toBe('');
});
+ it('shows no single-key hints while single-key shortcuts are off, since those keys do nothing then', async () => {
+ const { useSettingsStore } = await import('@/stores/settings-store');
+ const hint = (label: string) => {
+ const row = screen.getAllByRole('option').find((o) => o.querySelector('.flex-1')?.textContent === label)!;
+ return [...row.querySelectorAll('kbd')].map((k) => k.textContent).join(' ');
+ };
+ useSettingsStore.setState({ singleKeyShortcuts: false });
+ try {
+ render();
+ pressCtrlK();
+ expect(hint('compose')).toBe('');
+ expect(hint('shortcuts')).toBe('');
+ } finally {
+ useSettingsStore.setState({ singleKeyShortcuts: true });
+ }
+ });
+
+ it('names the account Sign out leaves, as the account menu does', async () => {
+ const { useAccountStore } = await import('@/stores/account-store');
+ const before = useAccountStore.getState().accounts;
+ useAccountStore.setState({ accounts: [
+ { id: 'a1', email: 'one@example.org', username: 'one@example.org' },
+ { id: 'a2', email: 'two@example.org', username: 'two@example.org' },
+ ] } as never);
+ useAuthStore.setState({ activeAccountId: 'a2' } as never);
+ try {
+ render();
+ pressCtrlK();
+ expect(screen.getByText('sign_out_of(two@example.org)')).toBeInTheDocument();
+ expect(screen.queryByText('sign_out')).toBeNull();
+ } finally {
+ useAccountStore.setState({ accounts: before } as never);
+ }
+ });
+
it('shows plugin entries with the plugin name and runs them with Enter', async () => {
act(() => setPluginActions('jira', 'Jira', 'command-palette', [{ id: 'new-issue', label: 'Create issue' }]));
render();
diff --git a/components/command-palette/__tests__/mail-palette-commands.test.ts b/components/command-palette/__tests__/mail-palette-commands.test.ts
new file mode 100644
index 000000000..247954e94
--- /dev/null
+++ b/components/command-palette/__tests__/mail-palette-commands.test.ts
@@ -0,0 +1,28 @@
+import { readFileSync } from 'node:fs';
+import path from 'node:path';
+import { describe, expect, it } from 'vitest';
+
+// Mail registers its palette commands in mail-app.tsx, which is too big to render
+// here. Each message action with a key in the shortcuts help is in the palette too,
+// under the key the help names.
+const source = readFileSync(path.join(process.cwd(), 'components/mail/mail-app.tsx'), 'utf8');
+const block = source.slice(source.indexOf("useRegisterCommands(t('sidebar.mail')"));
+
+describe('Mail commands in the palette', () => {
+ it.each([
+ ['reply', 'r'],
+ ['reply-all', 'a'],
+ ['forward', 'f'],
+ ['star', 's'],
+ ['archive', 'e'],
+ ['delete', '#'],
+ ['mark-unread', 'u'],
+ ['mark-read', 'Shift + I'],
+ ['spam', '!'],
+ ])('has %s, shown with %s', (id, key) => {
+ const at = block.indexOf(`id: '${id}'`);
+ expect(at).toBeGreaterThan(-1);
+ const entry = block.slice(at, block.indexOf('}', at));
+ expect(entry).toContain(`shortcut: '${key}'`);
+ });
+});
diff --git a/components/command-palette/command-palette.tsx b/components/command-palette/command-palette.tsx
index 058d4a448..8169769bd 100644
--- a/components/command-palette/command-palette.tsx
+++ b/components/command-palette/command-palette.tsx
@@ -27,6 +27,7 @@ import { useGlobalSearchStore } from "@/stores/global-search-store";
import { useCalendarStore } from "@/stores/calendar-store";
import { usePolicyStore } from "@/stores/policy-store";
import { useThemeStore } from "@/stores/theme-store";
+import { useSettingsStore } from "@/stores/settings-store";
import { usePluginActions } from "@/components/plugins/plugin-actions";
import { usePluginPages, pluginPageHref } from "@/hooks/use-plugin-pages";
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
@@ -38,7 +39,7 @@ import { commandMatches, useRegisteredCommandGroups } from "@/lib/command-regist
import { shortcutAlternatives } from "@/lib/shortcut-keys";
import { toUnicodeEmail } from "@/lib/idn";
import { isEditableEventTarget, isKeyboardLayerOpen } from "@/lib/keyboard";
-import { isSingleKeyShortcutOff } from "@/lib/single-key-shortcuts";
+import { isSingleKeyHint, isSingleKeyShortcutOff } from "@/lib/single-key-shortcuts";
import { useFocusTrap } from "@/hooks/use-focus-trap";
import { useMediaQuery } from "@/hooks/use-media-query";
import { IconButton } from "@/components/ui/icon-button";
@@ -162,6 +163,9 @@ function CommandPalette({ onClose, onShowShortcuts }: { onClose: () => void; onS
const activeAccountId = useAuthStore((s) => s.activeAccountId);
// A touch screen has no keyboard to use the shortcuts with (R8).
const touch = useMediaQuery("(pointer: coarse)");
+ // With single-key shortcuts off, "r" or "#" does nothing: no hint for it.
+ const singleKeys = useSettingsStore((s) => s.singleKeyShortcuts !== false);
+ const hintKeys = (shortcut: string) => shortcutAlternatives(shortcut).filter((key) => singleKeys || !isSingleKeyHint(key));
const inputRef = useRef(null);
const dialogRef = useFocusTrap({ isActive: true, onEscape: onClose, restoreFocus: true, initialFocus: () => inputRef.current });
@@ -255,7 +259,16 @@ function CommandPalette({ onClose, onShowShortcuts }: { onClose: () => void; onS
run: () => { void useAuthStore.getState().switchAccount(entry.id); },
});
}
- list.push({ id: "sign-out", group: account, icon: LogOut, label: tSidebar("sign_out"), run: () => { void useAuthStore.getState().logout(); } });
+ // It signs out of the open account only, as the account menu's entry says.
+ const current = accounts.find((entry) => entry.id === activeAccountId);
+ const currentName = current ? toUnicodeEmail(current.email || current.username) : "";
+ list.push({
+ id: "sign-out",
+ group: account,
+ icon: LogOut,
+ label: currentName ? tSidebar("sign_out_of", { account: currentName }) : tSidebar("sign_out"),
+ run: () => { void useAuthStore.getState().logout(); },
+ });
for (const action of pluginActions) {
list.push({
id: `plugin:${action.pluginId}:${action.id}`,
@@ -394,10 +407,10 @@ function CommandPalette({ onClose, onShowShortcuts }: { onClose: () => void; onS
{command.source && (
{command.source}
)}
- {command.shortcut && (
+ {command.shortcut && hintKeys(command.shortcut).length > 0 && (
// Key hints only where there is a keyboard (R8).
- {shortcutAlternatives(command.shortcut).map((key) => (
+ {hintKeys(command.shortcut).map((key) => (
{key}
))}
diff --git a/components/contacts/__tests__/contacts-app.test.tsx b/components/contacts/__tests__/contacts-app.test.tsx
index cd153f9e8..802c8f994 100644
--- a/components/contacts/__tests__/contacts-app.test.tsx
+++ b/components/contacts/__tests__/contacts-app.test.tsx
@@ -182,7 +182,7 @@ describe('ContactsApp', () => {
it('starts a new group with the contact when "Add to group" finds no group to add to', () => {
renderContacts();
fireEvent.contextMenu(screen.getByText('Alice Smith'));
- fireEvent.click(screen.getByText('context_menu.add_to_group'));
+ fireEvent.click(screen.getByText('contacts_add_to_group'));
expect(screen.getByText('groups.members_label (1)')).toBeInTheDocument();
});
@@ -190,11 +190,27 @@ describe('ContactsApp', () => {
const team: ContactCard = { id: 'g-team', kind: 'group', addressBookIds: {}, name: { full: 'Team' }, members: {} };
renderContacts([alice, bob, team]);
fireEvent.contextMenu(screen.getByText('Bob Jones'));
- fireEvent.click(screen.getByText('context_menu.add_to_group'));
+ fireEvent.click(screen.getByText('contacts_add_to_group'));
fireEvent.click(screen.getByText('bulk.new_group'));
expect(screen.getByText('groups.members_label (1)')).toBeInTheDocument();
});
+ it('offers no Edit, Move or Delete for a contact in an address book shared read-only', () => {
+ const shared = { ...alice, addressBookIds: { 'ab-shared': true } };
+ renderContacts([shared, bob]);
+ act(() => {
+ useContactStore.setState({ addressBooks: [{ id: 'ab-shared', name: 'Team', myRights: { mayRead: true, mayWrite: false } }] as never });
+ });
+ fireEvent.contextMenu(screen.getByText('Alice Smith'));
+ expect(screen.queryByRole('menuitem', { name: 'context_menu.edit' })).toBeNull();
+ expect(screen.queryByRole('menuitem', { name: /context_menu.delete/ })).toBeNull();
+ expect(screen.getByRole('menuitem', { name: 'context_menu.export_vcard' })).toBeInTheDocument();
+ fireEvent.keyDown(document.activeElement ?? document.body, { key: 'Escape' });
+
+ fireEvent.contextMenu(screen.getByText('Bob Jones'));
+ expect(screen.getByRole('menuitem', { name: 'context_menu.edit' })).toBeInTheDocument();
+ });
+
it('imports in a real dialog that Escape closes', () => {
renderContacts();
fireEvent.click(screen.getAllByRole('button', { name: 'open_categories' })[0]);
diff --git a/components/contacts/contact-context-menu.tsx b/components/contacts/contact-context-menu.tsx
index 9b6eea288..6281846ca 100644
--- a/components/contacts/contact-context-menu.tsx
+++ b/components/contacts/contact-context-menu.tsx
@@ -65,6 +65,8 @@ interface ContactContextMenuProps {
onBatchExport?: () => void;
onBatchAddToGroup?: () => void;
onBatchDelete?: () => void;
+ /** The contact is in an address book shared read-only: no Edit, Move or Delete. */
+ readOnly?: boolean;
}
export function ContactContextMenu({
@@ -88,8 +90,10 @@ export function ContactContextMenu({
onBatchExport,
onBatchAddToGroup,
onBatchDelete,
+ readOnly = false,
}: ContactContextMenuProps) {
const t = useTranslations("contacts");
+ const tMenu = useTranslations("menuAudit");
const printOff = useStrictestDevices().noPrint;
const email = getContactPrimaryEmail(contact);
const phone = getContactPrimaryPhone(contact);
@@ -134,7 +138,7 @@ export function ContactContextMenu({
onBatchAddToGroup?.())}
disabled={!onBatchAddToGroup}
/>
@@ -179,12 +183,12 @@ export function ContactContextMenu({
onClick={handle(handleCall)}
/>
)}
- {onEdit && }
+ {onEdit && !readOnly && }
{!inDetail && }
{onAddToGroup && (
)}
@@ -195,7 +199,7 @@ export function ContactContextMenu({
onClick={handle(onDuplicate)}
/>
)}
- {onMoveTo && (
+ {onMoveTo && !readOnly && (
)}
{email && (
@@ -225,13 +229,18 @@ export function ContactContextMenu({
onClick={handle(handlePrint)}
/>
)}
-
-
+ {!readOnly && (
+ <>
+
+
+ >
+ )}
);
}
diff --git a/components/contacts/contact-detail.tsx b/components/contacts/contact-detail.tsx
index 77edb2cdc..66a3969e2 100644
--- a/components/contacts/contact-detail.tsx
+++ b/components/contacts/contact-detail.tsx
@@ -35,6 +35,8 @@ interface ContactDetailProps {
onPrint?: () => void;
isMobile?: boolean;
className?: string;
+ /** In an address book shared read-only: no Edit, Move or Delete. */
+ readOnly?: boolean;
}
// Contexts and phone features the contact form offers, shown with the same
@@ -74,7 +76,7 @@ function getCompletedYears(dateInput: AnniversaryDate): number | null {
}
-export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDuplicate, onMoveTo, onCompose, onPrint, isMobile, className }: ContactDetailProps) {
+export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDuplicate, onMoveTo, onCompose, onPrint, isMobile, className, readOnly = false }: ContactDetailProps) {
const t = useTranslations("contacts");
const tCommon = useTranslations("common");
const locale = useLocale();
@@ -173,7 +175,7 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
)}
{/* A phone's row has no room for this label: the ⋮ fell onto a line of its own. */}
- {isMobile ? (
+ {readOnly ? null : isMobile ? (
@@ -207,6 +209,7 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
onClose={moreMenu.closeContextMenu}
menuRef={moreMenu.menuRef}
inDetail
+ readOnly={readOnly}
onCompose={onCompose}
onDelete={onDelete}
onAddToGroup={onAddToGroup}
diff --git a/components/contacts/contact-form.tsx b/components/contacts/contact-form.tsx
index 2191820e5..fac3205b0 100644
--- a/components/contacts/contact-form.tsx
+++ b/components/contacts/contact-form.tsx
@@ -1,6 +1,7 @@
"use client";
import { useState, useMemo, useCallback, useRef, useEffect, useId } from "react";
+import { useCloseBesideControls } from "@/hooks/use-close-beside-controls";
import { useTranslations } from "next-intl";
import { X, Plus, ChevronDown, ChevronRight, User, Building, MapPin, Globe, Cake, Heart, Tag, StickyNote, Mail, Phone, Calendar, UserCircle, Book, Camera, Trash2 } from "@/components/icons";
import { Button } from "@/components/ui/button";
@@ -220,6 +221,9 @@ function Select({ value, onChange, children, className, id, "aria-label": ariaLa
export function ContactForm({ contact, addressBooks, allKeywords, defaultAddressBookId, prefill, onSave, onCancel, loadCurrentMedia, onDirtyChange }: ContactFormProps) {
const t = useTranslations("contacts.form");
const isEditing = !!contact;
+ // The header's close goes to its start when the window's own close is at its end.
+ const [header, setHeader] = useState(null);
+ const closeFirst = useCloseBesideControls(header);
// Ids that tie each visible label to its field.
const fieldIdBase = useId();
const fieldId = (key: string) => `${fieldIdBase}-${key}`;
@@ -700,17 +704,22 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
const previewName = (isOrg ? "" : [givenName, surname].filter(Boolean).join(" ").trim()) || organization.trim();
const previewEmail = emails.find(e => e.address.trim())?.address.trim() || "";
+ const close = (
+
+
+
+ );
+
return (
// The app checks the fields itself and says what is wrong inline; the
// browser's own bubble covered that message.