diff options
author | Dessalines <tyhou13@gmx.com> | 2020-04-10 16:55:57 -0400 |
---|---|---|
committer | Dessalines <tyhou13@gmx.com> | 2020-04-10 16:55:57 -0400 |
commit | bb287cbd076940bd09f6afb61b642370d020f91e (patch) | |
tree | f3fe3afa29b6445fbb2232a8c5a9133b8d691a91 | |
parent | ed264aba3c12243352f68c2de6a5f21f23778bd0 (diff) |
Adding an admin settings page.
- Fixes #620
- Adding a UserListing component. Fixes #627
25 files changed, 633 insertions, 179 deletions
diff --git a/docker/dev/docker-compose.yml b/docker/dev/docker-compose.yml index a7d289b2..3c52d1e5 100644 --- a/docker/dev/docker-compose.yml +++ b/docker/dev/docker-compose.yml @@ -21,7 +21,7 @@ services: environment: - RUST_LOG=debug volumes: - - ../lemmy.hjson:/config/config.hjson:ro + - ../lemmy.hjson:/config/config.hjson depends_on: - postgres - pictshare diff --git a/docker/prod/docker-compose.yml b/docker/prod/docker-compose.yml index 325effa6..a1b36162 100644 --- a/docker/prod/docker-compose.yml +++ b/docker/prod/docker-compose.yml @@ -19,7 +19,7 @@ services: environment: - RUST_LOG=error volumes: - - ./lemmy.hjson:/config/config.hjson:ro + - ./lemmy.hjson:/config/config.hjson depends_on: - postgres - pictshare diff --git a/docs/src/contributing_websocket_http_api.md b/docs/src/contributing_websocket_http_api.md index a73a1c13..f228f94e 100644 --- a/docs/src/contributing_websocket_http_api.md +++ b/docs/src/contributing_websocket_http_api.md @@ -92,85 +92,93 @@ - [Request](#request-17) - [Response](#response-17) - [HTTP](#http-18) - * [Community](#community) - + [Get Community](#get-community) + + [Get Site Config](#get-site-config) - [Request](#request-18) - [Response](#response-18) - [HTTP](#http-19) - + [Create Community](#create-community) + + [Save Site Config](#save-site-config) - [Request](#request-19) - [Response](#response-19) - [HTTP](#http-20) - + [List Communities](#list-communities) + * [Community](#community) + + [Get Community](#get-community) - [Request](#request-20) - [Response](#response-20) - [HTTP](#http-21) - + [Ban from Community](#ban-from-community) + + [Create Community](#create-community) - [Request](#request-21) - [Response](#response-21) - [HTTP](#http-22) - + [Add Mod to Community](#add-mod-to-community) + + [List Communities](#list-communities) - [Request](#request-22) - [Response](#response-22) - [HTTP](#http-23) - + [Edit Community](#edit-community) + + [Ban from Community](#ban-from-community) - [Request](#request-23) - [Response](#response-23) - [HTTP](#http-24) - + [Follow Community](#follow-community) + + [Add Mod to Community](#add-mod-to-community) - [Request](#request-24) - [Response](#response-24) - [HTTP](#http-25) - + [Get Followed Communities](#get-followed-communities) + + [Edit Community](#edit-community) - [Request](#request-25) - [Response](#response-25) - [HTTP](#http-26) - + [Transfer Community](#transfer-community) + + [Follow Community](#follow-community) - [Request](#request-26) - [Response](#response-26) - [HTTP](#http-27) - * [Post](#post) - + [Create Post](#create-post) + + [Get Followed Communities](#get-followed-communities) - [Request](#request-27) - [Response](#response-27) - [HTTP](#http-28) - + [Get Post](#get-post) + + [Transfer Community](#transfer-community) - [Request](#request-28) - [Response](#response-28) - [HTTP](#http-29) - + [Get Posts](#get-posts) + * [Post](#post) + + [Create Post](#create-post) - [Request](#request-29) - [Response](#response-29) - [HTTP](#http-30) - + [Create Post Like](#create-post-like) + + [Get Post](#get-post) - [Request](#request-30) - [Response](#response-30) - [HTTP](#http-31) - + [Edit Post](#edit-post) + + [Get Posts](#get-posts) - [Request](#request-31) - [Response](#response-31) - [HTTP](#http-32) - + [Save Post](#save-post) + + [Create Post Like](#create-post-like) - [Request](#request-32) - [Response](#response-32) - [HTTP](#http-33) - * [Comment](#comment) - + [Create Comment](#create-comment) + + [Edit Post](#edit-post) - [Request](#request-33) - [Response](#response-33) - [HTTP](#http-34) - + [Edit Comment](#edit-comment) + + [Save Post](#save-post) - [Request](#request-34) - [Response](#response-34) - [HTTP](#http-35) - + [Save Comment](#save-comment) + * [Comment](#comment) + + [Create Comment](#create-comment) - [Request](#request-35) - [Response](#response-35) - [HTTP](#http-36) - + [Create Comment Like](#create-comment-like) + + [Edit Comment](#edit-comment) - [Request](#request-36) - [Response](#response-36) - [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) + [All](#all) + [Community](#community-1) @@ -779,6 +787,53 @@ Search types are `All, Comments, Posts, Communities, Users, Url` `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 #### Get Community ##### Request diff --git a/server/src/api/site.rs b/server/src/api/site.rs index 6bd90149..3720a2c4 100644 --- a/server/src/api/site.rs +++ b/server/src/api/site.rs @@ -97,6 +97,22 @@ pub struct TransferSite { 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> { fn perform(&self, conn: &PgConnection) -> Result<ListCategoriesResponse, Error> { 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 }) + } +} diff --git a/server/src/routes/api.rs b/server/src/routes/api.rs index 29a360e4..36a55f96 100644 --- a/server/src/routes/api.rs +++ b/server/src/routes/api.rs @@ -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::put().to(route_post::<EditSite, SiteResponse>)) .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/user/ban", web::post().to(route_post::<BanUser, BanUserResponse>)) // User account actions diff --git a/server/src/routes/index.rs b/server/src/routes/index.rs index c1c363c9..45ce204e 100644 --- a/server/src/routes/index.rs +++ b/server/src/routes/index.rs @@ -33,6 +33,7 @@ pub fn config(cfg: &mut web::ServiceConfig) { .route("/modlog/community/{community_id}", web::get().to(index)) .route("/modlog", web::get().to(index)) .route("/setup", web::get().to(index)) + .route("/admin", web::get().to(index)) .route( "/search/q/{q}/type/{type}/sort/{sort}/page/{page}", web::get().to(index), diff --git a/server/src/settings.rs b/server/src/settings.rs index 875323e9..216c057e 100644 --- a/server/src/settings.rs +++ b/server/src/settings.rs @@ -1,6 +1,8 @@ use config::{Config, ConfigError, Environment, File}; +use failure::Error; use serde::Deserialize; use std::env; +use std::fs; use std::net::IpAddr; static CONFIG_FILE_DEFAULTS: &str = "config/defaults.hjson"; @@ -112,4 +114,14 @@ impl Settings { pub fn api_endpoint(&self) -> String { 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)?; + Self::init()?; + Self::read_config_file() + } } diff --git a/server/src/websocket/mod.rs b/server/src/websocket/mod.rs index a1feede2..c7136423 100644 --- a/server/src/websocket/mod.rs +++ b/server/src/websocket/mod.rs @@ -46,4 +46,6 @@ pub enum UserOperation { GetPrivateMessages, UserJoin, GetComments, + GetSiteConfig, + SaveSiteConfig, } diff --git a/server/src/websocket/server.rs b/server/src/websocket/server.rs index 831f12ee..0f2d2d26 100644 --- a/server/src/websocket/server.rs +++ b/server/src/websocket/server.rs @@ -708,6 +708,16 @@ fn parse_json_message(chat: &mut ChatServer, msg: StandardMessage) -> Result<Str res.online = chat.sessions.len(); 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 => { do_user_operation::<Search, SearchResponse>(user_operation, data, &conn) } diff --git a/ui/src/components/admin-settings.tsx b/ui/src/components/admin-settings.tsx new file mode 100644 index 00000000..56af7114 --- /dev/null +++ b/ui/src/components/admin-settings.tsx @@ -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); + } + } +} diff --git a/ui/src/components/comment-node.tsx b/ui/src/components/comment-node.tsx index 39f29b5f..ba4301e1 100644 --- a/ui/src/components/comment-node.tsx +++ b/ui/src/components/comment-node.tsx @@ -24,8 +24,6 @@ import { getUnixTime, canMod, isMod, - pictshareAvatarThumbnail, - showAvatars, setupTippy, colorList, } from '../utils'; @@ -33,6 +31,7 @@ import moment from 'moment'; import { MomentTime } from './moment-time'; import { CommentForm } from './comment-form'; import { CommentNodes } from './comment-nodes'; +import { UserListing } from './user-listing'; import { i18n } from '../i18next'; interface CommentNodeState { @@ -148,20 +147,14 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> { 'ml-2'}`} > <div class="d-flex flex-wrap align-items-center mb-1 mt-1 text-muted small"> - <Link - className="mr-2 text-body font-weight-bold" - to={`/u/${node.comment.creator_name}`} - > - {node.comment.creator_avatar && showAvatars() && ( - <img - height="32" - width="32" - src={pictshareAvatarThumbnail(node.comment.creator_avatar)} - class="rounded-circle mr-1" - /> - )} - <span>{node.comment.creator_name}</span> - </Link> + <span class="mr-2"> + <UserListing + user={{ + name: node.comment.creator_name, + avatar: node.comment.creator_avatar, + }} + /> + </span> {this.isMod && ( <div className="badge badge-light d-none d-sm-inline mr-2"> {i18n.t('mod')} @@ -191,7 +184,7 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> { </> )} <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)} > {this.state.collapsed ? ( diff --git a/ui/src/components/main.tsx b/ui/src/components/main.tsx index 38003312..366d3be8 100644 --- a/ui/src/components/main.tsx +++ b/ui/src/components/main.tsx @@ -33,13 +33,12 @@ import { SortSelect } from './sort-select'; import { ListingTypeSelect } from './listing-type-select'; import { DataTypeSelect } from './data-type-select'; import { SiteForm } from './site-form'; +import { UserListing } from './user-listing'; import { wsJsonToRes, repoUrl, mdToHtml, fetchLimit, - pictshareAvatarThumbnail, - showAvatars, toast, getListingTypeFromProps, getPageFromProps, @@ -316,20 +315,12 @@ export class Main extends Component<any, MainState> { <li class="list-inline-item">{i18n.t('admins')}:</li> {this.state.siteRes.admins.map(admin => ( <li class="list-inline-item"> - <Link - class="text-body font-weight-bold" - to={`/u/${admin.name}`} - > - {admin.avatar && showAvatars() && ( - <img - height="32" - width="32" - src={pictshareAvatarThumbnail(admin.avatar)} - class="rounded-circle mr-1" - /> - )} - <span>{admin.name}</span> - </Link> + <UserListing + user={{ + name: admin.name, + avatar: admin.avatar, + }} + /> </li> ))} </ul> @@ -619,6 +610,7 @@ export class Main extends Component<any, MainState> { this.state.siteRes.site = data.site; this.state.showEditSite = false; this.setState(this.state); + toast(i18n.t('site_saved')); } else if (res.op == UserOperation.GetPosts) { let data = res.data as GetPostsResponse; this.state.posts = data.posts; diff --git a/ui/src/components/navbar.tsx b/ui/src/components/navbar.tsx index d7f3b5a8..e0d8aff5 100644 --- a/ui/src/components/navbar.tsx +++ b/ui/src/components/navbar.tsx @@ -16,6 +16,7 @@ import { Comment, CommentResponse, PrivateMessage, + UserView, PrivateMessageResponse, WebSocketJsonResponse, } from '../interfaces'; @@ -40,6 +41,7 @@ interface NavbarState { messages: Array<PrivateMessage>; unreadCount: number; siteName: string; + admins: Array<UserView>; } export class Navbar extends Component<any, NavbarState> { @@ -53,6 +55,7 @@ export class Navbar extends Component<any, NavbarState> { messages: [], expanded: false, siteName: undefined, + admins: [], }; constructor(props: any, context: any) { @@ -179,6 +182,19 @@ export class Navbar extends Component<any, NavbarState> { </li> </ul> <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 ? ( <> <li className="nav-item mt-1"> @@ -298,7 +314,10 @@ export class Navbar extends Component<any, NavbarState> { if (data.site && !this.state.siteName) { this.state.siteName = data.site.name; + this.state.admins = data.admins; WebSocketService.Instance.site = data.site; + WebSocketService.Instance.admins = data.admins; + 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() { if (UserService.Instance.user) { document.addEventListener('DOMContentLoaded', function() { diff --git a/ui/src/components/post-listing.tsx b/ui/src/components/post-listing.tsx index 101d1807..d0efa043 100644 --- a/ui/src/components/post-listing.tsx +++ b/ui/src/components/post-listing.tsx @@ -19,6 +19,7 @@ import { import { MomentTime } from './moment-time'; import { PostForm } from './post-form'; import { IFramelyCard } from './iframely-card'; +import { UserListing } from './user-listing'; import { md, mdToHtml, @@ -27,8 +28,6 @@ import { isImage, isVideo, getUnixTime, - pictshareAvatarThumbnail, - showAvatars, pictshareImage, setupTippy, previewLines, @@ -417,20 +416,12 @@ export class PostListing extends Component<PostListingProps, PostListingState> { <ul class="list-inline mb-0 text-muted small"> <li className="list-inline-item"> <span>{i18n.t('by')} </span> - <Link - className="text-body font-weight-bold" - to={`/u/${post.creator_name}`} - > - {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> + <UserListing + user={{ + name: post.creator_name, + avatar: post.creator_avatar, + }} + /> {this.isMod && ( <span className="mx-1 badge badge-light"> {i18n.t('mod')} |