diff --git a/app/(workout)/template/[id]/social.tsx b/app/(workout)/template/[id]/social.tsx index c777a09..317b5d0 100644 --- a/app/(workout)/template/[id]/social.tsx +++ b/app/(workout)/template/[id]/social.tsx @@ -1,16 +1,18 @@ // app/(workout)/template/[id]/social.tsx import React, { useState } from 'react'; -import { View, ScrollView, ActivityIndicator } from 'react-native'; +import { View, ScrollView, ActivityIndicator, TouchableOpacity } from 'react-native'; import { Text } from '@/components/ui/text'; -import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; -import { Card, CardContent } from '@/components/ui/card'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { - MessageCircle, - ThumbsUp + MessageSquare, + Zap, + Heart, + Repeat, + Bookmark } from 'lucide-react-native'; import { useTemplate } from './_layout'; +import { cn } from '@/lib/utils'; // Mock social feed data const mockSocialFeed = [ @@ -27,7 +29,10 @@ const mockSocialFeed = [ exercises: 5 }, likes: 12, - comments: 3 + comments: 3, + zaps: 5, + reposts: 2, + bookmarked: false }, { id: 'social2', @@ -42,7 +47,10 @@ const mockSocialFeed = [ exercises: "5+2" }, likes: 8, - comments: 1 + comments: 1, + zaps: 3, + reposts: 0, + bookmarked: false }, { id: 'social3', @@ -57,7 +65,10 @@ const mockSocialFeed = [ exercises: 5 }, likes: 24, - comments: 7 + comments: 7, + zaps: 15, + reposts: 6, + bookmarked: true }, { id: 'social4', @@ -72,77 +83,171 @@ const mockSocialFeed = [ exercises: 5 }, likes: 15, - comments: 2 + comments: 2, + zaps: 8, + reposts: 1, + bookmarked: false } ]; // Social Feed Item Component function SocialFeedItem({ item }: { item: typeof mockSocialFeed[0] }) { + const [liked, setLiked] = useState(false); + const [zapCount, setZapCount] = useState(item.zaps); + const [bookmarked, setBookmarked] = useState(item.bookmarked); + const [reposted, setReposted] = useState(false); + const [commentCount, setCommentCount] = useState(item.comments); + + const formatDate = (date: Date) => { + const now = new Date(); + const diffInHours = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60)); + + if (diffInHours < 1) { + return 'now'; + } else if (diffInHours < 24) { + return `${diffInHours}h`; + } else { + const diffInDays = Math.floor(diffInHours / 24); + return `${diffInDays}d`; + } + }; + return ( - - - {/* User info and timestamp */} - - - - - {item.userName.substring(0, 2)} - - - - - - {item.userName} - - {item.timestamp.toLocaleDateString()} - - + + {/* User info and timestamp */} + + + + + {item.userName.substring(0, 2)} + + + + + + {item.userName} - {item.pubkey.substring(0, 10)}... + {formatDate(item.timestamp)} + + @{item.pubkey.substring(0, 10)}... + + + + + {/* Post content */} + {item.content} + + {/* Workout metrics */} + + + Duration + {item.metrics.duration} min - {/* Post content */} - {item.content} - - {/* Workout metrics */} - - - Duration - {item.metrics.duration} min - - - - Volume - {item.metrics.volume} lbs - - - - Exercises - {item.metrics.exercises} - + + Volume + {item.metrics.volume} lbs - {/* Actions */} - - - - - - - + + Exercises + {item.metrics.exercises} - - + + + {/* Twitter-like action buttons */} + + {/* Comment button */} + setCommentCount(prev => prev + 1)} + > + + {commentCount > 0 && ( + {commentCount} + )} + + + {/* Repost button */} + setReposted(!reposted)} + > + + {(reposted || item.reposts > 0) && ( + + {reposted ? item.reposts + 1 : item.reposts} + + )} + + + {/* Like button */} + setLiked(!liked)} + > + + {(liked || item.likes > 0) && ( + + {liked ? item.likes + 1 : item.likes} + + )} + + + {/* Zap button */} + setZapCount(prev => prev + 1)} + > + + {zapCount > 0 && ( + {zapCount} + )} + + + {/* Bookmark button */} + setBookmarked(!bookmarked)} + > + + + + ); } @@ -152,38 +257,38 @@ export default function SocialTab() { return ( - - - - Recent Activity - - - Nostr - - - - {isLoading ? ( - - - Loading activity... - - ) : mockSocialFeed.length > 0 ? ( - mockSocialFeed.map((item) => ( - - )) - ) : ( - - - No social activity found - - This workout hasn't been shared on Nostr yet - - - )} + + + Recent Activity + + + Nostr + + + {isLoading ? ( + + + Loading activity... + + ) : mockSocialFeed.length > 0 ? ( + + {mockSocialFeed.map((item) => ( + + ))} + + ) : ( + + + No social activity found + + This workout hasn't been shared on Nostr yet + + + )} ); } \ No newline at end of file diff --git a/docs/design/Social/SocialDesignDocument.md b/docs/design/Social/SocialDesignDocument.md index 889f6e2..341f048 100644 --- a/docs/design/Social/SocialDesignDocument.md +++ b/docs/design/Social/SocialDesignDocument.md @@ -132,7 +132,7 @@ interface WorkoutRecord extends NostrEvent { ["d", string], // Unique identifier ["title", string], // Workout name ["type", string], // Workout type - ["template"?, string], // Optional template reference + ["template", "33402::", ""], // Explicit template reference ["exercise", ...string[]][], // Exercises with actual values ["start", string], // Start timestamp ["end", string], // End timestamp @@ -144,23 +144,33 @@ interface WorkoutRecord extends NostrEvent { ] } -// Comment (Kind 1111) +// Comment (Kind 1111 - as per NIP-22) interface WorkoutComment extends NostrEvent { kind: 1111; content: string; // Comment text tags: [ // Root reference (exercise, template, or record) - ["e", string, string, string], // id, relay, pubkey + ["e", string, string, string], // id, relay, marker "root" ["K", string], // Root kind (33401, 33402, or 33403) ["P", string, string], // Root pubkey, relay // Parent comment (for replies) - ["e"?, string, string, string], // id, relay, pubkey + ["e"?, string, string, string], // id, relay, marker "reply" ["k"?, string], // Parent kind (1111) ["p"?, string, string], // Parent pubkey, relay ] } +// Reaction (Kind 7 - as per NIP-25) +interface Reaction extends NostrEvent { + kind: 7; + content: "+" | "🔥" | "👍"; // Standard reaction symbols + tags: [ + ["e", string, string], // event-id, relay-url + ["p", string] // pubkey of the event creator + ] +} + // App Handler Registration (Kind 31990) interface AppHandler extends NostrEvent { kind: 31990; @@ -224,7 +234,125 @@ class SocialService { async reactToEvent( event: NostrEvent, reaction: "+" | "🔥" | "👍" - ): Promise; + ): 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); + } +} +``` + +### Data Flow Diagram + +```mermaid +graph TD + subgraph User + A[Create Content] --> B[Local Storage] + G[View Content] --> F[UI Components] + end + + subgraph LocalStorage + B --> C[SQLite Database] + C --> D[Event Processor] + end + + subgraph NostrNetwork + D -->|Publish| E[Relays] + E -->|Subscribe| F + end + + subgraph SocialInteractions + H[Comments] --> I[Comment Processor] + J[Reactions] --> K[Reaction Processor] + L[Zaps] --> M[NWC Manager] + end + + I -->|Publish| E + K -->|Publish| E + M -->|Request| N[Lightning Wallet] + N -->|Zap| E + + E -->|Fetch Related| F + C -->|Offline Data| F +``` + +### Query Examples + +```typescript +// Find all exercise templates +const exerciseTemplatesQuery = { + kinds: [33401], + limit: 50 +}; + +// Find workout templates that use a specific exercise +const templatesWithExerciseQuery = { + kinds: [33402], + "#exercise": [`33401:${pubkey}:${exerciseId}`] +}; + +// Find workout records for a specific template +const workoutRecordsQuery = { + kinds: [33403], + "#template": [`33402:${pubkey}:${templateId}`] +}; + +// Find comments on a workout record +const commentsQuery = { + kinds: [1111], + "#e": [workoutEventId], + "#K": ["33403"] // Root kind filter +}; + +// Find social posts (kind 1) that reference our workout events +const socialReferencesQuery = { + kinds: [1], + "#e": [workoutEventId] +}; + +// Get reactions to a workout record +const reactionsQuery = { + kinds: [7], + "#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 + }); + + // 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 + }; + }) + ); + + // Sort by usage count + return templateCounts.sort((a, b) => b.count - a.count); } ``` @@ -393,6 +521,32 @@ class SocialService { - 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