]> Untitled Git - lemmy.git/commitdiff
Merge branch 'dev' into federation
authorDessalines <tyhou13@gmx.com>
Tue, 14 Apr 2020 20:07:20 +0000 (16:07 -0400)
committerDessalines <tyhou13@gmx.com>
Tue, 14 Apr 2020 20:07:20 +0000 (16:07 -0400)
18 files changed:
1  2 
server/src/api/site.rs
server/src/lib.rs
server/src/main.rs
server/src/settings.rs
server/src/websocket/server.rs
ui/package.json
ui/src/components/comment-form.tsx
ui/src/components/comment-node.tsx
ui/src/components/navbar.tsx
ui/src/components/post-form.tsx
ui/src/components/post-listing.tsx
ui/src/components/post.tsx
ui/src/components/private-message-form.tsx
ui/src/components/private-message.tsx
ui/src/components/sidebar.tsx
ui/src/interfaces.ts
ui/src/utils.ts
ui/yarn.lock

diff --combined server/src/api/site.rs
index ad45e8d143709dde2b2839d9944b92e2f403f2e6,3720a2c4c1c8a7cf612aa20bd605e5750ab60e12..4202fea06e9cd5fcc13b23c9967498919340d2fa
@@@ -97,6 -97,22 +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;
@@@ -281,7 -297,8 +297,7 @@@ impl Perform<GetSiteResponse> for Oper<
    fn perform(&self, conn: &PgConnection) -> Result<GetSiteResponse, Error> {
      let _data: &GetSite = &self.data;
  
 -    let site = Site::read(&conn, 1);
 -    let site_view = if site.is_ok() {
 +    let site_view = if let Ok(_site) = Site::read(&conn, 1) {
        Some(SiteView::read(&conn)?)
      } else if let Some(setup) = Settings::get().setup.as_ref() {
        let register = Register {
      };
  
      let mut admins = UserView::admins(&conn)?;
 -    if site_view.is_some() {
 -      let site_creator_id = site_view.to_owned().unwrap().creator_id;
 -      let creator_index = admins.iter().position(|r| r.id == site_creator_id).unwrap();
 -      let creator_user = admins.remove(creator_index);
 -      admins.insert(0, creator_user);
 +
 +    // Make sure the site creator is the top admin
 +    if let Some(site_view) = site_view.to_owned() {
 +      let site_creator_id = site_view.creator_id;
 +      // TODO investigate why this is sometimes coming back null
 +      // Maybe user_.admin isn't being set to true?
 +      if let Some(creator_index) = admins.iter().position(|r| r.id == site_creator_id) {
 +        let creator_user = admins.remove(creator_index);
 +        admins.insert(0, creator_user);
 +      }
      }
  
      let banned = UserView::banned(&conn)?;
@@@ -514,3 -526,57 +530,57 @@@ impl Perform<GetSiteResponse> for Oper<
      })
    }
  }
+ 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 --combined server/src/lib.rs
index e45311eec3c2dd70fedd02bcd273129ee0a14f45,9bbfe251a27372f04991944e4f987411f9d4988e..2c78cfc2643d3813a10027daf06dcf5868a2bce2
@@@ -16,8 -16,6 +16,8 @@@ pub extern crate dotenv
  pub extern crate jsonwebtoken;
  pub extern crate lettre;
  pub extern crate lettre_email;
 +extern crate log;
 +pub extern crate openssl;
  pub extern crate rand;
  pub extern crate regex;
  pub extern crate rss;
@@@ -36,7 -34,7 +36,7 @@@ pub mod version
  pub mod websocket;
  
  use crate::settings::Settings;
 -use chrono::{DateTime, NaiveDateTime, Utc};
 +use chrono::{DateTime, FixedOffset, Local, NaiveDateTime};
  use isahc::prelude::*;
  use lettre::smtp::authentication::{Credentials, Mechanism};
  use lettre::smtp::extension::ClientId;
@@@ -50,6 -48,10 +50,6 @@@ use rand::{thread_rng, Rng}
  use regex::{Regex, RegexBuilder};
  use serde::Deserialize;
  
 -pub fn to_datetime_utc(ndt: NaiveDateTime) -> DateTime<Utc> {
 -  DateTime::<Utc>::from_utc(ndt, Utc)
 -}
 -
  pub fn naive_now() -> NaiveDateTime {
    chrono::prelude::Utc::now().naive_utc()
  }
@@@ -58,11 -60,6 +58,11 @@@ pub fn naive_from_unix(time: i64) -> Na
    NaiveDateTime::from_timestamp(time, 0)
  }
  
 +pub fn convert_datetime(datetime: NaiveDateTime) -> DateTime<FixedOffset> {
 +  let now = Local::now();
 +  DateTime::<FixedOffset>::from_utc(datetime, *now.offset())
 +}
 +
  pub fn is_email_regex(test: &str) -> bool {
    EMAIL_REGEX.is_match(test)
  }
@@@ -115,7 -112,7 +115,7 @@@ pub fn send_email
    to_username: &str,
    html: &str,
  ) -> 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()
      .to((to_email, to_username))
    } else {
      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)
    .authentication_mechanism(Mechanism::Plain)
    .connection_reuse(ConnectionReuseParameters::ReuseUnlimited);
diff --combined server/src/main.rs
index 59dc2cb7572428c8d353ade1d78fc8ff1e4c8cef,f3887527571a43b7424295a5d599dff3eb26bd74..88d62eb997c9fd374a4a1fc3661257d1f64029a0
@@@ -6,21 -6,15 +6,21 @@@ use actix::prelude::*
  use actix_web::*;
  use diesel::r2d2::{ConnectionManager, Pool};
  use diesel::PgConnection;
 +use failure::Error;
 +use lemmy_server::apub::fetcher::fetch_all;
 +use lemmy_server::db::code_migrations::run_advanced_migrations;
  use lemmy_server::routes::{api, federation, feeds, index, nodeinfo, webfinger, websocket};
  use lemmy_server::settings::Settings;
  use lemmy_server::websocket::server::*;
 -use std::io;
 +use log::warn;
 +use std::thread;
 +use std::thread::sleep;
 +use std::time::Duration;
  
  embed_migrations!();
  
  #[actix_rt::main]
 -async fn main() -> io::Result<()> {
 +async fn main() -> Result<(), Error> {
    env_logger::init();
    let settings = Settings::get();
  
    // Run the migrations from code
    let conn = pool.get().unwrap();
    embedded_migrations::run(&conn).unwrap();
 +  run_advanced_migrations(&conn).unwrap();
  
    // Set up websocket server
    let server = ChatServer::startup(pool.clone()).start();
  
 +  thread::spawn(move || {
 +    // some work here
 +    sleep(Duration::from_secs(5));
 +    println!("Fetching apub data");
 +    match fetch_all(&conn) {
 +      Ok(_) => {}
 +      Err(e) => warn!("Error during apub fetch: {}", e),
 +    }
 +  });
 +
    println!(
      "Starting http server at {}:{}",
      settings.bind, settings.port
    );
  
    // Create Http server with websocket support
 -  HttpServer::new(move || {
 -    let settings = Settings::get();
 -    App::new()
 -      .wrap(middleware::Logger::default())
 -      .data(pool.clone())
 -      .data(server.clone())
 -      // The routes
 -      .configure(api::config)
 -      .configure(federation::config)
 -      .configure(feeds::config)
 -      .configure(index::config)
 -      .configure(nodeinfo::config)
 -      .configure(webfinger::config)
 -      .configure(websocket::config)
 -      // static files
 -      .service(actix_files::Files::new(
 -        "/static",
 -        settings.front_end_dir.to_owned(),
 -      ))
 -      .service(actix_files::Files::new(
 -        "/docs",
 -        settings.front_end_dir + "/documentation",
 -      ))
 -  })
 -  .bind((settings.bind, settings.port))?
 -  .run()
 -  .await
 +  Ok(
 +    HttpServer::new(move || {
++      let settings = Settings::get();
 +      App::new()
 +        .wrap(middleware::Logger::default())
 +        .data(pool.clone())
 +        .data(server.clone())
 +        // The routes
 +        .configure(api::config)
 +        .configure(federation::config)
 +        .configure(feeds::config)
 +        .configure(index::config)
 +        .configure(nodeinfo::config)
 +        .configure(webfinger::config)
 +        .configure(websocket::config)
 +        // static files
 +        .service(actix_files::Files::new(
 +          "/static",
 +          settings.front_end_dir.to_owned(),
 +        ))
 +        .service(actix_files::Files::new(
 +          "/docs",
 +          settings.front_end_dir.to_owned() + "/documentation",
 +        ))
 +    })
 +    .bind((settings.bind, settings.port))?
 +    .run()
 +    .await?,
 +  )
  }
diff --combined server/src/settings.rs
index 29d5966bafc173fbf4dfa32db844a6181fffcc02,6e5667cb2ee7fbc56a9a8394c3aedae18f07efa9..8c3cd6a1251d035b7220a43f5e7440e266220767
@@@ -1,12 -1,15 +1,15 @@@
  use config::{Config, ConfigError, Environment, File};
+ use failure::Error;
  use serde::Deserialize;
  use std::env;
+ use std::fs;
  use std::net::IpAddr;
+ use std::sync::RwLock;
  
  static CONFIG_FILE_DEFAULTS: &str = "config/defaults.hjson";
  static CONFIG_FILE: &str = "config/config.hjson";
  
- #[derive(Debug, Deserialize)]
+ #[derive(Debug, Deserialize, Clone)]
  pub struct Settings {
    pub setup: Option<Setup>,
    pub database: Database,
    pub front_end_dir: String,
    pub rate_limit: RateLimitConfig,
    pub email: Option<EmailConfig>,
 -  pub federation_enabled: bool,
 +  pub federation: Federation,
  }
  
- #[derive(Debug, Deserialize)]
+ #[derive(Debug, Deserialize, Clone)]
  pub struct Setup {
    pub admin_username: String,
    pub admin_password: String,
@@@ -28,7 -31,7 +31,7 @@@
    pub site_name: String,
  }
  
- #[derive(Debug, Deserialize)]
+ #[derive(Debug, Deserialize, Clone)]
  pub struct RateLimitConfig {
    pub message: i32,
    pub message_per_second: i32,
@@@ -38,7 -41,7 +41,7 @@@
    pub register_per_second: i32,
  }
  
- #[derive(Debug, Deserialize)]
+ #[derive(Debug, Deserialize, Clone)]
  pub struct EmailConfig {
    pub smtp_server: String,
    pub smtp_login: Option<String>,
@@@ -47,7 -50,7 +50,7 @@@
    pub use_tls: bool,
  }
  
- #[derive(Debug, Deserialize)]
+ #[derive(Debug, Deserialize, Clone)]
  pub struct Database {
    pub user: String,
    pub password: String,
    pub pool_size: u32,
  }
  
 +#[derive(Debug, Deserialize)]
 +pub struct Federation {
 +  pub enabled: bool,
 +  pub followed_instances: String,
 +  pub tls_enabled: bool,
 +}
 +
  lazy_static! {
-   static ref SETTINGS: Settings = {
-     match Settings::init() {
-       Ok(c) => c,
-       Err(e) => panic!("{}", e),
-     }
-   };
+   static ref SETTINGS: RwLock<Settings> = RwLock::new(match Settings::init() {
+     Ok(c) => c,
+     Err(e) => panic!("{}", e),
+   });
  }
  
  impl Settings {
@@@ -96,8 -90,8 +97,8 @@@
    }
  
    /// Returns the config as a struct.
-   pub fn get() -> &'static Self {
-     &SETTINGS
+   pub fn get() -> Self {
+     SETTINGS.read().unwrap().to_owned()
    }
  
    /// Returns the postgres connection url. If LEMMY_DATABASE_URL is set, that is used,
    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)?;
+     // 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()
+   }
  }
index f205c91e61bd20bdc481d60bab596b8a64271063,0f2d2d26fdb4d89700e1c34efcb9738eed8a6f6d..faa8041cea2d60ace233d074397516a2bbc428d6
@@@ -505,6 -505,9 +505,6 @@@ fn parse_json_message(chat: &mut ChatSe
  
    let user_operation: UserOperation = UserOperation::from_str(&op)?;
  
 -  // TODO: none of the chat messages are going to work if stuff is submitted via http api,
 -  //       need to move that handling elsewhere
 -
    // A DDOS check
    chat.check_rate_limit_message(msg.id, false)?;
  
      }
      UserOperation::GetCommunity => {
        let get_community: GetCommunity = serde_json::from_str(data)?;
 +
        let mut res = Oper::new(get_community).perform(&conn)?;
 +
        let community_id = res.community.id;
  
        chat.join_community_room(community_id, msg.id);
      }
      UserOperation::GetPosts => {
        let get_posts: GetPosts = serde_json::from_str(data)?;
 +
        if get_posts.community_id.is_none() {
          // 0 is the "all" community
          chat.join_community_room(0, msg.id);
        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 --combined ui/package.json
index 7d946614c887d2bdb0213bb2c9c8b9ded46eff45,21458f0d2f20563e94f9467da69f25e3f0f3dc26..d2eb1de9ebd859e7590234d04439f272b44ae74f
    },
    "keywords": [],
    "dependencies": {
+     "@joeattardi/emoji-button": "^2.12.1",
      "@types/autosize": "^3.0.6",
-     "@types/js-cookie": "^2.2.5",
+     "@types/js-cookie": "^2.2.6",
      "@types/jwt-decode": "^2.2.1",
 -    "@types/markdown-it": "^10.0.0",
 +    "@types/markdown-it": "^0.0.9",
      "@types/markdown-it-container": "^2.0.2",
-     "@types/node": "^13.9.2",
+     "@types/node": "^13.11.1",
      "autosize": "^4.0.2",
      "bootswatch": "^4.3.1",
-     "classcat": "^1.1.3",
+     "classcat": "^4.0.2",
      "dotenv": "^8.2.0",
      "emoji-short-name": "^1.0.0",
-     "husky": "^4.2.3",
-     "i18next": "^19.3.3",
+     "husky": "^4.2.5",
+     "i18next": "^19.4.1",
      "inferno": "^7.4.2",
      "inferno-i18next": "nimbusec-oss/inferno-i18next",
      "inferno-router": "^7.4.2",
      "markdown-it-emoji": "^1.4.0",
      "mobius1-selectr": "^2.4.13",
      "moment": "^2.24.0",
-     "prettier": "^1.18.2",
+     "prettier": "^2.0.4",
      "reconnecting-websocket": "^4.4.0",
-     "rxjs": "^6.4.0",
-     "terser": "^4.6.7",
-     "tippy.js": "^6.1.0",
+     "rxjs": "^6.5.5",
+     "terser": "^4.6.11",
+     "tippy.js": "^6.1.1",
      "toastify-js": "^1.7.0",
-     "tributejs": "^5.1.2",
+     "tributejs": "^5.1.3",
      "twemoji": "^12.1.2",
      "ws": "^7.2.3"
    },
    "devDependencies": {
      "eslint": "^6.5.1",
      "eslint-plugin-inferno": "^7.14.3",
-     "eslint-plugin-jane": "^7.2.0",
+     "eslint-plugin-jane": "^7.2.1",
      "fuse-box": "^3.1.3",
-     "lint-staged": "^10.0.8",
-     "sortpack": "^2.1.2",
-     "ts-node": "^8.7.0",
-     "ts-transform-classcat": "^0.0.2",
-     "ts-transform-inferno": "^4.0.2",
+     "lint-staged": "^10.1.3",
+     "sortpack": "^2.1.4",
+     "ts-node": "^8.8.2",
+     "ts-transform-classcat": "^1.0.0",
+     "ts-transform-inferno": "^4.0.3",
      "typescript": "^3.8.3"
    },
    "engines": {
index ae3e7cfc3ff964b7b112f475c9f1c5a0544b08a1,5239eb2c7a10660182154a1dd7f91b305ca367b7..b3c1a9a164fe093191dc8aa17ee4f401c19b5be5
@@@ -17,10 -17,12 +17,12 @@@ import 
    toast,
    setupTribute,
    wsJsonToRes,
+   emojiPicker,
  } from '../utils';
  import { WebSocketService, UserService } from '../services';
  import autosize from 'autosize';
  import Tribute from 'tributejs/src/Tribute.js';
+ import emojiShortName from 'emoji-short-name';
  import { i18n } from '../i18next';
  
  interface CommentFormProps {
@@@ -69,6 -71,8 +71,8 @@@ export class CommentForm extends Compon
      super(props, context);
  
      this.tribute = setupTribute();
+     this.setupEmojiPicker();
      this.state = this.emptyState;
  
      if (this.props.node) {
                </button>
                {this.state.commentForm.content && (
                  <button
--                  className={`btn btn-sm mr-2 btn-secondary ${this.state
--                    .previewMode && 'active'}`}
++                  className={`btn btn-sm mr-2 btn-secondary ${
++                    this.state.previewMode && 'active'
++                  }`}
                    onClick={linkEvent(this, this.handlePreviewToggle)}
                  >
                    {i18n.t('preview')}
                    <use xlinkHref="#icon-spinner"></use>
                  </svg>
                )}
+               <span
+                 onClick={linkEvent(this, this.handleEmojiPickerClick)}
+                 class="pointer unselectable d-inline-block mr-3 float-right text-muted font-weight-bold"
+                 data-tippy-content={i18n.t('emoji_picker')}
+               >
+                 <svg class="icon icon-inline">
+                   <use xlinkHref="#icon-smile"></use>
+                 </svg>
+               </span>
              </div>
            </div>
          </form>
      );
    }
  
+   setupEmojiPicker() {
+     emojiPicker.on('emoji', twemojiHtmlStr => {
+       if (this.state.commentForm.content == null) {
+         this.state.commentForm.content = '';
+       }
+       var el = document.createElement('div');
+       el.innerHTML = twemojiHtmlStr;
+       let nativeUnicode = (el.childNodes[0] as HTMLElement).getAttribute('alt');
+       let shortName = `:${emojiShortName[nativeUnicode]}:`;
+       this.state.commentForm.content += shortName;
+       this.setState(this.state);
+     });
+   }
    handleFinished() {
      this.state.previewMode = false;
      this.state.loading = false;
      i.setState(i.state);
    }
  
+   handleEmojiPickerClick(_i: CommentForm, event: any) {
+     emojiPicker.togglePicker(event.target);
+   }
    handleCommentContentChange(i: CommentForm, event: any) {
      i.state.commentForm.content = event.target.value;
      i.setState(i.state);
index 39f29b5f84eab110dc4bd7ca37f9915e7b923cdb,ba4301e169a0475d9c69c6a9b71b48d4a7b01216..69a78f5015a4b0a5db4c0e7ecb0e5693a4a66818
@@@ -24,8 -24,6 +24,6 @@@ import 
    getUnixTime,
    canMod,
    isMod,
-   pictshareAvatarThumbnail,
-   showAvatars,
    setupTippy,
    colorList,
  } from '../utils';
@@@ -33,6 -31,7 +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 {
@@@ -143,25 -142,19 +142,21 @@@ export class CommentNode extends Compon
            }
          >
            <div
--            class={`${!this.props.noIndent &&
++            class={`${
++              !this.props.noIndent &&
                this.props.node.comment.parent_id &&
--              'ml-2'}`}
++              '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')}
                  </>
                )}
                <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 ? (
                          this.loadingIcon
                        ) : (
                          <svg
--                          class={`icon icon-inline ${node.comment.read &&
--                            'text-success'}`}
++                          class={`icon icon-inline ${
++                            node.comment.read && 'text-success'
++                          }`}
                          >
                            <use xlinkHref="#icon-check"></use>
                          </svg>
                            this.loadingIcon
                          ) : (
                            <svg
--                            class={`icon icon-inline ${node.comment.saved &&
--                              'text-warning'}`}
++                            class={`icon icon-inline ${
++                              node.comment.saved && 'text-warning'
++                            }`}
                            >
                              <use xlinkHref="#icon-star"></use>
                            </svg>
                              data-tippy-content={i18n.t('view_source')}
                            >
                              <svg
--                              class={`icon icon-inline ${this.state
--                                .viewSource && 'text-success'}`}
++                              class={`icon icon-inline ${
++                                this.state.viewSource && 'text-success'
++                              }`}
                              >
                                <use xlinkHref="#icon-file-text"></use>
                              </svg>
                                  }
                                >
                                  <svg
--                                  class={`icon icon-inline ${node.comment
--                                    .deleted && 'text-danger'}`}
++                                  class={`icon icon-inline ${
++                                    node.comment.deleted && 'text-danger'
++                                  }`}
                                  >
                                    <use xlinkHref="#icon-trash"></use>
                                  </svg>
index d7f3b5a8a1163ec1663f681e74b5cc59c8cc9881,e0d8aff50ad98789073adc97bad7a170638787d9..f1936be1812fcd01af3898889821d8192540b14d
@@@ -16,6 -16,7 +16,7 @@@ import 
    Comment,
    CommentResponse,
    PrivateMessage,
+   UserView,
    PrivateMessageResponse,
    WebSocketJsonResponse,
  } from '../interfaces';
@@@ -40,6 -41,7 +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 +55,7 @@@
      messages: [],
      expanded: false,
      siteName: undefined,
+     admins: [],
    };
  
    constructor(props: any, context: any) {
              </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">
  
        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);
        }
      }
      );
    }
  
+   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() {
++      document.addEventListener('DOMContentLoaded', function () {
          if (!Notification) {
            toast(i18n.t('notifications_error'), 'danger');
            return;
index 47920b9b4ecfb2a9051dce01c4bdf6b76e93c14f,912d8e5896157c89ae6e9be9925b08b10b2849c0..4dbc8b23a314b3d7bdd3d3e538f659b3ecbba9c8
@@@ -34,9 -34,11 +34,11 @@@ import 
    randomStr,
    setupTribute,
    setupTippy,
+   emojiPicker,
  } from '../utils';
  import autosize from 'autosize';
  import Tribute from 'tributejs/src/Tribute.js';
+ import emojiShortName from 'emoji-short-name';
  import Selectr from 'mobius1-selectr';
  import { i18n } from '../i18next';
  
@@@ -92,6 -94,8 +94,8 @@@ export class PostForm extends Component
      this.fetchPageTitle = debounce(this.fetchPageTitle).bind(this);
  
      this.tribute = setupTribute();
+     this.setupEmojiPicker();
      this.state = this.emptyState;
  
      if (this.props.post) {
                <form>
                  <label
                    htmlFor="file-upload"
--                  className={`${UserService.Instance.user &&
-                     'pointer'} d-inline-block float-right text-muted h6 font-weight-bold`}
 -                    'pointer'} d-inline-block float-right text-muted font-weight-bold`}
++                  className={`${
++                    UserService.Instance.user && 'pointer'
++                  } d-inline-block float-right text-muted font-weight-bold`}
                    data-tippy-content={i18n.t('upload_image')}
                  >
                    <svg class="icon icon-inline">
                )}
                {this.state.postForm.body && (
                  <button
--                  className={`mt-1 mr-2 btn btn-sm btn-secondary ${this.state
--                    .previewMode && 'active'}`}
++                  className={`mt-1 mr-2 btn btn-sm btn-secondary ${
++                    this.state.previewMode && 'active'
++                  }`}
                    onClick={linkEvent(this, this.handlePreviewToggle)}
                  >
                    {i18n.t('preview')}
                <a
                  href={markdownHelpUrl}
                  target="_blank"
-                 class="d-inline-block float-right text-muted h6 font-weight-bold"
+                 class="d-inline-block float-right text-muted font-weight-bold"
                  title={i18n.t('formatting_help')}
                >
                  <svg class="icon icon-inline">
                    <use xlinkHref="#icon-help-circle"></use>
                  </svg>
                </a>
+               <span
+                 onClick={linkEvent(this, this.handleEmojiPickerClick)}
+                 class="pointer unselectable d-inline-block mr-3 float-right text-muted font-weight-bold"
+                 data-tippy-content={i18n.t('emoji_picker')}
+               >
+                 <svg class="icon icon-inline">
+                   <use xlinkHref="#icon-smile"></use>
+                 </svg>
+               </span>
              </div>
            </div>
            {!this.props.post && (
      );
    }
  
+   setupEmojiPicker() {
+     emojiPicker.on('emoji', twemojiHtmlStr => {
+       if (this.state.postForm.body == null) {
+         this.state.postForm.body = '';
+       }
+       var el = document.createElement('div');
+       el.innerHTML = twemojiHtmlStr;
+       let nativeUnicode = (el.childNodes[0] as HTMLElement).getAttribute('alt');
+       let shortName = `:${emojiShortName[nativeUnicode]}:`;
+       this.state.postForm.body += shortName;
+       this.setState(this.state);
+     });
+   }
    handlePostSubmit(i: PostForm, event: any) {
      event.preventDefault();
      if (i.props.post) {
        });
    }
  
+   handleEmojiPickerClick(_i: PostForm, event: any) {
+     emojiPicker.togglePicker(event.target);
+   }
    parseMessage(msg: WebSocketJsonResponse) {
      let res = wsJsonToRes(msg);
      if (msg.error) {
index 49970dfc195c34a86018dca3ff5d6311598d9a70,d0efa0437225e298c1b382071ea7521a85d7bc6a..497492010690049c18bc6b37156d558c32584821
@@@ -19,18 -19,18 +19,19 @@@ import 
  import { MomentTime } from './moment-time';
  import { PostForm } from './post-form';
  import { IFramelyCard } from './iframely-card';
+ import { UserListing } from './user-listing';
  import {
+   md,
    mdToHtml,
    canMod,
    isMod,
    isImage,
    isVideo,
    getUnixTime,
-   pictshareAvatarThumbnail,
-   showAvatars,
    pictshareImage,
    setupTippy,
 +  hostname,
+   previewLines,
  } from '../utils';
  import { i18n } from '../i18next';
  
@@@ -150,9 -150,9 +151,9 @@@ export class PostListing extends Compon
      let post = this.props.post;
      return (
        <img
 -        className={`img-fluid thumbnail rounded ${(post.nsfw ||
 -          post.community_nsfw) &&
 -          'img-blur'}`}
 +        className={`img-fluid thumbnail rounded ${
 +          (post.nsfw || post.community_nsfw) && 'img-blur'
 +        }`}
          src={src}
        />
      );
                      </Link>
                    )}
                  </h5>
 -                {post.url &&
 -                  !(new URL(post.url).hostname == window.location.hostname) && (
 -                    <small class="d-inline-block">
 -                      <a
 -                        className="ml-2 text-muted font-italic"
 -                        href={post.url}
 -                        target="_blank"
 -                        title={post.url}
 -                      >
 -                        {new URL(post.url).hostname}
 -                        <svg class="ml-1 icon icon-inline">
 -                          <use xlinkHref="#icon-external-link"></use>
 -                        </svg>
 -                      </a>
 -                    </small>
 -                  )}
 +                {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}
 +                    >
 +                      {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 ? (
                <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')}
                    {this.props.showCommunity && (
                      <span>
                        <span> {i18n.t('to')} </span>
 -                      <Link to={`/c/${post.community_name}`}>
 -                        {post.community_name}
 -                      </Link>
 +                      {post.local ? (
 +                        <Link to={`/c/${post.community_name}`}>
 +                          {post.community_name}
 +                        </Link>
 +                      ) : (
 +                        <a href={post.community_actor_id} target="_blank">
 +                          {hostname(post.ap_id)}/{post.community_name}
 +                        </a>
 +                      )}
                      </span>
                    )}
                  </li>
                      <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>
+                   </>
+                 )}
                  <li className="list-inline-item">•</li>
                  {this.state.upvotes !== this.state.score && (
                    <>
                              }
                            >
                              <svg
 -                              class={`icon icon-inline ${post.saved &&
 -                                'text-warning'}`}
 +                              class={`icon icon-inline ${
 +                                post.saved && 'text-warning'
 +                              }`}
                              >
                                <use xlinkHref="#icon-star"></use>
                              </svg>
                              }
                            >
                              <svg
 -                              class={`icon icon-inline ${post.deleted &&
 -                                'text-danger'}`}
 +                              class={`icon icon-inline ${
 +                                post.deleted && 'text-danger'
 +                              }`}
                              >
                                <use xlinkHref="#icon-trash"></use>
                              </svg>
                                data-tippy-content={i18n.t('view_source')}
                              >
                                <svg
 -                                class={`icon icon-inline ${this.state
 -                                  .viewSource && 'text-success'}`}
 +                                class={`icon icon-inline ${
 +                                  this.state.viewSource && 'text-success'
 +                                }`}
                                >
                                  <use xlinkHref="#icon-file-text"></use>
                                </svg>
                                  }
                                >
                                  <svg
 -                                  class={`icon icon-inline ${post.locked &&
 -                                    'text-danger'}`}
 +                                  class={`icon icon-inline ${
 +                                    post.locked && 'text-danger'
 +                                  }`}
                                  >
                                    <use xlinkHref="#icon-lock"></use>
                                  </svg>
                                  }
                                >
                                  <svg
 -                                  class={`icon icon-inline ${post.stickied &&
 -                                    'text-success'}`}
 +                                  class={`icon icon-inline ${
 +                                    post.stickied && 'text-success'
 +                                  }`}
                                  >
                                    <use xlinkHref="#icon-pin"></use>
                                  </svg>
index f51ba6ff91c62b6ef24578c22ded673360b28637,de0f0e329c72bf60d24a7de43e2fcfaa89a427f1..cf9e748652e5241ddcaf9ba84f64da0d4f259ba4
@@@ -213,8 -213,8 +213,9 @@@ export class Post extends Component<any
      return (
        <div class="btn-group btn-group-toggle mb-2">
          <label
--          className={`btn btn-sm btn-secondary pointer ${this.state
--            .commentSort === CommentSortType.Hot && 'active'}`}
++          className={`btn btn-sm btn-secondary pointer ${
++            this.state.commentSort === CommentSortType.Hot && 'active'
++          }`}
          >
            {i18n.t('hot')}
            <input
            />
          </label>
          <label
--          className={`btn btn-sm btn-secondary pointer ${this.state
--            .commentSort === CommentSortType.Top && 'active'}`}
++          className={`btn btn-sm btn-secondary pointer ${
++            this.state.commentSort === CommentSortType.Top && 'active'
++          }`}
          >
            {i18n.t('top')}
            <input
            />
          </label>
          <label
--          className={`btn btn-sm btn-secondary pointer ${this.state
--            .commentSort === CommentSortType.New && 'active'}`}
++          className={`btn btn-sm btn-secondary pointer ${
++            this.state.commentSort === CommentSortType.New && 'active'
++          }`}
          >
            {i18n.t('new')}
            <input
            />
          </label>
          <label
--          className={`btn btn-sm btn-secondary pointer ${this.state
--            .commentSort === CommentSortType.Old && 'active'}`}
++          className={`btn btn-sm btn-secondary pointer ${
++            this.state.commentSort === CommentSortType.Old && 'active'
++          }`}
          >
            {i18n.t('old')}
            <input
      } else if (res.op == UserOperation.Search) {
        let data = res.data as SearchResponse;
        this.state.crossPosts = data.posts.filter(
-         p => p.id != this.state.post.id
+         p => p.id != Number(this.props.match.params.id)
        );
        this.setState(this.state);
      } else if (res.op == UserOperation.TransferSite) {
index 7e498bae3ca9048ac9f5855591556fd713f6ca13,6b607654b62b943ae4e3a9e71da7d6a2f9ee4e0d..14abacf62086ca1e0fccab3a49275eabdf11bc4b
@@@ -21,14 -21,13 +21,13 @@@ import 
    capitalizeFirstLetter,
    markdownHelpUrl,
    mdToHtml,
-   showAvatars,
-   pictshareAvatarThumbnail,
    wsJsonToRes,
    toast,
    randomStr,
    setupTribute,
    setupTippy,
  } from '../utils';
+ import { UserListing } from './user-listing';
  import Tribute from 'tributejs/src/Tribute.js';
  import autosize from 'autosize';
  import { i18n } from '../i18next';
@@@ -132,22 -131,12 +131,12 @@@ export class PrivateMessageForm extend
  
                {this.state.recipient && (
                  <div class="col-sm-10 form-control-plaintext">
-                   <Link
-                     className="text-body font-weight-bold"
-                     to={`/u/${this.state.recipient.name}`}
-                   >
-                     {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>
+                   <UserListing
+                     user={{
+                       name: this.state.recipient.name,
+                       avatar: this.state.recipient.avatar,
+                     }}
+                   />
                  </div>
                )}
              </div>
                </button>
                {this.state.privateMessageForm.content && (
                  <button
--                  className={`btn btn-secondary mr-2 ${this.state.previewMode &&
--                    'active'}`}
++                  className={`btn btn-secondary mr-2 ${
++                    this.state.previewMode && 'active'
++                  }`}
                    onClick={linkEvent(this, this.handlePreviewToggle)}
                  >
                    {i18n.t('preview')}
index ef128dd4a813329047d4580a217a1042c59412ff,337b165012000f5598dcdd6ff4d670703aec765f..3acd6e19f06c1c40d2171866ee1f63d4830e38d4
@@@ -58,6 -58,7 +58,7 @@@ export class PrivateMessage extends Com
        <div class="border-top border-light">
          <div>
            <ul class="list-inline mb-0 text-muted small">
+             {/* TODO refactor this */}
              <li className="list-inline-item">
                {this.mine ? i18n.t('to') : i18n.t('from')}
              </li>
                          }
                        >
                          <svg
--                          class={`icon icon-inline ${message.read &&
--                            'text-success'}`}
++                          class={`icon icon-inline ${
++                            message.read && 'text-success'
++                          }`}
                          >
                            <use xlinkHref="#icon-check"></use>
                          </svg>
                          }
                        >
                          <svg
--                          class={`icon icon-inline ${message.deleted &&
--                            'text-danger'}`}
++                          class={`icon icon-inline ${
++                            message.deleted && 'text-danger'
++                          }`}
                          >
                            <use xlinkHref="#icon-trash"></use>
                          </svg>
                      data-tippy-content={i18n.t('view_source')}
                    >
                      <svg
--                      class={`icon icon-inline ${this.state.viewSource &&
--                        'text-success'}`}
++                      class={`icon icon-inline ${
++                        this.state.viewSource && 'text-success'
++                      }`}
                      >
                        <use xlinkHref="#icon-file-text"></use>
                      </svg>
index 0f4a0e10d8f262cdbd900b43a96b9a4a27bdaa02,d66266f6cab0e0e5a0769d8aa381d0c3ba6cb618..4b317aaa29880c957072d38945e019279658eb5c
@@@ -15,6 -15,7 +15,7 @@@ import 
    showAvatars,
  } from '../utils';
  import { CommunityForm } from './community-form';
+ import { UserListing } from './user-listing';
  import { i18n } from '../i18next';
  
  interface SidebarProps {
@@@ -110,8 -111,8 +111,9 @@@ export class Sidebar extends Component<
                          }
                        >
                          <svg
--                          class={`icon icon-inline ${community.deleted &&
--                            'text-danger'}`}
++                          class={`icon icon-inline ${
++                            community.deleted && 'text-danger'
++                          }`}
                          >
                            <use xlinkHref="#icon-trash"></use>
                          </svg>
                <li class="list-inline-item">{i18n.t('mods')}: </li>
                {this.props.moderators.map(mod => (
                  <li class="list-inline-item">
-                   <Link
-                     class="text-body font-weight-bold"
-                     to={`/u/${mod.user_name}`}
-                   >
-                     {mod.avatar && showAvatars() && (
-                       <img
-                         height="32"
-                         width="32"
-                         src={pictshareAvatarThumbnail(mod.avatar)}
-                         class="rounded-circle mr-1"
-                       />
-                     )}
-                     <span>{mod.user_name}</span>
-                   </Link>
+                   <UserListing
+                     user={{
+                       name: mod.user_name,
+                       avatar: mod.avatar,
+                     }}
+                   />
                  </li>
                ))}
              </ul>
              <Link
--              class={`btn btn-sm btn-secondary btn-block mb-3 ${(community.deleted ||
--                community.removed) &&
--                'no-click'}`}
++              class={`btn btn-sm btn-secondary btn-block mb-3 ${
++                (community.deleted || community.removed) && 'no-click'
++              }`}
                to={`/create_post?community=${community.name}`}
              >
                {i18n.t('create_a_post')}
diff --combined ui/src/interfaces.ts
index b9842516167733e345d4cb4ec11364b022c5ff19,b77ccac65b657f120df8ae416f7ac43fb988e268..12430836c45d1b0ad9f95f550bdc4a6d1ea2e210
@@@ -43,6 -43,8 +43,8 @@@ export enum UserOperation 
    GetPrivateMessages,
    UserJoin,
    GetComments,
+   GetSiteConfig,
+   SaveSiteConfig,
  }
  
  export enum CommentSortType {
@@@ -98,13 -100,10 +100,13 @@@ export interface User 
  
  export interface UserView {
    id: number;
 +  actor_id: string;
    name: string;
    avatar?: string;
    email?: string;
    matrix_user_id?: string;
 +  bio?: string;
 +  local: boolean;
    published: string;
    number_of_posts: number;
    post_score: number;
  export interface CommunityUser {
    id: number;
    user_id: number;
 +  user_actor_id: string;
 +  user_local: boolean;
    user_name: string;
    avatar?: string;
    community_id: number;
 +  community_actor_id: string;
 +  community_local: boolean;
    community_name: string;
    published: string;
  }
  
  export interface Community {
    id: number;
 +  actor_id: string;
 +  local: boolean;
    name: string;
    title: string;
    description?: string;
    nsfw: boolean;
    published: string;
    updated?: string;
 +  creator_actor_id: string;
 +  creator_local: boolean;
 +  last_refreshed_at: string;
    creator_name: string;
    creator_avatar?: string;
    category_name: string;
@@@ -171,19 -161,13 +173,19 @@@ export interface Post 
    embed_description?: string;
    embed_html?: string;
    thumbnail_url?: string;
 +  ap_id: string;
 +  local: boolean;
    nsfw: boolean;
    banned: boolean;
    banned_from_community: boolean;
    published: string;
    updated?: string;
 +  creator_actor_id: string;
 +  creator_local: boolean;
    creator_name: string;
    creator_avatar?: string;
 +  community_actor_id: string;
 +  community_local: boolean;
    community_name: string;
    community_removed: boolean;
    community_deleted: boolean;
  
  export interface Comment {
    id: number;
 +  ap_id: string;
 +  local: boolean;
    creator_id: number;
    post_id: number;
    parent_id?: number;
    published: string;
    updated?: string;
    community_id: number;
 +  community_actor_id: string;
 +  community_local: boolean;
    community_name: string;
    banned: boolean;
    banned_from_community: boolean;
 +  creator_actor_id: string;
 +  creator_local: boolean;
    creator_name: string;
    creator_avatar?: string;
    score: number;
    saved?: boolean;
    user_mention_id?: number; // For mention type
    recipient_id?: number;
 +  recipient_actor_id?: string;
 +  recipient_local?: boolean;
    depth?: number;
  }
  
@@@ -724,6 -700,19 +726,19 @@@ export interface SiteForm 
    auth?: string;
  }
  
+ export interface GetSiteConfig {
+   auth?: string;
+ }
+ export interface GetSiteConfigResponse {
+   config_hjson: string;
+ }
+ export interface SiteConfigForm {
+   config_hjson: string;
+   auth?: string;
+ }
  export interface GetSiteResponse {
    site: Site;
    admins: Array<UserView>;
@@@ -871,7 -860,8 +886,8 @@@ export type MessageType 
    | PasswordChangeForm
    | PrivateMessageForm
    | EditPrivateMessageForm
-   | GetPrivateMessagesForm;
+   | GetPrivateMessagesForm
+   | SiteConfigForm;
  
  type ResponseType =
    | SiteResponse
    | BanUserResponse
    | AddAdminResponse
    | PrivateMessageResponse
-   | PrivateMessagesResponse;
+   | PrivateMessagesResponse
+   | GetSiteConfigResponse;
  
  export interface WebSocketResponse {
    op: UserOperation;
diff --combined ui/src/utils.ts
index e2310960d1ac489dedaae11d438c0cc3b9de4ec1,21a7fef83e83987fb5101d8b1531a5e8ff9bc5a9..480b41c7c313a6ffcd6403456583b4ee14fe2ee9
@@@ -43,8 -43,9 +43,9 @@@ import twemoji from 'twemoji'
  import emojiShortName from 'emoji-short-name';
  import Toastify from 'toastify-js';
  import tippy from 'tippy.js';
+ import EmojiButton from '@joeattardi/emoji-button';
  
- export const repoUrl = 'https://github.com/dessalines/lemmy';
+ 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`;
@@@ -88,6 -89,14 +89,14 @@@ export const themes = 
    'i386',
  ];
  
+ export const emojiPicker = new EmojiButton({
+   // Use the emojiShortName from native
+   style: 'twemoji',
+   theme: 'dark',
+   position: 'auto-start',
+   // TODO i18n
+ });
  export function randomStr() {
    return Math.random()
      .toString(36)
@@@ -109,11 -118,11 +118,11 @@@ export const md = new markdown_it(
    typographer: true,
  })
    .use(markdown_it_container, 'spoiler', {
 -    validate: function(params: any) {
 +    validate: function (params: any) {
        return params.trim().match(/^spoiler\s+(.*)$/);
      },
  
 -    render: function(tokens: any, idx: any) {
 +    render: function (tokens: any, idx: any) {
        var m = tokens[idx].info.trim().match(/^spoiler\s+(.*)$/);
  
        if (tokens[idx].nesting === 1) {
      defs: objectFlip(emojiShortName),
    });
  
 -md.renderer.rules.emoji = function(token, idx) {
 +md.renderer.rules.emoji = function (token, idx) {
    return twemoji.parse(token[idx].content);
  };
  
@@@ -275,7 -284,7 +284,7 @@@ export function debounce
    let timeout: any;
  
    // Calling debounce returns a new anonymous function
 -  return function() {
 +  return function () {
      // reference the context and args for the setTimeout function
      var context = this,
        args = arguments;
      clearTimeout(timeout);
  
      // Set the new timeout
 -    timeout = setTimeout(function() {
 +    timeout = setTimeout(function () {
        // Inside the timeout function, clear the timeout variable
        // which will let the next execution run when in 'immediate' mode
        timeout = null;
@@@ -473,8 -482,9 +482,9 @@@ export function setupTribute(): Tribut
        {
          trigger: ':',
          menuItemTemplate: (item: any) => {
-           let emoji = `:${item.original.key}:`;
-           return `${item.original.val} ${emoji}`;
+           let shortName = `:${item.original.key}:`;
+           let twemojiIcon = twemoji.parse(item.original.val);
+           return `${twemojiIcon} ${shortName}`;
          },
          selectTemplate: (item: any) => {
            return `:${item.original.key}:`;
@@@ -824,6 -834,10 +834,14 @@@ function randomHsl() 
    return `hsla(${Math.random() * 360}, 100%, 50%, 1)`;
  }
  
+ export function previewLines(text: string, lines: number = 3): string {
+   // Use lines * 2 because markdown requires 2 lines
+   return text
+     .split('\n')
+     .slice(0, lines * 2)
+     .join('\n');
+ }
++
 +export function hostname(url: string): string {
 +  return new URL(url).hostname;
 +}
diff --combined ui/yarn.lock
index 8d75052b905f12eb017da5a55457a6646e6f6657,35ad32a0ac2ea8390e621ed82083676bca5d49c0..4e94559eed7716121ba0f8da0b8e59849f4dadbd
      lodash "^4.17.13"
      to-fast-properties "^2.0.0"
  
- "@popperjs/core@^2.1.1":
-   version "2.1.1"
-   resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.1.1.tgz#12c572ab88ef7345b43f21883fca26631c223085"
-   integrity sha512-sLqWxCzC5/QHLhziXSCAksBxHfOnQlhPRVgPK0egEw+ktWvG75T2k+aYWVjVh9+WKeT3tlG3ZNbZQvZLmfuOIw==
+ "@fortawesome/fontawesome-common-types@^0.2.28":
+   version "0.2.28"
+   resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-0.2.28.tgz#1091bdfe63b3f139441e9cba27aa022bff97d8b2"
+   integrity sha512-gtis2/5yLdfI6n0ia0jH7NJs5i/Z/8M/ZbQL6jXQhCthEOe5Cr5NcQPhgTvFxNOtURE03/ZqUcEskdn2M+QaBg==
+ "@fortawesome/fontawesome-svg-core@^1.2.22":
+   version "1.2.28"
+   resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-1.2.28.tgz#e5b8c8814ef375f01f5d7c132d3c3a2f83a3abf9"
+   integrity sha512-4LeaNHWvrneoU0i8b5RTOJHKx7E+y7jYejplR7uSVB34+mp3Veg7cbKk7NBCLiI4TyoWS1wh9ZdoyLJR8wSAdg==
+   dependencies:
+     "@fortawesome/fontawesome-common-types" "^0.2.28"
+ "@fortawesome/free-regular-svg-icons@^5.10.2":
+   version "5.13.0"
+   resolved "https://registry.yarnpkg.com/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-5.13.0.tgz#925a13d8bdda0678f71551828cac80ab47b8150c"
+   integrity sha512-70FAyiS5j+ANYD4dh9NGowTorNDnyvQHHpCM7FpnF7GxtDjBUCKdrFqCPzesEIpNDFNd+La3vex+jDk4nnUfpA==
+   dependencies:
+     "@fortawesome/fontawesome-common-types" "^0.2.28"
+ "@fortawesome/free-solid-svg-icons@^5.10.2":
+   version "5.13.0"
+   resolved "https://registry.yarnpkg.com/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-5.13.0.tgz#44d9118668ad96b4fd5c9434a43efc5903525739"
+   integrity sha512-IHUgDJdomv6YtG4p3zl1B5wWf9ffinHIvebqQOmV3U+3SLw4fC+LUCCgwfETkbTtjy5/Qws2VoVf6z/ETQpFpg==
+   dependencies:
+     "@fortawesome/fontawesome-common-types" "^0.2.28"
+ "@joeattardi/emoji-button@^2.12.1":
+   version "2.12.1"
+   resolved "https://registry.yarnpkg.com/@joeattardi/emoji-button/-/emoji-button-2.12.1.tgz#190df7c00721e04742ed6f8852db828798a4cf98"
+   integrity sha512-rUuCXIcv4mRFK2IUKarYJN6J667wtH234smb1aQILzRf3/ycOoa6yUwnnvjxZeXMsPhuTnz15ndMOP2DhO5nNw==
+   dependencies:
+     "@fortawesome/fontawesome-svg-core" "^1.2.22"
+     "@fortawesome/free-regular-svg-icons" "^5.10.2"
+     "@fortawesome/free-solid-svg-icons" "^5.10.2"
+     "@popperjs/core" "^2.0.0"
+     focus-trap "^5.1.0"
+     tiny-emitter "^2.1.0"
+     tslib "^1.10.0"
+     twemoji "^12.1.5"
+ "@popperjs/core@^2.0.0":
+   version "2.2.3"
+   resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.2.3.tgz#0ae22b5650ab0b8fe508047245b66e71fc59e983"
+   integrity sha512-68EQPzEZRrpFavFX40V2+80eqzQIhgza2AGTXW+i8laxSA4It+Y13rmZInrAYoIujp8YO7YJPbvgOesDZcIulQ==
+ "@popperjs/core@^2.2.0":
+   version "2.3.2"
+   resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.3.2.tgz#1e56eb99bccddbda6a3e29aa4f3660f5b23edc43"
+   integrity sha512-18Tz3QghwsuHUC4gTNoxcEw1ClsrJ+lRypYpm+aucQonYNnmskQYvDZZKLHMPvQ7OwthWJl715UEX+Tg2fJkJw==
  
  "@samverschueren/stream-to-observable@^0.3.0":
    version "0.3.0"
    dependencies:
      "@types/sizzle" "*"
  
- "@types/js-cookie@^2.2.5":
-   version "2.2.5"
-   resolved "https://registry.yarnpkg.com/@types/js-cookie/-/js-cookie-2.2.5.tgz#38dfaacae8623b37cc0b0d27398e574e3fc28b1e"
-   integrity sha512-cpmwBRcHJmmZx0OGU7aPVwGWGbs4iKwVYchk9iuMtxNCA2zorwdaTz4GkLgs2WGxiRZRFKnV1k6tRUHX7tBMxg==
+ "@types/js-cookie@^2.2.6":
+   version "2.2.6"
+   resolved "https://registry.yarnpkg.com/@types/js-cookie/-/js-cookie-2.2.6.tgz#f1a1cb35aff47bc5cfb05cb0c441ca91e914c26f"
+   integrity sha512-+oY0FDTO2GYKEV0YPvSshGq9t7YozVkgvXLty7zogQNuCxBhT9/3INX9Q7H1aRZ4SUDRXAKlJuA4EA5nTt7SNw==
  
  "@types/json-schema@^7.0.3":
    version "7.0.4"
    dependencies:
      "@types/markdown-it" "*"
  
 -"@types/markdown-it@*":
 +"@types/markdown-it@*", "@types/markdown-it@^0.0.9":
    version "0.0.9"
    resolved "https://registry.yarnpkg.com/@types/markdown-it/-/markdown-it-0.0.9.tgz#a5d552f95216c478e0a27a5acc1b28dcffd989ce"
    integrity sha512-IFSepyZXbF4dgSvsk8EsgaQ/8Msv1I5eTL0BZ0X3iGO9jw6tCVtPG8HchIPm3wrkmGdqZOD42kE0zplVi1gYDA==
    dependencies:
      "@types/linkify-it" "*"
  
- "@types/node@^13.9.2":
-   version "13.9.2"
-   resolved "https://registry.yarnpkg.com/@types/node/-/node-13.9.2.tgz#ace1880c03594cc3e80206d96847157d8e7fa349"
-   integrity sha512-bnoqK579sAYrQbp73wwglccjJ4sfRdKU7WNEZ5FW4K2U6Kc0/eZ5kvXG0JKsEKFB50zrFmfFt52/cvBbZa7eXg==
 -"@types/markdown-it@^10.0.0":
 -  version "10.0.0"
 -  resolved "https://registry.yarnpkg.com/@types/markdown-it/-/markdown-it-10.0.0.tgz#a2b5f9fb444bb27c1e0c4a0116fea09b3c6ebc1e"
 -  integrity sha512-7UPBg1W0rfsqQ1JwNFfhxibKO0t7Q0scNt96XcFIFLGE/vhZamzZayaFS2LKha/26Pz7b/2GgiaxQZ1GUwW0dA==
 -  dependencies:
 -    "@types/linkify-it" "*"
 -    "@types/mdurl" "*"
 -
 -"@types/mdurl@*":
 -  version "1.0.2"
 -  resolved "https://registry.yarnpkg.com/@types/mdurl/-/mdurl-1.0.2.tgz#e2ce9d83a613bacf284c7be7d491945e39e1f8e9"
 -  integrity sha512-eC4U9MlIcu2q0KQmXszyn5Akca/0jrQmwDRgpAMJai7qBWq4amIQhZyNau4VYGtCeALvW1/NtjzJJ567aZxfKA==
 -
+ "@types/node@^13.11.1":
+   version "13.11.1"
+   resolved "https://registry.yarnpkg.com/@types/node/-/node-13.11.1.tgz#49a2a83df9d26daacead30d0ccc8762b128d53c7"
+   integrity sha512-eWQGP3qtxwL8FGneRrC5DwrJLGN4/dH1clNTuLfN81HCrxVtxRjygDTUoZJ5ASlDEeo0ppYFQjQIlXhtXpOn6g==
  
  "@types/normalize-package-data@^2.4.0":
    version "2.4.0"
@@@ -749,6 -807,14 +794,14 @@@ chalk@^3.0.0
      ansi-styles "^4.1.0"
      supports-color "^7.1.0"
  
+ chalk@^4.0.0:
+   version "4.0.0"
+   resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.0.0.tgz#6e98081ed2d17faab615eb52ac66ec1fe6209e72"
+   integrity sha512-N9oWFcegS0sFr9oh1oz2d7Npos6vNoWW9HvtCg5N1KRFpUhaAhvTv5Y58g880fZaEYSNm3qDz8SU1UrGvp+n7A==
+   dependencies:
+     ansi-styles "^4.1.0"
+     supports-color "^7.1.0"
  chardet@^0.4.0:
    version "0.4.2"
    resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.4.2.tgz#b5473b33dc97c424e5d98dc87d55d4d8a29c8bf2"
@@@ -790,10 -856,10 +843,10 @@@ class-utils@^0.3.5
      isobject "^3.0.0"
      static-extend "^0.1.1"
  
- classcat@^1.1.3:
-   version "1.1.3"
-   resolved "https://registry.yarnpkg.com/classcat/-/classcat-1.1.3.tgz#ec748eecd962ec195a5d8f73f01d67c3d9040912"
-   integrity sha512-nuf6HJ5RlEgUUPqN/giIy1wsfA0LJwCHpo/aMGMwEIAxYypbLW/ZdPH4SNrF+OwdrkL3wxJmAs4GPyoE3ZkQ4w==
+ classcat@^4.0.2:
+   version "4.0.2"
+   resolved "https://registry.yarnpkg.com/classcat/-/classcat-4.0.2.tgz#bd5d51b656e01e9cdd21c1aae3d29ed035a52126"
+   integrity sha512-RlMPOPp8VDu3CJOUVorPumhz/CI+t9ft6f0uexxxCguk28/M+Kf27eQXjNWeDTisEQWei/30oDfITOQqr1TNpQ==
  
  clean-css@^4.1.9:
    version "4.2.3"
@@@ -890,10 -956,10 +943,10 @@@ commander@^4.0.1
    resolved "https://registry.yarnpkg.com/commander/-/commander-4.1.0.tgz#545983a0603fe425bc672d66c9e3c89c42121a83"
    integrity sha512-NIQrwvv9V39FHgGFm36+U9SMQzbiHvU79k+iADraJTpmrFFfx7Ds0IvDoAdZsDrknlkRk14OYoWXb57uTh7/sw==
  
- compare-versions@^3.5.1:
-   version "3.5.1"
-   resolved "https://registry.yarnpkg.com/compare-versions/-/compare-versions-3.5.1.tgz#26e1f5cf0d48a77eced5046b9f67b6b61075a393"
-   integrity sha512-9fGPIB7C6AyM18CJJBHt5EnCZDG3oiTJYy0NjfIAGjKpzv0tkxWko7TNQHF5ymqm7IH03tqmeuBxtvD+Izh6mg==
+ compare-versions@^3.6.0:
+   version "3.6.0"
+   resolved "https://registry.yarnpkg.com/compare-versions/-/compare-versions-3.6.0.tgz#1a5689913685e5a87637b8d3ffca75514ec41d62"
+   integrity sha512-W6Af2Iw1z4CB7q4uU4hv646dW9GQuBM+YpC0UvUCWSD8w90SJjp+ujJuXaEMtAXBtSqGfMPuFOVn4/+FlaqfBA==
  
  component-emitter@^1.2.1:
    version "1.3.0"
@@@ -1283,10 -1349,10 +1336,10 @@@ eslint-plugin-inferno@^7.14.3
      object.values "^1.1.0"
      resolve "^1.12.0"
  
- eslint-plugin-jane@^7.2.0:
-   version "7.2.0"
-   resolved "https://registry.yarnpkg.com/eslint-plugin-jane/-/eslint-plugin-jane-7.2.0.tgz#a2454a6700c644e6c86821ca294adf303e75eddc"
-   integrity sha512-/BPZrfxWX9T45gJSf4/2GHfBYgsBYTW7StAQfxL8PxWABZIQKWPWy/5ZokX7UaJlgKHAoC42rJHCQLK5hmfJNA==
+ eslint-plugin-jane@^7.2.1:
+   version "7.2.1"
+   resolved "https://registry.yarnpkg.com/eslint-plugin-jane/-/eslint-plugin-jane-7.2.1.tgz#5ffba9ce75e0a5e5dbe3918fc0c5332d2cd89c13"
+   integrity sha512-hUmhEkHTDq6lQ4oLWZV5cLut9L67fcTiy0USbTsEOx658i9Jdikedt8NJhtamRqO5OUHBGSPU0JkOqBtVNUD+A==
    dependencies:
      "@typescript-eslint/eslint-plugin" "2.24.0"
      "@typescript-eslint/parser" "2.24.0"
      eslint-plugin-prettier "3.1.2"
      eslint-plugin-promise "4.2.1"
      eslint-plugin-react "7.19.0"
-     eslint-plugin-react-hooks "2.5.0"
+     eslint-plugin-react-hooks "2.5.1"
      eslint-plugin-unicorn "17.2.0"
  
  eslint-plugin-jest@23.8.2:
@@@ -1349,10 -1415,10 +1402,10 @@@ eslint-plugin-promise@4.2.1
    resolved "https://registry.yarnpkg.com/eslint-plugin-promise/-/eslint-plugin-promise-4.2.1.tgz#845fd8b2260ad8f82564c1222fce44ad71d9418a"
    integrity sha512-VoM09vT7bfA7D+upt+FjeBO5eHIJQBUWki1aPvB+vbNiHS3+oGIJGIeyBtKQTME6UPXXy3vV07OL1tHd3ANuDw==
  
- eslint-plugin-react-hooks@2.5.0:
-   version "2.5.0"
-   resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-2.5.0.tgz#c50ab7ca5945ce6d1cf8248d9e185c80b54171b6"
-   integrity sha512-bzvdX47Jx847bgAYf0FPX3u1oxU+mKU8tqrpj4UX9A96SbAmj/HVEefEy6rJUog5u8QIlOPTKZcBpGn5kkKfAQ==
+ eslint-plugin-react-hooks@2.5.1:
+   version "2.5.1"
+   resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-2.5.1.tgz#4ef5930592588ce171abeb26f400c7fbcbc23cd0"
+   integrity sha512-Y2c4b55R+6ZzwtTppKwSmK/Kar8AdLiC2f9NADCuxbcTgPPg41Gyqa6b9GppgXSvCtkRw43ZE86CT5sejKC6/g==
  
  eslint-plugin-react@7.19.0:
    version "7.19.0"
@@@ -1829,6 -1895,14 +1882,14 @@@ fliplog@^0.3.13
    dependencies:
      chain-able "^1.0.1"
  
+ focus-trap@^5.1.0:
+   version "5.1.0"
+   resolved "https://registry.yarnpkg.com/focus-trap/-/focus-trap-5.1.0.tgz#64a0bfabd95c382103397dbc96bfef3a3cf8e5ad"
+   integrity sha512-CkB/nrO55069QAUjWFBpX6oc+9V90Qhgpe6fBWApzruMq5gnlh90Oo7iSSDK7pKiV5ugG6OY2AXM5mxcmL3lwQ==
+   dependencies:
+     tabbable "^4.0.0"
+     xtend "^4.0.1"
  for-in@^1.0.1, for-in@^1.0.2:
    version "1.0.2"
    resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80"
@@@ -2182,14 -2256,14 +2243,14 @@@ human-signals@^1.1.1
    resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-1.1.1.tgz#c5b1cd14f50aeae09ab6c59fe63ba3395fe4dfa3"
    integrity sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==
  
- husky@^4.2.3:
-   version "4.2.3"
-   resolved "https://registry.yarnpkg.com/husky/-/husky-4.2.3.tgz#3b18d2ee5febe99e27f2983500202daffbc3151e"
-   integrity sha512-VxTsSTRwYveKXN4SaH1/FefRJYCtx+wx04sSVcOpD7N2zjoHxa+cEJ07Qg5NmV3HAK+IRKOyNVpi2YBIVccIfQ==
+ husky@^4.2.5:
+   version "4.2.5"
+   resolved "https://registry.yarnpkg.com/husky/-/husky-4.2.5.tgz#2b4f7622673a71579f901d9885ed448394b5fa36"
+   integrity sha512-SYZ95AjKcX7goYVZtVZF2i6XiZcHknw50iXvY7b0MiGoj5RwdgRQNEHdb+gPDPCXKlzwrybjFjkL6FOj8uRhZQ==
    dependencies:
-     chalk "^3.0.0"
+     chalk "^4.0.0"
      ci-info "^2.0.0"
-     compare-versions "^3.5.1"
+     compare-versions "^3.6.0"
      cosmiconfig "^6.0.0"
      find-versions "^3.2.0"
      opencollective-postinstall "^2.0.2"
      slash "^3.0.0"
      which-pm-runs "^1.0.0"
  
- i18next@^19.3.3:
-   version "19.3.3"
-   resolved "https://registry.yarnpkg.com/i18next/-/i18next-19.3.3.tgz#04bd79b315e5fe2c87ab8f411e5d55eda0a17bd8"
-   integrity sha512-CnuPqep5/JsltkGvQqzYN4d79eCe0TreCBRF3a8qHHi8x4SON1qqZ/pvR2X7BfNkNqpA5HXIqw0E731H+VsgSg==
+ i18next@^19.4.1:
+   version "19.4.1"
+   resolved "https://registry.yarnpkg.com/i18next/-/i18next-19.4.1.tgz#4929d15d3d01e4712350a368d005cefa50ff5455"
+   integrity sha512-dC3ue15jkLebN2je4xEjfjVYd/fSAo+UVK9f+JxvceCJRowkI+S0lGohgKejqU+FYLfvw9IAPylIIEWwR8Djrg==
    dependencies:
      "@babel/runtime" "^7.3.1"
  
@@@ -2814,10 -2888,10 +2875,10 @@@ linkify-it@^2.0.0
    dependencies:
      uc.micro "^1.0.1"
  
- lint-staged@^10.0.8:
-   version "10.0.8"
-   resolved "https://registry.yarnpkg.com/lint-staged/-/lint-staged-10.0.8.tgz#0f7849cdc336061f25f5d4fcbcfa385701ff4739"
-   integrity sha512-Oa9eS4DJqvQMVdywXfEor6F4vP+21fPHF8LUXgBbVWUSWBddjqsvO6Bv1LwMChmgQZZqwUvgJSHlu8HFHAPZmA==
+ lint-staged@^10.1.3:
+   version "10.1.3"
+   resolved "https://registry.yarnpkg.com/lint-staged/-/lint-staged-10.1.3.tgz#da27713d3ac519da305381b4de87d5f866b1d2f1"
+   integrity sha512-o2OkLxgVns5RwSC5QF7waeAjJA5nz5gnUfqL311LkZcFipKV7TztrSlhNUK5nQX9H0E5NELAdduMQ+M/JPT7RQ==
    dependencies:
      chalk "^3.0.0"
      commander "^4.0.1"
@@@ -3612,10 -3686,10 +3673,10 @@@ prettier-linter-helpers@^1.0.0
    dependencies:
      fast-diff "^1.1.2"
  
- prettier@^1.18.2:
-   version "1.19.1"
-   resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.19.1.tgz#f7d7f5ff8a9cd872a7be4ca142095956a60797cb"
-   integrity sha512-s7PoyDv/II1ObgQunCbB9PdLmUcBZcnWOcxDh7O0N/UwDEsHyqkW+Qh28jW+mVuCdx7gLB0BotYI1Y6uI9iyew==
+ prettier@^2.0.4:
+   version "2.0.4"
+   resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.0.4.tgz#2d1bae173e355996ee355ec9830a7a1ee05457ef"
+   integrity sha512-SVJIQ51spzFDvh4fIbCLvciiDMCrRhlN3mbZvv/+ycjvmF5E73bKdGfU8QDLNmjYJf+lsGnDBC4UUnvTe5OO0w==
  
  pretty-time@^0.2.0:
    version "0.2.0"
@@@ -4001,13 -4075,20 +4062,20 @@@ rx-lite@*, rx-lite@^4.0.8
    resolved "https://registry.yarnpkg.com/rx-lite/-/rx-lite-4.0.8.tgz#0b1e11af8bc44836f04a6407e92da42467b79444"
    integrity sha1-Cx4Rr4vESDbwSmQH6S2kJGe3lEQ=
  
- rxjs@^6.3.3, rxjs@^6.4.0, rxjs@^6.5.3:
+ rxjs@^6.3.3, rxjs@^6.5.3:
    version "6.5.4"
    resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.5.4.tgz#e0777fe0d184cec7872df147f303572d414e211c"
    integrity sha512-naMQXcgEo3csAEGvw/NydRA0fuS2nDZJiw1YUWFKU7aPPAPGZEsD4Iimit96qwCieH6y614MCLYwdkrWx7z/7Q==
    dependencies:
      tslib "^1.9.0"
  
+ rxjs@^6.5.5:
+   version "6.5.5"
+   resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.5.5.tgz#c5c884e3094c8cfee31bf27eb87e54ccfc87f9ec"
+   integrity sha512-WfQI+1gohdf0Dai/Bbmk5L5ItH5tYqm3ki2c5GdWhKjalzjg93N3avFjVStyZZz+A2Em+ZxKH5bNghw9UeylGQ==
+   dependencies:
+     tslib "^1.9.0"
  safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1:
    version "5.1.2"
    resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
@@@ -4197,10 -4278,10 +4265,10 @@@ snapdragon@^0.8.1
      source-map-resolve "^0.5.0"
      use "^3.1.0"
  
- sortpack@^2.1.2:
-   version "2.1.2"
-   resolved "https://registry.yarnpkg.com/sortpack/-/sortpack-2.1.2.tgz#25bf86f2923c81f43a00a2166ff4d271fafeed11"
-   integrity sha512-43fSND1vmAdyfgC38aOkVxZBV331f4blF8acjwQmx7Gba4nuL2ene/Cq5eixNmDhKA/qQHnvSeAl+jEWb31rfg==
+ sortpack@^2.1.4:
+   version "2.1.4"
+   resolved "https://registry.yarnpkg.com/sortpack/-/sortpack-2.1.4.tgz#a2e251c5868455135cc41d3c98a53756a6de5282"
+   integrity sha512-RGD0l9kGmuPelXMT8WMMiSv1MkUkaqElB39nMkboIaqVkYns1aaNx263B2EE5QzF1YVUOrBlXnQpd7RX68SSow==
  
  source-map-resolve@^0.5.0:
    version "0.5.3"
@@@ -4473,6 -4554,11 +4541,11 @@@ symbol-observable@^1.1.0
    resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804"
    integrity sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==
  
+ tabbable@^4.0.0:
+   version "4.0.0"
+   resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-4.0.0.tgz#5bff1d1135df1482cf0f0206434f15eadbeb9261"
+   integrity sha512-H1XoH1URcBOa/rZZWxLxHCtOdVUEev+9vo5YdYhC9tCY4wnybX+VQrCYuy9ubkg69fCBxCONJOSLGfw0DWMffQ==
  table@^5.2.3:
    version "5.4.6"
    resolved "https://registry.yarnpkg.com/table/-/table-5.4.6.tgz#1292d19500ce3f86053b05f0e8e7e4a3bb21079e"
      slice-ansi "^2.1.0"
      string-width "^3.0.0"
  
- terser@^4.6.7:
-   version "4.6.7"
-   resolved "https://registry.yarnpkg.com/terser/-/terser-4.6.7.tgz#478d7f9394ec1907f0e488c5f6a6a9a2bad55e72"
-   integrity sha512-fmr7M1f7DBly5cX2+rFDvmGBAaaZyPrHYK4mMdHEDAdNTqXSZgSOfqsfGq2HqPGT/1V0foZZuCZFx8CHKgAk3g==
+ terser@^4.6.11:
+   version "4.6.11"
+   resolved "https://registry.yarnpkg.com/terser/-/terser-4.6.11.tgz#12ff99fdd62a26de2a82f508515407eb6ccd8a9f"
+   integrity sha512-76Ynm7OXUG5xhOpblhytE7X58oeNSmC8xnNhjWVo8CksHit0U0kO4hfNbPrrYwowLWFgM2n9L176VNx2QaHmtA==
    dependencies:
      commander "^2.20.0"
      source-map "~0.6.1"
@@@ -4502,6 -4588,11 +4575,11 @@@ through@^2.3.6
    resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5"
    integrity sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=
  
+ tiny-emitter@^2.1.0:
+   version "2.1.0"
+   resolved "https://registry.yarnpkg.com/tiny-emitter/-/tiny-emitter-2.1.0.tgz#1d1a56edfc51c43e863cbb5382a72330e3555423"
+   integrity sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==
  tiny-invariant@^1.0.2:
    version "1.1.0"
    resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.1.0.tgz#634c5f8efdc27714b7f386c35e6760991d230875"
@@@ -4512,12 -4603,12 +4590,12 @@@ tiny-warning@^1.0.0
    resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754"
    integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==
  
- tippy.js@^6.1.0:
-   version "6.1.0"
-   resolved "https://registry.yarnpkg.com/tippy.js/-/tippy.js-6.1.0.tgz#9c58b94f92f3044d5e861b9d83da3c2a6d3d4323"
-   integrity sha512-cRFydlVZlvo4soQSUfVNbH2K77zDUhDAzaAjxseyn81gGIa+j72y98yDL2yB0n8gas/E+Zlr1iOyR5ckslUFqA==
+ tippy.js@^6.1.1:
+   version "6.1.1"
+   resolved "https://registry.yarnpkg.com/tippy.js/-/tippy.js-6.1.1.tgz#9ed09aa4f9c47fb06a0e280e03055f898f5ddfff"
+   integrity sha512-Sk+FPihack9XFbPOc2jRbn6iRLA9my2a8qhaGY6wwD3EeW57/xY5PAPkZOutKVYDWLyNZ/laCkJqg7QJG/gqQw==
    dependencies:
-     "@popperjs/core" "^2.1.1"
+     "@popperjs/core" "^2.2.0"
  
  tmp@^0.0.33:
    version "0.0.33"
@@@ -4581,15 -4672,15 +4659,15 @@@ tough-cookie@~2.4.3
      psl "^1.1.24"
      punycode "^1.4.1"
  
- tributejs@^5.1.2:
-   version "5.1.2"
-   resolved "https://registry.yarnpkg.com/tributejs/-/tributejs-5.1.2.tgz#d8492d974d3098d6016248d689fb063cda6e77f7"
-   integrity sha512-R9ff/q6w4T5f3Y9+RL+qinog3X1eAj1UnR/yfZaGJ8D3wuJs4/vicrGYul9+fgS9EJ/iYgwARekTb92xwark0g==
+ tributejs@^5.1.3:
+   version "5.1.3"
+   resolved "https://registry.yarnpkg.com/tributejs/-/tributejs-5.1.3.tgz#980600fc72865be5868893078b4bfde721129eae"
+   integrity sha512-B5CXihaVzXw+1UHhNFyAwUTMDk1EfoLP5Tj1VhD9yybZ1I8DZJEv8tZ1l0RJo0t0tk9ZhR8eG5tEsaCvRigmdQ==
  
- ts-node@^8.7.0:
-   version "8.7.0"
-   resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-8.7.0.tgz#266186947596bef9f3a034687595b30e31b20976"
-   integrity sha512-s659CsHrsxaRVDEleuOkGvbsA0rWHtszUNEt1r0CgAFN5ZZTQtDzpsluS7W5pOGJIa1xZE8R/zK4dEs+ldFezg==
+ ts-node@^8.8.2:
+   version "8.8.2"
+   resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-8.8.2.tgz#0b39e690bee39ea5111513a9d2bcdc0bc121755f"
+   integrity sha512-duVj6BpSpUpD/oM4MfhO98ozgkp3Gt9qIp3jGxwU2DFvl/3IRaEAvbLa8G60uS7C77457e/m5TMowjedeRxI1Q==
    dependencies:
      arg "^4.1.0"
      diff "^4.0.1"
      source-map-support "^0.5.6"
      yn "3.1.1"
  
- ts-transform-classcat@^0.0.2:
-   version "0.0.2"
-   resolved "https://registry.yarnpkg.com/ts-transform-classcat/-/ts-transform-classcat-0.0.2.tgz#2386c9418f3a7c1f03261ff51225b70d0a7664fb"
-   integrity sha512-7laOOhgVxWVqvhK10mIEfedJx2nnNOS8J4P/6a/ehXtHFvsBVRRS9/FcTifgzJweOScZsF5BRD5VOGeNidMSqQ==
-   dependencies:
-     typescript "^2.6.2"
+ ts-transform-classcat@^1.0.0:
+   version "1.0.0"
+   resolved "https://registry.yarnpkg.com/ts-transform-classcat/-/ts-transform-classcat-1.0.0.tgz#6ae1be1b32f1f3c6b1c4232daf8a28e3ced0b62f"
+   integrity sha512-LWXEYvBwHDOqBBtoDWSUmbPMsw8FI9vD4XZm98RgziN9UCIj5MRtpmXuP5YYoimCTlPU+D4TFR3IqS+5xSzWsQ==
  
- ts-transform-inferno@^4.0.2:
-   version "4.0.2"
-   resolved "https://registry.yarnpkg.com/ts-transform-inferno/-/ts-transform-inferno-4.0.2.tgz#06b9be45edf874ba7a6ebfb6107ba782509c6afe"
-   integrity sha512-CZb4+w/2l2zikPZ/c51fi3n+qnR2HCEfAS73oGQB80aqRLffkZqm25kYYTMmqUW2+oVfs4M5AZa0z14cvxlQ5w==
+ ts-transform-inferno@^4.0.3:
+   version "4.0.3"
+   resolved "https://registry.yarnpkg.com/ts-transform-inferno/-/ts-transform-inferno-4.0.3.tgz#2cc0eb125abdaff24b8298106a618ab7c6319edc"
+   integrity sha512-Pcg0PVQwJ7Fpv4+3R9obFNsrNKQyLbmUqsjeG7T7r4/4UTgIl0MSwurexjtuGpCp2iv2X/i9ffKPAfAOyYJ9og==
+ tslib@^1.10.0:
+   version "1.11.1"
+   resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.11.1.tgz#eb15d128827fbee2841549e171f45ed338ac7e35"
+   integrity sha512-aZW88SY8kQbU7gpV19lN24LtXh/yD4ZZg6qieAJDDg+YBsJcSmLGK9QpnUjAKVG/xefmvJGd1WUmfpT/g6AJGA==
  
  tslib@^1.8.0, tslib@^1.8.1, tslib@^1.9.0:
    version "1.10.0"
@@@ -4638,7 -4732,7 +4719,7 @@@ twemoji-parser@12.1.3
    resolved "https://registry.yarnpkg.com/twemoji-parser/-/twemoji-parser-12.1.3.tgz#916c0153e77bd5f1011e7a99cbeacf52e43c9371"
    integrity sha512-ND4LZXF4X92/PFrzSgGkq6KPPg8swy/U0yRw1k/+izWRVmq1HYi3khPwV3XIB6FRudgVICAaBhJfW8e8G3HC7Q==
  
- twemoji@^12.1.2:
+ twemoji@^12.1.2, twemoji@^12.1.5:
    version "12.1.5"
    resolved "https://registry.yarnpkg.com/twemoji/-/twemoji-12.1.5.tgz#a961fb65a1afcb1f729ad7e59391f9fe969820b9"
    integrity sha512-B0PBVy5xomwb1M/WZxf/IqPZfnoIYy1skXnlHjMwLwTNfZ9ljh8VgWQktAPcJXu8080WoEh6YwQGPVhDVqvrVQ==
@@@ -4673,11 -4767,6 +4754,6 @@@ type-is@~1.6.17, type-is@~1.6.18
      media-typer "0.3.0"
      mime-types "~2.1.24"
  
- typescript@^2.6.2:
-   version "2.9.2"
-   resolved "https://registry.yarnpkg.com/typescript/-/typescript-2.9.2.tgz#1cbf61d05d6b96269244eb6a3bce4bd914e0f00c"
-   integrity sha512-Gr4p6nFNaoufRIY4NMdpQRNmgxVIGMs4Fcu/ujdYk3nAZqk7supzBE9idmvfZIlH/Cuj//dvi+019qEue9lV0w==
  typescript@^3.8.3:
    version "3.8.3"
    resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.8.3.tgz#409eb8544ea0335711205869ec458ab109ee1061"
@@@ -4890,6 -4979,11 +4966,11 @@@ xregexp@^4.3.0
    dependencies:
      "@babel/runtime-corejs3" "^7.8.3"
  
+ xtend@^4.0.1:
+   version "4.0.2"
+   resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"
+   integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==
  yaml@^1.7.2:
    version "1.7.2"
    resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.7.2.tgz#f26aabf738590ab61efaca502358e48dc9f348b2"