diff --git a/app/src/App.tsx b/app/src/App.tsx index 2788dbc..195277c 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -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 = () => ( } /> } /> } /> + } /> } /> } /> } /> diff --git a/app/src/components/Header.tsx b/app/src/components/Header.tsx index cc2a4ba..d8aab76 100644 --- a/app/src/components/Header.tsx +++ b/app/src/components/Header.tsx @@ -163,6 +163,13 @@ const Header = () => { > BOOKMARKS + | + + FOLLOWING + )} diff --git a/app/src/components/PostCard.tsx b/app/src/components/PostCard.tsx index 5fab4b6..41f2d12 100644 --- a/app/src/components/PostCard.tsx +++ b/app/src/components/PostCard.tsx @@ -14,6 +14,8 @@ const PostCard: React.FC = ({ post }) => { pending, vote, togglePostBookmark, + toggleFollow, + isFollowing, cells, commentsByPost, } = useContent(); @@ -29,6 +31,10 @@ const PostCard: React.FC = ({ 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 = ({ post }) => { } }; + const handleFollow = async (e?: React.MouseEvent) => { + if (e) { + e.preventDefault(); + e.stopPropagation(); + } + setFollowLoading(true); + try { + await toggleFollow(post.author); + } finally { + setFollowLoading(false); + } + }; + return (
@@ -126,6 +145,18 @@ const PostCard: React.FC = ({ post }) => { > {isBookmarked ? 'unsave' : 'save'} + {currentUser && !isOwnPost && ( + <> + · + + + )} {isPending && ( <> · diff --git a/app/src/components/PostDetail.tsx b/app/src/components/PostDetail.tsx index f9ef834..0474979 100644 --- a/app/src/components/PostDetail.tsx +++ b/app/src/components/PostDetail.tsx @@ -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 && ( + + )}
diff --git a/app/src/components/ui/follow-button.tsx b/app/src/components/ui/follow-button.tsx new file mode 100644 index 0000000..fc41aa2 --- /dev/null +++ b/app/src/components/ui/follow-button.tsx @@ -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 ( + + ); +} diff --git a/app/src/components/ui/following-card.tsx b/app/src/components/ui/following-card.tsx new file mode 100644 index 0000000..589afd0 --- /dev/null +++ b/app/src/components/ui/following-card.tsx @@ -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 ( + + +
+ {/* User Info */} +
+
+ +
+
+

+ {displayName} +

+

+ Followed {formatDistanceToNow(new Date(following.followedAt), { addSuffix: true })} +

+
+
+ + {/* Actions */} +
+ +
+
+
+
+ ); +} + +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 ( +
+ +

+ {emptyMessage} +

+

+ Follow users to see their posts in your personalized feed. Your following + list is stored locally and won't be shared. +

+
+ ); + } + + return ( +
+ {following.map(f => ( + + ))} +
+ ); +} diff --git a/app/src/pages/FollowingPage.tsx b/app/src/pages/FollowingPage.tsx new file mode 100644 index 0000000..84149c4 --- /dev/null +++ b/app/src/pages/FollowingPage.tsx @@ -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 ( +
+
+
+
+

+ Authentication Required +

+

+ Please connect your wallet to view your following list. +

+
+
+
+ ); + } + + const handleUnfollow = async (followedAddress: string) => { + await unfollowUser(followedAddress); + }; + + const handleClearAll = async () => { + await clearAllFollowing(); + }; + + return ( +
+
+ +
+
+ {/* Header Section */} +
+
+
+ +

Following

+
+ + {following.length > 0 && ( + + + + + + + Unfollow All Users + + Are you sure you want to unfollow all users? This + action cannot be undone. + + + + Cancel + + Unfollow All + + + + + )} +
+ +

+ Manage the users you follow and see their posts in your personalized feed. +

+
+ + {/* Stats */} + {following.length > 0 && ( +
+ + + {following.length} Following + + + + {followingPosts.length} Posts + +
+ )} + + {/* Tabs */} + + setActiveTab(value as 'following' | 'feed') + } + className="w-full" + > + + + + Following ({following.length}) + + + + Feed ({followingPosts.length}) + + + + + + + + + {followingPosts.length === 0 ? ( +
+ +

+ No posts from followed users +

+

+ {following.length === 0 + ? 'Follow some users to see their posts here.' + : 'The users you follow haven\'t posted anything yet.'} +

+
+ ) : ( +
+ {sortedFollowingPosts.map(post => ( + + ))} +
+ )} +
+
+
+
+ +
+
+ ); +}; + +export default FollowingPage; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index bd6e28c..074b90d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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'; diff --git a/packages/core/src/lib/database/LocalDatabase.ts b/packages/core/src/lib/database/LocalDatabase.ts index dec8dc8..9675aae 100644 --- a/packages/core/src/lib/database/LocalDatabase.ts +++ b/packages/core/src/lib/database/LocalDatabase.ts @@ -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 { 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(STORE.CELLS), this.getAllFromStore(STORE.POSTS), @@ -291,6 +296,7 @@ export class LocalDatabase { STORE.USER_IDENTITIES ), this.getAllFromStore(STORE.BOOKMARKS), + this.getAllFromStore(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 { @@ -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 { + this.cache.following[following.id] = following; + this.put(STORE.FOLLOWING, following); + } + + /** + * Remove a following relationship + */ + public async removeFollowing(followingId: string): Promise { + 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 { + 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 { + 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. diff --git a/packages/core/src/lib/database/schema.ts b/packages/core/src/lib/database/schema.ts index 4a5fa2f..f57f975 100644 --- a/packages/core/src/lib/database/schema.ts +++ b/packages/core/src/lib/database/schema.ts @@ -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 { // 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 }); + } }; }); } diff --git a/packages/core/src/lib/forum/transformers.ts b/packages/core/src/lib/forum/transformers.ts index 95c3c7e..2b4dfd6 100644 --- a/packages/core/src/lib/forum/transformers.ts +++ b/packages/core/src/lib/forum/transformers.ts @@ -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 => { + 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[] }> => { diff --git a/packages/core/src/lib/services/FollowingService.ts b/packages/core/src/lib/services/FollowingService.ts new file mode 100644 index 0000000..ae779ad --- /dev/null +++ b/packages/core/src/lib/services/FollowingService.ts @@ -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 { + 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 { + const followingId = `${userId}:${followedAddress}`; + await localDatabase.removeFollowing(followingId); + } + + /** + * Toggle follow status for a user + */ + public static async toggleFollow( + userId: string, + followedAddress: string + ): Promise { + 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 { + 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 { + return localDatabase.getUserFollowing(userId); + } + + /** + * Get all users who follow a specific address + */ + public static async getFollowers(followedAddress: string): Promise { + 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 { + const following = await localDatabase.getUserFollowing(userId); + return following.length; + } + + /** + * Get the count of followers for an address + */ + public static async getFollowersCount(followedAddress: string): Promise { + const followers = await localDatabase.getFollowers(followedAddress); + return followers.length; + } + + /** + * Get posts from followed users + */ + public static async getFollowingPosts(userId: string): Promise { + 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 { + 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(); + } +} diff --git a/packages/core/src/types/forum.ts b/packages/core/src/types/forum.ts index 525937c..e0f2fe9 100644 --- a/packages/core/src/types/forum.ts +++ b/packages/core/src/types/forum.ts @@ -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; +} diff --git a/packages/react/src/v1/hooks/useContent.ts b/packages/react/src/v1/hooks/useContent.ts index 72f4574..0391f01 100644 --- a/packages/react/src/v1/hooks/useContent.ts +++ b/packages/react/src/v1/hooks/useContent.ts @@ -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): void { getDataFromCache().then(({ cells, posts, comments }: { cells: Cell[]; posts: Post[]; comments: Comment[] }) => { @@ -22,6 +22,7 @@ function reflectCache(client: ReturnType): 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): 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 ( + * + * ); + * } + * ``` + * + * ### 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 ( + *
+ *

Posts from people you follow ({following.length})

+ * {followingPosts.map(post => )} + *
+ * ); + * } + * ``` + * + * ### Example: Following List + * ```tsx + * function FollowingList() { + * const { following, unfollowUser } = useContent(); + * + * return ( + *
    + * {following.map(f => ( + *
  • + * {f.followedAddress} + * + *
  • + * ))} + *
+ * ); + * } + * ``` + */ 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 ( + // + // ); + // } + // ``` + // ============================================================================ + + /** + * 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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; } diff --git a/packages/react/src/v1/store/opchanStore.ts b/packages/react/src/v1/store/opchanStore.ts index 4d43de8..715d642 100644 --- a/packages/react/src/v1/store/opchanStore.ts +++ b/packages/react/src/v1/store/opchanStore.ts @@ -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; pendingVotes: Set; @@ -70,6 +72,7 @@ const defaultState: OpchanState = { posts: [], comments: [], bookmarks: [], + following: [], lastSync: null, pendingIds: new Set(), pendingVotes: new Set(),