mirror of
https://github.com/logos-messaging/OpChan.git
synced 2026-01-07 23:33:07 +00:00
feat: relevance index system for the feed (+ sorting) (#15)
* core relevance algorithm * feat: integrate relevance algo into UI + sorting
This commit is contained in:
parent
c0497610c3
commit
808820b4f4
@ -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<SortOption>('relevance');
|
||||
|
||||
// Apply sorting to cells
|
||||
const sortedCells = useMemo(() => {
|
||||
return sortCells(cells, sortOption);
|
||||
}, [cells, sortOption]);
|
||||
|
||||
if (isInitialLoading) {
|
||||
return (
|
||||
@ -31,6 +40,26 @@ const CellList = () => {
|
||||
<h1 className="text-2xl font-bold text-glow">Cells</h1>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Select value={sortOption} onValueChange={(value: SortOption) => setSortOption(value)}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="relevance">
|
||||
<div className="flex items-center gap-2">
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
<span>Relevance</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="time">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>Newest</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
@ -52,7 +81,7 @@ const CellList = () => {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
cells.map((cell) => (
|
||||
sortedCells.map((cell) => (
|
||||
<Link to={`/cell/${cell.id}`} key={cell.id} className="board-card group">
|
||||
<div className="flex gap-4 items-start">
|
||||
<CypherImage
|
||||
@ -64,9 +93,20 @@ const CellList = () => {
|
||||
<div className="flex-1">
|
||||
<h2 className="text-xl font-bold mb-1 group-hover:text-cyber-accent transition-colors">{cell.name}</h2>
|
||||
<p className="text-sm text-cyber-neutral mb-2">{cell.description}</p>
|
||||
<div className="flex items-center text-xs text-cyber-neutral">
|
||||
<MessageSquare className="w-3 h-3 mr-1" />
|
||||
<span>{getPostCount(cell.id)} threads</span>
|
||||
<div className="flex items-center text-xs text-cyber-neutral gap-2">
|
||||
<div className="flex items-center">
|
||||
<MessageSquare className="w-3 h-3 mr-1" />
|
||||
<span>{getPostCount(cell.id)} threads</span>
|
||||
</div>
|
||||
{cell.relevanceScore !== undefined && (
|
||||
<RelevanceIndicator
|
||||
score={cell.relevanceScore}
|
||||
details={cell.relevanceDetails}
|
||||
type="cell"
|
||||
className="text-xs"
|
||||
showTooltip={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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<PostCardProps> = ({ post, commentCount = 0 }) => {
|
||||
<span>Posted by u/{post.authorAddress.slice(0, 6)}...{post.authorAddress.slice(-4)}</span>
|
||||
<span>•</span>
|
||||
<span>{formatDistanceToNow(new Date(post.timestamp), { addSuffix: true })}</span>
|
||||
{post.relevanceScore !== undefined && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<RelevanceIndicator
|
||||
score={post.relevanceScore}
|
||||
details={post.relevanceDetails}
|
||||
type="post"
|
||||
className="text-xs"
|
||||
showTooltip={true}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Post title */}
|
||||
|
||||
@ -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 = () => {
|
||||
<span className="truncate max-w-[150px]">
|
||||
{post.authorAddress.slice(0, 6)}...{post.authorAddress.slice(-4)}
|
||||
</span>
|
||||
{post.relevanceScore !== undefined && (
|
||||
<RelevanceIndicator
|
||||
score={post.relevanceScore}
|
||||
details={post.relevanceDetails}
|
||||
type="post"
|
||||
className="text-xs"
|
||||
showTooltip={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -252,9 +262,20 @@ const PostDetail = () => {
|
||||
{comment.authorAddress.slice(0, 6)}...{comment.authorAddress.slice(-4)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDistanceToNow(comment.timestamp, { addSuffix: true })}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{comment.relevanceScore !== undefined && (
|
||||
<RelevanceIndicator
|
||||
score={comment.relevanceScore}
|
||||
details={comment.relevanceDetails}
|
||||
type="comment"
|
||||
className="text-xs"
|
||||
showTooltip={true}
|
||||
/>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDistanceToNow(comment.timestamp, { addSuffix: true })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm break-words">{comment.content}</p>
|
||||
{isCellAdmin && !comment.moderated && (
|
||||
|
||||
205
src/components/ui/relevance-indicator.tsx
Normal file
205
src/components/ui/relevance-indicator.tsx
Normal file
@ -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 (
|
||||
<div className="text-xs space-y-1">
|
||||
<div className="font-semibold">Relevance Score: {formatScore(score)}</div>
|
||||
<div>Base: {formatScore(details.baseScore)}</div>
|
||||
<div>Engagement: +{formatScore(details.engagementScore)}</div>
|
||||
{details.authorVerificationBonus > 0 && (
|
||||
<div>Author Bonus: +{formatScore(details.authorVerificationBonus)}</div>
|
||||
)}
|
||||
{details.verifiedUpvoteBonus > 0 && (
|
||||
<div>Verified Upvotes: +{formatScore(details.verifiedUpvoteBonus)}</div>
|
||||
)}
|
||||
{details.verifiedCommenterBonus > 0 && (
|
||||
<div>Verified Commenters: +{formatScore(details.verifiedCommenterBonus)}</div>
|
||||
)}
|
||||
<div>Time Decay: ×{details.timeDecayMultiplier.toFixed(2)}</div>
|
||||
{details.isModerated && (
|
||||
<div>Moderation: ×{details.moderationPenalty.toFixed(1)}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const badge = (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={`cursor-pointer ${getScoreColor(score)} text-white ${className}`}
|
||||
title={showTooltip ? undefined : "Click to see relevance score details"}
|
||||
>
|
||||
<TrendingUp className="w-3 h-3 mr-1" />
|
||||
{formatScore(score)}
|
||||
</Badge>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
{showTooltip ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DialogTrigger asChild>
|
||||
{badge}
|
||||
</DialogTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs">
|
||||
{createTooltipContent()}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<DialogTrigger asChild>
|
||||
{badge}
|
||||
</DialogTrigger>
|
||||
)}
|
||||
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<TrendingUp className="w-5 h-5" />
|
||||
Relevance Score Details
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Final Score: {formatScore(score)}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{type.charAt(0).toUpperCase() + type.slice(1)} relevance score
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{details && (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Score Breakdown</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 bg-blue-500 rounded"></div>
|
||||
<span>Base Score</span>
|
||||
</div>
|
||||
<span className="font-mono">{formatScore(details.baseScore)}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<ThumbsUp className="w-4 h-4 text-green-500" />
|
||||
<span>Engagement ({details.upvotes} upvotes, {details.comments} comments)</span>
|
||||
</div>
|
||||
<span className="font-mono">+{formatScore(details.engagementScore)}</span>
|
||||
</div>
|
||||
|
||||
{details.authorVerificationBonus > 0 && (
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<UserCheck className="w-4 h-4 text-purple-500" />
|
||||
<span>Author Verification Bonus</span>
|
||||
</div>
|
||||
<span className="font-mono">+{formatScore(details.authorVerificationBonus)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{details.verifiedUpvoteBonus > 0 && (
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="w-4 h-4 text-indigo-500" />
|
||||
<span>Verified Upvotes ({details.verifiedUpvotes})</span>
|
||||
</div>
|
||||
<span className="font-mono">+{formatScore(details.verifiedUpvoteBonus)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{details.verifiedCommenterBonus > 0 && (
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquare className="w-4 h-4 text-teal-500" />
|
||||
<span>Verified Commenters ({details.verifiedCommenters})</span>
|
||||
</div>
|
||||
<span className="font-mono">+{formatScore(details.verifiedCommenterBonus)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-4 h-4 text-orange-500" />
|
||||
<span>Time Decay ({details.daysOld.toFixed(1)} days old)</span>
|
||||
</div>
|
||||
<span className="font-mono">×{details.timeDecayMultiplier.toFixed(3)}</span>
|
||||
</div>
|
||||
|
||||
{details.isModerated && (
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="w-4 h-4 text-red-500" />
|
||||
<span>Moderation Penalty</span>
|
||||
</div>
|
||||
<span className="font-mono">×{details.moderationPenalty.toFixed(1)}</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">User Status</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-2">
|
||||
<UserCheck className={`w-4 h-4 ${details.isVerified ? 'text-green-500' : 'text-gray-400'}`} />
|
||||
<span className={details.isVerified ? 'text-green-600' : 'text-gray-500'}>
|
||||
{details.isVerified ? 'Verified User' : 'Unverified User'}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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<string>();
|
||||
|
||||
// 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 () => {
|
||||
|
||||
141
src/lib/forum/relevance.test.ts
Normal file
141
src/lib/forum/relevance.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
330
src/lib/forum/relevance.ts
Normal file
330
src/lib/forum/relevance.ts
Normal file
@ -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<string>();
|
||||
|
||||
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 };
|
||||
}
|
||||
}
|
||||
59
src/lib/forum/sorting.ts
Normal file
59
src/lib/forum/sorting.ts
Normal file
@ -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[];
|
||||
}
|
||||
};
|
||||
@ -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<string>();
|
||||
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<string>();
|
||||
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 };
|
||||
};
|
||||
|
||||
25
src/lib/forum/types.ts
Normal file
25
src/lib/forum/types.ts
Normal file
@ -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;
|
||||
};
|
||||
}
|
||||
@ -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<SortOption>('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 = () => {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Select value={sortOption} onValueChange={(value: SortOption) => setSortOption(value)}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="relevance">
|
||||
<div className="flex items-center gap-2">
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
<span>Relevance</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="time">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>Newest</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { CellMessage, CommentMessage, PostMessage, VoteMessage, ModerateMessage } from "@/lib/waku/types";
|
||||
import { RelevanceScoreDetails } from "@/lib/forum/relevance";
|
||||
|
||||
export type OpchanMessage = CellMessage | PostMessage | CommentMessage | VoteMessage | ModerateMessage;
|
||||
|
||||
@ -30,6 +31,9 @@ export interface Cell {
|
||||
icon?: string;
|
||||
signature?: string; // Message signature
|
||||
browserPubKey?: string; // Public key that signed the message
|
||||
relevanceScore?: number; // Calculated relevance score
|
||||
activeMemberCount?: number; // Number of active members in the cell
|
||||
relevanceDetails?: RelevanceScoreDetails; // Detailed breakdown of relevance score calculation
|
||||
}
|
||||
|
||||
export interface Post {
|
||||
@ -47,6 +51,10 @@ export interface Post {
|
||||
moderatedBy?: string;
|
||||
moderationReason?: string;
|
||||
moderationTimestamp?: number;
|
||||
relevanceScore?: number; // Calculated relevance score
|
||||
verifiedUpvotes?: number; // Count of upvotes from verified users
|
||||
verifiedCommenters?: string[]; // List of verified users who commented
|
||||
relevanceDetails?: RelevanceScoreDetails; // Detailed breakdown of relevance score calculation
|
||||
}
|
||||
|
||||
export interface Comment {
|
||||
@ -63,6 +71,8 @@ export interface Comment {
|
||||
moderatedBy?: string;
|
||||
moderationReason?: string;
|
||||
moderationTimestamp?: number;
|
||||
relevanceScore?: number; // Calculated relevance score
|
||||
relevanceDetails?: RelevanceScoreDetails; // Detailed breakdown of relevance score calculation
|
||||
}
|
||||
|
||||
// Extended message types for verification
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user