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
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 (
<Card className="mb-4">
<CardContent className="p-4">
{/* User info and timestamp */}
<View className="flex-row mb-3">
<Avatar className="h-10 w-10 mr-3" alt={`${item.userName}'s profile picture`}>
<AvatarImage source={{ uri: item.userAvatar }} />
<AvatarFallback>
<Text className="text-sm">{item.userName.substring(0, 2)}</Text>
</AvatarFallback>
</Avatar>
<View className="flex-1">
<View className="flex-row justify-between">
<Text className="font-semibold">{item.userName}</Text>
<Text className="text-xs text-muted-foreground">
{item.timestamp.toLocaleDateString()}
</Text>
</View>
<View className="p-4 border-b border-border">
{/* User info and timestamp */}
<View className="flex-row mb-3">
<Avatar className="h-10 w-10 mr-3" alt={`${item.userName}'s profile picture`}>
<AvatarImage source={{ uri: item.userAvatar }} />
<AvatarFallback>
<Text className="text-sm">{item.userName.substring(0, 2)}</Text>
</AvatarFallback>
</Avatar>
<View className="flex-1">
<View className="flex-row justify-between">
<Text className="font-semibold">{item.userName}</Text>
<Text className="text-xs text-muted-foreground">
{item.pubkey.substring(0, 10)}...
{formatDate(item.timestamp)}
</Text>
</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>
{/* Post content */}
<Text className="mb-3">{item.content}</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 className="items-center">
<Text className="text-xs text-muted-foreground">Volume</Text>
<Text className="font-semibold">{item.metrics.volume} lbs</Text>
</View>
{/* Actions */}
<View className="flex-row justify-between items-center">
<View className="flex-row items-center">
<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 className="items-center">
<Text className="text-xs text-muted-foreground">Exercises</Text>
<Text className="font-semibold">{item.metrics.exercises}</Text>
</View>
</CardContent>
</Card>
</View>
{/* 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 (
<ScrollView
className="flex-1"
className="flex-1 bg-background"
contentContainerStyle={{ paddingBottom: 20 }}
>
<View className="p-4">
<View className="flex-row justify-between items-center mb-4">
<Text className="text-base font-semibold text-foreground">
Recent Activity
</Text>
<Badge variant="outline">
<Text>Nostr</Text>
</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 className="px-4 py-2 flex-row justify-between items-center border-b border-border">
<Text className="text-base font-semibold text-foreground">
Recent Activity
</Text>
<Badge variant="outline">
<Text>Nostr</Text>
</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 ? (
<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>
);
}

View File

@ -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:<pubkey>:<d-tag>", "<relay-url>"], // 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<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
- 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