diff --git a/components/email/__tests__/reader-plugin-surfaces.test.tsx b/components/email/__tests__/reader-plugin-surfaces.test.tsx index ec53d2b5e..9b18b2a4e 100644 --- a/components/email/__tests__/reader-plugin-surfaces.test.tsx +++ b/components/email/__tests__/reader-plugin-surfaces.test.tsx @@ -246,6 +246,16 @@ describe('reader-selection-menu and the side panel\'s selection', () => { }); describe('reader-header and reader-below-subject', () => { + it('tell the frames which layout they are in', () => { + offerSlots('p1', ['email-banner']); + const { unmount } = viewer(textMail('Hi')); + expect(slotProps(screen.getByTestId('slot-email-banner')).deviceType).toBe('desktop'); + unmount(); + device.isMobile = true; + viewer(textMail('Hi')); + expect(slotProps(screen.getByTestId('slot-email-banner')).deviceType).toBe('mobile'); + }); + it('put the frames above the subject and under the sender, with the message', () => { offerSlots('p1', ['reader-header', 'reader-below-subject']); const { container } = viewer(textMail('Hi')); @@ -344,12 +354,13 @@ describe('thread-summary', () => { ]; it('sits above the messages of the phone conversation view', () => { + device.isMobile = true; offerSlots('p1', ['thread-summary']); const thread = { threadKey: 't1', latestEmail: conversation[1], emails: conversation, emailCount: 2 } as unknown as ThreadGroup; render( {}} />); const frame = screen.getByTestId('slot-thread-summary'); - expect(slotProps(frame)).toEqual({ threadId: 't1', emailIds: ['m2', 'm1'] }); + expect(slotProps(frame)).toEqual({ threadId: 't1', emailIds: ['m2', 'm1'], deviceType: 'mobile' }); expect(follows(frame, screen.getAllByText('Alice')[0])).toBe(true); }); @@ -358,7 +369,7 @@ describe('thread-summary', () => { useSettingsStore.setState({ interfaceLayout: 'mountain-view', markAsReadDelay: 0 }); viewer(conversation[1], { mvConversation: conversation }); - expect(slotProps(screen.getByTestId('slot-thread-summary'))).toEqual({ threadId: 't1', emailIds: ['m1', 'm2'] }); + expect(slotProps(screen.getByTestId('slot-thread-summary'))).toEqual({ threadId: 't1', emailIds: ['m1', 'm2'], deviceType: 'desktop' }); }); it('draws nothing without a plugin, and nothing for a single message', () => { diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 51664aca0..05993b093 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -1170,8 +1170,8 @@ export function EmailViewer({ const mvAfter = mvOpenIndex >= 0 ? mvConversation!.slice(mvOpenIndex + 1) : []; const mvConversationIds = mvOpenIndex >= 0 ? mvConversation!.map((e) => e.id).join(',') : ''; const threadSummaryProps = useMemo( - () => ({ threadId: email?.threadId ?? null, emailIds: mvConversationIds ? mvConversationIds.split(',') : [] }), - [email?.threadId, mvConversationIds], + () => ({ threadId: email?.threadId ?? null, emailIds: mvConversationIds ? mvConversationIds.split(',') : [], deviceType: isMobile ? 'mobile' : isTablet ? 'tablet' : 'desktop' }), + [email?.threadId, mvConversationIds, isMobile, isTablet], ); // Mountain View: which other messages of the conversation are unfolded. A // conversation opens with its unread messages unfolded, as Gmail does, so @@ -1512,7 +1512,10 @@ export function EmailViewer({ () => (email && wantsPluginEmailView ? emailToReadView(email) : null), [email, wantsPluginEmailView], ); - const pluginEmailProps = useMemo(() => ({ email: pluginEmailView }), [pluginEmailView]); + // Reader frames learn the layout: the bar a plugin shows on phones has no + // place next to a side panel on a desktop. + const pluginDeviceType = isMobile ? 'mobile' : isTablet ? 'tablet' : 'desktop'; + const pluginEmailProps = useMemo(() => ({ email: pluginEmailView, deviceType: pluginDeviceType }), [pluginEmailView, pluginDeviceType]); // The text selected in the body, for the side panel (a plugin working on // what the user highlights) and the right-click menu on it. Reset per // message; the body documents report changes a moment after they happen. diff --git a/components/email/thread-conversation-view.tsx b/components/email/thread-conversation-view.tsx index 4920015da..351eeb0a7 100644 --- a/components/email/thread-conversation-view.tsx +++ b/components/email/thread-conversation-view.tsx @@ -49,6 +49,7 @@ import { PluginSlot } from "@/components/plugins/plugin-slot"; import { ReaderSelectionMenu, useReaderSelectionMenu, watchBodySelection } from "./reader-selection-menu"; import { PluginActions } from "@/components/plugins/plugin-actions"; import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers"; +import { useDeviceDetection } from "@/hooks/use-media-query"; import { usePluginRenderedBody } from "@/hooks/use-plugin-rendered-body"; import { emailToReadView } from "@/lib/plugin-projection"; import { AttachmentPluginMenu, ReaderAboveQuoteSlot } from "./reader-plugin-surfaces"; @@ -191,9 +192,10 @@ export function ThreadConversationView({ const t = useTranslations(); const externalContent = useConversationExternalContent(); const emailIdList = emails.map((email) => email.id).join(','); + const { isMobile: summaryOnPhone, isTablet: summaryOnTablet } = useDeviceDetection(); const threadSummaryProps = useMemo( - () => ({ threadId: thread.latestEmail.threadId ?? null, emailIds: emailIdList ? emailIdList.split(',') : [] }), - [thread.latestEmail.threadId, emailIdList], + () => ({ threadId: thread.latestEmail.threadId ?? null, emailIds: emailIdList ? emailIdList.split(',') : [], deviceType: summaryOnPhone ? 'mobile' : summaryOnTablet ? 'tablet' : 'desktop' }), + [thread.latestEmail.threadId, emailIdList, summaryOnPhone, summaryOnTablet], ); // Track which emails are expanded (most recent by default) @@ -325,7 +327,9 @@ function useConversationPluginProps(email: Email) { const belowSubjectOffers = usePluginSlotOffers('reader-below-subject').length; const bannerOffers = usePluginSlotOffers('email-banner').length; const wantsView = headerOffers + belowSubjectOffers + bannerOffers > 0; - const emailProps = useMemo(() => ({ email: wantsView ? emailToReadView(email) : null }), [email, wantsView]); + const { isMobile, isTablet } = useDeviceDetection(); + const deviceType = isMobile ? 'mobile' : isTablet ? 'tablet' : 'desktop'; + const emailProps = useMemo(() => ({ email: wantsView ? emailToReadView(email) : null, deviceType }), [email, wantsView, deviceType]); const idProps = useMemo(() => ({ emailId: email.id }), [email.id]); return { emailProps, idProps }; } diff --git a/lib/plugin-slot-catalog.ts b/lib/plugin-slot-catalog.ts index 7df9872fb..daf10c626 100644 --- a/lib/plugin-slot-catalog.ts +++ b/lib/plugin-slot-catalog.ts @@ -65,10 +65,10 @@ export const SLOT_CATALOG = { // ─── Reader ──────────────────────────────────────────────── 'reader-header': S('reader', 'frame', ['ui:email-banner'], { email: 'email:read' }, 'at the top of the open message, above the subject'), 'reader-below-subject': S('reader', 'frame', ['ui:email-banner'], { email: 'email:read' }, 'right under the subject and sender of the open message'), - 'email-banner': S('reader', 'frame', ['ui:email-banner'], { email: 'email:read' }, 'a banner above the message body'), + 'email-banner': S('reader', 'frame', ['ui:email-banner'], { email: 'email:read', deviceType: null }, 'a banner above the message body; `deviceType` ("mobile" | "tablet" | "desktop") says whether the reader has a side panel (not on phones)'), 'reader-above-quote': S('reader', 'frame', ['ui:email-banner'], { email: 'email:read' }, 'between the new text of a message and the quoted text below it'), 'email-footer': S('reader', 'frame', ['ui:email-footer'], { emailId: 'email:read' }, 'below the message body'), - 'thread-summary': S('reader', 'frame', ['ui:email-banner'], { threadId: 'email:read', emailIds: 'email:read' }, 'above the messages of an open conversation (the phone conversation view and Mountain View; the default desktop layout opens one message at a time and has no conversation view)'), + 'thread-summary': S('reader', 'frame', ['ui:email-banner'], { threadId: 'email:read', emailIds: 'email:read', deviceType: null }, 'above the messages of an open conversation (the phone conversation view and Mountain View; the default desktop layout opens one message at a time and has no conversation view)'), 'reader-actions': S('reader', 'action', ['ui:toolbar'], { emailId: 'email:read' }, 'buttons in the open message\'s toolbar', true), 'email-detail-sidebar': S('reader', 'frame', ['ui:email-details'], { email: 'email:read', selection: 'email:read', focusRequest: null }, 'a side panel next to the open message; `selection` is the text selected in the message as the user changes it ({ text }); `focusRequest` counts up when the plugin opened the panel with api.ui.openPanel'), 'email-details-section': S('reader', 'frame', ['ui:email-details'], { email: 'email:read', category: null }, 'a section in the message details'),