Merge branch 'master' into federation

This commit is contained in:
Felix 2020-02-29 00:47:37 +01:00
commit f9443dfbd3
80 changed files with 3249 additions and 692 deletions

1
.github/FUNDING.yml vendored
View file

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

162
README.md vendored
View file

@ -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"> <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) ![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) [![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) [![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/) [![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) [![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> </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. <h3 align="center"><a href="https://dev.lemmy.ml">Lemmy</a></h3>
<br> <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> </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.* ## About The Project
This is a **very early beta version**, and a lot of features are currently broken or in active development, such as federation.
Front Page|Post 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. 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) ### Why's it called Lemmy?
- [Releases / Changelog](/RELEASES.md)
- [Contributing](https://dev.lemmy.ml/docs/contributing.html)
## 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) ### Built With
- [Gitea](https://yerbamate.dev/dessalines/lemmy)
- [GitLab](https://gitlab.com/dessalines/lemmy) - [Rust](https://www.rust-lang.org)
- [Actix](https://actix.rs/)
- [Diesel](http://diesel.rs/)
- [Inferno](https://infernojs.org)
- [Typescript](https://www.typescriptlang.org/)
## Features ## Features
@ -84,72 +90,25 @@ Made with [Rust](https://www.rust-lang.org), [Actix](https://actix.rs/), [Infern
- Can transfer site and communities to others. - Can transfer site and communities to others.
- Can fully erase your data, replacing all posts and comments. - Can fully erase your data, replacing all posts and comments.
- NSFW post / community support. - NSFW post / community support.
- OEmbed support via Iframely.
- High performance. - High performance.
- Server is written in rust. - Server is written in rust.
- Front end is `~80kB` gzipped. - Front end is `~80kB` gzipped.
- Supports arm64 / Raspberry Pi. - Supports arm64 / Raspberry Pi.
## Why's it called Lemmy? ## Installation
- Lead singer from [Motörhead](https://invidio.us/watch?v=pWB5JZRGl0U). - [Docker](https://dev.lemmy.ml/docs/administration_install_docker.html)
- The old school [video game](<https://en.wikipedia.org/wiki/Lemmings_(video_game)>). - [Ansible](https://dev.lemmy.ml/docs/administration_install_ansible.html)
- The [Koopa from Super Mario](https://www.mariowiki.com/Lemmy_Koopa). - [Kubernetes](https://dev.lemmy.ml/docs/administration_install_kubernetes.html)
- 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
```
## Support / Donate ## 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. Lemmy is free, open-source software, meaning no advertising, monetizing, or venture capital, ever. Your donations directly support full-time development of the project.
- [Support on Liberapay.](https://liberapay.com/Lemmy)
- [Support on Patreon](https://www.patreon.com/dessalines). - [Support on Patreon](https://www.patreon.com/dessalines).
- [List of Sponsors](https://dev.lemmy.ml/sponsors). - [List of Sponsors](https://dev.lemmy.ml/sponsors).
- Soon to add either liberapay or opencollective.
### Crypto ### Crypto
@ -157,28 +116,35 @@ Lemmy is free, open-source software, meaning no advertising, monetizing, or vent
- ethereum: `0x400c96c96acbC6E7B3B43B1dc1BB446540a88A01` - ethereum: `0x400c96c96acbC6E7B3B43B1dc1BB446540a88A01`
- monero: `41taVyY6e1xApqKyMVDRVxJ76sPkfZhALLTjRvVKpaAh2pBd4wv9RgYj1tSPrx8wc6iE1uWUfjtQdTmTy2FGMeChGVKPQuV` - 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). If you'd like to add translations, take a look at the [English translation file](ui/src/translations/en.ts).
- Languages supported: Catalan, (`ca`), Farsi (`fa`), English (`en`), Chinese (`zh`), Dutch (`nl`), Esperanto (`eo`), Finnish (`fi`), French (`fr`), Spanish (`es`), Swedish (`sv`), German (`de`), Russian (`ru`), Italian (`it`). - Languages supported: Brazilian Portuguese (`pt-br`), Catalan, (`ca`), Farsi (`fa`), English (`en`), Chinese (`zh`), Dutch (`nl`), Esperanto (`eo`), Finnish (`fi`), French (`fr`), Spanish (`es`), Swedish (`sv`), German (`de`), Russian (`ru`), Italian (`it`).
<!-- translations --> <!-- translations -->
lang | done | missing lang | done | missing
---- | ---- | ------- ---- | ---- | -------
ca | 98% | cross_posted_to,old,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,email_already_exists,couldnt_create_private_message,no_private_message_edit_allowed,couldnt_update_private_message,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 | 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,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 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 | 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,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 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 | 100% | cross_posted_to es | 99% | cross_posted_to,couldnt_get_comments,post_title_too_long
fi | 98% | cross_posted_to,old,time,action fi | 97% | cross_posted_to,old,support_on_liberapay,couldnt_get_comments,post_title_too_long,time,action
fr | 82% | 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,email_already_exists,couldnt_create_private_message,no_private_message_edit_allowed,couldnt_update_private_message,time,action fr | 100% |
it | 83% | 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,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 | 99% | cross_posted_to,time,action nl | 98% | cross_posted_to,couldnt_get_comments,post_title_too_long,time,action
ru | 71% | 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,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 pt-br | 99% | couldnt_get_comments,post_title_too_long
sv | 82% | 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,email_already_exists,couldnt_create_private_message,no_private_message_edit_allowed,couldnt_update_private_message,time,action 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
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,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 --> <!-- translationsstop -->
If you'd like to update this report, run: If you'd like to update this report, run:
@ -188,6 +154,14 @@ cd ui
ts-node translation_report.ts 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 ## Credits
Logo made by Andy Cuccaro (@andycuccaro) under the CC-BY-SA 4.0 license. Logo made by Andy Cuccaro (@andycuccaro) under the CC-BY-SA 4.0 license.

2
ansible/VERSION vendored
View file

@ -1 +1 @@
v0.6.11 v0.6.25

3
ansible/lemmy.yml vendored
View file

@ -35,6 +35,7 @@
with_items: with_items:
- { src: 'templates/docker-compose.yml', dest: '/lemmy/docker-compose.yml', mode: '0600' } - { src: 'templates/docker-compose.yml', dest: '/lemmy/docker-compose.yml', mode: '0600' }
- { src: 'templates/nginx.conf', dest: '/etc/nginx/sites-enabled/lemmy.conf', mode: '0644' } - { src: 'templates/nginx.conf', dest: '/etc/nginx/sites-enabled/lemmy.conf', mode: '0644' }
- { src: '../docker/iframely.config.local.js', dest: '/lemmy/iframely.config.local.js', mode: '0600' }
- name: add config file (only during initial setup) - name: add config file (only during initial setup)
template: src='templates/config.hjson' dest='/lemmy/lemmy.hjson' mode='0600' force='no' owner='1000' group='1000' template: src='templates/config.hjson' dest='/lemmy/lemmy.hjson' mode='0600' force='no' owner='1000' group='1000'
@ -63,4 +64,4 @@
special_time=daily special_time=daily
name=certbot-renew-lemmy name=certbot-renew-lemmy
user=root user=root
job="certbot certonly --nginx -d '{{ domain }}' --deploy-hook 'docker-compose -f /peertube/docker-compose.yml exec nginx nginx -s reload'" job="certbot certonly --nginx -d '{{ domain }}' --deploy-hook 'nginx -s reload'"

View file

@ -37,6 +37,7 @@
with_items: with_items:
- { src: 'templates/docker-compose.yml', dest: '/lemmy/docker-compose.yml', mode: '0600' } - { src: 'templates/docker-compose.yml', dest: '/lemmy/docker-compose.yml', mode: '0600' }
- { src: 'templates/nginx.conf', dest: '/etc/nginx/sites-enabled/lemmy.conf', mode: '0644' } - { src: 'templates/nginx.conf', dest: '/etc/nginx/sites-enabled/lemmy.conf', mode: '0644' }
- { src: '../docker/iframely.config.local.js', dest: '/lemmy/iframely.config.local.js', mode: '0600' }
- name: add config file (only during initial setup) - name: add config file (only during initial setup)
template: src='templates/config.hjson' dest='/lemmy/lemmy.hjson' mode='0600' force='no' owner='1000' group='1000' template: src='templates/config.hjson' dest='/lemmy/lemmy.hjson' mode='0600' force='no' owner='1000' group='1000'
@ -97,4 +98,4 @@
special_time=daily special_time=daily
name=certbot-renew-lemmy name=certbot-renew-lemmy
user=root user=root
job="certbot certonly --nginx -d '{{ domain }}' --deploy-hook 'docker-compose -f /peertube/docker-compose.yml exec nginx nginx -s reload'" job="certbot certonly --nginx -d '{{ domain }}' --deploy-hook 'nginx -s reload'"

View file

@ -30,6 +30,14 @@ services:
- lemmy_pictshare:/usr/share/nginx/html/data - lemmy_pictshare:/usr/share/nginx/html/data
restart: always restart: always
lemmy_iframely:
image: dogbin/iframely:latest
ports:
- "127.0.0.1:8061:8061"
volumes:
- ./iframely.config.local.js:/iframely/config.local.js:ro
restart: always
postfix: postfix:
image: mwader/postfix-relay image: mwader/postfix-relay
environment: environment:
@ -38,3 +46,4 @@ services:
volumes: volumes:
lemmy_db: lemmy_db:
lemmy_pictshare: lemmy_pictshare:
lemmy_iframely:

View file

@ -80,6 +80,13 @@ server {
add_header Cache-Control "public, max-age=31536000, immutable"; add_header Cache-Control "public, max-age=31536000, immutable";
} }
} }
location /iframely/ {
proxy_pass http://0.0.0.0:8061/;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
} }
# Anonymize IP addresses # Anonymize IP addresses

View file

@ -28,6 +28,14 @@ services:
volumes: volumes:
- lemmy_pictshare:/usr/share/nginx/html/data - lemmy_pictshare:/usr/share/nginx/html/data
restart: always restart: always
lemmy_iframely:
image: dogbin/iframely:latest
ports:
- "127.0.0.1:8061:8061"
volumes:
- ../iframely.config.local.js:/iframely/config.local.js:ro
restart: always
volumes: volumes:
lemmy_db: lemmy_db:
lemmy_pictshare: lemmy_pictshare:
lemmy_iframely:

283
docker/iframely.config.local.js vendored Normal file
View file

@ -0,0 +1,283 @@
(function() {
var config = {
// Specify a path for custom plugins. Custom plugins will override core plugins.
// CUSTOM_PLUGINS_PATH: __dirname + '/yourcustom-plugin-folder',
DEBUG: false,
RICH_LOG_ENABLED: false,
// For embeds that require render, baseAppUrl will be used as the host.
baseAppUrl: "http://yourdomain.com",
relativeStaticUrl: "/r",
// Or just skip built-in renders altogether
SKIP_IFRAMELY_RENDERS: true,
// For legacy reasons the response format of Iframely open-source is
// different by default as it does not group the links array by rel.
// In order to get the same grouped response as in Cloud API,
// add `&group=true` to your request to change response per request
// or set `GROUP_LINKS` in your config to `true` for a global change.
GROUP_LINKS: true,
// Number of maximum redirects to follow before aborting the page
// request with `redirect loop` error.
MAX_REDIRECTS: 4,
SKIP_OEMBED_RE_LIST: [
// /^https?:\/\/yourdomain\.com\//,
],
/*
// Used to pass parameters to the generate functions when creating HTML elements
// disableSizeWrapper: Don't wrap element (iframe, video, etc) in a positioned div
GENERATE_LINK_PARAMS: {
disableSizeWrapper: true
},
*/
port: 8061, //can be overridden by PORT env var
host: '0.0.0.0', // Dockers beware. See https://github.com/itteco/iframely/issues/132#issuecomment-242991246
//can be overridden by HOST env var
// Optional SSL cert, if you serve under HTTPS.
/*
ssl: {
key: require('fs').readFileSync(__dirname + '/key.pem'),
cert: require('fs').readFileSync(__dirname + '/cert.pem'),
port: 443
},
*/
/*
Supported cache engines:
- no-cache - no caching will be used.
- node-cache - good for debug, node memory will be used (https://github.com/tcs-de/nodecache).
- redis - https://github.com/mranney/node_redis.
- memcached - https://github.com/3rd-Eden/node-memcached
*/
CACHE_ENGINE: 'node-cache',
CACHE_TTL: 0, // In seconds.
// 0 = 'never expire' for memcached & node-cache to let cache engine decide itself when to evict the record
// 0 = 'no cache' for redis. Use high enough (e.g. 365*24*60*60*1000) ttl for similar 'never expire' approach instead
/*
// Redis cache options.
REDIS_OPTIONS: {
host: '127.0.0.1',
port: 6379
},
*/
/*
// Memcached options. See https://github.com/3rd-Eden/node-memcached#server-locations
MEMCACHED_OPTIONS: {
locations: "127.0.0.1:11211"
}
*/
/*
// Access-Control-Allow-Origin list.
allowedOrigins: [
"*",
"http://another_domain.com"
],
*/
/*
// Uncomment to enable plugin testing framework.
tests: {
mongodb: 'mongodb://localhost:27017/iframely-tests',
single_test_timeout: 10 * 1000,
plugin_test_period: 2 * 60 * 60 * 1000,
relaunch_script_period: 5 * 60 * 1000
},
*/
// If there's no response from remote server, the timeout will occur after
RESPONSE_TIMEOUT: 5 * 1000, //ms
/* From v1.4.0, Iframely supports HTTP/2 by default. Disable it, if you'd rather not.
Alternatively, you can also disable per origin. See `proxy` option below.
*/
// DISABLE_HTTP2: true,
// Customize API calls to oembed endpoints.
ADD_OEMBED_PARAMS: [{
// Endpoint url regexp array.
re: [/^http:\/\/api\.instagram\.com\/oembed/],
// Custom get params object.
params: {
hidecaption: true
}
}, {
re: [/^https:\/\/www\.facebook\.com\/plugins\/page\/oembed\.json/i],
params: {
show_posts: 0,
show_facepile: 0,
maxwidth: 600
}
}, {
// match i=user or i=moment or i=timeline to configure these types invidually
// see params spec at https://dev.twitter.com/web/embedded-timelines/oembed
re: [/^https?:\/\/publish\.twitter\.com\/oembed\?i=user/i],
params: {
limit: 1,
maxwidth: 600
}
/*
}, {
// Facebook https://developers.facebook.com/docs/plugins/oembed-endpoints
re: [/^https:\/\/www\.facebook\.com\/plugins\/\w+\/oembed\.json/i],
params: {
// Skip script tag and fb-root div.
omitscript: true
}
*/
}],
/*
// Configure use of HTTP proxies as needed.
// You don't have to specify all options per regex - just what you need to override
PROXY: [{
re: [/^https?:\/\/www\.domain\.com/],
proxy_server: 'http://1.2.3.4:8080',
user_agent: 'CHANGE YOUR AGENT',
headers: {
// HTTP headers
// Overrides previous params if overlapped.
},
request_options: {
// Refer to: https://github.com/request/request
// Overrides previous params if overlapped.
},
disable_http2: true
}],
*/
// Customize API calls to 3rd parties. At the very least - configure required keys.
providerOptions: {
locale: "en_US", // ISO 639-1 two-letter language code, e.g. en_CA or fr_CH.
// Will be added as highest priotity in accept-language header with each request.
// Plus is used in FB, YouTube and perhaps other plugins
"twitter": {
"max-width": 550,
"min-width": 250,
hide_media: false,
hide_thread: false,
omit_script: false,
center: false,
// dnt: true,
cache_ttl: 100 * 365 * 24 * 3600 // 100 Years.
},
readability: {
enabled: false
// allowPTagDescription: true // to enable description fallback to first paragraph
},
images: {
loadSize: false, // if true, will try an load first bytes of all images to get/confirm the sizes
checkFavicon: false // if true, will verify all favicons
},
tumblr: {
consumer_key: "INSERT YOUR VALUE"
// media_only: true // disables status embeds for images and videos - will return plain media
},
google: {
// https://developers.google.com/maps/documentation/embed/guide#api_key
maps_key: "INSERT YOUR VALUE"
},
/*
// Optional Camo Proxy to wrap all images: https://github.com/atmos/camo
camoProxy: {
camo_proxy_key: "INSERT YOUR VALUE",
camo_proxy_host: "INSERT YOUR VALUE"
// ssl_only: true // will only proxy non-ssl images
},
*/
// List of query parameters to add to YouTube and Vimeo frames
// Start it with leading "?". Or omit alltogether for default values
// API key is optional, youtube will work without it too.
// It is probably the same API key you use for Google Maps.
youtube: {
// api_key: "INSERT YOUR VALUE",
get_params: "?rel=0&showinfo=1" // https://developers.google.com/youtube/player_parameters
},
vimeo: {
get_params: "?byline=0&badge=0" // https://developer.vimeo.com/player/embedding
},
/*
soundcloud: {
old_player: true // enables classic player
},
giphy: {
media_only: true // disables branded player for gifs and returns just the image
}
*/
/*
bandcamp: {
get_params: '/size=large/bgcol=333333/linkcol=ffffff/artwork=small/transparent=true/',
media: {
album: {
height: 472,
'max-width': 700
},
track: {
height: 120,
'max-width': 700
}
}
}
*/
},
// WHITELIST_WILDCARD, if present, will be added to whitelist as record for top level domain: "*"
// with it, you can define what parsers do when they run accross unknown publisher.
// If absent or empty, all generic media parsers will be disabled except for known domains
// More about format: https://iframely.com/docs/qa-format
/*
WHITELIST_WILDCARD: {
"twitter": {
"player": "allow",
"photo": "deny"
},
"oembed": {
"video": "allow",
"photo": "allow",
"rich": "deny",
"link": "deny"
},
"og": {
"video": ["allow", "ssl", "responsive"]
},
"iframely": {
"survey": "allow",
"reader": "allow",
"player": "allow",
"image": "allow"
},
"html-meta": {
"video": ["allow", "responsive"],
"promo": "allow"
}
}
*/
// Black-list any of the inappropriate domains. Iframely will return 417
// At minimum, keep your localhosts blacklisted to avoid SSRF
BLACKLIST_DOMAINS_RE: [
/^https?:\/\/127\.0\.0\.1/i,
/^https?:\/\/localhost/i,
// And this is AWS metadata service
// https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-instance-metadata.html
/^https?:\/\/169\.254\.169\.254/
]
};
module.exports = config;
})();

2
docker/lemmy.hjson vendored
View file

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

View file

@ -11,7 +11,7 @@ services:
- lemmy_db:/var/lib/postgresql/data - lemmy_db:/var/lib/postgresql/data
restart: always restart: always
lemmy: lemmy:
image: dessalines/lemmy:v0.6.11 image: dessalines/lemmy:v0.6.25
ports: ports:
- "127.0.0.1:8536:8536" - "127.0.0.1:8536:8536"
restart: always restart: always
@ -26,6 +26,14 @@ services:
volumes: volumes:
- lemmy_pictshare:/usr/share/nginx/html/data - lemmy_pictshare:/usr/share/nginx/html/data
restart: always restart: always
lemmy_iframely:
image: dogbin/iframely:latest
ports:
- "127.0.0.1:8061:8061"
volumes:
- ./iframely.config.local.js:/iframely/config.local.js:ro
restart: always
volumes: volumes:
lemmy_db: lemmy_db:
lemmy_pictshare: lemmy_pictshare:
lemmy_iframely:

1
docs/src/SUMMARY.md vendored
View file

@ -4,6 +4,7 @@
- [Features](about_features.md) - [Features](about_features.md)
- [Goals](about_goals.md) - [Goals](about_goals.md)
- [Post and Comment Ranking](about_ranking.md) - [Post and Comment Ranking](about_ranking.md)
- [Guide](about_guide.md)
- [Administration](administration.md) - [Administration](administration.md)
- [Install with Docker](administration_install_docker.md) - [Install with Docker](administration_install_docker.md)
- [Install with Ansible](administration_install_ansible.md) - [Install with Ansible](administration_install_ansible.md)

16
docs/src/about.md vendored
View file

@ -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). [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. 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? ### Why's it called Lemmy?
- Lead singer from [Motörhead](https://invidio.us/watch?v=pWB5JZRGl0U). - 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 [Koopa from Super Mario](https://www.mariowiki.com/Lemmy_Koopa).
- The [furry rodents](http://sunchild.fpwc.org/lemming-the-little-giant-of-the-north/). - 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/)

View file

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

28
docs/src/about_guide.md vendored Normal file
View file

@ -0,0 +1,28 @@
# Lemmy Guide
Start typing...
- `@a_user_name` to get a list of usernames.
- `#a_community` to get a list of communities.
- `:emoji` to get a list of emojis.
## Markdown Guide
Type | Or | … to Get
--- | --- | ---
\*Italic\* | \_Italic\_ | _Italic_
\*\*Bold\*\* | \_\_Bold\_\_ | **Bold**
\# Heading 1 | Heading 1 <br> ========= | <h4>Heading 1</h4>
\## Heading 2 | Heading 2 <br>--------- | <h5>Heading 2</h5>
\[Link\](http://a.com) | \[Link\]\[1\]<br><br>\[1\]: http://b.org | [Link](https://commonmark.org/)
!\[Image\](http://url/a.png) | !\[Image\]\[1\]<br><br>\[1\]: http://url/b.jpg | ![Markdown](https://commonmark.org/help/images/favicon.png)
\> Blockquote | | <blockquote>Blockquote</blockquote>
\* List <br>\* List <br>\* List | \- List <br>\- List <br>\- List <br> | * List <br>* List <br>* List <br>
1\. One <br>2\. Two <br>3\. Three | 1) One<br>2) Two<br>3) Three | 1. One<br>2. Two<br>3. Three
Horizontal Rule <br>\--- | Horizontal Rule<br>\*\*\* | Horizontal Rule <br><hr>
\`Inline code\` with backticks | |`Inline code` with backticks
\`\`\`<br>\# code block <br>print '3 backticks or'<br>print 'indent 4 spaces' <br>\`\`\` | ····\# code block<br>····print '3 backticks or'<br>····print 'indent 4 spaces' | \# code block <br>print '3 backticks or'<br>print 'indent 4 spaces'
::: spoiler hidden or nsfw stuff<br>*a bunch of spoilers here*<br>::: | | <details><summary> hidden or nsfw stuff </summary><p><em>a bunch of spoilers here</em></p></details>
[CommonMark Tutorial](https://commonmark.org/help/tutorial/)

View file

@ -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.

View file

@ -1,6 +1,15 @@
# 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. 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 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
`LEMMY__DATABASE__POOL_SIZE=10`. `LEMMY__DATABASE__POOL_SIZE=10`.
An additional option `LEMMY_DATABASE_URL` is available, which can be used with a PostgreSQL connection string like `postgres://lemmy:password@lemmy_db:5432/lemmy`, passing all connection details at once. An additional option `LEMMY_DATABASE_URL` is available, which can be used with a PostgreSQL connection string like `postgres://lemmy:password@lemmy_db:5432/lemmy`, passing all connection details at once.
If the Docker container is not used, manually create the database specified above by running the following commands:
```bash
cd server
./db-init.sh
```

View file

@ -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. 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: Then run the following commands on your local computer:

View file

@ -1,3 +1,5 @@
# Docker Installation
Make sure you have both docker and docker-compose(>=`1.24.0`) installed: Make sure you have both docker and docker-compose(>=`1.24.0`) installed:
```bash ```bash
@ -5,20 +7,21 @@ mkdir lemmy/
cd 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/prod/docker-compose.yml
wget https://raw.githubusercontent.com/dessalines/lemmy/master/docker/lemmy.hjson wget https://raw.githubusercontent.com/dessalines/lemmy/master/docker/lemmy.hjson
# Edit lemmy.hjson to do more configuration wget https://raw.githubusercontent.com/dessalines/lemmy/master/docker/iframely.config.local.js
# Edit lemmy.hjson, and docker-compose.yml to do more configuration (like adding a custom password)
docker-compose up -d docker-compose up -d
``` ```
and go to http://localhost:8536. 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 ```bash
wget https://raw.githubusercontent.com/dessalines/lemmy/master/ansible/templates/nginx.conf wget https://raw.githubusercontent.com/dessalines/lemmy/master/ansible/templates/nginx.conf
# Replace the {{ vars }} # Replace the {{ vars }}
sudo mv nginx.conf /etc/nginx/sites-enabled/lemmy.conf sudo mv nginx.conf /etc/nginx/sites-enabled/lemmy.conf
``` ```
#### Updating ## Updating
To update to the newest version, run: To update to the newest version, run:

View file

@ -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/). 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. 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/). To try it locally, you can use [MicroK8s](https://microk8s.io/) or [Minikube](https://kubernetes.io/docs/tasks/tools/install-minikube/).

View file

@ -1,4 +1,6 @@
Run: # Docker Development
## Running
```bash ```bash
git clone https://github.com/dessalines/lemmy git clone https://github.com/dessalines/lemmy
@ -8,4 +10,4 @@ cd lemmy/docker/dev
and go to http://localhost:8536. 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).

View file

@ -7,9 +7,16 @@
#### Set up Postgres DB #### Set up Postgres DB
```bash ```bash
psql -c "create user lemmy with password 'password' superuser;" -U postgres cd server
psql -c 'create database lemmy with owner lemmy;' -U postgres ./db-init.sh
export LEMMY_DATABASE_URL=postgres://lemmy:password@localhost:5432/lemmy ```
Or run the commands manually:
```bash
psql -c "create user lemmy with password 'password' superuser;" -U postgres
psql -c 'create database lemmy with owner lemmy;' -U postgres
export LEMMY_DATABASE_URL=postgres://lemmy:password@localhost:5432/lemmy
``` ```
#### Running #### Running

View file

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

30
install.sh vendored
View file

@ -1,13 +1,41 @@
#!/bin/sh #!/bin/bash
set -e set -e
# Set the database variable to the default first.
# Don't forget to change this string to your actual database parameters
# if you don't plan to initialize the database in this script.
export LEMMY_DATABASE_URL=postgres://lemmy:password@localhost:5432/lemmy export LEMMY_DATABASE_URL=postgres://lemmy:password@localhost:5432/lemmy
# Set other environment variables
export JWT_SECRET=changeme export JWT_SECRET=changeme
export HOSTNAME=rrr export HOSTNAME=rrr
# Optionally initialize the database
init_db_valid=0
init_db_final=0
while [ "$init_db_valid" == 0 ]
do
read -p "Initialize database (y/n)? " init_db
case "${init_db,,}" in
y|yes ) init_db_valid=1; init_db_final=1;;
n|no ) init_db_valid=1; init_db_final=0;;
* ) echo "Invalid input" 1>&2;;
esac
echo
done
if [ "$init_db_final" = 1 ]
then
source ./server/db-init.sh
read -n 1 -s -r -p "Press ANY KEY to continue execution of this script, press CTRL+C to quit..."
echo
fi
# Build the web client
cd ui cd ui
yarn yarn
yarn build yarn build
# Build and run the backend
cd ../server cd ../server
cargo run cargo run

View file

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

43
server/db-init.sh vendored Executable file
View file

@ -0,0 +1,43 @@
#!/bin/bash
username=lemmy
dbname=lemmy
port=5432
password=""
password_confirm=""
password_valid=0
while [ "$password_valid" == 0 ]
do
read -p "Enter database password: " -s password
echo
read -p "Verify database password: " -s password_confirm
echo
echo
# Start the loop from the top if either check fails
if [ -z "$password" ]
then
echo "Error: Password cannot be empty." 1>&2
echo
continue
fi
if [ "$password" != "$password_confirm" ]
then
echo "Error: Passwords don't match." 1>&2
echo
continue
fi
# Set the password_valid variable to break out of the loop
password_valid=1
done
psql -c "CREATE USER $username WITH PASSWORD '$password' SUPERUSER;" -U postgres
psql -c 'CREATE DATABASE $dbname WITH OWNER $username;' -U postgres
export LEMMY_DATABASE_URL=postgres://$username:$password@localhost:$port/$dbname
echo $LEMMY_DATABASE_URL

View file

@ -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;

View file

@ -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;

View file

@ -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
;

View file

@ -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
;

View file

@ -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
;

View file

@ -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
;

View file

@ -2,6 +2,7 @@ use super::*;
use crate::send_email; use crate::send_email;
use crate::settings::Settings; use crate::settings::Settings;
use diesel::PgConnection; use diesel::PgConnection;
use std::str::FromStr;
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
pub struct CreateComment { pub struct CreateComment {
@ -47,6 +48,21 @@ pub struct CreateCommentLike {
auth: String, 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> { impl Perform<CommentResponse> for Oper<CreateComment> {
fn perform(&self, conn: &PgConnection) -> Result<CommentResponse, Error> { fn perform(&self, conn: &PgConnection) -> Result<CommentResponse, Error> {
let data: &CreateComment = &self.data; 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 })
}
}

View file

@ -126,7 +126,15 @@ impl Perform<PostResponse> for Oper<CreatePost> {
let inserted_post = match Post::create(&conn, &post_form) { let inserted_post = match Post::create(&conn, &post_form) {
Ok(post) => post, 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 // 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) { let _updated_post = match Post::update(&conn, data.edit_id, &post_form) {
Ok(post) => post, 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 // Mod tables

View file

@ -15,6 +15,7 @@ table! {
updated -> Nullable<Timestamp>, updated -> Nullable<Timestamp>,
deleted -> Bool, deleted -> Bool,
community_id -> Int4, community_id -> Int4,
community_name -> Varchar,
banned -> Bool, banned -> Bool,
banned_from_community -> Bool, banned_from_community -> Bool,
creator_name -> Varchar, creator_name -> Varchar,
@ -22,8 +23,10 @@ table! {
score -> BigInt, score -> BigInt,
upvotes -> BigInt, upvotes -> BigInt,
downvotes -> BigInt, downvotes -> BigInt,
hot_rank -> Int4,
user_id -> Nullable<Int4>, user_id -> Nullable<Int4>,
my_vote -> Nullable<Int4>, my_vote -> Nullable<Int4>,
subscribed -> Nullable<Bool>,
saved -> Nullable<Bool>, saved -> Nullable<Bool>,
} }
} }
@ -41,6 +44,7 @@ table! {
updated -> Nullable<Timestamp>, updated -> Nullable<Timestamp>,
deleted -> Bool, deleted -> Bool,
community_id -> Int4, community_id -> Int4,
community_name -> Varchar,
banned -> Bool, banned -> Bool,
banned_from_community -> Bool, banned_from_community -> Bool,
creator_name -> Varchar, creator_name -> Varchar,
@ -48,8 +52,10 @@ table! {
score -> BigInt, score -> BigInt,
upvotes -> BigInt, upvotes -> BigInt,
downvotes -> BigInt, downvotes -> BigInt,
hot_rank -> Int4,
user_id -> Nullable<Int4>, user_id -> Nullable<Int4>,
my_vote -> Nullable<Int4>, my_vote -> Nullable<Int4>,
subscribed -> Nullable<Bool>,
saved -> Nullable<Bool>, saved -> Nullable<Bool>,
} }
} }
@ -70,6 +76,7 @@ pub struct CommentView {
pub updated: Option<chrono::NaiveDateTime>, pub updated: Option<chrono::NaiveDateTime>,
pub deleted: bool, pub deleted: bool,
pub community_id: i32, pub community_id: i32,
pub community_name: String,
pub banned: bool, pub banned: bool,
pub banned_from_community: bool, pub banned_from_community: bool,
pub creator_name: String, pub creator_name: String,
@ -77,15 +84,19 @@ pub struct CommentView {
pub score: i64, pub score: i64,
pub upvotes: i64, pub upvotes: i64,
pub downvotes: i64, pub downvotes: i64,
pub hot_rank: i32,
pub user_id: Option<i32>, pub user_id: Option<i32>,
pub my_vote: Option<i32>, pub my_vote: Option<i32>,
pub subscribed: Option<bool>,
pub saved: Option<bool>, pub saved: Option<bool>,
} }
pub struct CommentQueryBuilder<'a> { pub struct CommentQueryBuilder<'a> {
conn: &'a PgConnection, conn: &'a PgConnection,
query: super::comment_view::comment_mview::BoxedQuery<'a, Pg>, query: super::comment_view::comment_mview::BoxedQuery<'a, Pg>,
listing_type: ListingType,
sort: &'a SortType, sort: &'a SortType,
for_community_id: Option<i32>,
for_post_id: Option<i32>, for_post_id: Option<i32>,
for_creator_id: Option<i32>, for_creator_id: Option<i32>,
search_term: Option<String>, search_term: Option<String>,
@ -104,7 +115,9 @@ impl<'a> CommentQueryBuilder<'a> {
CommentQueryBuilder { CommentQueryBuilder {
conn, conn,
query, query,
listing_type: ListingType::All,
sort: &SortType::New, sort: &SortType::New,
for_community_id: None,
for_post_id: None, for_post_id: None,
for_creator_id: None, for_creator_id: None,
search_term: 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 { pub fn sort(mut self, sort: &'a SortType) -> Self {
self.sort = sort; self.sort = sort;
self self
@ -130,6 +148,11 @@ impl<'a> CommentQueryBuilder<'a> {
self 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 { pub fn search_term<T: MaybeOptional<String>>(mut self, search_term: T) -> Self {
self.search_term = search_term.get_optional(); self.search_term = search_term.get_optional();
self self
@ -171,6 +194,10 @@ impl<'a> CommentQueryBuilder<'a> {
query = query.filter(creator_id.eq(for_creator_id)); 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 { if let Some(for_post_id) = self.for_post_id {
query = query.filter(post_id.eq(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))); 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 { if self.saved_only {
query = query.filter(saved.eq(true)); query = query.filter(saved.eq(true));
} }
query = match self.sort { 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::New => query.order_by(published.desc()),
SortType::TopAll => query.order_by(score.desc()), SortType::TopAll => query.order_by(score.desc()),
SortType::TopYear => query SortType::TopYear => query
@ -199,7 +232,7 @@ impl<'a> CommentQueryBuilder<'a> {
SortType::TopDay => query SortType::TopDay => query
.filter(published.gt(now - 1.days())) .filter(published.gt(now - 1.days()))
.order_by(score.desc()), .order_by(score.desc()),
_ => query.order_by(published.desc()), // _ => query.order_by(published.desc()),
}; };
let (limit, offset) = limit_and_offset(self.page, self.limit); let (limit, offset) = limit_and_offset(self.page, self.limit);
@ -218,9 +251,8 @@ impl CommentView {
from_comment_id: i32, from_comment_id: i32,
my_user_id: Option<i32>, my_user_id: Option<i32>,
) -> Result<Self, Error> { ) -> Result<Self, Error> {
use super::comment_view::comment_view::dsl::*; use super::comment_view::comment_mview::dsl::*;
let mut query = comment_mview.into_boxed();
let mut query = comment_view.into_boxed();
// The view lets you pass a null user_id, if you're not logged in // The view lets you pass a null user_id, if you're not logged in
if let Some(my_user_id) = my_user_id { if let Some(my_user_id) = my_user_id {
@ -251,6 +283,7 @@ table! {
updated -> Nullable<Timestamp>, updated -> Nullable<Timestamp>,
deleted -> Bool, deleted -> Bool,
community_id -> Int4, community_id -> Int4,
community_name -> Varchar,
banned -> Bool, banned -> Bool,
banned_from_community -> Bool, banned_from_community -> Bool,
creator_name -> Varchar, creator_name -> Varchar,
@ -258,8 +291,10 @@ table! {
score -> BigInt, score -> BigInt,
upvotes -> BigInt, upvotes -> BigInt,
downvotes -> BigInt, downvotes -> BigInt,
hot_rank -> Int4,
user_id -> Nullable<Int4>, user_id -> Nullable<Int4>,
my_vote -> Nullable<Int4>, my_vote -> Nullable<Int4>,
subscribed -> Nullable<Bool>,
saved -> Nullable<Bool>, saved -> Nullable<Bool>,
recipient_id -> Int4, recipient_id -> Int4,
} }
@ -281,6 +316,7 @@ pub struct ReplyView {
pub updated: Option<chrono::NaiveDateTime>, pub updated: Option<chrono::NaiveDateTime>,
pub deleted: bool, pub deleted: bool,
pub community_id: i32, pub community_id: i32,
pub community_name: String,
pub banned: bool, pub banned: bool,
pub banned_from_community: bool, pub banned_from_community: bool,
pub creator_name: String, pub creator_name: String,
@ -288,8 +324,10 @@ pub struct ReplyView {
pub score: i64, pub score: i64,
pub upvotes: i64, pub upvotes: i64,
pub downvotes: i64, pub downvotes: i64,
pub hot_rank: i32,
pub user_id: Option<i32>, pub user_id: Option<i32>,
pub my_vote: Option<i32>, pub my_vote: Option<i32>,
pub subscribed: Option<bool>,
pub saved: Option<bool>, pub saved: Option<bool>,
pub recipient_id: i32, pub recipient_id: i32,
} }
@ -474,6 +512,7 @@ mod tests {
creator_id: inserted_user.id, creator_id: inserted_user.id,
post_id: inserted_post.id, post_id: inserted_post.id,
community_id: inserted_community.id, community_id: inserted_community.id,
community_name: inserted_community.name.to_owned(),
parent_id: None, parent_id: None,
removed: false, removed: false,
deleted: false, deleted: false,
@ -486,9 +525,11 @@ mod tests {
creator_avatar: None, creator_avatar: None,
score: 1, score: 1,
downvotes: 0, downvotes: 0,
hot_rank: 0,
upvotes: 1, upvotes: 1,
user_id: None, user_id: None,
my_vote: None, my_vote: None,
subscribed: None,
saved: None, saved: None,
}; };
@ -498,6 +539,7 @@ mod tests {
creator_id: inserted_user.id, creator_id: inserted_user.id,
post_id: inserted_post.id, post_id: inserted_post.id,
community_id: inserted_community.id, community_id: inserted_community.id,
community_name: inserted_community.name.to_owned(),
parent_id: None, parent_id: None,
removed: false, removed: false,
deleted: false, deleted: false,
@ -510,21 +552,26 @@ mod tests {
creator_avatar: None, creator_avatar: None,
score: 1, score: 1,
downvotes: 0, downvotes: 0,
hot_rank: 0,
upvotes: 1, upvotes: 1,
user_id: Some(inserted_user.id), user_id: Some(inserted_user.id),
my_vote: Some(1), my_vote: Some(1),
subscribed: None,
saved: 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) .for_post_id(inserted_post.id)
.list() .list()
.unwrap(); .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) .for_post_id(inserted_post.id)
.my_user_id(inserted_user.id) .my_user_id(inserted_user.id)
.list() .list()
.unwrap(); .unwrap();
read_comment_views_with_user[0].hot_rank = 0;
let like_removed = CommentLike::remove(&conn, &comment_like_form).unwrap(); let like_removed = CommentLike::remove(&conn, &comment_like_form).unwrap();
let num_deleted = Comment::delete(&conn, inserted_comment.id).unwrap(); let num_deleted = Comment::delete(&conn, inserted_comment.id).unwrap();

View file

@ -227,9 +227,9 @@ impl CommunityView {
from_community_id: i32, from_community_id: i32,
from_user_id: Option<i32>, from_user_id: Option<i32>,
) -> Result<Self, Error> { ) -> Result<Self, Error> {
use super::community_view::community_view::dsl::*; use super::community_view::community_mview::dsl::*;
let mut query = community_view.into_boxed(); let mut query = community_mview.into_boxed();
query = query.filter(id.eq(from_community_id)); query = query.filter(id.eq(from_community_id));

View file

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

View file

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

View file

@ -144,9 +144,8 @@ impl<'a> UserQueryBuilder<'a> {
impl UserView { impl UserView {
pub fn read(conn: &PgConnection, from_user_id: i32) -> Result<Self, Error> { pub fn read(conn: &PgConnection, from_user_id: i32) -> Result<Self, Error> {
use super::user_view::user_view::dsl::*; use super::user_view::user_mview::dsl::*;
user_mview.find(from_user_id).first::<Self>(conn)
user_view.find(from_user_id).first::<Self>(conn)
} }
pub fn admins(conn: &PgConnection) -> Result<Vec<Self>, Error> { pub fn admins(conn: &PgConnection) -> Result<Vec<Self>, Error> {

View file

@ -6,7 +6,7 @@ pub fn config(cfg: &mut web::ServiceConfig) {
cfg cfg
.route("/", web::get().to(index)) .route("/", web::get().to(index))
.route( .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), web::get().to(index),
) )
.route("/login", 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("/communities", web::get().to(index))
.route("/post/{id}/comment/{id2}", web::get().to(index)) .route("/post/{id}/comment/{id2}", web::get().to(index))
.route("/post/{id}", 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("/c/{name}", web::get().to(index))
.route("/community/{id}", web::get().to(index)) .route("/community/{id}", web::get().to(index))
.route( .route(

View file

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

View file

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

View file

@ -12,6 +12,7 @@ use serde_json::Value;
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::str::FromStr; use std::str::FromStr;
use std::time::SystemTime; use std::time::SystemTime;
use strum::IntoEnumIterator;
use crate::api::comment::*; use crate::api::comment::*;
use crate::api::community::*; use crate::api::community::*;
@ -72,6 +73,13 @@ pub struct SessionInfo {
pub ip: IPAddr, 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 /// `ChatServer` manages chat rooms and responsible for coordinating chat
/// session. /// session.
pub struct ChatServer { pub struct ChatServer {
@ -88,8 +96,8 @@ pub struct ChatServer {
/// sessions (IE clients) /// sessions (IE clients)
user_rooms: HashMap<UserId, HashSet<ConnectionId>>, user_rooms: HashMap<UserId, HashSet<ConnectionId>>,
/// Rate limiting based on IP addr /// Rate limiting based on rate type and IP addr
rate_limits: HashMap<IPAddr, RateLimitBucket>, rate_limit_buckets: HashMap<RateLimitType, HashMap<IPAddr, RateLimitBucket>>,
rng: ThreadRng, rng: ThreadRng,
db: Pool<ConnectionManager<PgConnection>>, db: Pool<ConnectionManager<PgConnection>>,
@ -99,7 +107,7 @@ impl ChatServer {
pub fn startup(db: Pool<ConnectionManager<PgConnection>>) -> ChatServer { pub fn startup(db: Pool<ConnectionManager<PgConnection>>) -> ChatServer {
ChatServer { ChatServer {
sessions: HashMap::new(), sessions: HashMap::new(),
rate_limits: HashMap::new(), rate_limit_buckets: HashMap::new(),
post_rooms: HashMap::new(), post_rooms: HashMap::new(),
community_rooms: HashMap::new(), community_rooms: HashMap::new(),
user_rooms: HashMap::new(), user_rooms: HashMap::new(),
@ -114,6 +122,12 @@ impl ChatServer {
sessions.remove(&id); 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 the room doesn't exist yet
if self.community_rooms.get_mut(&community_id).is_none() { if self.community_rooms.get_mut(&community_id).is_none() {
self.community_rooms.insert(community_id, HashSet::new()); self.community_rooms.insert(community_id, HashSet::new());
@ -132,6 +146,12 @@ impl ChatServer {
sessions.remove(&id); 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 the room doesn't exist yet
if self.post_rooms.get_mut(&post_id).is_none() { if self.post_rooms.get_mut(&post_id).is_none() {
self.post_rooms.insert(post_id, HashSet::new()); self.post_rooms.insert(post_id, HashSet::new());
@ -236,6 +256,10 @@ impl ChatServer {
self.send_user_room_message(recipient_id, &comment_reply_sent_str, id); 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) Ok(comment_user_sent_str)
} }
@ -257,63 +281,86 @@ impl ChatServer {
self.send_community_room_message(0, &post_sent_str, id); self.send_community_room_message(0, &post_sent_str, id);
self.send_community_room_message(community_id, &post_sent_str, id); self.send_community_room_message(community_id, &post_sent_str, id);
// Send it to the post room
self.send_post_room_message(post_sent.post.id, &post_sent_str, id);
to_json_string(&user_operation, post) 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( self.check_rate_limit_full(
RateLimitType::Register,
id, id,
Settings::get().rate_limit.register, Settings::get().rate_limit.register,
Settings::get().rate_limit.register_per_second, 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( self.check_rate_limit_full(
RateLimitType::Post,
id, id,
Settings::get().rate_limit.post, Settings::get().rate_limit.post,
Settings::get().rate_limit.post_per_second, 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( self.check_rate_limit_full(
RateLimitType::Message,
id, id,
Settings::get().rate_limit.message, Settings::get().rate_limit.message,
Settings::get().rate_limit.message_per_second, Settings::get().rate_limit.message_per_second,
check_only,
) )
} }
#[allow(clippy::float_cmp)] #[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(info) = self.sessions.get(&id) {
if let Some(rate_limit) = self.rate_limits.get_mut(&info.ip) { if let Some(bucket) = self.rate_limit_buckets.get_mut(&type_) {
// The initial value if let Some(rate_limit) = bucket.get_mut(&info.ip) {
if rate_limit.allowance == -2f64 { let current = SystemTime::now();
rate_limit.allowance = rate as f64; let time_passed = current.duration_since(rate_limit.last_checked)?.as_secs() as f64;
};
let current = SystemTime::now(); // The initial value
let time_passed = current.duration_since(rate_limit.last_checked)?.as_secs() as f64; if rate_limit.allowance == -2f64 {
rate_limit.last_checked = current; rate_limit.allowance = rate as f64;
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 rate_limit.allowance < 1.0 { rate_limit.last_checked = current;
println!( rate_limit.allowance += time_passed * (rate as f64 / per as f64);
"Rate limited IP: {}, time_passed: {}, allowance: {}", if !check_only && rate_limit.allowance > rate as f64 {
&info.ip, time_passed, rate_limit.allowance rate_limit.allowance = rate as f64;
); }
Err(
APIError { if rate_limit.allowance < 1.0 {
message: format!("Too many requests. {} per {} seconds", rate, per), 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 { } else {
rate_limit.allowance -= 1.0;
Ok(()) Ok(())
} }
} else { } else {
@ -351,14 +398,24 @@ impl Handler<Connect> for ChatServer {
}, },
); );
if self.rate_limits.get(&msg.ip).is_none() { for rate_limit_type in RateLimitType::iter() {
self.rate_limits.insert( if self.rate_limit_buckets.get(&rate_limit_type).is_none() {
msg.ip, self
RateLimitBucket { .rate_limit_buckets
last_checked: SystemTime::now(), .insert(rate_limit_type, HashMap::new());
allowance: -2f64, }
},
); 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 id
@ -447,11 +504,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, // TODO: none of the chat messages are going to work if stuff is submitted via http api,
// need to move that handling elsewhere // need to move that handling elsewhere
// A DDOS check
chat.check_rate_limit_message(msg.id, false)?;
match user_operation { match user_operation {
UserOperation::Login => do_user_operation::<Login, LoginResponse>(user_operation, data, &conn), UserOperation::Login => do_user_operation::<Login, LoginResponse>(user_operation, data, &conn),
UserOperation::Register => { UserOperation::Register => {
chat.check_rate_limit_register(msg.id)?; chat.check_rate_limit_register(msg.id, true)?;
do_user_operation::<Register, LoginResponse>(user_operation, data, &conn) 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 => { UserOperation::GetUserDetails => {
do_user_operation::<GetUserDetails, GetUserDetailsResponse>(user_operation, data, &conn) do_user_operation::<GetUserDetails, GetUserDetailsResponse>(user_operation, data, &conn)
@ -527,8 +591,11 @@ fn parse_json_message(chat: &mut ChatServer, msg: StandardMessage) -> Result<Str
} }
} }
UserOperation::CreateCommunity => { UserOperation::CreateCommunity => {
chat.check_rate_limit_register(msg.id)?; chat.check_rate_limit_register(msg.id, true)?;
do_user_operation::<CreateCommunity, CommunityResponse>(user_operation, data, &conn) 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 => { UserOperation::EditCommunity => {
let edit_community: EditCommunity = serde_json::from_str(data)?; let edit_community: EditCommunity = serde_json::from_str(data)?;
@ -589,15 +656,24 @@ fn parse_json_message(chat: &mut ChatServer, msg: StandardMessage) -> Result<Str
let res = Oper::new(get_posts).perform(&conn)?; let res = Oper::new(get_posts).perform(&conn)?;
to_json_string(&user_operation, &res) 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 => { 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 create_post: CreatePost = serde_json::from_str(data)?;
let res = Oper::new(create_post).perform(&conn)?; let res = Oper::new(create_post).perform(&conn)?;
chat.check_rate_limit_post(msg.id, false)?;
chat.post_sends(UserOperation::CreatePost, res, msg.id) chat.post_sends(UserOperation::CreatePost, res, msg.id)
} }
UserOperation::CreatePostLike => { UserOperation::CreatePostLike => {
chat.check_rate_limit_message(msg.id)?;
let create_post_like: CreatePostLike = serde_json::from_str(data)?; let create_post_like: CreatePostLike = serde_json::from_str(data)?;
let res = Oper::new(create_post_like).perform(&conn)?; let res = Oper::new(create_post_like).perform(&conn)?;
@ -613,7 +689,6 @@ fn parse_json_message(chat: &mut ChatServer, msg: StandardMessage) -> Result<Str
do_user_operation::<SavePost, PostResponse>(user_operation, data, &conn) do_user_operation::<SavePost, PostResponse>(user_operation, data, &conn)
} }
UserOperation::CreateComment => { UserOperation::CreateComment => {
chat.check_rate_limit_message(msg.id)?;
let create_comment: CreateComment = serde_json::from_str(data)?; let create_comment: CreateComment = serde_json::from_str(data)?;
let res = Oper::new(create_comment).perform(&conn)?; let res = Oper::new(create_comment).perform(&conn)?;
@ -629,7 +704,6 @@ fn parse_json_message(chat: &mut ChatServer, msg: StandardMessage) -> Result<Str
do_user_operation::<SaveComment, CommentResponse>(user_operation, data, &conn) do_user_operation::<SaveComment, CommentResponse>(user_operation, data, &conn)
} }
UserOperation::CreateCommentLike => { UserOperation::CreateCommentLike => {
chat.check_rate_limit_message(msg.id)?;
let create_comment_like: CreateCommentLike = serde_json::from_str(data)?; let create_comment_like: CreateCommentLike = serde_json::from_str(data)?;
let res = Oper::new(create_comment_like).perform(&conn)?; let res = Oper::new(create_comment_like).perform(&conn)?;
@ -673,7 +747,6 @@ fn parse_json_message(chat: &mut ChatServer, msg: StandardMessage) -> Result<Str
do_user_operation::<PasswordChange, LoginResponse>(user_operation, data, &conn) do_user_operation::<PasswordChange, LoginResponse>(user_operation, data, &conn)
} }
UserOperation::CreatePrivateMessage => { UserOperation::CreatePrivateMessage => {
chat.check_rate_limit_message(msg.id)?;
let create_private_message: CreatePrivateMessage = serde_json::from_str(data)?; let create_private_message: CreatePrivateMessage = serde_json::from_str(data)?;
let recipient_id = create_private_message.recipient_id; let recipient_id = create_private_message.recipient_id;
let res = Oper::new(create_private_message).perform(&conn)?; let res = Oper::new(create_private_message).perform(&conn)?;

View file

@ -37,6 +37,7 @@
} }
.md-div img { .md-div img {
max-height: 90vh;
max-width: 100%; max-width: 100%;
height: auto; height: auto;
} }
@ -170,3 +171,40 @@ hr {
-o-filter: blur(10px); -o-filter: blur(10px);
-ms-filter: blur(10px); -ms-filter: blur(10px);
} }
.img-expanded {
max-height: 90vh;
}
.vote-animate:active {
transform: scale(1.2);
-webkit-transform: scale(1.2);
-ms-transform: scale(1.2);
}
.selectr-selected, .selectr-options-container {
background-color: var(--secondary);
color: var(--white);
border: unset;
}
.link-overlay:hover {
transition: .1s;
opacity: 1;
}
.link-overlay {
transition: opacity .1s ease-in-out;
position: absolute;
opacity: 0;
left: 0;
height: 100%;
width: 100%;
padding: 10px;
background: rgba(0,0,0,.6);
}
.placeholder {
height: 50px;
width: 50px;
}

7
ui/assets/css/selectr.min.css vendored Normal file

File diff suppressed because one or more lines are too long

11
ui/package.json vendored
View file

@ -1,13 +1,13 @@
{ {
"name": "lemmy", "name": "lemmy",
"description": "A simple UI for lemmy", "description": "The official Lemmy UI",
"version": "1.0.0", "version": "1.0.0",
"author": "Dessalines", "author": "Dessalines",
"license": "GPL-2.0-or-later", "license": "AGPL-3.0-or-later",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"build": "node fuse prod", "build": "node fuse prod",
"lint": "eslint --report-unused-disable-directives --ext .js,.ts,.tsx src", "lint": "tsc --noEmit && eslint --report-unused-disable-directives --ext .js,.ts,.tsx src",
"start": "node fuse dev" "start": "node fuse dev"
}, },
"keywords": [], "keywords": [],
@ -22,7 +22,7 @@
"bootswatch": "^4.3.1", "bootswatch": "^4.3.1",
"classcat": "^1.1.3", "classcat": "^1.1.3",
"dotenv": "^8.2.0", "dotenv": "^8.2.0",
"emoji-short-name": "^0.1.0", "emoji-short-name": "^1.0.0",
"husky": "^4.2.1", "husky": "^4.2.1",
"i18next": "^19.0.3", "i18next": "^19.0.3",
"inferno": "^7.0.1", "inferno": "^7.0.1",
@ -33,9 +33,10 @@
"markdown-it": "^10.0.0", "markdown-it": "^10.0.0",
"markdown-it-container": "^2.0.0", "markdown-it-container": "^2.0.0",
"markdown-it-emoji": "^1.4.0", "markdown-it-emoji": "^1.4.0",
"mobius1-selectr": "^2.4.13",
"moment": "^2.24.0", "moment": "^2.24.0",
"prettier": "^1.18.2", "prettier": "^1.18.2",
"reconnecting-websocket": "^4.3.0", "reconnecting-websocket": "^4.4.0",
"rxjs": "^6.4.0", "rxjs": "^6.4.0",
"terser": "^4.6.3", "terser": "^4.6.3",
"toastify-js": "^1.6.2", "toastify-js": "^1.6.2",

View file

@ -15,6 +15,8 @@ import {
TransferCommunityForm, TransferCommunityForm,
TransferSiteForm, TransferSiteForm,
BanType, BanType,
CommentSortType,
SortType,
} from '../interfaces'; } from '../interfaces';
import { WebSocketService, UserService } from '../services'; import { WebSocketService, UserService } from '../services';
import { import {
@ -46,8 +48,10 @@ interface CommentNodeState {
showConfirmAppointAsAdmin: boolean; showConfirmAppointAsAdmin: boolean;
collapsed: boolean; collapsed: boolean;
viewSource: boolean; viewSource: boolean;
upvoteLoading: boolean; my_vote: number;
downvoteLoading: boolean; score: number;
upvotes: number;
downvotes: number;
} }
interface CommentNodeProps { interface CommentNodeProps {
@ -58,7 +62,11 @@ interface CommentNodeProps {
markable?: boolean; markable?: boolean;
moderators: Array<CommunityUser>; moderators: Array<CommunityUser>;
admins: Array<UserView>; admins: Array<UserView>;
// TODO is this necessary, can't I get it from the node itself?
postCreatorId?: number; postCreatorId?: number;
showCommunity?: boolean;
sort?: CommentSortType;
sortType?: SortType;
} }
export class CommentNode extends Component<CommentNodeProps, CommentNodeState> { export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
@ -77,8 +85,10 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
showConfirmTransferCommunity: false, showConfirmTransferCommunity: false,
showConfirmAppointAsMod: false, showConfirmAppointAsMod: false,
showConfirmAppointAsAdmin: false, showConfirmAppointAsAdmin: false,
upvoteLoading: this.props.node.comment.upvoteLoading, my_vote: this.props.node.comment.my_vote,
downvoteLoading: this.props.node.comment.downvoteLoading, score: this.props.node.comment.score,
upvotes: this.props.node.comment.upvotes,
downvotes: this.props.node.comment.downvotes,
}; };
constructor(props: any, context: any) { constructor(props: any, context: any) {
@ -91,15 +101,11 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
} }
componentWillReceiveProps(nextProps: CommentNodeProps) { componentWillReceiveProps(nextProps: CommentNodeProps) {
if ( this.state.my_vote = nextProps.node.comment.my_vote;
nextProps.node.comment.upvoteLoading !== this.state.upvoteLoading || this.state.upvotes = nextProps.node.comment.upvotes;
nextProps.node.comment.downvoteLoading !== this.state.downvoteLoading this.state.downvotes = nextProps.node.comment.downvotes;
) { this.state.score = nextProps.node.comment.score;
this.setState({ this.setState(this.state);
upvoteLoading: false,
downvoteLoading: false,
});
}
} }
render() { render() {
@ -116,40 +122,26 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
.viewOnly && 'no-click'}`} .viewOnly && 'no-click'}`}
> >
<button <button
className={`btn btn-link p-0 ${ className={`vote-animate btn btn-link p-0 ${
node.comment.my_vote == 1 ? 'text-info' : 'text-muted' this.state.my_vote == 1 ? 'text-info' : 'text-muted'
}`} }`}
onClick={linkEvent(node, this.handleCommentUpvote)} onClick={linkEvent(node, this.handleCommentUpvote)}
> >
{this.state.upvoteLoading ? ( <svg class="icon upvote">
<svg class="icon icon-spinner spin upvote"> <use xlinkHref="#icon-arrow-up"></use>
<use xlinkHref="#icon-spinner"></use> </svg>
</svg>
) : (
<svg class="icon upvote">
<use xlinkHref="#icon-arrow-up"></use>
</svg>
)}
</button> </button>
<div class={`font-weight-bold text-muted`}> <div class={`font-weight-bold text-muted`}>{this.state.score}</div>
{node.comment.score}
</div>
{WebSocketService.Instance.site.enable_downvotes && ( {WebSocketService.Instance.site.enable_downvotes && (
<button <button
className={`btn btn-link p-0 ${ className={`vote-animate btn btn-link p-0 ${
node.comment.my_vote == -1 ? 'text-danger' : 'text-muted' this.state.my_vote == -1 ? 'text-danger' : 'text-muted'
}`} }`}
onClick={linkEvent(node, this.handleCommentDownvote)} onClick={linkEvent(node, this.handleCommentDownvote)}
> >
{this.state.downvoteLoading ? ( <svg class="icon downvote">
<svg class="icon icon-spinner spin downvote"> <use xlinkHref="#icon-arrow-down"></use>
<use xlinkHref="#icon-spinner"></use> </svg>
</svg>
) : (
<svg class="icon downvote">
<use xlinkHref="#icon-arrow-down"></use>
</svg>
)}
</button> </button>
)} )}
</div> </div>
@ -199,12 +191,20 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
)} )}
<li className="list-inline-item"> <li className="list-inline-item">
<span> <span>
(<span className="text-info">+{node.comment.upvotes}</span> (<span className="text-info">+{this.state.upvotes}</span>
<span> | </span> <span> | </span>
<span className="text-danger">-{node.comment.downvotes}</span> <span className="text-danger">-{this.state.downvotes}</span>
<span>) </span> <span>) </span>
</span> </span>
</li> </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"> <li className="list-inline-item">
<span> <span>
<MomentTime data={node.comment} /> <MomentTime data={node.comment} />
@ -620,6 +620,8 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
moderators={this.props.moderators} moderators={this.props.moderators}
admins={this.props.admins} admins={this.props.admins}
postCreatorId={this.props.postCreatorId} postCreatorId={this.props.postCreatorId}
sort={this.props.sort}
sortType={this.props.sortType}
/> />
)} )}
{/* A collapsed clearfix */} {/* A collapsed clearfix */}
@ -756,31 +758,57 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
} }
handleCommentUpvote(i: CommentNodeI) { handleCommentUpvote(i: CommentNodeI) {
if (UserService.Instance.user) { let new_vote = this.state.my_vote == 1 ? 0 : 1;
this.setState({
upvoteLoading: true, if (this.state.my_vote == 1) {
}); this.state.score--;
this.state.upvotes--;
} else if (this.state.my_vote == -1) {
this.state.downvotes--;
this.state.upvotes++;
this.state.score += 2;
} else {
this.state.upvotes++;
this.state.score++;
} }
this.state.my_vote = new_vote;
let form: CommentLikeForm = { let form: CommentLikeForm = {
comment_id: i.comment.id, comment_id: i.comment.id,
post_id: i.comment.post_id, post_id: i.comment.post_id,
score: i.comment.my_vote == 1 ? 0 : 1, score: this.state.my_vote,
}; };
WebSocketService.Instance.likeComment(form); WebSocketService.Instance.likeComment(form);
this.setState(this.state);
} }
handleCommentDownvote(i: CommentNodeI) { handleCommentDownvote(i: CommentNodeI) {
if (UserService.Instance.user) { let new_vote = this.state.my_vote == -1 ? 0 : -1;
this.setState({
downvoteLoading: true, if (this.state.my_vote == 1) {
}); this.state.score -= 2;
this.state.upvotes--;
this.state.downvotes++;
} else if (this.state.my_vote == -1) {
this.state.downvotes--;
this.state.score++;
} else {
this.state.downvotes++;
this.state.score--;
} }
this.state.my_vote = new_vote;
let form: CommentLikeForm = { let form: CommentLikeForm = {
comment_id: i.comment.id, comment_id: i.comment.id,
post_id: i.comment.post_id, post_id: i.comment.post_id,
score: i.comment.my_vote == -1 ? 0 : -1, score: this.state.my_vote,
}; };
WebSocketService.Instance.likeComment(form); WebSocketService.Instance.likeComment(form);
this.setState(this.state);
} }
handleModRemoveShow(i: CommentNode) { handleModRemoveShow(i: CommentNode) {

View file

@ -3,7 +3,10 @@ import {
CommentNode as CommentNodeI, CommentNode as CommentNodeI,
CommunityUser, CommunityUser,
UserView, UserView,
CommentSortType,
SortType,
} from '../interfaces'; } from '../interfaces';
import { commentSort, commentSortSortType } from '../utils';
import { CommentNode } from './comment-node'; import { CommentNode } from './comment-node';
interface CommentNodesState {} interface CommentNodesState {}
@ -17,6 +20,9 @@ interface CommentNodesProps {
viewOnly?: boolean; viewOnly?: boolean;
locked?: boolean; locked?: boolean;
markable?: boolean; markable?: boolean;
showCommunity?: boolean;
sort?: CommentSortType;
sortType?: SortType;
} }
export class CommentNodes extends Component< export class CommentNodes extends Component<
@ -30,7 +36,7 @@ export class CommentNodes extends Component<
render() { render() {
return ( return (
<div className="comments"> <div className="comments">
{this.props.nodes.map(node => ( {this.sorter().map(node => (
<CommentNode <CommentNode
node={node} node={node}
noIndent={this.props.noIndent} noIndent={this.props.noIndent}
@ -40,9 +46,22 @@ export class CommentNodes extends Component<
admins={this.props.admins} admins={this.props.admins}
postCreatorId={this.props.postCreatorId} postCreatorId={this.props.postCreatorId}
markable={this.props.markable} markable={this.props.markable}
showCommunity={this.props.showCommunity}
sort={this.props.sort}
sortType={this.props.sortType}
/> />
))} ))}
</div> </div>
); );
} }
sorter(): Array<CommentNodeI> {
if (this.props.sort !== undefined) {
commentSort(this.props.nodes, this.props.sort);
} else if (this.props.sortType !== undefined) {
commentSortSortType(this.props.nodes, this.props.sortType);
}
return this.props.nodes;
}
} }

View file

@ -13,17 +13,37 @@ import {
GetPostsForm, GetPostsForm,
GetCommunityForm, GetCommunityForm,
ListingType, ListingType,
DataType,
GetPostsResponse, GetPostsResponse,
PostResponse, PostResponse,
AddModToCommunityResponse, AddModToCommunityResponse,
BanFromCommunityResponse, BanFromCommunityResponse,
Comment,
GetCommentsForm,
GetCommentsResponse,
CommentResponse,
WebSocketJsonResponse, WebSocketJsonResponse,
} from '../interfaces'; } from '../interfaces';
import { WebSocketService, UserService } from '../services'; import { WebSocketService } from '../services';
import { PostListings } from './post-listings'; import { PostListings } from './post-listings';
import { CommentNodes } from './comment-nodes';
import { SortSelect } from './sort-select'; import { SortSelect } from './sort-select';
import { DataTypeSelect } from './data-type-select';
import { Sidebar } from './sidebar'; import { Sidebar } from './sidebar';
import { wsJsonToRes, routeSortTypeToEnum, fetchLimit, toast } from '../utils'; import {
wsJsonToRes,
fetchLimit,
toast,
getPageFromProps,
getSortTypeFromProps,
getDataTypeFromProps,
editCommentRes,
saveCommentRes,
createCommentLikeRes,
createPostLikeFindRes,
editPostFindRes,
commentsToFlatNodes,
} from '../utils';
import { i18n } from '../i18next'; import { i18n } from '../i18next';
interface State { interface State {
@ -35,6 +55,8 @@ interface State {
online: number; online: number;
loading: boolean; loading: boolean;
posts: Array<Post>; posts: Array<Post>;
comments: Array<Comment>;
dataType: DataType;
sort: SortType; sort: SortType;
page: number; page: number;
} }
@ -65,27 +87,18 @@ export class Community extends Component<any, State> {
online: null, online: null,
loading: true, loading: true,
posts: [], posts: [],
sort: this.getSortTypeFromProps(this.props), comments: [],
page: this.getPageFromProps(this.props), 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) { constructor(props: any, context: any) {
super(props, context); super(props, context);
this.state = this.emptyState; this.state = this.emptyState;
this.handleSortChange = this.handleSortChange.bind(this); this.handleSortChange = this.handleSortChange.bind(this);
this.handleDataTypeChange = this.handleDataTypeChange.bind(this);
this.subscription = WebSocketService.Instance.subject this.subscription = WebSocketService.Instance.subject
.pipe(retryWhen(errors => errors.pipe(delay(3000), take(10)))) .pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
@ -112,16 +125,18 @@ export class Community extends Component<any, State> {
nextProps.history.action == 'POP' || nextProps.history.action == 'POP' ||
nextProps.history.action == 'PUSH' nextProps.history.action == 'PUSH'
) { ) {
this.state.sort = this.getSortTypeFromProps(nextProps); this.state.dataType = getDataTypeFromProps(nextProps);
this.state.page = this.getPageFromProps(nextProps); this.state.sort = getSortTypeFromProps(nextProps);
this.state.page = getPageFromProps(nextProps);
this.setState(this.state); this.setState(this.state);
this.fetchPosts(); this.fetchData();
} }
} }
render() { render() {
return ( return (
<div class="container"> <div class="container">
{this.selects()}
{this.state.loading ? ( {this.state.loading ? (
<h5> <h5>
<svg class="icon icon-spinner spin"> <svg class="icon icon-spinner spin">
@ -144,8 +159,7 @@ export class Community extends Component<any, State> {
</small> </small>
)} )}
</h5> </h5>
{this.selects()} {this.listings()}
<PostListings posts={this.state.posts} removeDuplicates />
{this.paginator()} {this.paginator()}
</div> </div>
<div class="col-12 col-md-4"> <div class="col-12 col-md-4">
@ -162,17 +176,40 @@ export class Community extends Component<any, State> {
); );
} }
listings() {
return this.state.dataType == DataType.Post ? (
<PostListings
posts={this.state.posts}
removeDuplicates
sort={this.state.sort}
/>
) : (
<CommentNodes
nodes={commentsToFlatNodes(this.state.comments)}
noIndent
sortType={this.state.sort}
/>
);
}
selects() { selects() {
return ( return (
<div class="mb-2"> <div class="mb-2">
<SortSelect sort={this.state.sort} onChange={this.handleSortChange} /> <DataTypeSelect
type_={this.state.dataType}
onChange={this.handleDataTypeChange}
/>
<span class="mx-3">
<SortSelect sort={this.state.sort} onChange={this.handleSortChange} />
</span>
<a <a
href={`/feeds/c/${this.state.communityName}.xml?sort=${ href={`/feeds/c/${this.state.communityName}.xml?sort=${
SortType[this.state.sort] SortType[this.state.sort]
}`} }`}
target="_blank" target="_blank"
> >
<svg class="icon mx-2 text-muted small"> <svg class="icon text-muted small">
<use xlinkHref="#icon-rss">#</use> <use xlinkHref="#icon-rss">#</use>
</svg> </svg>
</a> </a>
@ -207,7 +244,7 @@ export class Community extends Component<any, State> {
i.state.page++; i.state.page++;
i.setState(i.state); i.setState(i.state);
i.updateUrl(); i.updateUrl();
i.fetchPosts(); i.fetchData();
window.scrollTo(0, 0); window.scrollTo(0, 0);
} }
@ -215,7 +252,7 @@ export class Community extends Component<any, State> {
i.state.page--; i.state.page--;
i.setState(i.state); i.setState(i.state);
i.updateUrl(); i.updateUrl();
i.fetchPosts(); i.fetchData();
window.scrollTo(0, 0); window.scrollTo(0, 0);
} }
@ -225,26 +262,48 @@ export class Community extends Component<any, State> {
this.state.loading = true; this.state.loading = true;
this.setState(this.state); this.setState(this.state);
this.updateUrl(); 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); window.scrollTo(0, 0);
} }
updateUrl() { updateUrl() {
let dataTypeStr = DataType[this.state.dataType].toLowerCase();
let sortStr = SortType[this.state.sort].toLowerCase(); let sortStr = SortType[this.state.sort].toLowerCase();
this.props.history.push( 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() { fetchData() {
let getPostsForm: GetPostsForm = { if (this.state.dataType == DataType.Post) {
page: this.state.page, let getPostsForm: GetPostsForm = {
limit: fetchLimit, page: this.state.page,
sort: SortType[this.state.sort], limit: fetchLimit,
type_: ListingType[ListingType.Community], sort: SortType[this.state.sort],
community_id: this.state.community.id, type_: ListingType[ListingType.Community],
}; community_id: this.state.community.id,
WebSocketService.Instance.getPosts(getPostsForm); };
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) { parseMessage(msg: WebSocketJsonResponse) {
@ -255,7 +314,7 @@ export class Community extends Component<any, State> {
this.context.router.history.push('/'); this.context.router.history.push('/');
return; return;
} else if (msg.reconnect) { } else if (msg.reconnect) {
this.fetchPosts(); this.fetchData();
} else if (res.op == UserOperation.GetCommunity) { } else if (res.op == UserOperation.GetCommunity) {
let data = res.data as GetCommunityResponse; let data = res.data as GetCommunityResponse;
this.state.community = data.community; this.state.community = data.community;
@ -264,7 +323,7 @@ export class Community extends Component<any, State> {
this.state.online = data.online; this.state.online = data.online;
document.title = `/c/${this.state.community.name} - ${WebSocketService.Instance.site.name}`; document.title = `/c/${this.state.community.name} - ${WebSocketService.Instance.site.name}`;
this.setState(this.state); this.setState(this.state);
this.fetchPosts(); this.fetchData();
} else if (res.op == UserOperation.EditCommunity) { } else if (res.op == UserOperation.EditCommunity) {
let data = res.data as CommunityResponse; let data = res.data as CommunityResponse;
this.state.community = data.community; this.state.community = data.community;
@ -282,12 +341,7 @@ export class Community extends Component<any, State> {
this.setState(this.state); this.setState(this.state);
} else if (res.op == UserOperation.EditPost) { } else if (res.op == UserOperation.EditPost) {
let data = res.data as PostResponse; let data = res.data as PostResponse;
let found = this.state.posts.find(c => c.id == data.post.id); editPostFindRes(data, this.state.posts);
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.CreatePost) { } else if (res.op == UserOperation.CreatePost) {
let data = res.data as PostResponse; let data = res.data as PostResponse;
@ -295,17 +349,7 @@ export class Community extends Component<any, State> {
this.setState(this.state); this.setState(this.state);
} else if (res.op == UserOperation.CreatePostLike) { } else if (res.op == UserOperation.CreatePostLike) {
let data = res.data as PostResponse; let data = res.data as PostResponse;
let found = this.state.posts.find(c => c.id == data.post.id); createPostLikeFindRes(data, this.state.posts);
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.AddModToCommunity) { } else if (res.op == UserOperation.AddModToCommunity) {
let data = res.data as AddModToCommunityResponse; let data = res.data as AddModToCommunityResponse;
@ -319,6 +363,31 @@ export class Community extends Component<any, State> {
.forEach(p => (p.banned = data.banned)); .forEach(p => (p.banned = data.banned));
this.setState(this.state); 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;
editCommentRes(data, this.state.comments);
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;
saveCommentRes(data, this.state.comments);
this.setState(this.state);
} else if (res.op == UserOperation.CreateCommentLike) {
let data = res.data as CommentResponse;
createCommentLikeRes(data, this.state.comments);
this.setState(this.state);
} }
} }
} }

65
ui/src/components/data-type-select.tsx vendored Normal file
View file

@ -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_);
}
}

87
ui/src/components/iframely-card.tsx vendored Normal file
View file

@ -0,0 +1,87 @@
import { Component, linkEvent } from 'inferno';
import { FramelyData } from '../interfaces';
import { mdToHtml } from '../utils';
interface FramelyCardProps {
iframely: FramelyData;
}
interface FramelyCardState {
expanded: boolean;
}
export class IFramelyCard extends Component<
FramelyCardProps,
FramelyCardState
> {
private emptyState: FramelyCardState = {
expanded: false,
};
constructor(props: any, context: any) {
super(props, context);
this.state = this.emptyState;
}
render() {
let iframely = this.props.iframely;
return (
<>
{iframely.title && !this.state.expanded && (
<div class="card mt-3 mb-2">
<div class="row">
<div class="col-12">
<div class="card-body">
<h5 class="card-title d-inline">
<span>
<a class="text-body" target="_blank" href={iframely.url}>
{iframely.title}
</a>
</span>
</h5>
<span class="d-inline-block ml-2 mb-2 small text-muted">
<a
class="text-muted font-italic"
target="_blank"
href={iframely.url}
>
{new URL(iframely.url).hostname}
<svg class="ml-1 icon">
<use xlinkHref="#icon-external-link"></use>
</svg>
</a>
{iframely.html && (
<span
class="ml-2 pointer text-monospace"
onClick={linkEvent(this, this.handleIframeExpand)}
>
{this.state.expanded ? '[-]' : '[+]'}
</span>
)}
</span>
{iframely.description && (
<div
className="card-text small text-muted md-div"
dangerouslySetInnerHTML={mdToHtml(iframely.description)}
/>
)}
</div>
</div>
</div>
</div>
)}
{this.state.expanded && (
<div
class="mt-3 mb-2"
dangerouslySetInnerHTML={{ __html: iframely.html }}
/>
)}
</>
);
}
handleIframeExpand(i: IFramelyCard) {
i.state.expanded = !i.state.expanded;
i.setState(i.state);
}
}

View file

@ -19,7 +19,16 @@ import {
PrivateMessageResponse, PrivateMessageResponse,
} from '../interfaces'; } from '../interfaces';
import { WebSocketService, UserService } from '../services'; import { WebSocketService, UserService } from '../services';
import { wsJsonToRes, fetchLimit, isCommentType, toast } from '../utils'; import {
wsJsonToRes,
fetchLimit,
isCommentType,
toast,
editCommentRes,
saveCommentRes,
createCommentLikeRes,
commentsToFlatNodes,
} from '../utils';
import { CommentNodes } from './comment-nodes'; import { CommentNodes } from './comment-nodes';
import { PrivateMessage } from './private-message'; import { PrivateMessage } from './private-message';
import { SortSelect } from './sort-select'; import { SortSelect } from './sort-select';
@ -197,9 +206,11 @@ export class Inbox extends Component<any, InboxState> {
replies() { replies() {
return ( return (
<div> <div>
{this.state.replies.map(reply => ( <CommentNodes
<CommentNodes nodes={[{ comment: reply }]} noIndent markable /> nodes={commentsToFlatNodes(this.state.replies)}
))} noIndent
markable
/>
</div> </div>
); );
} }
@ -362,15 +373,7 @@ export class Inbox extends Component<any, InboxState> {
this.setState(this.state); this.setState(this.state);
} else if (res.op == UserOperation.EditComment) { } else if (res.op == UserOperation.EditComment) {
let data = res.data as CommentResponse; let data = res.data as CommentResponse;
editCommentRes(data, this.state.replies);
let found = this.state.replies.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;
// If youre in the unread view, just remove it from the list // If youre in the unread view, just remove it from the list
if (this.state.unreadOrAll == UnreadOrAll.Unread && data.comment.read) { if (this.state.unreadOrAll == UnreadOrAll.Unread && data.comment.read) {
@ -418,28 +421,17 @@ export class Inbox extends Component<any, InboxState> {
this.setState(this.state); this.setState(this.state);
} else if (res.op == UserOperation.CreatePrivateMessage) { } else if (res.op == UserOperation.CreatePrivateMessage) {
let data = res.data as PrivateMessageResponse; let data = res.data as PrivateMessageResponse;
if (data.message.recipient_id == UserService.Instance.user.id) { if (data.message.recipient_id == UserService.Instance.user.id) {
this.state.messages.unshift(data.message); this.state.messages.unshift(data.message);
this.setState(this.state); this.setState(this.state);
} else if (data.message.creator_id == UserService.Instance.user.id) {
toast(i18n.t('message_sent'));
} }
this.setState(this.state);
} else if (res.op == UserOperation.SaveComment) { } else if (res.op == UserOperation.SaveComment) {
let data = res.data as CommentResponse; let data = res.data as CommentResponse;
let found = this.state.replies.find(c => c.id == data.comment.id); saveCommentRes(data, this.state.replies);
found.saved = data.comment.saved;
this.setState(this.state); this.setState(this.state);
} else if (res.op == UserOperation.CreateCommentLike) { } else if (res.op == UserOperation.CreateCommentLike) {
let data = res.data as CommentResponse; let data = res.data as CommentResponse;
let found: Comment = this.state.replies.find( createCommentLikeRes(data, this.state.replies);
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); this.setState(this.state);
} }
} }
@ -451,9 +443,9 @@ export class Inbox extends Component<any, InboxState> {
this.state.messages.filter( this.state.messages.filter(
r => !r.read && r.creator_id !== UserService.Instance.user.id r => !r.read && r.creator_id !== UserService.Instance.user.id
).length; ).length;
UserService.Instance.user.unreadCount = count;
UserService.Instance.sub.next({ UserService.Instance.sub.next({
user: UserService.Instance.user, user: UserService.Instance.user,
unreadCount: count,
}); });
} }
} }

View file

@ -12,30 +12,46 @@ import {
SortType, SortType,
GetSiteResponse, GetSiteResponse,
ListingType, ListingType,
DataType,
SiteResponse, SiteResponse,
GetPostsResponse, GetPostsResponse,
PostResponse, PostResponse,
Post, Post,
GetPostsForm, GetPostsForm,
Comment,
GetCommentsForm,
GetCommentsResponse,
CommentResponse,
AddAdminResponse, AddAdminResponse,
BanUserResponse, BanUserResponse,
WebSocketJsonResponse, WebSocketJsonResponse,
} from '../interfaces'; } from '../interfaces';
import { WebSocketService, UserService } from '../services'; import { WebSocketService, UserService } from '../services';
import { PostListings } from './post-listings'; import { PostListings } from './post-listings';
import { CommentNodes } from './comment-nodes';
import { SortSelect } from './sort-select'; import { SortSelect } from './sort-select';
import { ListingTypeSelect } from './listing-type-select'; import { ListingTypeSelect } from './listing-type-select';
import { DataTypeSelect } from './data-type-select';
import { SiteForm } from './site-form'; import { SiteForm } from './site-form';
import { import {
wsJsonToRes, wsJsonToRes,
repoUrl, repoUrl,
mdToHtml, mdToHtml,
fetchLimit, fetchLimit,
routeSortTypeToEnum,
routeListingTypeToEnum,
pictshareAvatarThumbnail, pictshareAvatarThumbnail,
showAvatars, showAvatars,
toast, toast,
getListingTypeFromProps,
getPageFromProps,
getSortTypeFromProps,
getDataTypeFromProps,
editCommentRes,
saveCommentRes,
createCommentLikeRes,
createPostLikeFindRes,
editPostFindRes,
commentsToFlatNodes,
commentSortSortType,
} from '../utils'; } from '../utils';
import { i18n } from '../i18next'; import { i18n } from '../i18next';
import { T } from 'inferno-i18next'; import { T } from 'inferno-i18next';
@ -47,7 +63,9 @@ interface MainState {
showEditSite: boolean; showEditSite: boolean;
loading: boolean; loading: boolean;
posts: Array<Post>; posts: Array<Post>;
type_: ListingType; comments: Array<Comment>;
listingType: ListingType;
dataType: DataType;
sort: SortType; sort: SortType;
page: number; page: number;
} }
@ -79,38 +97,21 @@ export class Main extends Component<any, MainState> {
showEditSite: false, showEditSite: false,
loading: true, loading: true,
posts: [], posts: [],
type_: this.getListingTypeFromProps(this.props), comments: [],
sort: this.getSortTypeFromProps(this.props), listingType: getListingTypeFromProps(this.props),
page: this.getPageFromProps(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) { constructor(props: any, context: any) {
super(props, context); super(props, context);
this.state = this.emptyState; this.state = this.emptyState;
this.handleEditCancel = this.handleEditCancel.bind(this); this.handleEditCancel = this.handleEditCancel.bind(this);
this.handleSortChange = this.handleSortChange.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 this.subscription = WebSocketService.Instance.subject
.pipe(retryWhen(errors => errors.pipe(delay(3000), take(10)))) .pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
@ -133,7 +134,7 @@ export class Main extends Component<any, MainState> {
WebSocketService.Instance.listCommunities(listCommunitiesForm); WebSocketService.Instance.listCommunities(listCommunitiesForm);
this.fetchPosts(); this.fetchData();
} }
componentWillUnmount() { componentWillUnmount() {
@ -146,11 +147,12 @@ export class Main extends Component<any, MainState> {
nextProps.history.action == 'POP' || nextProps.history.action == 'POP' ||
nextProps.history.action == 'PUSH' nextProps.history.action == 'PUSH'
) { ) {
this.state.type_ = this.getListingTypeFromProps(nextProps); this.state.listingType = getListingTypeFromProps(nextProps);
this.state.sort = this.getSortTypeFromProps(nextProps); this.state.dataType = getDataTypeFromProps(nextProps);
this.state.page = this.getPageFromProps(nextProps); this.state.sort = getSortTypeFromProps(nextProps);
this.state.page = getPageFromProps(nextProps);
this.setState(this.state); this.setState(this.state);
this.fetchPosts(); this.fetchData();
} }
} }
@ -251,10 +253,11 @@ export class Main extends Component<any, MainState> {
} }
updateUrl() { 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(); let sortStr = SortType[this.state.sort].toLowerCase();
this.props.history.push( 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}`
); );
} }
@ -383,6 +386,7 @@ export class Main extends Component<any, MainState> {
posts() { posts() {
return ( return (
<div class="main-content-wrapper"> <div class="main-content-wrapper">
{this.selects()}
{this.state.loading ? ( {this.state.loading ? (
<h5> <h5>
<svg class="icon icon-spinner spin"> <svg class="icon icon-spinner spin">
@ -391,12 +395,7 @@ export class Main extends Component<any, MainState> {
</h5> </h5>
) : ( ) : (
<div> <div>
{this.selects()} {this.listings()}
<PostListings
posts={this.state.posts}
showCommunity
removeDuplicates
/>
{this.paginator()} {this.paginator()}
</div> </div>
)} )}
@ -404,17 +403,41 @@ export class Main extends Component<any, MainState> {
); );
} }
listings() {
return this.state.dataType == DataType.Post ? (
<PostListings
posts={this.state.posts}
showCommunity
removeDuplicates
sort={this.state.sort}
/>
) : (
<CommentNodes
nodes={commentsToFlatNodes(this.state.comments)}
noIndent
showCommunity
sortType={this.state.sort}
/>
);
}
selects() { selects() {
return ( return (
<div className="mb-3"> <div className="mb-3">
<ListingTypeSelect <DataTypeSelect
type_={this.state.type_} type_={this.state.dataType}
onChange={this.handleTypeChange} onChange={this.handleDataTypeChange}
/> />
<span class="mx-2"> <span class="mx-3">
<ListingTypeSelect
type_={this.state.listingType}
onChange={this.handleListingTypeChange}
/>
</span>
<span class="mr-2">
<SortSelect sort={this.state.sort} onChange={this.handleSortChange} /> <SortSelect sort={this.state.sort} onChange={this.handleSortChange} />
</span> </span>
{this.state.type_ == ListingType.All && ( {this.state.listingType == ListingType.All && (
<a <a
href={`/feeds/all.xml?sort=${SortType[this.state.sort]}`} href={`/feeds/all.xml?sort=${SortType[this.state.sort]}`}
target="_blank" target="_blank"
@ -425,7 +448,7 @@ export class Main extends Component<any, MainState> {
</a> </a>
)} )}
{UserService.Instance.user && {UserService.Instance.user &&
this.state.type_ == ListingType.Subscribed && ( this.state.listingType == ListingType.Subscribed && (
<a <a
href={`/feeds/front/${UserService.Instance.auth}.xml?sort=${ href={`/feeds/front/${UserService.Instance.auth}.xml?sort=${
SortType[this.state.sort] SortType[this.state.sort]
@ -488,7 +511,7 @@ export class Main extends Component<any, MainState> {
i.state.loading = true; i.state.loading = true;
i.setState(i.state); i.setState(i.state);
i.updateUrl(); i.updateUrl();
i.fetchPosts(); i.fetchData();
window.scrollTo(0, 0); window.scrollTo(0, 0);
} }
@ -497,7 +520,7 @@ export class Main extends Component<any, MainState> {
i.state.loading = true; i.state.loading = true;
i.setState(i.state); i.setState(i.state);
i.updateUrl(); i.updateUrl();
i.fetchPosts(); i.fetchData();
window.scrollTo(0, 0); window.scrollTo(0, 0);
} }
@ -507,28 +530,48 @@ export class Main extends Component<any, MainState> {
this.state.loading = true; this.state.loading = true;
this.setState(this.state); this.setState(this.state);
this.updateUrl(); this.updateUrl();
this.fetchPosts(); this.fetchData();
window.scrollTo(0, 0); window.scrollTo(0, 0);
} }
handleTypeChange(val: ListingType) { handleListingTypeChange(val: ListingType) {
this.state.type_ = val; this.state.listingType = val;
this.state.page = 1; this.state.page = 1;
this.state.loading = true; this.state.loading = true;
this.setState(this.state); this.setState(this.state);
this.updateUrl(); this.updateUrl();
this.fetchPosts(); this.fetchData();
window.scrollTo(0, 0); window.scrollTo(0, 0);
} }
fetchPosts() { handleDataTypeChange(val: DataType) {
let getPostsForm: GetPostsForm = { this.state.dataType = val;
page: this.state.page, this.state.page = 1;
limit: fetchLimit, this.state.loading = true;
sort: SortType[this.state.sort], this.setState(this.state);
type_: ListingType[this.state.type_], this.updateUrl();
}; this.fetchData();
WebSocketService.Instance.getPosts(getPostsForm); 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) { parseMessage(msg: WebSocketJsonResponse) {
@ -538,7 +581,7 @@ export class Main extends Component<any, MainState> {
toast(i18n.t(msg.error), 'danger'); toast(i18n.t(msg.error), 'danger');
return; return;
} else if (msg.reconnect) { } else if (msg.reconnect) {
this.fetchPosts(); this.fetchData();
} else if (res.op == UserOperation.GetFollowedCommunities) { } else if (res.op == UserOperation.GetFollowedCommunities) {
let data = res.data as GetFollowedCommunitiesResponse; let data = res.data as GetFollowedCommunitiesResponse;
this.state.subscribedCommunities = data.communities; this.state.subscribedCommunities = data.communities;
@ -574,7 +617,7 @@ export class Main extends Component<any, MainState> {
let data = res.data as PostResponse; let data = res.data as PostResponse;
// If you're on subscribed, only push it if you're subscribed. // 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 ( if (
this.state.subscribedCommunities this.state.subscribedCommunities
.map(c => c.community_id) .map(c => c.community_id)
@ -589,26 +632,11 @@ export class Main extends Component<any, MainState> {
this.setState(this.state); this.setState(this.state);
} else if (res.op == UserOperation.EditPost) { } else if (res.op == UserOperation.EditPost) {
let data = res.data as PostResponse; let data = res.data as PostResponse;
let found = this.state.posts.find(c => c.id == data.post.id); editPostFindRes(data, this.state.posts);
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) { } else if (res.op == UserOperation.CreatePostLike) {
let data = res.data as PostResponse; let data = res.data as PostResponse;
let found = this.state.posts.find(c => c.id == data.post.id); createPostLikeFindRes(data, this.state.posts);
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) { } else if (res.op == UserOperation.AddAdmin) {
let data = res.data as AddAdminResponse; let data = res.data as AddAdminResponse;
@ -632,6 +660,42 @@ export class Main extends Component<any, MainState> {
.forEach(p => (p.banned = data.banned)); .forEach(p => (p.banned = data.banned));
this.setState(this.state); 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;
editCommentRes(data, this.state.comments);
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;
saveCommentRes(data, this.state.comments);
this.setState(this.state);
} else if (res.op == UserOperation.CreateCommentLike) {
let data = res.data as CommentResponse;
createCommentLikeRes(data, this.state.comments);
this.setState(this.state);
} }
} }
} }

View file

@ -60,8 +60,10 @@ export class Navbar extends Component<any, NavbarState> {
// Subscribe to user changes // Subscribe to user changes
this.userSub = UserService.Instance.sub.subscribe(user => { this.userSub = UserService.Instance.sub.subscribe(user => {
this.state.isLoggedIn = user.user !== undefined; this.state.isLoggedIn = user.user !== undefined;
this.state.unreadCount = user.unreadCount; if (this.state.isLoggedIn) {
this.requestNotificationPermission(); this.state.unreadCount = user.user.unreadCount;
this.requestNotificationPermission();
}
this.setState(this.state); this.setState(this.state);
}); });
@ -304,9 +306,9 @@ export class Navbar extends Component<any, NavbarState> {
} }
sendUnreadCount() { sendUnreadCount() {
UserService.Instance.user.unreadCount = this.state.unreadCount;
UserService.Instance.sub.next({ UserService.Instance.sub.next({
user: UserService.Instance.user, user: UserService.Instance.user,
unreadCount: this.state.unreadCount,
}); });
} }

View file

@ -35,8 +35,11 @@ import {
} from '../utils'; } from '../utils';
import autosize from 'autosize'; import autosize from 'autosize';
import Tribute from 'tributejs/src/Tribute.js'; import Tribute from 'tributejs/src/Tribute.js';
import Selectr from 'mobius1-selectr';
import { i18n } from '../i18next'; import { i18n } from '../i18next';
const MAX_POST_TITLE_LENGTH = 200;
interface PostFormProps { interface PostFormProps {
post?: Post; // If a post is given, that means this is an edit post?: Post; // If a post is given, that means this is an edit
params?: PostFormParams; params?: PostFormParams;
@ -232,7 +235,7 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
required required
rows={2} rows={2}
minLength={3} minLength={3}
maxLength={100} maxLength={MAX_POST_TITLE_LENGTH}
/> />
{this.state.suggestedPosts.length > 0 && ( {this.state.suggestedPosts.length > 0 && (
<> <>
@ -360,7 +363,10 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
} }
copySuggestedTitle(i: PostForm) { 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.state.suggestedTitle = undefined;
i.setState(i.state); i.setState(i.state);
} }
@ -509,14 +515,27 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
this.state.postForm.community_id = data.communities[0].id; this.state.postForm.community_id = data.communities[0].id;
} }
this.setState(this.state); this.setState(this.state);
// Set up select searching
let selectId: any = document.getElementById('post-community');
if (selectId) {
let selector = new Selectr(selectId, { nativeDropdown: false });
selector.on('selectr.select', option => {
this.state.postForm.community_id = Number(option.value);
});
}
} else if (res.op == UserOperation.CreatePost) { } else if (res.op == UserOperation.CreatePost) {
let data = res.data as PostResponse; let data = res.data as PostResponse;
this.state.loading = false; if (data.post.creator_id == UserService.Instance.user.id) {
this.props.onCreate(data.post.id); this.state.loading = false;
this.props.onCreate(data.post.id);
}
} else if (res.op == UserOperation.EditPost) { } else if (res.op == UserOperation.EditPost) {
let data = res.data as PostResponse; let data = res.data as PostResponse;
this.state.loading = false; if (data.post.creator_id == UserService.Instance.user.id) {
this.props.onEdit(data.post); this.state.loading = false;
this.props.onEdit(data.post);
}
} else if (res.op == UserOperation.Search) { } else if (res.op == UserOperation.Search) {
let data = res.data as SearchResponse; let data = res.data as SearchResponse;

View file

@ -15,9 +15,11 @@ import {
AddAdminForm, AddAdminForm,
TransferSiteForm, TransferSiteForm,
TransferCommunityForm, TransferCommunityForm,
FramelyData,
} from '../interfaces'; } from '../interfaces';
import { MomentTime } from './moment-time'; import { MomentTime } from './moment-time';
import { PostForm } from './post-form'; import { PostForm } from './post-form';
import { IFramelyCard } from './iframely-card';
import { import {
mdToHtml, mdToHtml,
canMod, canMod,
@ -43,8 +45,13 @@ interface PostListingState {
showConfirmTransferCommunity: boolean; showConfirmTransferCommunity: boolean;
imageExpanded: boolean; imageExpanded: boolean;
viewSource: boolean; viewSource: boolean;
upvoteLoading: boolean; my_vote: number;
downvoteLoading: boolean; score: number;
upvotes: number;
downvotes: number;
url: string;
iframely: FramelyData;
thumbnail: string;
} }
interface PostListingProps { interface PostListingProps {
@ -68,8 +75,13 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
showConfirmTransferCommunity: false, showConfirmTransferCommunity: false,
imageExpanded: false, imageExpanded: false,
viewSource: false, viewSource: false,
upvoteLoading: this.props.post.upvoteLoading, my_vote: this.props.post.my_vote,
downvoteLoading: this.props.post.downvoteLoading, score: this.props.post.score,
upvotes: this.props.post.upvotes,
downvotes: this.props.post.downvotes,
url: this.props.post.url,
iframely: null,
thumbnail: null,
}; };
constructor(props: any, context: any) { constructor(props: any, context: any) {
@ -80,18 +92,31 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
this.handlePostDisLike = this.handlePostDisLike.bind(this); this.handlePostDisLike = this.handlePostDisLike.bind(this);
this.handleEditPost = this.handleEditPost.bind(this); this.handleEditPost = this.handleEditPost.bind(this);
this.handleEditCancel = this.handleEditCancel.bind(this); this.handleEditCancel = this.handleEditCancel.bind(this);
if (this.state.url) {
this.setThumbnail();
this.fetchIframely();
}
} }
componentWillReceiveProps(nextProps: PostListingProps) { componentWillReceiveProps(nextProps: PostListingProps) {
if ( this.state.my_vote = nextProps.post.my_vote;
nextProps.post.upvoteLoading !== this.state.upvoteLoading || this.state.upvotes = nextProps.post.upvotes;
nextProps.post.downvoteLoading !== this.state.downvoteLoading this.state.downvotes = nextProps.post.downvotes;
) { this.state.score = nextProps.post.score;
this.setState({
upvoteLoading: false, if (nextProps.post.url !== this.state.url) {
downvoteLoading: false, this.state.url = nextProps.post.url;
}); if (this.state.url) {
this.setThumbnail();
this.fetchIframely();
} else {
this.state.iframely = null;
this.state.thumbnail = null;
}
} }
this.setState(this.state);
} }
render() { render() {
@ -112,62 +137,76 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
); );
} }
imgThumbnail() {
let post = this.props.post;
return (
<object
className={`img-fluid thumbnail rounded ${(post.nsfw ||
post.community_nsfw) &&
'img-blur'}`}
data={imageThumbnailer(this.state.thumbnail)}
></object>
);
}
listing() { listing() {
let post = this.props.post; let post = this.props.post;
return ( return (
<div class="listing col-12"> <div class="listing col-12">
<div className={`vote-bar mr-2 float-left small text-center`}> <div className={`vote-bar mr-2 float-left small text-center`}>
<button <button
className={`btn btn-link p-0 ${ className={`vote-animate btn btn-link p-0 ${
post.my_vote == 1 ? 'text-info' : 'text-muted' this.state.my_vote == 1 ? 'text-info' : 'text-muted'
}`} }`}
onClick={linkEvent(this, this.handlePostLike)} onClick={linkEvent(this, this.handlePostLike)}
> >
{this.state.upvoteLoading ? ( <svg class="icon upvote">
<svg class="icon icon-spinner spin upvote"> <use xlinkHref="#icon-arrow-up"></use>
<use xlinkHref="#icon-spinner"></use> </svg>
</svg>
) : (
<svg class="icon upvote">
<use xlinkHref="#icon-arrow-up"></use>
</svg>
)}
</button> </button>
<div class={`font-weight-bold text-muted`}>{post.score}</div> <div class={`font-weight-bold text-muted`}>{this.state.score}</div>
{WebSocketService.Instance.site.enable_downvotes && ( {WebSocketService.Instance.site.enable_downvotes && (
<button <button
className={`btn btn-link p-0 ${ className={`vote-animate btn btn-link p-0 ${
post.my_vote == -1 ? 'text-danger' : 'text-muted' this.state.my_vote == -1 ? 'text-danger' : 'text-muted'
}`} }`}
onClick={linkEvent(this, this.handlePostDisLike)} onClick={linkEvent(this, this.handlePostDisLike)}
> >
{this.state.downvoteLoading ? ( <svg class="icon downvote">
<svg class="icon icon-spinner spin downvote"> <use xlinkHref="#icon-arrow-down"></use>
<use xlinkHref="#icon-spinner"></use> </svg>
</svg>
) : (
<svg class="icon downvote">
<use xlinkHref="#icon-arrow-down"></use>
</svg>
)}
</button> </button>
)} )}
</div> </div>
{post.url && isImage(post.url) && !this.state.imageExpanded && ( {this.state.thumbnail && !this.state.imageExpanded && (
<span <div class="mx-2 mt-1 float-left position-relative">
title={i18n.t('expand_here')} {isImage(this.state.url) ? (
class="pointer" <span
onClick={linkEvent(this, this.handleImageExpandClick)} class="text-body pointer"
> title={i18n.t('expand_here')}
<img onClick={linkEvent(this, this.handleImageExpandClick)}
className={`mx-2 mt-1 float-left img-fluid thumbnail rounded ${(post.nsfw || >
post.community_nsfw) && {this.imgThumbnail()}
'img-blur'}`} <svg class="icon rounded link-overlay hover-link">
src={imageThumbnailer(post.url)} <use xlinkHref="#icon-image"></use>
/> </svg>
</span> </span>
) : (
<a
className="text-body"
href={this.state.url}
target="_blank"
title={this.state.url}
>
{this.imgThumbnail()}
<svg class="icon rounded link-overlay hover-link">
<use xlinkHref="#icon-external-link"></use>
</svg>
</a>
)}
</div>
)} )}
{post.url && isVideo(post.url) && ( {this.state.url && isVideo(this.state.url) && (
<video <video
playsinline playsinline
muted muted
@ -177,18 +216,18 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
height="100" height="100"
width="150" width="150"
> >
<source src={post.url} type="video/mp4" /> <source src={this.state.url} type="video/mp4" />
</video> </video>
)} )}
<div className="ml-4"> <div className="ml-4">
<div className="post-title text-wrap-truncate"> <div className="post-title">
<h5 className="mb-0 d-inline"> <h5 className="mb-0 d-inline">
{post.url ? ( {this.props.showBody && this.state.url ? (
<a <a
className="text-body" className="text-body"
href={post.url} href={this.state.url}
target="_blank" target="_blank"
title={post.url} title={this.state.url}
> >
{post.name} {post.name}
</a> </a>
@ -202,19 +241,25 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
</Link> </Link>
)} )}
</h5> </h5>
{post.url && ( {this.state.url &&
<small> !(
<a new URL(this.state.url).hostname == window.location.hostname
className="ml-2 text-muted font-italic" ) && (
href={post.url} <small class="d-inline-block">
target="_blank" <a
title={post.url} className="ml-2 text-muted font-italic"
> href={this.state.url}
{new URL(post.url).hostname} target="_blank"
</a> title={this.state.url}
</small> >
)} {new URL(this.state.url).hostname}
{post.url && isImage(post.url) && ( <svg class="ml-1 icon">
<use xlinkHref="#icon-external-link"></use>
</svg>
</a>
</small>
)}
{this.state.thumbnail && (
<> <>
{!this.state.imageExpanded ? ( {!this.state.imageExpanded ? (
<span <span
@ -237,7 +282,14 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
class="pointer" class="pointer"
onClick={linkEvent(this, this.handleImageExpandClick)} onClick={linkEvent(this, this.handleImageExpandClick)}
> >
<img class="img-fluid" src={post.url} /> <object
class="img-fluid img-expanded"
data={this.state.thumbnail}
>
<svg class="icon rounded placeholder">
<use xlinkHref="#icon-external-link"></use>
</svg>
</object>
</span> </span>
</div> </div>
</span> </span>
@ -315,9 +367,9 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
</li> </li>
<li className="list-inline-item"> <li className="list-inline-item">
<span> <span>
(<span className="text-info">+{post.upvotes}</span> (<span className="text-info">+{this.state.upvotes}</span>
<span> | </span> <span> | </span>
<span className="text-danger">-{post.downvotes}</span> <span className="text-danger">-{this.state.downvotes}</span>
<span>) </span> <span>) </span>
</span> </span>
</li> </li>
@ -596,6 +648,9 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
</li> </li>
)} )}
</ul> </ul>
{this.state.url && this.props.showBody && this.state.iframely && (
<IFramelyCard iframely={this.state.iframely} />
)}
{this.state.showRemoveDialog && ( {this.state.showRemoveDialog && (
<form <form
class="form-inline" class="form-inline"
@ -746,29 +801,97 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
); );
} }
handlePostLike(i: PostListing) { fetchIframely() {
if (UserService.Instance.user) { fetch(`/iframely/oembed?url=${this.state.url}`)
i.setState({ upvoteLoading: true }); .then(res => res.json())
.then(res => {
this.state.iframely = res;
this.setState(this.state);
// Store and fetch the image in pictshare
if (
this.state.iframely.thumbnail_url &&
isImage(this.state.iframely.thumbnail_url)
) {
fetch(
`/pictshare/api/geturl.php?url=${this.state.iframely.thumbnail_url}`
)
.then(res => res.json())
.then(res => {
let url = `${window.location.origin}/pictshare/${res.url}`;
if (res.filetype == 'mp4') {
url += '/raw';
}
this.state.thumbnail = url;
this.setState(this.state);
});
}
})
.catch(error => {
console.error(`Iframely service not set up properly. ${error}`);
});
}
setThumbnail() {
let simpleImg = isImage(this.state.url);
if (simpleImg) {
this.state.thumbnail = this.state.url;
} else {
this.state.thumbnail = null;
} }
this.setState(this.state);
}
handlePostLike(i: PostListing) {
let new_vote = i.state.my_vote == 1 ? 0 : 1;
if (i.state.my_vote == 1) {
i.state.score--;
i.state.upvotes--;
} else if (i.state.my_vote == -1) {
i.state.downvotes--;
i.state.upvotes++;
i.state.score += 2;
} else {
i.state.upvotes++;
i.state.score++;
}
i.state.my_vote = new_vote;
let form: CreatePostLikeForm = { let form: CreatePostLikeForm = {
post_id: i.props.post.id, post_id: i.props.post.id,
score: i.props.post.my_vote == 1 ? 0 : 1, score: i.state.my_vote,
}; };
WebSocketService.Instance.likePost(form); WebSocketService.Instance.likePost(form);
i.setState(i.state);
} }
handlePostDisLike(i: PostListing) { handlePostDisLike(i: PostListing) {
if (UserService.Instance.user) { let new_vote = i.state.my_vote == -1 ? 0 : -1;
i.setState({ downvoteLoading: true });
if (i.state.my_vote == 1) {
i.state.score -= 2;
i.state.upvotes--;
i.state.downvotes++;
} else if (i.state.my_vote == -1) {
i.state.downvotes--;
i.state.score++;
} else {
i.state.downvotes++;
i.state.score--;
} }
i.state.my_vote = new_vote;
let form: CreatePostLikeForm = { let form: CreatePostLikeForm = {
post_id: i.props.post.id, post_id: i.props.post.id,
score: i.props.post.my_vote == -1 ? 0 : -1, score: i.state.my_vote,
}; };
WebSocketService.Instance.likePost(form); WebSocketService.Instance.likePost(form);
i.setState(i.state);
} }
handleEditClick(i: PostListing) { handleEditClick(i: PostListing) {
@ -814,8 +937,8 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
get crossPostParams(): string { get crossPostParams(): string {
let params = `?title=${this.props.post.name}`; let params = `?title=${this.props.post.name}`;
if (this.props.post.url) { if (this.state.url) {
params += `&url=${this.props.post.url}`; params += `&url=${this.state.url}`;
} }
if (this.props.post.body) { if (this.props.post.body) {
params += `&body=${this.props.post.body}`; params += `&body=${this.props.post.body}`;

View file

@ -1,13 +1,16 @@
import { Component } from 'inferno'; import { Component } from 'inferno';
import { Link } from 'inferno-router'; import { Link } from 'inferno-router';
import { Post } from '../interfaces'; import { Post, SortType } from '../interfaces';
import { postSort } from '../utils';
import { PostListing } from './post-listing'; import { PostListing } from './post-listing';
import { i18n } from '../i18next'; import { i18n } from '../i18next';
import { T } from 'inferno-i18next';
interface PostListingsProps { interface PostListingsProps {
posts: Array<Post>; posts: Array<Post>;
showCommunity?: boolean; showCommunity?: boolean;
removeDuplicates?: boolean; removeDuplicates?: boolean;
sort?: SortType;
} }
export class PostListings extends Component<PostListingsProps, any> { export class PostListings extends Component<PostListingsProps, any> {
@ -19,10 +22,7 @@ export class PostListings extends Component<PostListingsProps, any> {
return ( return (
<div> <div>
{this.props.posts.length > 0 ? ( {this.props.posts.length > 0 ? (
(this.props.removeDuplicates this.outer().map(post => (
? this.removeDuplicates(this.props.posts)
: this.props.posts
).map(post => (
<> <>
<PostListing <PostListing
post={post} post={post}
@ -36,11 +36,9 @@ export class PostListings extends Component<PostListingsProps, any> {
<> <>
<div>{i18n.t('no_posts')}</div> <div>{i18n.t('no_posts')}</div>
{this.props.showCommunity !== undefined && ( {this.props.showCommunity !== undefined && (
<div> <T i18nKey="subscribe_to_communities">
<Link to="/communities"> #<Link to="/communities">#</Link>
{i18n.t('subscribe_to_communities')} </T>
</Link>
</div>
)} )}
</> </>
)} )}
@ -48,6 +46,19 @@ export class PostListings extends Component<PostListingsProps, any> {
); );
} }
outer(): Array<Post> {
let out = this.props.posts;
if (this.props.removeDuplicates) {
out = this.removeDuplicates(out);
}
if (this.props.sort !== undefined) {
postSort(out, this.props.sort);
}
return out;
}
removeDuplicates(posts: Array<Post>): Array<Post> { removeDuplicates(posts: Array<Post>): Array<Post> {
// A map from post url to list of posts (dupes) // A map from post url to list of posts (dupes)
let urlMap = new Map<string, Array<Post>>(); let urlMap = new Map<string, Array<Post>>();

View file

@ -29,7 +29,15 @@ import {
WebSocketJsonResponse, WebSocketJsonResponse,
} from '../interfaces'; } from '../interfaces';
import { WebSocketService, UserService } from '../services'; import { WebSocketService, UserService } from '../services';
import { wsJsonToRes, hotRank, toast } from '../utils'; import {
wsJsonToRes,
toast,
editCommentRes,
saveCommentRes,
createCommentLikeRes,
createPostLikeRes,
commentsToFlatNodes,
} from '../utils';
import { PostListing } from './post-listing'; import { PostListing } from './post-listing';
import { PostListings } from './post-listings'; import { PostListings } from './post-listings';
import { Sidebar } from './sidebar'; import { Sidebar } from './sidebar';
@ -148,6 +156,10 @@ export class Post extends Component<any, PostState> {
auth: null, auth: null,
}; };
WebSocketService.Instance.editComment(form); WebSocketService.Instance.editComment(form);
UserService.Instance.user.unreadCount--;
UserService.Instance.sub.next({
user: UserService.Instance.user,
});
} }
} }
@ -256,16 +268,14 @@ export class Post extends Component<any, PostState> {
<div class="d-none d-md-block new-comments mb-3 card border-secondary"> <div class="d-none d-md-block new-comments mb-3 card border-secondary">
<div class="card-body small"> <div class="card-body small">
<h6>{i18n.t('recent_comments')}</h6> <h6>{i18n.t('recent_comments')}</h6>
{this.state.comments.map(comment => ( <CommentNodes
<CommentNodes nodes={commentsToFlatNodes(this.state.comments)}
nodes={[{ comment: comment }]} noIndent
noIndent locked={this.state.post.locked}
locked={this.state.post.locked} moderators={this.state.moderators}
moderators={this.state.moderators} admins={this.state.admins}
admins={this.state.admins} postCreatorId={this.state.post.creator_id}
postCreatorId={this.state.post.creator_id} />
/>
))}
</div> </div>
</div> </div>
); );
@ -307,48 +317,9 @@ export class Post extends Component<any, PostState> {
} }
} }
this.sortTree(tree);
return tree; return tree;
} }
sortTree(tree: Array<CommentNodeI>) {
// First, put removed and deleted comments at the bottom, then do your other sorts
if (this.state.commentSort == CommentSortType.Top) {
tree.sort(
(a, b) =>
+a.comment.removed - +b.comment.removed ||
+a.comment.deleted - +b.comment.deleted ||
b.comment.score - a.comment.score
);
} else if (this.state.commentSort == CommentSortType.New) {
tree.sort(
(a, b) =>
+a.comment.removed - +b.comment.removed ||
+a.comment.deleted - +b.comment.deleted ||
b.comment.published.localeCompare(a.comment.published)
);
} else if (this.state.commentSort == CommentSortType.Old) {
tree.sort(
(a, b) =>
+a.comment.removed - +b.comment.removed ||
+a.comment.deleted - +b.comment.deleted ||
a.comment.published.localeCompare(b.comment.published)
);
} else if (this.state.commentSort == CommentSortType.Hot) {
tree.sort(
(a, b) =>
+a.comment.removed - +b.comment.removed ||
+a.comment.deleted - +b.comment.deleted ||
hotRank(b.comment) - hotRank(a.comment)
);
}
for (let node of tree) {
this.sortTree(node.children);
}
}
commentsTree() { commentsTree() {
let nodes = this.buildCommentsTree(); let nodes = this.buildCommentsTree();
return ( return (
@ -359,6 +330,7 @@ export class Post extends Component<any, PostState> {
moderators={this.state.moderators} moderators={this.state.moderators}
admins={this.state.admins} admins={this.state.admins}
postCreatorId={this.state.post.creator_id} postCreatorId={this.state.post.creator_id}
sort={this.state.commentSort}
/> />
</div> </div>
); );
@ -408,47 +380,19 @@ export class Post extends Component<any, PostState> {
} }
} else if (res.op == UserOperation.EditComment) { } else if (res.op == UserOperation.EditComment) {
let data = res.data as CommentResponse; let data = res.data as CommentResponse;
let found = this.state.comments.find(c => c.id == data.comment.id); editCommentRes(data, this.state.comments);
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) { } else if (res.op == UserOperation.SaveComment) {
let data = res.data as CommentResponse; let data = res.data as CommentResponse;
let found = this.state.comments.find(c => c.id == data.comment.id); saveCommentRes(data, this.state.comments);
found.saved = data.comment.saved;
this.setState(this.state); this.setState(this.state);
} else if (res.op == UserOperation.CreateCommentLike) { } else if (res.op == UserOperation.CreateCommentLike) {
let data = res.data as CommentResponse; let data = res.data as CommentResponse;
let found: Comment = this.state.comments.find( createCommentLikeRes(data, this.state.comments);
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;
}
this.setState(this.state); this.setState(this.state);
} else if (res.op == UserOperation.CreatePostLike) { } else if (res.op == UserOperation.CreatePostLike) {
let data = res.data as PostResponse; let data = res.data as PostResponse;
this.state.post.score = data.post.score; createPostLikeRes(data, this.state.post);
this.state.post.upvotes = data.post.upvotes;
this.state.post.downvotes = data.post.downvotes;
if (data.post.my_vote !== null) {
this.state.post.my_vote = data.post.my_vote;
this.state.post.upvoteLoading = false;
this.state.post.downvoteLoading = false;
}
this.setState(this.state); this.setState(this.state);
} else if (res.op == UserOperation.EditPost) { } else if (res.op == UserOperation.EditPost) {
let data = res.data as PostResponse; let data = res.data as PostResponse;
@ -504,7 +448,6 @@ export class Post extends Component<any, PostState> {
this.setState(this.state); this.setState(this.state);
} else if (res.op == UserOperation.TransferSite) { } else if (res.op == UserOperation.TransferSite) {
let data = res.data as GetSiteResponse; let data = res.data as GetSiteResponse;
this.state.admins = data.admins; this.state.admins = data.admins;
this.setState(this.state); this.setState(this.state);
} else if (res.op == UserOperation.TransferCommunity) { } else if (res.op == UserOperation.TransferCommunity) {

View file

@ -25,6 +25,9 @@ import {
pictshareAvatarThumbnail, pictshareAvatarThumbnail,
showAvatars, showAvatars,
toast, toast,
createCommentLikeRes,
createPostLikeFindRes,
commentsToFlatNodes,
} from '../utils'; } from '../utils';
import { PostListing } from './post-listing'; import { PostListing } from './post-listing';
import { SortSelect } from './sort-select'; import { SortSelect } from './sort-select';
@ -294,15 +297,11 @@ export class Search extends Component<any, SearchState> {
comments() { comments() {
return ( return (
<> <CommentNodes
{this.state.searchResponse.comments.map(comment => ( nodes={commentsToFlatNodes(this.state.searchResponse.comments)}
<div class="row"> locked
<div class="col-12"> noIndent
<CommentNodes nodes={[{ comment: comment }]} locked noIndent /> />
</div>
</div>
))}
</>
); );
} }
@ -474,27 +473,11 @@ export class Search extends Component<any, SearchState> {
this.setState(this.state); this.setState(this.state);
} else if (res.op == UserOperation.CreateCommentLike) { } else if (res.op == UserOperation.CreateCommentLike) {
let data = res.data as CommentResponse; let data = res.data as CommentResponse;
let found: Comment = this.state.searchResponse.comments.find( createCommentLikeRes(data, this.state.searchResponse.comments);
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;
}
this.setState(this.state); this.setState(this.state);
} else if (res.op == UserOperation.CreatePostLike) { } else if (res.op == UserOperation.CreatePostLike) {
let data = res.data as PostResponse; let data = res.data as PostResponse;
let found = this.state.searchResponse.posts.find( createPostLikeFindRes(data, this.state.searchResponse.posts);
c => c.id == data.post.id
);
found.my_vote = data.post.my_vote;
found.score = data.post.score;
found.upvotes = data.post.upvotes;
found.downvotes = data.post.downvotes;
this.setState(this.state); this.setState(this.state);
} }
} }

View file

@ -47,7 +47,13 @@ export class Sponsors extends Component<any, any> {
#<a href="https://github.com/dessalines/lemmy">#</a> #<a href="https://github.com/dessalines/lemmy">#</a>
</T> </T>
</p> </p>
<a class="btn btn-secondary" href="https://www.patreon.com/dessalines"> <a class="btn btn-secondary" href="https://liberapay.com/Lemmy/">
{i18n.t('support_on_liberapay')}
</a>
<a
class="btn btn-secondary ml-2"
href="https://www.patreon.com/dessalines"
>
{i18n.t('support_on_patreon')} {i18n.t('support_on_patreon')}
</a> </a>
</div> </div>

View file

@ -15,6 +15,16 @@ export class Symbols extends Component<any, any> {
xmlnsXlink="http://www.w3.org/1999/xlink" xmlnsXlink="http://www.w3.org/1999/xlink"
> >
<defs> <defs>
<symbol id="icon-image" viewBox="0 0 32 32">
<title>image</title>
<path d="M29.996 4c0.001 0.001 0.003 0.002 0.004 0.004v23.993c-0.001 0.001-0.002 0.003-0.004 0.004h-27.993c-0.001-0.001-0.003-0.002-0.004-0.004v-23.993c0.001-0.001 0.002-0.003 0.004-0.004h27.993zM30 2h-28c-1.1 0-2 0.9-2 2v24c0 1.1 0.9 2 2 2h28c1.1 0 2-0.9 2-2v-24c0-1.1-0.9-2-2-2v0z"></path>
<path d="M26 9c0 1.657-1.343 3-3 3s-3-1.343-3-3 1.343-3 3-3 3 1.343 3 3z"></path>
<path d="M28 26h-24v-4l7-12 8 10h2l7-6z"></path>
</symbol>
<symbol id="icon-external-link" viewBox="0 0 24 24">
<title>external-link</title>
<path d="M17 13v6c0 0.276-0.111 0.525-0.293 0.707s-0.431 0.293-0.707 0.293h-11c-0.276 0-0.525-0.111-0.707-0.293s-0.293-0.431-0.293-0.707v-11c0-0.276 0.111-0.525 0.293-0.707s0.431-0.293 0.707-0.293h6c0.552 0 1-0.448 1-1s-0.448-1-1-1h-6c-0.828 0-1.58 0.337-2.121 0.879s-0.879 1.293-0.879 2.121v11c0 0.828 0.337 1.58 0.879 2.121s1.293 0.879 2.121 0.879h11c0.828 0 1.58-0.337 2.121-0.879s0.879-1.293 0.879-2.121v-6c0-0.552-0.448-1-1-1s-1 0.448-1 1zM10.707 14.707l9.293-9.293v3.586c0 0.552 0.448 1 1 1s1-0.448 1-1v-6c0-0.136-0.027-0.265-0.076-0.383s-0.121-0.228-0.216-0.323c-0.001-0.001-0.001-0.001-0.002-0.002-0.092-0.092-0.202-0.166-0.323-0.216-0.118-0.049-0.247-0.076-0.383-0.076h-6c-0.552 0-1 0.448-1 1s0.448 1 1 1h3.586l-9.293 9.293c-0.391 0.391-0.391 1.024 0 1.414s1.024 0.391 1.414 0z"></path>
</symbol>
<symbol id="icon-coffee" viewBox="0 0 24 24"> <symbol id="icon-coffee" viewBox="0 0 24 24">
<title>coffee1</title> <title>coffee1</title>
<path d="M17 19h-12c-0.553 0-1-0.447-1-1s0.447-1 1-1h12c0.553 0 1 0.447 1 1s-0.447 1-1 1z"></path> <path d="M17 19h-12c-0.553 0-1-0.447-1-1s0.447-1 1-1h12c0.553 0 1 0.447 1 1s-0.447 1-1 1z"></path>

View file

@ -32,6 +32,11 @@ import {
languages, languages,
showAvatars, showAvatars,
toast, toast,
editCommentRes,
saveCommentRes,
createCommentLikeRes,
createPostLikeFindRes,
commentsToFlatNodes,
} from '../utils'; } from '../utils';
import { PostListing } from './post-listing'; import { PostListing } from './post-listing';
import { SortSelect } from './sort-select'; import { SortSelect } from './sort-select';
@ -316,13 +321,11 @@ export class User extends Component<any, UserState> {
comments() { comments() {
return ( return (
<div> <div>
{this.state.comments.map(comment => ( <CommentNodes
<CommentNodes nodes={commentsToFlatNodes(this.state.comments)}
nodes={[{ comment: comment }]} admins={this.state.admins}
admins={this.state.admins} noIndent
noIndent />
/>
))}
</div> </div>
); );
} }
@ -1032,44 +1035,27 @@ export class User extends Component<any, UserState> {
this.setState(this.state); this.setState(this.state);
} else if (res.op == UserOperation.EditComment) { } else if (res.op == UserOperation.EditComment) {
let data = res.data as CommentResponse; let data = res.data as CommentResponse;
editCommentRes(data, this.state.comments);
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); this.setState(this.state);
} else if (res.op == UserOperation.CreateComment) { } else if (res.op == UserOperation.CreateComment) {
// let res: CommentResponse = msg; let data = res.data as CommentResponse;
toast(i18n.t('reply_sent')); if (
// this.state.comments.unshift(res.comment); // TODO do this right UserService.Instance.user &&
// this.setState(this.state); data.comment.creator_id == UserService.Instance.user.id
) {
toast(i18n.t('reply_sent'));
}
} else if (res.op == UserOperation.SaveComment) { } else if (res.op == UserOperation.SaveComment) {
let data = res.data as CommentResponse; let data = res.data as CommentResponse;
let found = this.state.comments.find(c => c.id == data.comment.id); saveCommentRes(data, this.state.comments);
found.saved = data.comment.saved;
this.setState(this.state); this.setState(this.state);
} else if (res.op == UserOperation.CreateCommentLike) { } else if (res.op == UserOperation.CreateCommentLike) {
let data = res.data as CommentResponse; let data = res.data as CommentResponse;
let found: Comment = this.state.comments.find( createCommentLikeRes(data, this.state.comments);
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); this.setState(this.state);
} else if (res.op == UserOperation.CreatePostLike) { } else if (res.op == UserOperation.CreatePostLike) {
let data = res.data as PostResponse; let data = res.data as PostResponse;
let found = this.state.posts.find(c => c.id == data.post.id); createPostLikeFindRes(data, this.state.posts);
found.my_vote = data.post.my_vote;
found.score = data.post.score;
found.upvotes = data.post.upvotes;
found.downvotes = data.post.downvotes;
this.setState(this.state); this.setState(this.state);
} else if (res.op == UserOperation.BanUser) { } else if (res.op == UserOperation.BanUser) {
let data = res.data as BanUserResponse; let data = res.data as BanUserResponse;

2
ui/src/i18next.ts vendored
View file

@ -13,6 +13,7 @@ import { it } from './translations/it';
import { fi } from './translations/fi'; import { fi } from './translations/fi';
import { ca } from './translations/ca'; import { ca } from './translations/ca';
import { fa } from './translations/fa'; import { fa } from './translations/fa';
import { pt_BR } from './translations/pt_br';
// https://github.com/nimbusec-oss/inferno-i18next/blob/master/tests/T.test.js#L66 // https://github.com/nimbusec-oss/inferno-i18next/blob/master/tests/T.test.js#L66
const resources = { const resources = {
@ -29,6 +30,7 @@ const resources = {
fi, fi,
ca, ca,
fa, fa,
pt_BR,
}; };
function format(value: any, format: any, lng: any): any { function format(value: any, format: any, lng: any): any {

1
ui/src/index.html vendored
View file

@ -13,6 +13,7 @@
<!-- Styles --> <!-- Styles -->
<link rel="stylesheet" type="text/css" href="/static/assets/css/tribute.css" /> <link rel="stylesheet" type="text/css" href="/static/assets/css/tribute.css" />
<link rel="stylesheet" type="text/css" href="/static/assets/css/toastify.css" /> <link rel="stylesheet" type="text/css" href="/static/assets/css/toastify.css" />
<link rel="stylesheet" type="text/css" href="/static/assets/css/selectr.min.css" />
<link rel="stylesheet" type="text/css" href="/static/assets/css/themes/darkly.min.css" id="darkly" /> <link rel="stylesheet" type="text/css" href="/static/assets/css/themes/darkly.min.css" id="darkly" />
<link rel="stylesheet" type="text/css" href="/static/assets/css/main.css" /> <link rel="stylesheet" type="text/css" href="/static/assets/css/main.css" />

4
ui/src/index.tsx vendored
View file

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

43
ui/src/interfaces.ts vendored
View file

@ -42,6 +42,7 @@ export enum UserOperation {
EditPrivateMessage, EditPrivateMessage,
GetPrivateMessages, GetPrivateMessages,
UserJoin, UserJoin,
GetComments,
} }
export enum CommentSortType { export enum CommentSortType {
@ -57,6 +58,11 @@ export enum ListingType {
Community, Community,
} }
export enum DataType {
Post,
Comment,
}
export enum SortType { export enum SortType {
Hot, Hot,
New, New,
@ -87,6 +93,7 @@ export interface User {
lang: string; lang: string;
avatar?: string; avatar?: string;
show_avatars: boolean; show_avatars: boolean;
unreadCount?: number;
} }
export interface UserView { export interface UserView {
@ -165,13 +172,12 @@ export interface Post {
upvotes: number; upvotes: number;
downvotes: number; downvotes: number;
hot_rank: number; hot_rank: number;
newest_activity_time: string;
user_id?: number; user_id?: number;
my_vote?: number; my_vote?: number;
subscribed?: boolean; subscribed?: boolean;
read?: boolean; read?: boolean;
saved?: boolean; saved?: boolean;
upvoteLoading?: boolean;
downvoteLoading?: boolean;
duplicates?: Array<Post>; duplicates?: Array<Post>;
} }
@ -187,6 +193,7 @@ export interface Comment {
published: string; published: string;
updated?: string; updated?: string;
community_id: number; community_id: number;
community_name: string;
banned: boolean; banned: boolean;
banned_from_community: boolean; banned_from_community: boolean;
creator_name: string; creator_name: string;
@ -194,13 +201,13 @@ export interface Comment {
score: number; score: number;
upvotes: number; upvotes: number;
downvotes: number; downvotes: number;
hot_rank: number;
user_id?: number; user_id?: number;
my_vote?: number; my_vote?: number;
subscribed?: number;
saved?: boolean; saved?: boolean;
user_mention_id?: number; // For mention type user_mention_id?: number; // For mention type
recipient_id?: number; recipient_id?: number;
upvoteLoading?: boolean;
downvoteLoading?: boolean;
} }
export interface Category { export interface Category {
@ -659,6 +666,19 @@ export interface GetPostsResponse {
posts: Array<Post>; 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 { export interface CreatePostLikeForm {
post_id: number; post_id: number;
score: number; score: number;
@ -856,3 +876,18 @@ export interface WebSocketJsonResponse {
error?: string; error?: string;
reconnect?: boolean; reconnect?: boolean;
} }
export interface FramelyData {
url: string;
type: string;
version?: string;
title: string;
author?: string;
author_url?: string;
provider_name?: string;
thumbnail_url?: string;
thumbnail_width?: number;
thumbnail_height?: number;
description?: string;
html?: string;
}

View file

@ -7,9 +7,8 @@ import { Subject } from 'rxjs';
export class UserService { export class UserService {
private static _instance: UserService; private static _instance: UserService;
public user: User; public user: User;
public sub: Subject<{ user: User; unreadCount: number }> = new Subject<{ public sub: Subject<{ user: User }> = new Subject<{
user: User; user: User;
unreadCount: number;
}>(); }>();
private constructor() { private constructor() {
@ -32,7 +31,7 @@ export class UserService {
this.user = undefined; this.user = undefined;
Cookies.remove('jwt'); Cookies.remove('jwt');
setTheme(); setTheme();
this.sub.next({ user: undefined, unreadCount: 0 }); this.sub.next({ user: undefined });
console.log('Logged out.'); console.log('Logged out.');
} }
@ -45,7 +44,7 @@ export class UserService {
if (this.user.theme != 'darkly') { if (this.user.theme != 'darkly') {
setTheme(this.user.theme); setTheme(this.user.theme);
} }
this.sub.next({ user: this.user, unreadCount: 0 }); this.sub.next({ user: this.user });
console.log(this.user); console.log(this.user);
} }

View file

@ -38,6 +38,7 @@ import {
PrivateMessageForm, PrivateMessageForm,
EditPrivateMessageForm, EditPrivateMessageForm,
GetPrivateMessagesForm, GetPrivateMessagesForm,
GetCommentsForm,
UserJoinForm, UserJoinForm,
MessageType, MessageType,
WebSocketJsonResponse, WebSocketJsonResponse,
@ -172,6 +173,11 @@ export class WebSocketService {
this.ws.send(this.wsSendWrapper(UserOperation.GetPosts, form)); 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) { public likePost(form: CreatePostLikeForm) {
this.setAuth(form); this.setAuth(form);
this.ws.send(this.wsSendWrapper(UserOperation.CreatePostLike, form)); this.ws.send(this.wsSendWrapper(UserOperation.CreatePostLike, form));

View file

@ -150,6 +150,7 @@ export const de = {
sponsor_message: 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:', '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_patreon: 'Auf Patreon unterstützen',
support_on_liberapay: 'Auf Liberapay unterstützen',
general_sponsors: general_sponsors:
'Allgemeine Sponsoren sind die, die zwischen $10 und $39 zu Lemmy beitragen.', 'Allgemeine Sponsoren sind die, die zwischen $10 und $39 zu Lemmy beitragen.',
crypto: 'Kryptowährung', crypto: 'Kryptowährung',

View file

@ -171,6 +171,7 @@ export const en = {
sponsor_message: 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:', '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: 'Support on Patreon', support_on_patreon: 'Support on Patreon',
support_on_liberapay: 'Support on Liberapay',
donate_to_lemmy: 'Donate to Lemmy', donate_to_lemmy: 'Donate to Lemmy',
donate: 'Donate', donate: 'Donate',
general_sponsors: general_sponsors:
@ -200,6 +201,7 @@ export const en = {
couldnt_like_comment: "Couldn't like comment.", couldnt_like_comment: "Couldn't like comment.",
couldnt_update_comment: "Couldn't update comment.", couldnt_update_comment: "Couldn't update comment.",
couldnt_save_comment: "Couldn't save 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_comment_edit_allowed: 'Not allowed to edit comment.',
no_post_edit_allowed: 'Not allowed to edit post.', no_post_edit_allowed: 'Not allowed to edit post.',
no_community_edit_allowed: 'Not allowed to edit community.', no_community_edit_allowed: 'Not allowed to edit community.',
@ -210,6 +212,7 @@ export const en = {
community_follower_already_exists: 'Community follower already exists.', community_follower_already_exists: 'Community follower already exists.',
community_user_already_banned: 'Community user already banned.', community_user_already_banned: 'Community user already banned.',
couldnt_create_post: "Couldn't create post.", couldnt_create_post: "Couldn't create post.",
post_title_too_long: 'Post title too long.',
couldnt_like_post: "Couldn't like post.", couldnt_like_post: "Couldn't like post.",
couldnt_find_post: "Couldn't find post.", couldnt_find_post: "Couldn't find post.",
couldnt_get_posts: "Couldn't get posts", couldnt_get_posts: "Couldn't get posts",

View file

@ -169,6 +169,7 @@ export const es = {
sponsor_message: 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:', '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_patreon: 'Apoyo en Patreon',
support_on_liberapay: 'Apoyo en Liberapay',
donate_to_lemmy: 'Donar a Lemmy', donate_to_lemmy: 'Donar a Lemmy',
donate: 'Donar', donate: 'Donar',
general_sponsors: general_sponsors:

View file

@ -9,7 +9,8 @@ export const fr = {
posts: 'Publications', posts: 'Publications',
related_posts: 'Ces sujets peuvent être corrélés', related_posts: 'Ces sujets peuvent être corrélés',
cross_posts: 'Ce sujet a également été posté sur :', cross_posts: 'Ce sujet a également été posté sur :',
cross_post: 'crosspost', cross_post: 'publication croisée',
cross_posted_to: 'publication croisée à',
comments: 'Commentaires', comments: 'Commentaires',
number_of_comments: '{{count}} Commentaires', number_of_comments: '{{count}} Commentaires',
remove_comment: 'Supprimer le commentaire', remove_comment: 'Supprimer le commentaire',
@ -23,11 +24,18 @@ export const fr = {
list_of_communities: 'Liste des communautés', list_of_communities: 'Liste des communautés',
number_of_communities: '{{count}} communautés', number_of_communities: '{{count}} communautés',
community_reqs: 'en minuscule, sans espace et avec tiret du bas.', community_reqs: 'en minuscule, sans espace et avec tiret du bas.',
create_private_message: 'Créer un message privé',
send_secure_message: 'Envoyer le message sécurisé',
send_message: 'Enovyer le message',
message: 'Message',
edit: 'éditer', edit: 'éditer',
reply: 'répondre', reply: 'répondre',
cancel: 'Annuler', cancel: 'Annuler',
preview: 'prévisualiser', preview: 'prévisualiser',
upload_image: 'envoyer une image', upload_image: 'envoyer une image',
avatar: 'Avatar',
upload_avatar: 'Télécharger une avatar',
show_avatars: 'Afficher les avatars',
formatting_help: 'aide au formattage', formatting_help: 'aide au formattage',
view_source: 'voir la source', view_source: 'voir la source',
unlock: 'débloquer', unlock: 'débloquer',
@ -35,6 +43,7 @@ export const fr = {
sticky: 'épingler', sticky: 'épingler',
unsticky: 'décrocher', unsticky: 'décrocher',
link: 'lien', link: 'lien',
archive_link: 'archiver le lien',
mod: 'modérateur', mod: 'modérateur',
mods: 'modérateurs', mods: 'modérateurs',
moderates: 'Modérer', moderates: 'Modérer',
@ -89,6 +98,7 @@ export const fr = {
sort_type: 'Trier', sort_type: 'Trier',
hot: 'Tendances', hot: 'Tendances',
new: 'Nouveaux', new: 'Nouveaux',
old: 'Ancien',
top_day: 'Top du jour', top_day: 'Top du jour',
week: 'Semaine', week: 'Semaine',
month: 'Mois', month: 'Mois',
@ -96,12 +106,16 @@ export const fr = {
all: 'Tout', all: 'Tout',
top: 'Top', top: 'Top',
api: 'API', api: 'API',
docs: 'Documentations',
inbox: 'Boîte de réception', inbox: 'Boîte de réception',
inbox_for: 'Boîte de réception de <1>{{user}}</1>', inbox_for: 'Boîte de réception de <1>{{user}}</1>',
mark_all_as_read: 'Tout marquer comme lu', mark_all_as_read: 'Tout marquer comme lu',
type: 'Type', type: 'Type',
unread: 'Non-lu', unread: 'Non-lu',
replies: 'Réponses',
mentions: 'Mentions',
reply_sent: 'Réponse envoyée', reply_sent: 'Réponse envoyée',
message_sent: 'Message envoyé',
search: 'Rechercher', search: 'Rechercher',
overview: 'Général', overview: 'Général',
view: 'Voir', view: 'Voir',
@ -112,11 +126,29 @@ export const fr = {
notifications_error: notifications_error:
'Les notifications de bureau ne sont pas discponibles sur votre navigateur. Essayez Firefox ou Chrome.', 'Les notifications de bureau ne sont pas discponibles sur votre navigateur. Essayez Firefox ou Chrome.',
unread_messages: 'Messages non-lu', unread_messages: 'Messages non-lu',
messages: 'Messages',
password: 'Mot de passe', password: 'Mot de passe',
verify_password: 'Vérifiez le mot de passe', verify_password: 'Vérifiez le mot de passe',
old_password: 'Ancien mot de passe',
forgot_password: 'Mot de passe oublié',
reset_password_mail_sent: 'Un email a été envoyé pour réinitialiser votre mot de passe.',
password_change: 'Changement de mot de passe',
new_password: 'Nouveau mot de passe',
no_email_setup: "Ce serveur n'a pas correctement configuré la messagerie de email.",
email: 'Email', email: 'Email',
matrix_user_id: 'Utilisateur Matrix',
private_message_disclaimer:
"Attention: les messages privés en Matrix ne sont pas sécurisés. S'il vous plait, créer un compte de <1>Riot.im</1> pour des messages sécurisés.",
send_notifications_to_email: 'Envoyer des notifications par email',
optional: 'Optionnel', optional: 'Optionnel',
expires: 'Expire', expires: 'Expire',
language: 'Langue',
browser_default: 'Défaut pour le navigateur',
downvotes_disabled: 'Votes négatifs désactivés',
enable_downvotes: 'Votes négatifs activés',
open_registration: 'Ouvrir la regestration',
registration_closed: 'Régestration fermée',
enable_nsfw: 'Activer NSFW',
url: 'URL', url: 'URL',
body: 'Texte', body: 'Texte',
copy_suggested_title: 'Ajouter le titre suggéré: {{title}}', copy_suggested_title: 'Ajouter le titre suggéré: {{title}}',
@ -139,8 +171,10 @@ export const fr = {
sponsor_message: 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.", "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_patreon: 'Soutenir sur Patreon',
general_sponsors: support_on_liberapay: 'Soutenir sur Liberapay',
'General Sponsors are those that pledged $10 to $39 to Lemmy.', donate_to_lemmy: 'Faire un don à Lemmy',
donate: 'Faire un don',
general_sponsors: 'Les sponsors généraux sont ceux garantissant de 10 à 39$.',
crypto: 'Cryptomonnaies', crypto: 'Cryptomonnaies',
bitcoin: 'Bitcoin', bitcoin: 'Bitcoin',
ethereum: 'Ethereum', ethereum: 'Ethereum',
@ -149,6 +183,7 @@ export const fr = {
joined: 'Membre depuis', joined: 'Membre depuis',
by: 'par', by: 'par',
to: 'vers', to: 'vers',
from: 'de',
transfer_community: 'transférer la communauté', transfer_community: 'transférer la communauté',
transfer_site: 'transférer le site', transfer_site: 'transférer le site',
are_you_sure: 'Êtes-vous sûr ?', are_you_sure: 'Êtes-vous sûr ?',
@ -158,12 +193,14 @@ export const fr = {
landing_0: landing_0:
'Lemmy est un <1>aggrégateur de lien</1>, similaire à reddit et conçu pour fonctionner sur le <2>fédiverse</2>.<3></3>Il est auto-hébergeable, se met à jour en direct et est léger (<4>~80kB</4>). La fédération via Activitypub est prévue. <5></5>Lemmy est une <6>version beta très précoce</6>, et de nombreuses fonctionnalités sont manquantes ou non fonctionnelles. <7></7>Vous pouvez rapporter des bugs et suggérez de nouvelles fonctionnalités <8>ici.</8><9></9>Crée avec <10>Rust</10>, <11>Actix</11>, <12>Inferno</12>, <13>Typescript</13>.', 'Lemmy est un <1>aggrégateur de lien</1>, similaire à reddit et conçu pour fonctionner sur le <2>fédiverse</2>.<3></3>Il est auto-hébergeable, se met à jour en direct et est léger (<4>~80kB</4>). La fédération via Activitypub est prévue. <5></5>Lemmy est une <6>version beta très précoce</6>, et de nombreuses fonctionnalités sont manquantes ou non fonctionnelles. <7></7>Vous pouvez rapporter des bugs et suggérez de nouvelles fonctionnalités <8>ici.</8><9></9>Crée avec <10>Rust</10>, <11>Actix</11>, <12>Inferno</12>, <13>Typescript</13>.',
not_logged_in: "Vous n'êtes pas connecté.", not_logged_in: "Vous n'êtes pas connecté.",
logged_in: 'Vous êtes connecté.',
community_ban: 'Vous avez été banni de cette communauté.', community_ban: 'Vous avez été banni de cette communauté.',
site_ban: 'Vous avez été banni du site', site_ban: 'Vous avez été banni du site',
couldnt_create_comment: 'Impossible de poster le commentaire.', couldnt_create_comment: 'Impossible de poster le commentaire.',
couldnt_like_comment: "Impossible d'aimer le commentaire.", couldnt_like_comment: "Impossible d'aimer le commentaire.",
couldnt_update_comment: 'Impossible de mettre à jour le commentaire.', couldnt_update_comment: 'Impossible de mettre à jour le commentaire.',
couldnt_save_comment: 'Impossible de sauvegarder le commentaire.', couldnt_save_comment: 'Impossible de sauvegarder le commentaire.',
couldnt_get_comments: 'Impossible de obtenir les commentaires.',
no_comment_edit_allowed: no_comment_edit_allowed:
"Vous n'êtes pas autorisé à éditer ce commentaire.", "Vous n'êtes pas autorisé à éditer ce commentaire.",
no_post_edit_allowed: "Vous n'êtes pas autorisé à éditer sujet.", no_post_edit_allowed: "Vous n'êtes pas autorisé à éditer sujet.",
@ -176,6 +213,7 @@ export const fr = {
community_follower_already_exists: 'Ce membre est déjà abonné.', community_follower_already_exists: 'Ce membre est déjà abonné.',
community_user_already_banned: 'Ce membre est déjà banni.', community_user_already_banned: 'Ce membre est déjà banni.',
couldnt_create_post: 'Impossible de créer le sujet.', couldnt_create_post: 'Impossible de créer le sujet.',
post_title_too_long: 'Sujet titre trop long.',
couldnt_like_post: "Impossible d'aimer le sujet.", couldnt_like_post: "Impossible d'aimer le sujet.",
couldnt_find_post: 'Impossible de trouver le sujet.', couldnt_find_post: 'Impossible de trouver le sujet.',
couldnt_get_posts: "Impossible d'obtenir les sujets", couldnt_get_posts: "Impossible d'obtenir les sujets",
@ -191,8 +229,14 @@ export const fr = {
passwords_dont_match: 'Les mots de passes ne correspondent pas..', passwords_dont_match: 'Les mots de passes ne correspondent pas..',
admin_already_created: 'Désolé, il y a déjà un admin.', admin_already_created: 'Désolé, il y a déjà un admin.',
user_already_exists: "L'utilisateur existe déjà.", user_already_exists: "L'utilisateur existe déjà.",
email_already_exists: "L'email existe déjà",
couldnt_update_user: "Impossible de mettre à jour l'utilisateur.", couldnt_update_user: "Impossible de mettre à jour l'utilisateur.",
system_err_login: system_err_login:
'Erreur système. Essayez de vous déconneter puis de vous reconnecter.', 'Erreur système. Essayez de vous déconneter puis de vous reconnecter.',
couldnt_create_private_message: 'Impossible de créer un message privé.',
no_private_message_edit_allowed: 'Pas autorisé à modifier un message privé.',
couldnt_update_private_message: 'Impossible de modifier un message privé.',
time: 'Temps',
action: 'Action',
}, },
}; };

View file

@ -138,6 +138,7 @@ export const it = {
sponsors_of_lemmy: 'Sponsors di Lemmy', 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:', 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_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.', general_sponsors: 'I "General Sponsors" sono quelli che hanno investito dai 10$ ai 39$ su Lemmy.',
crypto: 'Crypto', crypto: 'Crypto',
bitcoin: 'Bitcoin', bitcoin: 'Bitcoin',

View file

@ -125,6 +125,7 @@ export const nl = {
sponsor_message: 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:', '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_patreon: 'Ondersteun op Patreon',
support_on_liberapay: 'Ondersteun op Liberapay',
general_sponsors: general_sponsors:
'Algemene sponsors zijn sponsors die tussen de $10 en $39 hebben gegeven aan Lemmy.', 'Algemene sponsors zijn sponsors die tussen de $10 en $39 hebben gegeven aan Lemmy.',
crypto: 'Cryptovaluta', crypto: 'Cryptovaluta',

241
ui/src/translations/pt_br.ts vendored Normal file
View file

@ -0,0 +1,241 @@
export const pt_BR = {
translation: {
post: 'publicação',
remove_post: 'Apagar publicação',
no_posts: 'Sem publicações.',
create_a_post: 'Criar uma publicação',
create_post: 'Criar publicação',
number_of_posts: '{{count}} publicações',
posts: 'Publicações',
related_posts: 'Essas publicações podem estar relacionadas',
cross_posts: 'Esse link também foi publicado em:',
cross_post: 're-publicar',
cross_posted_to: 'Publicado também em: ',
comments: 'Comentários',
number_of_comments: '{{count}} comentários',
remove_comment: 'Apagar comentário',
communities: 'Comunidades',
users: 'Usuários',
create_a_community: 'Criar uma comunidade',
create_community: 'Criar comunidade',
remove_community: 'Apagar comunidade',
subscribed_to_communities: 'Inscrito em <1>comunidades</1>',
trending_communities: '<1>Comunidades</1> em tendência',
list_of_communities: 'Lista de comunidades',
number_of_communities: '{{count}} comunidades',
community_reqs: 'minúsculas, sublinhados e sem espaços.',
create_private_message: 'Criar mensagem privada',
send_secure_message: 'Enviar mensagem segura',
send_message: 'Enviar mensagem',
message: 'Mensagem',
edit: 'editar',
reply: 'responder',
cancel: 'Cancelar',
preview: 'Pré-visualização',
upload_image: 'fazer upload de imagem',
avatar: 'Avatar',
upload_avatar: 'Fazer upload de avatar',
show_avatars: 'Mostrar Avatars',
formatting_help: 'ajuda de formatação',
view_source: 'ver fonte',
unlock: 'desbloquear',
lock: 'bloquear',
sticky: 'fixar',
unsticky: 'desafixar',
link: 'link',
archive_link: 'arquivar link',
mod: 'moderador',
mods: 'moderadores',
moderates: 'Modera',
settings: 'Configurações',
remove_as_mod: 'remover como moderador',
appoint_as_mod: 'designar como moderador',
modlog: 'Registro de moderação',
admin: 'administrador',
admins: 'administradores',
remove_as_admin: 'remover como administrador',
appoint_as_admin: 'designar como administrador',
remove: 'remover',
removed: 'removido',
locked: 'trancado',
stickied: 'fixado',
reason: 'Motivo',
mark_as_read: 'marcar como lido',
mark_as_unread: 'marcar como não lido',
delete: 'apagar',
deleted: 'apagado',
delete_account: 'Apagar conta',
delete_account_confirm:
'Aviso: isso vai apagar seus dados de forma permanente. Escreva sua senha para confirmar.',
restore: 'restaurar',
ban: 'banir',
ban_from_site: 'banido do site',
unban: 'readmitido',
unban_from_site: 'readmitido ao site',
banned: 'banido',
save: 'guardar',
unsave: 'descartar',
create: 'criar',
creator: 'criador',
username: 'nome de usuário',
email_or_username: 'E-mail ou nome de usuário',
number_of_users: '{{count}} usuários',
number_of_subscribers: '{{count}} inscritos',
number_of_points: '{{count}} pontos',
number_online: '{{count}} usuários online',
name: 'Nome',
title: 'Título',
category: 'Categoria',
subscribers: 'Inscritos',
both: 'Ambos',
saved: 'Guardado',
unsubscribe: 'Cancelar inscrição',
subscribe: 'Inscrever-se',
subscribed: 'Inscrito',
prev: 'Anterior',
next: 'Próximo',
sidebar: 'Barra lateral',
sort_type: 'Ordenação',
hot: 'Popular',
new: 'Novo',
old: 'Velho',
top_day: 'Top do dia',
week: 'Semana',
month: 'Mês',
year: 'Ano',
all: 'Tudo',
top: 'Top',
api: 'API',
docs: 'Docs',
inbox: 'Caixa de entrada',
inbox_for: 'Caixa de entrada de <1>{{user}}</1>',
mark_all_as_read: 'marcar tudo como lido',
type: 'Tipo',
unread: 'Não lido',
replies: 'Respostas',
mentions: 'Menções',
reply_sent: 'Resposta enviada',
message_sent: 'Mensagem enviada',
search: 'Busca',
overview: 'Visão geral',
view: 'Visualização',
logout: 'Sair',
login_sign_up: 'Entrar / Inscrever-se',
login: 'Entrar',
sign_up: 'Inscrever-se',
notifications_error:
'Seu navegador não oferece notificações para a área de trabalho. Tente o Firefox ou o Chrome.',
unread_messages: 'Mensagens não lidas',
messages: 'Mensagens',
password: 'Senha',
verify_password: 'Verifique a senha',
old_password: 'Senha antiga',
forgot_password: 'esqueci a senha',
reset_password_mail_sent: 'Enviado um e-mail para a alteração da senha.',
password_change: 'Alteração de senha',
new_password: 'Nova senha',
no_email_setup: 'Esse servidor não configurou corretamente o e-mail.',
email: 'E-mail',
matrix_user_id: 'Usuário Matrix',
private_message_disclaimer:
'Aviso: mensagens privadas no Lemmy não são seguras. Crie uma conta em <1>Riot.im</1> para troca segura de mensagens.',
send_notifications_to_email: 'Enviar notificações para o e-mail',
optional: 'Opcional',
expires: 'Expira',
language: 'Idioma',
browser_default: 'Padrão do navegador',
downvotes_disabled: 'Votos negativos desativados',
enable_downvotes: 'Permitir votos negativos',
open_registration: 'Permitir registro',
registration_closed: 'Registros desativados',
enable_nsfw: 'Permitir NSFW',
url: 'URL',
body: 'Conteúdo',
copy_suggested_title: 'copiar título sugerido: {{title}}',
community: 'Comunidade',
expand_here: 'Expandir aqui',
subscribe_to_communities: 'Inscreva-se em algumas <1>comunidades</1>.',
chat: 'Chat',
recent_comments: 'Últimos comentários',
no_results: 'Nenhum resultado.',
setup: 'Instalação',
lemmy_instance_setup: 'Criação de instância Lemmy',
setup_admin: 'Configurar administrador do site',
your_site: 'seu site',
modified: 'modificado',
nsfw: 'NSFW',
show_nsfw: 'Mostrar conteúdo NSFW',
theme: 'Tema',
sponsors: 'Patrocinadores',
sponsors_of_lemmy: 'Patrocinadores do Lemmy',
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:
'Patrocinadores são aqueles que doaram entre $10 e $39 ao Lemmy.',
crypto: 'Crypto',
bitcoin: 'Bitcoin',
ethereum: 'Ethereum',
monero: 'Monero',
code: 'Code',
joined: 'Entrou',
by: 'por',
to: 'para',
from: 'de',
transfer_community: 'transferir comunidade',
transfer_site: 'transferir site',
are_you_sure: 'tem certeza?',
yes: 'sim',
no: 'não',
powered_by: 'Powered by',
landing_0:
'Lemmy é um <1>agregador de links</1> / alternativa ao reddit, com a intenção de funcionar junto ao <2>fediverso</2>.<3></3>Pode ser hospedado em servidor próprio, tem atualização de comentários em tempo real e é minúsculo (<4>~80kB</4>). A federação com a rede ActivityPub está no roteiro do projeto. <5></5>Esta é uma <6>versão beta bastante antecipada</6>, e muitas funcionalidades ainda estão quebradas ou ausentes. <7></7>Sugira novas funcionalidades ou reporte erros <8>aqui.</8><9></9>Feito com <10>Rust</10>, <11>Actix</11>, <12>Inferno</12>, <13>Typescript</13>.',
not_logged_in: 'Não autenticado.',
logged_in: 'Autenticado.',
community_ban: 'Você foi banido desta comunidade.',
site_ban: 'Você foi banido do site',
couldnt_create_comment: 'Não foi possível criar o comentário.',
couldnt_like_comment: 'Não foi possível curtir o comentário.',
couldnt_update_comment: 'Não foi possível atualizar o comentário.',
couldnt_save_comment: 'Não foi possível guardar o comentário.',
no_comment_edit_allowed: 'Sem permissão para editar de comentário.',
no_post_edit_allowed: 'Sem permissão para editar publicação.',
no_community_edit_allowed: 'Sem permissão para editar comunidade.',
couldnt_find_community: 'Não foi possível encontrar a comunidade.',
couldnt_update_community: 'Não foi possível atualizar a comunidade.',
community_already_exists: 'Esta comunidade já existe.',
community_moderator_already_exists:
'Este moderador da comunidade já existe.',
community_follower_already_exists: 'Este seguidor da comunidade já existe.',
community_user_already_banned: 'Este usuário da comunidade já foi banido.',
couldnt_create_post: 'Não foi possível criar a publicação.',
couldnt_like_post: 'Não foi possível curtir a publicação.',
couldnt_find_post: 'Não foi possível encontrar a publicação.',
couldnt_get_posts: 'Não foi possível obter as publicações',
couldnt_update_post: 'Não foi possível atualizar a publicação',
couldnt_save_post: 'Não foi possível guardar a publicação.',
no_slurs: 'Sem insultos.',
not_an_admin: 'Não é administrador.',
site_already_exists: 'O site já existe.',
couldnt_update_site: 'Não foi possível atualizar o site.',
couldnt_find_that_username_or_email:
'Não foi possível encontrar esse usuário ou e-mail.',
password_incorrect: 'Senha incorreta.',
passwords_dont_match: 'As senhas não são iguais.',
admin_already_created: 'Desculpe, já há um administrador.',
user_already_exists: 'Este usuário já existe.',
email_already_exists: 'Este e-mail já existe.',
couldnt_update_user: 'Não foi possível atualizar o usuário.',
system_err_login: 'Erro no sistema. Tente sair e autenticar-se outra vez.',
couldnt_create_private_message: 'Não foi possível criar mensagem privada.',
no_private_message_edit_allowed:
'Sem permissão para editar mensagem privada.',
couldnt_update_private_message:
'Não foi possível atualizar a mensagem privada.',
time: 'Tempo',
action: 'Ação',
},
};

View file

@ -116,6 +116,7 @@ export const zh = {
sponsor_message: 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:', '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_patreon: '在 Patreon 赞助',
support_on_liberapay: '在 on 赞助',
general_sponsors: general_sponsors:
'General Sponsors are those that pledged $10 to $39 to Lemmy.', 'General Sponsors are those that pledged $10 to $39 to Lemmy.',
crypto: '加密', crypto: '加密',

244
ui/src/utils.ts vendored
View file

@ -10,19 +10,26 @@ import 'moment/locale/it';
import 'moment/locale/fi'; import 'moment/locale/fi';
import 'moment/locale/ca'; import 'moment/locale/ca';
import 'moment/locale/fa'; import 'moment/locale/fa';
import 'moment/locale/pt-br';
import { import {
UserOperation, UserOperation,
Comment, Comment,
CommentNode,
Post,
PrivateMessage, PrivateMessage,
User, User,
SortType, SortType,
CommentSortType,
ListingType, ListingType,
DataType,
SearchType, SearchType,
WebSocketResponse, WebSocketResponse,
WebSocketJsonResponse, WebSocketJsonResponse,
SearchForm, SearchForm,
SearchResponse, SearchResponse,
CommentResponse,
PostResponse,
} from './interfaces'; } from './interfaces';
import { UserService, WebSocketService } from './services'; import { UserService, WebSocketService } from './services';
@ -35,7 +42,7 @@ import emojiShortName from 'emoji-short-name';
import Toastify from 'toastify-js'; import Toastify from 'toastify-js';
export const repoUrl = 'https://github.com/dessalines/lemmy'; export const repoUrl = 'https://github.com/dessalines/lemmy';
export const markdownHelpUrl = 'https://commonmark.org/help/'; export const markdownHelpUrl = '/docs/about_guide.html';
export const archiveUrl = 'https://archive.is'; export const archiveUrl = 'https://archive.is';
export const postRefetchSeconds: number = 60 * 1000; export const postRefetchSeconds: number = 60 * 1000;
@ -87,15 +94,22 @@ md.renderer.rules.emoji = function(token, idx) {
return twemoji.parse(token[idx].content); return twemoji.parse(token[idx].content);
}; };
export function hotRank(comment: Comment): number { export function hotRankComment(comment: Comment): number {
// Rank = ScaleFactor * sign(Score) * log(1 + abs(Score)) / (Time + 2)^Gravity return hotRank(comment.score, comment.published);
}
let date: Date = new Date(comment.published + 'Z'); // Add Z to convert from UTC date export function hotRankPost(post: Post): number {
return hotRank(post.score, post.newest_activity_time);
}
export function hotRank(score: number, timeStr: string): number {
// Rank = ScaleFactor * sign(Score) * log(1 + abs(Score)) / (Time + 2)^Gravity
let date: Date = new Date(timeStr + 'Z'); // Add Z to convert from UTC date
let now: Date = new Date(); let now: Date = new Date();
let hoursElapsed: number = (now.getTime() - date.getTime()) / 36e5; let hoursElapsed: number = (now.getTime() - date.getTime()) / 36e5;
let rank = let rank =
(10000 * Math.log10(Math.max(1, 3 + comment.score))) / (10000 * Math.log10(Math.max(1, 3 + score))) /
Math.pow(hoursElapsed + 2, 1.8); Math.pow(hoursElapsed + 2, 1.8);
// console.log(`Comment: ${comment.content}\nRank: ${rank}\nScore: ${comment.score}\nHours: ${hoursElapsed}`); // console.log(`Comment: ${comment.content}\nRank: ${rank}\nScore: ${comment.score}\nHours: ${hoursElapsed}`);
@ -197,14 +211,18 @@ export function routeListingTypeToEnum(type: string): ListingType {
return ListingType[capitalizeFirstLetter(type)]; return ListingType[capitalizeFirstLetter(type)];
} }
export function routeDataTypeToEnum(type: string): DataType {
return DataType[capitalizeFirstLetter(type)];
}
export function routeSearchTypeToEnum(type: string): SearchType { export function routeSearchTypeToEnum(type: string): SearchType {
return SearchType[capitalizeFirstLetter(type)]; return SearchType[capitalizeFirstLetter(type)];
} }
export async function getPageTitle(url: string) { export async function getPageTitle(url: string) {
let res = await fetch(`https://textance.herokuapp.com/title/${url}`); let res = await fetch(`/iframely/oembed?url=${url}`).then(res => res.json());
let data = await res.text(); let title = await res.title;
return data; return title;
} }
export function debounce( export function debounce(
@ -260,6 +278,7 @@ export const languages = [
{ code: 'es', name: 'Español' }, { code: 'es', name: 'Español' },
{ code: 'de', name: 'Deutsch' }, { code: 'de', name: 'Deutsch' },
{ code: 'fa', name: 'فارسی' }, { code: 'fa', name: 'فارسی' },
{ code: 'pt_BR', name: 'Português Brasileiro' },
{ code: 'zh', name: '中文' }, { code: 'zh', name: '中文' },
{ code: 'fi', name: 'Suomi' }, { code: 'fi', name: 'Suomi' },
{ code: 'fr', name: 'Français' }, { code: 'fr', name: 'Français' },
@ -310,6 +329,8 @@ export function getMomentLanguage(): string {
lang = 'ca'; lang = 'ca';
} else if (lang.startsWith('fa')) { } else if (lang.startsWith('fa')) {
lang = 'fa'; lang = 'fa';
} else if (lang.startsWith('pt')) {
lang = 'pt-br';
} else { } else {
lang = 'en'; lang = 'en';
} }
@ -365,7 +386,7 @@ export function objectFlip(obj: any) {
export function pictshareAvatarThumbnail(src: string): string { export function pictshareAvatarThumbnail(src: string): string {
// sample url: http://localhost:8535/pictshare/gs7xuu.jpg // sample url: http://localhost:8535/pictshare/gs7xuu.jpg
let split = src.split('pictshare'); let split = src.split('pictshare');
let out = `${split[0]}pictshare/96x96${split[1]}`; let out = `${split[0]}pictshare/96${split[1]}`;
return out; return out;
} }
@ -380,7 +401,7 @@ export function showAvatars(): boolean {
export function imageThumbnailer(url: string): string { export function imageThumbnailer(url: string): string {
let split = url.split('pictshare'); let split = url.split('pictshare');
if (split.length > 1) { if (split.length > 1) {
let out = `${split[0]}pictshare/140x140${split[1]}`; let out = `${split[0]}pictshare/192${split[1]}`;
return out; return out;
} else { } else {
return url; return url;
@ -515,3 +536,206 @@ function communitySearch(text: string, cb: any) {
cb([]); 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;
}
export function editCommentRes(
data: CommentResponse,
comments: Array<Comment>
) {
let found = 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;
}
}
export function saveCommentRes(
data: CommentResponse,
comments: Array<Comment>
) {
let found = comments.find(c => c.id == data.comment.id);
if (found) {
found.saved = data.comment.saved;
}
}
export function createCommentLikeRes(
data: CommentResponse,
comments: Array<Comment>
) {
let found: Comment = comments.find(c => c.id === data.comment.id);
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;
}
}
}
export function createPostLikeFindRes(data: PostResponse, posts: Array<Post>) {
let found = posts.find(c => c.id == data.post.id);
if (found) {
createPostLikeRes(data, found);
}
}
export function createPostLikeRes(data: PostResponse, post: Post) {
if (post) {
post.score = data.post.score;
post.upvotes = data.post.upvotes;
post.downvotes = data.post.downvotes;
if (data.post.my_vote !== null) {
post.my_vote = data.post.my_vote;
}
}
}
export function editPostFindRes(data: PostResponse, posts: Array<Post>) {
let found = posts.find(c => c.id == data.post.id);
if (found) {
editPostRes(data, found);
}
}
export function editPostRes(data: PostResponse, post: Post) {
if (post) {
post.url = data.post.url;
post.name = data.post.name;
post.nsfw = data.post.nsfw;
}
}
export function commentsToFlatNodes(
comments: Array<Comment>
): Array<CommentNode> {
let nodes: Array<CommentNode> = [];
for (let comment of comments) {
nodes.push({ comment: comment });
}
return nodes;
}
export function commentSort(tree: Array<CommentNode>, sort: CommentSortType) {
// First, put removed and deleted comments at the bottom, then do your other sorts
if (sort == CommentSortType.Top) {
tree.sort(
(a, b) =>
+a.comment.removed - +b.comment.removed ||
+a.comment.deleted - +b.comment.deleted ||
b.comment.score - a.comment.score
);
} else if (sort == CommentSortType.New) {
tree.sort(
(a, b) =>
+a.comment.removed - +b.comment.removed ||
+a.comment.deleted - +b.comment.deleted ||
b.comment.published.localeCompare(a.comment.published)
);
} else if (sort == CommentSortType.Old) {
tree.sort(
(a, b) =>
+a.comment.removed - +b.comment.removed ||
+a.comment.deleted - +b.comment.deleted ||
a.comment.published.localeCompare(b.comment.published)
);
} else if (sort == CommentSortType.Hot) {
tree.sort(
(a, b) =>
+a.comment.removed - +b.comment.removed ||
+a.comment.deleted - +b.comment.deleted ||
hotRankComment(b.comment) - hotRankComment(a.comment)
);
}
// Go through the children recursively
for (let node of tree) {
if (node.children) {
commentSort(node.children, sort);
}
}
}
export function commentSortSortType(tree: Array<CommentNode>, sort: SortType) {
commentSort(tree, convertCommentSortType(sort));
}
function convertCommentSortType(sort: SortType): CommentSortType {
if (
sort == SortType.TopAll ||
sort == SortType.TopDay ||
sort == SortType.TopWeek ||
sort == SortType.TopMonth ||
sort == SortType.TopYear
) {
return CommentSortType.Top;
} else if (sort == SortType.New) {
return CommentSortType.New;
} else if (sort == SortType.Hot) {
return CommentSortType.Hot;
} else {
return CommentSortType.Hot;
}
}
export function postSort(posts: Array<Post>, sort: SortType) {
// First, put removed and deleted comments at the bottom, then do your other sorts
if (
sort == SortType.TopAll ||
sort == SortType.TopDay ||
sort == SortType.TopWeek ||
sort == SortType.TopMonth ||
sort == SortType.TopYear
) {
posts.sort(
(a, b) =>
+a.removed - +b.removed || +a.deleted - +b.deleted || b.score - a.score
);
} else if (sort == SortType.New) {
posts.sort(
(a, b) =>
+a.removed - +b.removed ||
+a.deleted - +b.deleted ||
b.published.localeCompare(a.published)
);
} else if (sort == SortType.Hot) {
posts.sort(
(a, b) =>
+a.removed - +b.removed ||
+a.deleted - +b.deleted ||
hotRankPost(b) - hotRankPost(a)
);
}
}

2
ui/src/version.ts vendored
View file

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

View file

@ -11,6 +11,7 @@ import { nl } from './src/translations/nl';
import { it } from './src/translations/it'; import { it } from './src/translations/it';
import { fi } from './src/translations/fi'; import { fi } from './src/translations/fi';
import { ca } from './src/translations/ca'; import { ca } from './src/translations/ca';
import { pt_BR } from './src/translations/pt_br';
import fs from 'fs'; import fs from 'fs';
const files = [ const files = [
@ -23,6 +24,7 @@ const files = [
{ t: fr, n: 'fr' }, { t: fr, n: 'fr' },
{ t: it, n: 'it' }, { t: it, n: 'it' },
{ t: nl, n: 'nl' }, { t: nl, n: 'nl' },
{ t: pt_BR, n: 'pt-br' },
{ t: ru, n: 'ru' }, { t: ru, n: 'ru' },
{ t: sv, n: 'sv' }, { t: sv, n: 'sv' },
{ t: zh, n: 'zh' }, { t: zh, n: 'zh' },

21
ui/yarn.lock vendored
View file

@ -1079,10 +1079,10 @@ emoji-regex@^8.0.0:
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37"
integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==
emoji-short-name@^0.1.0: emoji-short-name@^1.0.0:
version "0.1.4" version "1.0.0"
resolved "https://registry.yarnpkg.com/emoji-short-name/-/emoji-short-name-0.1.4.tgz#125a452adc22a399b089f802f9d8d46ecb6e5b08" resolved "https://registry.yarnpkg.com/emoji-short-name/-/emoji-short-name-1.0.0.tgz#82e6f543b6c68984d69bdc80eac735104fdd4af8"
integrity sha512-VTjEKkhN1UARtHLqlK70N5K3SwxuZAkmdm5sXvSjkV677kr0jt/O7mvB5eQqM+3rKCa+w3Qb5G7wwU/fezonKQ== integrity sha512-+tiniHvgRR7XMI1jAaGveumWg5LALE/nWkFD6CcOn6M5IDM9w4PkMs8UwzLTMoZtDLdTdQmzxGvLOxHVIjPzjg==
encodeurl@~1.0.2: encodeurl@~1.0.2:
version "1.0.2" version "1.0.2"
@ -3076,6 +3076,11 @@ mkdirp@^0.5.1:
dependencies: dependencies:
minimist "0.0.8" minimist "0.0.8"
mobius1-selectr@^2.4.13:
version "2.4.13"
resolved "https://registry.yarnpkg.com/mobius1-selectr/-/mobius1-selectr-2.4.13.tgz#0019dfd9f984840d6e40f70683ab3ec78ce3b5df"
integrity sha512-Mk9qDrvU44UUL0EBhbAA1phfQZ7aMZPjwtL7wkpiBzGh8dETGqfsh50mWoX9EkjDlkONlErWXArHCKfoxVg0Bw==
moment@^2.24.0: moment@^2.24.0:
version "2.24.0" version "2.24.0"
resolved "https://registry.yarnpkg.com/moment/-/moment-2.24.0.tgz#0d055d53f5052aa653c9f6eb68bb5d12bf5c2b5b" resolved "https://registry.yarnpkg.com/moment/-/moment-2.24.0.tgz#0d055d53f5052aa653c9f6eb68bb5d12bf5c2b5b"
@ -3731,10 +3736,10 @@ realm-utils@^1.0.9:
app-root-path "^1.3.0" app-root-path "^1.3.0"
mkdirp "^0.5.1" mkdirp "^0.5.1"
reconnecting-websocket@^4.3.0: reconnecting-websocket@^4.4.0:
version "4.3.0" version "4.4.0"
resolved "https://registry.yarnpkg.com/reconnecting-websocket/-/reconnecting-websocket-4.3.0.tgz#aaefbc7629a89450aa45324b89aec2276e728cc5" resolved "https://registry.yarnpkg.com/reconnecting-websocket/-/reconnecting-websocket-4.4.0.tgz#3b0e5b96ef119e78a03135865b8bb0af1b948783"
integrity sha512-3eaHIEVYB9Zb0GfYy1xdEHKJLA2JaawAegByZ1AZ8Npb3AiRgUN5l89cvE2H+pHTsFcoC88t32ky9qET6DJ75Q== integrity sha512-D2E33ceRPga0NvTDhJmphEgJ7FUYF0v4lr1ki0csq06OdlxKfugGzN0dSkxM/NfqCxYELK4KcaTOUOjTV6Dcng==
regenerate-unicode-properties@^8.1.0: regenerate-unicode-properties@^8.1.0:
version "8.1.0" version "8.1.0"