diff --git a/src/components/notes/NoteEditor.tsx b/src/components/notes/NoteEditor.tsx index 17e847b26..16dcefe7b 100644 --- a/src/components/notes/NoteEditor.tsx +++ b/src/components/notes/NoteEditor.tsx @@ -13,7 +13,10 @@ import { Search, Plus, Check, + Share2, } from "lucide-react"; +import ShareNoteDialog from "./ShareNoteDialog"; +import { useShareCacheEntry } from "../../stores/noteStore"; import { RichTextEditor } from "../ui/RichTextEditor"; import type { Editor } from "@tiptap/react"; import { MeetingTranscriptChat, SelectionBar } from "./MeetingTranscriptChat"; @@ -147,6 +150,9 @@ export default function NoteEditor({ const [isCreatingFolder, setIsCreatingFolder] = useState(false); const [newFolderName, setNewFolderName] = useState(""); const [isDiarizing, setIsDiarizing] = useState(false); + const [shareDialogOpen, setShareDialogOpen] = useState(false); + const shareCache = useShareCacheEntry(note.cloud_id); + const isShared = (shareCache?.share.visibility ?? "private") !== "private"; const [diarizedSegments, setDiarizedSegments] = useState(null); const [speakerMappings, setSpeakerMappings] = useState>({}); const [speakerProfiles, setSpeakerProfiles] = useState< @@ -778,6 +784,31 @@ export default function NoteEditor({ )} )} + {note.cloud_id && ( + + )} {(onExportNote || onExportTranscript) && ( @@ -947,6 +978,13 @@ export default function NoteEditor({ onNewChat={embeddedChat.startNewChat} /> )} + {note.cloud_id && ( + + )} ); } diff --git a/src/components/notes/ShareNoteDialog.tsx b/src/components/notes/ShareNoteDialog.tsx new file mode 100644 index 000000000..b3648e833 --- /dev/null +++ b/src/components/notes/ShareNoteDialog.tsx @@ -0,0 +1,423 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Check, Copy, Loader2, MoreHorizontal } from "lucide-react"; +import { Dialog, DialogContent, DialogTitle } from "../ui/dialog"; +import { Button } from "../ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "../ui/dropdown-menu"; +import { cn } from "../lib/utils"; +import ShareVisibilityMenu from "./ShareVisibilityMenu"; +import { useAuth } from "../../hooks/useAuth"; +import { NoteSharingService } from "../../services/NoteSharingService.js"; +import { + setShareCache, + updateShareCache, + useShareCacheEntry, +} from "../../stores/noteStore"; +import { useLocalStorage } from "../../hooks/useLocalStorage"; +import { + emailDomain, + isPersonalEmailDomain, +} from "../../utils/personalEmailDomains"; +import type { + NoteItem, + NoteShareInvitation, + ShareSettings, + ShareVisibility, +} from "../../types/electron"; + +const SHARE_VIEWER_BASE_URL = "https://notes.openwhispr.com"; +const LAST_VISIBILITY_KEY = "openwhispr.shareDefaultVisibility"; +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +interface ShareNoteDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + note: NoteItem; +} + +export default function ShareNoteDialog({ + open, + onOpenChange, + note, +}: ShareNoteDialogProps) { + const { user } = useAuth(); + const ownerName: string | null = user?.name ?? null; + const ownerEmail: string = user?.email ?? ""; + // The desktop has no concept of multi-user workspaces yet — the signed-in + // user is always the note owner. When workspaces ship, this becomes a + // server-side flag in the share settings response. + const isOwner = Boolean(user); + const { t } = useTranslation(); + const cloudId = note.cloud_id; + const cached = useShareCacheEntry(cloudId); + const [defaultVisibility, setDefaultVisibility] = useLocalStorage( + LAST_VISIBILITY_KEY, + "invited" + ); + + const [loading, setLoading] = useState(false); + const [savingVisibility, setSavingVisibility] = useState(false); + const [emailInput, setEmailInput] = useState(""); + const [inputError, setInputError] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [copied, setCopied] = useState(false); + const emailInputRef = useRef(null); + const copyTimeoutRef = useRef(null); + + const ownerDomain = useMemo(() => emailDomain(ownerEmail), [ownerEmail]); + const showDomainOption = ownerDomain && !isPersonalEmailDomain(ownerDomain); + + const share = cached?.share ?? null; + const invitations = cached?.invitations ?? []; + + // Load share state once per open. The dialog is the only place that reads + // share settings — invalidating on close keeps the cache scoped. + useEffect(() => { + if (!open || !cloudId) return; + let cancelled = false; + setLoading(true); + NoteSharingService.getShareSettings(cloudId) + .then((res) => { + if (cancelled) return; + setShareCache(cloudId, { + share: res.share, + invitations: res.invitations, + rawToken: null, + }); + }) + .catch((err) => { + if (!cancelled) console.error("Failed to load share settings:", err); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [open, cloudId]); + + // Apply the user's last-used visibility on first open of a still-private note. + useEffect(() => { + if (!open || !cloudId || !share || !isOwner) return; + if (share.visibility !== "private") return; + void applyVisibility(defaultVisibility, share); + // applyVisibility is stable-by-closure; suppressing exhaustive-deps lint + // would obscure intent. We genuinely only want this on first hit. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, share?.visibility]); + + useEffect(() => { + if (open) { + emailInputRef.current?.focus(); + } else { + setEmailInput(""); + setInputError(null); + setCopied(false); + } + }, [open]); + + useEffect(() => { + return () => { + if (copyTimeoutRef.current) window.clearTimeout(copyTimeoutRef.current); + }; + }, []); + + const applyVisibility = useCallback( + async (next: ShareVisibility, current: ShareSettings | null) => { + if (!cloudId || !current) return; + if (current.visibility === next) return; + setSavingVisibility(true); + const previous = current; + // Optimistic update so the dropdown feels instant. + updateShareCache(cloudId, (entry) => ({ + share: { + ...(entry?.share ?? previous), + visibility: next, + domain_allowlist: + next === "domain" && ownerDomain + ? entry?.share.domain_allowlist.length + ? entry.share.domain_allowlist + : [ownerDomain] + : entry?.share.domain_allowlist ?? [], + }, + invitations: entry?.invitations ?? [], + rawToken: entry?.rawToken ?? null, + })); + try { + const res = await NoteSharingService.updateShareSettings( + cloudId, + next, + next === "domain" && ownerDomain ? [ownerDomain] : [] + ); + updateShareCache(cloudId, (entry) => ({ + share: res.share, + invitations: entry?.invitations ?? [], + rawToken: res.raw_token ?? entry?.rawToken ?? null, + })); + setDefaultVisibility(next); + } catch (err) { + console.error("Failed to update visibility:", err); + updateShareCache(cloudId, (entry) => ({ + share: previous, + invitations: entry?.invitations ?? [], + rawToken: entry?.rawToken ?? null, + })); + } finally { + setSavingVisibility(false); + } + }, + [cloudId, ownerDomain, setDefaultVisibility] + ); + + const handleCopyLink = useCallback(async () => { + if (!cached?.rawToken) { + setInputError(t("noteEditor.share.dialog.error.linkUnavailable")); + return; + } + const url = `${SHARE_VIEWER_BASE_URL}/n/${encodeURIComponent(cached.rawToken)}`; + try { + await navigator.clipboard.writeText(url); + setCopied(true); + if (copyTimeoutRef.current) window.clearTimeout(copyTimeoutRef.current); + copyTimeoutRef.current = window.setTimeout(() => setCopied(false), 1500); + } catch (err) { + console.error("Clipboard write failed:", err); + } + }, [cached?.rawToken, t]); + + const handleInvite = useCallback(async () => { + if (!cloudId) return; + const trimmed = emailInput.trim(); + if (!trimmed) return; + if (!EMAIL_REGEX.test(trimmed)) { + setInputError(t("noteEditor.share.dialog.error.invalidEmail")); + return; + } + setInputError(null); + setSubmitting(true); + try { + const res = await NoteSharingService.inviteEmails(cloudId, [trimmed]); + if (res.already_invited.length > 0) { + setInputError( + t("noteEditor.share.dialog.error.alreadyInvited", { email: res.already_invited[0] }) + ); + } else { + setEmailInput(""); + } + // Re-fetch invitations so any new + still-pending rows appear. + const refreshed = await NoteSharingService.getShareSettings(cloudId); + updateShareCache(cloudId, (entry) => ({ + share: refreshed.share, + invitations: refreshed.invitations, + rawToken: entry?.rawToken ?? null, + })); + } catch (err) { + console.error("Invite failed:", err); + setInputError(t("noteEditor.share.dialog.error.inviteFailed")); + } finally { + setSubmitting(false); + } + }, [cloudId, emailInput, t]); + + const handleRevoke = useCallback( + async (invitation: NoteShareInvitation) => { + if (!cloudId) return; + updateShareCache(cloudId, (entry) => ({ + share: entry?.share ?? share!, + invitations: (entry?.invitations ?? invitations).filter((i) => i.id !== invitation.id), + rawToken: entry?.rawToken ?? null, + })); + try { + await NoteSharingService.revokeInvite(cloudId, invitation.id); + } catch (err) { + console.error("Revoke failed:", err); + } + }, + [cloudId, share, invitations] + ); + + const handleResend = useCallback( + async (invitation: NoteShareInvitation) => { + if (!cloudId) return; + try { + await NoteSharingService.resendInvite(cloudId, invitation.id); + } catch (err) { + console.error("Resend failed:", err); + } + }, + [cloudId] + ); + + const onKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" || (e.metaKey && e.key === "Enter")) { + e.preventDefault(); + void handleInvite(); + } + }; + + // NoteEditor only renders the dialog when cloud_id is set, so cloudId is + // guaranteed non-null here. We early-return for type-narrowing. + if (!cloudId) return null; + + return ( + + + {t("noteEditor.share.dialog.title")} + + {/* Invite row */} +
+ { + setEmailInput(e.target.value); + if (inputError) setInputError(null); + }} + onKeyDown={onKeyDown} + placeholder={t("noteEditor.share.dialog.searchPlaceholder")} + disabled={!isOwner || submitting} + className={cn( + "flex-1 h-8 px-2.5 rounded-md text-xs", + "bg-foreground/4 dark:bg-white/5 text-foreground placeholder:text-foreground/40", + "border border-transparent", + "focus:outline-none focus:bg-background dark:focus:bg-surface-1 focus:border-border/60", + "disabled:opacity-50 disabled:cursor-not-allowed transition-colors" + )} + aria-label={t("noteEditor.share.dialog.emailLabel")} + /> + +
+ + {inputError && ( +

{inputError}

+ )} + + {/* Members list */} +
+ + {t("noteEditor.share.dialog.owner")} + + } + /> + + {invitations.map((invitation) => ( + + + + + + void handleResend(invitation)} + > + {t("noteEditor.share.dialog.resend")} + + void handleRevoke(invitation)} + > + {t("noteEditor.share.dialog.revoke")} + + + + ) : null + } + /> + ))} +
+ + {/* Footer: visibility + copy link */} +
+ void applyVisibility(v, share)} + /> +
+ +
+ + {!isOwner && ( +

+ {t("noteEditor.share.dialog.permissionRequired")} +

+ )} + +
+ ); +} + +interface MemberRowProps { + primary: string; + secondary: string; + trailing: React.ReactNode; +} + +function MemberRow({ primary, secondary, trailing }: MemberRowProps) { + return ( +
+
+

{primary}

+

{secondary}

+
+ {trailing} +
+ ); +} diff --git a/src/components/notes/ShareVisibilityMenu.tsx b/src/components/notes/ShareVisibilityMenu.tsx new file mode 100644 index 000000000..f844658a1 --- /dev/null +++ b/src/components/notes/ShareVisibilityMenu.tsx @@ -0,0 +1,123 @@ +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import { Check, ChevronDown, Globe, Lock, Building2 } from "lucide-react"; +import { cn } from "../lib/utils"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "../ui/dropdown-menu"; +import type { ShareVisibility } from "../../types/electron"; + +interface ShareVisibilityMenuProps { + value: ShareVisibility; + ownerDomain: string; + showDomainOption: boolean; + disabled?: boolean; + onChange: (visibility: ShareVisibility) => void; +} + +export default function ShareVisibilityMenu({ + value, + ownerDomain, + showDomainOption, + disabled, + onChange, +}: ShareVisibilityMenuProps) { + const { t } = useTranslation(); + + const current = renderCurrent(value, ownerDomain, t); + + return ( + + + + + + } + label={t("noteEditor.share.dialog.visibility.invited")} + active={value === "invited"} + onSelect={() => onChange("invited")} + /> + } + label={t("noteEditor.share.dialog.visibility.link")} + active={value === "link"} + onSelect={() => onChange("link")} + /> + {showDomainOption && ( + } + label={t("noteEditor.share.dialog.visibility.domain", { domain: ownerDomain })} + active={value === "domain"} + onSelect={() => onChange("domain")} + /> + )} + + + ); +} + +function renderCurrent( + value: ShareVisibility, + ownerDomain: string, + t: ReturnType["t"] +): { icon: React.ReactNode; label: string } { + switch (value) { + case "link": + return { + icon: , + label: t("noteEditor.share.dialog.visibility.link"), + }; + case "domain": + return { + icon: , + label: t("noteEditor.share.dialog.visibility.domain", { domain: ownerDomain }), + }; + case "private": + case "invited": + default: + return { + icon: , + label: t("noteEditor.share.dialog.visibility.invited"), + }; + } +} + +function VisibilityItem({ + icon, + label, + active, + onSelect, +}: { + icon: React.ReactNode; + label: string; + active: boolean; + onSelect: () => void; +}) { + return ( + + {icon} + {label} + {active && } + + ); +} diff --git a/src/locales/de/translation.json b/src/locales/de/translation.json index ad5731540..b2fa217f1 100644 --- a/src/locales/de/translation.json +++ b/src/locales/de/translation.json @@ -2498,7 +2498,37 @@ "deleteNote": "Notiz löschen", "deleteConfirmTitle": "Notiz löschen?", "deleteConfirmDescription": "Diese Notiz wird dauerhaft gelöscht und kann nicht wiederhergestellt werden.", - "deleteConfirm": "Löschen" + "deleteConfirm": "Löschen", + "share": { + "button": "Teilen", + "dialog": { + "title": "Notiz teilen", + "searchPlaceholder": "Personen per E-Mail hinzufügen", + "emailLabel": "E-Mail des Empfängers", + "shareButton": "Teilen", + "copyLink": "Link kopieren", + "copied": "Kopiert", + "owner": "Eigentümer", + "pending": "Ausstehend", + "accepted": "Hat Zugriff", + "resend": "Einladung erneut senden", + "revoke": "Entfernen", + "invitationActions": "Einladungsaktionen", + "permissionRequired": "Nur der Notiz-Eigentümer kann die Freigabe ändern.", + "visibility": { + "label": "Sichtbarkeit", + "link": "Jeder mit dem Link", + "domain": "Alle bei {{domain}} mit dem Link", + "invited": "Nur eingeladene Personen" + }, + "error": { + "invalidEmail": "Gib eine gültige E-Mail-Adresse ein", + "alreadyInvited": "{{email}} ist bereits eingeladen", + "inviteFailed": "Die Einladung konnte nicht gesendet werden. Versuche es erneut.", + "linkUnavailable": "Generiere oder rotiere den Link, um ihn zu kopieren" + } + } + } }, "commandSearch": { "title": "Suche", diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 0ba5ec742..bf782434e 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -2594,7 +2594,37 @@ "deleteNote": "Delete note", "deleteConfirmTitle": "Delete note?", "deleteConfirmDescription": "This note will be permanently deleted and cannot be recovered.", - "deleteConfirm": "Delete" + "deleteConfirm": "Delete", + "share": { + "button": "Share", + "dialog": { + "title": "Share note", + "searchPlaceholder": "Add people by email", + "emailLabel": "Recipient email", + "shareButton": "Share", + "copyLink": "Copy link", + "copied": "Copied", + "owner": "Owner", + "pending": "Pending", + "accepted": "Has access", + "resend": "Resend invite", + "revoke": "Remove", + "invitationActions": "Invitation actions", + "permissionRequired": "Only the note owner can change sharing.", + "visibility": { + "label": "Visibility", + "link": "Anyone with the link", + "domain": "Everyone at {{domain}} with the link", + "invited": "Only people invited" + }, + "error": { + "invalidEmail": "Enter a valid email address", + "alreadyInvited": "{{email}} is already invited", + "inviteFailed": "Couldn't send the invitation. Try again.", + "linkUnavailable": "Generate or rotate the link to copy it" + } + } + } }, "commandSearch": { "title": "Search", diff --git a/src/locales/es/translation.json b/src/locales/es/translation.json index 717af76e0..1ffe2d1f4 100644 --- a/src/locales/es/translation.json +++ b/src/locales/es/translation.json @@ -2546,7 +2546,37 @@ "deleteNote": "Eliminar nota", "deleteConfirmTitle": "¿Eliminar nota?", "deleteConfirmDescription": "Esta nota se eliminará permanentemente y no se podrá recuperar.", - "deleteConfirm": "Eliminar" + "deleteConfirm": "Eliminar", + "share": { + "button": "Compartir", + "dialog": { + "title": "Compartir nota", + "searchPlaceholder": "Añade personas por correo", + "emailLabel": "Correo del destinatario", + "shareButton": "Compartir", + "copyLink": "Copiar enlace", + "copied": "Copiado", + "owner": "Propietario", + "pending": "Pendiente", + "accepted": "Tiene acceso", + "resend": "Reenviar invitación", + "revoke": "Quitar", + "invitationActions": "Acciones de la invitación", + "permissionRequired": "Solo el propietario de la nota puede cambiar el uso compartido.", + "visibility": { + "label": "Visibilidad", + "link": "Cualquiera con el enlace", + "domain": "Todos en {{domain}} con el enlace", + "invited": "Solo personas invitadas" + }, + "error": { + "invalidEmail": "Introduce un correo válido", + "alreadyInvited": "{{email}} ya está invitado", + "inviteFailed": "No se pudo enviar la invitación. Inténtalo de nuevo.", + "linkUnavailable": "Genera o rota el enlace para copiarlo" + } + } + } }, "commandSearch": { "title": "Buscar", diff --git a/src/locales/fr/translation.json b/src/locales/fr/translation.json index 9c8480ff4..c8b47be61 100644 --- a/src/locales/fr/translation.json +++ b/src/locales/fr/translation.json @@ -2546,7 +2546,37 @@ "deleteNote": "Supprimer la note", "deleteConfirmTitle": "Supprimer la note ?", "deleteConfirmDescription": "Cette note sera supprimée définitivement et ne pourra pas être récupérée.", - "deleteConfirm": "Supprimer" + "deleteConfirm": "Supprimer", + "share": { + "button": "Partager", + "dialog": { + "title": "Partager la note", + "searchPlaceholder": "Ajoute des personnes par e-mail", + "emailLabel": "E-mail du destinataire", + "shareButton": "Partager", + "copyLink": "Copier le lien", + "copied": "Copié", + "owner": "Propriétaire", + "pending": "En attente", + "accepted": "A accès", + "resend": "Renvoyer l'invitation", + "revoke": "Retirer", + "invitationActions": "Actions de l'invitation", + "permissionRequired": "Seul le propriétaire de la note peut modifier le partage.", + "visibility": { + "label": "Visibilité", + "link": "Toute personne ayant le lien", + "domain": "Tout le monde chez {{domain}} avec le lien", + "invited": "Uniquement les personnes invitées" + }, + "error": { + "invalidEmail": "Entre une adresse e-mail valide", + "alreadyInvited": "{{email}} est déjà invité(e)", + "inviteFailed": "Impossible d'envoyer l'invitation. Réessaie.", + "linkUnavailable": "Génère ou renouvelle le lien pour le copier" + } + } + } }, "commandSearch": { "title": "Rechercher", diff --git a/src/locales/it/translation.json b/src/locales/it/translation.json index 8c5797266..1ca0887eb 100644 --- a/src/locales/it/translation.json +++ b/src/locales/it/translation.json @@ -2498,7 +2498,37 @@ "deleteNote": "Elimina nota", "deleteConfirmTitle": "Eliminare la nota?", "deleteConfirmDescription": "Questa nota verrà eliminata definitivamente e non potrà essere recuperata.", - "deleteConfirm": "Elimina" + "deleteConfirm": "Elimina", + "share": { + "button": "Condividi", + "dialog": { + "title": "Condividi nota", + "searchPlaceholder": "Aggiungi persone tramite email", + "emailLabel": "Email del destinatario", + "shareButton": "Condividi", + "copyLink": "Copia link", + "copied": "Copiato", + "owner": "Proprietario", + "pending": "In attesa", + "accepted": "Ha accesso", + "resend": "Reinvia invito", + "revoke": "Rimuovi", + "invitationActions": "Azioni invito", + "permissionRequired": "Solo il proprietario della nota può modificare la condivisione.", + "visibility": { + "label": "Visibilità", + "link": "Chiunque con il link", + "domain": "Tutti in {{domain}} con il link", + "invited": "Solo le persone invitate" + }, + "error": { + "invalidEmail": "Inserisci un indirizzo email valido", + "alreadyInvited": "{{email}} è già stato invitato", + "inviteFailed": "Impossibile inviare l'invito. Riprova.", + "linkUnavailable": "Genera o ruota il link per copiarlo" + } + } + } }, "commandSearch": { "title": "Cerca", diff --git a/src/locales/ja/translation.json b/src/locales/ja/translation.json index 95663ac1b..2d232d06d 100644 --- a/src/locales/ja/translation.json +++ b/src/locales/ja/translation.json @@ -2498,7 +2498,37 @@ "deleteNote": "ノートを削除", "deleteConfirmTitle": "ノートを削除しますか?", "deleteConfirmDescription": "このノートは完全に削除され、復元できません。", - "deleteConfirm": "削除" + "deleteConfirm": "削除", + "share": { + "button": "共有", + "dialog": { + "title": "ノートを共有", + "searchPlaceholder": "メールアドレスでメンバーを追加", + "emailLabel": "受信者のメールアドレス", + "shareButton": "共有", + "copyLink": "リンクをコピー", + "copied": "コピーしました", + "owner": "オーナー", + "pending": "保留中", + "accepted": "アクセス権あり", + "resend": "招待を再送信", + "revoke": "削除", + "invitationActions": "招待の操作", + "permissionRequired": "共有設定を変更できるのはノートのオーナーのみです。", + "visibility": { + "label": "公開範囲", + "link": "リンクを持つ全員", + "domain": "{{domain}} の全員(リンクを持つ人)", + "invited": "招待された人のみ" + }, + "error": { + "invalidEmail": "有効なメールアドレスを入力してください", + "alreadyInvited": "{{email}} はすでに招待されています", + "inviteFailed": "招待を送信できませんでした。もう一度お試しください。", + "linkUnavailable": "リンクをコピーするには生成またはローテーションしてください" + } + } + } }, "commandSearch": { "title": "検索", diff --git a/src/locales/pt/translation.json b/src/locales/pt/translation.json index 5f778e0e3..c08a48f6b 100644 --- a/src/locales/pt/translation.json +++ b/src/locales/pt/translation.json @@ -2498,7 +2498,37 @@ "deleteNote": "Excluir nota", "deleteConfirmTitle": "Excluir nota?", "deleteConfirmDescription": "Esta nota será excluída permanentemente e não poderá ser recuperada.", - "deleteConfirm": "Excluir" + "deleteConfirm": "Excluir", + "share": { + "button": "Compartilhar", + "dialog": { + "title": "Compartilhar nota", + "searchPlaceholder": "Adicione pessoas por e-mail", + "emailLabel": "E-mail do destinatário", + "shareButton": "Compartilhar", + "copyLink": "Copiar link", + "copied": "Copiado", + "owner": "Proprietário", + "pending": "Pendente", + "accepted": "Com acesso", + "resend": "Reenviar convite", + "revoke": "Remover", + "invitationActions": "Ações do convite", + "permissionRequired": "Apenas o proprietário da nota pode alterar o compartilhamento.", + "visibility": { + "label": "Visibilidade", + "link": "Qualquer pessoa com o link", + "domain": "Todos em {{domain}} com o link", + "invited": "Apenas pessoas convidadas" + }, + "error": { + "invalidEmail": "Digite um endereço de e-mail válido", + "alreadyInvited": "{{email}} já foi convidado", + "inviteFailed": "Não foi possível enviar o convite. Tente novamente.", + "linkUnavailable": "Gere ou rotacione o link para copiá-lo" + } + } + } }, "commandSearch": { "title": "Pesquisar", diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index 992a8aed7..bf538363f 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -2500,7 +2500,37 @@ "deleteNote": "Удалить заметку", "deleteConfirmTitle": "Удалить заметку?", "deleteConfirmDescription": "Эта заметка будет удалена безвозвратно.", - "deleteConfirm": "Удалить" + "deleteConfirm": "Удалить", + "share": { + "button": "Поделиться", + "dialog": { + "title": "Поделиться заметкой", + "searchPlaceholder": "Добавьте людей по эл. почте", + "emailLabel": "Эл. почта получателя", + "shareButton": "Поделиться", + "copyLink": "Скопировать ссылку", + "copied": "Скопировано", + "owner": "Владелец", + "pending": "Ожидает", + "accepted": "Имеет доступ", + "resend": "Отправить приглашение снова", + "revoke": "Удалить", + "invitationActions": "Действия с приглашением", + "permissionRequired": "Изменять настройки публикации может только владелец заметки.", + "visibility": { + "label": "Видимость", + "link": "Любой со ссылкой", + "domain": "Все в {{domain}} со ссылкой", + "invited": "Только приглашённые" + }, + "error": { + "invalidEmail": "Введите корректный адрес эл. почты", + "alreadyInvited": "{{email}} уже приглашён", + "inviteFailed": "Не удалось отправить приглашение. Попробуйте ещё раз.", + "linkUnavailable": "Сгенерируйте или обновите ссылку, чтобы скопировать её" + } + } + } }, "commandSearch": { "title": "Поиск", diff --git a/src/locales/zh-CN/translation.json b/src/locales/zh-CN/translation.json index d3e404487..ca689eab1 100644 --- a/src/locales/zh-CN/translation.json +++ b/src/locales/zh-CN/translation.json @@ -2498,7 +2498,37 @@ "deleteNote": "删除笔记", "deleteConfirmTitle": "删除笔记?", "deleteConfirmDescription": "此笔记将被永久删除,无法恢复。", - "deleteConfirm": "删除" + "deleteConfirm": "删除", + "share": { + "button": "分享", + "dialog": { + "title": "分享笔记", + "searchPlaceholder": "通过电子邮件添加成员", + "emailLabel": "收件人邮箱", + "shareButton": "分享", + "copyLink": "复制链接", + "copied": "已复制", + "owner": "所有者", + "pending": "待处理", + "accepted": "已获得访问权限", + "resend": "重新发送邀请", + "revoke": "移除", + "invitationActions": "邀请操作", + "permissionRequired": "只有笔记的所有者可以更改分享设置。", + "visibility": { + "label": "可见性", + "link": "拥有链接的任何人", + "domain": "{{domain}} 内拥有链接的所有人", + "invited": "仅受邀者" + }, + "error": { + "invalidEmail": "请输入有效的电子邮件地址", + "alreadyInvited": "{{email}} 已经被邀请", + "inviteFailed": "无法发送邀请。请重试。", + "linkUnavailable": "生成或更换链接后即可复制" + } + } + } }, "commandSearch": { "title": "搜索", diff --git a/src/locales/zh-TW/translation.json b/src/locales/zh-TW/translation.json index 013298998..abb18555e 100644 --- a/src/locales/zh-TW/translation.json +++ b/src/locales/zh-TW/translation.json @@ -2498,7 +2498,37 @@ "deleteNote": "刪除筆記", "deleteConfirmTitle": "刪除筆記?", "deleteConfirmDescription": "此筆記將被永久刪除,無法復原。", - "deleteConfirm": "刪除" + "deleteConfirm": "刪除", + "share": { + "button": "分享", + "dialog": { + "title": "分享筆記", + "searchPlaceholder": "透過電子郵件新增成員", + "emailLabel": "收件人電子郵件", + "shareButton": "分享", + "copyLink": "複製連結", + "copied": "已複製", + "owner": "擁有者", + "pending": "待處理", + "accepted": "已取得存取權", + "resend": "重新傳送邀請", + "revoke": "移除", + "invitationActions": "邀請操作", + "permissionRequired": "只有筆記的擁有者可以變更分享設定。", + "visibility": { + "label": "可見性", + "link": "任何擁有連結的人", + "domain": "{{domain}} 中擁有連結的所有人", + "invited": "僅限受邀者" + }, + "error": { + "invalidEmail": "請輸入有效的電子郵件地址", + "alreadyInvited": "{{email}} 已被邀請", + "inviteFailed": "無法傳送邀請。請再試一次。", + "linkUnavailable": "產生或更換連結後即可複製" + } + } + } }, "commandSearch": { "title": "搜尋", diff --git a/src/services/NoteSharingService.ts b/src/services/NoteSharingService.ts new file mode 100644 index 000000000..6a4eb3f1e --- /dev/null +++ b/src/services/NoteSharingService.ts @@ -0,0 +1,88 @@ +import { cloudGet, cloudPost, cloudPatch, cloudDelete } from "./cloudApi.js"; +import type { + NoteShareInvitation, + ShareSettings, + ShareVisibility, +} from "../types/electron"; + +export interface ShareStateResponse { + share: ShareSettings; + invitations: NoteShareInvitation[]; +} + +export interface ShareMutationResponse { + share: ShareSettings; + raw_token: string | null; +} + +export interface RotateTokenResponse { + share: ShareSettings; + raw_token: string; +} + +export interface CreateInvitationsResponse { + created: NoteShareInvitation[]; + already_invited: string[]; + email_failed_ids: string[]; +} + +function sharePath(cloudId: string, suffix: string = ""): string { + return `/api/notes/${encodeURIComponent(cloudId)}/share${suffix}`; +} + +async function getShareSettings(cloudNoteId: string): Promise { + return cloudGet(sharePath(cloudNoteId)); +} + +async function updateShareSettings( + cloudNoteId: string, + visibility: ShareVisibility, + domainAllowlist: string[] +): Promise { + return cloudPatch(sharePath(cloudNoteId), { + visibility, + domain_allowlist: domainAllowlist, + }); +} + +async function clearShare(cloudNoteId: string): Promise<{ share: ShareSettings }> { + return cloudDelete<{ share: ShareSettings }>(sharePath(cloudNoteId)); +} + +async function rotateToken(cloudNoteId: string): Promise { + return cloudPost(sharePath(cloudNoteId, "/rotate-token")); +} + +async function inviteEmails( + cloudNoteId: string, + emails: string[] +): Promise { + return cloudPost(sharePath(cloudNoteId, "/invitations"), { + emails, + }); +} + +async function revokeInvite(cloudNoteId: string, invitationId: string): Promise { + await cloudDelete( + sharePath(cloudNoteId, `/invitations/${encodeURIComponent(invitationId)}`) + ); +} + +async function resendInvite( + cloudNoteId: string, + invitationId: string +): Promise<{ id: string; resent: boolean }> { + return cloudPost<{ id: string; resent: boolean }>( + sharePath(cloudNoteId, `/invitations/${encodeURIComponent(invitationId)}/resend`) + ); +} + +export const NoteSharingService = { + getShareSettings, + updateShareSettings, + clearShare, + rotateToken, + inviteEmails, + revokeInvite, + resendInvite, +}; diff --git a/src/stores/noteStore.ts b/src/stores/noteStore.ts index b146a2673..ecab6c8b4 100644 --- a/src/stores/noteStore.ts +++ b/src/stores/noteStore.ts @@ -1,11 +1,20 @@ import { create } from "zustand"; -import type { NoteItem } from "../types/electron"; +import type { NoteItem, NoteShareInvitation, ShareSettings } from "../types/electron"; + +export interface NoteShareCacheEntry { + share: ShareSettings; + invitations: NoteShareInvitation[]; + // Raw token is returned by the API exactly once (on generate or rotate) + // and is only kept in memory for the active dialog session. + rawToken: string | null; +} interface NoteState { notes: NoteItem[]; activeNoteId: number | null; activeFolderId: number | null; migration: { total: number; done: number } | null; + shareByCloudId: Map; } const useNoteStore = create()(() => ({ @@ -13,6 +22,7 @@ const useNoteStore = create()(() => ({ activeNoteId: null, activeFolderId: null, migration: null, + shareByCloudId: new Map(), })); let hasBoundIpcListeners = false; @@ -194,3 +204,34 @@ export async function startMigration(): Promise { useNoteStore.setState({ migration: null }); } + +export function setShareCache(cloudId: string, entry: NoteShareCacheEntry): void { + const { shareByCloudId } = useNoteStore.getState(); + const next = new Map(shareByCloudId); + next.set(cloudId, entry); + useNoteStore.setState({ shareByCloudId: next }); +} + +export function updateShareCache( + cloudId: string, + updater: (current: NoteShareCacheEntry | undefined) => NoteShareCacheEntry +): void { + const { shareByCloudId } = useNoteStore.getState(); + const next = new Map(shareByCloudId); + next.set(cloudId, updater(next.get(cloudId))); + useNoteStore.setState({ shareByCloudId: next }); +} + +export function clearShareCache(cloudId: string): void { + const { shareByCloudId } = useNoteStore.getState(); + if (!shareByCloudId.has(cloudId)) return; + const next = new Map(shareByCloudId); + next.delete(cloudId); + useNoteStore.setState({ shareByCloudId: next }); +} + +export function useShareCacheEntry(cloudId: string | null): NoteShareCacheEntry | null { + return useNoteStore((state) => + cloudId ? state.shareByCloudId.get(cloudId) ?? null : null + ); +} diff --git a/src/types/electron.ts b/src/types/electron.ts index 579de1dff..ff6c6495f 100644 --- a/src/types/electron.ts +++ b/src/types/electron.ts @@ -64,6 +64,26 @@ export interface NoteItem { team_id?: string | null; } +export type ShareVisibility = "private" | "link" | "domain" | "invited"; + +export interface ShareSettings { + visibility: ShareVisibility; + token_prefix: string | null; + domain_allowlist: string[]; + updated_by_user_id: string | null; + updated_at: string | null; +} + +export interface NoteShareInvitation { + id: string; + email: string; + invited_by_user_id: string; + accepted_at: string | null; + revoked_at: string | null; + last_emailed_at: string | null; + created_at: string; +} + export interface FolderItem { id: number; name: string; diff --git a/src/utils/personalEmailDomains.ts b/src/utils/personalEmailDomains.ts new file mode 100644 index 000000000..daeb30ad3 --- /dev/null +++ b/src/utils/personalEmailDomains.ts @@ -0,0 +1,45 @@ +const PERSONAL_EMAIL_DOMAINS = new Set([ + "gmail.com", + "googlemail.com", + "outlook.com", + "hotmail.com", + "live.com", + "msn.com", + "yahoo.com", + "yahoo.co.uk", + "ymail.com", + "rocketmail.com", + "icloud.com", + "me.com", + "mac.com", + "proton.me", + "protonmail.com", + "pm.me", + "aol.com", + "fastmail.com", + "fastmail.fm", + "tutanota.com", + "tuta.io", + "gmx.com", + "gmx.de", + "gmx.net", + "mail.com", + "zoho.com", + "yandex.com", + "yandex.ru", + "duck.com", + "hey.com", + "qq.com", + "163.com", + "126.com", + "sina.com", +]); + +export function emailDomain(email: string): string { + const at = email.indexOf("@"); + return at === -1 ? "" : email.slice(at + 1).toLowerCase(); +} + +export function isPersonalEmailDomain(domain: string): boolean { + return PERSONAL_EMAIL_DOMAINS.has(domain.toLowerCase()); +}