Adding comment sorting

- Fixes #15
This commit is contained in:
Dessalines 2019-03-29 23:08:02 -07:00
parent 743390113a
commit 480b627ff5
6 changed files with 126 additions and 23 deletions

View File

@ -38,10 +38,38 @@ We have a twitter alternative (mastodon), a facebook alternative (friendica), so
- [RXJS websocket](https://stackoverflow.com/questions/44060315/reconnecting-a-websocket-in-angular-and-rxjs/44067972#44067972)
- [Rust JWT](https://github.com/Keats/jsonwebtoken)
- [Hierarchical tree building javascript](https://stackoverflow.com/a/40732240/1655478)
- [Hot sorting discussion](https://meta.stackexchange.com/questions/11602/what-formula-should-be-used-to-determine-hot-questions) [2](https://medium.com/hacking-and-gonzo/how-reddit-ranking-algorithms-work-ef111e33d0d9)
## TODOs
- Endpoints
- DB
- Followers / following
# Trending / Hot / Best Sorting algorithm
## Goals
- During the day, new posts and comments should be near the top, so they can be voted on.
- After a day or so, the time factor should go away.
- Use a log scale, since votes tend to snowball, and so the first 10 votes are just as important as the next hundred.
## Reddit Sorting
[Reddit's comment sorting algorithm](https://medium.com/hacking-and-gonzo/how-reddit-ranking-algorithms-work-ef111e33d0d9), the wilson confidence sort, is inadequate, because it completely ignores time. What ends up happening, especially in smaller subreddits, is that the early comments end up getting upvoted, and newer comments stay at the bottom, never to be seen.
## Hacker News Sorting
The [Hacker New's ranking algorithm](https://medium.com/hacking-and-gonzo/how-hacker-news-ranking-algorithm-works-1d9b0cf2c08d) is great, but it doesn't use a log scale for the scores.
## My Algorithm
```
Rank = ScaleFactor * sign(Score) * log(1 + abs(Score)) / (Time + 2)^Gravity
Score = Upvotes - Downvotes
Time = time since submission (in hours)
Gravity = Decay gravity, 1.8 is default
```
- Add 1 to the score, so that the standard new comment score of +1 will be affected by time decay. Otherwise all new comments would stay at zero, near the bottom.
- The sign and abs of the score are necessary for dealing with the log of negative scores.
- A scale factor of 10k gets the rank in integer form.
A plot of rank over 24 hours, of scores of 1, 5, 10, 100, 1000, with a scale factor of 10k.
![](https://i.imgur.com/w8oBLlL.png)

View File

@ -1,9 +1,9 @@
import { Component, linkEvent } from 'inferno';
import { Subscription } from "rxjs";
import { retryWhen, delay, take } from 'rxjs/operators';
import { UserOperation, Community, Post as PostI, PostResponse, Comment, CommentForm as CommentFormI, CommentResponse, CommentLikeForm, CreateCommentLikeResponse } from '../interfaces';
import { UserOperation, Community, Post as PostI, PostResponse, Comment, CommentForm as CommentFormI, CommentResponse, CommentLikeForm, CreateCommentLikeResponse, CommentSortType } from '../interfaces';
import { WebSocketService, UserService } from '../services';
import { msgOp } from '../utils';
import { msgOp, hotRank } from '../utils';
import { MomentTime } from './moment-time';
interface CommentNodeI {
@ -14,6 +14,7 @@ interface CommentNodeI {
interface State {
post: PostI;
comments: Array<Comment>;
commentSort: CommentSortType;
}
export class Post extends Component<any, State> {
@ -27,7 +28,8 @@ export class Post extends Component<any, State> {
id: null,
published: null,
},
comments: []
comments: [],
commentSort: CommentSortType.Hot
}
constructor(props, context) {
@ -59,6 +61,7 @@ export class Post extends Component<any, State> {
<div class="col-12 col-sm-8 col-lg-7 mb-3">
{this.postHeader()}
<CommentForm postId={this.state.post.id} />
{this.sortRadios()}
{this.commentsTree()}
</div>
<div class="col-12 col-sm-4 col-lg-3 mb-3">
@ -88,6 +91,28 @@ export class Post extends Component<any, State> {
)
}
sortRadios() {
return (
<div class="btn-group btn-group-toggle mb-3">
<label className={`btn btn-sm btn-secondary ${this.state.commentSort === CommentSortType.Hot && 'active'}`}>Hot
<input type="radio" value={CommentSortType.Hot}
checked={this.state.commentSort === CommentSortType.Hot}
onChange={linkEvent(this, this.handleCommentSortChange)} />
</label>
<label className={`btn btn-sm btn-secondary ${this.state.commentSort === CommentSortType.Top && 'active'}`}>Top
<input type="radio" value={CommentSortType.Top}
checked={this.state.commentSort === CommentSortType.Top}
onChange={linkEvent(this, this.handleCommentSortChange)} />
</label>
<label className={`btn btn-sm btn-secondary ${this.state.commentSort === CommentSortType.New && 'active'}`}>New
<input type="radio" value={CommentSortType.New}
checked={this.state.commentSort === CommentSortType.New}
onChange={linkEvent(this, this.handleCommentSortChange)} />
</label>
</div>
)
}
newComments() {
return (
<div class="sticky-top">
@ -107,28 +132,50 @@ export class Post extends Component<any, State> {
</div>
);
}
handleCommentSortChange(i: Post, event) {
i.state.commentSort = Number(event.target.value);
i.setState(i.state);
}
private buildCommentsTree(): Array<CommentNodeI> {
let map = new Map<number, CommentNodeI>();
for (let comment of this.state.comments) {
let node: CommentNodeI = {
comment: comment,
children: []
};
map.set(comment.id, { ...node });
}
let tree: Array<CommentNodeI> = [];
for (let comment of this.state.comments) {
if( comment.parent_id ) {
map.get(comment.parent_id).children.push(map.get(comment.id));
}
else {
tree.push(map.get(comment.id));
}
}
this.sortTree(tree);
// buildCommentsTree(): Array<CommentNodeI> {
buildCommentsTree(): any {
let tree: Array<CommentNodeI> = this.createCommentsTree(this.state.comments);
console.log(tree); // TODO this is redoing every time and it shouldn't
return tree;
}
private createCommentsTree(comments: Array<Comment>): Array<CommentNodeI> {
let hashTable = {};
for (let comment of comments) {
let node: CommentNodeI = {
comment: comment
};
hashTable[comment.id] = { ...node, children : [] };
sortTree(tree: Array<CommentNodeI>) {
if (this.state.commentSort == CommentSortType.Top) {
tree.sort((a, b) => b.comment.score - a.comment.score);
} else if (this.state.commentSort == CommentSortType.New) {
tree.sort((a, b) => b.comment.published.localeCompare(a.comment.published));
} else if (this.state.commentSort == CommentSortType.Hot) {
tree.sort((a, b) => hotRank(b.comment) - hotRank(a.comment));
}
let tree: Array<CommentNodeI> = [];
for (let comment of comments) {
if( comment.parent_id ) hashTable[comment.parent_id].children.push(hashTable[comment.id]);
else tree.push(hashTable[comment.id]);
for (let node of tree) {
this.sortTree(node.children);
}
return tree;
}
commentsTree() {
@ -280,7 +327,7 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
}
</div>
{this.state.showReply && <CommentForm node={node} onReplyCancel={this.handleReplyCancel} />}
{this.props.node.children && <CommentNodes nodes={this.props.node.children}/>}
{this.props.node.children && <CommentNodes nodes={this.props.node.children} />}
</div>
)
}
@ -389,8 +436,8 @@ export class CommentForm extends Component<CommentFormProps, CommentFormState> {
</div>
<div class="row">
<div class="col-sm-12">
<button type="submit" class="btn btn-secondary mr-2">{this.state.buttonTitle}</button>
{this.props.node && <button type="button" class="btn btn-secondary" onClick={linkEvent(this, this.handleReplyCancel)}>Cancel</button>}
<button type="submit" class="btn btn-sm btn-secondary mr-2">{this.state.buttonTitle}</button>
{this.props.node && <button type="button" class="btn btn-sm btn-secondary" onClick={linkEvent(this, this.handleReplyCancel)}>Cancel</button>}
</div>
</div>
</form>

View File

@ -112,4 +112,8 @@ export interface LoginResponse {
jwt: string;
}
export enum CommentSortType {
Hot, Top, New
}

View File

@ -13,3 +13,13 @@ body {
.downvote:hover {
color: var(--danger);
}
.form-control, .form-control:focus {
background-color: var(--secondary);
color: #fff;
}
.custom-select {
color: #fff;
background-color: var(--secondary);
}

View File

@ -88,5 +88,6 @@ export class WebSocketService {
window.onbeforeunload = (e => {
WebSocketService.Instance.subject.unsubscribe();
WebSocketService.Instance.subject = null;
});

View File

@ -1,4 +1,4 @@
import { UserOperation } from './interfaces';
import { UserOperation, Comment } from './interfaces';
export let repoUrl = 'https://github.com/dessalines/rust-reddit-fediverse';
export let wsUri = (window.location.protocol=='https:'&&'wss://'||'ws://')+window.location.host + '/service/ws/';
@ -8,3 +8,16 @@ export function msgOp(msg: any): UserOperation {
return UserOperation[opStr];
}
export function hotRank(comment: Comment): number {
// Rank = ScaleFactor * sign(Score) * log(1 + abs(Score)) / (Time + 2)^Gravity
let date: Date = new Date(comment.published + 'Z'); // Add Z to convert from UTC date
let now: Date = new Date();
let hoursElapsed: number = (now.getTime() - date.getTime()) / 36e5;
let rank = (10000 * Math.sign(comment.score) * Math.log10(1 + Math.abs(comment.score))) / Math.pow(hoursElapsed + 2, 1.8);
// console.log(`Comment: ${comment.content}\nRank: ${rank}\nScore: ${comment.score}\nHours: ${hoursElapsed}`);
return rank;
}