diff --git a/EDIT_DELETE_FEATURE.md b/EDIT_DELETE_FEATURE.md new file mode 100644 index 0000000..4d831ee --- /dev/null +++ b/EDIT_DELETE_FEATURE.md @@ -0,0 +1,218 @@ +# Edit & Delete Flush Feature + +## Overview +This feature allows users to edit and delete their own flush records directly from their profile page. The implementation includes a modal interface for editing, client-side validation, and proper handling through the AT Protocol and Jetstream firehose. + +## What Was Added + +### 1. API Client Functions (`src/lib/api-client.ts`) +Added two new functions to handle record management: + +- **`deleteFlushRecord(session, recordUri)`**: Deletes a flush record using the AT Protocol's `deleteRecord` method +- **`updateFlushRecord(session, recordUri, text, emoji, originalCreatedAt)`**: Updates a flush record using the AT Protocol's `putRecord` method + +Both functions: +- Parse AT URIs correctly (format: `at://did:plc:xxx/collection.name/rkey`) +- Use the OAuth session's Agent for authenticated requests +- Include proper error handling and logging +- Preserve the original `createdAt` timestamp on updates + +### 2. Edit Modal Component (`src/components/EditFlushModal.tsx`) +A beautiful modal dialog that provides: + +- Pre-populated form with the flush's current text and emoji +- Character counter (59 character limit) +- Emoji selector with all approved emojis +- Content validation (banned words, character limits) +- Delete confirmation workflow +- Loading states for all async operations +- Error handling with user-friendly messages +- Backdrop click to close +- Responsive design for mobile devices + +### 3. Profile Page Updates (`src/app/profile/[handle]/page.tsx`) +Enhanced the profile page with: + +- Edit button on each flush (only visible to the flush owner) +- Authentication check using `useAuth()` hook to compare DIDs +- Integration with `EditFlushModal` component +- State management for editing operations +- Success/error message display +- Optimistic UI updates (updates local state immediately) + +New state variables: +- `editingFlush`: Tracks which flush is being edited +- `actionError`: Displays error messages +- `actionSuccess`: Displays success messages + +New functions: +- `isOwnProfile()`: Checks if the logged-in user owns the profile +- `handleUpdateFlush()`: Handles the update operation +- `handleDeleteFlush()`: Handles the delete operation + +### 4. Profile Styles (`src/app/profile/[handle]/profile.module.css`) +Added styles for: + +- `.contentRight`: Container for timestamp and edit button +- `.editButton`: Pencil icon button with hover effects +- `.actionError`: Error message styling +- `.actionSuccess`: Success message styling + +### 5. Edit Modal Styles (`src/components/EditFlushModal.module.css`) +Complete styling for the modal including: + +- Dark backdrop overlay +- Centered modal with max-width +- Form inputs and emoji grid +- Action buttons (Save, Cancel, Delete) +- Delete confirmation UI +- Responsive mobile layout +- Smooth transitions and hover effects + +### 6. Jetstream Consumer (`scripts/firehose-worker.js`) +Updated to properly handle: + +- **Delete operations**: Removes records from Supabase when deleted from the network +- **Update operations**: Updates existing records with new content +- URI construction for record matching +- Proper error handling for database operations + +## How It Works + +### User Flow + +1. **User navigates to their own profile** + - Edit buttons appear next to each of their flushes + - Buttons are hidden for other users' profiles + +2. **User clicks edit button** + - Modal opens with pre-filled form + - Current text and emoji are displayed + - User can modify text and/or emoji + - Character counter shows remaining characters + +3. **User saves changes** + - Validation runs (banned words, character limits) + - API call made to update the record via AT Protocol + - Local state updates immediately (optimistic UI) + - Success message displayed + - Modal closes automatically + +4. **User deletes a flush** + - Clicks "Delete Flush" button + - Confirmation prompt appears + - On confirmation, record is deleted via AT Protocol + - Record removed from local state + - Success message displayed + - Modal closes + +### Technical Flow + +#### Update Operation +``` +User clicks Save + → Validation (client-side) + → updateFlushRecord(session, uri, text, emoji, createdAt) + → Agent.api.com.atproto.repo.putRecord() + → PDS updates the record + → Jetstream firehose emits 'update' event + → Worker processes event + → Supabase record updated + → UI updates optimistically +``` + +#### Delete Operation +``` +User confirms delete + → deleteFlushRecord(session, uri) + → Agent.api.com.atproto.repo.deleteRecord() + → PDS deletes the record + → Jetstream firehose emits 'delete' event + → Worker processes event + → Supabase record deleted + → UI updates optimistically +``` + +## Authorization + +- Uses OAuth session from `@atproto/oauth-client-browser` +- Compares `session.sub` (user's DID) with `profileData.did` +- Edit buttons only visible when DIDs match +- AT Protocol handles authorization at the PDS level +- Users can only edit/delete their own records + +## Validation + +All validation from the original flush creation is preserved: + +- **Character limit**: 59 characters +- **Banned words**: Content filtering via `containsBannedWords()` +- **Text sanitization**: via `sanitizeText()` +- **Emoji validation**: Only approved emojis from the list +- **Authentication**: Must be logged in + +## Error Handling + +Comprehensive error handling at every level: + +- Network failures +- Authorization errors +- Validation errors +- User-friendly error messages +- Console logging for debugging +- Graceful degradation + +## Responsive Design + +The modal and edit buttons work beautifully on: + +- Desktop screens +- Tablets +- Mobile devices + +Features: +- Touch-friendly button sizes +- Readable text at all sizes +- Scrollable modal content +- Proper spacing and padding + +## Future Enhancements + +Potential improvements: + +1. **Edit history**: Track when records were edited +2. **Undo functionality**: Allow users to revert changes +3. **Bulk operations**: Edit/delete multiple flushes at once +4. **Keyboard shortcuts**: Quick access to edit/delete +5. **Inline editing**: Edit directly in the feed without modal +6. **Draft saving**: Save edits as drafts before publishing + +## Testing Checklist + +To verify the feature works correctly: + +- [ ] Edit button appears on own profile only +- [ ] Edit button hidden on other users' profiles +- [ ] Modal opens when edit button clicked +- [ ] Form pre-populates with current values +- [ ] Text changes are validated +- [ ] Emoji selection works +- [ ] Character counter updates correctly +- [ ] Save button updates the record +- [ ] Delete button shows confirmation +- [ ] Delete confirmation works +- [ ] Cancel buttons close modal +- [ ] Success messages display +- [ ] Error messages display +- [ ] Local state updates optimistically +- [ ] Jetstream updates Supabase correctly +- [ ] Mobile layout works properly + +## Notes + +- Records maintain their original `createdAt` timestamp when updated +- Updates create a new CID (Content Identifier) for the record +- The URI remains the same (same `rkey`) +- Deletes are permanent and cannot be undone +- All operations respect AT Protocol's distributed architecture + diff --git a/scripts/firehose-worker.js b/scripts/firehose-worker.js index bf8ae4c..704236f 100644 --- a/scripts/firehose-worker.js +++ b/scripts/firehose-worker.js @@ -342,10 +342,36 @@ async function processEvent(event) { console.log(`Processing ${operation} operation for DID: ${did}, collection: ${collection}, rkey: ${rkey}`); - // Skip delete operations + // Construct the URI for the record + const recordUri = `at://${did}/${collection}/${rkey}`; + + // Handle delete operations if (operation === 'delete') { - console.log(`Skipping delete operation`); - return; + console.log(`Processing delete operation for URI: ${recordUri}`); + + try { + const { data, error } = await supabase + .from('flushing_records') + .delete() + .eq('uri', recordUri); + + if (error) { + console.error(`Error deleting record: ${error.message}`); + } else { + console.log(`Successfully deleted record: ${recordUri}`); + } + } catch (deleteError) { + console.error(`Exception while deleting record: ${deleteError.message}`); + } + + return; // Done processing delete + } + + // Handle update operations (which are represented as 'update' in Jetstream) + if (operation === 'update') { + console.log(`Processing update operation for URI: ${recordUri}`); + // Updates are handled the same way as creates - we'll update the existing record + // Fall through to the normal processing below } // Try different approaches to get a handle diff --git a/src/app/profile/[handle]/page.tsx b/src/app/profile/[handle]/page.tsx index 07ff923..5cb04bd 100644 --- a/src/app/profile/[handle]/page.tsx +++ b/src/app/profile/[handle]/page.tsx @@ -6,6 +6,8 @@ import { useParams } from 'next/navigation'; import styles from './profile.module.css'; import { sanitizeText, containsBannedWords } from '@/lib/content-filter'; import { formatRelativeTime } from '@/lib/time-utils'; +import { useAuth } from '@/lib/auth-context'; +import EditFlushModal from '@/components/EditFlushModal'; // Define approved emojis list - keep in sync with API route const APPROVED_EMOJIS = [ @@ -34,6 +36,7 @@ interface EmojiStat { export default function ProfilePage() { const params = useParams(); const handle = params.handle as string; + const { session, isAuthenticated } = useAuth(); const [entries, setEntries] = useState([]); const [totalCount, setTotalCount] = useState(0); @@ -55,6 +58,9 @@ export default function ProfilePage() { avgStatusLength: number; mostFrequentTime: string; } | null>(null); + const [editingFlush, setEditingFlush] = useState(null); + const [actionError, setActionError] = useState(null); + const [actionSuccess, setActionSuccess] = useState(null); // Match Bluesky's API response format interface ProfileData { did: string; @@ -431,9 +437,109 @@ export default function ProfilePage() { } }; + // Check if the current user owns this profile + const isOwnProfile = () => { + if (!session || !profileData) return false; + return session.sub === profileData.did; + }; + + // Handle updating a flush + const handleUpdateFlush = async (text: string, emoji: string) => { + if (!session || !editingFlush) { + setActionError('You must be logged in to update a flush'); + return; + } + + try { + setActionError(null); + setActionSuccess(null); + + const { updateFlushRecord } = await import('@/lib/api-client'); + + await updateFlushRecord( + session, + editingFlush.uri, + text, + emoji, + editingFlush.created_at + ); + + setActionSuccess('Flush updated successfully!'); + + // Update the local state + setEntries(entries.map(entry => + entry.uri === editingFlush.uri + ? { ...entry, text, emoji } + : entry + )); + + // Clear success message after 3 seconds + setTimeout(() => setActionSuccess(null), 3000); + } catch (error: any) { + console.error('Error updating flush:', error); + setActionError(error.message || 'Failed to update flush'); + } + }; + + // Handle deleting a flush + const handleDeleteFlush = async () => { + if (!session || !editingFlush) { + setActionError('You must be logged in to delete a flush'); + return; + } + + try { + setActionError(null); + setActionSuccess(null); + + const { deleteFlushRecord } = await import('@/lib/api-client'); + + await deleteFlushRecord(session, editingFlush.uri); + + setActionSuccess('Flush deleted successfully!'); + + // Remove from local state + setEntries(entries.filter(entry => entry.uri !== editingFlush.uri)); + setTotalCount(totalCount - 1); + + // Clear success message after 3 seconds + setTimeout(() => setActionSuccess(null), 3000); + } catch (error: any) { + console.error('Error deleting flush:', error); + setActionError(error.message || 'Failed to delete flush'); + } + }; + return (
+ {/* Action messages */} + {actionError && ( +
+ {actionError} +
+ )} + + {actionSuccess && ( +
+ {actionSuccess} +
+ )} + + {/* Edit Modal */} + setEditingFlush(null)} + /> +
{profileLoading ? ( @@ -648,9 +754,24 @@ export default function ProfilePage() { )}
- - {formatRelativeTime(entry.created_at)} - +
+ + {formatRelativeTime(entry.created_at)} + + {isOwnProfile() && isAuthenticated && ( + + )} +
)) diff --git a/src/app/profile/[handle]/profile.module.css b/src/app/profile/[handle]/profile.module.css index 25fecc3..7573920 100644 --- a/src/app/profile/[handle]/profile.module.css +++ b/src/app/profile/[handle]/profile.module.css @@ -446,6 +446,55 @@ min-width: 0; } +.contentRight { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.editButton { + background: none; + border: 1px solid var(--border); + color: var(--foreground); + padding: 6px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; + width: 32px; + height: 32px; +} + +.editButton svg { + width: 16px; + height: 16px; +} + +.editButton:hover { + border-color: var(--primary-color); + color: var(--primary-color); + background: rgba(var(--primary-rgb), 0.05); +} + +.actionError { + background: #fee; + border: 1px solid #fcc; + color: #c33; + padding: 12px; + margin-bottom: 16px; + font-size: 0.9rem; +} + +.actionSuccess { + background: #efe; + border: 1px solid #cfc; + color: #363; + padding: 12px; + margin-bottom: 16px; + font-size: 0.9rem; +} + .userLine { display: flex; align-items: center; diff --git a/src/components/EditFlushModal.module.css b/src/components/EditFlushModal.module.css new file mode 100644 index 0000000..9f49de6 --- /dev/null +++ b/src/components/EditFlushModal.module.css @@ -0,0 +1,280 @@ +.modalBackdrop { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 20px; +} + +.modalContent { + background: var(--background); + border: 2px solid var(--border); + padding: 30px; + max-width: 600px; + width: 100%; + max-height: 90vh; + overflow-y: auto; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3); +} + +.modalHeader { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; +} + +.modalHeader h2 { + margin: 0; + font-size: 1.5rem; + color: var(--foreground); +} + +.closeButton { + background: none; + border: none; + font-size: 1.5rem; + cursor: pointer; + color: var(--foreground); + padding: 5px 10px; + line-height: 1; + transition: color 0.2s; +} + +.closeButton:hover { + color: var(--primary); +} + +.closeButton:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.error { + background: #fee; + border: 1px solid #fcc; + color: #c33; + padding: 12px; + margin-bottom: 20px; + font-size: 0.9rem; +} + +.formGroup { + margin-bottom: 24px; +} + +.formGroup label { + display: block; + margin-bottom: 8px; + font-weight: 600; + color: var(--foreground); + font-size: 0.95rem; +} + +.textInput { + width: 100%; + padding: 12px; + font-size: 1rem; + border: 2px solid var(--border); + background: var(--background); + color: var(--foreground); + font-family: inherit; + transition: border-color 0.2s; +} + +.textInput:focus { + outline: none; + border-color: var(--primary); +} + +.textInput:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.charCount { + text-align: right; + font-size: 0.85rem; + color: var(--muted); + margin-top: 4px; +} + +.emojiGrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(50px, 1fr)); + gap: 8px; + margin-top: 8px; +} + +.emojiButton { + background: var(--background); + border: 2px solid var(--border); + padding: 12px; + font-size: 1.5rem; + cursor: pointer; + transition: all 0.2s; + display: flex; + align-items: center; + justify-content: center; +} + +.emojiButton:hover { + border-color: var(--primary); + transform: scale(1.05); +} + +.emojiButton.selected { + background: var(--primary); + border-color: var(--primary); + transform: scale(1.1); +} + +.emojiButton:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.modalActions { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + margin-top: 24px; + padding-top: 24px; + border-top: 1px solid var(--border); +} + +.rightActions { + display: flex; + gap: 12px; +} + +.deleteButton { + background: transparent; + color: #c33; + border: 2px solid #c33; + padding: 10px 20px; + font-size: 0.95rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; +} + +.deleteButton:hover { + background: #c33; + color: white; +} + +.deleteButton:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.cancelButton { + background: transparent; + color: var(--foreground); + border: 2px solid var(--border); + padding: 10px 20px; + font-size: 0.95rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; +} + +.cancelButton:hover { + background: var(--border); +} + +.cancelButton:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.saveButton { + background: var(--primary); + color: var(--primary-foreground); + border: 2px solid var(--primary); + padding: 10px 20px; + font-size: 0.95rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; +} + +.saveButton:hover { + opacity: 0.9; + transform: translateY(-1px); +} + +.saveButton:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + +.deleteConfirmation { + width: 100%; +} + +.deleteConfirmation p { + margin: 0 0 16px 0; + color: var(--foreground); + font-size: 0.95rem; +} + +.confirmButtons { + display: flex; + gap: 12px; + justify-content: flex-end; +} + +.confirmDeleteButton { + background: #c33; + color: white; + border: 2px solid #c33; + padding: 10px 20px; + font-size: 0.95rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; +} + +.confirmDeleteButton:hover { + background: #a22; + border-color: #a22; +} + +.confirmDeleteButton:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +@media (max-width: 640px) { + .modalContent { + padding: 20px; + max-height: 95vh; + } + + .modalActions { + flex-direction: column; + align-items: stretch; + } + + .rightActions { + flex-direction: column; + } + + .deleteButton, + .cancelButton, + .saveButton, + .confirmDeleteButton { + width: 100%; + } +} + diff --git a/src/components/EditFlushModal.tsx b/src/components/EditFlushModal.tsx new file mode 100644 index 0000000..d2a23c1 --- /dev/null +++ b/src/components/EditFlushModal.tsx @@ -0,0 +1,208 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import styles from './EditFlushModal.module.css'; +import { containsBannedWords, sanitizeText, isAllowedEmoji } from '@/lib/content-filter'; + +interface EditFlushModalProps { + isOpen: boolean; + flushData: { + uri: string; + text: string; + emoji: string; + created_at: string; + } | null; + onSave: (text: string, emoji: string) => Promise; + onDelete: () => Promise; + onClose: () => void; +} + +// Define approved emojis list +const APPROVED_EMOJIS = [ + '🚽', '🧻', '💩', '💨', '🚾', '🧼', '🪠', '🚻', '🩸', '💧', '💦', '😌', + '😣', '🤢', '🤮', '🥴', '😮‍💨', '😳', '😵', '🌾', '🍦', '📱', '📖', '💭', + '1️⃣', '2️⃣', '🟡', '🟤' +]; + +export default function EditFlushModal({ isOpen, flushData, onSave, onDelete, onClose }: EditFlushModalProps) { + const [text, setText] = useState(''); + const [selectedEmoji, setSelectedEmoji] = useState('🚽'); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + + // Update form when flushData changes + useEffect(() => { + if (flushData) { + setText(flushData.text || ''); + setSelectedEmoji(flushData.emoji || '🚽'); + setError(null); + setShowDeleteConfirm(false); + } + }, [flushData]); + + if (!isOpen || !flushData) return null; + + const handleSave = async () => { + setError(null); + + // Validate text + if (containsBannedWords(text)) { + setError('Uh oh, looks like you have a potty mouth. Try again with cleaner language please...'); + return; + } + + // Check character limit + if (text.length > 59) { + setError('Your flush status is too long! Please keep it under 59 characters.'); + return; + } + + // Validate emoji + if (!isAllowedEmoji(selectedEmoji)) { + setError('Please select a valid emoji from the list.'); + return; + } + + setIsSubmitting(true); + try { + await onSave(sanitizeText(text), selectedEmoji); + onClose(); + } catch (err: any) { + console.error('Error updating flush:', err); + setError(err.message || 'Failed to update flush. Please try again.'); + } finally { + setIsSubmitting(false); + } + }; + + const handleDelete = async () => { + setIsSubmitting(true); + setError(null); + try { + await onDelete(); + onClose(); + } catch (err: any) { + console.error('Error deleting flush:', err); + setError(err.message || 'Failed to delete flush. Please try again.'); + } finally { + setIsSubmitting(false); + setShowDeleteConfirm(false); + } + }; + + const handleBackdropClick = (e: React.MouseEvent) => { + if (e.target === e.currentTarget && !isSubmitting) { + onClose(); + } + }; + + return ( +
+
+
+

Edit Your Flush

+ +
+ + {error && ( +
+ {error} +
+ )} + +
+ + setText(e.target.value)} + placeholder="is flushing" + maxLength={59} + disabled={isSubmitting} + className={styles.textInput} + /> +
+ {text.length}/59 +
+
+ +
+ +
+ {APPROVED_EMOJIS.map((emoji) => ( + + ))} +
+
+ +
+ {!showDeleteConfirm ? ( + <> + +
+ + +
+ + ) : ( +
+

Are you sure you want to delete this flush? This cannot be undone.

+
+ + +
+
+ )} +
+
+
+ ); +} + diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 09794ac..878e120 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -70,4 +70,96 @@ export async function createPost(session: OAuthSession, options: { console.error('Failed to create post:', error); throw error; } +} + +// Delete a flush record +export async function deleteFlushRecord(session: OAuthSession, recordUri: string) { + if (typeof window === 'undefined') { + throw new Error('API client can only be used on the client side'); + } + + try { + console.log('Deleting flush record:', recordUri); + + // Create an Agent instance using the OAuth session + const agent = new Agent(session); + + // Parse the AT URI to extract repo, collection, and rkey + // Format: at://did:plc:xxx/collection.name/rkey + const uriParts = recordUri.replace('at://', '').split('/'); + if (uriParts.length !== 3) { + throw new Error('Invalid record URI format'); + } + + const [repo, collection, rkey] = uriParts; + + console.log('Deleting record:', { repo, collection, rkey }); + + // Delete the record + const result = await agent.api.com.atproto.repo.deleteRecord({ + repo, + collection, + rkey + }); + + console.log('Record deleted successfully'); + return result; + } catch (error) { + console.error('Failed to delete record:', error); + throw error; + } +} + +// Update a flush record using putRecord +export async function updateFlushRecord( + session: OAuthSession, + recordUri: string, + text: string, + emoji: string, + originalCreatedAt?: string +) { + if (typeof window === 'undefined') { + throw new Error('API client can only be used on the client side'); + } + + try { + console.log('Updating flush record:', recordUri); + + // Create an Agent instance using the OAuth session + const agent = new Agent(session); + + // Parse the AT URI + const uriParts = recordUri.replace('at://', '').split('/'); + if (uriParts.length !== 3) { + throw new Error('Invalid record URI format'); + } + + const [repo, collection, rkey] = uriParts; + + console.log('Updating record:', { repo, collection, rkey }); + + // Create the updated record + const updatedRecord = { + $type: 'im.flushing.right.now', + text, + emoji, + createdAt: originalCreatedAt || new Date().toISOString(), + }; + + console.log('Updated record data:', updatedRecord); + + // Update the record using putRecord + const result = await agent.api.com.atproto.repo.putRecord({ + repo, + collection, + rkey, + record: updatedRecord + }); + + console.log('Record updated successfully'); + return result; + } catch (error) { + console.error('Failed to update record:', error); + throw error; + } } \ No newline at end of file