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