Merge pull request #5 from StaticallyTypedRice/master

Merge upstream
pull/722/head
Richie Zhang 4 years ago committed by GitHub
commit 633568da03

@ -1,3 +1,4 @@
# These are supported funding model platforms
patreon: dessalines
liberapay: Lemmy

159
README.md vendored

@ -1,34 +1,35 @@
<p align="center">
<a href="https://dev.lemmy.ml/" rel="noopener">
<img width=200px height=200px src="ui/assets/favicon.svg"></a>
</p>
<h3 align="center">Lemmy</h3>
<div align="center">
![GitHub stars](https://img.shields.io/github/stars/dessalines/lemmy?style=social)
[![Mastodon Follow](https://img.shields.io/mastodon/follow/810572?domain=https%3A%2F%2Fmastodon.social&style=social)](https://mastodon.social/@LemmyDev)
[![Matrix](https://img.shields.io/matrix/rust-reddit-fediverse:matrix.org.svg?label=matrix-chat)](https://riot.im/app/#/room/#rust-reddit-fediverse:matrix.org)
![GitHub tag (latest SemVer)](https://img.shields.io/github/tag/dessalines/lemmy.svg)
[![Build Status](https://travis-ci.org/dessalines/lemmy.svg?branch=master)](https://travis-ci.org/dessalines/lemmy)
[![GitHub issues](https://img.shields.io/github/issues-raw/dessalines/lemmy.svg)](https://github.com/dessalines/lemmy/issues)
[![Docker Pulls](https://img.shields.io/docker/pulls/dessalines/lemmy.svg)](https://cloud.docker.com/repository/docker/dessalines/lemmy/)
![GitHub commit activity](https://img.shields.io/github/commit-activity/m/dessalines/lemmy.svg)
![GitHub repo size](https://img.shields.io/github/repo-size/dessalines/lemmy.svg)
[![License](https://img.shields.io/github/license/dessalines/lemmy.svg)](LICENSE)
[![Patreon](https://img.shields.io/badge/-Support%20on%20Patreon-blueviolet.svg)](https://www.patreon.com/dessalines)
![GitHub stars](https://img.shields.io/github/stars/dessalines/lemmy?style=social)
</div>
---
<p align="center">
<a href="https://dev.lemmy.ml/" rel="noopener">
<img width=200px height=200px src="ui/assets/favicon.svg"></a>
<p align="center">A link aggregator / reddit clone for the fediverse.
<br>
<h3 align="center"><a href="https://dev.lemmy.ml">Lemmy</a></h3>
<p align="center">
A link aggregator / reddit clone for the fediverse.
<br />
<br />
<a href="https://dev.lemmy.ml">View Site</a>
·
<a href="https://dev.lemmy.ml/docs/index.html">Documentation</a>
·
<a href="https://github.com/dessalines/lemmy/issues">Report Bug</a>
·
<a href="https://github.com/dessalines/lemmy/issues">Request Feature</a>
·
<a href="https://github.com/dessalines/lemmy/blob/master/RELEASES.md">Releases</a>
</p>
</p>
[Lemmy Dev instance](https://dev.lemmy.ml) *This data is being backed up, and once federation is working, it will be the basis for a main instance.*
This is a **very early beta version**, and a lot of features are currently broken or in active development, such as federation.
## About The Project
Front Page|Post
---|---
@ -42,17 +43,22 @@ The overall goal is to create an easily self-hostable, decentralized alternative
Each lemmy server can set its own moderation policy; appointing site-wide admins, and community moderators to keep out the trolls, and foster a healthy, non-toxic environment where all can feel comfortable contributing.
Made with [Rust](https://www.rust-lang.org), [Actix](https://actix.rs/), [Inferno](https://infernojs.org), [Typescript](https://www.typescriptlang.org/) and [Diesel](http://diesel.rs/).
*Note: Federation is still in active development*
- [Documentation](https://dev.lemmy.ml/docs/index.html)
- [Releases / Changelog](/RELEASES.md)
- [Contributing](https://dev.lemmy.ml/docs/contributing.html)
### Why's it called Lemmy?
## Repository Mirrors
- Lead singer from [Motörhead](https://invidio.us/watch?v=pWB5JZRGl0U).
- The old school [video game](<https://en.wikipedia.org/wiki/Lemmings_(video_game)>).
- The [Koopa from Super Mario](https://www.mariowiki.com/Lemmy_Koopa).
- The [furry rodents](http://sunchild.fpwc.org/lemming-the-little-giant-of-the-north/).
- [GitHub](https://github.com/dessalines/lemmy)
- [Gitea](https://yerbamate.dev/dessalines/lemmy)
- [GitLab](https://gitlab.com/dessalines/lemmy)
### Built With
- [Rust](https://www.rust-lang.org)
- [Actix](https://actix.rs/)
- [Diesel](http://diesel.rs/)
- [Inferno](https://infernojs.org)
- [Typescript](https://www.typescriptlang.org/)
## Features
@ -89,60 +95,11 @@ Made with [Rust](https://www.rust-lang.org), [Actix](https://actix.rs/), [Infern
- Front end is `~80kB` gzipped.
- Supports arm64 / Raspberry Pi.
## Why's it called Lemmy?
- Lead singer from [Motörhead](https://invidio.us/watch?v=pWB5JZRGl0U).
- The old school [video game](<https://en.wikipedia.org/wiki/Lemmings_(video_game)>).
- The [Koopa from Super Mario](https://www.mariowiki.com/Lemmy_Koopa).
- The [furry rodents](http://sunchild.fpwc.org/lemming-the-little-giant-of-the-north/).
## Install
### Docker
Make sure you have both docker and docker-compose(>=`1.24.0`) installed:
```bash
mkdir lemmy/
cd lemmy/
wget https://raw.githubusercontent.com/dessalines/lemmy/master/docker/prod/docker-compose.yml
wget https://raw.githubusercontent.com/dessalines/lemmy/master/docker/lemmy.hjson
# Edit lemmy.hjson to do more configuration
docker-compose up -d
```
and go to http://localhost:8536.
[A sample nginx config](/ansible/templates/nginx.conf) (Image uploading won't work without this), could be setup with:
```bash
wget https://raw.githubusercontent.com/dessalines/lemmy/master/ansible/templates/nginx.conf
# Replace the {{ vars }}
sudo mv nginx.conf /etc/nginx/sites-enabled/lemmy.conf
```
#### Updating
To update to the newest version, run:
```bash
wget https://raw.githubusercontent.com/dessalines/lemmy/master/docker/prod/docker-compose.yml
docker-compose up -d
```
### Ansible
First, you need to [install Ansible on your local computer](https://docs.ansible.com/ansible/latest/installation_guide/intro_installation.html) (e.g. using `sudo apt install ansible`) or the equivalent for you platform.
Then run the following commands on your local computer:
```bash
git clone https://github.com/dessalines/lemmy.git
cd lemmy/ansible/
cp inventory.example inventory
nano inventory # enter your server, domain, contact email
ansible-playbook lemmy.yml --become
```
## Installation
- [Docker](https://dev.lemmy.ml/docs/administration_install_docker.html)
- [Ansible](https://dev.lemmy.ml/docs/administration_install_ansible.html)
- [Kubernetes](https://dev.lemmy.ml/docs/administration_install_kubernetes.html)
## Support / Donate
Lemmy is free, open-source software, meaning no advertising, monetizing, or venture capital, ever. Your donations directly support full-time development of the project.
@ -157,7 +114,13 @@ Lemmy is free, open-source software, meaning no advertising, monetizing, or vent
- ethereum: `0x400c96c96acbC6E7B3B43B1dc1BB446540a88A01`
- monero: `41taVyY6e1xApqKyMVDRVxJ76sPkfZhALLTjRvVKpaAh2pBd4wv9RgYj1tSPrx8wc6iE1uWUfjtQdTmTy2FGMeChGVKPQuV`
## Translations
## Contributing
- [Contributing instructions](https://dev.lemmy.ml/docs/contributing.html)
- [Docker Development](https://dev.lemmy.ml/docs/contributing_docker_development.html)
- [Local Development](https://dev.lemmy.ml/docs/contributing_local_development.html)
### Translations
If you'd like to add translations, take a look at the [English translation file](ui/src/translations/en.ts).
@ -167,19 +130,19 @@ If you'd like to add translations, take a look at the [English translation file]
lang | done | missing
---- | ---- | -------
ca | 98% | cross_posted_to,old,support_on_liberapay,time,action
de | 86% | cross_posted_to,create_private_message,send_secure_message,send_message,message,avatar,upload_avatar,show_avatars,old,docs,message_sent,messages,old_password,matrix_user_id,private_message_disclaimer,send_notifications_to_email,downvotes_disabled,enable_downvotes,open_registration,registration_closed,enable_nsfw,support_on_liberapay,donate_to_lemmy,donate,from,logged_in,email_already_exists,couldnt_create_private_message,no_private_message_edit_allowed,couldnt_update_private_message,time,action
fa | 72% | cross_post,cross_posted_to,subscribed_to_communities,trending_communities,create_private_message,send_secure_message,send_message,message,mod,mods,moderates,remove_as_mod,appoint_as_mod,modlog,stickied,ban,ban_from_site,unban,unban_from_site,banned,number_of_subscribers,subscribers,both,saved,unsubscribe,subscribe,subscribed,old,api,docs,inbox,inbox_for,message_sent,notifications_error,messages,no_email_setup,matrix_user_id,private_message_disclaimer,url,body,copy_suggested_title,community,expand_here,subscribe_to_communities,theme,sponsor_message,support_on_liberapay,general_sponsors,joined,by,to,from,landing_0,logged_in,community_moderator_already_exists,community_follower_already_exists,community_user_already_banned,no_slurs,admin_already_created,couldnt_create_private_message,no_private_message_edit_allowed,couldnt_update_private_message,time,action
eo | 74% | cross_posted_to,number_of_communities,create_private_message,send_secure_message,send_message,message,preview,upload_image,avatar,upload_avatar,show_avatars,formatting_help,view_source,sticky,unsticky,archive_link,stickied,delete_account,delete_account_confirm,banned,creator,number_online,old,docs,replies,mentions,message_sent,messages,old_password,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup,matrix_user_id,private_message_disclaimer,send_notifications_to_email,language,browser_default,downvotes_disabled,enable_downvotes,open_registration,registration_closed,enable_nsfw,theme,support_on_liberapay,donate_to_lemmy,donate,from,are_you_sure,yes,no,logged_in,email_already_exists,couldnt_create_private_message,no_private_message_edit_allowed,couldnt_update_private_message,time,action
es | 99% | cross_posted_to,support_on_liberapay
fi | 98% | cross_posted_to,old,support_on_liberapay,time,action
fr | 81% | cross_posted_to,create_private_message,send_secure_message,send_message,message,avatar,upload_avatar,show_avatars,archive_link,old,docs,replies,mentions,message_sent,messages,old_password,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup,matrix_user_id,private_message_disclaimer,send_notifications_to_email,language,browser_default,downvotes_disabled,enable_downvotes,open_registration,registration_closed,enable_nsfw,support_on_liberapay,donate_to_lemmy,donate,from,logged_in,email_already_exists,couldnt_create_private_message,no_private_message_edit_allowed,couldnt_update_private_message,time,action
it | 82% | cross_posted_to,create_private_message,send_secure_message,send_message,message,avatar,upload_avatar,show_avatars,archive_link,old,docs,message_sent,messages,old_password,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup,matrix_user_id,private_message_disclaimer,send_notifications_to_email,language,browser_default,downvotes_disabled,enable_downvotes,open_registration,registration_closed,enable_nsfw,support_on_liberapay,donate_to_lemmy,donate,from,logged_in,email_already_exists,couldnt_create_private_message,no_private_message_edit_allowed,couldnt_update_private_message,time,action
nl | 98% | cross_posted_to,support_on_liberapay,time,action
pt-br | 100% | support_on_liberapay
ru | 70% | cross_posts,cross_post,cross_posted_to,number_of_communities,create_private_message,send_secure_message,send_message,message,preview,upload_image,avatar,upload_avatar,show_avatars,formatting_help,view_source,sticky,unsticky,archive_link,stickied,delete_account,delete_account_confirm,banned,creator,number_online,old,docs,replies,mentions,message_sent,messages,old_password,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup,matrix_user_id,private_message_disclaimer,send_notifications_to_email,language,browser_default,downvotes_disabled,enable_downvotes,open_registration,registration_closed,enable_nsfw,recent_comments,theme,support_on_liberapay,donate_to_lemmy,donate,monero,by,to,from,transfer_community,transfer_site,are_you_sure,yes,no,logged_in,email_already_exists,couldnt_create_private_message,no_private_message_edit_allowed,couldnt_update_private_message,time,action
sv | 81% | cross_posted_to,create_private_message,send_secure_message,send_message,message,avatar,upload_avatar,show_avatars,archive_link,old,docs,replies,mentions,message_sent,messages,old_password,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup,matrix_user_id,private_message_disclaimer,send_notifications_to_email,language,browser_default,downvotes_disabled,enable_downvotes,open_registration,registration_closed,enable_nsfw,support_on_liberapay,donate_to_lemmy,donate,from,logged_in,email_already_exists,couldnt_create_private_message,no_private_message_edit_allowed,couldnt_update_private_message,time,action
zh | 69% | cross_posts,cross_post,cross_posted_to,users,number_of_communities,create_private_message,send_secure_message,send_message,message,preview,upload_image,avatar,upload_avatar,show_avatars,formatting_help,view_source,sticky,unsticky,archive_link,settings,stickied,delete_account,delete_account_confirm,banned,creator,number_online,old,docs,replies,mentions,message_sent,messages,old_password,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup,matrix_user_id,private_message_disclaimer,send_notifications_to_email,language,browser_default,downvotes_disabled,enable_downvotes,open_registration,registration_closed,enable_nsfw,recent_comments,nsfw,show_nsfw,theme,support_on_liberapay,donate_to_lemmy,donate,monero,by,to,from,transfer_community,transfer_site,are_you_sure,yes,no,logged_in,email_already_exists,couldnt_create_private_message,no_private_message_edit_allowed,couldnt_update_private_message,time,action
ca | 97% | cross_posted_to,old,support_on_liberapay,couldnt_get_comments,post_title_too_long,time,action
de | 86% | cross_posted_to,create_private_message,send_secure_message,send_message,message,avatar,upload_avatar,show_avatars,old,docs,message_sent,messages,old_password,matrix_user_id,private_message_disclaimer,send_notifications_to_email,downvotes_disabled,enable_downvotes,open_registration,registration_closed,enable_nsfw,donate_to_lemmy,donate,from,logged_in,couldnt_get_comments,post_title_too_long,email_already_exists,couldnt_create_private_message,no_private_message_edit_allowed,couldnt_update_private_message,time,action
fa | 71% | cross_post,cross_posted_to,subscribed_to_communities,trending_communities,create_private_message,send_secure_message,send_message,message,mod,mods,moderates,remove_as_mod,appoint_as_mod,modlog,stickied,ban,ban_from_site,unban,unban_from_site,banned,number_of_subscribers,subscribers,both,saved,unsubscribe,subscribe,subscribed,old,api,docs,inbox,inbox_for,message_sent,notifications_error,messages,no_email_setup,matrix_user_id,private_message_disclaimer,url,body,copy_suggested_title,community,expand_here,subscribe_to_communities,theme,sponsor_message,support_on_liberapay,general_sponsors,joined,by,to,from,landing_0,logged_in,couldnt_get_comments,community_moderator_already_exists,community_follower_already_exists,community_user_already_banned,post_title_too_long,no_slurs,admin_already_created,couldnt_create_private_message,no_private_message_edit_allowed,couldnt_update_private_message,time,action
eo | 73% | cross_posted_to,number_of_communities,create_private_message,send_secure_message,send_message,message,preview,upload_image,avatar,upload_avatar,show_avatars,formatting_help,view_source,sticky,unsticky,archive_link,stickied,delete_account,delete_account_confirm,banned,creator,number_online,old,docs,replies,mentions,message_sent,messages,old_password,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup,matrix_user_id,private_message_disclaimer,send_notifications_to_email,language,browser_default,downvotes_disabled,enable_downvotes,open_registration,registration_closed,enable_nsfw,theme,support_on_liberapay,donate_to_lemmy,donate,from,are_you_sure,yes,no,logged_in,couldnt_get_comments,post_title_too_long,email_already_exists,couldnt_create_private_message,no_private_message_edit_allowed,couldnt_update_private_message,time,action
es | 99% | cross_posted_to,couldnt_get_comments,post_title_too_long
fi | 97% | cross_posted_to,old,support_on_liberapay,couldnt_get_comments,post_title_too_long,time,action
fr | 81% | cross_posted_to,create_private_message,send_secure_message,send_message,message,avatar,upload_avatar,show_avatars,archive_link,old,docs,replies,mentions,message_sent,messages,old_password,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup,matrix_user_id,private_message_disclaimer,send_notifications_to_email,language,browser_default,downvotes_disabled,enable_downvotes,open_registration,registration_closed,enable_nsfw,donate_to_lemmy,donate,from,logged_in,couldnt_get_comments,post_title_too_long,email_already_exists,couldnt_create_private_message,no_private_message_edit_allowed,couldnt_update_private_message,time,action
it | 82% | cross_posted_to,create_private_message,send_secure_message,send_message,message,avatar,upload_avatar,show_avatars,archive_link,old,docs,message_sent,messages,old_password,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup,matrix_user_id,private_message_disclaimer,send_notifications_to_email,language,browser_default,downvotes_disabled,enable_downvotes,open_registration,registration_closed,enable_nsfw,donate_to_lemmy,donate,from,logged_in,couldnt_get_comments,post_title_too_long,email_already_exists,couldnt_create_private_message,no_private_message_edit_allowed,couldnt_update_private_message,time,action
nl | 98% | cross_posted_to,couldnt_get_comments,post_title_too_long,time,action
pt-br | 99% | couldnt_get_comments,post_title_too_long
ru | 70% | cross_posts,cross_post,cross_posted_to,number_of_communities,create_private_message,send_secure_message,send_message,message,preview,upload_image,avatar,upload_avatar,show_avatars,formatting_help,view_source,sticky,unsticky,archive_link,stickied,delete_account,delete_account_confirm,banned,creator,number_online,old,docs,replies,mentions,message_sent,messages,old_password,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup,matrix_user_id,private_message_disclaimer,send_notifications_to_email,language,browser_default,downvotes_disabled,enable_downvotes,open_registration,registration_closed,enable_nsfw,recent_comments,theme,support_on_liberapay,donate_to_lemmy,donate,monero,by,to,from,transfer_community,transfer_site,are_you_sure,yes,no,logged_in,couldnt_get_comments,post_title_too_long,email_already_exists,couldnt_create_private_message,no_private_message_edit_allowed,couldnt_update_private_message,time,action
sv | 81% | cross_posted_to,create_private_message,send_secure_message,send_message,message,avatar,upload_avatar,show_avatars,archive_link,old,docs,replies,mentions,message_sent,messages,old_password,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup,matrix_user_id,private_message_disclaimer,send_notifications_to_email,language,browser_default,downvotes_disabled,enable_downvotes,open_registration,registration_closed,enable_nsfw,support_on_liberapay,donate_to_lemmy,donate,from,logged_in,couldnt_get_comments,post_title_too_long,email_already_exists,couldnt_create_private_message,no_private_message_edit_allowed,couldnt_update_private_message,time,action
zh | 69% | cross_posts,cross_post,cross_posted_to,users,number_of_communities,create_private_message,send_secure_message,send_message,message,preview,upload_image,avatar,upload_avatar,show_avatars,formatting_help,view_source,sticky,unsticky,archive_link,settings,stickied,delete_account,delete_account_confirm,banned,creator,number_online,old,docs,replies,mentions,message_sent,messages,old_password,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup,matrix_user_id,private_message_disclaimer,send_notifications_to_email,language,browser_default,downvotes_disabled,enable_downvotes,open_registration,registration_closed,enable_nsfw,recent_comments,nsfw,show_nsfw,theme,donate_to_lemmy,donate,monero,by,to,from,transfer_community,transfer_site,are_you_sure,yes,no,logged_in,couldnt_get_comments,post_title_too_long,email_already_exists,couldnt_create_private_message,no_private_message_edit_allowed,couldnt_update_private_message,time,action
<!-- translationsstop -->
If you'd like to update this report, run:
@ -189,6 +152,14 @@ cd ui
ts-node translation_report.ts
```
## Contact
- [Mastodon](https://mastodon.social/@LemmyDev) - [![Mastodon Follow](https://img.shields.io/mastodon/follow/810572?domain=https%3A%2F%2Fmastodon.social&style=social)](https://mastodon.social/@LemmyDev)
- [Matrix](https://riot.im/app/#/room/#rust-reddit-fediverse:matrix.org) - [![Matrix](https://img.shields.io/matrix/rust-reddit-fediverse:matrix.org.svg?label=matrix-chat)](https://riot.im/app/#/room/#rust-reddit-fediverse:matrix.org)
- [GitHub](https://github.com/dessalines/lemmy)
- [Gitea](https://yerbamate.dev/dessalines/lemmy)
- [GitLab](https://gitlab.com/dessalines/lemmy)
## Credits
Logo made by Andy Cuccaro (@andycuccaro) under the CC-BY-SA 4.0 license.

2
ansible/VERSION vendored

@ -1 +1 @@
v0.6.11
v0.6.16

@ -29,7 +29,7 @@
# rate limits for various user actions, by user ip
rate_limit: {
# maximum number of messages created in interval
message: 30
message: 180
# interval length for message limit
message_per_second: 60
# maximum number of posts created in interval

@ -11,7 +11,7 @@ services:
- lemmy_db:/var/lib/postgresql/data
restart: always
lemmy:
image: dessalines/lemmy:v0.6.11
image: dessalines/lemmy:v0.6.16
ports:
- "127.0.0.1:8536:8536"
restart: always

16
docs/src/about.md vendored

@ -1,6 +1,8 @@
# Lemmy - A link aggregator / reddit clone for the fediverse.
## About The Project
[Lemmy Dev instance](https://dev.lemmy.ml) *for testing purposes only*
Front Page|Post
---|---
![main screen](https://i.imgur.com/kZSRcRu.png)|![chat screen](https://i.imgur.com/4XghNh6.png)
[Lemmy](https://github.com/dessalines/lemmy) is similar to sites like [Reddit](https://reddit.com), [Lobste.rs](https://lobste.rs), [Raddle](https://raddle.me), or [Hacker News](https://news.ycombinator.com/): you subscribe to forums you're interested in, post links and discussions, then vote, and comment on them. Behind the scenes, it is very different; anyone can easily run a server, and all these servers are federated (think email), and connected to the same universe, called the [Fediverse](https://en.wikipedia.org/wiki/Fediverse).
@ -10,6 +12,8 @@ The overall goal is to create an easily self-hostable, decentralized alternative
Each lemmy server can set its own moderation policy; appointing site-wide admins, and community moderators to keep out the trolls, and foster a healthy, non-toxic environment where all can feel comfortable contributing.
*Note: Federation is still in active development*
### Why's it called Lemmy?
- Lead singer from [Motörhead](https://invidio.us/watch?v=pWB5JZRGl0U).
@ -17,4 +21,10 @@ Each lemmy server can set its own moderation policy; appointing site-wide admins
- The [Koopa from Super Mario](https://www.mariowiki.com/Lemmy_Koopa).
- The [furry rodents](http://sunchild.fpwc.org/lemming-the-little-giant-of-the-north/).
Made with [Rust](https://www.rust-lang.org), [Actix](https://actix.rs/), [Inferno](https://infernojs.org), [Typescript](https://www.typescriptlang.org/) and [Diesel](http://diesel.rs/).
### Built With
- [Rust](https://www.rust-lang.org)
- [Actix](https://actix.rs/)
- [Diesel](http://diesel.rs/)
- [Inferno](https://infernojs.org)
- [Typescript](https://www.typescriptlang.org/)

@ -1,20 +1,27 @@
# Features
- Open source, [AGPL License](/LICENSE).
- Self hostable, easy to deploy.
- Comes with [Docker](#docker), [Ansible](#ansible), [Kubernetes](#kubernetes).
- Clean, mobile-friendly interface.
- Only a minimum of a username and password is required to sign up!
- User avatar support.
- Live-updating Comment threads.
- Full vote scores `(+/-)` like old reddit.
- Themes, including light, dark, and solarized.
- Emojis with autocomplete support. Start typing `:`
- User tagging using `@`, Community tagging using `#`.
- Integrated image uploading in both posts and comments.
- A post can consist of a title and any combination of self text, a URL, or nothing else.
- Notifications, on comment replies and when you're tagged.
- Notifications can be sent via email.
- i18n / internationalization support.
- RSS / Atom feeds for `All`, `Subscribed`, `Inbox`, `User`, and `Community`.
- Cross-posting support.
- A *similar post search* when creating new posts. Great for question / answer communities.
- Moderation abilities.
- Public Moderation Logs.
- Can sticky posts to the top of communities.
- Both site admins, and community moderators, who can appoint other moderators.
- Can lock, remove, and restore posts and comments.
- Can ban and unban users from communities and the site.

@ -1 +1,3 @@
Information for Lemmy instance admins, and those who want to start an instance.
# Admin info
Information for Lemmy instance admins, and those who want to start an instance.

@ -1,3 +1,5 @@
# Configuration
The configuration is based on the file [defaults.hjson](server/config/defaults.hjson). This file also contains documentation for all the available options. To override the defaults, you can copy the options you want to change into your local `config.hjson` file.
Additionally, you can override any config files with environment variables. These have the same name as the config options, and are prefixed with `LEMMY_`. For example, you can override the `database.password` with
@ -8,8 +10,6 @@ An additional option `LEMMY_DATABASE_URL` is available, which can be used with a
If the Docker container is not used, manually create the database specified above by running the following commands:
```bash
# Start at the root of the Lemmy repository
cd server
./db-init.sh
```

@ -1,3 +1,5 @@
# Ansible Installation
First, you need to [install Ansible on your local computer](https://docs.ansible.com/ansible/latest/installation_guide/intro_installation.html) (e.g. using `sudo apt install ansible`) or the equivalent for you platform.
Then run the following commands on your local computer:

@ -1,3 +1,5 @@
# Docker Installation
Make sure you have both docker and docker-compose(>=`1.24.0`) installed:
```bash
@ -5,20 +7,20 @@ mkdir lemmy/
cd lemmy/
wget https://raw.githubusercontent.com/dessalines/lemmy/master/docker/prod/docker-compose.yml
wget https://raw.githubusercontent.com/dessalines/lemmy/master/docker/lemmy.hjson
# Edit lemmy.hjson to do more configuration
# Edit lemmy.hjson, and docker-compose.yml to do more configuration (like adding a custom password)
docker-compose up -d
```
and go to http://localhost:8536.
[A sample nginx config](/ansible/templates/nginx.conf), could be setup with:
[A sample nginx config](/ansible/templates/nginx.conf) (Note: Avatar / Image uploading won't work without this), could be setup with:
```bash
wget https://raw.githubusercontent.com/dessalines/lemmy/master/ansible/templates/nginx.conf
# Replace the {{ vars }}
sudo mv nginx.conf /etc/nginx/sites-enabled/lemmy.conf
```
#### Updating
## Updating
To update to the newest version, run:

@ -1,3 +1,5 @@
# Kubernetes Installation
You'll need to have an existing Kubernetes cluster and [storage class](https://kubernetes.io/docs/concepts/storage/storage-classes/).
Setting this up will vary depending on your provider.
To try it locally, you can use [MicroK8s](https://microk8s.io/) or [Minikube](https://kubernetes.io/docs/tasks/tools/install-minikube/).

@ -1,4 +1,6 @@
Run:
# Docker Development
## Running
```bash
git clone https://github.com/dessalines/lemmy
@ -8,4 +10,4 @@ cd lemmy/docker/dev
and go to http://localhost:8536.
Note that compile times are relatively long with Docker, because builds can't be properly cached. If this is a problem for you, you should use [Local Development](contributing_local_development.md).
Note that compile times when changing `Cargo.toml` are relatively long with Docker, because builds can't be incrementally cached. If this is a problem for you, you should use [Local Development](contributing_local_development.md).

@ -7,8 +7,6 @@
#### Set up Postgres DB
```bash
# Start at the root of the Lemmy repository
cd server
./db-init.sh
```

@ -1,4 +1,5 @@
# Lemmy API
*Note: this may lag behind the actual API endpoints [here](../server/src/api).*
<!-- toc -->

@ -30,7 +30,7 @@
# rate limits for various user actions, by user ip
rate_limit: {
# maximum number of messages created in interval
message: 30
message: 180
# interval length for message limit
message_per_second: 60
# maximum number of posts created in interval

@ -0,0 +1,132 @@
-- Drop the dependent views
drop view post_view;
drop view post_mview;
drop materialized view post_aggregates_mview;
drop view post_aggregates_view;
drop view mod_remove_post_view;
drop view mod_sticky_post_view;
drop view mod_lock_post_view;
drop view mod_remove_comment_view;
alter table post alter column name type varchar(100);
-- regen post view
create view post_aggregates_view as
select
p.*,
(select u.banned from user_ u where p.creator_id = u.id) as banned,
(select cb.id::bool from community_user_ban cb where p.creator_id = cb.user_id and p.community_id = cb.community_id) as banned_from_community,
(select name from user_ where p.creator_id = user_.id) as creator_name,
(select avatar from user_ where p.creator_id = user_.id) as creator_avatar,
(select name from community where p.community_id = community.id) as community_name,
(select removed from community c where p.community_id = c.id) as community_removed,
(select deleted from community c where p.community_id = c.id) as community_deleted,
(select nsfw from community c where p.community_id = c.id) as community_nsfw,
(select count(*) from comment where comment.post_id = p.id) as number_of_comments,
coalesce(sum(pl.score), 0) as score,
count (case when pl.score = 1 then 1 else null end) as upvotes,
count (case when pl.score = -1 then 1 else null end) as downvotes,
hot_rank(coalesce(sum(pl.score) , 0), p.published) as hot_rank
from post p
left join post_like pl on p.id = pl.post_id
group by p.id;
create materialized view post_aggregates_mview as select * from post_aggregates_view;
create unique index idx_post_aggregates_mview_id on post_aggregates_mview (id);
create view post_view as
with all_post as (
select
pa.*
from post_aggregates_view pa
)
select
ap.*,
u.id as user_id,
coalesce(pl.score, 0) as my_vote,
(select cf.id::bool from community_follower cf where u.id = cf.user_id and cf.community_id = ap.community_id) as subscribed,
(select pr.id::bool from post_read pr where u.id = pr.user_id and pr.post_id = ap.id) as read,
(select ps.id::bool from post_saved ps where u.id = ps.user_id and ps.post_id = ap.id) as saved
from user_ u
cross join all_post ap
left join post_like pl on u.id = pl.user_id and ap.id = pl.post_id
union all
select
ap.*,
null as user_id,
null as my_vote,
null as subscribed,
null as read,
null as saved
from all_post ap
;
create view post_mview as
with all_post as (
select
pa.*
from post_aggregates_mview pa
)
select
ap.*,
u.id as user_id,
coalesce(pl.score, 0) as my_vote,
(select cf.id::bool from community_follower cf where u.id = cf.user_id and cf.community_id = ap.community_id) as subscribed,
(select pr.id::bool from post_read pr where u.id = pr.user_id and pr.post_id = ap.id) as read,
(select ps.id::bool from post_saved ps where u.id = ps.user_id and ps.post_id = ap.id) as saved
from user_ u
cross join all_post ap
left join post_like pl on u.id = pl.user_id and ap.id = pl.post_id
union all
select
ap.*,
null as user_id,
null as my_vote,
null as subscribed,
null as read,
null as saved
from all_post ap
;
-- The mod views
create view mod_remove_post_view as
select mrp.*,
(select name from user_ u where mrp.mod_user_id = u.id) as mod_user_name,
(select name from post p where mrp.post_id = p.id) as post_name,
(select c.id from post p, community c where mrp.post_id = p.id and p.community_id = c.id) as community_id,
(select c.name from post p, community c where mrp.post_id = p.id and p.community_id = c.id) as community_name
from mod_remove_post mrp;
create view mod_lock_post_view as
select mlp.*,
(select name from user_ u where mlp.mod_user_id = u.id) as mod_user_name,
(select name from post p where mlp.post_id = p.id) as post_name,
(select c.id from post p, community c where mlp.post_id = p.id and p.community_id = c.id) as community_id,
(select c.name from post p, community c where mlp.post_id = p.id and p.community_id = c.id) as community_name
from mod_lock_post mlp;
create view mod_remove_comment_view as
select mrc.*,
(select name from user_ u where mrc.mod_user_id = u.id) as mod_user_name,
(select c.id from comment c where mrc.comment_id = c.id) as comment_user_id,
(select name from user_ u, comment c where mrc.comment_id = c.id and u.id = c.creator_id) as comment_user_name,
(select content from comment c where mrc.comment_id = c.id) as comment_content,
(select p.id from post p, comment c where mrc.comment_id = c.id and c.post_id = p.id) as post_id,
(select p.name from post p, comment c where mrc.comment_id = c.id and c.post_id = p.id) as post_name,
(select co.id from comment c, post p, community co where mrc.comment_id = c.id and c.post_id = p.id and p.community_id = co.id) as community_id,
(select co.name from comment c, post p, community co where mrc.comment_id = c.id and c.post_id = p.id and p.community_id = co.id) as community_name
from mod_remove_comment mrc;
create view mod_sticky_post_view as
select msp.*,
(select name from user_ u where msp.mod_user_id = u.id) as mod_user_name,
(select name from post p where msp.post_id = p.id) as post_name,
(select c.id from post p, community c where msp.post_id = p.id and p.community_id = c.id) as community_id,
(select c.name from post p, community c where msp.post_id = p.id and p.community_id = c.id) as community_name
from mod_sticky_post msp;

@ -0,0 +1,133 @@
-- Drop the dependent views
drop view post_view;
drop view post_mview;
drop materialized view post_aggregates_mview;
drop view post_aggregates_view;
drop view mod_remove_post_view;
drop view mod_sticky_post_view;
drop view mod_lock_post_view;
drop view mod_remove_comment_view;
-- Add the extra post limit
alter table post alter column name type varchar(200);
-- regen post view
create view post_aggregates_view as
select
p.*,
(select u.banned from user_ u where p.creator_id = u.id) as banned,
(select cb.id::bool from community_user_ban cb where p.creator_id = cb.user_id and p.community_id = cb.community_id) as banned_from_community,
(select name from user_ where p.creator_id = user_.id) as creator_name,
(select avatar from user_ where p.creator_id = user_.id) as creator_avatar,
(select name from community where p.community_id = community.id) as community_name,
(select removed from community c where p.community_id = c.id) as community_removed,
(select deleted from community c where p.community_id = c.id) as community_deleted,
(select nsfw from community c where p.community_id = c.id) as community_nsfw,
(select count(*) from comment where comment.post_id = p.id) as number_of_comments,
coalesce(sum(pl.score), 0) as score,
count (case when pl.score = 1 then 1 else null end) as upvotes,
count (case when pl.score = -1 then 1 else null end) as downvotes,
hot_rank(coalesce(sum(pl.score) , 0), p.published) as hot_rank
from post p
left join post_like pl on p.id = pl.post_id
group by p.id;
create materialized view post_aggregates_mview as select * from post_aggregates_view;
create unique index idx_post_aggregates_mview_id on post_aggregates_mview (id);
create view post_view as
with all_post as (
select
pa.*
from post_aggregates_view pa
)
select
ap.*,
u.id as user_id,
coalesce(pl.score, 0) as my_vote,
(select cf.id::bool from community_follower cf where u.id = cf.user_id and cf.community_id = ap.community_id) as subscribed,
(select pr.id::bool from post_read pr where u.id = pr.user_id and pr.post_id = ap.id) as read,
(select ps.id::bool from post_saved ps where u.id = ps.user_id and ps.post_id = ap.id) as saved
from user_ u
cross join all_post ap
left join post_like pl on u.id = pl.user_id and ap.id = pl.post_id
union all
select
ap.*,
null as user_id,
null as my_vote,
null as subscribed,
null as read,
null as saved
from all_post ap
;
create view post_mview as
with all_post as (
select
pa.*
from post_aggregates_mview pa
)
select
ap.*,
u.id as user_id,
coalesce(pl.score, 0) as my_vote,
(select cf.id::bool from community_follower cf where u.id = cf.user_id and cf.community_id = ap.community_id) as subscribed,
(select pr.id::bool from post_read pr where u.id = pr.user_id and pr.post_id = ap.id) as read,
(select ps.id::bool from post_saved ps where u.id = ps.user_id and ps.post_id = ap.id) as saved
from user_ u
cross join all_post ap
left join post_like pl on u.id = pl.user_id and ap.id = pl.post_id
union all
select
ap.*,
null as user_id,
null as my_vote,
null as subscribed,
null as read,
null as saved
from all_post ap
;
-- The mod views
create view mod_remove_post_view as
select mrp.*,
(select name from user_ u where mrp.mod_user_id = u.id) as mod_user_name,
(select name from post p where mrp.post_id = p.id) as post_name,
(select c.id from post p, community c where mrp.post_id = p.id and p.community_id = c.id) as community_id,
(select c.name from post p, community c where mrp.post_id = p.id and p.community_id = c.id) as community_name
from mod_remove_post mrp;
create view mod_lock_post_view as
select mlp.*,
(select name from user_ u where mlp.mod_user_id = u.id) as mod_user_name,
(select name from post p where mlp.post_id = p.id) as post_name,
(select c.id from post p, community c where mlp.post_id = p.id and p.community_id = c.id) as community_id,
(select c.name from post p, community c where mlp.post_id = p.id and p.community_id = c.id) as community_name
from mod_lock_post mlp;
create view mod_remove_comment_view as
select mrc.*,
(select name from user_ u where mrc.mod_user_id = u.id) as mod_user_name,
(select c.id from comment c where mrc.comment_id = c.id) as comment_user_id,
(select name from user_ u, comment c where mrc.comment_id = c.id and u.id = c.creator_id) as comment_user_name,
(select content from comment c where mrc.comment_id = c.id) as comment_content,
(select p.id from post p, comment c where mrc.comment_id = c.id and c.post_id = p.id) as post_id,
(select p.name from post p, comment c where mrc.comment_id = c.id and c.post_id = p.id) as post_name,
(select co.id from comment c, post p, community co where mrc.comment_id = c.id and c.post_id = p.id and p.community_id = co.id) as community_id,
(select co.name from comment c, post p, community co where mrc.comment_id = c.id and c.post_id = p.id and p.community_id = co.id) as community_name
from mod_remove_comment mrc;
create view mod_sticky_post_view as
select msp.*,
(select name from user_ u where msp.mod_user_id = u.id) as mod_user_name,
(select name from post p where msp.post_id = p.id) as post_name,
(select c.id from post p, community c where msp.post_id = p.id and p.community_id = c.id) as community_id,
(select c.name from post p, community c where msp.post_id = p.id and p.community_id = c.id) as community_name
from mod_sticky_post msp;

@ -0,0 +1,206 @@
drop view reply_view;
drop view user_mention_view;
drop view user_mention_mview;
drop view comment_view;
drop view comment_mview;
drop materialized view comment_aggregates_mview;
drop view comment_aggregates_view;
-- reply and comment view
create view comment_aggregates_view as
select
c.*,
(select community_id from post p where p.id = c.post_id),
(select u.banned from user_ u where c.creator_id = u.id) as banned,
(select cb.id::bool from community_user_ban cb, post p where c.creator_id = cb.user_id and p.id = c.post_id and p.community_id = cb.community_id) as banned_from_community,
(select name from user_ where c.creator_id = user_.id) as creator_name,
(select avatar from user_ where c.creator_id = user_.id) as creator_avatar,
coalesce(sum(cl.score), 0) as score,
count (case when cl.score = 1 then 1 else null end) as upvotes,
count (case when cl.score = -1 then 1 else null end) as downvotes
from comment c
left join comment_like cl on c.id = cl.comment_id
group by c.id;
create materialized view comment_aggregates_mview as select * from comment_aggregates_view;
create unique index idx_comment_aggregates_mview_id on comment_aggregates_mview (id);
create view comment_view as
with all_comment as
(
select
ca.*
from comment_aggregates_view ca
)
select
ac.*,
u.id as user_id,
coalesce(cl.score, 0) as my_vote,
(select cs.id::bool from comment_saved cs where u.id = cs.user_id and cs.comment_id = ac.id) as saved
from user_ u
cross join all_comment ac
left join comment_like cl on u.id = cl.user_id and ac.id = cl.comment_id
union all
select
ac.*,
null as user_id,
null as my_vote,
null as saved
from all_comment ac
;
create view comment_mview as
with all_comment as
(
select
ca.*
from comment_aggregates_mview ca
)
select
ac.*,
u.id as user_id,
coalesce(cl.score, 0) as my_vote,
(select cs.id::bool from comment_saved cs where u.id = cs.user_id and cs.comment_id = ac.id) as saved
from user_ u
cross join all_comment ac
left join comment_like cl on u.id = cl.user_id and ac.id = cl.comment_id
union all
select
ac.*,
null as user_id,
null as my_vote,
null as saved
from all_comment ac
;
-- Do the reply_view referencing the comment_mview
create view reply_view as
with closereply as (
select
c2.id,
c2.creator_id as sender_id,
c.creator_id as recipient_id
from comment c
inner join comment c2 on c.id = c2.parent_id
where c2.creator_id != c.creator_id
-- Do union where post is null
union
select
c.id,
c.creator_id as sender_id,
p.creator_id as recipient_id
from comment c, post p
where c.post_id = p.id and c.parent_id is null and c.creator_id != p.creator_id
)
select cv.*,
closereply.recipient_id
from comment_mview cv, closereply
where closereply.id = cv.id
;
-- user mention
create view user_mention_view as
select
c.id,
um.id as user_mention_id,
c.creator_id,
c.post_id,
c.parent_id,
c.content,
c.removed,
um.read,
c.published,
c.updated,
c.deleted,
c.community_id,
c.banned,
c.banned_from_community,
c.creator_name,
c.creator_avatar,
c.score,
c.upvotes,
c.downvotes,
c.user_id,
c.my_vote,
c.saved,
um.recipient_id
from user_mention um, comment_view c
where um.comment_id = c.id;
create view user_mention_mview as
with all_comment as
(
select
ca.*
from comment_aggregates_mview ca
)
select
ac.id,
um.id as user_mention_id,
ac.creator_id,
ac.post_id,
ac.parent_id,
ac.content,
ac.removed,
um.read,
ac.published,
ac.updated,
ac.deleted,
ac.community_id,
ac.banned,
ac.banned_from_community,
ac.creator_name,
ac.creator_avatar,
ac.score,
ac.upvotes,
ac.downvotes,
u.id as user_id,
coalesce(cl.score, 0) as my_vote,
(select cs.id::bool from comment_saved cs where u.id = cs.user_id and cs.comment_id = ac.id) as saved,
um.recipient_id
from user_ u
cross join all_comment ac
left join comment_like cl on u.id = cl.user_id and ac.id = cl.comment_id
left join user_mention um on um.comment_id = ac.id
union all
select
ac.id,
um.id as user_mention_id,
ac.creator_id,
ac.post_id,
ac.parent_id,
ac.content,
ac.removed,
um.read,
ac.published,
ac.updated,
ac.deleted,
ac.community_id,
ac.banned,
ac.banned_from_community,
ac.creator_name,
ac.creator_avatar,
ac.score,
ac.upvotes,
ac.downvotes,
null as user_id,
null as my_vote,
null as saved,
um.recipient_id
from all_comment ac
left join user_mention um on um.comment_id = ac.id
;

@ -0,0 +1,220 @@
-- Adding community name, hot_rank, to comment_view, user_mention_view, and subscribed to comment_view
-- Rebuild the comment view
drop view reply_view;
drop view user_mention_view;
drop view user_mention_mview;
drop view comment_view;
drop view comment_mview;
drop materialized view comment_aggregates_mview;
drop view comment_aggregates_view;
-- reply and comment view
create view comment_aggregates_view as
select
c.*,
(select community_id from post p where p.id = c.post_id),
(select co.name from post p, community co where p.id = c.post_id and p.community_id = co.id) as community_name,
(select u.banned from user_ u where c.creator_id = u.id) as banned,
(select cb.id::bool from community_user_ban cb, post p where c.creator_id = cb.user_id and p.id = c.post_id and p.community_id = cb.community_id) as banned_from_community,
(select name from user_ where c.creator_id = user_.id) as creator_name,
(select avatar from user_ where c.creator_id = user_.id) as creator_avatar,
coalesce(sum(cl.score), 0) as score,
count (case when cl.score = 1 then 1 else null end) as upvotes,
count (case when cl.score = -1 then 1 else null end) as downvotes,
hot_rank(coalesce(sum(cl.score) , 0), c.published) as hot_rank
from comment c
left join comment_like cl on c.id = cl.comment_id
group by c.id;
create materialized view comment_aggregates_mview as select * from comment_aggregates_view;
create unique index idx_comment_aggregates_mview_id on comment_aggregates_mview (id);
create view comment_view as
with all_comment as
(
select
ca.*
from comment_aggregates_view ca
)
select
ac.*,
u.id as user_id,
coalesce(cl.score, 0) as my_vote,
(select cf.id::boolean from community_follower cf where u.id = cf.user_id and ac.community_id = cf.community_id) as subscribed,
(select cs.id::bool from comment_saved cs where u.id = cs.user_id and cs.comment_id = ac.id) as saved
from user_ u
cross join all_comment ac
left join comment_like cl on u.id = cl.user_id and ac.id = cl.comment_id
union all
select
ac.*,
null as user_id,
null as my_vote,
null as subscribed,
null as saved
from all_comment ac
;
create view comment_mview as
with all_comment as
(
select
ca.*
from comment_aggregates_mview ca
)
select
ac.*,
u.id as user_id,
coalesce(cl.score, 0) as my_vote,
(select cf.id::boolean from community_follower cf where u.id = cf.user_id and ac.community_id = cf.community_id) as subscribed,
(select cs.id::bool from comment_saved cs where u.id = cs.user_id and cs.comment_id = ac.id) as saved
from user_ u
cross join all_comment ac
left join comment_like cl on u.id = cl.user_id and ac.id = cl.comment_id
union all
select
ac.*,
null as user_id,
null as my_vote,
null as subscribed,
null as saved
from all_comment ac
;
-- Do the reply_view referencing the comment_mview
create view reply_view as
with closereply as (
select
c2.id,
c2.creator_id as sender_id,
c.creator_id as recipient_id
from comment c
inner join comment c2 on c.id = c2.parent_id
where c2.creator_id != c.creator_id
-- Do union where post is null
union
select
c.id,
c.creator_id as sender_id,
p.creator_id as recipient_id
from comment c, post p
where c.post_id = p.id and c.parent_id is null and c.creator_id != p.creator_id
)
select cv.*,
closereply.recipient_id
from comment_mview cv, closereply
where closereply.id = cv.id
;
-- user mention
create view user_mention_view as
select
c.id,
um.id as user_mention_id,
c.creator_id,
c.post_id,
c.parent_id,
c.content,
c.removed,
um.read,
c.published,
c.updated,
c.deleted,
c.community_id,
c.community_name,
c.banned,
c.banned_from_community,
c.creator_name,
c.creator_avatar,
c.score,
c.upvotes,
c.downvotes,
c.hot_rank,
c.user_id,
c.my_vote,
c.saved,
um.recipient_id
from user_mention um, comment_view c
where um.comment_id = c.id;
create view user_mention_mview as
with all_comment as
(
select
ca.*
from comment_aggregates_mview ca
)
select
ac.id,
um.id as user_mention_id,
ac.creator_id,
ac.post_id,
ac.parent_id,
ac.content,
ac.removed,
um.read,
ac.published,
ac.updated,
ac.deleted,
ac.community_id,
ac.community_name,
ac.banned,
ac.banned_from_community,
ac.creator_name,
ac.creator_avatar,
ac.score,
ac.upvotes,
ac.downvotes,
ac.hot_rank,
u.id as user_id,
coalesce(cl.score, 0) as my_vote,
(select cs.id::bool from comment_saved cs where u.id = cs.user_id and cs.comment_id = ac.id) as saved,
um.recipient_id
from user_ u
cross join all_comment ac
left join comment_like cl on u.id = cl.user_id and ac.id = cl.comment_id
left join user_mention um on um.comment_id = ac.id
union all
select
ac.id,
um.id as user_mention_id,
ac.creator_id,
ac.post_id,
ac.parent_id,
ac.content,
ac.removed,
um.read,
ac.published,
ac.updated,
ac.deleted,
ac.community_id,
ac.community_name,
ac.banned,
ac.banned_from_community,
ac.creator_name,
ac.creator_avatar,
ac.score,
ac.upvotes,
ac.downvotes,
ac.hot_rank,
null as user_id,
null as my_vote,
null as saved,
um.recipient_id
from all_comment ac
left join user_mention um on um.comment_id = ac.id
;

@ -0,0 +1,88 @@
drop view post_view;
drop view post_mview;
drop materialized view post_aggregates_mview;
drop view post_aggregates_view;
-- regen post view
create view post_aggregates_view as
select
p.*,
(select u.banned from user_ u where p.creator_id = u.id) as banned,
(select cb.id::bool from community_user_ban cb where p.creator_id = cb.user_id and p.community_id = cb.community_id) as banned_from_community,
(select name from user_ where p.creator_id = user_.id) as creator_name,
(select avatar from user_ where p.creator_id = user_.id) as creator_avatar,
(select name from community where p.community_id = community.id) as community_name,
(select removed from community c where p.community_id = c.id) as community_removed,
(select deleted from community c where p.community_id = c.id) as community_deleted,
(select nsfw from community c where p.community_id = c.id) as community_nsfw,
(select count(*) from comment where comment.post_id = p.id) as number_of_comments,
coalesce(sum(pl.score), 0) as score,
count (case when pl.score = 1 then 1 else null end) as upvotes,
count (case when pl.score = -1 then 1 else null end) as downvotes,
hot_rank(coalesce(sum(pl.score) , 0), p.published) as hot_rank
from post p
left join post_like pl on p.id = pl.post_id
group by p.id;
create materialized view post_aggregates_mview as select * from post_aggregates_view;
create unique index idx_post_aggregates_mview_id on post_aggregates_mview (id);
create view post_view as
with all_post as (
select
pa.*
from post_aggregates_view pa
)
select
ap.*,
u.id as user_id,
coalesce(pl.score, 0) as my_vote,
(select cf.id::bool from community_follower cf where u.id = cf.user_id and cf.community_id = ap.community_id) as subscribed,
(select pr.id::bool from post_read pr where u.id = pr.user_id and pr.post_id = ap.id) as read,
(select ps.id::bool from post_saved ps where u.id = ps.user_id and ps.post_id = ap.id) as saved
from user_ u
cross join all_post ap
left join post_like pl on u.id = pl.user_id and ap.id = pl.post_id
union all
select
ap.*,
null as user_id,
null as my_vote,
null as subscribed,
null as read,
null as saved
from all_post ap
;
create view post_mview as
with all_post as (
select
pa.*
from post_aggregates_mview pa
)
select
ap.*,
u.id as user_id,
coalesce(pl.score, 0) as my_vote,
(select cf.id::bool from community_follower cf where u.id = cf.user_id and cf.community_id = ap.community_id) as subscribed,
(select pr.id::bool from post_read pr where u.id = pr.user_id and pr.post_id = ap.id) as read,
(select ps.id::bool from post_saved ps where u.id = ps.user_id and ps.post_id = ap.id) as saved
from user_ u
cross join all_post ap
left join post_like pl on u.id = pl.user_id and ap.id = pl.post_id
union all
select
ap.*,
null as user_id,
null as my_vote,
null as subscribed,
null as read,
null as saved
from all_post ap
;

@ -0,0 +1,106 @@
-- Adds a newest_activity_time for the post_views, in order to sort by newest comment
drop view post_view;
drop view post_mview;
drop materialized view post_aggregates_mview;
drop view post_aggregates_view;
-- regen post view
create view post_aggregates_view as
select
p.*,
(select u.banned from user_ u where p.creator_id = u.id) as banned,
(select cb.id::bool from community_user_ban cb where p.creator_id = cb.user_id and p.community_id = cb.community_id) as banned_from_community,
(select name from user_ where p.creator_id = user_.id) as creator_name,
(select avatar from user_ where p.creator_id = user_.id) as creator_avatar,
(select name from community where p.community_id = community.id) as community_name,
(select removed from community c where p.community_id = c.id) as community_removed,
(select deleted from community c where p.community_id = c.id) as community_deleted,
(select nsfw from community c where p.community_id = c.id) as community_nsfw,
(select count(*) from comment where comment.post_id = p.id) as number_of_comments,
coalesce(sum(pl.score), 0) as score,
count (case when pl.score = 1 then 1 else null end) as upvotes,
count (case when pl.score = -1 then 1 else null end) as downvotes,
hot_rank(coalesce(sum(pl.score) , 0),
(
case when (p.published < ('now'::timestamp - '1 month'::interval)) then p.published -- Prevents necro-bumps
else greatest(c.recent_comment_time, p.published)
end
)
) as hot_rank,
(
case when (p.published < ('now'::timestamp - '1 month'::interval)) then p.published -- Prevents necro-bumps
else greatest(c.recent_comment_time, p.published)
end
) as newest_activity_time
from post p
left join post_like pl on p.id = pl.post_id
left join (
select post_id,
max(published) as recent_comment_time
from comment
group by 1
) c on p.id = c.post_id
group by p.id, c.recent_comment_time;
create materialized view post_aggregates_mview as select * from post_aggregates_view;
create unique index idx_post_aggregates_mview_id on post_aggregates_mview (id);
create view post_view as
with all_post as (
select
pa.*
from post_aggregates_view pa
)
select
ap.*,
u.id as user_id,
coalesce(pl.score, 0) as my_vote,
(select cf.id::bool from community_follower cf where u.id = cf.user_id and cf.community_id = ap.community_id) as subscribed,
(select pr.id::bool from post_read pr where u.id = pr.user_id and pr.post_id = ap.id) as read,
(select ps.id::bool from post_saved ps where u.id = ps.user_id and ps.post_id = ap.id) as saved
from user_ u
cross join all_post ap
left join post_like pl on u.id = pl.user_id and ap.id = pl.post_id
union all
select
ap.*,
null as user_id,
null as my_vote,
null as subscribed,
null as read,
null as saved
from all_post ap
;
create view post_mview as
with all_post as (
select
pa.*
from post_aggregates_mview pa
)
select
ap.*,
u.id as user_id,
coalesce(pl.score, 0) as my_vote,
(select cf.id::bool from community_follower cf where u.id = cf.user_id and cf.community_id = ap.community_id) as subscribed,
(select pr.id::bool from post_read pr where u.id = pr.user_id and pr.post_id = ap.id) as read,
(select ps.id::bool from post_saved ps where u.id = ps.user_id and ps.post_id = ap.id) as saved
from user_ u
cross join all_post ap
left join post_like pl on u.id = pl.user_id and ap.id = pl.post_id
union all
select
ap.*,
null as user_id,
null as my_vote,
null as subscribed,
null as read,
null as saved
from all_post ap
;

@ -2,6 +2,7 @@ use super::*;
use crate::send_email;
use crate::settings::Settings;
use diesel::PgConnection;
use std::str::FromStr;
#[derive(Serialize, Deserialize)]
pub struct CreateComment {
@ -47,6 +48,21 @@ pub struct CreateCommentLike {
auth: String,
}
#[derive(Serialize, Deserialize)]
pub struct GetComments {
type_: String,
sort: String,
page: Option<i64>,
limit: Option<i64>,
pub community_id: Option<i32>,
auth: Option<String>,
}
#[derive(Serialize, Deserialize)]
pub struct GetCommentsResponse {
comments: Vec<CommentView>,
}
impl Perform<CommentResponse> for Oper<CreateComment> {
fn perform(&self, conn: &PgConnection) -> Result<CommentResponse, Error> {
let data: &CreateComment = &self.data;
@ -456,3 +472,40 @@ impl Perform<CommentResponse> for Oper<CreateCommentLike> {
})
}
}
impl Perform<GetCommentsResponse> for Oper<GetComments> {
fn perform(&self, conn: &PgConnection) -> Result<GetCommentsResponse, Error> {
let data: &GetComments = &self.data;
let user_claims: Option<Claims> = match &data.auth {
Some(auth) => match Claims::decode(&auth) {
Ok(claims) => Some(claims.claims),
Err(_e) => None,
},
None => None,
};
let user_id = match &user_claims {
Some(claims) => Some(claims.id),
None => None,
};
let type_ = ListingType::from_str(&data.type_)?;
let sort = SortType::from_str(&data.sort)?;
let comments = match CommentQueryBuilder::create(&conn)
.listing_type(type_)
.sort(&sort)
.for_community_id(data.community_id)
.my_user_id(user_id)
.page(data.page)
.limit(data.limit)
.list()
{
Ok(comments) => comments,
Err(_e) => return Err(APIError::err("couldnt_get_comments").into()),
};
Ok(GetCommentsResponse { comments })
}
}

@ -126,7 +126,15 @@ impl Perform<PostResponse> for Oper<CreatePost> {
let inserted_post = match Post::create(&conn, &post_form) {
Ok(post) => post,
Err(_e) => return Err(APIError::err("couldnt_create_post").into()),
Err(e) => {
let err_type = if e.to_string() == "value too long for type character varying(200)" {
"post_title_too_long"
} else {
"couldnt_create_post"
};
return Err(APIError::err(err_type).into());
}
};
// They like their own post by default
@ -361,7 +369,15 @@ impl Perform<PostResponse> for Oper<EditPost> {
let _updated_post = match Post::update(&conn, data.edit_id, &post_form) {
Ok(post) => post,
Err(_e) => return Err(APIError::err("couldnt_update_post").into()),
Err(e) => {
let err_type = if e.to_string() == "value too long for type character varying(200)" {
"post_title_too_long"
} else {
"couldnt_update_post"
};
return Err(APIError::err(err_type).into());
}
};
// Mod tables

@ -15,6 +15,7 @@ table! {
updated -> Nullable<Timestamp>,
deleted -> Bool,
community_id -> Int4,
community_name -> Varchar,
banned -> Bool,
banned_from_community -> Bool,
creator_name -> Varchar,
@ -22,8 +23,10 @@ table! {
score -> BigInt,
upvotes -> BigInt,
downvotes -> BigInt,
hot_rank -> Int4,
user_id -> Nullable<Int4>,
my_vote -> Nullable<Int4>,
subscribed -> Nullable<Bool>,
saved -> Nullable<Bool>,
}
}
@ -41,6 +44,7 @@ table! {
updated -> Nullable<Timestamp>,
deleted -> Bool,
community_id -> Int4,
community_name -> Varchar,
banned -> Bool,
banned_from_community -> Bool,
creator_name -> Varchar,
@ -48,8 +52,10 @@ table! {
score -> BigInt,
upvotes -> BigInt,
downvotes -> BigInt,
hot_rank -> Int4,
user_id -> Nullable<Int4>,
my_vote -> Nullable<Int4>,
subscribed -> Nullable<Bool>,
saved -> Nullable<Bool>,
}
}
@ -70,6 +76,7 @@ pub struct CommentView {
pub updated: Option<chrono::NaiveDateTime>,
pub deleted: bool,
pub community_id: i32,
pub community_name: String,
pub banned: bool,
pub banned_from_community: bool,
pub creator_name: String,
@ -77,15 +84,19 @@ pub struct CommentView {
pub score: i64,
pub upvotes: i64,
pub downvotes: i64,
pub hot_rank: i32,
pub user_id: Option<i32>,
pub my_vote: Option<i32>,
pub subscribed: Option<bool>,
pub saved: Option<bool>,
}
pub struct CommentQueryBuilder<'a> {
conn: &'a PgConnection,
query: super::comment_view::comment_mview::BoxedQuery<'a, Pg>,
listing_type: ListingType,
sort: &'a SortType,
for_community_id: Option<i32>,
for_post_id: Option<i32>,
for_creator_id: Option<i32>,
search_term: Option<String>,
@ -104,7 +115,9 @@ impl<'a> CommentQueryBuilder<'a> {
CommentQueryBuilder {
conn,
query,
listing_type: ListingType::All,
sort: &SortType::New,
for_community_id: None,
for_post_id: None,
for_creator_id: None,
search_term: None,
@ -115,6 +128,11 @@ impl<'a> CommentQueryBuilder<'a> {
}
}
pub fn listing_type(mut self, listing_type: ListingType) -> Self {
self.listing_type = listing_type;
self
}
pub fn sort(mut self, sort: &'a SortType) -> Self {
self.sort = sort;
self
@ -130,6 +148,11 @@ impl<'a> CommentQueryBuilder<'a> {
self
}
pub fn for_community_id<T: MaybeOptional<i32>>(mut self, for_community_id: T) -> Self {
self.for_community_id = for_community_id.get_optional();
self
}
pub fn search_term<T: MaybeOptional<String>>(mut self, search_term: T) -> Self {
self.search_term = search_term.get_optional();
self
@ -171,6 +194,10 @@ impl<'a> CommentQueryBuilder<'a> {
query = query.filter(creator_id.eq(for_creator_id));
};
if let Some(for_community_id) = self.for_community_id {
query = query.filter(community_id.eq(for_community_id));
}
if let Some(for_post_id) = self.for_post_id {
query = query.filter(post_id.eq(for_post_id));
};
@ -179,12 +206,18 @@ impl<'a> CommentQueryBuilder<'a> {
query = query.filter(content.ilike(fuzzy_search(&search_term)));
};
if let ListingType::Subscribed = self.listing_type {
query = query.filter(subscribed.eq(true));
}
if self.saved_only {
query = query.filter(saved.eq(true));
}
query = match self.sort {
// SortType::Hot => query.order(hot_rank.desc(), published.desc()),
SortType::Hot => query
.order_by(hot_rank.desc())
.then_order_by(published.desc()),
SortType::New => query.order_by(published.desc()),
SortType::TopAll => query.order_by(score.desc()),
SortType::TopYear => query
@ -199,7 +232,7 @@ impl<'a> CommentQueryBuilder<'a> {
SortType::TopDay => query
.filter(published.gt(now - 1.days()))
.order_by(score.desc()),
_ => query.order_by(published.desc()),
// _ => query.order_by(published.desc()),
};
let (limit, offset) = limit_and_offset(self.page, self.limit);
@ -251,6 +284,7 @@ table! {
updated -> Nullable<Timestamp>,
deleted -> Bool,
community_id -> Int4,
community_name -> Varchar,
banned -> Bool,
banned_from_community -> Bool,
creator_name -> Varchar,
@ -258,8 +292,10 @@ table! {
score -> BigInt,
upvotes -> BigInt,
downvotes -> BigInt,
hot_rank -> Int4,
user_id -> Nullable<Int4>,
my_vote -> Nullable<Int4>,
subscribed -> Nullable<Bool>,
saved -> Nullable<Bool>,
recipient_id -> Int4,
}
@ -281,6 +317,7 @@ pub struct ReplyView {
pub updated: Option<chrono::NaiveDateTime>,
pub deleted: bool,
pub community_id: i32,
pub community_name: String,
pub banned: bool,
pub banned_from_community: bool,
pub creator_name: String,
@ -288,8 +325,10 @@ pub struct ReplyView {
pub score: i64,
pub upvotes: i64,
pub downvotes: i64,
pub hot_rank: i32,
pub user_id: Option<i32>,
pub my_vote: Option<i32>,
pub subscribed: Option<bool>,
pub saved: Option<bool>,
pub recipient_id: i32,
}
@ -474,6 +513,7 @@ mod tests {
creator_id: inserted_user.id,
post_id: inserted_post.id,
community_id: inserted_community.id,
community_name: inserted_community.name.to_owned(),
parent_id: None,
removed: false,
deleted: false,
@ -486,9 +526,11 @@ mod tests {
creator_avatar: None,
score: 1,
downvotes: 0,
hot_rank: 0,
upvotes: 1,
user_id: None,
my_vote: None,
subscribed: None,
saved: None,
};
@ -498,6 +540,7 @@ mod tests {
creator_id: inserted_user.id,
post_id: inserted_post.id,
community_id: inserted_community.id,
community_name: inserted_community.name.to_owned(),
parent_id: None,
removed: false,
deleted: false,
@ -510,21 +553,26 @@ mod tests {
creator_avatar: None,
score: 1,
downvotes: 0,
hot_rank: 0,
upvotes: 1,
user_id: Some(inserted_user.id),
my_vote: Some(1),
subscribed: None,
saved: None,
};
let read_comment_views_no_user = CommentQueryBuilder::create(&conn)
let mut read_comment_views_no_user = CommentQueryBuilder::create(&conn)
.for_post_id(inserted_post.id)
.list()
.unwrap();
let read_comment_views_with_user = CommentQueryBuilder::create(&conn)
read_comment_views_no_user[0].hot_rank = 0;
let mut read_comment_views_with_user = CommentQueryBuilder::create(&conn)
.for_post_id(inserted_post.id)
.my_user_id(inserted_user.id)
.list()
.unwrap();
read_comment_views_with_user[0].hot_rank = 0;
let like_removed = CommentLike::remove(&conn, &comment_like_form).unwrap();
let num_deleted = Comment::delete(&conn, inserted_comment.id).unwrap();

@ -31,6 +31,7 @@ table! {
upvotes -> BigInt,
downvotes -> BigInt,
hot_rank -> Int4,
newest_activity_time -> Timestamp,
user_id -> Nullable<Int4>,
my_vote -> Nullable<Int4>,
subscribed -> Nullable<Bool>,
@ -70,6 +71,7 @@ pub struct PostView {
pub upvotes: i64,
pub downvotes: i64,
pub hot_rank: i32,
pub newest_activity_time: chrono::NaiveDateTime,
pub user_id: Option<i32>,
pub my_vote: Option<i32>,
pub subscribed: Option<bool>,
@ -106,6 +108,7 @@ table! {
upvotes -> BigInt,
downvotes -> BigInt,
hot_rank -> Int4,
newest_activity_time -> Timestamp,
user_id -> Nullable<Int4>,
my_vote -> Nullable<Int4>,
subscribed -> Nullable<Bool>,
@ -121,6 +124,9 @@ pub struct PostQueryBuilder<'a> {
sort: &'a SortType,
my_user_id: Option<i32>,
for_creator_id: Option<i32>,
for_community_id: Option<i32>,
search_term: Option<String>,
url_search: Option<String>,
show_nsfw: bool,
saved_only: bool,
unread_only: bool,
@ -137,10 +143,13 @@ impl<'a> PostQueryBuilder<'a> {
PostQueryBuilder {
conn,
query,
my_user_id: None,
for_creator_id: None,
listing_type: ListingType::All,
sort: &SortType::Hot,
my_user_id: None,
for_creator_id: None,
for_community_id: None,
search_term: None,
url_search: None,
show_nsfw: true,
saved_only: false,
unread_only: false,
@ -160,34 +169,22 @@ impl<'a> PostQueryBuilder<'a> {
}
pub fn for_community_id<T: MaybeOptional<i32>>(mut self, for_community_id: T) -> Self {
use super::post_view::post_mview::dsl::*;
if let Some(for_community_id) = for_community_id.get_optional() {
self.query = self.query.filter(community_id.eq(for_community_id));
self.query = self.query.then_order_by(stickied.desc());
}
self.for_community_id = for_community_id.get_optional();
self
}
pub fn for_creator_id<T: MaybeOptional<i32>>(mut self, for_creator_id: T) -> Self {
if let Some(for_creator_id) = for_creator_id.get_optional() {
self.for_creator_id = Some(for_creator_id);
}
self.for_creator_id = for_creator_id.get_optional();
self
}
pub fn search_term<T: MaybeOptional<String>>(mut self, search_term: T) -> Self {
use super::post_view::post_mview::dsl::*;
if let Some(search_term) = search_term.get_optional() {
self.query = self.query.filter(name.ilike(fuzzy_search(&search_term)));
}
self.search_term = search_term.get_optional();
self
}
pub fn url_search<T: MaybeOptional<String>>(mut self, url_search: T) -> Self {
use super::post_view::post_mview::dsl::*;
if let Some(url_search) = url_search.get_optional() {
self.query = self.query.filter(url.eq(url_search));
}
self.url_search = url_search.get_optional();
self
}
@ -230,6 +227,22 @@ impl<'a> PostQueryBuilder<'a> {
query = query.filter(subscribed.eq(true));
}
if let Some(for_community_id) = self.for_community_id {
query = query.filter(community_id.eq(for_community_id));
query = query.then_order_by(stickied.desc());
}
if let Some(url_search) = self.url_search {
query = query.filter(url.eq(url_search));
}
if let Some(search_term) = self.search_term {
let searcher = fuzzy_search(&search_term);
query = query
.filter(name.ilike(searcher.to_owned()))
.or_filter(body.ilike(searcher));
}
query = match self.sort {
SortType::Hot => query
.then_order_by(hot_rank.desc())
@ -435,6 +448,7 @@ mod tests {
downvotes: 0,
hot_rank: 1728,
published: inserted_post.published,
newest_activity_time: inserted_post.published,
updated: None,
subscribed: None,
read: None,
@ -469,6 +483,7 @@ mod tests {
downvotes: 0,
hot_rank: 1728,
published: inserted_post.published,
newest_activity_time: inserted_post.published,
updated: None,
subscribed: None,
read: None,

@ -16,6 +16,7 @@ table! {
updated -> Nullable<Timestamp>,
deleted -> Bool,
community_id -> Int4,
community_name -> Varchar,
banned -> Bool,
banned_from_community -> Bool,
creator_name -> Varchar,
@ -23,6 +24,7 @@ table! {
score -> BigInt,
upvotes -> BigInt,
downvotes -> BigInt,
hot_rank -> Int4,
user_id -> Nullable<Int4>,
my_vote -> Nullable<Int4>,
saved -> Nullable<Bool>,
@ -44,6 +46,7 @@ table! {
updated -> Nullable<Timestamp>,
deleted -> Bool,
community_id -> Int4,
community_name -> Varchar,
banned -> Bool,
banned_from_community -> Bool,
creator_name -> Varchar,
@ -51,6 +54,7 @@ table! {
score -> BigInt,
upvotes -> BigInt,
downvotes -> BigInt,
hot_rank -> Int4,
user_id -> Nullable<Int4>,
my_vote -> Nullable<Int4>,
saved -> Nullable<Bool>,
@ -75,6 +79,7 @@ pub struct UserMentionView {
pub updated: Option<chrono::NaiveDateTime>,
pub deleted: bool,
pub community_id: i32,
pub community_name: String,
pub banned: bool,
pub banned_from_community: bool,
pub creator_name: String,
@ -82,6 +87,7 @@ pub struct UserMentionView {
pub score: i64,
pub upvotes: i64,
pub downvotes: i64,
pub hot_rank: i32,
pub user_id: Option<i32>,
pub my_vote: Option<i32>,
pub saved: Option<bool>,
@ -149,7 +155,9 @@ impl<'a> UserMentionQueryBuilder<'a> {
.filter(recipient_id.eq(self.for_user_id));
query = match self.sort {
// SortType::Hot => query.order_by(hot_rank.desc()),
SortType::Hot => query
.order_by(hot_rank.desc())
.then_order_by(published.desc()),
SortType::New => query.order_by(published.desc()),
SortType::TopAll => query.order_by(score.desc()),
SortType::TopYear => query
@ -164,7 +172,7 @@ impl<'a> UserMentionQueryBuilder<'a> {
SortType::TopDay => query
.filter(published.gt(now - 1.days()))
.order_by(score.desc()),
_ => query.order_by(published.desc()),
// _ => query.order_by(published.desc()),
};
let (limit, offset) = limit_and_offset(self.page, self.limit);

@ -6,7 +6,7 @@ pub fn config(cfg: &mut web::ServiceConfig) {
cfg
.route("/", web::get().to(index))
.route(
"/home/type/{type}/sort/{sort}/page/{page}",
"/home/data_type/{data_type}/listing_type/{listing_type}/sort/{sort}/page/{page}",
web::get().to(index),
)
.route("/login", web::get().to(index))
@ -17,7 +17,10 @@ pub fn config(cfg: &mut web::ServiceConfig) {
.route("/communities", web::get().to(index))
.route("/post/{id}/comment/{id2}", web::get().to(index))
.route("/post/{id}", web::get().to(index))
.route("/c/{name}/sort/{sort}/page/{page}", web::get().to(index))
.route(
"/c/{name}/data_type/{data_type}/sort/{sort}/page/{page}",
web::get().to(index),
)
.route("/c/{name}", web::get().to(index))
.route("/community/{id}", web::get().to(index))
.route(

@ -1 +1 @@
pub const VERSION: &str = "v0.6.11";
pub const VERSION: &str = "v0.6.16";

@ -45,4 +45,5 @@ pub enum UserOperation {
EditPrivateMessage,
GetPrivateMessages,
UserJoin,
GetComments,
}

@ -12,6 +12,7 @@ use serde_json::Value;
use std::collections::{HashMap, HashSet};
use std::str::FromStr;
use std::time::SystemTime;
use strum::IntoEnumIterator;
use crate::api::comment::*;
use crate::api::community::*;
@ -71,6 +72,13 @@ pub struct SessionInfo {
pub ip: IPAddr,
}
#[derive(Eq, PartialEq, Hash, Debug, EnumIter, Copy, Clone)]
pub enum RateLimitType {
Message,
Register,
Post,
}
/// `ChatServer` manages chat rooms and responsible for coordinating chat
/// session.
pub struct ChatServer {
@ -87,8 +95,8 @@ pub struct ChatServer {
/// sessions (IE clients)
user_rooms: HashMap<UserId, HashSet<ConnectionId>>,
/// Rate limiting based on IP addr
rate_limits: HashMap<IPAddr, RateLimitBucket>,
/// Rate limiting based on rate type and IP addr
rate_limit_buckets: HashMap<RateLimitType, HashMap<IPAddr, RateLimitBucket>>,
rng: ThreadRng,
db: Pool<ConnectionManager<PgConnection>>,
@ -98,7 +106,7 @@ impl ChatServer {
pub fn startup(db: Pool<ConnectionManager<PgConnection>>) -> ChatServer {
ChatServer {
sessions: HashMap::new(),
rate_limits: HashMap::new(),
rate_limit_buckets: HashMap::new(),
post_rooms: HashMap::new(),
community_rooms: HashMap::new(),
user_rooms: HashMap::new(),
@ -113,6 +121,12 @@ impl ChatServer {
sessions.remove(&id);
}
// Also leave all post rooms
// This avoids double messages
for sessions in self.post_rooms.values_mut() {
sessions.remove(&id);
}
// If the room doesn't exist yet
if self.community_rooms.get_mut(&community_id).is_none() {
self.community_rooms.insert(community_id, HashSet::new());
@ -131,6 +145,12 @@ impl ChatServer {
sessions.remove(&id);
}
// Also leave all communities
// This avoids double messages
for sessions in self.community_rooms.values_mut() {
sessions.remove(&id);
}
// If the room doesn't exist yet
if self.post_rooms.get_mut(&post_id).is_none() {
self.post_rooms.insert(post_id, HashSet::new());
@ -235,6 +255,10 @@ impl ChatServer {
self.send_user_room_message(recipient_id, &comment_reply_sent_str, id);
}
// Send it to the community too
self.send_community_room_message(0, &comment_post_sent_str, id);
self.send_community_room_message(comment.comment.community_id, &comment_post_sent_str, id);
Ok(comment_user_sent_str)
}
@ -259,60 +283,80 @@ impl ChatServer {
to_json_string(&user_operation, post)
}
fn check_rate_limit_register(&mut self, id: usize) -> Result<(), Error> {
fn check_rate_limit_register(&mut self, id: usize, check_only: bool) -> Result<(), Error> {
self.check_rate_limit_full(
RateLimitType::Register,
id,
Settings::get().rate_limit.register,
Settings::get().rate_limit.register_per_second,
check_only,
)
}
fn check_rate_limit_post(&mut self, id: usize) -> Result<(), Error> {
fn check_rate_limit_post(&mut self, id: usize, check_only: bool) -> Result<(), Error> {
self.check_rate_limit_full(
RateLimitType::Post,
id,
Settings::get().rate_limit.post,
Settings::get().rate_limit.post_per_second,
check_only,
)
}
fn check_rate_limit_message(&mut self, id: usize) -> Result<(), Error> {
fn check_rate_limit_message(&mut self, id: usize, check_only: bool) -> Result<(), Error> {
self.check_rate_limit_full(
RateLimitType::Message,
id,
Settings::get().rate_limit.message,
Settings::get().rate_limit.message_per_second,
check_only,
)
}
#[allow(clippy::float_cmp)]
fn check_rate_limit_full(&mut self, id: usize, rate: i32, per: i32) -> Result<(), Error> {
fn check_rate_limit_full(
&mut self,
type_: RateLimitType,
id: usize,
rate: i32,
per: i32,
check_only: bool,
) -> Result<(), Error> {
if let Some(info) = self.sessions.get(&id) {
if let Some(rate_limit) = self.rate_limits.get_mut(&info.ip) {
// The initial value
if rate_limit.allowance == -2f64 {
rate_limit.allowance = rate as f64;
};
let current = SystemTime::now();
let time_passed = current.duration_since(rate_limit.last_checked)?.as_secs() as f64;
rate_limit.last_checked = current;
rate_limit.allowance += time_passed * (rate as f64 / per as f64);
if rate_limit.allowance > rate as f64 {
rate_limit.allowance = rate as f64;
}
if let Some(bucket) = self.rate_limit_buckets.get_mut(&type_) {
if let Some(rate_limit) = bucket.get_mut(&info.ip) {
let current = SystemTime::now();
let time_passed = current.duration_since(rate_limit.last_checked)?.as_secs() as f64;
// The initial value
if rate_limit.allowance == -2f64 {
rate_limit.allowance = rate as f64;
};
rate_limit.last_checked = current;
rate_limit.allowance += time_passed * (rate as f64 / per as f64);
if !check_only && rate_limit.allowance > rate as f64 {
rate_limit.allowance = rate as f64;
}
if rate_limit.allowance < 1.0 {
println!(
"Rate limited IP: {}, time_passed: {}, allowance: {}",
&info.ip, time_passed, rate_limit.allowance
);
Err(
APIError {
message: format!("Too many requests. {} per {} seconds", rate, per),
if rate_limit.allowance < 1.0 {
println!(
"Rate limited IP: {}, time_passed: {}, allowance: {}",
&info.ip, time_passed, rate_limit.allowance
);
Err(
APIError {
message: format!("Too many requests. {} per {} seconds", rate, per),
}
.into(),
)
} else {
if !check_only {
rate_limit.allowance -= 1.0;
}
.into(),
)
Ok(())
}
} else {
rate_limit.allowance -= 1.0;
Ok(())
}
} else {
@ -350,14 +394,24 @@ impl Handler<Connect> for ChatServer {
},
);
if self.rate_limits.get(&msg.ip).is_none() {
self.rate_limits.insert(
msg.ip,
RateLimitBucket {
last_checked: SystemTime::now(),
allowance: -2f64,
},
);
for rate_limit_type in RateLimitType::iter() {
if self.rate_limit_buckets.get(&rate_limit_type).is_none() {
self
.rate_limit_buckets
.insert(rate_limit_type, HashMap::new());
}
if let Some(bucket) = self.rate_limit_buckets.get_mut(&rate_limit_type) {
if bucket.get(&msg.ip).is_none() {
bucket.insert(
msg.ip.to_owned(),
RateLimitBucket {
last_checked: SystemTime::now(),
allowance: -2f64,
},
);
}
}
}
id
@ -446,11 +500,18 @@ fn parse_json_message(chat: &mut ChatServer, msg: StandardMessage) -> Result<Str
// TODO: none of the chat messages are going to work if stuff is submitted via http api,
// need to move that handling elsewhere
// A DDOS check
chat.check_rate_limit_message(msg.id, false)?;
match user_operation {
UserOperation::Login => do_user_operation::<Login, LoginResponse>(user_operation, data, &conn),
UserOperation::Register => {
chat.check_rate_limit_register(msg.id)?;
do_user_operation::<Register, LoginResponse>(user_operation, data, &conn)
chat.check_rate_limit_register(msg.id, true)?;
let register: Register = serde_json::from_str(data)?;
let res = Oper::new(register).perform(&conn)?;
chat.check_rate_limit_register(msg.id, false)?;
to_json_string(&user_operation, &res)
}
UserOperation::GetUserDetails => {
do_user_operation::<GetUserDetails, GetUserDetailsResponse>(user_operation, data, &conn)
@ -503,8 +564,11 @@ fn parse_json_message(chat: &mut ChatServer, msg: StandardMessage) -> Result<Str
do_user_operation::<ListCommunities, ListCommunitiesResponse>(user_operation, data, &conn)
}
UserOperation::CreateCommunity => {
chat.check_rate_limit_register(msg.id)?;
do_user_operation::<CreateCommunity, CommunityResponse>(user_operation, data, &conn)
chat.check_rate_limit_register(msg.id, true)?;
let create_community: CreateCommunity = serde_json::from_str(data)?;
let res = Oper::new(create_community).perform(&conn)?;
chat.check_rate_limit_register(msg.id, false)?;
to_json_string(&user_operation, &res)
}
UserOperation::EditCommunity => {
let edit_community: EditCommunity = serde_json::from_str(data)?;
@ -565,15 +629,24 @@ fn parse_json_message(chat: &mut ChatServer, msg: StandardMessage) -> Result<Str
let res = Oper::new(get_posts).perform(&conn)?;
to_json_string(&user_operation, &res)
}
UserOperation::GetComments => {
let get_comments: GetComments = serde_json::from_str(data)?;
if get_comments.community_id.is_none() {
// 0 is the "all" community
chat.join_community_room(0, msg.id);
}
let res = Oper::new(get_comments).perform(&conn)?;
to_json_string(&user_operation, &res)
}
UserOperation::CreatePost => {
chat.check_rate_limit_post(msg.id)?;
chat.check_rate_limit_post(msg.id, true)?;
let create_post: CreatePost = serde_json::from_str(data)?;
let res = Oper::new(create_post).perform(&conn)?;
chat.check_rate_limit_post(msg.id, false)?;
chat.post_sends(UserOperation::CreatePost, res, msg.id)
}
UserOperation::CreatePostLike => {
chat.check_rate_limit_message(msg.id)?;
let create_post_like: CreatePostLike = serde_json::from_str(data)?;
let res = Oper::new(create_post_like).perform(&conn)?;
@ -589,7 +662,6 @@ fn parse_json_message(chat: &mut ChatServer, msg: StandardMessage) -> Result<Str
do_user_operation::<SavePost, PostResponse>(user_operation, data, &conn)
}
UserOperation::CreateComment => {
chat.check_rate_limit_message(msg.id)?;
let create_comment: CreateComment = serde_json::from_str(data)?;
let res = Oper::new(create_comment).perform(&conn)?;
@ -605,7 +677,6 @@ fn parse_json_message(chat: &mut ChatServer, msg: StandardMessage) -> Result<Str
do_user_operation::<SaveComment, CommentResponse>(user_operation, data, &conn)
}
UserOperation::CreateCommentLike => {
chat.check_rate_limit_message(msg.id)?;
let create_comment_like: CreateCommentLike = serde_json::from_str(data)?;
let res = Oper::new(create_comment_like).perform(&conn)?;
@ -649,7 +720,6 @@ fn parse_json_message(chat: &mut ChatServer, msg: StandardMessage) -> Result<Str
do_user_operation::<PasswordChange, LoginResponse>(user_operation, data, &conn)
}
UserOperation::CreatePrivateMessage => {
chat.check_rate_limit_message(msg.id)?;
let create_private_message: CreatePrivateMessage = serde_json::from_str(data)?;
let recipient_id = create_private_message.recipient_id;
let res = Oper::new(create_private_message).perform(&conn)?;

@ -37,6 +37,7 @@
}
.md-div img {
max-height: 90vh;
max-width: 100%;
height: auto;
}
@ -170,3 +171,7 @@ hr {
-o-filter: blur(10px);
-ms-filter: blur(10px);
}
.img-expanded {
max-height: 90vh;
}

@ -58,7 +58,9 @@ interface CommentNodeProps {
markable?: boolean;
moderators: Array<CommunityUser>;
admins: Array<UserView>;
// TODO is this necessary, can't I get it from the node itself?
postCreatorId?: number;
showCommunity?: boolean;
}
export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
@ -205,6 +207,14 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
<span>) </span>
</span>
</li>
{this.props.showCommunity && (
<li className="list-inline-item">
<span> {i18n.t('to')} </span>
<Link to={`/c/${node.comment.community_name}`}>
{node.comment.community_name}
</Link>
</li>
)}
<li className="list-inline-item">
<span>
<MomentTime data={node.comment} />

@ -17,6 +17,7 @@ interface CommentNodesProps {
viewOnly?: boolean;
locked?: boolean;
markable?: boolean;
showCommunity?: boolean;
}
export class CommentNodes extends Component<
@ -40,6 +41,7 @@ export class CommentNodes extends Component<
admins={this.props.admins}
postCreatorId={this.props.postCreatorId}
markable={this.props.markable}
showCommunity={this.props.showCommunity}
/>
))}
</div>

@ -13,17 +13,31 @@ import {
GetPostsForm,
GetCommunityForm,
ListingType,
DataType,
GetPostsResponse,
PostResponse,
AddModToCommunityResponse,
BanFromCommunityResponse,
Comment,
GetCommentsForm,
GetCommentsResponse,
CommentResponse,
WebSocketJsonResponse,
} from '../interfaces';
import { WebSocketService, UserService } from '../services';
import { WebSocketService } from '../services';
import { PostListings } from './post-listings';
import { CommentNodes } from './comment-nodes';
import { SortSelect } from './sort-select';
import { DataTypeSelect } from './data-type-select';
import { Sidebar } from './sidebar';
import { wsJsonToRes, routeSortTypeToEnum, fetchLimit, toast } from '../utils';
import {
wsJsonToRes,
fetchLimit,
toast,
getPageFromProps,
getSortTypeFromProps,
getDataTypeFromProps,
} from '../utils';
import { i18n } from '../i18next';
interface State {
@ -35,6 +49,8 @@ interface State {
online: number;
loading: boolean;
posts: Array<Post>;
comments: Array<Comment>;
dataType: DataType;
sort: SortType;
page: number;
}
@ -65,27 +81,18 @@ export class Community extends Component<any, State> {
online: null,
loading: true,
posts: [],
sort: this.getSortTypeFromProps(this.props),
page: this.getPageFromProps(this.props),
comments: [],
dataType: getDataTypeFromProps(this.props),
sort: getSortTypeFromProps(this.props),
page: getPageFromProps(this.props),
};
getSortTypeFromProps(props: any): SortType {
return props.match.params.sort
? routeSortTypeToEnum(props.match.params.sort)
: UserService.Instance.user
? UserService.Instance.user.default_sort_type
: SortType.Hot;
}
getPageFromProps(props: any): number {
return props.match.params.page ? Number(props.match.params.page) : 1;
}
constructor(props: any, context: any) {
super(props, context);
this.state = this.emptyState;
this.handleSortChange = this.handleSortChange.bind(this);
this.handleDataTypeChange = this.handleDataTypeChange.bind(this);
this.subscription = WebSocketService.Instance.subject
.pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
@ -112,10 +119,11 @@ export class Community extends Component<any, State> {
nextProps.history.action == 'POP' ||
nextProps.history.action == 'PUSH'
) {
this.state.sort = this.getSortTypeFromProps(nextProps);
this.state.page = this.getPageFromProps(nextProps);
this.state.dataType = getDataTypeFromProps(nextProps);
this.state.sort = getSortTypeFromProps(nextProps);
this.state.page = getPageFromProps(nextProps);
this.setState(this.state);
this.fetchPosts();
this.fetchData();
}
}
@ -145,7 +153,7 @@ export class Community extends Component<any, State> {
)}
</h5>
{this.selects()}
<PostListings posts={this.state.posts} removeDuplicates />
{this.listings()}
{this.paginator()}
</div>
<div class="col-12 col-md-4">
@ -162,10 +170,31 @@ export class Community extends Component<any, State> {
);
}
listings() {
return this.state.dataType == DataType.Post ? (
<PostListings posts={this.state.posts} removeDuplicates />
) : (
this.state.comments.map(comment => (
<div class="row">
<div class="col-12">
<CommentNodes nodes={[{ comment: comment }]} noIndent />
</div>
</div>
))
);
}
selects() {
return (
<div class="mb-2">
<SortSelect sort={this.state.sort} onChange={this.handleSortChange} />
<DataTypeSelect
type_={this.state.dataType}
onChange={this.handleDataTypeChange}
/>
<span class="mx-2">
<SortSelect sort={this.state.sort} onChange={this.handleSortChange} />
</span>
<a
href={`/feeds/c/${this.state.communityName}.xml?sort=${
SortType[this.state.sort]
@ -207,7 +236,7 @@ export class Community extends Component<any, State> {
i.state.page++;
i.setState(i.state);
i.updateUrl();
i.fetchPosts();
i.fetchData();
window.scrollTo(0, 0);
}
@ -215,7 +244,7 @@ export class Community extends Component<any, State> {
i.state.page--;
i.setState(i.state);
i.updateUrl();
i.fetchPosts();
i.fetchData();
window.scrollTo(0, 0);
}
@ -225,26 +254,48 @@ export class Community extends Component<any, State> {
this.state.loading = true;
this.setState(this.state);
this.updateUrl();
this.fetchPosts();
this.fetchData();
window.scrollTo(0, 0);
}
handleDataTypeChange(val: DataType) {
this.state.dataType = val;
this.state.page = 1;
this.state.loading = true;
this.setState(this.state);
this.updateUrl();
this.fetchData();
window.scrollTo(0, 0);
}
updateUrl() {
let dataTypeStr = DataType[this.state.dataType].toLowerCase();
let sortStr = SortType[this.state.sort].toLowerCase();
this.props.history.push(
`/c/${this.state.community.name}/sort/${sortStr}/page/${this.state.page}`
`/c/${this.state.community.name}/data_type/${dataTypeStr}/sort/${sortStr}/page/${this.state.page}`
);
}
fetchPosts() {
let getPostsForm: GetPostsForm = {
page: this.state.page,
limit: fetchLimit,
sort: SortType[this.state.sort],
type_: ListingType[ListingType.Community],
community_id: this.state.community.id,
};
WebSocketService.Instance.getPosts(getPostsForm);
fetchData() {
if (this.state.dataType == DataType.Post) {
let getPostsForm: GetPostsForm = {
page: this.state.page,
limit: fetchLimit,
sort: SortType[this.state.sort],
type_: ListingType[ListingType.Community],
community_id: this.state.community.id,
};
WebSocketService.Instance.getPosts(getPostsForm);
} else {
let getCommentsForm: GetCommentsForm = {
page: this.state.page,
limit: fetchLimit,
sort: SortType[this.state.sort],
type_: ListingType[ListingType.Community],
community_id: this.state.community.id,
};
WebSocketService.Instance.getComments(getCommentsForm);
}
}
parseMessage(msg: WebSocketJsonResponse) {
@ -255,7 +306,7 @@ export class Community extends Component<any, State> {
this.context.router.history.push('/');
return;
} else if (msg.reconnect) {
this.fetchPosts();
this.fetchData();
} else if (res.op == UserOperation.GetCommunity) {
let data = res.data as GetCommunityResponse;
this.state.community = data.community;
@ -264,7 +315,7 @@ export class Community extends Component<any, State> {
this.state.online = data.online;
document.title = `/c/${this.state.community.name} - ${WebSocketService.Instance.site.name}`;
this.setState(this.state);
this.fetchPosts();
this.fetchData();
} else if (res.op == UserOperation.EditCommunity) {
let data = res.data as CommunityResponse;
this.state.community = data.community;
@ -283,12 +334,12 @@ export class Community extends Component<any, State> {
} else if (res.op == UserOperation.EditPost) {
let data = res.data as PostResponse;
let found = this.state.posts.find(c => c.id == data.post.id);
found.url = data.post.url;
found.name = data.post.name;
found.nsfw = data.post.nsfw;
this.setState(this.state);
if (found) {
found.url = data.post.url;
found.name = data.post.name;
found.nsfw = data.post.nsfw;
this.setState(this.state);
}
} else if (res.op == UserOperation.CreatePost) {
let data = res.data as PostResponse;
this.state.posts.unshift(data.post);
@ -296,16 +347,16 @@ export class Community extends Component<any, State> {
} else if (res.op == UserOperation.CreatePostLike) {
let data = res.data as PostResponse;
let found = this.state.posts.find(c => c.id == data.post.id);
found.score = data.post.score;
found.upvotes = data.post.upvotes;
found.downvotes = data.post.downvotes;
if (data.post.my_vote !== null) {
found.my_vote = data.post.my_vote;
found.upvoteLoading = false;
found.downvoteLoading = false;
if (found) {
found.score = data.post.score;
found.upvotes = data.post.upvotes;
found.downvotes = data.post.downvotes;
if (data.post.my_vote !== null) {
found.my_vote = data.post.my_vote;
found.upvoteLoading = false;
found.downvoteLoading = false;
}
}
this.setState(this.state);
} else if (res.op == UserOperation.AddModToCommunity) {
let data = res.data as AddModToCommunityResponse;
@ -319,6 +370,48 @@ export class Community extends Component<any, State> {
.forEach(p => (p.banned = data.banned));
this.setState(this.state);
} else if (res.op == UserOperation.GetComments) {
let data = res.data as GetCommentsResponse;
this.state.comments = data.comments;
this.state.loading = false;
this.setState(this.state);
} else if (res.op == UserOperation.EditComment) {
let data = res.data as CommentResponse;
let found = this.state.comments.find(c => c.id == data.comment.id);
if (found) {
found.content = data.comment.content;
found.updated = data.comment.updated;
found.removed = data.comment.removed;
found.deleted = data.comment.deleted;
found.upvotes = data.comment.upvotes;
found.downvotes = data.comment.downvotes;
found.score = data.comment.score;
this.setState(this.state);
}
} else if (res.op == UserOperation.CreateComment) {
let data = res.data as CommentResponse;
// Necessary since it might be a user reply
if (data.recipient_ids.length == 0) {
this.state.comments.unshift(data.comment);
this.setState(this.state);
}
} else if (res.op == UserOperation.SaveComment) {
let data = res.data as CommentResponse;
let found = this.state.comments.find(c => c.id == data.comment.id);
found.saved = data.comment.saved;
this.setState(this.state);
} else if (res.op == UserOperation.CreateCommentLike) {
let data = res.data as CommentResponse;
let found: Comment = this.state.comments.find(
c => c.id === data.comment.id
);
found.score = data.comment.score;
found.upvotes = data.comment.upvotes;
found.downvotes = data.comment.downvotes;
if (data.comment.my_vote !== null) found.my_vote = data.comment.my_vote;
this.setState(this.state);
}
}
}

@ -0,0 +1,65 @@
import { Component, linkEvent } from 'inferno';
import { DataType } from '../interfaces';
import { i18n } from '../i18next';
interface DataTypeSelectProps {
type_: DataType;
onChange?(val: DataType): any;
}
interface DataTypeSelectState {
type_: DataType;
}
export class DataTypeSelect extends Component<
DataTypeSelectProps,
DataTypeSelectState
> {
private emptyState: DataTypeSelectState = {
type_: this.props.type_,
};
constructor(props: any, context: any) {
super(props, context);
this.state = this.emptyState;
}
render() {
return (
<div class="btn-group btn-group-toggle">
<label
className={`pointer btn btn-sm btn-secondary
${this.state.type_ == DataType.Post && 'active'}
`}
>
<input
type="radio"
value={DataType.Post}
checked={this.state.type_ == DataType.Post}
onChange={linkEvent(this, this.handleTypeChange)}
/>
{i18n.t('posts')}
</label>
<label
className={`pointer btn btn-sm btn-secondary ${this.state.type_ ==
DataType.Comment && 'active'}`}
>
<input
type="radio"
value={DataType.Comment}
checked={this.state.type_ == DataType.Comment}
onChange={linkEvent(this, this.handleTypeChange)}
/>
{i18n.t('comments')}
</label>
</div>
);
}
handleTypeChange(i: DataTypeSelect, event: any) {
i.state.type_ = Number(event.target.value);
i.setState(i.state);
i.props.onChange(i.state.type_);
}
}

@ -12,30 +12,39 @@ import {
SortType,
GetSiteResponse,
ListingType,
DataType,
SiteResponse,
GetPostsResponse,
PostResponse,
Post,
GetPostsForm,
Comment,
GetCommentsForm,
GetCommentsResponse,
CommentResponse,
AddAdminResponse,
BanUserResponse,
WebSocketJsonResponse,
} from '../interfaces';
import { WebSocketService, UserService } from '../services';
import { PostListings } from './post-listings';
import { CommentNodes } from './comment-nodes';
import { SortSelect } from './sort-select';
import { ListingTypeSelect } from './listing-type-select';
import { DataTypeSelect } from './data-type-select';
import { SiteForm } from './site-form';
import {
wsJsonToRes,
repoUrl,
mdToHtml,
fetchLimit,
routeSortTypeToEnum,
routeListingTypeToEnum,
pictshareAvatarThumbnail,
showAvatars,
toast,
getListingTypeFromProps,
getPageFromProps,
getSortTypeFromProps,
getDataTypeFromProps,
} from '../utils';
import { i18n } from '../i18next';
import { T } from 'inferno-i18next';
@ -47,7 +56,9 @@ interface MainState {
showEditSite: boolean;
loading: boolean;
posts: Array<Post>;
type_: ListingType;
comments: Array<Comment>;
listingType: ListingType;
dataType: DataType;
sort: SortType;
page: number;
}
@ -79,38 +90,21 @@ export class Main extends Component<any, MainState> {
showEditSite: false,
loading: true,
posts: [],
type_: this.getListingTypeFromProps(this.props),
sort: this.getSortTypeFromProps(this.props),
page: this.getPageFromProps(this.props),
comments: [],
listingType: getListingTypeFromProps(this.props),
dataType: getDataTypeFromProps(this.props),
sort: getSortTypeFromProps(this.props),
page: getPageFromProps(this.props),
};
getListingTypeFromProps(props: any): ListingType {
return props.match.params.type
? routeListingTypeToEnum(props.match.params.type)
: UserService.Instance.user
? UserService.Instance.user.default_listing_type
: ListingType.All;
}
getSortTypeFromProps(props: any): SortType {
return props.match.params.sort
? routeSortTypeToEnum(props.match.params.sort)
: UserService.Instance.user
? UserService.Instance.user.default_sort_type
: SortType.Hot;
}
getPageFromProps(props: any): number {
return props.match.params.page ? Number(props.match.params.page) : 1;
}
constructor(props: any, context: any) {
super(props, context);
this.state = this.emptyState;
this.handleEditCancel = this.handleEditCancel.bind(this);
this.handleSortChange = this.handleSortChange.bind(this);
this.handleTypeChange = this.handleTypeChange.bind(this);
this.handleListingTypeChange = this.handleListingTypeChange.bind(this);
this.handleDataTypeChange = this.handleDataTypeChange.bind(this);
this.subscription = WebSocketService.Instance.subject
.pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
@ -133,7 +127,7 @@ export class Main extends Component<any, MainState> {
WebSocketService.Instance.listCommunities(listCommunitiesForm);
this.fetchPosts();
this.fetchData();
}
componentWillUnmount() {
@ -146,11 +140,12 @@ export class Main extends Component<any, MainState> {
nextProps.history.action == 'POP' ||
nextProps.history.action == 'PUSH'
) {
this.state.type_ = this.getListingTypeFromProps(nextProps);
this.state.sort = this.getSortTypeFromProps(nextProps);
this.state.page = this.getPageFromProps(nextProps);
this.state.listingType = getListingTypeFromProps(nextProps);
this.state.dataType = getDataTypeFromProps(nextProps);
this.state.sort = getSortTypeFromProps(nextProps);
this.state.page = getPageFromProps(nextProps);
this.setState(this.state);
this.fetchPosts();
this.fetchData();
}
}
@ -251,10 +246,11 @@ export class Main extends Component<any, MainState> {
}
updateUrl() {
let typeStr = ListingType[this.state.type_].toLowerCase();
let listingTypeStr = ListingType[this.state.listingType].toLowerCase();
let dataTypeStr = DataType[this.state.dataType].toLowerCase();
let sortStr = SortType[this.state.sort].toLowerCase();
this.props.history.push(
`/home/type/${typeStr}/sort/${sortStr}/page/${this.state.page}`
`/home/data_type/${dataTypeStr}/listing_type/${listingTypeStr}/sort/${sortStr}/page/${this.state.page}`
);
}
@ -392,11 +388,7 @@ export class Main extends Component<any, MainState> {
) : (
<div>
{this.selects()}
<PostListings
posts={this.state.posts}
showCommunity
removeDuplicates
/>
{this.listings()}
{this.paginator()}
</div>
)}
@ -404,17 +396,41 @@ export class Main extends Component<any, MainState> {
);
}
listings() {
return this.state.dataType == DataType.Post ? (
<PostListings posts={this.state.posts} showCommunity removeDuplicates />
) : (
this.state.comments.map(comment => (
<div class="row">
<div class="col-12">
<CommentNodes
nodes={[{ comment: comment }]}
noIndent
showCommunity
/>
</div>
</div>
))
);
}
selects() {
return (
<div className="mb-3">
<ListingTypeSelect
type_={this.state.type_}
onChange={this.handleTypeChange}
<DataTypeSelect
type_={this.state.dataType}
onChange={this.handleDataTypeChange}
/>
<span class="mx-2">
<ListingTypeSelect
type_={this.state.listingType}
onChange={this.handleListingTypeChange}
/>
</span>
<span class="mr-2">
<SortSelect sort={this.state.sort} onChange={this.handleSortChange} />
</span>
{this.state.type_ == ListingType.All && (
{this.state.listingType == ListingType.All && (
<a
href={`/feeds/all.xml?sort=${SortType[this.state.sort]}`}
target="_blank"
@ -425,7 +441,7 @@ export class Main extends Component<any, MainState> {
</a>
)}
{UserService.Instance.user &&
this.state.type_ == ListingType.Subscribed && (
this.state.listingType == ListingType.Subscribed && (
<a
href={`/feeds/front/${UserService.Instance.auth}.xml?sort=${
SortType[this.state.sort]
@ -488,7 +504,7 @@ export class Main extends Component<any, MainState> {
i.state.loading = true;
i.setState(i.state);
i.updateUrl();
i.fetchPosts();
i.fetchData();
window.scrollTo(0, 0);
}
@ -497,7 +513,7 @@ export class Main extends Component<any, MainState> {
i.state.loading = true;
i.setState(i.state);
i.updateUrl();
i.fetchPosts();
i.fetchData();
window.scrollTo(0, 0);
}
@ -507,28 +523,48 @@ export class Main extends Component<any, MainState> {
this.state.loading = true;
this.setState(this.state);
this.updateUrl();
this.fetchPosts();
this.fetchData();
window.scrollTo(0, 0);
}
handleTypeChange(val: ListingType) {
this.state.type_ = val;
handleListingTypeChange(val: ListingType) {
this.state.listingType = val;
this.state.page = 1;
this.state.loading = true;
this.setState(this.state);
this.updateUrl();
this.fetchPosts();
this.fetchData();
window.scrollTo(0, 0);
}
fetchPosts() {
let getPostsForm: GetPostsForm = {
page: this.state.page,
limit: fetchLimit,
sort: SortType[this.state.sort],
type_: ListingType[this.state.type_],
};
WebSocketService.Instance.getPosts(getPostsForm);
handleDataTypeChange(val: DataType) {
this.state.dataType = val;
this.state.page = 1;
this.state.loading = true;
this.setState(this.state);
this.updateUrl();
this.fetchData();
window.scrollTo(0, 0);
}
fetchData() {
if (this.state.dataType == DataType.Post) {
let getPostsForm: GetPostsForm = {
page: this.state.page,
limit: fetchLimit,
sort: SortType[this.state.sort],
type_: ListingType[this.state.listingType],
};
WebSocketService.Instance.getPosts(getPostsForm);
} else {
let getCommentsForm: GetCommentsForm = {
page: this.state.page,
limit: fetchLimit,
sort: SortType[this.state.sort],
type_: ListingType[this.state.listingType],
};
WebSocketService.Instance.getComments(getCommentsForm);
}
}
parseMessage(msg: WebSocketJsonResponse) {
@ -538,7 +574,7 @@ export class Main extends Component<any, MainState> {
toast(i18n.t(msg.error), 'danger');
return;
} else if (msg.reconnect) {
this.fetchPosts();
this.fetchData();
} else if (res.op == UserOperation.GetFollowedCommunities) {
let data = res.data as GetFollowedCommunitiesResponse;
this.state.subscribedCommunities = data.communities;
@ -574,7 +610,7 @@ export class Main extends Component<any, MainState> {
let data = res.data as PostResponse;
// If you're on subscribed, only push it if you're subscribed.
if (this.state.type_ == ListingType.Subscribed) {
if (this.state.listingType == ListingType.Subscribed) {
if (
this.state.subscribedCommunities
.map(c => c.community_id)
@ -590,26 +626,27 @@ export class Main extends Component<any, MainState> {
} else if (res.op == UserOperation.EditPost) {
let data = res.data as PostResponse;
let found = this.state.posts.find(c => c.id == data.post.id);
if (found) {
found.url = data.post.url;
found.name = data.post.name;
found.nsfw = data.post.nsfw;
found.url = data.post.url;
found.name = data.post.name;
found.nsfw = data.post.nsfw;
this.setState(this.state);
this.setState(this.state);
}
} else if (res.op == UserOperation.CreatePostLike) {
let data = res.data as PostResponse;
let found = this.state.posts.find(c => c.id == data.post.id);
found.score = data.post.score;
found.upvotes = data.post.upvotes;
found.downvotes = data.post.downvotes;
if (data.post.my_vote !== null) {
found.my_vote = data.post.my_vote;
found.upvoteLoading = false;
found.downvoteLoading = false;
if (found) {
found.score = data.post.score;
found.upvotes = data.post.upvotes;
found.downvotes = data.post.downvotes;
if (data.post.my_vote !== null) {
found.my_vote = data.post.my_vote;
found.upvoteLoading = false;
found.downvoteLoading = false;
}
this.setState(this.state);
}
this.setState(this.state);
} else if (res.op == UserOperation.AddAdmin) {
let data = res.data as AddAdminResponse;
this.state.siteRes.admins = data.admins;
@ -632,6 +669,59 @@ export class Main extends Component<any, MainState> {
.forEach(p => (p.banned = data.banned));
this.setState(this.state);
} else if (res.op == UserOperation.GetComments) {
let data = res.data as GetCommentsResponse;
this.state.comments = data.comments;
this.state.loading = false;
this.setState(this.state);
} else if (res.op == UserOperation.EditComment) {
let data = res.data as CommentResponse;
let found = this.state.comments.find(c => c.id == data.comment.id);
if (found) {
found.content = data.comment.content;
found.updated = data.comment.updated;
found.removed = data.comment.removed;
found.deleted = data.comment.deleted;
found.upvotes = data.comment.upvotes;
found.downvotes = data.comment.downvotes;
found.score = data.comment.score;
this.setState(this.state);
}
} else if (res.op == UserOperation.CreateComment) {
let data = res.data as CommentResponse;
// Necessary since it might be a user reply
if (data.recipient_ids.length == 0) {
// If you're on subscribed, only push it if you're subscribed.
if (this.state.listingType == ListingType.Subscribed) {
if (
this.state.subscribedCommunities
.map(c => c.community_id)
.includes(data.comment.community_id)
) {
this.state.comments.unshift(data.comment);
}
} else {
this.state.comments.unshift(data.comment);
}
this.setState(this.state);
}
} else if (res.op == UserOperation.SaveComment) {
let data = res.data as CommentResponse;
let found = this.state.comments.find(c => c.id == data.comment.id);
found.saved = data.comment.saved;
this.setState(this.state);
} else if (res.op == UserOperation.CreateCommentLike) {
let data = res.data as CommentResponse;
let found: Comment = this.state.comments.find(
c => c.id === data.comment.id
);
found.score = data.comment.score;
found.upvotes = data.comment.upvotes;
found.downvotes = data.comment.downvotes;
if (data.comment.my_vote !== null) found.my_vote = data.comment.my_vote;
this.setState(this.state);
}
}
}

@ -37,6 +37,8 @@ import autosize from 'autosize';
import Tribute from 'tributejs/src/Tribute.js';
import { i18n } from '../i18next';
const MAX_POST_TITLE_LENGTH = 200;
interface PostFormProps {
post?: Post; // If a post is given, that means this is an edit
params?: PostFormParams;
@ -232,7 +234,7 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
required
rows={2}
minLength={3}
maxLength={100}
maxLength={MAX_POST_TITLE_LENGTH}
/>
{this.state.suggestedPosts.length > 0 && (
<>
@ -360,7 +362,10 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
}
copySuggestedTitle(i: PostForm) {
i.state.postForm.name = i.state.suggestedTitle;
i.state.postForm.name = i.state.suggestedTitle.substring(
0,
MAX_POST_TITLE_LENGTH
);
i.state.suggestedTitle = undefined;
i.setState(i.state);
}
@ -511,12 +516,16 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
this.setState(this.state);
} else if (res.op == UserOperation.CreatePost) {
let data = res.data as PostResponse;
this.state.loading = false;
this.props.onCreate(data.post.id);
if (data.post.creator_id == UserService.Instance.user.id) {
this.state.loading = false;
this.props.onCreate(data.post.id);
}
} else if (res.op == UserOperation.EditPost) {
let data = res.data as PostResponse;
this.state.loading = false;
this.props.onEdit(data.post);
if (data.post.creator_id == UserService.Instance.user.id) {
this.state.loading = false;
this.props.onEdit(data.post);
}
} else if (res.op == UserOperation.Search) {
let data = res.data as SearchResponse;

@ -237,7 +237,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
class="pointer"
onClick={linkEvent(this, this.handleImageExpandClick)}
>
<img class="img-fluid" src={post.url} />
<img class="img-fluid img-expanded" src={post.url} />
</span>
</div>
</span>

@ -409,33 +409,39 @@ export class Post extends Component<any, PostState> {
} else if (res.op == UserOperation.EditComment) {
let data = res.data as CommentResponse;
let found = this.state.comments.find(c => c.id == data.comment.id);
found.content = data.comment.content;
found.updated = data.comment.updated;
found.removed = data.comment.removed;
found.deleted = data.comment.deleted;
found.upvotes = data.comment.upvotes;
found.downvotes = data.comment.downvotes;
found.score = data.comment.score;
found.read = data.comment.read;
if (found) {
found.content = data.comment.content;
found.updated = data.comment.updated;
found.removed = data.comment.removed;
found.deleted = data.comment.deleted;
found.upvotes = data.comment.upvotes;
found.downvotes = data.comment.downvotes;
found.score = data.comment.score;
found.read = data.comment.read;
this.setState(this.state);
this.setState(this.state);
}
} else if (res.op == UserOperation.SaveComment) {
let data = res.data as CommentResponse;
let found = this.state.comments.find(c => c.id == data.comment.id);
found.saved = data.comment.saved;
this.setState(this.state);
if (found) {
found.saved = data.comment.saved;
this.setState(this.state);
}
} else if (res.op == UserOperation.CreateCommentLike) {
let data = res.data as CommentResponse;
let found: Comment = this.state.comments.find(
c => c.id === data.comment.id
);
found.score = data.comment.score;
found.upvotes = data.comment.upvotes;
found.downvotes = data.comment.downvotes;
if (data.comment.my_vote !== null) {
found.my_vote = data.comment.my_vote;
found.upvoteLoading = false;
found.downvoteLoading = false;
if (found) {
found.score = data.comment.score;
found.upvotes = data.comment.upvotes;
found.downvotes = data.comment.downvotes;
if (data.comment.my_vote !== null) {
found.my_vote = data.comment.my_vote;
found.upvoteLoading = false;
found.downvoteLoading = false;
}
}
this.setState(this.state);
} else if (res.op == UserOperation.CreatePostLike) {

@ -1034,20 +1034,24 @@ export class User extends Component<any, UserState> {
let data = res.data as CommentResponse;
let found = this.state.comments.find(c => c.id == data.comment.id);
found.content = data.comment.content;
found.updated = data.comment.updated;
found.removed = data.comment.removed;
found.deleted = data.comment.deleted;
found.upvotes = data.comment.upvotes;
found.downvotes = data.comment.downvotes;
found.score = data.comment.score;
this.setState(this.state);
if (found) {
found.content = data.comment.content;
found.updated = data.comment.updated;
found.removed = data.comment.removed;
found.deleted = data.comment.deleted;
found.upvotes = data.comment.upvotes;
found.downvotes = data.comment.downvotes;
found.score = data.comment.score;
this.setState(this.state);
}
} else if (res.op == UserOperation.CreateComment) {
// let res: CommentResponse = msg;
toast(i18n.t('reply_sent'));
// this.state.comments.unshift(res.comment); // TODO do this right
// this.setState(this.state);
let data = res.data as CommentResponse;
if (
UserService.Instance.user &&
data.comment.creator_id == UserService.Instance.user.id
) {
toast(i18n.t('reply_sent'));
}
} else if (res.op == UserOperation.SaveComment) {
let data = res.data as CommentResponse;
let found = this.state.comments.find(c => c.id == data.comment.id);

4
ui/src/index.tsx vendored

@ -41,7 +41,7 @@ class Index extends Component<any, any> {
<Switch>
<Route exact path={`/`} component={Main} />
<Route
path={`/home/type/:type/sort/:sort/page/:page`}
path={`/home/data_type/:data_type/listing_type/:listing_type/sort/:sort/page/:page`}
component={Main}
/>
<Route path={`/login`} component={Login} />
@ -56,7 +56,7 @@ class Index extends Component<any, any> {
<Route path={`/post/:id/comment/:comment_id`} component={Post} />
<Route path={`/post/:id`} component={Post} />
<Route
path={`/c/:name/sort/:sort/page/:page`}
path={`/c/:name/data_type/:data_type/sort/:sort/page/:page`}
component={Community}
/>
<Route path={`/community/:id`} component={Community} />

@ -42,6 +42,7 @@ export enum UserOperation {
EditPrivateMessage,
GetPrivateMessages,
UserJoin,
GetComments,
}
export enum CommentSortType {
@ -57,6 +58,11 @@ export enum ListingType {
Community,
}
export enum DataType {
Post,
Comment,
}
export enum SortType {
Hot,
New,
@ -165,6 +171,7 @@ export interface Post {
upvotes: number;
downvotes: number;
hot_rank: number;
newest_activity_time: string;
user_id?: number;
my_vote?: number;
subscribed?: boolean;
@ -187,6 +194,7 @@ export interface Comment {
published: string;
updated?: string;
community_id: number;
community_name: string;
banned: boolean;
banned_from_community: boolean;
creator_name: string;
@ -194,8 +202,10 @@ export interface Comment {
score: number;
upvotes: number;
downvotes: number;
hot_rank: number;
user_id?: number;
my_vote?: number;
subscribed?: number;
saved?: boolean;
user_mention_id?: number; // For mention type
recipient_id?: number;
@ -659,6 +669,19 @@ export interface GetPostsResponse {
posts: Array<Post>;
}
export interface GetCommentsForm {
type_: string;
sort: string;
page?: number;
limit: number;
community_id?: number;
auth?: string;
}
export interface GetCommentsResponse {
comments: Array<Comment>;
}
export interface CreatePostLikeForm {
post_id: number;
score: number;

@ -38,6 +38,7 @@ import {
PrivateMessageForm,
EditPrivateMessageForm,
GetPrivateMessagesForm,
GetCommentsForm,
UserJoinForm,
MessageType,
WebSocketJsonResponse,
@ -172,6 +173,11 @@ export class WebSocketService {
this.ws.send(this.wsSendWrapper(UserOperation.GetPosts, form));
}
public getComments(form: GetCommentsForm) {
this.setAuth(form, false);
this.ws.send(this.wsSendWrapper(UserOperation.GetComments, form));
}
public likePost(form: CreatePostLikeForm) {
this.setAuth(form);
this.ws.send(this.wsSendWrapper(UserOperation.CreatePostLike, form));

@ -150,6 +150,7 @@ export const de = {
sponsor_message:
'Lemmy ist freie <1>Open-Source</1> Software, also ohne Werbung, Monetarisierung oder Venturekapital, Punkt. Deine Spenden gehen direkt an die Vollzeit Entwicklung des Projekts. Vielen Dank an die folgenden Personen:',
support_on_patreon: 'Auf Patreon unterstützen',
support_on_liberapay: 'Auf Liberapay unterstützen',
general_sponsors:
'Allgemeine Sponsoren sind die, die zwischen $10 und $39 zu Lemmy beitragen.',
crypto: 'Kryptowährung',

@ -201,6 +201,7 @@ export const en = {
couldnt_like_comment: "Couldn't like comment.",
couldnt_update_comment: "Couldn't update comment.",
couldnt_save_comment: "Couldn't save comment.",
couldnt_get_comments: "Couldn't get comments.",
no_comment_edit_allowed: 'Not allowed to edit comment.',
no_post_edit_allowed: 'Not allowed to edit post.',
no_community_edit_allowed: 'Not allowed to edit community.',
@ -211,6 +212,7 @@ export const en = {
community_follower_already_exists: 'Community follower already exists.',
community_user_already_banned: 'Community user already banned.',
couldnt_create_post: "Couldn't create post.",
post_title_too_long: 'Post title too long.',
couldnt_like_post: "Couldn't like post.",
couldnt_find_post: "Couldn't find post.",
couldnt_get_posts: "Couldn't get posts",

@ -169,6 +169,7 @@ export const es = {
sponsor_message:
'Lemmy es software libre y de <1>código abierto</1>, lo que significa que no tendrá publicidades, monetización, ni capitales emprendedores, nunca. Tus donaciones apoyan directamente el desarrollo a tiempo completo del proyecto. Muchas gracias a las siguientes personas:',
support_on_patreon: 'Apoyo en Patreon',
support_on_liberapay: 'Apoyo en Liberapay',
donate_to_lemmy: 'Donar a Lemmy',
donate: 'Donar',
general_sponsors:

@ -139,6 +139,7 @@ export const fr = {
sponsor_message:
"Lemmy est gratuit et <1>open-source</1>, c'est à dire sans publicité et sans monétisation. Pour toujours. Vos dons soutiennent directement le développement du projet. Merci à nos soutiens.",
support_on_patreon: 'Soutenir sur Patreon',
support_on_liberapay: 'Soutenir sur Liberapay',
general_sponsors:
'General Sponsors are those that pledged $10 to $39 to Lemmy.',
crypto: 'Cryptomonnaies',

@ -138,6 +138,7 @@ export const it = {
sponsors_of_lemmy: 'Sponsors di Lemmy',
sponsor_message: 'Lemmy è un software gratuito e <1>open-source</1>, il che significa nessuna pubblicità, monetizzazione o investitori esterni, per sempre. Le tue donazioni supportano direttamente lo sviluppo full-time del progetto. Si ringraziano le seguenti persone:',
support_on_patreon: 'Supporta su Patreon',
support_on_liberapay: 'Supporta su Liberapay',
general_sponsors: 'I "General Sponsors" sono quelli che hanno investito dai 10$ ai 39$ su Lemmy.',
crypto: 'Crypto',
bitcoin: 'Bitcoin',

@ -125,6 +125,7 @@ export const nl = {
sponsor_message:
'Lemmy is vrije, <1>open-source</1> software, dus zonder reclame, winstoogmerk en durfkapitaal, punt. Jouw donaties gaan direct naar de full-time-ontwikkeling van het project. Met veel dank aan de volgende mensen:',
support_on_patreon: 'Ondersteun op Patreon',
support_on_liberapay: 'Ondersteun op Liberapay',
general_sponsors:
'Algemene sponsors zijn sponsors die tussen de $10 en $39 hebben gegeven aan Lemmy.',
crypto: 'Cryptovaluta',

@ -171,6 +171,7 @@ export const pt_BR = {
sponsor_message:
'Lemmy é um programa livre e de código aberto, o que significa que não haverá publicidade, monetização ou capital de risco, jamais. Suas doações apoiam de forma direta o desenvolvimento em tempo integral do projeto. Muitos agradecimentos às sequintes pessoas:',
support_on_patreon: 'Colabore no Patreon',
support_on_liberapay: 'Colabore no Liberapay',
donate_to_lemmy: 'Faça uma doação ao Lemmy',
donate: 'Doar',
general_sponsors:

@ -116,6 +116,7 @@ export const zh = {
sponsor_message:
'Lemmy is free, <1>open-source</1> software, meaning no advertising, monetizing, or venture capital, ever. Your donations directly support full-time development of the project. Thank you to the following people:',
support_on_patreon: '在 Patreon 赞助',
support_on_liberapay: '在 on 赞助',
general_sponsors:
'General Sponsors are those that pledged $10 to $39 to Lemmy.',
crypto: '加密',

34
ui/src/utils.ts vendored

@ -19,6 +19,7 @@ import {
User,
SortType,
ListingType,
DataType,
SearchType,
WebSocketResponse,
WebSocketJsonResponse,
@ -198,6 +199,10 @@ export function routeListingTypeToEnum(type: string): ListingType {
return ListingType[capitalizeFirstLetter(type)];
}
export function routeDataTypeToEnum(type: string): DataType {
return DataType[capitalizeFirstLetter(type)];
}
export function routeSearchTypeToEnum(type: string): SearchType {
return SearchType[capitalizeFirstLetter(type)];
}
@ -384,7 +389,7 @@ export function showAvatars(): boolean {
export function imageThumbnailer(url: string): string {
let split = url.split('pictshare');
if (split.length > 1) {
let out = `${split[0]}pictshare/140x140${split[1]}`;
let out = `${split[0]}pictshare/192x192${split[1]}`;
return out;
} else {
return url;
@ -519,3 +524,30 @@ function communitySearch(text: string, cb: any) {
cb([]);
}
}
export function getListingTypeFromProps(props: any): ListingType {
return props.match.params.listing_type
? routeListingTypeToEnum(props.match.params.listing_type)
: UserService.Instance.user
? UserService.Instance.user.default_listing_type
: ListingType.All;
}
// TODO might need to add a user setting for this too
export function getDataTypeFromProps(props: any): DataType {
return props.match.params.data_type
? routeDataTypeToEnum(props.match.params.data_type)
: DataType.Post;
}
export function getSortTypeFromProps(props: any): SortType {
return props.match.params.sort
? routeSortTypeToEnum(props.match.params.sort)
: UserService.Instance.user
? UserService.Instance.user.default_sort_type
: SortType.Hot;
}
export function getPageFromProps(props: any): number {
return props.match.params.page ? Number(props.match.params.page) : 1;
}

2
ui/src/version.ts vendored

@ -1 +1 @@
export const version: string = 'v0.6.11';
export const version: string = 'v0.6.16';

Loading…
Cancel
Save