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