]> Untitled Git - lemmy.git/blob - ui/src/utils.ts
Merge branch 'nutomic-federation' into federation
[lemmy.git] / ui / src / utils.ts
1 import 'moment/locale/es';
2 import 'moment/locale/eo';
3 import 'moment/locale/de';
4 import 'moment/locale/zh-cn';
5 import 'moment/locale/fr';
6 import 'moment/locale/sv';
7 import 'moment/locale/ru';
8 import 'moment/locale/nl';
9 import 'moment/locale/it';
10 import 'moment/locale/fi';
11 import 'moment/locale/ca';
12 import 'moment/locale/fa';
13 import 'moment/locale/pt-br';
14 import 'moment/locale/ja';
15
16 import {
17   UserOperation,
18   Comment,
19   CommentNode as CommentNodeI,
20   Post,
21   PrivateMessage,
22   User,
23   SortType,
24   CommentSortType,
25   ListingType,
26   DataType,
27   SearchType,
28   WebSocketResponse,
29   WebSocketJsonResponse,
30   SearchForm,
31   SearchResponse,
32   CommentResponse,
33   PostResponse,
34 } from './interfaces';
35 import { UserService, WebSocketService } from './services';
36
37 import Tribute from 'tributejs/src/Tribute.js';
38 import markdown_it from 'markdown-it';
39 import markdownitEmoji from 'markdown-it-emoji/light';
40 import markdown_it_container from 'markdown-it-container';
41 import twemoji from 'twemoji';
42 import emojiShortName from 'emoji-short-name';
43 import Toastify from 'toastify-js';
44 import tippy from 'tippy.js';
45
46 export const repoUrl = 'https://github.com/dessalines/lemmy';
47 export const helpGuideUrl = '/docs/about_guide.html';
48 export const markdownHelpUrl = `${helpGuideUrl}#markdown-guide`;
49 export const sortingHelpUrl = `${helpGuideUrl}#sorting`;
50 export const archiveUrl = 'https://archive.is';
51
52 export const postRefetchSeconds: number = 60 * 1000;
53 export const fetchLimit: number = 20;
54 export const mentionDropdownFetchLimit = 10;
55
56 export const languages = [
57   { code: 'ca', name: 'Català' },
58   { code: 'en', name: 'English' },
59   { code: 'eo', name: 'Esperanto' },
60   { code: 'es', name: 'Español' },
61   { code: 'de', name: 'Deutsch' },
62   { code: 'fa', name: 'فارسی' },
63   { code: 'ja', name: '日本語' },
64   { code: 'pt_BR', name: 'Português Brasileiro' },
65   { code: 'zh', name: '中文' },
66   { code: 'fi', name: 'Suomi' },
67   { code: 'fr', name: 'Français' },
68   { code: 'sv', name: 'Svenska' },
69   { code: 'ru', name: 'Русский' },
70   { code: 'nl', name: 'Nederlands' },
71   { code: 'it', name: 'Italiano' },
72 ];
73
74 export const themes = [
75   'litera',
76   'materia',
77   'minty',
78   'solar',
79   'united',
80   'cyborg',
81   'darkly',
82   'journal',
83   'sketchy',
84   'vaporwave',
85   'vaporwave-dark',
86   'i386',
87 ];
88
89 export function randomStr() {
90   return Math.random()
91     .toString(36)
92     .replace(/[^a-z]+/g, '')
93     .substr(2, 10);
94 }
95
96 export function wsJsonToRes(msg: WebSocketJsonResponse): WebSocketResponse {
97   let opStr: string = msg.op;
98   return {
99     op: UserOperation[opStr],
100     data: msg.data,
101   };
102 }
103
104 export const md = new markdown_it({
105   html: false,
106   linkify: true,
107   typographer: true,
108 })
109   .use(markdown_it_container, 'spoiler', {
110     validate: function(params: any) {
111       return params.trim().match(/^spoiler\s+(.*)$/);
112     },
113
114     render: function(tokens: any, idx: any) {
115       var m = tokens[idx].info.trim().match(/^spoiler\s+(.*)$/);
116
117       if (tokens[idx].nesting === 1) {
118         // opening tag
119         return `<details><summary> ${md.utils.escapeHtml(m[1])} </summary>\n`;
120       } else {
121         // closing tag
122         return '</details>\n';
123       }
124     },
125   })
126   .use(markdownitEmoji, {
127     defs: objectFlip(emojiShortName),
128   });
129
130 md.renderer.rules.emoji = function(token, idx) {
131   return twemoji.parse(token[idx].content);
132 };
133
134 export function hotRankComment(comment: Comment): number {
135   return hotRank(comment.score, comment.published);
136 }
137
138 export function hotRankPost(post: Post): number {
139   return hotRank(post.score, post.newest_activity_time);
140 }
141
142 export function hotRank(score: number, timeStr: string): number {
143   // Rank = ScaleFactor * sign(Score) * log(1 + abs(Score)) / (Time + 2)^Gravity
144   let date: Date = new Date(timeStr + 'Z'); // Add Z to convert from UTC date
145   let now: Date = new Date();
146   let hoursElapsed: number = (now.getTime() - date.getTime()) / 36e5;
147
148   let rank =
149     (10000 * Math.log10(Math.max(1, 3 + score))) /
150     Math.pow(hoursElapsed + 2, 1.8);
151
152   // console.log(`Comment: ${comment.content}\nRank: ${rank}\nScore: ${comment.score}\nHours: ${hoursElapsed}`);
153
154   return rank;
155 }
156
157 export function mdToHtml(text: string) {
158   return { __html: md.render(text) };
159 }
160
161 export function getUnixTime(text: string): number {
162   return text ? new Date(text).getTime() / 1000 : undefined;
163 }
164
165 export function addTypeInfo<T>(
166   arr: Array<T>,
167   name: string
168 ): Array<{ type_: string; data: T }> {
169   return arr.map(e => {
170     return { type_: name, data: e };
171   });
172 }
173
174 export function canMod(
175   user: User,
176   modIds: Array<number>,
177   creator_id: number,
178   onSelf: boolean = false
179 ): boolean {
180   // You can do moderator actions only on the mods added after you.
181   if (user) {
182     let yourIndex = modIds.findIndex(id => id == user.id);
183     if (yourIndex == -1) {
184       return false;
185     } else {
186       // onSelf +1 on mod actions not for yourself, IE ban, remove, etc
187       modIds = modIds.slice(0, yourIndex + (onSelf ? 0 : 1));
188       return !modIds.includes(creator_id);
189     }
190   } else {
191     return false;
192   }
193 }
194
195 export function isMod(modIds: Array<number>, creator_id: number): boolean {
196   return modIds.includes(creator_id);
197 }
198
199 const imageRegex = new RegExp(
200   /(http)?s?:?(\/\/[^"']*\.(?:jpg|jpeg|gif|png|svg))/
201 );
202 const videoRegex = new RegExp(`(http)?s?:?(\/\/[^"']*\.(?:mp4))`);
203
204 export function isImage(url: string) {
205   return imageRegex.test(url);
206 }
207
208 export function isVideo(url: string) {
209   return videoRegex.test(url);
210 }
211
212 export function validURL(str: string) {
213   try {
214     return !!new URL(str);
215   } catch {
216     return false;
217   }
218 }
219
220 export function validEmail(email: string) {
221   let re = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
222   return re.test(String(email).toLowerCase());
223 }
224
225 export function capitalizeFirstLetter(str: string): string {
226   return str.charAt(0).toUpperCase() + str.slice(1);
227 }
228
229 export function routeSortTypeToEnum(sort: string): SortType {
230   if (sort == 'new') {
231     return SortType.New;
232   } else if (sort == 'hot') {
233     return SortType.Hot;
234   } else if (sort == 'topday') {
235     return SortType.TopDay;
236   } else if (sort == 'topweek') {
237     return SortType.TopWeek;
238   } else if (sort == 'topmonth') {
239     return SortType.TopMonth;
240   } else if (sort == 'topyear') {
241     return SortType.TopYear;
242   } else if (sort == 'topall') {
243     return SortType.TopAll;
244   }
245 }
246
247 export function routeListingTypeToEnum(type: string): ListingType {
248   return ListingType[capitalizeFirstLetter(type)];
249 }
250
251 export function routeDataTypeToEnum(type: string): DataType {
252   return DataType[capitalizeFirstLetter(type)];
253 }
254
255 export function routeSearchTypeToEnum(type: string): SearchType {
256   return SearchType[capitalizeFirstLetter(type)];
257 }
258
259 export async function getPageTitle(url: string) {
260   let res = await fetch(`/iframely/oembed?url=${url}`).then(res => res.json());
261   let title = await res.title;
262   return title;
263 }
264
265 export function debounce(
266   func: any,
267   wait: number = 1000,
268   immediate: boolean = false
269 ) {
270   // 'private' variable for instance
271   // The returned function will be able to reference this due to closure.
272   // Each call to the returned function will share this common timer.
273   let timeout: any;
274
275   // Calling debounce returns a new anonymous function
276   return function() {
277     // reference the context and args for the setTimeout function
278     var context = this,
279       args = arguments;
280
281     // Should the function be called now? If immediate is true
282     //   and not already in a timeout then the answer is: Yes
283     var callNow = immediate && !timeout;
284
285     // This is the basic debounce behaviour where you can call this
286     //   function several times, but it will only execute once
287     //   [before or after imposing a delay].
288     //   Each time the returned function is called, the timer starts over.
289     clearTimeout(timeout);
290
291     // Set the new timeout
292     timeout = setTimeout(function() {
293       // Inside the timeout function, clear the timeout variable
294       // which will let the next execution run when in 'immediate' mode
295       timeout = null;
296
297       // Check if the function already ran with the immediate flag
298       if (!immediate) {
299         // Call the original function with apply
300         // apply lets you define the 'this' object as well as the arguments
301         //    (both captured before setTimeout)
302         func.apply(context, args);
303       }
304     }, wait);
305
306     // Immediate mode and no wait timer? Execute the function..
307     if (callNow) func.apply(context, args);
308   };
309 }
310
311 export function getLanguage(): string {
312   let user = UserService.Instance.user;
313   let lang = user && user.lang ? user.lang : 'browser';
314
315   if (lang == 'browser') {
316     return getBrowserLanguage();
317   } else {
318     return lang;
319   }
320 }
321
322 export function getBrowserLanguage(): string {
323   return navigator.language;
324 }
325
326 export function getMomentLanguage(): string {
327   let lang = getLanguage();
328   if (lang.startsWith('zh')) {
329     lang = 'zh-cn';
330   } else if (lang.startsWith('sv')) {
331     lang = 'sv';
332   } else if (lang.startsWith('fr')) {
333     lang = 'fr';
334   } else if (lang.startsWith('de')) {
335     lang = 'de';
336   } else if (lang.startsWith('ru')) {
337     lang = 'ru';
338   } else if (lang.startsWith('es')) {
339     lang = 'es';
340   } else if (lang.startsWith('eo')) {
341     lang = 'eo';
342   } else if (lang.startsWith('nl')) {
343     lang = 'nl';
344   } else if (lang.startsWith('it')) {
345     lang = 'it';
346   } else if (lang.startsWith('fi')) {
347     lang = 'fi';
348   } else if (lang.startsWith('ca')) {
349     lang = 'ca';
350   } else if (lang.startsWith('fa')) {
351     lang = 'fa';
352   } else if (lang.startsWith('pt')) {
353     lang = 'pt-br';
354   } else if (lang.startsWith('ja')) {
355     lang = 'ja';
356   } else {
357     lang = 'en';
358   }
359   return lang;
360 }
361
362 export function setTheme(theme: string = 'darkly') {
363   // unload all the other themes
364   for (var i = 0; i < themes.length; i++) {
365     let styleSheet = document.getElementById(themes[i]);
366     if (styleSheet) {
367       styleSheet.setAttribute('disabled', 'disabled');
368     }
369   }
370
371   // Load the theme dynamically
372   if (!document.getElementById(theme)) {
373     var head = document.getElementsByTagName('head')[0];
374     var link = document.createElement('link');
375     link.id = theme;
376     link.rel = 'stylesheet';
377     link.type = 'text/css';
378     link.href = `/static/assets/css/themes/${theme}.min.css`;
379     link.media = 'all';
380     head.appendChild(link);
381   }
382   document.getElementById(theme).removeAttribute('disabled');
383 }
384
385 export function objectFlip(obj: any) {
386   const ret = {};
387   Object.keys(obj).forEach(key => {
388     ret[obj[key]] = key;
389   });
390   return ret;
391 }
392
393 export function pictshareAvatarThumbnail(src: string): string {
394   // sample url: http://localhost:8535/pictshare/gs7xuu.jpg
395   let split = src.split('pictshare');
396   let out = `${split[0]}pictshare/96${split[1]}`;
397   return out;
398 }
399
400 export function showAvatars(): boolean {
401   return (
402     (UserService.Instance.user && UserService.Instance.user.show_avatars) ||
403     !UserService.Instance.user
404   );
405 }
406
407 // Converts to image thumbnail
408 export function pictshareImage(
409   hash: string,
410   thumbnail: boolean = false
411 ): string {
412   let root = `/pictshare`;
413
414   // Necessary for other servers / domains
415   if (hash.includes('pictshare')) {
416     let split = hash.split('/pictshare/');
417     root = `${split[0]}/pictshare`;
418     hash = split[1];
419   }
420
421   let out = `${root}/${thumbnail ? '192/' : ''}${hash}`;
422   return out;
423 }
424
425 export function isCommentType(item: Comment | PrivateMessage): item is Comment {
426   return (item as Comment).community_id !== undefined;
427 }
428
429 export function toast(text: string, background: string = 'success') {
430   let backgroundColor = `var(--${background})`;
431   Toastify({
432     text: text,
433     backgroundColor: backgroundColor,
434     gravity: 'bottom',
435     position: 'left',
436   }).showToast();
437 }
438
439 export function setupTribute(): Tribute {
440   return new Tribute({
441     collection: [
442       // Emojis
443       {
444         trigger: ':',
445         menuItemTemplate: (item: any) => {
446           let emoji = `:${item.original.key}:`;
447           return `${item.original.val} ${emoji}`;
448         },
449         selectTemplate: (item: any) => {
450           return `:${item.original.key}:`;
451         },
452         values: Object.entries(emojiShortName).map(e => {
453           return { key: e[1], val: e[0] };
454         }),
455         allowSpaces: false,
456         autocompleteMode: true,
457         menuItemLimit: mentionDropdownFetchLimit,
458         menuShowMinLength: 2,
459       },
460       // Users
461       {
462         trigger: '@',
463         selectTemplate: (item: any) => {
464           return `[/u/${item.original.key}](/u/${item.original.key})`;
465         },
466         values: (text: string, cb: any) => {
467           userSearch(text, (users: any) => cb(users));
468         },
469         allowSpaces: false,
470         autocompleteMode: true,
471         menuItemLimit: mentionDropdownFetchLimit,
472         menuShowMinLength: 2,
473       },
474
475       // Communities
476       {
477         trigger: '#',
478         selectTemplate: (item: any) => {
479           return `[/c/${item.original.key}](/c/${item.original.key})`;
480         },
481         values: (text: string, cb: any) => {
482           communitySearch(text, (communities: any) => cb(communities));
483         },
484         allowSpaces: false,
485         autocompleteMode: true,
486         menuItemLimit: mentionDropdownFetchLimit,
487         menuShowMinLength: 2,
488       },
489     ],
490   });
491 }
492
493 let tippyInstance = tippy('[data-tippy-content]');
494
495 export function setupTippy() {
496   tippyInstance.forEach(e => e.destroy());
497   tippyInstance = tippy('[data-tippy-content]', {
498     delay: [500, 0],
499     // Display on "long press"
500     touch: ['hold', 500],
501   });
502 }
503
504 function userSearch(text: string, cb: any) {
505   if (text) {
506     let form: SearchForm = {
507       q: text,
508       type_: SearchType[SearchType.Users],
509       sort: SortType[SortType.TopAll],
510       page: 1,
511       limit: mentionDropdownFetchLimit,
512     };
513
514     WebSocketService.Instance.search(form);
515
516     this.userSub = WebSocketService.Instance.subject.subscribe(
517       msg => {
518         let res = wsJsonToRes(msg);
519         if (res.op == UserOperation.Search) {
520           let data = res.data as SearchResponse;
521           let users = data.users.map(u => {
522             return { key: u.name };
523           });
524           cb(users);
525           this.userSub.unsubscribe();
526         }
527       },
528       err => console.error(err),
529       () => console.log('complete')
530     );
531   } else {
532     cb([]);
533   }
534 }
535
536 function communitySearch(text: string, cb: any) {
537   if (text) {
538     let form: SearchForm = {
539       q: text,
540       type_: SearchType[SearchType.Communities],
541       sort: SortType[SortType.TopAll],
542       page: 1,
543       limit: mentionDropdownFetchLimit,
544     };
545
546     WebSocketService.Instance.search(form);
547
548     this.communitySub = WebSocketService.Instance.subject.subscribe(
549       msg => {
550         let res = wsJsonToRes(msg);
551         if (res.op == UserOperation.Search) {
552           let data = res.data as SearchResponse;
553           let communities = data.communities.map(u => {
554             return { key: u.name };
555           });
556           cb(communities);
557           this.communitySub.unsubscribe();
558         }
559       },
560       err => console.error(err),
561       () => console.log('complete')
562     );
563   } else {
564     cb([]);
565   }
566 }
567
568 export function getListingTypeFromProps(props: any): ListingType {
569   return props.match.params.listing_type
570     ? routeListingTypeToEnum(props.match.params.listing_type)
571     : UserService.Instance.user
572     ? UserService.Instance.user.default_listing_type
573     : ListingType.All;
574 }
575
576 // TODO might need to add a user setting for this too
577 export function getDataTypeFromProps(props: any): DataType {
578   return props.match.params.data_type
579     ? routeDataTypeToEnum(props.match.params.data_type)
580     : DataType.Post;
581 }
582
583 export function getSortTypeFromProps(props: any): SortType {
584   return props.match.params.sort
585     ? routeSortTypeToEnum(props.match.params.sort)
586     : UserService.Instance.user
587     ? UserService.Instance.user.default_sort_type
588     : SortType.Hot;
589 }
590
591 export function getPageFromProps(props: any): number {
592   return props.match.params.page ? Number(props.match.params.page) : 1;
593 }
594
595 export function editCommentRes(
596   data: CommentResponse,
597   comments: Array<Comment>
598 ) {
599   let found = comments.find(c => c.id == data.comment.id);
600   if (found) {
601     found.content = data.comment.content;
602     found.updated = data.comment.updated;
603     found.removed = data.comment.removed;
604     found.deleted = data.comment.deleted;
605     found.upvotes = data.comment.upvotes;
606     found.downvotes = data.comment.downvotes;
607     found.score = data.comment.score;
608   }
609 }
610
611 export function saveCommentRes(
612   data: CommentResponse,
613   comments: Array<Comment>
614 ) {
615   let found = comments.find(c => c.id == data.comment.id);
616   if (found) {
617     found.saved = data.comment.saved;
618   }
619 }
620
621 export function createCommentLikeRes(
622   data: CommentResponse,
623   comments: Array<Comment>
624 ) {
625   let found: Comment = comments.find(c => c.id === data.comment.id);
626   if (found) {
627     found.score = data.comment.score;
628     found.upvotes = data.comment.upvotes;
629     found.downvotes = data.comment.downvotes;
630     if (data.comment.my_vote !== null) {
631       found.my_vote = data.comment.my_vote;
632     }
633   }
634 }
635
636 export function createPostLikeFindRes(data: PostResponse, posts: Array<Post>) {
637   let found = posts.find(c => c.id == data.post.id);
638   if (found) {
639     createPostLikeRes(data, found);
640   }
641 }
642
643 export function createPostLikeRes(data: PostResponse, post: Post) {
644   if (post) {
645     post.score = data.post.score;
646     post.upvotes = data.post.upvotes;
647     post.downvotes = data.post.downvotes;
648     if (data.post.my_vote !== null) {
649       post.my_vote = data.post.my_vote;
650     }
651   }
652 }
653
654 export function editPostFindRes(data: PostResponse, posts: Array<Post>) {
655   let found = posts.find(c => c.id == data.post.id);
656   if (found) {
657     editPostRes(data, found);
658   }
659 }
660
661 export function editPostRes(data: PostResponse, post: Post) {
662   if (post) {
663     post.url = data.post.url;
664     post.name = data.post.name;
665     post.nsfw = data.post.nsfw;
666   }
667 }
668
669 export function commentsToFlatNodes(
670   comments: Array<Comment>
671 ): Array<CommentNodeI> {
672   let nodes: Array<CommentNodeI> = [];
673   for (let comment of comments) {
674     nodes.push({ comment: comment });
675   }
676   return nodes;
677 }
678
679 export function commentSort(tree: Array<CommentNodeI>, sort: CommentSortType) {
680   // First, put removed and deleted comments at the bottom, then do your other sorts
681   if (sort == CommentSortType.Top) {
682     tree.sort(
683       (a, b) =>
684         +a.comment.removed - +b.comment.removed ||
685         +a.comment.deleted - +b.comment.deleted ||
686         b.comment.score - a.comment.score
687     );
688   } else if (sort == CommentSortType.New) {
689     tree.sort(
690       (a, b) =>
691         +a.comment.removed - +b.comment.removed ||
692         +a.comment.deleted - +b.comment.deleted ||
693         b.comment.published.localeCompare(a.comment.published)
694     );
695   } else if (sort == CommentSortType.Old) {
696     tree.sort(
697       (a, b) =>
698         +a.comment.removed - +b.comment.removed ||
699         +a.comment.deleted - +b.comment.deleted ||
700         a.comment.published.localeCompare(b.comment.published)
701     );
702   } else if (sort == CommentSortType.Hot) {
703     tree.sort(
704       (a, b) =>
705         +a.comment.removed - +b.comment.removed ||
706         +a.comment.deleted - +b.comment.deleted ||
707         hotRankComment(b.comment) - hotRankComment(a.comment)
708     );
709   }
710
711   // Go through the children recursively
712   for (let node of tree) {
713     if (node.children) {
714       commentSort(node.children, sort);
715     }
716   }
717 }
718
719 export function commentSortSortType(tree: Array<CommentNodeI>, sort: SortType) {
720   commentSort(tree, convertCommentSortType(sort));
721 }
722
723 function convertCommentSortType(sort: SortType): CommentSortType {
724   if (
725     sort == SortType.TopAll ||
726     sort == SortType.TopDay ||
727     sort == SortType.TopWeek ||
728     sort == SortType.TopMonth ||
729     sort == SortType.TopYear
730   ) {
731     return CommentSortType.Top;
732   } else if (sort == SortType.New) {
733     return CommentSortType.New;
734   } else if (sort == SortType.Hot) {
735     return CommentSortType.Hot;
736   } else {
737     return CommentSortType.Hot;
738   }
739 }
740
741 export function postSort(
742   posts: Array<Post>,
743   sort: SortType,
744   communityType: boolean
745 ) {
746   // First, put removed and deleted comments at the bottom, then do your other sorts
747   if (
748     sort == SortType.TopAll ||
749     sort == SortType.TopDay ||
750     sort == SortType.TopWeek ||
751     sort == SortType.TopMonth ||
752     sort == SortType.TopYear
753   ) {
754     posts.sort(
755       (a, b) =>
756         +a.removed - +b.removed ||
757         +a.deleted - +b.deleted ||
758         (communityType && +b.stickied - +a.stickied) ||
759         b.score - a.score
760     );
761   } else if (sort == SortType.New) {
762     posts.sort(
763       (a, b) =>
764         +a.removed - +b.removed ||
765         +a.deleted - +b.deleted ||
766         (communityType && +b.stickied - +a.stickied) ||
767         b.published.localeCompare(a.published)
768     );
769   } else if (sort == SortType.Hot) {
770     posts.sort(
771       (a, b) =>
772         +a.removed - +b.removed ||
773         +a.deleted - +b.deleted ||
774         (communityType && +b.stickied - +a.stickied) ||
775         hotRankPost(b) - hotRankPost(a)
776     );
777   }
778 }
779
780 export const colorList: Array<string> = [
781   hsl(0),
782   hsl(100),
783   hsl(150),
784   hsl(200),
785   hsl(250),
786   hsl(300),
787 ];
788
789 function hsl(num: number) {
790   return `hsla(${num}, 35%, 50%, 1)`;
791 }
792
793 function randomHsl() {
794   return `hsla(${Math.random() * 360}, 100%, 50%, 1)`;
795 }