diff --git a/apps/mobile/src/app/(tabs)/_layout.tsx b/apps/mobile/src/app/(tabs)/_layout.tsx
index 26db4dc..5e61c83 100644
--- a/apps/mobile/src/app/(tabs)/_layout.tsx
+++ b/apps/mobile/src/app/(tabs)/_layout.tsx
@@ -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();
@@ -44,6 +48,13 @@ export default function TabsLayout() {
tabBarIcon: ({ color }) => ,
}}
/>
+ ,
+ }}
+ />
);
}
diff --git a/apps/mobile/src/app/(tabs)/index.tsx b/apps/mobile/src/app/(tabs)/index.tsx
index 745bd4d..d35076d 100644
--- a/apps/mobile/src/app/(tabs)/index.tsx
+++ b/apps/mobile/src/app/(tabs)/index.tsx
@@ -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],
);
diff --git a/apps/mobile/src/app/(tabs)/social.tsx b/apps/mobile/src/app/(tabs)/social.tsx
new file mode 100644
index 0000000..42f36bc
--- /dev/null
+++ b/apps/mobile/src/app/(tabs)/social.tsx
@@ -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 (
+
+
+
+ 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 ? (
+
+ ) : (
+
+ )}
+
+
+
+ item.id}
+ contentContainerClassName="pb-32"
+ itemLayoutAnimation={motion.layout}
+ ListHeaderComponent={
+
+ }
+ ListEmptyComponent={loading ? null : }
+ renderItem={({ item, index }) => (
+ like(item)}
+ first={index === 0}
+ last={index === feed.length - 1}
+ />
+ )}
+ />
+
+ {
+ 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"
+ >
+
+
+
+ );
+}
+
+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 (
+
+
+ Feed
+
+
+ {count === 1 ? '1 thread' : `${count} threads`}
+
+
+ {!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.
+ 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"
+ >
+
+
+ Pick a handle to post
+
+
+ Reading needs nothing. Publishing needs a name.
+
+
+
+
+ ) : null}
+
+
+ onScope('everyone')} />
+ onScope('following')}
+ />
+
+
+ );
+}
+
+function Segment({
+ label,
+ active,
+ onPress,
+}: {
+ label: string;
+ active: boolean;
+ onPress: () => void;
+}) {
+ return (
+
+
+ {label}
+
+
+ );
+}
+
+function Empty({ scope }: { scope: 'following' | 'everyone' }) {
+ return (
+
+
+ {scope === 'following' ? 'Nobody you follow has posted.' : 'Nothing published yet.'}
+
+
+ {scope === 'following'
+ ? 'Switch to Everyone to find someone.'
+ : 'Share a note, or write a post.'}
+
+
+ );
+}
diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx
index 17a1536..6e81a56 100644
--- a/apps/mobile/src/app/_layout.tsx
+++ b/apps/mobile/src/app/_layout.tsx
@@ -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';
@@ -64,24 +65,39 @@ export default function RootLayout() {
-
-
-
-
-
-
-
-
+ {/* 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. */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/mobile/src/app/handle.tsx b/apps/mobile/src/app/handle.tsx
new file mode 100644
index 0000000..d4c1a0e
--- /dev/null
+++ b/apps/mobile/src/app/handle.tsx
@@ -0,0 +1,184 @@
+import { handleError, normalizeHandle } from '@dailynote/core';
+import { useRouter } from 'expo-router';
+import { useCallback, useMemo, useState } from 'react';
+import {
+ ActivityIndicator,
+ KeyboardAvoidingView,
+ Platform,
+ Pressable,
+ ScrollView,
+ Text,
+ TextInput,
+ View,
+} from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+
+import { Icon } from '@/components/icon';
+import { haptics } from '@/lib/haptics';
+import { useGoBack } from '@/lib/nav';
+import { useSocial } from '@/store/social-store';
+import { useTheme } from '@/theme';
+
+/**
+ * Claiming a handle: the one moment the app asks for an identity.
+ *
+ * It sits behind the Feed tab and nothing on the notes side ever routes here.
+ * A person can use this app for years without seeing this screen, and the copy
+ * says so rather than implying they are missing out.
+ */
+export default function ClaimHandle() {
+ const theme = useTheme();
+ const router = useRouter();
+ const goBack = useGoBack();
+ const { me, claim } = useSocial();
+
+ const [handle, setHandle] = useState('');
+ const [name, setName] = useState('');
+ const [taken, setTaken] = useState(null);
+ const [claiming, setClaiming] = useState(false);
+
+ const normalized = normalizeHandle(handle);
+ // Validation is shown only once there is enough typed to be worth judging;
+ // telling somebody their handle is too short at one character is nagging.
+ const problem = useMemo(
+ () => (normalized.length === 0 ? null : handleError(normalized)),
+ [normalized],
+ );
+ const error = taken ?? problem;
+ const ready = normalized.length > 0 && problem === null && !claiming;
+
+ const submit = useCallback(() => {
+ if (!ready) return;
+ setTaken(null);
+ setClaiming(true);
+ void (async () => {
+ const failure = await claim(normalized, name);
+ setClaiming(false);
+ if (failure !== null) {
+ setTaken(failure);
+ return;
+ }
+ haptics.success();
+ router.replace({ pathname: '/profile/[handle]', params: { handle: normalized } });
+ })();
+ }, [ready, claim, normalized, name, router]);
+
+ if (me !== null) {
+ return (
+
+
+ You are already @{me.handle}.
+
+
+ Go back
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ Pick a handle
+
+
+ It goes on the things you publish. Your notes stay private either way — nothing
+ already written moves.
+
+
+
+
+ Handle
+
+
+ @
+ {
+ setTaken(null);
+ setHandle(next);
+ }}
+ placeholder="yourname"
+ placeholderTextColor={theme.faint}
+ autoCapitalize="none"
+ autoCorrect={false}
+ autoFocus
+ maxLength={40}
+ returnKeyType="done"
+ onSubmitEditing={submit}
+ selectionColor={theme.accent}
+ className="flex-1 py-3.5 text-[17px] text-ink web:outline-none dark:text-ink-dark"
+ />
+
+
+ {error ?? 'Letters, numbers and underscores. 3 to 30 characters.'}
+
+
+
+
+
+ Display name (optional)
+
+
+
+
+
+ {claiming ? (
+
+ ) : (
+
+ Claim @{normalized.length > 0 ? normalized : 'handle'}
+
+ )}
+
+
+
+
+ );
+}
diff --git a/apps/mobile/src/app/note/[id].tsx b/apps/mobile/src/app/note/[id].tsx
index 4c9f4c2..2208fe1 100644
--- a/apps/mobile/src/app/note/[id].tsx
+++ b/apps/mobile/src/app/note/[id].tsx
@@ -10,7 +10,7 @@ import {
type Block,
type Note,
} from '@dailynote/core';
-import { useLocalSearchParams } from 'expo-router';
+import { useLocalSearchParams, useRouter } from 'expo-router';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Alert,
@@ -18,6 +18,7 @@ import {
Platform,
Pressable,
ScrollView,
+ Share,
Text,
View,
} from 'react-native';
@@ -34,6 +35,7 @@ import { useGoBack } from '@/lib/nav';
import { useHistory } from '@/lib/undo';
import { useNotebooks } from '@/store/notebooks-store';
import { useNotes } from '@/store/notes-store';
+import { useSocial } from '@/store/social-store';
import { useTheme } from '@/theme';
/** How long the writing has to stop before an edit is written to SQLite. */
@@ -78,8 +80,10 @@ function Missing() {
function Editor({ note }: { note: Note | null }) {
const theme = useTheme();
const goBack = useGoBack();
- const { create, setContent, softDelete, promote, move, togglePin, setLocked } = useNotes();
+ const { create, setContent, softDelete, promote, move, togglePin, setLocked, repo } = useNotes();
const { notebooks, find } = useNotebooks();
+ const { share: publish, unshare, shareError } = useSocial();
+ const router = useRouter();
const history = useHistory(
useMemo(() => parseDocument(note?.doc, note?.body ?? ''), [note?.doc, note?.body]),
@@ -232,6 +236,70 @@ function Editor({ note }: { note: Note | null }) {
]);
}, [softDelete, goBack, blocks]);
+ const published = note?.visibility === 'public';
+
+ /**
+ * Publishing, the deliberate second act.
+ *
+ * It is a menu item on a note that already exists and it confirms first --
+ * the composer above never mentions an audience, and this is the only place
+ * in the notes half of the app that does. See docs/product.md rule 1.
+ */
+ const confirmShare = useCallback(() => {
+ setSheet(null);
+ void (async () => {
+ const id = await requireSaved();
+ if (id === null) return;
+
+ const current = await repo.get(id);
+ if (current === null) return;
+
+ const problem = shareError(current);
+ if (problem !== null) {
+ Alert.alert(
+ 'Not this one',
+ problem === 'Claim a handle first.'
+ ? 'Pick a handle on the Feed tab before publishing anything.'
+ : problem,
+ );
+ return;
+ }
+
+ Alert.alert(
+ 'Share this note?',
+ 'It gets a public link that anyone can read. You can take it back at any time.',
+ [
+ { text: 'Cancel', style: 'cancel' },
+ {
+ text: 'Share',
+ onPress: () => {
+ void (async () => {
+ const url = await publish(id);
+ if (url === null) return;
+ haptics.success();
+ void Share.share({ message: url, url });
+ })();
+ },
+ },
+ ],
+ );
+ })();
+ }, [requireSaved, repo, shareError, publish]);
+
+ const confirmUnshare = useCallback(() => {
+ setSheet(null);
+ Alert.alert('Remove from the feed?', 'The note stays here. The public copy goes.', [
+ { text: 'Cancel', style: 'cancel' },
+ {
+ text: 'Remove',
+ style: 'destructive',
+ onPress: () => {
+ if (noteId.current !== null) void unshare(noteId.current);
+ },
+ },
+ ]);
+ }, [unshare]);
+
return (
@@ -340,6 +408,33 @@ function Editor({ note }: { note: Note | null }) {
})();
}}
/>
+ {published ? (
+ <>
+ {
+ setSheet(null);
+ if (note !== null) {
+ router.push({ pathname: '/post/[id]', params: { id: note.id } });
+ }
+ }}
+ />
+
+ >
+ ) : (
+
+ )}
();
+ const theme = useTheme();
+ const router = useRouter();
+ const goBack = useGoBack();
+ const {
+ me,
+ items,
+ following,
+ setLiked,
+ setFollowing,
+ setBlocked,
+ report,
+ recordView,
+ unshare,
+ } = useSocial();
+
+ const item = useMemo(() => items.find((candidate) => candidate.id === id) ?? null, [items, id]);
+ const [sheet, setSheet] = useState<'more' | 'report' | null>(null);
+
+ // Counted once per opening, not once per render. A view is someone arriving,
+ // and a re-render is not that.
+ const counted = useRef(false);
+ useEffect(() => {
+ if (item === null || counted.current) return;
+ counted.current = true;
+ void recordView(item.id);
+ }, [item, recordView]);
+
+ const share = useCallback(() => {
+ if (item === null) return;
+ const url = publicUrl(item.handle, item.slug);
+ void Share.share({ message: url, url });
+ }, [item]);
+
+ const confirmBlock = useCallback(() => {
+ if (item === null) return;
+ setSheet(null);
+ Alert.alert(`Block @${item.handle}?`, 'You will not see each other. This also unfollows.', [
+ { text: 'Cancel', style: 'cancel' },
+ {
+ text: 'Block',
+ style: 'destructive',
+ onPress: () => {
+ void setBlocked(item.authorId, true);
+ goBack();
+ },
+ },
+ ]);
+ }, [item, setBlocked, goBack]);
+
+ const confirmUnshare = useCallback(() => {
+ if (item === null) return;
+ setSheet(null);
+ Alert.alert('Remove from the feed?', 'The note stays. The public copy goes.', [
+ { text: 'Cancel', style: 'cancel' },
+ {
+ text: 'Remove',
+ style: 'destructive',
+ onPress: () => {
+ void unshare(item.id);
+ goBack();
+ },
+ },
+ ]);
+ }, [item, unshare, goBack]);
+
+ const file = useCallback(
+ (reason: string) => {
+ if (item === null) return;
+ setSheet(null);
+ void report(item.id, reason);
+ haptics.success();
+ // No queue to show and no promise about when: guideline 1.2 puts a
+ // 24-hour commitment on a person, so the copy promises a look, not a
+ // verdict.
+ Alert.alert('Reported', 'Thanks. We look at every report.');
+ },
+ [item, report],
+ );
+
+ if (item === null) {
+ return (
+
+ This is gone.
+
+ Go back
+
+
+ );
+ }
+
+ const mine = me?.id === item.authorId;
+ const followed = following.has(item.authorId);
+ const name = item.displayName ?? `@${item.handle}`;
+ const event = feedEvent(item);
+ const lines = item.body.split('\n').slice(1);
+
+ return (
+
+
+
+
+
+ setSheet('more')} />
+
+
+
+
+
+ router.push({ pathname: '/profile/[handle]', params: { handle: item.handle } })
+ }
+ className="active:opacity-60"
+ >
+
+
+
+
+
+ {name}
+
+
+ @{item.handle} · {feedEventLabel(event)} {relativeDay(item.activeAt).toLowerCase()}
+
+
+
+ {!mine ? (
+ {
+ haptics.tap();
+ void setFollowing(item.authorId, !followed);
+ }}
+ accessibilityRole="button"
+ accessibilityLabel={followed ? `Unfollow @${item.handle}` : `Follow @${item.handle}`}
+ accessibilityState={{ selected: followed }}
+ className={[
+ 'h-9 justify-center rounded-full px-4 active:opacity-70',
+ followed ? 'bg-elevated dark:bg-elevated-dark' : 'bg-accent dark:bg-accent-dark',
+ ].join(' ')}
+ >
+
+ {followed ? 'Following' : 'Follow'}
+
+
+ ) : null}
+
+
+ {event === 'followed-through' ? (
+ // The badge the whole product argues for. It is the only thing on
+ // this screen drawn in the accent that is not a control.
+
+
+
+ Followed through
+
+
+ ) : null}
+
+
+ {feedTitle(item)}
+
+
+
+ {lines.map((line, index) => (
+
+ {line}
+
+ ))}
+
+
+
+ {
+ haptics.tap();
+ void setLiked(item.id, !item.likedByMe);
+ }}
+ accessibilityRole="button"
+ accessibilityLabel={item.likedByMe ? 'Remove like' : 'Like'}
+ accessibilityState={{ selected: item.likedByMe }}
+ className="flex-row items-center gap-2 active:opacity-60"
+ >
+
+
+ {item.likeCount > 0 ? item.likeCount : 'Like'}
+
+
+
+
+
+ {item.viewCount}
+
+
+
+
+ {publicUrl(item.handle, item.slug)}
+
+
+
+ setSheet(null)} title="Thread">
+
+ {
+ setSheet(null);
+ share();
+ }} />
+ {mine ? (
+
+ ) : (
+ <>
+ {
+ setSheet(null);
+ void setFollowing(item.authorId, !followed);
+ }}
+ />
+ setSheet('report')} />
+
+ >
+ )}
+
+
+
+ setSheet(null)} title="Report">
+
+ {REPORT_REASONS.map((reason) => (
+ file(reason)} />
+ ))}
+
+
+
+ );
+}
+
+function MenuRow({
+ icon,
+ label,
+ detail,
+ destructive = false,
+ onPress,
+}: {
+ icon: IconName;
+ label: string;
+ detail?: string;
+ destructive?: boolean;
+ onPress: () => void;
+}) {
+ const theme = useTheme();
+ const tint = destructive ? theme.muted : theme.ink;
+
+ return (
+
+
+
+
+ {label}
+
+ {detail !== undefined ? (
+ {detail}
+ ) : null}
+
+
+ );
+}
+
+function ToolbarIcon({
+ name,
+ label,
+ onPress,
+}: {
+ name: IconName;
+ label: string;
+ onPress: () => void;
+}) {
+ const theme = useTheme();
+ return (
+
+
+
+ );
+}
diff --git a/apps/mobile/src/app/post/new.tsx b/apps/mobile/src/app/post/new.tsx
new file mode 100644
index 0000000..ee4f37d
--- /dev/null
+++ b/apps/mobile/src/app/post/new.tsx
@@ -0,0 +1,150 @@
+import { useCallback, useState } from 'react';
+import {
+ ActivityIndicator,
+ KeyboardAvoidingView,
+ Platform,
+ Pressable,
+ ScrollView,
+ Text,
+ TextInput,
+ View,
+} from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+
+import { Avatar } from '@/components/feed-card';
+import { Icon } from '@/components/icon';
+import { haptics } from '@/lib/haptics';
+import { useGoBack } from '@/lib/nav';
+import { useSocial } from '@/store/social-store';
+import { useTheme } from '@/theme';
+
+/**
+ * Writing straight to the feed.
+ *
+ * This is the one composer in the app that knows it has an audience, and it
+ * says so on the screen -- the handle and the word "public" are in the header,
+ * before you type. That is the trade that makes it safe to have at all: the
+ * notes composer stays a private surface with no audience control anywhere on
+ * it, and everything that performs lives behind the Feed tab where you had to
+ * go on purpose.
+ *
+ * A post is stored as a note that was public from birth. It gets threads,
+ * updates and the follow-through loop for free, and `bornPublic` keeps it out
+ * of the private library on the Notes tab.
+ */
+export default function NewPost() {
+ const theme = useTheme();
+ const goBack = useGoBack();
+ const { me, post } = useSocial();
+
+ const [body, setBody] = useState('');
+ const [publishing, setPublishing] = useState(false);
+
+ const trimmed = body.trim();
+ const ready = trimmed.length > 0 && !publishing && me !== null;
+
+ const publish = useCallback(() => {
+ if (!ready) return;
+ setPublishing(true);
+ void (async () => {
+ const url = await post(body);
+ if (url === null) {
+ setPublishing(false);
+ return;
+ }
+ haptics.success();
+ goBack();
+ })();
+ }, [ready, post, body, goBack]);
+
+ if (me === null) {
+ // Reachable only by deep link: the feed sends you to /handle first.
+ return (
+
+ Claim a handle first.
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+ {publishing ? (
+
+ ) : (
+
+ Post
+
+ )}
+
+
+
+ {/* The audience, stated before a word is typed. A composer that hides
+ who can see the result is the failure mode this header exists to
+ prevent. */}
+
+
+
+
+ @{me.handle}
+
+
+
+
+ Public
+
+
+
+
+
+
+
+
+
+
+ The first line becomes the title. This goes to the feed, not to your notes.
+
+
+
+
+ );
+}
diff --git a/apps/mobile/src/app/profile/[handle].tsx b/apps/mobile/src/app/profile/[handle].tsx
new file mode 100644
index 0000000..b3e23fb
--- /dev/null
+++ b/apps/mobile/src/app/profile/[handle].tsx
@@ -0,0 +1,371 @@
+import { composeFeed, profileName, type FeedItem, type Profile } from '@dailynote/core';
+import { useLocalSearchParams } from 'expo-router';
+import { useCallback, useEffect, useState } from 'react';
+import { Alert, Pressable, ScrollView, Text, TextInput, View } from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+
+import { Avatar, FeedCard } from '@/components/feed-card';
+import { Icon, type IconName } from '@/components/icon';
+import { Sheet } from '@/components/sheet';
+import { haptics } from '@/lib/haptics';
+import { useGoBack } from '@/lib/nav';
+import { useSocial } from '@/store/social-store';
+import { useTheme } from '@/theme';
+
+/**
+ * Somebody's published work, and nothing else.
+ *
+ * A profile shows only what its owner chose to make public -- there is no
+ * private surface here to leak, because the private notes never left the
+ * device that wrote them. On your own profile this is also the honest count of
+ * what you have published, which is the number worth watching if you are
+ * wondering whether the social layer is eating the notes app.
+ */
+export default function ProfileScreen() {
+ const { handle } = useLocalSearchParams<{ handle: string }>();
+ const theme = useTheme();
+ const goBack = useGoBack();
+ const { me, items, following, blocked, repo, setFollowing, setBlocked, setLiked, updateMe } =
+ useSocial();
+
+ const [profile, setProfile] = useState(null);
+ const [counts, setCounts] = useState({ followers: 0, following: 0 });
+ const [missing, setMissing] = useState(false);
+ const [sheet, setSheet] = useState<'more' | 'edit' | null>(null);
+
+ useEffect(() => {
+ let active = true;
+ void (async () => {
+ const found = await repo.profileByHandle(handle);
+ if (!active) return;
+ setProfile(found);
+ setMissing(found === null);
+ if (found !== null) setCounts(await repo.followCounts(found.id));
+ })();
+ return () => {
+ active = false;
+ };
+ // `items` is in the deps so the follower count refreshes after a follow,
+ // which is the only thing on this screen that changes underneath it.
+ }, [repo, handle, items, following]);
+
+ const like = useCallback(
+ (item: FeedItem) => {
+ void setLiked(item.id, !item.likedByMe);
+ },
+ [setLiked],
+ );
+
+ if (missing) {
+ return (
+
+ No such handle.
+
+ Go back
+
+
+ );
+ }
+
+ if (profile === null) {
+ return ;
+ }
+
+ const mine = me?.id === profile.id;
+ const followed = following.has(profile.id);
+ const isBlocked = blocked.has(profile.id);
+ const published = composeFeed(
+ items.filter((item) => item.authorId === profile.id),
+ { blocked: mine ? undefined : blocked },
+ );
+
+ return (
+
+
+
+
+ setSheet('more')} />
+
+
+
+
+
+
+ {profileName(profile)}
+
+
+ @{profile.handle}
+
+ {profile.bio !== null && profile.bio.trim().length > 0 ? (
+
+ {profile.bio}
+
+ ) : null}
+
+
+
+
+
+
+
+
+ {mine ? (
+ setSheet('edit')}
+ accessibilityRole="button"
+ accessibilityLabel="Edit profile"
+ className="h-10 justify-center rounded-full bg-elevated px-6 active:opacity-70 dark:bg-elevated-dark"
+ >
+
+ Edit profile
+
+
+ ) : (
+ {
+ haptics.tap();
+ void setFollowing(profile.id, !followed);
+ }}
+ disabled={isBlocked}
+ accessibilityRole="button"
+ accessibilityLabel={followed ? 'Unfollow' : 'Follow'}
+ accessibilityState={{ selected: followed, disabled: isBlocked }}
+ className={[
+ 'h-10 justify-center rounded-full px-8 active:opacity-70',
+ followed || isBlocked
+ ? 'bg-elevated dark:bg-elevated-dark'
+ : 'bg-accent dark:bg-accent-dark',
+ ].join(' ')}
+ >
+
+ {isBlocked ? 'Blocked' : followed ? 'Following' : 'Follow'}
+
+
+ )}
+
+
+
+ {published.length === 0 ? (
+
+ {mine ? 'You have not published anything yet.' : 'Nothing published yet.'}
+
+ ) : (
+ published.map((item, index) => (
+ like(item)}
+ first={index === 0}
+ last={index === published.length - 1}
+ />
+ ))
+ )}
+
+
+ setSheet(null)} title="Profile">
+
+ {mine ? (
+ setSheet('edit')} />
+ ) : (
+ {
+ setSheet(null);
+ if (isBlocked) {
+ void setBlocked(profile.id, false);
+ return;
+ }
+ Alert.alert(
+ `Block @${profile.handle}?`,
+ 'You will not see each other. This also unfollows.',
+ [
+ { text: 'Cancel', style: 'cancel' },
+ {
+ text: 'Block',
+ style: 'destructive',
+ onPress: () => void setBlocked(profile.id, true),
+ },
+ ],
+ );
+ }}
+ />
+ )}
+
+
+
+ setSheet(null)}
+ onSave={async (patch) => {
+ await updateMe(patch);
+ setProfile(await repo.profileByHandle(handle));
+ setSheet(null);
+ }}
+ />
+
+ );
+}
+
+function EditSheet({
+ visible,
+ profile,
+ onClose,
+ onSave,
+}: {
+ visible: boolean;
+ profile: Profile;
+ onClose: () => void;
+ onSave: (patch: { displayName: string | null; bio: string | null }) => Promise;
+}) {
+ const theme = useTheme();
+ const [name, setName] = useState(profile.displayName ?? '');
+ const [bio, setBio] = useState(profile.bio ?? '');
+
+ // Re-seeded each time the sheet opens, so a cancelled edit does not sit in
+ // the fields waiting to reappear. Adjusted during render rather than in an
+ // effect: an effect would paint the stale values for a frame first.
+ const [wasVisible, setWasVisible] = useState(visible);
+ if (visible !== wasVisible) {
+ setWasVisible(visible);
+ if (visible) {
+ setName(profile.displayName ?? '');
+ setBio(profile.bio ?? '');
+ }
+ }
+
+ return (
+
+
+
+
+
+ {
+ haptics.tap();
+ void onSave({
+ displayName: name.trim().length > 0 ? name.trim() : null,
+ bio: bio.trim().length > 0 ? bio.trim() : null,
+ });
+ }}
+ accessibilityRole="button"
+ accessibilityLabel="Save profile"
+ className="mt-1 h-12 items-center justify-center rounded-xl bg-accent active:opacity-70 dark:bg-accent-dark"
+ >
+
+ Save
+
+
+
+
+ );
+}
+
+function Field({
+ label,
+ value,
+ onChange,
+ placeholder,
+ multiline = false,
+}: {
+ label: string;
+ value: string;
+ onChange: (next: string) => void;
+ placeholder: string;
+ multiline?: boolean;
+}) {
+ const theme = useTheme();
+ return (
+
+ {label}
+
+
+ );
+}
+
+function Count({ value, label }: { value: number; label: string }) {
+ return (
+
+ {value}
+ {label}
+
+ );
+}
+
+function MenuRow({
+ icon,
+ label,
+ destructive = false,
+ onPress,
+}: {
+ icon: IconName;
+ label: string;
+ destructive?: boolean;
+ onPress: () => void;
+}) {
+ const theme = useTheme();
+ const tint = destructive ? theme.muted : theme.ink;
+
+ return (
+
+
+
+ {label}
+
+
+ );
+}
+
+function ToolbarIcon({
+ name,
+ label,
+ onPress,
+}: {
+ name: IconName;
+ label: string;
+ onPress: () => void;
+}) {
+ const theme = useTheme();
+ return (
+
+
+
+ );
+}
diff --git a/apps/mobile/src/components/feed-card.tsx b/apps/mobile/src/components/feed-card.tsx
new file mode 100644
index 0000000..9ab8b6f
--- /dev/null
+++ b/apps/mobile/src/components/feed-card.tsx
@@ -0,0 +1,191 @@
+import type { FeedItem, Profile } from '@dailynote/core';
+import { feedEvent, feedEventLabel, feedPreview, feedTitle } from '@dailynote/core';
+import { Image } from 'expo-image';
+import { useRouter } from 'expo-router';
+import { Pressable, Text, View } from 'react-native';
+
+import { Icon } from '@/components/icon';
+import { sinceThen } from '@/lib/format';
+import { haptics } from '@/lib/haptics';
+import { useTheme } from '@/theme';
+
+/**
+ * A person, at the size the feed draws them.
+ *
+ * Falls back to an initial on the accent rather than to a silhouette: an empty
+ * avatar is the common case here, so it should look like a deliberate colour
+ * choice and not like a missing image.
+ */
+export function Avatar({
+ profile,
+ size = 36,
+}: {
+ profile: Pick;
+ size?: number;
+}) {
+ const theme = useTheme();
+ const initial = (profile.displayName ?? profile.handle).trim().charAt(0).toUpperCase();
+
+ if (profile.avatarUrl !== null) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+ {initial}
+
+
+ );
+}
+
+/**
+ * One thread in the feed.
+ *
+ * The line above the title is the whole design: it says what *happened*, not
+ * when the note was written. A thread that came back six weeks later carrying
+ * "followed through" is the thing this product has and other feeds do not, so
+ * it gets the top line and its own colour.
+ */
+export function FeedCard({
+ item,
+ onLike,
+ first = false,
+ last = false,
+}: {
+ item: FeedItem;
+ onLike?: () => void;
+ first?: boolean;
+ last?: boolean;
+}) {
+ const theme = useTheme();
+ const router = useRouter();
+
+ const event = feedEvent(item);
+ const preview = feedPreview(item);
+ const name = item.displayName ?? `@${item.handle}`;
+ const completed = event === 'followed-through';
+
+ return (
+
+ router.push({ pathname: '/post/[id]', params: { id: item.id } })}
+ className={[
+ 'bg-card px-4 active:opacity-70 dark:bg-card-dark',
+ first ? 'rounded-t-2xl pt-4' : 'pt-3.5',
+ last ? 'rounded-b-2xl pb-3' : 'pb-3',
+ ].join(' ')}
+ >
+
+
+ router.push({ pathname: '/profile/[handle]', params: { handle: item.handle } })
+ }
+ className="active:opacity-60"
+ >
+
+
+
+
+
+ {name}
+
+ {' '}
+ {feedEventLabel(event)}
+
+
+
+ @{item.handle} · {sinceThen(item.activeAt)}
+
+
+
+ {completed ? (
+
+ ) : null}
+
+
+
+ {feedTitle(item)}
+
+ {preview.length > 0 ? (
+
+ {preview}
+
+ ) : null}
+
+
+
+
+ {item.updateCount > 0 ? (
+
+ ) : null}
+ {item.viewCount > 0 ? : null}
+
+
+
+ {!last ? : null}
+
+ );
+}
+
+function LikeButton({ item, onPress }: { item: FeedItem; onPress?: (() => void) | undefined }) {
+ const theme = useTheme();
+ const tint = item.likedByMe ? theme.accent : theme.faint;
+
+ return (
+ {
+ haptics.tap();
+ onPress?.();
+ }}
+ className="flex-row items-center gap-1.5 active:opacity-60"
+ >
+
+ {item.likeCount > 0 ? (
+
+ {item.likeCount}
+
+ ) : null}
+
+ );
+}
+
+function Stat({ icon, label }: { icon: 'thread' | 'views'; label: string }) {
+ const theme = useTheme();
+ return (
+
+
+ {label}
+
+ );
+}
diff --git a/apps/mobile/src/components/icon.tsx b/apps/mobile/src/components/icon.tsx
index 6be4288..f0f2d53 100644
--- a/apps/mobile/src/components/icon.tsx
+++ b/apps/mobile/src/components/icon.tsx
@@ -33,6 +33,18 @@ const FEATHER = {
files: 'folder',
allNotes: 'file-text',
edit: 'edit-2',
+
+ // Social.
+ profile: 'user',
+ follow: 'user-plus',
+ following: 'user-check',
+ block: 'slash',
+ report: 'flag',
+ link: 'link-2',
+ share: 'share',
+ globe: 'globe',
+ views: 'eye',
+ refresh: 'refresh-cw',
} as const satisfies Record;
const MATERIAL = {
@@ -45,6 +57,15 @@ const MATERIAL = {
pin: 'pin-outline',
unpin: 'pin-off-outline',
+ // Social. The feed's tab glyph is deliberately not a bird, a bell or a
+ // house: it is a stack of pages, because what is in there is other people's
+ // notes.
+ feed: 'file-multiple-outline',
+ like: 'heart-outline',
+ liked: 'heart',
+ followedThrough: 'check-decagram-outline',
+ thread: 'comment-text-multiple-outline',
+
// Format sheet.
textSize: 'format-size',
bold: 'format-bold',
diff --git a/apps/mobile/src/db/migrations.ts b/apps/mobile/src/db/migrations.ts
index 51652ec..01c3c6b 100644
--- a/apps/mobile/src/db/migrations.ts
+++ b/apps/mobile/src/db/migrations.ts
@@ -95,6 +95,100 @@ const MIGRATIONS: readonly ((db: SQLiteDatabase) => Promise)[] = [
create index notebooks_sort_idx on notebooks (sort_order);
`);
},
+
+ // v4 -- the social layer.
+ //
+ // Two shapes live here. `profiles`, `follows`, `reactions`, `blocks` and
+ // `reports` mirror the Postgres tables column for column, as everything else
+ // in this file does. `feed_items` does not: it is the local mirror of the
+ // *published* set -- other people's public threads plus your own -- which is
+ // a cache of a server view rather than a table anybody owns. It exists so the
+ // feed renders instantly and reads offline; a sync failure costs staleness,
+ // never a blank screen.
+ async (db) => {
+ await db.execAsync(`
+ alter table notes add column slug text;
+ -- A post written straight to the feed, rather than a private note the
+ -- author later chose to share. The only structural difference between
+ -- the two, and the notes composer never sets it.
+ alter table notes add column born_public integer not null default 0;
+ alter table notes add column view_count integer not null default 0;
+
+ create unique index notes_slug_idx on notes (slug) where slug is not null;
+
+ create table profiles (
+ id text primary key not null,
+ handle text not null unique,
+ display_name text,
+ bio text,
+ avatar_url text,
+ created_at text not null,
+ updated_at text not null
+ );
+
+ -- Who this device is. At most one row, forever: the check pins it.
+ create table social_identity (
+ id integer primary key check (id = 1),
+ profile_id text not null references profiles(id) on delete cascade
+ );
+
+ create table follows (
+ follower_id text not null references profiles(id) on delete cascade,
+ followee_id text not null references profiles(id) on delete cascade,
+ created_at text not null,
+ primary key (follower_id, followee_id),
+ constraint no_self_follow check (follower_id <> followee_id)
+ );
+
+ create table reactions (
+ note_id text not null,
+ user_id text not null references profiles(id) on delete cascade,
+ created_at text not null,
+ primary key (note_id, user_id)
+ );
+
+ create table blocks (
+ blocker_id text not null references profiles(id) on delete cascade,
+ blocked_id text not null references profiles(id) on delete cascade,
+ created_at text not null,
+ primary key (blocker_id, blocked_id),
+ constraint no_self_block check (blocker_id <> blocked_id)
+ );
+
+ create table reports (
+ id text primary key not null,
+ reporter_id text,
+ note_id text,
+ reason text not null,
+ status text not null default 'open'
+ check (status in ('open', 'actioned', 'dismissed')),
+ created_at text not null
+ );
+
+ create table feed_items (
+ id text primary key not null,
+ author_id text not null references profiles(id) on delete cascade,
+ slug text not null,
+ born_public integer not null default 0,
+ title text,
+ body text not null,
+ doc text,
+ published_at text not null,
+ -- The last thing that happened to the thread. What the feed sorts on:
+ -- a six-week-old thread coming back with "done" is the interesting
+ -- event, not the day it was created.
+ active_at text not null,
+ completed_at text,
+ update_count integer not null default 0,
+ like_count integer not null default 0,
+ view_count integer not null default 0
+ );
+
+ create index feed_items_active_idx on feed_items (active_at desc);
+ create index feed_items_author_idx on feed_items (author_id);
+ create index follows_followee_idx on follows (followee_id);
+ `);
+ },
];
/**
diff --git a/apps/mobile/src/db/notes-repo.ts b/apps/mobile/src/db/notes-repo.ts
index 17bd6ee..bfb287c 100644
--- a/apps/mobile/src/db/notes-repo.ts
+++ b/apps/mobile/src/db/notes-repo.ts
@@ -38,6 +38,8 @@ export interface CreateOptions {
parentId?: string | null;
kind?: Note['kind'];
notebookId?: string | null;
+ /** Set only by the feed composer. The note composer must never pass this. */
+ bornPublic?: boolean;
}
/** Client-generated UUIDs: offline-safe, and public URLs need no remap later. */
@@ -67,6 +69,8 @@ interface NoteRow {
visibility: string;
published_at: string | null;
local_only: number;
+ slug: string | null;
+ born_public: number;
created_at: string;
updated_at: string;
deleted: number;
@@ -95,6 +99,8 @@ function toNote(row: NoteRow): Note {
visibility: row.visibility as Note['visibility'],
publishedAt: row.published_at,
localOnly: row.local_only === 1,
+ slug: row.slug,
+ bornPublic: row.born_public === 1,
createdAt: row.created_at,
updatedAt: row.updated_at,
deleted: row.deleted === 1,
@@ -124,6 +130,8 @@ const COLUMNS: Record = {
visibility: 'visibility',
publishedAt: 'published_at',
localOnly: 'local_only',
+ slug: 'slug',
+ bornPublic: 'born_public',
createdAt: 'created_at',
updatedAt: 'updated_at',
deleted: 'deleted',
diff --git a/apps/mobile/src/db/social-repo.ts b/apps/mobile/src/db/social-repo.ts
new file mode 100644
index 0000000..8f2fc70
--- /dev/null
+++ b/apps/mobile/src/db/social-repo.ts
@@ -0,0 +1,454 @@
+import type { FeedItem, Profile } from '@dailynote/core';
+import type { SQLiteDatabase } from 'expo-sqlite';
+
+import { newId } from './notes-repo';
+
+/**
+ * All social persistence goes through here.
+ *
+ * Same contract as `notes-repo`: every read path in the social layer is one of
+ * these methods, so when the server exists this file is reimplemented over HTTP
+ * and nothing else changes. That matters more here than for notes, because this
+ * data is *inherently* remote -- the local tables are a mirror of a published
+ * set that lives somewhere else, not the system of record.
+ *
+ * The one rule from plan-rollout-2 section 0 that shapes this file: feed
+ * composition is not a query. These methods return rows; `composeFeed` in
+ * @dailynote/core decides what a reader may see and in what order, because that
+ * is logic worth unit-testing rather than burying in SQL.
+ */
+export interface SocialRepo {
+ // --- identity ---
+ /** The profile this device posts as, or null before a handle is claimed. */
+ me(): Promise;
+ claim(handle: string, displayName: string | null): Promise;
+ updateMe(patch: ProfilePatch): Promise;
+ profile(id: string): Promise;
+ profileByHandle(handle: string): Promise;
+ /** True if somebody already has it. Checked before a claim, not after. */
+ handleTaken(handle: string): Promise;
+ upsertProfile(profile: Profile): Promise;
+
+ // --- the published set ---
+ /** Every mirrored item, with the reader's own like state resolved. */
+ items(): Promise;
+ item(id: string): Promise;
+ byAuthor(authorId: string): Promise;
+ /** The slugs an author has used, so a new one can avoid them. */
+ slugsFor(authorId: string): Promise;
+ /** Adds or replaces the mirror row for a published thread. */
+ mirror(item: MirrorInput): Promise;
+ /** Removes it. Unpublishing means gone, not hidden. */
+ unmirror(id: string): Promise;
+ recordView(id: string): Promise;
+
+ // --- the graph ---
+ following(): Promise>;
+ setFollowing(authorId: string, following: boolean): Promise;
+ followCounts(authorId: string): Promise<{ followers: number; following: number }>;
+ setLiked(itemId: string, liked: boolean): Promise;
+
+ // --- safety ---
+ /** Symmetric: ids you blocked and ids that blocked you. */
+ blocked(): Promise>;
+ setBlocked(authorId: string, blocked: boolean): Promise;
+ report(itemId: string, reason: string): Promise;
+}
+
+export interface ProfilePatch {
+ displayName?: string | null;
+ bio?: string | null;
+ avatarUrl?: string | null;
+}
+
+/** What `mirror` needs. Author details are read back from `profiles` on select. */
+export interface MirrorInput {
+ id: string;
+ authorId: string;
+ slug: string;
+ bornPublic: boolean;
+ title: string | null;
+ body: string;
+ doc: string | null;
+ publishedAt: string;
+ activeAt: string;
+ completedAt: string | null;
+ updateCount: number;
+}
+
+interface ProfileRow {
+ id: string;
+ handle: string;
+ display_name: string | null;
+ bio: string | null;
+ avatar_url: string | null;
+ created_at: string;
+ updated_at: string;
+}
+
+interface ItemRow {
+ id: string;
+ author_id: string;
+ handle: string;
+ display_name: string | null;
+ avatar_url: string | null;
+ slug: string;
+ born_public: number;
+ title: string | null;
+ body: string;
+ doc: string | null;
+ published_at: string;
+ active_at: string;
+ completed_at: string | null;
+ update_count: number;
+ like_count: number;
+ view_count: number;
+ liked_by_me: number;
+}
+
+function toProfile(row: ProfileRow): Profile {
+ return {
+ id: row.id,
+ handle: row.handle,
+ displayName: row.display_name,
+ bio: row.bio,
+ avatarUrl: row.avatar_url,
+ createdAt: row.created_at,
+ };
+}
+
+function toItem(row: ItemRow): FeedItem {
+ return {
+ id: row.id,
+ authorId: row.author_id,
+ handle: row.handle,
+ displayName: row.display_name,
+ avatarUrl: row.avatar_url,
+ slug: row.slug,
+ bornPublic: row.born_public === 1,
+ title: row.title,
+ body: row.body,
+ doc: row.doc,
+ publishedAt: row.published_at,
+ activeAt: row.active_at,
+ completedAt: row.completed_at,
+ updateCount: row.update_count,
+ likeCount: row.like_count,
+ likedByMe: row.liked_by_me === 1,
+ viewCount: row.view_count,
+ };
+}
+
+/**
+ * A feed row joined to its author and to the reader's own like.
+ *
+ * The like count is counted from `reactions` rather than kept as a column on
+ * `feed_items`: a denormalized counter and a reaction row are two things that
+ * can disagree, and at one device's scale the count is free.
+ */
+const ITEM_SELECT = `
+ select f.*,
+ p.handle, p.display_name, p.avatar_url,
+ (select count(*) from reactions r where r.note_id = f.id) as like_count,
+ exists (
+ select 1 from reactions r
+ where r.note_id = f.id and r.user_id = ?
+ ) as liked_by_me
+ from feed_items f
+ join profiles p on p.id = f.author_id
+`;
+
+export function createSocialRepo(db: SQLiteDatabase): SocialRepo {
+ /** The reader's id, or a sentinel that matches nothing before a handle exists. */
+ async function myId(): Promise {
+ const row = await db.getFirstAsync<{ profile_id: string }>(
+ 'select profile_id from social_identity where id = 1',
+ );
+ return row?.profile_id ?? '';
+ }
+
+ return {
+ async me() {
+ const row = await db.getFirstAsync(
+ `select p.* from profiles p
+ join social_identity s on s.profile_id = p.id
+ where s.id = 1`,
+ );
+ return row ? toProfile(row) : null;
+ },
+
+ async claim(handle, displayName) {
+ const now = new Date().toISOString();
+ const profile: Profile = {
+ id: newId(),
+ handle,
+ displayName,
+ bio: null,
+ avatarUrl: null,
+ createdAt: now,
+ };
+
+ await db.withTransactionAsync(async () => {
+ await db.runAsync(
+ `insert into profiles
+ (id, handle, display_name, bio, avatar_url, created_at, updated_at)
+ values (?, ?, ?, null, null, ?, ?)`,
+ [profile.id, profile.handle, profile.displayName, now, now],
+ );
+ // The check on `id = 1` makes a second identity a constraint error
+ // rather than a silently split account.
+ await db.runAsync('insert into social_identity (id, profile_id) values (1, ?)', [
+ profile.id,
+ ]);
+ });
+
+ return profile;
+ },
+
+ async updateMe(patch) {
+ const id = await myId();
+ if (id === '') return null;
+
+ const columns: Record = {
+ displayName: 'display_name',
+ bio: 'bio',
+ avatarUrl: 'avatar_url',
+ };
+ const keys = (Object.keys(patch) as (keyof ProfilePatch)[]).filter(
+ (key) => patch[key] !== undefined,
+ );
+ if (keys.length > 0) {
+ const assignments = keys.map((key) => `${columns[key]} = ?`).join(', ');
+ await db.runAsync(
+ `update profiles set ${assignments}, updated_at = ? where id = ?`,
+ [...keys.map((key) => patch[key] ?? null), new Date().toISOString(), id],
+ );
+ }
+ return this.profile(id);
+ },
+
+ async profile(id) {
+ const row = await db.getFirstAsync('select * from profiles where id = ?', id);
+ return row ? toProfile(row) : null;
+ },
+
+ async profileByHandle(handle) {
+ const row = await db.getFirstAsync(
+ 'select * from profiles where handle = ?',
+ handle,
+ );
+ return row ? toProfile(row) : null;
+ },
+
+ async handleTaken(handle) {
+ const row = await db.getFirstAsync<{ count: number }>(
+ 'select count(*) as count from profiles where handle = ?',
+ handle,
+ );
+ return (row?.count ?? 0) > 0;
+ },
+
+ async upsertProfile(profile) {
+ const now = new Date().toISOString();
+ await db.runAsync(
+ `insert into profiles (id, handle, display_name, bio, avatar_url, created_at, updated_at)
+ values (?, ?, ?, ?, ?, ?, ?)
+ on conflict(id) do update set
+ handle = excluded.handle,
+ display_name = excluded.display_name,
+ bio = excluded.bio,
+ avatar_url = excluded.avatar_url,
+ updated_at = excluded.updated_at`,
+ [
+ profile.id,
+ profile.handle,
+ profile.displayName,
+ profile.bio,
+ profile.avatarUrl,
+ profile.createdAt,
+ now,
+ ],
+ );
+ },
+
+ async items() {
+ const rows = await db.getAllAsync(
+ `${ITEM_SELECT} order by f.active_at desc`,
+ await myId(),
+ );
+ return rows.map(toItem);
+ },
+
+ async item(id) {
+ const row = await db.getFirstAsync(`${ITEM_SELECT} where f.id = ?`, [
+ await myId(),
+ id,
+ ]);
+ return row ? toItem(row) : null;
+ },
+
+ async byAuthor(authorId) {
+ const rows = await db.getAllAsync(
+ `${ITEM_SELECT} where f.author_id = ? order by f.active_at desc`,
+ [await myId(), authorId],
+ );
+ return rows.map(toItem);
+ },
+
+ async slugsFor(authorId) {
+ const rows = await db.getAllAsync<{ slug: string }>(
+ 'select slug from feed_items where author_id = ?',
+ authorId,
+ );
+ return rows.map((row) => row.slug);
+ },
+
+ async mirror(input) {
+ await db.runAsync(
+ `insert into feed_items
+ (id, author_id, slug, born_public, title, body, doc,
+ published_at, active_at, completed_at, update_count, like_count, view_count)
+ values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0)
+ on conflict(id) do update set
+ slug = excluded.slug,
+ title = excluded.title,
+ body = excluded.body,
+ doc = excluded.doc,
+ active_at = excluded.active_at,
+ completed_at = excluded.completed_at,
+ update_count = excluded.update_count`,
+ [
+ input.id,
+ input.authorId,
+ input.slug,
+ input.bornPublic ? 1 : 0,
+ input.title,
+ input.body,
+ input.doc,
+ input.publishedAt,
+ input.activeAt,
+ input.completedAt,
+ input.updateCount,
+ ],
+ );
+ },
+
+ async unmirror(id) {
+ // Reactions go with it. A like on a thread nobody can read is an orphan
+ // row that would resurrect a stale count if the author republished.
+ await db.withTransactionAsync(async () => {
+ await db.runAsync('delete from reactions where note_id = ?', id);
+ await db.runAsync('delete from feed_items where id = ?', id);
+ });
+ },
+
+ async recordView(id) {
+ await db.runAsync('update feed_items set view_count = view_count + 1 where id = ?', id);
+ },
+
+ async following() {
+ const rows = await db.getAllAsync<{ followee_id: string }>(
+ 'select followee_id from follows where follower_id = ?',
+ await myId(),
+ );
+ return new Set(rows.map((row) => row.followee_id));
+ },
+
+ async setFollowing(authorId, following) {
+ const id = await myId();
+ if (id === '' || id === authorId) return;
+
+ if (following) {
+ await db.runAsync(
+ `insert into follows (follower_id, followee_id, created_at) values (?, ?, ?)
+ on conflict do nothing`,
+ [id, authorId, new Date().toISOString()],
+ );
+ } else {
+ await db.runAsync('delete from follows where follower_id = ? and followee_id = ?', [
+ id,
+ authorId,
+ ]);
+ }
+ },
+
+ async followCounts(authorId) {
+ const followers = await db.getFirstAsync<{ count: number }>(
+ 'select count(*) as count from follows where followee_id = ?',
+ authorId,
+ );
+ const following = await db.getFirstAsync<{ count: number }>(
+ 'select count(*) as count from follows where follower_id = ?',
+ authorId,
+ );
+ return { followers: followers?.count ?? 0, following: following?.count ?? 0 };
+ },
+
+ async setLiked(itemId, liked) {
+ const id = await myId();
+ if (id === '') return;
+
+ if (liked) {
+ await db.runAsync(
+ `insert into reactions (note_id, user_id, created_at) values (?, ?, ?)
+ on conflict do nothing`,
+ [itemId, id, new Date().toISOString()],
+ );
+ } else {
+ await db.runAsync('delete from reactions where note_id = ? and user_id = ?', [itemId, id]);
+ }
+ },
+
+ async blocked() {
+ const id = await myId();
+ const rows = await db.getAllAsync<{ blocker_id: string; blocked_id: string }>(
+ 'select blocker_id, blocked_id from blocks where blocker_id = ? or blocked_id = ?',
+ [id, id],
+ );
+ // Symmetric on purpose: blocking someone hides you from them as well,
+ // which is what people mean by the word.
+ const ids = new Set();
+ for (const row of rows) {
+ if (row.blocker_id === id) ids.add(row.blocked_id);
+ if (row.blocked_id === id) ids.add(row.blocker_id);
+ }
+ return ids;
+ },
+
+ async setBlocked(authorId, blocked) {
+ const id = await myId();
+ if (id === '' || id === authorId) return;
+
+ if (blocked) {
+ await db.withTransactionAsync(async () => {
+ await db.runAsync(
+ `insert into blocks (blocker_id, blocked_id, created_at) values (?, ?, ?)
+ on conflict do nothing`,
+ [id, authorId, new Date().toISOString()],
+ );
+ // Blocking implies unfollowing. Leaving the edge would put them back
+ // in the feed the moment the block came off, which is not what
+ // anyone expects from the word.
+ await db.runAsync('delete from follows where follower_id = ? and followee_id = ?', [
+ id,
+ authorId,
+ ]);
+ });
+ } else {
+ await db.runAsync('delete from blocks where blocker_id = ? and blocked_id = ?', [
+ id,
+ authorId,
+ ]);
+ }
+ },
+
+ async report(itemId, reason) {
+ const id = await myId();
+ await db.runAsync(
+ `insert into reports (id, reporter_id, note_id, reason, status, created_at)
+ values (?, ?, ?, ?, 'open', ?)`,
+ [newId(), id === '' ? null : id, itemId, reason, new Date().toISOString()],
+ );
+ },
+ };
+}
diff --git a/apps/mobile/src/lib/format.ts b/apps/mobile/src/lib/format.ts
index 5311808..0b16d7d 100644
--- a/apps/mobile/src/lib/format.ts
+++ b/apps/mobile/src/lib/format.ts
@@ -52,6 +52,23 @@ export function shortDate(iso: string): string {
return `${pad(date.getDate())}/${pad(date.getMonth() + 1)}/${date.getFullYear()}`;
}
+/**
+ * Feed ages: "now", "4m", "3h", "6d", then the date.
+ *
+ * Terse rather than "6 days ago" because it sits beside a handle in a line
+ * that is already carrying a name and an event, and because past about a week
+ * the actual date is the more useful thing anyway.
+ */
+export function sinceThen(iso: string, now: Date = new Date()): string {
+ const seconds = Math.max(0, Math.floor((now.getTime() - new Date(iso).getTime()) / 1000));
+ if (seconds < 60) return 'now';
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
+ if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`;
+
+ const days = Math.floor(seconds / 86400);
+ return days < 7 ? `${days}d` : formatDay(iso, now);
+}
+
/** The editor's meta line: "04/08/2026, 5:08 pm". */
export function editedAt(iso: string): string {
const time = new Date(iso)
diff --git a/apps/mobile/src/lib/social-seed.ts b/apps/mobile/src/lib/social-seed.ts
new file mode 100644
index 0000000..4fb12cb
--- /dev/null
+++ b/apps/mobile/src/lib/social-seed.ts
@@ -0,0 +1,159 @@
+import type { Profile } from '@dailynote/core';
+
+import type { SocialRepo } from '@/db/social-repo';
+
+/**
+ * A handful of other people, so the feed is not an empty room.
+ *
+ * This is a **development stand-in for the server**, and the only file in the
+ * app that invents content. When `social-repo` is reimplemented over HTTP this
+ * file is deleted outright -- nothing else imports it, and nothing else depends
+ * on these ids.
+ *
+ * The four threads are chosen to cover the states the feed has to render
+ * differently: a post written straight to the feed, a private note its author
+ * later shared, a thread that gained an update weeks later, and a commitment
+ * that was actually finished. That last one is the product's whole argument, so
+ * it needs to be visible on the first run.
+ */
+
+const DAY = 24 * 60 * 60 * 1000;
+
+const AUTHORS: readonly Omit[] = [
+ {
+ id: 'seed-0000-0000-0000-000000000001',
+ handle: 'mara',
+ displayName: 'Mara',
+ bio: 'Learning things in public, slowly.',
+ avatarUrl: null,
+ },
+ {
+ id: 'seed-0000-0000-0000-000000000002',
+ handle: 'tobias',
+ displayName: 'Tobias Vale',
+ bio: 'Gardener. Occasional woodworker.',
+ avatarUrl: null,
+ },
+ {
+ id: 'seed-0000-0000-0000-000000000003',
+ handle: 'quiet_ren',
+ displayName: null,
+ bio: 'notes to self, published by accident',
+ avatarUrl: null,
+ },
+];
+
+interface SeedItem {
+ id: string;
+ authorId: string;
+ slug: string;
+ bornPublic: boolean;
+ title: string;
+ body: string;
+ /** Days before now that it was published. */
+ publishedDaysAgo: number;
+ /** Days before now that something last happened to it. */
+ activeDaysAgo: number;
+ completed?: boolean;
+ updateCount?: number;
+}
+
+const ITEMS: readonly SeedItem[] = [
+ {
+ id: 'seed-item-0000-0000-000000000001',
+ authorId: 'seed-0000-0000-0000-000000000001',
+ slug: 'six-weeks-of-rust',
+ bornPublic: false,
+ title: 'Six weeks of Rust',
+ body: [
+ 'Six weeks of Rust',
+ 'Wrote this down in June and forgot about it until the app asked me what happened.',
+ 'What happened: I built a little CLI that renames photo files by their EXIF date.',
+ 'It is 300 lines and it works and I understand every one of them.',
+ 'The borrow checker stopped being an enemy somewhere around week four.',
+ ].join('\n'),
+ publishedDaysAgo: 44,
+ activeDaysAgo: 1,
+ completed: true,
+ updateCount: 2,
+ },
+ {
+ id: 'seed-item-0000-0000-000000000002',
+ authorId: 'seed-0000-0000-0000-000000000002',
+ slug: 'the-bench-is-finished',
+ bornPublic: false,
+ title: 'The bench is finished',
+ body: [
+ 'The bench is finished',
+ 'Started it in March with a plank I could not afford and no idea what a mortise was.',
+ 'It wobbles slightly to the left. I have decided this is character.',
+ ].join('\n'),
+ publishedDaysAgo: 9,
+ activeDaysAgo: 3,
+ updateCount: 1,
+ },
+ {
+ id: 'seed-item-0000-0000-000000000003',
+ authorId: 'seed-0000-0000-0000-000000000003',
+ slug: 'note',
+ bornPublic: true,
+ title: 'Nobody tells you',
+ body: [
+ 'Nobody tells you',
+ 'that the hard part of keeping notes is not writing them,',
+ 'it is believing that the version of you who reads them later is a real person',
+ 'whose time is worth something.',
+ ].join('\n'),
+ publishedDaysAgo: 2,
+ activeDaysAgo: 2,
+ },
+ {
+ id: 'seed-item-0000-0000-000000000004',
+ authorId: 'seed-0000-0000-0000-000000000001',
+ slug: 'reading-list-audit',
+ bornPublic: true,
+ title: 'Reading list audit',
+ body: [
+ 'Reading list audit',
+ 'Fourteen books started, three finished. Publishing this so I stop pretending the other eleven are pending.',
+ ].join('\n'),
+ publishedDaysAgo: 5,
+ activeDaysAgo: 5,
+ },
+];
+
+/**
+ * Fills the mirror if it has never been filled.
+ *
+ * Idempotent by the presence of the first seeded author: re-running is a
+ * no-op, and a user who blocks or unfollows these accounts does not get them
+ * pushed back on the next launch.
+ */
+export async function seedSocial(repo: SocialRepo): Promise {
+ const first = AUTHORS[0];
+ if (first === undefined) return;
+ if ((await repo.profile(first.id)) !== null) return;
+
+ const now = Date.now();
+ const ago = (days: number) => new Date(now - days * DAY).toISOString();
+
+ for (const author of AUTHORS) {
+ await repo.upsertProfile({ ...author, createdAt: ago(120) });
+ }
+
+ for (const item of ITEMS) {
+ await repo.mirror({
+ id: item.id,
+ authorId: item.authorId,
+ slug: item.slug,
+ bornPublic: item.bornPublic,
+ title: item.title,
+ body: item.body,
+ doc: null,
+ publishedAt: ago(item.publishedDaysAgo),
+ activeAt: ago(item.activeDaysAgo),
+ completedAt: item.completed === true ? ago(item.activeDaysAgo) : null,
+ updateCount: item.updateCount ?? 0,
+ });
+ }
+}
diff --git a/apps/mobile/src/store/social-store.tsx b/apps/mobile/src/store/social-store.tsx
new file mode 100644
index 0000000..eed90b6
--- /dev/null
+++ b/apps/mobile/src/store/social-store.tsx
@@ -0,0 +1,252 @@
+import type { FeedItem, Note, Profile } from '@dailynote/core';
+import {
+ handleError,
+ normalizeHandle,
+ publicUrl,
+ publishError,
+ publishPatch,
+ threadOf,
+ uniqueSlug,
+ unpublishPatch,
+} from '@dailynote/core';
+import { useSQLiteContext } from 'expo-sqlite';
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useState,
+ type ReactNode,
+} from 'react';
+
+import { createSocialRepo, type ProfilePatch, type SocialRepo } from '@/db/social-repo';
+import { seedSocial } from '@/lib/social-seed';
+import { useNotes } from '@/store/notes-store';
+
+/**
+ * The social layer's state.
+ *
+ * This provider sits inside `NotesProvider` and uses it, because publishing is
+ * an operation on a note that already exists -- there is no publish path that
+ * does not start from the notes table, including the one the feed composer
+ * takes. That is deliberate: `post()` writes a note and then publishes it
+ * through exactly the same code a shared note goes through, so "this is now
+ * public" happens in one place and can only be wrong once.
+ *
+ * The notes composer knows nothing about any of this. See docs/product.md.
+ */
+
+interface SocialContextValue {
+ /** Null until a handle is claimed. Everything that writes requires it. */
+ me: Profile | null;
+ loading: boolean;
+ /** Every mirrored item. `composeFeed` turns this into what a reader sees. */
+ items: FeedItem[];
+ following: ReadonlySet;
+ blocked: ReadonlySet;
+
+ refresh: () => Promise;
+ /** Returns an error message, or null if the handle was claimed. */
+ claim: (handle: string, displayName: string) => Promise;
+ updateMe: (patch: ProfilePatch) => Promise;
+
+ /** Writes a post straight to the feed. Never touches the notes list. */
+ post: (body: string) => Promise;
+ /** Publishes an existing private note. Returns its public URL, or null. */
+ share: (noteId: string) => Promise;
+ /** Takes it back. The thread stays; only the public copy goes. */
+ unshare: (noteId: string) => Promise;
+ /** Why this note cannot be shared, or null. */
+ shareError: (note: Note) => string | null;
+
+ setLiked: (itemId: string, liked: boolean) => Promise;
+ setFollowing: (authorId: string, following: boolean) => Promise;
+ setBlocked: (authorId: string, blocked: boolean) => Promise;
+ report: (itemId: string, reason: string) => Promise;
+ recordView: (itemId: string) => Promise;
+
+ repo: SocialRepo;
+}
+
+const SocialContext = createContext(null);
+
+export function SocialProvider({ children }: { children: ReactNode }) {
+ const db = useSQLiteContext();
+ const repo = useMemo(() => createSocialRepo(db), [db]);
+ const { notes, repo: notesRepo, refresh: refreshNotes } = useNotes();
+
+ const [me, setMe] = useState(null);
+ const [items, setItems] = useState([]);
+ const [following, setFollowingIds] = useState>(new Set());
+ const [blocked, setBlockedIds] = useState>(new Set());
+ const [loading, setLoading] = useState(true);
+
+ const refresh = useCallback(async () => {
+ const [profile, mirrored, follows, blocks] = await Promise.all([
+ repo.me(),
+ repo.items(),
+ repo.following(),
+ repo.blocked(),
+ ]);
+ setMe(profile);
+ setItems(mirrored);
+ setFollowingIds(follows);
+ setBlockedIds(blocks);
+ }, [repo]);
+
+ useEffect(() => {
+ let active = true;
+ void (async () => {
+ // The stand-in server. Idempotent, and it goes away with the real one.
+ await seedSocial(repo);
+ if (!active) return;
+ await refresh();
+ if (active) setLoading(false);
+ })();
+ return () => {
+ active = false;
+ };
+ }, [repo, refresh]);
+
+ const value = useMemo(() => {
+ /**
+ * The single publish path.
+ *
+ * Takes a note that exists, gives it an address, and copies it into the
+ * mirror along with the state of its thread. Both `post` and `share` end
+ * here; nothing else may make a note public.
+ */
+ async function publishNote(note: Note, author: Profile): Promise {
+ const thread = threadOf(notes, note.rootId);
+ const updates = thread.filter((entry) => entry.kind === 'update');
+
+ // The feed sorts on this: the last thing that happened to the thread,
+ // which for a live commitment is its most recent update rather than the
+ // day it was written.
+ const activeAt =
+ [note.updatedAt, ...updates.map((entry) => entry.createdAt)].sort().at(-1) ??
+ note.updatedAt;
+
+ const slug =
+ note.slug ?? uniqueSlug(note.title, await repo.slugsFor(author.id));
+ const publishedAt = note.publishedAt ?? new Date().toISOString();
+
+ await notesRepo.update(note.id, {
+ ...publishPatch(new Date(publishedAt)),
+ slug,
+ });
+
+ await repo.mirror({
+ id: note.id,
+ authorId: author.id,
+ slug,
+ bornPublic: note.bornPublic,
+ title: note.title,
+ body: note.body,
+ doc: note.doc,
+ publishedAt,
+ activeAt,
+ completedAt: note.completedAt,
+ updateCount: updates.length,
+ });
+
+ return publicUrl(author.handle, slug);
+ }
+
+ return {
+ me,
+ loading,
+ items,
+ following,
+ blocked,
+ repo,
+ refresh,
+
+ async claim(handle, displayName) {
+ const normalized = normalizeHandle(handle);
+ const problem = handleError(normalized);
+ if (problem !== null) return problem;
+ if (await repo.handleTaken(normalized)) return 'Somebody has that one.';
+
+ const trimmed = displayName.trim();
+ await repo.claim(normalized, trimmed.length > 0 ? trimmed : null);
+ await refresh();
+ return null;
+ },
+
+ async updateMe(patch) {
+ await repo.updateMe(patch);
+ await refresh();
+ },
+
+ shareError(note) {
+ if (me === null) return 'Claim a handle first.';
+ return publishError(note);
+ },
+
+ async post(body) {
+ if (me === null || body.trim().length === 0) return null;
+
+ // A post is a note that was public from birth. It reuses the whole
+ // note machinery -- threads, updates, follow-through -- and is kept
+ // out of the private notes list by `bornPublic` alone.
+ const note = await notesRepo.create(body, { bornPublic: true });
+ const url = await publishNote(note, me);
+ await Promise.all([refreshNotes(), refresh()]);
+ return url;
+ },
+
+ async share(noteId) {
+ if (me === null) return null;
+ const note = await notesRepo.get(noteId);
+ if (note === null || publishError(note) !== null) return null;
+
+ const url = await publishNote(note, me);
+ await Promise.all([refreshNotes(), refresh()]);
+ return url;
+ },
+
+ async unshare(noteId) {
+ // The public copy goes first: if the second write fails the note is
+ // still marked public but is unreadable, which is the safe way round.
+ await repo.unmirror(noteId);
+ await notesRepo.update(noteId, { ...unpublishPatch(), slug: null });
+ await Promise.all([refreshNotes(), refresh()]);
+ },
+
+ async setLiked(itemId, liked) {
+ await repo.setLiked(itemId, liked);
+ await refresh();
+ },
+
+ async setFollowing(authorId, follow) {
+ await repo.setFollowing(authorId, follow);
+ await refresh();
+ },
+
+ async setBlocked(authorId, block) {
+ await repo.setBlocked(authorId, block);
+ await refresh();
+ },
+
+ async report(itemId, reason) {
+ await repo.report(itemId, reason);
+ },
+
+ async recordView(itemId) {
+ await repo.recordView(itemId);
+ },
+ };
+ }, [me, loading, items, following, blocked, repo, refresh, notes, notesRepo, refreshNotes]);
+
+ return {children};
+}
+
+export function useSocial(): SocialContextValue {
+ const context = useContext(SocialContext);
+ if (context === null) {
+ throw new Error('useSocial must be used inside a ');
+ }
+ return context;
+}
diff --git a/docs/plan-rollout-2.md b/docs/plan-rollout-2.md
index 07207d6..979a9bb 100644
--- a/docs/plan-rollout-2.md
+++ b/docs/plan-rollout-2.md
@@ -6,6 +6,32 @@ stopping being a notes app.
---
+## Status — 2026-08-04
+
+The in-app half of R2.0, R2.1 and R2.2 is built, plus direct posting, which this plan did not
+have. Two departures from what is written below, both deliberate:
+
+1. **Direct posting is in.** Section 1's "it is not a status feed" and section 7's "if you ever
+ add a 'what's happening?' box, you have lost" are superseded — see the amendment note in
+ [product.md](product.md). The guard that survived is structural: two composers on two tabs, and
+ the note composer has no social affordance on it.
+2. **It runs against local storage, not a server.** `apps/mobile/src/db/social-repo.ts` is the
+ seam; every social read and write in the app is one of its methods. Swapping to Supabase plus
+ the Hono worker described below means reimplementing that one file. Until then
+ `apps/mobile/src/lib/social-seed.ts` stands in for other users, and it is deleted — not
+ adapted — when the server exists.
+
+Built: profiles and handles with a reserved list · publish and unpublish a note · post straight to
+the feed · chronological following/everyone feed · follows · one reaction · view counts · block ·
+report. `supabase/migrations/20260804200000_social.sql` is the server schema and is not yet
+applied anywhere.
+
+Not built: the Hono web surface and public URLs that render without an install (the links the app
+shows are addresses the server will honour, not pages that exist yet) · comments · push
+notifications · themes · discovery · an admin queue for the reports table.
+
+---
+
## 0. The three questions you asked
### Platform: stay mobile, add a read-only web surface
diff --git a/docs/product.md b/docs/product.md
index 9439cc4..1fdbc3a 100644
--- a/docs/product.md
+++ b/docs/product.md
@@ -28,13 +28,22 @@ The test is structural, not a matter of emphasis:
Three rules that keep it that way:
-1. **The composer never asks who will see this.** No audience selector, no visibility toggle, no
- "share to feed" checkbox. The moment capture becomes performance it stops being a notes app,
- and the notes app is what makes the social layer worth anything.
-2. **Private is the default and it is not a setting you can accidentally flip.** Publishing is a
- deliberate action on an existing note, taken later.
-3. **The published unit is a thread, not a post.** A thread is a commitment plus the record of
- acting on it. There is no "what's happening?" box, ever.
+1. **The *note* composer never asks who will see this.** No audience selector, no visibility
+ toggle, no "share to feed" checkbox on the writing surface you reach from the Notes tab. The
+ moment capture becomes performance it stops being a notes app, and the notes app is what makes
+ the social layer worth anything.
+2. **Private is the default and it is not a setting you can accidentally flip.** Publishing a note
+ is a deliberate action on an existing note, taken later.
+3. **Posting straight to the feed exists, and it lives behind the Feed tab.** It is a second
+ composer on a separate screen, which states the handle and the word "Public" before a word is
+ typed, and its output never enters the private library.
+
+> **Amended 2026-08-04.** Rule 1 used to cover every composer and rule 3 used to read "there is no
+> 'what's happening?' box, ever." Direct posting is now in, by decision. What the original rules
+> were actually protecting — that private capture never acquires an audience — is preserved
+> structurally rather than by absence: there are two composers, the Notes one has no social
+> affordance anywhere on it, and nothing on the Notes or To-dos tabs links into the feed. The
+> failure mode to watch is unchanged and is in the table at the bottom of this page.
## What's in
@@ -50,6 +59,7 @@ Three rules that keep it that way:
| Markdown folder import | expansion | 1 |
| Profiles and handles | social | 2 |
| **Publishing a thread to a public URL that reads without an install** | **the social wedge** | 2 |
+| **Posting straight to the feed, from the Feed tab only** | social | 2 |
| Followers, chronological feed, likes, view counts | social, table stakes | 2 |
| Comments, blocks, reports, moderation | social, non-negotiable once public | 2 |
| Themes as data, custom wallpapers | customization | 2 |
@@ -93,6 +103,6 @@ Store copy, landing page, anywhere:
| | The tell | The response |
|---|---|---|
| It becomes a to-do list | Due dates, overdue badges, streaks creep in | The review card has four answers including **Let go**. Guilt is the failure mode |
-| It becomes a worse Twitter | Anyone proposes a compose-to-feed box | See rule 1 above |
+| It becomes a worse Twitter | The compose-to-feed box migrates toward the Notes tab, or the feed becomes the launch screen | Two composers, two tabs, and Notes stays first. If a post ever starts life on the Notes tab, that is the tell |
| The social layer eats the notes app | Your own private-note volume drops after R2.1 | Stop and fix the composer before building more of R2 |
| Publishing feels like homework | Nobody publishes, including you | Fine. R2.1 is independently shippable — a private notes app with shareable links is already good |
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index a5313eb..b298cd5 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -5,3 +5,4 @@ export * from './title';
export * from './review';
export * from './search';
export * from './note';
+export * from './social';
diff --git a/packages/core/src/note.ts b/packages/core/src/note.ts
index c73a9dd..14a1af9 100644
--- a/packages/core/src/note.ts
+++ b/packages/core/src/note.ts
@@ -37,9 +37,14 @@ export function createNote(input: NewNoteInput, now: Date): Note {
source: input.source ?? 'composer',
sourceRef: input.sourceRef ?? null,
+ // A born-public note is still created private: the feed composer publishes
+ // it as a second step, through the same path a shared note takes. One
+ // publish path means one place where "this is now public" can go wrong.
visibility: 'private',
publishedAt: null,
localOnly: input.localOnly ?? false,
+ slug: null,
+ bornPublic: input.bornPublic ?? false,
createdAt: timestamp,
updatedAt: timestamp,
diff --git a/packages/core/src/social.test.ts b/packages/core/src/social.test.ts
new file mode 100644
index 0000000..2d1bac7
--- /dev/null
+++ b/packages/core/src/social.test.ts
@@ -0,0 +1,270 @@
+import { describe, expect, it } from 'vitest';
+import {
+ blockedIds,
+ canPublish,
+ composeFeed,
+ feedEvent,
+ feedPreview,
+ handleError,
+ normalizeHandle,
+ profileName,
+ publicUrl,
+ publishError,
+ publishPatch,
+ slugify,
+ uniqueSlug,
+ unpublishPatch,
+ type FeedItem,
+ type Profile,
+} from './social';
+import { makeNote } from './test-support';
+
+function makeItem(overrides: Partial = {}): FeedItem {
+ return {
+ id: 'item-1',
+ authorId: 'author-1',
+ handle: 'alex',
+ displayName: null,
+ avatarUrl: null,
+ slug: 'a-thread',
+ bornPublic: false,
+ title: 'A thread',
+ body: 'A thread\nthe rest of it',
+ doc: null,
+ publishedAt: '2026-01-01T09:00:00.000Z',
+ activeAt: '2026-01-01T09:00:00.000Z',
+ completedAt: null,
+ updateCount: 0,
+ likeCount: 0,
+ likedByMe: false,
+ viewCount: 0,
+ ...overrides,
+ };
+}
+
+function makeProfile(overrides: Partial = {}): Profile {
+ return {
+ id: 'author-1',
+ handle: 'alex',
+ displayName: null,
+ bio: null,
+ avatarUrl: null,
+ createdAt: '2026-01-01T09:00:00.000Z',
+ ...overrides,
+ };
+}
+
+describe('normalizeHandle', () => {
+ it('strips the @ and lowercases', () => {
+ expect(normalizeHandle(' @Alex_B ')).toBe('alex_b');
+ });
+
+ it('strips a doubled @, which autocorrect produces', () => {
+ expect(normalizeHandle('@@alex')).toBe('alex');
+ });
+});
+
+describe('handleError', () => {
+ it('accepts a plain handle', () => {
+ expect(handleError('alex_99')).toBeNull();
+ });
+
+ it('rejects one that is too short', () => {
+ expect(handleError('al')).toMatch(/at least/i);
+ });
+
+ it('rejects one that is too long', () => {
+ expect(handleError('a'.repeat(31))).toMatch(/at most/i);
+ });
+
+ it('rejects punctuation and spaces', () => {
+ expect(handleError('alex.b')).toMatch(/letters/i);
+ expect(handleError('alex b')).toMatch(/letters/i);
+ });
+
+ it('rejects reserved names, including the ones the web surface needs', () => {
+ expect(handleError('admin')).toMatch(/reserved/i);
+ expect(handleError('@Support')).toMatch(/reserved/i);
+ expect(handleError('terms')).toMatch(/reserved/i);
+ });
+});
+
+describe('profileName', () => {
+ it('prefers the display name', () => {
+ expect(profileName(makeProfile({ displayName: 'Alex' }))).toBe('Alex');
+ });
+
+ it('falls back to the handle when the display name is blank', () => {
+ expect(profileName(makeProfile({ displayName: ' ' }))).toBe('@alex');
+ });
+});
+
+describe('slugify', () => {
+ it('makes a title into a path segment', () => {
+ expect(slugify('Learning Rust, week 3!')).toBe('learning-rust-week-3');
+ });
+
+ it('folds accents rather than dropping the word', () => {
+ expect(slugify('Café notes')).toBe('cafe-notes');
+ });
+
+ it('falls back when nothing survives', () => {
+ expect(slugify('!!!')).toBe('note');
+ expect(slugify(null)).toBe('note');
+ });
+
+ it('never ends in a hyphen after truncation', () => {
+ const slug = slugify(`${'a'.repeat(59)} tail`);
+ expect(slug.endsWith('-')).toBe(false);
+ expect(slug.length).toBeLessThanOrEqual(60);
+ });
+});
+
+describe('uniqueSlug', () => {
+ it('leaves a free slug alone', () => {
+ expect(uniqueSlug('Learning Rust', [])).toBe('learning-rust');
+ });
+
+ it('suffixes past the ones this author already used', () => {
+ expect(uniqueSlug('Learning Rust', ['learning-rust', 'learning-rust-2'])).toBe(
+ 'learning-rust-3',
+ );
+ });
+});
+
+describe('publicUrl', () => {
+ it('is the handle and slug under the web origin', () => {
+ expect(publicUrl('alex', 'learning-rust')).toBe('https://dailynote.app/@alex/learning-rust');
+ });
+});
+
+describe('publishError', () => {
+ it('allows an ordinary note', () => {
+ expect(canPublish(makeNote({ body: 'something worth sharing' }))).toBe(true);
+ });
+
+ it('refuses a local-only note, which is the whole point of the flag', () => {
+ expect(publishError(makeNote({ localOnly: true }))).toMatch(/local/i);
+ });
+
+ it('refuses a deleted note', () => {
+ expect(publishError(makeNote({ deleted: true }))).toMatch(/deleted/i);
+ });
+
+ it('refuses a locked note', () => {
+ expect(publishError(makeNote({ locked: true }))).toMatch(/unlock/i);
+ });
+
+ it('refuses an update: the thread is the published unit', () => {
+ expect(publishError(makeNote({ kind: 'update' }))).toMatch(/thread/i);
+ });
+
+ it('refuses an empty note', () => {
+ expect(publishError(makeNote({ body: ' \n ' }))).toMatch(/nothing/i);
+ });
+});
+
+describe('publishPatch / unpublishPatch', () => {
+ it('publishing sets both fields together', () => {
+ expect(publishPatch(new Date('2026-02-01T10:00:00.000Z'))).toEqual({
+ visibility: 'public',
+ publishedAt: '2026-02-01T10:00:00.000Z',
+ });
+ });
+
+ it('unpublishing clears the timestamp, satisfying private_is_unpublished', () => {
+ expect(unpublishPatch()).toEqual({ visibility: 'private', publishedAt: null });
+ });
+});
+
+describe('composeFeed', () => {
+ it('is reverse chronological on activity, not on publication', () => {
+ const old = makeItem({
+ id: 'a',
+ publishedAt: '2026-01-01T09:00:00.000Z',
+ activeAt: '2026-03-01T09:00:00.000Z',
+ updateCount: 1,
+ });
+ const recent = makeItem({
+ id: 'b',
+ publishedAt: '2026-02-01T09:00:00.000Z',
+ activeAt: '2026-02-01T09:00:00.000Z',
+ });
+
+ expect(composeFeed([recent, old]).map((item) => item.id)).toEqual(['a', 'b']);
+ });
+
+ it('breaks ties by id so the order is stable between reads', () => {
+ const first = makeItem({ id: 'a' });
+ const second = makeItem({ id: 'b' });
+ expect(composeFeed([second, first]).map((item) => item.id)).toEqual(['a', 'b']);
+ });
+
+ it('shows everyone when no following set is given', () => {
+ const items = [makeItem({ id: 'a', authorId: 'x' }), makeItem({ id: 'b', authorId: 'y' })];
+ expect(composeFeed(items)).toHaveLength(2);
+ });
+
+ it('restricts to the people you follow when one is', () => {
+ const items = [makeItem({ id: 'a', authorId: 'x' }), makeItem({ id: 'b', authorId: 'y' })];
+ const feed = composeFeed(items, { following: new Set(['y']) });
+ expect(feed.map((item) => item.id)).toEqual(['b']);
+ });
+
+ it('keeps your own items in your following feed without following yourself', () => {
+ const items = [makeItem({ id: 'a', authorId: 'me' }), makeItem({ id: 'b', authorId: 'y' })];
+ const feed = composeFeed(items, { following: new Set(['y']), selfId: 'me' });
+ expect(feed.map((item) => item.id).sort()).toEqual(['a', 'b']);
+ });
+
+ it('drops blocked authors even when you follow them', () => {
+ const items = [makeItem({ id: 'a', authorId: 'x' })];
+ const feed = composeFeed(items, { following: new Set(['x']), blocked: new Set(['x']) });
+ expect(feed).toEqual([]);
+ });
+});
+
+describe('blockedIds', () => {
+ it('is symmetric: blocking hides you from them too', () => {
+ const blocks = [
+ { blockerId: 'me', blockedId: 'them' },
+ { blockerId: 'other', blockedId: 'me' },
+ ];
+ expect(blockedIds(blocks, 'me')).toEqual(new Set(['them', 'other']));
+ });
+
+ it('ignores blocks between other people', () => {
+ expect(blockedIds([{ blockerId: 'a', blockedId: 'b' }], 'me').size).toBe(0);
+ });
+});
+
+describe('feedEvent', () => {
+ it('calls a born-public item a post', () => {
+ expect(feedEvent(makeItem({ bornPublic: true }))).toBe('posted');
+ });
+
+ it('calls a published private note a share', () => {
+ expect(feedEvent(makeItem({ bornPublic: false }))).toBe('shared');
+ });
+
+ it('calls a thread that gained an update an update', () => {
+ const item = makeItem({ updateCount: 1, activeAt: '2026-03-01T09:00:00.000Z' });
+ expect(feedEvent(item)).toBe('updated');
+ });
+
+ it('calls a completion follow-through, which is the point of the feed', () => {
+ const item = makeItem({
+ updateCount: 1,
+ completedAt: '2026-03-01T09:00:00.000Z',
+ activeAt: '2026-03-01T09:00:00.000Z',
+ });
+ expect(feedEvent(item)).toBe('followed-through');
+ });
+});
+
+describe('feedPreview', () => {
+ it('drops the title line and flattens the rest', () => {
+ const item = makeItem({ body: 'Title\n- one\n- two' });
+ expect(feedPreview(item)).toBe('one two');
+ });
+});
diff --git a/packages/core/src/social.ts b/packages/core/src/social.ts
new file mode 100644
index 0000000..03966d7
--- /dev/null
+++ b/packages/core/src/social.ts
@@ -0,0 +1,338 @@
+import type { Note, Timestamp } from './types';
+
+/**
+ * The social layer's domain types and pure logic.
+ *
+ * Everything here is a total function over plain data -- no storage, no network.
+ * The mobile app's repo and the API both call into this so that "who can see
+ * this", "is this handle legal" and "what order does the feed go in" have one
+ * implementation, tested once.
+ *
+ * The governing rule from plan-rollout-2 section 0: feed composition is *not*
+ * expressed in row-level security. It is code, and this is that code.
+ */
+
+// --- profiles ---------------------------------------------------------------
+
+export interface Profile {
+ /** Matches the account id, so a profile is a face on an account, never a second identity. */
+ id: string;
+ /** Without the leading `@`. Lowercase, unique across the app. */
+ handle: string;
+ displayName: string | null;
+ bio: string | null;
+ /** A local file URI today; object storage once sync lands. */
+ avatarUrl: string | null;
+ createdAt: Timestamp;
+}
+
+export const HANDLE_MIN = 3;
+export const HANDLE_MAX = 30;
+
+const HANDLE_PATTERN = /^[a-z0-9_]{3,30}$/;
+
+/**
+ * Handles nobody may claim.
+ *
+ * Two groups: paths the web surface needs for itself, and names that would let
+ * someone pass as the app. Impersonation is cheapest to prevent here -- see
+ * plan-rollout-2 section 5.
+ */
+export const RESERVED_HANDLES: ReadonlySet = new Set([
+ 'about',
+ 'admin',
+ 'administrator',
+ 'api',
+ 'contact',
+ 'dailynote',
+ 'explore',
+ 'feed',
+ 'help',
+ 'home',
+ 'legal',
+ 'login',
+ 'me',
+ 'moderator',
+ 'new',
+ 'notes',
+ 'official',
+ 'privacy',
+ 'root',
+ 'search',
+ 'settings',
+ 'signup',
+ 'staff',
+ 'support',
+ 'system',
+ 'team',
+ 'terms',
+ 'user',
+ 'www',
+]);
+
+/** Strips the decoration people type: `@Name ` -> `name`. */
+export function normalizeHandle(input: string): string {
+ return input.trim().replace(/^@+/, '').toLowerCase();
+}
+
+/**
+ * Why a handle can't be used, or null if it can.
+ *
+ * Returns prose rather than a code because every caller shows it verbatim; a
+ * second table mapping codes to sentences would be a table with one reader.
+ */
+export function handleError(input: string): string | null {
+ const handle = normalizeHandle(input);
+ if (handle.length === 0) return 'Pick a handle.';
+ if (handle.length < HANDLE_MIN) return `At least ${HANDLE_MIN} characters.`;
+ if (handle.length > HANDLE_MAX) return `At most ${HANDLE_MAX} characters.`;
+ if (!HANDLE_PATTERN.test(handle)) return 'Letters, numbers and underscores only.';
+ if (RESERVED_HANDLES.has(handle)) return 'That one is reserved.';
+ return null;
+}
+
+export function isValidHandle(input: string): boolean {
+ return handleError(input) === null;
+}
+
+/** What a profile is called in the UI. Never empty: the handle is the floor. */
+export function profileName(profile: Profile): string {
+ const name = profile.displayName?.trim() ?? '';
+ return name.length > 0 ? name : `@${profile.handle}`;
+}
+
+// --- slugs ------------------------------------------------------------------
+
+const SLUG_MAX = 60;
+
+/**
+ * The readable half of a public URL, derived from the title.
+ *
+ * Deliberately lossy and deliberately not unique -- `uniqueSlug` handles
+ * collisions. A note titled in a script with no ASCII form degrades to the
+ * fallback, which is ugly but still a working link.
+ */
+export function slugify(title: string | null | undefined): string {
+ const slug = (title ?? '')
+ .toLowerCase()
+ .normalize('NFKD')
+ .replace(/[̀-ͯ]/g, '')
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '')
+ .slice(0, SLUG_MAX)
+ .replace(/-+$/, '');
+ return slug.length > 0 ? slug : 'note';
+}
+
+/**
+ * `slugify` plus a numeric suffix if the author already used that slug.
+ *
+ * Slugs are unique per author, not globally: two people may both publish
+ * `/learning-rust`, and they are different URLs because the handle is in the path.
+ */
+export function uniqueSlug(title: string | null | undefined, taken: Iterable): string {
+ const base = slugify(title);
+ const used = new Set(taken);
+ if (!used.has(base)) return base;
+
+ for (let suffix = 2; ; suffix++) {
+ const candidate = `${base}-${suffix}`;
+ if (!used.has(candidate)) return candidate;
+ }
+}
+
+/** The canonical public address of a published thread. */
+export const WEB_ORIGIN = 'https://dailynote.app';
+
+export function publicUrl(handle: string, slug: string): string {
+ return `${WEB_ORIGIN}/@${handle}/${slug}`;
+}
+
+// --- publishing -------------------------------------------------------------
+
+/**
+ * Why this note cannot be made public, or null if it can.
+ *
+ * `localOnly` is the privacy pressure valve ADR 0002 bought, so it is checked
+ * here *and* by a database constraint. A note marked local never leaves the
+ * device, and no code path may make that a lie.
+ */
+export function publishError(note: Note): string | null {
+ if (note.localOnly) return 'This note is marked local-only.';
+ if (note.deleted) return 'This note is in Recently deleted.';
+ if (note.kind === 'update') return 'Publish the thread, not one update.';
+ if (note.locked) return 'Unlock this note first.';
+ if (note.body.trim().length === 0) return 'There is nothing in it yet.';
+ return null;
+}
+
+export function canPublish(note: Note): boolean {
+ return publishError(note) === null;
+}
+
+/** The patch that makes a private note public. Publishing is never in-place editing. */
+export function publishPatch(now: Date): Pick {
+ return { visibility: 'public', publishedAt: now.toISOString() };
+}
+
+/**
+ * The patch that takes it back.
+ *
+ * `publishedAt` is cleared rather than kept as history: the DB constraint
+ * `private_is_unpublished` requires it, and a private note that remembers when
+ * it was briefly public is a record nobody asked us to keep.
+ */
+export function unpublishPatch(): Pick {
+ return { visibility: 'private', publishedAt: null };
+}
+
+// --- the feed ---------------------------------------------------------------
+
+/**
+ * One published thread, flattened for rendering.
+ *
+ * The author's details are denormalized onto it because a feed row draws a name
+ * and an avatar, and making every row join back to a profile is the difference
+ * between one query and fifty.
+ */
+export interface FeedItem {
+ /** The root note's id. A thread is addressed by its root, always. */
+ id: string;
+ authorId: string;
+ handle: string;
+ displayName: string | null;
+ avatarUrl: string | null;
+ slug: string;
+
+ /**
+ * True for a post written straight to the feed, false for a private note the
+ * author later chose to share. The feed says which, because they are
+ * different acts and readers can tell anyway.
+ */
+ bornPublic: boolean;
+
+ title: string | null;
+ body: string;
+ doc: string | null;
+
+ publishedAt: Timestamp;
+ /**
+ * The last thing that happened to the thread: published, an update added, or
+ * completed. This -- not creation -- is what the feed sorts on, because the
+ * whole thesis is that a six-week-old thread coming back with "done" is the
+ * interesting event.
+ */
+ activeAt: Timestamp;
+ completedAt: Timestamp | null;
+ updateCount: number;
+
+ likeCount: number;
+ likedByMe: boolean;
+ viewCount: number;
+}
+
+/** What the thread most recently did, for the one-line label above a feed row. */
+export type FeedEvent = 'posted' | 'shared' | 'updated' | 'followed-through';
+
+export function feedEvent(item: FeedItem): FeedEvent {
+ if (item.completedAt !== null && item.completedAt === item.activeAt) return 'followed-through';
+ if (item.updateCount > 0 && item.activeAt !== item.publishedAt) return 'updated';
+ return item.bornPublic ? 'posted' : 'shared';
+}
+
+export function feedEventLabel(event: FeedEvent): string {
+ switch (event) {
+ case 'posted':
+ return 'posted';
+ case 'shared':
+ return 'shared a note';
+ case 'updated':
+ return 'added an update';
+ case 'followed-through':
+ return 'followed through';
+ }
+}
+
+export interface FeedOptions {
+ /** Author ids whose items belong in the feed. Omit for "everyone". */
+ following?: ReadonlySet | undefined;
+ /** Author ids the reader blocked, and readers who blocked them. Always applied. */
+ blocked?: ReadonlySet | undefined;
+ /** The reader's own id, so their own posts appear in their following feed. */
+ selfId?: string | null | undefined;
+}
+
+/**
+ * The feed, composed.
+ *
+ * Reverse chronological on `activeAt` and nothing else -- decision 2 in
+ * plan-rollout-2, and the one decision most worth not revisiting. There is no
+ * score, no engagement weighting and no place to add one; the ordering is a
+ * single comparator so that staying honest stays easy.
+ */
+export function composeFeed(items: readonly FeedItem[], options: FeedOptions = {}): FeedItem[] {
+ const { following, blocked, selfId } = options;
+
+ return items
+ .filter((item) => {
+ if (blocked?.has(item.authorId) === true) return false;
+ if (following === undefined) return true;
+ return item.authorId === selfId || following.has(item.authorId);
+ })
+ .slice()
+ .sort((a, b) => {
+ const order = b.activeAt.localeCompare(a.activeAt);
+ // Ties are broken by id so the order never shifts between reads; two
+ // items published in the same millisecond otherwise swap places on
+ // every refresh, which reads as the list glitching.
+ return order !== 0 ? order : a.id.localeCompare(b.id);
+ });
+}
+
+/** The blocked set is symmetric: a block hides you from them as well. */
+export function blockedIds(
+ blocks: readonly { blockerId: string; blockedId: string }[],
+ selfId: string,
+): Set {
+ const ids = new Set();
+ for (const block of blocks) {
+ if (block.blockerId === selfId) ids.add(block.blockedId);
+ if (block.blockedId === selfId) ids.add(block.blockerId);
+ }
+ return ids;
+}
+
+// --- reading ----------------------------------------------------------------
+
+/** The first line, which the list already renders as the title. */
+export function feedTitle(item: FeedItem): string {
+ const title = item.title?.trim() ?? '';
+ return title.length > 0 ? title : 'Untitled';
+}
+
+/**
+ * The body minus its title line, flattened to one line for a feed row.
+ *
+ * Mirrors the notes list's preview so a shared note looks in the feed like it
+ * looks at home.
+ */
+export function feedPreview(item: FeedItem): string {
+ return item.body
+ .split('\n')
+ .slice(1)
+ .map((line) => line.replace(/^- /, ''))
+ .join(' ')
+ .replace(/\s+/g, ' ')
+ .trim();
+}
+
+export const REPORT_REASONS = [
+ 'Spam',
+ 'Harassment or hate',
+ 'Violence or self-harm',
+ 'Sexual content',
+ 'Impersonation',
+ 'Something else',
+] as const;
+
+export type ReportReason = (typeof REPORT_REASONS)[number];
diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts
index 5725c10..b11ee62 100644
--- a/packages/core/src/types.ts
+++ b/packages/core/src/types.ts
@@ -57,11 +57,23 @@ export interface Note {
/** URL, package name or file path, depending on `source`. */
sourceRef: string | null;
- // --- publishing (Rollout 2 seam; unused in Rollout 1) ---
+ // --- publishing ---
visibility: Visibility;
publishedAt: Timestamp | null;
/** Never leaves the device. Excluded from the outbound sync set. */
localOnly: boolean;
+ /** The readable half of the public URL, `/@handle/slug`. Null until published. */
+ slug: string | null;
+ /**
+ * True for a post written straight to the feed rather than a private note the
+ * author later shared.
+ *
+ * This is the *only* structural difference between the two, and it exists so
+ * the feed can label them honestly and the notes list can leave posts out of
+ * your private library. It is never a thing the note composer asks about --
+ * see docs/product.md.
+ */
+ bornPublic: boolean;
// --- sync ---
createdAt: Timestamp;
@@ -84,6 +96,8 @@ export interface NewNoteInput {
parentId?: string | null;
kind?: NoteKind;
notebookId?: string | null;
+ /** Set only by the feed composer, which creates a note that is public from birth. */
+ bornPublic?: boolean;
}
/**
diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts
index f7b42e1..29b39b3 100644
--- a/packages/db/src/schema.ts
+++ b/packages/db/src/schema.ts
@@ -11,6 +11,7 @@ import {
text,
timestamp,
unique,
+ uniqueIndex,
uuid,
} from 'drizzle-orm/pg-core';
@@ -58,11 +59,22 @@ export const notes = pgTable(
/** URL, package name or file path. */
sourceRef: text('source_ref'),
- // --- publishing (Rollout 2 seam; DB-enforced from day one) ---
+ // --- publishing ---
visibility: text('visibility').notNull().default('private'),
publishedAt: timestamp('published_at', { withTimezone: true }),
/** Never leaves the device. Enforced in the sync layer, not by RLS. */
localOnly: boolean('local_only').notNull().default(false),
+ /** The readable half of `/@handle/slug`. Unique per author, null until published. */
+ slug: text('slug'),
+ /** Written straight to the feed rather than shared from the private library. */
+ bornPublic: boolean('born_public').notNull().default(false),
+ /**
+ * A counter on the row, not a table of view events.
+ *
+ * Per-view rows are the fastest route to a hundred-million-row table nobody
+ * queries. The API increments this; nothing reads individual views.
+ */
+ viewCount: integer('view_count').notNull().default(0),
// --- sync ---
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
@@ -82,6 +94,19 @@ export const notes = pgTable(
'private_is_unpublished',
sql`${table.visibility} <> 'private' or ${table.publishedAt} is null`,
),
+ /**
+ * ADR 0002 makes `local_only` the entire privacy pressure valve, so it gets
+ * a database guarantee rather than a code path that could be forgotten.
+ */
+ check(
+ 'local_only_is_private',
+ sql`${table.localOnly} = false or ${table.visibility} = 'private'`,
+ ),
+ /** A public note has an address; a private one has none to leak. */
+ check(
+ 'published_has_slug',
+ sql`${table.visibility} = 'private' or ${table.slug} is not null`,
+ ),
/** The sync pull: everything of mine that changed since the last sync. */
index('notes_user_updated_idx').on(table.userId, table.updatedAt),
@@ -89,6 +114,120 @@ export const notes = pgTable(
index('notes_user_review_idx').on(table.userId, table.nextReviewAt),
/** Thread assembly. */
index('notes_root_idx').on(table.rootId),
+ /** The public URL lookup, and the guarantee that two of yours never collide. */
+ uniqueIndex('notes_user_slug_idx')
+ .on(table.userId, table.slug)
+ .where(sql`${table.slug} is not null`),
+ ],
+);
+
+// --- the social layer -------------------------------------------------------
+
+/**
+ * A face on an account.
+ *
+ * Keyed by the account id rather than carrying its own, so there is exactly one
+ * identity per user and no way to accumulate a second.
+ */
+export const profiles = pgTable(
+ 'profiles',
+ {
+ id: uuid('id')
+ .primaryKey()
+ .references(() => authUsers.id, { onDelete: 'cascade' }),
+ /** The format check forbids uppercase, so plain text is already case-unique. */
+ handle: text('handle').notNull().unique(),
+ displayName: text('display_name'),
+ bio: text('bio'),
+ avatarUrl: text('avatar_url'),
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
+ updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
+ },
+ (table) => [check('handle_format', sql`${table.handle} ~ '^[a-z0-9_]{3,30}$'`)],
+);
+
+export const follows = pgTable(
+ 'follows',
+ {
+ followerId: uuid('follower_id')
+ .notNull()
+ .references(() => profiles.id, { onDelete: 'cascade' }),
+ followeeId: uuid('followee_id')
+ .notNull()
+ .references(() => profiles.id, { onDelete: 'cascade' }),
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
+ },
+ (table) => [
+ primaryKey({ columns: [table.followerId, table.followeeId] }),
+ check('no_self_follow', sql`${table.followerId} <> ${table.followeeId}`),
+ /** "Who follows me", which the primary key's column order cannot answer. */
+ index('follows_followee_idx').on(table.followeeId),
+ ],
+);
+
+/**
+ * One reaction kind, deliberately.
+ *
+ * Six emoji would force every reader to decide what a 😂 means on a note about
+ * something painful. One ambiguous positive signal avoids the question entirely
+ * -- decision 3 in plan-rollout-2.
+ */
+export const reactions = pgTable(
+ 'reactions',
+ {
+ noteId: uuid('note_id')
+ .notNull()
+ .references(() => notes.id, { onDelete: 'cascade' }),
+ userId: uuid('user_id')
+ .notNull()
+ .references(() => profiles.id, { onDelete: 'cascade' }),
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
+ },
+ (table) => [
+ primaryKey({ columns: [table.noteId, table.userId] }),
+ index('reactions_note_idx').on(table.noteId),
+ ],
+);
+
+/**
+ * Blocks and reports are not a later phase.
+ *
+ * App Store guideline 1.2 requires a report mechanism and a block mechanism
+ * before a user-generated-content app may ship at all, so they exist from the
+ * first moment anything is publicly readable.
+ */
+export const blocks = pgTable(
+ 'blocks',
+ {
+ blockerId: uuid('blocker_id')
+ .notNull()
+ .references(() => profiles.id, { onDelete: 'cascade' }),
+ blockedId: uuid('blocked_id')
+ .notNull()
+ .references(() => profiles.id, { onDelete: 'cascade' }),
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
+ },
+ (table) => [
+ primaryKey({ columns: [table.blockerId, table.blockedId] }),
+ check('no_self_block', sql`${table.blockerId} <> ${table.blockedId}`),
+ ],
+);
+
+export const reports = pgTable(
+ 'reports',
+ {
+ id: uuid('id').primaryKey(),
+ /** Null once the reporter deletes their account; the report still stands. */
+ reporterId: uuid('reporter_id').references(() => profiles.id, { onDelete: 'set null' }),
+ noteId: uuid('note_id').references(() => notes.id, { onDelete: 'cascade' }),
+ reason: text('reason').notNull(),
+ status: text('status').notNull().default('open'),
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
+ },
+ (table) => [
+ check('reports_status_check', sql`${table.status} in ('open', 'actioned', 'dismissed')`),
+ /** The queue: open reports, oldest first. Guideline 1.2 gives you 24 hours. */
+ index('reports_status_idx').on(table.status, table.createdAt),
],
);
diff --git a/supabase/migrations/20260804200000_social.sql b/supabase/migrations/20260804200000_social.sql
new file mode 100644
index 0000000..07c34f9
--- /dev/null
+++ b/supabase/migrations/20260804200000_social.sql
@@ -0,0 +1,195 @@
+-- Rollout 2: the social layer.
+--
+-- Two things are being added: an identity (profiles), and the ability for a
+-- note to be readable by someone other than its author.
+--
+-- The rule this migration is written to respect, from plan-rollout-2 section 0:
+-- **no feed logic in RLS.** The policies below answer exactly one question --
+-- "is this row public" -- and nothing else. Following, blocking, muting and
+-- feed composition are decided in application code, where they can be read and
+-- unit-tested. The moment a policy needs to know who follows whom, it becomes
+-- a recursive policy that is slow, untestable and terrifying to change.
+
+-- ---------------------------------------------------------------------------
+-- notes: the columns publishing needs
+-- ---------------------------------------------------------------------------
+
+alter table notes add column if not exists slug text;
+alter table notes add column if not exists born_public boolean not null default false;
+-- A counter on the row, not a table of view events: per-view rows are the
+-- fastest route to a hundred-million-row table that nobody ever queries.
+alter table notes add column if not exists view_count int not null default 0;
+
+-- Slugs are unique per author, not globally. Two people may both publish
+-- `learning-rust`; the handle in the path is what makes the URLs different.
+create unique index if not exists notes_user_slug_idx
+ on notes (user_id, slug) where slug is not null;
+
+-- ADR 0002 makes local_only the entire privacy pressure valve. It gets a
+-- database guarantee rather than a code path somebody could forget.
+alter table notes drop constraint if exists local_only_is_private;
+alter table notes add constraint local_only_is_private
+ check (local_only = false or visibility = 'private');
+
+-- A public note has an address. A private one has none to leak.
+alter table notes drop constraint if exists published_has_slug;
+alter table notes add constraint published_has_slug
+ check (visibility = 'private' or slug is not null);
+
+-- ---------------------------------------------------------------------------
+-- Identity
+-- ---------------------------------------------------------------------------
+
+-- Keyed by the account id rather than carrying its own, so there is exactly
+-- one identity per user and no way to accumulate a second.
+create table if not exists profiles (
+ id uuid primary key references auth.users(id) on delete cascade,
+ handle text not null unique,
+ display_name text,
+ bio text,
+ avatar_url text,
+ created_at timestamptz not null default now(),
+ updated_at timestamptz not null default now(),
+
+ -- The format forbids uppercase, so plain text is already case-unique and
+ -- citext is not worth an extension.
+ constraint handle_format check (handle ~ '^[a-z0-9_]{3,30}$')
+);
+
+-- Impersonation is cheapest to prevent before the first claim. This list is
+-- the app's own paths plus names that would let someone pass as the app.
+create table if not exists reserved_handles (
+ handle text primary key
+);
+
+insert into reserved_handles (handle) values
+ ('about'), ('admin'), ('administrator'), ('api'), ('contact'), ('dailynote'),
+ ('explore'), ('feed'), ('help'), ('home'), ('legal'), ('login'), ('me'),
+ ('moderator'), ('new'), ('notes'), ('official'), ('privacy'), ('root'),
+ ('search'), ('settings'), ('signup'), ('staff'), ('support'), ('system'),
+ ('team'), ('terms'), ('user'), ('www')
+on conflict do nothing;
+
+create or replace function reject_reserved_handle()
+returns trigger
+language plpgsql
+as $$
+begin
+ if exists (select 1 from reserved_handles r where r.handle = new.handle) then
+ raise exception 'handle % is reserved', new.handle using errcode = 'check_violation';
+ end if;
+ return new;
+end;
+$$;
+
+drop trigger if exists profiles_reject_reserved on profiles;
+create trigger profiles_reject_reserved
+ before insert or update of handle on profiles
+ for each row execute function reject_reserved_handle();
+
+drop trigger if exists profiles_set_updated_at on profiles;
+create trigger profiles_set_updated_at
+ before update on profiles
+ for each row execute function set_updated_at();
+
+-- ---------------------------------------------------------------------------
+-- The graph
+-- ---------------------------------------------------------------------------
+
+create table if not exists follows (
+ follower_id uuid not null references profiles(id) on delete cascade,
+ followee_id uuid not null references profiles(id) on delete cascade,
+ created_at timestamptz not null default now(),
+ primary key (follower_id, followee_id),
+ constraint no_self_follow check (follower_id <> followee_id)
+);
+
+-- "Who follows me", which the primary key's column order cannot answer.
+create index if not exists follows_followee_idx on follows (followee_id);
+
+-- One reaction kind, deliberately. Six emoji would force every reader to
+-- decide what a laughing face means on a note about something painful.
+create table if not exists reactions (
+ note_id uuid not null references notes(id) on delete cascade,
+ user_id uuid not null references profiles(id) on delete cascade,
+ created_at timestamptz not null default now(),
+ primary key (note_id, user_id)
+);
+
+create index if not exists reactions_note_idx on reactions (note_id);
+
+-- ---------------------------------------------------------------------------
+-- Safety
+--
+-- App Store guideline 1.2 requires a report mechanism, a block mechanism and
+-- published contact details before a user-generated-content app may ship at
+-- all. These are not a later phase; they land with the first public row.
+-- ---------------------------------------------------------------------------
+
+create table if not exists blocks (
+ blocker_id uuid not null references profiles(id) on delete cascade,
+ blocked_id uuid not null references profiles(id) on delete cascade,
+ created_at timestamptz not null default now(),
+ primary key (blocker_id, blocked_id),
+ constraint no_self_block check (blocker_id <> blocked_id)
+);
+
+create table if not exists reports (
+ id uuid primary key,
+ -- Null once the reporter deletes their account; the report still stands.
+ reporter_id uuid references profiles(id) on delete set null,
+ note_id uuid references notes(id) on delete cascade,
+ reason text not null,
+ status text not null default 'open',
+ created_at timestamptz not null default now(),
+ constraint reports_status_check check (status in ('open', 'actioned', 'dismissed'))
+);
+
+-- The moderation queue: open reports, oldest first. Guideline 1.2 gives you 24
+-- hours to act on one, so this index is read by a person, daily.
+create index if not exists reports_status_idx on reports (status, created_at);
+
+-- ---------------------------------------------------------------------------
+-- Row level security
+--
+-- Rollout 1's own-rows policies are untouched. Everything below is additive.
+-- ---------------------------------------------------------------------------
+
+alter table profiles enable row level security;
+alter table follows enable row level security;
+alter table reactions enable row level security;
+alter table blocks enable row level security;
+alter table reports enable row level security;
+
+-- The one read policy this rollout adds. Note what it does not mention:
+-- followers, blocks, mutes. The database answers "is this row public"; it does
+-- not answer "should this person see this now".
+create policy public_notes_readable on notes
+ for select using (visibility = 'public' and deleted = false);
+
+-- Profiles are public by construction -- a handle you cannot look up is not an
+-- identity. Only the owner may write one.
+create policy profiles_readable on profiles for select using (true);
+create policy own_profile on profiles
+ for all using (auth.uid() = id) with check (auth.uid() = id);
+
+-- Follower and following lists are visible; you may only create and delete
+-- your own edges.
+create policy follows_readable on follows for select using (true);
+create policy own_follows on follows
+ for all using (auth.uid() = follower_id) with check (auth.uid() = follower_id);
+
+-- Counts are public, the identity of who liked what is not exposed by the app.
+create policy reactions_readable on reactions for select using (true);
+create policy own_reactions on reactions
+ for all using (auth.uid() = user_id) with check (auth.uid() = user_id);
+
+-- A block list is private to the person who made it. Being able to enumerate
+-- who blocked you is itself a harassment vector.
+create policy own_blocks on blocks
+ for all using (auth.uid() = blocker_id) with check (auth.uid() = blocker_id);
+
+-- Reports are write-only from the app's side: you may file one, and only
+-- moderators (service role, which bypasses RLS) may read the queue.
+create policy file_report on reports
+ for insert with check (auth.uid() = reporter_id);