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