]> Untitled Git - lemmy-ui.git/blob - src/shared/utils.ts
Moving javascript disabled warning to top.
[lemmy-ui.git] / src / shared / utils.ts
1 import 'moment/locale/es';
2 import 'moment/locale/el';
3 import 'moment/locale/eu';
4 import 'moment/locale/eo';
5 import 'moment/locale/de';
6 import 'moment/locale/zh-cn';
7 import 'moment/locale/fr';
8 import 'moment/locale/sv';
9 import 'moment/locale/ru';
10 import 'moment/locale/nl';
11 import 'moment/locale/it';
12 import 'moment/locale/fi';
13 import 'moment/locale/ca';
14 import 'moment/locale/fa';
15 import 'moment/locale/pl';
16 import 'moment/locale/pt-br';
17 import 'moment/locale/ja';
18 import 'moment/locale/ka';
19 import 'moment/locale/hi';
20 import 'moment/locale/gl';
21 import 'moment/locale/tr';
22 import 'moment/locale/hu';
23 import 'moment/locale/uk';
24 import 'moment/locale/sq';
25 import 'moment/locale/km';
26 import 'moment/locale/ga';
27 import 'moment/locale/sr';
28 import 'moment/locale/ko';
29
30 import {
31   UserOperation,
32   Comment,
33   CommentNode as CommentNodeI,
34   Post,
35   PrivateMessage,
36   User,
37   SortType,
38   ListingType,
39   SearchType,
40   WebSocketResponse,
41   WebSocketJsonResponse,
42   SearchForm,
43   SearchResponse,
44   CommentResponse,
45   PostResponse,
46   LemmyHttp,
47 } from 'lemmy-js-client';
48
49 import { httpUri } from './env';
50
51 import { CommentSortType, DataType, IsoData } from './interfaces';
52 import { UserService, WebSocketService } from './services';
53
54 var Tribute;
55 if (isBrowser()) {
56   Tribute = require('tributejs');
57 }
58 import markdown_it from 'markdown-it';
59 import markdown_it_sub from 'markdown-it-sub';
60 import markdown_it_sup from 'markdown-it-sup';
61 import markdownitEmoji from 'markdown-it-emoji/light';
62 import markdown_it_container from 'markdown-it-container';
63 import emojiShortName from 'emoji-short-name';
64 import Toastify from 'toastify-js';
65 import tippy from 'tippy.js';
66 import moment from 'moment';
67 import { Subscription } from 'rxjs';
68 import { retryWhen, delay, take } from 'rxjs/operators';
69
70 export const favIconUrl = '/static/assets/favicon.svg';
71 export const favIconPngUrl = '/static/assets/apple-touch-icon.png';
72 // TODO
73 // export const defaultFavIcon = `${window.location.protocol}//${window.location.host}${favIconPngUrl}`;
74 export const defaultFavIcon = 'test';
75 export const repoUrl = 'https://github.com/LemmyNet/lemmy';
76 export const helpGuideUrl = '/docs/about_guide.html';
77 export const markdownHelpUrl = `${helpGuideUrl}#markdown-guide`;
78 export const sortingHelpUrl = `${helpGuideUrl}#sorting`;
79 export const archiveUrl = 'https://archive.is';
80 export const elementUrl = 'https://element.io/';
81
82 export const postRefetchSeconds: number = 60 * 1000;
83 export const fetchLimit: number = 20;
84 export const mentionDropdownFetchLimit = 10;
85
86 export const lemmyHttp = new LemmyHttp(httpUri);
87
88 export const languages = [
89   { code: 'ca', name: 'Català' },
90   { code: 'en', name: 'English' },
91   { code: 'el', name: 'Ελληνικά' },
92   { code: 'eu', name: 'Euskara' },
93   { code: 'eo', name: 'Esperanto' },
94   { code: 'es', name: 'Español' },
95   { code: 'de', name: 'Deutsch' },
96   { code: 'ga', name: 'Gaeilge' },
97   { code: 'gl', name: 'Galego' },
98   { code: 'hu', name: 'Magyar Nyelv' },
99   { code: 'ka', name: 'ქართული ენა' },
100   { code: 'ko', name: '한국어' },
101   { code: 'km', name: 'ភាសាខ្មែរ' },
102   { code: 'hi', name: 'मानक हिन्दी' },
103   { code: 'fa', name: 'فارسی' },
104   { code: 'ja', name: '日本語' },
105   { code: 'pl', name: 'Polski' },
106   { code: 'pt_BR', name: 'Português Brasileiro' },
107   { code: 'zh', name: '中文' },
108   { code: 'fi', name: 'Suomi' },
109   { code: 'fr', name: 'Français' },
110   { code: 'sv', name: 'Svenska' },
111   { code: 'sq', name: 'Shqip' },
112   { code: 'sr_Latn', name: 'srpski' },
113   { code: 'tr', name: 'Türkçe' },
114   { code: 'uk', name: 'Українська Mова' },
115   { code: 'ru', name: 'Русский' },
116   { code: 'nl', name: 'Nederlands' },
117   { code: 'it', name: 'Italiano' },
118 ];
119
120 export const themes = [
121   'litera',
122   'materia',
123   'minty',
124   'solar',
125   'united',
126   'cyborg',
127   'darkly',
128   'journal',
129   'sketchy',
130   'vaporwave',
131   'vaporwave-dark',
132   'i386',
133   'litely',
134 ];
135
136 const DEFAULT_ALPHABET =
137   'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
138
139 function getRandomCharFromAlphabet(alphabet: string): string {
140   return alphabet.charAt(Math.floor(Math.random() * alphabet.length));
141 }
142
143 export function randomStr(
144   idDesiredLength: number = 20,
145   alphabet = DEFAULT_ALPHABET
146 ): string {
147   /**
148    * Create n-long array and map it to random chars from given alphabet.
149    * Then join individual chars as string
150    */
151   return Array.from({ length: idDesiredLength })
152     .map(() => {
153       return getRandomCharFromAlphabet(alphabet);
154     })
155     .join('');
156 }
157
158 export function wsJsonToRes(msg: WebSocketJsonResponse): WebSocketResponse {
159   let opStr: string = msg.op;
160   return {
161     op: UserOperation[opStr],
162     data: msg.data,
163   };
164 }
165
166 export const md = new markdown_it({
167   html: false,
168   linkify: true,
169   typographer: true,
170 })
171   .use(markdown_it_sub)
172   .use(markdown_it_sup)
173   .use(markdown_it_container, 'spoiler', {
174     validate: function (params: any) {
175       return params.trim().match(/^spoiler\s+(.*)$/);
176     },
177
178     render: function (tokens: any, idx: any) {
179       var m = tokens[idx].info.trim().match(/^spoiler\s+(.*)$/);
180
181       if (tokens[idx].nesting === 1) {
182         // opening tag
183         return `<details><summary> ${md.utils.escapeHtml(m[1])} </summary>\n`;
184       } else {
185         // closing tag
186         return '</details>\n';
187       }
188     },
189   })
190   .use(markdownitEmoji, {
191     defs: objectFlip(emojiShortName),
192   });
193
194 export function hotRankComment(comment: Comment): number {
195   return hotRank(comment.score, comment.published);
196 }
197
198 export function hotRankPost(post: Post): number {
199   return hotRank(post.score, post.newest_activity_time);
200 }
201
202 export function hotRank(score: number, timeStr: string): number {
203   // Rank = ScaleFactor * sign(Score) * log(1 + abs(Score)) / (Time + 2)^Gravity
204   let date: Date = new Date(timeStr + 'Z'); // Add Z to convert from UTC date
205   let now: Date = new Date();
206   let hoursElapsed: number = (now.getTime() - date.getTime()) / 36e5;
207
208   let rank =
209     (10000 * Math.log10(Math.max(1, 3 + score))) /
210     Math.pow(hoursElapsed + 2, 1.8);
211
212   // console.log(`Comment: ${comment.content}\nRank: ${rank}\nScore: ${comment.score}\nHours: ${hoursElapsed}`);
213
214   return rank;
215 }
216
217 export function mdToHtml(text: string) {
218   return { __html: md.render(text) };
219 }
220
221 export function getUnixTime(text: string): number {
222   return text ? new Date(text).getTime() / 1000 : undefined;
223 }
224
225 export function addTypeInfo<T>(
226   arr: T[],
227   name: string
228 ): { type_: string; data: T }[] {
229   return arr.map(e => {
230     return { type_: name, data: e };
231   });
232 }
233
234 export function canMod(
235   user: User,
236   modIds: number[],
237   creator_id: number,
238   onSelf: boolean = false
239 ): boolean {
240   // You can do moderator actions only on the mods added after you.
241   if (user) {
242     let yourIndex = modIds.findIndex(id => id == user.id);
243     if (yourIndex == -1) {
244       return false;
245     } else {
246       // onSelf +1 on mod actions not for yourself, IE ban, remove, etc
247       modIds = modIds.slice(0, yourIndex + (onSelf ? 0 : 1));
248       return !modIds.includes(creator_id);
249     }
250   } else {
251     return false;
252   }
253 }
254
255 export function isMod(modIds: number[], creator_id: number): boolean {
256   return modIds.includes(creator_id);
257 }
258
259 const imageRegex = new RegExp(
260   /(http)?s?:?(\/\/[^"']*\.(?:jpg|jpeg|gif|png|svg|webp))/
261 );
262 const videoRegex = new RegExp(`(http)?s?:?(\/\/[^"']*\.(?:mp4))`);
263
264 export function isImage(url: string) {
265   return imageRegex.test(url);
266 }
267
268 export function isVideo(url: string) {
269   return videoRegex.test(url);
270 }
271
272 // TODO this broke
273 export function validURL(str: string) {
274   // try {
275   return !!new URL(str);
276   // } catch {
277   // return false;
278   // }
279 }
280
281 export function validEmail(email: string) {
282   let re = /^(([^\s"(),.:;<>@[\\\]]+(\.[^\s"(),.:;<>@[\\\]]+)*)|(".+"))@((\[(?:\d{1,3}\.){3}\d{1,3}])|(([\dA-Za-z\-]+\.)+[A-Za-z]{2,}))$/;
283   return re.test(String(email).toLowerCase());
284 }
285
286 export function capitalizeFirstLetter(str: string): string {
287   return str.charAt(0).toUpperCase() + str.slice(1);
288 }
289
290 export function routeSortTypeToEnum(sort: string): SortType {
291   return SortType[sort];
292 }
293
294 export function routeListingTypeToEnum(type: string): ListingType {
295   return ListingType[type];
296 }
297
298 export function routeDataTypeToEnum(type: string): DataType {
299   return DataType[capitalizeFirstLetter(type)];
300 }
301
302 export function routeSearchTypeToEnum(type: string): SearchType {
303   return SearchType[type];
304 }
305
306 export async function getPageTitle(url: string) {
307   let res = await fetch(`/iframely/oembed?url=${url}`).then(res => res.json());
308   let title = await res.title;
309   return title;
310 }
311
312 export function debounce(
313   func: any,
314   wait: number = 1000,
315   immediate: boolean = false
316 ) {
317   // 'private' variable for instance
318   // The returned function will be able to reference this due to closure.
319   // Each call to the returned function will share this common timer.
320   let timeout: any;
321
322   // Calling debounce returns a new anonymous function
323   return function () {
324     // reference the context and args for the setTimeout function
325     var context = this,
326       args = arguments;
327
328     // Should the function be called now? If immediate is true
329     //   and not already in a timeout then the answer is: Yes
330     var callNow = immediate && !timeout;
331
332     // This is the basic debounce behaviour where you can call this
333     //   function several times, but it will only execute once
334     //   [before or after imposing a delay].
335     //   Each time the returned function is called, the timer starts over.
336     clearTimeout(timeout);
337
338     // Set the new timeout
339     timeout = setTimeout(function () {
340       // Inside the timeout function, clear the timeout variable
341       // which will let the next execution run when in 'immediate' mode
342       timeout = null;
343
344       // Check if the function already ran with the immediate flag
345       if (!immediate) {
346         // Call the original function with apply
347         // apply lets you define the 'this' object as well as the arguments
348         //    (both captured before setTimeout)
349         func.apply(context, args);
350       }
351     }, wait);
352
353     // Immediate mode and no wait timer? Execute the function..
354     if (callNow) func.apply(context, args);
355   };
356 }
357
358 // TODO
359 export function getLanguage(override?: string): string {
360   let user = UserService.Instance.user;
361   let lang = override || (user && user.lang ? user.lang : 'browser');
362
363   if (lang == 'browser' && isBrowser()) {
364     return getBrowserLanguage();
365   } else {
366     return lang;
367   }
368 }
369
370 // TODO
371 export function getBrowserLanguage(): string {
372   return navigator.language;
373 }
374
375 export function getMomentLanguage(): string {
376   let lang = getLanguage();
377   if (lang.startsWith('zh')) {
378     lang = 'zh-cn';
379   } else if (lang.startsWith('sv')) {
380     lang = 'sv';
381   } else if (lang.startsWith('fr')) {
382     lang = 'fr';
383   } else if (lang.startsWith('de')) {
384     lang = 'de';
385   } else if (lang.startsWith('ru')) {
386     lang = 'ru';
387   } else if (lang.startsWith('es')) {
388     lang = 'es';
389   } else if (lang.startsWith('eo')) {
390     lang = 'eo';
391   } else if (lang.startsWith('nl')) {
392     lang = 'nl';
393   } else if (lang.startsWith('it')) {
394     lang = 'it';
395   } else if (lang.startsWith('fi')) {
396     lang = 'fi';
397   } else if (lang.startsWith('ca')) {
398     lang = 'ca';
399   } else if (lang.startsWith('fa')) {
400     lang = 'fa';
401   } else if (lang.startsWith('pl')) {
402     lang = 'pl';
403   } else if (lang.startsWith('pt')) {
404     lang = 'pt-br';
405   } else if (lang.startsWith('ja')) {
406     lang = 'ja';
407   } else if (lang.startsWith('ka')) {
408     lang = 'ka';
409   } else if (lang.startsWith('hi')) {
410     lang = 'hi';
411   } else if (lang.startsWith('el')) {
412     lang = 'el';
413   } else if (lang.startsWith('eu')) {
414     lang = 'eu';
415   } else if (lang.startsWith('gl')) {
416     lang = 'gl';
417   } else if (lang.startsWith('tr')) {
418     lang = 'tr';
419   } else if (lang.startsWith('hu')) {
420     lang = 'hu';
421   } else if (lang.startsWith('uk')) {
422     lang = 'uk';
423   } else if (lang.startsWith('sq')) {
424     lang = 'sq';
425   } else if (lang.startsWith('km')) {
426     lang = 'km';
427   } else if (lang.startsWith('ga')) {
428     lang = 'ga';
429   } else if (lang.startsWith('sr')) {
430     lang = 'sr';
431   } else if (lang.startsWith('ko')) {
432     lang = 'ko';
433   } else {
434     lang = 'en';
435   }
436   return lang;
437 }
438
439 export function setTheme(theme: string, forceReload: boolean = false) {
440   if (isBrowser() && (theme !== 'darkly' || forceReload)) {
441     // Unload all the other themes
442     for (var i = 0; i < themes.length; i++) {
443       let styleSheet = document.getElementById(themes[i]);
444       if (styleSheet) {
445         styleSheet.setAttribute('disabled', 'disabled');
446       }
447     }
448
449     document
450       .getElementById('default-light')
451       .setAttribute('disabled', 'disabled');
452     document
453       .getElementById('default-dark')
454       .setAttribute('disabled', 'disabled');
455     // Load the theme dynamically
456     let cssLoc = `/static/assets/css/themes/${theme}.min.css`;
457     loadCss(theme, cssLoc);
458     document.getElementById(theme).removeAttribute('disabled');
459   }
460 }
461
462 export function loadCss(id: string, loc: string) {
463   if (!document.getElementById(id)) {
464     var head = document.getElementsByTagName('head')[0];
465     var link = document.createElement('link');
466     link.id = id;
467     link.rel = 'stylesheet';
468     link.type = 'text/css';
469     link.href = loc;
470     link.media = 'all';
471     head.appendChild(link);
472   }
473 }
474
475 export function objectFlip(obj: any) {
476   const ret = {};
477   Object.keys(obj).forEach(key => {
478     ret[obj[key]] = key;
479   });
480   return ret;
481 }
482
483 export function pictrsAvatarThumbnail(src: string): string {
484   // sample url: http://localhost:8535/pictrs/image/thumbnail256/gs7xuu.jpg
485   let split = src.split('/pictrs/image');
486   let out = `${split[0]}/pictrs/image/${
487     canUseWebP() ? 'webp/' : ''
488   }thumbnail96${split[1]}`;
489   return out;
490 }
491
492 export function showAvatars(): boolean {
493   return (
494     (UserService.Instance.user && UserService.Instance.user.show_avatars) ||
495     !UserService.Instance.user
496   );
497 }
498
499 export function isCakeDay(published: string): boolean {
500   // moment(undefined) or moment.utc(undefined) returns the current date/time
501   // moment(null) or moment.utc(null) returns null
502   const userCreationDate = moment.utc(published || null).local();
503   const currentDate = moment(new Date());
504
505   return (
506     userCreationDate.date() === currentDate.date() &&
507     userCreationDate.month() === currentDate.month() &&
508     userCreationDate.year() !== currentDate.year()
509   );
510 }
511
512 // Converts to image thumbnail
513 export function pictrsImage(hash: string, thumbnail: boolean = false): string {
514   let root = `/pictrs/image`;
515
516   // Necessary for other servers / domains
517   if (hash.includes('pictrs')) {
518     let split = hash.split('/pictrs/image/');
519     root = `${split[0]}/pictrs/image`;
520     hash = split[1];
521   }
522
523   let out = `${root}/${canUseWebP() ? 'webp/' : ''}${
524     thumbnail ? 'thumbnail256/' : ''
525   }${hash}`;
526   return out;
527 }
528
529 export function isCommentType(
530   item: Comment | PrivateMessage | Post
531 ): item is Comment {
532   return (
533     (item as Comment).community_id !== undefined &&
534     (item as Comment).content !== undefined
535   );
536 }
537
538 export function isPostType(
539   item: Comment | PrivateMessage | Post
540 ): item is Post {
541   return (item as Post).stickied !== undefined;
542 }
543
544 export function toast(text: string, background: string = 'success') {
545   let backgroundColor = `var(--${background})`;
546   Toastify({
547     text: text,
548     backgroundColor: backgroundColor,
549     gravity: 'bottom',
550     position: 'left',
551   }).showToast();
552 }
553
554 export function pictrsDeleteToast(
555   clickToDeleteText: string,
556   deletePictureText: string,
557   deleteUrl: string
558 ) {
559   let backgroundColor = `var(--light)`;
560   let toast = Toastify({
561     text: clickToDeleteText,
562     backgroundColor: backgroundColor,
563     gravity: 'top',
564     position: 'right',
565     duration: 10000,
566     onClick: () => {
567       if (toast) {
568         window.location.replace(deleteUrl);
569         alert(deletePictureText);
570         toast.hideToast();
571       }
572     },
573     close: true,
574   }).showToast();
575 }
576
577 interface NotifyInfo {
578   name: string;
579   icon: string;
580   link: string;
581   body: string;
582 }
583
584 export function messageToastify(info: NotifyInfo, router: any) {
585   let htmlBody = info.body ? md.render(info.body) : '';
586   let backgroundColor = `var(--light)`;
587
588   let toast = Toastify({
589     text: `${htmlBody}<br />${info.name}`,
590     avatar: info.icon,
591     backgroundColor: backgroundColor,
592     className: 'text-dark',
593     close: true,
594     gravity: 'top',
595     position: 'right',
596     duration: 5000,
597     onClick: () => {
598       if (toast) {
599         toast.hideToast();
600         router.history.push(info.link);
601       }
602     },
603   }).showToast();
604 }
605
606 export function notifyPost(post: Post, router: any) {
607   let info: NotifyInfo = {
608     name: post.community_name,
609     icon: post.community_icon ? post.community_icon : defaultFavIcon,
610     link: `/post/${post.id}`,
611     body: post.name,
612   };
613   notify(info, router);
614 }
615
616 export function notifyComment(comment: Comment, router: any) {
617   let info: NotifyInfo = {
618     name: comment.creator_name,
619     icon: comment.creator_avatar ? comment.creator_avatar : defaultFavIcon,
620     link: `/post/${comment.post_id}/comment/${comment.id}`,
621     body: comment.content,
622   };
623   notify(info, router);
624 }
625
626 export function notifyPrivateMessage(pm: PrivateMessage, router: any) {
627   let info: NotifyInfo = {
628     name: pm.creator_name,
629     icon: pm.creator_avatar ? pm.creator_avatar : defaultFavIcon,
630     link: `/inbox`,
631     body: pm.content,
632   };
633   notify(info, router);
634 }
635
636 function notify(info: NotifyInfo, router: any) {
637   messageToastify(info, router);
638
639   if (Notification.permission !== 'granted') Notification.requestPermission();
640   else {
641     var notification = new Notification(info.name, {
642       icon: info.icon,
643       body: info.body,
644     });
645
646     notification.onclick = () => {
647       event.preventDefault();
648       router.history.push(info.link);
649     };
650   }
651 }
652
653 export function setupTribute() {
654   return new Tribute({
655     noMatchTemplate: function () {
656       return '';
657     },
658     collection: [
659       // Emojis
660       {
661         trigger: ':',
662         menuItemTemplate: (item: any) => {
663           let shortName = `:${item.original.key}:`;
664           return `${item.original.val} ${shortName}`;
665         },
666         selectTemplate: (item: any) => {
667           return `:${item.original.key}:`;
668         },
669         values: Object.entries(emojiShortName).map(e => {
670           return { key: e[1], val: e[0] };
671         }),
672         allowSpaces: false,
673         autocompleteMode: true,
674         // TODO
675         // menuItemLimit: mentionDropdownFetchLimit,
676         menuShowMinLength: 2,
677       },
678       // Users
679       {
680         trigger: '@',
681         selectTemplate: (item: any) => {
682           let link = item.original.local
683             ? `[${item.original.key}](/u/${item.original.name})`
684             : `[${item.original.key}](/user/${item.original.id})`;
685           return link;
686         },
687         values: (text: string, cb: any) => {
688           userSearch(text, (users: any) => cb(users));
689         },
690         allowSpaces: false,
691         autocompleteMode: true,
692         // TODO
693         // menuItemLimit: mentionDropdownFetchLimit,
694         menuShowMinLength: 2,
695       },
696
697       // Communities
698       {
699         trigger: '!',
700         selectTemplate: (item: any) => {
701           let link = item.original.local
702             ? `[${item.original.key}](/c/${item.original.name})`
703             : `[${item.original.key}](/community/${item.original.id})`;
704           return link;
705         },
706         values: (text: string, cb: any) => {
707           communitySearch(text, (communities: any) => cb(communities));
708         },
709         allowSpaces: false,
710         autocompleteMode: true,
711         // TODO
712         // menuItemLimit: mentionDropdownFetchLimit,
713         menuShowMinLength: 2,
714       },
715     ],
716   });
717 }
718
719 var tippyInstance;
720 if (isBrowser()) {
721   tippyInstance = tippy('[data-tippy-content]');
722 }
723
724 export function setupTippy() {
725   if (isBrowser()) {
726     tippyInstance.forEach(e => e.destroy());
727     tippyInstance = tippy('[data-tippy-content]', {
728       delay: [500, 0],
729       // Display on "long press"
730       touch: ['hold', 500],
731     });
732   }
733 }
734
735 function userSearch(text: string, cb: any) {
736   if (text) {
737     let form: SearchForm = {
738       q: text,
739       type_: SearchType.Users,
740       sort: SortType.TopAll,
741       page: 1,
742       limit: mentionDropdownFetchLimit,
743     };
744
745     WebSocketService.Instance.search(form);
746
747     let userSub = WebSocketService.Instance.subject.subscribe(
748       msg => {
749         let res = wsJsonToRes(msg);
750         if (res.op == UserOperation.Search) {
751           let data = res.data as SearchResponse;
752           let users = data.users.map(u => {
753             return {
754               key: `@${u.name}@${hostname(u.actor_id)}`,
755               name: u.name,
756               local: u.local,
757               id: u.id,
758             };
759           });
760           cb(users);
761           userSub.unsubscribe();
762         }
763       },
764       err => console.error(err),
765       () => console.log('complete')
766     );
767   } else {
768     cb([]);
769   }
770 }
771
772 function communitySearch(text: string, cb: any) {
773   if (text) {
774     let form: SearchForm = {
775       q: text,
776       type_: SearchType.Communities,
777       sort: SortType.TopAll,
778       page: 1,
779       limit: mentionDropdownFetchLimit,
780     };
781
782     WebSocketService.Instance.search(form);
783
784     let communitySub = WebSocketService.Instance.subject.subscribe(
785       msg => {
786         let res = wsJsonToRes(msg);
787         if (res.op == UserOperation.Search) {
788           let data = res.data as SearchResponse;
789           let communities = data.communities.map(c => {
790             return {
791               key: `!${c.name}@${hostname(c.actor_id)}`,
792               name: c.name,
793               local: c.local,
794               id: c.id,
795             };
796           });
797           cb(communities);
798           communitySub.unsubscribe();
799         }
800       },
801       err => console.error(err),
802       () => console.log('complete')
803     );
804   } else {
805     cb([]);
806   }
807 }
808
809 export function getListingTypeFromProps(props: any): ListingType {
810   return props.match.params.listing_type
811     ? routeListingTypeToEnum(props.match.params.listing_type)
812     : UserService.Instance.user
813     ? Object.values(ListingType)[UserService.Instance.user.default_listing_type]
814     : ListingType.All;
815 }
816
817 // TODO might need to add a user setting for this too
818 export function getDataTypeFromProps(props: any): DataType {
819   return props.match.params.data_type
820     ? routeDataTypeToEnum(props.match.params.data_type)
821     : DataType.Post;
822 }
823
824 export function getSortTypeFromProps(props: any): SortType {
825   return props.match.params.sort
826     ? routeSortTypeToEnum(props.match.params.sort)
827     : UserService.Instance.user
828     ? Object.values(SortType)[UserService.Instance.user.default_sort_type]
829     : SortType.Active;
830 }
831
832 export function getPageFromProps(props: any): number {
833   return props.match.params.page ? Number(props.match.params.page) : 1;
834 }
835
836 export function getRecipientIdFromProps(props: any): number {
837   return props.match.params.recipient_id
838     ? Number(props.match.params.recipient_id)
839     : 1;
840 }
841
842 export function getIdFromProps(props: any): number {
843   return Number(props.match.params.id);
844 }
845
846 export function getCommentIdFromProps(props: any): number {
847   return Number(props.match.params.comment_id);
848 }
849
850 export function getUsernameFromProps(props: any): string {
851   return props.match.params.username;
852 }
853
854 export function editCommentRes(data: CommentResponse, comments: Comment[]) {
855   let found = comments.find(c => c.id == data.comment.id);
856   if (found) {
857     found.content = data.comment.content;
858     found.updated = data.comment.updated;
859     found.removed = data.comment.removed;
860     found.deleted = data.comment.deleted;
861     found.upvotes = data.comment.upvotes;
862     found.downvotes = data.comment.downvotes;
863     found.score = data.comment.score;
864   }
865 }
866
867 export function saveCommentRes(data: CommentResponse, comments: Comment[]) {
868   let found = comments.find(c => c.id == data.comment.id);
869   if (found) {
870     found.saved = data.comment.saved;
871   }
872 }
873
874 export function createCommentLikeRes(
875   data: CommentResponse,
876   comments: Comment[]
877 ) {
878   let found: Comment = comments.find(c => c.id === data.comment.id);
879   if (found) {
880     found.score = data.comment.score;
881     found.upvotes = data.comment.upvotes;
882     found.downvotes = data.comment.downvotes;
883     if (data.comment.my_vote !== null) {
884       found.my_vote = data.comment.my_vote;
885     }
886   }
887 }
888
889 export function createPostLikeFindRes(data: PostResponse, posts: Post[]) {
890   let found = posts.find(c => c.id == data.post.id);
891   if (found) {
892     createPostLikeRes(data, found);
893   }
894 }
895
896 export function createPostLikeRes(data: PostResponse, post: Post) {
897   if (post) {
898     post.score = data.post.score;
899     post.upvotes = data.post.upvotes;
900     post.downvotes = data.post.downvotes;
901     if (data.post.my_vote !== null) {
902       post.my_vote = data.post.my_vote;
903     }
904   }
905 }
906
907 export function editPostFindRes(data: PostResponse, posts: Post[]) {
908   let found = posts.find(c => c.id == data.post.id);
909   if (found) {
910     editPostRes(data, found);
911   }
912 }
913
914 export function editPostRes(data: PostResponse, post: Post) {
915   if (post) {
916     post.url = data.post.url;
917     post.name = data.post.name;
918     post.nsfw = data.post.nsfw;
919     post.deleted = data.post.deleted;
920     post.removed = data.post.removed;
921     post.stickied = data.post.stickied;
922     post.body = data.post.body;
923     post.locked = data.post.locked;
924   }
925 }
926
927 export function commentsToFlatNodes(comments: Comment[]): CommentNodeI[] {
928   let nodes: CommentNodeI[] = [];
929   for (let comment of comments) {
930     nodes.push({ comment: comment });
931   }
932   return nodes;
933 }
934
935 export function commentSort(tree: CommentNodeI[], sort: CommentSortType) {
936   // First, put removed and deleted comments at the bottom, then do your other sorts
937   if (sort == CommentSortType.Top) {
938     tree.sort(
939       (a, b) =>
940         +a.comment.removed - +b.comment.removed ||
941         +a.comment.deleted - +b.comment.deleted ||
942         b.comment.score - a.comment.score
943     );
944   } else if (sort == CommentSortType.New) {
945     tree.sort(
946       (a, b) =>
947         +a.comment.removed - +b.comment.removed ||
948         +a.comment.deleted - +b.comment.deleted ||
949         b.comment.published.localeCompare(a.comment.published)
950     );
951   } else if (sort == CommentSortType.Old) {
952     tree.sort(
953       (a, b) =>
954         +a.comment.removed - +b.comment.removed ||
955         +a.comment.deleted - +b.comment.deleted ||
956         a.comment.published.localeCompare(b.comment.published)
957     );
958   } else if (sort == CommentSortType.Hot) {
959     tree.sort(
960       (a, b) =>
961         +a.comment.removed - +b.comment.removed ||
962         +a.comment.deleted - +b.comment.deleted ||
963         hotRankComment(b.comment) - hotRankComment(a.comment)
964     );
965   }
966
967   // Go through the children recursively
968   for (let node of tree) {
969     if (node.children) {
970       commentSort(node.children, sort);
971     }
972   }
973 }
974
975 export function commentSortSortType(tree: CommentNodeI[], sort: SortType) {
976   commentSort(tree, convertCommentSortType(sort));
977 }
978
979 function convertCommentSortType(sort: SortType): CommentSortType {
980   if (
981     sort == SortType.TopAll ||
982     sort == SortType.TopDay ||
983     sort == SortType.TopWeek ||
984     sort == SortType.TopMonth ||
985     sort == SortType.TopYear
986   ) {
987     return CommentSortType.Top;
988   } else if (sort == SortType.New) {
989     return CommentSortType.New;
990   } else if (sort == SortType.Hot || sort == SortType.Active) {
991     return CommentSortType.Hot;
992   } else {
993     return CommentSortType.Hot;
994   }
995 }
996
997 export function postSort(
998   posts: Post[],
999   sort: SortType,
1000   communityType: boolean
1001 ) {
1002   // First, put removed and deleted comments at the bottom, then do your other sorts
1003   if (
1004     sort == SortType.TopAll ||
1005     sort == SortType.TopDay ||
1006     sort == SortType.TopWeek ||
1007     sort == SortType.TopMonth ||
1008     sort == SortType.TopYear
1009   ) {
1010     posts.sort(
1011       (a, b) =>
1012         +a.removed - +b.removed ||
1013         +a.deleted - +b.deleted ||
1014         (communityType && +b.stickied - +a.stickied) ||
1015         b.score - a.score
1016     );
1017   } else if (sort == SortType.New) {
1018     posts.sort(
1019       (a, b) =>
1020         +a.removed - +b.removed ||
1021         +a.deleted - +b.deleted ||
1022         (communityType && +b.stickied - +a.stickied) ||
1023         b.published.localeCompare(a.published)
1024     );
1025   } else if (sort == SortType.Hot) {
1026     posts.sort(
1027       (a, b) =>
1028         +a.removed - +b.removed ||
1029         +a.deleted - +b.deleted ||
1030         (communityType && +b.stickied - +a.stickied) ||
1031         b.hot_rank - a.hot_rank
1032     );
1033   } else if (sort == SortType.Active) {
1034     posts.sort(
1035       (a, b) =>
1036         +a.removed - +b.removed ||
1037         +a.deleted - +b.deleted ||
1038         (communityType && +b.stickied - +a.stickied) ||
1039         b.hot_rank_active - a.hot_rank_active
1040     );
1041   }
1042 }
1043
1044 export const colorList: string[] = [
1045   hsl(0),
1046   hsl(100),
1047   hsl(150),
1048   hsl(200),
1049   hsl(250),
1050   hsl(300),
1051 ];
1052
1053 function hsl(num: number) {
1054   return `hsla(${num}, 35%, 50%, 1)`;
1055 }
1056
1057 // function randomHsl() {
1058 //   return `hsla(${Math.random() * 360}, 100%, 50%, 1)`;
1059 // }
1060
1061 export function previewLines(
1062   text: string,
1063   maxChars: number = 300,
1064   maxLines: number = 1
1065 ): string {
1066   return (
1067     text
1068       .slice(0, maxChars)
1069       .split('\n')
1070       // Use lines * 2 because markdown requires 2 lines
1071       .slice(0, maxLines * 2)
1072       .join('\n') + '...'
1073   );
1074 }
1075
1076 export function hostname(url: string): string {
1077   let cUrl = new URL(url);
1078   return cUrl.port ? `${cUrl.hostname}:${cUrl.port}` : `${cUrl.hostname}`;
1079 }
1080
1081 function canUseWebP() {
1082   // TODO pictshare might have a webp conversion bug, try disabling this
1083   return false;
1084
1085   // var elem = document.createElement('canvas');
1086   // if (!!(elem.getContext && elem.getContext('2d'))) {
1087   //   var testString = !(window.mozInnerScreenX == null) ? 'png' : 'webp';
1088   //   // was able or not to get WebP representation
1089   //   return (
1090   //     elem.toDataURL('image/webp').startsWith('data:image/' + testString)
1091   //   );
1092   // }
1093
1094   // // very old browser like IE 8, canvas not supported
1095   // return false;
1096 }
1097
1098 export function validTitle(title?: string): boolean {
1099   // Initial title is null, minimum length is taken care of by textarea's minLength={3}
1100   if (title === null || title.length < 3) return true;
1101
1102   const regex = new RegExp(/.*\S.*/, 'g');
1103
1104   return regex.test(title);
1105 }
1106
1107 export function siteBannerCss(banner: string): string {
1108   return ` \
1109     background-image: linear-gradient( rgba(0, 0, 0, 0.8), rgba(0, 0, 0, 0.8) ) ,url("${banner}"); \
1110     background-attachment: fixed; \
1111     background-position: top; \
1112     background-repeat: no-repeat; \
1113     background-size: 100% cover; \
1114
1115     width: 100%; \
1116     max-height: 100vh; \
1117     `;
1118 }
1119
1120 export function isBrowser() {
1121   return typeof window !== 'undefined';
1122 }
1123
1124 export function setAuth(obj: any, auth: string) {
1125   if (auth) {
1126     obj.auth = auth;
1127   }
1128 }
1129
1130 export function setIsoData(context: any): IsoData {
1131   let isoData: IsoData = isBrowser()
1132     ? window.isoData
1133     : context.router.staticContext;
1134   return isoData;
1135 }
1136
1137 export function wsSubscribe(parseMessage: any): Subscription {
1138   if (isBrowser()) {
1139     return WebSocketService.Instance.subject
1140       .pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
1141       .subscribe(
1142         msg => parseMessage(msg),
1143         err => console.error(err),
1144         () => console.log('complete')
1145       );
1146   } else {
1147     return null;
1148   }
1149 }