This repository has no description
1'use client';
2
3import { useState, useEffect } from 'react';
4import Link from 'next/link';
5import { useRouter } from 'next/navigation';
6import styles from './page.module.css';
7import { useAuth } from '@/lib/auth-context';
8import { containsBannedWords, sanitizeText, isAllowedEmoji } from '@/lib/content-filter';
9import { formatRelativeTime } from '@/lib/time-utils';
10import EditFlushModal from '@/components/EditFlushModal';
11
12// Types for feed entries
13interface FlushingEntry {
14 id: string;
15 uri: string;
16 cid: string;
17 authorDid: string;
18 authorHandle: string;
19 text: string;
20 emoji: string;
21 createdAt: string;
22}
23
24export default function Home() {
25 const router = useRouter();
26 const { isAuthenticated, session, signOut } = useAuth();
27 const did = session?.sub;
28 const handle = null; // Will be fetched when needed
29
30 // Status update state
31 const [text, setText] = useState('is ');
32 const [selectedEmoji, setSelectedEmoji] = useState('🚽');
33 const [statusOpen, setStatusOpen] = useState(false);
34 const [isSubmitting, setIsSubmitting] = useState(false);
35 const [statusError, setStatusError] = useState<string | null>(null);
36 const [success, setSuccess] = useState<string | null>(null);
37
38 // Feed state
39 const [entries, setEntries] = useState<FlushingEntry[]>([]);
40 const [loading, setLoading] = useState(true);
41 const [error, setError] = useState<string | null>(null);
42 const [newEntryIds, setNewEntryIds] = useState<Set<string>>(new Set());
43 const [editingFlush, setEditingFlush] = useState<FlushingEntry | null>(null);
44 const [actionError, setActionError] = useState<string | null>(null);
45 const [actionSuccess, setActionSuccess] = useState<string | null>(null);
46
47 useEffect(() => {
48 // Fetch the latest entries when the component mounts
49 fetchLatestEntries(true); // Force refresh on initial load
50
51 // Removed auto-refresh to avoid excessive API calls
52 }, []);
53
54 // Toggle status update form
55 const toggleStatusUpdate = () => {
56 setStatusOpen(!statusOpen);
57 setStatusError(null);
58 setSuccess(null);
59 };
60
61 // Handle emoji selection
62 const handleEmojiSelect = (emoji: string) => {
63 setSelectedEmoji(emoji);
64 };
65
66 // Check rate limit - 2 posts per 30 minutes, except for the plumber account
67 const checkRateLimit = (): boolean => {
68 // Define the plumber's DID
69 const PLUMBER_DID = 'did:plc:fouf3svmcxzn6bpiw3lgwz22';
70
71 // Exempt the plumber account from rate limiting
72 if (did === PLUMBER_DID) {
73 console.log('Plumber account detected - bypassing rate limits');
74 return true; // Always return true (under limit) for the plumber account
75 }
76
77 const now = Date.now();
78 const thirtyMinutesAgo = now - 30 * 60 * 1000; // 30 minutes in milliseconds
79
80 // Filter entries to get only the user's entries from the last 30 minutes
81 const userRecentEntries = entries.filter(entry =>
82 entry.authorDid === did &&
83 new Date(entry.createdAt).getTime() > thirtyMinutesAgo
84 );
85
86 // Return true if under limit, false if over limit
87 return userRecentEntries.length < 2;
88 };
89
90 // Submit flushing status
91 const handleSubmit = async (e: React.FormEvent) => {
92 e.preventDefault();
93
94 if (!session || !isAuthenticated) {
95 setStatusError('Please sign in to post a flush');
96 return;
97 }
98
99 // Check for banned words
100 if (text && containsBannedWords(text)) {
101 setStatusError('Uh oh, looks like you have a potty mouth. Try flushing again, but go a bit easier on the language please... this is a semi-family-friendly restroom');
102 return;
103 }
104
105 // Check character limit - 59 characters maximum
106 if (text.length > 59) {
107 setStatusError("Your flush status is too long! Please keep it under 59 characters.");
108 return;
109 }
110
111 // Check rate limit - 2 posts per 30 minutes (except for the plumber account)
112 if (!checkRateLimit()) {
113 setStatusError("Trying to make more than 2 flushes in 30 minutes?? Might be time to get the plunger. 🪠 Regular users are limited to 2 flushes per 30 minutes.");
114 return;
115 }
116
117 setIsSubmitting(true);
118 setStatusError(null);
119 setSuccess(null);
120
121 try {
122 // Use the new simplified API client
123 const { createPost } = await import('@/lib/api-client');
124
125 // Format status text to ensure it begins with "is"
126 let formattedText = text.trim();
127
128 // If text is empty or just "is", use default "is flushing"
129 if (!formattedText || formattedText === "is") {
130 formattedText = "is flushing";
131 }
132 // If text doesn't start with "is", add it
133 else if (!formattedText.toLowerCase().startsWith("is ")) {
134 formattedText = `is ${formattedText}`;
135 }
136
137 // Create the status update with the simplified API
138 // Just send the formatted text with emoji - no need to add handle/name since OAuth session handles user identity
139 const result = await createPost(session, {
140 text: `${formattedText} ${selectedEmoji}`,
141 langs: ['en']
142 });
143
144 console.log('Status update result:', result);
145
146 // Reset form and show success message
147 setText('is ');
148 setSuccess('Your flushing status has been updated!');
149
150 // Close status form after successful submission
151 setTimeout(() => {
152 setStatusOpen(false);
153 }, 2000);
154
155 // Refresh the feed after a delay to get the newly created entry
156 setTimeout(() => {
157 console.log('Refreshing feed to show new entry...');
158 fetchLatestEntries(true);
159 }, 2500);
160 } catch (err: any) {
161 console.error('Failed to update status:', err);
162 setStatusError(`Failed to update status: ${err.message || 'Unknown error'}`);
163 } finally {
164 setIsSubmitting(false);
165 }
166 };
167
168 // Function to fetch the latest entries
169 const fetchLatestEntries = async (forceRefresh = false) => {
170 try {
171 setLoading(true);
172 setError(null);
173
174 // Add a timestamp to ensure we bypass browser caching
175 const timestamp = Date.now();
176
177 // Use our simple API endpoint for reliability
178 // Add refresh=true when forcing a refresh to ensure we get fresh data
179 const url = forceRefresh
180 ? `/api/bluesky/feed-simple?refresh=true&_t=${timestamp}`
181 : `/api/bluesky/feed-simple?_t=${timestamp}`;
182
183 console.log(`Fetching feed from ${url} at ${new Date().toISOString()}`);
184
185 const response = await fetch(url, {
186 method: 'GET',
187 cache: 'no-store',
188 headers: {
189 'Cache-Control': 'no-cache, no-store, must-revalidate',
190 'Pragma': 'no-cache',
191 'Expires': '0'
192 }
193 });
194
195 if (!response.ok) {
196 throw new Error(`Failed to fetch feed: ${response.status}`);
197 }
198
199 const data = await response.json();
200 console.log(`Received ${data.entries?.length || 0} entries from API`);
201
202 // Debug: Log the most recent entries we received
203 if (data.entries && data.entries.length > 0) {
204 console.log('Latest entries from API:');
205 for (let i = 0; i < Math.min(3, data.entries.length); i++) {
206 const entry = data.entries[i];
207 console.log(` ${i+1}. ID: ${entry.id}, Handle: @${entry.authorHandle}, Text: "${entry.text.substring(0, 20)}..."`);
208 }
209 }
210
211 // Check for new entries
212 if (entries.length > 0) {
213 const currentIds = new Set(entries.map((entry: FlushingEntry) => entry.id));
214 const newEntries = data.entries.filter((entry: FlushingEntry) => !currentIds.has(entry.id));
215
216 // Log new entries
217 if (newEntries.length > 0) {
218 console.log(`Found ${newEntries.length} new entries`);
219
220 // Mark new entries for animation
221 setNewEntryIds(new Set(newEntries.map((entry: FlushingEntry) => entry.id)));
222
223 // Clear the animation markers after animation completes
224 setTimeout(() => {
225 setNewEntryIds(new Set());
226 }, 2000);
227 } else {
228 console.log('No new entries found in this update');
229 }
230 }
231
232 setEntries(data.entries);
233 } catch (err: any) {
234 console.error('Error fetching feed:', err);
235 setError(err.message || 'Failed to load feed');
236 } finally {
237 setLoading(false);
238 }
239 };
240
241 // Function to load older entries
242 const loadOlderEntries = async () => {
243 try {
244 // Save current scroll position
245 const scrollPosition = window.scrollY;
246
247 setLoading(true);
248 setError(null);
249
250 // Get the oldest entry we currently have
251 const oldestEntry = entries[entries.length - 1];
252 if (!oldestEntry) {
253 return; // No entries to use as cursor
254 }
255
256 console.log(`Loading older entries before ID ${oldestEntry.id}`);
257
258 // Use the oldest entry's ID as the cursor, plus add a unique timestamp
259 // Use our simple API for reliable pagination
260 const url = `/api/bluesky/feed-simple?before=${oldestEntry.id}&_t=${Date.now()}`;
261
262 const response = await fetch(url, {
263 cache: 'no-store',
264 headers: {
265 'Cache-Control': 'no-cache, no-store, must-revalidate',
266 'Pragma': 'no-cache',
267 'Expires': '0'
268 }
269 });
270
271 if (!response.ok) {
272 throw new Error(`Failed to fetch older entries: ${response.status}`);
273 }
274
275 const data = await response.json();
276
277 if (data.entries && data.entries.length > 0) {
278 console.log(`Loaded ${data.entries.length} older entries`);
279
280 // Debug: log the first few older entries
281 for (let i = 0; i < Math.min(3, data.entries.length); i++) {
282 const entry = data.entries[i];
283 console.log(` Older ${i+1}. ID: ${entry.id}, Handle: @${entry.authorHandle}, Text: "${entry.text.substring(0, 20)}..."`);
284 }
285
286 // Append the new entries to our existing list
287 setEntries([...entries, ...data.entries]);
288
289 // Wait for DOM to update with new entries
290 setTimeout(() => {
291 // Restore scroll position after state update and render
292 window.scrollTo({
293 top: scrollPosition,
294 behavior: 'instant' // Use instant to avoid additional animation
295 });
296 }, 0);
297 } else {
298 console.log('No older entries found');
299 }
300 } catch (err: any) {
301 console.error('Error fetching older entries:', err);
302 setError(err.message || 'Failed to load older entries');
303 } finally {
304 setLoading(false);
305 }
306 };
307
308 // Function to handle logout
309 const handleLogout = async () => {
310 await signOut();
311 };
312
313 // Check if the current user owns this flush
314 const isOwnFlush = (authorDid: string) => {
315 if (!session) return false;
316 return session.sub === authorDid;
317 };
318
319 // Handle updating a flush
320 const handleUpdateFlush = async (text: string, emoji: string) => {
321 if (!session || !editingFlush) {
322 setActionError('You must be logged in to update a flush');
323 return;
324 }
325
326 try {
327 setActionError(null);
328 setActionSuccess(null);
329
330 const { updateFlushRecord } = await import('@/lib/api-client');
331
332 await updateFlushRecord(
333 session,
334 editingFlush.uri,
335 text,
336 emoji,
337 editingFlush.createdAt
338 );
339
340 setActionSuccess('Flush updated successfully!');
341
342 // Update the local state
343 setEntries(entries.map(entry =>
344 entry.uri === editingFlush.uri
345 ? { ...entry, text, emoji }
346 : entry
347 ));
348
349 // Clear success message after 3 seconds
350 setTimeout(() => setActionSuccess(null), 3000);
351 } catch (error: any) {
352 console.error('Error updating flush:', error);
353 setActionError(error.message || 'Failed to update flush');
354 }
355 };
356
357 // Handle deleting a flush
358 const handleDeleteFlush = async () => {
359 if (!session || !editingFlush) {
360 setActionError('You must be logged in to delete a flush');
361 return;
362 }
363
364 try {
365 setActionError(null);
366 setActionSuccess(null);
367
368 const { deleteFlushRecord } = await import('@/lib/api-client');
369
370 await deleteFlushRecord(session, editingFlush.uri);
371
372 setActionSuccess('Flush deleted successfully!');
373
374 // Remove from local state
375 setEntries(entries.filter(entry => entry.uri !== editingFlush.uri));
376
377 // Clear success message after 3 seconds
378 setTimeout(() => setActionSuccess(null), 3000);
379 } catch (error: any) {
380 console.error('Error deleting flush:', error);
381 setActionError(error.message || 'Failed to delete flush');
382 }
383 };
384
385 // List of emojis for status selection
386 const EMOJIS = [
387 '🚽', '🧻', '💩', '💨', '🚾', '🧼', '🪠', '🚻', '🩸', '💧', '💦', '😌',
388 '😣', '🤢', '🤮', '🥴', '😮💨', '😳', '😵', '🌾', '🍦', '📱', '📖', '💭',
389 '1️⃣', '2️⃣', '🟡', '🟤'
390 ];
391
392 return (
393 <div className={styles.container}>
394
395 {/* Action messages */}
396 {actionError && (
397 <div className={styles.error}>
398 {actionError}
399 </div>
400 )}
401
402 {actionSuccess && (
403 <div className={styles.success}>
404 {actionSuccess}
405 </div>
406 )}
407
408 {/* Edit Modal */}
409 <EditFlushModal
410 isOpen={editingFlush !== null}
411 flushData={editingFlush ? {
412 uri: editingFlush.uri,
413 text: editingFlush.text,
414 emoji: editingFlush.emoji,
415 created_at: editingFlush.createdAt
416 } : null}
417 onSave={handleUpdateFlush}
418 onDelete={handleDeleteFlush}
419 onClose={() => setEditingFlush(null)}
420 />
421
422 <header className={styles.header}>
423 <div className={styles.headerContent}>
424 {/* New header layout with 4 center-aligned lines */}
425 <h1 className={styles.tagline}>
426 The Decentralized Toilet Network of Planet Earth & Simulation 12B
427 </h1>
428 <p className={styles.description}>
429 Share a "flush" whenever you're in the bathroom.
430 </p>
431 <p className={styles.donateText}>
432 Like the app? Donate to <a href="https://ko-fi.com/dameis" target="_blank" rel="noopener noreferrer" className={styles.kofiLink}>our toilet paper fund</a>.
433 </p>
434 <p className={styles.creditLine}>
435 Made by <a href="https://bsky.app/profile/dame.is" target="_blank" rel="noopener noreferrer">@dame.is</a> and <a href="https://bsky.app/profile/atpota.to" target="_blank" rel="noopener noreferrer">@atpota.to</a>
436 </p>
437 </div>
438 </header>
439
440 {/* Status update section - only visible when logged in */}
441 {isAuthenticated && (
442 <>
443 {/* Status update toggle button */}
444 <button
445 className={`${styles.toggleButton} ${statusOpen ? styles.toggleButtonActive : ''}`}
446 onClick={toggleStatusUpdate}
447 >
448 {statusOpen ? 'Close' : 'Update your status'}
449 <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
450 <path d="M19 9L12 16L5 9" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
451 </svg>
452 </button>
453
454 {/* Collapsible status update form */}
455 <div className={`${styles.statusUpdateContainer} ${statusOpen ? styles.statusUpdateOpen : ''}`}>
456 <div className={styles.card}>
457 {statusError && <div className={styles.error}>{statusError}</div>}
458 {success && <div className={styles.success}>{success}</div>}
459
460 <form onSubmit={handleSubmit} className={styles.form}>
461 <div className={styles.formGroup}>
462 <label>Select an emoji for your status</label>
463 <div className={styles.emojiGrid}>
464 {EMOJIS.map((emoji) => (
465 <button
466 key={emoji}
467 type="button"
468 className={`${styles.emojiButton} ${
469 emoji === selectedEmoji ? styles.selectedEmoji : ''
470 }`}
471 onClick={() => handleEmojiSelect(emoji)}
472 disabled={isSubmitting}
473 aria-label={`Select emoji ${emoji}`}
474 >
475 {emoji}
476 </button>
477 ))}
478 </div>
479 </div>
480
481 <div className={styles.formGroup}>
482 <label htmlFor="status">What's your status? (optional)</label>
483 <div className={styles.inputWrapper}>
484 <span className={styles.inputPrefix}>is </span>
485 <input
486 type="text"
487 id="status"
488 value={text.startsWith("is ") ? text.substring(3) : text}
489 onChange={(e) => setText(`is ${e.target.value}`)}
490 placeholder="flushing"
491 maxLength={56} /* 60 - 3 for "is " */
492 className={styles.inputWithPrefix}
493 disabled={isSubmitting}
494 />
495 </div>
496 <div className={styles.charCount}>
497 {text.length}/59
498 </div>
499 </div>
500
501 <button
502 type="submit"
503 className={styles.submitButton}
504 disabled={isSubmitting || text.length > 59}
505 >
506 {isSubmitting ? 'Flushing...' : 'Post Flush'}
507 </button>
508 </form>
509 </div>
510 </div>
511 </>
512 )}
513
514 {/* Feed Section */}
515 <div className={styles.feedSection}>
516
517 <div className={styles.feedHeader}>
518 <div className={styles.feedHeaderLeft}>
519 <h2>Recent flushes</h2>
520 <p className={styles.feedSubheader}>
521 Click on a username to see their flushes profile.
522 </p>
523 </div>
524 <button
525 onClick={async () => {
526 try {
527 setLoading(true);
528 setError(null);
529
530 // Use the simple API endpoint with a refresh parameter and timestamp
531 const timestamp = Date.now();
532 const url = `/api/bluesky/feed-simple?refresh=true&_t=${timestamp}`;
533 console.log(`🔄 MANUAL REFRESH @ ${new Date().toISOString()}`);
534 console.log(`Using simple API URL: ${url}`);
535
536 // Use strong no-cache headers to ensure browsers don't use cached responses
537 const response = await fetch(url, {
538 method: 'GET',
539 cache: 'no-store',
540 headers: {
541 'Cache-Control': 'no-cache, no-store, must-revalidate',
542 'Pragma': 'no-cache',
543 'Expires': '0',
544 'X-Force-Fresh-Data': 'true' // Custom header to signal intent
545 }
546 });
547
548 if (!response.ok) {
549 console.error(`API error: ${response.status}, ${response.statusText}`);
550 throw new Error(`API error: ${response.status}`);
551 }
552
553 // Attempt to extract response headers for debugging
554 console.log('Response headers:', Object.fromEntries(response.headers.entries()));
555
556 const data = await response.json();
557 console.log(`Refresh received ${data.entries?.length || 0} entries`);
558
559 if (data.entries && data.entries.length > 0) {
560 console.log(`🔍 Highest ID from refresh: ${data.entries[0].id}`);
561 for (let i = 0; i < Math.min(5, data.entries.length); i++) {
562 console.log(` ${i+1}. ID: ${data.entries[i].id}, Handle: @${data.entries[i].authorHandle}, Text: "${data.entries[i].text.substring(0, 20)}..."`);
563 }
564
565 // Compare with current entries
566 if (entries.length > 0) {
567 const currentHighestId = entries[0].id;
568 const newHighestId = data.entries[0].id;
569 console.log(`📊 Comparison - Current highest ID: ${currentHighestId}, New highest ID: ${newHighestId}`);
570
571 if (newHighestId > currentHighestId) {
572 console.log('✅ Refresh successful! New entries are more recent.');
573 } else if (newHighestId === currentHighestId) {
574 console.log('⚠️ Refresh returned same highest ID - no newer entries available.');
575 } else {
576 console.warn('❌ WARNING: New entries have lower IDs than existing ones!');
577 }
578 }
579 } else {
580 console.log('No entries returned from refresh');
581 }
582
583 // Update the entries with the new data
584 setEntries(data.entries || []);
585 } catch (err) {
586 console.error('Manual refresh error:', err);
587 setError('Failed to refresh. Try again.');
588 } finally {
589 setLoading(false);
590 }
591 }}
592 className={styles.refreshButton}
593 disabled={loading}
594 >
595 {loading ? 'Loading...' : 'Refresh'}
596 </button>
597 </div>
598
599 {error && <div className={styles.error}>{error}</div>}
600
601 {/* Debug info (hidden in production) */}
602 {entries && entries.length > 0 && (
603 <div className={styles.debugInfo} style={{ fontSize: '10px', color: '#666', margin: '5px 0', display: 'none' }}>
604 <p>Debug: Latest entry ID: {entries[0].id}, Count: {entries.length}</p>
605 </div>
606 )}
607
608 {loading ? (
609 <div className={styles.loadingContainer}>
610 <div className={styles.loader}></div>
611 <p>Loading latest entries...</p>
612 </div>
613 ) : (
614 <div className={styles.feedList}>
615 {entries.length > 0 ? (
616 // Filter first to determine if we have any valid entries
617 (() => {
618 const validEntries = entries.filter(entry => isAllowedEmoji(entry.emoji));
619 return validEntries.length > 0 ? (
620 <>
621 {validEntries.map((entry) => (
622 <div
623 key={entry.id}
624 className={`${styles.feedItem} ${newEntryIds.has(entry.id) ? styles.newFeedItem : ''}`}
625 >
626 <div className={styles.content}>
627 <div className={styles.contentLeft}>
628 <span className={styles.emoji}>{entry.emoji}</span>
629 <Link
630 href={`/profile/${entry.authorHandle}`}
631 className={styles.authorLink}
632 >
633 @{entry.authorHandle}
634 </Link>
635 <span className={styles.text}>
636 {entry.text ? (
637 // Check if handle ends with .is
638 entry.authorHandle && entry.authorHandle.endsWith('.is') ?
639 // For handles ending with .is, remove the "is" prefix if it exists
640 (sanitizeText(entry.text).toLowerCase().startsWith('is ') ?
641 (entry.text.length > 63 ? `${sanitizeText(entry.text.substring(3, 63))}...` : sanitizeText(entry.text.substring(3))) :
642 (entry.text.length > 60 ? `${sanitizeText(entry.text.substring(0, 60))}...` : sanitizeText(entry.text))
643 ) :
644 // For regular handles, display normal text
645 (entry.text.length > 60 ? `${sanitizeText(entry.text.substring(0, 60))}...` : sanitizeText(entry.text))
646 ) : (
647 // If no text, show default message
648 entry.authorHandle && entry.authorHandle.endsWith('.is') ?
649 'flushing' : 'is flushing'
650 )}
651 </span>
652 </div>
653 <div className={styles.contentRight}>
654 <span className={styles.timestamp}>
655 {formatRelativeTime(entry.createdAt)}
656 </span>
657 {isOwnFlush(entry.authorDid) && isAuthenticated && (
658 <button
659 className={styles.editButton}
660 onClick={() => setEditingFlush(entry)}
661 aria-label="Edit flush"
662 title="Edit or delete this flush"
663 >
664 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
665 <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
666 <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
667 </svg>
668 </button>
669 )}
670 </div>
671 </div>
672 </div>
673 ))}
674
675 <button
676 className={styles.loadMoreButton}
677 onClick={(e) => {
678 e.preventDefault(); // Prevent default action
679 loadOlderEntries();
680 }}
681 disabled={loading}
682 >
683 {loading ? 'Loading...' : 'Load older flushes'}
684 {!loading && (
685 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
686 <polyline points="7 13 12 18 17 13"></polyline>
687 <polyline points="7 6 12 11 17 6"></polyline>
688 </svg>
689 )}
690 </button>
691 </>
692 ) : (
693 <div className={styles.emptyState}>
694 <p>No valid entries found. Login and be the first to share your status!</p>
695 </div>
696 );
697 })()
698 ) : (
699 <div className={styles.emptyState}>
700 <p>No entries found. Login and be the first to share your status!</p>
701 </div>
702 )}
703 </div>
704 )}
705 </div>
706 </div>
707 );
708}