Phone menu: Calendar and Contacts slide in from the right
The phone's left menu gets Calendar and Contacts (above the divider, when the plugins are installed). Each opens the desktop's side panel on its own, as a sheet from the right: the calendar panel (mini month, agenda, new event), or the people panel starting on All contacts — a title instead of the tabs, and no "In conversation" source without a conversation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -65,11 +65,15 @@ export function RightPanel({
|
|||||||
onTab,
|
onTab,
|
||||||
onClose,
|
onClose,
|
||||||
thread,
|
thread,
|
||||||
|
single = false,
|
||||||
}: {
|
}: {
|
||||||
tab: PanelTab;
|
tab: PanelTab;
|
||||||
onTab: (t: PanelTab) => void;
|
onTab: (t: PanelTab) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
thread: ThreadDetail | undefined;
|
thread: ThreadDetail | undefined;
|
||||||
|
/** Opened as Calendar or Contacts on its own (the phone's menu): a title
|
||||||
|
* instead of the tabs, and Contacts starts on all contacts. */
|
||||||
|
single?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
// "Invite all to new event" (People tab) opens the SHARED calendar event form in
|
// "Invite all to new event" (People tab) opens the SHARED calendar event form in
|
||||||
@@ -84,7 +88,12 @@ export function RightPanel({
|
|||||||
return (
|
return (
|
||||||
<aside className="flex h-full min-h-0 flex-col bg-app-surface">
|
<aside className="flex h-full min-h-0 flex-col bg-app-surface">
|
||||||
<div className="flex h-[52px] shrink-0 items-center gap-4 border-b border-app-border px-4">
|
<div className="flex h-[52px] shrink-0 items-center gap-4 border-b border-app-border px-4">
|
||||||
{TABS.map(([id, label]) => (
|
{single && (
|
||||||
|
<h2 className="text-[17px] font-semibold">
|
||||||
|
{tab === "people" ? t("panel.contacts") : t("panel.calendar")}
|
||||||
|
</h2>
|
||||||
|
)}
|
||||||
|
{!single && TABS.map(([id, label]) => (
|
||||||
<button
|
<button
|
||||||
key={id}
|
key={id}
|
||||||
onClick={() => onTab(id)}
|
onClick={() => onTab(id)}
|
||||||
@@ -105,7 +114,15 @@ export function RightPanel({
|
|||||||
</div>
|
</div>
|
||||||
{tab !== "calendar" && (
|
{tab !== "calendar" && (
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||||
{tab === "people" && <PeopleTab thread={thread} onInviteAll={inviteAll} />}
|
{tab === "people" && (
|
||||||
|
<PeopleTab
|
||||||
|
// A fresh list when switching between a conversation's people and Contacts.
|
||||||
|
key={single ? "contacts" : "conversation"}
|
||||||
|
thread={single ? undefined : thread}
|
||||||
|
initialSource={single || !thread ? "all" : "conversation"}
|
||||||
|
onInviteAll={inviteAll}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{tab === "attachments" && <AttachmentsTab thread={thread} />}
|
{tab === "attachments" && <AttachmentsTab thread={thread} />}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -171,10 +188,18 @@ function MenuItem({ checked, onClick, children }: { checked?: boolean; onClick:
|
|||||||
// People tab
|
// People tab
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
type PeopleSource = "conversation" | "all" | "recent" | "vip" | "autosaved";
|
type PeopleSource = "conversation" | "all" | "recent" | "vip" | "autosaved";
|
||||||
function PeopleTab({ thread, onInviteAll }: { thread: ThreadDetail | undefined; onInviteAll: (emails: string[]) => void }) {
|
function PeopleTab({
|
||||||
|
thread,
|
||||||
|
onInviteAll,
|
||||||
|
initialSource = "conversation",
|
||||||
|
}: {
|
||||||
|
thread: ThreadDetail | undefined;
|
||||||
|
onInviteAll: (emails: string[]) => void;
|
||||||
|
initialSource?: PeopleSource;
|
||||||
|
}) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [source, setSource] = useState<PeopleSource>("conversation");
|
const [source, setSource] = useState<PeopleSource>(initialSource);
|
||||||
const [srcMenu, setSrcMenu] = useState(false);
|
const [srcMenu, setSrcMenu] = useState(false);
|
||||||
const [moreMenu, setMoreMenu] = useState(false);
|
const [moreMenu, setMoreMenu] = useState(false);
|
||||||
const [openEmail, setOpenEmail] = useState<string | null>(null);
|
const [openEmail, setOpenEmail] = useState<string | null>(null);
|
||||||
@@ -237,7 +262,8 @@ function PeopleTab({ thread, onInviteAll }: { thread: ThreadDetail | undefined;
|
|||||||
);
|
);
|
||||||
|
|
||||||
const SOURCES: [PeopleSource, string][] = [
|
const SOURCES: [PeopleSource, string][] = [
|
||||||
["conversation", t("panel.inConversation")],
|
// Only with a conversation to take people from (not as Contacts on its own).
|
||||||
|
...(thread ? ([["conversation", t("panel.inConversation")]] as [PeopleSource, string][]) : []),
|
||||||
["all", t("panel.allContacts")],
|
["all", t("panel.allContacts")],
|
||||||
["recent", t("panel.recent")],
|
["recent", t("panel.recent")],
|
||||||
["vip", t("panel.vips")],
|
["vip", t("panel.vips")],
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ export const en = {
|
|||||||
"panel.toggle": "Toggle side panel",
|
"panel.toggle": "Toggle side panel",
|
||||||
"panel.calendar": "Calendar",
|
"panel.calendar": "Calendar",
|
||||||
"panel.people": "People",
|
"panel.people": "People",
|
||||||
|
"panel.contacts": "Contacts",
|
||||||
"panel.attachments": "Attachments",
|
"panel.attachments": "Attachments",
|
||||||
"panel.search": "Search",
|
"panel.search": "Search",
|
||||||
"panel.newEvent": "New event",
|
"panel.newEvent": "New event",
|
||||||
@@ -387,6 +388,31 @@ export const en = {
|
|||||||
"settings.secret": "Secret",
|
"settings.secret": "Secret",
|
||||||
"settings.confirm": "Confirm",
|
"settings.confirm": "Confirm",
|
||||||
"settings.sessions": "Active sessions",
|
"settings.sessions": "Active sessions",
|
||||||
|
"appPasswords.title": "App passwords",
|
||||||
|
"appPasswords.help":
|
||||||
|
"Calendar and contacts apps — your phone's, Thunderbird, macOS — sign in to Ultimo with an app password, never your account password. Give each app its own, so you can revoke one without affecting the others.",
|
||||||
|
"appPasswords.connectTitle": "Calendars and contacts in other apps",
|
||||||
|
"appPasswords.connectServer": "Server",
|
||||||
|
"appPasswords.connectUser": "User name",
|
||||||
|
"appPasswords.connectPassword": "Password",
|
||||||
|
"appPasswords.connectPasswordValue": "an app password (create one below)",
|
||||||
|
"appPasswords.connectHint":
|
||||||
|
"iPhone/iPad: Settings → Apps → Calendar (or Contacts) → Calendar Accounts → Add Account → Other → Add CalDAV (CardDAV) Account. Android: the DAVx⁵ app. Thunderbird: New Calendar → On the Network, and New Address Book → CardDAV.",
|
||||||
|
"appPasswords.new": "Create app password",
|
||||||
|
"appPasswords.name": "Name",
|
||||||
|
"appPasswords.namePlaceholder": "e.g. Thunderbird on my laptop",
|
||||||
|
"appPasswords.confirmPassword": "Your account password",
|
||||||
|
"appPasswords.create": "Create",
|
||||||
|
"appPasswords.madeFor": "App password for “{name}”:",
|
||||||
|
"appPasswords.copy": "Copy",
|
||||||
|
"appPasswords.copied": "Copied",
|
||||||
|
"appPasswords.onceOnly":
|
||||||
|
"Enter it in the app's password field, without spaces. You won't see it again — if you lose it, revoke it and create a new one.",
|
||||||
|
"appPasswords.done": "Done",
|
||||||
|
"appPasswords.created": "Created {date}",
|
||||||
|
"appPasswords.lastUsed": "last used {date}",
|
||||||
|
"appPasswords.neverUsed": "never used",
|
||||||
|
"appPasswords.confirmRevoke": "Revoke the app password “{name}”? The app using it will stop signing in.",
|
||||||
"settings.thisDevice": "this device",
|
"settings.thisDevice": "this device",
|
||||||
"settings.revoke": "Revoke",
|
"settings.revoke": "Revoke",
|
||||||
"settings.threadOrder": "Conversation order",
|
"settings.threadOrder": "Conversation order",
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ import { useIsMobile } from "../lib/useIsMobile";
|
|||||||
import { useKeyboardShortcuts } from "../lib/useKeyboardShortcuts";
|
import { useKeyboardShortcuts } from "../lib/useKeyboardShortcuts";
|
||||||
import { subscribeWs } from "../lib/ws";
|
import { subscribeWs } from "../lib/ws";
|
||||||
import { PluginSlot } from "../plugins/PluginSlot";
|
import { PluginSlot } from "../plugins/PluginSlot";
|
||||||
|
import { usePluginPages } from "../plugins/manifest";
|
||||||
|
|
||||||
function quotaLabel(q: QuotaOut): string {
|
function quotaLabel(q: QuotaOut): string {
|
||||||
const pct = q.quota_bytes > 0 ? (q.used_bytes / q.quota_bytes) * 100 : 0;
|
const pct = q.quota_bytes > 0 ? (q.used_bytes / q.quota_bytes) * 100 : 0;
|
||||||
@@ -225,10 +226,21 @@ export function MailLayout() {
|
|||||||
// then kept so it can slide out again.
|
// then kept so it can slide out again.
|
||||||
const [sheetOpen, setSheetOpen] = useState(false);
|
const [sheetOpen, setSheetOpen] = useState(false);
|
||||||
const [sheetUsed, setSheetUsed] = useState(false);
|
const [sheetUsed, setSheetUsed] = useState(false);
|
||||||
|
// Opened from the phone's menu as Calendar or Contacts on its own (no tabs).
|
||||||
|
const [sheetSingle, setSheetSingle] = useState(false);
|
||||||
const openSheet = () => {
|
const openSheet = () => {
|
||||||
|
setSheetSingle(false);
|
||||||
setSheetUsed(true);
|
setSheetUsed(true);
|
||||||
setSheetOpen(true);
|
setSheetOpen(true);
|
||||||
};
|
};
|
||||||
|
const openFromMenu = (tab: PanelTab) => {
|
||||||
|
setDrawerOpen(false);
|
||||||
|
setRightTab(tab);
|
||||||
|
setSheetSingle(true);
|
||||||
|
setSheetUsed(true);
|
||||||
|
setSheetOpen(true);
|
||||||
|
};
|
||||||
|
const pluginNames = new Set(usePluginPages().map((p) => p.plugin));
|
||||||
useEffect(() => localStorage.setItem("mail.listOrder", listOrder), [listOrder]);
|
useEffect(() => localStorage.setItem("mail.listOrder", listOrder), [listOrder]);
|
||||||
|
|
||||||
const isDrafts = view === "folder:drafts";
|
const isDrafts = view === "folder:drafts";
|
||||||
@@ -1085,6 +1097,31 @@ export function MailLayout() {
|
|||||||
</nav>
|
</nav>
|
||||||
{mobile && (
|
{mobile && (
|
||||||
<div className="p-2 text-[15px]">
|
<div className="p-2 text-[15px]">
|
||||||
|
{/* Calendar and Contacts slide in from the right (the desktop's side panel). */}
|
||||||
|
{pluginNames.has("calendar") && (
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-left text-app-muted hover:bg-app-hover hover:text-app-fg"
|
||||||
|
onClick={() => openFromMenu("calendar")}
|
||||||
|
>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||||
|
<rect x="3.5" y="5" width="17" height="15.5" rx="2.5" />
|
||||||
|
<path d="M3.5 10h17M8 3v4M16 3v4" />
|
||||||
|
</svg>
|
||||||
|
{t("panel.calendar")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{pluginNames.has("contacts") && (
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-left text-app-muted hover:bg-app-hover hover:text-app-fg"
|
||||||
|
onClick={() => openFromMenu("people")}
|
||||||
|
>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||||
|
<circle cx="12" cy="8.5" r="3.75" />
|
||||||
|
<path d="M4.5 20c.8-3.7 3.9-5.75 7.5-5.75S18.7 16.3 19.5 20" />
|
||||||
|
</svg>
|
||||||
|
{t("panel.contacts")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<InstallAppEntry onDone={() => setDrawerOpen(false)} />
|
<InstallAppEntry onDone={() => setDrawerOpen(false)} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1670,7 +1707,13 @@ export function MailLayout() {
|
|||||||
sheetOpen ? "translate-x-0" : "pointer-events-none translate-x-full"
|
sheetOpen ? "translate-x-0" : "pointer-events-none translate-x-full"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<RightPanel tab={rightTab} onTab={setRightTab} onClose={() => setSheetOpen(false)} thread={detailQuery.data} />
|
<RightPanel
|
||||||
|
tab={rightTab}
|
||||||
|
onTab={setRightTab}
|
||||||
|
onClose={() => setSheetOpen(false)}
|
||||||
|
thread={detailQuery.data}
|
||||||
|
single={sheetSingle}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user