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