mirror of
https://github.com/logos-messaging/OpChan.git
synced 2026-01-02 12:53:10 +00:00
feat: integrate Waku, remove mock functions
This commit is contained in:
parent
e3128876de
commit
03d4ba38a0
30
package-lock.json
generated
30
package-lock.json
generated
@ -56,6 +56,7 @@
|
||||
"sonner": "^1.5.0",
|
||||
"tailwind-merge": "^2.5.2",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"uuid": "^11.1.0",
|
||||
"vaul": "^0.9.3",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
@ -65,6 +66,7 @@
|
||||
"@types/node": "^22.5.5",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vitejs/plugin-react-swc": "^3.5.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.9.0",
|
||||
@ -3485,6 +3487,13 @@
|
||||
"integrity": "sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/uuid": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
|
||||
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
@ -3770,6 +3779,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@waku/core/node_modules/uuid": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
|
||||
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/@waku/discovery": {
|
||||
"version": "0.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@waku/discovery/-/discovery-0.0.7.tgz",
|
||||
@ -8382,16 +8404,16 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
|
||||
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
|
||||
"version": "11.1.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
|
||||
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
"uuid": "dist/esm/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/vaul": {
|
||||
|
||||
@ -59,6 +59,7 @@
|
||||
"sonner": "^1.5.0",
|
||||
"tailwind-merge": "^2.5.2",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"uuid": "^11.1.0",
|
||||
"vaul": "^0.9.3",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
@ -68,6 +69,7 @@
|
||||
"@types/node": "^22.5.5",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vitejs/plugin-react-swc": "^3.5.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.9.0",
|
||||
|
||||
12
src/App.tsx
12
src/App.tsx
@ -1,3 +1,15 @@
|
||||
//TODO: research into having signatures somehow?
|
||||
//TODO research: **each message sent should not be able to be spoofed**
|
||||
/**
|
||||
* Reference:
|
||||
* https://www.notion.so/Logos-Forum-PoC-Waku-Powered-Opchan-1968f96fb65c8078b343c43429d66d0a#1968f96fb65c8025a929c2c9255a57c4
|
||||
* Also note that for UX purposes, **we should not ask a user to sign with their Bitcoin wallet for every action.**
|
||||
*
|
||||
* Instead, a key delegation system should be developed.
|
||||
*
|
||||
* - User sign an in-browser key with their wallet and broadcast it
|
||||
* - Browser uses in-browser key to sign messages moving forward
|
||||
*/
|
||||
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
import { Toaster as Sonner } from "@/components/ui/sonner";
|
||||
|
||||
@ -1,15 +1,15 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useForum } from '@/contexts/ForumContext';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Layout, MessageSquare } from 'lucide-react';
|
||||
import { Layout, MessageSquare, RefreshCw } from 'lucide-react';
|
||||
import { CreateCellDialog } from './CreateCellDialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
const CellList = () => {
|
||||
const { cells, loading, posts } = useForum();
|
||||
const { cells, isInitialLoading, posts, refreshData, isRefreshing } = useForum();
|
||||
|
||||
if (loading) {
|
||||
if (isInitialLoading) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8 max-w-4xl">
|
||||
<h1 className="text-2xl font-bold mb-6 text-glow">Loading Cells...</h1>
|
||||
@ -42,29 +42,48 @@ const CellList = () => {
|
||||
<Layout className="text-cyber-accent w-6 h-6" />
|
||||
<h1 className="text-2xl font-bold text-glow">Cells</h1>
|
||||
</div>
|
||||
<CreateCellDialog />
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={refreshData}
|
||||
disabled={isRefreshing}
|
||||
title="Refresh data"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${isRefreshing ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
<CreateCellDialog />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{cells.map((cell) => (
|
||||
<Link to={`/cell/${cell.id}`} key={cell.id} className="board-card group">
|
||||
<div className="flex gap-4 items-start">
|
||||
<img
|
||||
src={cell.icon}
|
||||
alt={cell.name}
|
||||
className="w-16 h-16 object-cover rounded-sm border border-cyber-muted group-hover:border-cyber-accent transition-colors"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<h2 className="text-xl font-bold mb-1 group-hover:text-cyber-accent transition-colors">{cell.name}</h2>
|
||||
<p className="text-sm text-cyber-neutral mb-2">{cell.description}</p>
|
||||
<div className="flex items-center text-xs text-cyber-neutral">
|
||||
<MessageSquare className="w-3 h-3 mr-1" />
|
||||
<span>{getPostCount(cell.id)} threads</span>
|
||||
{cells.length === 0 ? (
|
||||
<div className="col-span-2 text-center py-12">
|
||||
<div className="text-cyber-neutral mb-4">
|
||||
No cells found. Be the first to create one!
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
cells.map((cell) => (
|
||||
<Link to={`/cell/${cell.id}`} key={cell.id} className="board-card group">
|
||||
<div className="flex gap-4 items-start">
|
||||
<img
|
||||
src={cell.icon}
|
||||
alt={cell.name}
|
||||
className="w-16 h-16 object-cover rounded-sm border border-cyber-muted group-hover:border-cyber-accent transition-colors"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<h2 className="text-xl font-bold mb-1 group-hover:text-cyber-accent transition-colors">{cell.name}</h2>
|
||||
<p className="text-sm text-cyber-neutral mb-2">{cell.description}</p>
|
||||
<div className="flex items-center text-xs text-cyber-neutral">
|
||||
<MessageSquare className="w-3 h-3 mr-1" />
|
||||
<span>{getPostCount(cell.id)} threads</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
|
||||
import React from 'react';
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
@ -32,7 +31,7 @@ const formSchema = z.object({
|
||||
});
|
||||
|
||||
export function CreateCellDialog() {
|
||||
const { createCell } = useForum();
|
||||
const { createCell, isPostingCell } = useForum();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
@ -73,7 +72,7 @@ export function CreateCellDialog() {
|
||||
<FormItem>
|
||||
<FormLabel>Title</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Enter cell title" {...field} />
|
||||
<Input placeholder="Enter cell title" {...field} disabled={isPostingCell} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@ -89,6 +88,7 @@ export function CreateCellDialog() {
|
||||
<Textarea
|
||||
placeholder="Enter cell description"
|
||||
{...field}
|
||||
disabled={isPostingCell}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@ -106,6 +106,7 @@ export function CreateCellDialog() {
|
||||
placeholder="Enter icon URL"
|
||||
type="url"
|
||||
{...field}
|
||||
disabled={isPostingCell}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@ -115,9 +116,9 @@ export function CreateCellDialog() {
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={form.formState.isSubmitting}
|
||||
disabled={isPostingCell}
|
||||
>
|
||||
{form.formState.isSubmitting && (
|
||||
{isPostingCell && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Create Cell
|
||||
|
||||
@ -1,12 +1,15 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { useForum } from '@/contexts/ForumContext';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ShieldCheck, LogOut, Terminal } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ShieldCheck, LogOut, Terminal, Wifi, WifiOff } from 'lucide-react';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
|
||||
const Header = () => {
|
||||
const { currentUser, isAuthenticated, connectWallet, disconnectWallet, verifyOrdinal } = useAuth();
|
||||
const { isNetworkConnected, isRefreshing } = useForum();
|
||||
|
||||
const handleConnect = async () => {
|
||||
await connectWallet();
|
||||
@ -33,7 +36,36 @@ const Header = () => {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<div className="flex gap-2 items-center">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Badge
|
||||
variant={isNetworkConnected ? "default" : "destructive"}
|
||||
className="flex items-center gap-1 mr-2"
|
||||
>
|
||||
{isNetworkConnected ? (
|
||||
<>
|
||||
<Wifi className="w-3 h-3" />
|
||||
<span className="text-xs">Connected</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<WifiOff className="w-3 h-3" />
|
||||
<span className="text-xs">Offline</span>
|
||||
</>
|
||||
)}
|
||||
</Badge>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{isNetworkConnected
|
||||
? "Connected to Waku network"
|
||||
: "Not connected to Waku network. Some features may be unavailable."}</p>
|
||||
{isRefreshing && <p>Refreshing data...</p>}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{!currentUser ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Link, useParams, useNavigate } from 'react-router-dom';
|
||||
import { useForum } from '@/contexts/ForumContext';
|
||||
@ -6,19 +5,31 @@ import { useAuth } from '@/contexts/AuthContext';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ArrowLeft, ArrowUp, ArrowDown, Clock, MessageCircle, Send } from 'lucide-react';
|
||||
import { ArrowLeft, ArrowUp, ArrowDown, Clock, MessageCircle, Send, RefreshCw } from 'lucide-react';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { Comment } from '@/types';
|
||||
|
||||
const PostDetail = () => {
|
||||
const { postId } = useParams<{ postId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { posts, comments, getCommentsByPost, createComment, votePost, voteComment, getCellById, loading } = useForum();
|
||||
const {
|
||||
posts,
|
||||
comments,
|
||||
getCommentsByPost,
|
||||
createComment,
|
||||
votePost,
|
||||
voteComment,
|
||||
getCellById,
|
||||
isInitialLoading,
|
||||
isPostingComment,
|
||||
isVoting,
|
||||
isRefreshing,
|
||||
refreshData
|
||||
} = useForum();
|
||||
const { currentUser, isAuthenticated } = useAuth();
|
||||
const [newComment, setNewComment] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
if (!postId || loading) {
|
||||
if (!postId || isInitialLoading) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8 max-w-4xl">
|
||||
<div className="mb-6">
|
||||
@ -77,14 +88,13 @@ const PostDetail = () => {
|
||||
|
||||
if (!newComment.trim()) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const result = await createComment(postId, newComment);
|
||||
if (result) {
|
||||
setNewComment('');
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
} catch (error) {
|
||||
console.error("Error creating comment:", error);
|
||||
}
|
||||
};
|
||||
|
||||
@ -98,19 +108,18 @@ const PostDetail = () => {
|
||||
await voteComment(commentId, isUpvote);
|
||||
};
|
||||
|
||||
const isPostUpvoted = currentUser && post.upvotes.includes(currentUser.address);
|
||||
const isPostDownvoted = currentUser && post.downvotes.includes(currentUser.address);
|
||||
const isPostUpvoted = currentUser && post.upvotes.some(vote => vote.author === currentUser.address);
|
||||
const isPostDownvoted = currentUser && post.downvotes.some(vote => vote.author === currentUser.address);
|
||||
|
||||
const isCommentVoted = (comment: Comment, isUpvote: boolean) => {
|
||||
if (!currentUser) return false;
|
||||
return isUpvote
|
||||
? comment.upvotes.includes(currentUser.address)
|
||||
: comment.downvotes.includes(currentUser.address);
|
||||
const votes = isUpvote ? comment.upvotes : comment.downvotes;
|
||||
return votes.some(vote => vote.author === currentUser.address);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8 max-w-4xl">
|
||||
<div className="mb-6">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<Link
|
||||
to={cell ? `/cell/${cell.id}` : '/'}
|
||||
className="text-cyber-accent hover:underline flex items-center gap-1 text-sm"
|
||||
@ -118,6 +127,15 @@ const PostDetail = () => {
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
{cell ? `Back to ${cell.name}` : 'Back to Cells'}
|
||||
</Link>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={refreshData}
|
||||
disabled={isRefreshing}
|
||||
title="Refresh data"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${isRefreshing ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="border border-cyber-muted rounded-sm p-4 mb-8">
|
||||
@ -126,7 +144,7 @@ const PostDetail = () => {
|
||||
<button
|
||||
className={`p-1 rounded-sm hover:bg-cyber-muted/50 ${isPostUpvoted ? 'text-cyber-accent' : ''}`}
|
||||
onClick={() => handleVotePost(true)}
|
||||
disabled={!isAuthenticated}
|
||||
disabled={!isAuthenticated || isVoting}
|
||||
title={isAuthenticated ? "Upvote" : "Verify Ordinal to vote"}
|
||||
>
|
||||
<ArrowUp className="w-5 h-5" />
|
||||
@ -135,7 +153,7 @@ const PostDetail = () => {
|
||||
<button
|
||||
className={`p-1 rounded-sm hover:bg-cyber-muted/50 ${isPostDownvoted ? 'text-cyber-accent' : ''}`}
|
||||
onClick={() => handleVotePost(false)}
|
||||
disabled={!isAuthenticated}
|
||||
disabled={!isAuthenticated || isVoting}
|
||||
title={isAuthenticated ? "Downvote" : "Verify Ordinal to vote"}
|
||||
>
|
||||
<ArrowDown className="w-5 h-5" />
|
||||
@ -143,6 +161,7 @@ const PostDetail = () => {
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<h2 className="text-xl font-bold mb-2">{post.title}</h2>
|
||||
<p className="text-lg mb-4">{post.content}</p>
|
||||
<div className="flex items-center gap-4 text-xs text-cyber-neutral">
|
||||
<span className="flex items-center">
|
||||
@ -170,11 +189,11 @@ const PostDetail = () => {
|
||||
value={newComment}
|
||||
onChange={(e) => setNewComment(e.target.value)}
|
||||
className="flex-1 bg-cyber-muted/50 border-cyber-muted resize-none"
|
||||
disabled={isSubmitting}
|
||||
disabled={isPostingComment}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !newComment.trim()}
|
||||
disabled={isPostingComment || !newComment.trim()}
|
||||
size="icon"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
@ -204,7 +223,7 @@ const PostDetail = () => {
|
||||
<button
|
||||
className={`p-0.5 rounded-sm hover:bg-cyber-muted/50 ${isCommentVoted(comment, true) ? 'text-cyber-accent' : ''}`}
|
||||
onClick={() => handleVoteComment(comment.id, true)}
|
||||
disabled={!isAuthenticated}
|
||||
disabled={!isAuthenticated || isVoting}
|
||||
title={isAuthenticated ? "Upvote" : "Verify Ordinal to vote"}
|
||||
>
|
||||
<ArrowUp className="w-4 h-4" />
|
||||
@ -213,7 +232,7 @@ const PostDetail = () => {
|
||||
<button
|
||||
className={`p-0.5 rounded-sm hover:bg-cyber-muted/50 ${isCommentVoted(comment, false) ? 'text-cyber-accent' : ''}`}
|
||||
onClick={() => handleVoteComment(comment.id, false)}
|
||||
disabled={!isAuthenticated}
|
||||
disabled={!isAuthenticated || isVoting}
|
||||
title={isAuthenticated ? "Downvote" : "Verify Ordinal to vote"}
|
||||
>
|
||||
<ArrowDown className="w-4 h-4" />
|
||||
|
||||
@ -1,22 +1,31 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { useForum } from '@/contexts/ForumContext';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ArrowLeft, MessageSquare, MessageCircle, ArrowUp, ArrowDown, Clock } from 'lucide-react';
|
||||
import { ArrowLeft, MessageSquare, MessageCircle, ArrowUp, ArrowDown, Clock, RefreshCw } from 'lucide-react';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
const PostList = () => {
|
||||
const { cellId } = useParams<{ cellId: string }>();
|
||||
const { getCellById, getPostsByCell, createPost, loading } = useForum();
|
||||
const {
|
||||
getCellById,
|
||||
getPostsByCell,
|
||||
getCommentsByPost,
|
||||
createPost,
|
||||
isInitialLoading,
|
||||
isPostingPost,
|
||||
isRefreshing,
|
||||
refreshData
|
||||
} = useForum();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const [newPostTitle, setNewPostTitle] = useState('');
|
||||
const [newPostContent, setNewPostContent] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
if (!cellId || loading) {
|
||||
if (!cellId || isInitialLoading) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8 max-w-4xl">
|
||||
<div className="mb-6">
|
||||
@ -70,14 +79,14 @@ const PostList = () => {
|
||||
|
||||
if (!newPostContent.trim()) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const post = await createPost(cellId, newPostContent);
|
||||
const post = await createPost(cellId, newPostTitle, newPostContent);
|
||||
if (post) {
|
||||
setNewPostTitle('');
|
||||
setNewPostContent('');
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
} catch (error) {
|
||||
console.error("Error creating post:", error);
|
||||
}
|
||||
};
|
||||
|
||||
@ -95,8 +104,19 @@ const PostList = () => {
|
||||
alt={cell.name}
|
||||
className="w-12 h-12 object-cover rounded-sm border border-cyber-muted"
|
||||
/>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-glow">{cell.name}</h1>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-glow">{cell.name}</h1>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={refreshData}
|
||||
disabled={isRefreshing}
|
||||
title="Refresh data"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${isRefreshing ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-cyber-neutral">{cell.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -108,19 +128,28 @@ const PostList = () => {
|
||||
<MessageSquare className="w-4 h-4" />
|
||||
New Thread
|
||||
</h2>
|
||||
<Textarea
|
||||
placeholder="What's on your mind?"
|
||||
value={newPostContent}
|
||||
onChange={(e) => setNewPostContent(e.target.value)}
|
||||
className="mb-3 bg-cyber-muted/50 border-cyber-muted resize-none"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<div className="mb-3">
|
||||
<Input
|
||||
placeholder="Thread title"
|
||||
value={newPostTitle}
|
||||
onChange={(e) => setNewPostTitle(e.target.value)}
|
||||
className="mb-3 bg-cyber-muted/50 border-cyber-muted"
|
||||
disabled={isPostingPost}
|
||||
/>
|
||||
<Textarea
|
||||
placeholder="What's on your mind?"
|
||||
value={newPostContent}
|
||||
onChange={(e) => setNewPostContent(e.target.value)}
|
||||
className="bg-cyber-muted/50 border-cyber-muted resize-none"
|
||||
disabled={isPostingPost}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !newPostContent.trim()}
|
||||
disabled={isPostingPost || !newPostContent.trim() || !newPostTitle.trim()}
|
||||
>
|
||||
{isSubmitting ? 'Posting...' : 'Post Thread'}
|
||||
{isPostingPost ? 'Posting...' : 'Post Thread'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
@ -142,6 +171,7 @@ const PostList = () => {
|
||||
posts.map(post => (
|
||||
<Link to={`/post/${post.id}`} key={post.id} className="thread-card block">
|
||||
<div>
|
||||
<h3 className="font-medium mb-1">{post.title}</h3>
|
||||
<p className="text-sm mb-3">{post.content}</p>
|
||||
<div className="flex items-center gap-4 text-xs text-cyber-neutral">
|
||||
<span className="flex items-center">
|
||||
@ -150,8 +180,7 @@ const PostList = () => {
|
||||
</span>
|
||||
<span className="flex items-center">
|
||||
<MessageCircle className="w-3 h-3 mr-1" />
|
||||
{/* This would need to be calculated based on actual comments */}
|
||||
0 comments
|
||||
{getCommentsByPost(post.id).length} comments
|
||||
</span>
|
||||
<div className="flex items-center">
|
||||
<ArrowUp className="w-3 h-3 mr-1" />
|
||||
|
||||
@ -1,23 +1,34 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { Cell, Post, Comment } from '@/types';
|
||||
import { mockCells, mockPosts, mockComments } from '@/data/mockData';
|
||||
import { useAuth } from './AuthContext';
|
||||
import messageManager from '@/lib/waku';
|
||||
import { CellMessage, CommentMessage, MessageType, PostMessage, VoteMessage } from '@/lib/waku/types';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
interface ForumContextType {
|
||||
cells: Cell[];
|
||||
posts: Post[];
|
||||
comments: Comment[];
|
||||
loading: boolean;
|
||||
// Granular loading states
|
||||
isInitialLoading: boolean;
|
||||
isPostingCell: boolean;
|
||||
isPostingPost: boolean;
|
||||
isPostingComment: boolean;
|
||||
isVoting: boolean;
|
||||
isRefreshing: boolean;
|
||||
// Network status
|
||||
isNetworkConnected: boolean;
|
||||
error: string | null;
|
||||
getCellById: (id: string) => Cell | undefined;
|
||||
getPostsByCell: (cellId: string) => Post[];
|
||||
getCommentsByPost: (postId: string) => Comment[];
|
||||
createPost: (cellId: string, content: string) => Promise<Post | null>;
|
||||
createPost: (cellId: string, title: string, content: string) => Promise<Post | null>;
|
||||
createComment: (postId: string, content: string) => Promise<Comment | null>;
|
||||
votePost: (postId: string, isUpvote: boolean) => Promise<boolean>;
|
||||
voteComment: (commentId: string, isUpvote: boolean) => Promise<boolean>;
|
||||
createCell: (title: string, description: string, icon: string) => Promise<Cell | null>;
|
||||
createCell: (name: string, description: string, icon: string) => Promise<Cell | null>;
|
||||
refreshData: () => Promise<void>;
|
||||
}
|
||||
|
||||
const ForumContext = createContext<ForumContextType | undefined>(undefined);
|
||||
@ -26,28 +37,230 @@ export function ForumProvider({ children }: { children: React.ReactNode }) {
|
||||
const [cells, setCells] = useState<Cell[]>([]);
|
||||
const [posts, setPosts] = useState<Post[]>([]);
|
||||
const [comments, setComments] = useState<Comment[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Replace single loading state with granular loading states
|
||||
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
||||
const [isPostingCell, setIsPostingCell] = useState(false);
|
||||
const [isPostingPost, setIsPostingPost] = useState(false);
|
||||
const [isPostingComment, setIsPostingComment] = useState(false);
|
||||
const [isVoting, setIsVoting] = useState(false);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
// Network connection status
|
||||
const [isNetworkConnected, setIsNetworkConnected] = useState(false);
|
||||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { currentUser, isAuthenticated } = useAuth();
|
||||
const { toast } = useToast();
|
||||
|
||||
// Helper function to transform CellMessage to Cell
|
||||
const transformCell = (cellMessage: CellMessage): Cell => {
|
||||
return {
|
||||
id: cellMessage.id,
|
||||
name: cellMessage.name,
|
||||
description: cellMessage.description,
|
||||
icon: cellMessage.icon
|
||||
};
|
||||
};
|
||||
|
||||
// Helper function to transform PostMessage to Post with vote aggregation
|
||||
const transformPost = (postMessage: PostMessage): Post => {
|
||||
// Find all votes related to this post
|
||||
const votes = Object.values(messageManager.messageCache.votes).filter(
|
||||
vote => vote.targetId === postMessage.id
|
||||
);
|
||||
|
||||
const upvotes = votes.filter(vote => vote.value === 1);
|
||||
const downvotes = votes.filter(vote => vote.value === -1);
|
||||
|
||||
return {
|
||||
id: postMessage.id,
|
||||
cellId: postMessage.cellId,
|
||||
authorAddress: postMessage.author,
|
||||
title: postMessage.title,
|
||||
content: postMessage.content,
|
||||
timestamp: postMessage.timestamp,
|
||||
upvotes: upvotes,
|
||||
downvotes: downvotes
|
||||
};
|
||||
};
|
||||
|
||||
// Helper function to transform CommentMessage to Comment with vote aggregation
|
||||
const transformComment = (commentMessage: CommentMessage): Comment => {
|
||||
// Find all votes related to this comment
|
||||
const votes = Object.values(messageManager.messageCache.votes).filter(
|
||||
vote => vote.targetId === commentMessage.id
|
||||
);
|
||||
|
||||
const upvotes = votes.filter(vote => vote.value === 1);
|
||||
const downvotes = votes.filter(vote => vote.value === -1);
|
||||
|
||||
return {
|
||||
id: commentMessage.id,
|
||||
postId: commentMessage.postId,
|
||||
authorAddress: commentMessage.author,
|
||||
content: commentMessage.content,
|
||||
timestamp: commentMessage.timestamp,
|
||||
upvotes: upvotes,
|
||||
downvotes: downvotes
|
||||
};
|
||||
};
|
||||
|
||||
// Function to update UI state from message cache
|
||||
const updateStateFromCache = () => {
|
||||
// Transform cells
|
||||
const cellsArray = Object.values(messageManager.messageCache.cells).map(transformCell);
|
||||
|
||||
// Transform posts
|
||||
const postsArray = Object.values(messageManager.messageCache.posts).map(transformPost);
|
||||
|
||||
// Transform comments
|
||||
const commentsArray = Object.values(messageManager.messageCache.comments).map(transformComment);
|
||||
|
||||
setCells(cellsArray);
|
||||
setPosts(postsArray);
|
||||
setComments(commentsArray);
|
||||
};
|
||||
|
||||
// Function to refresh data from the network
|
||||
const refreshData = async () => {
|
||||
try {
|
||||
setIsRefreshing(true);
|
||||
toast({
|
||||
title: "Refreshing data",
|
||||
description: "Fetching latest messages from the network...",
|
||||
});
|
||||
|
||||
// Try to connect if not already connected
|
||||
if (!isNetworkConnected) {
|
||||
try {
|
||||
await messageManager.waitForRemotePeer(10000);
|
||||
} catch (err) {
|
||||
console.warn("Could not connect to peer during refresh:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Query historical messages from the store
|
||||
await messageManager.queryStore();
|
||||
|
||||
// Update UI state from the cache
|
||||
updateStateFromCache();
|
||||
|
||||
toast({
|
||||
title: "Data refreshed",
|
||||
description: "Your view has been updated with the latest messages.",
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Error refreshing data:", err);
|
||||
toast({
|
||||
title: "Refresh failed",
|
||||
description: "Could not fetch the latest messages. Please try again.",
|
||||
variant: "destructive",
|
||||
});
|
||||
setError("Failed to refresh data. Please try again later.");
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Monitor network connection status
|
||||
useEffect(() => {
|
||||
// Initial status
|
||||
setIsNetworkConnected(messageManager.isReady);
|
||||
|
||||
// Subscribe to health changes
|
||||
const unsubscribe = messageManager.onHealthChange((isReady) => {
|
||||
setIsNetworkConnected(isReady);
|
||||
|
||||
if (isReady) {
|
||||
toast({
|
||||
title: "Network connected",
|
||||
description: "Connected to the Waku network",
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: "Network disconnected",
|
||||
description: "Lost connection to the Waku network",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [toast]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
setCells(mockCells);
|
||||
setPosts(mockPosts);
|
||||
setComments(mockComments);
|
||||
setIsInitialLoading(true);
|
||||
|
||||
toast({
|
||||
title: "Loading data",
|
||||
description: "Connecting to the Waku network...",
|
||||
});
|
||||
|
||||
// Wait for peer connection with timeout
|
||||
try {
|
||||
await messageManager.waitForRemotePeer(15000);
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Connection timeout",
|
||||
description: "Could not connect to any peers. Some features may be unavailable.",
|
||||
variant: "destructive",
|
||||
});
|
||||
console.warn("Timeout connecting to peer:", err);
|
||||
}
|
||||
|
||||
// Query historical messages from the store
|
||||
await messageManager.queryStore();
|
||||
|
||||
// Subscribe to new messages
|
||||
await messageManager.subscribeToMessages();
|
||||
|
||||
// Update UI state from the cache
|
||||
updateStateFromCache();
|
||||
} catch (err) {
|
||||
console.error("Error loading forum data:", err);
|
||||
setError("Failed to load forum data. Please try again later.");
|
||||
|
||||
toast({
|
||||
title: "Connection error",
|
||||
description: "Failed to connect to Waku network. Please try refreshing.",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setIsInitialLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
// Set up a polling mechanism to refresh the UI every few seconds
|
||||
// This is a temporary solution until we implement real-time updates with message callbacks
|
||||
const uiRefreshInterval = setInterval(() => {
|
||||
updateStateFromCache();
|
||||
}, 5000);
|
||||
|
||||
// Set up regular network queries to fetch new messages
|
||||
const networkQueryInterval = setInterval(async () => {
|
||||
if (isNetworkConnected) {
|
||||
try {
|
||||
await messageManager.queryStore();
|
||||
// No need to call updateStateFromCache() here as the UI refresh interval will handle that
|
||||
} catch (err) {
|
||||
console.warn("Error during scheduled network query:", err);
|
||||
}
|
||||
}
|
||||
}, 3000);
|
||||
|
||||
return () => {
|
||||
clearInterval(uiRefreshInterval);
|
||||
clearInterval(networkQueryInterval);
|
||||
// You might want to clean up subscriptions here
|
||||
};
|
||||
}, [toast]);
|
||||
|
||||
const getCellById = (id: string): Cell | undefined => {
|
||||
return cells.find(cell => cell.id === id);
|
||||
@ -63,7 +276,7 @@ export function ForumProvider({ children }: { children: React.ReactNode }) {
|
||||
.sort((a, b) => a.timestamp - b.timestamp);
|
||||
};
|
||||
|
||||
const createPost = async (cellId: string, content: string): Promise<Post | null> => {
|
||||
const createPost = async (cellId: string, title: string, content: string): Promise<Post | null> => {
|
||||
if (!isAuthenticated || !currentUser) {
|
||||
toast({
|
||||
title: "Authentication Required",
|
||||
@ -74,24 +287,38 @@ export function ForumProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
try {
|
||||
const newPost: Post = {
|
||||
id: `post-${Date.now()}`,
|
||||
setIsPostingPost(true);
|
||||
|
||||
toast({
|
||||
title: "Creating post",
|
||||
description: "Sending your post to the network...",
|
||||
});
|
||||
|
||||
const postId = uuidv4();
|
||||
|
||||
const postMessage: PostMessage = {
|
||||
type: MessageType.POST,
|
||||
id: postId,
|
||||
cellId,
|
||||
authorAddress: currentUser.address,
|
||||
title,
|
||||
content,
|
||||
timestamp: Date.now(),
|
||||
upvotes: [],
|
||||
downvotes: [],
|
||||
author: currentUser.address
|
||||
};
|
||||
|
||||
setPosts(prev => [newPost, ...prev]);
|
||||
// Send the message to the network
|
||||
await messageManager.sendMessage(postMessage);
|
||||
|
||||
// Update UI (the cache is already updated in sendMessage)
|
||||
updateStateFromCache();
|
||||
|
||||
toast({
|
||||
title: "Post Created",
|
||||
description: "Your post has been published successfully.",
|
||||
});
|
||||
|
||||
return newPost;
|
||||
// Return the transformed post
|
||||
return transformPost(postMessage);
|
||||
} catch (error) {
|
||||
console.error("Error creating post:", error);
|
||||
toast({
|
||||
@ -100,6 +327,8 @@ export function ForumProvider({ children }: { children: React.ReactNode }) {
|
||||
variant: "destructive",
|
||||
});
|
||||
return null;
|
||||
} finally {
|
||||
setIsPostingPost(false);
|
||||
}
|
||||
};
|
||||
|
||||
@ -114,24 +343,37 @@ export function ForumProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
try {
|
||||
const newComment: Comment = {
|
||||
id: `comment-${Date.now()}`,
|
||||
setIsPostingComment(true);
|
||||
|
||||
toast({
|
||||
title: "Posting comment",
|
||||
description: "Sending your comment to the network...",
|
||||
});
|
||||
|
||||
const commentId = uuidv4();
|
||||
|
||||
const commentMessage: CommentMessage = {
|
||||
type: MessageType.COMMENT,
|
||||
id: commentId,
|
||||
postId,
|
||||
authorAddress: currentUser.address,
|
||||
content,
|
||||
timestamp: Date.now(),
|
||||
upvotes: [],
|
||||
downvotes: [],
|
||||
author: currentUser.address
|
||||
};
|
||||
|
||||
setComments(prev => [...prev, newComment]);
|
||||
// Send the message to the network
|
||||
await messageManager.sendMessage(commentMessage);
|
||||
|
||||
// Update UI (the cache is already updated in sendMessage)
|
||||
updateStateFromCache();
|
||||
|
||||
toast({
|
||||
title: "Comment Added",
|
||||
description: "Your comment has been published.",
|
||||
});
|
||||
|
||||
return newComment;
|
||||
// Return the transformed comment
|
||||
return transformComment(commentMessage);
|
||||
} catch (error) {
|
||||
console.error("Error creating comment:", error);
|
||||
toast({
|
||||
@ -140,6 +382,8 @@ export function ForumProvider({ children }: { children: React.ReactNode }) {
|
||||
variant: "destructive",
|
||||
});
|
||||
return null;
|
||||
} finally {
|
||||
setIsPostingComment(false);
|
||||
}
|
||||
};
|
||||
|
||||
@ -154,29 +398,35 @@ export function ForumProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
try {
|
||||
setPosts(prev => prev.map(post => {
|
||||
if (post.id === postId) {
|
||||
const userAddress = currentUser.address;
|
||||
|
||||
const filteredUpvotes = post.upvotes.filter(addr => addr !== userAddress);
|
||||
const filteredDownvotes = post.downvotes.filter(addr => addr !== userAddress);
|
||||
|
||||
if (isUpvote) {
|
||||
return {
|
||||
...post,
|
||||
upvotes: [...filteredUpvotes, userAddress],
|
||||
downvotes: filteredDownvotes,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
...post,
|
||||
upvotes: filteredUpvotes,
|
||||
downvotes: [...filteredDownvotes, userAddress],
|
||||
};
|
||||
}
|
||||
}
|
||||
return post;
|
||||
}));
|
||||
setIsVoting(true);
|
||||
|
||||
const voteType = isUpvote ? "upvote" : "downvote";
|
||||
toast({
|
||||
title: `Sending ${voteType}`,
|
||||
description: "Recording your vote on the network...",
|
||||
});
|
||||
|
||||
const voteId = uuidv4();
|
||||
|
||||
const voteMessage: VoteMessage = {
|
||||
type: MessageType.VOTE,
|
||||
id: voteId,
|
||||
targetId: postId,
|
||||
value: isUpvote ? 1 : -1,
|
||||
timestamp: Date.now(),
|
||||
author: currentUser.address
|
||||
};
|
||||
|
||||
// Send the vote message to the network
|
||||
await messageManager.sendMessage(voteMessage);
|
||||
|
||||
// Update UI (the cache is already updated in sendMessage)
|
||||
updateStateFromCache();
|
||||
|
||||
toast({
|
||||
title: "Vote Recorded",
|
||||
description: `Your ${voteType} has been registered.`,
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
@ -187,6 +437,8 @@ export function ForumProvider({ children }: { children: React.ReactNode }) {
|
||||
variant: "destructive",
|
||||
});
|
||||
return false;
|
||||
} finally {
|
||||
setIsVoting(false);
|
||||
}
|
||||
};
|
||||
|
||||
@ -201,29 +453,35 @@ export function ForumProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
try {
|
||||
setComments(prev => prev.map(comment => {
|
||||
if (comment.id === commentId) {
|
||||
const userAddress = currentUser.address;
|
||||
|
||||
const filteredUpvotes = comment.upvotes.filter(addr => addr !== userAddress);
|
||||
const filteredDownvotes = comment.downvotes.filter(addr => addr !== userAddress);
|
||||
|
||||
if (isUpvote) {
|
||||
return {
|
||||
...comment,
|
||||
upvotes: [...filteredUpvotes, userAddress],
|
||||
downvotes: filteredDownvotes,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
...comment,
|
||||
upvotes: filteredUpvotes,
|
||||
downvotes: [...filteredDownvotes, userAddress],
|
||||
};
|
||||
}
|
||||
}
|
||||
return comment;
|
||||
}));
|
||||
setIsVoting(true);
|
||||
|
||||
const voteType = isUpvote ? "upvote" : "downvote";
|
||||
toast({
|
||||
title: `Sending ${voteType}`,
|
||||
description: "Recording your vote on the network...",
|
||||
});
|
||||
|
||||
const voteId = uuidv4();
|
||||
|
||||
const voteMessage: VoteMessage = {
|
||||
type: MessageType.VOTE,
|
||||
id: voteId,
|
||||
targetId: commentId,
|
||||
value: isUpvote ? 1 : -1,
|
||||
timestamp: Date.now(),
|
||||
author: currentUser.address
|
||||
};
|
||||
|
||||
// Send the vote message to the network
|
||||
await messageManager.sendMessage(voteMessage);
|
||||
|
||||
// Update UI (the cache is already updated in sendMessage)
|
||||
updateStateFromCache();
|
||||
|
||||
toast({
|
||||
title: "Vote Recorded",
|
||||
description: `Your ${voteType} has been registered.`,
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
@ -234,10 +492,12 @@ export function ForumProvider({ children }: { children: React.ReactNode }) {
|
||||
variant: "destructive",
|
||||
});
|
||||
return false;
|
||||
} finally {
|
||||
setIsVoting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const createCell = async (title: string, description: string, icon: string): Promise<Cell | null> => {
|
||||
const createCell = async (name: string, description: string, icon: string): Promise<Cell | null> => {
|
||||
if (!isAuthenticated || !currentUser) {
|
||||
toast({
|
||||
title: "Authentication Required",
|
||||
@ -248,29 +508,47 @@ export function ForumProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
try {
|
||||
const newCell: Cell = {
|
||||
id: `cell-${Date.now()}`,
|
||||
name: title,
|
||||
setIsPostingCell(true);
|
||||
|
||||
toast({
|
||||
title: "Creating cell",
|
||||
description: "Sending your cell to the network...",
|
||||
});
|
||||
|
||||
const cellId = uuidv4();
|
||||
|
||||
const cellMessage: CellMessage = {
|
||||
type: MessageType.CELL,
|
||||
id: cellId,
|
||||
name,
|
||||
description,
|
||||
icon,
|
||||
timestamp: Date.now(),
|
||||
author: currentUser.address
|
||||
};
|
||||
|
||||
setCells(prev => [...prev, newCell]);
|
||||
// Send the cell message to the network
|
||||
await messageManager.sendMessage(cellMessage);
|
||||
|
||||
// Update UI (the cache is already updated in sendMessage)
|
||||
updateStateFromCache();
|
||||
|
||||
toast({
|
||||
title: "Cell Created",
|
||||
description: "Your cell has been created successfully.",
|
||||
});
|
||||
|
||||
return newCell;
|
||||
return transformCell(cellMessage);
|
||||
} catch (error) {
|
||||
console.error("Error creating cell:", error);
|
||||
toast({
|
||||
title: "Creation Failed",
|
||||
title: "Cell Creation Failed",
|
||||
description: "Failed to create cell. Please try again.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return null;
|
||||
} finally {
|
||||
setIsPostingCell(false);
|
||||
}
|
||||
};
|
||||
|
||||
@ -280,7 +558,13 @@ export function ForumProvider({ children }: { children: React.ReactNode }) {
|
||||
cells,
|
||||
posts,
|
||||
comments,
|
||||
loading,
|
||||
isInitialLoading,
|
||||
isPostingCell,
|
||||
isPostingPost,
|
||||
isPostingComment,
|
||||
isVoting,
|
||||
isRefreshing,
|
||||
isNetworkConnected,
|
||||
error,
|
||||
getCellById,
|
||||
getPostsByCell,
|
||||
@ -290,6 +574,7 @@ export function ForumProvider({ children }: { children: React.ReactNode }) {
|
||||
votePost,
|
||||
voteComment,
|
||||
createCell,
|
||||
refreshData
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
@ -300,7 +585,7 @@ export function ForumProvider({ children }: { children: React.ReactNode }) {
|
||||
export const useForum = () => {
|
||||
const context = useContext(ForumContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useForum must be used within a ForumProvider");
|
||||
throw new Error('useForum must be used within a ForumProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
@ -1,29 +1,45 @@
|
||||
import { createDecoder, createEncoder } from '@waku/sdk';
|
||||
import { MessageType } from './types';
|
||||
import { CellMessage, PostMessage, CommentMessage, VoteMessage } from './types';
|
||||
import { CONTENT_TOPICS } from './constants';
|
||||
import { CONTENT_TOPICS, NETWORK_CONFIG } from './constants';
|
||||
import { OpchanMessage } from '@/types';
|
||||
|
||||
export const encoders = {
|
||||
[MessageType.CELL]: createEncoder({
|
||||
contentTopic: CONTENT_TOPICS['cell'],
|
||||
pubsubTopicShardInfo: {clusterId: NETWORK_CONFIG.clusterId, shard: 0}
|
||||
}),
|
||||
[MessageType.POST]: createEncoder({
|
||||
contentTopic: CONTENT_TOPICS['post'],
|
||||
pubsubTopicShardInfo: {clusterId: NETWORK_CONFIG.clusterId, shard: 0}
|
||||
}),
|
||||
[MessageType.COMMENT]: createEncoder({
|
||||
contentTopic: CONTENT_TOPICS['comment'],
|
||||
pubsubTopicShardInfo: {clusterId: NETWORK_CONFIG.clusterId, shard: 0}
|
||||
}),
|
||||
[MessageType.VOTE]: createEncoder({
|
||||
contentTopic: CONTENT_TOPICS['vote'],
|
||||
pubsubTopicShardInfo: {clusterId: NETWORK_CONFIG.clusterId, shard: 0}
|
||||
}),
|
||||
}
|
||||
|
||||
export const decoders = {
|
||||
[MessageType.CELL]: createDecoder(CONTENT_TOPICS['cell']),
|
||||
[MessageType.POST]: createDecoder(CONTENT_TOPICS['post']),
|
||||
[MessageType.COMMENT]: createDecoder(CONTENT_TOPICS['comment']),
|
||||
[MessageType.VOTE]: createDecoder(CONTENT_TOPICS['vote']),
|
||||
[MessageType.CELL]: createDecoder(CONTENT_TOPICS['cell'], {
|
||||
clusterId: NETWORK_CONFIG.clusterId,
|
||||
shard: 0
|
||||
}),
|
||||
[MessageType.POST]: createDecoder(CONTENT_TOPICS['post'], {
|
||||
clusterId: NETWORK_CONFIG.clusterId,
|
||||
shard: 0
|
||||
}),
|
||||
[MessageType.COMMENT]: createDecoder(CONTENT_TOPICS['comment'], {
|
||||
clusterId: NETWORK_CONFIG.clusterId,
|
||||
shard: 0
|
||||
}),
|
||||
[MessageType.VOTE]: createDecoder(CONTENT_TOPICS['vote'], {
|
||||
clusterId: NETWORK_CONFIG.clusterId,
|
||||
shard: 0
|
||||
}),
|
||||
}
|
||||
|
||||
/**
|
||||
@ -35,36 +51,24 @@ export function encodeMessage(message: OpchanMessage): Uint8Array {
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Type-specific decoders
|
||||
*/
|
||||
export function decodeCellMessage(payload: Uint8Array): CellMessage {
|
||||
return decodeMessage(payload, MessageType.CELL) as CellMessage;
|
||||
}
|
||||
|
||||
export function decodePostMessage(payload: Uint8Array): PostMessage {
|
||||
return decodeMessage(payload, MessageType.POST) as PostMessage;
|
||||
}
|
||||
|
||||
export function decodeCommentMessage(payload: Uint8Array): CommentMessage {
|
||||
return decodeMessage(payload, MessageType.COMMENT) as CommentMessage;
|
||||
}
|
||||
|
||||
export function decodeVoteMessage(payload: Uint8Array): VoteMessage {
|
||||
return decodeMessage(payload, MessageType.VOTE) as VoteMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a message from a Uint8Array based on its type
|
||||
*/
|
||||
function decodeMessage(payload: Uint8Array, type?: MessageType): OpchanMessage {
|
||||
export function decodeMessage(payload: Uint8Array): CellMessage | PostMessage | CommentMessage | VoteMessage {
|
||||
const messageJson = new TextDecoder().decode(payload);
|
||||
const message = JSON.parse(messageJson) as OpchanMessage;
|
||||
|
||||
if (type && message.type !== type) {
|
||||
throw new Error(`Expected message of type ${type}, but got ${message.type}`);
|
||||
}
|
||||
|
||||
// Return the decoded message
|
||||
return message;
|
||||
switch(message.type) {
|
||||
case MessageType.CELL:
|
||||
return message as CellMessage;
|
||||
case MessageType.POST:
|
||||
return message as PostMessage;
|
||||
case MessageType.COMMENT:
|
||||
return message as CommentMessage;
|
||||
case MessageType.VOTE:
|
||||
return message as VoteMessage;
|
||||
default:
|
||||
throw new Error(`Unknown message type: ${message}`);
|
||||
}
|
||||
}
|
||||
@ -12,9 +12,9 @@ export const CONTENT_TOPICS: Record<MessageType, string> = {
|
||||
};
|
||||
|
||||
export const NETWORK_CONFIG: NetworkConfig = {
|
||||
contentTopics: Object.values(CONTENT_TOPICS),
|
||||
shards: [1],
|
||||
clusterId: 42
|
||||
// contentTopics: Object.values(CONTENT_TOPICS),
|
||||
clusterId: 42,
|
||||
shards: [0]
|
||||
}
|
||||
|
||||
/**
|
||||
@ -22,8 +22,10 @@ export const NETWORK_CONFIG: NetworkConfig = {
|
||||
* These are public Waku nodes that our node will connect to on startup
|
||||
*/
|
||||
export const BOOTSTRAP_NODES = {
|
||||
"42": ["/dns4/waku-test.bloxy.one/tcp/8095/wss/p2p/16Uiu2HAmSZbDB7CusdRhgkD81VssRjQV5ZH13FbzCGcdnbbh6VwZ",
|
||||
"42": [
|
||||
"/dns4/waku-test.bloxy.one/tcp/8095/wss/p2p/16Uiu2HAmSZbDB7CusdRhgkD81VssRjQV5ZH13FbzCGcdnbbh6VwZ",
|
||||
"/dns4/node-01.do-ams3.waku.sandbox.status.im/tcp/30303/p2p/16Uiu2HAmNaeL4p3WEYzC9mgXBmBWSgWjPHRvatZTXnp8Jgv3iKsb",
|
||||
"/dns4/waku.fryorcraken.xyz/tcp/8000/wss/p2p/16Uiu2HAmMRvhDHrtiHft1FTUYnn6cVA8AWVrTyLUayJJ3MWpUZDB",
|
||||
"/dns4/vps-aaa00d52.vps.ovh.ca/tcp/8000/wss/p2p/16Uiu2HAm9PftGgHZwWE3wzdMde4m3kT2eYJFXLZfGoSED3gysofk"]
|
||||
// "/dns4/waku.fryorcraken.xyz/tcp/8000/wss/p2p/16Uiu2HAmMRvhDHrtiHft1FTUYnn6cVA8AWVrTyLUayJJ3MWpUZDB",
|
||||
// "/dns4/vps-aaa00d52.vps.ovh.ca/tcp/8000/wss/p2p/16Uiu2HAm9PftGgHZwWE3wzdMde4m3kT2eYJFXLZfGoSED3gysofk"
|
||||
]
|
||||
};
|
||||
@ -1,4 +1,7 @@
|
||||
import { createDecoder, createLightNode, LightNode } from "@waku/sdk";
|
||||
//TODO: perhaps store all messages in an indexed DB? (helpful when Waku is down)
|
||||
// with a `isPublished` flag to indicate if the message has been sent to the network
|
||||
|
||||
import { createDecoder, createLightNode, HealthStatus, HealthStatusChangeEvents, LightNode } from "@waku/sdk";
|
||||
import { BOOTSTRAP_NODES } from "./constants";
|
||||
import StoreManager from "./store";
|
||||
import { CommentCache, MessageType, VoteCache } from "./types";
|
||||
@ -8,11 +11,16 @@ import { OpchanMessage } from "@/types";
|
||||
import { EphemeralProtocolsManager } from "./lightpush_filter";
|
||||
import { NETWORK_CONFIG } from "./constants";
|
||||
|
||||
export type HealthChangeCallback = (isReady: boolean) => void;
|
||||
|
||||
class MessageManager {
|
||||
private node: LightNode;
|
||||
//TODO: implement SDS?
|
||||
private ephemeralProtocolsManager: EphemeralProtocolsManager;
|
||||
private storeManager: StoreManager;
|
||||
private _isReady: boolean = false;
|
||||
private healthListeners: Set<HealthChangeCallback> = new Set();
|
||||
private peerCheckInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
|
||||
public readonly messageCache: {
|
||||
@ -33,19 +41,115 @@ class MessageManager {
|
||||
networkConfig: NETWORK_CONFIG,
|
||||
autoStart: true,
|
||||
bootstrapPeers: BOOTSTRAP_NODES[42],
|
||||
lightPush:{autoRetry: true, retryIntervalMs: 1000}
|
||||
});
|
||||
|
||||
return new MessageManager(node);
|
||||
}
|
||||
|
||||
public async stop() {
|
||||
if (this.peerCheckInterval) {
|
||||
clearInterval(this.peerCheckInterval);
|
||||
this.peerCheckInterval = null;
|
||||
}
|
||||
await this.node.stop();
|
||||
this.setIsReady(false);
|
||||
}
|
||||
|
||||
private constructor(node: LightNode) {
|
||||
this.node = node;
|
||||
this.ephemeralProtocolsManager = new EphemeralProtocolsManager(node);
|
||||
this.storeManager = new StoreManager(node);
|
||||
|
||||
// Start peer monitoring
|
||||
this.startPeerMonitoring();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start monitoring connected peers to determine node health
|
||||
* Runs every 1 second to check if we have at least one peer
|
||||
*/
|
||||
private startPeerMonitoring() {
|
||||
// Initial peer check
|
||||
this.checkPeers();
|
||||
|
||||
// Regular peer checking
|
||||
this.peerCheckInterval = setInterval(() => {
|
||||
this.checkPeers();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we have connected peers and update ready state
|
||||
*/
|
||||
private async checkPeers() {
|
||||
try {
|
||||
const peers = await this.node.getConnectedPeers();
|
||||
this.setIsReady(peers.length >= 1);
|
||||
} catch (err) {
|
||||
console.error("Error checking peers:", err);
|
||||
this.setIsReady(false);
|
||||
}
|
||||
}
|
||||
|
||||
private setIsReady(isReady: boolean) {
|
||||
if (this._isReady !== isReady) {
|
||||
this._isReady = isReady;
|
||||
// Notify all health listeners
|
||||
this.healthListeners.forEach(listener => listener(isReady));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the node is currently healthy and ready for use
|
||||
*/
|
||||
public get isReady(): boolean {
|
||||
return this._isReady;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to health status changes
|
||||
* @param callback Function to call when health status changes
|
||||
* @returns Function to unsubscribe
|
||||
*/
|
||||
public onHealthChange(callback: HealthChangeCallback): () => void {
|
||||
this.healthListeners.add(callback);
|
||||
|
||||
// Immediately call with current status
|
||||
callback(this._isReady);
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => {
|
||||
this.healthListeners.delete(callback);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for the node to connect to at least one peer
|
||||
* @param timeoutMs Maximum time to wait in milliseconds
|
||||
* @returns Promise that resolves when connected or rejects on timeout
|
||||
*/
|
||||
public async waitForRemotePeer(timeoutMs: number = 15000): Promise<boolean> {
|
||||
if (this._isReady) return true;
|
||||
|
||||
return new Promise<boolean>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error(`Timed out waiting for remote peer after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
|
||||
const checkHandler = (isReady: boolean) => {
|
||||
if (isReady) {
|
||||
clearTimeout(timeout);
|
||||
this.healthListeners.delete(checkHandler);
|
||||
resolve(true);
|
||||
}
|
||||
};
|
||||
|
||||
// Add temporary listener for peer connection
|
||||
this.healthListeners.add(checkHandler);
|
||||
|
||||
// Also do an immediate check in case we already have peers
|
||||
this.checkPeers();
|
||||
});
|
||||
}
|
||||
|
||||
public async queryStore() {
|
||||
@ -97,9 +201,8 @@ class MessageManager {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
const messageManager = await MessageManager.create();
|
||||
export default messageManager;
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { LightNode } from "@waku/sdk";
|
||||
import { decodeCellMessage, decodeCommentMessage, decodePostMessage, decoders, decodeVoteMessage, encodeMessage, encoders } from "./codec";
|
||||
import { CellMessage, CommentMessage, MessageType, PostMessage, VoteMessage } from "./types";
|
||||
import { CONTENT_TOPICS } from "./constants";
|
||||
import { OpchanMessage } from "@/types";
|
||||
import { encodeMessage, encoders, decoders, decodeMessage } from "./codec";
|
||||
|
||||
export class EphemeralProtocolsManager {
|
||||
private node: LightNode;
|
||||
@ -13,45 +13,21 @@ export class EphemeralProtocolsManager {
|
||||
|
||||
public async sendMessage(message: OpchanMessage) {
|
||||
const encodedMessage = encodeMessage(message);
|
||||
await this.node.lightPush.send(encoders[message.type], {
|
||||
const result = await this.node.lightPush.send(encoders[message.type], {
|
||||
payload: encodedMessage
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
public async subscribeToMessages(types: MessageType[]) {
|
||||
const result: (CellMessage | PostMessage | CommentMessage | VoteMessage)[] = [];
|
||||
|
||||
const subscription = await this.node.filter.subscribe(Object.values(decoders), async (message) => {
|
||||
const {contentTopic, payload} = message;
|
||||
const toDecode = [
|
||||
types.includes(MessageType.CELL) ? decodeCellMessage(payload) : null,
|
||||
types.includes(MessageType.POST) ? decodePostMessage(payload) : null,
|
||||
types.includes(MessageType.COMMENT) ? decodeCommentMessage(payload) : null,
|
||||
types.includes(MessageType.VOTE) ? decodeVoteMessage(payload) : null
|
||||
]
|
||||
const decodedMessage = await Promise.race(toDecode);
|
||||
const {payload} = message;
|
||||
|
||||
let parsedMessage: OpchanMessage | null = null;
|
||||
switch(contentTopic) {
|
||||
case CONTENT_TOPICS['cell']:
|
||||
parsedMessage = decodedMessage as CellMessage;
|
||||
break;
|
||||
case CONTENT_TOPICS['post']:
|
||||
parsedMessage = decodedMessage as PostMessage;
|
||||
break;
|
||||
case CONTENT_TOPICS['comment']:
|
||||
parsedMessage = decodedMessage as CommentMessage;
|
||||
break;
|
||||
case CONTENT_TOPICS['vote']:
|
||||
parsedMessage = decodedMessage as VoteMessage;
|
||||
break;
|
||||
default:
|
||||
console.error(`Unknown content topic: ${contentTopic}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsedMessage) {
|
||||
result.push(parsedMessage);
|
||||
const decodedMessage = decodeMessage(payload);
|
||||
if (types.includes(decodedMessage.type)) {
|
||||
result.push(decodedMessage);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { IDecodedMessage, LightNode } from "@waku/sdk";
|
||||
import { decoders, decodeCellMessage, decodePostMessage, decodeCommentMessage, decodeVoteMessage } from "./codec";
|
||||
import { decodeMessage, decoders} from "./codec";
|
||||
import { CONTENT_TOPICS } from "./constants";
|
||||
import { CellMessage, PostMessage, CommentMessage, VoteMessage } from "./types";
|
||||
|
||||
@ -16,32 +16,12 @@ class StoreManager {
|
||||
await this.node.store.queryWithOrderedCallback(
|
||||
Object.values(decoders),
|
||||
(message: IDecodedMessage) => {
|
||||
const {contentTopic, payload} = message;
|
||||
let parsedMessage: (CellMessage | PostMessage | CommentMessage | VoteMessage) | null = null;
|
||||
|
||||
switch(contentTopic) {
|
||||
case CONTENT_TOPICS['cell']:
|
||||
parsedMessage = decodeCellMessage(payload) as CellMessage;
|
||||
break;
|
||||
case CONTENT_TOPICS['post']:
|
||||
parsedMessage = decodePostMessage(payload) as PostMessage;
|
||||
break;
|
||||
case CONTENT_TOPICS['comment']:
|
||||
parsedMessage = decodeCommentMessage(payload) as CommentMessage;
|
||||
break;
|
||||
case CONTENT_TOPICS['vote']:
|
||||
parsedMessage = decodeVoteMessage(payload) as VoteMessage;
|
||||
break;
|
||||
default:
|
||||
console.error(`Unknown content topic: ${contentTopic}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsedMessage) {
|
||||
result.push(parsedMessage);
|
||||
}
|
||||
const { payload} = message;
|
||||
const decodedMessage = decodeMessage(payload);
|
||||
result.push(decodedMessage);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@ -1,14 +1,30 @@
|
||||
|
||||
import React from 'react';
|
||||
import Header from '@/components/Header';
|
||||
import CellList from '@/components/CellList';
|
||||
import { useForum } from '@/contexts/ForumContext';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Wifi } from 'lucide-react';
|
||||
|
||||
const Index = () => {
|
||||
const { isNetworkConnected, refreshData } = useForum();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-cyber-dark text-white">
|
||||
<Header />
|
||||
<main className="flex-1">
|
||||
<main className="flex-1 relative">
|
||||
<CellList />
|
||||
{!isNetworkConnected && (
|
||||
<div className="fixed bottom-4 right-4">
|
||||
<Button
|
||||
onClick={refreshData}
|
||||
variant="destructive"
|
||||
className="flex items-center gap-2 shadow-lg animate-pulse"
|
||||
>
|
||||
<Wifi className="w-4 h-4" />
|
||||
Reconnect
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
<footer className="border-t border-cyber-muted py-4 text-center text-xs text-cyber-neutral">
|
||||
<p>OpChan - A decentralized forum built on Waku & Bitcoin Ordinals</p>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user