import 'moment/locale/ru';
import 'moment/locale/nl';
import 'moment/locale/it';
+import 'moment/locale/fi';
+import 'moment/locale/ca';
+import 'moment/locale/fa';
+import 'moment/locale/pt-br';
+import 'moment/locale/ja';
+import 'moment/locale/ka';
import {
UserOperation,
Comment,
+ CommentNode as CommentNodeI,
+ Post,
+ PrivateMessage,
User,
SortType,
+ CommentSortType,
ListingType,
+ DataType,
SearchType,
WebSocketResponse,
+ WebSocketJsonResponse,
+ SearchForm,
+ SearchResponse,
+ CommentResponse,
+ PostResponse,
} from './interfaces';
-import { UserService } from './services/UserService';
+import { UserService, WebSocketService } from './services';
+
+import Tribute from 'tributejs/src/Tribute.js';
import markdown_it from 'markdown-it';
import markdownitEmoji from 'markdown-it-emoji/light';
import markdown_it_container from 'markdown-it-container';
-import * as twemoji from 'twemoji';
-import * as emojiShortName from 'emoji-short-name';
-
-export const repoUrl = 'https://github.com/dessalines/lemmy';
-export const markdownHelpUrl = 'https://commonmark.org/help/';
+import twemoji from 'twemoji';
+import emojiShortName from 'emoji-short-name';
+import Toastify from 'toastify-js';
+import tippy from 'tippy.js';
+
+export const repoUrl = 'https://github.com/LemmyNet/lemmy';
+export const helpGuideUrl = '/docs/about_guide.html';
+export const markdownHelpUrl = `${helpGuideUrl}#markdown-guide`;
+export const sortingHelpUrl = `${helpGuideUrl}#sorting`;
export const archiveUrl = 'https://archive.is';
export const postRefetchSeconds: number = 60 * 1000;
export const fetchLimit: number = 20;
-export const mentionDropdownFetchLimit = 6;
+export const mentionDropdownFetchLimit = 10;
+
+export const languages = [
+ { code: 'ca', name: 'Català' },
+ { code: 'en', name: 'English' },
+ { code: 'eo', name: 'Esperanto' },
+ { code: 'es', name: 'Español' },
+ { code: 'de', name: 'Deutsch' },
+ { code: 'ka', name: 'ქართული ენა' },
+ { code: 'fa', name: 'فارسی' },
+ { code: 'ja', name: '日本語' },
+ { code: 'pt_BR', name: 'Português Brasileiro' },
+ { code: 'zh', name: '中文' },
+ { code: 'fi', name: 'Suomi' },
+ { code: 'fr', name: 'Français' },
+ { code: 'sv', name: 'Svenska' },
+ { code: 'ru', name: 'Русский' },
+ { code: 'nl', name: 'Nederlands' },
+ { code: 'it', name: 'Italiano' },
+];
+
+export const themes = [
+ 'litera',
+ 'materia',
+ 'minty',
+ 'solar',
+ 'united',
+ 'cyborg',
+ 'darkly',
+ 'journal',
+ 'sketchy',
+ 'vaporwave',
+ 'vaporwave-dark',
+ 'i386',
+];
export function randomStr() {
return Math.random()
.substr(2, 10);
}
-export function wsJsonToRes(msg: any): WebSocketResponse {
+export function wsJsonToRes(msg: WebSocketJsonResponse): WebSocketResponse {
let opStr: string = msg.op;
return {
op: UserOperation[opStr],
return twemoji.parse(token[idx].content);
};
-export function hotRank(comment: Comment): number {
- // Rank = ScaleFactor * sign(Score) * log(1 + abs(Score)) / (Time + 2)^Gravity
+export function hotRankComment(comment: Comment): number {
+ return hotRank(comment.score, comment.published);
+}
- let date: Date = new Date(comment.published + 'Z'); // Add Z to convert from UTC date
+export function hotRankPost(post: Post): number {
+ return hotRank(post.score, post.newest_activity_time);
+}
+
+export function hotRank(score: number, timeStr: string): number {
+ // Rank = ScaleFactor * sign(Score) * log(1 + abs(Score)) / (Time + 2)^Gravity
+ let date: Date = new Date(timeStr + 'Z'); // Add Z to convert from UTC date
let now: Date = new Date();
let hoursElapsed: number = (now.getTime() - date.getTime()) / 36e5;
let rank =
- (10000 * Math.log10(Math.max(1, 3 + comment.score))) /
+ (10000 * Math.log10(Math.max(1, 3 + score))) /
Math.pow(hoursElapsed + 2, 1.8);
// console.log(`Comment: ${comment.content}\nRank: ${rank}\nScore: ${comment.score}\nHours: ${hoursElapsed}`);
return modIds.includes(creator_id);
}
-var imageRegex = new RegExp(
- `(http)?s?:?(\/\/[^"']*\.(?:png|jpg|jpeg|gif|png|svg))`
+const imageRegex = new RegExp(
+ /(http)?s?:?(\/\/[^"']*\.(?:jpg|jpeg|gif|png|svg))/
);
-var videoRegex = new RegExp(`(http)?s?:?(\/\/[^"']*\.(?:mp4))`);
+const videoRegex = new RegExp(`(http)?s?:?(\/\/[^"']*\.(?:mp4))`);
export function isImage(url: string) {
return imageRegex.test(url);
}
export function validEmail(email: string) {
- 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,}))$/;
+ let re = /^(([^\s"(),.:;<>@[\\\]]+(\.[^\s"(),.:;<>@[\\\]]+)*)|(".+"))@((\[(?:\d{1,3}\.){3}\d{1,3}])|(([\dA-Za-z\-]+\.)+[A-Za-z]{2,}))$/;
return re.test(String(email).toLowerCase());
}
return ListingType[capitalizeFirstLetter(type)];
}
+export function routeDataTypeToEnum(type: string): DataType {
+ return DataType[capitalizeFirstLetter(type)];
+}
+
export function routeSearchTypeToEnum(type: string): SearchType {
return SearchType[capitalizeFirstLetter(type)];
}
export async function getPageTitle(url: string) {
- let res = await fetch(`https://textance.herokuapp.com/title/${url}`);
- let data = await res.text();
- return data;
+ let res = await fetch(`/iframely/oembed?url=${url}`).then(res => res.json());
+ let title = await res.title;
+ return title;
}
export function debounce(
// 'private' variable for instance
// The returned function will be able to reference this due to closure.
// Each call to the returned function will share this common timer.
- let timeout: number;
+ let timeout: any;
// Calling debounce returns a new anonymous function
return function() {
};
}
-export const languages = [
- { code: 'en', name: 'English' },
- { code: 'eo', name: 'Esperanto' },
- { code: 'es', name: 'Español' },
- { code: 'de', name: 'Deutsch' },
- { code: 'zh', name: '中文' },
- { code: 'fr', name: 'Français' },
- { code: 'sv', name: 'Svenska' },
- { code: 'ru', name: 'Русский' },
- { code: 'nl', name: 'Nederlands' },
- { code: 'it', name: 'Italiano' },
-];
-
export function getLanguage(): string {
let user = UserService.Instance.user;
let lang = user && user.lang ? user.lang : 'browser';
lang = 'nl';
} else if (lang.startsWith('it')) {
lang = 'it';
+ } else if (lang.startsWith('fi')) {
+ lang = 'fi';
+ } else if (lang.startsWith('ca')) {
+ lang = 'ca';
+ } else if (lang.startsWith('fa')) {
+ lang = 'fa';
+ } else if (lang.startsWith('pt')) {
+ lang = 'pt-br';
+ } else if (lang.startsWith('ja')) {
+ lang = 'ja';
+ } else if (lang.startsWith('ka')) {
+ lang = 'ka';
} else {
lang = 'en';
}
return lang;
}
-export const themes = [
- 'litera',
- 'minty',
- 'solar',
- 'united',
- 'cyborg',
- 'darkly',
- 'journal',
- 'sketchy',
- 'vaporwave',
- 'vaporwave-dark',
-];
-
export function setTheme(theme: string = 'darkly') {
// unload all the other themes
for (var i = 0; i < themes.length; i++) {
export function pictshareAvatarThumbnail(src: string): string {
// sample url: http://localhost:8535/pictshare/gs7xuu.jpg
let split = src.split('pictshare');
- let out = `${split[0]}pictshare/96x96${split[1]}`;
+ let out = `${split[0]}pictshare/96${split[1]}`;
return out;
}
);
}
-/// Converts to image thumbnail (only supports pictshare currently)
-export function imageThumbnailer(url: string): string {
- let split = url.split('pictshare');
- if (split.length > 1) {
- let out = `${split[0]}pictshare/140x140${split[1]}`;
- return out;
+// Converts to image thumbnail
+export function pictshareImage(
+ hash: string,
+ thumbnail: boolean = false
+): string {
+ let root = `/pictshare`;
+
+ // Necessary for other servers / domains
+ if (hash.includes('pictshare')) {
+ let split = hash.split('/pictshare/');
+ root = `${split[0]}/pictshare`;
+ hash = split[1];
+ }
+
+ let out = `${root}/${thumbnail ? '192/' : ''}${hash}`;
+ return out;
+}
+
+export function isCommentType(item: Comment | PrivateMessage): item is Comment {
+ return (item as Comment).community_id !== undefined;
+}
+
+export function toast(text: string, background: string = 'success') {
+ let backgroundColor = `var(--${background})`;
+ Toastify({
+ text: text,
+ backgroundColor: backgroundColor,
+ gravity: 'bottom',
+ position: 'left',
+ }).showToast();
+}
+
+export function messageToastify(
+ creator: string,
+ avatar: string,
+ body: string,
+ link: string,
+ router: any
+) {
+ let backgroundColor = `var(--light)`;
+
+ let toast = Toastify({
+ text: `${body}<br />${creator}`,
+ avatar: avatar,
+ backgroundColor: backgroundColor,
+ close: true,
+ gravity: 'top',
+ position: 'right',
+ duration: 0,
+ onClick: () => {
+ if (toast) {
+ toast.hideToast();
+ router.history.push(link);
+ }
+ },
+ }).showToast();
+}
+
+export function setupTribute(): Tribute {
+ return new Tribute({
+ collection: [
+ // Emojis
+ {
+ trigger: ':',
+ menuItemTemplate: (item: any) => {
+ let emoji = `:${item.original.key}:`;
+ return `${item.original.val} ${emoji}`;
+ },
+ selectTemplate: (item: any) => {
+ return `:${item.original.key}:`;
+ },
+ values: Object.entries(emojiShortName).map(e => {
+ return { key: e[1], val: e[0] };
+ }),
+ allowSpaces: false,
+ autocompleteMode: true,
+ menuItemLimit: mentionDropdownFetchLimit,
+ menuShowMinLength: 2,
+ },
+ // Users
+ {
+ trigger: '@',
+ selectTemplate: (item: any) => {
+ return `[/u/${item.original.key}](/u/${item.original.key})`;
+ },
+ values: (text: string, cb: any) => {
+ userSearch(text, (users: any) => cb(users));
+ },
+ allowSpaces: false,
+ autocompleteMode: true,
+ menuItemLimit: mentionDropdownFetchLimit,
+ menuShowMinLength: 2,
+ },
+
+ // Communities
+ {
+ trigger: '#',
+ selectTemplate: (item: any) => {
+ return `[/c/${item.original.key}](/c/${item.original.key})`;
+ },
+ values: (text: string, cb: any) => {
+ communitySearch(text, (communities: any) => cb(communities));
+ },
+ allowSpaces: false,
+ autocompleteMode: true,
+ menuItemLimit: mentionDropdownFetchLimit,
+ menuShowMinLength: 2,
+ },
+ ],
+ });
+}
+
+let tippyInstance = tippy('[data-tippy-content]');
+
+export function setupTippy() {
+ tippyInstance.forEach(e => e.destroy());
+ tippyInstance = tippy('[data-tippy-content]', {
+ delay: [500, 0],
+ // Display on "long press"
+ touch: ['hold', 500],
+ });
+}
+
+function userSearch(text: string, cb: any) {
+ if (text) {
+ let form: SearchForm = {
+ q: text,
+ type_: SearchType[SearchType.Users],
+ sort: SortType[SortType.TopAll],
+ page: 1,
+ limit: mentionDropdownFetchLimit,
+ };
+
+ WebSocketService.Instance.search(form);
+
+ this.userSub = WebSocketService.Instance.subject.subscribe(
+ msg => {
+ let res = wsJsonToRes(msg);
+ if (res.op == UserOperation.Search) {
+ let data = res.data as SearchResponse;
+ let users = data.users.map(u => {
+ return { key: u.name };
+ });
+ cb(users);
+ this.userSub.unsubscribe();
+ }
+ },
+ err => console.error(err),
+ () => console.log('complete')
+ );
} else {
- return url;
+ cb([]);
}
}
+
+function communitySearch(text: string, cb: any) {
+ if (text) {
+ let form: SearchForm = {
+ q: text,
+ type_: SearchType[SearchType.Communities],
+ sort: SortType[SortType.TopAll],
+ page: 1,
+ limit: mentionDropdownFetchLimit,
+ };
+
+ WebSocketService.Instance.search(form);
+
+ this.communitySub = WebSocketService.Instance.subject.subscribe(
+ msg => {
+ let res = wsJsonToRes(msg);
+ if (res.op == UserOperation.Search) {
+ let data = res.data as SearchResponse;
+ let communities = data.communities.map(u => {
+ return { key: u.name };
+ });
+ cb(communities);
+ this.communitySub.unsubscribe();
+ }
+ },
+ err => console.error(err),
+ () => console.log('complete')
+ );
+ } else {
+ cb([]);
+ }
+}
+
+export function getListingTypeFromProps(props: any): ListingType {
+ return props.match.params.listing_type
+ ? routeListingTypeToEnum(props.match.params.listing_type)
+ : UserService.Instance.user
+ ? UserService.Instance.user.default_listing_type
+ : ListingType.All;
+}
+
+// TODO might need to add a user setting for this too
+export function getDataTypeFromProps(props: any): DataType {
+ return props.match.params.data_type
+ ? routeDataTypeToEnum(props.match.params.data_type)
+ : DataType.Post;
+}
+
+export function getSortTypeFromProps(props: any): SortType {
+ return props.match.params.sort
+ ? routeSortTypeToEnum(props.match.params.sort)
+ : UserService.Instance.user
+ ? UserService.Instance.user.default_sort_type
+ : SortType.Hot;
+}
+
+export function getPageFromProps(props: any): number {
+ return props.match.params.page ? Number(props.match.params.page) : 1;
+}
+
+export function editCommentRes(
+ data: CommentResponse,
+ comments: Array<Comment>
+) {
+ let found = comments.find(c => c.id == data.comment.id);
+ if (found) {
+ found.content = data.comment.content;
+ found.updated = data.comment.updated;
+ found.removed = data.comment.removed;
+ found.deleted = data.comment.deleted;
+ found.upvotes = data.comment.upvotes;
+ found.downvotes = data.comment.downvotes;
+ found.score = data.comment.score;
+ }
+}
+
+export function saveCommentRes(
+ data: CommentResponse,
+ comments: Array<Comment>
+) {
+ let found = comments.find(c => c.id == data.comment.id);
+ if (found) {
+ found.saved = data.comment.saved;
+ }
+}
+
+export function createCommentLikeRes(
+ data: CommentResponse,
+ comments: Array<Comment>
+) {
+ let found: Comment = comments.find(c => c.id === data.comment.id);
+ if (found) {
+ found.score = data.comment.score;
+ found.upvotes = data.comment.upvotes;
+ found.downvotes = data.comment.downvotes;
+ if (data.comment.my_vote !== null) {
+ found.my_vote = data.comment.my_vote;
+ }
+ }
+}
+
+export function createPostLikeFindRes(data: PostResponse, posts: Array<Post>) {
+ let found = posts.find(c => c.id == data.post.id);
+ if (found) {
+ createPostLikeRes(data, found);
+ }
+}
+
+export function createPostLikeRes(data: PostResponse, post: Post) {
+ if (post) {
+ post.score = data.post.score;
+ post.upvotes = data.post.upvotes;
+ post.downvotes = data.post.downvotes;
+ if (data.post.my_vote !== null) {
+ post.my_vote = data.post.my_vote;
+ }
+ }
+}
+
+export function editPostFindRes(data: PostResponse, posts: Array<Post>) {
+ let found = posts.find(c => c.id == data.post.id);
+ if (found) {
+ editPostRes(data, found);
+ }
+}
+
+export function editPostRes(data: PostResponse, post: Post) {
+ if (post) {
+ post.url = data.post.url;
+ post.name = data.post.name;
+ post.nsfw = data.post.nsfw;
+ }
+}
+
+export function commentsToFlatNodes(
+ comments: Array<Comment>
+): Array<CommentNodeI> {
+ let nodes: Array<CommentNodeI> = [];
+ for (let comment of comments) {
+ nodes.push({ comment: comment });
+ }
+ return nodes;
+}
+
+export function commentSort(tree: Array<CommentNodeI>, sort: CommentSortType) {
+ // First, put removed and deleted comments at the bottom, then do your other sorts
+ if (sort == CommentSortType.Top) {
+ tree.sort(
+ (a, b) =>
+ +a.comment.removed - +b.comment.removed ||
+ +a.comment.deleted - +b.comment.deleted ||
+ b.comment.score - a.comment.score
+ );
+ } else if (sort == CommentSortType.New) {
+ tree.sort(
+ (a, b) =>
+ +a.comment.removed - +b.comment.removed ||
+ +a.comment.deleted - +b.comment.deleted ||
+ b.comment.published.localeCompare(a.comment.published)
+ );
+ } else if (sort == CommentSortType.Old) {
+ tree.sort(
+ (a, b) =>
+ +a.comment.removed - +b.comment.removed ||
+ +a.comment.deleted - +b.comment.deleted ||
+ a.comment.published.localeCompare(b.comment.published)
+ );
+ } else if (sort == CommentSortType.Hot) {
+ tree.sort(
+ (a, b) =>
+ +a.comment.removed - +b.comment.removed ||
+ +a.comment.deleted - +b.comment.deleted ||
+ hotRankComment(b.comment) - hotRankComment(a.comment)
+ );
+ }
+
+ // Go through the children recursively
+ for (let node of tree) {
+ if (node.children) {
+ commentSort(node.children, sort);
+ }
+ }
+}
+
+export function commentSortSortType(tree: Array<CommentNodeI>, sort: SortType) {
+ commentSort(tree, convertCommentSortType(sort));
+}
+
+function convertCommentSortType(sort: SortType): CommentSortType {
+ if (
+ sort == SortType.TopAll ||
+ sort == SortType.TopDay ||
+ sort == SortType.TopWeek ||
+ sort == SortType.TopMonth ||
+ sort == SortType.TopYear
+ ) {
+ return CommentSortType.Top;
+ } else if (sort == SortType.New) {
+ return CommentSortType.New;
+ } else if (sort == SortType.Hot) {
+ return CommentSortType.Hot;
+ } else {
+ return CommentSortType.Hot;
+ }
+}
+
+export function postSort(
+ posts: Array<Post>,
+ sort: SortType,
+ communityType: boolean
+) {
+ // First, put removed and deleted comments at the bottom, then do your other sorts
+ if (
+ sort == SortType.TopAll ||
+ sort == SortType.TopDay ||
+ sort == SortType.TopWeek ||
+ sort == SortType.TopMonth ||
+ sort == SortType.TopYear
+ ) {
+ posts.sort(
+ (a, b) =>
+ +a.removed - +b.removed ||
+ +a.deleted - +b.deleted ||
+ (communityType && +b.stickied - +a.stickied) ||
+ b.score - a.score
+ );
+ } else if (sort == SortType.New) {
+ posts.sort(
+ (a, b) =>
+ +a.removed - +b.removed ||
+ +a.deleted - +b.deleted ||
+ (communityType && +b.stickied - +a.stickied) ||
+ b.published.localeCompare(a.published)
+ );
+ } else if (sort == SortType.Hot) {
+ posts.sort(
+ (a, b) =>
+ +a.removed - +b.removed ||
+ +a.deleted - +b.deleted ||
+ (communityType && +b.stickied - +a.stickied) ||
+ hotRankPost(b) - hotRankPost(a)
+ );
+ }
+}
+
+export const colorList: Array<string> = [
+ hsl(0),
+ hsl(100),
+ hsl(150),
+ hsl(200),
+ hsl(250),
+ hsl(300),
+];
+
+function hsl(num: number) {
+ return `hsla(${num}, 35%, 50%, 1)`;
+}
+
+function randomHsl() {
+ return `hsla(${Math.random() * 360}, 100%, 50%, 1)`;
+}
+
+export function previewLines(text: string, lines: number = 3): string {
+ // Use lines * 2 because markdown requires 2 lines
+ return text
+ .split('\n')
+ .slice(0, lines * 2)
+ .join('\n');
+}