]> Untitled Git - lemmy-ui.git/blobdiff - src/shared/utils.ts
Merge branch 'main' into main
[lemmy-ui.git] / src / shared / utils.ts
index 2e18e2b8a1612abd12c77ff0e64c7098e1e90917..d42243d6ff82ca569ffd50597b9428a181d70b52 100644 (file)
@@ -1,22 +1,24 @@
+import { isBrowser } from "@utils/browser";
+import { debounce, groupBy } from "@utils/helpers";
 import { Picker } from "emoji-mart";
 import emojiShortName from "emoji-short-name";
 import {
   BlockCommunityResponse,
   BlockPersonResponse,
+  CommentAggregates,
   Comment as CommentI,
+  CommentReplyView,
   CommentReportView,
   CommentSortType,
   CommentView,
-  CommunityModeratorView,
   CommunityView,
   CustomEmojiView,
   GetSiteMetadata,
   GetSiteResponse,
   Language,
   LemmyHttp,
-  LemmyWebsocket,
   MyUserInfo,
-  Person,
+  PersonMentionView,
   PersonView,
   PostReportView,
   PostView,
@@ -24,8 +26,8 @@ import {
   PrivateMessageView,
   RegistrationApplicationView,
   Search,
+  SearchType,
   SortType,
-  UploadImageResponse,
 } from "lemmy-js-client";
 import { default as MarkdownIt } from "markdown-it";
 import markdown_it_container from "markdown-it-container";
@@ -37,22 +39,25 @@ import markdown_it_sup from "markdown-it-sup";
 import Renderer from "markdown-it/lib/renderer";
 import Token from "markdown-it/lib/token";
 import moment from "moment";
-import { Subscription } from "rxjs";
-import { delay, retryWhen, take } from "rxjs/operators";
 import tippy from "tippy.js";
 import Toastify from "toastify-js";
 import { getHttpBase } from "./env";
-import { i18n, languages } from "./i18next";
-import { CommentNodeI, DataType, IsoData } from "./interfaces";
-import { UserService, WebSocketService } from "./services";
+import { i18n } from "./i18next";
+import {
+  CommentNodeI,
+  DataType,
+  IsoData,
+  RouteData,
+  VoteType,
+} from "./interfaces";
+import { HttpService, UserService } from "./services";
+import { RequestState } from "./services/HttpService";
 
 let Tribute: any;
 if (isBrowser()) {
   Tribute = require("tributejs");
 }
 
-export const wsClient = new LemmyWebsocket();
-
 export const favIconUrl = "/static/assets/icons/favicon.svg";
 export const favIconPngUrl = "/static/assets/icons/apple-touch-icon.png";
 // TODO
@@ -60,10 +65,10 @@ export const favIconPngUrl = "/static/assets/icons/apple-touch-icon.png";
 export const repoUrl = "https://github.com/LemmyNet";
 export const joinLemmyUrl = "https://join-lemmy.org";
 export const donateLemmyUrl = `${joinLemmyUrl}/donate`;
-export const docsUrl = `${joinLemmyUrl}/docs/en/index.html`;
-export const helpGuideUrl = `${joinLemmyUrl}/docs/en/users/01-getting-started.html`; // TODO find a way to redirect to the non-en folder
-export const markdownHelpUrl = `${joinLemmyUrl}/docs/en/users/02-media.html`;
-export const sortingHelpUrl = `${helpGuideUrl}/docs/en/users/03-votes-and-ranking.html`;
+export const docsUrl = `${joinLemmyUrl}/docs/index.html`;
+export const helpGuideUrl = `${joinLemmyUrl}/docs/users/01-getting-started.html`; // TODO find a way to redirect to the non-en folder
+export const markdownHelpUrl = `${joinLemmyUrl}/docs/users/02-media.html`;
+export const sortingHelpUrl = `${joinLemmyUrl}/docs/users/03-votes-and-ranking.html`;
 export const archiveTodayUrl = "https://archive.today";
 export const ghostArchiveUrl = "https://ghostarchive.org";
 export const webArchiveUrl = "https://web.archive.org";
@@ -77,6 +82,7 @@ export const commentTreeMaxDepth = 8;
 export const markdownFieldCharacterLimit = 50000;
 export const maxUploadImages = 20;
 export const concurrentImageUpload = 4;
+export const updateUnreadCountsInterval = 30000;
 
 export const relTags = "noopener nofollow";
 
@@ -105,7 +111,12 @@ export type ThemeColor =
   | "gray"
   | "gray-dark";
 
-let customEmojis: EmojiMartCategory[] = [];
+export interface ErrorPageData {
+  error?: string;
+  adminMatrixIds?: string[];
+}
+
+const customEmojis: EmojiMartCategory[] = [];
 export let customEmojisLookup: Map<string, CustomEmojiView> = new Map<
   string,
   CustomEmojiView
@@ -187,11 +198,11 @@ export function hotRankPost(post_view: PostView): number {
 
 export function hotRank(score: number, timeStr: string): number {
   // Rank = ScaleFactor * sign(Score) * log(1 + abs(Score)) / (Time + 2)^Gravity
-  let date: Date = new Date(timeStr + "Z"); // Add Z to convert from UTC date
-  let now: Date = new Date();
-  let hoursElapsed: number = (now.getTime() - date.getTime()) / 36e5;
+  const date: Date = new Date(timeStr + "Z"); // Add Z to convert from UTC date
+  const now: Date = new Date();
+  const hoursElapsed: number = (now.getTime() - date.getTime()) / 36e5;
 
-  let rank =
+  const rank =
     (10000 * Math.log10(Math.max(1, 3 + Number(score)))) /
     Math.pow(hoursElapsed + 2, 1.8);
 
@@ -224,94 +235,9 @@ export function futureDaysToUnixTime(days?: number): number | undefined {
     : undefined;
 }
 
-export function canMod(
-  creator_id: number,
-  mods?: CommunityModeratorView[],
-  admins?: PersonView[],
-  myUserInfo = UserService.Instance.myUserInfo,
-  onSelf = false
-): boolean {
-  // You can do moderator actions only on the mods added after you.
-  let adminsThenMods =
-    admins
-      ?.map(a => a.person.id)
-      .concat(mods?.map(m => m.moderator.id) ?? []) ?? [];
-
-  if (myUserInfo) {
-    let myIndex = adminsThenMods.findIndex(
-      id => id == myUserInfo.local_user_view.person.id
-    );
-    if (myIndex == -1) {
-      return false;
-    } else {
-      // onSelf +1 on mod actions not for yourself, IE ban, remove, etc
-      adminsThenMods = adminsThenMods.slice(0, myIndex + (onSelf ? 0 : 1));
-      return !adminsThenMods.includes(creator_id);
-    }
-  } else {
-    return false;
-  }
-}
-
-export function canAdmin(
-  creatorId: number,
-  admins?: PersonView[],
-  myUserInfo = UserService.Instance.myUserInfo,
-  onSelf = false
-): boolean {
-  return canMod(creatorId, undefined, admins, myUserInfo, onSelf);
-}
-
-export function isMod(
-  creatorId: number,
-  mods?: CommunityModeratorView[]
-): boolean {
-  return mods?.map(m => m.moderator.id).includes(creatorId) ?? false;
-}
-
-export function amMod(
-  mods?: CommunityModeratorView[],
-  myUserInfo = UserService.Instance.myUserInfo
-): boolean {
-  return myUserInfo ? isMod(myUserInfo.local_user_view.person.id, mods) : false;
-}
-
-export function isAdmin(creatorId: number, admins?: PersonView[]): boolean {
-  return admins?.map(a => a.person.id).includes(creatorId) ?? false;
-}
-
-export function amAdmin(myUserInfo = UserService.Instance.myUserInfo): boolean {
-  return myUserInfo?.local_user_view.person.admin ?? false;
-}
-
-export function amCommunityCreator(
-  creator_id: number,
-  mods?: CommunityModeratorView[],
-  myUserInfo = UserService.Instance.myUserInfo
-): boolean {
-  let myId = myUserInfo?.local_user_view.person.id;
-  // Don't allow mod actions on yourself
-  return myId == mods?.at(0)?.moderator.id && myId != creator_id;
-}
-
-export function amSiteCreator(
-  creator_id: number,
-  admins?: PersonView[],
-  myUserInfo = UserService.Instance.myUserInfo
-): boolean {
-  let myId = myUserInfo?.local_user_view.person.id;
-  return myId == admins?.at(0)?.person.id && myId != creator_id;
-}
-
-export function amTopMod(
-  mods: CommunityModeratorView[],
-  myUserInfo = UserService.Instance.myUserInfo
-): boolean {
-  return mods.at(0)?.moderator.id == myUserInfo?.local_user_view.person.id;
-}
-
 const imageRegex = /(http)?s?:?(\/\/[^"']*\.(?:jpg|jpeg|gif|png|svg|webp))/;
 const videoRegex = /(http)?s?:?(\/\/[^"']*\.(?:mp4|webm))/;
+const tldRegex = /([a-z0-9]+\.)*[a-z0-9]+\.[a-z]+/;
 
 export function isImage(url: string) {
   return imageRegex.test(url);
@@ -322,16 +248,25 @@ export function isVideo(url: string) {
 }
 
 export function validURL(str: string) {
-  return !!new URL(str);
+  try {
+    new URL(str);
+    return true;
+  } catch {
+    return false;
+  }
+}
+
+export function validInstanceTLD(str: string) {
+  return tldRegex.test(str);
 }
 
 export function communityRSSUrl(actorId: string, sort: string): string {
-  let url = new URL(actorId);
+  const url = new URL(actorId);
   return `${url.origin}/feeds${url.pathname}.xml?sort=${sort}`;
 }
 
 export function validEmail(email: string) {
-  let re =
+  const re =
     /^(([^\s"(),.:;<>@[\\\]]+(\.[^\s"(),.:;<>@[\\\]]+)*)|(".+"))@((\[(?:\d{1,3}\.){3}\d{1,3}])|(([\dA-Za-z\-]+\.)+[A-Za-z]{2,}))$/;
   return re.test(String(email).toLowerCase());
 }
@@ -341,8 +276,8 @@ export function capitalizeFirstLetter(str: string): string {
 }
 
 export async function getSiteMetadata(url: string) {
-  let form: GetSiteMetadata = { url };
-  let client = new LemmyHttp(getHttpBase());
+  const form: GetSiteMetadata = { url };
+  const client = new LemmyHttp(getHttpBase());
   return client.getSiteMetadata(form);
 }
 
@@ -350,76 +285,6 @@ export function getDataTypeString(dt: DataType) {
   return dt === DataType.Post ? "Post" : "Comment";
 }
 
-export function debounce<T extends any[], R>(
-  func: (...e: T) => R,
-  wait = 1000,
-  immediate = false
-) {
-  // 'private' variable for instance
-  // The returned function will be able to reference this due to closure.
-  // Each call to the returned function will share this common timer.
-  let timeout: NodeJS.Timeout | null;
-
-  // Calling debounce returns a new anonymous function
-  return function () {
-    // reference the context and args for the setTimeout function
-    const args = arguments;
-
-    // Should the function be called now? If immediate is true
-    //   and not already in a timeout then the answer is: Yes
-    const callNow = immediate && !timeout;
-
-    // This is the basic debounce behavior where you can call this
-    //   function several times, but it will only execute once
-    //   [before or after imposing a delay].
-    //   Each time the returned function is called, the timer starts over.
-    clearTimeout(timeout ?? undefined);
-
-    // Set the new timeout
-    timeout = setTimeout(function () {
-      // Inside the timeout function, clear the timeout variable
-      // which will let the next execution run when in 'immediate' mode
-      timeout = null;
-
-      // Check if the function already ran with the immediate flag
-      if (!immediate) {
-        // Call the original function with apply
-        // apply lets you define the 'this' object as well as the arguments
-        //    (both captured before setTimeout)
-        func.apply(this, args);
-      }
-    }, wait);
-
-    // Immediate mode and no wait timer? Execute the function..
-    if (callNow) func.apply(this, args);
-  } as (...e: T) => R;
-}
-
-export function getLanguages(
-  override?: string,
-  myUserInfo = UserService.Instance.myUserInfo
-): string[] {
-  let myLang = myUserInfo?.local_user_view.local_user.interface_language;
-  let lang = override || myLang || "browser";
-
-  if (lang == "browser" && isBrowser()) {
-    return getBrowserLanguages();
-  } else {
-    return [lang];
-  }
-}
-
-function getBrowserLanguages(): string[] {
-  // Intersect lemmy's langs, with the browser langs
-  let langs = languages ? languages.map(l => l.code) : ["en"];
-
-  // NOTE, mobile browsers seem to be missing this list, so append en
-  let allowedLangs = navigator.languages
-    .concat("en")
-    .filter(v => langs.includes(v));
-  return allowedLangs;
-}
-
 export async function fetchThemeList(): Promise<string[]> {
   return fetch("/css/themelist").then(res => res.json());
 }
@@ -436,11 +301,11 @@ export async function setTheme(theme: string, forceReload = false) {
     theme = "darkly";
   }
 
-  let themeList = await fetchThemeList();
+  const themeList = await fetchThemeList();
 
   // Unload all the other themes
   for (var i = 0; i < themeList.length; i++) {
-    let styleSheet = document.getElementById(themeList[i]);
+    const styleSheet = document.getElementById(themeList[i]);
     if (styleSheet) {
       styleSheet.setAttribute("disabled", "disabled");
     }
@@ -452,7 +317,7 @@ export async function setTheme(theme: string, forceReload = false) {
   document.getElementById("default-dark")?.setAttribute("disabled", "disabled");
 
   // Load the theme dynamically
-  let cssLoc = `/css/themes/${theme}.css`;
+  const cssLoc = `/css/themes/${theme}.css`;
 
   loadCss(theme, cssLoc);
   document.getElementById(theme)?.removeAttribute("disabled");
@@ -506,7 +371,7 @@ export function isCakeDay(published: string): boolean {
 
 export function toast(text: string, background: ThemeColor = "success") {
   if (isBrowser()) {
-    const backgroundColor = `var(--${background})`;
+    const backgroundColor = `var(--bs-${background})`;
     Toastify({
       text: text,
       backgroundColor: backgroundColor,
@@ -527,7 +392,7 @@ export function pictrsDeleteToast(filename: string, deleteUrl: string) {
       filename,
     });
 
-    const backgroundColor = `var(--light)`;
+    const backgroundColor = `var(--bs-light)`;
 
     const toast = Toastify({
       text: clickToDeleteText,
@@ -554,86 +419,6 @@ export function pictrsDeleteToast(filename: string, deleteUrl: string) {
   }
 }
 
-interface NotifyInfo {
-  name: string;
-  icon?: string;
-  link: string;
-  body?: string;
-}
-
-export function messageToastify(info: NotifyInfo, router: any) {
-  if (isBrowser()) {
-    let htmlBody = info.body ? md.render(info.body) : "";
-    let backgroundColor = `var(--light)`;
-
-    let toast = Toastify({
-      text: `${htmlBody}<br />${info.name}`,
-      avatar: info.icon,
-      backgroundColor: backgroundColor,
-      className: "text-dark",
-      close: true,
-      gravity: "top",
-      position: "right",
-      duration: 5000,
-      escapeMarkup: false,
-      onClick: () => {
-        if (toast) {
-          toast.hideToast();
-          router.history.push(info.link);
-        }
-      },
-    });
-    toast.showToast();
-  }
-}
-
-export function notifyPost(post_view: PostView, router: any) {
-  let info: NotifyInfo = {
-    name: post_view.community.name,
-    icon: post_view.community.icon,
-    link: `/post/${post_view.post.id}`,
-    body: post_view.post.name,
-  };
-  notify(info, router);
-}
-
-export function notifyComment(comment_view: CommentView, router: any) {
-  let info: NotifyInfo = {
-    name: comment_view.creator.name,
-    icon: comment_view.creator.avatar,
-    link: `/comment/${comment_view.comment.id}`,
-    body: comment_view.comment.content,
-  };
-  notify(info, router);
-}
-
-export function notifyPrivateMessage(pmv: PrivateMessageView, router: any) {
-  let info: NotifyInfo = {
-    name: pmv.creator.name,
-    icon: pmv.creator.avatar,
-    link: `/inbox`,
-    body: pmv.private_message.content,
-  };
-  notify(info, router);
-}
-
-function notify(info: NotifyInfo, router: any) {
-  messageToastify(info, router);
-
-  if (Notification.permission !== "granted") Notification.requestPermission();
-  else {
-    var notification = new Notification(info.name, {
-      ...{ body: info.body },
-      ...(info.icon && { icon: info.icon }),
-    });
-
-    notification.onclick = (ev: Event): any => {
-      ev.preventDefault();
-      router.history.push(info.link);
-    };
-  }
-}
-
 export function setupTribute() {
   return new Tribute({
     noMatchTemplate: function () {
@@ -644,11 +429,11 @@ export function setupTribute() {
       {
         trigger: ":",
         menuItemTemplate: (item: any) => {
-          let shortName = `:${item.original.key}:`;
+          const shortName = `:${item.original.key}:`;
           return `${item.original.val} ${shortName}`;
         },
         selectTemplate: (item: any) => {
-          let customEmoji = customEmojisLookup.get(
+          const customEmoji = customEmojisLookup.get(
             item.original.key
           )?.custom_emoji;
           if (customEmoji == undefined) return `${item.original.val}`;
@@ -675,7 +460,7 @@ export function setupTribute() {
       {
         trigger: "@",
         selectTemplate: (item: any) => {
-          let it: PersonTribute = item.original;
+          const it: PersonTribute = item.original;
           return `[${it.key}](${it.view.person.actor_id})`;
         },
         values: debounce(async (text: string, cb: any) => {
@@ -692,7 +477,7 @@ export function setupTribute() {
       {
         trigger: "!",
         selectTemplate: (item: any) => {
-          let it: CommunityTribute = item.original;
+          const it: CommunityTribute = item.original;
           return `[${it.key}](${it.view.community.actor_id})`;
         },
         values: debounce(async (text: string, cb: any) => {
@@ -709,7 +494,10 @@ export function setupTribute() {
 }
 
 function setupEmojiDataModel(custom_emoji_views: CustomEmojiView[]) {
-  let groupedEmojis = groupBy(custom_emoji_views, x => x.custom_emoji.category);
+  const groupedEmojis = groupBy(
+    custom_emoji_views,
+    x => x.custom_emoji.category
+  );
   for (const [category, emojis] of Object.entries(groupedEmojis)) {
     customEmojis.push({
       id: category,
@@ -734,7 +522,7 @@ export function updateEmojiDataModel(custom_emoji_view: CustomEmojiView) {
     keywords: custom_emoji_view.keywords.map(x => x.keyword),
     skins: [{ src: custom_emoji_view.custom_emoji.image_url }],
   };
-  let categoryIndex = customEmojis.findIndex(
+  const categoryIndex = customEmojis.findIndex(
     x => x.id == custom_emoji_view.custom_emoji.category
   );
   if (categoryIndex == -1) {
@@ -744,7 +532,7 @@ export function updateEmojiDataModel(custom_emoji_view: CustomEmojiView) {
       emojis: [emoji],
     });
   } else {
-    let emojiIndex = customEmojis[categoryIndex].emojis.findIndex(
+    const emojiIndex = customEmojis[categoryIndex].emojis.findIndex(
       x => x.id == custom_emoji_view.custom_emoji.shortcode
     );
     if (emojiIndex == -1) {
@@ -761,7 +549,7 @@ export function updateEmojiDataModel(custom_emoji_view: CustomEmojiView) {
 
 export function removeFromEmojiDataModel(id: number) {
   let view: CustomEmojiView | undefined;
-  for (let item of customEmojisLookup.values()) {
+  for (const item of customEmojisLookup.values()) {
     if (item.custom_emoji.id === id) {
       view = item;
       break;
@@ -812,7 +600,7 @@ function setupMarkdown() {
       defs: emojiDefs,
     })
     .disable("image");
-  var defaultRenderer = md.renderer.rules.image;
+  const defaultRenderer = md.renderer.rules.image;
   md.renderer.rules.image = function (
     tokens: Token[],
     idx: number,
@@ -831,6 +619,9 @@ function setupMarkdown() {
     const alt_text = item.content;
     return `<img class="icon icon-emoji" src="${src}" title="${title}" alt="${alt_text}"/>`;
   };
+  md.renderer.rules.table_open = function () {
+    return '<table class="table">';
+  };
 }
 
 export function getEmojiMart(
@@ -867,15 +658,12 @@ interface PersonTribute {
 }
 
 async function personSearch(text: string): Promise<PersonTribute[]> {
-  let users = (await fetchUsers(text)).users;
-  let persons: PersonTribute[] = users.map(pv => {
-    let tribute: PersonTribute = {
-      key: `@${pv.person.name}@${hostname(pv.person.actor_id)}`,
-      view: pv,
-    };
-    return tribute;
-  });
-  return persons;
+  const usersResponse = await fetchUsers(text);
+
+  return usersResponse.map(pv => ({
+    key: `@${pv.person.name}@${hostname(pv.person.actor_id)}`,
+    view: pv,
+  }));
 }
 
 interface CommunityTribute {
@@ -884,15 +672,12 @@ interface CommunityTribute {
 }
 
 async function communitySearch(text: string): Promise<CommunityTribute[]> {
-  let comms = (await fetchCommunities(text)).communities;
-  let communities: CommunityTribute[] = comms.map(cv => {
-    let tribute: CommunityTribute = {
-      key: `!${cv.community.name}@${hostname(cv.community.actor_id)}`,
-      view: cv,
-    };
-    return tribute;
-  });
-  return communities;
+  const communitiesResponse = await fetchCommunities(text);
+
+  return communitiesResponse.map(cv => ({
+    key: `!${cv.community.name}@${hostname(cv.community.actor_id)}`,
+    view: cv,
+  }));
 }
 
 export function getRecipientIdFromProps(props: any): number {
@@ -902,51 +687,137 @@ export function getRecipientIdFromProps(props: any): number {
 }
 
 export function getIdFromProps(props: any): number | undefined {
-  let id = props.match.params.post_id;
+  const id = props.match.params.post_id;
   return id ? Number(id) : undefined;
 }
 
 export function getCommentIdFromProps(props: any): number | undefined {
-  let id = props.match.params.comment_id;
+  const id = props.match.params.comment_id;
   return id ? Number(id) : undefined;
 }
 
-export function editCommentRes(data: CommentView, comments?: CommentView[]) {
-  let found = comments?.find(c => c.comment.id == data.comment.id);
-  if (found) {
-    found.comment.content = data.comment.content;
-    found.comment.distinguished = data.comment.distinguished;
-    found.comment.updated = data.comment.updated;
-    found.comment.removed = data.comment.removed;
-    found.comment.deleted = data.comment.deleted;
-    found.counts.upvotes = data.counts.upvotes;
-    found.counts.downvotes = data.counts.downvotes;
-    found.counts.score = data.counts.score;
-  }
+type ImmutableListKey =
+  | "comment"
+  | "comment_reply"
+  | "person_mention"
+  | "community"
+  | "private_message"
+  | "post"
+  | "post_report"
+  | "comment_report"
+  | "private_message_report"
+  | "registration_application";
+
+function editListImmutable<
+  T extends { [key in F]: { id: number } },
+  F extends ImmutableListKey
+>(fieldName: F, data: T, list: T[]): T[] {
+  return [
+    ...list.map(c => (c[fieldName].id === data[fieldName].id ? data : c)),
+  ];
+}
+
+export function editComment(
+  data: CommentView,
+  comments: CommentView[]
+): CommentView[] {
+  return editListImmutable("comment", data, comments);
 }
 
-export function saveCommentRes(data: CommentView, comments?: CommentView[]) {
-  let found = comments?.find(c => c.comment.id == data.comment.id);
-  if (found) {
-    found.saved = data.saved;
-  }
+export function editCommentReply(
+  data: CommentReplyView,
+  replies: CommentReplyView[]
+): CommentReplyView[] {
+  return editListImmutable("comment_reply", data, replies);
+}
+
+interface WithComment {
+  comment: CommentI;
+  counts: CommentAggregates;
+  my_vote?: number;
+  saved: boolean;
+}
+
+export function editMention(
+  data: PersonMentionView,
+  comments: PersonMentionView[]
+): PersonMentionView[] {
+  return editListImmutable("person_mention", data, comments);
+}
+
+export function editCommunity(
+  data: CommunityView,
+  communities: CommunityView[]
+): CommunityView[] {
+  return editListImmutable("community", data, communities);
+}
+
+export function editPrivateMessage(
+  data: PrivateMessageView,
+  messages: PrivateMessageView[]
+): PrivateMessageView[] {
+  return editListImmutable("private_message", data, messages);
+}
+
+export function editPost(data: PostView, posts: PostView[]): PostView[] {
+  return editListImmutable("post", data, posts);
+}
+
+export function editPostReport(
+  data: PostReportView,
+  reports: PostReportView[]
+) {
+  return editListImmutable("post_report", data, reports);
+}
+
+export function editCommentReport(
+  data: CommentReportView,
+  reports: CommentReportView[]
+): CommentReportView[] {
+  return editListImmutable("comment_report", data, reports);
+}
+
+export function editPrivateMessageReport(
+  data: PrivateMessageReportView,
+  reports: PrivateMessageReportView[]
+): PrivateMessageReportView[] {
+  return editListImmutable("private_message_report", data, reports);
+}
+
+export function editRegistrationApplication(
+  data: RegistrationApplicationView,
+  apps: RegistrationApplicationView[]
+): RegistrationApplicationView[] {
+  return editListImmutable("registration_application", data, apps);
+}
+
+export function editWith<D extends WithComment, L extends WithComment>(
+  { comment, counts, saved, my_vote }: D,
+  list: L[]
+) {
+  return [
+    ...list.map(c =>
+      c.comment.id === comment.id
+        ? { ...c, comment, counts, saved, my_vote }
+        : c
+    ),
+  ];
 }
 
 export function updatePersonBlock(
   data: BlockPersonResponse,
   myUserInfo: MyUserInfo | undefined = UserService.Instance.myUserInfo
 ) {
-  let mui = myUserInfo;
-  if (mui) {
+  if (myUserInfo) {
     if (data.blocked) {
-      mui.person_blocks.push({
-        person: mui.local_user_view.person,
+      myUserInfo.person_blocks.push({
+        person: myUserInfo.local_user_view.person,
         target: data.person_view.person,
       });
       toast(`${i18n.t("blocked")} ${data.person_view.person.name}`);
     } else {
-      mui.person_blocks = mui.person_blocks.filter(
-        i => i.target.id != data.person_view.person.id
+      myUserInfo.person_blocks = myUserInfo.person_blocks.filter(
+        i => i.target.id !== data.person_view.person.id
       );
       toast(`${i18n.t("unblocked")} ${data.person_view.person.name}`);
     }
@@ -957,164 +828,68 @@ export function updateCommunityBlock(
   data: BlockCommunityResponse,
   myUserInfo: MyUserInfo | undefined = UserService.Instance.myUserInfo
 ) {
-  let mui = myUserInfo;
-  if (mui) {
+  if (myUserInfo) {
     if (data.blocked) {
-      mui.community_blocks.push({
-        person: mui.local_user_view.person,
+      myUserInfo.community_blocks.push({
+        person: myUserInfo.local_user_view.person,
         community: data.community_view.community,
       });
       toast(`${i18n.t("blocked")} ${data.community_view.community.name}`);
     } else {
-      mui.community_blocks = mui.community_blocks.filter(
-        i => i.community.id != data.community_view.community.id
+      myUserInfo.community_blocks = myUserInfo.community_blocks.filter(
+        i => i.community.id !== data.community_view.community.id
       );
       toast(`${i18n.t("unblocked")} ${data.community_view.community.name}`);
     }
   }
 }
 
-export function createCommentLikeRes(
-  data: CommentView,
-  comments?: CommentView[]
-) {
-  let found = comments?.find(c => c.comment.id === data.comment.id);
-  if (found) {
-    found.counts.score = data.counts.score;
-    found.counts.upvotes = data.counts.upvotes;
-    found.counts.downvotes = data.counts.downvotes;
-    if (data.my_vote !== null) {
-      found.my_vote = data.my_vote;
-    }
-  }
-}
-
-export function createPostLikeFindRes(data: PostView, posts?: PostView[]) {
-  let found = posts?.find(p => p.post.id == data.post.id);
-  if (found) {
-    createPostLikeRes(data, found);
-  }
-}
-
-export function createPostLikeRes(data: PostView, post_view?: PostView) {
-  if (post_view) {
-    post_view.counts.score = data.counts.score;
-    post_view.counts.upvotes = data.counts.upvotes;
-    post_view.counts.downvotes = data.counts.downvotes;
-    if (data.my_vote !== null) {
-      post_view.my_vote = data.my_vote;
-    }
-  }
-}
-
-export function editPostFindRes(data: PostView, posts?: PostView[]) {
-  let found = posts?.find(p => p.post.id == data.post.id);
-  if (found) {
-    editPostRes(data, found);
-  }
-}
-
-export function editPostRes(data: PostView, post: PostView) {
-  if (post) {
-    post.post.url = data.post.url;
-    post.post.name = data.post.name;
-    post.post.nsfw = data.post.nsfw;
-    post.post.deleted = data.post.deleted;
-    post.post.removed = data.post.removed;
-    post.post.featured_community = data.post.featured_community;
-    post.post.featured_local = data.post.featured_local;
-    post.post.body = data.post.body;
-    post.post.locked = data.post.locked;
-    post.saved = data.saved;
-  }
-}
-
-// TODO possible to make these generic?
-export function updatePostReportRes(
-  data: PostReportView,
-  reports?: PostReportView[]
-) {
-  let found = reports?.find(p => p.post_report.id == data.post_report.id);
-  if (found) {
-    found.post_report = data.post_report;
-  }
-}
-
-export function updateCommentReportRes(
-  data: CommentReportView,
-  reports?: CommentReportView[]
-) {
-  let found = reports?.find(c => c.comment_report.id == data.comment_report.id);
-  if (found) {
-    found.comment_report = data.comment_report;
-  }
-}
-
-export function updatePrivateMessageReportRes(
-  data: PrivateMessageReportView,
-  reports?: PrivateMessageReportView[]
-) {
-  let found = reports?.find(
-    c => c.private_message_report.id == data.private_message_report.id
-  );
-  if (found) {
-    found.private_message_report = data.private_message_report;
-  }
-}
-
-export function updateRegistrationApplicationRes(
-  data: RegistrationApplicationView,
-  applications?: RegistrationApplicationView[]
-) {
-  let found = applications?.find(
-    ra => ra.registration_application.id == data.registration_application.id
-  );
-  if (found) {
-    found.registration_application = data.registration_application;
-    found.admin = data.admin;
-    found.creator_local_user = data.creator_local_user;
-  }
-}
-
 export function commentsToFlatNodes(comments: CommentView[]): CommentNodeI[] {
-  let nodes: CommentNodeI[] = [];
-  for (let comment of comments) {
+  const nodes: CommentNodeI[] = [];
+  for (const comment of comments) {
     nodes.push({ comment_view: comment, children: [], depth: 0 });
   }
   return nodes;
 }
 
 export function convertCommentSortType(sort: SortType): CommentSortType {
-  if (
-    sort == "TopAll" ||
-    sort == "TopDay" ||
-    sort == "TopWeek" ||
-    sort == "TopMonth" ||
-    sort == "TopYear"
-  ) {
-    return "Top";
-  } else if (sort == "New") {
-    return "New";
-  } else if (sort == "Hot" || sort == "Active") {
-    return "Hot";
-  } else {
-    return "Hot";
-  }
+    switch(sort) {
+        case "TopAll":
+        case "TopHour":
+        case "TopSixHour":
+        case "TopTwelveHour":
+        case "TopDay":
+        case "TopWeek":
+        case "TopMonth":
+        case "TopYear": {
+            return "Top";
+        }
+        case "New": {
+            return "New";
+        }
+        case "Hot":
+        case "Active": {
+            return "Hot";
+        }
+        default: {
+            return "Hot";
+        }
+    }
 }
 
 export function buildCommentsTree(
   comments: CommentView[],
   parentComment: boolean
 ): CommentNodeI[] {
-  let map = new Map<number, CommentNodeI>();
-  let depthOffset = !parentComment
+  const map = new Map<number, CommentNodeI>();
+  const depthOffset = !parentComment
     ? 0
     : getDepthFromComment(comments[0].comment) ?? 0;
 
-  for (let comment_view of comments) {
-    let depthI = getDepthFromComment(comment_view.comment) ?? 0;
-    let depth = depthI ? depthI - depthOffset : 0;
-    let node: CommentNodeI = {
+  for (const comment_view of comments) {
+    const depthI = getDepthFromComment(comment_view.comment) ?? 0;
+    const depth = depthI ? depthI - depthOffset : 0;
+    const node: CommentNodeI = {
       comment_view,
       children: [],
       depth,
@@ -1122,22 +897,22 @@ export function buildCommentsTree(
     map.set(comment_view.comment.id, { ...node });
   }
 
-  let tree: CommentNodeI[] = [];
+  const tree: CommentNodeI[] = [];
 
   // if its a parent comment fetch, then push the first comment to the top node.
   if (parentComment) {
-    let cNode = map.get(comments[0].comment.id);
+    const cNode = map.get(comments[0].comment.id);
     if (cNode) {
       tree.push(cNode);
     }
   }
 
-  for (let comment_view of comments) {
-    let child = map.get(comment_view.comment.id);
+  for (const comment_view of comments) {
+    const child = map.get(comment_view.comment.id);
     if (child) {
-      let parent_id = getCommentParentId(comment_view.comment);
+      const parent_id = getCommentParentId(comment_view.comment);
       if (parent_id) {
-        let parent = map.get(parent_id);
+        const parent = map.get(parent_id);
         // Necessary because blocked comment might not exist
         if (parent) {
           parent.children.push(child);
@@ -1154,7 +929,7 @@ export function buildCommentsTree(
 }
 
 export function getCommentParentId(comment?: CommentI): number | undefined {
-  let split = comment?.path.split(".");
+  const split = comment?.path.split(".");
   // remove the 0
   split?.shift();
 
@@ -1164,25 +939,26 @@ export function getCommentParentId(comment?: CommentI): number | undefined {
 }
 
 export function getDepthFromComment(comment?: CommentI): number | undefined {
-  let len = comment?.path.split(".").length;
+  const len = comment?.path.split(".").length;
   return len ? len - 2 : undefined;
 }
 
+// TODO make immutable
 export function insertCommentIntoTree(
   tree: CommentNodeI[],
   cv: CommentView,
   parentComment: boolean
 ) {
   // Building a fake node to be used for later
-  let node: CommentNodeI = {
+  const node: CommentNodeI = {
     comment_view: cv,
     children: [],
     depth: 0,
   };
 
-  let parentId = getCommentParentId(cv.comment);
+  const parentId = getCommentParentId(cv.comment);
   if (parentId) {
-    let parent_comment = searchCommentTree(tree, parentId);
+    const parent_comment = searchCommentTree(tree, parentId);
     if (parent_comment) {
       node.depth = parent_comment.depth + 1;
       parent_comment.children.unshift(node);
@@ -1196,13 +972,13 @@ export function searchCommentTree(
   tree: CommentNodeI[],
   id: number
 ): CommentNodeI | undefined {
-  for (let node of tree) {
+  for (const node of tree) {
     if (node.comment_view.comment.id === id) {
       return node;
     }
 
     for (const child of node.children) {
-      let res = searchCommentTree([child], id);
+      const res = searchCommentTree([child], id);
 
       if (res) {
         return res;
@@ -1223,11 +999,11 @@ export const colorList: string[] = [
 ];
 
 function hsl(num: number) {
-  return `hsla(${num}, 35%, 50%, 1)`;
+  return `hsla(${num}, 35%, 50%, 0.5)`;
 }
 
 export function hostname(url: string): string {
-  let cUrl = new URL(url);
+  const cUrl = new URL(url);
   return cUrl.port ? `${cUrl.hostname}:${cUrl.port}` : `${cUrl.hostname}`;
 }
 
@@ -1253,40 +1029,13 @@ export function siteBannerCss(banner: string): string {
     `;
 }
 
-export function isBrowser() {
-  return typeof window !== "undefined";
-}
-
-export function setIsoData(context: any): IsoData {
+export function setIsoData<T extends RouteData>(context: any): IsoData<T> {
   // If its the browser, you need to deserialize the data from the window
   if (isBrowser()) {
-    let json = window.isoData;
-    let routeData = json.routeData;
-    let site_res = json.site_res;
-
-    let isoData: IsoData = {
-      path: json.path,
-      site_res,
-      routeData,
-    };
-    return isoData;
+    return window.isoData;
   } else return context.router.staticContext;
 }
 
-export function wsSubscribe(parseMessage: any): Subscription | undefined {
-  if (isBrowser()) {
-    return WebSocketService.Instance.subject
-      .pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
-      .subscribe(
-        msg => parseMessage(msg),
-        err => console.error(err),
-        () => console.log("complete")
-      );
-  } else {
-    return undefined;
-  }
-}
-
 moment.updateLocale("en", {
   relativeTime: {
     future: "in %s",
@@ -1309,14 +1058,14 @@ moment.updateLocale("en", {
 });
 
 export function saveScrollPosition(context: any) {
-  let path: string = context.router.route.location.pathname;
-  let y = window.scrollY;
+  const path: string = context.router.route.location.pathname;
+  const y = window.scrollY;
   sessionStorage.setItem(`scrollPosition_${path}`, y.toString());
 }
 
 export function restoreScrollPosition(context: any) {
-  let path: string = context.router.route.location.pathname;
-  let y = Number(sessionStorage.getItem(`scrollPosition_${path}`));
+  const path: string = context.router.route.location.pathname;
+  const y = Number(sessionStorage.getItem(`scrollPosition_${path}`));
   window.scrollTo(0, y);
 }
 
@@ -1350,32 +1099,30 @@ export function personToChoice(pvs: PersonView): Choice {
   };
 }
 
-export async function fetchCommunities(q: string) {
-  let form: Search = {
+function fetchSearchResults(q: string, type_: SearchType) {
+  const form: Search = {
     q,
-    type_: "Communities",
+    type_,
     sort: "TopAll",
     listing_type: "All",
     page: 1,
     limit: fetchLimit,
-    auth: myAuth(false),
+    auth: myAuth(),
   };
-  let client = new LemmyHttp(getHttpBase());
-  return client.search(form);
+
+  return HttpService.client.search(form);
+}
+
+export async function fetchCommunities(q: string) {
+  const res = await fetchSearchResults(q, "Communities");
+
+  return res.state === "success" ? res.data.communities : [];
 }
 
 export async function fetchUsers(q: string) {
-  let form: Search = {
-    q,
-    type_: "Users",
-    sort: "TopAll",
-    listing_type: "All",
-    page: 1,
-    limit: fetchLimit,
-    auth: myAuth(false),
-  };
-  let client = new LemmyHttp(getHttpBase());
-  return client.search(form);
+  const res = await fetchSearchResults(q, "Users");
+
+  return res.state === "success" ? res.data.users : [];
 }
 
 export function communitySelectName(cv: CommunityView): string {
@@ -1391,10 +1138,12 @@ export function personSelectName({
   return local ? pName : `${hostname(actor_id)}/${pName}`;
 }
 
-export function initializeSite(site: GetSiteResponse) {
-  UserService.Instance.myUserInfo = site.my_user;
-  i18n.changeLanguage(getLanguages()[0]);
-  setupEmojiDataModel(site.custom_emojis);
+export function initializeSite(site?: GetSiteResponse) {
+  UserService.Instance.myUserInfo = site?.my_user;
+  i18n.changeLanguage();
+  if (site) {
+    setupEmojiDataModel(site.custom_emojis ?? []);
+  }
   setupMarkdown();
 }
 
@@ -1409,23 +1158,12 @@ export function numToSI(value: number): string {
   return SHORTNUM_SI_FORMAT.format(value);
 }
 
-export function isBanned(ps: Person): boolean {
-  let expires = ps.ban_expires;
-  // Add Z to convert from UTC date
-  // TODO this check probably isn't necessary anymore
-  if (expires) {
-    if (ps.banned && new Date(expires + "Z") > new Date()) {
-      return true;
-    } else {
-      return false;
-    }
-  } else {
-    return ps.banned;
-  }
+export function myAuth(): string | undefined {
+  return UserService.Instance.auth();
 }
 
-export function myAuth(throwErr = true): string | undefined {
-  return UserService.Instance.auth(throwErr);
+export function myAuthRequired(): string {
+  return UserService.Instance.auth(true) ?? "";
 }
 
 export function enableDownvotes(siteRes: GetSiteResponse): boolean {
@@ -1451,15 +1189,6 @@ export function postToCommentSortType(sort: SortType): CommentSortType {
   }
 }
 
-export function canCreateCommunity(
-  siteRes: GetSiteResponse,
-  myUserInfo = UserService.Instance.myUserInfo
-): boolean {
-  const adminOnly = siteRes.site_view.local_site.community_creation_admin_only;
-  // TODO: Make this check if user is logged on as well
-  return !adminOnly || amAdmin(myUserInfo);
-}
-
 export function isPostBlocked(
   pv: PostView,
   myUserInfo: MyUserInfo | undefined = UserService.Instance.myUserInfo
@@ -1480,8 +1209,8 @@ export function nsfwCheck(
   pv: PostView,
   myUserInfo = UserService.Instance.myUserInfo
 ): boolean {
-  let nsfw = pv.post.nsfw || pv.community.nsfw;
-  let myShowNsfw = myUserInfo?.local_user_view.local_user.show_nsfw ?? false;
+  const nsfw = pv.post.nsfw || pv.community.nsfw;
+  const myShowNsfw = myUserInfo?.local_user_view.local_user.show_nsfw ?? false;
   return !nsfw || (nsfw && myShowNsfw);
 }
 
@@ -1505,10 +1234,10 @@ export function selectableLanguages(
   showSite?: boolean,
   myUserInfo = UserService.Instance.myUserInfo
 ): Language[] {
-  let allLangIds = allLanguages.map(l => l.id);
+  const allLangIds = allLanguages.map(l => l.id);
   let myLangs = myUserInfo?.discussion_languages ?? allLangIds;
   myLangs = myLangs.length == 0 ? allLangIds : myLangs;
-  let siteLangs = siteLanguages.length == 0 ? allLangIds : siteLanguages;
+  const siteLangs = siteLanguages.length == 0 ? allLangIds : siteLanguages;
 
   if (showAll) {
     return allLanguages;
@@ -1523,12 +1252,6 @@ export function selectableLanguages(
   }
 }
 
-export function uploadImage(image: File): Promise<UploadImageResponse> {
-  const client = new LemmyHttp(getHttpBase());
-
-  return client.uploadImage({ image });
-}
-
 interface EmojiMartCategory {
   id: string;
   name: string;
@@ -1546,60 +1269,20 @@ interface EmojiMartSkin {
   src: string;
 }
 
-const groupBy = <T>(
-  array: T[],
-  predicate: (value: T, index: number, array: T[]) => string
-) =>
-  array.reduce((acc, value, index, array) => {
-    (acc[predicate(value, index, array)] ||= []).push(value);
-    return acc;
-  }, {} as { [key: string]: T[] });
-
-export type QueryParams<T extends Record<string, any>> = {
-  [key in keyof T]?: string;
-};
-
-export function getQueryParams<T extends Record<string, any>>(processors: {
-  [K in keyof T]: (param: string) => T[K];
-}): T {
-  if (isBrowser()) {
-    const searchParams = new URLSearchParams(window.location.search);
-
-    return Array.from(Object.entries(processors)).reduce(
-      (acc, [key, process]) => ({
-        ...acc,
-        [key]: process(searchParams.get(key)),
-      }),
-      {} as T
-    );
-  }
-
-  return {} as T;
-}
-
-export function getQueryString<T extends Record<string, string | undefined>>(
-  obj: T
-) {
-  return Object.entries(obj)
-    .filter(([, val]) => val !== undefined && val !== null)
-    .reduce(
-      (acc, [key, val], index) => `${acc}${index > 0 ? "&" : ""}${key}=${val}`,
-      "?"
-    );
-}
-
 export function isAuthPath(pathname: string) {
-  return /create_.*|inbox|settings|setup|admin|reports|registration_applications/g.test(
+  return /create_.*|inbox|settings|admin|reports|registration_applications/g.test(
     pathname
   );
 }
 
-export function canShare() {
-  return isBrowser() && !!navigator.canShare;
-}
-
-export function share(shareData: ShareData) {
-  if (isBrowser()) {
-    navigator.share(shareData);
+export function newVote(voteType: VoteType, myVote?: number): number {
+  if (voteType == VoteType.Upvote) {
+    return myVote == 1 ? 0 : 1;
+  } else {
+    return myVote == -1 ? 0 : -1;
   }
 }
+
+export type RouteDataResponse<T extends Record<string, any>> = {
+  [K in keyof T]: RequestState<T[K]>;
+};