]> Untitled Git - lemmy.git/blob - ui/src/utils.ts
Some small fixes.
[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(override?: string): string {
354   let user = UserService.Instance.user;
355   let lang = override || (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     noMatchTemplate: function () {
592       return '';
593     },
594     collection: [
595       // Emojis
596       {
597         trigger: ':',
598         menuItemTemplate: (item: any) => {
599           let shortName = `:${item.original.key}:`;
600           return `${item.original.val} ${shortName}`;
601         },
602         selectTemplate: (item: any) => {
603           return `:${item.original.key}:`;
604         },
605         values: Object.entries(emojiShortName).map(e => {
606           return { key: e[1], val: e[0] };
607         }),
608         allowSpaces: false,
609         autocompleteMode: true,
610         menuItemLimit: mentionDropdownFetchLimit,
611         menuShowMinLength: 2,
612       },
613       // Users
614       {
615         trigger: '@',
616         selectTemplate: (item: any) => {
617           let link = item.original.local
618             ? `[${item.original.key}](/u/${item.original.name})`
619             : `[${item.original.key}](/user/${item.original.id})`;
620           return link;
621         },
622         values: (text: string, cb: any) => {
623           userSearch(text, (users: any) => cb(users));
624         },
625         allowSpaces: false,
626         autocompleteMode: true,
627         menuItemLimit: mentionDropdownFetchLimit,
628         menuShowMinLength: 2,
629       },
630
631       // Communities
632       {
633         trigger: '!',
634         selectTemplate: (item: any) => {
635           let link = item.original.local
636             ? `[${item.original.key}](/c/${item.original.name})`
637             : `[${item.original.key}](/community/${item.original.id})`;
638           return link;
639         },
640         values: (text: string, cb: any) => {
641           communitySearch(text, (communities: any) => cb(communities));
642         },
643         allowSpaces: false,
644         autocompleteMode: true,
645         menuItemLimit: mentionDropdownFetchLimit,
646         menuShowMinLength: 2,
647       },
648     ],
649   });
650 }
651
652 let tippyInstance = tippy('[data-tippy-content]');
653
654 export function setupTippy() {
655   tippyInstance.forEach(e => e.destroy());
656   tippyInstance = tippy('[data-tippy-content]', {
657     delay: [500, 0],
658     // Display on "long press"
659     touch: ['hold', 500],
660   });
661 }
662
663 function userSearch(text: string, cb: any) {
664   if (text) {
665     let form: SearchForm = {
666       q: text,
667       type_: SearchType[SearchType.Users],
668       sort: SortType[SortType.TopAll],
669       page: 1,
670       limit: mentionDropdownFetchLimit,
671     };
672
673     WebSocketService.Instance.search(form);
674
675     let userSub = WebSocketService.Instance.subject.subscribe(
676       msg => {
677         let res = wsJsonToRes(msg);
678         if (res.op == UserOperation.Search) {
679           let data = res.data as SearchResponse;
680           let users = data.users.map(u => {
681             return {
682               key: `@${u.name}@${hostname(u.actor_id)}`,
683               name: u.name,
684               local: u.local,
685               id: u.id,
686             };
687           });
688           cb(users);
689           userSub.unsubscribe();
690         }
691       },
692       err => console.error(err),
693       () => console.log('complete')
694     );
695   } else {
696     cb([]);
697   }
698 }
699
700 function communitySearch(text: string, cb: any) {
701   if (text) {
702     let form: SearchForm = {
703       q: text,
704       type_: SearchType[SearchType.Communities],
705       sort: SortType[SortType.TopAll],
706       page: 1,
707       limit: mentionDropdownFetchLimit,
708     };
709
710     WebSocketService.Instance.search(form);
711
712     let communitySub = WebSocketService.Instance.subject.subscribe(
713       msg => {
714         let res = wsJsonToRes(msg);
715         if (res.op == UserOperation.Search) {
716           let data = res.data as SearchResponse;
717           let communities = data.communities.map(c => {
718             return {
719               key: `!${c.name}@${hostname(c.actor_id)}`,
720               name: c.name,
721               local: c.local,
722               id: c.id,
723             };
724           });
725           cb(communities);
726           communitySub.unsubscribe();
727         }
728       },
729       err => console.error(err),
730       () => console.log('complete')
731     );
732   } else {
733     cb([]);
734   }
735 }
736
737 export function getListingTypeFromProps(props: any): ListingType {
738   return props.match.params.listing_type
739     ? routeListingTypeToEnum(props.match.params.listing_type)
740     : UserService.Instance.user
741     ? UserService.Instance.user.default_listing_type
742     : ListingType.All;
743 }
744
745 // TODO might need to add a user setting for this too
746 export function getDataTypeFromProps(props: any): DataType {
747   return props.match.params.data_type
748     ? routeDataTypeToEnum(props.match.params.data_type)
749     : DataType.Post;
750 }
751
752 export function getSortTypeFromProps(props: any): SortType {
753   return props.match.params.sort
754     ? routeSortTypeToEnum(props.match.params.sort)
755     : UserService.Instance.user
756     ? UserService.Instance.user.default_sort_type
757     : SortType.Hot;
758 }
759
760 export function getPageFromProps(props: any): number {
761   return props.match.params.page ? Number(props.match.params.page) : 1;
762 }
763
764 export function editCommentRes(
765   data: CommentResponse,
766   comments: Array<Comment>
767 ) {
768   let found = comments.find(c => c.id == data.comment.id);
769   if (found) {
770     found.content = data.comment.content;
771     found.updated = data.comment.updated;
772     found.removed = data.comment.removed;
773     found.deleted = data.comment.deleted;
774     found.upvotes = data.comment.upvotes;
775     found.downvotes = data.comment.downvotes;
776     found.score = data.comment.score;
777   }
778 }
779
780 export function saveCommentRes(
781   data: CommentResponse,
782   comments: Array<Comment>
783 ) {
784   let found = comments.find(c => c.id == data.comment.id);
785   if (found) {
786     found.saved = data.comment.saved;
787   }
788 }
789
790 export function createCommentLikeRes(
791   data: CommentResponse,
792   comments: Array<Comment>
793 ) {
794   let found: Comment = comments.find(c => c.id === data.comment.id);
795   if (found) {
796     found.score = data.comment.score;
797     found.upvotes = data.comment.upvotes;
798     found.downvotes = data.comment.downvotes;
799     if (data.comment.my_vote !== null) {
800       found.my_vote = data.comment.my_vote;
801     }
802   }
803 }
804
805 export function createPostLikeFindRes(data: PostResponse, posts: Array<Post>) {
806   let found = posts.find(c => c.id == data.post.id);
807   if (found) {
808     createPostLikeRes(data, found);
809   }
810 }
811
812 export function createPostLikeRes(data: PostResponse, post: Post) {
813   if (post) {
814     post.score = data.post.score;
815     post.upvotes = data.post.upvotes;
816     post.downvotes = data.post.downvotes;
817     if (data.post.my_vote !== null) {
818       post.my_vote = data.post.my_vote;
819     }
820   }
821 }
822
823 export function editPostFindRes(data: PostResponse, posts: Array<Post>) {
824   let found = posts.find(c => c.id == data.post.id);
825   if (found) {
826     editPostRes(data, found);
827   }
828 }
829
830 export function editPostRes(data: PostResponse, post: Post) {
831   if (post) {
832     post.url = data.post.url;
833     post.name = data.post.name;
834     post.nsfw = data.post.nsfw;
835     post.deleted = data.post.deleted;
836     post.removed = data.post.removed;
837     post.stickied = data.post.stickied;
838     post.body = data.post.body;
839     post.locked = data.post.locked;
840   }
841 }
842
843 export function commentsToFlatNodes(
844   comments: Array<Comment>
845 ): Array<CommentNodeI> {
846   let nodes: Array<CommentNodeI> = [];
847   for (let comment of comments) {
848     nodes.push({ comment: comment });
849   }
850   return nodes;
851 }
852
853 export function commentSort(tree: Array<CommentNodeI>, sort: CommentSortType) {
854   // First, put removed and deleted comments at the bottom, then do your other sorts
855   if (sort == CommentSortType.Top) {
856     tree.sort(
857       (a, b) =>
858         +a.comment.removed - +b.comment.removed ||
859         +a.comment.deleted - +b.comment.deleted ||
860         b.comment.score - a.comment.score
861     );
862   } else if (sort == CommentSortType.New) {
863     tree.sort(
864       (a, b) =>
865         +a.comment.removed - +b.comment.removed ||
866         +a.comment.deleted - +b.comment.deleted ||
867         b.comment.published.localeCompare(a.comment.published)
868     );
869   } else if (sort == CommentSortType.Old) {
870     tree.sort(
871       (a, b) =>
872         +a.comment.removed - +b.comment.removed ||
873         +a.comment.deleted - +b.comment.deleted ||
874         a.comment.published.localeCompare(b.comment.published)
875     );
876   } else if (sort == CommentSortType.Hot) {
877     tree.sort(
878       (a, b) =>
879         +a.comment.removed - +b.comment.removed ||
880         +a.comment.deleted - +b.comment.deleted ||
881         hotRankComment(b.comment) - hotRankComment(a.comment)
882     );
883   }
884
885   // Go through the children recursively
886   for (let node of tree) {
887     if (node.children) {
888       commentSort(node.children, sort);
889     }
890   }
891 }
892
893 export function commentSortSortType(tree: Array<CommentNodeI>, sort: SortType) {
894   commentSort(tree, convertCommentSortType(sort));
895 }
896
897 function convertCommentSortType(sort: SortType): CommentSortType {
898   if (
899     sort == SortType.TopAll ||
900     sort == SortType.TopDay ||
901     sort == SortType.TopWeek ||
902     sort == SortType.TopMonth ||
903     sort == SortType.TopYear
904   ) {
905     return CommentSortType.Top;
906   } else if (sort == SortType.New) {
907     return CommentSortType.New;
908   } else if (sort == SortType.Hot) {
909     return CommentSortType.Hot;
910   } else {
911     return CommentSortType.Hot;
912   }
913 }
914
915 export function postSort(
916   posts: Array<Post>,
917   sort: SortType,
918   communityType: boolean
919 ) {
920   // First, put removed and deleted comments at the bottom, then do your other sorts
921   if (
922     sort == SortType.TopAll ||
923     sort == SortType.TopDay ||
924     sort == SortType.TopWeek ||
925     sort == SortType.TopMonth ||
926     sort == SortType.TopYear
927   ) {
928     posts.sort(
929       (a, b) =>
930         +a.removed - +b.removed ||
931         +a.deleted - +b.deleted ||
932         (communityType && +b.stickied - +a.stickied) ||
933         b.score - a.score
934     );
935   } else if (sort == SortType.New) {
936     posts.sort(
937       (a, b) =>
938         +a.removed - +b.removed ||
939         +a.deleted - +b.deleted ||
940         (communityType && +b.stickied - +a.stickied) ||
941         b.published.localeCompare(a.published)
942     );
943   } else if (sort == SortType.Hot) {
944     posts.sort(
945       (a, b) =>
946         +a.removed - +b.removed ||
947         +a.deleted - +b.deleted ||
948         (communityType && +b.stickied - +a.stickied) ||
949         b.hot_rank - a.hot_rank
950     );
951   }
952 }
953
954 export const colorList: Array<string> = [
955   hsl(0),
956   hsl(100),
957   hsl(150),
958   hsl(200),
959   hsl(250),
960   hsl(300),
961 ];
962
963 function hsl(num: number) {
964   return `hsla(${num}, 35%, 50%, 1)`;
965 }
966
967 function randomHsl() {
968   return `hsla(${Math.random() * 360}, 100%, 50%, 1)`;
969 }
970
971 export function previewLines(text: string, lines: number = 3): string {
972   // Use lines * 2 because markdown requires 2 lines
973   return text
974     .split('\n')
975     .slice(0, lines * 2)
976     .join('\n');
977 }
978
979 export function hostname(url: string): string {
980   let cUrl = new URL(url);
981   return window.location.port
982     ? `${cUrl.hostname}:${cUrl.port}`
983     : `${cUrl.hostname}`;
984 }
985
986 function canUseWebP() {
987   // TODO pictshare might have a webp conversion bug, try disabling this
988   return false;
989
990   // var elem = document.createElement('canvas');
991   // if (!!(elem.getContext && elem.getContext('2d'))) {
992   //   var testString = !(window.mozInnerScreenX == null) ? 'png' : 'webp';
993   //   // was able or not to get WebP representation
994   //   return (
995   //     elem.toDataURL('image/webp').startsWith('data:image/' + testString)
996   //   );
997   // }
998
999   // // very old browser like IE 8, canvas not supported
1000   // return false;
1001 }
1002
1003 export function validTitle(title?: string): boolean {
1004   // Initial title is null, minimum length is taken care of by textarea's minLength={3}
1005   if (title === null || title.length < 3) return true;
1006
1007   const regex = new RegExp(/.*\S.*/, 'g');
1008
1009   return regex.test(title);
1010 }