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