Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions apps/mobile/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Copy to .env.local and fill in. See docs/auth.md for where each value comes from.
#
# EXPO_PUBLIC_ variables are compiled into the app bundle and are not secret.
# The anon key is designed to be public and is protected by row-level security;
# an OAuth client ID is public by definition. Never put a service-role key here.
#
# Leaving these blank is a supported build: notes and reading the feed work,
# and the sign-in screen says it is not set up.

EXPO_PUBLIC_SUPABASE_URL=
EXPO_PUBLIC_SUPABASE_ANON_KEY=

# The *web* OAuth client ID, on Android too. The Android client is matched by
# package name and signing fingerprint and is never named here.
EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID=
8 changes: 6 additions & 2 deletions apps/mobile/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
"ios": {
"icon": "./assets/expo.icon",
"bundleIdentifier": "com.zacktiger.dailynote",
"supportsTablet": true
"supportsTablet": true,
"usesAppleSignIn": true
},
"android": {
"package": "com.zacktiger.dailynote",
Expand Down Expand Up @@ -48,7 +49,10 @@
{
"faceIDPermission": "DailyNote uses Face ID to open notes you have locked."
}
]
],
"@react-native-google-signin/google-signin",
"expo-apple-authentication",
"expo-web-browser"
],
"experiments": {
"typedRoutes": true,
Expand Down
5 changes: 5 additions & 0 deletions apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@
"@expo-google-fonts/newsreader": "^0.4.1",
"@expo/ui": "~57.0.8",
"@expo/vector-icons": "^15.0.2",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-google-signin/google-signin": "^16.1.4",
"@supabase/supabase-js": "^2.112.0",
"expo": "~57.0.8",
"expo-apple-authentication": "~57.0.1",
"expo-constants": "~57.0.7",
"expo-crypto": "~57.0.1",
"expo-file-system": "~57.0.1",
Expand All @@ -33,6 +37,7 @@
"expo-sqlite": "~57.0.1",
"expo-status-bar": "~57.0.1",
"expo-system-ui": "~57.0.2",
"expo-web-browser": "~57.0.2",
"nativewind": "^4.2.1",
"react": "19.2.3",
"react-dom": "19.2.3",
Expand Down
49 changes: 28 additions & 21 deletions apps/mobile/src/app/(tabs)/social.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { composeFeed, type FeedItem } from '@dailynote/core';
import { useRouter } from 'expo-router';
import { useRouter, type Href } from 'expo-router';
import { useCallback, useMemo, useState } from 'react';
import { Pressable, Text, View } from 'react-native';
import Animated, { useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated';
Expand Down Expand Up @@ -28,10 +28,20 @@ import { useTheme } from '@/theme';
export default function Feed() {
const theme = useTheme();
const router = useRouter();
const { me, items, following, blocked, loading, setLiked } = useSocial();
const { me, needed, items, following, blocked, loading, setLiked } = useSocial();

const [scope, setScope] = useState<'following' | 'everyone'>('everyone');

/**
* The screen standing between the reader and publishing, or null.
*
* Sign-in and the handle claim are two steps, and this names whichever is
* outstanding so nobody is sent to one they have already satisfied. Reading
* never consults it.
*/
const gate: Href | null =
needed === 'sign-in' ? '/sign-in' : needed === 'handle' ? '/handle' : null;

const feed = useMemo(
() =>
composeFeed(items, {
Expand Down Expand Up @@ -62,9 +72,9 @@ export default function Feed() {
accessibilityRole="button"
accessibilityLabel={me === null ? 'Set up your profile' : 'Your profile'}
onPress={() =>
me === null
? router.push('/handle')
: router.push({ pathname: '/profile/[handle]', params: { handle: me.handle } })
router.push(
gate ?? { pathname: '/profile/[handle]', params: { handle: me?.handle ?? '' } },
)
}
className="h-11 w-11 items-center justify-center rounded-full active:opacity-50"
>
Expand All @@ -82,12 +92,7 @@ export default function Feed() {
contentContainerClassName="pb-32"
itemLayoutAnimation={motion.layout}
ListHeaderComponent={
<Header
scope={scope}
onScope={setScope}
count={feed.length}
claimed={me !== null}
/>
<Header scope={scope} onScope={setScope} count={feed.length} needed={needed} />
}
ListEmptyComponent={loading ? null : <Empty scope={scope} />}
renderItem={({ item, index }) => (
Expand All @@ -105,7 +110,7 @@ export default function Feed() {
accessibilityLabel="Write a post"
onPress={() => {
haptics.tap();
router.push(me === null ? '/handle' : '/post/new');
router.push(gate ?? '/post/new');
}}
onPressIn={() => {
fabPressed.value = withSpring(1, motion.SPRING);
Expand All @@ -126,12 +131,12 @@ function Header({
scope,
onScope,
count,
claimed,
needed,
}: {
scope: 'following' | 'everyone';
onScope: (next: 'following' | 'everyone') => void;
count: number;
claimed: boolean;
needed: 'sign-in' | 'handle' | null;
}) {
const router = useRouter();
const theme = useTheme();
Expand All @@ -145,21 +150,23 @@ function Header({
{count === 1 ? '1 thread' : `${count} threads`}
</Text>

{!claimed ? (
// Shown until a handle exists rather than blocking the screen: reading
// the feed needs no account, and being asked to sign up before you can
// see anything is how a social tab teaches people to avoid it.
{needed !== null ? (
// A prompt, not a wall. Reading the feed needs no account at all, and
// demanding a sign-up before you can see anything is how a social tab
// teaches people to avoid it.
<Pressable
accessibilityRole="button"
onPress={() => router.push('/handle')}
onPress={() => router.push(needed === 'sign-in' ? '/sign-in' : '/handle')}
className="mx-4 mt-4 flex-row items-center gap-3 rounded-2xl bg-card px-4 py-3.5 active:opacity-70 dark:bg-card-dark"
>
<View className="flex-1">
<Text className="text-[15px] font-semibold text-ink dark:text-ink-dark">
Pick a handle to post
{needed === 'sign-in' ? 'Sign in to publish' : 'Pick a handle to post'}
</Text>
<Text className="pt-0.5 text-[13px] text-muted dark:text-muted-dark">
Reading needs nothing. Publishing needs a name.
{needed === 'sign-in'
? 'Reading needs nothing. Your notes need nothing either.'
: 'One more step, and it is the only one that shows.'}
</Text>
</View>
<Icon name="chevron" size={20} color={theme.faint} />
Expand Down
83 changes: 52 additions & 31 deletions apps/mobile/src/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { SafeAreaProvider } from 'react-native-safe-area-context';

import { migrate } from '@/db/migrations';
import { AuthProvider } from '@/store/auth-store';
import { NotebooksProvider } from '@/store/notebooks-store';
import { NotesProvider } from '@/store/notes-store';
import { SocialProvider } from '@/store/social-store';
Expand Down Expand Up @@ -67,37 +68,57 @@ export default function RootLayout() {
<NotesProvider>
{/* Inside NotesProvider, not beside it: publishing is an
operation on a note, so the social store reads the notes
store rather than keeping a second copy of the same rows. */}
<SocialProvider>
<StatusBar style={theme.dark ? 'light' : 'dark'} />
<Stack
screenOptions={{
// Every screen draws its own header: the reference chrome
// is a plain icon row, not a navigator title bar.
headerShown: false,
contentStyle: { backgroundColor: theme.canvas },
}}
>
<Stack.Screen name="(tabs)" />
<Stack.Screen name="note/[id]" />
<Stack.Screen
name="notebooks"
options={{ presentation: 'modal', animation: 'slide_from_bottom' }}
/>
<Stack.Screen name="trash" />
<Stack.Screen name="locked" />
<Stack.Screen name="post/[id]" />
<Stack.Screen
name="post/new"
options={{ presentation: 'modal', animation: 'slide_from_bottom' }}
/>
<Stack.Screen name="profile/[handle]" />
<Stack.Screen
name="handle"
options={{ presentation: 'modal', animation: 'slide_from_bottom' }}
/>
</Stack>
</SocialProvider>
store rather than keeping a second copy of the same rows.
Auth wraps social because a profile is keyed to an account,
and nothing in the notes half reads either of them. */}
<AuthProvider>
<SocialProvider>
<StatusBar style={theme.dark ? 'light' : 'dark'} />
<Stack
screenOptions={{
// Every screen draws its own header: the reference chrome
// is a plain icon row, not a navigator title bar.
headerShown: false,
contentStyle: { backgroundColor: theme.canvas },
}}
>
<Stack.Screen name="(tabs)" />
<Stack.Screen name="note/[id]" />
<Stack.Screen
name="notebooks"
options={{
presentation: 'modal',
animation: 'slide_from_bottom',
}}
/>
<Stack.Screen name="trash" />
<Stack.Screen name="locked" />
<Stack.Screen name="post/[id]" />
<Stack.Screen
name="post/new"
options={{
presentation: 'modal',
animation: 'slide_from_bottom',
}}
/>
<Stack.Screen name="profile/[handle]" />
<Stack.Screen
name="handle"
options={{
presentation: 'modal',
animation: 'slide_from_bottom',
}}
/>
<Stack.Screen
name="sign-in"
options={{
presentation: 'modal',
animation: 'slide_from_bottom',
}}
/>
</Stack>
</SocialProvider>
</AuthProvider>
</NotesProvider>
</NotebooksProvider>
</SQLiteProvider>
Expand Down
19 changes: 19 additions & 0 deletions apps/mobile/src/app/handle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { SafeAreaView } from 'react-native-safe-area-context';
import { Icon } from '@/components/icon';
import { haptics } from '@/lib/haptics';
import { useGoBack } from '@/lib/nav';
import { useAuth } from '@/store/auth-store';
import { useSocial } from '@/store/social-store';
import { useTheme } from '@/theme';

Expand All @@ -31,6 +32,7 @@ export default function ClaimHandle() {
const router = useRouter();
const goBack = useGoBack();
const { me, claim } = useSocial();
const { userId } = useAuth();

const [handle, setHandle] = useState('');
const [name, setName] = useState('');
Expand Down Expand Up @@ -63,6 +65,23 @@ export default function ClaimHandle() {
})();
}, [ready, claim, normalized, name, router]);

// Reachable by deep link, or by signing out with this screen still on the
// stack. A handle belongs to an account, so there is nothing to claim yet.
if (userId === null) {
return (
<SafeAreaView className="flex-1 items-center justify-center bg-canvas dark:bg-canvas-dark">
<Text className="text-[17px] text-muted dark:text-muted-dark">Sign in first.</Text>
<Pressable
onPress={() => router.replace('/sign-in')}
className="mt-4 active:opacity-60"
accessibilityRole="button"
>
<Text className="text-[15px] font-medium text-accent dark:text-accent-dark">Sign in</Text>
</Pressable>
</SafeAreaView>
);
}

if (me !== null) {
return (
<SafeAreaView className="flex-1 items-center justify-center bg-canvas dark:bg-canvas-dark">
Expand Down
23 changes: 18 additions & 5 deletions apps/mobile/src/app/note/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -256,11 +256,24 @@ function Editor({ note }: { note: Note | null }) {

const problem = shareError(current);
if (problem !== null) {
// Two of these are fixable in one tap, so they get a button rather
// than an instruction to go and find the right screen.
const route =
problem === 'Sign in to publish.'
? '/sign-in'
: problem === 'Claim a handle first.'
? '/handle'
: null;

Alert.alert(
'Not this one',
problem === 'Claim a handle first.'
? 'Pick a handle on the Feed tab before publishing anything.'
: problem,
route === null ? 'Not this one' : 'Almost',
problem,
route === null
? undefined
: [
{ text: 'Not now', style: 'cancel' },
{ text: 'Continue', onPress: () => router.push(route) },
],
);
return;
}
Expand All @@ -284,7 +297,7 @@ function Editor({ note }: { note: Note | null }) {
],
);
})();
}, [requireSaved, repo, shareError, publish]);
}, [requireSaved, repo, shareError, publish, router]);

const confirmUnshare = useCallback(() => {
setSheet(null);
Expand Down
25 changes: 24 additions & 1 deletion apps/mobile/src/app/profile/[handle].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { Icon, type IconName } from '@/components/icon';
import { Sheet } from '@/components/sheet';
import { haptics } from '@/lib/haptics';
import { useGoBack } from '@/lib/nav';
import { useAuth } from '@/store/auth-store';
import { useSocial } from '@/store/social-store';
import { useTheme } from '@/theme';

Expand All @@ -27,6 +28,7 @@ export default function ProfileScreen() {
const goBack = useGoBack();
const { me, items, following, blocked, repo, setFollowing, setBlocked, setLiked, updateMe } =
useSocial();
const { signOut } = useAuth();

const [profile, setProfile] = useState<Profile | null>(null);
const [counts, setCounts] = useState({ followers: 0, following: 0 });
Expand Down Expand Up @@ -171,7 +173,28 @@ export default function ProfileScreen() {
<Sheet visible={sheet === 'more'} onClose={() => setSheet(null)} title="Profile">
<View className="pb-2">
{mine ? (
<MenuRow icon="edit" label="Edit profile" onPress={() => setSheet('edit')} />
<>
<MenuRow icon="edit" label="Edit profile" onPress={() => setSheet('edit')} />
<MenuRow
icon="block"
label="Sign out"
destructive
onPress={() => {
setSheet(null);
Alert.alert('Sign out?', 'Your notes stay on this device either way.', [
{ text: 'Cancel', style: 'cancel' },
{
text: 'Sign out',
style: 'destructive',
onPress: () => {
void signOut();
goBack();
},
},
]);
}}
/>
</>
) : (
<MenuRow
icon="block"
Expand Down
Loading
Loading