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