fixed ui for template social feed

This commit is contained in:
DocNR 2025-02-27 00:13:44 -05:00
parent 0a0af436c0
commit 0d460e8f3e
2 changed files with 358 additions and 99 deletions

View File

@ -1,16 +1,18 @@
// app/(workout)/template/[id]/social.tsx // app/(workout)/template/[id]/social.tsx
import React, { useState } from 'react'; 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 { Text } from '@/components/ui/text';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { import {
MessageCircle, MessageSquare,
ThumbsUp Zap,
Heart,
Repeat,
Bookmark
} from 'lucide-react-native'; } from 'lucide-react-native';
import { useTemplate } from './_layout'; import { useTemplate } from './_layout';
import { cn } from '@/lib/utils';
// Mock social feed data // Mock social feed data
const mockSocialFeed = [ const mockSocialFeed = [
@ -27,7 +29,10 @@ const mockSocialFeed = [
exercises: 5 exercises: 5
}, },
likes: 12, likes: 12,
comments: 3 comments: 3,
zaps: 5,
reposts: 2,
bookmarked: false
}, },
{ {
id: 'social2', id: 'social2',
@ -42,7 +47,10 @@ const mockSocialFeed = [
exercises: "5+2" exercises: "5+2"
}, },
likes: 8, likes: 8,
comments: 1 comments: 1,
zaps: 3,
reposts: 0,
bookmarked: false
}, },
{ {
id: 'social3', id: 'social3',
@ -57,7 +65,10 @@ const mockSocialFeed = [
exercises: 5 exercises: 5
}, },
likes: 24, likes: 24,
comments: 7 comments: 7,
zaps: 15,
reposts: 6,
bookmarked: true
}, },
{ {
id: 'social4', id: 'social4',
@ -72,77 +83,171 @@ const mockSocialFeed = [
exercises: 5 exercises: 5
}, },
likes: 15, likes: 15,
comments: 2 comments: 2,
zaps: 8,
reposts: 1,
bookmarked: false
} }
]; ];
// Social Feed Item Component // Social Feed Item Component
function SocialFeedItem({ item }: { item: typeof mockSocialFeed[0] }) { 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 ( return (
<Card className="mb-4"> <View className="p-4 border-b border-border">
<CardContent className="p-4"> {/* User info and timestamp */}
{/* User info and timestamp */} <View className="flex-row mb-3">
<View className="flex-row mb-3"> <Avatar className="h-10 w-10 mr-3" alt={`${item.userName}'s profile picture`}>
<Avatar className="h-10 w-10 mr-3" alt={`${item.userName}'s profile picture`}> <AvatarImage source={{ uri: item.userAvatar }} />
<AvatarImage source={{ uri: item.userAvatar }} /> <AvatarFallback>
<AvatarFallback> <Text className="text-sm">{item.userName.substring(0, 2)}</Text>
<Text className="text-sm">{item.userName.substring(0, 2)}</Text> </AvatarFallback>
</AvatarFallback> </Avatar>
</Avatar>
<View className="flex-1">
<View className="flex-1"> <View className="flex-row justify-between">
<View className="flex-row justify-between"> <Text className="font-semibold">{item.userName}</Text>
<Text className="font-semibold">{item.userName}</Text>
<Text className="text-xs text-muted-foreground">
{item.timestamp.toLocaleDateString()}
</Text>
</View>
<Text className="text-xs text-muted-foreground"> <Text className="text-xs text-muted-foreground">
{item.pubkey.substring(0, 10)}... {formatDate(item.timestamp)}
</Text> </Text>
</View> </View>
<Text className="text-xs text-muted-foreground">
@{item.pubkey.substring(0, 10)}...
</Text>
</View>
</View>
{/* Post content */}
<Text className="mb-3">{item.content}</Text>
{/* Workout metrics */}
<View className="flex-row justify-between mb-3 p-3 bg-muted/50 rounded-lg">
<View className="items-center">
<Text className="text-xs text-muted-foreground">Duration</Text>
<Text className="font-semibold">{item.metrics.duration} min</Text>
</View> </View>
{/* Post content */} <View className="items-center">
<Text className="mb-3">{item.content}</Text> <Text className="text-xs text-muted-foreground">Volume</Text>
<Text className="font-semibold">{item.metrics.volume} lbs</Text>
{/* Workout metrics */}
<View className="flex-row justify-between mb-3 p-3 bg-muted rounded-lg">
<View className="items-center">
<Text className="text-xs text-muted-foreground">Duration</Text>
<Text className="font-semibold">{item.metrics.duration} min</Text>
</View>
<View className="items-center">
<Text className="text-xs text-muted-foreground">Volume</Text>
<Text className="font-semibold">{item.metrics.volume} lbs</Text>
</View>
<View className="items-center">
<Text className="text-xs text-muted-foreground">Exercises</Text>
<Text className="font-semibold">{item.metrics.exercises}</Text>
</View>
</View> </View>
{/* Actions */} <View className="items-center">
<View className="flex-row justify-between items-center"> <Text className="text-xs text-muted-foreground">Exercises</Text>
<View className="flex-row items-center"> <Text className="font-semibold">{item.metrics.exercises}</Text>
<Button variant="ghost" size="sm" className="p-1">
<ThumbsUp size={16} className="text-muted-foreground mr-1" />
<Text className="text-muted-foreground">{item.likes}</Text>
</Button>
<Button variant="ghost" size="sm" className="p-1">
<MessageCircle size={16} className="text-muted-foreground mr-1" />
<Text className="text-muted-foreground">{item.comments}</Text>
</Button>
</View>
<Button variant="outline" size="sm">
<Text>View Workout</Text>
</Button>
</View> </View>
</CardContent> </View>
</Card>
{/* Twitter-like action buttons */}
<View className="flex-row justify-between items-center mt-2">
{/* Comment button */}
<TouchableOpacity
activeOpacity={0.7}
className="flex-row items-center"
onPress={() => setCommentCount(prev => prev + 1)}
>
<MessageSquare size={18} className="text-muted-foreground" />
{commentCount > 0 && (
<Text className="text-xs text-muted-foreground ml-1">{commentCount}</Text>
)}
</TouchableOpacity>
{/* Repost button */}
<TouchableOpacity
activeOpacity={0.7}
className="flex-row items-center"
onPress={() => setReposted(!reposted)}
>
<Repeat
size={18}
className={cn(
reposted ? "text-green-500" : "text-muted-foreground"
)}
/>
{(reposted || item.reposts > 0) && (
<Text
className={cn(
"text-xs ml-1",
reposted ? "text-green-500" : "text-muted-foreground"
)}
>
{reposted ? item.reposts + 1 : item.reposts}
</Text>
)}
</TouchableOpacity>
{/* Like button */}
<TouchableOpacity
activeOpacity={0.7}
className="flex-row items-center"
onPress={() => setLiked(!liked)}
>
<Heart
size={18}
className={cn(
liked ? "text-red-500 fill-red-500" : "text-muted-foreground"
)}
/>
{(liked || item.likes > 0) && (
<Text
className={cn(
"text-xs ml-1",
liked ? "text-red-500" : "text-muted-foreground"
)}
>
{liked ? item.likes + 1 : item.likes}
</Text>
)}
</TouchableOpacity>
{/* Zap button */}
<TouchableOpacity
activeOpacity={0.7}
className="flex-row items-center"
onPress={() => setZapCount(prev => prev + 1)}
>
<Zap
size={18}
className="text-amber-500"
/>
{zapCount > 0 && (
<Text className="text-xs text-muted-foreground ml-1">{zapCount}</Text>
)}
</TouchableOpacity>
{/* Bookmark button */}
<TouchableOpacity
activeOpacity={0.7}
onPress={() => setBookmarked(!bookmarked)}
>
<Bookmark
size={18}
className={cn(
bookmarked ? "text-blue-500 fill-blue-500" : "text-muted-foreground"
)}
/>
</TouchableOpacity>
</View>
</View>
); );
} }
@ -152,38 +257,38 @@ export default function SocialTab() {
return ( return (
<ScrollView <ScrollView
className="flex-1" className="flex-1 bg-background"
contentContainerStyle={{ paddingBottom: 20 }} contentContainerStyle={{ paddingBottom: 20 }}
> >
<View className="p-4"> <View className="px-4 py-2 flex-row justify-between items-center border-b border-border">
<View className="flex-row justify-between items-center mb-4"> <Text className="text-base font-semibold text-foreground">
<Text className="text-base font-semibold text-foreground"> Recent Activity
Recent Activity </Text>
</Text> <Badge variant="outline">
<Badge variant="outline"> <Text>Nostr</Text>
<Text>Nostr</Text> </Badge>
</Badge>
</View>
{isLoading ? (
<View className="items-center justify-center py-8">
<ActivityIndicator size="small" className="mb-2" />
<Text className="text-muted-foreground">Loading activity...</Text>
</View>
) : mockSocialFeed.length > 0 ? (
mockSocialFeed.map((item) => (
<SocialFeedItem key={item.id} item={item} />
))
) : (
<View className="items-center justify-center py-8 bg-muted rounded-lg">
<MessageCircle size={24} className="text-muted-foreground mb-2" />
<Text className="text-muted-foreground text-center">No social activity found</Text>
<Text className="text-xs text-muted-foreground text-center mt-1">
This workout hasn't been shared on Nostr yet
</Text>
</View>
)}
</View> </View>
{isLoading ? (
<View className="items-center justify-center py-8">
<ActivityIndicator size="small" className="mb-2" />
<Text className="text-muted-foreground">Loading activity...</Text>
</View>
) : mockSocialFeed.length > 0 ? (
<View>
{mockSocialFeed.map((item) => (
<SocialFeedItem key={item.id} item={item} />
))}
</View>
) : (
<View className="items-center justify-center py-8 mx-4 mt-4 bg-muted rounded-lg">
<MessageSquare size={24} className="text-muted-foreground mb-2" />
<Text className="text-muted-foreground text-center">No social activity found</Text>
<Text className="text-xs text-muted-foreground text-center mt-1">
This workout hasn't been shared on Nostr yet
</Text>
</View>
)}
</ScrollView> </ScrollView>
); );
} }

View File

@ -132,7 +132,7 @@ interface WorkoutRecord extends NostrEvent {
["d", string], // Unique identifier ["d", string], // Unique identifier
["title", string], // Workout name ["title", string], // Workout name
["type", string], // Workout type ["type", string], // Workout type
["template"?, string], // Optional template reference ["template", "33402:<pubkey>:<d-tag>", "<relay-url>"], // Explicit template reference
["exercise", ...string[]][], // Exercises with actual values ["exercise", ...string[]][], // Exercises with actual values
["start", string], // Start timestamp ["start", string], // Start timestamp
["end", string], // End 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 { interface WorkoutComment extends NostrEvent {
kind: 1111; kind: 1111;
content: string; // Comment text content: string; // Comment text
tags: [ tags: [
// Root reference (exercise, template, or record) // 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) ["K", string], // Root kind (33401, 33402, or 33403)
["P", string, string], // Root pubkey, relay ["P", string, string], // Root pubkey, relay
// Parent comment (for replies) // Parent comment (for replies)
["e"?, string, string, string], // id, relay, pubkey ["e"?, string, string, string], // id, relay, marker "reply"
["k"?, string], // Parent kind (1111) ["k"?, string], // Parent kind (1111)
["p"?, string, string], // Parent pubkey, relay ["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) // App Handler Registration (Kind 31990)
interface AppHandler extends NostrEvent { interface AppHandler extends NostrEvent {
kind: 31990; kind: 31990;
@ -224,7 +234,125 @@ class SocialService {
async reactToEvent( async reactToEvent(
event: NostrEvent, event: NostrEvent,
reaction: "+" | "🔥" | "👍" reaction: "+" | "🔥" | "👍"
): Promise<NostrEvent>; ): Promise<NostrEvent> {
const reactionEvent = {
kind: 7,
content: reaction,
tags: [
["e", event.id, "<relay-url>"],
["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 - User control over content visibility
- Protection against spam and abuse - 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 ## Rollout Strategy
### Development Phase ### Development Phase