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);