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