Merge branch 'main' into federation-authorisation

This commit is contained in:
Felix Ableitner 2020-08-07 15:19:08 +02:00
commit 0cc49e6ca9
77 changed files with 3650 additions and 1112 deletions

40
RELEASES.md vendored
View file

@ -1,3 +1,43 @@
# Lemmy v0.7.40 Pre-Release (2020-08-05)
We've [added a lot](https://github.com/LemmyNet/lemmy/compare/v0.7.40...v0.7.0) in this pre-release:
- New post sorts `Active` (previously called hot), and `Hot`. Active shows posts with recent comments, hot shows highly ranked posts.
- Customizeable site icon and banner, user icon and banner, and community icon and banner.
- Added user preferred names / display names, bios, and cakedays.
- User settings are now shared across browsers (a page refresh will pick up changes).
- Visual / Audio captchas through the lemmy API.
- Lots of UI prettiness.
- Lots of bug fixes.
- Lots of additional translations.
- Lots of federation prepping / additions / refactors.
This release removes the need for you to have a pictrs nginx route (the requests are now routed through lemmy directly). Follow the upgrade instructions below to replace your nginx with the new one.
## Upgrading
**With Ansible:**
```
# run these commands locally
git pull
cd ansible
ansible-playbook lemmy.yml
```
**With manual Docker installation:**
```
# run these commands on your server
cd /lemmy
wget https://raw.githubusercontent.com/LemmyNet/lemmy/master/ansible/templates/nginx.conf
# Replace the {{ vars }}
sudo mv nginx.conf /etc/nginx/sites-enabled/lemmy.conf
sudo nginx -s reload
wget https://raw.githubusercontent.com/LemmyNet/lemmy/master/docker/prod/docker-compose.yml
sudo docker-compose up -d
```
# Lemmy v0.7.0 Release (2020-06-23)
This release replaces [pictshare](https://github.com/HaschekSolutions/pictshare)

2
ansible/VERSION vendored
View file

@ -1 +1 @@
v0.7.39
v0.7.43

View file

@ -74,18 +74,6 @@ server {
return 301 /pictrs/image/$1;
}
# pict-rs images
location /pictrs {
location /pictrs/image {
proxy_pass http://0.0.0.0:8537/image;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Block the import
return 403;
}
location /iframely/ {
proxy_pass http://0.0.0.0:8061/;
proxy_set_header X-Real-IP $remote_addr;

View file

@ -21,7 +21,8 @@ services:
postgres:
image: postgres:12-alpine
ports:
- "127.0.0.1:5432:5432"
# use a different port so it doesnt conflict with postgres running on the host
- "127.0.0.1:5433:5432"
environment:
- POSTGRES_USER=lemmy
- POSTGRES_PASSWORD=password

View file

@ -26,18 +26,6 @@ http {
proxy_set_header Connection "upgrade";
}
# pict-rs images
location /pictrs {
location /pictrs/image {
proxy_pass http://pictrs:8080/image;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Block the import
return 403;
}
location /iframely/ {
proxy_pass http://iframely:80/;
proxy_set_header X-Real-IP $remote_addr;
@ -69,18 +57,6 @@ http {
proxy_set_header Connection "upgrade";
}
# pict-rs images
location /pictrs {
location /pictrs/image {
proxy_pass http://pictrs:8080/image;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Block the import
return 403;
}
location /iframely/ {
proxy_pass http://iframely:80/;
proxy_set_header X-Real-IP $remote_addr;
@ -112,18 +88,6 @@ http {
proxy_set_header Connection "upgrade";
}
# pict-rs images
location /pictrs {
location /pictrs/image {
proxy_pass http://pictrs:8080/image;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Block the import
return 403;
}
location /iframely/ {
proxy_pass http://iframely:80/;
proxy_set_header X-Real-IP $remote_addr;

9
docker/lemmy.hjson vendored
View file

@ -2,6 +2,15 @@
# for more info about the config, check out the documentation
# https://dev.lemmy.ml/docs/administration_configuration.html
setup: {
# username for the admin user
admin_username: "lemmy"
# password for the admin user
admin_password: "lemmy"
# name of the site (can be changed later)
site_name: "lemmy-test"
}
# the domain name of your instance (eg "dev.lemmy.ml")
hostname: "my_domain"
# address where lemmy should listen for incoming requests

View file

@ -12,7 +12,7 @@ services:
restart: always
lemmy:
image: dessalines/lemmy:v0.7.39
image: dessalines/lemmy:v0.7.43
ports:
- "127.0.0.1:8536:8536"
restart: always

View file

@ -1,5 +1,5 @@
#!/bin/sh
echo "$DOCKER_PASSWORD" | docker login -u "$DOCKER_USERNAME" --password-stdin
docker tag dessalines/lemmy:travis \
dessalines/lemmy:v0.7.39
docker push dessalines/lemmy:v0.7.39
dessalines/lemmy:v0.7.43
docker push dessalines/lemmy:v0.7.43

View file

@ -18,7 +18,9 @@ Score = Upvotes - Downvotes
Time = time since submission (in hours)
Gravity = Decay gravity, 1.8 is default
```
- For posts, in order to bring up active posts, it uses the latest comment time (limited to a max creation age of a month ago)
- Lemmy uses the same `Rank` algorithm above, in two sorts: `Active`, and `Hot`.
- `Active` uses the post votes, and latest comment time (limited to two days).
- `Hot` uses the post votes, and the post published time.
- Use Max(1, score) to make sure all comments are affected by time decay.
- Add 3 to the score, so that everything that has less than 3 downvotes will seem new. Otherwise all new comments would stay at zero, near the bottom.
- The sign and abs of the score are necessary for dealing with the log of negative scores.

View file

@ -330,7 +330,8 @@ curl -i -H \
These go wherever there is a `sort` field. The available sort types are:
- `Hot` - the hottest posts/communities, depending on votes, views, comments and publish date
- `Active` - the hottest posts/communities, depending on votes, and newest comment publish date.
- `Hot` - the hottest posts/communities, depending on votes and publish date.
- `New` - the newest posts/communities
- `TopDay` - the most upvoted posts/communities of the current day.
- `TopWeek` - the most upvoted posts/communities of the current week.
@ -482,7 +483,19 @@ These expire after 10 minutes.
theme: String, // Default 'darkly'
default_sort_type: i16, // The Sort types from above, zero indexed as a number
default_listing_type: i16, // Post listing types are `All, Subscribed, Community`
auth: String
lang: String,
avatar: Option<String>,
banner: Option<String>,
preferred_username: Option<String>,
email: Option<String>,
bio: Option<String>,
matrix_user_id: Option<String>,
new_password: Option<String>,
new_password_verify: Option<String>,
old_password: Option<String>,
show_avatars: bool,
send_notifications_to_email: bool,
auth: String,
}
}
```
@ -924,6 +937,8 @@ Search types are `All, Comments, Posts, Communities, Users, Url`
data: {
name: String,
description: Option<String>,
icon: Option<String>,
banner: Option<String>,
auth: String
}
}
@ -950,6 +965,8 @@ Search types are `All, Comments, Posts, Communities, Users, Url`
data: {
name: String,
description: Option<String>,
icon: Option<String>,
banner: Option<String>,
auth: String
}
}
@ -1105,6 +1122,8 @@ Search types are `All, Comments, Posts, Communities, Users, Url`
name: String,
title: String,
description: Option<String>,
icon: Option<String>,
banner: Option<String>,
category_id: i32 ,
auth: String
}
@ -1215,6 +1234,8 @@ Only mods can edit a community.
edit_id: i32,
title: String,
description: Option<String>,
icon: Option<String>,
banner: Option<String>,
category_id: i32,
auth: String
}

View file

@ -35,6 +35,8 @@
jwt_secret: "changeme"
# The location of the frontend
front_end_dir: "../ui/dist"
# address where pictrs is available
pictrs_url: "http://pictrs:8080"
# rate limits for various user actions, by user ip
rate_limit: {
# maximum number of messages created in interval
@ -49,6 +51,10 @@
register: 3
# interval length for registration limit
register_per_second: 3600
# maximum number of image uploads in interval
image: 6
# interval length for image uploads
image_per_second: 3600
}
# settings related to activitypub federation
federation: {

View file

@ -107,6 +107,7 @@ mod tests {
email: None,
matrix_user_id: None,
avatar: None,
banner: None,
admin: false,
banned: false,
updated: None,

View file

@ -255,6 +255,7 @@ mod tests {
email: None,
matrix_user_id: None,
avatar: None,
banner: None,
admin: false,
banned: false,
updated: None,
@ -291,6 +292,8 @@ mod tests {
public_key: None,
last_refreshed_at: None,
published: None,
banner: None,
icon: None,
};
let inserted_community = Community::create(&conn, &new_community).unwrap();

View file

@ -23,17 +23,20 @@ table! {
community_actor_id -> Text,
community_local -> Bool,
community_name -> Varchar,
community_icon -> Nullable<Text>,
banned -> Bool,
banned_from_community -> Bool,
creator_actor_id -> Text,
creator_local -> Bool,
creator_name -> Varchar,
creator_preferred_username -> Nullable<Varchar>,
creator_published -> Timestamp,
creator_avatar -> Nullable<Text>,
score -> BigInt,
upvotes -> BigInt,
downvotes -> BigInt,
hot_rank -> Int4,
hot_rank_active -> Int4,
user_id -> Nullable<Int4>,
my_vote -> Nullable<Int4>,
subscribed -> Nullable<Bool>,
@ -60,17 +63,20 @@ table! {
community_actor_id -> Text,
community_local -> Bool,
community_name -> Varchar,
community_icon -> Nullable<Text>,
banned -> Bool,
banned_from_community -> Bool,
creator_actor_id -> Text,
creator_local -> Bool,
creator_name -> Varchar,
creator_preferred_username -> Nullable<Varchar>,
creator_published -> Timestamp,
creator_avatar -> Nullable<Text>,
score -> BigInt,
upvotes -> BigInt,
downvotes -> BigInt,
hot_rank -> Int4,
hot_rank_active -> Int4,
user_id -> Nullable<Int4>,
my_vote -> Nullable<Int4>,
subscribed -> Nullable<Bool>,
@ -100,17 +106,20 @@ pub struct CommentView {
pub community_actor_id: String,
pub community_local: bool,
pub community_name: String,
pub community_icon: Option<String>,
pub banned: bool,
pub banned_from_community: bool,
pub creator_actor_id: String,
pub creator_local: bool,
pub creator_name: String,
pub creator_preferred_username: Option<String>,
pub creator_published: chrono::NaiveDateTime,
pub creator_avatar: Option<String>,
pub score: i64,
pub upvotes: i64,
pub downvotes: i64,
pub hot_rank: i32,
pub hot_rank_active: i32,
pub user_id: Option<i32>,
pub my_vote: Option<i32>,
pub subscribed: Option<bool>,
@ -244,6 +253,9 @@ impl<'a> CommentQueryBuilder<'a> {
SortType::Hot => query
.order_by(hot_rank.desc())
.then_order_by(published.desc()),
SortType::Active => query
.order_by(hot_rank_active.desc())
.then_order_by(published.desc()),
SortType::New => query.order_by(published.desc()),
SortType::TopAll => query.order_by(score.desc()),
SortType::TopYear => query
@ -315,17 +327,20 @@ table! {
community_actor_id -> Text,
community_local -> Bool,
community_name -> Varchar,
community_icon -> Nullable<Varchar>,
banned -> Bool,
banned_from_community -> Bool,
creator_actor_id -> Text,
creator_local -> Bool,
creator_name -> Varchar,
creator_preferred_username -> Nullable<Varchar>,
creator_avatar -> Nullable<Text>,
creator_published -> Timestamp,
score -> BigInt,
upvotes -> BigInt,
downvotes -> BigInt,
hot_rank -> Int4,
hot_rank_active -> Int4,
user_id -> Nullable<Int4>,
my_vote -> Nullable<Int4>,
subscribed -> Nullable<Bool>,
@ -356,17 +371,20 @@ pub struct ReplyView {
pub community_actor_id: String,
pub community_local: bool,
pub community_name: String,
pub community_icon: Option<String>,
pub banned: bool,
pub banned_from_community: bool,
pub creator_actor_id: String,
pub creator_local: bool,
pub creator_name: String,
pub creator_preferred_username: Option<String>,
pub creator_avatar: Option<String>,
pub creator_published: chrono::NaiveDateTime,
pub score: i64,
pub upvotes: i64,
pub downvotes: i64,
pub hot_rank: i32,
pub hot_rank_active: i32,
pub user_id: Option<i32>,
pub my_vote: Option<i32>,
pub subscribed: Option<bool>,
@ -437,7 +455,7 @@ impl<'a> ReplyQueryBuilder<'a> {
}
query = match self.sort {
// SortType::Hot => query.order_by(hot_rank.desc()),
// SortType::Hot => query.order_by(hot_rank.desc()), // TODO why is this commented
SortType::New => query.order_by(published.desc()),
SortType::TopAll => query.order_by(score.desc()),
SortType::TopYear => query
@ -488,6 +506,7 @@ mod tests {
email: None,
matrix_user_id: None,
avatar: None,
banner: None,
admin: false,
banned: false,
updated: None,
@ -524,6 +543,8 @@ mod tests {
public_key: None,
last_refreshed_at: None,
published: None,
icon: None,
banner: None,
};
let inserted_community = Community::create(&conn, &new_community).unwrap();
@ -584,6 +605,7 @@ mod tests {
post_name: inserted_post.name.to_owned(),
community_id: inserted_community.id,
community_name: inserted_community.name.to_owned(),
community_icon: None,
parent_id: None,
removed: false,
deleted: false,
@ -593,11 +615,13 @@ mod tests {
published: inserted_comment.published,
updated: None,
creator_name: inserted_user.name.to_owned(),
creator_preferred_username: None,
creator_published: inserted_user.published,
creator_avatar: None,
score: 1,
downvotes: 0,
hot_rank: 0,
hot_rank_active: 0,
upvotes: 1,
user_id: None,
my_vote: None,
@ -619,6 +643,7 @@ mod tests {
post_name: inserted_post.name.to_owned(),
community_id: inserted_community.id,
community_name: inserted_community.name.to_owned(),
community_icon: None,
parent_id: None,
removed: false,
deleted: false,
@ -628,11 +653,13 @@ mod tests {
published: inserted_comment.published,
updated: None,
creator_name: inserted_user.name.to_owned(),
creator_preferred_username: None,
creator_published: inserted_user.published,
creator_avatar: None,
score: 1,
downvotes: 0,
hot_rank: 0,
hot_rank_active: 0,
upvotes: 1,
user_id: Some(inserted_user.id),
my_vote: Some(1),
@ -651,6 +678,7 @@ mod tests {
.list()
.unwrap();
read_comment_views_no_user[0].hot_rank = 0;
read_comment_views_no_user[0].hot_rank_active = 0;
let mut read_comment_views_with_user = CommentQueryBuilder::create(&conn)
.for_post_id(inserted_post.id)
@ -658,6 +686,7 @@ mod tests {
.list()
.unwrap();
read_comment_views_with_user[0].hot_rank = 0;
read_comment_views_with_user[0].hot_rank_active = 0;
let like_removed = CommentLike::remove(&conn, &comment_like_form).unwrap();
let num_deleted = Comment::delete(&conn, inserted_comment.id).unwrap();

View file

@ -28,6 +28,8 @@ pub struct Community {
pub private_key: Option<String>,
pub public_key: Option<String>,
pub last_refreshed_at: chrono::NaiveDateTime,
pub icon: Option<String>,
pub banner: Option<String>,
}
#[derive(Insertable, AsChangeset, Clone, Serialize, Deserialize, Debug)]
@ -48,6 +50,8 @@ pub struct CommunityForm {
pub private_key: Option<String>,
pub public_key: Option<String>,
pub last_refreshed_at: Option<chrono::NaiveDateTime>,
pub icon: Option<Option<String>>,
pub banner: Option<Option<String>>,
}
impl Crud<CommunityForm> for Community {
@ -299,6 +303,7 @@ mod tests {
email: None,
matrix_user_id: None,
avatar: None,
banner: None,
admin: false,
banned: false,
updated: None,
@ -335,6 +340,8 @@ mod tests {
public_key: None,
last_refreshed_at: None,
published: None,
icon: None,
banner: None,
};
let inserted_community = Community::create(&conn, &new_community).unwrap();
@ -356,6 +363,8 @@ mod tests {
private_key: None,
public_key: None,
last_refreshed_at: inserted_community.published,
icon: None,
banner: None,
};
let community_follower_form = CommunityFollowerForm {

View file

@ -8,6 +8,8 @@ table! {
id -> Int4,
name -> Varchar,
title -> Varchar,
icon -> Nullable<Text>,
banner -> Nullable<Text>,
description -> Nullable<Text>,
category_id -> Int4,
creator_id -> Int4,
@ -22,6 +24,7 @@ table! {
creator_actor_id -> Text,
creator_local -> Bool,
creator_name -> Varchar,
creator_preferred_username -> Nullable<Varchar>,
creator_avatar -> Nullable<Text>,
category_name -> Varchar,
number_of_subscribers -> BigInt,
@ -38,6 +41,8 @@ table! {
id -> Int4,
name -> Varchar,
title -> Varchar,
icon -> Nullable<Text>,
banner -> Nullable<Text>,
description -> Nullable<Text>,
category_id -> Int4,
creator_id -> Int4,
@ -52,6 +57,7 @@ table! {
creator_actor_id -> Text,
creator_local -> Bool,
creator_name -> Varchar,
creator_preferred_username -> Nullable<Varchar>,
creator_avatar -> Nullable<Text>,
category_name -> Varchar,
number_of_subscribers -> BigInt,
@ -72,10 +78,12 @@ table! {
user_actor_id -> Text,
user_local -> Bool,
user_name -> Varchar,
user_preferred_username -> Nullable<Varchar>,
avatar -> Nullable<Text>,
community_actor_id -> Text,
community_local -> Bool,
community_name -> Varchar,
community_icon -> Nullable<Text>,
}
}
@ -88,10 +96,12 @@ table! {
user_actor_id -> Text,
user_local -> Bool,
user_name -> Varchar,
user_preferred_username -> Nullable<Varchar>,
avatar -> Nullable<Text>,
community_actor_id -> Text,
community_local -> Bool,
community_name -> Varchar,
community_icon -> Nullable<Text>,
}
}
@ -104,10 +114,12 @@ table! {
user_actor_id -> Text,
user_local -> Bool,
user_name -> Varchar,
user_preferred_username -> Nullable<Varchar>,
avatar -> Nullable<Text>,
community_actor_id -> Text,
community_local -> Bool,
community_name -> Varchar,
community_icon -> Nullable<Text>,
}
}
@ -119,6 +131,8 @@ pub struct CommunityView {
pub id: i32,
pub name: String,
pub title: String,
pub icon: Option<String>,
pub banner: Option<String>,
pub description: Option<String>,
pub category_id: i32,
pub creator_id: i32,
@ -133,6 +147,7 @@ pub struct CommunityView {
pub creator_actor_id: String,
pub creator_local: bool,
pub creator_name: String,
pub creator_preferred_username: Option<String>,
pub creator_avatar: Option<String>,
pub category_name: String,
pub number_of_subscribers: i64,
@ -288,10 +303,12 @@ pub struct CommunityModeratorView {
pub user_actor_id: String,
pub user_local: bool,
pub user_name: String,
pub user_preferred_username: Option<String>,
pub avatar: Option<String>,
pub community_actor_id: String,
pub community_local: bool,
pub community_name: String,
pub community_icon: Option<String>,
}
impl CommunityModeratorView {
@ -324,10 +341,12 @@ pub struct CommunityFollowerView {
pub user_actor_id: String,
pub user_local: bool,
pub user_name: String,
pub user_preferred_username: Option<String>,
pub avatar: Option<String>,
pub community_actor_id: String,
pub community_local: bool,
pub community_name: String,
pub community_icon: Option<String>,
}
impl CommunityFollowerView {
@ -358,10 +377,12 @@ pub struct CommunityUserBanView {
pub user_actor_id: String,
pub user_local: bool,
pub user_name: String,
pub user_preferred_username: Option<String>,
pub avatar: Option<String>,
pub community_actor_id: String,
pub community_local: bool,
pub community_name: String,
pub community_icon: Option<String>,
}
impl CommunityUserBanView {

View file

@ -134,6 +134,7 @@ pub fn get_database_url_from_env() -> Result<String, VarError> {
#[derive(EnumString, ToString, Debug, Serialize, Deserialize)]
pub enum SortType {
Active,
Hot,
New,
TopDay,
@ -180,6 +181,20 @@ pub fn is_email_regex(test: &str) -> bool {
EMAIL_REGEX.is_match(test)
}
pub fn diesel_option_overwrite(opt: &Option<String>) -> Option<Option<String>> {
match opt {
// An empty string is an erase
Some(unwrapped) => {
if !unwrapped.eq("") {
Some(Some(unwrapped.to_owned()))
} else {
Some(None)
}
}
None => None,
}
}
lazy_static! {
static ref EMAIL_REGEX: Regex =
Regex::new(r"^[a-zA-Z0-9.!#$%&*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$").unwrap();

View file

@ -460,6 +460,7 @@ mod tests {
email: None,
matrix_user_id: None,
avatar: None,
banner: None,
admin: false,
banned: false,
updated: None,
@ -487,6 +488,7 @@ mod tests {
email: None,
matrix_user_id: None,
avatar: None,
banner: None,
admin: false,
banned: false,
updated: None,
@ -523,6 +525,8 @@ mod tests {
public_key: None,
last_refreshed_at: None,
published: None,
icon: None,
banner: None,
};
let inserted_community = Community::create(&conn, &new_community).unwrap();

View file

@ -95,6 +95,7 @@ mod tests {
email: None,
matrix_user_id: None,
avatar: None,
banner: None,
admin: false,
banned: false,
updated: None,

View file

@ -316,6 +316,7 @@ mod tests {
email: None,
matrix_user_id: None,
avatar: None,
banner: None,
admin: false,
banned: false,
updated: None,
@ -352,6 +353,8 @@ mod tests {
public_key: None,
last_refreshed_at: None,
published: None,
icon: None,
banner: None,
};
let inserted_community = Community::create(&conn, &new_community).unwrap();

View file

@ -28,6 +28,7 @@ table! {
creator_actor_id -> Text,
creator_local -> Bool,
creator_name -> Varchar,
creator_preferred_username -> Nullable<Varchar>,
creator_published -> Timestamp,
creator_avatar -> Nullable<Text>,
banned -> Bool,
@ -35,6 +36,7 @@ table! {
community_actor_id -> Text,
community_local -> Bool,
community_name -> Varchar,
community_icon -> Nullable<Text>,
community_removed -> Bool,
community_deleted -> Bool,
community_nsfw -> Bool,
@ -43,6 +45,7 @@ table! {
upvotes -> BigInt,
downvotes -> BigInt,
hot_rank -> Int4,
hot_rank_active -> Int4,
newest_activity_time -> Timestamp,
user_id -> Nullable<Int4>,
my_vote -> Nullable<Int4>,
@ -76,6 +79,7 @@ table! {
creator_actor_id -> Text,
creator_local -> Bool,
creator_name -> Varchar,
creator_preferred_username -> Nullable<Varchar>,
creator_published -> Timestamp,
creator_avatar -> Nullable<Text>,
banned -> Bool,
@ -83,6 +87,7 @@ table! {
community_actor_id -> Text,
community_local -> Bool,
community_name -> Varchar,
community_icon -> Nullable<Text>,
community_removed -> Bool,
community_deleted -> Bool,
community_nsfw -> Bool,
@ -91,6 +96,7 @@ table! {
upvotes -> BigInt,
downvotes -> BigInt,
hot_rank -> Int4,
hot_rank_active -> Int4,
newest_activity_time -> Timestamp,
user_id -> Nullable<Int4>,
my_vote -> Nullable<Int4>,
@ -127,6 +133,7 @@ pub struct PostView {
pub creator_actor_id: String,
pub creator_local: bool,
pub creator_name: String,
pub creator_preferred_username: Option<String>,
pub creator_published: chrono::NaiveDateTime,
pub creator_avatar: Option<String>,
pub banned: bool,
@ -134,6 +141,7 @@ pub struct PostView {
pub community_actor_id: String,
pub community_local: bool,
pub community_name: String,
pub community_icon: Option<String>,
pub community_removed: bool,
pub community_deleted: bool,
pub community_nsfw: bool,
@ -142,6 +150,7 @@ pub struct PostView {
pub upvotes: i64,
pub downvotes: i64,
pub hot_rank: i32,
pub hot_rank_active: i32,
pub newest_activity_time: chrono::NaiveDateTime,
pub user_id: Option<i32>,
pub my_vote: Option<i32>,
@ -289,6 +298,9 @@ impl<'a> PostQueryBuilder<'a> {
}
query = match self.sort {
SortType::Active => query
.then_order_by(hot_rank_active.desc())
.then_order_by(published.desc()),
SortType::Hot => query
.then_order_by(hot_rank.desc())
.then_order_by(published.desc()),
@ -405,6 +417,7 @@ mod tests {
email: None,
matrix_user_id: None,
avatar: None,
banner: None,
updated: None,
admin: false,
banned: false,
@ -441,6 +454,8 @@ mod tests {
public_key: None,
last_refreshed_at: None,
published: None,
icon: None,
banner: None,
};
let inserted_community = Community::create(&conn, &new_community).unwrap();
@ -519,6 +534,7 @@ mod tests {
body: None,
creator_id: inserted_user.id,
creator_name: user_name.to_owned(),
creator_preferred_username: None,
creator_published: inserted_user.published,
creator_avatar: None,
banned: false,
@ -529,6 +545,7 @@ mod tests {
locked: false,
stickied: false,
community_name: community_name.to_owned(),
community_icon: None,
community_removed: false,
community_deleted: false,
community_nsfw: false,
@ -537,6 +554,7 @@ mod tests {
upvotes: 1,
downvotes: 0,
hot_rank: read_post_listing_no_user.hot_rank,
hot_rank_active: read_post_listing_no_user.hot_rank_active,
published: inserted_post.published,
newest_activity_time: inserted_post.published,
updated: None,
@ -569,12 +587,14 @@ mod tests {
stickied: false,
creator_id: inserted_user.id,
creator_name: user_name,
creator_preferred_username: None,
creator_published: inserted_user.published,
creator_avatar: None,
banned: false,
banned_from_community: false,
community_id: inserted_community.id,
community_name,
community_icon: None,
community_removed: false,
community_deleted: false,
community_nsfw: false,
@ -583,6 +603,7 @@ mod tests {
upvotes: 1,
downvotes: 0,
hot_rank: read_post_listing_with_user.hot_rank,
hot_rank_active: read_post_listing_with_user.hot_rank_active,
published: inserted_post.published,
newest_activity_time: inserted_post.published,
updated: None,

View file

@ -147,6 +147,7 @@ mod tests {
email: None,
matrix_user_id: None,
avatar: None,
banner: None,
admin: false,
banned: false,
updated: None,
@ -174,6 +175,7 @@ mod tests {
email: None,
matrix_user_id: None,
avatar: None,
banner: None,
admin: false,
banned: false,
updated: None,

View file

@ -16,10 +16,12 @@ table! {
ap_id -> Text,
local -> Bool,
creator_name -> Varchar,
creator_preferred_username -> Nullable<Varchar>,
creator_avatar -> Nullable<Text>,
creator_actor_id -> Text,
creator_local -> Bool,
recipient_name -> Varchar,
recipient_preferred_username -> Nullable<Varchar>,
recipient_avatar -> Nullable<Text>,
recipient_actor_id -> Text,
recipient_local -> Bool,
@ -42,10 +44,12 @@ pub struct PrivateMessageView {
pub ap_id: String,
pub local: bool,
pub creator_name: String,
pub creator_preferred_username: Option<String>,
pub creator_avatar: Option<String>,
pub creator_actor_id: String,
pub creator_local: bool,
pub recipient_name: String,
pub recipient_preferred_username: Option<String>,
pub recipient_avatar: Option<String>,
pub recipient_actor_id: String,
pub recipient_local: bool,

View file

@ -52,17 +52,20 @@ table! {
community_actor_id -> Nullable<Varchar>,
community_local -> Nullable<Bool>,
community_name -> Nullable<Varchar>,
community_icon -> Nullable<Text>,
banned -> Nullable<Bool>,
banned_from_community -> Nullable<Bool>,
creator_actor_id -> Nullable<Varchar>,
creator_local -> Nullable<Bool>,
creator_name -> Nullable<Varchar>,
creator_preferred_username -> Nullable<Varchar>,
creator_published -> Nullable<Timestamp>,
creator_avatar -> Nullable<Text>,
score -> Nullable<Int8>,
upvotes -> Nullable<Int8>,
downvotes -> Nullable<Int8>,
hot_rank -> Nullable<Int4>,
hot_rank_active -> Nullable<Int4>,
}
}
@ -104,6 +107,8 @@ table! {
private_key -> Nullable<Text>,
public_key -> Nullable<Text>,
last_refreshed_at -> Timestamp,
icon -> Nullable<Text>,
banner -> Nullable<Text>,
}
}
@ -112,6 +117,8 @@ table! {
id -> Int4,
name -> Nullable<Varchar>,
title -> Nullable<Varchar>,
icon -> Nullable<Text>,
banner -> Nullable<Text>,
description -> Nullable<Text>,
category_id -> Nullable<Int4>,
creator_id -> Nullable<Int4>,
@ -126,6 +133,7 @@ table! {
creator_actor_id -> Nullable<Varchar>,
creator_local -> Nullable<Bool>,
creator_name -> Nullable<Varchar>,
creator_preferred_username -> Nullable<Varchar>,
creator_avatar -> Nullable<Text>,
category_name -> Nullable<Varchar>,
number_of_subscribers -> Nullable<Int8>,
@ -319,6 +327,7 @@ table! {
creator_actor_id -> Nullable<Varchar>,
creator_local -> Nullable<Bool>,
creator_name -> Nullable<Varchar>,
creator_preferred_username -> Nullable<Varchar>,
creator_published -> Nullable<Timestamp>,
creator_avatar -> Nullable<Text>,
banned -> Nullable<Bool>,
@ -326,6 +335,7 @@ table! {
community_actor_id -> Nullable<Varchar>,
community_local -> Nullable<Bool>,
community_name -> Nullable<Varchar>,
community_icon -> Nullable<Text>,
community_removed -> Nullable<Bool>,
community_deleted -> Nullable<Bool>,
community_nsfw -> Nullable<Bool>,
@ -334,6 +344,7 @@ table! {
upvotes -> Nullable<Int8>,
downvotes -> Nullable<Int8>,
hot_rank -> Nullable<Int4>,
hot_rank_active -> Nullable<Int4>,
newest_activity_time -> Nullable<Timestamp>,
}
}
@ -392,6 +403,8 @@ table! {
enable_downvotes -> Bool,
open_registration -> Bool,
enable_nsfw -> Bool,
icon -> Nullable<Text>,
banner -> Nullable<Text>,
}
}
@ -421,6 +434,7 @@ table! {
private_key -> Nullable<Text>,
public_key -> Nullable<Text>,
last_refreshed_at -> Timestamp,
banner -> Nullable<Text>,
}
}
@ -437,7 +451,9 @@ table! {
id -> Int4,
actor_id -> Nullable<Varchar>,
name -> Nullable<Varchar>,
preferred_username -> Nullable<Varchar>,
avatar -> Nullable<Text>,
banner -> Nullable<Text>,
email -> Nullable<Text>,
matrix_user_id -> Nullable<Text>,
bio -> Nullable<Text>,

View file

@ -1,4 +1,4 @@
use crate::{schema::site, Crud};
use crate::{naive_now, schema::site, Crud};
use diesel::{dsl::*, result::Error, *};
use serde::{Deserialize, Serialize};
@ -14,6 +14,8 @@ pub struct Site {
pub enable_downvotes: bool,
pub open_registration: bool,
pub enable_nsfw: bool,
pub icon: Option<String>,
pub banner: Option<String>,
}
#[derive(Insertable, AsChangeset, Clone, Serialize, Deserialize)]
@ -26,6 +28,9 @@ pub struct SiteForm {
pub enable_downvotes: bool,
pub open_registration: bool,
pub enable_nsfw: bool,
// when you want to null out a column, you have to send Some(None)), since sending None means you just don't want to update that column.
pub icon: Option<Option<String>>,
pub banner: Option<Option<String>>,
}
impl Crud<SiteForm> for Site {
@ -51,3 +56,12 @@ impl Crud<SiteForm> for Site {
.get_result::<Self>(conn)
}
}
impl Site {
pub fn transfer(conn: &PgConnection, new_creator_id: i32) -> Result<Self, Error> {
use crate::schema::site::dsl::*;
diesel::update(site.find(1))
.set((creator_id.eq(new_creator_id), updated.eq(naive_now())))
.get_result::<Self>(conn)
}
}

View file

@ -12,7 +12,10 @@ table! {
enable_downvotes -> Bool,
open_registration -> Bool,
enable_nsfw -> Bool,
icon -> Nullable<Text>,
banner -> Nullable<Text>,
creator_name -> Varchar,
creator_preferred_username -> Nullable<Varchar>,
creator_avatar -> Nullable<Text>,
number_of_users -> BigInt,
number_of_posts -> BigInt,
@ -35,7 +38,10 @@ pub struct SiteView {
pub enable_downvotes: bool,
pub open_registration: bool,
pub enable_nsfw: bool,
pub icon: Option<String>,
pub banner: Option<String>,
pub creator_name: String,
pub creator_preferred_username: Option<String>,
pub creator_avatar: Option<String>,
pub number_of_users: i64,
pub number_of_posts: i64,

View file

@ -35,6 +35,7 @@ pub struct User_ {
pub private_key: Option<String>,
pub public_key: Option<String>,
pub last_refreshed_at: chrono::NaiveDateTime,
pub banner: Option<String>,
}
#[derive(Insertable, AsChangeset, Clone, Debug)]
@ -46,7 +47,7 @@ pub struct UserForm {
pub admin: bool,
pub banned: bool,
pub email: Option<String>,
pub avatar: Option<String>,
pub avatar: Option<Option<String>>,
pub updated: Option<chrono::NaiveDateTime>,
pub show_nsfw: bool,
pub theme: String,
@ -62,6 +63,7 @@ pub struct UserForm {
pub private_key: Option<String>,
pub public_key: Option<String>,
pub last_refreshed_at: Option<chrono::NaiveDateTime>,
pub banner: Option<Option<String>>,
}
impl Crud<UserForm> for User_ {
@ -167,6 +169,7 @@ mod tests {
email: None,
matrix_user_id: None,
avatar: None,
banner: None,
admin: false,
banned: false,
updated: None,
@ -195,6 +198,7 @@ mod tests {
email: None,
matrix_user_id: None,
avatar: None,
banner: None,
admin: false,
banned: false,
published: inserted_user.published,

View file

@ -100,6 +100,7 @@ mod tests {
email: None,
matrix_user_id: None,
avatar: None,
banner: None,
admin: false,
banned: false,
updated: None,
@ -127,6 +128,7 @@ mod tests {
email: None,
matrix_user_id: None,
avatar: None,
banner: None,
admin: false,
banned: false,
updated: None,
@ -163,6 +165,8 @@ mod tests {
public_key: None,
last_refreshed_at: None,
published: None,
icon: None,
banner: None,
};
let inserted_community = Community::create(&conn, &new_community).unwrap();

View file

@ -23,14 +23,17 @@ table! {
community_actor_id -> Text,
community_local -> Bool,
community_name -> Varchar,
community_icon -> Nullable<Text>,
banned -> Bool,
banned_from_community -> Bool,
creator_name -> Varchar,
creator_preferred_username -> Nullable<Varchar>,
creator_avatar -> Nullable<Text>,
score -> BigInt,
upvotes -> BigInt,
downvotes -> BigInt,
hot_rank -> Int4,
hot_rank_active -> Int4,
user_id -> Nullable<Int4>,
my_vote -> Nullable<Int4>,
saved -> Nullable<Bool>,
@ -60,14 +63,17 @@ table! {
community_actor_id -> Text,
community_local -> Bool,
community_name -> Varchar,
community_icon -> Nullable<Text>,
banned -> Bool,
banned_from_community -> Bool,
creator_name -> Varchar,
creator_preferred_username -> Nullable<Varchar>,
creator_avatar -> Nullable<Text>,
score -> BigInt,
upvotes -> BigInt,
downvotes -> BigInt,
hot_rank -> Int4,
hot_rank_active -> Int4,
user_id -> Nullable<Int4>,
my_vote -> Nullable<Int4>,
saved -> Nullable<Bool>,
@ -100,14 +106,17 @@ pub struct UserMentionView {
pub community_actor_id: String,
pub community_local: bool,
pub community_name: String,
pub community_icon: Option<String>,
pub banned: bool,
pub banned_from_community: bool,
pub creator_name: String,
pub creator_preferred_username: Option<String>,
pub creator_avatar: Option<String>,
pub score: i64,
pub upvotes: i64,
pub downvotes: i64,
pub hot_rank: i32,
pub hot_rank_active: i32,
pub user_id: Option<i32>,
pub my_vote: Option<i32>,
pub saved: Option<bool>,
@ -180,6 +189,9 @@ impl<'a> UserMentionQueryBuilder<'a> {
SortType::Hot => query
.order_by(hot_rank.desc())
.then_order_by(published.desc()),
SortType::Active => query
.order_by(hot_rank_active.desc())
.then_order_by(published.desc()),
SortType::New => query.order_by(published.desc()),
SortType::TopAll => query.order_by(score.desc()),
SortType::TopYear => query

View file

@ -8,7 +8,9 @@ table! {
id -> Int4,
actor_id -> Text,
name -> Varchar,
preferred_username -> Nullable<Varchar>,
avatar -> Nullable<Text>,
banner -> Nullable<Text>,
email -> Nullable<Text>,
matrix_user_id -> Nullable<Text>,
bio -> Nullable<Text>,
@ -30,7 +32,9 @@ table! {
id -> Int4,
actor_id -> Text,
name -> Varchar,
preferred_username -> Nullable<Varchar>,
avatar -> Nullable<Text>,
banner -> Nullable<Text>,
email -> Nullable<Text>,
matrix_user_id -> Nullable<Text>,
bio -> Nullable<Text>,
@ -55,7 +59,9 @@ pub struct UserView {
pub id: i32,
pub actor_id: String,
pub name: String,
pub preferred_username: Option<String>,
pub avatar: Option<String>,
pub banner: Option<String>,
pub email: Option<String>, // TODO this shouldn't be in this view
pub matrix_user_id: Option<String>,
pub bio: Option<String>,
@ -126,6 +132,9 @@ impl<'a> UserQueryBuilder<'a> {
SortType::Hot => query
.order_by(comment_score.desc())
.then_order_by(published.desc()),
SortType::Active => query
.order_by(comment_score.desc())
.then_order_by(published.desc()),
SortType::New => query.order_by(published.desc()),
SortType::TopAll => query.order_by(comment_score.desc()),
SortType::TopYear => query
@ -164,7 +173,9 @@ impl UserView {
id,
actor_id,
name,
preferred_username,
avatar,
banner,
"".into_sql::<Nullable<Text>>(),
matrix_user_id,
bio,
@ -192,7 +203,9 @@ impl UserView {
id,
actor_id,
name,
preferred_username,
avatar,
banner,
"".into_sql::<Nullable<Text>>(),
matrix_user_id,
bio,

View file

@ -14,6 +14,7 @@ pub struct Settings {
pub port: u16,
pub jwt_secret: String,
pub front_end_dir: String,
pub pictrs_url: String,
pub rate_limit: RateLimitConfig,
pub email: Option<EmailConfig>,
pub federation: Federation,
@ -36,6 +37,8 @@ pub struct RateLimitConfig {
pub post_per_second: i32,
pub register: i32,
pub register_per_second: i32,
pub image: i32,
pub image_per_second: i32,
}
#[derive(Debug, Deserialize, Clone)]

View file

@ -0,0 +1,704 @@
-- Drops first
drop view site_view;
drop table user_fast;
drop view user_view;
drop view post_fast_view;
drop table post_aggregates_fast;
drop view post_view;
drop view post_aggregates_view;
drop view community_moderator_view;
drop view community_follower_view;
drop view community_user_ban_view;
drop view community_view;
drop view community_aggregates_view;
drop view community_fast_view;
drop table community_aggregates_fast;
drop view private_message_view;
drop view user_mention_view;
drop view reply_fast_view;
drop view comment_fast_view;
drop view comment_view;
drop view user_mention_fast_view;
drop table comment_aggregates_fast;
drop view comment_aggregates_view;
alter table site
drop column icon,
drop column banner;
alter table community
drop column icon,
drop column banner;
alter table user_ drop column banner;
-- Site
create view site_view as
select *,
(select name from user_ u where s.creator_id = u.id) as creator_name,
(select avatar from user_ u where s.creator_id = u.id) as creator_avatar,
(select count(*) from user_) as number_of_users,
(select count(*) from post) as number_of_posts,
(select count(*) from comment) as number_of_comments,
(select count(*) from community) as number_of_communities
from site s;
-- User
create view user_view as
select
u.id,
u.actor_id,
u.name,
u.avatar,
u.email,
u.matrix_user_id,
u.bio,
u.local,
u.admin,
u.banned,
u.show_avatars,
u.send_notifications_to_email,
u.published,
coalesce(pd.posts, 0) as number_of_posts,
coalesce(pd.score, 0) as post_score,
coalesce(cd.comments, 0) as number_of_comments,
coalesce(cd.score, 0) as comment_score
from user_ u
left join (
select
p.creator_id as creator_id,
count(distinct p.id) as posts,
sum(pl.score) as score
from post p
join post_like pl on p.id = pl.post_id
group by p.creator_id
) pd on u.id = pd.creator_id
left join (
select
c.creator_id,
count(distinct c.id) as comments,
sum(cl.score) as score
from comment c
join comment_like cl on c.id = cl.comment_id
group by c.creator_id
) cd on u.id = cd.creator_id;
create table user_fast as select * from user_view;
alter table user_fast add primary key (id);
-- Post fast
create view post_aggregates_view as
select
p.*,
-- creator details
u.actor_id as creator_actor_id,
u."local" as creator_local,
u."name" as creator_name,
u.published as creator_published,
u.avatar as creator_avatar,
u.banned as banned,
cb.id::bool as banned_from_community,
-- community details
c.actor_id as community_actor_id,
c."local" as community_local,
c."name" as community_name,
c.removed as community_removed,
c.deleted as community_deleted,
c.nsfw as community_nsfw,
-- post score data/comment count
coalesce(ct.comments, 0) as number_of_comments,
coalesce(pl.score, 0) as score,
coalesce(pl.upvotes, 0) as upvotes,
coalesce(pl.downvotes, 0) as downvotes,
hot_rank(
coalesce(pl.score , 0), (
case
when (p.published < ('now'::timestamp - '1 month'::interval))
then p.published
else greatest(ct.recent_comment_time, p.published)
end
)
) as hot_rank,
(
case
when (p.published < ('now'::timestamp - '1 month'::interval))
then p.published
else greatest(ct.recent_comment_time, p.published)
end
) as newest_activity_time
from post p
left join user_ u on p.creator_id = u.id
left join community_user_ban cb on p.creator_id = cb.user_id and p.community_id = cb.community_id
left join community c on p.community_id = c.id
left join (
select
post_id,
count(*) as comments,
max(published) as recent_comment_time
from comment
group by post_id
) ct on ct.post_id = p.id
left join (
select
post_id,
sum(score) as score,
sum(score) filter (where score = 1) as upvotes,
-sum(score) filter (where score = -1) as downvotes
from post_like
group by post_id
) pl on pl.post_id = p.id
order by p.id;
create view post_view as
select
pav.*,
us.id as user_id,
us.user_vote as my_vote,
us.is_subbed::bool as subscribed,
us.is_read::bool as read,
us.is_saved::bool as saved
from post_aggregates_view pav
cross join lateral (
select
u.id,
coalesce(cf.community_id, 0) as is_subbed,
coalesce(pr.post_id, 0) as is_read,
coalesce(ps.post_id, 0) as is_saved,
coalesce(pl.score, 0) as user_vote
from user_ u
left join community_user_ban cb on u.id = cb.user_id and cb.community_id = pav.community_id
left join community_follower cf on u.id = cf.user_id and cf.community_id = pav.community_id
left join post_read pr on u.id = pr.user_id and pr.post_id = pav.id
left join post_saved ps on u.id = ps.user_id and ps.post_id = pav.id
left join post_like pl on u.id = pl.user_id and pav.id = pl.post_id
) as us
union all
select
pav.*,
null as user_id,
null as my_vote,
null as subscribed,
null as read,
null as saved
from post_aggregates_view pav;
create table post_aggregates_fast as select * from post_aggregates_view;
alter table post_aggregates_fast add primary key (id);
create view post_fast_view as
select
pav.*,
us.id as user_id,
us.user_vote as my_vote,
us.is_subbed::bool as subscribed,
us.is_read::bool as read,
us.is_saved::bool as saved
from post_aggregates_fast pav
cross join lateral (
select
u.id,
coalesce(cf.community_id, 0) as is_subbed,
coalesce(pr.post_id, 0) as is_read,
coalesce(ps.post_id, 0) as is_saved,
coalesce(pl.score, 0) as user_vote
from user_ u
left join community_user_ban cb on u.id = cb.user_id and cb.community_id = pav.community_id
left join community_follower cf on u.id = cf.user_id and cf.community_id = pav.community_id
left join post_read pr on u.id = pr.user_id and pr.post_id = pav.id
left join post_saved ps on u.id = ps.user_id and ps.post_id = pav.id
left join post_like pl on u.id = pl.user_id and pav.id = pl.post_id
) as us
union all
select
pav.*,
null as user_id,
null as my_vote,
null as subscribed,
null as read,
null as saved
from post_aggregates_fast pav;
-- Community
create view community_aggregates_view as
select
c.id,
c.name,
c.title,
c.description,
c.category_id,
c.creator_id,
c.removed,
c.published,
c.updated,
c.deleted,
c.nsfw,
c.actor_id,
c.local,
c.last_refreshed_at,
u.actor_id as creator_actor_id,
u.local as creator_local,
u.name as creator_name,
u.avatar as creator_avatar,
cat.name as category_name,
coalesce(cf.subs, 0) as number_of_subscribers,
coalesce(cd.posts, 0) as number_of_posts,
coalesce(cd.comments, 0) as number_of_comments,
hot_rank(cf.subs, c.published) as hot_rank
from community c
left join user_ u on c.creator_id = u.id
left join category cat on c.category_id = cat.id
left join (
select
p.community_id,
count(distinct p.id) as posts,
count(distinct ct.id) as comments
from post p
join comment ct on p.id = ct.post_id
group by p.community_id
) cd on cd.community_id = c.id
left join (
select
community_id,
count(*) as subs
from community_follower
group by community_id
) cf on cf.community_id = c.id;
create view community_view as
select
cv.*,
us.user as user_id,
us.is_subbed::bool as subscribed
from community_aggregates_view cv
cross join lateral (
select
u.id as user,
coalesce(cf.community_id, 0) as is_subbed
from user_ u
left join community_follower cf on u.id = cf.user_id and cf.community_id = cv.id
) as us
union all
select
cv.*,
null as user_id,
null as subscribed
from community_aggregates_view cv;
create view community_moderator_view as
select
cm.*,
u.actor_id as user_actor_id,
u.local as user_local,
u.name as user_name,
u.avatar as avatar,
c.actor_id as community_actor_id,
c.local as community_local,
c.name as community_name
from community_moderator cm
left join user_ u on cm.user_id = u.id
left join community c on cm.community_id = c.id;
create view community_follower_view as
select
cf.*,
u.actor_id as user_actor_id,
u.local as user_local,
u.name as user_name,
u.avatar as avatar,
c.actor_id as community_actor_id,
c.local as community_local,
c.name as community_name
from community_follower cf
left join user_ u on cf.user_id = u.id
left join community c on cf.community_id = c.id;
create view community_user_ban_view as
select
cb.*,
u.actor_id as user_actor_id,
u.local as user_local,
u.name as user_name,
u.avatar as avatar,
c.actor_id as community_actor_id,
c.local as community_local,
c.name as community_name
from community_user_ban cb
left join user_ u on cb.user_id = u.id
left join community c on cb.community_id = c.id;
-- The community fast table
create table community_aggregates_fast as select * from community_aggregates_view;
alter table community_aggregates_fast add primary key (id);
create view community_fast_view as
select
ac.*,
u.id as user_id,
(select cf.id::boolean from community_follower cf where u.id = cf.user_id and ac.id = cf.community_id) as subscribed
from user_ u
cross join (
select
ca.*
from community_aggregates_fast ca
) ac
union all
select
caf.*,
null as user_id,
null as subscribed
from community_aggregates_fast caf;
-- Private message
create view private_message_view as
select
pm.*,
u.name as creator_name,
u.avatar as creator_avatar,
u.actor_id as creator_actor_id,
u.local as creator_local,
u2.name as recipient_name,
u2.avatar as recipient_avatar,
u2.actor_id as recipient_actor_id,
u2.local as recipient_local
from private_message pm
inner join user_ u on u.id = pm.creator_id
inner join user_ u2 on u2.id = pm.recipient_id;
-- Comments, mentions, replies
create view comment_aggregates_view as
select
ct.*,
-- post details
p."name" as post_name,
p.community_id,
-- community details
c.actor_id as community_actor_id,
c."local" as community_local,
c."name" as community_name,
-- creator details
u.banned as banned,
coalesce(cb.id, 0)::bool as banned_from_community,
u.actor_id as creator_actor_id,
u.local as creator_local,
u.name as creator_name,
u.published as creator_published,
u.avatar as creator_avatar,
-- score details
coalesce(cl.total, 0) as score,
coalesce(cl.up, 0) as upvotes,
coalesce(cl.down, 0) as downvotes,
hot_rank(coalesce(cl.total, 0), ct.published) as hot_rank
from comment ct
left join post p on ct.post_id = p.id
left join community c on p.community_id = c.id
left join user_ u on ct.creator_id = u.id
left join community_user_ban cb on ct.creator_id = cb.user_id and p.id = ct.post_id and p.community_id = cb.community_id
left join (
select
l.comment_id as id,
sum(l.score) as total,
count(case when l.score = 1 then 1 else null end) as up,
count(case when l.score = -1 then 1 else null end) as down
from comment_like l
group by comment_id
) as cl on cl.id = ct.id;
create or replace view comment_view as (
select
cav.*,
us.user_id as user_id,
us.my_vote as my_vote,
us.is_subbed::bool as subscribed,
us.is_saved::bool as saved
from comment_aggregates_view cav
cross join lateral (
select
u.id as user_id,
coalesce(cl.score, 0) as my_vote,
coalesce(cf.id, 0) as is_subbed,
coalesce(cs.id, 0) as is_saved
from user_ u
left join comment_like cl on u.id = cl.user_id and cav.id = cl.comment_id
left join comment_saved cs on u.id = cs.user_id and cs.comment_id = cav.id
left join community_follower cf on u.id = cf.user_id and cav.community_id = cf.community_id
) as us
union all
select
cav.*,
null as user_id,
null as my_vote,
null as subscribed,
null as saved
from comment_aggregates_view cav
);
create table comment_aggregates_fast as select * from comment_aggregates_view;
alter table comment_aggregates_fast add primary key (id);
create view comment_fast_view as
select
cav.*,
us.user_id as user_id,
us.my_vote as my_vote,
us.is_subbed::bool as subscribed,
us.is_saved::bool as saved
from comment_aggregates_fast cav
cross join lateral (
select
u.id as user_id,
coalesce(cl.score, 0) as my_vote,
coalesce(cf.id, 0) as is_subbed,
coalesce(cs.id, 0) as is_saved
from user_ u
left join comment_like cl on u.id = cl.user_id and cav.id = cl.comment_id
left join comment_saved cs on u.id = cs.user_id and cs.comment_id = cav.id
left join community_follower cf on u.id = cf.user_id and cav.community_id = cf.community_id
) as us
union all
select
cav.*,
null as user_id,
null as my_vote,
null as subscribed,
null as saved
from comment_aggregates_fast cav;
create view user_mention_view as
select
c.id,
um.id as user_mention_id,
c.creator_id,
c.creator_actor_id,
c.creator_local,
c.post_id,
c.post_name,
c.parent_id,
c.content,
c.removed,
um.read,
c.published,
c.updated,
c.deleted,
c.community_id,
c.community_actor_id,
c.community_local,
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,
(select actor_id from user_ u where u.id = um.recipient_id) as recipient_actor_id,
(select local from user_ u where u.id = um.recipient_id) as recipient_local
from user_mention um, comment_view c
where um.comment_id = c.id;
create view user_mention_fast_view as
select
ac.id,
um.id as user_mention_id,
ac.creator_id,
ac.creator_actor_id,
ac.creator_local,
ac.post_id,
ac.post_name,
ac.parent_id,
ac.content,
ac.removed,
um.read,
ac.published,
ac.updated,
ac.deleted,
ac.community_id,
ac.community_actor_id,
ac.community_local,
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,
(select actor_id from user_ u where u.id = um.recipient_id) as recipient_actor_id,
(select local from user_ u where u.id = um.recipient_id) as recipient_local
from user_ u
cross join (
select
ca.*
from comment_aggregates_fast ca
) 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.creator_actor_id,
ac.creator_local,
ac.post_id,
ac.post_name,
ac.parent_id,
ac.content,
ac.removed,
um.read,
ac.published,
ac.updated,
ac.deleted,
ac.community_id,
ac.community_actor_id,
ac.community_local,
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,
(select actor_id from user_ u where u.id = um.recipient_id) as recipient_actor_id,
(select local from user_ u where u.id = um.recipient_id) as recipient_local
from comment_aggregates_fast ac
left join user_mention um on um.comment_id = ac.id
;
-- Do the reply_view referencing the comment_fast_view
create view reply_fast_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_fast_view cv, closereply
where closereply.id = cv.id
;
-- redoing the triggers
create or replace function refresh_post()
returns trigger language plpgsql
as $$
begin
IF (TG_OP = 'DELETE') THEN
delete from post_aggregates_fast where id = OLD.id;
-- Update community number of posts
update community_aggregates_fast set number_of_posts = number_of_posts - 1 where id = OLD.community_id;
ELSIF (TG_OP = 'UPDATE') THEN
delete from post_aggregates_fast where id = OLD.id;
insert into post_aggregates_fast select * from post_aggregates_view where id = NEW.id;
ELSIF (TG_OP = 'INSERT') THEN
insert into post_aggregates_fast select * from post_aggregates_view where id = NEW.id;
-- Update that users number of posts, post score
delete from user_fast where id = NEW.creator_id;
insert into user_fast select * from user_view where id = NEW.creator_id;
-- Update community number of posts
update community_aggregates_fast set number_of_posts = number_of_posts + 1 where id = NEW.community_id;
-- Update the hot rank on the post table
-- TODO this might not correctly update it, using a 1 week interval
update post_aggregates_fast as paf
set hot_rank = pav.hot_rank
from post_aggregates_view as pav
where paf.id = pav.id and (pav.published > ('now'::timestamp - '1 week'::interval));
END IF;
return null;
end $$;
create or replace function refresh_comment()
returns trigger language plpgsql
as $$
begin
IF (TG_OP = 'DELETE') THEN
delete from comment_aggregates_fast where id = OLD.id;
-- Update community number of comments
update community_aggregates_fast as caf
set number_of_comments = number_of_comments - 1
from post as p
where caf.id = p.community_id and p.id = OLD.post_id;
ELSIF (TG_OP = 'UPDATE') THEN
delete from comment_aggregates_fast where id = OLD.id;
insert into comment_aggregates_fast select * from comment_aggregates_view where id = NEW.id;
ELSIF (TG_OP = 'INSERT') THEN
insert into comment_aggregates_fast select * from comment_aggregates_view where id = NEW.id;
-- Update user view due to comment count
update user_fast
set number_of_comments = number_of_comments + 1
where id = NEW.creator_id;
-- Update post view due to comment count, new comment activity time, but only on new posts
-- TODO this could be done more efficiently
delete from post_aggregates_fast where id = NEW.post_id;
insert into post_aggregates_fast select * from post_aggregates_view where id = NEW.post_id;
-- Force the hot rank as zero on week-older posts
update post_aggregates_fast as paf
set hot_rank = 0
where paf.id = NEW.post_id and (paf.published < ('now'::timestamp - '1 week'::interval));
-- Update community number of comments
update community_aggregates_fast as caf
set number_of_comments = number_of_comments + 1
from post as p
where caf.id = p.community_id and p.id = NEW.post_id;
END IF;
return null;
end $$;

View file

@ -0,0 +1,748 @@
-- This adds the following columns, as well as updates the views:
-- Site icon
-- Site banner
-- Community icon
-- Community Banner
-- User Banner (User avatar is already there)
-- User preferred name (already in table, needs to be added to view)
-- It also adds hot_rank_active to post_view
alter table site
add column icon text,
add column banner text;
alter table community
add column icon text,
add column banner text;
alter table user_ add column banner text;
drop view site_view;
create view site_view as
select s.*,
u.name as creator_name,
u.preferred_username as creator_preferred_username,
u.avatar as creator_avatar,
(select count(*) from user_) as number_of_users,
(select count(*) from post) as number_of_posts,
(select count(*) from comment) as number_of_comments,
(select count(*) from community) as number_of_communities
from site s
left join user_ u on s.creator_id = u.id;
-- User
drop table user_fast;
drop view user_view;
create view user_view as
select
u.id,
u.actor_id,
u.name,
u.preferred_username,
u.avatar,
u.banner,
u.email,
u.matrix_user_id,
u.bio,
u.local,
u.admin,
u.banned,
u.show_avatars,
u.send_notifications_to_email,
u.published,
coalesce(pd.posts, 0) as number_of_posts,
coalesce(pd.score, 0) as post_score,
coalesce(cd.comments, 0) as number_of_comments,
coalesce(cd.score, 0) as comment_score
from user_ u
left join (
select
p.creator_id as creator_id,
count(distinct p.id) as posts,
sum(pl.score) as score
from post p
join post_like pl on p.id = pl.post_id
group by p.creator_id
) pd on u.id = pd.creator_id
left join (
select
c.creator_id,
count(distinct c.id) as comments,
sum(cl.score) as score
from comment c
join comment_like cl on c.id = cl.comment_id
group by c.creator_id
) cd on u.id = cd.creator_id;
create table user_fast as select * from user_view;
alter table user_fast add primary key (id);
-- private message
drop view private_message_view;
create view private_message_view as
select
pm.*,
u.name as creator_name,
u.preferred_username as creator_preferred_username,
u.avatar as creator_avatar,
u.actor_id as creator_actor_id,
u.local as creator_local,
u2.name as recipient_name,
u2.preferred_username as recipient_preferred_username,
u2.avatar as recipient_avatar,
u2.actor_id as recipient_actor_id,
u2.local as recipient_local
from private_message pm
inner join user_ u on u.id = pm.creator_id
inner join user_ u2 on u2.id = pm.recipient_id;
-- Post fast
drop view post_fast_view;
drop table post_aggregates_fast;
drop view post_view;
drop view post_aggregates_view;
create view post_aggregates_view as
select
p.*,
-- creator details
u.actor_id as creator_actor_id,
u."local" as creator_local,
u."name" as creator_name,
u."preferred_username" as creator_preferred_username,
u.published as creator_published,
u.avatar as creator_avatar,
u.banned as banned,
cb.id::bool as banned_from_community,
-- community details
c.actor_id as community_actor_id,
c."local" as community_local,
c."name" as community_name,
c.icon as community_icon,
c.removed as community_removed,
c.deleted as community_deleted,
c.nsfw as community_nsfw,
-- post score data/comment count
coalesce(ct.comments, 0) as number_of_comments,
coalesce(pl.score, 0) as score,
coalesce(pl.upvotes, 0) as upvotes,
coalesce(pl.downvotes, 0) as downvotes,
hot_rank(coalesce(pl.score, 1), p.published) as hot_rank,
hot_rank(coalesce(pl.score, 1), greatest(ct.recent_comment_time, p.published)) as hot_rank_active,
greatest(ct.recent_comment_time, p.published) as newest_activity_time
from post p
left join user_ u on p.creator_id = u.id
left join community_user_ban cb on p.creator_id = cb.user_id and p.community_id = cb.community_id
left join community c on p.community_id = c.id
left join (
select
post_id,
count(*) as comments,
max(published) as recent_comment_time
from comment
group by post_id
) ct on ct.post_id = p.id
left join (
select
post_id,
sum(score) as score,
sum(score) filter (where score = 1) as upvotes,
-sum(score) filter (where score = -1) as downvotes
from post_like
group by post_id
) pl on pl.post_id = p.id
order by p.id;
create view post_view as
select
pav.*,
us.id as user_id,
us.user_vote as my_vote,
us.is_subbed::bool as subscribed,
us.is_read::bool as read,
us.is_saved::bool as saved
from post_aggregates_view pav
cross join lateral (
select
u.id,
coalesce(cf.community_id, 0) as is_subbed,
coalesce(pr.post_id, 0) as is_read,
coalesce(ps.post_id, 0) as is_saved,
coalesce(pl.score, 0) as user_vote
from user_ u
left join community_user_ban cb on u.id = cb.user_id and cb.community_id = pav.community_id
left join community_follower cf on u.id = cf.user_id and cf.community_id = pav.community_id
left join post_read pr on u.id = pr.user_id and pr.post_id = pav.id
left join post_saved ps on u.id = ps.user_id and ps.post_id = pav.id
left join post_like pl on u.id = pl.user_id and pav.id = pl.post_id
) as us
union all
select
pav.*,
null as user_id,
null as my_vote,
null as subscribed,
null as read,
null as saved
from post_aggregates_view pav;
create table post_aggregates_fast as select * from post_aggregates_view;
alter table post_aggregates_fast add primary key (id);
-- For the hot rank resorting
create index idx_post_aggregates_fast_hot_rank_published on post_aggregates_fast (hot_rank desc, published desc);
create index idx_post_aggregates_fast_hot_rank_active_published on post_aggregates_fast (hot_rank_active desc, published desc);
create view post_fast_view as
select
pav.*,
us.id as user_id,
us.user_vote as my_vote,
us.is_subbed::bool as subscribed,
us.is_read::bool as read,
us.is_saved::bool as saved
from post_aggregates_fast pav
cross join lateral (
select
u.id,
coalesce(cf.community_id, 0) as is_subbed,
coalesce(pr.post_id, 0) as is_read,
coalesce(ps.post_id, 0) as is_saved,
coalesce(pl.score, 0) as user_vote
from user_ u
left join community_user_ban cb on u.id = cb.user_id and cb.community_id = pav.community_id
left join community_follower cf on u.id = cf.user_id and cf.community_id = pav.community_id
left join post_read pr on u.id = pr.user_id and pr.post_id = pav.id
left join post_saved ps on u.id = ps.user_id and ps.post_id = pav.id
left join post_like pl on u.id = pl.user_id and pav.id = pl.post_id
) as us
union all
select
pav.*,
null as user_id,
null as my_vote,
null as subscribed,
null as read,
null as saved
from post_aggregates_fast pav;
-- Community
drop view community_moderator_view;
drop view community_follower_view;
drop view community_user_ban_view;
drop view community_view;
drop view community_aggregates_view;
drop view community_fast_view;
drop table community_aggregates_fast;
create view community_aggregates_view as
select
c.id,
c.name,
c.title,
c.icon,
c.banner,
c.description,
c.category_id,
c.creator_id,
c.removed,
c.published,
c.updated,
c.deleted,
c.nsfw,
c.actor_id,
c.local,
c.last_refreshed_at,
u.actor_id as creator_actor_id,
u.local as creator_local,
u.name as creator_name,
u.preferred_username as creator_preferred_username,
u.avatar as creator_avatar,
cat.name as category_name,
coalesce(cf.subs, 0) as number_of_subscribers,
coalesce(cd.posts, 0) as number_of_posts,
coalesce(cd.comments, 0) as number_of_comments,
hot_rank(cf.subs, c.published) as hot_rank
from community c
left join user_ u on c.creator_id = u.id
left join category cat on c.category_id = cat.id
left join (
select
p.community_id,
count(distinct p.id) as posts,
count(distinct ct.id) as comments
from post p
join comment ct on p.id = ct.post_id
group by p.community_id
) cd on cd.community_id = c.id
left join (
select
community_id,
count(*) as subs
from community_follower
group by community_id
) cf on cf.community_id = c.id;
create view community_view as
select
cv.*,
us.user as user_id,
us.is_subbed::bool as subscribed
from community_aggregates_view cv
cross join lateral (
select
u.id as user,
coalesce(cf.community_id, 0) as is_subbed
from user_ u
left join community_follower cf on u.id = cf.user_id and cf.community_id = cv.id
) as us
union all
select
cv.*,
null as user_id,
null as subscribed
from community_aggregates_view cv;
create view community_moderator_view as
select
cm.*,
u.actor_id as user_actor_id,
u.local as user_local,
u.name as user_name,
u.preferred_username as user_preferred_username,
u.avatar as avatar,
c.actor_id as community_actor_id,
c.local as community_local,
c.name as community_name,
c.icon as community_icon
from community_moderator cm
left join user_ u on cm.user_id = u.id
left join community c on cm.community_id = c.id;
create view community_follower_view as
select
cf.*,
u.actor_id as user_actor_id,
u.local as user_local,
u.name as user_name,
u.preferred_username as user_preferred_username,
u.avatar as avatar,
c.actor_id as community_actor_id,
c.local as community_local,
c.name as community_name,
c.icon as community_icon
from community_follower cf
left join user_ u on cf.user_id = u.id
left join community c on cf.community_id = c.id;
create view community_user_ban_view as
select
cb.*,
u.actor_id as user_actor_id,
u.local as user_local,
u.name as user_name,
u.preferred_username as user_preferred_username,
u.avatar as avatar,
c.actor_id as community_actor_id,
c.local as community_local,
c.name as community_name,
c.icon as community_icon
from community_user_ban cb
left join user_ u on cb.user_id = u.id
left join community c on cb.community_id = c.id;
-- The community fast table
create table community_aggregates_fast as select * from community_aggregates_view;
alter table community_aggregates_fast add primary key (id);
create view community_fast_view as
select
ac.*,
u.id as user_id,
(select cf.id::boolean from community_follower cf where u.id = cf.user_id and ac.id = cf.community_id) as subscribed
from user_ u
cross join (
select
ca.*
from community_aggregates_fast ca
) ac
union all
select
caf.*,
null as user_id,
null as subscribed
from community_aggregates_fast caf;
-- Comments, mentions, replies
drop view user_mention_view;
drop view reply_fast_view;
drop view comment_fast_view;
drop view comment_view;
drop view user_mention_fast_view;
drop table comment_aggregates_fast;
drop view comment_aggregates_view;
create view comment_aggregates_view as
select
ct.*,
-- post details
p."name" as post_name,
p.community_id,
-- community details
c.actor_id as community_actor_id,
c."local" as community_local,
c."name" as community_name,
c.icon as community_icon,
-- creator details
u.banned as banned,
coalesce(cb.id, 0)::bool as banned_from_community,
u.actor_id as creator_actor_id,
u.local as creator_local,
u.name as creator_name,
u.preferred_username as creator_preferred_username,
u.published as creator_published,
u.avatar as creator_avatar,
-- score details
coalesce(cl.total, 0) as score,
coalesce(cl.up, 0) as upvotes,
coalesce(cl.down, 0) as downvotes,
hot_rank(coalesce(cl.total, 1), p.published) as hot_rank,
hot_rank(coalesce(cl.total, 1), ct.published) as hot_rank_active
from comment ct
left join post p on ct.post_id = p.id
left join community c on p.community_id = c.id
left join user_ u on ct.creator_id = u.id
left join community_user_ban cb on ct.creator_id = cb.user_id and p.id = ct.post_id and p.community_id = cb.community_id
left join (
select
l.comment_id as id,
sum(l.score) as total,
count(case when l.score = 1 then 1 else null end) as up,
count(case when l.score = -1 then 1 else null end) as down
from comment_like l
group by comment_id
) as cl on cl.id = ct.id;
create or replace view comment_view as (
select
cav.*,
us.user_id as user_id,
us.my_vote as my_vote,
us.is_subbed::bool as subscribed,
us.is_saved::bool as saved
from comment_aggregates_view cav
cross join lateral (
select
u.id as user_id,
coalesce(cl.score, 0) as my_vote,
coalesce(cf.id, 0) as is_subbed,
coalesce(cs.id, 0) as is_saved
from user_ u
left join comment_like cl on u.id = cl.user_id and cav.id = cl.comment_id
left join comment_saved cs on u.id = cs.user_id and cs.comment_id = cav.id
left join community_follower cf on u.id = cf.user_id and cav.community_id = cf.community_id
) as us
union all
select
cav.*,
null as user_id,
null as my_vote,
null as subscribed,
null as saved
from comment_aggregates_view cav
);
create table comment_aggregates_fast as select * from comment_aggregates_view;
alter table comment_aggregates_fast add primary key (id);
create view comment_fast_view as
select
cav.*,
us.user_id as user_id,
us.my_vote as my_vote,
us.is_subbed::bool as subscribed,
us.is_saved::bool as saved
from comment_aggregates_fast cav
cross join lateral (
select
u.id as user_id,
coalesce(cl.score, 0) as my_vote,
coalesce(cf.id, 0) as is_subbed,
coalesce(cs.id, 0) as is_saved
from user_ u
left join comment_like cl on u.id = cl.user_id and cav.id = cl.comment_id
left join comment_saved cs on u.id = cs.user_id and cs.comment_id = cav.id
left join community_follower cf on u.id = cf.user_id and cav.community_id = cf.community_id
) as us
union all
select
cav.*,
null as user_id,
null as my_vote,
null as subscribed,
null as saved
from comment_aggregates_fast cav;
create view user_mention_view as
select
c.id,
um.id as user_mention_id,
c.creator_id,
c.creator_actor_id,
c.creator_local,
c.post_id,
c.post_name,
c.parent_id,
c.content,
c.removed,
um.read,
c.published,
c.updated,
c.deleted,
c.community_id,
c.community_actor_id,
c.community_local,
c.community_name,
c.community_icon,
c.banned,
c.banned_from_community,
c.creator_name,
c.creator_preferred_username,
c.creator_avatar,
c.score,
c.upvotes,
c.downvotes,
c.hot_rank,
c.hot_rank_active,
c.user_id,
c.my_vote,
c.saved,
um.recipient_id,
(select actor_id from user_ u where u.id = um.recipient_id) as recipient_actor_id,
(select local from user_ u where u.id = um.recipient_id) as recipient_local
from user_mention um, comment_view c
where um.comment_id = c.id;
create view user_mention_fast_view as
select
ac.id,
um.id as user_mention_id,
ac.creator_id,
ac.creator_actor_id,
ac.creator_local,
ac.post_id,
ac.post_name,
ac.parent_id,
ac.content,
ac.removed,
um.read,
ac.published,
ac.updated,
ac.deleted,
ac.community_id,
ac.community_actor_id,
ac.community_local,
ac.community_name,
ac.community_icon,
ac.banned,
ac.banned_from_community,
ac.creator_name,
ac.creator_preferred_username,
ac.creator_avatar,
ac.score,
ac.upvotes,
ac.downvotes,
ac.hot_rank,
ac.hot_rank_active,
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,
(select actor_id from user_ u where u.id = um.recipient_id) as recipient_actor_id,
(select local from user_ u where u.id = um.recipient_id) as recipient_local
from user_ u
cross join (
select
ca.*
from comment_aggregates_fast ca
) 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.creator_actor_id,
ac.creator_local,
ac.post_id,
ac.post_name,
ac.parent_id,
ac.content,
ac.removed,
um.read,
ac.published,
ac.updated,
ac.deleted,
ac.community_id,
ac.community_actor_id,
ac.community_local,
ac.community_name,
ac.community_icon,
ac.banned,
ac.banned_from_community,
ac.creator_name,
ac.creator_preferred_username,
ac.creator_avatar,
ac.score,
ac.upvotes,
ac.downvotes,
ac.hot_rank,
ac.hot_rank_active,
null as user_id,
null as my_vote,
null as saved,
um.recipient_id,
(select actor_id from user_ u where u.id = um.recipient_id) as recipient_actor_id,
(select local from user_ u where u.id = um.recipient_id) as recipient_local
from comment_aggregates_fast ac
left join user_mention um on um.comment_id = ac.id
;
-- Do the reply_view referencing the comment_fast_view
create view reply_fast_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_fast_view cv, closereply
where closereply.id = cv.id
;
-- Adding hot rank active to the triggers
create or replace function refresh_post()
returns trigger language plpgsql
as $$
begin
IF (TG_OP = 'DELETE') THEN
delete from post_aggregates_fast where id = OLD.id;
-- Update community number of posts
update community_aggregates_fast set number_of_posts = number_of_posts - 1 where id = OLD.community_id;
ELSIF (TG_OP = 'UPDATE') THEN
delete from post_aggregates_fast where id = OLD.id;
insert into post_aggregates_fast select * from post_aggregates_view where id = NEW.id;
ELSIF (TG_OP = 'INSERT') THEN
insert into post_aggregates_fast select * from post_aggregates_view where id = NEW.id;
-- Update that users number of posts, post score
delete from user_fast where id = NEW.creator_id;
insert into user_fast select * from user_view where id = NEW.creator_id;
-- Update community number of posts
update community_aggregates_fast set number_of_posts = number_of_posts + 1 where id = NEW.community_id;
-- Update the hot rank on the post table
-- TODO this might not correctly update it, using a 1 week interval
update post_aggregates_fast as paf
set
hot_rank = pav.hot_rank,
hot_rank_active = pav.hot_rank_active
from post_aggregates_view as pav
where paf.id = pav.id and (pav.published > ('now'::timestamp - '1 week'::interval));
END IF;
return null;
end $$;
create or replace function refresh_comment()
returns trigger language plpgsql
as $$
begin
IF (TG_OP = 'DELETE') THEN
delete from comment_aggregates_fast where id = OLD.id;
-- Update community number of comments
update community_aggregates_fast as caf
set number_of_comments = number_of_comments - 1
from post as p
where caf.id = p.community_id and p.id = OLD.post_id;
ELSIF (TG_OP = 'UPDATE') THEN
delete from comment_aggregates_fast where id = OLD.id;
insert into comment_aggregates_fast select * from comment_aggregates_view where id = NEW.id;
ELSIF (TG_OP = 'INSERT') THEN
insert into comment_aggregates_fast select * from comment_aggregates_view where id = NEW.id;
-- Update user view due to comment count
update user_fast
set number_of_comments = number_of_comments + 1
where id = NEW.creator_id;
-- Update post view due to comment count, new comment activity time, but only on new posts
-- TODO this could be done more efficiently
delete from post_aggregates_fast where id = NEW.post_id;
insert into post_aggregates_fast select * from post_aggregates_view where id = NEW.post_id;
-- Update the comment hot_ranks as of last week
update comment_aggregates_fast as caf
set
hot_rank = cav.hot_rank,
hot_rank_active = cav.hot_rank_active
from comment_aggregates_view as cav
where caf.id = cav.id and (cav.published > ('now'::timestamp - '1 week'::interval));
-- Update the post ranks
update post_aggregates_fast as paf
set
hot_rank = pav.hot_rank,
hot_rank_active = pav.hot_rank_active
from post_aggregates_view as pav
where paf.id = pav.id and (pav.published > ('now'::timestamp - '1 week'::interval));
-- Force the hot rank active as zero on 2 day-older posts (necro-bump)
update post_aggregates_fast as paf
set hot_rank_active = 0
where paf.id = NEW.post_id and (paf.published < ('now'::timestamp - '2 days'::interval));
-- Update community number of comments
update community_aggregates_fast as caf
set number_of_comments = number_of_comments + 1
from post as p
where caf.id = p.community_id and p.id = NEW.post_id;
END IF;
return null;
end $$;

View file

@ -10,7 +10,15 @@ use crate::{
},
DbPool,
};
use lemmy_db::{naive_now, Bannable, Crud, Followable, Joinable, SortType};
use lemmy_db::{
diesel_option_overwrite,
naive_now,
Bannable,
Crud,
Followable,
Joinable,
SortType,
};
use lemmy_utils::{
generate_actor_keypair,
is_valid_community_name,
@ -40,6 +48,8 @@ pub struct CreateCommunity {
name: String,
title: String,
description: Option<String>,
icon: Option<String>,
banner: Option<String>,
category_id: i32,
nsfw: bool,
auth: String,
@ -97,6 +107,8 @@ pub struct EditCommunity {
pub edit_id: i32,
title: String,
description: Option<String>,
icon: Option<String>,
banner: Option<String>,
category_id: i32,
nsfw: bool,
auth: String,
@ -251,6 +263,8 @@ impl Perform for Oper<CreateCommunity> {
name: data.name.to_owned(),
title: data.title.to_owned(),
description: data.description.to_owned(),
icon: Some(data.icon.to_owned()),
banner: Some(data.banner.to_owned()),
category_id: data.category_id,
creator_id: user.id,
removed: None,
@ -332,10 +346,15 @@ impl Perform for Oper<EditCommunity> {
let edit_id = data.edit_id;
let read_community = blocking(pool, move |conn| Community::read(conn, edit_id)).await??;
let icon = diesel_option_overwrite(&data.icon);
let banner = diesel_option_overwrite(&data.banner);
let community_form = CommunityForm {
name: read_community.name,
title: data.title.to_owned(),
description: data.description.to_owned(),
icon,
banner,
category_id: data.category_id.to_owned(),
creator_id: read_community.creator_id,
removed: Some(read_community.removed),

View file

@ -21,6 +21,7 @@ use lemmy_db::{
category::*,
comment_view::*,
community_view::*,
diesel_option_overwrite,
moderator::*,
moderator_views::*,
naive_now,
@ -91,6 +92,8 @@ pub struct GetModlogResponse {
pub struct CreateSite {
pub name: String,
pub description: Option<String>,
pub icon: Option<String>,
pub banner: Option<String>,
pub enable_downvotes: bool,
pub open_registration: bool,
pub enable_nsfw: bool,
@ -101,6 +104,8 @@ pub struct CreateSite {
pub struct EditSite {
name: String,
description: Option<String>,
icon: Option<String>,
banner: Option<String>,
enable_downvotes: bool,
open_registration: bool,
enable_nsfw: bool,
@ -263,6 +268,8 @@ impl Perform for Oper<CreateSite> {
let site_form = SiteForm {
name: data.name.to_owned(),
description: data.description.to_owned(),
icon: Some(data.icon.to_owned()),
banner: Some(data.banner.to_owned()),
creator_id: user.id,
enable_downvotes: data.enable_downvotes,
open_registration: data.open_registration,
@ -300,9 +307,14 @@ impl Perform for Oper<EditSite> {
let found_site = blocking(pool, move |conn| Site::read(conn, 1)).await??;
let icon = diesel_option_overwrite(&data.icon);
let banner = diesel_option_overwrite(&data.banner);
let site_form = SiteForm {
name: data.name.to_owned(),
description: data.description.to_owned(),
icon,
banner,
creator_id: found_site.creator_id,
updated: Some(naive_now()),
enable_downvotes: data.enable_downvotes,
@ -365,6 +377,8 @@ impl Perform for Oper<GetSite> {
let create_site = CreateSite {
name: setup.site_name.to_owned(),
description: None,
icon: None,
banner: None,
enable_downvotes: true,
open_registration: true,
enable_nsfw: true,
@ -611,18 +625,9 @@ impl Perform for Oper<TransferSite> {
return Err(APIError::err("not_an_admin").into());
}
let site_form = SiteForm {
name: read_site.name,
description: read_site.description,
creator_id: data.user_id,
updated: Some(naive_now()),
enable_downvotes: read_site.enable_downvotes,
open_registration: read_site.open_registration,
enable_nsfw: read_site.enable_nsfw,
};
let update_site = move |conn: &'_ _| Site::update(conn, 1, &site_form);
if blocking(pool, update_site).await?.is_err() {
let new_creator_id = data.user_id;
let transfer_site = move |conn: &'_ _| Site::transfer(conn, new_creator_id);
if blocking(pool, transfer_site).await?.is_err() {
return Err(APIError::err("couldnt_update_site").into());
};

View file

@ -28,6 +28,7 @@ use lemmy_db::{
comment_view::*,
community::*,
community_view::*,
diesel_option_overwrite,
moderator::*,
naive_now,
password_reset_request::*,
@ -103,6 +104,8 @@ pub struct SaveUserSettings {
default_listing_type: i16,
lang: String,
avatar: Option<String>,
banner: Option<String>,
preferred_username: Option<String>,
email: Option<String>,
bio: Option<String>,
matrix_user_id: Option<String>,
@ -395,6 +398,7 @@ impl Perform for Oper<Register> {
email: data.email.to_owned(),
matrix_user_id: None,
avatar: None,
banner: None,
password_encrypted: data.password.to_owned(),
preferred_username: None,
updated: None,
@ -402,7 +406,7 @@ impl Perform for Oper<Register> {
banned: false,
show_nsfw: data.show_nsfw,
theme: "darkly".into(),
default_sort_type: SortType::Hot as i16,
default_sort_type: SortType::Active as i16,
default_listing_type: ListingType::Subscribed as i16,
lang: "browser".into(),
show_avatars: true,
@ -454,6 +458,8 @@ impl Perform for Oper<Register> {
public_key: Some(main_community_keypair.public_key),
last_refreshed_at: None,
published: None,
icon: None,
banner: None,
};
blocking(pool, move |conn| Community::create(conn, &community_form)).await??
}
@ -569,9 +575,13 @@ impl Perform for Oper<SaveUserSettings> {
None => read_user.bio,
};
let avatar = match &data.avatar {
Some(avatar) => Some(avatar.to_owned()),
None => read_user.avatar,
let avatar = diesel_option_overwrite(&data.avatar);
let banner = diesel_option_overwrite(&data.banner);
// The DB constraint should stop too many characters
let preferred_username = match &data.preferred_username {
Some(preferred_username) => Some(preferred_username.to_owned()),
None => read_user.preferred_username,
};
let password_encrypted = match &data.new_password {
@ -612,8 +622,9 @@ impl Perform for Oper<SaveUserSettings> {
email,
matrix_user_id: data.matrix_user_id.to_owned(),
avatar,
banner,
password_encrypted,
preferred_username: read_user.preferred_username,
preferred_username,
updated: Some(naive_now()),
admin: read_user.admin,
banned: read_user.banned,

View file

@ -34,7 +34,7 @@ use activitystreams::{
base::{AnyBase, BaseExt},
collection::{OrderedCollection, UnorderedCollection},
context,
object::Tombstone,
object::{Image, Tombstone},
prelude::*,
public,
};
@ -361,6 +361,32 @@ impl FromApub for CommunityForm {
check_slurs(&title)?;
check_slurs_opt(&description)?;
let icon = match group.icon() {
Some(any_image) => Some(
Image::from_any_base(any_image.as_one().unwrap().clone())
.unwrap()
.unwrap()
.url()
.unwrap()
.as_single_xsd_any_uri()
.map(|u| u.to_string()),
),
None => None,
};
let banner = match group.image() {
Some(any_image) => Some(
Image::from_any_base(any_image.as_one().unwrap().clone())
.unwrap()
.unwrap()
.url()
.unwrap()
.as_single_xsd_any_uri()
.map(|u| u.to_string()),
),
None => None,
};
Ok(CommunityForm {
name,
title,
@ -377,6 +403,8 @@ impl FromApub for CommunityForm {
private_key: None,
public_key: Some(group.ext_two.to_owned().public_key.public_key_pem),
last_refreshed_at: Some(naive_now()),
icon,
banner,
})
}
}

View file

@ -194,6 +194,8 @@ async fn receive_delete_community(
private_key: community.private_key,
public_key: community.public_key,
last_refreshed_at: None,
icon: Some(community.icon.to_owned()),
banner: Some(community.banner.to_owned()),
};
let community_id = community.id;

View file

@ -202,6 +202,8 @@ async fn receive_remove_community(
private_key: community.private_key,
public_key: community.public_key,
last_refreshed_at: None,
icon: Some(community.icon.to_owned()),
banner: Some(community.banner.to_owned()),
};
let community_id = community.id;

View file

@ -402,6 +402,8 @@ async fn receive_undo_delete_community(
private_key: community.private_key,
public_key: community.public_key,
last_refreshed_at: None,
icon: Some(community.icon.to_owned()),
banner: Some(community.banner.to_owned()),
};
let community_id = community.id;
@ -466,6 +468,8 @@ async fn receive_undo_remove_community(
private_key: community.private_key,
public_key: community.public_key,
last_refreshed_at: None,
icon: Some(community.icon.to_owned()),
banner: Some(community.banner.to_owned()),
};
let community_id = community.id;

View file

@ -65,6 +65,12 @@ impl ToApub for User_ {
person.set_icon(image.into_any_base()?);
}
if let Some(banner_url) = &self.banner {
let mut image = Image::new();
image.set_url(banner_url.to_owned());
person.set_image(image.into_any_base()?);
}
if let Some(bio) = &self.bio {
person.set_summary(bio.to_owned());
}
@ -214,13 +220,28 @@ impl FromApub for UserForm {
expected_domain: Option<Url>,
) -> Result<Self, LemmyError> {
let avatar = match person.icon() {
Some(any_image) => Image::from_any_base(any_image.as_one().unwrap().clone())
Some(any_image) => Some(
Image::from_any_base(any_image.as_one().unwrap().clone())
.unwrap()
.unwrap()
.url()
.unwrap()
.as_single_xsd_any_uri()
.map(|u| u.to_string()),
),
None => None,
};
let banner = match person.image() {
Some(any_image) => Some(
Image::from_any_base(any_image.as_one().unwrap().clone())
.unwrap()
.unwrap()
.url()
.unwrap()
.as_single_xsd_any_uri()
.map(|u| u.to_string()),
),
None => None,
};
@ -249,6 +270,7 @@ impl FromApub for UserForm {
banned: false,
email: None,
avatar,
banner,
updated: person.updated().map(|u| u.to_owned().naive_local()),
show_nsfw: false,
theme: "".to_string(),

View file

@ -53,7 +53,8 @@ fn user_updates_2020_04_02(conn: &PgConnection) -> Result<(), LemmyError> {
name: cuser.name.to_owned(),
email: cuser.email.to_owned(),
matrix_user_id: cuser.matrix_user_id.to_owned(),
avatar: cuser.avatar.to_owned(),
avatar: Some(cuser.avatar.to_owned()),
banner: Some(cuser.banner.to_owned()),
password_encrypted: cuser.password_encrypted.to_owned(),
preferred_username: cuser.preferred_username.to_owned(),
updated: None,
@ -116,6 +117,8 @@ fn community_updates_2020_04_02(conn: &PgConnection) -> Result<(), LemmyError> {
public_key: Some(keypair.public_key),
last_refreshed_at: Some(naive_now()),
published: None,
icon: Some(ccommunity.icon.to_owned()),
banner: Some(ccommunity.banner.to_owned()),
};
Community::update(&conn, ccommunity.id, &form)?;

View file

@ -27,7 +27,7 @@ use lemmy_server::{
blocking,
code_migrations::run_advanced_migrations,
rate_limit::{rate_limiter::RateLimiter, RateLimit},
routes::{api, federation, feeds, index, nodeinfo, webfinger},
routes::*,
websocket::server::*,
LemmyError,
};
@ -91,9 +91,10 @@ async fn main() -> Result<(), LemmyError> {
.data(server.clone())
.data(Client::default())
// The routes
.configure(move |cfg| api::config(cfg, &rate_limiter))
.configure(|cfg| api::config(cfg, &rate_limiter))
.configure(federation::config)
.configure(feeds::config)
.configure(|cfg| images::config(cfg, &rate_limiter))
.configure(index::config)
.configure(nodeinfo::config)
.configure(webfinger::config)

View file

@ -45,6 +45,10 @@ impl RateLimit {
self.kind(RateLimitType::Register)
}
pub fn image(&self) -> RateLimited {
self.kind(RateLimitType::Image)
}
fn kind(&self, type_: RateLimitType) -> RateLimited {
RateLimited {
rate_limiter: self.rate_limiter.clone(),
@ -101,6 +105,15 @@ impl RateLimited {
true,
)?;
}
RateLimitType::Image => {
limiter.check_rate_limit_full(
self.type_,
&ip_addr,
rate_limit.image,
rate_limit.image_per_second,
false,
)?;
}
};
}

View file

@ -15,6 +15,7 @@ pub enum RateLimitType {
Message,
Register,
Post,
Image,
}
/// Rate limiting based on rate type and IP addr

140
server/src/routes/images.rs Normal file
View file

@ -0,0 +1,140 @@
use crate::rate_limit::RateLimit;
use actix::clock::Duration;
use actix_web::{body::BodyStream, http::StatusCode, *};
use awc::Client;
use lemmy_utils::settings::Settings;
use serde::{Deserialize, Serialize};
pub fn config(cfg: &mut web::ServiceConfig, rate_limit: &RateLimit) {
let client = Client::build()
.header("User-Agent", "pict-rs-frontend, v0.1.0")
.timeout(Duration::from_secs(30))
.finish();
cfg
.data(client)
.service(
web::resource("/pictrs/image")
.wrap(rate_limit.image())
.route(web::post().to(upload)),
)
.service(web::resource("/pictrs/image/{filename}").route(web::get().to(full_res)))
.service(
web::resource("/pictrs/image/thumbnail{size}/{filename}").route(web::get().to(thumbnail)),
)
.service(web::resource("/pictrs/image/delete/{token}/{filename}").route(web::get().to(delete)));
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Image {
file: String,
delete_token: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Images {
msg: String,
files: Option<Vec<Image>>,
}
async fn upload(
req: HttpRequest,
body: web::Payload,
client: web::Data<Client>,
) -> Result<HttpResponse, Error> {
// TODO: check auth and rate limit here
let mut res = client
.request_from(format!("{}/image", Settings::get().pictrs_url), req.head())
.if_some(req.head().peer_addr, |addr, req| {
req.header("X-Forwarded-For", addr.to_string())
})
.send_stream(body)
.await?;
let images = res.json::<Images>().await?;
Ok(HttpResponse::build(res.status()).json(images))
}
async fn full_res(
filename: web::Path<String>,
req: HttpRequest,
client: web::Data<Client>,
) -> Result<HttpResponse, Error> {
let url = format!(
"{}/image/{}",
Settings::get().pictrs_url,
&filename.into_inner()
);
image(url, req, client).await
}
async fn thumbnail(
parts: web::Path<(u64, String)>,
req: HttpRequest,
client: web::Data<Client>,
) -> Result<HttpResponse, Error> {
let (size, file) = parts.into_inner();
let url = format!(
"{}/image/thumbnail{}/{}",
Settings::get().pictrs_url,
size,
&file
);
image(url, req, client).await
}
async fn image(
url: String,
req: HttpRequest,
client: web::Data<Client>,
) -> Result<HttpResponse, Error> {
let res = client
.request_from(url, req.head())
.if_some(req.head().peer_addr, |addr, req| {
req.header("X-Forwarded-For", addr.to_string())
})
.no_decompress()
.send()
.await?;
if res.status() == StatusCode::NOT_FOUND {
return Ok(HttpResponse::NotFound().finish());
}
let mut client_res = HttpResponse::build(res.status());
for (name, value) in res.headers().iter().filter(|(h, _)| *h != "connection") {
client_res.header(name.clone(), value.clone());
}
Ok(client_res.body(BodyStream::new(res)))
}
async fn delete(
components: web::Path<(String, String)>,
req: HttpRequest,
client: web::Data<Client>,
) -> Result<HttpResponse, Error> {
let (token, file) = components.into_inner();
let url = format!(
"{}/image/delete/{}/{}",
Settings::get().pictrs_url,
&token,
&file
);
let res = client
.request_from(url, req.head())
.if_some(req.head().peer_addr, |addr, req| {
req.header("X-Forwarded-For", addr.to_string())
})
.no_decompress()
.send()
.await?;
Ok(HttpResponse::build(res.status()).body(BodyStream::new(res)))
}

View file

@ -1,6 +1,7 @@
pub mod api;
pub mod federation;
pub mod feeds;
pub mod images;
pub mod index;
pub mod nodeinfo;
pub mod webfinger;

View file

@ -1 +1 @@
pub const VERSION: &str = "v0.7.39";
pub const VERSION: &str = "v0.7.43";

View file

@ -282,3 +282,19 @@ br.big {
margin-top: 1rem;
}
.banner {
object-fit: cover;
width: 100%;
max-height: 240px;
}
.avatar-overlay {
width: 20%;
height: 20%;
max-width: 120px;
max-height: 120px;
}
.avatar-pushup {
margin-top: -60px;
}

View file

@ -0,0 +1,104 @@
$white: #fff;
$gray-100: #f8f9fa;
$gray-200: #ebebeb;
$gray-300: #dee2e6;
$gray-400: #ced4da;
$gray-500: #adb5bd;
$gray-600: #888;
$gray-700: #444;
$gray-800: #303030;
$gray-900: #222;
$black: #000;
$blue: #375a7f;
$indigo: #6610f2;
$purple: #6f42c1;
$pink: #e83e8c;
$red: #e74c3c;
$orange: #fd7e14;
$yellow: #f39c12;
$green: #00bc8c;
$teal: #20c997;
$cyan: #3498db;
$primary: $blue;
$secondary: $gray-700;
$success: $green;
$info: $cyan;
$warning: $yellow;
$danger: $red;
$dark: $gray-300;
$yiq-contrasted-threshold: 175;
$body-bg: $gray-900;
$body-color: $gray-300;
$link-color: $success;
$font-family-sans-serif: "Lato", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
$font-size-base: 0.9375rem;
$h1-font-size: 3rem;
$h2-font-size: 2.5rem;
$h3-font-size: 2rem;
$text-muted: $gray-600;
$table-accent-bg: $gray-800;
$table-border-color: $gray-700;
$input-border-color: $body-bg;
$input-group-addon-color: $gray-500;
$input-group-addon-bg: $gray-700;
$custom-file-color: $gray-500;
$custom-file-border-color: $body-bg;
$dropdown-bg: $gray-900;
$dropdown-border-color: $gray-700;
$dropdown-divider-bg: $gray-700;
$dropdown-link-color: $white;
$dropdown-link-hover-color: $white;
$dropdown-link-hover-bg: $primary;
$nav-link-padding-x: 2rem;
$nav-link-disabled-color: $gray-500;
$nav-tabs-border-color: $gray-700;
$nav-tabs-link-hover-border-color: $nav-tabs-border-color $nav-tabs-border-color transparent;
$nav-tabs-link-active-color: $white;
$nav-tabs-link-active-border-color: $nav-tabs-border-color $nav-tabs-border-color transparent;
$navbar-padding-y: 1rem;
$navbar-dark-color: rgba($white,.6);
$navbar-dark-hover-color: $white;
$navbar-light-color: rgba($white,.6);
$navbar-light-hover-color: $white;
$navbar-light-active-color: $white;
$navbar-light-toggler-border-color: rgba($gray-900, .1);
$pagination-color: $white;
$pagination-bg: $success;
$pagination-border-width: 0;
$pagination-border-color: transparent;
$pagination-hover-color: $white;
$pagination-hover-bg: lighten($success, 10%);
$pagination-hover-border-color: transparent;
$pagination-active-bg: $pagination-hover-bg;
$pagination-active-border-color: transparent;
$pagination-disabled-color: $white;
$pagination-disabled-bg: darken($success, 15%);
$pagination-disabled-border-color: transparent;
$jumbotron-bg: $gray-800;
$card-cap-bg: $gray-700;
$card-bg: $gray-800;
$popover-bg: $gray-800;
$popover-header-bg: $gray-700;
$toast-background-color: $gray-700;
$toast-header-background-color: $gray-800;
$modal-content-bg: $gray-800;
$modal-content-border-color: $gray-700;
$modal-header-border-color: $gray-700;
$progress-bg: $gray-700;
$list-group-bg: $gray-800;
$list-group-border-color: $gray-700;
$list-group-hover-bg: $gray-700;
$breadcrumb-bg: $gray-700;
$close-color: $white;
$close-text-shadow: none;
$pre-color: inherit;
$mark-bg: #333;
$custom-select-bg: $secondary;
$custom-select-color: $white;
$input-bg: $secondary;
$input-color: $white;
$input-disabled-bg: darken($secondary, 10%);;
$light: $gray-800;
$navbar-light-brand-color: $navbar-dark-active-color;
$navbar-light-brand-hover-color: $navbar-dark-active-color;

File diff suppressed because one or more lines are too long

View file

@ -668,7 +668,7 @@ export async function saveUserSettingsBio(
let form: UserSettingsForm = {
show_nsfw: true,
theme: 'darkly',
default_sort_type: SortType.Hot,
default_sort_type: SortType.Active,
default_listing_type: ListingType.All,
lang: 'en',
show_avatars: true,

View file

@ -125,6 +125,7 @@ export class AdminSettings extends Component<any, AdminSettingsState> {
<UserListing
user={{
name: admin.name,
preferred_username: admin.preferred_username,
avatar: admin.avatar,
id: admin.id,
local: admin.local,
@ -148,6 +149,7 @@ export class AdminSettings extends Component<any, AdminSettingsState> {
<UserListing
user={{
name: banned.name,
preferred_username: banned.preferred_username,
avatar: banned.avatar,
id: banned.id,
local: banned.local,

View file

@ -0,0 +1,30 @@
import { Component } from 'inferno';
interface BannerIconHeaderProps {
banner?: string;
icon?: string;
}
export class BannerIconHeader extends Component<BannerIconHeaderProps, any> {
constructor(props: any, context: any) {
super(props, context);
}
render() {
return (
<div class="position-relative mb-2">
{this.props.banner && (
<img src={this.props.banner} class="banner img-fluid" />
)}
{this.props.icon && (
<img
src={this.props.icon}
className={`ml-2 mb-0 ${
this.props.banner ? 'avatar-pushup' : ''
} rounded-circle avatar-overlay`}
/>
)}
</div>
);
}
}

View file

@ -158,6 +158,7 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
<UserListing
user={{
name: node.comment.creator_name,
preferred_username: node.comment.creator_preferred_username,
avatar: node.comment.creator_avatar,
id: node.comment.creator_id,
local: node.comment.creator_local,
@ -196,6 +197,7 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
id: node.comment.community_id,
local: node.comment.community_local,
actor_id: node.comment.community_actor_id,
icon: node.comment.community_icon,
}}
/>
<span class="mx-2"></span>

View file

@ -101,7 +101,6 @@ export class Communities extends Component<any, CommunitiesState> {
<thead class="pointer">
<tr>
<th>{i18n.t('name')}</th>
<th class="d-none d-lg-table-cell">{i18n.t('title')}</th>
<th>{i18n.t('category')}</th>
<th class="text-right">{i18n.t('subscribers')}</th>
<th class="text-right d-none d-lg-table-cell">
@ -119,7 +118,6 @@ export class Communities extends Component<any, CommunitiesState> {
<td>
<CommunityLink community={community} />
</td>
<td class="d-none d-lg-table-cell">{community.title}</td>
<td>{community.category_name}</td>
<td class="text-right">
{community.number_of_subscribers}

View file

@ -16,6 +16,7 @@ import { i18n } from '../i18next';
import { Community } from '../interfaces';
import { MarkdownTextArea } from './markdown-textarea';
import { ImageUploadForm } from './image-upload-form';
interface CommunityFormProps {
community?: Community; // If a community is given, that means this is an edit
@ -44,6 +45,8 @@ export class CommunityForm extends Component<
title: null,
category_id: null,
nsfw: false,
icon: null,
banner: null,
},
categories: [],
loading: false,
@ -58,6 +61,12 @@ export class CommunityForm extends Component<
this
);
this.handleIconUpload = this.handleIconUpload.bind(this);
this.handleIconRemove = this.handleIconRemove.bind(this);
this.handleBannerUpload = this.handleBannerUpload.bind(this);
this.handleBannerRemove = this.handleBannerRemove.bind(this);
if (this.props.community) {
this.state.communityForm = {
name: this.props.community.name,
@ -66,6 +75,8 @@ export class CommunityForm extends Component<
description: this.props.community.description,
edit_id: this.props.community.id,
nsfw: this.props.community.nsfw,
icon: this.props.community.icon,
banner: this.props.community.banner,
auth: null,
};
}
@ -166,6 +177,25 @@ export class CommunityForm extends Component<
/>
</div>
</div>
<div class="form-group">
<label>{i18n.t('icon')}</label>
<ImageUploadForm
uploadTitle={i18n.t('upload_icon')}
imageSrc={this.state.communityForm.icon}
onUpload={this.handleIconUpload}
onRemove={this.handleIconRemove}
rounded
/>
</div>
<div class="form-group">
<label>{i18n.t('banner')}</label>
<ImageUploadForm
uploadTitle={i18n.t('upload_banner')}
imageSrc={this.state.communityForm.banner}
onUpload={this.handleBannerUpload}
onRemove={this.handleBannerRemove}
/>
</div>
<div class="form-group row">
<label class="col-12 col-form-label" htmlFor={this.id}>
{i18n.t('sidebar')}
@ -286,6 +316,26 @@ export class CommunityForm extends Component<
i.props.onCancel();
}
handleIconUpload(url: string) {
this.state.communityForm.icon = url;
this.setState(this.state);
}
handleIconRemove() {
this.state.communityForm.icon = '';
this.setState(this.state);
}
handleBannerUpload(url: string) {
this.state.communityForm.banner = url;
this.setState(this.state);
}
handleBannerRemove() {
this.state.communityForm.banner = '';
this.setState(this.state);
}
parseMessage(msg: WebSocketJsonResponse) {
let res = wsJsonToRes(msg);
console.log(msg);
@ -305,9 +355,7 @@ export class CommunityForm extends Component<
let data = res.data as CommunityResponse;
this.state.loading = false;
this.props.onCreate(data.community);
}
// TODO is this necessary
else if (res.op == UserOperation.EditCommunity) {
} else if (res.op == UserOperation.EditCommunity) {
let data = res.data as CommunityResponse;
this.state.loading = false;
this.props.onEdit(data.community);

View file

@ -1,11 +1,12 @@
import { Component } from 'inferno';
import { Link } from 'inferno-router';
import { Community } from '../interfaces';
import { hostname } from '../utils';
import { hostname, pictrsAvatarThumbnail, showAvatars } from '../utils';
interface CommunityOther {
name: string;
id?: number; // Necessary if its federated
icon?: string;
local?: boolean;
actor_id?: string;
}
@ -13,6 +14,9 @@ interface CommunityOther {
interface CommunityLinkProps {
community: Community | CommunityOther;
realLink?: boolean;
useApubName?: boolean;
muted?: boolean;
hideAvatar?: boolean;
}
export class CommunityLink extends Component<CommunityLinkProps, any> {
@ -33,6 +37,24 @@ export class CommunityLink extends Component<CommunityLinkProps, any> {
? `/community/${community.id}`
: community.actor_id;
}
return <Link to={link}>{name_}</Link>;
let apubName = `!${name_}`;
let displayName = this.props.useApubName ? apubName : name_;
return (
<Link
title={apubName}
className={`${this.props.muted ? 'text-muted' : ''}`}
to={link}
>
{!this.props.hideAvatar && community.icon && showAvatars() && (
<img
style="width: 2rem; height: 2rem;"
src={pictrsAvatarThumbnail(community.icon)}
class="rounded-circle mr-2"
/>
)}
<span>{displayName}</span>
</Link>
);
}
}

View file

@ -33,6 +33,8 @@ import { CommentNodes } from './comment-nodes';
import { SortSelect } from './sort-select';
import { DataTypeSelect } from './data-type-select';
import { Sidebar } from './sidebar';
import { CommunityLink } from './community-link';
import { BannerIconHeader } from './banner-icon-header';
import {
wsJsonToRes,
fetchLimit,
@ -47,6 +49,7 @@ import {
editPostFindRes,
commentsToFlatNodes,
setupTippy,
favIconUrl,
} from '../utils';
import { i18n } from '../i18next';
@ -126,6 +129,9 @@ export class Community extends Component<any, State> {
enable_downvotes: undefined,
open_registration: undefined,
enable_nsfw: undefined,
icon: undefined,
banner: undefined,
creator_preferred_username: undefined,
},
};
@ -183,10 +189,25 @@ export class Community extends Component<any, State> {
}
}
get favIcon(): string {
return this.state.community.icon
? this.state.community.icon
: this.state.site.icon
? this.state.site.icon
: favIconUrl;
}
render() {
return (
<div class="container">
<Helmet title={this.documentTitle} />
<Helmet title={this.documentTitle}>
<link
id="favicon"
rel="icon"
type="image/x-icon"
href={this.favIcon}
/>
</Helmet>
{this.state.loading ? (
<h5>
<svg class="icon icon-spinner spin">
@ -196,6 +217,7 @@ export class Community extends Component<any, State> {
) : (
<div class="row">
<div class="col-12 col-md-8">
{this.communityInfo()}
{this.selects()}
{this.listings()}
{this.paginator()}
@ -235,6 +257,26 @@ export class Community extends Component<any, State> {
);
}
communityInfo() {
return (
<div>
<BannerIconHeader
banner={this.state.community.banner}
icon={this.state.community.icon}
/>
<h5 class="mb-0">{this.state.community.title}</h5>
<CommunityLink
community={this.state.community}
realLink
useApubName
muted
hideAvatar
/>
<hr />
</div>
);
}
selects() {
return (
<div class="mb-3">

114
ui/src/components/image-upload-form.tsx vendored Normal file
View file

@ -0,0 +1,114 @@
import { Component, linkEvent } from 'inferno';
import { UserService } from '../services';
import { toast, randomStr } from '../utils';
interface ImageUploadFormProps {
uploadTitle: string;
imageSrc: string;
onUpload(url: string): any;
onRemove(): any;
rounded?: boolean;
}
interface ImageUploadFormState {
loading: boolean;
}
export class ImageUploadForm extends Component<
ImageUploadFormProps,
ImageUploadFormState
> {
private id = `image-upload-form-${randomStr()}`;
private emptyState: ImageUploadFormState = {
loading: false,
};
constructor(props: any, context: any) {
super(props, context);
this.state = this.emptyState;
}
render() {
return (
<form class="d-inline">
<label
htmlFor={this.id}
class="pointer ml-4 text-muted small font-weight-bold"
>
{!this.props.imageSrc ? (
<span class="btn btn-secondary">{this.props.uploadTitle}</span>
) : (
<span class="d-inline-block position-relative">
<img
src={this.props.imageSrc}
height={this.props.rounded ? 60 : ''}
width={this.props.rounded ? 60 : ''}
className={`img-fluid ${
this.props.rounded ? 'rounded-circle' : ''
}`}
/>
<a onClick={linkEvent(this, this.handleRemoveImage)}>
<svg class="icon mini-overlay">
<use xlinkHref="#icon-x"></use>
</svg>
</a>
</span>
)}
</label>
<input
id={this.id}
type="file"
accept="image/*,video/*"
name={this.id}
class="d-none"
disabled={!UserService.Instance.user}
onChange={linkEvent(this, this.handleImageUpload)}
/>
</form>
);
}
handleImageUpload(i: ImageUploadForm, event: any) {
event.preventDefault();
let file = event.target.files[0];
const imageUploadUrl = `/pictrs/image`;
const formData = new FormData();
formData.append('images[]', file);
i.state.loading = true;
i.setState(i.state);
fetch(imageUploadUrl, {
method: 'POST',
body: formData,
})
.then(res => res.json())
.then(res => {
console.log('pictrs upload:');
console.log(res);
if (res.msg == 'ok') {
let hash = res.files[0].file;
let url = `${window.location.origin}/pictrs/image/${hash}`;
i.state.loading = false;
i.setState(i.state);
i.props.onUpload(url);
} else {
i.state.loading = false;
i.setState(i.state);
toast(JSON.stringify(res), 'danger');
}
})
.catch(error => {
i.state.loading = false;
i.setState(i.state);
toast(error, 'danger');
});
}
handleRemoveImage(i: ImageUploadForm, event: any) {
event.preventDefault();
i.state.loading = true;
i.setState(i.state);
i.props.onRemove();
}
}

View file

@ -36,6 +36,7 @@ import { DataTypeSelect } from './data-type-select';
import { SiteForm } from './site-form';
import { UserListing } from './user-listing';
import { CommunityLink } from './community-link';
import { BannerIconHeader } from './banner-icon-header';
import {
wsJsonToRes,
repoUrl,
@ -53,6 +54,7 @@ import {
editPostFindRes,
commentsToFlatNodes,
setupTippy,
favIconUrl,
} from '../utils';
import { i18n } from '../i18next';
import { T } from 'inferno-i18next';
@ -104,6 +106,9 @@ export class Main extends Component<any, MainState> {
enable_downvotes: null,
open_registration: null,
enable_nsfw: null,
icon: null,
banner: null,
creator_preferred_username: null,
},
admins: [],
banned: [],
@ -186,10 +191,23 @@ export class Main extends Component<any, MainState> {
}
}
get favIcon(): string {
return this.state.siteRes.site.icon
? this.state.siteRes.site.icon
: favIconUrl;
}
render() {
return (
<div class="container">
<Helmet title={this.documentTitle} />
<Helmet title={this.documentTitle}>
<link
id="favicon"
rel="icon"
type="image/x-icon"
href={this.favIcon}
/>
</Helmet>
<div class="row">
<main role="main" class="col-12 col-md-8">
{this.posts()}
@ -207,9 +225,12 @@ export class Main extends Component<any, MainState> {
<div>
<div class="card bg-transparent border-secondary mb-3">
<div class="card-header bg-transparent border-secondary">
<div class="mb-2">
{this.siteName()}
{this.adminButtons()}
</div>
<BannerIconHeader banner={this.state.siteRes.site.banner} />
</div>
<div class="card-body">
{this.trendingCommunities()}
{this.createCommunityButton()}
@ -284,6 +305,7 @@ export class Main extends Component<any, MainState> {
id: community.community_id,
local: community.community_local,
actor_id: community.community_actor_id,
icon: community.community_icon,
}}
/>
</li>
@ -346,6 +368,7 @@ export class Main extends Component<any, MainState> {
<UserListing
user={{
name: admin.name,
preferred_username: admin.preferred_username,
avatar: admin.avatar,
local: admin.local,
actor_id: admin.actor_id,

View file

@ -16,7 +16,6 @@ import {
Comment,
CommentResponse,
PrivateMessage,
UserView,
PrivateMessageResponse,
WebSocketJsonResponse,
} from '../interfaces';
@ -41,12 +40,11 @@ interface NavbarState {
mentions: Array<Comment>;
messages: Array<PrivateMessage>;
unreadCount: number;
siteName: string;
version: string;
admins: Array<UserView>;
searchParam: string;
toggleSearch: boolean;
siteLoading: boolean;
siteRes: GetSiteResponse;
onSiteBanner?(url: string): any;
}
export class Navbar extends Component<any, NavbarState> {
@ -61,9 +59,30 @@ export class Navbar extends Component<any, NavbarState> {
mentions: [],
messages: [],
expanded: false,
siteName: undefined,
version: undefined,
siteRes: {
site: {
id: null,
name: null,
creator_id: null,
creator_name: null,
published: null,
number_of_users: null,
number_of_posts: null,
number_of_comments: null,
number_of_communities: null,
enable_downvotes: null,
open_registration: null,
enable_nsfw: null,
icon: null,
banner: null,
creator_preferred_username: null,
},
my_user: null,
admins: [],
banned: [],
online: null,
version: null,
},
searchParam: '',
toggleSearch: false,
siteLoading: true,
@ -158,12 +177,25 @@ export class Navbar extends Component<any, NavbarState> {
// TODO class active corresponding to current page
navbar() {
let user = UserService.Instance.user;
return (
<nav class="navbar navbar-expand-lg navbar-light shadow-sm p-0 px-3">
<div class="container">
{!this.state.siteLoading ? (
<Link title={this.state.version} class="navbar-brand" to="/">
{this.state.siteName}
<Link
title={this.state.siteRes.version}
class="d-flex align-items-center navbar-brand mr-md-3"
to="/"
>
{this.state.siteRes.site.icon && showAvatars() && (
<img
src={pictrsAvatarThumbnail(this.state.siteRes.site.icon)}
height="32"
width="32"
class="rounded-circle mr-2"
/>
)}
{this.state.siteRes.site.name}
</Link>
) : (
<div class="navbar-item">
@ -182,14 +214,14 @@ export class Navbar extends Component<any, NavbarState> {
<use xlinkHref="#icon-bell"></use>
</svg>
{this.state.unreadCount > 0 && (
<span class="ml-1 badge badge-light">
<span class="mx-1 badge badge-light">
{this.state.unreadCount}
</span>
)}
</Link>
)}
<button
class="navbar-toggler border-0"
class="navbar-toggler border-0 p-1"
type="button"
aria-label="menu"
onClick={linkEvent(this, this.expandNavbar)}
@ -246,6 +278,21 @@ export class Navbar extends Component<any, NavbarState> {
</Link>
</li>
</ul>
<ul class="navbar-nav my-2">
{this.canAdmin && (
<li className="nav-item">
<Link
class="nav-link"
to={`/admin`}
title={i18n.t('admin_settings')}
>
<svg class="icon">
<use xlinkHref="#icon-settings"></use>
</svg>
</Link>
</li>
)}
</ul>
{!this.context.router.history.location.pathname.match(
/^\/search/
) && (
@ -267,7 +314,7 @@ export class Navbar extends Component<any, NavbarState> {
<button
name="search-btn"
onClick={linkEvent(this, this.handleSearchBtn)}
class="btn btn-link"
class="px-1 btn btn-link"
style="color: var(--gray)"
>
<svg class="icon">
@ -276,21 +323,6 @@ export class Navbar extends Component<any, NavbarState> {
</button>
</form>
)}
<ul class="navbar-nav my-2">
{this.canAdmin && (
<li className="nav-item">
<Link
class="nav-link"
to={`/admin`}
title={i18n.t('admin_settings')}
>
<svg class="icon">
<use xlinkHref="#icon-settings"></use>
</svg>
</Link>
</li>
)}
</ul>
{this.state.isLoggedIn ? (
<>
<ul class="navbar-nav my-2">
@ -315,22 +347,21 @@ export class Navbar extends Component<any, NavbarState> {
<li className="nav-item">
<Link
class="nav-link"
to={`/u/${UserService.Instance.user.name}`}
to={`/u/${user.name}`}
title={i18n.t('settings')}
>
<span>
{UserService.Instance.user.avatar &&
showAvatars() && (
{user.avatar && showAvatars() && (
<img
src={pictrsAvatarThumbnail(
UserService.Instance.user.avatar
)}
src={pictrsAvatarThumbnail(user.avatar)}
height="32"
width="32"
class="rounded-circle mr-2"
/>
)}
{UserService.Instance.user.name}
{user.preferred_username
? user.preferred_username
: user.name}
</span>
</Link>
</li>
@ -422,11 +453,7 @@ export class Navbar extends Component<any, NavbarState> {
} else if (res.op == UserOperation.GetSite) {
let data = res.data as GetSiteResponse;
if (data.site && !this.state.siteName) {
this.state.siteName = data.site.name;
this.state.version = data.version;
this.state.admins = data.admins;
}
this.state.siteRes = data;
// The login
if (data.my_user) {
@ -495,7 +522,9 @@ export class Navbar extends Component<any, NavbarState> {
get canAdmin(): boolean {
return (
UserService.Instance.user &&
this.state.admins.map(a => a.id).includes(UserService.Instance.user.id)
this.state.siteRes.admins
.map(a => a.id)
.includes(UserService.Instance.user.id)
);
}

View file

@ -163,7 +163,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
return (
<img
className={`img-fluid thumbnail rounded ${
(post.nsfw || post.community_nsfw) && 'img-blur'
post.nsfw || post.community_nsfw ? 'img-blur' : ''
}`}
src={src}
/>
@ -190,8 +190,8 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
if (isImage(post.url)) {
return (
<span
class="text-body pointer"
<div
class="float-right text-body pointer d-inline-block position-relative"
data-tippy-content={i18n.t('expand_here')}
onClick={linkEvent(this, this.handleImageExpandClick)}
>
@ -199,12 +199,12 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
<svg class="icon mini-overlay">
<use xlinkHref="#icon-image"></use>
</svg>
</span>
</div>
);
} else if (post.thumbnail_url) {
return (
<a
className="text-body"
class="float-right text-body d-inline-block position-relative"
href={post.url}
target="_blank"
rel="noopener"
@ -265,10 +265,93 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
}
}
listing() {
createdLine() {
let post = this.props.post;
return (
<div class="row">
<ul class="list-inline mb-1 text-muted small">
<li className="list-inline-item">
<UserListing
user={{
name: post.creator_name,
preferred_username: post.creator_preferred_username,
avatar: post.creator_avatar,
id: post.creator_id,
local: post.creator_local,
actor_id: post.creator_actor_id,
published: post.creator_published,
}}
/>
{this.isMod && (
<span className="mx-1 badge badge-light">{i18n.t('mod')}</span>
)}
{this.isAdmin && (
<span className="mx-1 badge badge-light">{i18n.t('admin')}</span>
)}
{(post.banned_from_community || post.banned) && (
<span className="mx-1 badge badge-danger">{i18n.t('banned')}</span>
)}
{this.props.showCommunity && (
<span>
<span class="mx-1"> {i18n.t('to')} </span>
<CommunityLink
community={{
name: post.community_name,
id: post.community_id,
local: post.community_local,
actor_id: post.community_actor_id,
icon: post.community_icon,
}}
/>
</span>
)}
</li>
<li className="list-inline-item"></li>
{post.url && !(hostname(post.url) == window.location.hostname) && (
<>
<li className="list-inline-item">
<a
className="text-muted font-italic"
href={post.url}
target="_blank"
title={post.url}
rel="noopener"
>
{hostname(post.url)}
</a>
</li>
<li className="list-inline-item"></li>
</>
)}
<li className="list-inline-item">
<span>
<MomentTime data={post} />
</span>
</li>
{post.body && (
<>
<li className="list-inline-item"></li>
<li className="list-inline-item">
{/* Using a link with tippy doesn't work on touch devices unfortunately */}
<Link
className="text-muted"
data-tippy-content={md.render(previewLines(post.body))}
data-tippy-allowHtml={true}
to={`/post/${post.id}`}
>
<svg class="mr-1 icon icon-inline">
<use xlinkHref="#icon-book-open"></use>
</svg>
</Link>
</li>
</>
)}
</ul>
);
}
voteBar() {
return (
<div className={`vote-bar col-1 pr-0 small text-center`}>
<button
className={`btn-animate btn btn-link p-0 ${
@ -301,18 +384,14 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
</button>
)}
</div>
{!this.state.imageExpanded && (
<div class="col-3 col-sm-2 pr-0">
<div class="position-relative">{this.thumbnail()}</div>
</div>
)}
<div
class={`${this.state.imageExpanded ? 'col-12' : 'col-8 col-sm-9'}`}
>
<div class="row">
<div className="col-12">
<div className="post-title">
<h5 className="mb-1 d-inline-block">
);
}
postTitleLine() {
let post = this.props.post;
return (
<div className="post-title overflow-hidden">
<h5>
{this.props.showBody && post.url ? (
<a
className={!post.stickied ? 'text-body' : 'text-primary'}
@ -332,23 +411,6 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
{post.name}
</Link>
)}
</h5>
{post.url && !(hostname(post.url) == window.location.hostname) && (
<small class="d-inline-block">
<a
className="ml-2 text-muted font-italic"
href={post.url}
target="_blank"
title={post.url}
rel="noopener"
>
{hostname(post.url)}
<svg class="ml-1 icon icon-inline">
<use xlinkHref="#icon-external-link"></use>
</svg>
</a>
</small>
)}
{(isImage(post.url) || this.props.post.thumbnail_url) && (
<>
{!this.state.imageExpanded ? (
@ -374,10 +436,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
<div>
<span
class="pointer"
onClick={linkEvent(
this,
this.handleImageExpandClick
)}
onClick={linkEvent(this, this.handleImageExpandClick)}
>
<img
class="img-fluid img-expanded"
@ -429,80 +488,15 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
{i18n.t('nsfw')}
</small>
)}
</h5>
</div>
</div>
</div>
<div class="row">
<div className="details col-12">
<ul class="list-inline mb-1 text-muted small">
<li className="list-inline-item">
<span>{i18n.t('by')} </span>
<UserListing
user={{
name: post.creator_name,
avatar: post.creator_avatar,
id: post.creator_id,
local: post.creator_local,
actor_id: post.creator_actor_id,
published: post.creator_published,
}}
/>
);
}
{this.isMod && (
<span className="mx-1 badge badge-light">
{i18n.t('mod')}
</span>
)}
{this.isAdmin && (
<span className="mx-1 badge badge-light">
{i18n.t('admin')}
</span>
)}
{(post.banned_from_community || post.banned) && (
<span className="mx-1 badge badge-danger">
{i18n.t('banned')}
</span>
)}
{this.props.showCommunity && (
<span>
<span> {i18n.t('to')} </span>
<CommunityLink
community={{
name: post.community_name,
id: post.community_id,
local: post.community_local,
actor_id: post.community_actor_id,
}}
/>
</span>
)}
</li>
<li className="list-inline-item"></li>
<li className="list-inline-item">
<span>
<MomentTime data={post} />
</span>
</li>
{post.body && (
<>
<li className="list-inline-item"></li>
<li className="list-inline-item">
{/* Using a link with tippy doesn't work on touch devices unfortunately */}
<Link
className="text-muted"
data-tippy-content={md.render(previewLines(post.body))}
data-tippy-allowHtml={true}
to={`/post/${post.id}`}
>
<svg class="mr-1 icon icon-inline">
<use xlinkHref="#icon-book-open"></use>
</svg>
</Link>
</li>
</>
)}
</ul>
<ul class="list-inline mb-1 text-muted small">
commentsLine(showVotes: boolean = false) {
let post = this.props.post;
return (
<ul class="d-flex align-items-center list-inline mb-1 text-muted small">
<li className="list-inline-item">
<Link
className="text-muted"
@ -519,7 +513,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
})}
</Link>
</li>
{this.state.upvotes !== this.state.score && (
{(showVotes || this.state.upvotes !== this.state.score) && (
<>
<li className="list-inline-item"></li>
<span
@ -527,26 +521,41 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
data-tippy-content={this.pointsTippy}
>
<li className="list-inline-item">
<span className="text-muted">
<a
className={`btn-animate btn btn-link p-0 ${
this.state.my_vote == 1 ? 'text-info' : 'text-muted'
}`}
onClick={linkEvent(this, this.handlePostLike)}
>
<svg class="small icon icon-inline mr-1">
<use xlinkHref="#icon-arrow-up"></use>
</svg>
{this.state.upvotes}
</span>
</a>
</li>
<li className="list-inline-item">
<span className="text-muted">
<a
className={`btn-animate btn btn-link p-0 ${
this.state.my_vote == -1 ? 'text-danger' : 'text-muted'
}`}
onClick={linkEvent(this, this.handlePostDisLike)}
>
<svg class="small icon icon-inline mr-1">
<use xlinkHref="#icon-arrow-down"></use>
</svg>
{this.state.downvotes}
</span>
</a>
</li>
</span>
</>
)}
</ul>
{this.props.post.duplicates && (
);
}
duplicatesLine() {
return (
this.props.post.duplicates && (
<ul class="list-inline mb-1 small text-muted">
<>
<li className="list-inline-item mr-2">
@ -554,14 +563,18 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
</li>
{this.props.post.duplicates.map(post => (
<li className="list-inline-item mr-2">
<Link to={`/post/${post.id}`}>
{post.community_name}
</Link>
<Link to={`/post/${post.id}`}>{post.community_name}</Link>
</li>
))}
</>
</ul>
)}
)
);
}
postActions() {
let post = this.props.post;
return (
<ul class="list-inline mb-1 text-muted font-weight-bold">
{UserService.Instance.user && (
<>
@ -576,9 +589,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
}
>
<svg
class={`icon icon-inline ${
post.saved && 'text-warning'
}`}
class={`icon icon-inline ${post.saved && 'text-warning'}`}
>
<use xlinkHref="#icon-star"></use>
</svg>
@ -615,9 +626,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
class="btn btn-link btn-animate text-muted"
onClick={linkEvent(this, this.handleDeleteClick)}
data-tippy-content={
!post.deleted
? i18n.t('delete')
: i18n.t('restore')
!post.deleted ? i18n.t('delete') : i18n.t('restore')
}
>
<svg
@ -670,9 +679,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
class="btn btn-link btn-animate text-muted"
onClick={linkEvent(this, this.handleModLock)}
data-tippy-content={
post.locked
? i18n.t('unlock')
: i18n.t('lock')
post.locked ? i18n.t('unlock') : i18n.t('lock')
}
>
<svg
@ -689,9 +696,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
class="btn btn-link btn-animate text-muted"
onClick={linkEvent(this, this.handleModSticky)}
data-tippy-content={
post.stickied
? i18n.t('unsticky')
: i18n.t('sticky')
post.stickied ? i18n.t('unsticky') : i18n.t('sticky')
}
>
<svg
@ -711,20 +716,14 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
{!post.removed ? (
<span
class="pointer"
onClick={linkEvent(
this,
this.handleModRemoveShow
)}
onClick={linkEvent(this, this.handleModRemoveShow)}
>
{i18n.t('remove')}
</span>
) : (
<span
class="pointer"
onClick={linkEvent(
this,
this.handleModRemoveSubmit
)}
onClick={linkEvent(this, this.handleModRemoveSubmit)}
>
{i18n.t('restore')}
</span>
@ -776,8 +775,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
</>
)}
{/* Community creators and admins can transfer community to another mod */}
{(this.amCommunityCreator || this.canAdmin) &&
this.isMod && (
{(this.amCommunityCreator || this.canAdmin) && this.isMod && (
<li className="list-inline-item">
{!this.state.showConfirmTransferCommunity ? (
<span
@ -807,8 +805,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
class="pointer d-inline-block"
onClick={linkEvent(
this,
this
.handleCancelShowConfirmTransferCommunity
this.handleCancelShowConfirmTransferCommunity
)}
>
{i18n.t('no')}
@ -825,20 +822,14 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
{!post.banned ? (
<span
class="pointer"
onClick={linkEvent(
this,
this.handleModBanShow
)}
onClick={linkEvent(this, this.handleModBanShow)}
>
{i18n.t('ban_from_site')}
</span>
) : (
<span
class="pointer"
onClick={linkEvent(
this,
this.handleModBanSubmit
)}
onClick={linkEvent(this, this.handleModBanSubmit)}
>
{i18n.t('unban_from_site')}
</span>
@ -879,10 +870,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
</span>
<span
class="pointer d-inline-block mr-1"
onClick={linkEvent(
this,
this.handleTransferSite
)}
onClick={linkEvent(this, this.handleTransferSite)}
>
{i18n.t('yes')}
</span>
@ -904,6 +892,13 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
</>
)}
</ul>
);
}
removeAndBanDialogs() {
let post = this.props.post;
return (
<>
{this.state.showRemoveDialog && (
<form
class="form-inline"
@ -948,10 +943,91 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
</div>
</form>
)}
</>
);
}
mobileThumbnail() {
return this.props.post.thumbnail_url || isImage(this.props.post.url) ? (
<div class="row">
<div className={`${this.state.imageExpanded ? 'col-12' : 'col-8'}`}>
{this.postTitleLine()}
</div>
<div class="col-4">
{/* Post body prev or thumbnail */}
{!this.state.imageExpanded && this.thumbnail()}
</div>
</div>
) : (
this.postTitleLine()
);
}
showMobilePreview() {
return (
this.props.post.body &&
!this.props.showBody && (
<div
className="md-div mb-1"
dangerouslySetInnerHTML={{
__html: md.render(previewLines(this.props.post.body)),
}}
/>
)
);
}
listing() {
return (
<>
{/* The mobile view*/}
<div class="d-block d-sm-none">
<div class="row">
<div class="col-12">
{this.createdLine()}
{/* If it has a thumbnail, do a right aligned thumbnail */}
{this.mobileThumbnail()}
{/* Show a preview of the post body */}
{this.showMobilePreview()}
{this.commentsLine(true)}
{this.duplicatesLine()}
{this.postActions()}
{this.removeAndBanDialogs()}
</div>
</div>
</div>
{/* The larger view*/}
<div class="d-none d-sm-block">
<div class="row">
{this.voteBar()}
{!this.state.imageExpanded && (
<div class="col-sm-2 pr-0">
<div class="">{this.thumbnail()}</div>
</div>
)}
<div
class={`${
this.state.imageExpanded ? 'col-12' : 'col-12 col-sm-9'
}`}
>
<div class="row">
<div className="col-12">
{this.postTitleLine()}
{this.createdLine()}
{this.commentsLine()}
{this.duplicatesLine()}
{this.postActions()}
{this.removeAndBanDialogs()}
</div>
</div>
</div>
</div>
</div>
</>
);
}

View file

@ -39,6 +39,7 @@ import {
createPostLikeRes,
commentsToFlatNodes,
setupTippy,
favIconUrl,
} from '../utils';
import { PostListing } from './post-listing';
import { Sidebar } from './sidebar';
@ -189,10 +190,21 @@ export class Post extends Component<any, PostState> {
}
}
get favIcon(): string {
return this.state.post ? this.state.post.community_icon : favIconUrl;
}
render() {
return (
<div class="container">
<Helmet title={this.documentTitle} />
<Helmet title={this.documentTitle}>
<link
id="favicon"
rel="icon"
type="image/x-icon"
href={this.favIcon}
/>
</Helmet>
{this.state.loading ? (
<h5>
<svg class="icon icon-spinner spin">
@ -332,6 +344,7 @@ export class Post extends Component<any, PostState> {
admins={this.state.siteRes.admins}
online={this.state.online}
enableNsfw={this.state.siteRes.site.enable_nsfw}
showIcon
/>
</div>
);

View file

@ -128,6 +128,8 @@ export class PrivateMessageForm extends Component<
<UserListing
user={{
name: this.state.recipient.name,
preferred_username: this.state.recipient
.preferred_username,
avatar: this.state.recipient.avatar,
id: this.state.recipient.id,
local: this.state.recipient.local,

View file

@ -9,6 +9,7 @@ import { WebSocketService, UserService } from '../services';
import { mdToHtml, pictrsAvatarThumbnail, showAvatars, toast } from '../utils';
import { MomentTime } from './moment-time';
import { PrivateMessageForm } from './private-message-form';
import { UserListing, UserOther } from './user-listing';
import { i18n } from '../i18next';
interface PrivateMessageState {
@ -53,6 +54,26 @@ export class PrivateMessage extends Component<
render() {
let message = this.props.privateMessage;
let userOther: UserOther = this.mine
? {
name: message.recipient_name,
preferred_username: message.recipient_preferred_username,
id: message.id,
avatar: message.recipient_avatar,
local: message.recipient_local,
actor_id: message.recipient_actor_id,
published: message.published,
}
: {
name: message.creator_name,
preferred_username: message.creator_preferred_username,
id: message.id,
avatar: message.creator_avatar,
local: message.creator_local,
actor_id: message.creator_actor_id,
published: message.published,
};
return (
<div class="border-top border-light">
<div>
@ -62,33 +83,7 @@ export class PrivateMessage extends Component<
{this.mine ? i18n.t('to') : i18n.t('from')}
</li>
<li className="list-inline-item">
<Link
className="text-body font-weight-bold"
to={
this.mine
? `/u/${message.recipient_name}`
: `/u/${message.creator_name}`
}
>
{(this.mine
? message.recipient_avatar
: message.creator_avatar) &&
showAvatars() && (
<img
height="32"
width="32"
src={pictrsAvatarThumbnail(
this.mine
? message.recipient_avatar
: message.creator_avatar
)}
class="rounded-circle mr-1"
/>
)}
<span>
{this.mine ? message.recipient_name : message.creator_name}
</span>
</Link>
<UserListing user={userOther} />
</li>
<li className="list-inline-item">
<span>

View file

@ -315,6 +315,8 @@ export class Search extends Component<any, SearchState> {
<UserListing
user={{
name: (i.data as UserView).name,
preferred_username: (i.data as UserView)
.preferred_username,
avatar: (i.data as UserView).avatar,
}}
/>

View file

@ -9,10 +9,11 @@ import {
UserView,
} from '../interfaces';
import { WebSocketService, UserService } from '../services';
import { mdToHtml, getUnixTime } from '../utils';
import { mdToHtml, getUnixTime, pictrsAvatarThumbnail } from '../utils';
import { CommunityForm } from './community-form';
import { UserListing } from './user-listing';
import { CommunityLink } from './community-link';
import { BannerIconHeader } from './banner-icon-header';
import { i18n } from '../i18next';
interface SidebarProps {
@ -21,6 +22,7 @@ interface SidebarProps {
admins: Array<UserView>;
online: number;
enableNsfw: boolean;
showIcon?: boolean;
}
interface SidebarState {
@ -86,7 +88,11 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
communityTitle() {
let community = this.props.community;
return (
<h5 className="mb-2">
<div>
<h5 className="mb-0">
{this.props.showIcon && (
<BannerIconHeader icon={community.icon} banner={community.banner} />
)}
<span>{community.title}</span>
{community.removed && (
<small className="ml-2 text-muted font-italic">
@ -104,6 +110,14 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
</small>
)}
</h5>
<CommunityLink
community={community}
realLink
useApubName
muted
hideAvatar
/>
</div>
);
}
@ -160,6 +174,7 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
<UserListing
user={{
name: mod.user_name,
preferred_username: mod.user_preferred_username,
avatar: mod.avatar,
id: mod.user_id,
local: mod.user_local,

View file

@ -1,6 +1,7 @@
import { Component, linkEvent } from 'inferno';
import { Prompt } from 'inferno-router';
import { MarkdownTextArea } from './markdown-textarea';
import { ImageUploadForm } from './image-upload-form';
import { Site, SiteForm as SiteFormI } from '../interfaces';
import { WebSocketService } from '../services';
import { capitalizeFirstLetter, randomStr } from '../utils';
@ -24,6 +25,8 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> {
open_registration: true,
enable_nsfw: true,
name: null,
icon: null,
banner: null,
},
loading: false,
};
@ -36,6 +39,12 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> {
this
);
this.handleIconUpload = this.handleIconUpload.bind(this);
this.handleIconRemove = this.handleIconRemove.bind(this);
this.handleBannerUpload = this.handleBannerUpload.bind(this);
this.handleBannerRemove = this.handleBannerRemove.bind(this);
if (this.props.site) {
this.state.siteForm = {
name: this.props.site.name,
@ -43,6 +52,8 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> {
enable_downvotes: this.props.site.enable_downvotes,
open_registration: this.props.site.open_registration,
enable_nsfw: this.props.site.enable_nsfw,
icon: this.props.site.icon,
banner: this.props.site.banner,
};
}
}
@ -103,6 +114,25 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> {
/>
</div>
</div>
<div class="form-group">
<label>{i18n.t('icon')}</label>
<ImageUploadForm
uploadTitle={i18n.t('upload_icon')}
imageSrc={this.state.siteForm.icon}
onUpload={this.handleIconUpload}
onRemove={this.handleIconRemove}
rounded
/>
</div>
<div class="form-group">
<label>{i18n.t('banner')}</label>
<ImageUploadForm
uploadTitle={i18n.t('upload_banner')}
imageSrc={this.state.siteForm.banner}
onUpload={this.handleBannerUpload}
onRemove={this.handleBannerRemove}
/>
</div>
<div class="form-group row">
<label class="col-12 col-form-label" htmlFor={this.id}>
{i18n.t('sidebar')}
@ -247,4 +277,24 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> {
handleCancel(i: SiteForm) {
i.props.onCancel();
}
handleIconUpload(url: string) {
this.state.siteForm.icon = url;
this.setState(this.state);
}
handleIconRemove() {
this.state.siteForm.icon = '';
this.setState(this.state);
}
handleBannerUpload(url: string) {
this.state.siteForm.banner = url;
this.setState(this.state);
}
handleBannerRemove() {
this.state.siteForm.banner = '';
this.setState(this.state);
}
}

View file

@ -39,7 +39,10 @@ export class SortSelect extends Component<SortSelectProps, SortSelectState> {
>
<option disabled>{i18n.t('sort_type')}</option>
{!this.props.hideHot && (
<>
<option value={SortType.Active}>{i18n.t('active')}</option>
<option value={SortType.Hot}>{i18n.t('hot')}</option>
</>
)}
<option value={SortType.New}>{i18n.t('new')}</option>
<option disabled></option>

View file

@ -15,6 +15,9 @@ export class Symbols extends Component<any, any> {
xmlnsXlink="http://www.w3.org/1999/xlink"
>
<defs>
<symbol id="icon-x" viewBox="0 0 24 24">
<path d="M5.293 6.707l5.293 5.293-5.293 5.293c-0.391 0.391-0.391 1.024 0 1.414s1.024 0.391 1.414 0l5.293-5.293 5.293 5.293c0.391 0.391 1.024 0.391 1.414 0s0.391-1.024 0-1.414l-5.293-5.293 5.293-5.293c0.391-0.391 0.391-1.024 0-1.414s-1.024-0.391-1.414 0l-5.293 5.293-5.293-5.293c-0.391-0.391-1.024-0.391-1.414 0s-0.391 1.024 0 1.414z"></path>
</symbol>
<symbol id="icon-refresh-cw" viewBox="0 0 24 24">
<path d="M4.453 9.334c0.737-2.083 2.247-3.669 4.096-4.552s4.032-1.059 6.114-0.322c1.186 0.42 2.206 1.088 2.983 1.88l2.83 2.66h-3.476c-0.552 0-1 0.448-1 1s0.448 1 1 1h5.997c0.005 0 0.009 0 0.014 0 0.137-0.001 0.268-0.031 0.386-0.082 0.119-0.051 0.229-0.126 0.324-0.225 0.012-0.013 0.024-0.026 0.036-0.039 0.075-0.087 0.133-0.183 0.173-0.285s0.064-0.211 0.069-0.326c0.001-0.015 0.001-0.029 0.001-0.043v-6c0-0.552-0.448-1-1-1s-1 0.448-1 1v3.689l-2.926-2.749c-0.992-1.010-2.271-1.843-3.743-2.364-2.603-0.921-5.335-0.699-7.643 0.402s-4.199 3.086-5.12 5.689c-0.185 0.52 0.088 1.091 0.608 1.276s1.092-0.088 1.276-0.609zM2 16.312l2.955 2.777c1.929 1.931 4.49 2.908 7.048 2.909s5.119-0.975 7.072-2.927c1.104-1.104 1.901-2.407 2.361-3.745 0.18-0.522-0.098-1.091-0.621-1.271s-1.091 0.098-1.271 0.621c-0.361 1.050-0.993 2.091-1.883 2.981-1.563 1.562-3.609 2.342-5.657 2.342s-4.094-0.782-5.679-2.366l-2.8-2.633h3.475c0.552 0 1-0.448 1-1s-0.448-1-1-1h-5.997c-0.005 0-0.009 0-0.014 0-0.137 0.001-0.268 0.031-0.386 0.082-0.119 0.051-0.229 0.126-0.324 0.225-0.012 0.013-0.024 0.026-0.036 0.039-0.075 0.087-0.133 0.183-0.173 0.285s-0.064 0.211-0.069 0.326c-0.001 0.015-0.001 0.029-0.001 0.043v6c0 0.552 0.448 1 1 1s1-0.448 1-1z"></path>
</symbol>

View file

@ -9,8 +9,9 @@ import {
} from '../utils';
import { CakeDay } from './cake-day';
interface UserOther {
export interface UserOther {
name: string;
preferred_username?: string;
id?: number; // Necessary if its federated
avatar?: string;
local?: boolean;
@ -21,6 +22,9 @@ interface UserOther {
interface UserListingProps {
user: UserView | UserOther;
realLink?: boolean;
useApubName?: boolean;
muted?: boolean;
hideAvatar?: boolean;
}
export class UserListing extends Component<UserListingProps, any> {
@ -31,30 +35,40 @@ export class UserListing extends Component<UserListingProps, any> {
render() {
let user = this.props.user;
let local = user.local == null ? true : user.local;
let name_: string, link: string;
let apubName: string, link: string;
if (local) {
name_ = user.name;
apubName = `@${user.name}`;
link = `/u/${user.name}`;
} else {
name_ = `${user.name}@${hostname(user.actor_id)}`;
apubName = `@${user.name}@${hostname(user.actor_id)}`;
link = !this.props.realLink ? `/user/${user.id}` : user.actor_id;
}
let displayName = this.props.useApubName
? apubName
: user.preferred_username
? user.preferred_username
: apubName;
return (
<>
<Link className="text-info" to={link}>
{user.avatar && showAvatars() && (
<Link
title={apubName}
className={this.props.muted ? 'text-muted' : 'text-info'}
to={link}
>
{!this.props.hideAvatar && user.avatar && showAvatars() && (
<img
style="width: 2rem; height: 2rem;"
src={pictrsAvatarThumbnail(user.avatar)}
class="rounded-circle mr-2"
/>
)}
<span>{name_}</span>
<span>{displayName}</span>
</Link>
{isCakeDay(user.published) && <CakeDay creatorName={name_} />}
{isCakeDay(user.published) && <CakeDay creatorName={apubName} />}
</>
);
}

View file

@ -27,11 +27,12 @@ import {
themes,
setTheme,
languages,
showAvatars,
toast,
setupTippy,
getLanguage,
mdToHtml,
elementUrl,
favIconUrl,
} from '../utils';
import { UserListing } from './user-listing';
import { SortSelect } from './sort-select';
@ -41,6 +42,8 @@ import { i18n } from '../i18next';
import moment from 'moment';
import { UserDetails } from './user-details';
import { MarkdownTextArea } from './markdown-textarea';
import { ImageUploadForm } from './image-upload-form';
import { BannerIconHeader } from './banner-icon-header';
interface UserState {
user: UserView;
@ -52,7 +55,6 @@ interface UserState {
sort: SortType;
page: number;
loading: boolean;
avatarLoading: boolean;
userSettingsForm: UserSettingsForm;
userSettingsLoading: boolean;
deleteAccountLoading: boolean;
@ -98,7 +100,6 @@ export class User extends Component<any, UserState> {
follows: [],
moderates: [],
loading: true,
avatarLoading: false,
view: User.getViewFromProps(this.props.match.view),
sort: User.getSortTypeFromProps(this.props.match.sort),
page: User.getPageFromProps(this.props.match.page),
@ -112,6 +113,7 @@ export class User extends Component<any, UserState> {
send_notifications_to_email: null,
auth: null,
bio: null,
preferred_username: null,
},
userSettingsLoading: null,
deleteAccountLoading: null,
@ -136,6 +138,9 @@ export class User extends Component<any, UserState> {
enable_downvotes: undefined,
open_registration: undefined,
enable_nsfw: undefined,
icon: undefined,
banner: undefined,
creator_preferred_username: undefined,
},
version: undefined,
},
@ -157,6 +162,12 @@ export class User extends Component<any, UserState> {
this
);
this.handleAvatarUpload = this.handleAvatarUpload.bind(this);
this.handleAvatarRemove = this.handleAvatarRemove.bind(this);
this.handleBannerUpload = this.handleBannerUpload.bind(this);
this.handleBannerRemove = this.handleBannerRemove.bind(this);
this.state.user_id = Number(this.props.match.params.id) || null;
this.state.username = this.props.match.params.username;
@ -226,23 +237,27 @@ export class User extends Component<any, UserState> {
}
}
get favIcon(): string {
return this.state.user.avatar
? this.state.user.avatar
: this.state.siteRes.site.icon
? this.state.siteRes.site.icon
: favIconUrl;
}
render() {
return (
<div class="container">
<Helmet title={this.documentTitle} />
<Helmet title={this.documentTitle}>
<link
id="favicon"
rel="icon"
type="image/x-icon"
href={this.favIcon}
/>
</Helmet>
<div class="row">
<div class="col-12 col-md-8">
<h5>
{this.state.user.avatar && showAvatars() && (
<img
height="80"
width="80"
src={this.state.user.avatar}
class="rounded-circle mr-2"
/>
)}
<span>@{this.state.username}</span>
</h5>
{this.state.loading ? (
<h5>
<svg class="icon icon-spinner spin">
@ -250,8 +265,12 @@ export class User extends Component<any, UserState> {
</svg>
</h5>
) : (
this.selects()
<>
{this.userInfo()}
<hr />
</>
)}
{!this.state.loading && this.selects()}
<UserDetails
user_id={this.state.user_id}
username={this.state.username}
@ -268,7 +287,6 @@ export class User extends Component<any, UserState> {
{!this.state.loading && (
<div class="col-12 col-md-4">
{this.userInfo()}
{this.isCurrentUser && this.userSettings()}
{this.moderates()}
{this.follows()}
@ -365,14 +383,29 @@ export class User extends Component<any, UserState> {
userInfo() {
let user = this.state.user;
return (
<div>
<div class="card bg-transparent border-secondary mb-3">
<div class="card-body">
<h5>
<ul class="list-inline mb-0">
<BannerIconHeader
banner={this.state.user.banner}
icon={this.state.user.avatar}
/>
<div class="mb-3">
<div class="">
<div class="mb-0 d-flex flex-wrap">
<div>
{user.preferred_username && (
<h5 class="mb-0">{user.preferred_username}</h5>
)}
<ul class="list-inline mb-2">
<li className="list-inline-item">
<UserListing user={user} realLink />
<UserListing
user={user}
realLink
useApubName
muted
hideAvatar
/>
</li>
{user.banned && (
<li className="list-inline-item badge badge-danger">
@ -380,65 +413,11 @@ export class User extends Component<any, UserState> {
</li>
)}
</ul>
</h5>
{user.bio && (
<div className="d-flex align-items-center mb-2">
<div
className="md-div"
dangerouslySetInnerHTML={mdToHtml(user.bio)}
/>
</div>
)}
<div className="d-flex align-items-center mb-2">
<svg class="icon">
<use xlinkHref="#icon-cake"></use>
</svg>
<span className="ml-2">
{i18n.t('cake_day_title')}{' '}
{moment.utc(user.published).local().format('MMM DD, YYYY')}
</span>
</div>
<div>
{i18n.t('joined')} <MomentTime data={user} showAgo />
</div>
<div class="table-responsive mt-1">
<table class="table table-bordered table-sm mt-2 mb-0">
{/*
<tr>
<td class="text-center" colSpan={2}>
{i18n.t('number_of_points', {
count: user.post_score + user.comment_score,
})}
</td>
</tr>
*/}
<tr>
{/*
<td>
{i18n.t('number_of_points', { count: user.post_score })}
</td>
*/}
<td>
{i18n.t('number_of_posts', { count: user.number_of_posts })}
</td>
{/*
</tr>
<tr>
<td>
{i18n.t('number_of_points', { count: user.comment_score })}
</td>
*/}
<td>
{i18n.t('number_of_comments', {
count: user.number_of_comments,
})}
</td>
</tr>
</table>
</div>
<div className="flex-grow-1 unselectable pointer mx-2"></div>
{this.isCurrentUser ? (
<button
class="btn btn-block btn-secondary mt-3"
class="d-flex align-self-start btn btn-secondary ml-2"
onClick={linkEvent(this, this.handleLogoutClick)}
>
{i18n.t('logout')}
@ -446,8 +425,8 @@ export class User extends Component<any, UserState> {
) : (
<>
<a
className={`btn btn-block btn-secondary mt-3 ${
!this.state.user.matrix_user_id && 'disabled'
className={`d-flex align-self-start btn btn-secondary ml-2 ${
!this.state.user.matrix_user_id && 'invisible'
}`}
target="_blank"
rel="noopener"
@ -456,7 +435,7 @@ export class User extends Component<any, UserState> {
{i18n.t('send_secure_message')}
</a>
<Link
class="btn btn-block btn-secondary mt-3"
class="d-flex align-self-start btn btn-secondary ml-2"
to={`/create_private_message?recipient_id=${this.state.user.id}`}
>
{i18n.t('send_message')}
@ -464,6 +443,39 @@ export class User extends Component<any, UserState> {
</>
)}
</div>
{user.bio && (
<div className="d-flex align-items-center mb-2">
<div
className="md-div"
dangerouslySetInnerHTML={mdToHtml(user.bio)}
/>
</div>
)}
<div>
<ul class="list-inline mb-2">
<li className="list-inline-item badge badge-light">
{i18n.t('number_of_posts', { count: user.number_of_posts })}
</li>
<li className="list-inline-item badge badge-light">
{i18n.t('number_of_comments', {
count: user.number_of_comments,
})}
</li>
</ul>
</div>
<div class="text-muted">
{i18n.t('joined')} <MomentTime data={user} showAgo />
</div>
<div className="d-flex align-items-center text-muted mb-2">
<svg class="icon">
<use xlinkHref="#icon-cake"></use>
</svg>
<span className="ml-2">
{i18n.t('cake_day_title')}{' '}
{moment.utc(user.published).local().format('MMM DD, YYYY')}
</span>
</div>
</div>
</div>
</div>
);
@ -478,47 +490,23 @@ export class User extends Component<any, UserState> {
<form onSubmit={linkEvent(this, this.handleUserSettingsSubmit)}>
<div class="form-group">
<label>{i18n.t('avatar')}</label>
<form class="d-inline">
<label
htmlFor="file-upload"
class="pointer ml-4 text-muted small font-weight-bold"
>
{!this.checkSettingsAvatar ? (
<span class="btn btn-secondary">
{i18n.t('upload_avatar')}
</span>
) : (
<img
height="80"
width="80"
src={this.state.userSettingsForm.avatar}
class="rounded-circle"
<ImageUploadForm
uploadTitle={i18n.t('upload_avatar')}
imageSrc={this.state.userSettingsForm.avatar}
onUpload={this.handleAvatarUpload}
onRemove={this.handleAvatarRemove}
rounded
/>
)}
</label>
<input
id="file-upload"
type="file"
accept="image/*,video/*"
name="file"
class="d-none"
disabled={!UserService.Instance.user}
onChange={linkEvent(this, this.handleImageUpload)}
/>
</form>
</div>
{this.checkSettingsAvatar && (
<div class="form-group">
<button
class="btn btn-secondary btn-block"
onClick={linkEvent(this, this.removeAvatar)}
>
{`${capitalizeFirstLetter(i18n.t('remove'))} ${i18n.t(
'avatar'
)}`}
</button>
<label>{i18n.t('banner')}</label>
<ImageUploadForm
uploadTitle={i18n.t('upload_banner')}
imageSrc={this.state.userSettingsForm.banner}
onUpload={this.handleBannerUpload}
onRemove={this.handleBannerRemove}
/>
</div>
)}
<div class="form-group">
<label>{i18n.t('language')}</label>
<select
@ -565,6 +553,38 @@ export class User extends Component<any, UserState> {
onChange={this.handleUserSettingsSortTypeChange}
/>
</form>
<div class="form-group row">
<label class="col-lg-5 col-form-label">
{i18n.t('display_name')}
</label>
<div class="col-lg-7">
<input
type="text"
class="form-control"
placeholder={i18n.t('optional')}
value={this.state.userSettingsForm.preferred_username}
onInput={linkEvent(
this,
this.handleUserSettingsPreferredUsernameChange
)}
minLength={3}
maxLength={20}
/>
</div>
</div>
<div class="form-group row">
<label class="col-lg-3 col-form-label" htmlFor="user-bio">
{i18n.t('bio')}
</label>
<div class="col-lg-9">
<MarkdownTextArea
initialContent={this.state.userSettingsForm.bio}
onContentChange={this.handleUserSettingsBioChange}
maxLength={300}
hideNavigationWarnings
/>
</div>
</div>
<div class="form-group row">
<label class="col-lg-3 col-form-label" htmlFor="user-email">
{i18n.t('email')}
@ -584,26 +604,9 @@ export class User extends Component<any, UserState> {
/>
</div>
</div>
<div class="form-group row">
<label class="col-lg-3 col-form-label" htmlFor="user-bio">
{i18n.t('bio')}
</label>
<div class="col-lg-9">
<MarkdownTextArea
initialContent={this.state.userSettingsForm.bio}
onContentChange={this.handleUserSettingsBioChange}
maxLength={300}
hideNavigationWarnings
/>
</div>
</div>
<div class="form-group row">
<label class="col-lg-5 col-form-label">
<a
href="https://about.riot.im/"
target="_blank"
rel="noopener"
>
<a href={elementUrl} target="_blank" rel="noopener">
{i18n.t('matrix_user_id')}
</a>
</label>
@ -932,6 +935,31 @@ export class User extends Component<any, UserState> {
this.setState(this.state);
}
handleAvatarUpload(url: string) {
this.state.userSettingsForm.avatar = url;
this.setState(this.state);
}
handleAvatarRemove() {
this.state.userSettingsForm.avatar = '';
this.setState(this.state);
}
handleBannerUpload(url: string) {
this.state.userSettingsForm.banner = url;
this.setState(this.state);
}
handleBannerRemove() {
this.state.userSettingsForm.banner = '';
this.setState(this.state);
}
handleUserSettingsPreferredUsernameChange(i: User, event: any) {
i.state.userSettingsForm.preferred_username = event.target.value;
i.setState(i.state);
}
handleUserSettingsMatrixUserIdChange(i: User, event: any) {
i.state.userSettingsForm.matrix_user_id = event.target.value;
if (
@ -967,59 +995,6 @@ export class User extends Component<any, UserState> {
i.setState(i.state);
}
handleImageUpload(i: User, event: any) {
event.preventDefault();
let file = event.target.files[0];
const imageUploadUrl = `/pictrs/image`;
const formData = new FormData();
formData.append('images[]', file);
i.state.avatarLoading = true;
i.setState(i.state);
fetch(imageUploadUrl, {
method: 'POST',
body: formData,
})
.then(res => res.json())
.then(res => {
console.log('pictrs upload:');
console.log(res);
if (res.msg == 'ok') {
let hash = res.files[0].file;
let url = `${window.location.origin}/pictrs/image/${hash}`;
i.state.userSettingsForm.avatar = url;
i.state.avatarLoading = false;
i.setState(i.state);
} else {
i.state.avatarLoading = false;
i.setState(i.state);
toast(JSON.stringify(res), 'danger');
}
})
.catch(error => {
i.state.avatarLoading = false;
i.setState(i.state);
toast(error, 'danger');
});
}
removeAvatar(i: User, event: any) {
event.preventDefault();
i.state.userSettingsLoading = true;
i.state.userSettingsForm.avatar = '';
i.setState(i.state);
WebSocketService.Instance.saveUserSettings(i.state.userSettingsForm);
}
get checkSettingsAvatar(): boolean {
return (
this.state.userSettingsForm.avatar &&
this.state.userSettingsForm.avatar != ''
);
}
handleUserSettingsSubmit(i: User, event: any) {
event.preventDefault();
i.state.userSettingsLoading = true;
@ -1062,7 +1037,6 @@ export class User extends Component<any, UserState> {
}
this.setState({
deleteAccountLoading: false,
avatarLoading: false,
userSettingsLoading: false,
});
return;
@ -1088,6 +1062,9 @@ export class User extends Component<any, UserState> {
UserService.Instance.user.default_listing_type;
this.state.userSettingsForm.lang = UserService.Instance.user.lang;
this.state.userSettingsForm.avatar = UserService.Instance.user.avatar;
this.state.userSettingsForm.banner = UserService.Instance.user.banner;
this.state.userSettingsForm.preferred_username =
UserService.Instance.user.preferred_username;
this.state.userSettingsForm.email = this.state.user.email;
this.state.userSettingsForm.bio = this.state.user.bio;
this.state.userSettingsForm.send_notifications_to_email = this.state.user.send_notifications_to_email;
@ -1102,6 +1079,9 @@ export class User extends Component<any, UserState> {
const data = res.data as LoginResponse;
UserService.Instance.login(data);
this.state.user.bio = this.state.userSettingsForm.bio;
this.state.user.preferred_username = this.state.userSettingsForm.preferred_username;
this.state.user.banner = this.state.userSettingsForm.banner;
this.state.user.avatar = this.state.userSettingsForm.avatar;
this.state.userSettingsLoading = false;
this.setState(this.state);

26
ui/src/interfaces.ts vendored
View file

@ -83,6 +83,7 @@ export enum DataType {
}
export enum SortType {
Active,
Hot,
New,
TopDay,
@ -112,6 +113,7 @@ export interface User {
preferred_username?: string;
email?: string;
avatar?: string;
banner?: string;
admin: boolean;
banned: boolean;
published: string;
@ -134,7 +136,9 @@ export interface UserView {
id: number;
actor_id: string;
name: string;
preferred_username?: string;
avatar?: string;
banner?: string;
email?: string;
matrix_user_id?: string;
bio?: string;
@ -155,11 +159,13 @@ export interface CommunityUser {
user_actor_id: string;
user_local: boolean;
user_name: string;
user_preferred_username?: string;
avatar?: string;
community_id: number;
community_actor_id: string;
community_local: boolean;
community_name: string;
community_icon?: string;
published: string;
}
@ -169,6 +175,8 @@ export interface Community {
local: boolean;
name: string;
title: string;
icon?: string;
banner?: string;
description?: string;
category_id: number;
creator_id: number;
@ -181,6 +189,7 @@ export interface Community {
creator_local: boolean;
last_refreshed_at: string;
creator_name: string;
creator_preferred_username?: string;
creator_avatar?: string;
category_name: string;
number_of_subscribers: number;
@ -215,11 +224,13 @@ export interface Post {
creator_actor_id: string;
creator_local: boolean;
creator_name: string;
creator_preferred_username?: string;
creator_published: string;
creator_avatar?: string;
community_actor_id: string;
community_local: boolean;
community_name: string;
community_icon?: string;
community_removed: boolean;
community_deleted: boolean;
community_nsfw: boolean;
@ -228,6 +239,7 @@ export interface Post {
upvotes: number;
downvotes: number;
hot_rank: number;
hot_rank_active: number;
newest_activity_time: string;
user_id?: number;
my_vote?: number;
@ -255,17 +267,20 @@ export interface Comment {
community_actor_id: string;
community_local: boolean;
community_name: string;
community_icon?: string;
banned: boolean;
banned_from_community: boolean;
creator_actor_id: string;
creator_local: boolean;
creator_name: string;
creator_preferred_username?: string;
creator_avatar?: string;
creator_published: string;
score: number;
upvotes: number;
downvotes: number;
hot_rank: number;
hot_rank_active: number;
user_id?: number;
my_vote?: number;
subscribed?: number;
@ -290,6 +305,7 @@ export interface Site {
published: string;
updated?: string;
creator_name: string;
creator_preferred_username?: string;
number_of_users: number;
number_of_posts: number;
number_of_comments: number;
@ -297,6 +313,8 @@ export interface Site {
enable_downvotes: boolean;
open_registration: boolean;
enable_nsfw: boolean;
icon?: string;
banner?: string;
}
export interface PrivateMessage {
@ -311,10 +329,12 @@ export interface PrivateMessage {
ap_id: string;
local: boolean;
creator_name: string;
creator_preferred_username?: string;
creator_avatar?: string;
creator_actor_id: string;
creator_local: boolean;
recipient_name: string;
recipient_preferred_username?: string;
recipient_avatar?: string;
recipient_actor_id: string;
recipient_local: boolean;
@ -596,6 +616,8 @@ export interface UserSettingsForm {
default_listing_type: ListingType;
lang: string;
avatar?: string;
banner?: string;
preferred_username?: string;
email?: string;
bio?: string;
matrix_user_id?: string;
@ -612,6 +634,8 @@ export interface CommunityForm {
edit_id?: number;
title: string;
description?: string;
icon?: string;
banner?: string;
category_id: number;
nsfw: boolean;
auth?: string;
@ -814,6 +838,8 @@ export interface CreatePostLikeForm {
export interface SiteForm {
name: string;
description?: string;
icon?: string;
banner?: string;
enable_downvotes: boolean;
open_registration: boolean;
enable_nsfw: boolean;

35
ui/src/utils.ts vendored
View file

@ -58,11 +58,13 @@ import Toastify from 'toastify-js';
import tippy from 'tippy.js';
import moment from 'moment';
export const favIconUrl = '/static/assets/favicon.svg';
export const repoUrl = 'https://github.com/LemmyNet/lemmy';
export const helpGuideUrl = '/docs/about_guide.html';
export const markdownHelpUrl = `${helpGuideUrl}#markdown-guide`;
export const sortingHelpUrl = `${helpGuideUrl}#sorting`;
export const archiveUrl = 'https://archive.is';
export const elementUrl = 'https://element.io/';
export const postRefetchSeconds: number = 60 * 1000;
export const fetchLimit: number = 20;
@ -273,6 +275,8 @@ export function routeSortTypeToEnum(sort: string): SortType {
return SortType.New;
} else if (sort == 'hot') {
return SortType.Hot;
} else if (sort == 'active') {
return SortType.Active;
} else if (sort == 'topday') {
return SortType.TopDay;
} else if (sort == 'topweek') {
@ -754,7 +758,7 @@ export function getSortTypeFromProps(props: any): SortType {
? routeSortTypeToEnum(props.match.params.sort)
: UserService.Instance.user
? UserService.Instance.user.default_sort_type
: SortType.Hot;
: SortType.Active;
}
export function getPageFromProps(props: any): number {
@ -905,7 +909,7 @@ function convertCommentSortType(sort: SortType): CommentSortType {
return CommentSortType.Top;
} else if (sort == SortType.New) {
return CommentSortType.New;
} else if (sort == SortType.Hot) {
} else if (sort == SortType.Hot || sort == SortType.Active) {
return CommentSortType.Hot;
} else {
return CommentSortType.Hot;
@ -948,6 +952,14 @@ export function postSort(
(communityType && +b.stickied - +a.stickied) ||
b.hot_rank - a.hot_rank
);
} else if (sort == SortType.Active) {
posts.sort(
(a, b) =>
+a.removed - +b.removed ||
+a.deleted - +b.deleted ||
(communityType && +b.stickied - +a.stickied) ||
b.hot_rank_active - a.hot_rank_active
);
}
}
@ -970,10 +982,12 @@ function randomHsl() {
export function previewLines(text: string, lines: number = 3): string {
// Use lines * 2 because markdown requires 2 lines
return text
return (
text
.split('\n')
.slice(0, lines * 2)
.join('\n');
.join('\n') + '...'
);
}
export function hostname(url: string): string {
@ -1008,3 +1022,16 @@ export function validTitle(title?: string): boolean {
return regex.test(title);
}
export function siteBannerCss(banner: string): string {
return ` \
background-image: linear-gradient( rgba(0, 0, 0, 0.8), rgba(0, 0, 0, 0.8) ) ,url("${banner}"); \
background-attachment: fixed; \
background-position: top; \
background-repeat: no-repeat; \
background-size: 100% cover; \
width: 100%; \
max-height: 100vh; \
`;
}

View file

@ -40,6 +40,10 @@
"upload_image": "upload image",
"avatar": "Avatar",
"upload_avatar": "Upload Avatar",
"banner": "Banner",
"upload_banner": "Upload Banner",
"icon": "Icon",
"upload_icon": "Upload Icon",
"show_avatars": "Show Avatars",
"show_context": "Show context",
"formatting_help": "formatting help",
@ -125,6 +129,7 @@
"sidebar": "Sidebar",
"sort_type": "Sort type",
"hot": "Hot",
"active": "Active",
"new": "New",
"old": "Old",
"top_day": "Top day",
@ -231,7 +236,7 @@
"landing_0":
"Lemmy is a <1>link aggregator</1> / reddit alternative, intended to work in the <2>fediverse</2>.<3></3>It's self-hostable, has live-updating comment threads, and is tiny (<4>~80kB</4>). Federation into the ActivityPub network is on the roadmap. <5></5>This is a <6>very early beta version</6>, and a lot of features are currently broken or missing. <7></7>Suggest new features or report bugs <8>here.</8><9></9>Made with <10>Rust</10>, <11>Actix</11>, <12>Inferno</12>, <13>Typescript</13>. <14></14> <15>Thank you to our contributors: </15> dessalines, Nutomic, asonix, zacanger, and iav.",
"not_logged_in": "Not logged in.",
"bio_length_overflow": "User bio cannot exceed 300 characters!",
"bio_length_overflow": "User bio cannot exceed 300 characters.",
"logged_in": "Logged in.",
"must_login": "You must <1>log in or register</1> to comment.",
"site_saved": "Site Saved.",

View file

@ -84,7 +84,7 @@
"category": "Categoria",
"subscribers": "Iscritti",
"both": "Entrambi",
"saved": "Salvato",
"saved": "Salvati",
"unsubscribe": "Disiscriviti",
"subscribe": "Iscriviti",
"subscribed": "Iscritto",
@ -205,7 +205,7 @@
"old_password": "Vecchia Password",
"forgot_password": "password dimenticata",
"new_password": "Nuova Password",
"private_message_disclaimer": "Attenzione: i messaggi privati su Lemmy non sono sicuri. Crea un account su <1>Riot.im</1> per una messaggistica sicura.",
"private_message_disclaimer": "Attenzione: i messaggi privati su Lemmy non sono sicuri. Crea un account su <1>Element.io</1> per una messaggistica sicura.",
"language": "Lingua",
"enable_downvotes": "Abilita voti negativi",
"enable_nsfw": "Abilita NSFW",
@ -217,7 +217,7 @@
"downvotes_disabled": "Voti negativi disabilitati",
"post_title_too_long": "Titolo della pubblicazione troppo lungo.",
"email_already_exists": "Indirizzo email già presente.",
"cross_posted_to": "pubblicato pure su: ",
"cross_posted_to": "pubblicato anche su: ",
"support_on_open_collective": "Sostieni su OpenCollective",
"admin_settings": "Impostazioni per Admin",
"site_config": "Configurazione del sito",
@ -260,7 +260,18 @@
"what_is": "Cos'è",
"must_login": "Devi <1>effettuare l'accesso o registrarti</1> per commentare.",
"no_password_reset": "Non sarai in grado di resettare la tua password senza una email.",
"cake_day_title": "Cake day:",
"cake_day_title": "Torta-giorno:",
"cake_day_info": "Oggi è il cake day di {{ creator_name }}!",
"invalid_post_title": "Titolo della pubblicazione non valido"
"invalid_post_title": "Titolo della pubblicazione non valido",
"bold": "grassetto",
"italic": "corsivo",
"subscript": "pedice",
"superscript": "apice",
"header": "intestazione",
"strikethrough": "barrato",
"quote": "citazione",
"spoiler": "spoiler",
"list": "lista",
"invalid_url": "URL non valido.",
"not_a_moderator": "Non moderatore."
}