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