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