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