]> Untitled Git - lemmy.git/blob - ui/src/utils.ts
Upping thumbnail size.
[lemmy.git] / ui / src / 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
25 import {
26   UserOperation,
27   Comment,
28   CommentNode as CommentNodeI,
29   Post,
30   PrivateMessage,
31   User,
32   SortType,
33   CommentSortType,
34   ListingType,
35   DataType,
36   SearchType,
37   WebSocketResponse,
38   WebSocketJsonResponse,
39   SearchForm,
40   SearchResponse,
41   CommentResponse,
42   PostResponse,
43 } from './interfaces';
44 import { UserService, WebSocketService } from './services';
45
46 import Tribute from 'tributejs/src/Tribute.js';
47 import markdown_it from 'markdown-it';
48 import markdownitEmoji from 'markdown-it-emoji/light';
49 import markdown_it_container from 'markdown-it-container';
50 import twemoji from 'twemoji';
51 import emojiShortName from 'emoji-short-name';
52 import Toastify from 'toastify-js';
53 import tippy from 'tippy.js';
54 import EmojiButton from '@joeattardi/emoji-button';
55
56 export const repoUrl = 'https://github.com/LemmyNet/lemmy';
57 export const helpGuideUrl = '/docs/about_guide.html';
58 export const markdownHelpUrl = `${helpGuideUrl}#markdown-guide`;
59 export const sortingHelpUrl = `${helpGuideUrl}#sorting`;
60 export const archiveUrl = 'https://archive.is';
61
62 export const postRefetchSeconds: number = 60 * 1000;
63 export const fetchLimit: number = 20;
64 export const mentionDropdownFetchLimit = 10;
65
66 export const languages = [
67   { code: 'ca', name: 'Català' },
68   { code: 'en', name: 'English' },
69   { code: 'el', name: 'Ελληνικά' },
70   { code: 'eu', name: 'Euskara' },
71   { code: 'eo', name: 'Esperanto' },
72   { code: 'es', name: 'Español' },
73   { code: 'de', name: 'Deutsch' },
74   { code: 'gl', name: 'Galego' },
75   { code: 'hu', name: 'Magyar Nyelv' },
76   { code: 'ka', name: 'ქართული ენა' },
77   { code: 'hi', name: 'मानक हिन्दी' },
78   { code: 'fa', name: 'فارسی' },
79   { code: 'ja', name: '日本語' },
80   { code: 'pl', name: 'Polski' },
81   { code: 'pt_BR', name: 'Português Brasileiro' },
82   { code: 'zh', name: '中文' },
83   { code: 'fi', name: 'Suomi' },
84   { code: 'fr', name: 'Français' },
85   { code: 'sv', name: 'Svenska' },
86   { code: 'tr', name: 'Türkçe' },
87   { code: 'uk', name: 'українська мова' },
88   { code: 'ru', name: 'Русский' },
89   { code: 'nl', name: 'Nederlands' },
90   { code: 'it', name: 'Italiano' },
91 ];
92
93 export const themes = [
94   'litera',
95   'materia',
96   'minty',
97   'solar',
98   'united',
99   'cyborg',
100   'darkly',
101   'journal',
102   'sketchy',
103   'vaporwave',
104   'vaporwave-dark',
105   'i386',
106 ];
107
108 export const emojiPicker = new EmojiButton({
109   // Use the emojiShortName from native
110   style: 'twemoji',
111   theme: 'dark',
112   position: 'auto-start',
113   // TODO i18n
114 });
115
116 export function randomStr() {
117   return Math.random()
118     .toString(36)
119     .replace(/[^a-z]+/g, '')
120     .substr(2, 10);
121 }
122
123 export function wsJsonToRes(msg: WebSocketJsonResponse): WebSocketResponse {
124   let opStr: string = msg.op;
125   return {
126     op: UserOperation[opStr],
127     data: msg.data,
128   };
129 }
130
131 export const md = new markdown_it({
132   html: false,
133   linkify: true,
134   typographer: true,
135 })
136   .use(markdown_it_container, 'spoiler', {
137     validate: function (params: any) {
138       return params.trim().match(/^spoiler\s+(.*)$/);
139     },
140
141     render: function (tokens: any, idx: any) {
142       var m = tokens[idx].info.trim().match(/^spoiler\s+(.*)$/);
143
144       if (tokens[idx].nesting === 1) {
145         // opening tag
146         return `<details><summary> ${md.utils.escapeHtml(m[1])} </summary>\n`;
147       } else {
148         // closing tag
149         return '</details>\n';
150       }
151     },
152   })
153   .use(markdownitEmoji, {
154     defs: objectFlip(emojiShortName),
155   });
156
157 md.renderer.rules.emoji = function (token, idx) {
158   return twemoji.parse(token[idx].content);
159 };
160
161 export function hotRankComment(comment: Comment): number {
162   return hotRank(comment.score, comment.published);
163 }
164
165 export function hotRankPost(post: Post): number {
166   return hotRank(post.score, post.newest_activity_time);
167 }
168
169 export function hotRank(score: number, timeStr: string): number {
170   // Rank = ScaleFactor * sign(Score) * log(1 + abs(Score)) / (Time + 2)^Gravity
171   let date: Date = new Date(timeStr + 'Z'); // Add Z to convert from UTC date
172   let now: Date = new Date();
173   let hoursElapsed: number = (now.getTime() - date.getTime()) / 36e5;
174
175   let rank =
176     (10000 * Math.log10(Math.max(1, 3 + score))) /
177     Math.pow(hoursElapsed + 2, 1.8);
178
179   // console.log(`Comment: ${comment.content}\nRank: ${rank}\nScore: ${comment.score}\nHours: ${hoursElapsed}`);
180
181   return rank;
182 }
183
184 export function mdToHtml(text: string) {
185   return { __html: md.render(text) };
186 }
187
188 export function getUnixTime(text: string): number {
189   return text ? new Date(text).getTime() / 1000 : undefined;
190 }
191
192 export function addTypeInfo<T>(
193   arr: Array<T>,
194   name: string
195 ): Array<{ type_: string; data: T }> {
196   return arr.map(e => {
197     return { type_: name, data: e };
198   });
199 }
200
201 export function canMod(
202   user: User,
203   modIds: Array<number>,
204   creator_id: number,
205   onSelf: boolean = false
206 ): boolean {
207   // You can do moderator actions only on the mods added after you.
208   if (user) {
209     let yourIndex = modIds.findIndex(id => id == user.id);
210     if (yourIndex == -1) {
211       return false;
212     } else {
213       // onSelf +1 on mod actions not for yourself, IE ban, remove, etc
214       modIds = modIds.slice(0, yourIndex + (onSelf ? 0 : 1));
215       return !modIds.includes(creator_id);
216     }
217   } else {
218     return false;
219   }
220 }
221
222 export function isMod(modIds: Array<number>, creator_id: number): boolean {
223   return modIds.includes(creator_id);
224 }
225
226 const imageRegex = new RegExp(
227   /(http)?s?:?(\/\/[^"']*\.(?:jpg|jpeg|gif|png|svg|webp))/
228 );
229 const videoRegex = new RegExp(`(http)?s?:?(\/\/[^"']*\.(?:mp4))`);
230
231 export function isImage(url: string) {
232   return imageRegex.test(url);
233 }
234
235 export function isVideo(url: string) {
236   return videoRegex.test(url);
237 }
238
239 export function validURL(str: string) {
240   try {
241     return !!new URL(str);
242   } catch {
243     return false;
244   }
245 }
246
247 export function validEmail(email: string) {
248   let re = /^(([^\s"(),.:;<>@[\\\]]+(\.[^\s"(),.:;<>@[\\\]]+)*)|(".+"))@((\[(?:\d{1,3}\.){3}\d{1,3}])|(([\dA-Za-z\-]+\.)+[A-Za-z]{2,}))$/;
249   return re.test(String(email).toLowerCase());
250 }
251
252 export function capitalizeFirstLetter(str: string): string {
253   return str.charAt(0).toUpperCase() + str.slice(1);
254 }
255
256 export function routeSortTypeToEnum(sort: string): SortType {
257   if (sort == 'new') {
258     return SortType.New;
259   } else if (sort == 'hot') {
260     return SortType.Hot;
261   } else if (sort == 'topday') {
262     return SortType.TopDay;
263   } else if (sort == 'topweek') {
264     return SortType.TopWeek;
265   } else if (sort == 'topmonth') {
266     return SortType.TopMonth;
267   } else if (sort == 'topyear') {
268     return SortType.TopYear;
269   } else if (sort == 'topall') {
270     return SortType.TopAll;
271   }
272 }
273
274 export function routeListingTypeToEnum(type: string): ListingType {
275   return ListingType[capitalizeFirstLetter(type)];
276 }
277
278 export function routeDataTypeToEnum(type: string): DataType {
279   return DataType[capitalizeFirstLetter(type)];
280 }
281
282 export function routeSearchTypeToEnum(type: string): SearchType {
283   return SearchType[capitalizeFirstLetter(type)];
284 }
285
286 export async function getPageTitle(url: string) {
287   let res = await fetch(`/iframely/oembed?url=${url}`).then(res => res.json());
288   let title = await res.title;
289   return title;
290 }
291
292 export function debounce(
293   func: any,
294   wait: number = 1000,
295   immediate: boolean = false
296 ) {
297   // 'private' variable for instance
298   // The returned function will be able to reference this due to closure.
299   // Each call to the returned function will share this common timer.
300   let timeout: any;
301
302   // Calling debounce returns a new anonymous function
303   return function () {
304     // reference the context and args for the setTimeout function
305     var context = this,
306       args = arguments;
307
308     // Should the function be called now? If immediate is true
309     //   and not already in a timeout then the answer is: Yes
310     var callNow = immediate && !timeout;
311
312     // This is the basic debounce behaviour where you can call this
313     //   function several times, but it will only execute once
314     //   [before or after imposing a delay].
315     //   Each time the returned function is called, the timer starts over.
316     clearTimeout(timeout);
317
318     // Set the new timeout
319     timeout = setTimeout(function () {
320       // Inside the timeout function, clear the timeout variable
321       // which will let the next execution run when in 'immediate' mode
322       timeout = null;
323
324       // Check if the function already ran with the immediate flag
325       if (!immediate) {
326         // Call the original function with apply
327         // apply lets you define the 'this' object as well as the arguments
328         //    (both captured before setTimeout)
329         func.apply(context, args);
330       }
331     }, wait);
332
333     // Immediate mode and no wait timer? Execute the function..
334     if (callNow) func.apply(context, args);
335   };
336 }
337
338 export function getLanguage(): string {
339   let user = UserService.Instance.user;
340   let lang = user && user.lang ? user.lang : 'browser';
341
342   if (lang == 'browser') {
343     return getBrowserLanguage();
344   } else {
345     return lang;
346   }
347 }
348
349 export function getBrowserLanguage(): string {
350   return navigator.language;
351 }
352
353 export function getMomentLanguage(): string {
354   let lang = getLanguage();
355   if (lang.startsWith('zh')) {
356     lang = 'zh-cn';
357   } else if (lang.startsWith('sv')) {
358     lang = 'sv';
359   } else if (lang.startsWith('fr')) {
360     lang = 'fr';
361   } else if (lang.startsWith('de')) {
362     lang = 'de';
363   } else if (lang.startsWith('ru')) {
364     lang = 'ru';
365   } else if (lang.startsWith('es')) {
366     lang = 'es';
367   } else if (lang.startsWith('eo')) {
368     lang = 'eo';
369   } else if (lang.startsWith('nl')) {
370     lang = 'nl';
371   } else if (lang.startsWith('it')) {
372     lang = 'it';
373   } else if (lang.startsWith('fi')) {
374     lang = 'fi';
375   } else if (lang.startsWith('ca')) {
376     lang = 'ca';
377   } else if (lang.startsWith('fa')) {
378     lang = 'fa';
379   } else if (lang.startsWith('pl')) {
380     lang = 'pl';
381   } else if (lang.startsWith('pt')) {
382     lang = 'pt-br';
383   } else if (lang.startsWith('ja')) {
384     lang = 'ja';
385   } else if (lang.startsWith('ka')) {
386     lang = 'ka';
387   } else if (lang.startsWith('hi')) {
388     lang = 'hi';
389   } else if (lang.startsWith('el')) {
390     lang = 'el';
391   } else if (lang.startsWith('eu')) {
392     lang = 'eu';
393   } else if (lang.startsWith('gl')) {
394     lang = 'gl';
395   } else if (lang.startsWith('tr')) {
396     lang = 'tr';
397   } else if (lang.startsWith('hu')) {
398     lang = 'hu';
399   } else if (lang.startsWith('uk')) {
400     lang = 'uk';
401   } else {
402     lang = 'en';
403   }
404   return lang;
405 }
406
407 export function setTheme(theme: string = 'darkly', loggedIn: boolean = false) {
408   // unload all the other themes
409   for (var i = 0; i < themes.length; i++) {
410     let styleSheet = document.getElementById(themes[i]);
411     if (styleSheet) {
412       styleSheet.setAttribute('disabled', 'disabled');
413     }
414   }
415
416   // if the user is not logged in, we load the default themes and let the browser decide
417   if (!loggedIn) {
418     document.getElementById('default-light').removeAttribute('disabled');
419     document.getElementById('default-dark').removeAttribute('disabled');
420   } else {
421     document
422       .getElementById('default-light')
423       .setAttribute('disabled', 'disabled');
424     document
425       .getElementById('default-dark')
426       .setAttribute('disabled', 'disabled');
427
428     // Load the theme dynamically
429     let cssLoc = `/static/assets/css/themes/${theme}.min.css`;
430     loadCss(theme, cssLoc);
431     document.getElementById(theme).removeAttribute('disabled');
432   }
433 }
434
435 export function loadCss(id: string, loc: string) {
436   if (!document.getElementById(id)) {
437     var head = document.getElementsByTagName('head')[0];
438     var link = document.createElement('link');
439     link.id = id;
440     link.rel = 'stylesheet';
441     link.type = 'text/css';
442     link.href = loc;
443     link.media = 'all';
444     head.appendChild(link);
445   }
446 }
447
448 export function objectFlip(obj: any) {
449   const ret = {};
450   Object.keys(obj).forEach(key => {
451     ret[obj[key]] = key;
452   });
453   return ret;
454 }
455
456 export function pictrsAvatarThumbnail(src: string): string {
457   // sample url: http://localhost:8535/pictrs/image/thumbnail256/gs7xuu.jpg
458   let split = src.split('/pictrs/image');
459   let out = `${split[0]}/pictrs/image/${
460     canUseWebP() ? 'webp/' : ''
461   }thumbnail96${split[1]}`;
462   return out;
463 }
464
465 export function showAvatars(): boolean {
466   return (
467     (UserService.Instance.user && UserService.Instance.user.show_avatars) ||
468     !UserService.Instance.user
469   );
470 }
471
472 // Converts to image thumbnail
473 export function pictrsImage(hash: string, thumbnail: boolean = false): string {
474   let root = `/pictrs/image`;
475
476   // Necessary for other servers / domains
477   if (hash.includes('pictrs')) {
478     let split = hash.split('/pictrs/image/');
479     root = `${split[0]}/pictrs/image`;
480     hash = split[1];
481   }
482
483   let out = `${root}/${canUseWebP() ? 'webp/' : ''}${
484     thumbnail ? 'thumbnail256/' : ''
485   }${hash}`;
486   return out;
487 }
488
489 export function isCommentType(item: Comment | PrivateMessage): item is Comment {
490   return (item as Comment).community_id !== undefined;
491 }
492
493 export function toast(text: string, background: string = 'success') {
494   let backgroundColor = `var(--${background})`;
495   Toastify({
496     text: text,
497     backgroundColor: backgroundColor,
498     gravity: 'bottom',
499     position: 'left',
500   }).showToast();
501 }
502
503 export function pictrsDeleteToast(
504   clickToDeleteText: string,
505   deletePictureText: string,
506   deleteUrl: string
507 ) {
508   let backgroundColor = `var(--light)`;
509   let toast = Toastify({
510     text: clickToDeleteText,
511     backgroundColor: backgroundColor,
512     gravity: 'top',
513     position: 'right',
514     duration: 0,
515     onClick: () => {
516       if (toast) {
517         window.location.replace(deleteUrl);
518         alert(deletePictureText);
519         toast.hideToast();
520       }
521     },
522     close: true,
523   }).showToast();
524 }
525
526 export function messageToastify(
527   creator: string,
528   avatar: string,
529   body: string,
530   link: string,
531   router: any
532 ) {
533   let backgroundColor = `var(--light)`;
534
535   let toast = Toastify({
536     text: `${body}<br />${creator}`,
537     avatar: avatar,
538     backgroundColor: backgroundColor,
539     className: 'text-dark',
540     close: true,
541     gravity: 'top',
542     position: 'right',
543     duration: 0,
544     onClick: () => {
545       if (toast) {
546         toast.hideToast();
547         router.history.push(link);
548       }
549     },
550   }).showToast();
551 }
552
553 export function setupTribute(): Tribute {
554   return new Tribute({
555     collection: [
556       // Emojis
557       {
558         trigger: ':',
559         menuItemTemplate: (item: any) => {
560           let shortName = `:${item.original.key}:`;
561           let twemojiIcon = twemoji.parse(item.original.val);
562           return `${twemojiIcon} ${shortName}`;
563         },
564         selectTemplate: (item: any) => {
565           return `:${item.original.key}:`;
566         },
567         values: Object.entries(emojiShortName).map(e => {
568           return { key: e[1], val: e[0] };
569         }),
570         allowSpaces: false,
571         autocompleteMode: true,
572         menuItemLimit: mentionDropdownFetchLimit,
573         menuShowMinLength: 2,
574       },
575       // Users
576       {
577         trigger: '@',
578         selectTemplate: (item: any) => {
579           return `[/u/${item.original.key}](/u/${item.original.key})`;
580         },
581         values: (text: string, cb: any) => {
582           userSearch(text, (users: any) => cb(users));
583         },
584         allowSpaces: false,
585         autocompleteMode: true,
586         menuItemLimit: mentionDropdownFetchLimit,
587         menuShowMinLength: 2,
588       },
589
590       // Communities
591       {
592         trigger: '#',
593         selectTemplate: (item: any) => {
594           return `[/c/${item.original.key}](/c/${item.original.key})`;
595         },
596         values: (text: string, cb: any) => {
597           communitySearch(text, (communities: any) => cb(communities));
598         },
599         allowSpaces: false,
600         autocompleteMode: true,
601         menuItemLimit: mentionDropdownFetchLimit,
602         menuShowMinLength: 2,
603       },
604     ],
605   });
606 }
607
608 let tippyInstance = tippy('[data-tippy-content]');
609
610 export function setupTippy() {
611   tippyInstance.forEach(e => e.destroy());
612   tippyInstance = tippy('[data-tippy-content]', {
613     delay: [500, 0],
614     // Display on "long press"
615     touch: ['hold', 500],
616   });
617 }
618
619 function userSearch(text: string, cb: any) {
620   if (text) {
621     let form: SearchForm = {
622       q: text,
623       type_: SearchType[SearchType.Users],
624       sort: SortType[SortType.TopAll],
625       page: 1,
626       limit: mentionDropdownFetchLimit,
627     };
628
629     WebSocketService.Instance.search(form);
630
631     this.userSub = WebSocketService.Instance.subject.subscribe(
632       msg => {
633         let res = wsJsonToRes(msg);
634         if (res.op == UserOperation.Search) {
635           let data = res.data as SearchResponse;
636           let users = data.users.map(u => {
637             return { key: u.name };
638           });
639           cb(users);
640           this.userSub.unsubscribe();
641         }
642       },
643       err => console.error(err),
644       () => console.log('complete')
645     );
646   } else {
647     cb([]);
648   }
649 }
650
651 function communitySearch(text: string, cb: any) {
652   if (text) {
653     let form: SearchForm = {
654       q: text,
655       type_: SearchType[SearchType.Communities],
656       sort: SortType[SortType.TopAll],
657       page: 1,
658       limit: mentionDropdownFetchLimit,
659     };
660
661     WebSocketService.Instance.search(form);
662
663     this.communitySub = WebSocketService.Instance.subject.subscribe(
664       msg => {
665         let res = wsJsonToRes(msg);
666         if (res.op == UserOperation.Search) {
667           let data = res.data as SearchResponse;
668           let communities = data.communities.map(u => {
669             return { key: u.name };
670           });
671           cb(communities);
672           this.communitySub.unsubscribe();
673         }
674       },
675       err => console.error(err),
676       () => console.log('complete')
677     );
678   } else {
679     cb([]);
680   }
681 }
682
683 export function getListingTypeFromProps(props: any): ListingType {
684   return props.match.params.listing_type
685     ? routeListingTypeToEnum(props.match.params.listing_type)
686     : UserService.Instance.user
687     ? UserService.Instance.user.default_listing_type
688     : ListingType.All;
689 }
690
691 // TODO might need to add a user setting for this too
692 export function getDataTypeFromProps(props: any): DataType {
693   return props.match.params.data_type
694     ? routeDataTypeToEnum(props.match.params.data_type)
695     : DataType.Post;
696 }
697
698 export function getSortTypeFromProps(props: any): SortType {
699   return props.match.params.sort
700     ? routeSortTypeToEnum(props.match.params.sort)
701     : UserService.Instance.user
702     ? UserService.Instance.user.default_sort_type
703     : SortType.Hot;
704 }
705
706 export function getPageFromProps(props: any): number {
707   return props.match.params.page ? Number(props.match.params.page) : 1;
708 }
709
710 export function editCommentRes(
711   data: CommentResponse,
712   comments: Array<Comment>
713 ) {
714   let found = comments.find(c => c.id == data.comment.id);
715   if (found) {
716     found.content = data.comment.content;
717     found.updated = data.comment.updated;
718     found.removed = data.comment.removed;
719     found.deleted = data.comment.deleted;
720     found.upvotes = data.comment.upvotes;
721     found.downvotes = data.comment.downvotes;
722     found.score = data.comment.score;
723   }
724 }
725
726 export function saveCommentRes(
727   data: CommentResponse,
728   comments: Array<Comment>
729 ) {
730   let found = comments.find(c => c.id == data.comment.id);
731   if (found) {
732     found.saved = data.comment.saved;
733   }
734 }
735
736 export function createCommentLikeRes(
737   data: CommentResponse,
738   comments: Array<Comment>
739 ) {
740   let found: Comment = comments.find(c => c.id === data.comment.id);
741   if (found) {
742     found.score = data.comment.score;
743     found.upvotes = data.comment.upvotes;
744     found.downvotes = data.comment.downvotes;
745     if (data.comment.my_vote !== null) {
746       found.my_vote = data.comment.my_vote;
747     }
748   }
749 }
750
751 export function createPostLikeFindRes(data: PostResponse, posts: Array<Post>) {
752   let found = posts.find(c => c.id == data.post.id);
753   if (found) {
754     createPostLikeRes(data, found);
755   }
756 }
757
758 export function createPostLikeRes(data: PostResponse, post: Post) {
759   if (post) {
760     post.score = data.post.score;
761     post.upvotes = data.post.upvotes;
762     post.downvotes = data.post.downvotes;
763     if (data.post.my_vote !== null) {
764       post.my_vote = data.post.my_vote;
765     }
766   }
767 }
768
769 export function editPostFindRes(data: PostResponse, posts: Array<Post>) {
770   let found = posts.find(c => c.id == data.post.id);
771   if (found) {
772     editPostRes(data, found);
773   }
774 }
775
776 export function editPostRes(data: PostResponse, post: Post) {
777   if (post) {
778     post.url = data.post.url;
779     post.name = data.post.name;
780     post.nsfw = data.post.nsfw;
781   }
782 }
783
784 export function commentsToFlatNodes(
785   comments: Array<Comment>
786 ): Array<CommentNodeI> {
787   let nodes: Array<CommentNodeI> = [];
788   for (let comment of comments) {
789     nodes.push({ comment: comment });
790   }
791   return nodes;
792 }
793
794 export function commentSort(tree: Array<CommentNodeI>, sort: CommentSortType) {
795   // First, put removed and deleted comments at the bottom, then do your other sorts
796   if (sort == CommentSortType.Top) {
797     tree.sort(
798       (a, b) =>
799         +a.comment.removed - +b.comment.removed ||
800         +a.comment.deleted - +b.comment.deleted ||
801         b.comment.score - a.comment.score
802     );
803   } else if (sort == CommentSortType.New) {
804     tree.sort(
805       (a, b) =>
806         +a.comment.removed - +b.comment.removed ||
807         +a.comment.deleted - +b.comment.deleted ||
808         b.comment.published.localeCompare(a.comment.published)
809     );
810   } else if (sort == CommentSortType.Old) {
811     tree.sort(
812       (a, b) =>
813         +a.comment.removed - +b.comment.removed ||
814         +a.comment.deleted - +b.comment.deleted ||
815         a.comment.published.localeCompare(b.comment.published)
816     );
817   } else if (sort == CommentSortType.Hot) {
818     tree.sort(
819       (a, b) =>
820         +a.comment.removed - +b.comment.removed ||
821         +a.comment.deleted - +b.comment.deleted ||
822         hotRankComment(b.comment) - hotRankComment(a.comment)
823     );
824   }
825
826   // Go through the children recursively
827   for (let node of tree) {
828     if (node.children) {
829       commentSort(node.children, sort);
830     }
831   }
832 }
833
834 export function commentSortSortType(tree: Array<CommentNodeI>, sort: SortType) {
835   commentSort(tree, convertCommentSortType(sort));
836 }
837
838 function convertCommentSortType(sort: SortType): CommentSortType {
839   if (
840     sort == SortType.TopAll ||
841     sort == SortType.TopDay ||
842     sort == SortType.TopWeek ||
843     sort == SortType.TopMonth ||
844     sort == SortType.TopYear
845   ) {
846     return CommentSortType.Top;
847   } else if (sort == SortType.New) {
848     return CommentSortType.New;
849   } else if (sort == SortType.Hot) {
850     return CommentSortType.Hot;
851   } else {
852     return CommentSortType.Hot;
853   }
854 }
855
856 export function postSort(
857   posts: Array<Post>,
858   sort: SortType,
859   communityType: boolean
860 ) {
861   // First, put removed and deleted comments at the bottom, then do your other sorts
862   if (
863     sort == SortType.TopAll ||
864     sort == SortType.TopDay ||
865     sort == SortType.TopWeek ||
866     sort == SortType.TopMonth ||
867     sort == SortType.TopYear
868   ) {
869     posts.sort(
870       (a, b) =>
871         +a.removed - +b.removed ||
872         +a.deleted - +b.deleted ||
873         (communityType && +b.stickied - +a.stickied) ||
874         b.score - a.score
875     );
876   } else if (sort == SortType.New) {
877     posts.sort(
878       (a, b) =>
879         +a.removed - +b.removed ||
880         +a.deleted - +b.deleted ||
881         (communityType && +b.stickied - +a.stickied) ||
882         b.published.localeCompare(a.published)
883     );
884   } else if (sort == SortType.Hot) {
885     posts.sort(
886       (a, b) =>
887         +a.removed - +b.removed ||
888         +a.deleted - +b.deleted ||
889         (communityType && +b.stickied - +a.stickied) ||
890         hotRankPost(b) - hotRankPost(a)
891     );
892   }
893 }
894
895 export const colorList: Array<string> = [
896   hsl(0),
897   hsl(100),
898   hsl(150),
899   hsl(200),
900   hsl(250),
901   hsl(300),
902 ];
903
904 function hsl(num: number) {
905   return `hsla(${num}, 35%, 50%, 1)`;
906 }
907
908 function randomHsl() {
909   return `hsla(${Math.random() * 360}, 100%, 50%, 1)`;
910 }
911
912 export function previewLines(text: string, lines: number = 3): string {
913   // Use lines * 2 because markdown requires 2 lines
914   return text
915     .split('\n')
916     .slice(0, lines * 2)
917     .join('\n');
918 }
919
920 function canUseWebP() {
921   // TODO pictshare might have a webp conversion bug, try disabling this
922   return false;
923
924   // var elem = document.createElement('canvas');
925   // if (!!(elem.getContext && elem.getContext('2d'))) {
926   //   var testString = !(window.mozInnerScreenX == null) ? 'png' : 'webp';
927   //   // was able or not to get WebP representation
928   //   return (
929   //     elem.toDataURL('image/webp').startsWith('data:image/' + testString)
930   //   );
931   // }
932
933   // // very old browser like IE 8, canvas not supported
934   // return false;
935 }