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,
|
||||
onClose,
|
||||
thread,
|
||||
single = false,
|
||||
}: {
|
||||
tab: PanelTab;
|
||||
onTab: (t: PanelTab) => void;
|
||||
onClose: () => void;
|
||||
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();
|
||||
// "Invite all to new event" (People tab) opens the SHARED calendar event form in
|
||||
@@ -84,7 +88,12 @@ export function RightPanel({
|
||||
return (
|
||||
<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">
|
||||
{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
|
||||
key={id}
|
||||
onClick={() => onTab(id)}
|
||||
@@ -105,7 +114,15 @@ export function RightPanel({
|
||||
</div>
|
||||
{tab !== "calendar" && (
|
||||
<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} />}
|
||||
</div>
|
||||
)}
|
||||
@@ -171,10 +188,18 @@ function MenuItem({ checked, onClick, children }: { checked?: boolean; onClick:
|
||||
// People tab
|
||||
// ===========================================================================
|
||||
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 navigate = useNavigate();
|
||||
const [source, setSource] = useState<PeopleSource>("conversation");
|
||||
const [source, setSource] = useState<PeopleSource>(initialSource);
|
||||
const [srcMenu, setSrcMenu] = useState(false);
|
||||
const [moreMenu, setMoreMenu] = useState(false);
|
||||
const [openEmail, setOpenEmail] = useState<string | null>(null);
|
||||
@@ -237,7 +262,8 @@ function PeopleTab({ thread, onInviteAll }: { thread: ThreadDetail | undefined;
|
||||
);
|
||||
|
||||
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")],
|
||||
["recent", t("panel.recent")],
|
||||
["vip", t("panel.vips")],
|
||||
|
||||
@@ -143,6 +143,7 @@ export const en = {
|
||||
"panel.toggle": "Toggle side panel",
|
||||
"panel.calendar": "Calendar",
|
||||
"panel.people": "People",
|
||||
"panel.contacts": "Contacts",
|
||||
"panel.attachments": "Attachments",
|
||||
"panel.search": "Search",
|
||||
"panel.newEvent": "New event",
|
||||
@@ -387,6 +388,31 @@ export const en = {
|
||||
"settings.secret": "Secret",
|
||||
"settings.confirm": "Confirm",
|
||||
"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.revoke": "Revoke",
|
||||
"settings.threadOrder": "Conversation order",
|
||||
|
||||
@@ -50,6 +50,7 @@ import { useIsMobile } from "../lib/useIsMobile";
|
||||
import { useKeyboardShortcuts } from "../lib/useKeyboardShortcuts";
|
||||
import { subscribeWs } from "../lib/ws";
|
||||
import { PluginSlot } from "../plugins/PluginSlot";
|
||||
import { usePluginPages } from "../plugins/manifest";
|
||||
|
||||
function quotaLabel(q: QuotaOut): string {
|
||||
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.
|
||||
const [sheetOpen, setSheetOpen] = 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 = () => {
|
||||
setSheetSingle(false);
|
||||
setSheetUsed(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]);
|
||||
|
||||
const isDrafts = view === "folder:drafts";
|
||||
@@ -1085,6 +1097,31 @@ export function MailLayout() {
|
||||
</nav>
|
||||
{mobile && (
|
||||
<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)} />
|
||||
</div>
|
||||
)}
|
||||
@@ -1670,7 +1707,13 @@ export function MailLayout() {
|
||||
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>
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user