mirror of
https://github.com/DocNR/POWR.git
synced 2025-06-05 16:52:07 +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
|
// 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
@ -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
|
||||||
|
Loading…
x
Reference in New Issue
Block a user