mirror of
https://github.com/logos-messaging/OpChan.git
synced 2026-01-02 12:53:10 +00:00
Merge 78b7ef64beb21adf149b054307615da1a1a5955c into 759aff01d0a21f6e3da4c10f337e35f6db28d35e
This commit is contained in:
commit
dfd2c525e3
@ -10,6 +10,7 @@ import Dashboard from './pages/Dashboard';
|
||||
import Index from './pages/Index';
|
||||
import ProfilePage from './pages/ProfilePage';
|
||||
import BookmarksPage from './pages/BookmarksPage';
|
||||
import FollowingPage from './pages/FollowingPage';
|
||||
import DebugPage from './pages/DebugPage';
|
||||
import TermsPage from './pages/TermsPage';
|
||||
import PrivacyPage from './pages/PrivacyPage';
|
||||
@ -30,6 +31,7 @@ const App = () => (
|
||||
<Route path="/post/:postId" element={<PostPage />} />
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
<Route path="/bookmarks" element={<BookmarksPage />} />
|
||||
<Route path="/following" element={<FollowingPage />} />
|
||||
<Route path="/debug" element={<DebugPage />} />
|
||||
<Route path="/terms" element={<TermsPage />} />
|
||||
<Route path="/privacy" element={<PrivacyPage />} />
|
||||
|
||||
@ -163,6 +163,13 @@ const Header = () => {
|
||||
>
|
||||
BOOKMARKS
|
||||
</Link>
|
||||
<span className="text-muted-foreground">|</span>
|
||||
<Link
|
||||
to="/following"
|
||||
className={location.pathname === '/following' ? 'text-primary' : 'text-muted-foreground hover:text-foreground'}
|
||||
>
|
||||
FOLLOWING
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
@ -14,6 +14,8 @@ const PostCard: React.FC<PostCardProps> = ({ post }) => {
|
||||
pending,
|
||||
vote,
|
||||
togglePostBookmark,
|
||||
toggleFollow,
|
||||
isFollowing,
|
||||
cells,
|
||||
commentsByPost,
|
||||
} = useContent();
|
||||
@ -29,6 +31,10 @@ const PostCard: React.FC<PostCardProps> = ({ post }) => {
|
||||
b => b.targetId === post.id && b.type === 'post'
|
||||
);
|
||||
const [bookmarkLoading, setBookmarkLoading] = React.useState(false);
|
||||
const [followLoading, setFollowLoading] = React.useState(false);
|
||||
|
||||
const isOwnPost = currentUser?.address === post.author;
|
||||
const isFollowingAuthor = isFollowing(post.author);
|
||||
|
||||
const score = post.upvotes.length - post.downvotes.length;
|
||||
const userUpvoted = Boolean(
|
||||
@ -65,6 +71,19 @@ const PostCard: React.FC<PostCardProps> = ({ post }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleFollow = async (e?: React.MouseEvent) => {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
setFollowLoading(true);
|
||||
try {
|
||||
await toggleFollow(post.author);
|
||||
} finally {
|
||||
setFollowLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-b border-border/30 py-3 px-4 text-sm">
|
||||
<div className="flex items-start gap-3">
|
||||
@ -126,6 +145,18 @@ const PostCard: React.FC<PostCardProps> = ({ post }) => {
|
||||
>
|
||||
{isBookmarked ? 'unsave' : 'save'}
|
||||
</button>
|
||||
{currentUser && !isOwnPost && (
|
||||
<>
|
||||
<span className="text-muted-foreground text-xs">·</span>
|
||||
<button
|
||||
onClick={handleFollow}
|
||||
disabled={followLoading}
|
||||
className={`hover:underline text-xs ${isFollowingAuthor ? 'text-red-400' : 'text-muted-foreground'}`}
|
||||
>
|
||||
{isFollowingAuthor ? 'unfollow' : 'follow'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{isPending && (
|
||||
<>
|
||||
<span className="text-muted-foreground text-xs">·</span>
|
||||
|
||||
@ -44,6 +44,7 @@ const PostDetail = () => {
|
||||
b => b.targetId === post?.id && b.type === 'post'
|
||||
);
|
||||
const [bookmarkLoading, setBookmarkLoading] = React.useState(false);
|
||||
const [followLoading, setFollowLoading] = React.useState(false);
|
||||
|
||||
const [newComment, setNewComment] = useState('');
|
||||
|
||||
@ -119,6 +120,22 @@ const PostDetail = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleFollow = async (e?: React.MouseEvent) => {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
setFollowLoading(true);
|
||||
try {
|
||||
await content.toggleFollow(post.author);
|
||||
} finally {
|
||||
setFollowLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isOwnPost = currentUser?.address === post.author;
|
||||
const isFollowingAuthor = content.isFollowing(post.author);
|
||||
|
||||
const score = post.upvotes.length - post.downvotes.length;
|
||||
const isPostUpvoted = Boolean(
|
||||
post.upvotes.some(v => v.author === currentUser?.address)
|
||||
@ -248,6 +265,15 @@ const PostDetail = () => {
|
||||
url={`${window.location.origin}/post/${post.id}`}
|
||||
title={post.title}
|
||||
/>
|
||||
{currentUser && !isOwnPost && (
|
||||
<button
|
||||
onClick={handleFollow}
|
||||
disabled={followLoading}
|
||||
className={`hover:underline ${isFollowingAuthor ? 'text-red-400' : ''}`}
|
||||
>
|
||||
{isFollowingAuthor ? 'unfollow' : 'follow'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
72
app/src/components/ui/follow-button.tsx
Normal file
72
app/src/components/ui/follow-button.tsx
Normal file
@ -0,0 +1,72 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { UserPlus, UserMinus, Loader2 } from 'lucide-react';
|
||||
import { useContent, useAuth } from '@/hooks';
|
||||
import { cn } from '../../utils';
|
||||
|
||||
interface FollowButtonProps {
|
||||
address: string;
|
||||
className?: string;
|
||||
variant?: 'default' | 'ghost' | 'outline';
|
||||
size?: 'default' | 'sm' | 'lg' | 'icon';
|
||||
showText?: boolean;
|
||||
}
|
||||
|
||||
export function FollowButton({
|
||||
address,
|
||||
className,
|
||||
variant = 'outline',
|
||||
size = 'sm',
|
||||
showText = true,
|
||||
}: FollowButtonProps) {
|
||||
const { currentUser } = useAuth();
|
||||
const { isFollowing, toggleFollow } = useContent();
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
|
||||
const isCurrentlyFollowing = isFollowing(address);
|
||||
const isOwnAddress = currentUser?.address === address;
|
||||
|
||||
// Don't show follow button for own address
|
||||
if (isOwnAddress || !currentUser) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleClick = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setLoading(true);
|
||||
try {
|
||||
await toggleFollow(address);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant={variant}
|
||||
size={size}
|
||||
onClick={handleClick}
|
||||
disabled={loading}
|
||||
className={cn(
|
||||
isCurrentlyFollowing
|
||||
? 'border-red-400/30 text-red-400 hover:bg-red-400/10 hover:text-red-300'
|
||||
: 'border-cyber-accent/30 text-cyber-accent hover:bg-cyber-accent/10',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : isCurrentlyFollowing ? (
|
||||
<UserMinus size={14} />
|
||||
) : (
|
||||
<UserPlus size={14} />
|
||||
)}
|
||||
{showText && (
|
||||
<span className="ml-1">
|
||||
{isCurrentlyFollowing ? 'Unfollow' : 'Follow'}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
118
app/src/components/ui/following-card.tsx
Normal file
118
app/src/components/ui/following-card.tsx
Normal file
@ -0,0 +1,118 @@
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { UserMinus, Users } from 'lucide-react';
|
||||
import { Following } from '@opchan/core';
|
||||
import { useUserDisplay } from '@opchan/react';
|
||||
import { cn } from '../../utils';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
interface FollowingCardProps {
|
||||
following: Following;
|
||||
onUnfollow: (followedAddress: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FollowingCard({
|
||||
following,
|
||||
onUnfollow,
|
||||
className,
|
||||
}: FollowingCardProps) {
|
||||
const userInfo = useUserDisplay(following.followedAddress);
|
||||
|
||||
// Fallback to truncated address if no display name
|
||||
const truncatedAddress = `${following.followedAddress.slice(0, 6)}...${following.followedAddress.slice(-4)}`;
|
||||
const displayName = userInfo.displayName || truncatedAddress;
|
||||
|
||||
const handleUnfollow = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onUnfollow(following.followedAddress);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
'group transition-all duration-200 hover:bg-cyber-muted/20 hover:border-cyber-accent/30',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
{/* User Info */}
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div className="w-10 h-10 rounded-full bg-cyber-accent/20 flex items-center justify-center flex-shrink-0">
|
||||
<Users size={20} className="text-cyber-accent" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-medium text-cyber-light truncate">
|
||||
{displayName}
|
||||
</h3>
|
||||
<p className="text-xs text-cyber-neutral truncate">
|
||||
Followed {formatDistanceToNow(new Date(following.followedAt), { addSuffix: true })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleUnfollow}
|
||||
className="text-red-400 hover:text-red-300 hover:bg-red-400/10"
|
||||
title="Unfollow"
|
||||
>
|
||||
<UserMinus size={14} className="mr-1" />
|
||||
Unfollow
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
interface FollowingListProps {
|
||||
following: Following[];
|
||||
onUnfollow: (followedAddress: string) => void;
|
||||
emptyMessage?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FollowingList({
|
||||
following,
|
||||
onUnfollow,
|
||||
emptyMessage = 'Not following anyone yet',
|
||||
className,
|
||||
}: FollowingListProps) {
|
||||
if (following.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center py-12 text-center',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Users size={48} className="text-cyber-neutral/50 mb-4" />
|
||||
<h3 className="text-lg font-medium text-cyber-light mb-2">
|
||||
{emptyMessage}
|
||||
</h3>
|
||||
<p className="text-cyber-neutral max-w-md">
|
||||
Follow users to see their posts in your personalized feed. Your following
|
||||
list is stored locally and won't be shared.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-4', className)}>
|
||||
{following.map(f => (
|
||||
<FollowingCard
|
||||
key={f.id}
|
||||
following={f}
|
||||
onUnfollow={onUnfollow}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
208
app/src/pages/FollowingPage.tsx
Normal file
208
app/src/pages/FollowingPage.tsx
Normal file
@ -0,0 +1,208 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import Header from '@/components/Header';
|
||||
import Footer from '@/components/Footer';
|
||||
import { FollowingList } from '@/components/ui/following-card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Post } from '@opchan/core';
|
||||
import {
|
||||
Trash2,
|
||||
Users,
|
||||
FileText,
|
||||
} from 'lucide-react';
|
||||
import { useAuth, useContent } from '@/hooks';
|
||||
import PostCard from '@/components/PostCard';
|
||||
|
||||
const FollowingPage = () => {
|
||||
const { currentUser } = useAuth();
|
||||
const { following, posts, unfollowUser, clearAllFollowing } = useContent();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'following' | 'feed'>('following');
|
||||
|
||||
// Get posts from followed users
|
||||
const followedAddresses = useMemo(
|
||||
() => following.map(f => f.followedAddress),
|
||||
[following]
|
||||
);
|
||||
|
||||
const followingPosts = useMemo(
|
||||
() => posts.filter(post => followedAddresses.includes(post.authorAddress)),
|
||||
[posts, followedAddresses]
|
||||
);
|
||||
|
||||
// Sort posts by timestamp (newest first)
|
||||
const sortedFollowingPosts = useMemo(
|
||||
() => [...followingPosts].sort((a, b) => b.timestamp - a.timestamp),
|
||||
[followingPosts]
|
||||
);
|
||||
|
||||
// Redirect to login if not authenticated
|
||||
if (!currentUser) {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-cyber-dark text-white">
|
||||
<Header />
|
||||
<main className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold text-cyber-light mb-4">
|
||||
Authentication Required
|
||||
</h1>
|
||||
<p className="text-cyber-neutral">
|
||||
Please connect your wallet to view your following list.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleUnfollow = async (followedAddress: string) => {
|
||||
await unfollowUser(followedAddress);
|
||||
};
|
||||
|
||||
const handleClearAll = async () => {
|
||||
await clearAllFollowing();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<Header />
|
||||
|
||||
<main className="page-content">
|
||||
<div className="page-main">
|
||||
{/* Header Section */}
|
||||
<div className="page-header">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Users className="text-cyber-accent" size={32} />
|
||||
<h1 className="page-title">Following</h1>
|
||||
</div>
|
||||
|
||||
{following.length > 0 && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-red-400 border-red-400/30 hover:bg-red-400/10"
|
||||
>
|
||||
<Trash2 size={16} className="mr-2" />
|
||||
Unfollow All
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Unfollow All Users</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to unfollow all users? This
|
||||
action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleClearAll}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
Unfollow All
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="page-subtitle">
|
||||
Manage the users you follow and see their posts in your personalized feed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
{following.length > 0 && (
|
||||
<div className="flex gap-4 mb-6">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-cyber-accent/30 text-cyber-accent"
|
||||
>
|
||||
<Users size={14} className="mr-1" />
|
||||
{following.length} Following
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-cyber-accent/30 text-cyber-accent"
|
||||
>
|
||||
<FileText size={14} className="mr-1" />
|
||||
{followingPosts.length} Posts
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={value =>
|
||||
setActiveTab(value as 'following' | 'feed')
|
||||
}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-2 mb-6">
|
||||
<TabsTrigger value="following" className="flex items-center gap-2">
|
||||
<Users size={16} />
|
||||
Following ({following.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="feed" className="flex items-center gap-2">
|
||||
<FileText size={16} />
|
||||
Feed ({followingPosts.length})
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="following">
|
||||
<FollowingList
|
||||
following={following}
|
||||
onUnfollow={handleUnfollow}
|
||||
emptyMessage="Not following anyone yet"
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="feed">
|
||||
{followingPosts.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<FileText size={48} className="text-cyber-neutral/50 mb-4" />
|
||||
<h3 className="text-lg font-medium text-cyber-light mb-2">
|
||||
No posts from followed users
|
||||
</h3>
|
||||
<p className="text-cyber-neutral max-w-md">
|
||||
{following.length === 0
|
||||
? 'Follow some users to see their posts here.'
|
||||
: 'The users you follow haven\'t posted anything yet.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{sortedFollowingPosts.map(post => (
|
||||
<PostCard key={post.id} post={post} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FollowingPage;
|
||||
@ -28,6 +28,7 @@ export * from './lib/forum/transformers';
|
||||
|
||||
// Export services
|
||||
export { BookmarkService } from './lib/services/BookmarkService';
|
||||
export { FollowingService } from './lib/services/FollowingService';
|
||||
export { MessageService } from './lib/services/MessageService';
|
||||
export { UserIdentityService, type UserIdentity } from './lib/services/UserIdentityService';
|
||||
|
||||
|
||||
@ -17,7 +17,7 @@ import { MessageValidator } from '../utils/MessageValidator';
|
||||
import { EDisplayPreference, EVerificationStatus, User } from '../../types/identity';
|
||||
import { DelegationInfo } from '../delegation/types';
|
||||
import { openLocalDB, STORE, StoreName } from '../database/schema';
|
||||
import { Bookmark, BookmarkCache } from '../../types/forum';
|
||||
import { Bookmark, BookmarkCache, Following, FollowingCache } from '../../types/forum';
|
||||
|
||||
export interface LocalDatabaseCache {
|
||||
cells: CellCache;
|
||||
@ -31,6 +31,7 @@ export interface LocalDatabaseCache {
|
||||
moderations: { [key: string]: (ModerateMessage & { key?: string }) };
|
||||
userIdentities: UserIdentityCache;
|
||||
bookmarks: BookmarkCache;
|
||||
following: FollowingCache;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -54,6 +55,7 @@ export class LocalDatabase {
|
||||
moderations: {},
|
||||
userIdentities: {},
|
||||
bookmarks: {},
|
||||
following: {},
|
||||
};
|
||||
|
||||
constructor() {
|
||||
@ -134,6 +136,7 @@ export class LocalDatabase {
|
||||
this.cache.moderations = {};
|
||||
this.cache.userIdentities = {};
|
||||
this.cache.bookmarks = {};
|
||||
this.cache.following = {};
|
||||
}
|
||||
|
||||
/**
|
||||
@ -158,6 +161,7 @@ export class LocalDatabase {
|
||||
STORE.UI_STATE,
|
||||
STORE.META,
|
||||
STORE.BOOKMARKS,
|
||||
STORE.FOLLOWING,
|
||||
];
|
||||
|
||||
const tx = this.db.transaction(storeNames, 'readwrite');
|
||||
@ -273,7 +277,7 @@ export class LocalDatabase {
|
||||
private async hydrateFromIndexedDB(): Promise<void> {
|
||||
if (!this.db) return;
|
||||
|
||||
const [cells, posts, comments, votes, moderations, identities, bookmarks]: [
|
||||
const [cells, posts, comments, votes, moderations, identities, bookmarks, following]: [
|
||||
CellMessage[],
|
||||
PostMessage[],
|
||||
CommentMessage[],
|
||||
@ -281,6 +285,7 @@ export class LocalDatabase {
|
||||
(ModerateMessage & { key: string })[],
|
||||
({ address: string } & UserIdentityCache[string])[],
|
||||
Bookmark[],
|
||||
Following[],
|
||||
] = await Promise.all([
|
||||
this.getAllFromStore<CellMessage>(STORE.CELLS),
|
||||
this.getAllFromStore<PostMessage>(STORE.POSTS),
|
||||
@ -291,6 +296,7 @@ export class LocalDatabase {
|
||||
STORE.USER_IDENTITIES
|
||||
),
|
||||
this.getAllFromStore<Bookmark>(STORE.BOOKMARKS),
|
||||
this.getAllFromStore<Following>(STORE.FOLLOWING),
|
||||
]);
|
||||
|
||||
this.cache.cells = Object.fromEntries(cells.map(c => [c.id, c]));
|
||||
@ -313,6 +319,7 @@ export class LocalDatabase {
|
||||
})
|
||||
);
|
||||
this.cache.bookmarks = Object.fromEntries(bookmarks.map(b => [b.id, b]));
|
||||
this.cache.following = Object.fromEntries(following.map(f => [f.id, f]));
|
||||
}
|
||||
|
||||
private async hydratePendingFromMeta(): Promise<void> {
|
||||
@ -356,6 +363,7 @@ export class LocalDatabase {
|
||||
| { key: string; value: DelegationInfo; timestamp: number }
|
||||
| { key: string; value: unknown; timestamp: number }
|
||||
| Bookmark
|
||||
| Following
|
||||
): void {
|
||||
if (!this.db) return;
|
||||
const tx = this.db.transaction(storeName, 'readwrite');
|
||||
@ -650,6 +658,84 @@ export class LocalDatabase {
|
||||
return Object.values(this.cache.bookmarks);
|
||||
}
|
||||
|
||||
// ===== Following Storage =====
|
||||
|
||||
/**
|
||||
* Add a following relationship
|
||||
*/
|
||||
public async addFollowing(following: Following): Promise<void> {
|
||||
this.cache.following[following.id] = following;
|
||||
this.put(STORE.FOLLOWING, following);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a following relationship
|
||||
*/
|
||||
public async removeFollowing(followingId: string): Promise<void> {
|
||||
delete this.cache.following[followingId];
|
||||
if (!this.db) return;
|
||||
|
||||
const tx = this.db.transaction(STORE.FOLLOWING, 'readwrite');
|
||||
const store = tx.objectStore(STORE.FOLLOWING);
|
||||
store.delete(followingId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all accounts a user is following
|
||||
*/
|
||||
public async getUserFollowing(userId: string): Promise<Following[]> {
|
||||
if (!this.db) return [];
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = this.db!.transaction(STORE.FOLLOWING, 'readonly');
|
||||
const store = tx.objectStore(STORE.FOLLOWING);
|
||||
const index = store.index('by_userId');
|
||||
const request = index.getAll(userId);
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve(request.result as Following[]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all users who follow a specific address
|
||||
*/
|
||||
public async getFollowers(followedAddress: string): Promise<Following[]> {
|
||||
if (!this.db) return [];
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = this.db!.transaction(STORE.FOLLOWING, 'readonly');
|
||||
const store = tx.objectStore(STORE.FOLLOWING);
|
||||
const index = store.index('by_followedAddress');
|
||||
const request = index.getAll(followedAddress);
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve(request.result as Following[]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user is following another address (sync from cache)
|
||||
*/
|
||||
public isFollowing(userId: string, followedAddress: string): boolean {
|
||||
const followingId = `${userId}:${followedAddress}`;
|
||||
return !!this.cache.following[followingId];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get following by ID
|
||||
*/
|
||||
public getFollowing(followingId: string): Following | undefined {
|
||||
return this.cache.following[followingId];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all following from cache
|
||||
*/
|
||||
public getAllFollowing(): Following[] {
|
||||
return Object.values(this.cache.following);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert a user identity record into the centralized cache and IndexedDB.
|
||||
* Use this to keep ENS/verification status in one place.
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
export const DB_NAME = 'opchan-local';
|
||||
export const DB_VERSION = 5;
|
||||
export const DB_VERSION = 6;
|
||||
|
||||
export const STORE = {
|
||||
CELLS: 'cells',
|
||||
@ -13,6 +13,7 @@ export const STORE = {
|
||||
UI_STATE: 'uiState',
|
||||
META: 'meta',
|
||||
BOOKMARKS: 'bookmarks',
|
||||
FOLLOWING: 'following',
|
||||
} as const;
|
||||
|
||||
export type StoreName = (typeof STORE)[keyof typeof STORE];
|
||||
@ -84,6 +85,13 @@ export function openLocalDB(): Promise<IDBDatabase> {
|
||||
// Index to fetch bookmarks by targetId
|
||||
store.createIndex('by_targetId', 'targetId', { unique: false });
|
||||
}
|
||||
if (!db.objectStoreNames.contains(STORE.FOLLOWING)) {
|
||||
const store = db.createObjectStore(STORE.FOLLOWING, { keyPath: 'id' });
|
||||
// Index to fetch following by user
|
||||
store.createIndex('by_userId', 'userId', { unique: false });
|
||||
// Index to fetch following by followed address
|
||||
store.createIndex('by_followedAddress', 'followedAddress', { unique: false });
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Cell, Post, Comment } from '../../types/forum';
|
||||
import { Cell, Post, Comment, Following } from '../../types/forum';
|
||||
import {
|
||||
CellMessage,
|
||||
CommentMessage,
|
||||
@ -250,6 +250,21 @@ export const transformVote = async (
|
||||
return voteMessage;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get posts from users that the specified user is following
|
||||
*/
|
||||
export const getFollowingPostsFromCache = async (
|
||||
userId: string
|
||||
): Promise<Post[]> => {
|
||||
const followingRecords = Object.values(localDatabase.cache.following);
|
||||
const followedAddresses = followingRecords
|
||||
.filter(f => f.userId === userId)
|
||||
.map(f => f.followedAddress);
|
||||
|
||||
const { posts } = await getDataFromCache();
|
||||
return posts.filter(post => followedAddresses.includes(post.authorAddress));
|
||||
};
|
||||
|
||||
export const getDataFromCache = async (
|
||||
_verifyMessage?: unknown, // Deprecated parameter, kept for compatibility
|
||||
): Promise<{ cells: Cell[]; posts: Post[]; comments: Comment[] }> => {
|
||||
|
||||
129
packages/core/src/lib/services/FollowingService.ts
Normal file
129
packages/core/src/lib/services/FollowingService.ts
Normal file
@ -0,0 +1,129 @@
|
||||
import { Following, Post } from '../../types/forum';
|
||||
import { localDatabase } from '../database/LocalDatabase';
|
||||
import { getDataFromCache } from '../forum/transformers';
|
||||
|
||||
/**
|
||||
* Service for managing following relationships
|
||||
* Handles all following-related operations including CRUD operations
|
||||
* and post filtering for followed users
|
||||
*/
|
||||
export class FollowingService {
|
||||
/**
|
||||
* Follow a user
|
||||
*/
|
||||
public static async followUser(
|
||||
userId: string,
|
||||
followedAddress: string
|
||||
): Promise<Following> {
|
||||
const following: Following = {
|
||||
id: `${userId}:${followedAddress}`,
|
||||
userId,
|
||||
followedAddress,
|
||||
followedAt: Date.now(),
|
||||
};
|
||||
|
||||
await localDatabase.addFollowing(following);
|
||||
return following;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unfollow a user
|
||||
*/
|
||||
public static async unfollowUser(
|
||||
userId: string,
|
||||
followedAddress: string
|
||||
): Promise<void> {
|
||||
const followingId = `${userId}:${followedAddress}`;
|
||||
await localDatabase.removeFollowing(followingId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle follow status for a user
|
||||
*/
|
||||
public static async toggleFollow(
|
||||
userId: string,
|
||||
followedAddress: string
|
||||
): Promise<boolean> {
|
||||
const isFollowing = localDatabase.isFollowing(userId, followedAddress);
|
||||
|
||||
if (isFollowing) {
|
||||
await this.unfollowUser(userId, followedAddress);
|
||||
return false;
|
||||
} else {
|
||||
await this.followUser(userId, followedAddress);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user is following another address (sync)
|
||||
*/
|
||||
public static isFollowing(userId: string, followedAddress: string): boolean {
|
||||
return localDatabase.isFollowing(userId, followedAddress);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all addresses a user is following
|
||||
*/
|
||||
public static async getFollowing(userId: string): Promise<string[]> {
|
||||
const following = await localDatabase.getUserFollowing(userId);
|
||||
return following.map(f => f.followedAddress);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all following records for a user
|
||||
*/
|
||||
public static async getFollowingRecords(userId: string): Promise<Following[]> {
|
||||
return localDatabase.getUserFollowing(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all users who follow a specific address
|
||||
*/
|
||||
public static async getFollowers(followedAddress: string): Promise<string[]> {
|
||||
const followers = await localDatabase.getFollowers(followedAddress);
|
||||
return followers.map(f => f.userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the count of addresses a user is following
|
||||
*/
|
||||
public static async getFollowingCount(userId: string): Promise<number> {
|
||||
const following = await localDatabase.getUserFollowing(userId);
|
||||
return following.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the count of followers for an address
|
||||
*/
|
||||
public static async getFollowersCount(followedAddress: string): Promise<number> {
|
||||
const followers = await localDatabase.getFollowers(followedAddress);
|
||||
return followers.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get posts from followed users
|
||||
*/
|
||||
public static async getFollowingPosts(userId: string): Promise<Post[]> {
|
||||
const following = await this.getFollowing(userId);
|
||||
const { posts } = await getDataFromCache();
|
||||
return posts.filter(post => following.includes(post.authorAddress));
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all following for a user (useful for account cleanup)
|
||||
*/
|
||||
public static async clearUserFollowing(userId: string): Promise<void> {
|
||||
const following = await localDatabase.getUserFollowing(userId);
|
||||
await Promise.all(
|
||||
following.map(f => localDatabase.removeFollowing(f.id))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all following (for debugging/admin purposes)
|
||||
*/
|
||||
public static getAllFollowing(): Following[] {
|
||||
return localDatabase.getAllFollowing();
|
||||
}
|
||||
}
|
||||
@ -157,3 +157,20 @@ export interface Bookmark {
|
||||
export interface BookmarkCache {
|
||||
[bookmarkId: string]: Bookmark;
|
||||
}
|
||||
|
||||
/**
|
||||
* Following relationship data structure
|
||||
*/
|
||||
export interface Following {
|
||||
id: string; // Composite key: `${userId}:${followedAddress}`
|
||||
userId: string; // Follower's address
|
||||
followedAddress: string; // Address being followed
|
||||
followedAt: number; // Timestamp when followed
|
||||
}
|
||||
|
||||
/**
|
||||
* Following cache for in-memory storage
|
||||
*/
|
||||
export interface FollowingCache {
|
||||
[followingId: string]: Following;
|
||||
}
|
||||
|
||||
@ -10,7 +10,7 @@ import {
|
||||
BookmarkType,
|
||||
getDataFromCache,
|
||||
} from '@opchan/core';
|
||||
import { BookmarkService } from '@opchan/core';
|
||||
import { BookmarkService, FollowingService } from '@opchan/core';
|
||||
|
||||
function reflectCache(client: ReturnType<typeof useClient>): void {
|
||||
getDataFromCache().then(({ cells, posts, comments }: { cells: Cell[]; posts: Post[]; comments: Comment[] }) => {
|
||||
@ -22,6 +22,7 @@ function reflectCache(client: ReturnType<typeof useClient>): void {
|
||||
posts,
|
||||
comments,
|
||||
bookmarks: Object.values(client.database.cache.bookmarks),
|
||||
following: Object.values(client.database.cache.following),
|
||||
lastSync: client.database.getSyncState().lastSync,
|
||||
pendingIds: prev.content.pendingIds,
|
||||
pendingVotes: prev.content.pendingVotes,
|
||||
@ -32,6 +33,87 @@ function reflectCache(client: ReturnType<typeof useClient>): void {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for accessing and managing forum content including cells, posts, comments,
|
||||
* bookmarks, and following relationships.
|
||||
*
|
||||
* ## Following Feature
|
||||
*
|
||||
* The hook provides methods to follow/unfollow users and filter posts by followed users.
|
||||
* Following data is stored locally in IndexedDB and persists across sessions.
|
||||
*
|
||||
* ### Data
|
||||
* - `following`: Array of `Following` objects containing `{ id, userId, followedAddress, followedAt }`
|
||||
*
|
||||
* ### Methods
|
||||
* - `toggleFollow(address)`: Toggle follow status, returns `true` if now following
|
||||
* - `followUser(address)`: Follow a user
|
||||
* - `unfollowUser(address)`: Unfollow a user
|
||||
* - `isFollowing(address)`: Synchronously check if following a user
|
||||
* - `getFollowingPosts()`: Get posts from all followed users
|
||||
* - `clearAllFollowing()`: Unfollow all users
|
||||
*
|
||||
* ### Example: Follow Button
|
||||
* ```tsx
|
||||
* function FollowButton({ authorAddress }: { authorAddress: string }) {
|
||||
* const { currentUser } = useAuth();
|
||||
* const { isFollowing, toggleFollow } = useContent();
|
||||
* const [loading, setLoading] = useState(false);
|
||||
*
|
||||
* // Don't show for own address or when not logged in
|
||||
* if (!currentUser || currentUser.address === authorAddress) return null;
|
||||
*
|
||||
* const handleClick = async () => {
|
||||
* setLoading(true);
|
||||
* await toggleFollow(authorAddress);
|
||||
* setLoading(false);
|
||||
* };
|
||||
*
|
||||
* return (
|
||||
* <button onClick={handleClick} disabled={loading}>
|
||||
* {isFollowing(authorAddress) ? 'Unfollow' : 'Follow'}
|
||||
* </button>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* ### Example: Following Feed
|
||||
* ```tsx
|
||||
* function FollowingFeed() {
|
||||
* const { following, posts } = useContent();
|
||||
*
|
||||
* const followedAddresses = following.map(f => f.followedAddress);
|
||||
* const followingPosts = posts.filter(p => followedAddresses.includes(p.authorAddress));
|
||||
*
|
||||
* return (
|
||||
* <div>
|
||||
* <h2>Posts from people you follow ({following.length})</h2>
|
||||
* {followingPosts.map(post => <PostCard key={post.id} post={post} />)}
|
||||
* </div>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* ### Example: Following List
|
||||
* ```tsx
|
||||
* function FollowingList() {
|
||||
* const { following, unfollowUser } = useContent();
|
||||
*
|
||||
* return (
|
||||
* <ul>
|
||||
* {following.map(f => (
|
||||
* <li key={f.id}>
|
||||
* {f.followedAddress}
|
||||
* <button onClick={() => unfollowUser(f.followedAddress)}>
|
||||
* Unfollow
|
||||
* </button>
|
||||
* </li>
|
||||
* ))}
|
||||
* </ul>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useContent() {
|
||||
const client = useClient();
|
||||
const content = useOpchanStore(s => s.content);
|
||||
@ -273,6 +355,118 @@ export function useContent() {
|
||||
setOpchanState(prev => ({ ...prev, content: { ...prev.content, bookmarks: updated } }));
|
||||
}, [client, session.currentUser?.address]);
|
||||
|
||||
// ============================================================================
|
||||
// FOLLOWING METHODS
|
||||
// ============================================================================
|
||||
// The following feature allows users to follow other users and see their posts
|
||||
// in a personalized feed. Following data is stored locally in IndexedDB.
|
||||
//
|
||||
// Data:
|
||||
// - `following`: Array of Following objects for the current user
|
||||
//
|
||||
// Methods:
|
||||
// - `toggleFollow(address)`: Toggle follow status, returns true if now following
|
||||
// - `followUser(address)`: Follow a user
|
||||
// - `unfollowUser(address)`: Unfollow a user
|
||||
// - `isFollowing(address)`: Check if following (synchronous)
|
||||
// - `getFollowingPosts()`: Get posts from followed users
|
||||
// - `clearAllFollowing()`: Unfollow all users
|
||||
//
|
||||
// Example usage:
|
||||
// ```tsx
|
||||
// function FollowButton({ authorAddress }: { authorAddress: string }) {
|
||||
// const { isFollowing, toggleFollow } = useContent();
|
||||
// const [loading, setLoading] = useState(false);
|
||||
//
|
||||
// const handleClick = async () => {
|
||||
// setLoading(true);
|
||||
// await toggleFollow(authorAddress);
|
||||
// setLoading(false);
|
||||
// };
|
||||
//
|
||||
// return (
|
||||
// <button onClick={handleClick} disabled={loading}>
|
||||
// {isFollowing(authorAddress) ? 'Unfollow' : 'Follow'}
|
||||
// </button>
|
||||
// );
|
||||
// }
|
||||
// ```
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Toggle follow status for a user.
|
||||
* @param followedAddress - The address of the user to follow/unfollow
|
||||
* @returns true if now following, false if unfollowed or user not logged in
|
||||
*/
|
||||
const toggleFollow = React.useCallback(async (followedAddress: string): Promise<boolean> => {
|
||||
const address = session.currentUser?.address;
|
||||
if (!address) return false;
|
||||
const isNowFollowing = await FollowingService.toggleFollow(address, followedAddress);
|
||||
const updated = await client.database.getUserFollowing(address);
|
||||
setOpchanState(prev => ({ ...prev, content: { ...prev.content, following: updated } }));
|
||||
return isNowFollowing;
|
||||
}, [client, session.currentUser?.address]);
|
||||
|
||||
/**
|
||||
* Follow a user.
|
||||
* @param followedAddress - The address of the user to follow
|
||||
* @returns true if successful, false if user not logged in
|
||||
*/
|
||||
const followUser = React.useCallback(async (followedAddress: string): Promise<boolean> => {
|
||||
const address = session.currentUser?.address;
|
||||
if (!address) return false;
|
||||
await FollowingService.followUser(address, followedAddress);
|
||||
const updated = await client.database.getUserFollowing(address);
|
||||
setOpchanState(prev => ({ ...prev, content: { ...prev.content, following: updated } }));
|
||||
return true;
|
||||
}, [client, session.currentUser?.address]);
|
||||
|
||||
/**
|
||||
* Unfollow a user.
|
||||
* @param followedAddress - The address of the user to unfollow
|
||||
* @returns true if successful, false if user not logged in
|
||||
*/
|
||||
const unfollowUser = React.useCallback(async (followedAddress: string): Promise<boolean> => {
|
||||
const address = session.currentUser?.address;
|
||||
if (!address) return false;
|
||||
await FollowingService.unfollowUser(address, followedAddress);
|
||||
const updated = await client.database.getUserFollowing(address);
|
||||
setOpchanState(prev => ({ ...prev, content: { ...prev.content, following: updated } }));
|
||||
return true;
|
||||
}, [client, session.currentUser?.address]);
|
||||
|
||||
/**
|
||||
* Check if the current user is following another user (synchronous).
|
||||
* @param followedAddress - The address to check
|
||||
* @returns true if following, false otherwise
|
||||
*/
|
||||
const isFollowing = React.useCallback((followedAddress: string): boolean => {
|
||||
const address = session.currentUser?.address;
|
||||
if (!address) return false;
|
||||
return FollowingService.isFollowing(address, followedAddress);
|
||||
}, [session.currentUser?.address]);
|
||||
|
||||
/**
|
||||
* Get all posts from users that the current user follows.
|
||||
* @returns Array of posts from followed users
|
||||
*/
|
||||
const getFollowingPosts = React.useCallback(async (): Promise<Post[]> => {
|
||||
const address = session.currentUser?.address;
|
||||
if (!address) return [];
|
||||
return FollowingService.getFollowingPosts(address);
|
||||
}, [session.currentUser?.address]);
|
||||
|
||||
/**
|
||||
* Unfollow all users. Useful for account cleanup.
|
||||
*/
|
||||
const clearAllFollowing = React.useCallback(async (): Promise<void> => {
|
||||
const address = session.currentUser?.address;
|
||||
if (!address) return;
|
||||
await FollowingService.clearUserFollowing(address);
|
||||
const updated = await client.database.getUserFollowing(address);
|
||||
setOpchanState(prev => ({ ...prev, content: { ...prev.content, following: updated } }));
|
||||
}, [client, session.currentUser?.address]);
|
||||
|
||||
const refresh = React.useCallback(async () => {
|
||||
// Minimal refresh: re-reflect cache; network refresh is via useNetwork
|
||||
reflectCache(client);
|
||||
@ -284,6 +478,7 @@ export function useContent() {
|
||||
posts: content.posts,
|
||||
comments: content.comments,
|
||||
bookmarks: content.bookmarks,
|
||||
following: content.following,
|
||||
postsByCell,
|
||||
commentsByPost,
|
||||
cellsWithStats,
|
||||
@ -303,6 +498,12 @@ export function useContent() {
|
||||
toggleCommentBookmark,
|
||||
removeBookmark,
|
||||
clearAllBookmarks,
|
||||
toggleFollow,
|
||||
followUser,
|
||||
unfollowUser,
|
||||
isFollowing,
|
||||
getFollowingPosts,
|
||||
clearAllFollowing,
|
||||
refresh,
|
||||
} as const;
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import type {
|
||||
Post,
|
||||
Comment,
|
||||
Bookmark,
|
||||
Following,
|
||||
User,
|
||||
EVerificationStatus,
|
||||
DelegationFullStatus,
|
||||
@ -23,6 +24,7 @@ export interface ContentSlice {
|
||||
posts: Post[];
|
||||
comments: Comment[];
|
||||
bookmarks: Bookmark[];
|
||||
following: Following[];
|
||||
lastSync: number | null;
|
||||
pendingIds: Set<string>;
|
||||
pendingVotes: Set<string>;
|
||||
@ -70,6 +72,7 @@ const defaultState: OpchanState = {
|
||||
posts: [],
|
||||
comments: [],
|
||||
bookmarks: [],
|
||||
following: [],
|
||||
lastSync: null,
|
||||
pendingIds: new Set<string>(),
|
||||
pendingVotes: new Set<string>(),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user