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