From 808820b4f43d12806c5891d102ceee9d19780788 Mon Sep 17 00:00:00 2001 From: Danish Arora <35004822+danisharora099@users.noreply.github.com> Date: Mon, 11 Aug 2025 12:14:19 +0530 Subject: [PATCH] feat: relevance index system for the feed (+ sorting) (#15) * core relevance algorithm * feat: integrate relevance algo into UI + sorting --- src/components/CellList.tsx | 52 +++- src/components/PostCard.tsx | 13 + src/components/PostDetail.tsx | 27 +- src/components/ui/relevance-indicator.tsx | 205 ++++++++++++++ src/contexts/ForumContext.tsx | 60 ++-- src/lib/forum/relevance.test.ts | 141 +++++++++ src/lib/forum/relevance.ts | 330 ++++++++++++++++++++++ src/lib/forum/sorting.ts | 59 ++++ src/lib/forum/transformers.ts | 127 ++++++++- src/lib/forum/types.ts | 25 ++ src/pages/FeedPage.tsx | 36 ++- src/types/index.ts | 10 + 12 files changed, 1037 insertions(+), 48 deletions(-) create mode 100644 src/components/ui/relevance-indicator.tsx create mode 100644 src/lib/forum/relevance.test.ts create mode 100644 src/lib/forum/relevance.ts create mode 100644 src/lib/forum/sorting.ts create mode 100644 src/lib/forum/types.ts diff --git a/src/components/CellList.tsx b/src/components/CellList.tsx index b8577f2..3749347 100644 --- a/src/components/CellList.tsx +++ b/src/components/CellList.tsx @@ -1,13 +1,22 @@ -import React from 'react'; +import React, { useState, useMemo } from 'react'; import { Link } from 'react-router-dom'; import { useForum } from '@/contexts/useForum'; -import { Layout, MessageSquare, RefreshCw, Loader2 } from 'lucide-react'; +import { Layout, MessageSquare, RefreshCw, Loader2, TrendingUp, Clock } from 'lucide-react'; import { CreateCellDialog } from './CreateCellDialog'; import { Button } from '@/components/ui/button'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { CypherImage } from './ui/CypherImage'; +import { RelevanceIndicator } from './ui/relevance-indicator'; +import { sortCells, SortOption } from '@/lib/forum/sorting'; const CellList = () => { const { cells, isInitialLoading, posts, refreshData, isRefreshing } = useForum(); + const [sortOption, setSortOption] = useState('relevance'); + + // Apply sorting to cells + const sortedCells = useMemo(() => { + return sortCells(cells, sortOption); + }, [cells, sortOption]); if (isInitialLoading) { return ( @@ -31,6 +40,26 @@ const CellList = () => {

Cells

+ +
) : ( - cells.map((cell) => ( + sortedCells.map((cell) => (
{

{cell.name}

{cell.description}

-
- - {getPostCount(cell.id)} threads +
+
+ + {getPostCount(cell.id)} threads +
+ {cell.relevanceScore !== undefined && ( + + )}
diff --git a/src/components/PostCard.tsx b/src/components/PostCard.tsx index 9c19122..b238f70 100644 --- a/src/components/PostCard.tsx +++ b/src/components/PostCard.tsx @@ -5,6 +5,7 @@ import { formatDistanceToNow } from 'date-fns'; import { Post } from '@/types'; import { useForum } from '@/contexts/useForum'; import { useAuth } from '@/contexts/useAuth'; +import { RelevanceIndicator } from '@/components/ui/relevance-indicator'; interface PostCardProps { post: Post; @@ -82,6 +83,18 @@ const PostCard: React.FC = ({ post, commentCount = 0 }) => { Posted by u/{post.authorAddress.slice(0, 6)}...{post.authorAddress.slice(-4)} {formatDistanceToNow(new Date(post.timestamp), { addSuffix: true })} + {post.relevanceScore !== undefined && ( + <> + + + + )}
{/* Post title */} diff --git a/src/components/PostDetail.tsx b/src/components/PostDetail.tsx index f2ab7db..6b956c8 100644 --- a/src/components/PostDetail.tsx +++ b/src/components/PostDetail.tsx @@ -9,6 +9,7 @@ import { formatDistanceToNow } from 'date-fns'; import { Comment } from '@/types'; import { CypherImage } from './ui/CypherImage'; import { Badge } from '@/components/ui/badge'; +import { RelevanceIndicator } from './ui/relevance-indicator'; const PostDetail = () => { const { postId } = useParams<{ postId: string }>(); @@ -165,6 +166,15 @@ const PostDetail = () => { {post.authorAddress.slice(0, 6)}...{post.authorAddress.slice(-4)} + {post.relevanceScore !== undefined && ( + + )} @@ -252,9 +262,20 @@ const PostDetail = () => { {comment.authorAddress.slice(0, 6)}...{comment.authorAddress.slice(-4)} - - {formatDistanceToNow(comment.timestamp, { addSuffix: true })} - +
+ {comment.relevanceScore !== undefined && ( + + )} + + {formatDistanceToNow(comment.timestamp, { addSuffix: true })} + +

{comment.content}

{isCellAdmin && !comment.moderated && ( diff --git a/src/components/ui/relevance-indicator.tsx b/src/components/ui/relevance-indicator.tsx new file mode 100644 index 0000000..05fbca3 --- /dev/null +++ b/src/components/ui/relevance-indicator.tsx @@ -0,0 +1,205 @@ +import React, { useState } from 'react'; +import { Badge } from '@/components/ui/badge'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Separator } from '@/components/ui/separator'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { TrendingUp, Clock, Shield, UserCheck, MessageSquare, ThumbsUp } from 'lucide-react'; +import { RelevanceScoreDetails } from '@/lib/forum/relevance'; + +interface RelevanceIndicatorProps { + score: number; + details?: RelevanceScoreDetails; + type: 'post' | 'comment' | 'cell'; + className?: string; + showTooltip?: boolean; +} + +export function RelevanceIndicator({ score, details, type, className, showTooltip = false }: RelevanceIndicatorProps) { + const [isOpen, setIsOpen] = useState(false); + + const getScoreColor = (score: number) => { + if (score >= 15) return 'bg-green-500 hover:bg-green-600'; + if (score >= 10) return 'bg-blue-500 hover:bg-blue-600'; + if (score >= 5) return 'bg-yellow-500 hover:bg-yellow-600'; + return 'bg-gray-500 hover:bg-gray-600'; + }; + + const formatScore = (score: number) => { + return score.toFixed(1); + }; + + const createTooltipContent = () => { + if (!details) return `Relevance Score: ${formatScore(score)}`; + + return ( +
+
Relevance Score: {formatScore(score)}
+
Base: {formatScore(details.baseScore)}
+
Engagement: +{formatScore(details.engagementScore)}
+ {details.authorVerificationBonus > 0 && ( +
Author Bonus: +{formatScore(details.authorVerificationBonus)}
+ )} + {details.verifiedUpvoteBonus > 0 && ( +
Verified Upvotes: +{formatScore(details.verifiedUpvoteBonus)}
+ )} + {details.verifiedCommenterBonus > 0 && ( +
Verified Commenters: +{formatScore(details.verifiedCommenterBonus)}
+ )} +
Time Decay: ×{details.timeDecayMultiplier.toFixed(2)}
+ {details.isModerated && ( +
Moderation: ×{details.moderationPenalty.toFixed(1)}
+ )} +
+ ); + }; + + const badge = ( + + + {formatScore(score)} + + ); + + return ( + <> + + {showTooltip ? ( + + + + + {badge} + + + + {createTooltipContent()} + + + + ) : ( + + {badge} + + )} + + + + + + Relevance Score Details + + + +
+ + + Final Score: {formatScore(score)} + + +
+ {type.charAt(0).toUpperCase() + type.slice(1)} relevance score +
+
+
+ + {details && ( + <> + + + Score Breakdown + + +
+
+
+ Base Score +
+ {formatScore(details.baseScore)} +
+ +
+
+ + Engagement ({details.upvotes} upvotes, {details.comments} comments) +
+ +{formatScore(details.engagementScore)} +
+ + {details.authorVerificationBonus > 0 && ( +
+
+ + Author Verification Bonus +
+ +{formatScore(details.authorVerificationBonus)} +
+ )} + + {details.verifiedUpvoteBonus > 0 && ( +
+
+ + Verified Upvotes ({details.verifiedUpvotes}) +
+ +{formatScore(details.verifiedUpvoteBonus)} +
+ )} + + {details.verifiedCommenterBonus > 0 && ( +
+
+ + Verified Commenters ({details.verifiedCommenters}) +
+ +{formatScore(details.verifiedCommenterBonus)} +
+ )} + + + +
+
+ + Time Decay ({details.daysOld.toFixed(1)} days old) +
+ ×{details.timeDecayMultiplier.toFixed(3)} +
+ + {details.isModerated && ( +
+
+ + Moderation Penalty +
+ ×{details.moderationPenalty.toFixed(1)} +
+ )} +
+
+ + + + User Status + + +
+ + + {details.isVerified ? 'Verified User' : 'Unverified User'} + +
+
+
+ + )} +
+
+
+ + ); +} diff --git a/src/contexts/ForumContext.tsx b/src/contexts/ForumContext.tsx index a19d6bd..227d25d 100644 --- a/src/contexts/ForumContext.tsx +++ b/src/contexts/ForumContext.tsx @@ -1,5 +1,5 @@ import React, { createContext, useContext, useState, useEffect, useCallback, useMemo } from 'react'; -import { Cell, Post, Comment, OpchanMessage } from '@/types'; +import { Cell, Post, Comment, OpchanMessage, User } from '@/types'; import { useToast } from '@/components/ui/use-toast'; import { useAuth } from '@/contexts/useAuth'; import { @@ -17,7 +17,8 @@ import { initializeNetwork } from '@/lib/waku/network'; import messageManager from '@/lib/waku'; -import { transformCell, transformComment, transformPost } from '@/lib/forum/transformers'; +import { getDataFromCache } from '@/lib/forum/transformers'; +import { RelevanceCalculator, UserVerificationStatus } from '@/lib/forum/relevance'; import { AuthService } from '@/lib/identity/services/AuthService'; interface ForumContextType { @@ -92,26 +93,45 @@ export function ForumProvider({ children }: { children: React.ReactNode }) { (message: OpchanMessage) => authService.verifyMessage(message) : undefined; - // Transform cells with verification - setCells( - Object.values(messageManager.messageCache.cells) - .map(cell => transformCell(cell, verifyFn)) - .filter(cell => cell !== null) as Cell[] - ); + // Build user verification status for relevance calculation + const relevanceCalculator = new RelevanceCalculator(); + const allUsers: User[] = []; - // Transform posts with verification - setPosts( - Object.values(messageManager.messageCache.posts) - .map(post => transformPost(post, verifyFn)) - .filter(post => post !== null) as Post[] - ); + // Collect all unique users from posts, comments, and votes + const userAddresses = new Set(); - // Transform comments with verification - setComments( - Object.values(messageManager.messageCache.comments) - .map(comment => transformComment(comment, verifyFn)) - .filter(comment => comment !== null) as Comment[] - ); + // Add users from posts + Object.values(messageManager.messageCache.posts).forEach(post => { + userAddresses.add(post.author); + }); + + // Add users from comments + Object.values(messageManager.messageCache.comments).forEach(comment => { + userAddresses.add(comment.author); + }); + + // Add users from votes + Object.values(messageManager.messageCache.votes).forEach(vote => { + userAddresses.add(vote.author); + }); + + // Create user objects for verification status + Array.from(userAddresses).forEach(address => { + allUsers.push({ + address, + walletType: 'bitcoin', // Default, will be updated if we have more info + verificationStatus: 'unverified' + }); + }); + + const userVerificationStatus = relevanceCalculator.buildUserVerificationStatus(allUsers); + + // Transform data with relevance calculation + const { cells, posts, comments } = getDataFromCache(verifyFn, userVerificationStatus); + + setCells(cells); + setPosts(posts); + setComments(comments); }, [authService, isAuthenticated]); const handleRefreshData = async () => { diff --git a/src/lib/forum/relevance.test.ts b/src/lib/forum/relevance.test.ts new file mode 100644 index 0000000..5f89371 --- /dev/null +++ b/src/lib/forum/relevance.test.ts @@ -0,0 +1,141 @@ +import { RelevanceCalculator } from './relevance'; +import { Post, Comment, Cell, User } from '@/types'; +import { MessageType, VoteMessage } from '@/lib/waku/types'; +import { expect, describe, beforeEach, it } from 'vitest'; +import { UserVerificationStatus } from './types'; + +describe('RelevanceCalculator', () => { + let calculator: RelevanceCalculator; + let mockUserVerificationStatus: UserVerificationStatus; + + beforeEach(() => { + calculator = new RelevanceCalculator(); + mockUserVerificationStatus = { + 'user1': { isVerified: true, hasENS: true, hasOrdinal: false }, + 'user2': { isVerified: false, hasENS: false, hasOrdinal: false }, + 'user3': { isVerified: true, hasENS: false, hasOrdinal: true } + }; + }); + + describe('calculatePostScore', () => { + it('should calculate base score for a new post', () => { + const post: Post = { + id: '1', + cellId: 'cell1', + authorAddress: 'user2', + title: 'Test Post', + content: 'Test content', + timestamp: Date.now(), + upvotes: [], + downvotes: [] + }; + + const result = calculator.calculatePostScore(post, [], [], mockUserVerificationStatus); + + expect(result.score).toBeGreaterThan(0); + expect(result.details.baseScore).toBe(10); + expect(result.details.isVerified).toBe(false); + }); + + it('should apply verification bonus for verified author', () => { + const post: Post = { + id: '1', + cellId: 'cell1', + authorAddress: 'user1', + title: 'Test Post', + content: 'Test content', + timestamp: Date.now(), + upvotes: [], + downvotes: [] + }; + + const result = calculator.calculatePostScore(post, [], [], mockUserVerificationStatus); + + expect(result.details.isVerified).toBe(true); + expect(result.details.authorVerificationBonus).toBeGreaterThan(0); + }); + + it('should apply moderation penalty', () => { + const post: Post = { + id: '1', + cellId: 'cell1', + authorAddress: 'user2', + title: 'Test Post', + content: 'Test content', + timestamp: Date.now(), + upvotes: [], + downvotes: [], + moderated: true + }; + + const result = calculator.calculatePostScore(post, [], [], mockUserVerificationStatus); + + expect(result.details.isModerated).toBe(true); + expect(result.details.moderationPenalty).toBe(0.5); + }); + + it('should calculate engagement bonuses', () => { + const post: Post = { + id: '1', + cellId: 'cell1', + authorAddress: 'user2', + title: 'Test Post', + content: 'Test content', + timestamp: Date.now(), + upvotes: [], + downvotes: [] + }; + + const votes: VoteMessage[] = [ + { id: 'vote1', targetId: '1', value: 1, author: 'user1', timestamp: Date.now(), type: MessageType.VOTE }, + { id: 'vote2', targetId: '1', value: 1, author: 'user3', timestamp: Date.now(), type: MessageType.VOTE } + ]; + + const comments: Comment[] = [ + { id: 'comment1', postId: '1', authorAddress: 'user1', content: 'Test comment', timestamp: Date.now(), upvotes: [], downvotes: [] } + ]; + + const result = calculator.calculatePostScore(post, votes, comments, mockUserVerificationStatus); + + expect(result.details.upvotes).toBe(2); + expect(result.details.comments).toBe(1); + expect(result.details.verifiedUpvotes).toBe(2); + expect(result.details.verifiedCommenters).toBe(1); + }); + }); + + describe('timeDecay', () => { + it('should apply time decay to older posts', () => { + const now = Date.now(); + const oneDayAgo = now - (24 * 60 * 60 * 1000); + const oneWeekAgo = now - (7 * 24 * 60 * 60 * 1000); + + const recentPost: Post = { + id: '1', + cellId: 'cell1', + authorAddress: 'user2', + title: 'Recent Post', + content: 'Recent content', + timestamp: now, + upvotes: [], + downvotes: [] + }; + + const oldPost: Post = { + id: '2', + cellId: 'cell1', + authorAddress: 'user2', + title: 'Old Post', + content: 'Old content', + timestamp: oneWeekAgo, + upvotes: [], + downvotes: [] + }; + + const recentResult = calculator.calculatePostScore(recentPost, [], [], mockUserVerificationStatus); + const oldResult = calculator.calculatePostScore(oldPost, [], [], mockUserVerificationStatus); + + expect(recentResult.score).toBeGreaterThan(oldResult.score); + }); + }); +}); diff --git a/src/lib/forum/relevance.ts b/src/lib/forum/relevance.ts new file mode 100644 index 0000000..037bb54 --- /dev/null +++ b/src/lib/forum/relevance.ts @@ -0,0 +1,330 @@ +import { Post, Comment, Cell, User } from '@/types'; +import { VoteMessage } from '@/lib/waku/types'; +import { RelevanceScoreDetails, UserVerificationStatus } from './types'; + +export class RelevanceCalculator { + private static readonly BASE_SCORES = { + POST: 10, + COMMENT: 5, + CELL: 15 + }; + + private static readonly ENGAGEMENT_SCORES = { + UPVOTE: 1, + COMMENT: 0.5 + }; + + private static readonly VERIFICATION_BONUS = 1.25; // 25% increase + private static readonly VERIFIED_UPVOTE_BONUS = 0.1; + private static readonly VERIFIED_COMMENTER_BONUS = 0.05; + + private static readonly DECAY_RATE = 0.1; // λ = 0.1 + private static readonly MODERATION_PENALTY = 0.5; // 50% reduction + + /** + * Calculate relevance score for a post + */ + calculatePostScore( + post: Post, + votes: VoteMessage[], + comments: Comment[], + userVerificationStatus: UserVerificationStatus + ): { score: number; details: RelevanceScoreDetails } { + let score = this.applyBaseScore('POST'); + const baseScore = score; + + const upvotes = votes.filter(vote => vote.value === 1); + const engagementScore = this.applyEngagementScore(upvotes, comments); + score += engagementScore; + + const { bonus: authorVerificationBonus, isVerified } = this.applyAuthorVerificationBonus( + score, + post.authorAddress, + userVerificationStatus + ); + score += authorVerificationBonus; + + const { bonus: verifiedUpvoteBonus, verifiedUpvotes } = this.applyVerifiedUpvoteBonus( + upvotes, + userVerificationStatus + ); + score += verifiedUpvoteBonus; + + const { bonus: verifiedCommenterBonus, verifiedCommenters } = this.applyVerifiedCommenterBonus( + comments, + userVerificationStatus + ); + score += verifiedCommenterBonus; + + const { decayedScore, multiplier: timeDecayMultiplier, daysOld } = this.applyTimeDecay(score, post.timestamp); + score = decayedScore; + + const { penalizedScore, penalty: moderationPenalty } = this.applyModerationPenalty(score, post.moderated || false); + score = penalizedScore; + + const finalScore = Math.max(0, score); // Ensure non-negative score + + return { + score: finalScore, + details: { + baseScore, + engagementScore, + authorVerificationBonus, + verifiedUpvoteBonus, + verifiedCommenterBonus, + timeDecayMultiplier, + moderationPenalty, + finalScore, + isVerified, + upvotes: upvotes.length, + comments: comments.length, + verifiedUpvotes, + verifiedCommenters, + daysOld, + isModerated: post.moderated || false + } + }; + } + + /** + * Calculate relevance score for a comment + */ + calculateCommentScore( + comment: Comment, + votes: VoteMessage[], + userVerificationStatus: UserVerificationStatus + ): { score: number; details: RelevanceScoreDetails } { + // Apply base score + let score = this.applyBaseScore('COMMENT'); + const baseScore = score; + + const upvotes = votes.filter(vote => vote.value === 1); + const engagementScore = this.applyEngagementScore(upvotes, []); + score += engagementScore; + + const { bonus: authorVerificationBonus, isVerified } = this.applyAuthorVerificationBonus( + score, + comment.authorAddress, + userVerificationStatus + ); + score += authorVerificationBonus; + + const { bonus: verifiedUpvoteBonus, verifiedUpvotes } = this.applyVerifiedUpvoteBonus( + upvotes, + userVerificationStatus + ); + score += verifiedUpvoteBonus; + + const { decayedScore, multiplier: timeDecayMultiplier, daysOld } = this.applyTimeDecay(score, comment.timestamp); + score = decayedScore; + + const { penalizedScore, penalty: moderationPenalty } = this.applyModerationPenalty(score, comment.moderated || false); + score = penalizedScore; + + const finalScore = Math.max(0, score); // Ensure non-negative score + + return { + score: finalScore, + details: { + baseScore, + engagementScore, + authorVerificationBonus, + verifiedUpvoteBonus, + verifiedCommenterBonus: 0, // Comments don't have commenters + timeDecayMultiplier, + moderationPenalty, + finalScore, + isVerified, + upvotes: upvotes.length, + comments: 0, // Comments don't have comments + verifiedUpvotes, + verifiedCommenters: 0, + daysOld, + isModerated: comment.moderated || false + } + }; + } + + /** + * Calculate relevance score for a cell + */ + calculateCellScore( + cell: Cell, + posts: Post[], + userVerificationStatus: UserVerificationStatus + ): { score: number; details: RelevanceScoreDetails } { + // Apply base score + let score = this.applyBaseScore('CELL'); + const baseScore = score; + + // Calculate cell-specific engagement + const cellPosts = posts.filter(post => post.cellId === cell.id); + const totalUpvotes = cellPosts.reduce((sum, post) => { + return sum + (post.upvotes?.length || 0); + }, 0); + + const activityScore = cellPosts.length * RelevanceCalculator.ENGAGEMENT_SCORES.COMMENT; + const engagementBonus = totalUpvotes * 0.1; // Small bonus for cell activity + const engagementScore = activityScore + engagementBonus; + score += engagementScore; + + const mostRecentPost = cellPosts.reduce((latest, post) => { + return post.timestamp > latest.timestamp ? post : latest; + }, { timestamp: Date.now() }); + const { decayedScore, multiplier: timeDecayMultiplier, daysOld } = this.applyTimeDecay(score, mostRecentPost.timestamp); + score = decayedScore; + + const finalScore = Math.max(0, score); // Ensure non-negative score + + return { + score: finalScore, + details: { + baseScore, + engagementScore, + authorVerificationBonus: 0, // Cells don't have authors in the same way + verifiedUpvoteBonus: 0, + verifiedCommenterBonus: 0, + timeDecayMultiplier, + moderationPenalty: 1, // Cells aren't moderated + finalScore, + isVerified: false, // Cells don't have verification status + upvotes: totalUpvotes, + comments: cellPosts.length, + verifiedUpvotes: 0, + verifiedCommenters: 0, + daysOld, + isModerated: false + } + }; + } + + + + /** + * Check if a user is verified (has ENS or ordinal ownership) + */ + isUserVerified(user: User): boolean { + return !!(user.ensOwnership || user.ordinalOwnership); + } + + /** + * Build user verification status map from users array + */ + buildUserVerificationStatus(users: User[]): UserVerificationStatus { + const status: UserVerificationStatus = {}; + + users.forEach(user => { + status[user.address] = { + isVerified: this.isUserVerified(user), + hasENS: !!user.ensOwnership, + hasOrdinal: !!user.ordinalOwnership + }; + }); + + return status; + } + + /** + * Apply base score to the current score + */ + private applyBaseScore(type: 'POST' | 'COMMENT' | 'CELL'): number { + return RelevanceCalculator.BASE_SCORES[type]; + } + + /** + * Apply engagement score based on upvotes and comments + */ + private applyEngagementScore( + upvotes: VoteMessage[], + comments: Comment[] = [] + ): number { + const upvoteScore = upvotes.length * RelevanceCalculator.ENGAGEMENT_SCORES.UPVOTE; + const commentScore = comments.length * RelevanceCalculator.ENGAGEMENT_SCORES.COMMENT; + return upvoteScore + commentScore; + } + + /** + * Apply author verification bonus + */ + private applyAuthorVerificationBonus( + score: number, + authorAddress: string, + userVerificationStatus: UserVerificationStatus + ): { bonus: number; isVerified: boolean } { + const authorStatus = userVerificationStatus[authorAddress]; + const isVerified = authorStatus?.isVerified || false; + + if (isVerified) { + const bonus = score * (RelevanceCalculator.VERIFICATION_BONUS - 1); + return { bonus, isVerified }; + } + + return { bonus: 0, isVerified }; + } + + /** + * Apply verified upvote bonus + */ + private applyVerifiedUpvoteBonus( + upvotes: VoteMessage[], + userVerificationStatus: UserVerificationStatus + ): { bonus: number; verifiedUpvotes: number } { + const verifiedUpvotes = upvotes.filter(vote => { + const voterStatus = userVerificationStatus[vote.author]; + return voterStatus?.isVerified; + }); + + const bonus = verifiedUpvotes.length * RelevanceCalculator.VERIFIED_UPVOTE_BONUS; + return { bonus, verifiedUpvotes: verifiedUpvotes.length }; + } + + /** + * Apply verified commenter bonus + */ + private applyVerifiedCommenterBonus( + comments: Comment[], + userVerificationStatus: UserVerificationStatus + ): { bonus: number; verifiedCommenters: number } { + const verifiedCommenters = new Set(); + + comments.forEach(comment => { + const commenterStatus = userVerificationStatus[comment.authorAddress]; + if (commenterStatus?.isVerified) { + verifiedCommenters.add(comment.authorAddress); + } + }); + + const bonus = verifiedCommenters.size * RelevanceCalculator.VERIFIED_COMMENTER_BONUS; + return { bonus, verifiedCommenters: verifiedCommenters.size }; + } + + /** + * Apply time decay to a score + */ + private applyTimeDecay(score: number, timestamp: number): { + decayedScore: number; + multiplier: number; + daysOld: number + } { + const daysOld = (Date.now() - timestamp) / (1000 * 60 * 60 * 24); + const multiplier = Math.exp(-RelevanceCalculator.DECAY_RATE * daysOld); + const decayedScore = score * multiplier; + + return { decayedScore, multiplier, daysOld }; + } + + /** + * Apply moderation penalty + */ + private applyModerationPenalty( + score: number, + isModerated: boolean + ): { penalizedScore: number; penalty: number } { + if (isModerated) { + const penalizedScore = score * RelevanceCalculator.MODERATION_PENALTY; + return { penalizedScore, penalty: RelevanceCalculator.MODERATION_PENALTY }; + } + + return { penalizedScore: score, penalty: 1 }; + } +} diff --git a/src/lib/forum/sorting.ts b/src/lib/forum/sorting.ts new file mode 100644 index 0000000..4167621 --- /dev/null +++ b/src/lib/forum/sorting.ts @@ -0,0 +1,59 @@ +import { Post, Comment, Cell } from '@/types'; + +export type SortOption = 'relevance' | 'time'; + +/** + * Sort posts by relevance score (highest first) + */ +export const sortByRelevance = (items: Post[] | Comment[] | Cell[]) => { + return items.sort((a, b) => (b.relevanceScore || 0) - (a.relevanceScore || 0)); +}; + +/** + * Sort by timestamp (newest first) + */ +export const sortByTime = (items: Post[] | Comment[] | Cell[]) => { + return items.sort((a, b) => b.timestamp - a.timestamp); +}; + +/** + * Sort posts with a specific option + */ +export const sortPosts = (posts: Post[], option: SortOption): Post[] => { + switch (option) { + case 'relevance': + return sortByRelevance(posts) as Post[]; + case 'time': + return sortByTime(posts) as Post[]; + default: + return sortByRelevance(posts) as Post[]; + } +}; + +/** + * Sort comments with a specific option + */ +export const sortComments = (comments: Comment[], option: SortOption): Comment[] => { + switch (option) { + case 'relevance': + return sortByRelevance(comments) as Comment[]; + case 'time': + return sortByTime(comments) as Comment[]; + default: + return sortByRelevance(comments) as Comment[]; + } +}; + +/** + * Sort cells with a specific option + */ +export const sortCells = (cells: Cell[], option: SortOption): Cell[] => { + switch (option) { + case 'relevance': + return sortByRelevance(cells) as Cell[]; + case 'time': + return sortByTime(cells) as Cell[]; + default: + return sortByRelevance(cells) as Cell[]; + } +}; diff --git a/src/lib/forum/transformers.ts b/src/lib/forum/transformers.ts index ed64958..6c7ac87 100644 --- a/src/lib/forum/transformers.ts +++ b/src/lib/forum/transformers.ts @@ -1,19 +1,22 @@ -import { Cell, Post, Comment, OpchanMessage } from '@/types'; +import { Cell, Post, Comment, OpchanMessage, User } from '@/types'; import { CellMessage, CommentMessage, MessageType, PostMessage, VoteMessage } from '@/lib/waku/types'; import messageManager from '@/lib/waku'; +import { RelevanceCalculator, UserVerificationStatus } from './relevance'; type VerifyFunction = (message: OpchanMessage) => boolean; export const transformCell = ( cellMessage: CellMessage, - verifyMessage?: VerifyFunction + verifyMessage?: VerifyFunction, + userVerificationStatus?: UserVerificationStatus, + posts?: Post[] ): Cell | null => { if (verifyMessage && !verifyMessage(cellMessage)) { console.warn(`Cell message ${cellMessage.id} failed verification`); return null; } - return { + const transformedCell = { id: cellMessage.id, name: cellMessage.name, description: cellMessage.description, @@ -21,11 +24,39 @@ export const transformCell = ( signature: cellMessage.signature, browserPubKey: cellMessage.browserPubKey, }; + + // Calculate relevance score if user verification status and posts are provided + if (userVerificationStatus && posts) { + const relevanceCalculator = new RelevanceCalculator(); + + const relevanceResult = relevanceCalculator.calculateCellScore( + transformedCell, + posts, + userVerificationStatus + ); + + // Calculate active member count + const cellPosts = posts.filter(post => post.cellId === cellMessage.id); + const activeMembers = new Set(); + cellPosts.forEach(post => { + activeMembers.add(post.authorAddress); + }); + + return { + ...transformedCell, + relevanceScore: relevanceResult.score, + activeMemberCount: activeMembers.size, + relevanceDetails: relevanceResult.details + }; + } + + return transformedCell; }; export const transformPost = ( postMessage: PostMessage, - verifyMessage?: VerifyFunction + verifyMessage?: VerifyFunction, + userVerificationStatus?: UserVerificationStatus ): Post | null => { if (verifyMessage && !verifyMessage(postMessage)) { console.warn(`Post message ${postMessage.id} failed verification`); @@ -48,7 +79,7 @@ export const transformPost = ( ); const isUserModerated = !!userModMsg; - return { + const transformedPost = { id: postMessage.id, cellId: postMessage.cellId, authorAddress: postMessage.author, @@ -64,11 +95,56 @@ export const transformPost = ( moderationReason: isPostModerated ? modMsg.reason : isUserModerated ? userModMsg!.reason : undefined, moderationTimestamp: isPostModerated ? modMsg.timestamp : isUserModerated ? userModMsg!.timestamp : undefined, }; + + // Calculate relevance score if user verification status is provided + if (userVerificationStatus) { + const relevanceCalculator = new RelevanceCalculator(); + + // Get comments for this post + const comments = Object.values(messageManager.messageCache.comments) + .map((comment) => transformComment(comment, verifyMessage, userVerificationStatus)) + .filter(Boolean) as Comment[]; + const postComments = comments.filter(comment => comment.postId === postMessage.id); + + const relevanceResult = relevanceCalculator.calculatePostScore( + transformedPost, + filteredVotes, + postComments, + userVerificationStatus + ); + + const relevanceScore = relevanceResult.score; + + // Calculate verified upvotes and commenters + const verifiedUpvotes = upvotes.filter(vote => { + const voterStatus = userVerificationStatus[vote.author]; + return voterStatus?.isVerified; + }).length; + + const verifiedCommenters = new Set(); + postComments.forEach(comment => { + const commenterStatus = userVerificationStatus[comment.authorAddress]; + if (commenterStatus?.isVerified) { + verifiedCommenters.add(comment.authorAddress); + } + }); + + return { + ...transformedPost, + relevanceScore, + verifiedUpvotes, + verifiedCommenters: Array.from(verifiedCommenters), + relevanceDetails: relevanceResult.details + }; + } + + return transformedPost; }; export const transformComment = ( commentMessage: CommentMessage, verifyMessage?: VerifyFunction, + userVerificationStatus?: UserVerificationStatus ): Comment | null => { if (verifyMessage && !verifyMessage(commentMessage)) { console.warn(`Comment message ${commentMessage.id} failed verification`); @@ -93,7 +169,7 @@ export const transformComment = ( ); const isUserModerated = !!userModMsg; - return { + const transformedComment = { id: commentMessage.id, postId: commentMessage.postId, authorAddress: commentMessage.author, @@ -108,6 +184,25 @@ export const transformComment = ( moderationReason: isCommentModerated ? modMsg.reason : isUserModerated ? userModMsg!.reason : undefined, moderationTimestamp: isCommentModerated ? modMsg.timestamp : isUserModerated ? userModMsg!.timestamp : undefined, }; + + // Calculate relevance score if user verification status is provided + if (userVerificationStatus) { + const relevanceCalculator = new RelevanceCalculator(); + + const relevanceResult = relevanceCalculator.calculateCommentScore( + transformedComment, + filteredVotes, + userVerificationStatus + ); + + return { + ...transformedComment, + relevanceScore: relevanceResult.score, + relevanceDetails: relevanceResult.details + }; + } + + return transformedComment; }; export const transformVote = ( @@ -121,15 +216,23 @@ export const transformVote = ( return voteMessage; }; -export const getDataFromCache = (verifyMessage?: VerifyFunction) => { - const cells = Object.values(messageManager.messageCache.cells) - .map((cell) => transformCell(cell, verifyMessage)) - .filter(Boolean) as Cell[]; +export const getDataFromCache = ( + verifyMessage?: VerifyFunction, + userVerificationStatus?: UserVerificationStatus +) => { + // First transform posts and comments to get relevance scores const posts = Object.values(messageManager.messageCache.posts) - .map((post) => transformPost(post, verifyMessage)) + .map((post) => transformPost(post, verifyMessage, userVerificationStatus)) .filter(Boolean) as Post[]; + const comments = Object.values(messageManager.messageCache.comments) - .map((c) => transformComment(c, verifyMessage)) + .map((c) => transformComment(c, verifyMessage, userVerificationStatus)) .filter(Boolean) as Comment[]; + + // Then transform cells with posts for relevance calculation + const cells = Object.values(messageManager.messageCache.cells) + .map((cell) => transformCell(cell, verifyMessage, userVerificationStatus, posts)) + .filter(Boolean) as Cell[]; + return { cells, posts, comments }; }; diff --git a/src/lib/forum/types.ts b/src/lib/forum/types.ts new file mode 100644 index 0000000..4016bef --- /dev/null +++ b/src/lib/forum/types.ts @@ -0,0 +1,25 @@ +export interface RelevanceScoreDetails { + baseScore: number; + engagementScore: number; + authorVerificationBonus: number; + verifiedUpvoteBonus: number; + verifiedCommenterBonus: number; + timeDecayMultiplier: number; + moderationPenalty: number; + finalScore: number; + isVerified: boolean; + upvotes: number; + comments: number; + verifiedUpvotes: number; + verifiedCommenters: number; + daysOld: number; + isModerated: boolean; + } + + export interface UserVerificationStatus { + [address: string]: { + isVerified: boolean; + hasENS: boolean; + hasOrdinal: boolean; + }; + } \ No newline at end of file diff --git a/src/pages/FeedPage.tsx b/src/pages/FeedPage.tsx index 51fbd21..da4ac8d 100644 --- a/src/pages/FeedPage.tsx +++ b/src/pages/FeedPage.tsx @@ -1,11 +1,13 @@ -import React, { useMemo } from 'react'; -import { RefreshCw, Plus } from 'lucide-react'; +import React, { useMemo, useState } from 'react'; +import { RefreshCw, Plus, TrendingUp, Clock } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Skeleton } from '@/components/ui/skeleton'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import PostCard from '@/components/PostCard'; import FeedSidebar from '@/components/FeedSidebar'; import { useForum } from '@/contexts/useForum'; import { useAuth } from '@/contexts/useAuth'; +import { sortPosts, SortOption } from '@/lib/forum/sorting'; const FeedPage: React.FC = () => { const { @@ -16,13 +18,13 @@ const FeedPage: React.FC = () => { refreshData } = useForum(); const { verificationStatus } = useAuth(); + const [sortOption, setSortOption] = useState('relevance'); - // Combine posts from all cells and sort by timestamp (newest first) + // Combine posts from all cells and apply sorting const allPosts = useMemo(() => { - return [...posts] - .sort((a, b) => b.timestamp - a.timestamp) - .filter(post => !post.moderated); // Hide moderated posts from main feed - }, [posts]); + const filteredPosts = posts.filter(post => !post.moderated); // Hide moderated posts from main feed + return sortPosts(filteredPosts, sortOption); + }, [posts, sortOption]); // Calculate comment counts for each post const getCommentCount = (postId: string) => { @@ -92,6 +94,26 @@ const FeedPage: React.FC = () => {

+ +