mirror of
https://github.com/logos-messaging/OpChan.git
synced 2026-01-05 22:33:07 +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",
|
"sonner": "^1.5.0",
|
||||||
"tailwind-merge": "^2.5.2",
|
"tailwind-merge": "^2.5.2",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"uuid": "^11.1.0",
|
||||||
"vaul": "^0.9.3",
|
"vaul": "^0.9.3",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
@ -65,6 +66,7 @@
|
|||||||
"@types/node": "^22.5.5",
|
"@types/node": "^22.5.5",
|
||||||
"@types/react": "^18.3.3",
|
"@types/react": "^18.3.3",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
"@vitejs/plugin-react-swc": "^3.5.0",
|
"@vitejs/plugin-react-swc": "^3.5.0",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"eslint": "^9.9.0",
|
"eslint": "^9.9.0",
|
||||||
@ -3485,6 +3487,13 @@
|
|||||||
"integrity": "sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==",
|
"integrity": "sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/ws": {
|
||||||
"version": "8.18.1",
|
"version": "8.18.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
"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": {
|
"node_modules/@waku/discovery": {
|
||||||
"version": "0.0.7",
|
"version": "0.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/@waku/discovery/-/discovery-0.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/@waku/discovery/-/discovery-0.0.7.tgz",
|
||||||
@ -8382,16 +8404,16 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/uuid": {
|
"node_modules/uuid": {
|
||||||
"version": "9.0.1",
|
"version": "11.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
|
||||||
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
|
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
|
||||||
"funding": [
|
"funding": [
|
||||||
"https://github.com/sponsors/broofa",
|
"https://github.com/sponsors/broofa",
|
||||||
"https://github.com/sponsors/ctavan"
|
"https://github.com/sponsors/ctavan"
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
"uuid": "dist/bin/uuid"
|
"uuid": "dist/esm/bin/uuid"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vaul": {
|
"node_modules/vaul": {
|
||||||
|
|||||||
@ -59,6 +59,7 @@
|
|||||||
"sonner": "^1.5.0",
|
"sonner": "^1.5.0",
|
||||||
"tailwind-merge": "^2.5.2",
|
"tailwind-merge": "^2.5.2",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"uuid": "^11.1.0",
|
||||||
"vaul": "^0.9.3",
|
"vaul": "^0.9.3",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
@ -68,6 +69,7 @@
|
|||||||
"@types/node": "^22.5.5",
|
"@types/node": "^22.5.5",
|
||||||
"@types/react": "^18.3.3",
|
"@types/react": "^18.3.3",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
"@vitejs/plugin-react-swc": "^3.5.0",
|
"@vitejs/plugin-react-swc": "^3.5.0",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"eslint": "^9.9.0",
|
"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 } from "@/components/ui/toaster";
|
||||||
import { Toaster as Sonner } from "@/components/ui/sonner";
|
import { Toaster as Sonner } from "@/components/ui/sonner";
|
||||||
|
|||||||
@ -1,15 +1,15 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { useForum } from '@/contexts/ForumContext';
|
import { useForum } from '@/contexts/ForumContext';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Layout, MessageSquare } from 'lucide-react';
|
import { Layout, MessageSquare, RefreshCw } from 'lucide-react';
|
||||||
import { CreateCellDialog } from './CreateCellDialog';
|
import { CreateCellDialog } from './CreateCellDialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
const CellList = () => {
|
const CellList = () => {
|
||||||
const { cells, loading, posts } = useForum();
|
const { cells, isInitialLoading, posts, refreshData, isRefreshing } = useForum();
|
||||||
|
|
||||||
if (loading) {
|
if (isInitialLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-4 py-8 max-w-4xl">
|
<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>
|
<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" />
|
<Layout className="text-cyber-accent w-6 h-6" />
|
||||||
<h1 className="text-2xl font-bold text-glow">Cells</h1>
|
<h1 className="text-2xl font-bold text-glow">Cells</h1>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
{cells.map((cell) => (
|
{cells.length === 0 ? (
|
||||||
<Link to={`/cell/${cell.id}`} key={cell.id} className="board-card group">
|
<div className="col-span-2 text-center py-12">
|
||||||
<div className="flex gap-4 items-start">
|
<div className="text-cyber-neutral mb-4">
|
||||||
<img
|
No cells found. Be the first to create one!
|
||||||
src={cell.icon}
|
</div>
|
||||||
alt={cell.name}
|
</div>
|
||||||
className="w-16 h-16 object-cover rounded-sm border border-cyber-muted group-hover:border-cyber-accent transition-colors"
|
) : (
|
||||||
/>
|
cells.map((cell) => (
|
||||||
<div className="flex-1">
|
<Link to={`/cell/${cell.id}`} key={cell.id} className="board-card group">
|
||||||
<h2 className="text-xl font-bold mb-1 group-hover:text-cyber-accent transition-colors">{cell.name}</h2>
|
<div className="flex gap-4 items-start">
|
||||||
<p className="text-sm text-cyber-neutral mb-2">{cell.description}</p>
|
<img
|
||||||
<div className="flex items-center text-xs text-cyber-neutral">
|
src={cell.icon}
|
||||||
<MessageSquare className="w-3 h-3 mr-1" />
|
alt={cell.name}
|
||||||
<span>{getPostCount(cell.id)} threads</span>
|
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>
|
</div>
|
||||||
</div>
|
</Link>
|
||||||
</Link>
|
))
|
||||||
))}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
@ -32,7 +31,7 @@ const formSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export function CreateCellDialog() {
|
export function CreateCellDialog() {
|
||||||
const { createCell } = useForum();
|
const { createCell, isPostingCell } = useForum();
|
||||||
const { isAuthenticated } = useAuth();
|
const { isAuthenticated } = useAuth();
|
||||||
const [open, setOpen] = React.useState(false);
|
const [open, setOpen] = React.useState(false);
|
||||||
|
|
||||||
@ -73,7 +72,7 @@ export function CreateCellDialog() {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Title</FormLabel>
|
<FormLabel>Title</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="Enter cell title" {...field} />
|
<Input placeholder="Enter cell title" {...field} disabled={isPostingCell} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@ -89,6 +88,7 @@ export function CreateCellDialog() {
|
|||||||
<Textarea
|
<Textarea
|
||||||
placeholder="Enter cell description"
|
placeholder="Enter cell description"
|
||||||
{...field}
|
{...field}
|
||||||
|
disabled={isPostingCell}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@ -106,6 +106,7 @@ export function CreateCellDialog() {
|
|||||||
placeholder="Enter icon URL"
|
placeholder="Enter icon URL"
|
||||||
type="url"
|
type="url"
|
||||||
{...field}
|
{...field}
|
||||||
|
disabled={isPostingCell}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@ -115,9 +116,9 @@ export function CreateCellDialog() {
|
|||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
disabled={form.formState.isSubmitting}
|
disabled={isPostingCell}
|
||||||
>
|
>
|
||||||
{form.formState.isSubmitting && (
|
{isPostingCell && (
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
)}
|
)}
|
||||||
Create Cell
|
Create Cell
|
||||||
|
|||||||
@ -1,12 +1,15 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
|
import { useForum } from '@/contexts/ForumContext';
|
||||||
import { Button } from '@/components/ui/button';
|
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 Header = () => {
|
||||||
const { currentUser, isAuthenticated, connectWallet, disconnectWallet, verifyOrdinal } = useAuth();
|
const { currentUser, isAuthenticated, connectWallet, disconnectWallet, verifyOrdinal } = useAuth();
|
||||||
|
const { isNetworkConnected, isRefreshing } = useForum();
|
||||||
|
|
||||||
const handleConnect = async () => {
|
const handleConnect = async () => {
|
||||||
await connectWallet();
|
await connectWallet();
|
||||||
@ -33,7 +36,36 @@ const Header = () => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</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 ? (
|
{!currentUser ? (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Link, useParams, useNavigate } from 'react-router-dom';
|
import { Link, useParams, useNavigate } from 'react-router-dom';
|
||||||
import { useForum } from '@/contexts/ForumContext';
|
import { useForum } from '@/contexts/ForumContext';
|
||||||
@ -6,19 +5,31 @@ import { useAuth } from '@/contexts/AuthContext';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
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 { formatDistanceToNow } from 'date-fns';
|
||||||
import { Comment } from '@/types';
|
import { Comment } from '@/types';
|
||||||
|
|
||||||
const PostDetail = () => {
|
const PostDetail = () => {
|
||||||
const { postId } = useParams<{ postId: string }>();
|
const { postId } = useParams<{ postId: string }>();
|
||||||
const navigate = useNavigate();
|
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 { currentUser, isAuthenticated } = useAuth();
|
||||||
const [newComment, setNewComment] = useState('');
|
const [newComment, setNewComment] = useState('');
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
|
|
||||||
if (!postId || loading) {
|
if (!postId || isInitialLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-4 py-8 max-w-4xl">
|
<div className="container mx-auto px-4 py-8 max-w-4xl">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
@ -77,14 +88,13 @@ const PostDetail = () => {
|
|||||||
|
|
||||||
if (!newComment.trim()) return;
|
if (!newComment.trim()) return;
|
||||||
|
|
||||||
setIsSubmitting(true);
|
|
||||||
try {
|
try {
|
||||||
const result = await createComment(postId, newComment);
|
const result = await createComment(postId, newComment);
|
||||||
if (result) {
|
if (result) {
|
||||||
setNewComment('');
|
setNewComment('');
|
||||||
}
|
}
|
||||||
} finally {
|
} catch (error) {
|
||||||
setIsSubmitting(false);
|
console.error("Error creating comment:", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -98,19 +108,18 @@ const PostDetail = () => {
|
|||||||
await voteComment(commentId, isUpvote);
|
await voteComment(commentId, isUpvote);
|
||||||
};
|
};
|
||||||
|
|
||||||
const isPostUpvoted = currentUser && post.upvotes.includes(currentUser.address);
|
const isPostUpvoted = currentUser && post.upvotes.some(vote => vote.author === currentUser.address);
|
||||||
const isPostDownvoted = currentUser && post.downvotes.includes(currentUser.address);
|
const isPostDownvoted = currentUser && post.downvotes.some(vote => vote.author === currentUser.address);
|
||||||
|
|
||||||
const isCommentVoted = (comment: Comment, isUpvote: boolean) => {
|
const isCommentVoted = (comment: Comment, isUpvote: boolean) => {
|
||||||
if (!currentUser) return false;
|
if (!currentUser) return false;
|
||||||
return isUpvote
|
const votes = isUpvote ? comment.upvotes : comment.downvotes;
|
||||||
? comment.upvotes.includes(currentUser.address)
|
return votes.some(vote => vote.author === currentUser.address);
|
||||||
: comment.downvotes.includes(currentUser.address);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-4 py-8 max-w-4xl">
|
<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
|
<Link
|
||||||
to={cell ? `/cell/${cell.id}` : '/'}
|
to={cell ? `/cell/${cell.id}` : '/'}
|
||||||
className="text-cyber-accent hover:underline flex items-center gap-1 text-sm"
|
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" />
|
<ArrowLeft className="w-4 h-4" />
|
||||||
{cell ? `Back to ${cell.name}` : 'Back to Cells'}
|
{cell ? `Back to ${cell.name}` : 'Back to Cells'}
|
||||||
</Link>
|
</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>
|
||||||
|
|
||||||
<div className="border border-cyber-muted rounded-sm p-4 mb-8">
|
<div className="border border-cyber-muted rounded-sm p-4 mb-8">
|
||||||
@ -126,7 +144,7 @@ const PostDetail = () => {
|
|||||||
<button
|
<button
|
||||||
className={`p-1 rounded-sm hover:bg-cyber-muted/50 ${isPostUpvoted ? 'text-cyber-accent' : ''}`}
|
className={`p-1 rounded-sm hover:bg-cyber-muted/50 ${isPostUpvoted ? 'text-cyber-accent' : ''}`}
|
||||||
onClick={() => handleVotePost(true)}
|
onClick={() => handleVotePost(true)}
|
||||||
disabled={!isAuthenticated}
|
disabled={!isAuthenticated || isVoting}
|
||||||
title={isAuthenticated ? "Upvote" : "Verify Ordinal to vote"}
|
title={isAuthenticated ? "Upvote" : "Verify Ordinal to vote"}
|
||||||
>
|
>
|
||||||
<ArrowUp className="w-5 h-5" />
|
<ArrowUp className="w-5 h-5" />
|
||||||
@ -135,7 +153,7 @@ const PostDetail = () => {
|
|||||||
<button
|
<button
|
||||||
className={`p-1 rounded-sm hover:bg-cyber-muted/50 ${isPostDownvoted ? 'text-cyber-accent' : ''}`}
|
className={`p-1 rounded-sm hover:bg-cyber-muted/50 ${isPostDownvoted ? 'text-cyber-accent' : ''}`}
|
||||||
onClick={() => handleVotePost(false)}
|
onClick={() => handleVotePost(false)}
|
||||||
disabled={!isAuthenticated}
|
disabled={!isAuthenticated || isVoting}
|
||||||
title={isAuthenticated ? "Downvote" : "Verify Ordinal to vote"}
|
title={isAuthenticated ? "Downvote" : "Verify Ordinal to vote"}
|
||||||
>
|
>
|
||||||
<ArrowDown className="w-5 h-5" />
|
<ArrowDown className="w-5 h-5" />
|
||||||
@ -143,6 +161,7 @@ const PostDetail = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
|
<h2 className="text-xl font-bold mb-2">{post.title}</h2>
|
||||||
<p className="text-lg mb-4">{post.content}</p>
|
<p className="text-lg mb-4">{post.content}</p>
|
||||||
<div className="flex items-center gap-4 text-xs text-cyber-neutral">
|
<div className="flex items-center gap-4 text-xs text-cyber-neutral">
|
||||||
<span className="flex items-center">
|
<span className="flex items-center">
|
||||||
@ -170,11 +189,11 @@ const PostDetail = () => {
|
|||||||
value={newComment}
|
value={newComment}
|
||||||
onChange={(e) => setNewComment(e.target.value)}
|
onChange={(e) => setNewComment(e.target.value)}
|
||||||
className="flex-1 bg-cyber-muted/50 border-cyber-muted resize-none"
|
className="flex-1 bg-cyber-muted/50 border-cyber-muted resize-none"
|
||||||
disabled={isSubmitting}
|
disabled={isPostingComment}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isSubmitting || !newComment.trim()}
|
disabled={isPostingComment || !newComment.trim()}
|
||||||
size="icon"
|
size="icon"
|
||||||
>
|
>
|
||||||
<Send className="w-4 h-4" />
|
<Send className="w-4 h-4" />
|
||||||
@ -204,7 +223,7 @@ const PostDetail = () => {
|
|||||||
<button
|
<button
|
||||||
className={`p-0.5 rounded-sm hover:bg-cyber-muted/50 ${isCommentVoted(comment, true) ? 'text-cyber-accent' : ''}`}
|
className={`p-0.5 rounded-sm hover:bg-cyber-muted/50 ${isCommentVoted(comment, true) ? 'text-cyber-accent' : ''}`}
|
||||||
onClick={() => handleVoteComment(comment.id, true)}
|
onClick={() => handleVoteComment(comment.id, true)}
|
||||||
disabled={!isAuthenticated}
|
disabled={!isAuthenticated || isVoting}
|
||||||
title={isAuthenticated ? "Upvote" : "Verify Ordinal to vote"}
|
title={isAuthenticated ? "Upvote" : "Verify Ordinal to vote"}
|
||||||
>
|
>
|
||||||
<ArrowUp className="w-4 h-4" />
|
<ArrowUp className="w-4 h-4" />
|
||||||
@ -213,7 +232,7 @@ const PostDetail = () => {
|
|||||||
<button
|
<button
|
||||||
className={`p-0.5 rounded-sm hover:bg-cyber-muted/50 ${isCommentVoted(comment, false) ? 'text-cyber-accent' : ''}`}
|
className={`p-0.5 rounded-sm hover:bg-cyber-muted/50 ${isCommentVoted(comment, false) ? 'text-cyber-accent' : ''}`}
|
||||||
onClick={() => handleVoteComment(comment.id, false)}
|
onClick={() => handleVoteComment(comment.id, false)}
|
||||||
disabled={!isAuthenticated}
|
disabled={!isAuthenticated || isVoting}
|
||||||
title={isAuthenticated ? "Downvote" : "Verify Ordinal to vote"}
|
title={isAuthenticated ? "Downvote" : "Verify Ordinal to vote"}
|
||||||
>
|
>
|
||||||
<ArrowDown className="w-4 h-4" />
|
<ArrowDown className="w-4 h-4" />
|
||||||
|
|||||||
@ -1,22 +1,31 @@
|
|||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Link, useParams } from 'react-router-dom';
|
import { Link, useParams } from 'react-router-dom';
|
||||||
import { useForum } from '@/contexts/ForumContext';
|
import { useForum } from '@/contexts/ForumContext';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
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';
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
|
|
||||||
const PostList = () => {
|
const PostList = () => {
|
||||||
const { cellId } = useParams<{ cellId: string }>();
|
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 { isAuthenticated } = useAuth();
|
||||||
|
const [newPostTitle, setNewPostTitle] = useState('');
|
||||||
const [newPostContent, setNewPostContent] = useState('');
|
const [newPostContent, setNewPostContent] = useState('');
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
|
|
||||||
if (!cellId || loading) {
|
if (!cellId || isInitialLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-4 py-8 max-w-4xl">
|
<div className="container mx-auto px-4 py-8 max-w-4xl">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
@ -70,14 +79,14 @@ const PostList = () => {
|
|||||||
|
|
||||||
if (!newPostContent.trim()) return;
|
if (!newPostContent.trim()) return;
|
||||||
|
|
||||||
setIsSubmitting(true);
|
|
||||||
try {
|
try {
|
||||||
const post = await createPost(cellId, newPostContent);
|
const post = await createPost(cellId, newPostTitle, newPostContent);
|
||||||
if (post) {
|
if (post) {
|
||||||
|
setNewPostTitle('');
|
||||||
setNewPostContent('');
|
setNewPostContent('');
|
||||||
}
|
}
|
||||||
} finally {
|
} catch (error) {
|
||||||
setIsSubmitting(false);
|
console.error("Error creating post:", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -95,8 +104,19 @@ const PostList = () => {
|
|||||||
alt={cell.name}
|
alt={cell.name}
|
||||||
className="w-12 h-12 object-cover rounded-sm border border-cyber-muted"
|
className="w-12 h-12 object-cover rounded-sm border border-cyber-muted"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div className="flex-1">
|
||||||
<h1 className="text-2xl font-bold text-glow">{cell.name}</h1>
|
<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>
|
<p className="text-cyber-neutral">{cell.description}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -108,19 +128,28 @@ const PostList = () => {
|
|||||||
<MessageSquare className="w-4 h-4" />
|
<MessageSquare className="w-4 h-4" />
|
||||||
New Thread
|
New Thread
|
||||||
</h2>
|
</h2>
|
||||||
<Textarea
|
<div className="mb-3">
|
||||||
placeholder="What's on your mind?"
|
<Input
|
||||||
value={newPostContent}
|
placeholder="Thread title"
|
||||||
onChange={(e) => setNewPostContent(e.target.value)}
|
value={newPostTitle}
|
||||||
className="mb-3 bg-cyber-muted/50 border-cyber-muted resize-none"
|
onChange={(e) => setNewPostTitle(e.target.value)}
|
||||||
disabled={isSubmitting}
|
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">
|
<div className="flex justify-end">
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isSubmitting || !newPostContent.trim()}
|
disabled={isPostingPost || !newPostContent.trim() || !newPostTitle.trim()}
|
||||||
>
|
>
|
||||||
{isSubmitting ? 'Posting...' : 'Post Thread'}
|
{isPostingPost ? 'Posting...' : 'Post Thread'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@ -142,6 +171,7 @@ const PostList = () => {
|
|||||||
posts.map(post => (
|
posts.map(post => (
|
||||||
<Link to={`/post/${post.id}`} key={post.id} className="thread-card block">
|
<Link to={`/post/${post.id}`} key={post.id} className="thread-card block">
|
||||||
<div>
|
<div>
|
||||||
|
<h3 className="font-medium mb-1">{post.title}</h3>
|
||||||
<p className="text-sm mb-3">{post.content}</p>
|
<p className="text-sm mb-3">{post.content}</p>
|
||||||
<div className="flex items-center gap-4 text-xs text-cyber-neutral">
|
<div className="flex items-center gap-4 text-xs text-cyber-neutral">
|
||||||
<span className="flex items-center">
|
<span className="flex items-center">
|
||||||
@ -150,8 +180,7 @@ const PostList = () => {
|
|||||||
</span>
|
</span>
|
||||||
<span className="flex items-center">
|
<span className="flex items-center">
|
||||||
<MessageCircle className="w-3 h-3 mr-1" />
|
<MessageCircle className="w-3 h-3 mr-1" />
|
||||||
{/* This would need to be calculated based on actual comments */}
|
{getCommentsByPost(post.id).length} comments
|
||||||
0 comments
|
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<ArrowUp className="w-3 h-3 mr-1" />
|
<ArrowUp className="w-3 h-3 mr-1" />
|
||||||
|
|||||||
@ -1,23 +1,34 @@
|
|||||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
import { Cell, Post, Comment } from '@/types';
|
import { Cell, Post, Comment } from '@/types';
|
||||||
import { mockCells, mockPosts, mockComments } from '@/data/mockData';
|
|
||||||
import { useAuth } from './AuthContext';
|
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 {
|
interface ForumContextType {
|
||||||
cells: Cell[];
|
cells: Cell[];
|
||||||
posts: Post[];
|
posts: Post[];
|
||||||
comments: Comment[];
|
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;
|
error: string | null;
|
||||||
getCellById: (id: string) => Cell | undefined;
|
getCellById: (id: string) => Cell | undefined;
|
||||||
getPostsByCell: (cellId: string) => Post[];
|
getPostsByCell: (cellId: string) => Post[];
|
||||||
getCommentsByPost: (postId: string) => Comment[];
|
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>;
|
createComment: (postId: string, content: string) => Promise<Comment | null>;
|
||||||
votePost: (postId: string, isUpvote: boolean) => Promise<boolean>;
|
votePost: (postId: string, isUpvote: boolean) => Promise<boolean>;
|
||||||
voteComment: (commentId: 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);
|
const ForumContext = createContext<ForumContextType | undefined>(undefined);
|
||||||
@ -26,28 +37,230 @@ export function ForumProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const [cells, setCells] = useState<Cell[]>([]);
|
const [cells, setCells] = useState<Cell[]>([]);
|
||||||
const [posts, setPosts] = useState<Post[]>([]);
|
const [posts, setPosts] = useState<Post[]>([]);
|
||||||
const [comments, setComments] = useState<Comment[]>([]);
|
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 [error, setError] = useState<string | null>(null);
|
||||||
const { currentUser, isAuthenticated } = useAuth();
|
const { currentUser, isAuthenticated } = useAuth();
|
||||||
const { toast } = useToast();
|
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(() => {
|
useEffect(() => {
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
try {
|
try {
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
setIsInitialLoading(true);
|
||||||
setCells(mockCells);
|
|
||||||
setPosts(mockPosts);
|
toast({
|
||||||
setComments(mockComments);
|
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) {
|
} catch (err) {
|
||||||
console.error("Error loading forum data:", err);
|
console.error("Error loading forum data:", err);
|
||||||
setError("Failed to load forum data. Please try again later.");
|
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 {
|
} finally {
|
||||||
setLoading(false);
|
setIsInitialLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadData();
|
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 => {
|
const getCellById = (id: string): Cell | undefined => {
|
||||||
return cells.find(cell => cell.id === id);
|
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);
|
.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) {
|
if (!isAuthenticated || !currentUser) {
|
||||||
toast({
|
toast({
|
||||||
title: "Authentication Required",
|
title: "Authentication Required",
|
||||||
@ -74,24 +287,38 @@ export function ForumProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const newPost: Post = {
|
setIsPostingPost(true);
|
||||||
id: `post-${Date.now()}`,
|
|
||||||
|
toast({
|
||||||
|
title: "Creating post",
|
||||||
|
description: "Sending your post to the network...",
|
||||||
|
});
|
||||||
|
|
||||||
|
const postId = uuidv4();
|
||||||
|
|
||||||
|
const postMessage: PostMessage = {
|
||||||
|
type: MessageType.POST,
|
||||||
|
id: postId,
|
||||||
cellId,
|
cellId,
|
||||||
authorAddress: currentUser.address,
|
title,
|
||||||
content,
|
content,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
upvotes: [],
|
author: currentUser.address
|
||||||
downvotes: [],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
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({
|
toast({
|
||||||
title: "Post Created",
|
title: "Post Created",
|
||||||
description: "Your post has been published successfully.",
|
description: "Your post has been published successfully.",
|
||||||
});
|
});
|
||||||
|
|
||||||
return newPost;
|
// Return the transformed post
|
||||||
|
return transformPost(postMessage);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error creating post:", error);
|
console.error("Error creating post:", error);
|
||||||
toast({
|
toast({
|
||||||
@ -100,6 +327,8 @@ export function ForumProvider({ children }: { children: React.ReactNode }) {
|
|||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
});
|
});
|
||||||
return null;
|
return null;
|
||||||
|
} finally {
|
||||||
|
setIsPostingPost(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -114,24 +343,37 @@ export function ForumProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const newComment: Comment = {
|
setIsPostingComment(true);
|
||||||
id: `comment-${Date.now()}`,
|
|
||||||
|
toast({
|
||||||
|
title: "Posting comment",
|
||||||
|
description: "Sending your comment to the network...",
|
||||||
|
});
|
||||||
|
|
||||||
|
const commentId = uuidv4();
|
||||||
|
|
||||||
|
const commentMessage: CommentMessage = {
|
||||||
|
type: MessageType.COMMENT,
|
||||||
|
id: commentId,
|
||||||
postId,
|
postId,
|
||||||
authorAddress: currentUser.address,
|
|
||||||
content,
|
content,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
upvotes: [],
|
author: currentUser.address
|
||||||
downvotes: [],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
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({
|
toast({
|
||||||
title: "Comment Added",
|
title: "Comment Added",
|
||||||
description: "Your comment has been published.",
|
description: "Your comment has been published.",
|
||||||
});
|
});
|
||||||
|
|
||||||
return newComment;
|
// Return the transformed comment
|
||||||
|
return transformComment(commentMessage);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error creating comment:", error);
|
console.error("Error creating comment:", error);
|
||||||
toast({
|
toast({
|
||||||
@ -140,6 +382,8 @@ export function ForumProvider({ children }: { children: React.ReactNode }) {
|
|||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
});
|
});
|
||||||
return null;
|
return null;
|
||||||
|
} finally {
|
||||||
|
setIsPostingComment(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -154,29 +398,35 @@ export function ForumProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setPosts(prev => prev.map(post => {
|
setIsVoting(true);
|
||||||
if (post.id === postId) {
|
|
||||||
const userAddress = currentUser.address;
|
const voteType = isUpvote ? "upvote" : "downvote";
|
||||||
|
toast({
|
||||||
const filteredUpvotes = post.upvotes.filter(addr => addr !== userAddress);
|
title: `Sending ${voteType}`,
|
||||||
const filteredDownvotes = post.downvotes.filter(addr => addr !== userAddress);
|
description: "Recording your vote on the network...",
|
||||||
|
});
|
||||||
if (isUpvote) {
|
|
||||||
return {
|
const voteId = uuidv4();
|
||||||
...post,
|
|
||||||
upvotes: [...filteredUpvotes, userAddress],
|
const voteMessage: VoteMessage = {
|
||||||
downvotes: filteredDownvotes,
|
type: MessageType.VOTE,
|
||||||
};
|
id: voteId,
|
||||||
} else {
|
targetId: postId,
|
||||||
return {
|
value: isUpvote ? 1 : -1,
|
||||||
...post,
|
timestamp: Date.now(),
|
||||||
upvotes: filteredUpvotes,
|
author: currentUser.address
|
||||||
downvotes: [...filteredDownvotes, userAddress],
|
};
|
||||||
};
|
|
||||||
}
|
// Send the vote message to the network
|
||||||
}
|
await messageManager.sendMessage(voteMessage);
|
||||||
return post;
|
|
||||||
}));
|
// Update UI (the cache is already updated in sendMessage)
|
||||||
|
updateStateFromCache();
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Vote Recorded",
|
||||||
|
description: `Your ${voteType} has been registered.`,
|
||||||
|
});
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -187,6 +437,8 @@ export function ForumProvider({ children }: { children: React.ReactNode }) {
|
|||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
});
|
});
|
||||||
return false;
|
return false;
|
||||||
|
} finally {
|
||||||
|
setIsVoting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -201,29 +453,35 @@ export function ForumProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setComments(prev => prev.map(comment => {
|
setIsVoting(true);
|
||||||
if (comment.id === commentId) {
|
|
||||||
const userAddress = currentUser.address;
|
const voteType = isUpvote ? "upvote" : "downvote";
|
||||||
|
toast({
|
||||||
const filteredUpvotes = comment.upvotes.filter(addr => addr !== userAddress);
|
title: `Sending ${voteType}`,
|
||||||
const filteredDownvotes = comment.downvotes.filter(addr => addr !== userAddress);
|
description: "Recording your vote on the network...",
|
||||||
|
});
|
||||||
if (isUpvote) {
|
|
||||||
return {
|
const voteId = uuidv4();
|
||||||
...comment,
|
|
||||||
upvotes: [...filteredUpvotes, userAddress],
|
const voteMessage: VoteMessage = {
|
||||||
downvotes: filteredDownvotes,
|
type: MessageType.VOTE,
|
||||||
};
|
id: voteId,
|
||||||
} else {
|
targetId: commentId,
|
||||||
return {
|
value: isUpvote ? 1 : -1,
|
||||||
...comment,
|
timestamp: Date.now(),
|
||||||
upvotes: filteredUpvotes,
|
author: currentUser.address
|
||||||
downvotes: [...filteredDownvotes, userAddress],
|
};
|
||||||
};
|
|
||||||
}
|
// Send the vote message to the network
|
||||||
}
|
await messageManager.sendMessage(voteMessage);
|
||||||
return comment;
|
|
||||||
}));
|
// Update UI (the cache is already updated in sendMessage)
|
||||||
|
updateStateFromCache();
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Vote Recorded",
|
||||||
|
description: `Your ${voteType} has been registered.`,
|
||||||
|
});
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -234,10 +492,12 @@ export function ForumProvider({ children }: { children: React.ReactNode }) {
|
|||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
});
|
});
|
||||||
return false;
|
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) {
|
if (!isAuthenticated || !currentUser) {
|
||||||
toast({
|
toast({
|
||||||
title: "Authentication Required",
|
title: "Authentication Required",
|
||||||
@ -248,29 +508,47 @@ export function ForumProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const newCell: Cell = {
|
setIsPostingCell(true);
|
||||||
id: `cell-${Date.now()}`,
|
|
||||||
name: title,
|
toast({
|
||||||
|
title: "Creating cell",
|
||||||
|
description: "Sending your cell to the network...",
|
||||||
|
});
|
||||||
|
|
||||||
|
const cellId = uuidv4();
|
||||||
|
|
||||||
|
const cellMessage: CellMessage = {
|
||||||
|
type: MessageType.CELL,
|
||||||
|
id: cellId,
|
||||||
|
name,
|
||||||
description,
|
description,
|
||||||
icon,
|
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({
|
toast({
|
||||||
title: "Cell Created",
|
title: "Cell Created",
|
||||||
description: "Your cell has been created successfully.",
|
description: "Your cell has been created successfully.",
|
||||||
});
|
});
|
||||||
|
|
||||||
return newCell;
|
return transformCell(cellMessage);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error creating cell:", error);
|
console.error("Error creating cell:", error);
|
||||||
toast({
|
toast({
|
||||||
title: "Creation Failed",
|
title: "Cell Creation Failed",
|
||||||
description: "Failed to create cell. Please try again.",
|
description: "Failed to create cell. Please try again.",
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
});
|
});
|
||||||
return null;
|
return null;
|
||||||
|
} finally {
|
||||||
|
setIsPostingCell(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -280,7 +558,13 @@ export function ForumProvider({ children }: { children: React.ReactNode }) {
|
|||||||
cells,
|
cells,
|
||||||
posts,
|
posts,
|
||||||
comments,
|
comments,
|
||||||
loading,
|
isInitialLoading,
|
||||||
|
isPostingCell,
|
||||||
|
isPostingPost,
|
||||||
|
isPostingComment,
|
||||||
|
isVoting,
|
||||||
|
isRefreshing,
|
||||||
|
isNetworkConnected,
|
||||||
error,
|
error,
|
||||||
getCellById,
|
getCellById,
|
||||||
getPostsByCell,
|
getPostsByCell,
|
||||||
@ -290,6 +574,7 @@ export function ForumProvider({ children }: { children: React.ReactNode }) {
|
|||||||
votePost,
|
votePost,
|
||||||
voteComment,
|
voteComment,
|
||||||
createCell,
|
createCell,
|
||||||
|
refreshData
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@ -300,7 +585,7 @@ export function ForumProvider({ children }: { children: React.ReactNode }) {
|
|||||||
export const useForum = () => {
|
export const useForum = () => {
|
||||||
const context = useContext(ForumContext);
|
const context = useContext(ForumContext);
|
||||||
if (context === undefined) {
|
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;
|
return context;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,29 +1,45 @@
|
|||||||
import { createDecoder, createEncoder } from '@waku/sdk';
|
import { createDecoder, createEncoder } from '@waku/sdk';
|
||||||
import { MessageType } from './types';
|
import { MessageType } from './types';
|
||||||
import { CellMessage, PostMessage, CommentMessage, VoteMessage } 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';
|
import { OpchanMessage } from '@/types';
|
||||||
|
|
||||||
export const encoders = {
|
export const encoders = {
|
||||||
[MessageType.CELL]: createEncoder({
|
[MessageType.CELL]: createEncoder({
|
||||||
contentTopic: CONTENT_TOPICS['cell'],
|
contentTopic: CONTENT_TOPICS['cell'],
|
||||||
|
pubsubTopicShardInfo: {clusterId: NETWORK_CONFIG.clusterId, shard: 0}
|
||||||
}),
|
}),
|
||||||
[MessageType.POST]: createEncoder({
|
[MessageType.POST]: createEncoder({
|
||||||
contentTopic: CONTENT_TOPICS['post'],
|
contentTopic: CONTENT_TOPICS['post'],
|
||||||
|
pubsubTopicShardInfo: {clusterId: NETWORK_CONFIG.clusterId, shard: 0}
|
||||||
}),
|
}),
|
||||||
[MessageType.COMMENT]: createEncoder({
|
[MessageType.COMMENT]: createEncoder({
|
||||||
contentTopic: CONTENT_TOPICS['comment'],
|
contentTopic: CONTENT_TOPICS['comment'],
|
||||||
|
pubsubTopicShardInfo: {clusterId: NETWORK_CONFIG.clusterId, shard: 0}
|
||||||
}),
|
}),
|
||||||
[MessageType.VOTE]: createEncoder({
|
[MessageType.VOTE]: createEncoder({
|
||||||
contentTopic: CONTENT_TOPICS['vote'],
|
contentTopic: CONTENT_TOPICS['vote'],
|
||||||
|
pubsubTopicShardInfo: {clusterId: NETWORK_CONFIG.clusterId, shard: 0}
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const decoders = {
|
export const decoders = {
|
||||||
[MessageType.CELL]: createDecoder(CONTENT_TOPICS['cell']),
|
[MessageType.CELL]: createDecoder(CONTENT_TOPICS['cell'], {
|
||||||
[MessageType.POST]: createDecoder(CONTENT_TOPICS['post']),
|
clusterId: NETWORK_CONFIG.clusterId,
|
||||||
[MessageType.COMMENT]: createDecoder(CONTENT_TOPICS['comment']),
|
shard: 0
|
||||||
[MessageType.VOTE]: createDecoder(CONTENT_TOPICS['vote']),
|
}),
|
||||||
|
[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
|
* 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 messageJson = new TextDecoder().decode(payload);
|
||||||
const message = JSON.parse(messageJson) as OpchanMessage;
|
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
|
switch(message.type) {
|
||||||
return message;
|
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 = {
|
export const NETWORK_CONFIG: NetworkConfig = {
|
||||||
contentTopics: Object.values(CONTENT_TOPICS),
|
// contentTopics: Object.values(CONTENT_TOPICS),
|
||||||
shards: [1],
|
clusterId: 42,
|
||||||
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
|
* These are public Waku nodes that our node will connect to on startup
|
||||||
*/
|
*/
|
||||||
export const BOOTSTRAP_NODES = {
|
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/node-01.do-ams3.waku.sandbox.status.im/tcp/30303/p2p/16Uiu2HAmNaeL4p3WEYzC9mgXBmBWSgWjPHRvatZTXnp8Jgv3iKsb",
|
||||||
"/dns4/waku.fryorcraken.xyz/tcp/8000/wss/p2p/16Uiu2HAmMRvhDHrtiHft1FTUYnn6cVA8AWVrTyLUayJJ3MWpUZDB",
|
// "/dns4/waku.fryorcraken.xyz/tcp/8000/wss/p2p/16Uiu2HAmMRvhDHrtiHft1FTUYnn6cVA8AWVrTyLUayJJ3MWpUZDB",
|
||||||
"/dns4/vps-aaa00d52.vps.ovh.ca/tcp/8000/wss/p2p/16Uiu2HAm9PftGgHZwWE3wzdMde4m3kT2eYJFXLZfGoSED3gysofk"]
|
// "/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 { BOOTSTRAP_NODES } from "./constants";
|
||||||
import StoreManager from "./store";
|
import StoreManager from "./store";
|
||||||
import { CommentCache, MessageType, VoteCache } from "./types";
|
import { CommentCache, MessageType, VoteCache } from "./types";
|
||||||
@ -8,11 +11,16 @@ import { OpchanMessage } from "@/types";
|
|||||||
import { EphemeralProtocolsManager } from "./lightpush_filter";
|
import { EphemeralProtocolsManager } from "./lightpush_filter";
|
||||||
import { NETWORK_CONFIG } from "./constants";
|
import { NETWORK_CONFIG } from "./constants";
|
||||||
|
|
||||||
|
export type HealthChangeCallback = (isReady: boolean) => void;
|
||||||
|
|
||||||
class MessageManager {
|
class MessageManager {
|
||||||
private node: LightNode;
|
private node: LightNode;
|
||||||
//TODO: implement SDS?
|
//TODO: implement SDS?
|
||||||
private ephemeralProtocolsManager: EphemeralProtocolsManager;
|
private ephemeralProtocolsManager: EphemeralProtocolsManager;
|
||||||
private storeManager: StoreManager;
|
private storeManager: StoreManager;
|
||||||
|
private _isReady: boolean = false;
|
||||||
|
private healthListeners: Set<HealthChangeCallback> = new Set();
|
||||||
|
private peerCheckInterval: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
|
||||||
public readonly messageCache: {
|
public readonly messageCache: {
|
||||||
@ -33,19 +41,115 @@ class MessageManager {
|
|||||||
networkConfig: NETWORK_CONFIG,
|
networkConfig: NETWORK_CONFIG,
|
||||||
autoStart: true,
|
autoStart: true,
|
||||||
bootstrapPeers: BOOTSTRAP_NODES[42],
|
bootstrapPeers: BOOTSTRAP_NODES[42],
|
||||||
lightPush:{autoRetry: true, retryIntervalMs: 1000}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return new MessageManager(node);
|
return new MessageManager(node);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async stop() {
|
public async stop() {
|
||||||
|
if (this.peerCheckInterval) {
|
||||||
|
clearInterval(this.peerCheckInterval);
|
||||||
|
this.peerCheckInterval = null;
|
||||||
|
}
|
||||||
await this.node.stop();
|
await this.node.stop();
|
||||||
|
this.setIsReady(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private constructor(node: LightNode) {
|
private constructor(node: LightNode) {
|
||||||
this.node = node;
|
this.node = node;
|
||||||
this.ephemeralProtocolsManager = new EphemeralProtocolsManager(node);
|
this.ephemeralProtocolsManager = new EphemeralProtocolsManager(node);
|
||||||
this.storeManager = new StoreManager(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() {
|
public async queryStore() {
|
||||||
@ -97,9 +201,8 @@ class MessageManager {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create singleton instance
|
||||||
const messageManager = await MessageManager.create();
|
const messageManager = await MessageManager.create();
|
||||||
export default messageManager;
|
export default messageManager;
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import { LightNode } from "@waku/sdk";
|
import { LightNode } from "@waku/sdk";
|
||||||
import { decodeCellMessage, decodeCommentMessage, decodePostMessage, decoders, decodeVoteMessage, encodeMessage, encoders } from "./codec";
|
|
||||||
import { CellMessage, CommentMessage, MessageType, PostMessage, VoteMessage } from "./types";
|
import { CellMessage, CommentMessage, MessageType, PostMessage, VoteMessage } from "./types";
|
||||||
import { CONTENT_TOPICS } from "./constants";
|
import { CONTENT_TOPICS } from "./constants";
|
||||||
import { OpchanMessage } from "@/types";
|
import { OpchanMessage } from "@/types";
|
||||||
|
import { encodeMessage, encoders, decoders, decodeMessage } from "./codec";
|
||||||
|
|
||||||
export class EphemeralProtocolsManager {
|
export class EphemeralProtocolsManager {
|
||||||
private node: LightNode;
|
private node: LightNode;
|
||||||
@ -13,45 +13,21 @@ export class EphemeralProtocolsManager {
|
|||||||
|
|
||||||
public async sendMessage(message: OpchanMessage) {
|
public async sendMessage(message: OpchanMessage) {
|
||||||
const encodedMessage = encodeMessage(message);
|
const encodedMessage = encodeMessage(message);
|
||||||
await this.node.lightPush.send(encoders[message.type], {
|
const result = await this.node.lightPush.send(encoders[message.type], {
|
||||||
payload: encodedMessage
|
payload: encodedMessage
|
||||||
});
|
});
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async subscribeToMessages(types: MessageType[]) {
|
public async subscribeToMessages(types: MessageType[]) {
|
||||||
const result: (CellMessage | PostMessage | CommentMessage | VoteMessage)[] = [];
|
const result: (CellMessage | PostMessage | CommentMessage | VoteMessage)[] = [];
|
||||||
|
|
||||||
const subscription = await this.node.filter.subscribe(Object.values(decoders), async (message) => {
|
const subscription = await this.node.filter.subscribe(Object.values(decoders), async (message) => {
|
||||||
const {contentTopic, payload} = message;
|
const {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);
|
|
||||||
|
|
||||||
let parsedMessage: OpchanMessage | null = null;
|
const decodedMessage = decodeMessage(payload);
|
||||||
switch(contentTopic) {
|
if (types.includes(decodedMessage.type)) {
|
||||||
case CONTENT_TOPICS['cell']:
|
result.push(decodedMessage);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { IDecodedMessage, LightNode } from "@waku/sdk";
|
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 { CONTENT_TOPICS } from "./constants";
|
||||||
import { CellMessage, PostMessage, CommentMessage, VoteMessage } from "./types";
|
import { CellMessage, PostMessage, CommentMessage, VoteMessage } from "./types";
|
||||||
|
|
||||||
@ -16,32 +16,12 @@ class StoreManager {
|
|||||||
await this.node.store.queryWithOrderedCallback(
|
await this.node.store.queryWithOrderedCallback(
|
||||||
Object.values(decoders),
|
Object.values(decoders),
|
||||||
(message: IDecodedMessage) => {
|
(message: IDecodedMessage) => {
|
||||||
const {contentTopic, payload} = message;
|
const { payload} = message;
|
||||||
let parsedMessage: (CellMessage | PostMessage | CommentMessage | VoteMessage) | null = null;
|
const decodedMessage = decodeMessage(payload);
|
||||||
|
result.push(decodedMessage);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,14 +1,30 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Header from '@/components/Header';
|
import Header from '@/components/Header';
|
||||||
import CellList from '@/components/CellList';
|
import CellList from '@/components/CellList';
|
||||||
|
import { useForum } from '@/contexts/ForumContext';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Wifi } from 'lucide-react';
|
||||||
|
|
||||||
const Index = () => {
|
const Index = () => {
|
||||||
|
const { isNetworkConnected, refreshData } = useForum();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col bg-cyber-dark text-white">
|
<div className="min-h-screen flex flex-col bg-cyber-dark text-white">
|
||||||
<Header />
|
<Header />
|
||||||
<main className="flex-1">
|
<main className="flex-1 relative">
|
||||||
<CellList />
|
<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>
|
</main>
|
||||||
<footer className="border-t border-cyber-muted py-4 text-center text-xs text-cyber-neutral">
|
<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>
|
<p>OpChan - A decentralized forum built on Waku & Bitcoin Ordinals</p>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user