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