From 80ab58282ef407094906c28ac981fe87a433ba2d Mon Sep 17 00:00:00 2001 From: Kshitij Bachhav Date: Tue, 4 Aug 2026 20:51:07 +0530 Subject: [PATCH] social: the feed, posting, and publishing a note Adds Rollout 2's in-app half: an identity, a published set, and a third tab to read it from. Two composers, deliberately. The note editor gains one menu item -- Share to the feed -- which confirms before it does anything; the feed's own composer states the handle and the word Public before a word is typed and writes nothing to the private library. Nothing on Notes or To-dos links into the feed, and the app still opens on Notes. A post is a note that was public from birth. `bornPublic` is the only structural difference between it and a note shared later, which buys posts threads, updates and the follow-through loop for free, and keeps them out of the notes list. Both go through one publish path. Feed order is reverse chronological on the thread's last activity, not on creation -- a six-week-old commitment coming back with "done" is the event worth showing, and `composeFeed` has nowhere to put a ranking signal. Safety ships with the first public row rather than later, per App Store 1.2: report, block (symmetric, and it unfollows), and a reports table with a queue index. No server yet. `db/social-repo.ts` is the seam -- every social read and write is one of its methods -- and `lib/social-seed.ts` stands in for other users until there is a real one. The Supabase migration is written and not yet applied; its RLS adds exactly one policy, "is this row public", with feed composition left in testable code. docs/product.md rules 1 and 3 forbade a compose-to-feed box; amended in this commit, with the structural guard they were protecting kept. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017DRL4dVgg5oebLSTenQv2G --- apps/mobile/src/app/(tabs)/_layout.tsx | 13 +- apps/mobile/src/app/(tabs)/index.tsx | 6 +- apps/mobile/src/app/(tabs)/social.tsx | 225 +++++++++ apps/mobile/src/app/_layout.tsx | 52 +- apps/mobile/src/app/handle.tsx | 184 +++++++ apps/mobile/src/app/note/[id].tsx | 99 +++- apps/mobile/src/app/post/[id].tsx | 345 +++++++++++++ apps/mobile/src/app/post/new.tsx | 150 ++++++ apps/mobile/src/app/profile/[handle].tsx | 371 ++++++++++++++ apps/mobile/src/components/feed-card.tsx | 191 ++++++++ apps/mobile/src/components/icon.tsx | 21 + apps/mobile/src/db/migrations.ts | 94 ++++ apps/mobile/src/db/notes-repo.ts | 8 + apps/mobile/src/db/social-repo.ts | 454 ++++++++++++++++++ apps/mobile/src/lib/format.ts | 17 + apps/mobile/src/lib/social-seed.ts | 159 ++++++ apps/mobile/src/store/social-store.tsx | 252 ++++++++++ docs/plan-rollout-2.md | 26 + docs/product.md | 26 +- packages/core/src/index.ts | 1 + packages/core/src/note.ts | 5 + packages/core/src/social.test.ts | 270 +++++++++++ packages/core/src/social.ts | 338 +++++++++++++ packages/core/src/types.ts | 16 +- packages/db/src/schema.ts | 141 +++++- supabase/migrations/20260804200000_social.sql | 195 ++++++++ 26 files changed, 3626 insertions(+), 33 deletions(-) create mode 100644 apps/mobile/src/app/(tabs)/social.tsx create mode 100644 apps/mobile/src/app/handle.tsx create mode 100644 apps/mobile/src/app/post/[id].tsx create mode 100644 apps/mobile/src/app/post/new.tsx create mode 100644 apps/mobile/src/app/profile/[handle].tsx create mode 100644 apps/mobile/src/components/feed-card.tsx create mode 100644 apps/mobile/src/db/social-repo.ts create mode 100644 apps/mobile/src/lib/social-seed.ts create mode 100644 apps/mobile/src/store/social-store.tsx create mode 100644 packages/core/src/social.test.ts create mode 100644 packages/core/src/social.ts create mode 100644 supabase/migrations/20260804200000_social.sql 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);