mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat: show the contact card for @mentions in the composer and in received mail
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -622,7 +622,9 @@
|
||||
"not_in_contacts": "ليست ضمن جهات اتصالك",
|
||||
"add_to_contacts": "إضافة إلى جهات الاتصال",
|
||||
"copied": "تم النسخ!",
|
||||
"copy_failed": "فشل النسخ"
|
||||
"copy_failed": "فشل النسخ",
|
||||
"view_contact": "عرض جهة الاتصال",
|
||||
"view_details": "عرض التفاصيل"
|
||||
},
|
||||
"send_now": "إرسال الآن",
|
||||
"verification_code": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -622,7 +622,9 @@
|
||||
"not_in_contacts": "در مخاطبین شما نیست",
|
||||
"add_to_contacts": "افزودن به مخاطبین",
|
||||
"copied": "کپی شد!",
|
||||
"copy_failed": "کپی ناموفق بود"
|
||||
"copy_failed": "کپی ناموفق بود",
|
||||
"view_contact": "مشاهده مخاطب",
|
||||
"view_details": "مشاهده جزئیات"
|
||||
},
|
||||
"send_now": "ارسال فوری",
|
||||
"verification_code": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -587,7 +587,9 @@
|
||||
"not_in_contacts": "לא בקשרים שלך",
|
||||
"add_to_contacts": "הוסף לקשרים",
|
||||
"copied": "הועתק!",
|
||||
"copy_failed": "העתקה נכשלה"
|
||||
"copy_failed": "העתקה נכשלה",
|
||||
"view_contact": "הצגת איש הקשר",
|
||||
"view_details": "הצגת פרטים"
|
||||
},
|
||||
"send_now": "שלח עכשיו",
|
||||
"verification_code": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -622,7 +622,9 @@
|
||||
"not_in_contacts": "連絡先に登録されていません",
|
||||
"add_to_contacts": "連絡先に追加",
|
||||
"copied": "コピーしました!",
|
||||
"copy_failed": "コピーに失敗しました"
|
||||
"copy_failed": "コピーに失敗しました",
|
||||
"view_contact": "連絡先を表示",
|
||||
"view_details": "詳細を表示"
|
||||
},
|
||||
"send_now": "今すぐ送信",
|
||||
"verification_code": {
|
||||
|
||||
@@ -622,7 +622,9 @@
|
||||
"not_in_contacts": "연락처에 없음",
|
||||
"add_to_contacts": "연락처에 추가",
|
||||
"copied": "복사됨!",
|
||||
"copy_failed": "복사하지 못했습니다"
|
||||
"copy_failed": "복사하지 못했습니다",
|
||||
"view_contact": "연락처 보기",
|
||||
"view_details": "세부 정보 보기"
|
||||
},
|
||||
"send_now": "지금 보내기",
|
||||
"verification_code": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -622,7 +622,9 @@
|
||||
"not_in_contacts": "Таны харилцагчид байхгүй",
|
||||
"add_to_contacts": "Харилцагчдад нэмэх",
|
||||
"copied": "Хуулбарласан!",
|
||||
"copy_failed": "Хуулж чадсангүй"
|
||||
"copy_failed": "Хуулж чадсангүй",
|
||||
"view_contact": "Харилцагчийг харах",
|
||||
"view_details": "Дэлгэрэнгүй харах"
|
||||
},
|
||||
"send_now": "Одоо илгээх",
|
||||
"verification_code": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -622,7 +622,9 @@
|
||||
"not_in_contacts": "Нет в ваших контактах",
|
||||
"add_to_contacts": "Добавить в контакты",
|
||||
"copied": "Скопировано!",
|
||||
"copy_failed": "Не удалось скопировать"
|
||||
"copy_failed": "Не удалось скопировать",
|
||||
"view_contact": "Открыть контакт",
|
||||
"view_details": "Подробнее"
|
||||
},
|
||||
"send_now": "Отправить сейчас",
|
||||
"verification_code": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -622,7 +622,9 @@
|
||||
"not_in_contacts": "Немає у ваших контактах",
|
||||
"add_to_contacts": "Додати до контактів",
|
||||
"copied": "Скопійовано!",
|
||||
"copy_failed": "Не вдалося скопіювати"
|
||||
"copy_failed": "Не вдалося скопіювати",
|
||||
"view_contact": "Переглянути контакт",
|
||||
"view_details": "Докладніше"
|
||||
},
|
||||
"send_now": "Надіслати зараз",
|
||||
"verification_code": {
|
||||
|
||||
@@ -622,7 +622,9 @@
|
||||
"not_in_contacts": "不在您的聯絡人清單中",
|
||||
"add_to_contacts": "新增至聯絡人",
|
||||
"copied": "已複製!",
|
||||
"copy_failed": "複製失敗"
|
||||
"copy_failed": "複製失敗",
|
||||
"view_contact": "檢視聯絡人",
|
||||
"view_details": "檢視詳細資料"
|
||||
},
|
||||
"send_now": "立即傳送",
|
||||
"verification_code": {
|
||||
|
||||
@@ -622,7 +622,9 @@
|
||||
"not_in_contacts": "不在您的联系人中",
|
||||
"add_to_contacts": "添加到联系人",
|
||||
"copied": "已复制!",
|
||||
"copy_failed": "复制失败"
|
||||
"copy_failed": "复制失败",
|
||||
"view_contact": "查看联系人",
|
||||
"view_details": "查看详情"
|
||||
},
|
||||
"send_now": "立即发送",
|
||||
"verification_code": {
|
||||
|
||||
Reference in New Issue
Block a user