From: Dessalines <tyhou13@gmx.com>
Date: Thu, 11 Feb 2021 20:35:27 +0000 (-0500)
Subject: Adding an icon component. Fixes #172
X-Git-Url: http://these/git/%7BpictrsAvatarThumbnail%28user.avatar%29%7D?a=commitdiff_plain;h=2a85b93c589b33738d31dcdf124fba6b3e809e06;p=lemmy-ui.git

Adding an icon component. Fixes #172
---

diff --git a/src/shared/components/admin-settings.tsx b/src/shared/components/admin-settings.tsx
index 6b985a4..596c83c 100644
--- a/src/shared/components/admin-settings.tsx
+++ b/src/shared/components/admin-settings.tsx
@@ -25,6 +25,7 @@ import autosize from 'autosize';
 import { SiteForm } from './site-form';
 import { UserListing } from './user-listing';
 import { HtmlTags } from './html-tags';
+import { Spinner } from './icon';
 import { i18n } from '../i18next';
 import { InitialFetchRequest } from 'shared/interfaces';
 
@@ -109,9 +110,7 @@ export class AdminSettings extends Component<any, AdminSettingsState> {
         />
         {this.state.loading ? (
           <h5>
-            <svg class="icon icon-spinner spin">
-              <use xlinkHref="#icon-spinner"></use>
-            </svg>
+            <Spinner />
           </h5>
         ) : (
           <div class="row">
@@ -185,9 +184,7 @@ export class AdminSettings extends Component<any, AdminSettingsState> {
             <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>
+                  <Spinner />
                 ) : (
                   capitalizeFirstLetter(i18n.t('save'))
                 )}
diff --git a/src/shared/components/cake-day.tsx b/src/shared/components/cake-day.tsx
index f28be33..b96f02e 100644
--- a/src/shared/components/cake-day.tsx
+++ b/src/shared/components/cake-day.tsx
@@ -1,5 +1,6 @@
 import { Component } from 'inferno';
 import { i18n } from '../i18next';
+import { Icon } from './icon';
 
 interface CakeDayProps {
   creatorName: string;
@@ -12,9 +13,7 @@ export class CakeDay extends Component<CakeDayProps, any> {
         className={`mx-2 d-inline-block unselectable pointer`}
         data-tippy-content={this.cakeDayTippy()}
       >
-        <svg class="icon icon-inline">
-          <use xlinkHref="#icon-cake"></use>
-        </svg>
+        <Icon icon="cake" classes="icon-inline" />
       </div>
     );
   }
diff --git a/src/shared/components/comment-form.tsx b/src/shared/components/comment-form.tsx
index 53e7934..4142048 100644
--- a/src/shared/components/comment-form.tsx
+++ b/src/shared/components/comment-form.tsx
@@ -20,6 +20,7 @@ import { WebSocketService, UserService } from '../services';
 import { i18n } from '../i18next';
 import { T } from 'inferno-i18next';
 import { MarkdownTextArea } from './markdown-textarea';
+import { Icon } from './icon';
 
 interface CommentFormProps {
   postId?: number;
@@ -84,9 +85,7 @@ export class CommentForm extends Component<CommentFormProps, CommentFormState> {
           />
         ) : (
           <div class="alert alert-light" role="alert">
-            <svg class="icon icon-inline mr-2">
-              <use xlinkHref="#icon-alert-triangle"></use>
-            </svg>
+            <Icon icon="alert-triangle" classes="icon-inline mr-2" />
             <T i18nKey="must_login" class="d-inline">
               #
               <Link className="alert-link" to="/login">
diff --git a/src/shared/components/comment-node.tsx b/src/shared/components/comment-node.tsx
index bf57a59..ab190ad 100644
--- a/src/shared/components/comment-node.tsx
+++ b/src/shared/components/comment-node.tsx
@@ -36,6 +36,7 @@ import { CommentForm } from './comment-form';
 import { CommentNodes } from './comment-nodes';
 import { UserListing } from './user-listing';
 import { CommunityLink } from './community-link';
+import { Icon, Spinner } from './icon';
 import { i18n } from '../i18next';
 
 interface CommentNodeState {
@@ -253,13 +254,12 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
                       {this.state.readLoading ? (
                         this.loadingIcon
                       ) : (
-                        <svg
-                          class={`icon icon-inline ${
+                        <Icon
+                          icon="check"
+                          classes={`icon-inline ${
                             this.commentOrMentionRead && 'text-success'
                           }`}
-                        >
-                          <use xlinkHref="#icon-check"></use>
-                        </svg>
+                        />
                       )}
                     </button>
                   )}
@@ -273,9 +273,7 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
                         data-tippy-content={i18n.t('upvote')}
                         aria-label={i18n.t('upvote')}
                       >
-                        <svg class="icon icon-inline">
-                          <use xlinkHref="#icon-arrow-up1"></use>
-                        </svg>
+                        <Icon icon="arrow-up1" classes="icon-inline" />
                         {this.state.upvotes !== this.state.score && (
                           <span class="ml-1">{this.state.upvotes}</span>
                         )}
@@ -291,9 +289,7 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
                           data-tippy-content={i18n.t('downvote')}
                           aria-label={i18n.t('downvote')}
                         >
-                          <svg class="icon icon-inline">
-                            <use xlinkHref="#icon-arrow-down1"></use>
-                          </svg>
+                          <Icon icon="arrow-down1" classes="icon-inline" />
                           {this.state.upvotes !== this.state.score && (
                             <span class="ml-1">{this.state.downvotes}</span>
                           )}
@@ -305,9 +301,7 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
                         data-tippy-content={i18n.t('reply')}
                         aria-label={i18n.t('reply')}
                       >
-                        <svg class="icon icon-inline">
-                          <use xlinkHref="#icon-reply1"></use>
-                        </svg>
+                        <Icon icon="reply1" classes="icon-inline" />
                       </button>
                       {!this.state.showAdvanced ? (
                         <button
@@ -316,9 +310,7 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
                           data-tippy-content={i18n.t('more')}
                           aria-label={i18n.t('more')}
                         >
-                          <svg class="icon icon-inline">
-                            <use xlinkHref="#icon-more-vertical"></use>
-                          </svg>
+                          <Icon icon="more-vertical" classes="icon-inline" />
                         </button>
                       ) : (
                         <>
@@ -329,9 +321,7 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
                                 to={`/create_private_message/recipient/${cv.creator.id}`}
                                 title={i18n.t('message').toLowerCase()}
                               >
-                                <svg class="icon">
-                                  <use xlinkHref="#icon-mail"></use>
-                                </svg>
+                                <Icon icon="mail" />
                               </Link>
                             </button>
                           )}
@@ -352,13 +342,12 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
                             {this.state.saveLoading ? (
                               this.loadingIcon
                             ) : (
-                              <svg
-                                class={`icon icon-inline ${
+                              <Icon
+                                icon="star"
+                                classes={`icon-inline ${
                                   cv.saved && 'text-warning'
                                 }`}
-                              >
-                                <use xlinkHref="#icon-star"></use>
-                              </svg>
+                              />
                             )}
                           </button>
                           <button
@@ -367,13 +356,12 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
                             data-tippy-content={i18n.t('view_source')}
                             aria-label={i18n.t('view_source')}
                           >
-                            <svg
-                              class={`icon icon-inline ${
+                            <Icon
+                              icon="file-text"
+                              classes={`icon-inline ${
                                 this.state.viewSource && 'text-success'
                               }`}
-                            >
-                              <use xlinkHref="#icon-file-text"></use>
-                            </svg>
+                            />
                           </button>
                           {this.myComment && (
                             <>
@@ -383,9 +371,7 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
                                 data-tippy-content={i18n.t('edit')}
                                 aria-label={i18n.t('edit')}
                               >
-                                <svg class="icon icon-inline">
-                                  <use xlinkHref="#icon-edit"></use>
-                                </svg>
+                                <Icon icon="edit" classes="icon-inline" />
                               </button>
                               <button
                                 class="btn btn-link btn-animate text-muted"
@@ -404,13 +390,12 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
                                     : i18n.t('restore')
                                 }
                               >
-                                <svg
-                                  class={`icon icon-inline ${
+                                <Icon
+                                  icon="trash"
+                                  classes={`icon-inline ${
                                     cv.comment.deleted && 'text-danger'
                                   }`}
-                                >
-                                  <use xlinkHref="#icon-trash"></use>
-                                </svg>
+                                />
                               </button>
                             </>
                           )}
@@ -760,19 +745,13 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
         to={`/post/${cv.post.id}/comment/${cv.comment.id}`}
         title={this.props.showContext ? i18n.t('show_context') : i18n.t('link')}
       >
-        <svg class="icon icon-inline">
-          <use xlinkHref="#icon-link"></use>
-        </svg>
+        <Icon icon="link" classes="icon-inline" />
       </Link>
     );
   }
 
   get loadingIcon() {
-    return (
-      <svg class="icon icon-spinner spin">
-        <use xlinkHref="#icon-spinner"></use>
-      </svg>
-    );
+    return <Spinner />;
   }
 
   get myComment(): boolean {
diff --git a/src/shared/components/communities.tsx b/src/shared/components/communities.tsx
index e90c09d..e9fb81c 100644
--- a/src/shared/components/communities.tsx
+++ b/src/shared/components/communities.tsx
@@ -26,6 +26,7 @@ import {
   setOptionalAuth,
 } from '../utils';
 import { CommunityLink } from './community-link';
+import { Spinner } from './icon';
 import { i18n } from '../i18next';
 import { InitialFetchRequest } from 'shared/interfaces';
 
@@ -104,10 +105,8 @@ export class Communities extends Component<any, CommunitiesState> {
           path={this.context.router.route.match.url}
         />
         {this.state.loading ? (
-          <h5 class="">
-            <svg class="icon icon-spinner spin">
-              <use xlinkHref="#icon-spinner"></use>
-            </svg>
+          <h5>
+            <Spinner />
           </h5>
         ) : (
           <div>
diff --git a/src/shared/components/community-form.tsx b/src/shared/components/community-form.tsx
index df4b171..7852233 100644
--- a/src/shared/components/community-form.tsx
+++ b/src/shared/components/community-form.tsx
@@ -24,6 +24,7 @@ import { i18n } from '../i18next';
 
 import { MarkdownTextArea } from './markdown-textarea';
 import { ImageUploadForm } from './image-upload-form';
+import { Icon, Spinner } from './icon';
 
 interface CommunityFormProps {
   community_view?: CommunityView; // If a community is given, that means this is an edit
@@ -132,9 +133,7 @@ export class CommunityForm extends Component<
                   class="pointer unselectable ml-2 text-muted"
                   data-tippy-content={i18n.t('name_explain')}
                 >
-                  <svg class="icon icon-inline">
-                    <use xlinkHref="#icon-help-circle"></use>
-                  </svg>
+                  <Icon icon="help-circle" classes="icon-inline" />
                 </span>
               </label>
               <div class="col-12">
@@ -160,9 +159,7 @@ export class CommunityForm extends Component<
                 class="pointer unselectable ml-2 text-muted"
                 data-tippy-content={i18n.t('display_name_explain')}
               >
-                <svg class="icon icon-inline">
-                  <use xlinkHref="#icon-help-circle"></use>
-                </svg>
+                <Icon icon="help-circle" classes="icon-inline" />
               </span>
             </label>
             <div class="col-12">
@@ -252,9 +249,7 @@ export class CommunityForm extends Component<
                 disabled={this.state.loading}
               >
                 {this.state.loading ? (
-                  <svg class="icon icon-spinner spin">
-                    <use xlinkHref="#icon-spinner"></use>
-                  </svg>
+                  <Spinner />
                 ) : this.props.community_view ? (
                   capitalizeFirstLetter(i18n.t('save'))
                 ) : (
diff --git a/src/shared/components/community.tsx b/src/shared/components/community.tsx
index 86ac243..156a9e9 100644
--- a/src/shared/components/community.tsx
+++ b/src/shared/components/community.tsx
@@ -31,6 +31,7 @@ import { DataTypeSelect } from './data-type-select';
 import { Sidebar } from './sidebar';
 import { CommunityLink } from './community-link';
 import { BannerIconHeader } from './banner-icon-header';
+import { Icon, Spinner } from './icon';
 import {
   wsJsonToRes,
   fetchLimit,
@@ -244,9 +245,7 @@ export class Community extends Component<any, State> {
       <div class="container">
         {this.state.communityLoading ? (
           <h5>
-            <svg class="icon icon-spinner spin">
-              <use xlinkHref="#icon-spinner"></use>
-            </svg>
+            <Spinner />
           </h5>
         ) : (
           <div class="row">
@@ -283,9 +282,7 @@ export class Community extends Component<any, State> {
     return this.state.dataType == DataType.Post ? (
       this.state.postsLoading ? (
         <h5>
-          <svg class="icon icon-spinner spin">
-            <use xlinkHref="#icon-spinner"></use>
-          </svg>
+          <Spinner />
         </h5>
       ) : (
         <PostListings
@@ -297,9 +294,7 @@ export class Community extends Component<any, State> {
       )
     ) : this.state.commentsLoading ? (
       <h5>
-        <svg class="icon icon-spinner spin">
-          <use xlinkHref="#icon-spinner"></use>
-        </svg>
+        <Spinner />
       </h5>
     ) : (
       <CommentNodes
@@ -350,9 +345,7 @@ export class Community extends Component<any, State> {
           title="RSS"
           rel="noopener"
         >
-          <svg class="icon text-muted small">
-            <use xlinkHref="#icon-rss">#</use>
-          </svg>
+          <Icon icon="rss" classes="text-muted small" />
         </a>
       </div>
     );
diff --git a/src/shared/components/create-community.tsx b/src/shared/components/create-community.tsx
index aab5480..85b3547 100644
--- a/src/shared/components/create-community.tsx
+++ b/src/shared/components/create-community.tsx
@@ -2,6 +2,7 @@ import { Component } from 'inferno';
 import { Subscription } from 'rxjs';
 import { CommunityForm } from './community-form';
 import { HtmlTags } from './html-tags';
+import { Spinner } from './icon';
 import {
   CommunityView,
   UserOperation,
@@ -77,9 +78,7 @@ export class CreateCommunity extends Component<any, CreateCommunityState> {
         />
         {this.state.loading ? (
           <h5>
-            <svg class="icon icon-spinner spin">
-              <use xlinkHref="#icon-spinner"></use>
-            </svg>
+            <Spinner />
           </h5>
         ) : (
           <div class="row">
diff --git a/src/shared/components/create-post.tsx b/src/shared/components/create-post.tsx
index 2c0d9c1..8b8d2a7 100644
--- a/src/shared/components/create-post.tsx
+++ b/src/shared/components/create-post.tsx
@@ -2,6 +2,7 @@ import { Component } from 'inferno';
 import { Subscription } from 'rxjs';
 import { PostForm } from './post-form';
 import { HtmlTags } from './html-tags';
+import { Spinner } from './icon';
 import {
   authField,
   isBrowser,
@@ -95,9 +96,7 @@ export class CreatePost extends Component<any, CreatePostState> {
         />
         {this.state.loading ? (
           <h5>
-            <svg class="icon icon-spinner spin">
-              <use xlinkHref="#icon-spinner"></use>
-            </svg>
+            <Spinner />
           </h5>
         ) : (
           <div class="row">
diff --git a/src/shared/components/create-private-message.tsx b/src/shared/components/create-private-message.tsx
index 506e3ee..b6a700d 100644
--- a/src/shared/components/create-private-message.tsx
+++ b/src/shared/components/create-private-message.tsx
@@ -2,6 +2,7 @@ import { Component } from 'inferno';
 import { Subscription } from 'rxjs';
 import { PrivateMessageForm } from './private-message-form';
 import { HtmlTags } from './html-tags';
+import { Spinner } from './icon';
 import { UserService, WebSocketService } from '../services';
 import {
   SiteView,
@@ -112,9 +113,7 @@ export class CreatePrivateMessage extends Component<
         />
         {this.state.loading ? (
           <h5>
-            <svg class="icon icon-spinner spin">
-              <use xlinkHref="#icon-spinner"></use>
-            </svg>
+            <Spinner />
           </h5>
         ) : (
           <div class="row">
diff --git a/src/shared/components/icon.tsx b/src/shared/components/icon.tsx
new file mode 100644
index 0000000..add6d68
--- /dev/null
+++ b/src/shared/components/icon.tsx
@@ -0,0 +1,31 @@
+import { Component } from 'inferno';
+
+interface IconProps {
+  icon: string;
+  classes?: string;
+}
+
+export class Icon extends Component<IconProps, any> {
+  constructor(props: any, context: any) {
+    super(props, context);
+  }
+
+  render() {
+    return (
+      <svg class={`icon ${this.props.classes}`}>
+        <title>{this.props.icon}</title>
+        <use xlinkHref={`#icon-${this.props.icon}`}></use>
+      </svg>
+    );
+  }
+}
+
+export class Spinner extends Component<any, any> {
+  constructor(props: any, context: any) {
+    super(props, context);
+  }
+
+  render() {
+    return <Icon icon="spinner" classes="icon-spinner spin" />;
+  }
+}
diff --git a/src/shared/components/iframely-card.tsx b/src/shared/components/iframely-card.tsx
index 11bc290..3624c95 100644
--- a/src/shared/components/iframely-card.tsx
+++ b/src/shared/components/iframely-card.tsx
@@ -2,6 +2,7 @@ import { Component, linkEvent } from 'inferno';
 import { Post } from 'lemmy-js-client';
 import { mdToHtml } from '../utils';
 import { i18n } from '../i18next';
+import { Icon } from './icon';
 
 interface FramelyCardProps {
   post: Post;
@@ -52,9 +53,7 @@ export class IFramelyCard extends Component<
                         rel="noopener"
                       >
                         {new URL(post.url).hostname}
-                        <svg class="ml-1 icon">
-                          <use xlinkHref="#icon-external-link"></use>
-                        </svg>
+                        <Icon icon="external-link" classes="ml-1" />
                       </a>
                     </span>,
                   ]}
diff --git a/src/shared/components/image-upload-form.tsx b/src/shared/components/image-upload-form.tsx
index 4b8866f..cb28831 100644
--- a/src/shared/components/image-upload-form.tsx
+++ b/src/shared/components/image-upload-form.tsx
@@ -3,6 +3,7 @@ import { pictrsUri } from '../env';
 import { UserService } from '../services';
 import { toast, randomStr } from '../utils';
 import { i18n } from '../i18next';
+import { Icon } from './icon';
 
 interface ImageUploadFormProps {
   uploadTitle: string;
@@ -53,9 +54,7 @@ export class ImageUploadForm extends Component<
                 onClick={linkEvent(this, this.handleRemoveImage)}
                 aria-label={i18n.t('remove')}
               >
-                <svg class="icon mini-overlay">
-                  <use xlinkHref="#icon-x"></use>
-                </svg>
+                <Icon icon="x" classes="mini-overlay" />
               </a>
             </span>
           )}
diff --git a/src/shared/components/inbox.tsx b/src/shared/components/inbox.tsx
index ab0c312..f77f1e2 100644
--- a/src/shared/components/inbox.tsx
+++ b/src/shared/components/inbox.tsx
@@ -38,6 +38,7 @@ import { CommentNodes } from './comment-nodes';
 import { PrivateMessage } from './private-message';
 import { HtmlTags } from './html-tags';
 import { SortSelect } from './sort-select';
+import { Icon, Spinner } from './icon';
 import { i18n } from '../i18next';
 import { InitialFetchRequest } from 'shared/interfaces';
 
@@ -137,9 +138,7 @@ export class Inbox extends Component<any, InboxState> {
       <div class="container">
         {this.state.loading ? (
           <h5>
-            <svg class="icon icon-spinner spin">
-              <use xlinkHref="#icon-spinner"></use>
-            </svg>
+            <Spinner />
           </h5>
         ) : (
           <div class="row">
@@ -157,9 +156,7 @@ export class Inbox extends Component<any, InboxState> {
                     title="RSS"
                     rel="noopener"
                   >
-                    <svg class="icon ml-2 text-muted small">
-                      <use xlinkHref="#icon-rss">#</use>
-                    </svg>
+                    <Icon icon="rss" classes="ml-2 text-muted small" />
                   </a>
                 </small>
               </h5>
diff --git a/src/shared/components/login.tsx b/src/shared/components/login.tsx
index 7006ae7..7fa1702 100644
--- a/src/shared/components/login.tsx
+++ b/src/shared/components/login.tsx
@@ -24,6 +24,7 @@ import {
 } from '../utils';
 import { i18n } from '../i18next';
 import { HtmlTags } from './html-tags';
+import { Icon, Spinner } from './icon';
 
 interface State {
   loginForm: LoginForm;
@@ -148,13 +149,7 @@ export class Login extends Component<any, State> {
           <div class="form-group row">
             <div class="col-sm-10">
               <button type="submit" class="btn btn-secondary">
-                {this.state.loginLoading ? (
-                  <svg class="icon icon-spinner spin">
-                    <use xlinkHref="#icon-spinner"></use>
-                  </svg>
-                ) : (
-                  i18n.t('login')
-                )}
+                {this.state.loginLoading ? <Spinner /> : i18n.t('login')}
               </button>
             </div>
           </div>
@@ -204,9 +199,7 @@ export class Login extends Component<any, State> {
             />
             {!validEmail(this.state.registerForm.email) && (
               <div class="mt-2 mb-0 alert alert-light" role="alert">
-                <svg class="icon icon-inline mr-2">
-                  <use xlinkHref="#icon-alert-triangle"></use>
-                </svg>
+                <Icon icon="alert-triangle" classes="icon-inline mr-2" />
                 {i18n.t('no_password_reset')}
               </div>
             )}
@@ -261,9 +254,7 @@ export class Login extends Component<any, State> {
                 class="btn btn-secondary"
                 onClick={linkEvent(this, this.handleRegenCaptcha)}
               >
-                <svg class="icon icon-refresh-cw">
-                  <use xlinkHref="#icon-refresh-cw"></use>
-                </svg>
+                <Icon icon="refresh-cw" classes="icon-refresh-cw" />
               </button>
             </label>
             {this.showCaptcha()}
@@ -303,13 +294,7 @@ export class Login extends Component<any, State> {
         <div class="form-group row">
           <div class="col-sm-10">
             <button type="submit" class="btn btn-secondary">
-              {this.state.registerLoading ? (
-                <svg class="icon icon-spinner spin">
-                  <use xlinkHref="#icon-spinner"></use>
-                </svg>
-              ) : (
-                i18n.t('sign_up')
-              )}
+              {this.state.registerLoading ? <Spinner /> : i18n.t('sign_up')}
             </button>
           </div>
         </div>
@@ -337,9 +322,7 @@ export class Login extends Component<any, State> {
                 type="button"
                 disabled={this.state.captchaPlaying}
               >
-                <svg class="icon icon-play">
-                  <use xlinkHref="#icon-play"></use>
-                </svg>
+                <Icon icon="play" classes="icon-play" />
               </button>
             )}
           </>
diff --git a/src/shared/components/main.tsx b/src/shared/components/main.tsx
index 2d4b7d7..a80d787 100644
--- a/src/shared/components/main.tsx
+++ b/src/shared/components/main.tsx
@@ -34,6 +34,7 @@ import { SiteForm } from './site-form';
 import { UserListing } from './user-listing';
 import { CommunityLink } from './community-link';
 import { BannerIconHeader } from './banner-icon-header';
+import { Icon, Spinner } from './icon';
 import {
   wsJsonToRes,
   mdToHtml,
@@ -513,9 +514,7 @@ export class Main extends Component<any, MainState> {
               aria-label={i18n.t('edit')}
               data-tippy-content={i18n.t('edit')}
             >
-              <svg class="icon icon-inline">
-                <use xlinkHref="#icon-edit"></use>
-              </svg>
+              <Icon icon="edit" classes="icon-inline" />
             </span>
           </li>
         </ul>
@@ -539,9 +538,7 @@ export class Main extends Component<any, MainState> {
       <div class="main-content-wrapper">
         {this.state.loading ? (
           <h5>
-            <svg class="icon icon-spinner spin">
-              <use xlinkHref="#icon-spinner"></use>
-            </svg>
+            <Spinner />
           </h5>
         ) : (
           <div>
@@ -601,9 +598,7 @@ export class Main extends Component<any, MainState> {
             rel="noopener"
             title="RSS"
           >
-            <svg class="icon text-muted small">
-              <use xlinkHref="#icon-rss">#</use>
-            </svg>
+            <Icon icon="rss" classes="text-muted small" />
           </a>
         )}
         {this.state.listingType == ListingType.Local && (
@@ -613,9 +608,7 @@ export class Main extends Component<any, MainState> {
             rel="noopener"
             title="RSS"
           >
-            <svg class="icon text-muted small">
-              <use xlinkHref="#icon-rss">#</use>
-            </svg>
+            <Icon icon="rss" classes="text-muted small" />
           </a>
         )}
         {UserService.Instance.user &&
@@ -626,9 +619,7 @@ export class Main extends Component<any, MainState> {
               title="RSS"
               rel="noopener"
             >
-              <svg class="icon text-muted small">
-                <use xlinkHref="#icon-rss">#</use>
-              </svg>
+              <Icon icon="rss" classes="text-muted small" />
             </a>
           )}
       </div>
diff --git a/src/shared/components/markdown-textarea.tsx b/src/shared/components/markdown-textarea.tsx
index cffc432..bc53c74 100644
--- a/src/shared/components/markdown-textarea.tsx
+++ b/src/shared/components/markdown-textarea.tsx
@@ -14,6 +14,7 @@ import { UserService } from '../services';
 import autosize from 'autosize';
 import { i18n } from '../i18next';
 import { pictrsUri } from '../env';
+import { Icon, Spinner } from './icon';
 
 interface MarkdownTextAreaProps {
   initialContent: string;
@@ -148,9 +149,7 @@ export class MarkdownTextArea extends Component<
                 disabled={this.props.disabled || this.state.loading}
               >
                 {this.state.loading ? (
-                  <svg class="icon icon-spinner spin">
-                    <use xlinkHref="#icon-spinner"></use>
-                  </svg>
+                  <Spinner />
                 ) : (
                   <span>{this.props.buttonTitle}</span>
                 )}
@@ -182,27 +181,21 @@ export class MarkdownTextArea extends Component<
               data-tippy-content={i18n.t('bold')}
               onClick={linkEvent(this, this.handleInsertBold)}
             >
-              <svg class="icon icon-inline">
-                <use xlinkHref="#icon-bold"></use>
-              </svg>
+              <Icon icon="bold" classes="icon-inline" />
             </button>
             <button
               class="btn btn-sm text-muted"
               data-tippy-content={i18n.t('italic')}
               onClick={linkEvent(this, this.handleInsertItalic)}
             >
-              <svg class="icon icon-inline">
-                <use xlinkHref="#icon-italic"></use>
-              </svg>
+              <Icon icon="italic" classes="icon-inline" />
             </button>
             <button
               class="btn btn-sm text-muted"
               data-tippy-content={i18n.t('link')}
               onClick={linkEvent(this, this.handleInsertLink)}
             >
-              <svg class="icon icon-inline">
-                <use xlinkHref="#icon-link"></use>
-              </svg>
+              <Icon icon="link" classes="icon-inline" />
             </button>
             <form class="btn btn-sm text-muted font-weight-bold">
               <label
@@ -211,13 +204,9 @@ export class MarkdownTextArea extends Component<
                 data-tippy-content={i18n.t('upload_image')}
               >
                 {this.state.imageLoading ? (
-                  <svg class="icon icon-spinner spin">
-                    <use xlinkHref="#icon-spinner"></use>
-                  </svg>
+                  <Spinner />
                 ) : (
-                  <svg class="icon icon-inline">
-                    <use xlinkHref="#icon-image"></use>
-                  </svg>
+                  <Icon icon="image" classes="icon-inline" />
                 )}
               </label>
               <input
@@ -236,9 +225,7 @@ export class MarkdownTextArea extends Component<
               aria-label={i18n.t('header')}
               onClick={linkEvent(this, this.handleInsertHeader)}
             >
-              <svg class="icon icon-inline">
-                <use xlinkHref="#icon-header"></use>
-              </svg>
+              <Icon icon="header" classes="icon-inline" />
             </button>
             <button
               class="btn btn-sm text-muted"
@@ -246,9 +233,7 @@ export class MarkdownTextArea extends Component<
               aria-label={i18n.t('strikethrough')}
               onClick={linkEvent(this, this.handleInsertStrikethrough)}
             >
-              <svg class="icon icon-inline">
-                <use xlinkHref="#icon-strikethrough"></use>
-              </svg>
+              <Icon icon="strikethrough" classes="icon-inline" />
             </button>
             <button
               class="btn btn-sm text-muted"
@@ -256,9 +241,7 @@ export class MarkdownTextArea extends Component<
               aria-label={i18n.t('quote')}
               onClick={linkEvent(this, this.handleInsertQuote)}
             >
-              <svg class="icon icon-inline">
-                <use xlinkHref="#icon-format_quote"></use>
-              </svg>
+              <Icon icon="format_quote" classes="icon-inline" />
             </button>
             <button
               class="btn btn-sm text-muted"
@@ -266,9 +249,7 @@ export class MarkdownTextArea extends Component<
               aria-label={i18n.t('list')}
               onClick={linkEvent(this, this.handleInsertList)}
             >
-              <svg class="icon icon-inline">
-                <use xlinkHref="#icon-list"></use>
-              </svg>
+              <Icon icon="list" classes="icon-inline" />
             </button>
             <button
               class="btn btn-sm text-muted"
@@ -276,9 +257,7 @@ export class MarkdownTextArea extends Component<
               aria-label={i18n.t('code')}
               onClick={linkEvent(this, this.handleInsertCode)}
             >
-              <svg class="icon icon-inline">
-                <use xlinkHref="#icon-code"></use>
-              </svg>
+              <Icon icon="code" classes="icon-inline" />
             </button>
             <button
               class="btn btn-sm text-muted"
@@ -286,9 +265,7 @@ export class MarkdownTextArea extends Component<
               aria-label={i18n.t('subscript')}
               onClick={linkEvent(this, this.handleInsertSubscript)}
             >
-              <svg class="icon icon-inline">
-                <use xlinkHref="#icon-subscript"></use>
-              </svg>
+              <Icon icon="subscript" classes="icon-inline" />
             </button>
             <button
               class="btn btn-sm text-muted"
@@ -296,9 +273,7 @@ export class MarkdownTextArea extends Component<
               aria-label={i18n.t('superscript')}
               onClick={linkEvent(this, this.handleInsertSuperscript)}
             >
-              <svg class="icon icon-inline">
-                <use xlinkHref="#icon-superscript"></use>
-              </svg>
+              <Icon icon="superscript" classes="icon-inline" />
             </button>
             <button
               class="btn btn-sm text-muted"
@@ -306,9 +281,7 @@ export class MarkdownTextArea extends Component<
               aria-label={i18n.t('spoiler')}
               onClick={linkEvent(this, this.handleInsertSpoiler)}
             >
-              <svg class="icon icon-inline">
-                <use xlinkHref="#icon-alert-triangle"></use>
-              </svg>
+              <Icon icon="alert-triangle" classes="icon-inline" />
             </button>
             <a
               href={markdownHelpUrl}
@@ -317,9 +290,7 @@ export class MarkdownTextArea extends Component<
               title={i18n.t('formatting_help')}
               rel="noopener"
             >
-              <svg class="icon icon-inline">
-                <use xlinkHref="#icon-help-circle"></use>
-              </svg>
+              <Icon icon="help-circle" classes="icon-inline" />
             </a>
           </div>
         </div>
diff --git a/src/shared/components/modlog.tsx b/src/shared/components/modlog.tsx
index cfdaef7..f92fa83 100644
--- a/src/shared/components/modlog.tsx
+++ b/src/shared/components/modlog.tsx
@@ -34,6 +34,7 @@ import { i18n } from '../i18next';
 import { InitialFetchRequest } from 'shared/interfaces';
 import { UserListing } from './user-listing';
 import { CommunityLink } from './community-link';
+import { Spinner } from './icon';
 
 enum ModlogEnum {
   ModRemovePost,
@@ -364,10 +365,8 @@ export class Modlog extends Component<any, ModlogState> {
           path={this.context.router.route.match.url}
         />
         {this.state.loading ? (
-          <h5 class="">
-            <svg class="icon icon-spinner spin">
-              <use xlinkHref="#icon-spinner"></use>
-            </svg>
+          <h5>
+            <Spinner />
           </h5>
         ) : (
           <div>
diff --git a/src/shared/components/moment-time.tsx b/src/shared/components/moment-time.tsx
index baa3c25..3efabdf 100644
--- a/src/shared/components/moment-time.tsx
+++ b/src/shared/components/moment-time.tsx
@@ -2,6 +2,7 @@ import { Component } from 'inferno';
 import moment from 'moment';
 import { getMomentLanguage, capitalizeFirstLetter } from '../utils';
 import { i18n } from '../i18next';
+import { Icon } from './icon';
 
 interface MomentTimeProps {
   data: {
@@ -31,9 +32,7 @@ export class MomentTime extends Component<MomentTimeProps, any> {
           )} ${this.format(this.props.data.updated)}`}
           className="font-italics pointer unselectable"
         >
-          <svg class="icon icon-inline mr-1">
-            <use xlinkHref="#icon-edit-2"></use>
-          </svg>
+          <Icon icon="edit-2" classes="icon-inline mr-1" />
           {moment.utc(this.props.data.updated).fromNow(!this.props.showAgo)}
         </span>
       );
diff --git a/src/shared/components/navbar.tsx b/src/shared/components/navbar.tsx
index b4d2cbe..a0ba817 100644
--- a/src/shared/components/navbar.tsx
+++ b/src/shared/components/navbar.tsx
@@ -35,6 +35,7 @@ import {
 } from '../utils';
 import { i18n } from '../i18next';
 import { PictrsImage } from './pictrs-image';
+import { Icon } from './icon';
 
 interface NavbarProps {
   site_res: GetSiteResponse;
@@ -198,9 +199,7 @@ export class Navbar extends Component<NavbarProps, NavbarState> {
               to="/inbox"
               title={i18n.t('inbox')}
             >
-              <svg class="icon">
-                <use xlinkHref="#icon-bell"></use>
-              </svg>
+              <Icon icon="bell" />
               {this.state.unreadCount > 0 && (
                 <span class="mx-1 badge badge-light">
                   {this.state.unreadCount}
@@ -215,9 +214,7 @@ export class Navbar extends Component<NavbarProps, NavbarState> {
             onClick={linkEvent(this, this.expandNavbar)}
             data-tippy-content={i18n.t('expand_here')}
           >
-            <svg class="icon">
-              <use xlinkHref="#icon-menu"></use>
-            </svg>
+            <Icon icon="menu" />
           </button>
           <div
             className={`${!this.state.expanded && 'collapse'} navbar-collapse`}
@@ -259,9 +256,7 @@ export class Navbar extends Component<NavbarProps, NavbarState> {
                   title={i18n.t('support_lemmy')}
                   href={supportLemmyUrl}
                 >
-                  <svg class="icon small">
-                    <use xlinkHref="#icon-beer"></use>
-                  </svg>
+                  <Icon icon="beer" classes="small" />
                 </a>
               </li>
             </ul>
@@ -273,9 +268,7 @@ export class Navbar extends Component<NavbarProps, NavbarState> {
                     to={`/admin`}
                     title={i18n.t('admin_settings')}
                   >
-                    <svg class="icon">
-                      <use xlinkHref="#icon-settings"></use>
-                    </svg>
+                    <Icon icon="settings" />
                   </Link>
                 </li>
               )}
@@ -304,9 +297,7 @@ export class Navbar extends Component<NavbarProps, NavbarState> {
                   class="px-1 btn btn-link"
                   style="color: var(--gray)"
                 >
-                  <svg class="icon">
-                    <use xlinkHref="#icon-search"></use>
-                  </svg>
+                  <Icon icon="search" />
                 </button>
               </form>
             )}
@@ -319,9 +310,7 @@ export class Navbar extends Component<NavbarProps, NavbarState> {
                       to="/inbox"
                       title={i18n.t('inbox')}
                     >
-                      <svg class="icon">
-                        <use xlinkHref="#icon-bell"></use>
-                      </svg>
+                      <Icon icon="bell" />
                       {this.state.unreadCount > 0 && (
                         <span class="ml-1 badge badge-light">
                           {this.state.unreadCount}
diff --git a/src/shared/components/password_change.tsx b/src/shared/components/password_change.tsx
index 373d10f..db9ddd8 100644
--- a/src/shared/components/password_change.tsx
+++ b/src/shared/components/password_change.tsx
@@ -19,6 +19,7 @@ import {
 } from '../utils';
 import { i18n } from '../i18next';
 import { HtmlTags } from './html-tags';
+import { Spinner } from './icon';
 
 interface State {
   passwordChangeForm: PasswordChangeForm;
@@ -113,9 +114,7 @@ export class PasswordChange extends Component<any, State> {
           <div class="col-sm-10">
             <button type="submit" class="btn btn-secondary">
               {this.state.loading ? (
-                <svg class="icon icon-spinner spin">
-                  <use xlinkHref="#icon-spinner"></use>
-                </svg>
+                <Spinner />
               ) : (
                 capitalizeFirstLetter(i18n.t('save'))
               )}
diff --git a/src/shared/components/post-form.tsx b/src/shared/components/post-form.tsx
index 553a6e6..86e6603 100644
--- a/src/shared/components/post-form.tsx
+++ b/src/shared/components/post-form.tsx
@@ -2,6 +2,7 @@ import { Component, linkEvent } from 'inferno';
 import { Prompt } from 'inferno-router';
 import { PostListings } from './post-listings';
 import { MarkdownTextArea } from './markdown-textarea';
+import { Icon, Spinner } from './icon';
 import { Subscription } from 'rxjs';
 import {
   CreatePost,
@@ -196,9 +197,7 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
                   } d-inline-block float-right text-muted font-weight-bold`}
                   data-tippy-content={i18n.t('upload_image')}
                 >
-                  <svg class="icon icon-inline">
-                    <use xlinkHref="#icon-image"></use>
-                  </svg>
+                  <Icon icon="image" classes="icon-inline" />
                 </label>
                 <input
                   id="file-upload"
@@ -222,11 +221,7 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
                   {i18n.t('archive_link')}
                 </a>
               )}
-              {this.state.imageLoading && (
-                <svg class="icon icon-spinner spin">
-                  <use xlinkHref="#icon-spinner"></use>
-                </svg>
-              )}
+              {this.state.imageLoading && <Spinner />}
               {isImage(this.state.postForm.url) && (
                 <img src={this.state.postForm.url} class="img-fluid" alt="" />
               )}
@@ -347,9 +342,7 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
                 class="btn btn-secondary mr-2"
               >
                 {this.state.loading ? (
-                  <svg class="icon icon-spinner spin">
-                    <use xlinkHref="#icon-spinner"></use>
-                  </svg>
+                  <Spinner />
                 ) : this.props.post_view ? (
                   capitalizeFirstLetter(i18n.t('save'))
                 ) : (
diff --git a/src/shared/components/post-listing.tsx b/src/shared/components/post-listing.tsx
index 2d0192b..d68805b 100644
--- a/src/shared/components/post-listing.tsx
+++ b/src/shared/components/post-listing.tsx
@@ -25,6 +25,7 @@ import { IFramelyCard } from './iframely-card';
 import { UserListing } from './user-listing';
 import { CommunityLink } from './community-link';
 import { PictrsImage } from './pictrs-image';
+import { Icon } from './icon';
 import {
   md,
   mdToHtml,
@@ -205,9 +206,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
           aria-label={i18n.t('expand_here')}
         >
           {this.imgThumb(this.getImageSrc())}
-          <svg class="icon mini-overlay">
-            <use xlinkHref="#icon-image"></use>
-          </svg>
+          <Icon icon="image" classes="mini-overlay" />
         </div>
       );
     } else if (post.thumbnail_url) {
@@ -220,9 +219,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
           title={post.url}
         >
           {this.imgThumb(this.getImageSrc())}
-          <svg class="icon mini-overlay">
-            <use xlinkHref="#icon-external-link"></use>
-          </svg>
+          <Icon icon="external-link" classes="mini-overlay" />
         </a>
       );
     } else if (post.url) {
@@ -250,9 +247,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
             rel="noopener"
           >
             <div class="thumbnail rounded bg-light d-flex justify-content-center">
-              <svg class="icon d-flex align-items-center">
-                <use xlinkHref="#icon-external-link"></use>
-              </svg>
+              <Icon icon="external-link" classes="d-flex align-items-center" />
             </div>
           </a>
         );
@@ -265,9 +260,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
           title={i18n.t('comments')}
         >
           <div class="thumbnail rounded bg-light d-flex justify-content-center">
-            <svg class="icon d-flex align-items-center">
-              <use xlinkHref="#icon-message-square"></use>
-            </svg>
+            <Icon icon="message-square" classes="d-flex align-items-center" />
           </div>
         </Link>
       );
@@ -334,9 +327,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
                 aria-label={i18n.t('upvote')}
                 to={`/post/${post_view.post.id}`}
               >
-                <svg class="mr-1 icon icon-inline">
-                  <use xlinkHref="#icon-book-open"></use>
-                </svg>
+                <Icon icon="book-open" classes="icon-inline mr-1" />
               </Link>
             </li>
           </>
@@ -356,9 +347,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
           data-tippy-content={i18n.t('upvote')}
           aria-label={i18n.t('upvote')}
         >
-          <svg class="icon upvote">
-            <use xlinkHref="#icon-arrow-up1"></use>
-          </svg>
+          <Icon icon="arrow-up1" classes="upvote" />
         </button>
         <div
           class={`unselectable pointer font-weight-bold text-muted px-1`}
@@ -375,9 +364,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
             data-tippy-content={i18n.t('downvote')}
             aria-label={i18n.t('downvote')}
           >
-            <svg class="icon downvote">
-              <use xlinkHref="#icon-arrow-down1"></use>
-            </svg>
+            <Icon icon="arrow-down1" classes="downvote" />
           </button>
         )}
       </div>
@@ -415,9 +402,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
                 data-tippy-content={i18n.t('expand_here')}
                 onClick={linkEvent(this, this.handleImageExpandClick)}
               >
-                <svg class="icon icon-inline">
-                  <use xlinkHref="#icon-plus-square"></use>
-                </svg>
+                <Icon icon="plus-square" classes="icon-inline" />
               </span>
             ) : (
               <span>
@@ -425,9 +410,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
                   class="text-monospace unselectable pointer ml-2 text-muted small"
                   onClick={linkEvent(this, this.handleImageExpandClick)}
                 >
-                  <svg class="icon icon-inline">
-                    <use xlinkHref="#icon-minus-square"></use>
-                  </svg>
+                  <Icon icon="minus-square" classes="icon-inline" />
                 </span>
                 <div>
                   <span
@@ -449,9 +432,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
               className="unselectable pointer ml-2 text-muted font-italic"
               data-tippy-content={i18n.t('deleted')}
             >
-              <svg class={`icon icon-inline text-danger`}>
-                <use xlinkHref="#icon-trash"></use>
-              </svg>
+              <Icon icon="trash" classes="icon-inline text-danger" />
             </small>
           )}
           {post.locked && (
@@ -459,9 +440,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
               className="unselectable pointer ml-2 text-muted font-italic"
               data-tippy-content={i18n.t('locked')}
             >
-              <svg class={`icon icon-inline text-danger`}>
-                <use xlinkHref="#icon-lock"></use>
-              </svg>
+              <Icon icon="lock" classes="icon-inline text-danger" />
             </small>
           )}
           {post.stickied && (
@@ -469,9 +448,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
               className="unselectable pointer ml-2 text-muted font-italic"
               data-tippy-content={i18n.t('stickied')}
             >
-              <svg class={`icon icon-inline text-primary`}>
-                <use xlinkHref="#icon-pin"></use>
-              </svg>
+              <Icon icon="pin" classes="icon-inline text-primary" />
             </small>
           )}
           {post.nsfw && (
@@ -496,9 +473,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
             })}
             to={`/post/${post_view.post.id}`}
           >
-            <svg class="mr-1 icon icon-inline">
-              <use xlinkHref="#icon-message-square"></use>
-            </svg>
+            <Icon icon="message-square" classes="icon-inline mr-1" />
             {i18n.t('number_of_comments', {
               count: post_view.counts.comments,
             })}
@@ -513,9 +488,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
                 aria-label={i18n.t('downvote')}
               >
                 <small>
-                  <svg class="icon icon-inline mr-1">
-                    <use xlinkHref="#icon-arrow-down1"></use>
-                  </svg>
+                  <Icon icon="arrow-down1" classes="icon-inline mr-1" />
                   <span>{this.state.downvotes}</span>
                 </small>
               </button>
@@ -530,13 +503,10 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
                 aria-label={post_view.saved ? i18n.t('unsave') : i18n.t('save')}
               >
                 <small>
-                  <svg
-                    class={`icon icon-inline ${
-                      post_view.saved && 'text-warning'
-                    }`}
-                  >
-                    <use xlinkHref="#icon-star"></use>
-                  </svg>
+                  <Icon
+                    icon="star"
+                    classes={`icon-inline ${post_view.saved && 'text-warning'}`}
+                  />
                 </small>
               </button>
             )}
@@ -555,9 +525,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
                 onClick={linkEvent(this, this.handlePostLike)}
                 aria-label={i18n.t('upvote')}
               >
-                <svg class="small icon icon-inline mr-2">
-                  <use xlinkHref="#icon-arrow-up1"></use>
-                </svg>
+                <Icon icon="arrow-up1" classes="icon-inline small mr-2" />
                 {this.state.upvotes}
               </button>
               {this.props.enableDownvotes && (
@@ -569,9 +537,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
                   data-tippy-content={this.pointsTippy}
                   aria-label={i18n.t('downvote')}
                 >
-                  <svg class="small icon icon-inline mr-2">
-                    <use xlinkHref="#icon-arrow-down1"></use>
-                  </svg>
+                  <Icon icon="arrow-down1" classes="icon-inline small mr-2" />
                   {this.state.downvotes !== 0 && (
                     <span>{this.state.downvotes}</span>
                   )}
@@ -586,11 +552,10 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
                 post_view.saved ? i18n.t('unsave') : i18n.t('save')
               }
             >
-              <svg
-                class={`icon icon-inline ${post_view.saved && 'text-warning'}`}
-              >
-                <use xlinkHref="#icon-star"></use>
-              </svg>
+              <Icon
+                icon="star"
+                classes={`icon-inline ${post_view.saved && 'text-warning'}`}
+              />
             </button>
 
             {!this.state.showMoreMobile && this.props.showBody && (
@@ -600,9 +565,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
                 aria-label={i18n.t('more')}
                 data-tippy-content={i18n.t('more')}
               >
-                <svg class="icon icon-inline">
-                  <use xlinkHref="#icon-more-vertical"></use>
-                </svg>
+                <Icon icon="more-vertical" classes="icon-inline" />
               </button>
             )}
             {this.state.showMoreMobile && this.postActions(mobile)}
@@ -655,13 +618,10 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
                     post_view.saved ? i18n.t('unsave') : i18n.t('save')
                   }
                 >
-                  <svg
-                    class={`icon icon-inline ${
-                      post_view.saved && 'text-warning'
-                    }`}
-                  >
-                    <use xlinkHref="#icon-star"></use>
-                  </svg>
+                  <Icon
+                    icon="star"
+                    classes={`icon-inline ${post_view.saved && 'text-warning'}`}
+                  />
                 </button>
               )}
               <Link
@@ -669,9 +629,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
                 to={`/create_post${this.crossPostParams}`}
                 title={i18n.t('cross_post')}
               >
-                <svg class="icon icon-inline">
-                  <use xlinkHref="#icon-copy"></use>
-                </svg>
+                <Icon icon="copy" classes="icon-inline" />
               </Link>
             </>
           )}
@@ -682,9 +640,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
                 onClick={linkEvent(this, this.handleEditClick)}
                 data-tippy-content={i18n.t('edit')}
               >
-                <svg class="icon icon-inline">
-                  <use xlinkHref="#icon-edit"></use>
-                </svg>
+                <Icon icon="edit" classes="icon-inline" />
               </button>
               <button
                 class="btn btn-link btn-animate text-muted py-0"
@@ -693,13 +649,12 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
                   !post_view.post.deleted ? i18n.t('delete') : i18n.t('restore')
                 }
               >
-                <svg
-                  class={`icon icon-inline ${
+                <Icon
+                  icon="trash"
+                  classes={`icon-inline ${
                     post_view.post.deleted && 'text-danger'
                   }`}
-                >
-                  <use xlinkHref="#icon-trash"></use>
-                </svg>
+                />
               </button>
             </>
           )}
@@ -711,9 +666,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
               data-tippy-content={i18n.t('more')}
               aria-label={i18n.t('more')}
             >
-              <svg class="icon icon-inline">
-                <use xlinkHref="#icon-more-vertical"></use>
-              </svg>
+              <Icon icon="more-vertical" classes="icon-inline" />
             </button>
           ) : (
             <>
@@ -723,13 +676,12 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
                   onClick={linkEvent(this, this.handleViewSource)}
                   data-tippy-content={i18n.t('view_source')}
                 >
-                  <svg
-                    class={`icon icon-inline ${
+                  <Icon
+                    icon="file-text"
+                    classes={`icon-inline ${
                       this.state.viewSource && 'text-success'
                     }`}
-                  >
-                    <use xlinkHref="#icon-file-text"></use>
-                  </svg>
+                  />
                 </button>
               )}
               {this.canModOnSelf && (
@@ -741,13 +693,12 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
                       post_view.post.locked ? i18n.t('unlock') : i18n.t('lock')
                     }
                   >
-                    <svg
-                      class={`icon icon-inline ${
+                    <Icon
+                      icon="lock"
+                      classes={`icon-inline ${
                         post_view.post.locked && 'text-danger'
                       }`}
-                    >
-                      <use xlinkHref="#icon-lock"></use>
-                    </svg>
+                    />
                   </button>
                   <button
                     class="btn btn-link btn-animate text-muted py-0"
@@ -758,13 +709,12 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
                         : i18n.t('sticky')
                     }
                   >
-                    <svg
-                      class={`icon icon-inline ${
+                    <Icon
+                      icon="pin"
+                      classes={`icon-inline ${
                         post_view.post.stickied && 'text-success'
                       }`}
-                    >
-                      <use xlinkHref="#icon-pin"></use>
-                    </svg>
+                    />
                   </button>
                 </>
               )}
diff --git a/src/shared/components/post.tsx b/src/shared/components/post.tsx
index 6db6104..519c5fa 100644
--- a/src/shared/components/post.tsx
+++ b/src/shared/components/post.tsx
@@ -1,5 +1,6 @@
 import { Component, linkEvent } from 'inferno';
 import { HtmlTags } from './html-tags';
+import { Spinner } from './icon';
 import { Subscription } from 'rxjs';
 import {
   UserOperation,
@@ -256,9 +257,7 @@ export class Post extends Component<any, PostState> {
       <div class="container">
         {this.state.loading ? (
           <h5>
-            <svg class="icon icon-spinner spin">
-              <use xlinkHref="#icon-spinner"></use>
-            </svg>
+            <Spinner />
           </h5>
         ) : (
           <div class="row">
diff --git a/src/shared/components/private-message-form.tsx b/src/shared/components/private-message-form.tsx
index b4a43a1..7a1507d 100644
--- a/src/shared/components/private-message-form.tsx
+++ b/src/shared/components/private-message-form.tsx
@@ -23,6 +23,7 @@ import {
 } from '../utils';
 import { UserListing } from './user-listing';
 import { MarkdownTextArea } from './markdown-textarea';
+import { Icon, Spinner } from './icon';
 import { i18n } from '../i18next';
 import { T } from 'inferno-i18next';
 
@@ -121,9 +122,7 @@ export class PrivateMessageForm extends Component<
                 data-tippy-content={i18n.t('disclaimer')}
                 aria-label={i18n.t('disclaimer')}
               >
-                <svg class={`icon icon-inline`}>
-                  <use xlinkHref="#icon-alert-triangle"></use>
-                </svg>
+                <Icon icon="alert-triangle" classes="icon-inline" />
               </span>
             </label>
             <div class="col-sm-10">
@@ -161,9 +160,7 @@ export class PrivateMessageForm extends Component<
                 disabled={this.state.loading}
               >
                 {this.state.loading ? (
-                  <svg class="icon icon-spinner spin">
-                    <use xlinkHref="#icon-spinner"></use>
-                  </svg>
+                  <Spinner />
                 ) : this.props.privateMessage ? (
                   capitalizeFirstLetter(i18n.t('save'))
                 ) : (
diff --git a/src/shared/components/private-message.tsx b/src/shared/components/private-message.tsx
index 7b22b74..9aeb665 100644
--- a/src/shared/components/private-message.tsx
+++ b/src/shared/components/private-message.tsx
@@ -10,6 +10,7 @@ import { authField, mdToHtml, toast, wsClient } from '../utils';
 import { MomentTime } from './moment-time';
 import { PrivateMessageForm } from './private-message-form';
 import { UserListing } from './user-listing';
+import { Icon } from './icon';
 import { i18n } from '../i18next';
 
 interface PrivateMessageState {
@@ -82,13 +83,9 @@ export class PrivateMessage extends Component<
                 onClick={linkEvent(this, this.handleMessageCollapse)}
               >
                 {this.state.collapsed ? (
-                  <svg class="icon icon-inline">
-                    <use xlinkHref="#icon-plus-square"></use>
-                  </svg>
+                  <Icon icon="plus-square" classes="icon-inline" />
                 ) : (
-                  <svg class="icon icon-inline">
-                    <use xlinkHref="#icon-minus-square"></use>
-                  </svg>
+                  <Icon icon="minus-square" classes="icon-inline" />
                 )}
               </div>
             </li>
@@ -130,13 +127,12 @@ export class PrivateMessage extends Component<
                             : i18n.t('mark_as_read')
                         }
                       >
-                        <svg
-                          class={`icon icon-inline ${
+                        <Icon
+                          icon="check"
+                          classes={`icon-inline ${
                             message_view.private_message.read && 'text-success'
                           }`}
-                        >
-                          <use xlinkHref="#icon-check"></use>
-                        </svg>
+                        />
                       </button>
                     </li>
                     <li className="list-inline-item">
@@ -146,9 +142,7 @@ export class PrivateMessage extends Component<
                         data-tippy-content={i18n.t('reply')}
                         aria-label={i18n.t('reply')}
                       >
-                        <svg class="icon icon-inline">
-                          <use xlinkHref="#icon-reply1"></use>
-                        </svg>
+                        <Icon icon="reply1" classes="icon-inline" />
                       </button>
                     </li>
                   </>
@@ -162,9 +156,7 @@ export class PrivateMessage extends Component<
                         data-tippy-content={i18n.t('edit')}
                         aria-label={i18n.t('edit')}
                       >
-                        <svg class="icon icon-inline">
-                          <use xlinkHref="#icon-edit"></use>
-                        </svg>
+                        <Icon icon="edit" classes="icon-inline" />
                       </button>
                     </li>
                     <li className="list-inline-item">
@@ -182,14 +174,13 @@ export class PrivateMessage extends Component<
                             : i18n.t('restore')
                         }
                       >
-                        <svg
-                          class={`icon icon-inline ${
+                        <Icon
+                          icon="trash"
+                          classes={`icon-inline ${
                             message_view.private_message.deleted &&
                             'text-danger'
                           }`}
-                        >
-                          <use xlinkHref="#icon-trash"></use>
-                        </svg>
+                        />
                       </button>
                     </li>
                   </>
@@ -201,13 +192,12 @@ export class PrivateMessage extends Component<
                     data-tippy-content={i18n.t('view_source')}
                     aria-label={i18n.t('view_source')}
                   >
-                    <svg
-                      class={`icon icon-inline ${
+                    <Icon
+                      icon="file-text"
+                      classes={`icon-inline ${
                         this.state.viewSource && 'text-success'
                       }`}
-                    >
-                      <use xlinkHref="#icon-file-text"></use>
-                    </svg>
+                    />
                   </button>
                 </li>
               </ul>
diff --git a/src/shared/components/search.tsx b/src/shared/components/search.tsx
index 1b61e3b..11d9f4a 100644
--- a/src/shared/components/search.tsx
+++ b/src/shared/components/search.tsx
@@ -35,6 +35,7 @@ import {
 } from '../utils';
 import { PostListing } from './post-listing';
 import { HtmlTags } from './html-tags';
+import { Spinner } from './icon';
 import { UserListing } from './user-listing';
 import { CommunityLink } from './community-link';
 import { SortSelect } from './sort-select';
@@ -215,13 +216,7 @@ export class Search extends Component<any, SearchState> {
           minLength={3}
         />
         <button type="submit" class="btn btn-secondary mr-2 mb-2">
-          {this.state.loading ? (
-            <svg class="icon icon-spinner spin">
-              <use xlinkHref="#icon-spinner"></use>
-            </svg>
-          ) : (
-            <span>{i18n.t('search')}</span>
-          )}
+          {this.state.loading ? <Spinner /> : <span>{i18n.t('search')}</span>}
         </button>
       </form>
     );
diff --git a/src/shared/components/setup.tsx b/src/shared/components/setup.tsx
index 1abd1c4..c833a0f 100644
--- a/src/shared/components/setup.tsx
+++ b/src/shared/components/setup.tsx
@@ -6,6 +6,7 @@ import { Register, LoginResponse, UserOperation } from 'lemmy-js-client';
 import { WebSocketService, UserService } from '../services';
 import { wsUserOp, wsJsonToRes, toast, wsClient } from '../utils';
 import { SiteForm } from './site-form';
+import { Spinner } from './icon';
 import { i18n } from '../i18next';
 
 interface State {
@@ -143,13 +144,7 @@ export class Setup extends Component<any, State> {
         <div class="form-group row">
           <div class="col-sm-10">
             <button type="submit" class="btn btn-secondary">
-              {this.state.userLoading ? (
-                <svg class="icon icon-spinner spin">
-                  <use xlinkHref="#icon-spinner"></use>
-                </svg>
-              ) : (
-                i18n.t('sign_up')
-              )}
+              {this.state.userLoading ? <Spinner /> : i18n.t('sign_up')}
             </button>
           </div>
         </div>
diff --git a/src/shared/components/sidebar.tsx b/src/shared/components/sidebar.tsx
index db96efa..02525b8 100644
--- a/src/shared/components/sidebar.tsx
+++ b/src/shared/components/sidebar.tsx
@@ -16,6 +16,7 @@ import { CommunityForm } from './community-form';
 import { UserListing } from './user-listing';
 import { CommunityLink } from './community-link';
 import { BannerIconHeader } from './banner-icon-header';
+import { Icon } from './icon';
 import { i18n } from '../i18next';
 
 interface SidebarProps {
@@ -108,9 +109,7 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
               href="#"
               onClick={linkEvent(community.id, this.handleUnsubscribe)}
             >
-              <svg class="text-success mr-1 icon icon-inline">
-                <use xlinkHref="#icon-check"></use>
-              </svg>
+              <Icon icon="check" classes="icon-inline text-success mr-1" />
               {i18n.t('joined')}
             </a>
           )}
@@ -305,9 +304,7 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
                   data-tippy-content={i18n.t('edit')}
                   aria-label={i18n.t('edit')}
                 >
-                  <svg class="icon icon-inline">
-                    <use xlinkHref="#icon-edit"></use>
-                  </svg>
+                  <Icon icon="edit" classes="icon-inline" />
                 </span>
               </li>
               {!this.amCreator &&
@@ -368,13 +365,12 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
                         : i18n.t('restore')
                     }
                   >
-                    <svg
-                      class={`icon icon-inline ${
+                    <Icon
+                      icon="trash"
+                      classes={`icon-inline ${
                         community_view.community.deleted && 'text-danger'
                       }`}
-                    >
-                      <use xlinkHref="#icon-trash"></use>
-                    </svg>
+                    />
                   </span>
                 </li>
               )}
diff --git a/src/shared/components/site-form.tsx b/src/shared/components/site-form.tsx
index 2446b05..6a4211b 100644
--- a/src/shared/components/site-form.tsx
+++ b/src/shared/components/site-form.tsx
@@ -1,6 +1,7 @@
 import { Component, linkEvent } from 'inferno';
 import { Prompt } from 'inferno-router';
 import { MarkdownTextArea } from './markdown-textarea';
+import { Spinner } from './icon';
 import { ImageUploadForm } from './image-upload-form';
 import { Site, EditSite } from 'lemmy-js-client';
 import { WebSocketService } from '../services';
@@ -220,9 +221,7 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> {
                 disabled={this.state.loading}
               >
                 {this.state.loading ? (
-                  <svg class="icon icon-spinner spin">
-                    <use xlinkHref="#icon-spinner"></use>
-                  </svg>
+                  <Spinner />
                 ) : this.props.site ? (
                   capitalizeFirstLetter(i18n.t('save'))
                 ) : (
diff --git a/src/shared/components/sort-select.tsx b/src/shared/components/sort-select.tsx
index 5ed2bc6..ca6984f 100644
--- a/src/shared/components/sort-select.tsx
+++ b/src/shared/components/sort-select.tsx
@@ -1,6 +1,7 @@
 import { Component, linkEvent } from 'inferno';
 import { SortType } from 'lemmy-js-client';
 import { sortingHelpUrl, randomStr } from '../utils';
+import { Icon } from './icon';
 import { i18n } from '../i18next';
 
 interface SortSelectProps {
@@ -42,7 +43,9 @@ export class SortSelect extends Component<SortSelectProps, SortSelectState> {
           class="custom-select w-auto mr-2 mb-2"
           aria-label={i18n.t('sort_type')}
         >
-          <option disabled aria-hidden="true">{i18n.t('sort_type')}</option>
+          <option disabled aria-hidden="true">
+            {i18n.t('sort_type')}
+          </option>
           {!this.props.hideHot && [
             <option value={SortType.Hot}>{i18n.t('hot')}</option>,
             <option value={SortType.Active}>{i18n.t('active')}</option>,
@@ -53,7 +56,9 @@ export class SortSelect extends Component<SortSelectProps, SortSelectState> {
               {i18n.t('most_comments')}
             </option>
           )}
-          <option disabled aria-hidden="true">─────</option>
+          <option disabled aria-hidden="true">
+            ─────
+          </option>
           <option value={SortType.TopDay}>{i18n.t('top_day')}</option>
           <option value={SortType.TopWeek}>{i18n.t('top_week')}</option>
           <option value={SortType.TopMonth}>{i18n.t('top_month')}</option>
@@ -67,9 +72,7 @@ export class SortSelect extends Component<SortSelectProps, SortSelectState> {
           rel="noopener"
           title={i18n.t('sorting_help')}
         >
-          <svg class={`icon icon-inline`}>
-            <use xlinkHref="#icon-help-circle"></use>
-          </svg>
+          <Icon icon="help-circle" classes="icon-inline" />
         </a>
       </>
     );
diff --git a/src/shared/components/user.tsx b/src/shared/components/user.tsx
index 0dbe252..09858bc 100644
--- a/src/shared/components/user.tsx
+++ b/src/shared/components/user.tsx
@@ -57,6 +57,7 @@ import { i18n } from '../i18next';
 import moment from 'moment';
 import { UserDetails } from './user-details';
 import { MarkdownTextArea } from './markdown-textarea';
+import { Icon, Spinner } from './icon';
 import { ImageUploadForm } from './image-upload-form';
 import { BannerIconHeader } from './banner-icon-header';
 import { CommunityLink } from './community-link';
@@ -272,9 +273,7 @@ export class User extends Component<any, UserState> {
       <div class="container">
         {this.state.loading ? (
           <h5>
-            <svg class="icon icon-spinner spin">
-              <use xlinkHref="#icon-spinner"></use>
-            </svg>
+            <Spinner />
           </h5>
         ) : (
           <div class="row">
@@ -393,9 +392,7 @@ export class User extends Component<any, UserState> {
           rel="noopener"
           title="RSS"
         >
-          <svg class="icon mx-2 text-muted small">
-            <use xlinkHref="#icon-rss">#</use>
-          </svg>
+          <Icon icon="rss" classes="text-muted small mx-2" />
         </a>
       </div>
     );
@@ -485,9 +482,7 @@ export class User extends Component<any, UserState> {
               <MomentTime data={uv.user} showAgo ignoreUpdated />
             </div>
             <div className="d-flex align-items-center text-muted mb-2">
-              <svg class="icon">
-                <use xlinkHref="#icon-cake"></use>
-              </svg>
+              <Icon icon="cake" />
               <span className="ml-2">
                 {i18n.t('cake_day_title')}{' '}
                 {moment.utc(uv.user.published).local().format('MMM DD, YYYY')}
@@ -787,9 +782,7 @@ export class User extends Component<any, UserState> {
               <div class="form-group">
                 <button type="submit" class="btn btn-block btn-secondary mr-4">
                   {this.state.userSettingsLoading ? (
-                    <svg class="icon icon-spinner spin">
-                      <use xlinkHref="#icon-spinner"></use>
-                    </svg>
+                    <Spinner />
                   ) : (
                     capitalizeFirstLetter(i18n.t('save'))
                   )}
@@ -827,9 +820,7 @@ export class User extends Component<any, UserState> {
                       onClick={linkEvent(this, this.handleDeleteAccount)}
                     >
                       {this.state.deleteAccountLoading ? (
-                        <svg class="icon icon-spinner spin">
-                          <use xlinkHref="#icon-spinner"></use>
-                        </svg>
+                        <Spinner />
                       ) : (
                         capitalizeFirstLetter(i18n.t('delete'))
                       )}