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