This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

add supabase

+851 -9
+8
app/.env.example
··· 1 + # Supabase configuration 2 + NEXT_PUBLIC_SUPABASE_URL=your-supabase-url 3 + SUPABASE_SERVICE_ROLE_KEY=your-service-role-key 4 + 5 + # Optional: Bluesky API configuration 6 + # Only needed if you want to authenticate with the Bluesky API 7 + # BLUESKY_API_USERNAME=your-bluesky-username 8 + # BLUESKY_API_PASSWORD=your-bluesky-password
+16 -3
app/README.md
··· 6 6 7 7 - Bluesky OAuth authentication 8 8 - Custom lexicon schema for status updates 9 - - Emoji selection 9 + - Emoji selection 10 10 - Responsive design 11 + - Feed of all users' flushing status updates 11 12 12 13 ## Tech Stack 13 14 ··· 15 16 - React 16 17 - TypeScript 17 18 - Bluesky AT Protocol 19 + - Supabase (for feed storage) 20 + - WebSockets (for firehose connection) 18 21 19 22 ## Local Development 20 23 ··· 25 28 npm install 26 29 ``` 27 30 28 - 3. Start the development server: 31 + 3. Create a `.env.local` file based on `.env.example` and add your Supabase credentials 32 + 33 + 4. Start the development server: 29 34 30 35 ```bash 31 36 npm run dev 32 37 ``` 33 38 34 - 4. Open [http://localhost:3000](http://localhost:3000) in your browser 39 + 5. Open [http://localhost:3000](http://localhost:3000) in your browser 40 + 41 + 6. For the firehose connection (optional, for feed functionality): 42 + - Set up a Supabase project with the SQL in the `sql/setup.sql` file 43 + - Run the firehose worker script on a server: 44 + 45 + ```bash 46 + node scripts/firehose-worker.js 47 + ``` 35 48 36 49 ## Deployment 37 50
+5 -1
app/package.json
··· 12 12 "next": "^14.1.0", 13 13 "react": "^18.2.0", 14 14 "react-dom": "^18.2.0", 15 - "@atproto/api": "^0.12.0" 15 + "@atproto/api": "^0.12.0", 16 + "@supabase/supabase-js": "^2.39.0", 17 + "cbor-web": "^8.1.0", 18 + "dotenv": "^16.3.1", 19 + "ws": "^8.16.0" 16 20 }, 17 21 "devDependencies": { 18 22 "@types/node": "^20.10.5",
+192
app/scripts/firehose-worker.js
··· 1 + const WebSocket = require('ws'); 2 + const cbor = require('cbor-web'); 3 + const { createClient } = require('@supabase/supabase-js'); 4 + require('dotenv').config(); 5 + 6 + // Constants 7 + const FIREHOSE_URL = 'wss://bsky.network/xrpc/com.atproto.sync.subscribeRepos'; 8 + const FLUSHING_STATUS_NSID = 'im.flushing.right.now'; 9 + 10 + // Supabase setup - ensure you have these set in your .env file 11 + const supabaseUrl = process.env.SUPABASE_URL; 12 + const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY; 13 + const supabase = createClient(supabaseUrl, supabaseKey); 14 + 15 + // Reconnection parameters 16 + const MAX_RECONNECT_DELAY = 30000; // 30 seconds 17 + let reconnectAttempts = 0; 18 + let ws = null; 19 + 20 + // Connect to the firehose 21 + function connectToFirehose() { 22 + console.log('Connecting to Bluesky firehose...'); 23 + 24 + ws = new WebSocket(FIREHOSE_URL); 25 + 26 + ws.on('open', () => { 27 + console.log('Connected to firehose.'); 28 + // Reset reconnect counter on successful connection 29 + reconnectAttempts = 0; 30 + }); 31 + 32 + ws.on('message', async (data) => { 33 + try { 34 + // In a real implementation, parse CBOR data to extract repo operations 35 + // For now, log the message to track activity 36 + console.log('Received message from firehose'); 37 + 38 + // Decode the CBOR message (this is a simplified version) 39 + // The actual implementation would need to handle the header and payload separately 40 + const decoded = cbor.decode(data); 41 + 42 + // Process the message if it's a commit 43 + if (decoded.op === 1 && decoded.t === '#commit') { 44 + // Process repo commit 45 + const commit = decoded.payload; 46 + 47 + // Check if this commit contains a flushing record 48 + const flushingOps = commit.ops.filter(op => { 49 + return op.path.startsWith(FLUSHING_STATUS_NSID) && op.action === 'create'; 50 + }); 51 + 52 + if (flushingOps.length > 0) { 53 + console.log(`Found ${flushingOps.length} flushing records in commit from ${commit.repo}`); 54 + 55 + // Process each flushing record 56 + for (const op of flushingOps) { 57 + await processFlushingRecord(commit.repo, op.path, op.cid, commit.blocks); 58 + } 59 + } 60 + } 61 + } catch (error) { 62 + console.error('Error processing message:', error); 63 + } 64 + }); 65 + 66 + ws.on('error', (error) => { 67 + console.error('WebSocket error:', error); 68 + }); 69 + 70 + ws.on('close', (code, reason) => { 71 + console.log(`Connection closed: ${code} - ${reason}`); 72 + 73 + // Implement exponential backoff for reconnection 74 + reconnectAttempts++; 75 + const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), MAX_RECONNECT_DELAY); 76 + 77 + console.log(`Reconnecting in ${delay}ms (attempt ${reconnectAttempts})...`); 78 + setTimeout(connectToFirehose, delay); 79 + }); 80 + } 81 + 82 + // Process a flushing record and store it in Supabase 83 + async function processFlushingRecord(authorDid, recordPath, cid, blocks) { 84 + try { 85 + // Extract the record data from the blocks (simplified) 86 + // In a real implementation, you would need to properly decode the IPLD blocks 87 + const recordData = blocks[cid]; 88 + 89 + if (!recordData) { 90 + console.error('Record data not found in blocks'); 91 + return; 92 + } 93 + 94 + // Extract the record URI 95 + const uri = `at://${authorDid}/${recordPath}`; 96 + 97 + // Check if we already have this record 98 + const { data: existingRecord } = await supabase 99 + .from('flushing_entries') 100 + .select('id') 101 + .eq('uri', uri) 102 + .single(); 103 + 104 + if (existingRecord) { 105 + console.log('Record already exists, skipping'); 106 + return; 107 + } 108 + 109 + // Create a new entry in Supabase 110 + const newEntry = { 111 + uri, 112 + cid, 113 + author_did: authorDid, 114 + text: recordData.text, 115 + emoji: recordData.emoji, 116 + created_at: recordData.createdAt 117 + }; 118 + 119 + const { error } = await supabase 120 + .from('flushing_entries') 121 + .insert(newEntry); 122 + 123 + if (error) { 124 + console.error('Error inserting record:', error); 125 + } else { 126 + console.log('Successfully stored new flushing record'); 127 + } 128 + 129 + // Also try to resolve the author's handle if we don't have it 130 + const { data: authorData } = await supabase 131 + .from('users') 132 + .select('handle') 133 + .eq('did', authorDid) 134 + .single(); 135 + 136 + if (!authorData || !authorData.handle) { 137 + // TODO: Use the Bluesky API to resolve the handle from the DID 138 + // This would be done with the BskyAgent or direct API call 139 + console.log('Need to resolve handle for DID:', authorDid); 140 + } 141 + } catch (error) { 142 + console.error('Error processing flushing record:', error); 143 + } 144 + } 145 + 146 + // Create the necessary tables if they don't exist 147 + async function setupDatabase() { 148 + try { 149 + // Create flushing_entries table 150 + const { error: entriesError } = await supabase.rpc('create_flushing_entries_table_if_not_exists'); 151 + if (entriesError) { 152 + console.error('Error creating flushing_entries table:', entriesError); 153 + } 154 + 155 + // Create users table 156 + const { error: usersError } = await supabase.rpc('create_users_table_if_not_exists'); 157 + if (usersError) { 158 + console.error('Error creating users table:', usersError); 159 + } 160 + } catch (error) { 161 + console.error('Error setting up database:', error); 162 + } 163 + } 164 + 165 + // Start the worker 166 + async function start() { 167 + console.log('Starting firehose worker...'); 168 + 169 + // Check if we have the required environment variables 170 + if (!supabaseUrl || !supabaseKey) { 171 + console.error('Missing Supabase credentials. Please set SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY environment variables.'); 172 + process.exit(1); 173 + } 174 + 175 + // Setup the database 176 + await setupDatabase(); 177 + 178 + // Connect to the firehose 179 + connectToFirehose(); 180 + } 181 + 182 + // Handle process termination 183 + process.on('SIGINT', () => { 184 + console.log('Shutting down...'); 185 + if (ws) { 186 + ws.close(); 187 + } 188 + process.exit(0); 189 + }); 190 + 191 + // Start the worker 192 + start();
+83
app/sql/setup.sql
··· 1 + -- Setup script for Supabase database 2 + 3 + -- Users table to store user profile information 4 + CREATE TABLE IF NOT EXISTS users ( 5 + did TEXT PRIMARY KEY, 6 + handle TEXT NOT NULL, 7 + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() 8 + ); 9 + 10 + -- Function to create users table if it doesn't exist 11 + CREATE OR REPLACE FUNCTION create_users_table_if_not_exists() 12 + RETURNS void AS $$ 13 + BEGIN 14 + IF NOT EXISTS (SELECT FROM pg_tables WHERE tablename = 'users') THEN 15 + CREATE TABLE users ( 16 + did TEXT PRIMARY KEY, 17 + handle TEXT NOT NULL, 18 + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() 19 + ); 20 + END IF; 21 + END; 22 + $$ LANGUAGE plpgsql; 23 + 24 + -- Flushing entries table to store flushing status records 25 + CREATE TABLE IF NOT EXISTS flushing_entries ( 26 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 27 + uri TEXT UNIQUE NOT NULL, 28 + cid TEXT NOT NULL, 29 + author_did TEXT NOT NULL, 30 + author_handle TEXT, 31 + text TEXT NOT NULL, 32 + emoji TEXT NOT NULL, 33 + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), 34 + indexed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() 35 + ); 36 + 37 + -- Function to create flushing_entries table if it doesn't exist 38 + CREATE OR REPLACE FUNCTION create_flushing_entries_table_if_not_exists() 39 + RETURNS void AS $$ 40 + BEGIN 41 + IF NOT EXISTS (SELECT FROM pg_tables WHERE tablename = 'flushing_entries') THEN 42 + CREATE TABLE flushing_entries ( 43 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 44 + uri TEXT UNIQUE NOT NULL, 45 + cid TEXT NOT NULL, 46 + author_did TEXT NOT NULL, 47 + author_handle TEXT, 48 + text TEXT NOT NULL, 49 + emoji TEXT NOT NULL, 50 + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), 51 + indexed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() 52 + ); 53 + END IF; 54 + END; 55 + $$ LANGUAGE plpgsql; 56 + 57 + -- Create indexes for performance 58 + CREATE INDEX IF NOT EXISTS idx_flushing_entries_created_at ON flushing_entries(created_at DESC); 59 + CREATE INDEX IF NOT EXISTS idx_flushing_entries_author_did ON flushing_entries(author_did); 60 + CREATE INDEX IF NOT EXISTS idx_users_handle ON users(handle); 61 + 62 + -- Create a trigger to update the users table when a flushing entry is inserted with an author_handle 63 + CREATE OR REPLACE FUNCTION update_user_from_entry() 64 + RETURNS TRIGGER AS $$ 65 + BEGIN 66 + IF NEW.author_handle IS NOT NULL THEN 67 + INSERT INTO users (did, handle, updated_at) 68 + VALUES (NEW.author_did, NEW.author_handle, NOW()) 69 + ON CONFLICT (did) 70 + DO UPDATE SET 71 + handle = EXCLUDED.handle, 72 + updated_at = NOW(); 73 + END IF; 74 + RETURN NEW; 75 + END; 76 + $$ LANGUAGE plpgsql; 77 + 78 + -- Create the trigger 79 + DROP TRIGGER IF EXISTS trigger_update_user_from_entry ON flushing_entries; 80 + CREATE TRIGGER trigger_update_user_from_entry 81 + AFTER INSERT OR UPDATE ON flushing_entries 82 + FOR EACH ROW 83 + EXECUTE FUNCTION update_user_from_entry();
+147
app/src/app/api/bluesky/feed/route.ts
··· 1 + import { NextRequest, NextResponse } from 'next/server'; 2 + import { createClient } from '@supabase/supabase-js'; 3 + import { BskyAgent } from '@atproto/api'; 4 + 5 + // Constants 6 + const FLUSHING_STATUS_NSID = 'im.flushing.right.now'; 7 + const MAX_ENTRIES = 20; 8 + 9 + // Cache settings to avoid hitting the database too frequently 10 + const CACHE_TTL = 5 * 60 * 1000; // 5 minutes in milliseconds 11 + let cachedEntries: any[] = []; 12 + let lastFetchTime = 0; 13 + 14 + // Supabase client - using environment variables 15 + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || ''; 16 + const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY || ''; 17 + 18 + // Bluesky agent for public interactions (used to resolve DIDs to handles if needed) 19 + const agent = new BskyAgent({ 20 + service: 'https://bsky.social' 21 + }); 22 + 23 + export async function GET(request: NextRequest) { 24 + try { 25 + const now = Date.now(); 26 + 27 + // Check if cache is still valid 28 + if (now - lastFetchTime < CACHE_TTL && cachedEntries.length > 0) { 29 + console.log('Returning cached entries'); 30 + return NextResponse.json({ entries: cachedEntries }); 31 + } 32 + 33 + console.log('Fetching fresh entries'); 34 + 35 + // If we have Supabase credentials, fetch from there 36 + if (supabaseUrl && supabaseKey) { 37 + const supabase = createClient(supabaseUrl, supabaseKey); 38 + 39 + // Fetch the latest entries from Supabase 40 + const { data: entries, error } = await supabase 41 + .from('flushing_entries') 42 + .select(` 43 + id, 44 + uri, 45 + cid, 46 + author_did, 47 + author_handle, 48 + text, 49 + emoji, 50 + created_at 51 + `) 52 + .order('created_at', { ascending: false }) 53 + .limit(MAX_ENTRIES); 54 + 55 + if (error) { 56 + throw new Error(`Supabase error: ${error.message}`); 57 + } 58 + 59 + // Transform the data to match our client-side model 60 + const processedEntries = (entries || []).map(entry => ({ 61 + id: entry.id, 62 + uri: entry.uri, 63 + cid: entry.cid, 64 + authorDid: entry.author_did, 65 + authorHandle: entry.author_handle || 'unknown', 66 + text: entry.text, 67 + emoji: entry.emoji, 68 + createdAt: entry.created_at 69 + })); 70 + 71 + // Update the cache 72 + cachedEntries = processedEntries; 73 + lastFetchTime = now; 74 + 75 + return NextResponse.json({ entries: processedEntries }); 76 + } else { 77 + // If no Supabase credentials, fall back to mock data 78 + console.log('No Supabase credentials, using mock data'); 79 + const mockEntries = await getMockEntries(); 80 + 81 + // Update cache 82 + cachedEntries = mockEntries; 83 + lastFetchTime = now; 84 + 85 + return NextResponse.json({ entries: mockEntries }); 86 + } 87 + } catch (error: any) { 88 + console.error('Error fetching feed:', error); 89 + return NextResponse.json( 90 + { error: 'Failed to fetch feed', message: error.message }, 91 + { status: 500 } 92 + ); 93 + } 94 + } 95 + 96 + // Function to generate mock entries for testing 97 + // This is used when Supabase is not configured 98 + async function getMockEntries() { 99 + // Create some mock entries for testing 100 + const mockEntries = [ 101 + { 102 + id: '1', 103 + uri: 'at://did:plc:12345/im.flushing.right.now/1', 104 + cid: 'bafyreiabc123', 105 + authorDid: 'did:plc:12345', 106 + authorHandle: 'alice.bsky.social', 107 + text: 'Taking a quick break at work', 108 + emoji: '🚽', 109 + createdAt: new Date(Date.now() - 15 * 60000).toISOString() // 15 minutes ago 110 + }, 111 + { 112 + id: '2', 113 + uri: 'at://did:plc:67890/im.flushing.right.now/2', 114 + cid: 'bafyreiabc456', 115 + authorDid: 'did:plc:67890', 116 + authorHandle: 'bob.bsky.social', 117 + text: 'Reading the news on my phone', 118 + emoji: '📱', 119 + createdAt: new Date(Date.now() - 45 * 60000).toISOString() // 45 minutes ago 120 + }, 121 + { 122 + id: '3', 123 + uri: 'at://did:plc:abcdef/im.flushing.right.now/3', 124 + cid: 'bafyreiabc789', 125 + authorDid: 'did:plc:abcdef', 126 + authorHandle: 'charlie.bsky.social', 127 + text: 'Just finished a great book chapter', 128 + emoji: '📚', 129 + createdAt: new Date(Date.now() - 120 * 60000).toISOString() // 2 hours ago 130 + } 131 + ]; 132 + 133 + return mockEntries; 134 + } 135 + 136 + // Function to attempt to resolve a DID to a handle using the Bluesky API 137 + // This is used when we have a record with an author_did but no author_handle 138 + async function resolveDidToHandle(did: string): Promise<string | null> { 139 + try { 140 + await agent.login({ identifier: 'user.bsky.social', password: 'none' }); 141 + const response = await agent.getProfile({ actor: did }); 142 + return response.data.handle; 143 + } catch (error) { 144 + console.error(`Failed to resolve handle for DID ${did}:`, error); 145 + return null; 146 + } 147 + }
+24 -2
app/src/app/dashboard/dashboard.module.css
··· 23 23 24 24 .userInfo { 25 25 display: flex; 26 - align-items: center; 27 - gap: 1rem; 26 + flex-direction: column; 27 + align-items: flex-end; 28 + gap: 0.5rem; 29 + } 30 + 31 + .actions { 32 + display: flex; 33 + gap: 0.5rem; 34 + } 35 + 36 + .feedButton { 37 + background-color: var(--primary-color); 38 + color: white; 39 + border: none; 40 + padding: 0.3rem 0.8rem; 41 + font-size: 0.9rem; 42 + border-radius: 4px; 43 + cursor: pointer; 44 + } 45 + 46 + .feedButton:hover { 47 + background-color: var(--secondary-color); 28 48 } 29 49 30 50 .logoutButton { ··· 33 53 border: 1px solid var(--primary-color); 34 54 padding: 0.3rem 0.8rem; 35 55 font-size: 0.9rem; 56 + border-radius: 4px; 57 + cursor: pointer; 36 58 } 37 59 38 60 .logoutButton:hover {
+11 -3
app/src/app/dashboard/page.tsx
··· 131 131 <h1>I&apos;m Flushing Dashboard</h1> 132 132 <div className={styles.userInfo}> 133 133 <span>Logged in as: @{handle}</span> 134 - <button onClick={handleLogout} className={styles.logoutButton}> 135 - Logout 136 - </button> 134 + <div className={styles.actions}> 135 + <button 136 + onClick={() => router.push('/feed')} 137 + className={styles.feedButton} 138 + > 139 + View Feed 140 + </button> 141 + <button onClick={handleLogout} className={styles.logoutButton}> 142 + Logout 143 + </button> 144 + </div> 137 145 </div> 138 146 </header> 139 147
+170
app/src/app/feed/feed.module.css
··· 1 + .container { 2 + max-width: 800px; 3 + margin: 0 auto; 4 + padding: 2rem 1rem; 5 + } 6 + 7 + .header { 8 + text-align: center; 9 + margin-bottom: 2rem; 10 + } 11 + 12 + .subtitle { 13 + font-size: 1.1rem; 14 + color: #666; 15 + margin-top: 0.5rem; 16 + } 17 + 18 + .controls { 19 + display: flex; 20 + justify-content: space-between; 21 + margin-bottom: 2rem; 22 + } 23 + 24 + .refreshButton { 25 + background-color: #3897f0; 26 + color: white; 27 + border: none; 28 + border-radius: 4px; 29 + padding: 0.5rem 1rem; 30 + font-size: 1rem; 31 + cursor: pointer; 32 + transition: background-color 0.2s; 33 + } 34 + 35 + .refreshButton:hover { 36 + background-color: #1877f2; 37 + } 38 + 39 + .refreshButton:disabled { 40 + background-color: #ccc; 41 + cursor: not-allowed; 42 + } 43 + 44 + .homeLink { 45 + display: inline-block; 46 + background-color: #f1f1f1; 47 + color: #333; 48 + text-decoration: none; 49 + border-radius: 4px; 50 + padding: 0.5rem 1rem; 51 + font-size: 1rem; 52 + transition: background-color 0.2s; 53 + } 54 + 55 + .homeLink:hover { 56 + background-color: #e1e1e1; 57 + } 58 + 59 + .error { 60 + background-color: #ffebee; 61 + color: #c62828; 62 + padding: 1rem; 63 + border-radius: 4px; 64 + margin-bottom: 1rem; 65 + } 66 + 67 + .loadingContainer { 68 + display: flex; 69 + flex-direction: column; 70 + align-items: center; 71 + justify-content: center; 72 + padding: 2rem; 73 + } 74 + 75 + .loader { 76 + border: 4px solid #f3f3f3; 77 + border-top: 4px solid #3897f0; 78 + border-radius: 50%; 79 + width: 40px; 80 + height: 40px; 81 + animation: spin 1s linear infinite; 82 + margin-bottom: 1rem; 83 + } 84 + 85 + @keyframes spin { 86 + 0% { transform: rotate(0deg); } 87 + 100% { transform: rotate(360deg); } 88 + } 89 + 90 + .feedList { 91 + display: flex; 92 + flex-direction: column; 93 + gap: 1rem; 94 + } 95 + 96 + .feedItem { 97 + background-color: white; 98 + border: 1px solid #e1e1e1; 99 + border-radius: 8px; 100 + padding: 1rem; 101 + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05); 102 + transition: transform 0.2s, box-shadow 0.2s; 103 + } 104 + 105 + .feedItem:hover { 106 + transform: translateY(-2px); 107 + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); 108 + } 109 + 110 + .feedHeader { 111 + display: flex; 112 + justify-content: space-between; 113 + align-items: center; 114 + margin-bottom: 0.75rem; 115 + } 116 + 117 + .authorLink { 118 + color: #3897f0; 119 + font-weight: 600; 120 + text-decoration: none; 121 + } 122 + 123 + .authorLink:hover { 124 + text-decoration: underline; 125 + } 126 + 127 + .timestamp { 128 + font-size: 0.85rem; 129 + color: #888; 130 + } 131 + 132 + .content { 133 + display: flex; 134 + align-items: flex-start; 135 + gap: 0.75rem; 136 + } 137 + 138 + .emoji { 139 + font-size: 1.5rem; 140 + } 141 + 142 + .text { 143 + font-size: 1.1rem; 144 + line-height: 1.4; 145 + color: #333; 146 + } 147 + 148 + .emptyState { 149 + text-align: center; 150 + padding: 2rem; 151 + background-color: #f9f9f9; 152 + border-radius: 8px; 153 + border: 1px dashed #ccc; 154 + } 155 + 156 + .createButton { 157 + display: inline-block; 158 + margin-top: 1rem; 159 + background-color: #3897f0; 160 + color: white; 161 + text-decoration: none; 162 + border-radius: 4px; 163 + padding: 0.5rem 1rem; 164 + font-size: 1rem; 165 + transition: background-color 0.2s; 166 + } 167 + 168 + .createButton:hover { 169 + background-color: #1877f2; 170 + }
+127
app/src/app/feed/page.tsx
··· 1 + 'use client'; 2 + 3 + import { useState, useEffect } from 'react'; 4 + import Link from 'next/link'; 5 + import styles from './feed.module.css'; 6 + 7 + // Types for our feed entries 8 + interface FlushingEntry { 9 + id: string; 10 + uri: string; 11 + cid: string; 12 + authorDid: string; 13 + authorHandle: string; 14 + text: string; 15 + emoji: string; 16 + createdAt: string; 17 + } 18 + 19 + export default function FeedPage() { 20 + const [entries, setEntries] = useState<FlushingEntry[]>([]); 21 + const [loading, setLoading] = useState(true); 22 + const [error, setError] = useState<string | null>(null); 23 + 24 + useEffect(() => { 25 + // Fetch the latest entries when the component mounts 26 + fetchLatestEntries(); 27 + }, []); 28 + 29 + // Function to fetch the latest entries 30 + const fetchLatestEntries = async () => { 31 + try { 32 + setLoading(true); 33 + setError(null); 34 + 35 + // Call our API endpoint to get the latest entries 36 + const response = await fetch('/api/bluesky/feed'); 37 + 38 + if (!response.ok) { 39 + throw new Error(`Failed to fetch feed: ${response.status}`); 40 + } 41 + 42 + const data = await response.json(); 43 + setEntries(data.entries); 44 + } catch (err: any) { 45 + console.error('Error fetching feed:', err); 46 + setError(err.message || 'Failed to load feed'); 47 + } finally { 48 + setLoading(false); 49 + } 50 + }; 51 + 52 + // Format date to a readable string 53 + const formatDate = (dateString: string) => { 54 + const date = new Date(dateString); 55 + return date.toLocaleString(); 56 + }; 57 + 58 + return ( 59 + <div className={styles.container}> 60 + <header className={styles.header}> 61 + <h1>Flushing Feed</h1> 62 + <p className={styles.subtitle}> 63 + See what everyone is doing in the bathroom right now 64 + </p> 65 + </header> 66 + 67 + <div className={styles.controls}> 68 + <button 69 + onClick={fetchLatestEntries} 70 + className={styles.refreshButton} 71 + disabled={loading} 72 + > 73 + {loading ? 'Loading...' : 'Refresh Feed'} 74 + </button> 75 + <Link href="/" className={styles.homeLink}> 76 + Go to Dashboard 77 + </Link> 78 + </div> 79 + 80 + {error && ( 81 + <div className={styles.error}> 82 + Error: {error} 83 + </div> 84 + )} 85 + 86 + {loading && ( 87 + <div className={styles.loadingContainer}> 88 + <div className={styles.loader}></div> 89 + <p>Loading latest entries...</p> 90 + </div> 91 + )} 92 + 93 + <div className={styles.feedList}> 94 + {entries.length > 0 ? ( 95 + entries.map((entry) => ( 96 + <div key={entry.id} className={styles.feedItem}> 97 + <div className={styles.feedHeader}> 98 + <a 99 + href={`https://bsky.app/profile/${entry.authorHandle}`} 100 + target="_blank" 101 + rel="noopener noreferrer" 102 + className={styles.authorLink} 103 + > 104 + @{entry.authorHandle} 105 + </a> 106 + <span className={styles.timestamp}> 107 + {formatDate(entry.createdAt)} 108 + </span> 109 + </div> 110 + <div className={styles.content}> 111 + <span className={styles.emoji}>{entry.emoji}</span> 112 + <span className={styles.text}>{entry.text}</span> 113 + </div> 114 + </div> 115 + )) 116 + ) : !loading ? ( 117 + <div className={styles.emptyState}> 118 + <p>No entries found. Be the first to share your status!</p> 119 + <Link href="/" className={styles.createButton}> 120 + Create Status 121 + </Link> 122 + </div> 123 + ) : null} 124 + </div> 125 + </div> 126 + ); 127 + }
+22
app/src/app/page.module.css
··· 29 29 30 30 .btnContainer { 31 31 margin-top: 2rem; 32 + display: flex; 33 + flex-direction: column; 34 + gap: 1rem; 35 + align-items: center; 32 36 } 33 37 34 38 .loginButton { ··· 46 50 background-color: var(--secondary-color); 47 51 transform: translateY(-2px); 48 52 box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); 53 + text-decoration: none; 54 + } 55 + 56 + .feedButton { 57 + display: inline-block; 58 + background-color: transparent; 59 + color: var(--primary-color); 60 + border: 1px solid var(--primary-color); 61 + padding: 0.8rem 1.5rem; 62 + border-radius: 4px; 63 + font-size: 1.1rem; 64 + font-weight: 500; 65 + transition: all 0.2s ease; 66 + } 67 + 68 + .feedButton:hover { 69 + background-color: rgba(91, 173, 240, 0.1); 70 + transform: translateY(-2px); 49 71 text-decoration: none; 50 72 }
+3
app/src/app/page.tsx
··· 14 14 <Link href="/auth/login" className={styles.loginButton}> 15 15 Login with Bluesky 16 16 </Link> 17 + <Link href="/feed" className={styles.feedButton}> 18 + View Feed 19 + </Link> 17 20 </div> 18 21 </div> 19 22 </div>
+43
contextual info for claude/answers_for_cluade2.md
··· 1 + 1. Do you want to implement a server-side or client-side solution for the 2 + 3 + firehose? 4 + 5 + - I don't really know the difference, can you recommend? Maybe we start small and upgrade later? 6 + 7 + 1. Do you have a preferred WebSocket library for Node.js or the browser? 8 + 9 + - my answer: I'm not sure, I don't know enough about this. Could you make a recommendation and we can try it and then change course later if needed? 10 + 11 + 1. How do you want to handle CBOR encoding/decoding? 12 + 13 + - my answer: I'm not sure, recommend? 14 + 15 + 1. How many entries do you want to display in the feed? 16 + 17 + - my answer: maybe like the last 20 to start? 18 + 19 + 1. Do you need historical data or just new activity? 20 + 21 + - my answer: I'd like historically the last 20 ideally, would that be hard? 22 + 23 + 1. Any UI preferences for displaying the feed? 24 + 25 + - my answer: let's do a simple clean list that shows the user handle, timestamp, emoji + text, and then if you click on thier username it takes you to their profile at https://bsky.app/profile/username.example 26 + 27 + 1. Authentication requirements for firehose access? 28 + 29 + - I don't think it needs auth, can you check the documentation? Let me know what you need to proceed. 30 + 31 + 1. Do you want to filter the firehose for im.flushing.right.now records on 32 + 33 + the server or client? 34 + 35 + - I'm not sure, maybe client? what's easier? 36 + 37 + 1. Do you want to integrate this with a database for persistence? 38 + 39 + - yeah I have a supabase account that i'd like to connect 40 + 41 + 1. Environment considerations 42 + 43 + - Is this app deployed on a platform that supports WebSockets? I don't know, does Vercel support that?