mirror of
https://github.com/LemmyNet/lemmy.git
synced 2024-11-27 06:41:18 +00:00
parent
db1fd88870
commit
b61ca7885f
6 changed files with 126 additions and 23 deletions
28
README.md
28
README.md
|
@ -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)
|
- [RXJS websocket](https://stackoverflow.com/questions/44060315/reconnecting-a-websocket-in-angular-and-rxjs/44067972#44067972)
|
||||||
- [Rust JWT](https://github.com/Keats/jsonwebtoken)
|
- [Rust JWT](https://github.com/Keats/jsonwebtoken)
|
||||||
- [Hierarchical tree building javascript](https://stackoverflow.com/a/40732240/1655478)
|
- [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
|
## TODOs
|
||||||
- Endpoints
|
- Endpoints
|
||||||
- DB
|
- DB
|
||||||
- Followers / following
|
- 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)
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import { Component, linkEvent } from 'inferno';
|
import { Component, linkEvent } from 'inferno';
|
||||||
import { Subscription } from "rxjs";
|
import { Subscription } from "rxjs";
|
||||||
import { retryWhen, delay, take } from 'rxjs/operators';
|
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 { WebSocketService, UserService } from '../services';
|
||||||
import { msgOp } from '../utils';
|
import { msgOp, hotRank } from '../utils';
|
||||||
import { MomentTime } from './moment-time';
|
import { MomentTime } from './moment-time';
|
||||||
|
|
||||||
interface CommentNodeI {
|
interface CommentNodeI {
|
||||||
|
@ -14,6 +14,7 @@ interface CommentNodeI {
|
||||||
interface State {
|
interface State {
|
||||||
post: PostI;
|
post: PostI;
|
||||||
comments: Array<Comment>;
|
comments: Array<Comment>;
|
||||||
|
commentSort: CommentSortType;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Post extends Component<any, State> {
|
export class Post extends Component<any, State> {
|
||||||
|
@ -27,7 +28,8 @@ export class Post extends Component<any, State> {
|
||||||
id: null,
|
id: null,
|
||||||
published: null,
|
published: null,
|
||||||
},
|
},
|
||||||
comments: []
|
comments: [],
|
||||||
|
commentSort: CommentSortType.Hot
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(props, context) {
|
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">
|
<div class="col-12 col-sm-8 col-lg-7 mb-3">
|
||||||
{this.postHeader()}
|
{this.postHeader()}
|
||||||
<CommentForm postId={this.state.post.id} />
|
<CommentForm postId={this.state.post.id} />
|
||||||
|
{this.sortRadios()}
|
||||||
{this.commentsTree()}
|
{this.commentsTree()}
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-sm-4 col-lg-3 mb-3">
|
<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() {
|
newComments() {
|
||||||
return (
|
return (
|
||||||
<div class="sticky-top">
|
<div class="sticky-top">
|
||||||
|
@ -108,27 +133,49 @@ export class Post extends Component<any, State> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildCommentsTree(): Array<CommentNodeI> {
|
handleCommentSortChange(i: Post, event) {
|
||||||
buildCommentsTree(): any {
|
i.state.commentSort = Number(event.target.value);
|
||||||
let tree: Array<CommentNodeI> = this.createCommentsTree(this.state.comments);
|
i.setState(i.state);
|
||||||
console.log(tree); // TODO this is redoing every time and it shouldn't
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
return tree;
|
return tree;
|
||||||
}
|
}
|
||||||
|
|
||||||
private createCommentsTree(comments: Array<Comment>): Array<CommentNodeI> {
|
sortTree(tree: Array<CommentNodeI>) {
|
||||||
let hashTable = {};
|
|
||||||
for (let comment of comments) {
|
if (this.state.commentSort == CommentSortType.Top) {
|
||||||
let node: CommentNodeI = {
|
tree.sort((a, b) => b.comment.score - a.comment.score);
|
||||||
comment: comment
|
} else if (this.state.commentSort == CommentSortType.New) {
|
||||||
};
|
tree.sort((a, b) => b.comment.published.localeCompare(a.comment.published));
|
||||||
hashTable[comment.id] = { ...node, children : [] };
|
} 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) {
|
for (let node of tree) {
|
||||||
if( comment.parent_id ) hashTable[comment.parent_id].children.push(hashTable[comment.id]);
|
this.sortTree(node.children);
|
||||||
else tree.push(hashTable[comment.id]);
|
|
||||||
}
|
}
|
||||||
return tree;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
commentsTree() {
|
commentsTree() {
|
||||||
|
@ -280,7 +327,7 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
{this.state.showReply && <CommentForm node={node} onReplyCancel={this.handleReplyCancel} />}
|
{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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -389,8 +436,8 @@ export class CommentForm extends Component<CommentFormProps, CommentFormState> {
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-sm-12">
|
<div class="col-sm-12">
|
||||||
<button type="submit" class="btn btn-secondary mr-2">{this.state.buttonTitle}</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-secondary" onClick={linkEvent(this, this.handleReplyCancel)}>Cancel</button>}
|
{this.props.node && <button type="button" class="btn btn-sm btn-secondary" onClick={linkEvent(this, this.handleReplyCancel)}>Cancel</button>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
@ -112,4 +112,8 @@ export interface LoginResponse {
|
||||||
jwt: string;
|
jwt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum CommentSortType {
|
||||||
|
Hot, Top, New
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -13,3 +13,13 @@ body {
|
||||||
.downvote:hover {
|
.downvote:hover {
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-control, .form-control:focus {
|
||||||
|
background-color: var(--secondary);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-select {
|
||||||
|
color: #fff;
|
||||||
|
background-color: var(--secondary);
|
||||||
|
}
|
||||||
|
|
|
@ -88,5 +88,6 @@ export class WebSocketService {
|
||||||
|
|
||||||
window.onbeforeunload = (e => {
|
window.onbeforeunload = (e => {
|
||||||
WebSocketService.Instance.subject.unsubscribe();
|
WebSocketService.Instance.subject.unsubscribe();
|
||||||
|
WebSocketService.Instance.subject = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -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 repoUrl = 'https://github.com/dessalines/rust-reddit-fediverse';
|
||||||
export let wsUri = (window.location.protocol=='https:'&&'wss://'||'ws://')+window.location.host + '/service/ws/';
|
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];
|
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;
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in a new issue