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