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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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%)`;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user