feat: show the contact card for @mentions in the composer and in received mail

This commit is contained in:
Linus Rath
2026-10-18 18:23:45 +02:00
parent 2abe5eb078
commit eb18a13878
34 changed files with 451 additions and 267 deletions
@@ -470,6 +470,7 @@ describe('composer with the @ recipient list', () => {
const shown = await screen.findByTestId('mention-card');
expect(shown).toHaveTextContent('Bob Builder');
expect(shown).toHaveTextContent('bob@example.com');
expect(shown).toHaveTextContent('action_copy');
expect(shown).not.toHaveTextContent('mention_card_not_recipient');
expect(within(shown).queryByRole('button', { name: 'mention_card_add' })).toBeNull();
@@ -1,7 +1,7 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import React from 'react';
import { RecipientPopover } from '../recipient-popover';
import { RecipientCardPopover, RecipientPopover } from '../recipient-popover';
import { useContactStore } from '@/stores/contact-store';
import type { ContactCard } from '@/lib/jmap/types';
@@ -37,3 +37,41 @@ describe('RecipientPopover', () => {
expect(screen.getByText(address)).toBeTruthy();
});
});
describe('RecipientCardPopover', () => {
const anchor = { top: 10, bottom: 30, left: 10 };
it('offers copy, email and view details, and extra actions', () => {
const onViewContact = vi.fn();
render(
<RecipientCardPopover
anchor={anchor}
email={address}
name="WordPress"
onViewContact={onViewContact}
onClose={() => {}}
actions={<button type="button">extra</button>}
/>,
);
const card = screen.getByRole('dialog', { name: 'WordPress' });
expect(card).toHaveTextContent('action_copy');
expect(card).toHaveTextContent('action_email');
fireEvent.click(screen.getByRole('button', { name: /view_details/ }));
expect(onViewContact).toHaveBeenCalledWith(null, address);
expect(screen.getByRole('button', { name: 'extra' })).toBeTruthy();
});
it('closes on a press outside, Escape, and the window losing focus', () => {
const onClose = vi.fn();
render(<RecipientCardPopover anchor={anchor} email={address} onClose={onClose} />);
fireEvent.mouseDown(screen.getByRole('dialog'));
expect(onClose).not.toHaveBeenCalled();
fireEvent.mouseDown(document.body);
expect(onClose).toHaveBeenCalledTimes(1);
fireEvent.keyDown(document, { key: 'Escape' });
expect(onClose).toHaveBeenCalledTimes(2);
// A click into a message body's iframe.
fireEvent.blur(window);
expect(onClose).toHaveBeenCalledTimes(3);
});
});
+36 -1
View File
@@ -115,7 +115,8 @@ import { stripCrossAccountIdentityPrefix } from "@/hooks/use-pro-multi-account-i
import { useTour } from "@/components/tour/tour-provider";
import { useMenuNavigation } from "@/hooks/use-menu-navigation";
import { findCalendarAttachment, isCalendarMimeType } from "@/lib/calendar-invitation";
import { RecipientPopover } from "./recipient-popover";
import { RecipientPopover, RecipientCardPopover, type CardAnchor } from "./recipient-popover";
import { mentionFromLink } from "@/lib/recipient-mentions";
import { MailtoLink } from "@/components/ui/mailto-link";
import { requestInternalMailto } from "@/lib/protocol-handlers/mailto";
import { imageBlobUrl, inertBlobType, isFilePreviewable, isMimeTypeSafeForInlinePreview, toInertBlob } from "@/lib/file-preview";
@@ -1773,6 +1774,13 @@ export function EmailViewer({
setContactSidebarEmail(null);
}, [email?.id]);
// An @mention clicked in the message body: the card for that person, as
// for a name in the header, instead of starting a new message.
const [bodyMention, setBodyMention] = useState<{ email: string; anchor: CardAnchor } | null>(null);
useEffect(() => {
setBodyMention(null);
}, [email?.id]);
const [dismissedUnsubBanners, setDismissedUnsubBanners] = useState<Set<string>>(
() => {
if (typeof window === 'undefined') return new Set();
@@ -3115,6 +3123,19 @@ export function EmailViewer({
const href = targetEl.getAttribute('href') || '';
if (!href || href.startsWith('#')) return;
if (/^mailto:/i.test(href)) {
const mention = mentionFromLink(targetEl as HTMLElement);
if (mention) {
ev.preventDefault();
ev.stopPropagation();
// The link's box is in the iframe's coordinates.
const frame = iframe.getBoundingClientRect();
const box = targetEl.getBoundingClientRect();
setBodyMention({
email: mention.email,
anchor: { top: frame.top + box.top, bottom: frame.top + box.bottom, left: frame.left + box.left },
});
return;
}
// Write it here, like any other address in the app; the mail page
// asks onMailtoIntercept first. Not taken (no recipient, nobody
// listening): the browser's own handling stays.
@@ -3150,6 +3171,9 @@ export function EmailViewer({
window.open(target, '_blank', 'noopener,noreferrer');
};
doc.addEventListener('click', onLinkClick, true);
// A press in the body closes a mention's card. The page never sees
// it: the focus is already inside the iframe, so there is no blur.
doc.addEventListener('mousedown', () => setBodyMention(null), true);
}
} catch {
@@ -6368,6 +6392,17 @@ export function EmailViewer({
</>
)}
{bodyMention && (
<RecipientCardPopover
anchor={bodyMention.anchor}
email={bodyMention.email}
name={[...(email?.from || []), ...(email?.to || []), ...(email?.cc || []), ...(email?.replyTo || [])]
.find((r) => r.email.toLowerCase() === bodyMention.email.toLowerCase())?.name || undefined}
onViewContact={handleViewContactSidebar}
onClose={() => setBodyMention(null)}
/>
)}
{/* Contact Detail Sidebar - desktop only */}
{contactSidebarEmail && !isMobileDevice && (
<ContactSidebarPanel
+28 -56
View File
@@ -6,8 +6,9 @@ import { Node, type Editor } from "@tiptap/core";
import { NodeSelection, Plugin, PluginKey } from "@tiptap/pm/state";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
import { useTranslations } from "next-intl";
import { Avatar } from "@/components/ui/avatar";
import type { MentionCandidate, MentionContact } from "@/lib/recipient-mentions";
import { X } from "@/components/icons";
import { RecipientCard, recipientCardClass } from "@/components/email/recipient-popover";
import { mentionFromLink, mentionHref, type MentionCandidate, type MentionContact } from "@/lib/recipient-mentions";
/**
* Addresses on the message (To and Cc) that mentions are checked against;
@@ -24,35 +25,6 @@ export function setMentionRecipients(editor: Editor, emails: string[] | null) {
editor.view.dispatch(editor.state.tr.setMeta(mentionRecipientsKey, next).setMeta("addToHistory", false));
}
/**
* A mailto: href for the address. Characters that would start a query or
* fragment (a crafted reply-all address) are escaped, so the link can only
* ever address this one mailbox.
*/
export function mentionHref(email: string): string {
return `mailto:${email.replace(/[%?#&\s"<>]/g, (c) => encodeURIComponent(c))}`;
}
/**
* The mention a link stands for: a mailto: link whose whole text is
* "@Name". That is what a sent mention is, from Bulwark or from Outlook,
* so a reopened draft or a pasted mention becomes a chip again.
*/
export function mentionFromLink(element: HTMLElement): { label: string; email: string } | null {
const href = element.getAttribute("href")?.trim() ?? "";
if (!/^mailto:/i.test(href) || element.children.length > 0) return null;
const text = element.textContent?.trim() ?? "";
if (!text.startsWith("@") || text.length < 2) return null;
let email: string;
try {
email = decodeURIComponent(href.slice("mailto:".length).split("?")[0]).trim();
} catch {
return null;
}
if (email.lastIndexOf("@") <= 0) return null;
return { label: text.slice(1), email };
}
/**
* An @mention in the body: one inline unit, deleted and selected as a
* whole. In the sent message it is a mailto: link reading "@Name", which
@@ -291,9 +263,8 @@ export function MentionChipCard({ editor, recipients, findContacts, addsTo, onAd
role="dialog"
aria-label={name}
data-testid="mention-card"
className="z-50 w-72 max-w-[calc(100vw-16px)] bg-background border border-border rounded-md shadow-lg p-3 text-sm"
className={recipientCardClass}
style={{
position: "fixed",
left: position?.x ?? 0,
top: position?.y ?? 0,
visibility: position ? undefined : "hidden",
@@ -307,34 +278,35 @@ export function MentionChipCard({ editor, recipients, findContacts, addsTo, onAd
timer.current = setTimeout(close, LEAVE_DELAY);
}}
>
<div className="flex items-center gap-2 min-w-0">
<Avatar name={name} email={email} size="sm" className="shrink-0 w-8 h-8 text-xs" />
<div className="min-w-0">
<div className="font-medium text-foreground truncate">{name}</div>
<div className="text-muted-foreground truncate">{email}</div>
</div>
</div>
{outside && (
<p className="mt-2 text-xs text-amber-600 dark:text-amber-400">{t("mention_card_not_recipient")}</p>
)}
<div className="mt-3 flex flex-wrap gap-2">
{outside && onAdd && (
<RecipientCard
name={name !== label ? name : undefined}
email={email}
actions={
<button
type="button"
onClick={add}
className="px-2.5 py-1 rounded-md text-xs font-medium bg-primary text-primary-foreground hover:bg-primary/90"
onClick={remove}
className="ms-auto flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground px-2 py-1.5 rounded hover:bg-muted transition-colors"
>
{t("mention_card_add", { field: t(addsTo) })}
<X className="w-3.5 h-3.5" />
{t("mention_card_remove")}
</button>
}
>
{outside && (
<div className="space-y-2">
<p className="text-xs text-amber-600 dark:text-amber-400">{t("mention_card_not_recipient")}</p>
{onAdd && (
<button
type="button"
onClick={add}
className="px-2.5 py-1 rounded-md text-xs font-medium bg-primary text-primary-foreground hover:bg-primary/90"
>
{t("mention_card_add", { field: t(addsTo) })}
</button>
)}
</div>
)}
<button
type="button"
onClick={remove}
className="px-2.5 py-1 rounded-md text-xs font-medium bg-muted text-foreground hover:bg-accent"
>
{t("mention_card_remove")}
</button>
</div>
</RecipientCard>
</div>,
document.body,
);
+233 -180
View File
@@ -1,7 +1,8 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { useState, useRef, useEffect, useLayoutEffect, type ReactNode, type RefObject } from "react";
import { createPortal } from "react-dom";
import { useTranslations } from "next-intl";
import { Mail, Phone, Building, ExternalLink, Copy, Send, UserPlus } from "@/components/icons";
import { Avatar } from "@/components/ui/avatar";
import { cn } from "@/lib/utils";
@@ -11,21 +12,31 @@ import { toast } from "@/stores/toast-store";
import { useOwnDomainAddress } from "@/hooks/use-own-domain-address";
import type { ContactCard } from "@/lib/jmap/types";
interface RecipientPopoverProps {
const CARD_WIDTH = 300;
const actionClass =
"flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground px-2 py-1.5 rounded hover:bg-muted transition-colors";
export interface RecipientCardProps {
name?: string;
email: string;
/** Display label override (e.g. "me") */
displayLabel?: string;
/** Called when user clicks "View contact" - receives the contact and email */
onViewContact?: (contact: ContactCard | null, email: string) => void;
className?: string;
/** Called after an action that is done with the card ("View contact"). */
onClose?: () => void;
/** Shown under the contact details, above the actions. */
children?: ReactNode;
/** Extra buttons at the end of the action row. */
actions?: ReactNode;
}
export function RecipientPopover({ name, email, displayLabel, onViewContact, className }: RecipientPopoverProps) {
const [isOpen, setIsOpen] = useState(false);
const [position, setPosition] = useState<{ top: number; left: number } | null>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
/**
* Who an address belongs to: the saved contact's name, other addresses,
* phones and company, with Copy, Email and View contact. Shared by the
* names in a message header and by @mentions.
*/
export function RecipientCard({ name, email, onViewContact, onClose, children, actions }: RecipientCardProps) {
const t = useTranslations("email_viewer.contact_sidebar");
const contacts = useContactStore((s) => s.contacts);
const shownEmail = useOwnDomainAddress()(email);
@@ -47,92 +58,218 @@ export function RecipientPopover({ name, email, displayLabel, onViewContact, cla
const phones = contact?.phones ? Object.values(contact.phones) : [];
const orgs = contact?.organizations ? Object.values(contact.organizations) : [];
const handleToggle = () => {
if (isOpen) {
handleClose();
return;
}
if (!triggerRef.current) return;
const rect = triggerRef.current.getBoundingClientRect();
const popoverWidth = 300;
const popoverHeight = 250;
let top = rect.bottom + 4;
let left = rect.left;
// Keep within viewport
if (left + popoverWidth > window.innerWidth - 8) {
left = window.innerWidth - popoverWidth - 8;
}
if (left < 8) left = 8;
if (top + popoverHeight > window.innerHeight - 8) {
top = rect.top - popoverHeight - 4;
}
setPosition({ top, left });
setIsOpen(true);
};
const handleClose = () => {
setIsOpen(false);
setPosition(null);
};
// Close on outside click
useEffect(() => {
if (!isOpen) return;
const handler = (e: MouseEvent) => {
if (
popoverRef.current &&
!popoverRef.current.contains(e.target as Node) &&
triggerRef.current &&
!triggerRef.current.contains(e.target as Node)
) {
handleClose();
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [isOpen]);
// Close on Escape
useEffect(() => {
if (!isOpen) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") handleClose();
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [isOpen]);
// Close on scroll so the popover does not float while content moves.
useEffect(() => {
if (!isOpen) return;
const handler = () => handleClose();
document.addEventListener("scroll", handler, true);
window.addEventListener("resize", handler);
return () => {
document.removeEventListener("scroll", handler, true);
window.removeEventListener("resize", handler);
};
}, [isOpen]);
const handleViewContact = () => {
if (onViewContact) {
onViewContact(contact ?? null, email);
}
handleClose();
onViewContact?.(contact ?? null, email);
onClose?.();
};
const handleCopyEmail = async (addr: string) => {
try {
await navigator.clipboard.writeText(addr);
toast.success("Copied!");
toast.success(t("copied"));
} catch {
toast.error("Failed to copy");
toast.error(t("copy_failed"));
}
};
return (
<>
{/* Header with avatar and name */}
<div className="px-4 pt-4 pb-3 flex items-center gap-3">
<Avatar
name={contactName || shownEmail}
email={email}
size="md"
/>
<div className="min-w-0 flex-1">
<div className="font-semibold text-sm truncate">
{contactName || shownEmail}
</div>
{contactName && contactName.toLowerCase() !== email.toLowerCase() && (
<div className="text-xs text-muted-foreground truncate">
{shownEmail}
</div>
)}
{orgs.length > 0 && orgs[0].name && (
<div className="text-xs text-muted-foreground truncate flex items-center gap-1">
<Building className="w-3 h-3 shrink-0" />
{orgs[0].name}
</div>
)}
</div>
</div>
{/* Contact details */}
<div className="px-4 pb-3 space-y-1.5">
{/* Show additional emails if contact has them */}
{emails.length > 1 && (
<div className="space-y-1">
{emails.slice(1).map((e, i) => (
<div key={i} className="flex items-center gap-2 text-xs text-muted-foreground">
<Mail className="w-3 h-3 shrink-0" />
<span className="truncate">{e.address}</span>
</div>
))}
</div>
)}
{/* Phone numbers */}
{phones.length > 0 && (
<div className="space-y-1">
{phones.map((p, i) => (
<div key={i} className="flex items-center gap-2 text-xs text-muted-foreground">
<Phone className="w-3 h-3 shrink-0" />
<a href={`tel:${p.number}`} className="hover:text-foreground hover:underline truncate">
{p.number}
</a>
</div>
))}
</div>
)}
{children}
</div>
{/* Actions */}
<div className="border-t border-border px-2 py-2 flex items-center gap-1">
<button
type="button"
onClick={() => handleCopyEmail(email)}
className={actionClass}
title={t("action_copy_title")}
>
<Copy className="w-3.5 h-3.5" />
{t("action_copy")}
</button>
<MailtoLink
to={email}
className={actionClass}
title={t("action_email_title")}
>
<Send className="w-3.5 h-3.5" />
{t("action_email")}
</MailtoLink>
{onViewContact && (
<button
type="button"
onClick={handleViewContact}
className={cn(actionClass, "ms-auto")}
title={contact ? t("view_contact") : t("view_details")}
>
{contact ? <ExternalLink className="w-3.5 h-3.5" /> : <UserPlus className="w-3.5 h-3.5" />}
{contact ? t("view_contact") : t("view_details")}
</button>
)}
{actions}
</div>
</>
);
}
/** Where a card opens: under the element that opened it, in viewport pixels. */
export interface CardAnchor {
top: number;
bottom: number;
left: number;
}
/** Under the anchor, or above it when there is no room below; always on screen. */
export function placeCard(anchor: CardAnchor, height: number): { top: number; left: number } {
let top = anchor.bottom + 4;
let left = anchor.left;
if (left + CARD_WIDTH > window.innerWidth - 8) left = window.innerWidth - CARD_WIDTH - 8;
if (left < 8) left = 8;
if (top + height > window.innerHeight - 8 && anchor.top - height - 4 > 8) top = anchor.top - height - 4;
return { top, left };
}
export const recipientCardClass =
"fixed z-50 w-[300px] max-w-[calc(100vw-16px)] bg-background rounded-lg shadow-lg border border-border animate-in fade-in-0 zoom-in-95 duration-100";
interface RecipientCardPopoverProps extends RecipientCardProps {
anchor: CardAnchor;
onClose: () => void;
/** A press here doesn't count as outside (the trigger, which toggles). */
ignoreRef?: RefObject<HTMLElement | null>;
}
/**
* A RecipientCard floating at an anchor. Closes on a press outside it,
* Escape, scrolling, resizing, and the window losing focus - which is what
* a click into a message body's iframe looks like from here.
*/
export function RecipientCardPopover({ anchor, onClose, ignoreRef, ...card }: RecipientCardPopoverProps) {
const popoverRef = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState<{ top: number; left: number } | null>(null);
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
useLayoutEffect(() => {
setPosition(placeCard(anchor, popoverRef.current?.offsetHeight ?? 250));
}, [anchor]);
useEffect(() => {
const close = () => onCloseRef.current();
const onPress = (e: MouseEvent) => {
const target = e.target as Node;
if (popoverRef.current?.contains(target) || ignoreRef?.current?.contains(target)) return;
close();
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") close();
};
document.addEventListener("mousedown", onPress);
document.addEventListener("keydown", onKey);
document.addEventListener("scroll", close, true);
window.addEventListener("resize", close);
window.addEventListener("blur", close);
return () => {
document.removeEventListener("mousedown", onPress);
document.removeEventListener("keydown", onKey);
document.removeEventListener("scroll", close, true);
window.removeEventListener("resize", close);
window.removeEventListener("blur", close);
};
}, [ignoreRef]);
return createPortal(
<div
ref={popoverRef}
role="dialog"
aria-label={card.name || card.email}
className={recipientCardClass}
style={{ top: position?.top ?? 0, left: position?.left ?? 0, visibility: position ? undefined : "hidden" }}
>
<RecipientCard {...card} onClose={onClose} />
</div>,
document.body
);
}
interface RecipientPopoverProps {
name?: string;
email: string;
/** Display label override (e.g. "me") */
displayLabel?: string;
/** Called when user clicks "View contact" - receives the contact and email */
onViewContact?: (contact: ContactCard | null, email: string) => void;
className?: string;
}
export function RecipientPopover({ name, email, displayLabel, onViewContact, className }: RecipientPopoverProps) {
const [anchor, setAnchor] = useState<CardAnchor | null>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const shownEmail = useOwnDomainAddress()(email);
const handleToggle = () => {
if (anchor) {
setAnchor(null);
return;
}
const rect = triggerRef.current?.getBoundingClientRect();
if (rect) setAnchor({ top: rect.top, bottom: rect.bottom, left: rect.left });
};
return (
<>
<button
@@ -146,100 +283,16 @@ export function RecipientPopover({ name, email, displayLabel, onViewContact, cla
<bdi>{displayLabel || name || shownEmail}</bdi>
</button>
{isOpen &&
position &&
createPortal(
<div
ref={popoverRef}
className="fixed z-50 w-[300px] bg-background rounded-lg shadow-lg border border-border animate-in fade-in-0 zoom-in-95 duration-100"
style={{ top: position.top, left: position.left }}
>
{/* Header with avatar and name */}
<div className="px-4 pt-4 pb-3 flex items-center gap-3">
<Avatar
name={contactName || shownEmail}
email={email}
size="md"
/>
<div className="min-w-0 flex-1">
<div className="font-semibold text-sm truncate">
{contactName || shownEmail}
</div>
{contactName && contactName.toLowerCase() !== email.toLowerCase() && (
<div className="text-xs text-muted-foreground truncate">
{shownEmail}
</div>
)}
{orgs.length > 0 && orgs[0].name && (
<div className="text-xs text-muted-foreground truncate flex items-center gap-1">
<Building className="w-3 h-3 shrink-0" />
{orgs[0].name}
</div>
)}
</div>
</div>
{/* Contact details */}
<div className="px-4 pb-3 space-y-1.5">
{/* Show additional emails if contact has them */}
{emails.length > 1 && (
<div className="space-y-1">
{emails.slice(1).map((e, i) => (
<div key={i} className="flex items-center gap-2 text-xs text-muted-foreground">
<Mail className="w-3 h-3 shrink-0" />
<span className="truncate">{e.address}</span>
</div>
))}
</div>
)}
{/* Phone numbers */}
{phones.length > 0 && (
<div className="space-y-1">
{phones.map((p, i) => (
<div key={i} className="flex items-center gap-2 text-xs text-muted-foreground">
<Phone className="w-3 h-3 shrink-0" />
<a href={`tel:${p.number}`} className="hover:text-foreground hover:underline truncate">
{p.number}
</a>
</div>
))}
</div>
)}
</div>
{/* Actions */}
<div className="border-t border-border px-2 py-2 flex items-center gap-1">
<button
onClick={() => handleCopyEmail(email)}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground px-2 py-1.5 rounded hover:bg-muted transition-colors"
title="Copy email"
>
<Copy className="w-3.5 h-3.5" />
Copy
</button>
<MailtoLink
to={email}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground px-2 py-1.5 rounded hover:bg-muted transition-colors"
title="Send email"
>
<Send className="w-3.5 h-3.5" />
Email
</MailtoLink>
{onViewContact && (
<button
onClick={handleViewContact}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground px-2 py-1.5 rounded hover:bg-muted transition-colors ms-auto"
title={contact ? "View contact" : "View details"}
>
{contact ? <ExternalLink className="w-3.5 h-3.5" /> : <UserPlus className="w-3.5 h-3.5" />}
{contact ? "View contact" : "View details"}
</button>
)}
</div>
</div>,
document.body
)}
{anchor && (
<RecipientCardPopover
anchor={anchor}
name={name}
email={email}
onViewContact={onViewContact}
onClose={() => setAnchor(null)}
ignoreRef={triggerRef}
/>
)}
</>
);
}
+29
View File
@@ -210,3 +210,32 @@ export function filterMentionCandidates(candidates: MentionCandidate[], query: s
.some((term) => fold(term).startsWith(typed)),
);
}
/**
* A mailto: href for the address. Characters that would start a query or
* fragment (a crafted reply-all address) are escaped, so the link can only
* ever address this one mailbox.
*/
export function mentionHref(email: string): string {
return `mailto:${email.replace(/[%?#&\s"'<>]/g, (c) => encodeURIComponent(c))}`;
}
/**
* The mention a link stands for: a mailto: link whose whole text is
* "@Name". That is what a sent mention is, from Bulwark or from Outlook,
* so a reopened draft or a pasted mention becomes a chip again.
*/
export function mentionFromLink(element: HTMLElement): { label: string; email: string } | null {
const href = element.getAttribute('href')?.trim() ?? '';
if (!/^mailto:/i.test(href) || element.children.length > 0) return null;
const text = element.textContent?.trim() ?? '';
if (!text.startsWith('@') || text.length < 2) return null;
let email: string;
try {
email = decodeURIComponent(href.slice('mailto:'.length).split('?')[0]).trim();
} catch {
return null;
}
if (email.lastIndexOf('@') <= 0) return null;
return { label: text.slice(1), email };
}
+3 -1
View File
@@ -622,7 +622,9 @@
"not_in_contacts": "ليست ضمن جهات اتصالك",
"add_to_contacts": "إضافة إلى جهات الاتصال",
"copied": "تم النسخ!",
"copy_failed": "فشل النسخ"
"copy_failed": "فشل النسخ",
"view_contact": "عرض جهة الاتصال",
"view_details": "عرض التفاصيل"
},
"send_now": "إرسال الآن",
"verification_code": {
+3 -1
View File
@@ -622,7 +622,9 @@
"not_in_contacts": "No és als vostres contactes",
"add_to_contacts": "Afegeix als contactes",
"copied": "Copiat!",
"copy_failed": "No s'ha pogut copiar"
"copy_failed": "No s'ha pogut copiar",
"view_contact": "Mostra el contacte",
"view_details": "Mostra els detalls"
},
"send_now": "Envia ara",
"verification_code": {
+3 -1
View File
@@ -622,7 +622,9 @@
"not_in_contacts": "Není ve vašich kontaktech",
"add_to_contacts": "Přidat do kontaktů",
"copied": "Zkopírováno!",
"copy_failed": "Kopírování se nezdařilo"
"copy_failed": "Kopírování se nezdařilo",
"view_contact": "Zobrazit kontakt",
"view_details": "Zobrazit podrobnosti"
},
"send_now": "Odeslat nyní",
"verification_code": {
+3 -1
View File
@@ -622,7 +622,9 @@
"not_in_contacts": "Ikke i dine kontakter",
"add_to_contacts": "Tilføj til kontakter",
"copied": "Kopieret!",
"copy_failed": "Kunne ikke kopiere"
"copy_failed": "Kunne ikke kopiere",
"view_contact": "Vis kontakt",
"view_details": "Vis detaljer"
},
"send_now": "Send nu",
"verification_code": {
+3 -1
View File
@@ -622,7 +622,9 @@
"not_in_contacts": "Nicht in Ihren Kontakten",
"add_to_contacts": "Zu Kontakten hinzufügen",
"copied": "Kopiert!",
"copy_failed": "Kopieren fehlgeschlagen"
"copy_failed": "Kopieren fehlgeschlagen",
"view_contact": "Kontakt ansehen",
"view_details": "Details ansehen"
},
"send_now": "Jetzt senden",
"verification_code": {
+3 -1
View File
@@ -622,7 +622,9 @@
"not_in_contacts": "Not in your contacts",
"add_to_contacts": "Add to contacts",
"copied": "Copied!",
"copy_failed": "Failed to copy"
"copy_failed": "Failed to copy",
"view_contact": "View contact",
"view_details": "View details"
},
"send_now": "Send now",
"verification_code": {
+3 -1
View File
@@ -622,7 +622,9 @@
"not_in_contacts": "No está en sus contactos",
"add_to_contacts": "Añadir a contactos",
"copied": "¡Copiado!",
"copy_failed": "Error al copiar"
"copy_failed": "Error al copiar",
"view_contact": "Ver contacto",
"view_details": "Ver detalles"
},
"send_now": "Enviar ahora",
"verification_code": {
+3 -1
View File
@@ -622,7 +622,9 @@
"not_in_contacts": "Ei ole sinu kontaktides",
"add_to_contacts": "Lisa kontaktidesse",
"copied": "Kopeeritud!",
"copy_failed": "Kopeerimine ebaõnnestus"
"copy_failed": "Kopeerimine ebaõnnestus",
"view_contact": "Kuva kontakt",
"view_details": "Kuva üksikasjad"
},
"send_now": "Saada kohe",
"verification_code": {
+3 -1
View File
@@ -622,7 +622,9 @@
"not_in_contacts": "در مخاطبین شما نیست",
"add_to_contacts": "افزودن به مخاطبین",
"copied": "کپی شد!",
"copy_failed": "کپی ناموفق بود"
"copy_failed": "کپی ناموفق بود",
"view_contact": "مشاهده مخاطب",
"view_details": "مشاهده جزئیات"
},
"send_now": "ارسال فوری",
"verification_code": {
+3 -1
View File
@@ -622,7 +622,9 @@
"not_in_contacts": "Pas dans vos contacts",
"add_to_contacts": "Ajouter aux contacts",
"copied": "Copié !",
"copy_failed": "Échec de la copie"
"copy_failed": "Échec de la copie",
"view_contact": "Voir le contact",
"view_details": "Voir les détails"
},
"send_now": "Envoyer maintenant",
"verification_code": {
+3 -1
View File
@@ -587,7 +587,9 @@
"not_in_contacts": "לא בקשרים שלך",
"add_to_contacts": "הוסף לקשרים",
"copied": "הועתק!",
"copy_failed": "העתקה נכשלה"
"copy_failed": "העתקה נכשלה",
"view_contact": "הצגת איש הקשר",
"view_details": "הצגת פרטים"
},
"send_now": "שלח עכשיו",
"verification_code": {
+3 -1
View File
@@ -622,7 +622,9 @@
"not_in_contacts": "Nincs a névjegyei között",
"add_to_contacts": "Hozzáadás a névjegyekhez",
"copied": "Másolva!",
"copy_failed": "A másolás nem sikerült"
"copy_failed": "A másolás nem sikerült",
"view_contact": "Névjegy megtekintése",
"view_details": "Részletek megtekintése"
},
"send_now": "Küldés most",
"verification_code": {
+3 -1
View File
@@ -622,7 +622,9 @@
"not_in_contacts": "Non è nei tuoi contatti",
"add_to_contacts": "Aggiungi ai contatti",
"copied": "Copiato!",
"copy_failed": "Copia non riuscita"
"copy_failed": "Copia non riuscita",
"view_contact": "Visualizza contatto",
"view_details": "Visualizza dettagli"
},
"send_now": "Invia ora",
"verification_code": {
+3 -1
View File
@@ -622,7 +622,9 @@
"not_in_contacts": "連絡先に登録されていません",
"add_to_contacts": "連絡先に追加",
"copied": "コピーしました!",
"copy_failed": "コピーに失敗しました"
"copy_failed": "コピーに失敗しました",
"view_contact": "連絡先を表示",
"view_details": "詳細を表示"
},
"send_now": "今すぐ送信",
"verification_code": {
+3 -1
View File
@@ -622,7 +622,9 @@
"not_in_contacts": "연락처에 없음",
"add_to_contacts": "연락처에 추가",
"copied": "복사됨!",
"copy_failed": "복사하지 못했습니다"
"copy_failed": "복사하지 못했습니다",
"view_contact": "연락처 보기",
"view_details": "세부 정보 보기"
},
"send_now": "지금 보내기",
"verification_code": {
+3 -1
View File
@@ -622,7 +622,9 @@
"not_in_contacts": "Nav jūsu kontaktpersonās",
"add_to_contacts": "Pievienot kontaktpersonām",
"copied": "Nokopēts!",
"copy_failed": "Neizdevās nokopēt"
"copy_failed": "Neizdevās nokopēt",
"view_contact": "Skatīt kontaktu",
"view_details": "Skatīt informāciju"
},
"send_now": "Sūtīt tagad",
"verification_code": {
+3 -1
View File
@@ -622,7 +622,9 @@
"not_in_contacts": "Таны харилцагчид байхгүй",
"add_to_contacts": "Харилцагчдад нэмэх",
"copied": "Хуулбарласан!",
"copy_failed": "Хуулж чадсангүй"
"copy_failed": "Хуулж чадсангүй",
"view_contact": "Харилцагчийг харах",
"view_details": "Дэлгэрэнгүй харах"
},
"send_now": "Одоо илгээх",
"verification_code": {
+3 -1
View File
@@ -622,7 +622,9 @@
"not_in_contacts": "Ikke i kontaktene dine",
"add_to_contacts": "Legg til som kontakt",
"copied": "Kopiert!",
"copy_failed": "Kunne ikke kopiere"
"copy_failed": "Kunne ikke kopiere",
"view_contact": "Vis kontakt",
"view_details": "Vis detaljer"
},
"send_now": "Send nå",
"verification_code": {
+3 -1
View File
@@ -622,7 +622,9 @@
"not_in_contacts": "Niet in uw contacten",
"add_to_contacts": "Aan contacten toevoegen",
"copied": "Gekopieerd!",
"copy_failed": "Kopiëren mislukt"
"copy_failed": "Kopiëren mislukt",
"view_contact": "Contact bekijken",
"view_details": "Details bekijken"
},
"send_now": "Nu verzenden",
"verification_code": {
+3 -1
View File
@@ -622,7 +622,9 @@
"not_in_contacts": "Brak w kontaktach",
"add_to_contacts": "Dodaj do kontaktów",
"copied": "Skopiowano!",
"copy_failed": "Nie udało się skopiować"
"copy_failed": "Nie udało się skopiować",
"view_contact": "Pokaż kontakt",
"view_details": "Pokaż szczegóły"
},
"send_now": "Wyślij teraz",
"verification_code": {
+3 -1
View File
@@ -622,7 +622,9 @@
"not_in_contacts": "Não está nos seus contatos",
"add_to_contacts": "Adicionar aos contatos",
"copied": "Copiado!",
"copy_failed": "Falha ao copiar"
"copy_failed": "Falha ao copiar",
"view_contact": "Ver contacto",
"view_details": "Ver detalhes"
},
"send_now": "Enviar agora",
"verification_code": {
+3 -1
View File
@@ -622,7 +622,9 @@
"not_in_contacts": "Nu se află în lista dvs. de contacte",
"add_to_contacts": "Adaugă la contacte",
"copied": "Copiat!",
"copy_failed": "Copierea a eșuat"
"copy_failed": "Copierea a eșuat",
"view_contact": "Vezi contactul",
"view_details": "Vezi detaliile"
},
"send_now": "Trimite acum",
"verification_code": {
+3 -1
View File
@@ -622,7 +622,9 @@
"not_in_contacts": "Нет в ваших контактах",
"add_to_contacts": "Добавить в контакты",
"copied": "Скопировано!",
"copy_failed": "Не удалось скопировать"
"copy_failed": "Не удалось скопировать",
"view_contact": "Открыть контакт",
"view_details": "Подробнее"
},
"send_now": "Отправить сейчас",
"verification_code": {
+3 -1
View File
@@ -622,7 +622,9 @@
"not_in_contacts": "Nie je vo vašich kontaktoch",
"add_to_contacts": "Pridať do kontaktov",
"copied": "Skopírované!",
"copy_failed": "Kopírovanie zlyhalo"
"copy_failed": "Kopírovanie zlyhalo",
"view_contact": "Zobraziť kontakt",
"view_details": "Zobraziť podrobnosti"
},
"send_now": "Odoslať teraz",
"verification_code": {
+3 -1
View File
@@ -622,7 +622,9 @@
"not_in_contacts": "Kişilerinizde yok",
"add_to_contacts": "Kişilere ekle",
"copied": "Kopyalandı!",
"copy_failed": "Kopyalanamadı"
"copy_failed": "Kopyalanamadı",
"view_contact": "Kişiyi görüntüle",
"view_details": "Ayrıntıları görüntüle"
},
"send_now": "Şimdi gönder",
"verification_code": {
+3 -1
View File
@@ -622,7 +622,9 @@
"not_in_contacts": "Немає у ваших контактах",
"add_to_contacts": "Додати до контактів",
"copied": "Скопійовано!",
"copy_failed": "Не вдалося скопіювати"
"copy_failed": "Не вдалося скопіювати",
"view_contact": "Переглянути контакт",
"view_details": "Докладніше"
},
"send_now": "Надіслати зараз",
"verification_code": {
+3 -1
View File
@@ -622,7 +622,9 @@
"not_in_contacts": "不在您的聯絡人清單中",
"add_to_contacts": "新增至聯絡人",
"copied": "已複製!",
"copy_failed": "複製失敗"
"copy_failed": "複製失敗",
"view_contact": "檢視聯絡人",
"view_details": "檢視詳細資料"
},
"send_now": "立即傳送",
"verification_code": {
+3 -1
View File
@@ -622,7 +622,9 @@
"not_in_contacts": "不在您的联系人中",
"add_to_contacts": "添加到联系人",
"copied": "已复制!",
"copy_failed": "复制失败"
"copy_failed": "复制失败",
"view_contact": "查看联系人",
"view_details": "查看详情"
},
"send_now": "立即发送",
"verification_code": {