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