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 (
+ <>
+
+ >
+ );
+}
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 = () => {
+
+