diff --git a/app/(tabs)/library/exercises.tsx b/app/(tabs)/library/exercises.tsx index be77ec4..79fddba 100644 --- a/app/(tabs)/library/exercises.tsx +++ b/app/(tabs)/library/exercises.tsx @@ -11,6 +11,7 @@ import { ExerciseDetails } from '@/components/exercises/ExerciseDetails'; import { ExerciseDisplay, ExerciseType, BaseExercise, Equipment } from '@/types/exercise'; import { useExercises } from '@/lib/hooks/useExercises'; import { FilterSheet, type FilterOptions, type SourceType } from '@/components/library/FilterSheet'; +import { useWorkoutStore } from '@/stores/workoutStore'; // Default available filters const availableFilters = { @@ -33,6 +34,8 @@ export default function ExercisesScreen() { const [filterSheetOpen, setFilterSheetOpen] = useState(false); const [currentFilters, setCurrentFilters] = useState(initialFilters); const [activeFilters, setActiveFilters] = useState(0); + const { isActive, isMinimized } = useWorkoutStore(); + const shouldShowFAB = !isActive || !isMinimized; const { exercises, @@ -167,10 +170,12 @@ export default function ExercisesScreen() { )} {/* FAB for adding new exercise */} - setShowNewExercise(true)} - /> + {shouldShowFAB && ( + setShowNewExercise(true)} + /> + )} {/* New exercise sheet */} (null); // Use the NDK hooks const { ndk, isLoading: ndkLoading } = useNDK(); @@ -269,45 +268,9 @@ export default function ProgramsScreen() { setIsLoginSheetOpen(true); }; - // Close login sheet const handleCloseLogin = () => { setIsLoginSheetOpen(false); }; - - // Handle key generation - const handleGenerateKeys = async () => { - try { - const { nsec } = generateKeys(); - setPrivateKey(nsec); - setError(null); - } catch (err) { - setError('Failed to generate keys'); - console.error('Key generation error:', err); - } - }; - - // Handle login - const handleLogin = async () => { - if (!privateKey.trim()) { - setError('Please enter your private key or generate a new one'); - return; - } - - setError(null); - try { - const success = await login(privateKey); - - if (success) { - setPrivateKey(''); - handleCloseLogin(); - } else { - setError('Failed to login with the provided key'); - } - } catch (err) { - console.error('Login error:', err); - setError(err instanceof Error ? err.message : 'An unexpected error occurred'); - } - }; // Handle logout const handleLogout = async () => { @@ -735,74 +698,11 @@ export default function ProgramsScreen() { {/* Login Modal */} - - - - - Login with Nostr - - - - - - - Enter your Nostr private key (nsec) - - - {error && ( - - {error} - - )} - - - - - - - - - - - What is a Nostr Key? - - - Nostr is a decentralized protocol where your private key (nsec) is your identity and password. - Your private key is securely stored on your device and is never sent to any servers. - - - - - - + + {/* Create Event */} diff --git a/app/(tabs)/library/templates.tsx b/app/(tabs)/library/templates.tsx index 7d5522c..6f8638c 100644 --- a/app/(tabs)/library/templates.tsx +++ b/app/(tabs)/library/templates.tsx @@ -71,6 +71,8 @@ export default function TemplatesScreen() { const [filterSheetOpen, setFilterSheetOpen] = useState(false); const [currentFilters, setCurrentFilters] = useState(initialFilters); const [activeFilters, setActiveFilters] = useState(0); + const { isActive, isMinimized } = useWorkoutStore(); + const shouldShowFAB = !isActive || !isMinimized; const handleDelete = (id: string) => { setTemplates(current => current.filter(t => t.id !== id)); @@ -266,10 +268,12 @@ export default function TemplatesScreen() { - setShowNewTemplate(true)} - /> + {shouldShowFAB && ( + setShowNewTemplate(true)} + /> + )} ([]); const [selectedIds, setSelectedIds] = useState([]); const [search, setSearch] = useState(''); + const [isNewExerciseSheetOpen, setIsNewExerciseSheetOpen] = useState(false); const insets = useSafeAreaInsets(); const { addExercises } = useWorkoutStore(); @@ -59,76 +61,128 @@ export default function AddExercisesScreen() { router.back(); }; + const handleNewExerciseSubmit = (exercise: BaseExercise) => { + // Add to exercises list + setExercises(prev => [exercise, ...prev]); + // Auto-select the new exercise + setSelectedIds(prev => [...prev, exercise.id]); + }; + + // Purple color used throughout the app + const purpleColor = 'hsl(261, 90%, 66%)'; + return ( - - {/* Standard header with back button */} - - - Add Exercises + + {/* Header with back button */} + + + + Add Exercises + + + + + {selectedIds.length} selected + + + + {/* Search input */} - + + + + + + - - - Selected: {selectedIds.length} exercises - - + - {filteredExercises.map(exercise => ( - - - - - {exercise.title} - {exercise.category} - {exercise.equipment && ( - {exercise.equipment} - )} - - - - - - ))} + {filteredExercises.map(exercise => { + const isSelected = selectedIds.includes(exercise.id); + return ( + handleToggleSelection(exercise.id)} + activeOpacity={0.7} + > + + + + + + {exercise.title} + + + {exercise.category} + {exercise.equipment && ( + • {exercise.equipment} + )} + + + + + + + ); + })} + + {filteredExercises.length === 0 && ( + + No exercises found + + )} - + {/* Action button with proper safe area padding */} + + + {/* New Exercise Sheet */} + setIsNewExerciseSheetOpen(false)} + onSubmit={handleNewExerciseSubmit} + /> ); diff --git a/app/(workout)/create.tsx b/app/(workout)/create.tsx index 1a1105a..106a167 100644 --- a/app/(workout)/create.tsx +++ b/app/(workout)/create.tsx @@ -1,11 +1,10 @@ // app/(workout)/create.tsx import React, { useState, useEffect } from 'react'; -import { View, ScrollView, StyleSheet } from 'react-native'; +import { View, ScrollView, StyleSheet, TouchableOpacity } from 'react-native'; import { router, useNavigation } from 'expo-router'; import { TabScreen } from '@/components/layout/TabScreen'; import { Text } from '@/components/ui/text'; import { Button } from '@/components/ui/button'; -import { Card, CardContent } from '@/components/ui/card'; import { AlertDialog, AlertDialogAction, @@ -26,13 +25,7 @@ import { formatTime } from '@/utils/formatTime'; import { ParamListBase } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import SetInput from '@/components/workout/SetInput'; - -// Define styles outside of component -const styles = StyleSheet.create({ - timerText: { - fontVariant: ['tabular-nums'] - } -}); +import { useColorScheme } from '@/lib/useColorScheme'; export default function CreateWorkoutScreen() { const { @@ -55,6 +48,84 @@ export default function CreateWorkoutScreen() { maximizeWorkout } = useWorkoutStore.getState(); + // Get theme colors + const { isDarkColorScheme } = useColorScheme(); + + // Create dynamic styles based on theme + const dynamicStyles = StyleSheet.create({ + timerText: { + fontVariant: ['tabular-nums'] + }, + cardContainer: { + marginBottom: 24, + backgroundColor: isDarkColorScheme ? '#1F1F23' : 'white', + borderRadius: 8, + borderWidth: 1, + borderColor: isDarkColorScheme ? '#333' : '#eee', + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.2, + shadowRadius: 2, + elevation: 2, + }, + cardHeader: { + padding: 16, + flexDirection: 'row', + justifyContent: 'space-between', + borderBottomWidth: 1, + borderBottomColor: isDarkColorScheme ? '#333' : '#eee' + }, + cardTitle: { + fontSize: 18, + fontWeight: 'bold', + color: '#8B5CF6' // Purple is used in both themes + }, + setsInfo: { + paddingHorizontal: 16, + paddingVertical: 4 + }, + setsInfoText: { + fontSize: 14, + color: isDarkColorScheme ? '#999' : '#666' + }, + headerRow: { + flexDirection: 'row', + paddingHorizontal: 16, + paddingVertical: 4, + borderTopWidth: 1, + borderTopColor: isDarkColorScheme ? '#333' : '#eee', + backgroundColor: isDarkColorScheme ? 'rgba(255,255,255,0.03)' : 'rgba(0,0,0,0.03)' + }, + headerCell: { + fontSize: 14, + fontWeight: '500', + color: isDarkColorScheme ? '#999' : '#666', + textAlign: 'center' + }, + setNumberCell: { + width: 32 + }, + prevCell: { + width: 80 + }, + valueCell: { + flex: 1 + }, + spacer: { + width: 44 + }, + setsList: { + padding: 0 + }, + actionButton: { + borderTopWidth: 1, + borderTopColor: isDarkColorScheme ? '#333' : '#eee' + }, + iconColor: { + color: isDarkColorScheme ? '#999' : '#666' + } + }); + type CreateScreenNavigationProp = NativeStackNavigationProp; const navigation = useNavigation(); @@ -165,7 +236,9 @@ export default function CreateWorkoutScreen() { variant="outline" onPress={() => useWorkoutStore.getState().extendRest(30)} > - + + + Add 30s @@ -190,7 +263,9 @@ export default function CreateWorkoutScreen() { router.back(); }} > - + + + Back @@ -220,7 +295,7 @@ export default function CreateWorkoutScreen() { {/* Timer Display */} - @@ -234,7 +309,9 @@ export default function CreateWorkoutScreen() { className="ml-2" onPress={pauseWorkout} > - + + + ) : ( )} @@ -262,45 +341,37 @@ export default function CreateWorkoutScreen() { // Exercise List when exercises exist <> {activeWorkout.exercises.map((exercise, exerciseIndex) => ( - + {/* Exercise Header */} - - + + {exercise.title} - + console.log('Open exercise options')}> + + + + {/* Sets Info */} - - + + {exercise.sets.filter(s => s.isCompleted).length} sets completed {/* Set Headers */} - - SET - PREV - KG - REPS - {/* Space for the checkmark/complete button */} + + SET + PREV + KG + REPS + {/* Exercise Sets */} - + {exercise.sets.map((set, setIndex) => { const previousSet = setIndex > 0 ? exercise.sets[setIndex - 1] : undefined; @@ -317,18 +388,22 @@ export default function CreateWorkoutScreen() { /> ); })} - + {/* Add Set Button */} - - + + + + ))} {/* Add Exercises Button */} @@ -352,7 +427,9 @@ export default function CreateWorkoutScreen() { ) : ( // Empty State with nice message and icon - + + + No exercises added diff --git a/components/library/NewExerciseSheet.tsx b/components/library/NewExerciseSheet.tsx index 75dcd82..0388866 100644 --- a/components/library/NewExerciseSheet.tsx +++ b/components/library/NewExerciseSheet.tsx @@ -1,6 +1,6 @@ // components/library/NewExerciseSheet.tsx import React, { useState } from 'react'; -import { View, ScrollView } from 'react-native'; +import { View, ScrollView, KeyboardAvoidingView, Platform, TouchableWithoutFeedback, Keyboard } from 'react-native'; import { Text } from '@/components/ui/text'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; @@ -106,96 +106,124 @@ export function NewExerciseSheet({ isOpen, onClose, onSubmit }: NewExerciseSheet onClose(); }; + // Purple color used throughout the app + const purpleColor = 'hsl(261, 90%, 66%)'; + return ( - - New Exercise - - - - - Exercise Name - setFormData(prev => ({ ...prev, title: text }))} - placeholder="e.g., Barbell Back Squat" - className="text-foreground" - /> - + + + + + Create New Exercise + + + + + Exercise Name + setFormData(prev => ({ ...prev, title: text }))} + placeholder="e.g., Barbell Back Squat" + className="text-foreground" + /> + {!formData.title && ( + + * Required field + + )} + - - Type - - {EXERCISE_TYPES.map((type) => ( - + ))} + + + + + Category + + {CATEGORIES.map((category) => ( + + ))} + + + + + Equipment + + {EQUIPMENT_OPTIONS.map((eq) => ( + + ))} + + {!formData.equipment && ( + + * Required field + + )} + + + + Description + setFormData(prev => ({ ...prev, description: text }))} + placeholder="Exercise description..." + multiline + numberOfLines={4} + textAlignVertical="top" + className="min-h-24 py-2" + /> + + + - ))} - + + - - - Category - - {CATEGORIES.map((category) => ( - - ))} - - - - - Equipment - - {EQUIPMENT_OPTIONS.map((eq) => ( - - ))} - - - - - Description - setFormData(prev => ({ ...prev, description: text }))} - placeholder="Exercise description..." - multiline - numberOfLines={4} - /> - - - - - + + ); diff --git a/components/library/NewTemplateSheet.tsx b/components/library/NewTemplateSheet.tsx index 6ec7770..09aaed6 100644 --- a/components/library/NewTemplateSheet.tsx +++ b/components/library/NewTemplateSheet.tsx @@ -17,7 +17,7 @@ import { ExerciseDisplay } from '@/types/exercise'; import { generateId } from '@/utils/ids'; import { useSQLiteContext } from 'expo-sqlite'; import { LibraryService } from '@/lib/db/services/LibraryService'; -import { ChevronLeft, ChevronRight, Dumbbell, Clock, RotateCw, List } from 'lucide-react-native'; +import { ChevronLeft, ChevronRight, Dumbbell, Clock, RotateCw, List, Search } from 'lucide-react-native'; interface NewTemplateSheetProps { isOpen: boolean; @@ -28,6 +28,9 @@ interface NewTemplateSheetProps { // Steps in template creation type CreationStep = 'type' | 'info' | 'exercises' | 'config' | 'review'; +// Purple color used throughout the app +const purpleColor = 'hsl(261, 90%, 66%)'; + // Step 0: Workout Type Selection interface WorkoutTypeStepProps { onSelectType: (type: TemplateType) => void; @@ -78,9 +81,10 @@ function WorkoutTypeStep({ onSelectType, onCancel }: WorkoutTypeStepProps) { onSelectType(workout.type)} className="flex-row justify-between items-center" + activeOpacity={0.7} > - + {workout.title} @@ -89,7 +93,7 @@ function WorkoutTypeStep({ onSelectType, onCancel }: WorkoutTypeStepProps) { - + ) : ( @@ -114,7 +118,7 @@ function WorkoutTypeStep({ onSelectType, onCancel }: WorkoutTypeStepProps) { ))} - @@ -157,6 +161,11 @@ function BasicInfoStep({ placeholder="e.g., Full Body Strength" className="text-foreground" /> + {!title && ( + + * Required field + + )} @@ -166,7 +175,8 @@ function BasicInfoStep({ onChangeText={onDescriptionChange} placeholder="Describe this workout..." numberOfLines={4} - className="bg-input placeholder:text-muted-foreground" + className="bg-input placeholder:text-muted-foreground min-h-24" + textAlignVertical="top" /> @@ -178,8 +188,9 @@ function BasicInfoStep({ key={cat} variant={category === cat ? 'default' : 'outline'} onPress={() => onCategoryChange(cat)} + style={category === cat ? { backgroundColor: purpleColor } : {}} > - + {cat} @@ -191,8 +202,12 @@ function BasicInfoStep({ - @@ -236,12 +251,17 @@ function ExerciseSelectionStep({ return ( - + + + + + + @@ -252,27 +272,43 @@ function ExerciseSelectionStep({ {filteredExercises.map(exercise => ( - - - - {exercise.title} - {exercise.category} - {exercise.equipment && ( - {exercise.equipment} - )} + handleToggleSelection(exercise.id)} + activeOpacity={0.7} + > + + + + {exercise.title} + {exercise.category} + {exercise.equipment && ( + {exercise.equipment} + )} + + - - + ))} + + {filteredExercises.length === 0 && ( + + No exercises found + + )} @@ -284,8 +320,9 @@ function ExerciseSelectionStep({ @@ -313,7 +350,7 @@ function ExerciseConfigStep({ return ( - + {exercises.map((exercise, index) => ( {exercise.title} @@ -357,8 +394,11 @@ function ExerciseConfigStep({ - @@ -427,8 +467,11 @@ function ReviewStep({ - diff --git a/components/sheets/NostrLoginSheet.tsx b/components/sheets/NostrLoginSheet.tsx index f48da05..8888355 100644 --- a/components/sheets/NostrLoginSheet.tsx +++ b/components/sheets/NostrLoginSheet.tsx @@ -1,11 +1,12 @@ // components/sheets/NostrLoginSheet.tsx import React, { useState } from 'react'; -import { Modal, View, StyleSheet, Platform, KeyboardAvoidingView, ScrollView, ActivityIndicator, TouchableOpacity } from 'react-native'; -import { Info, X } from 'lucide-react-native'; +import { View, ActivityIndicator, Modal, TouchableOpacity } from 'react-native'; import { Text } from '@/components/ui/text'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; +import { X, Info } from 'lucide-react-native'; import { useNDKAuth } from '@/lib/hooks/useNDK'; +import { useColorScheme } from '@/lib/useColorScheme'; interface NostrLoginSheetProps { open: boolean; @@ -16,6 +17,7 @@ export default function NostrLoginSheet({ open, onClose }: NostrLoginSheetProps) const [privateKey, setPrivateKey] = useState(''); const [error, setError] = useState(null); const { login, generateKeys, isLoading } = useNDKAuth(); + const { isDarkColorScheme } = useColorScheme(); // Handle key generation const handleGenerateKeys = async () => { @@ -52,8 +54,6 @@ export default function NostrLoginSheet({ open, onClose }: NostrLoginSheetProps) } }; - if (!open) return null; - return ( - - - - Login with Nostr - + + + + Login with Nostr + - - - - Enter your Nostr private key (nsec) - - - {error && ( - - {error} - - )} - - - - - - - - - - - What is a Nostr Key? - - - Nostr is a decentralized protocol where your private key (nsec) is your identity and password. - - - Your private key is securely stored on your device and is never sent to any servers. - - + + Enter your Nostr private key (nsec) + + + {error && ( + + {error} - - + )} + + + + + + + + + + + What is a Nostr Key? + + + Nostr is a decentralized protocol where your private key (nsec) is your identity and password. + Your private key is securely stored on your device and is never sent to any servers. + + + ); -} - -const styles = StyleSheet.create({ - modalOverlay: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - backgroundColor: 'rgba(0, 0, 0, 0.5)', - }, - modalContent: { - width: '90%', - maxWidth: 500, - backgroundColor: '#FFFFFF', - borderRadius: 12, - padding: 20, - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.25, - shadowRadius: 3.84, - elevation: 5, - }, - header: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - marginBottom: 15, - }, - title: { - fontSize: 18, - fontWeight: 'bold', - }, - container: { - maxHeight: '80%', - }, - scrollView: { - flex: 1, - }, - content: { - padding: 10, - }, - loader: { - marginRight: 8, - } -}); \ No newline at end of file +} \ No newline at end of file diff --git a/docs/design/Social/SocialDesignDocument.md b/docs/design/Social/SocialDesignDocument.md index 341f048..f96d512 100644 --- a/docs/design/Social/SocialDesignDocument.md +++ b/docs/design/Social/SocialDesignDocument.md @@ -8,6 +8,7 @@ POWR needs to integrate social features that leverage the Nostr protocol while m ### Functional Requirements - Custom Nostr event types for exercises, workout templates, and workout records - Social sharing of workout content via NIP-19 references +- Content management including deletion requests - Comment system on exercises, templates, and workout records - Reactions and likes on shared content - App discovery through NIP-89 handlers @@ -27,7 +28,7 @@ POWR needs to integrate social features that leverage the Nostr protocol while m ## Design Decisions ### 1. Custom Event Kinds vs. Standard Kinds -**Approach**: Use custom event kinds (33401, 33402, 33403) for exercises, templates, and workout records rather than generic kind 1 events. +**Approach**: Use custom event kinds (33401, 33402, 1301) for exercises, templates, and workout records rather than generic kind 1 events. **Rationale**: - Custom kinds enable clear data separation and easier filtering @@ -86,6 +87,21 @@ POWR needs to integrate social features that leverage the Nostr protocol while m - Dependency on external wallet implementations - Requires careful error handling for payment flows +### 5. Content Publishing and Deletion Workflow +**Approach**: Implement a three-tier approach to content sharing with NIP-09 deletion requests. + +**Rationale**: +- Gives users control over content visibility +- Maintains local-first philosophy +- Provides clear separation between private and public data +- Follows Nostr standards for content management +- Enables social sharing while maintaining specialized data format + +**Trade-offs**: +- Deletion on Nostr is not guaranteed across all relays +- Additional UI complexity to explain publishing/deletion states +- Need to track content state across local storage and relays + ## Technical Design ### Core Components @@ -124,9 +140,9 @@ interface WorkoutTemplate extends NostrEvent { ] } -// Workout Record Event (Kind 33403) +// Workout Record Event (Kind 1301) interface WorkoutRecord extends NostrEvent { - kind: 33403; + kind: 1301; content: string; // Workout notes tags: [ ["d", string], // Unique identifier @@ -144,6 +160,34 @@ interface WorkoutRecord extends NostrEvent { ] } +// Social Share (Kind 1) +interface SocialShare extends NostrEvent { + kind: 1; + content: string; // Social post text + tags: [ + // Quote reference to the exercise, template or workout + ["q", string, string, string], // event-id, relay-url, pubkey + // Mention author's pubkey + ["p", string], // pubkey of the event creator + // App handler registration (NIP-89) + ["client", string, string, string] // Name, 31990 reference, relay-url + ] +} + +// Deletion Request (Kind 5) - NIP-09 +interface DeletionRequest extends NostrEvent { + kind: 5; + content: string; // Reason for deletion (optional) + tags: [ + // Event reference(s) to delete + ["e", string], // event-id(s) to delete + // Or addressable event reference + ["a", string], // "::" + // Kind of the event being deleted + ["k", string] // kind number as string + ] +} + // Comment (Kind 1111 - as per NIP-22) interface WorkoutComment extends NostrEvent { kind: 1111; @@ -151,7 +195,7 @@ interface WorkoutComment extends NostrEvent { tags: [ // Root reference (exercise, template, or record) ["e", string, string, string], // id, relay, marker "root" - ["K", string], // Root kind (33401, 33402, or 33403) + ["K", string], // Root kind (33401, 33402, or 1301) ["P", string, string], // Root pubkey, relay // Parent comment (for replies) @@ -178,7 +222,7 @@ interface AppHandler extends NostrEvent { tags: [ ["k", "33401", "exercise-template"], ["k", "33402", "workout-template"], - ["k", "33403", "workout-record"], + ["k", "1301", "workout-record"], ["web", string], // App URL ["name", string], // App name ["description", string] // App description @@ -234,23 +278,49 @@ class SocialService { async reactToEvent( event: NostrEvent, reaction: "+" | "🔥" | "👍" - ): Promise { - const reactionEvent = { - kind: 7, - content: reaction, - tags: [ - ["e", event.id, ""], - ["p", event.pubkey] - ], - created_at: Math.floor(Date.now() / 1000) - }; - - // Sign and publish the reaction - return await this.ndk.publish(reactionEvent); - } + ): Promise; + + // Request deletion of event + async requestDeletion( + eventId: string, + eventKind: number, + reason?: string + ): Promise; + + // Request deletion of addressable event + async requestAddressableDeletion( + kind: number, + pubkey: string, + dTag: string, + reason?: string + ): Promise; } ``` +### Content Publishing Workflow + +```mermaid +graph TD + A[Create Content] --> B{Publish to Relays?} + B -->|No| C[Local Storage Only] + B -->|Yes| D[Save to Local Storage] + D --> E[Publish to Relays] + E --> F{Share Socially?} + F -->|No| G[Done - Content on Relays] + F -->|Yes| H[Create kind:1 Social Post] + H --> I[Reference Original Event] + I --> J[Done - Content Shared] + + K[Delete Content] --> L{Delete from Relays?} + L -->|No| M[Delete from Local Only] + L -->|Yes| N[Create kind:5 Deletion Request] + N --> O[Publish Deletion Request] + O --> P{Delete Locally?} + P -->|No| Q[Done - Deletion Requested] + P -->|Yes| R[Delete from Local Storage] + R --> S[Done - Content Deleted] +``` + ### Data Flow Diagram ```mermaid @@ -258,6 +328,7 @@ graph TD subgraph User A[Create Content] --> B[Local Storage] G[View Content] --> F[UI Components] + T[Request Deletion] --> U[Deletion Manager] end subgraph LocalStorage @@ -268,6 +339,7 @@ graph TD subgraph NostrNetwork D -->|Publish| E[Relays] E -->|Subscribe| F + U -->|Publish| E end subgraph SocialInteractions @@ -302,7 +374,7 @@ const templatesWithExerciseQuery = { // Find workout records for a specific template const workoutRecordsQuery = { - kinds: [33403], + kinds: [1301], "#template": [`33402:${pubkey}:${templateId}`] }; @@ -310,13 +382,13 @@ const workoutRecordsQuery = { const commentsQuery = { kinds: [1111], "#e": [workoutEventId], - "#K": ["33403"] // Root kind filter + "#K": ["1301"] // Root kind filter }; // Find social posts (kind 1) that reference our workout events const socialReferencesQuery = { kinds: [1], - "#e": [workoutEventId] + "#q": [workoutEventId] }; // Get reactions to a workout record @@ -325,100 +397,241 @@ const reactionsQuery = { "#e": [workoutEventId] }; -// Find popular templates based on usage count -async function findPopularTemplates() { - // First get all templates - const templates = await ndk.fetchEvents({ - kinds: [33402], - limit: 100 +// Find deletion requests for an event +const deletionRequestQuery = { + kinds: [5], + "#e": [eventId] +}; + +// Find deletion requests for an addressable event +const addressableDeletionRequestQuery = { + kinds: [5], + "#a": [`${kind}:${pubkey}:${dTag}`] +}; +``` + +## Event Publishing and Deletion Implementation + +### Publishing Workflow + +POWR implements a three-tier approach to content publishing: + +1. **Local Only** + - Content is saved only to the device's local storage + - No Nostr events are published + - Content is completely private to the user + +2. **Publish to Relays** + - Content is saved locally and published to user-selected relays + - Published as appropriate Nostr events (33401, 33402, 1301) + - Content becomes discoverable by compatible apps via NIP-89 + - Local copy is marked as "published to relays" + +3. **Social Sharing** + - Content is published to relays as in step 2 + - Additionally, a kind:1 social post is created + - The social post quotes the specialized content + - Makes content visible in standard Nostr social clients + - Links back to the specialized content via NIP-19 references + +### Deletion Workflow + +POWR implements NIP-09 for deletion requests: + +1. **Local Deletion** + - Content is removed from local storage only + - No effect on previously published relay content + - User maintains control over local data independent of relay status + +2. **Relay Deletion Request** + - Creates a kind:5 deletion request event + - References the content to be deleted + - Includes the kind of content being deleted + - Published to relays that had the original content + - Original content may remain in local storage if desired + +3. **Complete Deletion** + - Combination of local deletion and relay deletion request + - Content is removed locally and requested for deletion from relays + - Any social shares remain unless specifically deleted + +### Example Implementation + +```typescript +// Publishing Content +async function publishExerciseTemplate(exercise) { + // Save locally first + const localId = await localDb.saveExercise(exercise); + + // If user wants to publish to relays + if (exercise.publishToRelays) { + // Create Nostr event + const event = { + kind: 33401, + pubkey: userPubkey, + created_at: Math.floor(Date.now() / 1000), + tags: [ + ["d", localId], + ["title", exercise.title], + ["format", ...Object.keys(exercise.format)], + ["format_units", ...formatUnitsToArray(exercise.format_units)], + ["equipment", exercise.equipment], + ...exercise.tags.map(tag => ["t", tag]) + ], + content: exercise.description || "" + }; + + // Sign and publish + event.id = getEventHash(event); + event.sig = signEvent(event, userPrivkey); + await publishToRelays(event); + + // Update local record to reflect published status + await localDb.markAsPublished(localId, event.id); + + // If user wants to share socially + if (exercise.shareAsSocialPost) { + await createSocialShare(event, exercise.socialShareText || "Check out this exercise!"); + } + + return { localId, eventId: event.id }; + } + + return { localId }; +} + +// Requesting Deletion +async function requestDeletion(eventId, eventKind, options = {}) { + const { deleteLocally = false, reason = "" } = options; + + // Create deletion request + const deletionRequest = { + kind: 5, + pubkey: userPubkey, + created_at: Math.floor(Date.now() / 1000), + tags: [ + ["e", eventId], + ["k", eventKind.toString()] + ], + content: reason + }; + + // Sign and publish + deletionRequest.id = getEventHash(deletionRequest); + deletionRequest.sig = signEvent(deletionRequest, userPrivkey); + await publishToRelays(deletionRequest); + + // Update local storage + await localDb.markAsDeletedFromRelays(eventId); + + // Delete locally if requested + if (deleteLocally) { + await localDb.deleteContentLocally(eventId); + } + + return deletionRequest; +} + +// Request deletion of addressable event +async function requestAddressableDeletion(kind, pubkey, dTag, options = {}) { + const { deleteLocally = false, reason = "" } = options; + + const deletionRequest = { + kind: 5, + pubkey: userPubkey, + created_at: Math.floor(Date.now() / 1000), + tags: [ + ["a", `${kind}:${pubkey}:${dTag}`], + ["k", kind.toString()] + ], + content: reason + }; + + // Sign and publish + deletionRequest.id = getEventHash(deletionRequest); + deletionRequest.sig = signEvent(deletionRequest, userPrivkey); + await publishToRelays(deletionRequest); + + // Update local storage + await localDb.markAddressableEventAsDeletedFromRelays(kind, pubkey, dTag); + + // Delete locally if requested + if (deleteLocally) { + await localDb.deleteAddressableContentLocally(kind, pubkey, dTag); + } + + return deletionRequest; +} + +// Check for deletion requests when viewing content +async function checkDeletionStatus(eventId) { + const deletionRequests = await ndk.fetchEvents({ + kinds: [5], + "#e": [eventId] }); - // Then count associated workout records for each - const templateCounts = await Promise.all( - templates.map(async (template) => { - const dTag = template.tags.find(t => t[0] === 'd')?.[1]; - if (!dTag) return { template, count: 0 }; - - const records = await ndk.fetchEvents({ - kinds: [33403], - "#template": [`33402:${template.pubkey}:${dTag}`] - }); - - return { - template, - count: records.length - }; - }) - ); + for (const request of deletionRequests) { + // Verify the deletion request is from the original author + if (request.pubkey === event.pubkey) { + return { isDeleted: true, request }; + } + } - // Sort by usage count - return templateCounts.sort((a, b) => b.count - a.count); + return { isDeleted: false }; } ``` -### Integration Points +## User Interface Design -#### Nostr Protocol Integration -- NDK for Nostr event management and relay communication -- NIP-19 for nevent URL encoding/decoding -- NIP-22 for comment threading -- NIP-25 for reactions and likes -- NIP-47 for Nostr Wallet Connect -- NIP-57 for zaps -- NIP-89 for app handler registration +### Content Status Indicators -#### Application Integration -- **Library Screen**: - - Displays social metrics on exercise/template cards (usage count, likes, comments) - - Filters for trending/popular content - - Visual indicators for content source (local, POWR, Nostr) +The UI should clearly indicate the status of fitness content: -- **Detail Screens**: - - Shows comments and reactions on exercises, templates, and workout records - - Displays creator information with follow option - - Presents usage statistics and popularity metrics - - Provides zap/tip options for content creators +1. **Local Only** + - Visual indicator showing content is only on device + - Options to publish to relays or share socially -- **Profile Screen**: - - Displays user's created workouts, templates, and exercises - - Shows workout history and statistics visualization - - Presents achievements, PRs, and milestone tracking - - Includes user's social activity (comments, reactions) - - Provides analytics dashboard of workout progress and consistency +2. **Published to Relays** + - Indicator showing content is published + - Display relay publishing status + - Option to create social share -- **Settings Screen**: - - Nostr Wallet Connect management - - Relay configuration and connection management - - Social preferences (public/private sharing defaults) - - Notification settings for social interactions - - Mute and content filtering options - - Profile visibility and privacy controls +3. **Socially Shared** + - Indicator showing content has been shared socially + - Link to view social post + - Stats on social engagement (comments, reactions) -- **Share Sheet**: - - Social sharing interface for workout records and achievements - - Options for including stats, images, or workout summaries - - Relay selection for content publishing - - Privacy option to share publicly or to specific relays only +4. **Deletion Requested** + - Indicator showing deletion has been requested + - Option to delete locally if not already done + - Explanation that deletion from all relays cannot be guaranteed -- **Comment UI**: - - Thread-based comment creation and display - - Reply functionality with proper nesting - - Reaction options with count displays - - Comment filtering and sorting options +### Deletion Interface -#### External Dependencies -- SQLite for local storage -- NDK (Nostr Development Kit) for Nostr integration -- NWC libraries for wallet connectivity -- Lightning payment providers +The UI for deletion should be clear and informative: + +1. **Deletion Options** + - "Delete Locally" - Removes from device only + - "Request Deletion from Relays" - Issues NIP-09 deletion request + - "Delete Completely" - Both local and relay deletion + +2. **Confirmation Dialog** + - Clear explanation of deletion scope + - Warning that relay deletion is not guaranteed + - Option to provide reason for deletion (for relay requests) + +3. **Deletion Status** + - Visual indicator for content with deletion requests + - Option to view deletion request details + - Ability to check status across relays ## Implementation Plan ### Phase 1: Core Nostr Event Structure -1. Implement custom event kinds (33401, 33402, 33403) -2. Create event validation and processing functions -3. Build local-first storage with Nostr event structure -4. Develop basic event publishing to relays +1. Implement custom event kinds (33401, 33402, 1301) +2. Create local storage schema with publishing status tracking +3. Build basic event publishing to relays +4. Implement NIP-09 deletion requests ### Phase 2: Social Interaction Foundation 1. Implement NIP-22 comments system @@ -445,74 +658,26 @@ async function findPopularTemplates() { ### Unit Tests - Event validation and processing tests +- Deletion request handling tests - Comment threading logic tests - Wallet connection management tests - Relay communication tests - Social share URL generation tests ### Integration Tests -- End-to-end comment flow testing -- Reaction and like functionality testing +- End-to-end publishing flow testing +- Deletion request workflow testing +- Comment and reaction functionality testing - Template usage tracking tests - Social sharing workflow tests - Zap flow testing -- Cross-client compatibility testing ### User Testing -- Usability of social sharing flows -- Clarity of comment interfaces +- Usability of publishing and deletion workflows +- Clarity of content status indicators - Wallet connection experience - Performance on different devices and connection speeds -## Observability - -### Logging -- Social event publishing attempts and results -- Relay connection status -- Comment submission success/failure -- Wallet connection events -- Payment attempts and results - -### Metrics -- Template popularity (usage counts) -- Comment engagement rates -- Social sharing frequency -- Zaps received/sent -- Relay response times -- Offline content creation stats - -## Future Considerations - -### Potential Enhancements -- Group fitness challenges with bounties -- Subscription model for premium content -- Coaching marketplace with Lightning payments -- Team workout coordination -- Custom fitness community creation -- AI-powered workout recommendations based on social data - -### Known Limitations -- Reliance on external Lightning wallets -- Comment moderation limited to client-side filtering -- Content discovery dependent on relay availability -- Limited backward compatibility with generic Nostr clients - -## Dependencies - -### Runtime Dependencies -- NDK (Nostr Development Kit) -- SQLite database -- Nostr relay connections -- Lightning network (for zaps) -- NWC-compatible wallets - -### Development Dependencies -- TypeScript -- React Native -- Expo -- Jest for testing -- NativeWind for styling - ## Security Considerations - Never store or request user private keys - Secure management of NWC connection secrets @@ -521,61 +686,38 @@ async function findPopularTemplates() { - User control over content visibility - Protection against spam and abuse -### Privacy Control Mechanisms - -The application implements several layers of privacy controls: - -1. **Publication Controls**: - - Per-content privacy settings (public, followers-only, private) - - Relay selection for each published event - - Option to keep all workout data local-only - -2. **Content Visibility**: - - Anonymous workout publishing (remove identifying data) - - Selective stat sharing (choose which metrics to publish) - - Time-delayed publishing (share workouts after a delay) - -3. **Technical Mechanisms**: - - Local-first storage ensures all data is usable offline - - Content encryption for sensitive information (using NIP-44) - - Private relay support for limited audience sharing - - Event expiration tags for temporary content - -4. **User Interface**: - - Clear visual indicators for public vs. private content - - Confirmation dialogs before publishing to relays - - Privacy setting presets (public account, private account, mixed) - - Granular permission controls for different content types - ## Rollout Strategy ### Development Phase 1. Implement custom event kinds and validation -2. Create UI components for social interactions +2. Create UI components for content publishing status 3. Develop local-first storage with Nostr sync -4. Build and test commenting system +4. Build and test deletion request functionality 5. Implement wallet connection interface 6. Add documentation for Nostr integration ### Beta Testing 1. Release to limited test group 2. Monitor relay performance and sync issues -3. Gather feedback on social interaction flows +3. Gather feedback on publishing and deletion flows 4. Test cross-client compatibility 5. Evaluate Lightning payment reliability ### Production Deployment 1. Deploy app handler registration -2. Roll out social features progressively +2. Roll out features progressively 3. Monitor engagement and performance metrics -4. Provide guides for social feature usage +4. Provide guides for feature usage 5. Establish relay connection recommendations 6. Create nostr:// URI scheme handlers ## References - [Nostr NIPs Repository](https://github.com/nostr-protocol/nips) +- [NIP-09 Event Deletion](https://github.com/nostr-protocol/nips/blob/master/09.md) +- [NIP-10 Text Notes and Threads](https://github.com/nostr-protocol/nips/blob/master/10.md) +- [NIP-19 bech32-encoded entities](https://github.com/nostr-protocol/nips/blob/master/19.md) +- [NIP-22 Comment](https://github.com/nostr-protocol/nips/blob/master/22.md) +- [NIP-89 Recommended Application Handlers](https://github.com/nostr-protocol/nips/blob/master/89.md) - [NDK Documentation](https://github.com/nostr-dev-kit/ndk) -- [POWR Workout NIP Draft](nostr-exercise-nip.md) - [NIP-47 Wallet Connect](https://github.com/nostr-protocol/nips/blob/master/47.md) -- [NIP-57 Lightning Zaps](https://github.com/nostr-protocol/nips/blob/master/57.md) -- [NIP-89 App Handlers](https://github.com/nostr-protocol/nips/blob/master/89.md) \ No newline at end of file +- [NIP-57 Lightning Zaps](https://github.com/nostr-protocol/nips/blob/master/57.md) \ No newline at end of file