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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 48 additions & 19 deletions docs/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,34 +13,42 @@
* Has a logo and description of the product
* Has a login button
* If logged in
* Shows a list of people who have followed you, with a toggle to choose which are close friends
* Has a button to export the list of people as a string you can use in your email client
* Shows a list of updates. An update can be someone following you, someone asking to join one of your groups, or you being accepted into a group
* Shows a button to view your followers, with the first 5 shown as a preview
* Shows a button to view your groups, with the first 5 shown as a preview
* Followers screen
* Shows a list of people who have followed you, with a toggle next to each one to choose whether than person is a close friend
* Has a button to manually add new followers by email address
* Shows a list of groups you are in
* Has a button to create a new group
* Profile
* URL is slowpost.org/username
* Shows a person’s name, photo, and blurb
* Lists public groups that they are part of, and private groups you are both in
* Has a button to follow their posts
* If it’s your profile, can edit your name, photo, blurb
* Profile
* URL is slowpost.org/username
* Shows a person’s name, photo, and blurb
* Lists public groups that they are part of, and private groups you are both in
* Has a button to follow their posts
* If it’s your profile, can edit your name, photo, blurb
* Uploading a new profile photo supports common image formats up to 5MB and updates the photo immediately on your profile page
* Login
* URL is slowpast.org/p/login
* Asks for the user’s email address
* Then asks for the PIN it emailed them so the user can log in
* When running on localhost, allows the user to click "skip pin" to log in without having a PIN
* On the server, only allows "skip pin" if run with an env var telling it this is allowed
* Asks you for your user name slug and real full name the first time you sign in
* If you try to sign in with an email that hasn't been signed in before, it asks you if you want to sign in
* If you try to sign up with an email that /has/ been signed in before, it asks you if you want to sign up
* Group
* URL is slowpost.org/g/\[groupKey\]
* Has a non-guessable URL that is a key
* Shows a list of people in that group
* URL is slowpost.org/g/\[groupname\]
* Shows a list of people in that group
* Shows the name (and link to profile) of the group admin
* Each person's group bio shows a short summary (editable by that person) of their role in the group.
* Clicking on a person takes you to their profile
* Also has a button to ask to join the group
* If you are a group admin, shows people who have asked to join, with a button to decide to admit them or not
* Followers
* Shows a list of people who have asked to follow you
* You can click on them to see their profile and ask to follow them
* You can choose which of them to give the “close friend” post
* Notifications
* Shows a list of updates
* Join Group
* Shown if you click the "join group" button on a group
* Asks you to enter a bio line to appear next to your profile on the group
* Lets you provide an optional message to the group admin


## Implementation
Expand All @@ -60,10 +68,31 @@ I have the domain with cloudflare

## Database Model

* A "profile" collection contains one document for each person
* A "profile" collection contains one document for each person, with their username, real name, and bio
* A "group" collection contains one document for each group
* A "follow" collection contains one document for each follow relationship, with a key saying whether it is a "close" follow, and indexes to look up follow relationships in both directions
* A "member" collection contains one document for each group membership
* A "member" collection contains one document for each group membership, with their username, name, and per-group bio
* A "notifications" collection contains one document for each notification sent to a user. Each of these is also sent my email.
* An "auth" collection has one document for each user. This contains their email, active login PIN, and any active login sessions (with their expirey times, and secure tokens)
* Also have whatever collections you might need to manage login


## Database Adapter

* Regular server API functions don't call MongoDB directly. Instead they talk to it through a simple adapter layer.
* This adapter has the following methods:
* getDocument(collection, key) - Gets a single object. useful for getting profile, group, "user", etc objects that there is one of
* addDocument(collection, newKey, data) - Create a document
* updateDocument(collection, key, update) - Updates a document
* getChildLinks(collection, parentKey) | getParentLinks(collection, childKey) - Some collections represent one thing being part of something else. E.g. a group has members, a person has followers. In that case, we can query either all the children of a parent (e.g. the followers of a person) or the parents of a child (e.g. the people someone follows)
* addLink(collection, parentKey, childKey, data) - Create a new link


## Login model

* A cookie contains username + real name + auth token + session expirey time
* An API allows you to request that it send an email (using PostMark) with a random PIN to the user's email address
* A "login" API takes an email and a valid PIN and generates a new login session, returns it as a value and sets it as a cookie
* If SKIP_PIN is set as an env variable (true when running local dev) then
* If the user is logged in, their name appears in the to right on the top-bar. Otherwise it says "Log In | Sign Up".

137 changes: 137 additions & 0 deletions packages/client/components/ProfilePhotoUploader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { ChangeEvent, useEffect, useId, useRef, useState } from 'react';
import { Avatar, Button, Text, VertBox } from '../style';

const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024; // 5MB

type UploadStatus = 'idle' | 'uploading' | 'success' | 'error';

export interface ProfilePhotoUploaderProps {
username: string;
name: string;
initialPhotoUrl: string;
onPhotoUpdated?: (photoUrl: string) => void;
}

async function readFileAsDataUrl(file: File): Promise<string> {
return await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === 'string') {
resolve(reader.result);
} else {
reject(new Error('Could not read the selected file.'));
}
};
reader.onerror = () => {
reject(new Error('Could not read the selected file.'));
};
reader.readAsDataURL(file);
});
}

export function ProfilePhotoUploader({
username,
name,
initialPhotoUrl,
onPhotoUpdated
}: ProfilePhotoUploaderProps) {
const [photoUrl, setPhotoUrl] = useState(initialPhotoUrl);
const [status, setStatus] = useState<UploadStatus>('idle');
const [message, setMessage] = useState<string | null>(null);
const fileInputId = useId();
const fileInputRef = useRef<HTMLInputElement | null>(null);

useEffect(() => {
setPhotoUrl(initialPhotoUrl);
}, [initialPhotoUrl]);

const resetInput = () => {
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};

const handleSelectFile = () => {
fileInputRef.current?.click();
};

const handleFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) {
return;
}

if (!file.type.startsWith('image/')) {
setStatus('error');
setMessage('Please choose an image file.');
resetInput();
return;
}

if (file.size > MAX_FILE_SIZE_BYTES) {
setStatus('error');
setMessage('Please choose an image smaller than 5MB.');
resetInput();
return;
}

setStatus('uploading');
setMessage('Uploading photo…');

try {
const photoData = await readFileAsDataUrl(file);
const response = await fetch(`/api/profile/${username}/photo`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ photoData })
});
if (!response.ok) {
let errorMessage = 'Unable to upload your photo. Please try again.';
try {
const errorJson = (await response.json()) as { message?: string };
if (errorJson.message) {
errorMessage = errorJson.message;
}
} catch (parseError) {
console.warn('Unable to parse photo upload error response', parseError);
}
throw new Error(errorMessage);
}
const data = (await response.json()) as { photoUrl: string };
setPhotoUrl(data.photoUrl);
onPhotoUpdated?.(data.photoUrl);
setStatus('success');
setMessage('Profile photo updated.');
} catch (error) {
console.error('Failed to upload profile photo', error);
setStatus('error');
setMessage(error instanceof Error ? error.message : 'Unable to upload your photo.');
} finally {
resetInput();
}
};

return (
<VertBox gap="xs" align="center">
<Avatar src={photoUrl} alt={name} size={96} tone="bold" />
<input
id={fileInputId}
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleFileChange}
style={{ display: 'none' }}
/>
<Button onClick={handleSelectFile}>Change photo</Button>
{message ? (
<Text as="p" size="sm" tone={status === 'error' ? 'copper' : 'muted'} aria-live="polite">
{message}
</Text>
) : null}
</VertBox>
);
}

export default ProfilePhotoUploader;
19 changes: 18 additions & 1 deletion packages/client/components/ProfileSummary.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,35 @@
import Link from 'next/link';
import { useEffect, useState } from 'react';
import type { Profile } from '../lib/data';
import { Avatar, Button, Card, HorizBox, Pad, PadBox, Text, TileGrid, VertBox } from '../style';
import { ProfilePhotoUploader } from './ProfilePhotoUploader';

export type ProfileSummaryProps = {
profile: Profile;
};

export function ProfileSummary({ profile }: ProfileSummaryProps) {
const [photoUrl, setPhotoUrl] = useState(profile.photoUrl);

useEffect(() => {
setPhotoUrl(profile.photoUrl);
}, [profile.photoUrl]);

return (
<Card tone="gradient" maxWidth={720} margin="lg">
<PadBox vert="xl" horiz="xl">
<VertBox gap="xl">
<HorizBox gap="lg" align="center">
<Avatar src={profile.photoUrl} alt={profile.name} size={96} tone="bold" />
{profile.isSelf ? (
<ProfilePhotoUploader
username={profile.username}
name={profile.name}
initialPhotoUrl={photoUrl}
onPhotoUpdated={setPhotoUrl}
/>
) : (
<Avatar src={photoUrl} alt={profile.name} size={96} tone="bold" />
)}
<VertBox gap="sm">
<h1>
<Link href={`/${profile.username}`}>{profile.name}</Link>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ type Story = StoryObj<typeof ProfileSummary>;
export const OwnProfile: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByRole('button', { name: /change photo/i })).toBeInTheDocument();
await expect(canvas.getByRole('button', { name: /edit profile/i })).toBeInTheDocument();
}
};
Expand All @@ -30,5 +31,6 @@ export const OtherProfile: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByRole('button', { name: /follow/i })).toBeInTheDocument();
expect(canvas.queryByRole('button', { name: /change photo/i })).not.toBeInTheDocument();
}
};
11 changes: 11 additions & 0 deletions packages/client/vitest.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,17 @@ const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) =>
);
}

if (url.includes('/api/profile/') && url.endsWith('/photo') && method === 'POST') {
const body = typeof init?.body === 'string' ? JSON.parse(init.body) : {};
return new Response(
JSON.stringify({ photoUrl: typeof body?.photoData === 'string' ? body.photoData : '' }),
{
status: 200,
headers: { 'Content-Type': 'application/json' }
}
);
}

return new Response('Not Found', { status: 404 });
});

Expand Down
10 changes: 10 additions & 0 deletions packages/data/src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,16 @@ class SlowpostStoreImpl implements SlowpostStore {
return { username: profile.username, pendingFollowers };
}

async updateProfilePhoto(username: string, photoUrl: string): Promise<Profile> {
const sanitizedUrl = photoUrl.trim();
if (!sanitizedUrl) {
throw new Error('Photo URL is required');
}
const profile = await this.requireProfile(username);
await this.collections.profiles.updateOne({ username: profile.username }, { $set: { photoUrl: sanitizedUrl } });
return { ...profile, photoUrl: sanitizedUrl };
}

async createLoginSession(email: string, intent: 'login' | 'signup'): Promise<LoginSession> {
const normalizedEmail = this.normalizeEmail(email);
const pin = randomBytes(3).toString('hex');
Expand Down
1 change: 1 addition & 0 deletions packages/data/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ export interface SlowpostStore {
requestGroupJoin(username: string, groupKey: string): Promise<{ requestId: string }>;
requestFollow(follower: string, following: string): Promise<Follow>;
getFollowersView(username: string): Promise<FollowersView>;
updateProfilePhoto(username: string, photoUrl: string): Promise<Profile>;
createLoginSession(email: string, intent: 'login' | 'signup'): Promise<LoginSession>;
verifyLogin(email: string, pin: string): Promise<LoginSession>;
forceVerifyLogin(email: string, intent?: 'login' | 'signup'): Promise<LoginSession>;
Expand Down
Loading