diff --git a/components/email/__tests__/composer-mention.test.tsx b/components/email/__tests__/composer-mention.test.tsx index 317835ae8..35776d325 100644 --- a/components/email/__tests__/composer-mention.test.tsx +++ b/components/email/__tests__/composer-mention.test.tsx @@ -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(); diff --git a/components/email/__tests__/recipient-popover.test.tsx b/components/email/__tests__/recipient-popover.test.tsx index 9a5306720..de864b1e5 100644 --- a/components/email/__tests__/recipient-popover.test.tsx +++ b/components/email/__tests__/recipient-popover.test.tsx @@ -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( + {}} + actions={} + />, + ); + 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(); + 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); + }); +}); diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 2427a5e86..8f95cf80e 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -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>( () => { 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 && ( + r.email.toLowerCase() === bodyMention.email.toLowerCase())?.name || undefined} + onViewContact={handleViewContactSidebar} + onClose={() => setBodyMention(null)} + /> + )} + {/* Contact Detail Sidebar - desktop only */} {contactSidebarEmail && !isMobileDevice && ( ]/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); }} > -
- -
-
{name}
-
{email}
-
-
- {outside && ( -

{t("mention_card_not_recipient")}

- )} -
- {outside && onAdd && ( + - {t("mention_card_add", { field: t(addsTo) })} + + {t("mention_card_remove")} + } + > + {outside && ( +
+

{t("mention_card_not_recipient")}

+ {onAdd && ( + + )} +
)} - -
+ , document.body, ); diff --git a/components/email/recipient-popover.tsx b/components/email/recipient-popover.tsx index 929aa84c2..faacdef5a 100644 --- a/components/email/recipient-popover.tsx +++ b/components/email/recipient-popover.tsx @@ -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(null); - const popoverRef = useRef(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 */} +
+ +
+
+ {contactName || shownEmail} +
+ {contactName && contactName.toLowerCase() !== email.toLowerCase() && ( +
+ {shownEmail} +
+ )} + {orgs.length > 0 && orgs[0].name && ( +
+ + {orgs[0].name} +
+ )} +
+
+ + {/* Contact details */} +
+ {/* Show additional emails if contact has them */} + {emails.length > 1 && ( +
+ {emails.slice(1).map((e, i) => ( +
+ + {e.address} +
+ ))} +
+ )} + + {/* Phone numbers */} + {phones.length > 0 && ( +
+ {phones.map((p, i) => ( + + ))} +
+ )} + + {children} +
+ + {/* Actions */} +
+ + + + {t("action_email")} + + {onViewContact && ( + + )} + {actions} +
+ + ); +} + +/** 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; +} + +/** + * 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(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( +
+ +
, + 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(null); + const triggerRef = useRef(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 ( <> - {isOpen && - position && - createPortal( -
- {/* Header with avatar and name */} -
- -
-
- {contactName || shownEmail} -
- {contactName && contactName.toLowerCase() !== email.toLowerCase() && ( -
- {shownEmail} -
- )} - {orgs.length > 0 && orgs[0].name && ( -
- - {orgs[0].name} -
- )} -
-
- - {/* Contact details */} -
- {/* Show additional emails if contact has them */} - {emails.length > 1 && ( -
- {emails.slice(1).map((e, i) => ( -
- - {e.address} -
- ))} -
- )} - - {/* Phone numbers */} - {phones.length > 0 && ( -
- {phones.map((p, i) => ( - - ))} -
- )} -
- - {/* Actions */} -
- - - - Email - - {onViewContact && ( - - )} -
-
, - document.body - )} + {anchor && ( + setAnchor(null)} + ignoreRef={triggerRef} + /> + )} ); } diff --git a/lib/recipient-mentions.ts b/lib/recipient-mentions.ts index c52bcc4b4..ed60ee6fc 100644 --- a/lib/recipient-mentions.ts +++ b/lib/recipient-mentions.ts @@ -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 }; +} diff --git a/locales/ar/common.json b/locales/ar/common.json index 94d47c4a9..170e58fd7 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -622,7 +622,9 @@ "not_in_contacts": "ليست ضمن جهات اتصالك", "add_to_contacts": "إضافة إلى جهات الاتصال", "copied": "تم النسخ!", - "copy_failed": "فشل النسخ" + "copy_failed": "فشل النسخ", + "view_contact": "عرض جهة الاتصال", + "view_details": "عرض التفاصيل" }, "send_now": "إرسال الآن", "verification_code": { diff --git a/locales/ca/common.json b/locales/ca/common.json index 8a137fcd9..66dbfe51d 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -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": { diff --git a/locales/cs/common.json b/locales/cs/common.json index 3232e7647..f9f1af7a5 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -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": { diff --git a/locales/da/common.json b/locales/da/common.json index 450e650ec..f0be6ed13 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -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": { diff --git a/locales/de/common.json b/locales/de/common.json index cda2d2d80..ed88b5585 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -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": { diff --git a/locales/en/common.json b/locales/en/common.json index f3953ff72..004a083ec 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -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": { diff --git a/locales/es/common.json b/locales/es/common.json index b71cee3f3..c8c4f4602 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -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": { diff --git a/locales/et/common.json b/locales/et/common.json index b1704049f..72ca924b9 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -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": { diff --git a/locales/fa/common.json b/locales/fa/common.json index ceed4146e..8b04e2d21 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -622,7 +622,9 @@ "not_in_contacts": "در مخاطبین شما نیست", "add_to_contacts": "افزودن به مخاطبین", "copied": "کپی شد!", - "copy_failed": "کپی ناموفق بود" + "copy_failed": "کپی ناموفق بود", + "view_contact": "مشاهده مخاطب", + "view_details": "مشاهده جزئیات" }, "send_now": "ارسال فوری", "verification_code": { diff --git a/locales/fr/common.json b/locales/fr/common.json index cc88f4a75..b820bfe17 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -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": { diff --git a/locales/he/common.json b/locales/he/common.json index b62151940..4c2a521e9 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -587,7 +587,9 @@ "not_in_contacts": "לא בקשרים שלך", "add_to_contacts": "הוסף לקשרים", "copied": "הועתק!", - "copy_failed": "העתקה נכשלה" + "copy_failed": "העתקה נכשלה", + "view_contact": "הצגת איש הקשר", + "view_details": "הצגת פרטים" }, "send_now": "שלח עכשיו", "verification_code": { diff --git a/locales/hu/common.json b/locales/hu/common.json index e372879ce..35b405f78 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -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": { diff --git a/locales/it/common.json b/locales/it/common.json index 457be8ae8..b89fab78a 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -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": { diff --git a/locales/ja/common.json b/locales/ja/common.json index a13d7b83c..66d5b9ded 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -622,7 +622,9 @@ "not_in_contacts": "連絡先に登録されていません", "add_to_contacts": "連絡先に追加", "copied": "コピーしました!", - "copy_failed": "コピーに失敗しました" + "copy_failed": "コピーに失敗しました", + "view_contact": "連絡先を表示", + "view_details": "詳細を表示" }, "send_now": "今すぐ送信", "verification_code": { diff --git a/locales/ko/common.json b/locales/ko/common.json index 17ed49b6f..436fe7e3f 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -622,7 +622,9 @@ "not_in_contacts": "연락처에 없음", "add_to_contacts": "연락처에 추가", "copied": "복사됨!", - "copy_failed": "복사하지 못했습니다" + "copy_failed": "복사하지 못했습니다", + "view_contact": "연락처 보기", + "view_details": "세부 정보 보기" }, "send_now": "지금 보내기", "verification_code": { diff --git a/locales/lv/common.json b/locales/lv/common.json index 5ca5f882f..dd6de77ff 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -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": { diff --git a/locales/mn/common.json b/locales/mn/common.json index b2a713c54..94dcb7570 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -622,7 +622,9 @@ "not_in_contacts": "Таны харилцагчид байхгүй", "add_to_contacts": "Харилцагчдад нэмэх", "copied": "Хуулбарласан!", - "copy_failed": "Хуулж чадсангүй" + "copy_failed": "Хуулж чадсангүй", + "view_contact": "Харилцагчийг харах", + "view_details": "Дэлгэрэнгүй харах" }, "send_now": "Одоо илгээх", "verification_code": { diff --git a/locales/nb/common.json b/locales/nb/common.json index a1b1650f5..8ca6cea92 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -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": { diff --git a/locales/nl/common.json b/locales/nl/common.json index a8380f379..979f5ecb2 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -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": { diff --git a/locales/pl/common.json b/locales/pl/common.json index 8568c0b1b..18062e6dc 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -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": { diff --git a/locales/pt/common.json b/locales/pt/common.json index 4caa3a523..86d3b48ba 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -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": { diff --git a/locales/ro/common.json b/locales/ro/common.json index 977b02210..50c73c026 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -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": { diff --git a/locales/ru/common.json b/locales/ru/common.json index 0e0f194b0..cc32e222e 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -622,7 +622,9 @@ "not_in_contacts": "Нет в ваших контактах", "add_to_contacts": "Добавить в контакты", "copied": "Скопировано!", - "copy_failed": "Не удалось скопировать" + "copy_failed": "Не удалось скопировать", + "view_contact": "Открыть контакт", + "view_details": "Подробнее" }, "send_now": "Отправить сейчас", "verification_code": { diff --git a/locales/sk/common.json b/locales/sk/common.json index e74fe5089..acb1cbd36 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -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": { diff --git a/locales/tr/common.json b/locales/tr/common.json index d03297699..097c3d206 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -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": { diff --git a/locales/uk/common.json b/locales/uk/common.json index 05f138b15..1d8c35959 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -622,7 +622,9 @@ "not_in_contacts": "Немає у ваших контактах", "add_to_contacts": "Додати до контактів", "copied": "Скопійовано!", - "copy_failed": "Не вдалося скопіювати" + "copy_failed": "Не вдалося скопіювати", + "view_contact": "Переглянути контакт", + "view_details": "Докладніше" }, "send_now": "Надіслати зараз", "verification_code": { diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index bc242d47a..bd513c957 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -622,7 +622,9 @@ "not_in_contacts": "不在您的聯絡人清單中", "add_to_contacts": "新增至聯絡人", "copied": "已複製!", - "copy_failed": "複製失敗" + "copy_failed": "複製失敗", + "view_contact": "檢視聯絡人", + "view_details": "檢視詳細資料" }, "send_now": "立即傳送", "verification_code": { diff --git a/locales/zh/common.json b/locales/zh/common.json index e83c34f17..66518df84 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -622,7 +622,9 @@ "not_in_contacts": "不在您的联系人中", "add_to_contacts": "添加到联系人", "copied": "已复制!", - "copy_failed": "复制失败" + "copy_failed": "复制失败", + "view_contact": "查看联系人", + "view_details": "查看详情" }, "send_now": "立即发送", "verification_code": {