feat: write Mountain View replies in place under the conversation

This commit is contained in:
Linus Rath
2026-10-12 01:29:55 +02:00
parent c652fec371
commit 78e68e8886
33 changed files with 158 additions and 34 deletions
@@ -2,7 +2,7 @@ import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import React from 'react';
import { EmailComposer } from '../email-composer';
import { MvComposeWindow } from '../mv-compose-window';
import { MvComposeInline, MvComposeWindow } from '../mv-compose-window';
// ─── Heavy component mocks (mirrors reply-addressing.test.tsx) ────────────────
@@ -227,6 +227,16 @@ describe('composer in the Mountain View compose window', () => {
expect(editor).toHaveAttribute('data-placement', 'bottom');
});
it('writes a reply in place with a bar that pops it out, and no window controls', () => {
const onPopOut = vi.fn();
render(<MvComposeInline onPopOut={onPopOut}><EmailComposer initialData={{ ...DATA, mode: 'reply' as never }} mode="reply" /></MvComposeInline>);
expect(document.querySelector('[data-mv-compose-inline-bar]')).not.toBeNull();
expect(screen.queryByRole('button', { name: 'minimize' })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'pop_out' }));
expect(onPopOut).toHaveBeenCalledTimes(1);
});
it('keeps its own header and a top toolbar outside the window', () => {
render(<EmailComposer initialData={DATA} />);
expect(document.querySelector('[data-mv-compose-titlebar]')).toBeNull();
+37 -1
View File
@@ -29,6 +29,10 @@ import {
Maximize2,
Minimize2,
Trash2,
ExternalLink,
ReplyAll,
Reply,
Forward,
} from "@/components/icons";
import { ComposerToolbarMenu, ComposerToolbarMenuItem, ComposerToolbarMenuCheckItem } from "@/components/email/composer-toolbar-menu";
import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils";
@@ -3012,7 +3016,39 @@ export function EmailComposer({
)}
{/* Header - mobile: clean bar with close/send, desktop: title bar.
In Mountain View's window it is the window's title bar instead. */}
{mvWindow ? (
{mvWindow?.variant === "inline" ? (
<div className="flex items-center gap-2 h-10 ps-4 pe-1.5 shrink-0 border-b border-border/50" data-mv-compose-inline-bar="">
{mode === 'replyAll'
? <ReplyAll className="w-4 h-4 text-muted-foreground flex-shrink-0" />
: mode === 'forward'
? <Forward className="w-4 h-4 text-muted-foreground flex-shrink-0" />
: <Reply className="w-4 h-4 text-muted-foreground flex-shrink-0" />}
<h3 className="flex-1 min-w-0 truncate text-sm" data-testid="composer-save-status" data-status={saveStatus}>
{mode === 'reply' ? t('reply') : mode === 'replyAll' ? t('reply_all') : mode === 'forward' ? t('forward') : t('new_message')}
</h3>
{saveStatus === 'saving' && <Save className="w-3.5 h-3.5 animate-pulse text-muted-foreground flex-shrink-0" aria-label={t('saving')} />}
{mvWindow.popOut && (
<button
type="button"
onClick={mvWindow.popOut}
className="grid h-8 w-8 place-items-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label={t('pop_out')}
title={t('pop_out')}
>
<ExternalLink className="h-4 w-4" />
</button>
)}
<button
type="button"
onClick={() => handleClose()}
className="grid h-8 w-8 place-items-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label={tCommon('close')}
title={tCommon('close')}
>
<X className="h-4 w-4" />
</button>
</div>
) : mvWindow ? (
<div
className={cn(
"flex items-center gap-1 h-10 ps-4 pe-1.5 shrink-0 bg-[var(--mv-search-bg)]",
+11 -1
View File
@@ -166,6 +166,8 @@ interface EmailViewerProps {
*/
mvConversation?: Email[];
onOpenConversationEmail?: (email: Email) => void;
/** Mountain View: a reply being written in place, shown under the conversation instead of the reply buttons. */
mvInlineComposer?: React.ReactNode;
onShowShortcuts?: () => void;
onEditDraft?: () => void;
onCancelScheduledForEdit?: () => void;
@@ -809,6 +811,7 @@ export function EmailViewer({
mvFolderName,
mvConversation,
onOpenConversationEmail,
mvInlineComposer,
onShowShortcuts,
onEditDraft,
onCancelScheduledForEdit,
@@ -5551,8 +5554,15 @@ export function EmailViewer({
</div>
)}
{/* Mountain View: a reply being written in place. */}
{mvViewer && mvInlineComposer && (
<div className="px-6 lg:ps-[80px] pt-6 pb-6" data-mv-inline-reply="" ref={(el) => el?.scrollIntoView({ block: 'nearest' })}>
{mvInlineComposer}
</div>
)}
{/* Mountain View: Reply / Reply all / Forward as buttons under the message. */}
{mvReplyRow && !isDraft && !isScheduled && !isBodyLoading && (effectiveEmailContent.isHtml ? iframeReady : true) && (
{mvReplyRow && !(mvViewer && mvInlineComposer) && !isDraft && !isScheduled && !isBodyLoading && (effectiveEmailContent.isHtml ? iframeReady : true) && (
<div
className={cn("pb-6 pt-2", isMobile ? "grid grid-cols-3 gap-2 px-4" : "flex flex-wrap items-center gap-3 px-6 lg:ps-[80px] pt-6")}
data-mv-reply-row=""
+7
View File
@@ -11,8 +11,15 @@ export type MvComposeWindowState = "normal" | "minimized" | "maximized";
* null and the composer keeps its own chrome.
*/
export interface MvComposeWindowControls {
/**
* `window` is the docked compose window; `inline` is a reply written in
* place under the conversation, which can be popped out into a window.
*/
variant: "window" | "inline";
state: MvComposeWindowState;
setState: (state: MvComposeWindowState) => void;
/** Inline only: move the reply into a docked window. */
popOut?: () => void;
}
export const MvComposeWindowContext = createContext<MvComposeWindowControls | null>(null);
+25 -1
View File
@@ -15,7 +15,7 @@ export function MvComposeWindow({ children }: { children: ReactNode }) {
const [state, setState] = useState<MvComposeWindowState>("normal");
const minimized = state === "minimized";
const maximized = state === "maximized";
const controls = useMemo(() => ({ state, setState }), [state]);
const controls = useMemo(() => ({ variant: "window" as const, state, setState }), [state]);
return (
<MvComposeWindowContext.Provider value={controls}>
@@ -43,3 +43,27 @@ export function MvComposeWindow({ children }: { children: ReactNode }) {
</MvComposeWindowContext.Provider>
);
}
const NOOP = () => {};
/**
* Mountain View's inline reply: the composer as a card under the open
* conversation, as Gmail writes replies, with a button that pops it out into
* the docked window.
*/
export function MvComposeInline({ children, onPopOut }: { children: ReactNode; onPopOut: () => void }) {
const controls = useMemo(
() => ({ variant: "inline" as const, state: "normal" as const, setState: NOOP, popOut: onPopOut }),
[onPopOut],
);
return (
<MvComposeWindowContext.Provider value={controls}>
<div
data-mv-compose-inline=""
className="flex h-[min(480px,70dvh)] flex-col overflow-hidden rounded-lg border border-border bg-background shadow-[0_1px_2px_0_rgb(60_64_67/0.3),0_2px_6px_2px_rgb(60_64_67/0.15)]"
>
{children}
</div>
</MvComposeWindowContext.Provider>
);
}
+13 -3
View File
@@ -5,7 +5,7 @@ import { usePathname } from "next/navigation";
import { useTranslations } from "next-intl";
import { Sidebar } from "@/components/layout/sidebar";
import { EmailList } from "@/components/email/email-list";
import { MvComposeWindow } from "@/components/email/mv-compose-window";
import { MvComposeInline, MvComposeWindow } from "@/components/email/mv-compose-window";
import { MessageListTabs } from "@/components/email/message-list-tabs";
import dynamic from "next/dynamic";
import type { ComposerDraftData } from "@/components/email/email-composer";
@@ -201,6 +201,13 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
const dragStartWidth = useRef(0);
// Mobile conversation view state
const [conversationThread, setConversationThread] = useState<ThreadGroup | null>(null);
// Mountain View writes replies in place under the conversation until the
// user pops one out into the docked window.
const [mvReplyPoppedOut, setMvReplyPoppedOut] = useState(false);
useEffect(() => {
if (!showComposer) setMvReplyPoppedOut(false);
}, [showComposer]);
const handleMvPopOut = useCallback(() => setMvReplyPoppedOut(true), []);
// Mountain View: the open message's whole conversation, light properties only.
const [mvConversation, setMvConversation] = useState<Email[] | null>(null);
const [conversationEmails, setConversationEmails] = useState<Email[]>([]);
@@ -3618,7 +3625,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
const isHorizontalMailLayout = mailLayout === 'horizontal' && !isMobile && !isTablet;
// Under Mountain View on desktop the composer floats over the list in a
// docked window instead of taking the reading pane.
const floatingComposer = mvShell && showComposer && !isEmbedded;
const mvInlineReply = mvShell && showComposer && !isEmbedded && !mvReplyPoppedOut
&& (pendingDraft?.mode ?? composerMode) !== 'compose' && Boolean(selectedEmail);
const floatingComposer = mvShell && showComposer && !isEmbedded && !mvInlineReply;
const hasViewerContent = (showComposer && !floatingComposer) || Boolean(conversationThread)
|| Boolean(selectedEmail && !(mvShell && cursorOnly));
const shouldCollapseListPane = (isTablet && !tabletListVisible && !readingPaneEmpty) || (!isMobile && isFocusedMailLayout && hasViewerContent);
@@ -4651,7 +4660,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
{/* Inline Composer - shown in viewer pane.
In Pro/embedded mode the composer is hoisted into its own
Pro tab (see the effect below), so we never render it inline. */}
{(showComposer && !isEmbedded && !floatingComposer) ? composerNode : (
{(showComposer && !isEmbedded && !floatingComposer && !mvInlineReply) ? composerNode : (
<>
{/* Pending draft banner */}
{pendingDraft && (
@@ -4753,6 +4762,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
mvFolderName={mvShell && !isScheduledView && !isUnifiedView ? currentMailboxName : undefined}
mvConversation={mvShell ? mvConversation ?? undefined : undefined}
onOpenConversationEmail={mvShell ? (e) => void handleEmailSelect(e) : undefined}
mvInlineComposer={mvInlineReply ? <MvComposeInline onPopOut={handleMvPopOut}>{composerNode}</MvComposeInline> : undefined}
onToggleFullscreen={!isEmbedded && !isMobile ? () => setFullscreenReading((v) => !v) : undefined}
isFullscreen={fullscreenReading}
onShowShortcuts={() => setShowShortcutsModal(true)}
+2 -1
View File
@@ -803,7 +803,8 @@
"background_color": "لون الخلفية",
"remove_background_color": "إزالة لون الخلفية"
},
"send_filing_warning": "تم الإرسال - لكن التنظيف بعد الإرسال فشل، وقد تبقى مسودة قديمة."
"send_filing_warning": "تم الإرسال - لكن التنظيف بعد الإرسال فشل، وقد تبقى مسودة قديمة.",
"pop_out": "فتح الرد في نافذة منفصلة"
},
"confirm_dialog": {
"confirm": "تأكيد",
+2 -1
View File
@@ -803,7 +803,8 @@
"background_color": "Color de fons",
"remove_background_color": "Elimina el color de fons"
},
"send_filing_warning": "Enviat, però la neteja posterior a l'enviament ha fallat; és possible que quedi un esborrany obsolet."
"send_filing_warning": "Enviat, però la neteja posterior a l'enviament ha fallat; és possible que quedi un esborrany obsolet.",
"pop_out": "Obre la resposta en una finestra"
},
"confirm_dialog": {
"confirm": "Confirma",
+2 -1
View File
@@ -803,7 +803,8 @@
"background_color": "Barva pozadí",
"remove_background_color": "Odebrat barvu pozadí"
},
"send_filing_warning": "Odesláno - ale následný úklid selhal, může zůstat zastaralý koncept."
"send_filing_warning": "Odesláno - ale následný úklid selhal, může zůstat zastaralý koncept.",
"pop_out": "Otevřít odpověď v okně"
},
"confirm_dialog": {
"confirm": "Potvrdit",
+2 -1
View File
@@ -803,7 +803,8 @@
"background_color": "Baggrundsfarve",
"remove_background_color": "Fjern baggrundsfarve"
},
"send_filing_warning": "Sendt - men oprydningen bagefter mislykkedes, en forældet kladde kan blive stående."
"send_filing_warning": "Sendt - men oprydningen bagefter mislykkedes, en forældet kladde kan blive stående.",
"pop_out": "Åbn svaret i et vindue"
},
"confirm_dialog": {
"confirm": "Bekræft",
+2 -1
View File
@@ -803,7 +803,8 @@
"background_color": "Hintergrundfarbe",
"remove_background_color": "Hintergrundfarbe entfernen"
},
"send_filing_warning": "Gesendet - aber das Aufräumen danach schlug fehl, evtl. bleibt ein alter Entwurf sichtbar."
"send_filing_warning": "Gesendet - aber das Aufräumen danach schlug fehl, evtl. bleibt ein alter Entwurf sichtbar.",
"pop_out": "Antwort in eigenem Fenster öffnen"
},
"confirm_dialog": {
"confirm": "Bestätigen",
+2 -1
View File
@@ -803,7 +803,8 @@
"background_color": "Background color",
"remove_background_color": "Remove background color"
},
"send_filing_warning": "Sent - but the post-send cleanup failed, a stale draft may remain."
"send_filing_warning": "Sent - but the post-send cleanup failed, a stale draft may remain.",
"pop_out": "Pop out reply"
},
"confirm_dialog": {
"confirm": "Confirm",
+2 -1
View File
@@ -803,7 +803,8 @@
"background_color": "Color de fondo",
"remove_background_color": "Quitar color de fondo"
},
"send_filing_warning": "Enviado - pero la limpieza posterior falló, puede quedar un borrador obsoleto."
"send_filing_warning": "Enviado - pero la limpieza posterior falló, puede quedar un borrador obsoleto.",
"pop_out": "Abrir la respuesta en una ventana"
},
"confirm_dialog": {
"confirm": "Confirmar",
+2 -1
View File
@@ -803,7 +803,8 @@
"background_color": "رنگ پس‌زمینه",
"remove_background_color": "حذف رنگ پس‌زمینه"
},
"send_filing_warning": "ارسال شد - اما پاک‌سازی پس از ارسال ناموفق بود، ممکن است پیش‌نویس قدیمی باقی بماند."
"send_filing_warning": "ارسال شد - اما پاک‌سازی پس از ارسال ناموفق بود، ممکن است پیش‌نویس قدیمی باقی بماند.",
"pop_out": "باز کردن پاسخ در پنجره جدا"
},
"confirm_dialog": {
"confirm": "تأیید",
+2 -1
View File
@@ -803,7 +803,8 @@
"background_color": "Couleur d'arrière-plan",
"remove_background_color": "Supprimer la couleur d'arrière-plan"
},
"send_filing_warning": "Envoyé - mais le nettoyage après envoi a échoué, un ancien brouillon peut subsister."
"send_filing_warning": "Envoyé - mais le nettoyage après envoi a échoué, un ancien brouillon peut subsister.",
"pop_out": "Ouvrir la réponse dans une fenêtre"
},
"confirm_dialog": {
"confirm": "Confirmer",
+2 -1
View File
@@ -768,7 +768,8 @@
"background_color": "צבע רקע",
"remove_background_color": "הסר צבע רקע"
},
"send_filing_warning": "נשלח - אך הניקוי שלאחר השליחה נכשל, ייתכן שתישאר טיוטה ישנה."
"send_filing_warning": "נשלח - אך הניקוי שלאחר השליחה נכשל, ייתכן שתישאר טיוטה ישנה.",
"pop_out": "פתיחת התשובה בחלון נפרד"
},
"confirm_dialog": {
"confirm": "אשר",
+2 -1
View File
@@ -803,7 +803,8 @@
"background_color": "Háttérszín",
"remove_background_color": "Háttérszín eltávolítása"
},
"send_filing_warning": "Elküldve - de az utólagos rendrakás nem sikerült, egy elavult piszkozat megmaradhat."
"send_filing_warning": "Elküldve - de az utólagos rendrakás nem sikerült, egy elavult piszkozat megmaradhat.",
"pop_out": "Válasz megnyitása külön ablakban"
},
"confirm_dialog": {
"confirm": "Megerősítés",
+2 -1
View File
@@ -803,7 +803,8 @@
"background_color": "Colore di sfondo",
"remove_background_color": "Rimuovi colore di sfondo"
},
"send_filing_warning": "Inviato - ma la pulizia successiva non è riuscita, potrebbe restare una bozza obsoleta."
"send_filing_warning": "Inviato - ma la pulizia successiva non è riuscita, potrebbe restare una bozza obsoleta.",
"pop_out": "Apri la risposta in una finestra"
},
"confirm_dialog": {
"confirm": "Conferma",
+2 -1
View File
@@ -803,7 +803,8 @@
"background_color": "背景色",
"remove_background_color": "背景色を削除"
},
"send_filing_warning": "送信されましたが、送信後の整理に失敗しました。古い下書きが残る場合があります。"
"send_filing_warning": "送信されましたが、送信後の整理に失敗しました。古い下書きが残る場合があります。",
"pop_out": "返信を別ウィンドウで開く"
},
"confirm_dialog": {
"confirm": "確認",
+2 -1
View File
@@ -803,7 +803,8 @@
"background_color": "배경색",
"remove_background_color": "배경색 제거"
},
"send_filing_warning": "보냈지만 전송 후 정리에 실패했습니다. 오래된 임시 보관 메일이 남아 있을 수 있습니다."
"send_filing_warning": "보냈지만 전송 후 정리에 실패했습니다. 오래된 임시 보관 메일이 남아 있을 수 있습니다.",
"pop_out": "답장을 별도 창에서 열기"
},
"confirm_dialog": {
"confirm": "확인",
+2 -1
View File
@@ -803,7 +803,8 @@
"background_color": "Fona krāsa",
"remove_background_color": "Noņemt fona krāsu"
},
"send_filing_warning": "Nosūtīts - bet pēcapstrāde neizdevās, var palikt novecojis melnraksts."
"send_filing_warning": "Nosūtīts - bet pēcapstrāde neizdevās, var palikt novecojis melnraksts.",
"pop_out": "Atvērt atbildi atsevišķā logā"
},
"confirm_dialog": {
"confirm": "Apstiprināt",
+2 -1
View File
@@ -803,7 +803,8 @@
"background_color": "Дэвсгэр өнгө",
"remove_background_color": "Дэвсгэр өнгийг арилгах"
},
"send_filing_warning": "Илгээсэн - гэхдээ илгээсний дараах цэвэрлэгээ амжилтгүй болсон тул хуучирсан ноорог үлдэж болзошгүй."
"send_filing_warning": "Илгээсэн - гэхдээ илгээсний дараах цэвэрлэгээ амжилтгүй болсон тул хуучирсан ноорог үлдэж болзошгүй.",
"pop_out": "Хариуг тусдаа цонхонд нээх"
},
"confirm_dialog": {
"confirm": "Баталгаажуулах",
+2 -1
View File
@@ -803,7 +803,8 @@
"background_color": "Bakgrunnsfarge",
"remove_background_color": "Fjern bakgrunnsfarge"
},
"send_filing_warning": "Meldingen ble sendt, men oppryddingen etterpå mislyktes. Et gammelt utkast kan bli liggende."
"send_filing_warning": "Meldingen ble sendt, men oppryddingen etterpå mislyktes. Et gammelt utkast kan bli liggende.",
"pop_out": "Åpne svaret i et eget vindu"
},
"confirm_dialog": {
"confirm": "Bekreft",
+2 -1
View File
@@ -803,7 +803,8 @@
"background_color": "Achtergrondkleur",
"remove_background_color": "Achtergrondkleur verwijderen"
},
"send_filing_warning": "Verzonden - maar het opruimen daarna is mislukt, mogelijk blijft een oud concept staan."
"send_filing_warning": "Verzonden - maar het opruimen daarna is mislukt, mogelijk blijft een oud concept staan.",
"pop_out": "Antwoord in een apart venster openen"
},
"confirm_dialog": {
"confirm": "Bevestigen",
+2 -1
View File
@@ -803,7 +803,8 @@
"background_color": "Kolor tła",
"remove_background_color": "Usuń kolor tła"
},
"send_filing_warning": "Wysłano - ale późniejsze porządkowanie nie powiodło się, może pozostać nieaktualna wersja robocza."
"send_filing_warning": "Wysłano - ale późniejsze porządkowanie nie powiodło się, może pozostać nieaktualna wersja robocza.",
"pop_out": "Otwórz odpowiedź w oknie"
},
"confirm_dialog": {
"confirm": "Potwierdź",
+2 -1
View File
@@ -803,7 +803,8 @@
"background_color": "Cor de fundo",
"remove_background_color": "Remover cor de fundo"
},
"send_filing_warning": "Enviado - mas a limpeza posterior falhou, um rascunho antigo pode permanecer."
"send_filing_warning": "Enviado - mas a limpeza posterior falhou, um rascunho antigo pode permanecer.",
"pop_out": "Abrir a resposta numa janela"
},
"confirm_dialog": {
"confirm": "Confirmar",
+2 -1
View File
@@ -803,7 +803,8 @@
"background_color": "Culoarea fundalului",
"remove_background_color": "Elimină culoarea fundalului"
},
"send_filing_warning": "Trimis - dar curățarea ulterioară a eșuat, poate rămâne o ciornă veche."
"send_filing_warning": "Trimis - dar curățarea ulterioară a eșuat, poate rămâne o ciornă veche.",
"pop_out": "Deschide răspunsul într-o fereastră"
},
"confirm_dialog": {
"confirm": "Confirmare",
+2 -1
View File
@@ -803,7 +803,8 @@
"background_color": "Цвет фона",
"remove_background_color": "Убрать цвет фона"
},
"send_filing_warning": "Отправлено - но последующая очистка не удалась, может остаться устаревший черновик."
"send_filing_warning": "Отправлено - но последующая очистка не удалась, может остаться устаревший черновик.",
"pop_out": "Открыть ответ в отдельном окне"
},
"confirm_dialog": {
"confirm": "Подтвердить",
+2 -1
View File
@@ -803,7 +803,8 @@
"background_color": "Farba pozadia",
"remove_background_color": "Odstrániť farbu pozadia"
},
"send_filing_warning": "Odoslané - ale následné upratovanie zlyhalo, môže zostať zastaraný koncept."
"send_filing_warning": "Odoslané - ale následné upratovanie zlyhalo, môže zostať zastaraný koncept.",
"pop_out": "Otvoriť odpoveď v okne"
},
"confirm_dialog": {
"confirm": "Potvrdiť",
+2 -1
View File
@@ -803,7 +803,8 @@
"background_color": "Arka plan rengi",
"remove_background_color": "Arka plan rengini kaldır"
},
"send_filing_warning": "Gönderildi - ancak sonrasındaki temizleme başarısız oldu, eski bir taslak kalabilir."
"send_filing_warning": "Gönderildi - ancak sonrasındaki temizleme başarısız oldu, eski bir taslak kalabilir.",
"pop_out": "Yanıtı ayrı pencerede aç"
},
"confirm_dialog": {
"confirm": "Onayla",
+2 -1
View File
@@ -803,7 +803,8 @@
"background_color": "Колір фону",
"remove_background_color": "Прибрати колір фону"
},
"send_filing_warning": "Надіслано - але подальше очищення не вдалося, може залишитися застаріла чернетка."
"send_filing_warning": "Надіслано - але подальше очищення не вдалося, може залишитися застаріла чернетка.",
"pop_out": "Відкрити відповідь в окремому вікні"
},
"confirm_dialog": {
"confirm": "Підтвердити",
+2 -1
View File
@@ -803,7 +803,8 @@
"background_color": "背景顏色",
"remove_background_color": "移除背景顏色"
},
"send_filing_warning": "郵件已傳送,但寄送後清理失敗,可能仍保留舊草稿。"
"send_filing_warning": "郵件已傳送,但寄送後清理失敗,可能仍保留舊草稿。",
"pop_out": "在獨立視窗中開啟回覆"
},
"confirm_dialog": {
"confirm": "確認",
+2 -1
View File
@@ -803,7 +803,8 @@
"background_color": "背景颜色",
"remove_background_color": "移除背景颜色"
},
"send_filing_warning": "已发送,但发送后的清理失败,可能会残留旧草稿。"
"send_filing_warning": "已发送,但发送后的清理失败,可能会残留旧草稿。",
"pop_out": "在单独窗口中打开回复"
},
"confirm_dialog": {
"confirm": "确认",