Merge branch 'admin_settings' into dev

This commit is contained in:
Dessalines 2020-04-14 15:45:49 -04:00
commit fec2e95833
29 changed files with 660 additions and 198 deletions

2
.gitignore vendored
View file

@ -7,4 +7,4 @@ build/
.idea/ .idea/
ui/src/translations ui/src/translations
docker/dev/volumes docker/dev/volumes
docker/federation/volumes docker/federation-test/volumes

View file

@ -21,7 +21,7 @@ services:
environment: environment:
- RUST_LOG=debug - RUST_LOG=debug
volumes: volumes:
- ../lemmy.hjson:/config/config.hjson:ro - ../lemmy.hjson:/config/config.hjson
depends_on: depends_on:
- postgres - postgres
- pictshare - pictshare

View file

@ -19,7 +19,7 @@ services:
environment: environment:
- RUST_LOG=error - RUST_LOG=error
volumes: volumes:
- ./lemmy.hjson:/config/config.hjson:ro - ./lemmy.hjson:/config/config.hjson
depends_on: depends_on:
- postgres - postgres
- pictshare - pictshare

View file

@ -92,85 +92,93 @@
- [Request](#request-17) - [Request](#request-17)
- [Response](#response-17) - [Response](#response-17)
- [HTTP](#http-18) - [HTTP](#http-18)
* [Community](#community) + [Get Site Config](#get-site-config)
+ [Get Community](#get-community)
- [Request](#request-18) - [Request](#request-18)
- [Response](#response-18) - [Response](#response-18)
- [HTTP](#http-19) - [HTTP](#http-19)
+ [Create Community](#create-community) + [Save Site Config](#save-site-config)
- [Request](#request-19) - [Request](#request-19)
- [Response](#response-19) - [Response](#response-19)
- [HTTP](#http-20) - [HTTP](#http-20)
+ [List Communities](#list-communities) * [Community](#community)
+ [Get Community](#get-community)
- [Request](#request-20) - [Request](#request-20)
- [Response](#response-20) - [Response](#response-20)
- [HTTP](#http-21) - [HTTP](#http-21)
+ [Ban from Community](#ban-from-community) + [Create Community](#create-community)
- [Request](#request-21) - [Request](#request-21)
- [Response](#response-21) - [Response](#response-21)
- [HTTP](#http-22) - [HTTP](#http-22)
+ [Add Mod to Community](#add-mod-to-community) + [List Communities](#list-communities)
- [Request](#request-22) - [Request](#request-22)
- [Response](#response-22) - [Response](#response-22)
- [HTTP](#http-23) - [HTTP](#http-23)
+ [Edit Community](#edit-community) + [Ban from Community](#ban-from-community)
- [Request](#request-23) - [Request](#request-23)
- [Response](#response-23) - [Response](#response-23)
- [HTTP](#http-24) - [HTTP](#http-24)
+ [Follow Community](#follow-community) + [Add Mod to Community](#add-mod-to-community)
- [Request](#request-24) - [Request](#request-24)
- [Response](#response-24) - [Response](#response-24)
- [HTTP](#http-25) - [HTTP](#http-25)
+ [Get Followed Communities](#get-followed-communities) + [Edit Community](#edit-community)
- [Request](#request-25) - [Request](#request-25)
- [Response](#response-25) - [Response](#response-25)
- [HTTP](#http-26) - [HTTP](#http-26)
+ [Transfer Community](#transfer-community) + [Follow Community](#follow-community)
- [Request](#request-26) - [Request](#request-26)
- [Response](#response-26) - [Response](#response-26)
- [HTTP](#http-27) - [HTTP](#http-27)
* [Post](#post) + [Get Followed Communities](#get-followed-communities)
+ [Create Post](#create-post)
- [Request](#request-27) - [Request](#request-27)
- [Response](#response-27) - [Response](#response-27)
- [HTTP](#http-28) - [HTTP](#http-28)
+ [Get Post](#get-post) + [Transfer Community](#transfer-community)
- [Request](#request-28) - [Request](#request-28)
- [Response](#response-28) - [Response](#response-28)
- [HTTP](#http-29) - [HTTP](#http-29)
+ [Get Posts](#get-posts) * [Post](#post)
+ [Create Post](#create-post)
- [Request](#request-29) - [Request](#request-29)
- [Response](#response-29) - [Response](#response-29)
- [HTTP](#http-30) - [HTTP](#http-30)
+ [Create Post Like](#create-post-like) + [Get Post](#get-post)
- [Request](#request-30) - [Request](#request-30)
- [Response](#response-30) - [Response](#response-30)
- [HTTP](#http-31) - [HTTP](#http-31)
+ [Edit Post](#edit-post) + [Get Posts](#get-posts)
- [Request](#request-31) - [Request](#request-31)
- [Response](#response-31) - [Response](#response-31)
- [HTTP](#http-32) - [HTTP](#http-32)
+ [Save Post](#save-post) + [Create Post Like](#create-post-like)
- [Request](#request-32) - [Request](#request-32)
- [Response](#response-32) - [Response](#response-32)
- [HTTP](#http-33) - [HTTP](#http-33)
* [Comment](#comment) + [Edit Post](#edit-post)
+ [Create Comment](#create-comment)
- [Request](#request-33) - [Request](#request-33)
- [Response](#response-33) - [Response](#response-33)
- [HTTP](#http-34) - [HTTP](#http-34)
+ [Edit Comment](#edit-comment) + [Save Post](#save-post)
- [Request](#request-34) - [Request](#request-34)
- [Response](#response-34) - [Response](#response-34)
- [HTTP](#http-35) - [HTTP](#http-35)
+ [Save Comment](#save-comment) * [Comment](#comment)
+ [Create Comment](#create-comment)
- [Request](#request-35) - [Request](#request-35)
- [Response](#response-35) - [Response](#response-35)
- [HTTP](#http-36) - [HTTP](#http-36)
+ [Create Comment Like](#create-comment-like) + [Edit Comment](#edit-comment)
- [Request](#request-36) - [Request](#request-36)
- [Response](#response-36) - [Response](#response-36)
- [HTTP](#http-37) - [HTTP](#http-37)
+ [Save Comment](#save-comment)
- [Request](#request-37)
- [Response](#response-37)
- [HTTP](#http-38)
+ [Create Comment Like](#create-comment-like)
- [Request](#request-38)
- [Response](#response-38)
- [HTTP](#http-39)
* [RSS / Atom feeds](#rss--atom-feeds) * [RSS / Atom feeds](#rss--atom-feeds)
+ [All](#all) + [All](#all)
+ [Community](#community-1) + [Community](#community-1)
@ -779,6 +787,53 @@ Search types are `All, Comments, Posts, Communities, Users, Url`
`POST /site/transfer` `POST /site/transfer`
#### Get Site Config
##### Request
```rust
{
op: "GetSiteConfig",
data: {
auth: String
}
}
```
##### Response
```rust
{
op: "GetSiteConfig",
data: {
config_hjson: String,
}
}
```
##### HTTP
`GET /site/config`
#### Save Site Config
##### Request
```rust
{
op: "SaveSiteConfig",
data: {
config_hjson: String,
auth: String
}
}
```
##### Response
```rust
{
op: "SaveSiteConfig",
data: {
config_hjson: String,
}
}
```
##### HTTP
`PUT /site/config`
### Community ### Community
#### Get Community #### Get Community
##### Request ##### Request

View file

@ -97,6 +97,22 @@ pub struct TransferSite {
auth: String, auth: String,
} }
#[derive(Serialize, Deserialize)]
pub struct GetSiteConfig {
auth: String,
}
#[derive(Serialize, Deserialize)]
pub struct GetSiteConfigResponse {
config_hjson: String,
}
#[derive(Serialize, Deserialize)]
pub struct SaveSiteConfig {
config_hjson: String,
auth: String,
}
impl Perform<ListCategoriesResponse> for Oper<ListCategories> { impl Perform<ListCategoriesResponse> for Oper<ListCategories> {
fn perform(&self, conn: &PgConnection) -> Result<ListCategoriesResponse, Error> { fn perform(&self, conn: &PgConnection) -> Result<ListCategoriesResponse, Error> {
let _data: &ListCategories = &self.data; let _data: &ListCategories = &self.data;
@ -510,3 +526,57 @@ impl Perform<GetSiteResponse> for Oper<TransferSite> {
}) })
} }
} }
impl Perform<GetSiteConfigResponse> for Oper<GetSiteConfig> {
fn perform(&self, conn: &PgConnection) -> Result<GetSiteConfigResponse, Error> {
let data: &GetSiteConfig = &self.data;
let claims = match Claims::decode(&data.auth) {
Ok(claims) => claims.claims,
Err(_e) => return Err(APIError::err("not_logged_in").into()),
};
let user_id = claims.id;
// Only let admins read this
let admins = UserView::admins(&conn)?;
let admin_ids: Vec<i32> = admins.into_iter().map(|m| m.id).collect();
if !admin_ids.contains(&user_id) {
return Err(APIError::err("not_an_admin").into());
}
let config_hjson = Settings::read_config_file()?;
Ok(GetSiteConfigResponse { config_hjson })
}
}
impl Perform<GetSiteConfigResponse> for Oper<SaveSiteConfig> {
fn perform(&self, conn: &PgConnection) -> Result<GetSiteConfigResponse, Error> {
let data: &SaveSiteConfig = &self.data;
let claims = match Claims::decode(&data.auth) {
Ok(claims) => claims.claims,
Err(_e) => return Err(APIError::err("not_logged_in").into()),
};
let user_id = claims.id;
// Only let admins read this
let admins = UserView::admins(&conn)?;
let admin_ids: Vec<i32> = admins.into_iter().map(|m| m.id).collect();
if !admin_ids.contains(&user_id) {
return Err(APIError::err("not_an_admin").into());
}
// Make sure docker doesn't have :ro at the end of the volume, so its not a read-only filesystem
let config_hjson = match Settings::save_config_file(&data.config_hjson) {
Ok(config_hjson) => config_hjson,
Err(_e) => return Err(APIError::err("couldnt_update_site").into()),
};
Ok(GetSiteConfigResponse { config_hjson })
}
}

View file

@ -253,7 +253,7 @@ impl Perform<LoginResponse> for Oper<Register> {
// Register the new user // Register the new user
let user_form = UserForm { let user_form = UserForm {
name: data.username.to_owned(), name: data.username.to_owned(),
fedi_name: Settings::get().hostname.to_owned(), fedi_name: Settings::get().hostname,
email: data.email.to_owned(), email: data.email.to_owned(),
matrix_user_id: None, matrix_user_id: None,
avatar: None, avatar: None,

View file

@ -112,7 +112,7 @@ pub fn send_email(
to_username: &str, to_username: &str,
html: &str, html: &str,
) -> Result<(), String> { ) -> Result<(), String> {
let email_config = Settings::get().email.as_ref().ok_or("no_email_setup")?; let email_config = Settings::get().email.ok_or("no_email_setup")?;
let email = Email::builder() let email = Email::builder()
.to((to_email, to_username)) .to((to_email, to_username))
@ -127,7 +127,7 @@ pub fn send_email(
} else { } else {
SmtpClient::new(&email_config.smtp_server, ClientSecurity::None).unwrap() SmtpClient::new(&email_config.smtp_server, ClientSecurity::None).unwrap()
} }
.hello_name(ClientId::Domain(Settings::get().hostname.to_owned())) .hello_name(ClientId::Domain(Settings::get().hostname))
.smtp_utf8(true) .smtp_utf8(true)
.authentication_mechanism(Mechanism::Plain) .authentication_mechanism(Mechanism::Plain)
.connection_reuse(ConnectionReuseParameters::ReuseUnlimited); .connection_reuse(ConnectionReuseParameters::ReuseUnlimited);

View file

@ -39,6 +39,7 @@ async fn main() -> io::Result<()> {
// Create Http server with websocket support // Create Http server with websocket support
HttpServer::new(move || { HttpServer::new(move || {
let settings = Settings::get();
App::new() App::new()
.wrap(middleware::Logger::default()) .wrap(middleware::Logger::default())
.data(pool.clone()) .data(pool.clone())
@ -58,7 +59,7 @@ async fn main() -> io::Result<()> {
)) ))
.service(actix_files::Files::new( .service(actix_files::Files::new(
"/docs", "/docs",
settings.front_end_dir.to_owned() + "/documentation", settings.front_end_dir + "/documentation",
)) ))
}) })
.bind((settings.bind, settings.port))? .bind((settings.bind, settings.port))?

View file

@ -52,6 +52,8 @@ pub fn config(cfg: &mut web::ServiceConfig) {
.route("/api/v1/site", web::post().to(route_post::<CreateSite, SiteResponse>)) .route("/api/v1/site", web::post().to(route_post::<CreateSite, SiteResponse>))
.route("/api/v1/site", web::put().to(route_post::<EditSite, SiteResponse>)) .route("/api/v1/site", web::put().to(route_post::<EditSite, SiteResponse>))
.route("/api/v1/site/transfer", web::post().to(route_post::<TransferSite, GetSiteResponse>)) .route("/api/v1/site/transfer", web::post().to(route_post::<TransferSite, GetSiteResponse>))
.route("/api/v1/site/config", web::get().to(route_get::<GetSiteConfig, GetSiteConfigResponse>))
.route("/api/v1/site/config", web::put().to(route_post::<SaveSiteConfig, GetSiteConfigResponse>))
.route("/api/v1/admin/add", web::post().to(route_post::<AddAdmin, AddAdminResponse>)) .route("/api/v1/admin/add", web::post().to(route_post::<AddAdmin, AddAdminResponse>))
.route("/api/v1/user/ban", web::post().to(route_post::<BanUser, BanUserResponse>)) .route("/api/v1/user/ban", web::post().to(route_post::<BanUser, BanUserResponse>))
// User account actions // User account actions

View file

@ -33,6 +33,7 @@ pub fn config(cfg: &mut web::ServiceConfig) {
.route("/modlog/community/{community_id}", web::get().to(index)) .route("/modlog/community/{community_id}", web::get().to(index))
.route("/modlog", web::get().to(index)) .route("/modlog", web::get().to(index))
.route("/setup", web::get().to(index)) .route("/setup", web::get().to(index))
.route("/admin", web::get().to(index))
.route( .route(
"/search/q/{q}/type/{type}/sort/{sort}/page/{page}", "/search/q/{q}/type/{type}/sort/{sort}/page/{page}",
web::get().to(index), web::get().to(index),
@ -44,6 +45,6 @@ pub fn config(cfg: &mut web::ServiceConfig) {
async fn index() -> Result<NamedFile, actix_web::error::Error> { async fn index() -> Result<NamedFile, actix_web::error::Error> {
Ok(NamedFile::open( Ok(NamedFile::open(
Settings::get().front_end_dir.to_owned() + "/index.html", Settings::get().front_end_dir + "/index.html",
)?) )?)
} }

View file

@ -1,12 +1,15 @@
use config::{Config, ConfigError, Environment, File}; use config::{Config, ConfigError, Environment, File};
use failure::Error;
use serde::Deserialize; use serde::Deserialize;
use std::env; use std::env;
use std::fs;
use std::net::IpAddr; use std::net::IpAddr;
use std::sync::RwLock;
static CONFIG_FILE_DEFAULTS: &str = "config/defaults.hjson"; static CONFIG_FILE_DEFAULTS: &str = "config/defaults.hjson";
static CONFIG_FILE: &str = "config/config.hjson"; static CONFIG_FILE: &str = "config/config.hjson";
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, Clone)]
pub struct Settings { pub struct Settings {
pub setup: Option<Setup>, pub setup: Option<Setup>,
pub database: Database, pub database: Database,
@ -20,7 +23,7 @@ pub struct Settings {
pub federation_enabled: bool, pub federation_enabled: bool,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, Clone)]
pub struct Setup { pub struct Setup {
pub admin_username: String, pub admin_username: String,
pub admin_password: String, pub admin_password: String,
@ -28,7 +31,7 @@ pub struct Setup {
pub site_name: String, pub site_name: String,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, Clone)]
pub struct RateLimitConfig { pub struct RateLimitConfig {
pub message: i32, pub message: i32,
pub message_per_second: i32, pub message_per_second: i32,
@ -38,7 +41,7 @@ pub struct RateLimitConfig {
pub register_per_second: i32, pub register_per_second: i32,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, Clone)]
pub struct EmailConfig { pub struct EmailConfig {
pub smtp_server: String, pub smtp_server: String,
pub smtp_login: Option<String>, pub smtp_login: Option<String>,
@ -47,7 +50,7 @@ pub struct EmailConfig {
pub use_tls: bool, pub use_tls: bool,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, Clone)]
pub struct Database { pub struct Database {
pub user: String, pub user: String,
pub password: String, pub password: String,
@ -58,12 +61,10 @@ pub struct Database {
} }
lazy_static! { lazy_static! {
static ref SETTINGS: Settings = { static ref SETTINGS: RwLock<Settings> = RwLock::new(match Settings::init() {
match Settings::init() { Ok(c) => c,
Ok(c) => c, Err(e) => panic!("{}", e),
Err(e) => panic!("{}", e), });
}
};
} }
impl Settings { impl Settings {
@ -89,8 +90,8 @@ impl Settings {
} }
/// Returns the config as a struct. /// Returns the config as a struct.
pub fn get() -> &'static Self { pub fn get() -> Self {
&SETTINGS SETTINGS.read().unwrap().to_owned()
} }
/// Returns the postgres connection url. If LEMMY_DATABASE_URL is set, that is used, /// Returns the postgres connection url. If LEMMY_DATABASE_URL is set, that is used,
@ -112,4 +113,22 @@ impl Settings {
pub fn api_endpoint(&self) -> String { pub fn api_endpoint(&self) -> String {
format!("{}/api/v1", self.hostname) format!("{}/api/v1", self.hostname)
} }
pub fn read_config_file() -> Result<String, Error> {
Ok(fs::read_to_string(CONFIG_FILE)?)
}
pub fn save_config_file(data: &str) -> Result<String, Error> {
fs::write(CONFIG_FILE, data)?;
// Reload the new settings
// From https://stackoverflow.com/questions/29654927/how-do-i-assign-a-string-to-a-mutable-static-variable/47181804#47181804
let mut new_settings = SETTINGS.write().unwrap();
*new_settings = match Settings::init() {
Ok(c) => c,
Err(e) => panic!("{}", e),
};
Self::read_config_file()
}
} }

View file

@ -46,4 +46,6 @@ pub enum UserOperation {
GetPrivateMessages, GetPrivateMessages,
UserJoin, UserJoin,
GetComments, GetComments,
GetSiteConfig,
SaveSiteConfig,
} }

View file

@ -708,6 +708,16 @@ fn parse_json_message(chat: &mut ChatServer, msg: StandardMessage) -> Result<Str
res.online = chat.sessions.len(); res.online = chat.sessions.len();
to_json_string(&user_operation, &res) to_json_string(&user_operation, &res)
} }
UserOperation::GetSiteConfig => {
let get_site_config: GetSiteConfig = serde_json::from_str(data)?;
let res = Oper::new(get_site_config).perform(&conn)?;
to_json_string(&user_operation, &res)
}
UserOperation::SaveSiteConfig => {
let save_site_config: SaveSiteConfig = serde_json::from_str(data)?;
let res = Oper::new(save_site_config).perform(&conn)?;
to_json_string(&user_operation, &res)
}
UserOperation::Search => { UserOperation::Search => {
do_user_operation::<Search, SearchResponse>(user_operation, data, &conn) do_user_operation::<Search, SearchResponse>(user_operation, data, &conn)
} }

241
ui/src/components/admin-settings.tsx vendored Normal file
View file

@ -0,0 +1,241 @@
import { Component, linkEvent } from 'inferno';
import { Subscription } from 'rxjs';
import { retryWhen, delay, take } from 'rxjs/operators';
import {
UserOperation,
SiteResponse,
GetSiteResponse,
SiteConfigForm,
GetSiteConfigResponse,
WebSocketJsonResponse,
} from '../interfaces';
import { WebSocketService } from '../services';
import { wsJsonToRes, capitalizeFirstLetter, toast, randomStr } from '../utils';
import autosize from 'autosize';
import { SiteForm } from './site-form';
import { UserListing } from './user-listing';
import { i18n } from '../i18next';
interface AdminSettingsState {
siteRes: GetSiteResponse;
siteConfigRes: GetSiteConfigResponse;
siteConfigForm: SiteConfigForm;
loading: boolean;
siteConfigLoading: boolean;
}
export class AdminSettings extends Component<any, AdminSettingsState> {
private siteConfigTextAreaId = `site-config-${randomStr()}`;
private subscription: Subscription;
private emptyState: AdminSettingsState = {
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,
},
admins: [],
banned: [],
online: null,
},
siteConfigForm: {
config_hjson: null,
auth: null,
},
siteConfigRes: {
config_hjson: null,
},
loading: true,
siteConfigLoading: null,
};
constructor(props: any, context: any) {
super(props, context);
this.state = this.emptyState;
this.subscription = WebSocketService.Instance.subject
.pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
.subscribe(
msg => this.parseMessage(msg),
err => console.error(err),
() => console.log('complete')
);
WebSocketService.Instance.getSite();
WebSocketService.Instance.getSiteConfig();
}
componentWillUnmount() {
this.subscription.unsubscribe();
}
render() {
return (
<div class="container">
{this.state.loading ? (
<h5>
<svg class="icon icon-spinner spin">
<use xlinkHref="#icon-spinner"></use>
</svg>
</h5>
) : (
<div class="row">
<div class="col-12 col-md-6">
<SiteForm site={this.state.siteRes.site} />
{this.admins()}
{this.bannedUsers()}
</div>
<div class="col-12 col-md-6">{this.adminSettings()}</div>
</div>
)}
</div>
);
}
admins() {
return (
<>
<h5>{capitalizeFirstLetter(i18n.t('admins'))}</h5>
<ul class="list-unstyled">
{this.state.siteRes.admins.map(admin => (
<li class="list-inline-item">
<UserListing
user={{
name: admin.name,
avatar: admin.avatar,
}}
/>
</li>
))}
</ul>
</>
);
}
bannedUsers() {
return (
<>
<h5>{i18n.t('banned_users')}</h5>
<ul class="list-unstyled">
{this.state.siteRes.banned.map(banned => (
<li class="list-inline-item">
<UserListing
user={{
name: banned.name,
avatar: banned.avatar,
}}
/>
</li>
))}
</ul>
</>
);
}
adminSettings() {
return (
<div>
<h5>{i18n.t('admin_settings')}</h5>
<form onSubmit={linkEvent(this, this.handleSiteConfigSubmit)}>
<div class="form-group row">
<label
class="col-12 col-form-label"
htmlFor={this.siteConfigTextAreaId}
>
{i18n.t('site_config')}
</label>
<div class="col-12">
<textarea
id={this.siteConfigTextAreaId}
value={this.state.siteConfigForm.config_hjson}
onInput={linkEvent(this, this.handleSiteConfigHjsonChange)}
class="form-control text-monospace"
rows={3}
/>
</div>
</div>
<div class="form-group row">
<div class="col-12">
<button type="submit" class="btn btn-secondary mr-2">
{this.state.siteConfigLoading ? (
<svg class="icon icon-spinner spin">
<use xlinkHref="#icon-spinner"></use>
</svg>
) : (
capitalizeFirstLetter(i18n.t('save'))
)}
</button>
</div>
</div>
</form>
</div>
);
}
handleSiteConfigSubmit(i: AdminSettings, event: any) {
event.preventDefault();
i.state.siteConfigLoading = true;
WebSocketService.Instance.saveSiteConfig(i.state.siteConfigForm);
i.setState(i.state);
}
handleSiteConfigHjsonChange(i: AdminSettings, event: any) {
i.state.siteConfigForm.config_hjson = event.target.value;
i.setState(i.state);
}
parseMessage(msg: WebSocketJsonResponse) {
console.log(msg);
let res = wsJsonToRes(msg);
if (msg.error) {
toast(i18n.t(msg.error), 'danger');
this.context.router.history.push('/');
this.state.loading = false;
this.setState(this.state);
return;
} else if (msg.reconnect) {
} else if (res.op == UserOperation.GetSite) {
let data = res.data as GetSiteResponse;
// This means it hasn't been set up yet
if (!data.site) {
this.context.router.history.push('/setup');
}
this.state.siteRes = data;
this.setState(this.state);
document.title = `${i18n.t('admin_settings')} - ${
this.state.siteRes.site.name
}`;
} else if (res.op == UserOperation.EditSite) {
let data = res.data as SiteResponse;
this.state.siteRes.site = data.site;
this.setState(this.state);
toast(i18n.t('site_saved'));
} else if (res.op == UserOperation.GetSiteConfig) {
let data = res.data as GetSiteConfigResponse;
this.state.siteConfigRes = data;
this.state.loading = false;
this.state.siteConfigForm.config_hjson = this.state.siteConfigRes.config_hjson;
this.setState(this.state);
var textarea: any = document.getElementById(this.siteConfigTextAreaId);
autosize(textarea);
} else if (res.op == UserOperation.SaveSiteConfig) {
let data = res.data as GetSiteConfigResponse;
this.state.siteConfigRes = data;
this.state.siteConfigForm.config_hjson = this.state.siteConfigRes.config_hjson;
this.state.siteConfigLoading = false;
toast(i18n.t('site_saved'));
this.setState(this.state);
}
}
}

View file

@ -24,8 +24,6 @@ import {
getUnixTime, getUnixTime,
canMod, canMod,
isMod, isMod,
pictshareAvatarThumbnail,
showAvatars,
setupTippy, setupTippy,
colorList, colorList,
} from '../utils'; } from '../utils';
@ -33,6 +31,7 @@ import moment from 'moment';
import { MomentTime } from './moment-time'; import { MomentTime } from './moment-time';
import { CommentForm } from './comment-form'; import { CommentForm } from './comment-form';
import { CommentNodes } from './comment-nodes'; import { CommentNodes } from './comment-nodes';
import { UserListing } from './user-listing';
import { i18n } from '../i18next'; import { i18n } from '../i18next';
interface CommentNodeState { interface CommentNodeState {
@ -148,20 +147,14 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
'ml-2'}`} 'ml-2'}`}
> >
<div class="d-flex flex-wrap align-items-center mb-1 mt-1 text-muted small"> <div class="d-flex flex-wrap align-items-center mb-1 mt-1 text-muted small">
<Link <span class="mr-2">
className="mr-2 text-body font-weight-bold" <UserListing
to={`/u/${node.comment.creator_name}`} user={{
> name: node.comment.creator_name,
{node.comment.creator_avatar && showAvatars() && ( avatar: node.comment.creator_avatar,
<img }}
height="32" />
width="32" </span>
src={pictshareAvatarThumbnail(node.comment.creator_avatar)}
class="rounded-circle mr-1"
/>
)}
<span>{node.comment.creator_name}</span>
</Link>
{this.isMod && ( {this.isMod && (
<div className="badge badge-light d-none d-sm-inline mr-2"> <div className="badge badge-light d-none d-sm-inline mr-2">
{i18n.t('mod')} {i18n.t('mod')}
@ -191,7 +184,7 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
</> </>
)} )}
<div <div
className="mr-lg-4 flex-grow-1 flex-lg-grow-0 unselectable pointer mr-2" className="mr-lg-4 flex-grow-1 flex-lg-grow-0 unselectable pointer mx-2"
onClick={linkEvent(this, this.handleCommentCollapse)} onClick={linkEvent(this, this.handleCommentCollapse)}
> >
{this.state.collapsed ? ( {this.state.collapsed ? (

View file

@ -33,13 +33,12 @@ import { SortSelect } from './sort-select';
import { ListingTypeSelect } from './listing-type-select'; import { ListingTypeSelect } from './listing-type-select';
import { DataTypeSelect } from './data-type-select'; import { DataTypeSelect } from './data-type-select';
import { SiteForm } from './site-form'; import { SiteForm } from './site-form';
import { UserListing } from './user-listing';
import { import {
wsJsonToRes, wsJsonToRes,
repoUrl, repoUrl,
mdToHtml, mdToHtml,
fetchLimit, fetchLimit,
pictshareAvatarThumbnail,
showAvatars,
toast, toast,
getListingTypeFromProps, getListingTypeFromProps,
getPageFromProps, getPageFromProps,
@ -316,20 +315,12 @@ export class Main extends Component<any, MainState> {
<li class="list-inline-item">{i18n.t('admins')}:</li> <li class="list-inline-item">{i18n.t('admins')}:</li>
{this.state.siteRes.admins.map(admin => ( {this.state.siteRes.admins.map(admin => (
<li class="list-inline-item"> <li class="list-inline-item">
<Link <UserListing
class="text-body font-weight-bold" user={{
to={`/u/${admin.name}`} name: admin.name,
> avatar: admin.avatar,
{admin.avatar && showAvatars() && ( }}
<img />
height="32"
width="32"
src={pictshareAvatarThumbnail(admin.avatar)}
class="rounded-circle mr-1"
/>
)}
<span>{admin.name}</span>
</Link>
</li> </li>
))} ))}
</ul> </ul>
@ -619,6 +610,7 @@ export class Main extends Component<any, MainState> {
this.state.siteRes.site = data.site; this.state.siteRes.site = data.site;
this.state.showEditSite = false; this.state.showEditSite = false;
this.setState(this.state); this.setState(this.state);
toast(i18n.t('site_saved'));
} else if (res.op == UserOperation.GetPosts) { } else if (res.op == UserOperation.GetPosts) {
let data = res.data as GetPostsResponse; let data = res.data as GetPostsResponse;
this.state.posts = data.posts; this.state.posts = data.posts;

View file

@ -16,6 +16,7 @@ import {
Comment, Comment,
CommentResponse, CommentResponse,
PrivateMessage, PrivateMessage,
UserView,
PrivateMessageResponse, PrivateMessageResponse,
WebSocketJsonResponse, WebSocketJsonResponse,
} from '../interfaces'; } from '../interfaces';
@ -40,6 +41,7 @@ interface NavbarState {
messages: Array<PrivateMessage>; messages: Array<PrivateMessage>;
unreadCount: number; unreadCount: number;
siteName: string; siteName: string;
admins: Array<UserView>;
} }
export class Navbar extends Component<any, NavbarState> { export class Navbar extends Component<any, NavbarState> {
@ -53,6 +55,7 @@ export class Navbar extends Component<any, NavbarState> {
messages: [], messages: [],
expanded: false, expanded: false,
siteName: undefined, siteName: undefined,
admins: [],
}; };
constructor(props: any, context: any) { constructor(props: any, context: any) {
@ -179,6 +182,19 @@ export class Navbar extends Component<any, NavbarState> {
</li> </li>
</ul> </ul>
<ul class="navbar-nav ml-auto"> <ul class="navbar-nav ml-auto">
{this.canAdmin && (
<li className="nav-item mt-1">
<Link
class="nav-link"
to={`/admin`}
title={i18n.t('admin_settings')}
>
<svg class="icon">
<use xlinkHref="#icon-settings"></use>
</svg>
</Link>
</li>
)}
{this.state.isLoggedIn ? ( {this.state.isLoggedIn ? (
<> <>
<li className="nav-item mt-1"> <li className="nav-item mt-1">
@ -298,7 +314,10 @@ export class Navbar extends Component<any, NavbarState> {
if (data.site && !this.state.siteName) { if (data.site && !this.state.siteName) {
this.state.siteName = data.site.name; this.state.siteName = data.site.name;
this.state.admins = data.admins;
WebSocketService.Instance.site = data.site; WebSocketService.Instance.site = data.site;
WebSocketService.Instance.admins = data.admins;
this.setState(this.state); this.setState(this.state);
} }
} }
@ -353,6 +372,13 @@ 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)
);
}
requestNotificationPermission() { requestNotificationPermission() {
if (UserService.Instance.user) { if (UserService.Instance.user) {
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {

View file

@ -19,6 +19,7 @@ import {
import { MomentTime } from './moment-time'; import { MomentTime } from './moment-time';
import { PostForm } from './post-form'; import { PostForm } from './post-form';
import { IFramelyCard } from './iframely-card'; import { IFramelyCard } from './iframely-card';
import { UserListing } from './user-listing';
import { import {
md, md,
mdToHtml, mdToHtml,
@ -27,8 +28,6 @@ import {
isImage, isImage,
isVideo, isVideo,
getUnixTime, getUnixTime,
pictshareAvatarThumbnail,
showAvatars,
pictshareImage, pictshareImage,
setupTippy, setupTippy,
previewLines, previewLines,
@ -417,20 +416,12 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
<ul class="list-inline mb-0 text-muted small"> <ul class="list-inline mb-0 text-muted small">
<li className="list-inline-item"> <li className="list-inline-item">
<span>{i18n.t('by')} </span> <span>{i18n.t('by')} </span>
<Link <UserListing
className="text-body font-weight-bold" user={{
to={`/u/${post.creator_name}`} name: post.creator_name,
> avatar: post.creator_avatar,
{post.creator_avatar && showAvatars() && ( }}
<img />
height="32"
width="32"
src={pictshareAvatarThumbnail(post.creator_avatar)}
class="rounded-circle mr-1"
/>
)}
<span>{post.creator_name}</span>
</Link>
{this.isMod && ( {this.isMod && (
<span className="mx-1 badge badge-light"> <span className="mx-1 badge badge-light">
{i18n.t('mod')} {i18n.t('mod')}

View file

@ -21,14 +21,13 @@ import {
capitalizeFirstLetter, capitalizeFirstLetter,
markdownHelpUrl, markdownHelpUrl,
mdToHtml, mdToHtml,
showAvatars,
pictshareAvatarThumbnail,
wsJsonToRes, wsJsonToRes,
toast, toast,
randomStr, randomStr,
setupTribute, setupTribute,
setupTippy, setupTippy,
} from '../utils'; } from '../utils';
import { UserListing } from './user-listing';
import Tribute from 'tributejs/src/Tribute.js'; import Tribute from 'tributejs/src/Tribute.js';
import autosize from 'autosize'; import autosize from 'autosize';
import { i18n } from '../i18next'; import { i18n } from '../i18next';
@ -132,22 +131,12 @@ export class PrivateMessageForm extends Component<
{this.state.recipient && ( {this.state.recipient && (
<div class="col-sm-10 form-control-plaintext"> <div class="col-sm-10 form-control-plaintext">
<Link <UserListing
className="text-body font-weight-bold" user={{
to={`/u/${this.state.recipient.name}`} name: this.state.recipient.name,
> avatar: this.state.recipient.avatar,
{this.state.recipient.avatar && showAvatars() && ( }}
<img />
height="32"
width="32"
src={pictshareAvatarThumbnail(
this.state.recipient.avatar
)}
class="rounded-circle mr-1"
/>
)}
<span>{this.state.recipient.name}</span>
</Link>
</div> </div>
)} )}
</div> </div>

View file

@ -58,6 +58,7 @@ export class PrivateMessage extends Component<
<div class="border-top border-light"> <div class="border-top border-light">
<div> <div>
<ul class="list-inline mb-0 text-muted small"> <ul class="list-inline mb-0 text-muted small">
{/* TODO refactor this */}
<li className="list-inline-item"> <li className="list-inline-item">
{this.mine ? i18n.t('to') : i18n.t('from')} {this.mine ? i18n.t('to') : i18n.t('from')}
</li> </li>

View file

@ -30,6 +30,7 @@ import {
commentsToFlatNodes, commentsToFlatNodes,
} from '../utils'; } from '../utils';
import { PostListing } from './post-listing'; import { PostListing } from './post-listing';
import { UserListing } from './user-listing';
import { SortSelect } from './sort-select'; import { SortSelect } from './sort-select';
import { CommentNodes } from './comment-nodes'; import { CommentNodes } from './comment-nodes';
import { i18n } from '../i18next'; import { i18n } from '../i18next';
@ -266,22 +267,12 @@ export class Search extends Component<any, SearchState> {
{i.type_ == 'users' && ( {i.type_ == 'users' && (
<div> <div>
<span> <span>
<Link <UserListing
className="text-info" user={{
to={`/u/${(i.data as UserView).name}`} name: (i.data as UserView).name,
> avatar: (i.data as UserView).avatar,
{(i.data as UserView).avatar && showAvatars() && ( }}
<img />
height="32"
width="32"
src={pictshareAvatarThumbnail(
(i.data as UserView).avatar
)}
class="rounded-circle mr-1"
/>
)}
<span>{`/u/${(i.data as UserView).name}`}</span>
</Link>
</span> </span>
<span>{` - ${ <span>{` - ${
(i.data as UserView).comment_score (i.data as UserView).comment_score

View file

@ -15,6 +15,7 @@ import {
showAvatars, showAvatars,
} from '../utils'; } from '../utils';
import { CommunityForm } from './community-form'; import { CommunityForm } from './community-form';
import { UserListing } from './user-listing';
import { i18n } from '../i18next'; import { i18n } from '../i18next';
interface SidebarProps { interface SidebarProps {
@ -204,20 +205,12 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
<li class="list-inline-item">{i18n.t('mods')}: </li> <li class="list-inline-item">{i18n.t('mods')}: </li>
{this.props.moderators.map(mod => ( {this.props.moderators.map(mod => (
<li class="list-inline-item"> <li class="list-inline-item">
<Link <UserListing
class="text-body font-weight-bold" user={{
to={`/u/${mod.user_name}`} name: mod.user_name,
> avatar: mod.avatar,
{mod.avatar && showAvatars() && ( }}
<img />
height="32"
width="32"
src={pictshareAvatarThumbnail(mod.avatar)}
class="rounded-circle mr-1"
/>
)}
<span>{mod.user_name}</span>
</Link>
</li> </li>
))} ))}
</ul> </ul>

View file

@ -58,12 +58,19 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> {
}); });
} }
// Necessary to stop the loading
componentWillReceiveProps() {
this.state.loading = false;
this.setState(this.state);
}
render() { render() {
return ( return (
<> <>
<Prompt <Prompt
when={ when={
!this.state.loading && !this.state.loading &&
!this.props.site &&
(this.state.siteForm.name || this.state.siteForm.description) (this.state.siteForm.name || this.state.siteForm.description)
} }
message={i18n.t('block_leaving')} message={i18n.t('block_leaving')}

File diff suppressed because one or more lines are too long

36
ui/src/components/user-listing.tsx vendored Normal file
View file

@ -0,0 +1,36 @@
import { Component } from 'inferno';
import { Link } from 'inferno-router';
import { UserView } from '../interfaces';
import { pictshareAvatarThumbnail, showAvatars } from '../utils';
interface UserOther {
name: string;
avatar?: string;
}
interface UserListingProps {
user: UserView | UserOther;
}
export class UserListing extends Component<UserListingProps, any> {
constructor(props: any, context: any) {
super(props, context);
}
render() {
let user = this.props.user;
return (
<Link className="text-body font-weight-bold" to={`/u/${user.name}`}>
{user.avatar && showAvatars() && (
<img
height="32"
width="32"
src={pictshareAvatarThumbnail(user.avatar)}
class="rounded-circle mr-2"
/>
)}
<span>{user.name}</span>
</Link>
);
}
}

116
ui/src/index.tsx vendored
View file

@ -15,79 +15,85 @@ import { Communities } from './components/communities';
import { User } from './components/user'; import { User } from './components/user';
import { Modlog } from './components/modlog'; import { Modlog } from './components/modlog';
import { Setup } from './components/setup'; import { Setup } from './components/setup';
import { AdminSettings } from './components/admin-settings';
import { Inbox } from './components/inbox'; import { Inbox } from './components/inbox';
import { Search } from './components/search'; import { Search } from './components/search';
import { Sponsors } from './components/sponsors'; import { Sponsors } from './components/sponsors';
import { Symbols } from './components/symbols'; import { Symbols } from './components/symbols';
import { i18n } from './i18next'; import { i18n } from './i18next';
import { WebSocketService, UserService } from './services';
const container = document.getElementById('app'); const container = document.getElementById('app');
class Index extends Component<any, any> { class Index extends Component<any, any> {
constructor(props: any, context: any) { constructor(props: any, context: any) {
super(props, context); super(props, context);
WebSocketService.Instance;
UserService.Instance;
} }
render() { render() {
return ( return (
<Provider i18next={i18n}> <Provider i18next={i18n}>
<BrowserRouter> <BrowserRouter>
<Navbar /> <div>
<div class="mt-4 p-0 fl-1"> <Navbar />
<Switch> <div class="mt-4 p-0 fl-1">
<Route exact path={`/`} component={Main} /> <Switch>
<Route <Route exact path={`/`} component={Main} />
path={`/home/data_type/:data_type/listing_type/:listing_type/sort/:sort/page/:page`} <Route
component={Main} path={`/home/data_type/:data_type/listing_type/:listing_type/sort/:sort/page/:page`}
/> component={Main}
<Route path={`/login`} component={Login} /> />
<Route path={`/create_post`} component={CreatePost} /> <Route path={`/login`} component={Login} />
<Route path={`/create_community`} component={CreateCommunity} /> <Route path={`/create_post`} component={CreatePost} />
<Route <Route path={`/create_community`} component={CreateCommunity} />
path={`/create_private_message`} <Route
component={CreatePrivateMessage} path={`/create_private_message`}
/> component={CreatePrivateMessage}
<Route path={`/communities/page/:page`} component={Communities} /> />
<Route path={`/communities`} component={Communities} /> <Route
<Route path={`/post/:id/comment/:comment_id`} component={Post} /> path={`/communities/page/:page`}
<Route path={`/post/:id`} component={Post} /> component={Communities}
<Route />
path={`/c/:name/data_type/:data_type/sort/:sort/page/:page`} <Route path={`/communities`} component={Communities} />
component={Community} <Route
/> path={`/post/:id/comment/:comment_id`}
<Route path={`/community/:id`} component={Community} /> component={Post}
<Route path={`/c/:name`} component={Community} /> />
<Route <Route path={`/post/:id`} component={Post} />
path={`/u/:username/view/:view/sort/:sort/page/:page`} <Route
component={User} path={`/c/:name/data_type/:data_type/sort/:sort/page/:page`}
/> component={Community}
<Route path={`/user/:id`} component={User} /> />
<Route path={`/u/:username`} component={User} /> <Route path={`/community/:id`} component={Community} />
<Route path={`/inbox`} component={Inbox} /> <Route path={`/c/:name`} component={Community} />
<Route <Route
path={`/modlog/community/:community_id`} path={`/u/:username/view/:view/sort/:sort/page/:page`}
component={Modlog} component={User}
/> />
<Route path={`/modlog`} component={Modlog} /> <Route path={`/user/:id`} component={User} />
<Route path={`/setup`} component={Setup} /> <Route path={`/u/:username`} component={User} />
<Route <Route path={`/inbox`} component={Inbox} />
path={`/search/q/:q/type/:type/sort/:sort/page/:page`} <Route
component={Search} path={`/modlog/community/:community_id`}
/> component={Modlog}
<Route path={`/search`} component={Search} /> />
<Route path={`/sponsors`} component={Sponsors} /> <Route path={`/modlog`} component={Modlog} />
<Route <Route path={`/setup`} component={Setup} />
path={`/password_change/:token`} <Route path={`/admin`} component={AdminSettings} />
component={PasswordChange} <Route
/> path={`/search/q/:q/type/:type/sort/:sort/page/:page`}
</Switch> component={Search}
<Symbols /> />
<Route path={`/search`} component={Search} />
<Route path={`/sponsors`} component={Sponsors} />
<Route
path={`/password_change/:token`}
component={PasswordChange}
/>
</Switch>
<Symbols />
</div>
<Footer />
</div> </div>
<Footer />
</BrowserRouter> </BrowserRouter>
</Provider> </Provider>
); );

22
ui/src/interfaces.ts vendored
View file

@ -43,6 +43,8 @@ export enum UserOperation {
GetPrivateMessages, GetPrivateMessages,
UserJoin, UserJoin,
GetComments, GetComments,
GetSiteConfig,
SaveSiteConfig,
} }
export enum CommentSortType { export enum CommentSortType {
@ -102,7 +104,6 @@ export interface UserView {
avatar?: string; avatar?: string;
email?: string; email?: string;
matrix_user_id?: string; matrix_user_id?: string;
fedi_name: string;
published: string; published: string;
number_of_posts: number; number_of_posts: number;
post_score: number; post_score: number;
@ -699,6 +700,19 @@ export interface SiteForm {
auth?: string; auth?: string;
} }
export interface GetSiteConfig {
auth?: string;
}
export interface GetSiteConfigResponse {
config_hjson: string;
}
export interface SiteConfigForm {
config_hjson: string;
auth?: string;
}
export interface GetSiteResponse { export interface GetSiteResponse {
site: Site; site: Site;
admins: Array<UserView>; admins: Array<UserView>;
@ -846,7 +860,8 @@ export type MessageType =
| PasswordChangeForm | PasswordChangeForm
| PrivateMessageForm | PrivateMessageForm
| EditPrivateMessageForm | EditPrivateMessageForm
| GetPrivateMessagesForm; | GetPrivateMessagesForm
| SiteConfigForm;
type ResponseType = type ResponseType =
| SiteResponse | SiteResponse
@ -868,7 +883,8 @@ type ResponseType =
| BanUserResponse | BanUserResponse
| AddAdminResponse | AddAdminResponse
| PrivateMessageResponse | PrivateMessageResponse
| PrivateMessagesResponse; | PrivateMessagesResponse
| GetSiteConfigResponse;
export interface WebSocketResponse { export interface WebSocketResponse {
op: UserOperation; op: UserOperation;

View file

@ -40,6 +40,8 @@ import {
GetPrivateMessagesForm, GetPrivateMessagesForm,
GetCommentsForm, GetCommentsForm,
UserJoinForm, UserJoinForm,
GetSiteConfig,
SiteConfigForm,
MessageType, MessageType,
WebSocketJsonResponse, WebSocketJsonResponse,
} from '../interfaces'; } from '../interfaces';
@ -268,6 +270,12 @@ export class WebSocketService {
this.ws.send(this.wsSendWrapper(UserOperation.GetSite, {})); this.ws.send(this.wsSendWrapper(UserOperation.GetSite, {}));
} }
public getSiteConfig() {
let siteConfig: GetSiteConfig = {};
this.setAuth(siteConfig);
this.ws.send(this.wsSendWrapper(UserOperation.GetSiteConfig, siteConfig));
}
public search(form: SearchForm) { public search(form: SearchForm) {
this.setAuth(form, false); this.setAuth(form, false);
this.ws.send(this.wsSendWrapper(UserOperation.Search, form)); this.ws.send(this.wsSendWrapper(UserOperation.Search, form));
@ -314,6 +322,11 @@ export class WebSocketService {
this.ws.send(this.wsSendWrapper(UserOperation.GetPrivateMessages, form)); this.ws.send(this.wsSendWrapper(UserOperation.GetPrivateMessages, form));
} }
public saveSiteConfig(form: SiteConfigForm) {
this.setAuth(form);
this.ws.send(this.wsSendWrapper(UserOperation.SaveSiteConfig, form));
}
private wsSendWrapper(op: UserOperation, data: MessageType) { private wsSendWrapper(op: UserOperation, data: MessageType) {
let send = { op: UserOperation[op], data: data }; let send = { op: UserOperation[op], data: data };
console.log(send); console.log(send);

View file

@ -53,6 +53,8 @@
"mods": "mods", "mods": "mods",
"moderates": "Moderates", "moderates": "Moderates",
"settings": "Settings", "settings": "Settings",
"admin_settings": "Admin Settings",
"site_config": "Site Configuration",
"remove_as_mod": "remove as mod", "remove_as_mod": "remove as mod",
"appoint_as_mod": "appoint as mod", "appoint_as_mod": "appoint as mod",
"modlog": "Modlog", "modlog": "Modlog",
@ -78,6 +80,7 @@
"unban": "unban", "unban": "unban",
"unban_from_site": "unban from site", "unban_from_site": "unban from site",
"banned": "banned", "banned": "banned",
"banned_users": "Banned Users",
"save": "save", "save": "save",
"unsave": "unsave", "unsave": "unsave",
"create": "create", "create": "create",
@ -211,6 +214,7 @@
"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>.", "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>.",
"not_logged_in": "Not logged in.", "not_logged_in": "Not logged in.",
"logged_in": "Logged in.", "logged_in": "Logged in.",
"site_saved": "Site Saved.",
"community_ban": "You have been banned from this community.", "community_ban": "You have been banned from this community.",
"site_ban": "You have been banned from the site", "site_ban": "You have been banned from the site",
"couldnt_create_comment": "Couldn't create comment.", "couldnt_create_comment": "Couldn't create comment.",