diff --git a/app/(main)/[locale]/contacts/page.tsx b/app/(main)/[locale]/contacts/page.tsx index c8fb7a46..aac399b4 100644 --- a/app/(main)/[locale]/contacts/page.tsx +++ b/app/(main)/[locale]/contacts/page.tsx @@ -2,6 +2,8 @@ import { useState, useEffect, useCallback, useRef, useMemo } from "react"; import { useTranslations } from "next-intl"; +import { useSearchParams } from "next/navigation"; +import { useRouter } from "@/i18n/navigation"; import { ArrowLeft, Users, AlertTriangle } from "lucide-react"; import { Button } from "@/components/ui/button"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; @@ -93,6 +95,8 @@ export default function ContactsPage() { const [renamingAddressBook, setRenamingAddressBook] = useState(null); const [sharingAddressBookId, setSharingAddressBookId] = useState(null); const [defaultBookIdForCreate, setDefaultBookIdForCreate] = useState(undefined); + const [createPrefill, setCreatePrefill] = useState<{ email?: string; name?: string } | undefined>(undefined); + const [returnToEmail, setReturnToEmail] = useState(false); const [renamingKeyword, setRenamingKeyword] = useState(null); const [selectedGroupId, setSelectedGroupId] = useState(null); const hasFetched = useRef(false); @@ -100,6 +104,12 @@ export default function ContactsPage() { const isMobile = useIsMobile(); const isDesktop = useIsDesktop(); const isEmbedded = useIsEmbedded(); + const router = useRouter(); + const searchParams = useSearchParams(); + // One-shot intent flag: only consume the URL params on the first render that + // has them. After applying, we strip the query so a later refresh or + // re-mount doesn't re-trigger the navigation. + const intentAppliedRef = useRef(false); // Narrow pane (Pro split or small window): the categories sidebar collapses // into a burger-toggled overlay. const isNarrow = !isDesktop; @@ -154,6 +164,29 @@ export default function ContactsPage() { } }, [client, supportsSync, fetchContacts, isEmbedded]); + // Consume one-shot URL params (set by the mobile recipient popover when no + // sidebar is available) and strip them so a refresh doesn't replay the + // intent. `from=email` flips the mobile back button to `router.back()`. + useEffect(() => { + if (intentAppliedRef.current) return; + const contactId = searchParams.get('contactId'); + const addEmail = searchParams.get('addEmail'); + const addName = searchParams.get('addName'); + const from = searchParams.get('from'); + if (!contactId && !addEmail && !from) return; + intentAppliedRef.current = true; + if (from === 'email') setReturnToEmail(true); + if (contactId) { + setSelectedContact(contactId); + setView('detail'); + } else if (addEmail) { + setCreatePrefill({ email: addEmail, name: addName ?? undefined }); + setSelectedContact(null); + setView('create'); + } + router.replace('/contacts'); + }, [searchParams, router, setSelectedContact]); + // Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh) // and refresh contacts via JMAP instead of reloading the page. useRefreshGesture({ @@ -365,8 +398,14 @@ export default function ContactsPage() { toast.success(t("toast.created")); } setDefaultBookIdForCreate(undefined); + setCreatePrefill(undefined); + if (returnToEmail) { + setReturnToEmail(false); + router.back(); + return; + } setView("list"); - }, [supportsSync, client, createContact, addLocalContact, t]); + }, [supportsSync, client, createContact, addLocalContact, t, returnToEmail, router]); const handleSaveEdit = useCallback(async (data: Partial) => { if (!selectedContact) return; @@ -383,6 +422,14 @@ export default function ContactsPage() { const handleCancel = () => { setDefaultBookIdForCreate(undefined); + // Came from email → cancel returns to the email instead of the contact list. + if (returnToEmail && view === "create") { + setCreatePrefill(undefined); + setReturnToEmail(false); + router.back(); + return; + } + if (view === "create") setCreatePrefill(undefined); if (view === "group-create" || view === "group-edit") { setView(selectedGroup ? "group-detail" : "list"); } else if (view === "bulk-add-to-group") { @@ -554,7 +601,7 @@ export default function ContactsPage() { const renderRightPanel = () => { switch (view) { case "create": - return ; + return ; case "edit": if (!selectedContact) return null; @@ -686,6 +733,12 @@ export default function ContactsPage() { const showRightPanel = !isMobile || view !== "list"; const mobileBackToList = () => { + if (returnToEmail) { + setReturnToEmail(false); + setCreatePrefill(undefined); + router.back(); + return; + } setView("list"); clearSelection(); }; @@ -853,7 +906,7 @@ export default function ContactsPage() { className="touch-manipulation" > - {t("back_to_contacts")} + {returnToEmail ? t("back_to_email") : t("back_to_contacts")} )} diff --git a/components/contacts/contact-form.tsx b/components/contacts/contact-form.tsx index c99491fa..462fdd9d 100644 --- a/components/contacts/contact-form.tsx +++ b/components/contacts/contact-form.tsx @@ -52,6 +52,8 @@ interface ContactFormProps { addressBooks?: AddressBook[]; allKeywords?: string[]; defaultAddressBookId?: string; + /** Prefills the create form (ignored when `contact` is set). */ + prefill?: { email?: string; name?: string }; onSave: (data: Partial) => Promise; onCancel: () => void; } @@ -145,10 +147,22 @@ function Select({ value, onChange, children, className }: { ); } -export function ContactForm({ contact, addressBooks, allKeywords, defaultAddressBookId, onSave, onCancel }: ContactFormProps) { +export function ContactForm({ contact, addressBooks, allKeywords, defaultAddressBookId, prefill, onSave, onCancel }: ContactFormProps) { const t = useTranslations("contacts.form"); const isEditing = !!contact; + // Split a free-form display name into given/surname for prefill. + const prefillGivenName = (() => { + if (contact || !prefill?.name) return ""; + const parts = prefill.name.trim().split(/\s+/); + return parts[0] || ""; + })(); + const prefillSurname = (() => { + if (contact || !prefill?.name) return ""; + const parts = prefill.name.trim().split(/\s+/); + return parts.slice(1).join(" "); + })(); + // Accept JSContact-standard kinds (RFC 9553) and legacy vCard-style aliases. const findComponent = (...kinds: string[]) => contact?.name?.components?.find(c => kinds.includes(c.kind))?.value || ""; @@ -215,9 +229,9 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress } const [prefix, setPrefix] = useState(findComponent("title", "prefix")); - const [givenName, setGivenName] = useState(findComponent("given")); + const [givenName, setGivenName] = useState(findComponent("given") || prefillGivenName); const [additionalName, setAdditionalName] = useState(findComponent("given2", "additional", "middle")); - const [surname, setSurname] = useState(findComponent("surname")); + const [surname, setSurname] = useState(findComponent("surname") || prefillSurname); const [suffix, setSuffix] = useState(findComponent("generation", "suffix")); const [nickname, setNickname] = useState( @@ -231,7 +245,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress context: e.contexts?.work ? "work" : e.contexts?.private ? "private" : "", })); } - return [{ address: "", context: "" }]; + return [{ address: prefill?.email || "", context: "" }]; }); const [phones, setPhones] = useState(() => { diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index ce196519..411468a6 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -65,6 +65,7 @@ import { PenSquare, } from "lucide-react"; import { useTranslations } from "next-intl"; +import { useRouter } from "@/i18n/navigation"; import type { Attachment as PostalMimeAttachment } from 'postal-mime'; import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store"; import { useUIStore } from "@/stores/ui-store"; @@ -1137,9 +1138,34 @@ export function EmailViewer({ const [contactSidebarEmail, setContactSidebarEmail] = useState(null); const contacts = useContactStore((s) => s.contacts); const { isMobile: isMobileDevice } = useDeviceDetection(); + const router = useRouter(); const handleViewContactSidebar = (contact: ContactCard | null, recipientEmail: string) => { - if (isMobileDevice) return; // no sidebar on mobile + if (isMobileDevice) { + // No room for a sidebar on mobile — send the user to the contacts page + // with params describing what to show. The `from=email` flag turns the + // page's mobile back button into a router.back() that returns here. + const allRecipients = [ + ...(email?.from || []), + ...(email?.to || []), + ...(email?.cc || []), + ...(email?.bcc || []), + ...(email?.replyTo || []), + ]; + const recipientName = allRecipients.find( + (r) => r.email.toLowerCase() === recipientEmail.toLowerCase() + )?.name; + const params = new URLSearchParams(); + if (contact) { + params.set('contactId', contact.id); + } else { + params.set('addEmail', recipientEmail); + if (recipientName) params.set('addName', recipientName); + } + params.set('from', 'email'); + router.push(`/contacts?${params.toString()}`); + return; + } setContactSidebarEmail(recipientEmail); }; diff --git a/locales/cs/common.json b/locales/cs/common.json index ee0d0883..af67b846 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -1950,6 +1950,7 @@ "delete_confirm": "Opravdu chcete odstranit tento kontakt?", "local_mode": "Kontakty jsou uloženy lokálně (server nepodporuje JMAP Contacts)", "back_to_contacts": "Zpět na kontakty", + "back_to_email": "Zpět na e-mail", "tabs": { "all": "Všechny", "groups": "Skupiny" diff --git a/locales/da/common.json b/locales/da/common.json index ab941962..66c1e56c 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -1950,6 +1950,7 @@ "delete_confirm": "Er du sikker på, at du vil slette denne kontakt?", "local_mode": "Kontakter gemmes lokalt (serveren understøtter ikke JMAP-kontakter)", "back_to_contacts": "Tilbage til kontakter", + "back_to_email": "Tilbage til e-mail", "tabs": { "all": "Alle", "groups": "Grupper" diff --git a/locales/de/common.json b/locales/de/common.json index 4b0c168d..40a4f82e 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -1962,6 +1962,7 @@ "delete_confirm": "Möchten Sie diesen Kontakt wirklich löschen?", "local_mode": "Kontakte werden lokal gespeichert (Server unterstützt kein JMAP Contacts)", "back_to_contacts": "Zurück zu Kontakten", + "back_to_email": "Zurück zur E-Mail", "tabs": { "all": "Alle", "groups": "Gruppen" diff --git a/locales/en/common.json b/locales/en/common.json index cd572e62..e1979b6d 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -1962,6 +1962,7 @@ "delete_confirm": "Are you sure you want to delete this contact?", "local_mode": "Contacts are stored locally (server does not support JMAP Contacts)", "back_to_contacts": "Back to contacts", + "back_to_email": "Back to email", "open_categories": "Open categories", "tabs": { "all": "All", diff --git a/locales/es/common.json b/locales/es/common.json index 0f530962..10d2aa31 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -1950,6 +1950,7 @@ "delete_confirm": "¿Estás seguro de que quieres eliminar este contacto?", "local_mode": "Los contactos se almacenan localmente (el servidor no soporta JMAP Contacts)", "back_to_contacts": "Volver a contactos", + "back_to_email": "Volver al correo", "tabs": { "all": "Todos", "groups": "Grupos" diff --git a/locales/fr/common.json b/locales/fr/common.json index c7b50e76..1da607b9 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -1950,6 +1950,7 @@ "delete_confirm": "Êtes-vous sûr de vouloir supprimer ce contact ?", "local_mode": "Les contacts sont stockés localement (le serveur ne prend pas en charge JMAP Contacts)", "back_to_contacts": "Retour aux contacts", + "back_to_email": "Retour à l'e-mail", "tabs": { "all": "Tous", "groups": "Groupes" diff --git a/locales/it/common.json b/locales/it/common.json index 19610f4b..847379cf 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -1950,6 +1950,7 @@ "delete_confirm": "Sei sicuro di voler eliminare questo contatto?", "local_mode": "I contatti sono salvati localmente (il server non supporta JMAP Contacts)", "back_to_contacts": "Torna ai contatti", + "back_to_email": "Torna all'e-mail", "tabs": { "all": "Tutti", "groups": "Gruppi" diff --git a/locales/ja/common.json b/locales/ja/common.json index fa7d13b5..22427a57 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -1950,6 +1950,7 @@ "delete_confirm": "この連絡先を削除してもよろしいですか?", "local_mode": "連絡先はローカルに保存されています(サーバーがJMAPコンタクトをサポートしていません)", "back_to_contacts": "連絡先に戻る", + "back_to_email": "メールに戻る", "tabs": { "all": "すべて", "groups": "グループ" diff --git a/locales/ko/common.json b/locales/ko/common.json index 2a88993c..041c0760 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -1950,6 +1950,7 @@ "delete_confirm": "정말 이 연락처를 삭제할까요?", "local_mode": "연락처가 로컬에 저장돼요 (서버가 JMAP Contacts를 지원하지 않아요)", "back_to_contacts": "연락처로 돌아가기", + "back_to_email": "이메일로 돌아가기", "tabs": { "all": "전체", "groups": "그룹" diff --git a/locales/lv/common.json b/locales/lv/common.json index adc2a089..d6cf53a4 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -1946,6 +1946,7 @@ "delete_confirm": "Vai tiešām vēlaties dzēst šo kontaktu?", "local_mode": "Kontakti tiek glabāti lokāli (serveris neatbalsta JMAP Contacts)", "back_to_contacts": "Atpakaļ pie kontaktiem", + "back_to_email": "Atpakaļ pie e-pasta", "tabs": { "all": "Visi", "groups": "Grupas" diff --git a/locales/nl/common.json b/locales/nl/common.json index 8652cab5..72adf8e4 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -1950,6 +1950,7 @@ "delete_confirm": "Weet u zeker dat u dit contact wilt verwijderen?", "local_mode": "Contacten worden lokaal opgeslagen (server ondersteunt geen JMAP Contacts)", "back_to_contacts": "Terug naar contacten", + "back_to_email": "Terug naar e-mail", "tabs": { "all": "Alle", "groups": "Groepen" diff --git a/locales/pl/common.json b/locales/pl/common.json index 1fa5dd5c..25f56e20 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -1950,6 +1950,7 @@ "delete_confirm": "Czy na pewno chcesz usunąć ten kontakt?", "local_mode": "Kontakty są przechowywane lokalnie (serwer nie obsługuje JMAP Contacts)", "back_to_contacts": "Powrót do kontaktów", + "back_to_email": "Powrót do wiadomości", "tabs": { "all": "Wszystkie", "groups": "Grupy" diff --git a/locales/pt/common.json b/locales/pt/common.json index 0982fefd..c0566111 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -1950,6 +1950,7 @@ "delete_confirm": "Tem certeza de que deseja excluir este contato?", "local_mode": "Os contatos são armazenados localmente (o servidor não suporta JMAP Contacts)", "back_to_contacts": "Voltar aos contatos", + "back_to_email": "Voltar ao e-mail", "tabs": { "all": "Todos", "groups": "Grupos" diff --git a/locales/ru/common.json b/locales/ru/common.json index 4d199691..54687350 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -1950,6 +1950,7 @@ "delete_confirm": "Вы уверены, что хотите удалить этот контакт?", "local_mode": "Контакты хранятся локально (сервер не поддерживает JMAP Contacts)", "back_to_contacts": "Вернуться к контактам", + "back_to_email": "Вернуться к письму", "tabs": { "all": "Все", "groups": "Группы" diff --git a/locales/tr/common.json b/locales/tr/common.json index d05fd476..3c343cfa 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -1950,6 +1950,7 @@ "delete_confirm": "Bu kişiyi silmek istediğinizden emin misiniz?", "local_mode": "Kişiler yerel olarak saklanıyor (sunucu JMAP Kişilerini desteklemiyor)", "back_to_contacts": "Kişilere geri dön", + "back_to_email": "E-postaya geri dön", "tabs": { "all": "Tümü", "groups": "Gruplar" diff --git a/locales/uk/common.json b/locales/uk/common.json index d7601acd..7e182fe9 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -1950,6 +1950,7 @@ "delete_confirm": "Ви впевнені, що хочете видалити цей контакт?", "local_mode": "Контакти зберігаються локально (сервер не підтримує контакти JMAP)", "back_to_contacts": "Назад до контактів", + "back_to_email": "Назад до листа", "tabs": { "all": "все", "groups": "Групи" diff --git a/locales/zh/common.json b/locales/zh/common.json index 0240363f..f18f7767 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -1950,6 +1950,7 @@ "delete_confirm": "您确定要删除此联系人吗?", "local_mode": "联系人存储在本地(服务器不支持 JMAP 联系人)", "back_to_contacts": "返回联系人", + "back_to_email": "返回邮件", "tabs": { "all": "全部", "groups": "群组"