]> Untitled Git - lemmy.git/blob - ui/src/utils.ts
Add default themes with media queries (#796)
[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.getElementById("default-light").setAttribute('disabled', 'disabled');
422     document.getElementById("default-dark").setAttribute('disabled', 'disabled');
423
424     // Load the theme dynamically
425     let cssLoc = `/static/assets/css/themes/${theme}.min.css`;
426     loadCss(theme, cssLoc);
427     document.getElementById(theme).removeAttribute('disabled');
428   }
429 }
430
431 export function loadCss(id: string, loc: string) {
432   if (!document.getElementById(id)) {
433     var head = document.getElementsByTagName('head')[0];
434     var link = document.createElement('link');
435     link.id = id;
436     link.rel = 'stylesheet';
437     link.type = 'text/css';
438     link.href = loc;
439     link.media = 'all';
440     head.appendChild(link);
441   }
442 }
443
444 export function objectFlip(obj: any) {
445   const ret = {};
446   Object.keys(obj).forEach(key => {
447     ret[obj[key]] = key;
448   });
449   return ret;
450 }
451
452 export function pictshareAvatarThumbnail(src: string): string {
453   // sample url: http://localhost:8535/pictshare/gs7xuu.jpg
454   let split = src.split('pictshare');
455   let out = `${split[0]}pictshare/${canUseWebP() ? 'webp/' : ''}96${split[1]}`;
456   return out;
457 }
458
459 export function showAvatars(): boolean {
460   return (
461     (UserService.Instance.user && UserService.Instance.user.show_avatars) ||
462     !UserService.Instance.user
463   );
464 }
465
466 // Converts to image thumbnail
467 export function pictshareImage(
468   hash: string,
469   thumbnail: boolean = false
470 ): string {
471   let root = `/pictshare`;
472
473   // Necessary for other servers / domains
474   if (hash.includes('pictshare')) {
475     let split = hash.split('/pictshare/');
476     root = `${split[0]}/pictshare`;
477     hash = split[1];
478   }
479
480   let out = `${root}/${canUseWebP() ? 'webp/' : ''}${
481     thumbnail ? '192/' : ''
482   }${hash}`;
483   return out;
484 }
485
486 export function isCommentType(item: Comment | PrivateMessage): item is Comment {
487   return (item as Comment).community_id !== undefined;
488 }
489
490 export function toast(text: string, background: string = 'success') {
491   let backgroundColor = `var(--${background})`;
492   Toastify({
493     text: text,
494     backgroundColor: backgroundColor,
495     gravity: 'bottom',
496     position: 'left',
497   }).showToast();
498 }
499
500 export function messageToastify(
501   creator: string,
502   avatar: string,
503   body: string,
504   link: string,
505   router: any
506 ) {
507   let backgroundColor = `var(--light)`;
508
509   let toast = Toastify({
510     text: `${body}<br />${creator}`,
511     avatar: avatar,
512     backgroundColor: backgroundColor,
513     className: 'text-dark',
514     close: true,
515     gravity: 'top',
516     position: 'right',
517     duration: 0,
518     onClick: () => {
519       if (toast) {
520         toast.hideToast();
521         router.history.push(link);
522       }
523     },
524   }).showToast();
525 }
526
527 export function setupTribute(): Tribute {
528   return new Tribute({
529     collection: [
530       // Emojis
531       {
532         trigger: ':',
533         menuItemTemplate: (item: any) => {
534           let shortName = `:${item.original.key}:`;
535           let twemojiIcon = twemoji.parse(item.original.val);
536           return `${twemojiIcon} ${shortName}`;
537         },
538         selectTemplate: (item: any) => {
539           return `:${item.original.key}:`;
540         },
541         values: Object.entries(emojiShortName).map(e => {
542           return { key: e[1], val: e[0] };
543         }),
544         allowSpaces: false,
545         autocompleteMode: true,
546         menuItemLimit: mentionDropdownFetchLimit,
547         menuShowMinLength: 2,
548       },
549       // Users
550       {
551         trigger: '@',
552         selectTemplate: (item: any) => {
553           return `[/u/${item.original.key}](/u/${item.original.key})`;
554         },
555         values: (text: string, cb: any) => {
556           userSearch(text, (users: any) => cb(users));
557         },
558         allowSpaces: false,
559         autocompleteMode: true,
560         menuItemLimit: mentionDropdownFetchLimit,
561         menuShowMinLength: 2,
562       },
563
564       // Communities
565       {
566         trigger: '#',
567         selectTemplate: (item: any) => {
568           return `[/c/${item.original.key}](/c/${item.original.key})`;
569         },
570         values: (text: string, cb: any) => {
571           communitySearch(text, (communities: any) => cb(communities));
572         },
573         allowSpaces: false,
574         autocompleteMode: true,
575         menuItemLimit: mentionDropdownFetchLimit,
576         menuShowMinLength: 2,
577       },
578     ],
579   });
580 }
581
582 let tippyInstance = tippy('[data-tippy-content]');
583
584 export function setupTippy() {
585   tippyInstance.forEach(e => e.destroy());
586   tippyInstance = tippy('[data-tippy-content]', {
587     delay: [500, 0],
588     // Display on "long press"
589     touch: ['hold', 500],
590   });
591 }
592
593 function userSearch(text: string, cb: any) {
594   if (text) {
595     let form: SearchForm = {
596       q: text,
597       type_: SearchType[SearchType.Users],
598       sort: SortType[SortType.TopAll],
599       page: 1,
600       limit: mentionDropdownFetchLimit,
601     };
602
603     WebSocketService.Instance.search(form);
604
605     this.userSub = WebSocketService.Instance.subject.subscribe(
606       msg => {
607         let res = wsJsonToRes(msg);
608         if (res.op == UserOperation.Search) {
609           let data = res.data as SearchResponse;
610           let users = data.users.map(u => {
611             return { key: u.name };
612           });
613           cb(users);
614           this.userSub.unsubscribe();
615         }
616       },
617       err => console.error(err),
618       () => console.log('complete')
619     );
620   } else {
621     cb([]);
622   }
623 }
624
625 function communitySearch(text: string, cb: any) {
626   if (text) {
627     let form: SearchForm = {
628       q: text,
629       type_: SearchType[SearchType.Communities],
630       sort: SortType[SortType.TopAll],
631       page: 1,
632       limit: mentionDropdownFetchLimit,
633     };
634
635     WebSocketService.Instance.search(form);
636
637     this.communitySub = WebSocketService.Instance.subject.subscribe(
638       msg => {
639         let res = wsJsonToRes(msg);
640         if (res.op == UserOperation.Search) {
641           let data = res.data as SearchResponse;
642           let communities = data.communities.map(u => {
643             return { key: u.name };
644           });
645           cb(communities);
646           this.communitySub.unsubscribe();
647         }
648       },
649       err => console.error(err),
650       () => console.log('complete')
651     );
652   } else {
653     cb([]);
654   }
655 }
656
657 export function getListingTypeFromProps(props: any): ListingType {
658   return props.match.params.listing_type
659     ? routeListingTypeToEnum(props.match.params.listing_type)
660     : UserService.Instance.user
661     ? UserService.Instance.user.default_listing_type
662     : ListingType.All;
663 }
664
665 // TODO might need to add a user setting for this too
666 export function getDataTypeFromProps(props: any): DataType {
667   return props.match.params.data_type
668     ? routeDataTypeToEnum(props.match.params.data_type)
669     : DataType.Post;
670 }
671
672 export function getSortTypeFromProps(props: any): SortType {
673   return props.match.params.sort
674     ? routeSortTypeToEnum(props.match.params.sort)
675     : UserService.Instance.user
676     ? UserService.Instance.user.default_sort_type
677     : SortType.Hot;
678 }
679
680 export function getPageFromProps(props: any): number {
681   return props.match.params.page ? Number(props.match.params.page) : 1;
682 }
683
684 export function editCommentRes(
685   data: CommentResponse,
686   comments: Array<Comment>
687 ) {
688   let found = comments.find(c => c.id == data.comment.id);
689   if (found) {
690     found.content = data.comment.content;
691     found.updated = data.comment.updated;
692     found.removed = data.comment.removed;
693     found.deleted = data.comment.deleted;
694     found.upvotes = data.comment.upvotes;
695     found.downvotes = data.comment.downvotes;
696     found.score = data.comment.score;
697   }
698 }
699
700 export function saveCommentRes(
701   data: CommentResponse,
702   comments: Array<Comment>
703 ) {
704   let found = comments.find(c => c.id == data.comment.id);
705   if (found) {
706     found.saved = data.comment.saved;
707   }
708 }
709
710 export function createCommentLikeRes(
711   data: CommentResponse,
712   comments: Array<Comment>
713 ) {
714   let found: Comment = comments.find(c => c.id === data.comment.id);
715   if (found) {
716     found.score = data.comment.score;
717     found.upvotes = data.comment.upvotes;
718     found.downvotes = data.comment.downvotes;
719     if (data.comment.my_vote !== null) {
720       found.my_vote = data.comment.my_vote;
721     }
722   }
723 }
724
725 export function createPostLikeFindRes(data: PostResponse, posts: Array<Post>) {
726   let found = posts.find(c => c.id == data.post.id);
727   if (found) {
728     createPostLikeRes(data, found);
729   }
730 }
731
732 export function createPostLikeRes(data: PostResponse, post: Post) {
733   if (post) {
734     post.score = data.post.score;
735     post.upvotes = data.post.upvotes;
736     post.downvotes = data.post.downvotes;
737     if (data.post.my_vote !== null) {
738       post.my_vote = data.post.my_vote;
739     }
740   }
741 }
742
743 export function editPostFindRes(data: PostResponse, posts: Array<Post>) {
744   let found = posts.find(c => c.id == data.post.id);
745   if (found) {
746     editPostRes(data, found);
747   }
748 }
749
750 export function editPostRes(data: PostResponse, post: Post) {
751   if (post) {
752     post.url = data.post.url;
753     post.name = data.post.name;
754     post.nsfw = data.post.nsfw;
755   }
756 }
757
758 export function commentsToFlatNodes(
759   comments: Array<Comment>
760 ): Array<CommentNodeI> {
761   let nodes: Array<CommentNodeI> = [];
762   for (let comment of comments) {
763     nodes.push({ comment: comment });
764   }
765   return nodes;
766 }
767
768 export function commentSort(tree: Array<CommentNodeI>, sort: CommentSortType) {
769   // First, put removed and deleted comments at the bottom, then do your other sorts
770   if (sort == CommentSortType.Top) {
771     tree.sort(
772       (a, b) =>
773         +a.comment.removed - +b.comment.removed ||
774         +a.comment.deleted - +b.comment.deleted ||
775         b.comment.score - a.comment.score
776     );
777   } else if (sort == CommentSortType.New) {
778     tree.sort(
779       (a, b) =>
780         +a.comment.removed - +b.comment.removed ||
781         +a.comment.deleted - +b.comment.deleted ||
782         b.comment.published.localeCompare(a.comment.published)
783     );
784   } else if (sort == CommentSortType.Old) {
785     tree.sort(
786       (a, b) =>
787         +a.comment.removed - +b.comment.removed ||
788         +a.comment.deleted - +b.comment.deleted ||
789         a.comment.published.localeCompare(b.comment.published)
790     );
791   } else if (sort == CommentSortType.Hot) {
792     tree.sort(
793       (a, b) =>
794         +a.comment.removed - +b.comment.removed ||
795         +a.comment.deleted - +b.comment.deleted ||
796         hotRankComment(b.comment) - hotRankComment(a.comment)
797     );
798   }
799
800   // Go through the children recursively
801   for (let node of tree) {
802     if (node.children) {
803       commentSort(node.children, sort);
804     }
805   }
806 }
807
808 export function commentSortSortType(tree: Array<CommentNodeI>, sort: SortType) {
809   commentSort(tree, convertCommentSortType(sort));
810 }
811
812 function convertCommentSortType(sort: SortType): CommentSortType {
813   if (
814     sort == SortType.TopAll ||
815     sort == SortType.TopDay ||
816     sort == SortType.TopWeek ||
817     sort == SortType.TopMonth ||
818     sort == SortType.TopYear
819   ) {
820     return CommentSortType.Top;
821   } else if (sort == SortType.New) {
822     return CommentSortType.New;
823   } else if (sort == SortType.Hot) {
824     return CommentSortType.Hot;
825   } else {
826     return CommentSortType.Hot;
827   }
828 }
829
830 export function postSort(
831   posts: Array<Post>,
832   sort: SortType,
833   communityType: boolean
834 ) {
835   // First, put removed and deleted comments at the bottom, then do your other sorts
836   if (
837     sort == SortType.TopAll ||
838     sort == SortType.TopDay ||
839     sort == SortType.TopWeek ||
840     sort == SortType.TopMonth ||
841     sort == SortType.TopYear
842   ) {
843     posts.sort(
844       (a, b) =>
845         +a.removed - +b.removed ||
846         +a.deleted - +b.deleted ||
847         (communityType && +b.stickied - +a.stickied) ||
848         b.score - a.score
849     );
850   } else if (sort == SortType.New) {
851     posts.sort(
852       (a, b) =>
853         +a.removed - +b.removed ||
854         +a.deleted - +b.deleted ||
855         (communityType && +b.stickied - +a.stickied) ||
856         b.published.localeCompare(a.published)
857     );
858   } else if (sort == SortType.Hot) {
859     posts.sort(
860       (a, b) =>
861         +a.removed - +b.removed ||
862         +a.deleted - +b.deleted ||
863         (communityType && +b.stickied - +a.stickied) ||
864         hotRankPost(b) - hotRankPost(a)
865     );
866   }
867 }
868
869 export const colorList: Array<string> = [
870   hsl(0),
871   hsl(100),
872   hsl(150),
873   hsl(200),
874   hsl(250),
875   hsl(300),
876 ];
877
878 function hsl(num: number) {
879   return `hsla(${num}, 35%, 50%, 1)`;
880 }
881
882 function randomHsl() {
883   return `hsla(${Math.random() * 360}, 100%, 50%, 1)`;
884 }
885
886 export function previewLines(text: string, lines: number = 3): string {
887   // Use lines * 2 because markdown requires 2 lines
888   return text
889     .split('\n')
890     .slice(0, lines * 2)
891     .join('\n');
892 }
893
894 function canUseWebP() {
895   // TODO pictshare might have a webp conversion bug, try disabling this
896   return false;
897
898   // var elem = document.createElement('canvas');
899   // if (!!(elem.getContext && elem.getContext('2d'))) {
900   //   var testString = !(window.mozInnerScreenX == null) ? 'png' : 'webp';
901   //   // was able or not to get WebP representation
902   //   return (
903   //     elem.toDataURL('image/webp').startsWith('data:image/' + testString)
904   //   );
905   // }
906
907   // // very old browser like IE 8, canvas not supported
908   // return false;
909 }