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