mirror of
https://github.com/DocNR/POWR.git
synced 2025-06-04 00:02:06 +00:00
fixed ui for template social feed
This commit is contained in:
parent
0a0af436c0
commit
0d460e8f3e
@ -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>
|
||||
);
|
||||
}
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user