]> Untitled Git - lemmy.git/blob - ui/src/utils.ts
Add Emoji Picker for comments and post body.
[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 emoji = `:${item.original.key}:`;
486           return `${item.original.val} ${emoji}`;
487         },
488         selectTemplate: (item: any) => {
489           return `:${item.original.key}:`;
490         },
491         values: Object.entries(emojiShortName).map(e => {
492           return { key: e[1], val: e[0] };
493         }),
494         allowSpaces: false,
495         autocompleteMode: true,
496         menuItemLimit: mentionDropdownFetchLimit,
497         menuShowMinLength: 2,
498       },
499       // Users
500       {
501         trigger: '@',
502         selectTemplate: (item: any) => {
503           return `[/u/${item.original.key}](/u/${item.original.key})`;
504         },
505         values: (text: string, cb: any) => {
506           userSearch(text, (users: any) => cb(users));
507         },
508         allowSpaces: false,
509         autocompleteMode: true,
510         menuItemLimit: mentionDropdownFetchLimit,
511         menuShowMinLength: 2,
512       },
513
514       // Communities
515       {
516         trigger: '#',
517         selectTemplate: (item: any) => {
518           return `[/c/${item.original.key}](/c/${item.original.key})`;
519         },
520         values: (text: string, cb: any) => {
521           communitySearch(text, (communities: any) => cb(communities));
522         },
523         allowSpaces: false,
524         autocompleteMode: true,
525         menuItemLimit: mentionDropdownFetchLimit,
526         menuShowMinLength: 2,
527       },
528     ],
529   });
530 }
531
532 let tippyInstance = tippy('[data-tippy-content]');
533
534 export function setupTippy() {
535   tippyInstance.forEach(e => e.destroy());
536   tippyInstance = tippy('[data-tippy-content]', {
537     delay: [500, 0],
538     // Display on "long press"
539     touch: ['hold', 500],
540   });
541 }
542
543 function userSearch(text: string, cb: any) {
544   if (text) {
545     let form: SearchForm = {
546       q: text,
547       type_: SearchType[SearchType.Users],
548       sort: SortType[SortType.TopAll],
549       page: 1,
550       limit: mentionDropdownFetchLimit,
551     };
552
553     WebSocketService.Instance.search(form);
554
555     this.userSub = WebSocketService.Instance.subject.subscribe(
556       msg => {
557         let res = wsJsonToRes(msg);
558         if (res.op == UserOperation.Search) {
559           let data = res.data as SearchResponse;
560           let users = data.users.map(u => {
561             return { key: u.name };
562           });
563           cb(users);
564           this.userSub.unsubscribe();
565         }
566       },
567       err => console.error(err),
568       () => console.log('complete')
569     );
570   } else {
571     cb([]);
572   }
573 }
574
575 function communitySearch(text: string, cb: any) {
576   if (text) {
577     let form: SearchForm = {
578       q: text,
579       type_: SearchType[SearchType.Communities],
580       sort: SortType[SortType.TopAll],
581       page: 1,
582       limit: mentionDropdownFetchLimit,
583     };
584
585     WebSocketService.Instance.search(form);
586
587     this.communitySub = WebSocketService.Instance.subject.subscribe(
588       msg => {
589         let res = wsJsonToRes(msg);
590         if (res.op == UserOperation.Search) {
591           let data = res.data as SearchResponse;
592           let communities = data.communities.map(u => {
593             return { key: u.name };
594           });
595           cb(communities);
596           this.communitySub.unsubscribe();
597         }
598       },
599       err => console.error(err),
600       () => console.log('complete')
601     );
602   } else {
603     cb([]);
604   }
605 }
606
607 export function getListingTypeFromProps(props: any): ListingType {
608   return props.match.params.listing_type
609     ? routeListingTypeToEnum(props.match.params.listing_type)
610     : UserService.Instance.user
611     ? UserService.Instance.user.default_listing_type
612     : ListingType.All;
613 }
614
615 // TODO might need to add a user setting for this too
616 export function getDataTypeFromProps(props: any): DataType {
617   return props.match.params.data_type
618     ? routeDataTypeToEnum(props.match.params.data_type)
619     : DataType.Post;
620 }
621
622 export function getSortTypeFromProps(props: any): SortType {
623   return props.match.params.sort
624     ? routeSortTypeToEnum(props.match.params.sort)
625     : UserService.Instance.user
626     ? UserService.Instance.user.default_sort_type
627     : SortType.Hot;
628 }
629
630 export function getPageFromProps(props: any): number {
631   return props.match.params.page ? Number(props.match.params.page) : 1;
632 }
633
634 export function editCommentRes(
635   data: CommentResponse,
636   comments: Array<Comment>
637 ) {
638   let found = comments.find(c => c.id == data.comment.id);
639   if (found) {
640     found.content = data.comment.content;
641     found.updated = data.comment.updated;
642     found.removed = data.comment.removed;
643     found.deleted = data.comment.deleted;
644     found.upvotes = data.comment.upvotes;
645     found.downvotes = data.comment.downvotes;
646     found.score = data.comment.score;
647   }
648 }
649
650 export function saveCommentRes(
651   data: CommentResponse,
652   comments: Array<Comment>
653 ) {
654   let found = comments.find(c => c.id == data.comment.id);
655   if (found) {
656     found.saved = data.comment.saved;
657   }
658 }
659
660 export function createCommentLikeRes(
661   data: CommentResponse,
662   comments: Array<Comment>
663 ) {
664   let found: Comment = comments.find(c => c.id === data.comment.id);
665   if (found) {
666     found.score = data.comment.score;
667     found.upvotes = data.comment.upvotes;
668     found.downvotes = data.comment.downvotes;
669     if (data.comment.my_vote !== null) {
670       found.my_vote = data.comment.my_vote;
671     }
672   }
673 }
674
675 export function createPostLikeFindRes(data: PostResponse, posts: Array<Post>) {
676   let found = posts.find(c => c.id == data.post.id);
677   if (found) {
678     createPostLikeRes(data, found);
679   }
680 }
681
682 export function createPostLikeRes(data: PostResponse, post: Post) {
683   if (post) {
684     post.score = data.post.score;
685     post.upvotes = data.post.upvotes;
686     post.downvotes = data.post.downvotes;
687     if (data.post.my_vote !== null) {
688       post.my_vote = data.post.my_vote;
689     }
690   }
691 }
692
693 export function editPostFindRes(data: PostResponse, posts: Array<Post>) {
694   let found = posts.find(c => c.id == data.post.id);
695   if (found) {
696     editPostRes(data, found);
697   }
698 }
699
700 export function editPostRes(data: PostResponse, post: Post) {
701   if (post) {
702     post.url = data.post.url;
703     post.name = data.post.name;
704     post.nsfw = data.post.nsfw;
705   }
706 }
707
708 export function commentsToFlatNodes(
709   comments: Array<Comment>
710 ): Array<CommentNodeI> {
711   let nodes: Array<CommentNodeI> = [];
712   for (let comment of comments) {
713     nodes.push({ comment: comment });
714   }
715   return nodes;
716 }
717
718 export function commentSort(tree: Array<CommentNodeI>, sort: CommentSortType) {
719   // First, put removed and deleted comments at the bottom, then do your other sorts
720   if (sort == CommentSortType.Top) {
721     tree.sort(
722       (a, b) =>
723         +a.comment.removed - +b.comment.removed ||
724         +a.comment.deleted - +b.comment.deleted ||
725         b.comment.score - a.comment.score
726     );
727   } else if (sort == CommentSortType.New) {
728     tree.sort(
729       (a, b) =>
730         +a.comment.removed - +b.comment.removed ||
731         +a.comment.deleted - +b.comment.deleted ||
732         b.comment.published.localeCompare(a.comment.published)
733     );
734   } else if (sort == CommentSortType.Old) {
735     tree.sort(
736       (a, b) =>
737         +a.comment.removed - +b.comment.removed ||
738         +a.comment.deleted - +b.comment.deleted ||
739         a.comment.published.localeCompare(b.comment.published)
740     );
741   } else if (sort == CommentSortType.Hot) {
742     tree.sort(
743       (a, b) =>
744         +a.comment.removed - +b.comment.removed ||
745         +a.comment.deleted - +b.comment.deleted ||
746         hotRankComment(b.comment) - hotRankComment(a.comment)
747     );
748   }
749
750   // Go through the children recursively
751   for (let node of tree) {
752     if (node.children) {
753       commentSort(node.children, sort);
754     }
755   }
756 }
757
758 export function commentSortSortType(tree: Array<CommentNodeI>, sort: SortType) {
759   commentSort(tree, convertCommentSortType(sort));
760 }
761
762 function convertCommentSortType(sort: SortType): CommentSortType {
763   if (
764     sort == SortType.TopAll ||
765     sort == SortType.TopDay ||
766     sort == SortType.TopWeek ||
767     sort == SortType.TopMonth ||
768     sort == SortType.TopYear
769   ) {
770     return CommentSortType.Top;
771   } else if (sort == SortType.New) {
772     return CommentSortType.New;
773   } else if (sort == SortType.Hot) {
774     return CommentSortType.Hot;
775   } else {
776     return CommentSortType.Hot;
777   }
778 }
779
780 export function postSort(
781   posts: Array<Post>,
782   sort: SortType,
783   communityType: boolean
784 ) {
785   // First, put removed and deleted comments at the bottom, then do your other sorts
786   if (
787     sort == SortType.TopAll ||
788     sort == SortType.TopDay ||
789     sort == SortType.TopWeek ||
790     sort == SortType.TopMonth ||
791     sort == SortType.TopYear
792   ) {
793     posts.sort(
794       (a, b) =>
795         +a.removed - +b.removed ||
796         +a.deleted - +b.deleted ||
797         (communityType && +b.stickied - +a.stickied) ||
798         b.score - a.score
799     );
800   } else if (sort == SortType.New) {
801     posts.sort(
802       (a, b) =>
803         +a.removed - +b.removed ||
804         +a.deleted - +b.deleted ||
805         (communityType && +b.stickied - +a.stickied) ||
806         b.published.localeCompare(a.published)
807     );
808   } else if (sort == SortType.Hot) {
809     posts.sort(
810       (a, b) =>
811         +a.removed - +b.removed ||
812         +a.deleted - +b.deleted ||
813         (communityType && +b.stickied - +a.stickied) ||
814         hotRankPost(b) - hotRankPost(a)
815     );
816   }
817 }
818
819 export const colorList: Array<string> = [
820   hsl(0),
821   hsl(100),
822   hsl(150),
823   hsl(200),
824   hsl(250),
825   hsl(300),
826 ];
827
828 function hsl(num: number) {
829   return `hsla(${num}, 35%, 50%, 1)`;
830 }
831
832 function randomHsl() {
833   return `hsla(${Math.random() * 360}, 100%, 50%, 1)`;
834 }
835
836 export function previewLines(text: string, lines: number = 3): string {
837   // Use lines * 2 because markdown requires 2 lines
838   return text
839     .split('\n')
840     .slice(0, lines * 2)
841     .join('\n');
842 }