mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 08:06:13 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db07acc37c | ||
|
|
39628d84dc | ||
|
|
78901f2375 | ||
|
|
a4985a63a8 |
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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?",
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.64",
|
||||
"version": "0.1.66",
|
||||
"license": "AGPL-3.0-only",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
|
||||
@@ -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' }] },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user