Merge 78b7ef64beb21adf149b054307615da1a1a5955c into 759aff01d0a21f6e3da4c10f337e35f6db28d35e

This commit is contained in:
Arseniy Klempner 2025-12-12 12:53:03 -08:00 committed by GitHub
commit dfd2c525e3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 929 additions and 5 deletions

View File

@ -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 />} />

View File

@ -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>

View File

@ -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>

View File

@ -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>

View 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>
);
}

View 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>
);
}

View 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;

View File

@ -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';

View File

@ -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.

View File

@ -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 });
}
};
});
}

View File

@ -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[] }> => {

View 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();
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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>(),