Event invitations in Mail, video previews, People tab, matching avatars

Invitations received by email (iMIP): above the message body, a card with
when, what, where (or "Join video call"), the organizer, what else is in the
calendar then (a conflict, else the previous event that day), and Yes / No /
Maybe. Yes and Maybe put the event in the calendar (or update it), No takes
it out — and is still shown as the answer given; each sends the organizer
the iTIP REPLY, then a "Reply sent" toast, and an open calendar refreshes.
A cancellation offers "Remove from calendar". It is the calendar plugin's
message.banner slot (ui/invite.js, imip.py, /invitations endpoints); the SDK
gains the user's own accounts and settings. An Outlook-style invitation sent
only as text/calendar is now kept as invite.ics too.

Calendar: clicking an empty day in the side panel's agenda opens a new event
on that date; a video-call link can be removed (✕ beside Copy); the page
guests see after answering, and our invitations' footer, say Ultimo Calendar
(with the logo).

Attachments: videos (mp4, mov, webm, …) play in the full-screen preview,
paused until played; attachments are now served from disk with HTTP Range,
so a video starts at once and can be seeked.

People tab: everyone in the thread, the user included, A–Z, under the name
any message gives them. Initials circles share one colour rule in the list,
the reading pane and the People tab. The contact view drops its duplicate
"Message" button. A general corner toast (webmail:toast) joins Undo's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 21:50:29 +02:00
co-authored by Claude Opus 5.5
parent fba3c947bf
commit 2337375bb7
24 changed files with 1443 additions and 100 deletions
+44
View File
@@ -0,0 +1,44 @@
import { useEffect, useState } from "react";
// A short confirmation in the corner — the Undo-send toast's look, without the
// button. Raised by anything (core or a plugin bundle) with
// window.dispatchEvent(new CustomEvent("webmail:toast", { detail: { text } }))
// e.g. "Reply sent" after answering an invitation. Gone after a few seconds.
const SHOW_MS = 3500;
export function Toast({ mobile }: { mobile: boolean }) {
const [toast, setToast] = useState<{ text: string; seq: number } | null>(null);
useEffect(() => {
let timer: number | undefined;
let seq = 0;
const onToast = (e: Event) => {
const text = (e as CustomEvent<{ text?: string }>).detail?.text;
if (!text) return;
setToast({ text, seq: ++seq });
window.clearTimeout(timer);
timer = window.setTimeout(() => setToast(null), SHOW_MS);
};
window.addEventListener("webmail:toast", onToast);
return () => {
window.removeEventListener("webmail:toast", onToast);
window.clearTimeout(timer);
};
}, []);
if (!toast) return null;
return (
<div
key={toast.seq}
role="status"
className={
mobile
? // Level with the Compose button on the other side of the screen.
"fixed bottom-[calc(4.375rem+env(safe-area-inset-bottom))] left-4 z-40 flex h-11 items-center rounded-xl bg-app-fg px-4 text-[15px] font-medium text-app-surface shadow-lg"
: "fixed bottom-4 right-4 z-50 flex w-80 items-center rounded-lg bg-app-fg px-4 py-2.5 text-sm text-app-surface shadow-lg"
}
>
{toast.text}
</div>
);
}
+2 -7
View File
@@ -5,6 +5,7 @@ import { useEffect, useRef } from "react";
import { useT } from "../../i18n";
import { api } from "../../lib/api";
import { useListDividers } from "../../lib/listDividers";
import { avatarColor } from "../../lib/avatarColor";
import { useSavedThreads } from "../../lib/offline";
import { formatClock, getDateLocale } from "../../lib/timeFormat";
import type { Address, ThreadSummary } from "../../lib/types";
@@ -22,12 +23,6 @@ function timeLabel(iso: string): string {
}
// Stable per-sender hue so a sender's avatar keeps a consistent colour.
function hue(key: string): number {
let h = 0;
for (let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) % 360;
return h;
}
function initials(p: Address | undefined): string {
const name = (p?.name || p?.email || "?").trim();
const words = name.split(/\s+/).filter(Boolean);
@@ -187,7 +182,7 @@ export function MessageList({
// The badge still counts everyone involved — the name is narrowed, the
// "how many people are in this" signal is not.
const extra = Math.max(0, thread.participants.length - 1);
const avatarBg = `hsl(${hue(primary?.email || primary?.name || "?")} 45% 45%)`;
const avatarBg = avatarColor(primary?.email, primary?.name, accounts.data);
const stop = (fn: () => void) => (e: React.MouseEvent) => {
e.stopPropagation();
fn();
+37 -25
View File
@@ -9,8 +9,9 @@ import { lazy, Suspense, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useT } from "../../i18n";
import { avatarColor } from "../../lib/avatarColor";
import { api, apiFetch } from "../../lib/api";
import type { Address, AttachmentOut, MessageEnvelope, ThreadDetail } from "../../lib/types";
import type { AttachmentOut, MessageEnvelope, ThreadDetail } from "../../lib/types";
import { AvatarImage } from "../SenderAvatar";
import { useIsMobile } from "../../lib/useIsMobile";
import { loadBundle } from "../../plugins/bundleLoader";
@@ -27,22 +28,17 @@ const AttachmentPreview = lazy(() =>
export type PanelTab = "calendar" | "people" | "attachments";
// ---- small helpers ---------------------------------------------------------
const AV_COLORS = ["#ef4444", "#f97316", "#eab308", "#22c55e", "#14b8a6", "#3b82f6", "#8b5cf6", "#ec4899", "#6b7280"];
function avatarColor(key: string) {
let h = 0;
for (const c of key || "?") h = (h * 31 + c.charCodeAt(0)) >>> 0;
return AV_COLORS[h % AV_COLORS.length];
}
function initials(name: string) {
const p = (name || "").trim().split(/\s+/).filter(Boolean);
if (!p.length) return "?";
return ((p[0][0] || "") + (p.length > 1 ? p[p.length - 1][0] : "")).toUpperCase();
}
function Avatar({ name, email, size = 36 }: { name: string; email: string; size?: number }) {
const accounts = useQuery({ queryKey: ["accounts"], queryFn: api.accounts });
return (
<span
className="relative inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full font-semibold text-white"
style={{ width: size, height: size, background: avatarColor(email || name), fontSize: Math.round(size * 0.38) }}
style={{ width: size, height: size, background: avatarColor(email, name, accounts.data), fontSize: Math.round(size * 0.38) }}
>
{initials(name || email)}
<AvatarImage email={email} />
@@ -184,14 +180,23 @@ function PeopleTab({ thread, onInviteAll }: { thread: ThreadDetail | undefined;
const [openEmail, setOpenEmail] = useState<string | null>(null);
const [openName, setOpenName] = useState("");
// "In conversation" = unique senders from the open thread.
// "In conversation" = everyone in the open thread — who wrote it, and who it
// went to (To, Cc, Bcc: e.g. whom a message was forwarded to), the user
// included. Each person once, under the name any message gives them, A–Z.
const convPeople = useMemo(() => {
const seen = new Map<string, Address>();
const names = new Map<string, string>();
for (const m of thread?.messages ?? []) {
const a = m.from_addr[0];
if (a?.email && !seen.has(a.email.toLowerCase())) seen.set(a.email.toLowerCase(), a);
for (const a of [...m.from_addr, ...m.to_addr, ...m.cc_addr, ...m.bcc_addr]) {
const key = a?.email?.toLowerCase();
if (!key) continue;
// Some messages carry the address itself as the name: that is no name.
const name = a.name && a.name.toLowerCase() !== key ? a.name : "";
if (!names.get(key)) names.set(key, name);
}
}
return [...seen.values()].map((a) => ({ id: "", display_name: a.name || a.email, primary_email: a.email }));
return [...names.entries()]
.map(([email, name]) => ({ id: "", display_name: name || email, primary_email: email }))
.sort((a, b) => a.display_name.localeCompare(b.display_name, undefined, { sensitivity: "base" }));
}, [thread]);
const groups = useQuery({ queryKey: ["contact-groups"], queryFn: () => apiFetch<Group[]>("/plugins/contacts/groups"), enabled: source === "autosaved" });
@@ -213,6 +218,24 @@ function PeopleTab({ thread, onInviteAll }: { thread: ThreadDetail | undefined;
return <PersonDetail email={openEmail} name={openName} onBack={() => setOpenEmail(null)} />;
}
const personRow = (p: { display_name: string; primary_email: string }) => (
<button
key={p.primary_email}
className="flex w-full items-center gap-3 px-4 py-2 text-left hover:bg-app-hover"
onClick={() => {
setOpenName(p.display_name);
setOpenEmail(p.primary_email);
}}
>
<Avatar name={p.display_name} email={p.primary_email} />
<div className="min-w-0 flex-1">
<div className="truncate font-medium">{p.display_name}</div>
<div className="truncate text-[13px] text-app-muted">{p.primary_email}</div>
</div>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" className="text-app-faint"><path d="M6 4l4 4-4 4" /></svg>
</button>
);
const SOURCES: [PeopleSource, string][] = [
["conversation", t("panel.inConversation")],
["all", t("panel.allContacts")],
@@ -260,20 +283,10 @@ function PeopleTab({ thread, onInviteAll }: { thread: ThreadDetail | undefined;
</div>
</div>
<div className="px-3 py-2 text-[12px] font-semibold uppercase tracking-wide text-app-faint">{t("panel.senders")}</div>
{people.length === 0 ? (
<div className="px-4 py-6 text-sm text-app-faint">{t("panel.noPeople")}</div>
) : (
people.map((p, i) => (
<button key={p.primary_email + i} className="flex w-full items-center gap-3 px-4 py-2 text-left hover:bg-app-hover" onClick={() => { setOpenName(p.display_name); setOpenEmail(p.primary_email); }}>
<Avatar name={p.display_name} email={p.primary_email} />
<div className="min-w-0 flex-1">
<div className="truncate font-medium">{p.display_name}</div>
<div className="truncate text-[13px] text-app-muted">{p.primary_email}</div>
</div>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" className="text-app-faint"><path d="M6 4l4 4-4 4" /></svg>
</button>
))
people.map((p) => personRow(p))
)}
</div>
);
@@ -316,7 +329,6 @@ function PersonDetail({ email, name, onBack }: { email: string; name: string; on
</div>
<div className="mt-4 flex justify-around">
<ActionBtn label={t("panel.call")} disabled={!c?.phones?.length} onClick={() => c?.phones?.[0] && window.open(`tel:${c.phones[0].value}`)} icon={<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><path d="M4 4h4l2 5-3 2a12 12 0 0 0 6 6l2-3 5 2v4a2 2 0 0 1-2 2A16 16 0 0 1 2 6a2 2 0 0 1 2-2" /></svg>} />
<ActionBtn label={t("panel.message")} onClick={() => navigate(`/mail?compose=1&to=${encodeURIComponent(primary)}`)} icon={<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15a2 2 0 0 1-2 2H8l-4 4V5a2 2 0 0 1 2-2h13a2 2 0 0 1 2 2z" /></svg>} />
<ActionBtn label={t("panel.compose")} onClick={() => navigate(`/mail?compose=1&to=${encodeURIComponent(primary)}`)} icon={<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><path d="M12 20h9" /><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z" /></svg>} />
<ActionBtn label={t("panel.invite")} onClick={() => window.open(`/p/calendar?guest=${encodeURIComponent(primary)}`, "_blank", "noopener")} icon={<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" /><path d="M16 2v4M8 2v4M3 10h18" /></svg>} />
</div>
@@ -147,15 +147,17 @@ export function AttachmentPreview({
</span>
</div>
<div className="flex items-center gap-1">
<button
type="button"
onClick={printAtt}
title={t("mail.print")}
aria-label={t("mail.print")}
className="rounded p-1.5 text-white/80 hover:bg-white/10 hover:text-white"
>
<Icon name="print" size={19} />
</button>
{kind !== "video" && (
<button
type="button"
onClick={printAtt}
title={t("mail.print")}
aria-label={t("mail.print")}
className="rounded p-1.5 text-white/80 hover:bg-white/10 hover:text-white"
>
<Icon name="print" size={19} />
</button>
)}
<button
type="button"
onClick={download}
@@ -174,11 +176,12 @@ export function AttachmentPreview({
<div
className="relative min-h-0 flex-1"
onClick={(e) => {
if (!(e.target as Element).closest("img, iframe, canvas, pre, button, a, form, input"))
if (!(e.target as Element).closest("img, video, iframe, canvas, pre, button, a, form, input"))
onClose();
}}
>
{kind === "image" && <ImageView url={url} zoom={zoom} alt={att.filename} />}
{kind === "video" && <VideoView key={att.id} url={url} onDownload={download} />}
{kind === "text" && (
<TextView url={url} zoom={zoom} onLoaded={(tx) => (textRef.current = tx)} />
)}
@@ -208,8 +211,8 @@ export function AttachmentPreview({
)}
</div>
{/* Bottom controls */}
<div className="flex shrink-0 items-center justify-center py-3">
{/* Bottom controls (a video has its own, in the player) */}
<div className={`flex shrink-0 items-center justify-center py-3 ${kind === "video" ? "hidden" : ""}`}>
<div className="flex items-center gap-2 rounded-full bg-black/60 px-3 py-1.5 text-sm text-white ring-1 ring-white/15">
{kind === "pdf" && page.total > 0 && (
<>
@@ -270,6 +273,41 @@ function ZoomButton({ onClick, label }: { onClick: () => void; label: string })
);
}
// A video, in the browser's own player. A format it cannot play (an HEVC .mov,
// say) says so and offers the download instead of a dead black box.
function VideoView({ url, onDownload }: { url: string; onDownload: () => void }) {
const t = useT();
const [failed, setFailed] = useState(false);
if (failed) {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-3 text-white/80">
<span>{t("mail.previewVideoUnsupported")}</span>
<button
type="button"
onClick={onDownload}
className="rounded-md bg-white/15 px-3 py-1.5 text-sm text-white hover:bg-white/25"
>
{t("mail.download")}
</button>
</div>
);
}
return (
<div className="flex h-full w-full items-center justify-center p-4">
{/* Paused until the user plays it. The #t fragment makes browsers that
would otherwise show black (Safari) fetch and show the first frame. */}
<video
src={`${url}#t=0.001`}
controls
playsInline
preload="metadata"
onError={() => setFailed(true)}
className="max-h-full max-w-full rounded bg-black shadow-2xl"
/>
</div>
);
}
function ImageView({ url, zoom, alt }: { url: string; zoom: number; alt: string }) {
return (
<div className="flex h-full w-full items-center justify-center overflow-auto p-4">
+18 -1
View File
@@ -6,7 +6,7 @@ import spreadsheetSvgRaw from "../../../icons/spreadsheet.svg?raw";
import { useT } from "../../i18n";
import { api } from "../../lib/api";
import type { AttachmentOut } from "../../lib/types";
import { extOf, isPreviewable } from "./previewKind";
import { extOf, isPreviewable, VIDEO_EXT } from "./previewKind";
// These icons carry their own (baked-in) colours via inline styles, so render
// them raw rather than through <Icon>, which strips styles. Drop the XML prolog.
@@ -75,8 +75,25 @@ const ARCHIVE_EXT = new Set(["zip", "rar", "7z", "tar", "gz", "bz2", "xz"]);
const SPREADSHEET_EXT = new Set(["xlsx", "ods"]);
const DOC_EXT = new Set(["txt", "docx", "odt"]); // blue document icon
// Video: a film frame with a play triangle.
function VideoGlyph() {
return (
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" aria-hidden>
<rect x="3" y="5" width="18" height="14" rx="2" />
<path d="M10 9.2v5.6l4.8-2.8z" fill="currentColor" />
</svg>
);
}
function AttachmentIcon({ filename }: { filename: string }) {
const e = extOf(filename);
if (VIDEO_EXT.has(e)) {
return (
<span className="shrink-0 text-violet-500">
<VideoGlyph />
</span>
);
}
if (e === "pdf") {
return (
<span className="shrink-0 text-red-600 dark:text-red-500">
@@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
import { useT } from "../../i18n";
import { personHue } from "../../lib/avatarColor";
import { api } from "../../lib/api";
import { printMessages } from "../../lib/print";
import { useIsMobile } from "../../lib/useIsMobile";
@@ -98,10 +99,7 @@ function initials(msg: MessageEnvelope): string {
// Stable per-sender hue so a sender's avatar + name share a colour (like the mockup).
function hue(msg: MessageEnvelope): number {
const key = (msg.from_addr[0]?.email || msg.from_addr[0]?.name || "?").toLowerCase();
let h = 0;
for (let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) % 360;
return h;
return personHue(msg.from_addr[0]?.email || msg.from_addr[0]?.name || "?");
}
function fmtRelative(iso: string): string {
@@ -493,7 +491,10 @@ function MessageItem({
</div>
</div>
{/* Plugin-contributed per-message banner (SPEC §11.3 message.banner). */}
<PluginSlot name="message.banner" props={{ messageId: message.id }} />
<PluginSlot
name="message.banner"
props={{ messageId: message.id, hasAttachments: message.has_attachments }}
/>
<div className="px-4 pb-3">
<MessageBody message={message} onMailto={onMailto} />
</div>
@@ -2,6 +2,8 @@
// clickability without pulling the heavy preview bundle (lazy-loaded on open).
const IMAGE_EXT = new Set(["jpg", "jpeg", "png", "gif", "webp"]);
// Played in the preview's <video> (the server serves these inline, with Range).
export const VIDEO_EXT = new Set(["mp4", "m4v", "mov", "webm", "ogv"]);
// Office docs converted to HTML client-side (docx via mammoth, ODF via JSZip).
const OFFICE_EXT = new Set(["docx", "xlsx", "odt", "ods", "odp", "odg"]);
@@ -11,15 +13,16 @@ export function extOf(name: string): string {
export function isPreviewable(name: string): boolean {
const e = extOf(name);
return e === "pdf" || e === "txt" || IMAGE_EXT.has(e) || OFFICE_EXT.has(e);
return e === "pdf" || e === "txt" || IMAGE_EXT.has(e) || OFFICE_EXT.has(e) || VIDEO_EXT.has(e);
}
export type PreviewKind = "image" | "pdf" | "text" | "office";
export type PreviewKind = "image" | "pdf" | "text" | "office" | "video";
export function kindOf(name: string): PreviewKind {
const e = extOf(name);
if (e === "pdf") return "pdf";
if (e === "txt") return "text";
if (OFFICE_EXT.has(e)) return "office";
if (VIDEO_EXT.has(e)) return "video";
return "image";
}
+1 -1
View File
@@ -95,6 +95,7 @@ export const en = {
"mail.expandAll": "Expand all",
"mail.collapseAll": "Collapse all",
"mail.print": "Print",
"mail.previewVideoUnsupported": "This video can't be played in the browser.",
"mail.previewPage": "Page {current} / {total}",
"mail.previewResetZoom": "Reset zoom",
"mail.previewPrev": "Previous",
@@ -169,7 +170,6 @@ export const en = {
"panel.composeAll": "Compose to all",
"panel.inviteAll": "Invite all to new event",
"panel.call": "Call",
"panel.message": "Message",
"panel.compose": "Compose",
"panel.invite": "Invite",
"panel.contactDetails": "Contact details",
+23
View File
@@ -0,0 +1,23 @@
// One colour per person, the same wherever their initials circle appears —
// the mail list, the reading pane, the People tab. The user's own accounts use
// the colour chosen for the account; anyone else a stable hue from their address.
import type { AccountOut } from "./types";
export function personHue(key: string): number {
const k = (key || "?").toLowerCase();
let h = 0;
for (let i = 0; i < k.length; i++) h = (h * 31 + k.charCodeAt(i)) % 360;
return h;
}
/** Background of a person's initials circle. */
export function avatarColor(
email: string | null | undefined,
name: string | null | undefined,
accounts: AccountOut[] | undefined,
): string {
const key = (email || "").toLowerCase();
const own = key ? accounts?.find((a) => a.email.toLowerCase() === key) : undefined;
return own?.color ?? `hsl(${personHue(email || name || "?")} 45% 45%)`;
}
+2
View File
@@ -7,6 +7,7 @@ import { AppSwitcher } from "../components/AppSwitcher";
import { ComposeDock } from "../components/compose/ComposeDock";
import { Composer, type ComposerInit, draftToInit, localToInit } from "../components/compose/Composer";
import { UndoSendToast } from "../components/compose/UndoSendToast";
import { Toast } from "../components/Toast";
import { usePendingSend } from "../components/compose/undoSend";
import {
collapseExpanded,
@@ -1530,6 +1531,7 @@ export function MailLayout() {
fullScreen={mobile}
/>
<UndoSendToast mobile={mobile} onUndone={reopenUnsent} />
<Toast mobile={mobile} />
</div>
);
}