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
13 changes: 12 additions & 1 deletion apps/mobile/src/app/(tabs)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,15 @@ import { Icon } from '@/components/icon';
import { useTheme } from '@/theme';

/**
* Notes and To-dos, the reference's two tabs.
* Notes, To-dos and the Feed.
*
* To-dos is not a second store: a to-do is a note that has been promoted to a
* commitment, so the tab is a view over the same table rather than a parallel
* feature with its own data.
*
* Feed is last, and that ordering is load-bearing. The app opens on Notes; the
* social layer is somewhere you go, not somewhere you land. Nothing on the
* first two tabs links into the third.
*/
export default function TabsLayout() {
const theme = useTheme();
Expand Down Expand Up @@ -44,6 +48,13 @@ export default function TabsLayout() {
tabBarIcon: ({ color }) => <Icon name="todos" size={20} color={color} />,
}}
/>
<Tabs.Screen
name="social"
options={{
title: 'Feed',
tabBarIcon: ({ color }) => <Icon name="feed" size={21} color={color} />,
}}
/>
</Tabs>
);
}
6 changes: 4 additions & 2 deletions apps/mobile/src/app/(tabs)/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,11 @@ export default function NotesList() {
);

// Only root notes are listed; updates belong inside their thread. Locked
// notes stay out of every list until the lock is satisfied.
// notes stay out of every list until the lock is satisfied, and posts written
// straight to the feed stay out of it entirely -- this list is the private
// library, and a post was never part of it.
const rootNotes = useMemo(
() => notes.filter((note) => note.kind === 'note' && !note.locked),
() => notes.filter((note) => note.kind === 'note' && !note.locked && !note.bornPublic),
[notes],
);

Expand Down
225 changes: 225 additions & 0 deletions apps/mobile/src/app/(tabs)/social.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
import { composeFeed, type FeedItem } from '@dailynote/core';
import { useRouter } 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';
import { SafeAreaView } from 'react-native-safe-area-context';

import { Avatar, FeedCard } from '@/components/feed-card';
import { Icon } from '@/components/icon';
import { haptics } from '@/lib/haptics';
import * as motion from '@/lib/motion';
import { AnimatedPressable } from '@/lib/motion';
import { useSocial } from '@/store/social-store';
import { useTheme } from '@/theme';

/**
* The feed: other people's notes, and the ones you chose to share.
*
* Two things live behind this tab and they are deliberately both here rather
* than anywhere near the notes composer -- posting straight to the feed, and
* reading what other people published. The notes app on the first tab does not
* know this screen exists, which is the arrangement docs/product.md asks for:
* capture never acquires an audience.
*
* Order is reverse chronological and there is nowhere to put a ranking signal.
* See `composeFeed`.
*/
export default function Feed() {
const theme = useTheme();
const router = useRouter();
const { me, items, following, blocked, loading, setLiked } = useSocial();

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

const feed = useMemo(
() =>
composeFeed(items, {
following: scope === 'following' ? following : undefined,
blocked,
selfId: me?.id ?? null,
}),
[items, scope, following, blocked, me],
);

const like = useCallback(
(item: FeedItem) => {
void setLiked(item.id, !item.likedByMe);
},
[setLiked],
);

const fabPressed = useSharedValue(0);
const fabStyle = useAnimatedStyle(() => ({
transform: [{ scale: 1 - 0.08 * fabPressed.value }],
}));

return (
<SafeAreaView className="flex-1 bg-canvas dark:bg-canvas-dark" edges={['top', 'left', 'right']}>
<View className="flex-row items-center justify-end gap-1 px-3 pb-1 pt-1">
<Pressable
hitSlop={8}
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 } })
}
className="h-11 w-11 items-center justify-center rounded-full active:opacity-50"
>
{me === null ? (
<Icon name="profile" size={22} color={theme.ink} />
) : (
<Avatar profile={me} size={30} />
)}
</Pressable>
</View>

<Animated.FlatList
data={feed}
keyExtractor={(item) => item.id}
contentContainerClassName="pb-32"
itemLayoutAnimation={motion.layout}
ListHeaderComponent={
<Header
scope={scope}
onScope={setScope}
count={feed.length}
claimed={me !== null}
/>
}
ListEmptyComponent={loading ? null : <Empty scope={scope} />}
renderItem={({ item, index }) => (
<FeedCard
item={item}
onLike={() => like(item)}
first={index === 0}
last={index === feed.length - 1}
/>
)}
/>

<AnimatedPressable
accessibilityRole="button"
accessibilityLabel="Write a post"
onPress={() => {
haptics.tap();
router.push(me === null ? '/handle' : '/post/new');
}}
onPressIn={() => {
fabPressed.value = withSpring(1, motion.SPRING);
}}
onPressOut={() => {
fabPressed.value = withSpring(0, motion.SPRING);
}}
style={fabStyle}
className="absolute bottom-6 right-5 h-16 w-16 items-center justify-center rounded-full bg-accent shadow-lg dark:bg-accent-dark"
>
<Icon name="edit" size={26} color={theme.accentInk} />
</AnimatedPressable>
</SafeAreaView>
);
}

function Header({
scope,
onScope,
count,
claimed,
}: {
scope: 'following' | 'everyone';
onScope: (next: 'following' | 'everyone') => void;
count: number;
claimed: boolean;
}) {
const router = useRouter();
const theme = useTheme();

return (
<View className="pb-4">
<Text className="px-5 text-[40px] font-bold leading-[48px] text-ink dark:text-ink-dark">
Feed
</Text>
<Text className="px-5 pt-0.5 text-[15px] text-muted dark:text-muted-dark">
{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.
<Pressable
accessibilityRole="button"
onPress={() => router.push('/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
</Text>
<Text className="pt-0.5 text-[13px] text-muted dark:text-muted-dark">
Reading needs nothing. Publishing needs a name.
</Text>
</View>
<Icon name="chevron" size={20} color={theme.faint} />
</Pressable>
) : null}

<View className="flex-row gap-2 px-5 pt-4">
<Segment label="Everyone" active={scope === 'everyone'} onPress={() => onScope('everyone')} />
<Segment
label="Following"
active={scope === 'following'}
onPress={() => onScope('following')}
/>
</View>
</View>
);
}

function Segment({
label,
active,
onPress,
}: {
label: string;
active: boolean;
onPress: () => void;
}) {
return (
<Pressable
onPress={onPress}
accessibilityRole="button"
accessibilityState={{ selected: active }}
className={[
'h-11 justify-center rounded-xl px-4 active:opacity-60',
active ? 'bg-elevated dark:bg-elevated-dark' : 'bg-card dark:bg-card-dark',
].join(' ')}
>
<Text
className={[
'text-[15px]',
active ? 'font-semibold text-ink dark:text-ink-dark' : 'text-muted dark:text-muted-dark',
].join(' ')}
>
{label}
</Text>
</Pressable>
);
}

function Empty({ scope }: { scope: 'following' | 'everyone' }) {
return (
<Animated.View entering={motion.enterFade} className="items-center px-10 pt-24">
<Text className="text-[17px] text-muted dark:text-muted-dark">
{scope === 'following' ? 'Nobody you follow has posted.' : 'Nothing published yet.'}
</Text>
<Text className="pt-2 text-center text-[15px] text-faint dark:text-faint-dark">
{scope === 'following'
? 'Switch to Everyone to find someone.'
: 'Share a note, or write a post.'}
</Text>
</Animated.View>
);
}
52 changes: 34 additions & 18 deletions apps/mobile/src/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { SafeAreaProvider } from 'react-native-safe-area-context';
import { migrate } from '@/db/migrations';
import { NotebooksProvider } from '@/store/notebooks-store';
import { NotesProvider } from '@/store/notes-store';
import { SocialProvider } from '@/store/social-store';
import { useTheme } from '@/theme';

import '../global.css';
Expand Down Expand Up @@ -64,24 +65,39 @@ export default function RootLayout() {
<SQLiteProvider databaseName={DATABASE_NAME} onInit={migrate} useSuspense>
<NotebooksProvider>
<NotesProvider>
<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>
{/* 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>
</NotesProvider>
</NotebooksProvider>
</SQLiteProvider>
Expand Down
Loading
Loading