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