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