Compare commits

...
4 Commits
24 changed files with 214 additions and 62 deletions
+9 -15
View File
@@ -58,10 +58,11 @@ import { useCalendarStore } from './src/stores/calendar-store';
import { useContactsStore } from './src/stores/contacts-store';
import { useEmailStore, viewerParamsForRow } from './src/stores/email-store';
import { mailboxAccountId } from './src/lib/mailbox-tree';
import { prefetchMessage } from './src/lib/email-detail-cache';
import { loadDetail, prefetchMessage } from './src/lib/email-detail-cache';
import { useHasCalendar, useHasContacts, useHasFiles } from './src/lib/capabilities';
import { useSettingsStore } from './src/stores/settings-store';
import { useLocaleStore } from './src/stores/locale-store';
import { toast } from './src/stores/toast-store';
import { useNetworkStore } from './src/stores/network-store';
import { useUpdatesStore } from './src/stores/updates-store';
import { UpdateBanner } from './src/components/UpdateBanner';
@@ -69,10 +70,9 @@ import { PushOnboardingPrompt } from './src/components/PushOnboardingPrompt';
import { ToastHost } from './src/components/ToastHost';
import { UndoSnackbar } from './src/components/UndoSnackbar';
import { AppIconBadge } from './src/components/AppIconBadge';
import { getEmails, getFullEmail } from './src/api/email';
import { getEmails } from './src/api/email';
import { signOutWidgets, startWidgetSync } from './src/widgets/sync';
import { draftContextFromEmail } from './src/lib/draft-context';
import { replyComposeParams } from './src/lib/reply-compose';
import { handleDeepLink, parseDeepLink, shareToDeepLink, type DeepLink } from './src/navigation/linking';
import { addShareListener, getInitialShare, shareAttachments } from './src/lib/share-intent';
import { OfflineCacheBanner } from './src/components/OfflineCacheBanner';
@@ -134,7 +134,7 @@ async function openCalendarReminder(target: CalendarReminderTarget): Promise<voi
// Deep links (bulwarkmobile://, webmail https permalinks, mailto:) and
// Android share-sheet payloads all end up here once the navigator is ready.
async function openDeepLink(link: DeepLink): Promise<void> {
await handleDeepLink(link, {
const opened = await handleDeepLink(link, {
navigation: navigationRef,
resolveThreadId: async (emailId, jmapAccountId) => {
try {
@@ -144,19 +144,10 @@ async function openDeepLink(link: DeepLink): Promise<void> {
return null;
}
},
openReply: async (emailId, jmapAccountId) => {
try {
const params = replyComposeParams('reply', await getFullEmail(emailId, jmapAccountId), jmapAccountId);
if (!params || !navigationRef.isReady()) return false;
navigationRef.navigate('Compose', params);
return true;
} catch {
return false;
}
},
openDraft: async (emailId, jmapAccountId) => {
try {
const draft = draftContextFromEmail(await getFullEmail(emailId, jmapAccountId), jmapAccountId);
// Cache first, so a draft read before still opens offline.
const draft = draftContextFromEmail(await loadDetail(emailId, jmapAccountId), jmapAccountId);
if (!navigationRef.isReady()) return false;
navigationRef.navigate('Compose', { draft });
return true;
@@ -172,6 +163,9 @@ async function openDeepLink(link: DeepLink): Promise<void> {
return useAuthStore.getState().activeAccountId === accountId;
},
});
// A widget or a link elsewhere brought the app up for this; say it failed
// rather than leave the user wondering why the app just opened.
if (!opened) toast.error(useLocaleStore.getState().t('deep_link.open_failed', "Couldn't open this"));
}
// Toasts and the undo bar show on whichever stack screen is up, so a failure
+1 -1
View File
@@ -1 +1 @@
0.1.64
0.1.66
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Home-screen widget edge, drawn over the widget by layout/rn_widget.xml. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<stroke
android:width="1dp"
android:color="@color/widget_frame" />
<corners android:radius="@dimen/widget_radius" />
</shape>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Home-screen widget root (layout/rn_widget.xml): its rounded outline is what the widget clips to. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/widget_surface" />
<corners android:radius="@dimen/widget_radius" />
</shape>
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Night twin of layout/rn_widget.xml, which explains the override. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@android:id/background"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/widget_surface"
android:clipToOutline="true"
android:foreground="@drawable/widget_frame"
tools:targetApi="31">
<ImageView
android:id="@+id/rn_widget_image_light"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/transparent"
android:scaleType="matrix"
android:visibility="gone" />
<ImageView
android:id="@+id/rn_widget_image_dark"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/transparent"
android:scaleType="matrix"
android:visibility="visible" />
<FrameLayout
android:id="@+id/rn_widget_clickable_container"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<FrameLayout
android:id="@+id/rn_widget_collection_container"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</FrameLayout>
@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Overrides react-native-android-widget's rn_widget.xml (same ids and children;
layout-night/rn_widget.xml is its night twin).
The library draws each widget into one bitmap sized from the size the
launcher reports, and pins it to the top left without scaling. When the real
frame is smaller (after a rotation, or on a launcher that over-reports), the
bitmap's bottom edge is cut off and takes the rounded corners and the border
with it, so the bottom corners fall back to whatever the launcher clips to.
This root always has the real size, so it owns the shape instead: it clips to
a rounded outline (Android 12+; the launcher then leaves the corners to us),
fills in where the bitmap falls short, and draws the frame on top.
-->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@android:id/background"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/widget_surface"
android:clipToOutline="true"
android:foreground="@drawable/widget_frame"
tools:targetApi="31">
<ImageView
android:id="@+id/rn_widget_image_light"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/transparent"
android:scaleType="matrix"
android:visibility="visible" />
<ImageView
android:id="@+id/rn_widget_image_dark"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/transparent"
android:scaleType="matrix"
android:visibility="gone" />
<FrameLayout
android:id="@+id/rn_widget_clickable_container"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<FrameLayout
android:id="@+id/rn_widget_collection_container"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</FrameLayout>
@@ -3,4 +3,6 @@
<color name="splashscreen_background">#09090b</color>
<!-- Matches the dark palette background (src/theme/tokens.ts). -->
<color name="window_background">#09090b</color>
<!-- DARK.bg in src/widgets/theme.ts. -->
<color name="widget_surface">#0a0a0a</color>
</resources>
@@ -6,4 +6,8 @@
<color name="colorPrimaryDark">#09090b</color>
<!-- Behind the React root until the first frame; matches the light palette background (src/theme/tokens.ts). -->
<color name="window_background">#ffffff</color>
<!-- Home-screen widget surface (LIGHT.bg in src/widgets/theme.ts); it only shows where the drawn widget falls short of the launcher's frame. -->
<color name="widget_surface">#ffffff</color>
<!-- Home-screen widget edge, one colour for both schemes: the app can force a theme the launcher's night mode doesn't know about. -->
<color name="widget_frame">#4d808080</color>
</resources>
@@ -0,0 +1,4 @@
<resources>
<!-- Home-screen widget corners; WIDGET_RADIUS in src/widgets/primitives.tsx. -->
<dimen name="widget_radius">22dp</dimen>
</resources>
+3
View File
@@ -249,6 +249,9 @@
"color_tag": "Tag",
"remove_color": "Remove tag"
},
"deep_link": {
"open_failed": "Couldn't open this"
},
"email_composer": {
"attach_camera": "Camera",
"attachment_reminder_body": "Your message mentions \"{keyword}\" but no file is attached. Send anyway?",
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "bulwark-mobile",
"version": "0.1.64",
"version": "0.1.66",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-mobile",
"version": "0.1.64",
"version": "0.1.66",
"license": "AGPL-3.0-only",
"dependencies": {
"@react-native-async-storage/async-storage": "2.2.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bulwark-mobile",
"version": "0.1.64",
"version": "0.1.66",
"license": "AGPL-3.0-only",
"main": "index.ts",
"scripts": {
+25 -5
View File
@@ -3,6 +3,7 @@ import { handleDeepLink, parseDeepLink, shareToDeepLink } from '../linking';
import { usePendingSettingsTab } from '../pending-settings-tab';
import { usePendingCalendarOpen } from '../pending-calendar-open';
import { usePendingMailSearch } from '../pending-mail-search';
import { links } from '../../widgets/clicks';
describe('parseDeepLink', () => {
it('parses app-scheme mail links', () => {
@@ -80,6 +81,14 @@ describe('parseDeepLink', () => {
expect(parseDeepLink('bulwarkmobile://mail/message/M1?account=a%40b&jmapAccount=team&action=reply')).toEqual({
kind: 'message', emailId: 'M1', accountId: 'a@b', jmapAccountId: 'team', action: 'reply',
});
// What the widgets build is what the app reads, thread included.
const m = { id: 'M/1', threadId: 'T1', accountId: 'a@b', jmapAccountId: 'team' };
expect(parseDeepLink(links.reply(m))).toEqual({
kind: 'message', emailId: 'M/1', accountId: 'a@b', jmapAccountId: 'team', threadId: 'T1', action: 'reply',
});
expect(parseDeepLink(links.message({ id: 'M2', accountId: 'a@b' }))).toEqual({
kind: 'message', emailId: 'M2', accountId: 'a@b',
});
expect(parseDeepLink('bulwarkmobile://mail/draft/D1')).toEqual({ kind: 'draft', emailId: 'D1', accountId: undefined });
expect(parseDeepLink('bulwarkmobile://mail/unified?view=starred')).toEqual({ kind: 'unified', view: 'starred' });
expect(parseDeepLink('bulwarkmobile://mail/unified?role=drafts&view=bogus')).toEqual({ kind: 'unified', role: 'drafts' });
@@ -179,7 +188,7 @@ describe('handleDeepLink', () => {
}));
});
it('opens a group-mailbox message in its JMAP account and hands replies to the composer opener', async () => {
it('opens a group-mailbox message in its JMAP account', async () => {
const navigation = nav();
const resolveThreadId = vi.fn(async () => 'T1');
await handleDeepLink(
@@ -188,14 +197,25 @@ describe('handleDeepLink', () => {
);
expect(resolveThreadId).toHaveBeenCalledWith('M1', 'team');
expect(navigation.navigate).toHaveBeenCalledWith('EmailThread', { emailId: 'M1', threadId: 'T1', jmapAccountId: 'team' });
});
const openReply = vi.fn(async () => true);
it('opens a reply over its message, without a lookup when the link names the thread', async () => {
// Offline, or before the session is back: the lookup would fail.
const resolveThreadId = vi.fn(async () => null);
const navigation = nav();
const ok = await handleDeepLink(
{ kind: 'message', emailId: 'M2', action: 'reply' },
{ navigation: nav() as never, resolveThreadId, openReply },
{ kind: 'message', emailId: 'M2', threadId: 'T2', action: 'reply' },
{ navigation: navigation as never, resolveThreadId },
);
expect(ok).toBe(true);
expect(openReply).toHaveBeenCalledWith('M2', undefined);
expect(resolveThreadId).not.toHaveBeenCalled();
expect(navigation.navigate).toHaveBeenCalledWith('EmailThread', { emailId: 'M2', threadId: 'T2', action: 'reply' });
// Without the thread and without a server, nothing opens (the app says so).
expect(await handleDeepLink(
{ kind: 'message', emailId: 'M3', action: 'reply' },
{ navigation: nav() as never, resolveThreadId },
)).toBe(false);
});
it('opens drafts, unified views, scheduled mail and searches', async () => {
+12 -9
View File
@@ -19,8 +19,9 @@ type UnifiedView = typeof UNIFIED_VIEWS[number];
export type DeepLink =
// `jmapAccountId`: the JMAP account owning a message in a group/shared
// mailbox (`?jmapAccount=`); `action: 'reply'` opens a reply to it.
| { kind: 'message'; emailId: string; accountId?: string; jmapAccountId?: string; action?: 'reply' }
// mailbox (`?jmapAccount=`); `threadId` (`?thread=`) saves looking it up;
// `action: 'reply'` opens a reply to it.
| { kind: 'message'; emailId: string; accountId?: string; jmapAccountId?: string; threadId?: string; action?: 'reply' }
| { kind: 'draft'; emailId: string; accountId?: string; jmapAccountId?: string }
| { kind: 'thread'; threadId: string; accountId?: string }
| { kind: 'folder'; ref: string; accountId?: string }
@@ -96,11 +97,13 @@ export function parseDeepLink(url: string): DeepLink | null {
case 'mail': {
const jmapAccountId = search.get('jmapAccount') ?? undefined;
if (kind === 'message' && value) {
const threadId = search.get('thread');
return {
kind: 'message',
emailId: decodeSegment(value),
accountId,
...(jmapAccountId ? { jmapAccountId } : {}),
...(threadId ? { threadId } : {}),
...(search.get('action') === 'reply' ? { action: 'reply' as const } : {}),
};
}
@@ -169,9 +172,8 @@ export interface DeepLinkNavigator {
// null when the message cannot be loaded. `jmapAccountId` names the
// group/shared account holding it, when it is not the user's own.
resolveThreadId: (emailId: string, jmapAccountId?: string) => Promise<string | null>;
// Open the composer on a reply to / on a server draft of this message.
// Resolve false when the message cannot be loaded.
openReply?: (emailId: string, jmapAccountId?: string) => Promise<boolean>;
// Open the composer on a server draft. Resolve false when the draft cannot
// be loaded.
openDraft?: (emailId: string, jmapAccountId?: string) => Promise<boolean>;
// Switch to the account a permalink names (`?account=`); resolves false
// when that account is not signed in on this device.
@@ -189,15 +191,16 @@ export async function handleDeepLink(link: DeepLink, nav: DeepLinkNavigator): Pr
switch (link.kind) {
case 'message': {
if (link.action === 'reply' && nav.openReply) {
return nav.openReply(link.emailId, link.jmapAccountId);
}
const threadId = await nav.resolveThreadId(link.emailId, link.jmapAccountId);
const threadId = link.threadId ?? await nav.resolveThreadId(link.emailId, link.jmapAccountId);
if (!threadId) return false;
// A reply opens over the message, like the reader's own Reply: the
// reader loads it (from its cache when offline) and then opens the
// composer, or shows why it could not.
navigation.navigate('EmailThread', {
emailId: link.emailId,
threadId,
...(link.jmapAccountId ? { jmapAccountId: link.jmapAccountId } : {}),
...(link.action === 'reply' ? { action: 'reply' as const } : {}),
});
return true;
}
+2
View File
@@ -67,6 +67,8 @@ export type RootStackParamList = {
* viewer pages over the store's current folder.
*/
emailIds?: string[];
/** Open the composer on a reply once the message has loaded (widget Reply). */
action?: 'reply';
};
EmailSource: { emailId: string; blobId: string; subject?: string; jmapAccountId?: string };
Compose:
+9
View File
@@ -536,6 +536,15 @@ function EmailViewer({ route, navigation }: Props) {
if (params) navigation.navigate('Compose', params);
}, [email, navigation, ownerAccountId]);
// Opened to reply (a widget's Reply): the composer follows as soon as the
// message is here, once. If it never loads, the reader's error says why.
const replyPending = React.useRef(route.params.action === 'reply');
React.useEffect(() => {
if (!replyPending.current || email?.id !== route.params.emailId) return;
replyPending.current = false;
navigateCompose('reply', email);
}, [email, route.params.emailId, navigateCompose]);
// Forward the raw message as a message/rfc822 attachment (webmail 1.8.1).
const onForwardAsAttachment = () => {
setMoreMenuOpen(false);
+8 -4
View File
@@ -37,12 +37,16 @@ function query(params: Record<string, string | undefined>): string {
return parts.length ? `?${parts.join('&')}` : '';
}
// Message links carry the thread so the app opens them without asking the
// server first, which also works offline from its cache.
type MessageRef = Pick<MailItem, 'id' | 'accountId' | 'jmapAccountId'> & { threadId?: string };
export const links = {
inbox: () => `${base}mail/folder/inbox`,
message: (m: Pick<MailItem, 'id' | 'accountId' | 'jmapAccountId'>) =>
`${base}mail/message/${enc(m.id)}${query({ account: m.accountId, jmapAccount: m.jmapAccountId })}`,
reply: (m: Pick<MailItem, 'id' | 'accountId' | 'jmapAccountId'>) =>
`${base}mail/message/${enc(m.id)}${query({ account: m.accountId, jmapAccount: m.jmapAccountId, action: 'reply' })}`,
message: (m: MessageRef) =>
`${base}mail/message/${enc(m.id)}${query({ account: m.accountId, jmapAccount: m.jmapAccountId, thread: m.threadId })}`,
reply: (m: MessageRef) =>
`${base}mail/message/${enc(m.id)}${query({ account: m.accountId, jmapAccount: m.jmapAccountId, thread: m.threadId, action: 'reply' })}`,
draft: (m: Pick<MailItem, 'id' | 'accountId' | 'jmapAccountId'>) =>
`${base}mail/draft/${enc(m.id)}${query({ account: m.accountId, jmapAccount: m.jmapAccountId })}`,
unified: (params: { view?: 'all' | 'unread' | 'starred'; role?: string } = {}) =>
+2 -1
View File
@@ -184,7 +184,7 @@ export async function fetchMailSection(
calls.push(['Email/get', {
accountId,
'#ids': { resultOf: 'qa', name: 'Email/query', path: '/ids' },
properties: ['id', 'from', 'receivedAt', 'attachments'],
properties: ['id', 'threadId', 'from', 'receivedAt', 'attachments'],
bodyProperties: ['name', 'type', 'disposition', 'cid', 'size'],
}, 'ga']);
}
@@ -221,6 +221,7 @@ export async function fetchMailSection(
}>)
.map((e) => ({
emailId: e.id,
threadId: e.threadId,
accountId: registryAccountId,
fromName: e.from?.[0]?.name || e.from?.[0]?.email || '',
receivedAt: Date.parse(e.receivedAt) || 0,
+9 -11
View File
@@ -29,8 +29,6 @@ import type { EventItem, WidgetSnapshot } from '../snapshot';
import { chipBackground, normalizeHex, type WidgetPalette } from '../theme';
import type { Layout } from './types';
/** Surface's 1dp frame; Android adds a border to the padding, so it eats space. */
const FRAME = 1;
const HOUR = 3600000;
const GRID_START_HOUR = 8;
const GRID_END_HOUR = 18;
@@ -207,7 +205,7 @@ export const AgendaLayout: Layout = ({ s, p, f, now, height }) => {
);
}
// Header bar plus its 1dp rule.
const room = height - 2 * FRAME - headerHeight - 1;
const room = height - headerHeight - 1;
const { sections, hidden } = planAgenda(days, room);
const rows: React.JSX.Element[] = [];
sections.forEach((day, d) => {
@@ -366,12 +364,12 @@ export const WeekLayout: Layout = ({ s, p, f, now, width, height }) => {
const events = s.calendar.events;
const first = startOfWeek(now, s.weekStart);
const days = Array.from({ length: 7 }, (_, i) => addDays(first, i));
const colWidth = (width - 2 * FRAME - 2 * WEEK_PAD) / 7;
const colWidth = (width - 2 * WEEK_PAD) / 7;
const segments = allDaySegments(events, days);
const strip = segments.some((seg) => seg.event !== null);
const gridHeight = Math.max(
0,
height - 2 * FRAME - WEEK_PAD - WEEK_LABEL_HEIGHT - 2 - WEEK_DAY_SIZE - WEEK_GAP
height - WEEK_PAD - WEEK_LABEL_HEIGHT - 2 - WEEK_DAY_SIZE - WEEK_GAP
- (strip ? WEEK_STRIP_HEIGHT + WEEK_GAP : 0) - 1,
);
const windowStart = startOfDay(now) + GRID_START_HOUR * HOUR;
@@ -566,11 +564,11 @@ export const MonthLayout: Layout = ({ s, p, f, now, width, height }) => {
const weeks = monthGrid(s.calendar.events, now, s.weekStart);
const next = upcoming(s.calendar.events, now);
const headerHeight = height >= 340 ? 44 : 38;
const cellWidth = (width - 2 * FRAME - 2 * MONTH_PAD) / 7;
const cellWidth = (width - 2 * MONTH_PAD) / 7;
const chipsHeight = (n: number) =>
n === 0 ? 0 : MONTH_CHIPS_TOP + n * MONTH_CHIP_HEIGHT + (n - 1) * 4 + MONTH_CHIPS_BOTTOM;
const gridRoom = (n: number) =>
height - 2 * FRAME - headerHeight - MONTH_LABEL_HEIGHT - MONTH_GRID_TOP - chipsHeight(n);
height - headerHeight - MONTH_LABEL_HEIGHT - MONTH_GRID_TOP - chipsHeight(n);
// Two chips when the weeks still get a comfortable row, else one, else none.
let chips = Math.min(2, next.length);
while (chips > 0 && gridRoom(chips) / weeks.length < MONTH_CELL_MIN) chips -= 1;
@@ -700,7 +698,7 @@ function TodayColumn({
const tomorrowList = eventsOnDay(events, addDays(today, 1));
// The "N more this week" line is always reserved; chips share the rest.
const room = height - 2 * FRAME - 2 * TODAY_PAD - TODAY_LINE;
const room = height - 2 * TODAY_PAD - TODAY_LINE;
const row = TODAY_CHIP + TODAY_GAP;
let withTomorrow = tomorrowList.length > 0;
let slots = Math.floor((room - (withTomorrow ? TOMORROW_BLOCK : 0) + TODAY_GAP) / row);
@@ -792,7 +790,7 @@ function TodayColumn({
export const TodayEventsLayout: Layout = ({ s, p, f, now, height }) => {
// The date block is ~108dp tall at 40sp; a short 4x2 gets a smaller number
// and, if still tight, loses the month line.
const room = height - 2 * FRAME - 2 * TODAY_PAD;
const room = height - 2 * TODAY_PAD;
const big = room >= 108 ? 40 : 32;
const showMonth = room >= 3 * 18 + Math.ceil(big * 1.33);
return (
@@ -820,7 +818,7 @@ export const NextEventLayout: Layout = ({ s, p, f, now, height }) => {
const when = running ? f.t('widgets.calendar.now', 'Now') : capitalize(f.relative(e.start, now), f.locale);
// Status line, gap, one title line and the time range always show; then the
// place, the calendar and a second title line, as long as they fit.
const room = height - 2 * FRAME - 28;
const room = height - 28;
let used = 16 + 6 + 22 + 18;
const place = placeOf(f, e);
const showPlace = !!place && used + 16 <= room;
@@ -888,7 +886,7 @@ function TwoLineChip({ f, e, now }: { f: Fmt; e: EventItem; now: number }) {
}
export const DateNextLayout: Layout = ({ s, p, f, now, height }) => {
const room = height - 2 * FRAME - 2 * DATE_PAD - DATE_CIRCLE - 8;
const room = height - 2 * DATE_PAD - DATE_CIRCLE - 8;
const count = room >= 2 * DATE_CHIP + 4 ? 2 : 1;
const next = s.calendar.supported ? upcoming(s.calendar.events, now).slice(0, count) : [];
const chips: React.JSX.Element[] = [];
+3 -3
View File
@@ -306,8 +306,8 @@ export const MailAndNextLayout: Layout = ({ s, p, f, now, height }) => {
height: 'match_parent',
padding: pad,
backgroundColor: unread > 0 ? p.unreadRow : p.bg,
borderTopLeftRadius: WIDGET_RADIUS - 1,
borderBottomLeftRadius: WIDGET_RADIUS - 1,
borderTopLeftRadius: WIDGET_RADIUS,
borderBottomLeftRadius: WIDGET_RADIUS,
}}
>
<FlexWidget style={{ flexDirection: 'row', alignItems: 'center' }}>
@@ -616,7 +616,7 @@ export const AttachmentsLayout: Layout = ({ s, p, f, now, width, height }) => {
return (
<FlexWidget
key={g.emailId}
{...open(links.message({ id: g.emailId, accountId: g.accountId }))}
{...open(links.message({ id: g.emailId, threadId: g.threadId, accountId: g.accountId }))}
style={{
width: 'match_parent',
paddingHorizontal: 14,
+6 -3
View File
@@ -17,9 +17,14 @@ import { chipBackground, normalizeHex, type WidgetPalette } from './theme';
import { filledIcon, icon, type FilledIconName, type IconName } from './icons';
import type { WidgetClick } from './clicks';
/** Corner radius of the widget surface; launchers clip to roughly this on Android 12+. */
/** Corner radius of the widget surface; `widget_radius` in android/app/src/main/res/values/dimens.xml. */
export const WIDGET_RADIUS = 22;
/**
* The widget's background. Its edge and the clip to rounded corners come from
* the root layout (android/app/src/main/res/layout/rn_widget.xml), which has the
* launcher's real size; this bitmap is drawn at the reported one and can be cut off.
*/
export function Surface({
p,
children,
@@ -39,8 +44,6 @@ export function Surface({
width: 'match_parent',
backgroundColor: p.bg,
borderRadius: WIDGET_RADIUS,
borderWidth: 1,
borderColor: p.frame,
overflow: 'hidden',
...style,
}}
+2 -2
View File
@@ -157,9 +157,9 @@ export function sampleSnapshot(now: number): WidgetSnapshot {
recentSearches: ['from:sarah', 'invoice', 'has:attachment', 'Q4 timeline', 'is:unread'],
tags: [{ keyword: '$label:receipts', name: 'Receipts', color: '#f97316', unread: 2, total: 14, latestFrom: 'Sarah Kim', latestAt: ago(46) }],
attachments: [
{ emailId: 'm2', accountId: 'demo@example.com@demo', fromName: 'Sarah Kim', receivedAt: ago(46),
{ emailId: 'm2', threadId: 't-m2', accountId: 'demo@example.com@demo', fromName: 'Sarah Kim', receivedAt: ago(46),
files: [{ name: 'Invoice-2024-089.pdf', type: 'application/pdf' }, { name: 'prototype-v3.png', type: 'image/png' }] },
{ emailId: 'm7', accountId: 'demo@example.com@demo', fromName: 'Anna Kowalski', receivedAt: ago(60 * 48),
{ emailId: 'm7', threadId: 't-m7', accountId: 'demo@example.com@demo', fromName: 'Anna Kowalski', receivedAt: ago(60 * 48),
files: [{ name: 'wedding-001.jpg', type: 'image/jpeg' }, { name: 'wedding-014.jpg', type: 'image/jpeg' }, { name: 'wedding-038.jpg', type: 'image/jpeg' }] },
],
},
+2
View File
@@ -75,6 +75,8 @@ export interface TagCount {
export interface AttachmentGroup {
emailId: string;
/** Absent in snapshots stored before it was kept. */
threadId?: string;
accountId: string;
fromName: string;
receivedAt: number;
+4 -5
View File
@@ -10,15 +10,16 @@ export type WidgetScheme = 'light' | 'dark';
export interface WidgetPalette {
scheme: WidgetScheme;
/** Widget surface (webmail `--color-background`). */
/**
* Widget surface (webmail `--color-background`). The edge around it is drawn
* natively, as `widget_frame` in android/app/src/main/res/values/colors.xml.
*/
bg: ColorProp;
fg: ColorProp;
muted: ColorProp;
/** Search pill, segmented track, quota ring track (webmail `--color-muted`). */
mutedBg: ColorProp;
border: ColorProp;
/** Outer widget edge. */
frame: ColorProp;
primary: ColorProp;
primaryFg: ColorProp;
accent: ColorProp;
@@ -66,7 +67,6 @@ export const LIGHT: WidgetPalette = {
muted: '#64748b',
mutedBg: '#f1f5f9',
border: '#e2e8f0',
frame: '#e2e8f0',
primary: '#3b82f6',
primaryFg: '#ffffff',
accent: '#dbeafe',
@@ -108,7 +108,6 @@ export const DARK: WidgetPalette = {
muted: '#a3a3a3',
mutedBg: '#262626',
border: '#2d2d2d',
frame: 'rgba(128, 128, 128, 0.3)',
primary: '#fafafa',
primaryFg: '#171717',
accent: '#1e3a8a',