feat(mobile): add two step confirmation for mobile journal (#13266)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Introduced a new mobile journals page with a two-step confirmation
flow, allowing users to select a date and confirm before creating or
opening a journal.
  * Added a dedicated route for journals on mobile devices.
* Implemented a placeholder view when no journal exists for a selected
date on both desktop and mobile.

* **Enhancements**
* Improved mobile and desktop styling for journals pages, including
responsive adjustments for mobile layouts.
* Updated journal navigation behavior based on a feature flag, enabling
or disabling the two-step confirmation flow.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Cats Juice
2025-07-21 13:30:24 +08:00
committed by GitHub
parent 612c73cab1
commit 52e69e0dde
7 changed files with 228 additions and 44 deletions
@@ -20,12 +20,22 @@ export const body = style({
width: '100%',
height: '100%',
borderTop: `0.5px solid ${cssVarV2.layer.insideBorder.border}`,
selectors: {
'&[data-mobile]': {
borderTop: 'none',
},
},
});
export const content = style({
maxWidth: 944,
padding: '0px 50px',
margin: '0 auto',
selectors: {
'[data-mobile] &': {
padding: '0 24px',
},
},
});
export const docTitleContainer = style({
@@ -33,7 +33,7 @@ import {
import { AllDocSidebarTabs } from '../layouts/all-doc-sidebar-tabs';
import * as styles from './index.css';
function getDateFromUrl(location: Location) {
export function getDateFromUrl(location: Location) {
const searchParams = new URLSearchParams(location.search);
const date = searchParams.get('date')
? dayjs(searchParams.get('date'))
@@ -41,6 +41,49 @@ function getDateFromUrl(location: Location) {
return date.format(JOURNAL_DATE_FORMAT);
}
export const JournalPlaceholder = ({ dateString }: { dateString: string }) => {
const t = useI18n();
const [redirecting, setRedirecting] = useState(false);
const workbench = useService(WorkbenchService).workbench;
const journalService = useService(JournalService);
const createJournal = useCallback(() => {
if (redirecting) return;
setRedirecting(true);
const doc = journalService.ensureJournalByDate(dateString);
workbench.openDoc(doc.id, {
replaceHistory: true,
at: 'active',
});
}, [dateString, journalService, redirecting, workbench]);
return (
<div className={styles.body} data-mobile={BUILD_CONFIG.isMobileEdition}>
<div className={styles.content}>
<BlocksuiteEditorJournalDocTitleUI
date={dateString}
overrideClassName={styles.docTitleContainer}
/>
<div className={styles.placeholder}>
<div className={styles.placeholderIcon}>
<TodayIcon />
</div>
<div className={styles.placeholderText}>
{t['com.affine.journal.placeholder.title']()}
</div>
<Button
variant="primary"
onClick={createJournal}
data-testid="confirm-create-journal-button"
>
{t['com.affine.journal.placeholder.create']()}
</Button>
</div>
</div>
</div>
);
};
const weekStyle = { maxWidth: 800, width: '100%' };
// this route page acts as a redirector to today's journal
export const JournalsPageWithConfirmation = () => {
@@ -55,19 +98,8 @@ export const JournalsPageWithConfirmation = () => {
const todayString = dayjs().format(JOURNAL_DATE_FORMAT);
const isToday = dateString === todayString;
const [redirecting, setRedirecting] = useState(false);
const [ready, setReady] = useState(false);
const createJournal = useCallback(() => {
if (redirecting) return;
setRedirecting(true);
const doc = journalService.ensureJournalByDate(dateString);
workbench.openDoc(doc.id, {
replaceHistory: true,
at: 'active',
});
}, [dateString, journalService, redirecting, workbench]);
const openJournal = useCallback(
(date: string) => {
workbench.open(`/journals?date=${date}`, { at: 'active' });
@@ -118,29 +150,7 @@ export const JournalsPageWithConfirmation = () => {
</div>
</ViewHeader>
<ViewBody>
<div className={styles.body}>
<div className={styles.content}>
<BlocksuiteEditorJournalDocTitleUI
date={dateString}
overrideClassName={styles.docTitleContainer}
/>
<div className={styles.placeholder}>
<div className={styles.placeholderIcon}>
<TodayIcon />
</div>
<div className={styles.placeholderText}>
{t['com.affine.journal.placeholder.title']()}
</div>
<Button
variant="primary"
onClick={createJournal}
data-testid="confirm-create-journal-button"
>
{t['com.affine.journal.placeholder.create']()}
</Button>
</div>
</div>
</div>
<JournalPlaceholder dateString={dateString} />
</ViewBody>
<AllDocSidebarTabs />
</>
@@ -1,4 +1,5 @@
import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta';
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { JournalService } from '@affine/core/modules/journal';
import { WorkbenchService } from '@affine/core/modules/workbench';
import { TodayIcon } from '@blocksuite/icons/rc';
@@ -13,15 +14,23 @@ export const AppTabJournal = ({ tab }: AppTabCustomFCProps) => {
const location = useLiveData(workbench.location$);
const journalService = useService(JournalService);
const docDisplayMetaService = useService(DocDisplayMetaService);
const featureFlagService = useService(FeatureFlagService);
const isTwoStepJournalConfirmationEnabled = useLiveData(
featureFlagService.flags.enable_two_step_journal_confirmation.$
);
const maybeDocId = location.pathname.split('/')[1];
const journalDate = useLiveData(journalService.journalDate$(maybeDocId));
const JournalIcon = useLiveData(docDisplayMetaService.icon$(maybeDocId));
const handleOpenToday = useCallback(() => {
const docId = journalService.ensureJournalByDate(new Date()).id;
workbench.openDoc({ docId, fromTab: 'true' }, { at: 'active' });
}, [journalService, workbench]);
if (isTwoStepJournalConfirmationEnabled) {
workbench.open('/journals', { at: 'active' });
} else {
const docId = journalService.ensureJournalByDate(new Date()).id;
workbench.openDoc({ docId, fromTab: 'true' }, { at: 'active' });
}
}, [workbench, journalService, isTwoStepJournalConfirmationEnabled]);
const Icon = journalDate ? JournalIcon : TodayIcon;
@@ -242,6 +242,10 @@ const MobileDetailPage = ({
const workbench = useService(WorkbenchService).workbench;
const [showTitle, setShowTitle] = useState(checkShowTitle);
const title = useLiveData(docDisplayMetaService.title$(pageId));
const featureFlagService = useService(FeatureFlagService);
const isTwoStepJournalConfirmationEnabled = useLiveData(
featureFlagService.flags.enable_two_step_journal_confirmation.$
);
const canAccess = useGuard('Doc_Read', pageId);
@@ -252,13 +256,25 @@ const MobileDetailPage = ({
const handleDateChange = useCallback(
(date: string) => {
const docId = journalService.ensureJournalByDate(date).id;
workbench.openDoc(
{ docId, fromTab: fromTab ? 'true' : undefined },
{ replaceHistory: true }
);
if (isTwoStepJournalConfirmationEnabled) {
const docs = journalService.journalsByDate$(date).value;
if (docs.length > 0) {
workbench.openDoc(
{ docId: docs[0].id, fromTab: fromTab ? 'true' : undefined },
{ replaceHistory: true }
);
} else {
workbench.open(`/journals?date=${date}`);
}
} else {
const docId = journalService.ensureJournalByDate(date).id;
workbench.openDoc(
{ docId, fromTab: fromTab ? 'true' : undefined },
{ replaceHistory: true }
);
}
},
[fromTab, journalService, workbench]
[fromTab, isTwoStepJournalConfirmationEnabled, journalService, workbench]
);
useGlobalEvent(
@@ -0,0 +1,26 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const container = style({
display: 'flex',
flexDirection: 'column',
height: '100dvh',
overflow: 'hidden',
backgroundColor: cssVarV2('layer/background/primary'),
});
export const header = style({
backgroundColor: cssVarV2('layer/background/primary'),
});
export const headerTitle = style({
color: cssVarV2('text/primary'),
fontSize: 17,
lineHeight: '22px',
fontWeight: 600,
letterSpacing: -0.43,
});
export const journalDatePicker = style({
backgroundColor: cssVarV2('layer/background/primary'),
});
@@ -0,0 +1,107 @@
import {
getDateFromUrl,
JournalPlaceholder,
} from '@affine/core/desktop/pages/workspace/journals';
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
import {
JOURNAL_DATE_FORMAT,
JournalService,
} from '@affine/core/modules/journal';
import { ViewService, WorkbenchService } from '@affine/core/modules/workbench';
import { i18nTime } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra';
import { cssVarV2 } from '@toeverything/theme/v2';
import dayjs from 'dayjs';
import { useCallback, useEffect, useLayoutEffect, useState } from 'react';
import { AppTabs, PageHeader } from '../../components';
import { JournalDatePicker } from './detail/journal-date-picker';
import * as styles from './journals.css';
export const JournalsPageWithConfirmation = () => {
const journalService = useService(JournalService);
const workbench = useService(WorkbenchService).workbench;
const view = useService(ViewService).view;
const location = useLiveData(view.location$);
const dateString = getDateFromUrl(location);
const [ready, setReady] = useState(false);
const allJournalDates = useLiveData(journalService.allJournalDates$);
const handleDateChange = useCallback(
(date: string) => {
workbench.open(`/journals?date=${date}`, { at: 'active' });
},
[workbench]
);
useLayoutEffect(() => {
// only handle current route
if (!location.pathname.startsWith('/journals')) return;
// check if the journal is created
const docs = journalService.journalsByDate$(dateString).value;
if (docs.length === 0) {
setReady(true);
return;
}
// if created, redirect to the journal
const journal = docs[0];
workbench.openDoc(journal.id, { replaceHistory: true, at: 'active' });
}, [dateString, journalService, location.pathname, view, workbench]);
if (!ready) return null;
return (
<>
<div className={styles.container}>
<PageHeader
className={styles.header}
bottom={
<JournalDatePicker
date={dateString}
onChange={handleDateChange}
withDotDates={allJournalDates}
className={styles.journalDatePicker}
/>
}
contentClassName={styles.headerTitle}
bottomSpacer={94}
>
{i18nTime(dayjs(dateString), { absolute: { accuracy: 'month' } })}
</PageHeader>
<JournalPlaceholder dateString={dateString} />
</div>
<AppTabs background={cssVarV2('layer/background/primary')} />
</>
);
};
export const JournalsPageWithoutConfirmation = () => {
const journalService = useService(JournalService);
const workbench = useService(WorkbenchService).workbench;
useEffect(() => {
const today = dayjs().format(JOURNAL_DATE_FORMAT);
const doc = journalService.ensureJournalByDate(today);
workbench.openDoc(doc.id, {
replaceHistory: true,
at: 'active',
});
}, [journalService, workbench]);
return null;
};
export const Component = () => {
const featureFlagService = useService(FeatureFlagService);
const isTwoStepJournalConfirmationEnabled = useLiveData(
featureFlagService.flags.enable_two_step_journal_confirmation.$
);
if (isTwoStepJournalConfirmationEnabled) {
return <JournalsPageWithConfirmation />;
}
return <JournalsPageWithoutConfirmation />;
};
@@ -4,6 +4,7 @@ import { Component as All } from './pages/workspace/all';
import { Component as Collection } from './pages/workspace/collection';
import { Component as CollectionDetail } from './pages/workspace/collection/detail';
import { Component as Home } from './pages/workspace/home';
import { Component as Journals } from './pages/workspace/journals';
import { Component as Search } from './pages/workspace/search';
import { Component as Tag } from './pages/workspace/tag';
import { Component as TagDetail } from './pages/workspace/tag/detail';
@@ -41,6 +42,11 @@ export const workbenchRoutes = [
// lazy: () => import('./pages/workspace/tag/detail'),
Component: TagDetail,
},
{
path: '/journals',
// lazy: () => import('./pages/workspace/journals'),
Component: Journals,
},
{
path: '/trash',
lazy: () => import('./pages/workspace/trash'),