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