]> Untitled Git - lemmy.git/blob - ui/src/utils.ts
Merge branch 'master' 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,
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 function randomStr() {
57   return Math.random()
58     .toString(36)
59     .replace(/[^a-z]+/g, '')
60     .substr(2, 10);
61 }
62
63 export function wsJsonToRes(msg: WebSocketJsonResponse): WebSocketResponse {
64   let opStr: string = msg.op;
65   return {
66     op: UserOperation[opStr],
67     data: msg.data,
68   };
69 }
70
71 export const md = new markdown_it({
72   html: false,
73   linkify: true,
74   typographer: true,
75 })
76   .use(markdown_it_container, 'spoiler', {
77     validate: function(params: any) {
78       return params.trim().match(/^spoiler\s+(.*)$/);
79     },
80
81     render: function(tokens: any, idx: any) {
82       var m = tokens[idx].info.trim().match(/^spoiler\s+(.*)$/);
83
84       if (tokens[idx].nesting === 1) {
85         // opening tag
86         return `<details><summary> ${md.utils.escapeHtml(m[1])} </summary>\n`;
87       } else {
88         // closing tag
89         return '</details>\n';
90       }
91     },
92   })
93   .use(markdownitEmoji, {
94     defs: objectFlip(emojiShortName),
95   });
96
97 md.renderer.rules.emoji = function(token, idx) {
98   return twemoji.parse(token[idx].content);
99 };
100
101 export function hotRankComment(comment: Comment): number {
102   return hotRank(comment.score, comment.published);
103 }
104
105 export function hotRankPost(post: Post): number {
106   return hotRank(post.score, post.newest_activity_time);
107 }
108
109 export function hotRank(score: number, timeStr: string): number {
110   // Rank = ScaleFactor * sign(Score) * log(1 + abs(Score)) / (Time + 2)^Gravity
111   let date: Date = new Date(timeStr + 'Z'); // Add Z to convert from UTC date
112   let now: Date = new Date();
113   let hoursElapsed: number = (now.getTime() - date.getTime()) / 36e5;
114
115   let rank =
116     (10000 * Math.log10(Math.max(1, 3 + score))) /
117     Math.pow(hoursElapsed + 2, 1.8);
118
119   // console.log(`Comment: ${comment.content}\nRank: ${rank}\nScore: ${comment.score}\nHours: ${hoursElapsed}`);
120
121   return rank;
122 }
123
124 export function mdToHtml(text: string) {
125   return { __html: md.render(text) };
126 }
127
128 export function getUnixTime(text: string): number {
129   return text ? new Date(text).getTime() / 1000 : undefined;
130 }
131
132 export function addTypeInfo<T>(
133   arr: Array<T>,
134   name: string
135 ): Array<{ type_: string; data: T }> {
136   return arr.map(e => {
137     return { type_: name, data: e };
138   });
139 }
140
141 export function canMod(
142   user: User,
143   modIds: Array<number>,
144   creator_id: number,
145   onSelf: boolean = false
146 ): boolean {
147   // You can do moderator actions only on the mods added after you.
148   if (user) {
149     let yourIndex = modIds.findIndex(id => id == user.id);
150     if (yourIndex == -1) {
151       return false;
152     } else {
153       // onSelf +1 on mod actions not for yourself, IE ban, remove, etc
154       modIds = modIds.slice(0, yourIndex + (onSelf ? 0 : 1));
155       return !modIds.includes(creator_id);
156     }
157   } else {
158     return false;
159   }
160 }
161
162 export function isMod(modIds: Array<number>, creator_id: number): boolean {
163   return modIds.includes(creator_id);
164 }
165
166 const imageRegex = new RegExp(
167   /(http)?s?:?(\/\/[^"']*\.(?:jpg|jpeg|gif|png|svg))/
168 );
169 const videoRegex = new RegExp(`(http)?s?:?(\/\/[^"']*\.(?:mp4))`);
170
171 export function isImage(url: string) {
172   return imageRegex.test(url);
173 }
174
175 export function isVideo(url: string) {
176   return videoRegex.test(url);
177 }
178
179 export function validURL(str: string) {
180   try {
181     return !!new URL(str);
182   } catch {
183     return false;
184   }
185 }
186
187 export function validEmail(email: string) {
188   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,}))$/;
189   return re.test(String(email).toLowerCase());
190 }
191
192 export function capitalizeFirstLetter(str: string): string {
193   return str.charAt(0).toUpperCase() + str.slice(1);
194 }
195
196 export function routeSortTypeToEnum(sort: string): SortType {
197   if (sort == 'new') {
198     return SortType.New;
199   } else if (sort == 'hot') {
200     return SortType.Hot;
201   } else if (sort == 'topday') {
202     return SortType.TopDay;
203   } else if (sort == 'topweek') {
204     return SortType.TopWeek;
205   } else if (sort == 'topmonth') {
206     return SortType.TopMonth;
207   } else if (sort == 'topyear') {
208     return SortType.TopYear;
209   } else if (sort == 'topall') {
210     return SortType.TopAll;
211   }
212 }
213
214 export function routeListingTypeToEnum(type: string): ListingType {
215   return ListingType[capitalizeFirstLetter(type)];
216 }
217
218 export function routeDataTypeToEnum(type: string): DataType {
219   return DataType[capitalizeFirstLetter(type)];
220 }
221
222 export function routeSearchTypeToEnum(type: string): SearchType {
223   return SearchType[capitalizeFirstLetter(type)];
224 }
225
226 export async function getPageTitle(url: string) {
227   let res = await fetch(`/iframely/oembed?url=${url}`).then(res => res.json());
228   let title = await res.title;
229   return title;
230 }
231
232 export function debounce(
233   func: any,
234   wait: number = 1000,
235   immediate: boolean = false
236 ) {
237   // 'private' variable for instance
238   // The returned function will be able to reference this due to closure.
239   // Each call to the returned function will share this common timer.
240   let timeout: any;
241
242   // Calling debounce returns a new anonymous function
243   return function() {
244     // reference the context and args for the setTimeout function
245     var context = this,
246       args = arguments;
247
248     // Should the function be called now? If immediate is true
249     //   and not already in a timeout then the answer is: Yes
250     var callNow = immediate && !timeout;
251
252     // This is the basic debounce behaviour where you can call this
253     //   function several times, but it will only execute once
254     //   [before or after imposing a delay].
255     //   Each time the returned function is called, the timer starts over.
256     clearTimeout(timeout);
257
258     // Set the new timeout
259     timeout = setTimeout(function() {
260       // Inside the timeout function, clear the timeout variable
261       // which will let the next execution run when in 'immediate' mode
262       timeout = null;
263
264       // Check if the function already ran with the immediate flag
265       if (!immediate) {
266         // Call the original function with apply
267         // apply lets you define the 'this' object as well as the arguments
268         //    (both captured before setTimeout)
269         func.apply(context, args);
270       }
271     }, wait);
272
273     // Immediate mode and no wait timer? Execute the function..
274     if (callNow) func.apply(context, args);
275   };
276 }
277
278 export const languages = [
279   { code: 'ca', name: 'Català' },
280   { code: 'en', name: 'English' },
281   { code: 'eo', name: 'Esperanto' },
282   { code: 'es', name: 'Español' },
283   { code: 'de', name: 'Deutsch' },
284   { code: 'fa', name: 'فارسی' },
285   { code: 'ja', name: '日本語' },
286   { code: 'pt_BR', name: 'Português Brasileiro' },
287   { code: 'zh', name: '中文' },
288   { code: 'fi', name: 'Suomi' },
289   { code: 'fr', name: 'Français' },
290   { code: 'sv', name: 'Svenska' },
291   { code: 'ru', name: 'Русский' },
292   { code: 'nl', name: 'Nederlands' },
293   { code: 'it', name: 'Italiano' },
294 ];
295
296 export function getLanguage(): string {
297   let user = UserService.Instance.user;
298   let lang = user && user.lang ? user.lang : 'browser';
299
300   if (lang == 'browser') {
301     return getBrowserLanguage();
302   } else {
303     return lang;
304   }
305 }
306
307 export function getBrowserLanguage(): string {
308   return navigator.language;
309 }
310
311 export function getMomentLanguage(): string {
312   let lang = getLanguage();
313   if (lang.startsWith('zh')) {
314     lang = 'zh-cn';
315   } else if (lang.startsWith('sv')) {
316     lang = 'sv';
317   } else if (lang.startsWith('fr')) {
318     lang = 'fr';
319   } else if (lang.startsWith('de')) {
320     lang = 'de';
321   } else if (lang.startsWith('ru')) {
322     lang = 'ru';
323   } else if (lang.startsWith('es')) {
324     lang = 'es';
325   } else if (lang.startsWith('eo')) {
326     lang = 'eo';
327   } else if (lang.startsWith('nl')) {
328     lang = 'nl';
329   } else if (lang.startsWith('it')) {
330     lang = 'it';
331   } else if (lang.startsWith('fi')) {
332     lang = 'fi';
333   } else if (lang.startsWith('ca')) {
334     lang = 'ca';
335   } else if (lang.startsWith('fa')) {
336     lang = 'fa';
337   } else if (lang.startsWith('pt')) {
338     lang = 'pt-br';
339   } else if (lang.startsWith('ja')) {
340     lang = 'ja';
341   } else {
342     lang = 'en';
343   }
344   return lang;
345 }
346
347 export const themes = [
348   'litera',
349   'materia',
350   'minty',
351   'solar',
352   'united',
353   'cyborg',
354   'darkly',
355   'journal',
356   'sketchy',
357   'vaporwave',
358   'vaporwave-dark',
359   'i386',
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<CommentNode> {
672   let nodes: Array<CommentNode> = [];
673   for (let comment of comments) {
674     nodes.push({ comment: comment });
675   }
676   return nodes;
677 }
678
679 export function commentSort(tree: Array<CommentNode>, 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<CommentNode>, 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 }