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