feat: tell reader plugin frames whether they are on a phone, a tablet or a desktop

This commit is contained in:
Linus Rath
2026-10-20 17:21:13 +02:00
parent b89c127ed6
commit ad3e529b8e
4 changed files with 28 additions and 10 deletions
@@ -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(<ThreadConversationView thread={thread} emails={[conversation[1], conversation[0]]} onBack={() => {}} />);
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', () => {
+6 -3
View File
@@ -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.
@@ -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 };
}
+2 -2
View File
@@ -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'),