You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

1307 lines
37 KiB

/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
// @TODO: Figure out how to do this properly with esmodules
import 'moment/locale/es';
import 'moment/locale/el';
import 'moment/locale/eu';
import 'moment/locale/eo';
import 'moment/locale/de';
import 'moment/locale/zh-cn';
import 'moment/locale/fr';
import 'moment/locale/sv';
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/pl';
import 'moment/locale/pt-br';
import 'moment/locale/ja';
import 'moment/locale/ka';
import 'moment/locale/hi';
import 'moment/locale/gl';
import 'moment/locale/tr';
import 'moment/locale/hu';
import 'moment/locale/uk';
import 'moment/locale/sq';
import 'moment/locale/km';
import 'moment/locale/ga';
import 'moment/locale/sr';
import {
UserOperation,
Comment,
CommentNode as CommentNodeI,
Post,
PrivateMessage,
User,
SortType,
CommentSortType,
ListingType,
DataType,
SearchType,
WebSocketResponse,
WebSocketJsonResponse,
SearchForm,
SearchResponse,
CommentResponse,
PostResponse,
GetSiteModeratorsResponse,
CommunityModsState,
BanUserResponse,
} from './interfaces';
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 iterator from 'markdown-it-for-inline';
import emojiShortName from 'emoji-short-name';
import Toastify from 'toastify-js';
import tippy from 'tippy.js';
// import { EmojiButton } from '@joeattardi/emoji-button';
import blocklist from './embed-blocklist.json';
import { emojiReplacements, replaceEmojis } from './custom-emojis';
import moment from 'moment';
import { BASE_PATH, isProduction } from './isProduction';
import axios from 'axios';
// import normalizeUrl from './normalize-url';
import update from 'immutability-helper';
import { List } from 'immutable';
export const repoUrl = 'https://git.chapo.chat/hexbear-collective';
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 = 40;
export const commentFetchLimit = 15;
export const mentionDropdownFetchLimit = 10;
export const languages = [
{ code: 'ca', name: 'Català' },
{ code: 'en', name: 'English' },
{ code: 'el', name: 'Ελληνικά' },
{ code: 'eu', name: 'Euskara' },
{ code: 'eo', name: 'Esperanto' },
{ code: 'es', name: 'Español' },
{ code: 'de', name: 'Deutsch' },
{ code: 'ga', name: 'Gaeilge' },
{ code: 'gl', name: 'Galego' },
{ code: 'hu', name: 'Magyar Nyelv' },
{ code: 'ka', name: 'ქართული ენა' },
{ code: 'km', name: 'ភាសាខ្មែរ' },
{ code: 'hi', name: 'मानक हिन्दी' },
{ code: 'fa', name: 'فارسی' },
{ code: 'ja', name: '日本語' },
{ code: 'pl', name: 'Polski' },
{ code: 'pt_BR', name: 'Português Brasileiro' },
{ code: 'zh', name: '中文' },
{ code: 'fi', name: 'Suomi' },
{ code: 'fr', name: 'Français' },
{ code: 'sv', name: 'Svenska' },
{ code: 'sq', name: 'Shqip' },
{ code: 'sr_Latn', name: 'srpski' },
{ code: 'tr', name: 'Türkçe' },
{ code: 'uk', name: 'Українська Mова' },
{ code: 'ru', name: 'Русский' },
{ code: 'nl', name: 'Nederlands' },
{ code: 'it', name: 'Italiano' },
];
const DEFAULT_ALPHABET =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
function getRandomCharFromAlphabet(alphabet: string): string {
return alphabet.charAt(Math.floor(Math.random() * alphabet.length));
}
export function randomStr(
idDesiredLength = 20,
alphabet = DEFAULT_ALPHABET
): string {
/**
* Create n-long array and map it to random chars from given alphabet.
* Then join individual chars as string
*/
return Array.from({ length: idDesiredLength })
.map(() => {
return getRandomCharFromAlphabet(alphabet);
})
.join('');
}
export function wsJsonToRes(msg: WebSocketJsonResponse): WebSocketResponse {
let opStr: string = msg.op;
return {
op: UserOperation[opStr],
data: msg.data,
};
}
export const md = new markdown_it({
html: false,
linkify: true,
typographer: true,
})
.use(markdown_it_container, 'spoiler', {
validate: function (params: any) {
return params.trim().match(/^spoiler\s+(.*)$/);
},
render: function (tokens: any, idx: any) {
var m = tokens[idx].info.trim().match(/^spoiler\s+(.*)$/);
if (tokens[idx].nesting === 1) {
// opening tag
return `<details><summary> ${md.utils.escapeHtml(m[1])} </summary>\n`;
} else {
// closing tag
return '</details>\n';
}
},
})
.use(markdownitEmoji, {
defs: objectFlip(emojiShortName),
})
.use(iterator, 'url_new_win', 'link_open', function (tokens, idx) {
// in markdown, if a link tag is missing a protocol, add it
if (tokens[idx].type === 'link_open') {
const hrefIndex = tokens[idx].attrs.findIndex(
attribute => attribute[0] === 'href'
);
if (hrefIndex !== -1) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const hrefValue = tokens[idx].attrs[hrefIndex][1];
// the following causes a crash on safari
// don't throw errors here in case invalid URLs are passed
// try {
// tokens[idx].attrs[hrefIndex][1] = normalizeUrl(hrefValue);
// } catch (error) {
// console.error(error);
// }
}
}
// make sure all inline links open in a new window and don't include the referrer
tokens[idx].attrPush(['target', '_blank']);
tokens[idx].attrPush(['rel', 'noreferrer']);
})
.disable('image');
export function hotRankCommentActive(c1: Comment, c2: Comment): number {
if (c1.hot_rank_active === c2.hot_rank_active) {
return c1.published.localeCompare(c2.published);
} else {
return c2.hot_rank_active - c1.hot_rank_active;
}
}
export function hotRankComment(comment: Comment): number {
return hotRank(comment.score, comment.published);
}
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 + score))) /
Math.pow(hoursElapsed + 2, 1.8);
// console.log(`Comment: ${comment.content}\nRank: ${rank}\nScore: ${comment.score}\nHours: ${hoursElapsed}`);
return rank;
}
export function mdToHtml(text = ''): { __html: string } {
return { __html: replaceEmojis(md.render(text || '')) };
}
export function getUnixTime(text: string): number {
return text ? new Date(text).getTime() / 1000 : undefined;
}
export function addTypeInfo<T>(
arr: Array<T>,
name: string
): Array<{ type_: string; data: T }> {
return arr.map(e => {
return { type_: name, data: e };
});
}
export function canMod(
user: User,
modIds: Array<number>,
creator_id: number,
onSelf = false
): boolean {
// You can do moderator actions only on the mods added after you.
if (user) {
let yourIndex = modIds.findIndex(id => id == user.id);
if (yourIndex == -1) {
return false;
} else {
// onSelf +1 on mod actions not for yourself, IE ban, remove, etc
modIds = modIds.slice(0, yourIndex + (onSelf ? 0 : 1));
return !modIds.includes(creator_id);
}
} else {
return false;
}
}
export function isMod(modIds: Array<number>, creator_id: number): boolean {
return modIds.includes(creator_id);
}
const imageRegex = new RegExp(
/(http)?s?:?(\/\/[^"']*\.(?:jpg|jpeg|gif|png|svg|webp))/
);
const videoRegex = new RegExp(`(http)?s?:?(\/\/[^"']*\.(?:mp4|webm))`);
const embedRegex = new RegExp('((' + blocklist.join(')|(') + '))'); //used to block iframely embeds from certain sites e.g. gist.github.com
export function isImage(url: string): boolean {
return imageRegex.test(url);
}
export function isVideo(url: string): boolean {
return videoRegex.test(url);
}
export function isValidEmbed(url: string): boolean {
return !embedRegex.test(url);
}
export function validURL(str: string): boolean {
try {
return !!new URL(str);
} catch {
return false;
}
}
export function validEmail(email: string): boolean {
let re = /^(([^\s"(),.:;<>@[\\\]]+(\.[^\s"(),.:;<>@[\\\]]+)*)|(".+"))@((\[(?:\d{1,3}\.){3}\d{1,3}])|(([\dA-Za-z\-]+\.)+[A-Za-z]{2,}))$/;
return re.test(String(email).toLowerCase());
}
export function capitalizeFirstLetter(str: string): string {
return str.charAt(0).toUpperCase() + str.slice(1);
}
export function routeSortTypeToEnum(sort: string): SortType {
if (sort == 'new') {
return SortType.New;
} else if (sort == 'active') {
return SortType.Active;
} else if (sort == 'hot') {
return SortType.Hot;
} else if (sort == 'topday') {
return SortType.TopDay;
} else if (sort == 'topweek') {
return SortType.TopWeek;
} else if (sort == 'topmonth') {
return SortType.TopMonth;
} else if (sort == 'topyear') {
return SortType.TopYear;
} else if (sort == 'topall') {
return SortType.TopAll;
}
}
export function routeListingTypeToEnum(type: string): ListingType {
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 | null): Promise<string | null> {
let res = await fetch(`/iframely/oembed?url=${url}`);
if (!res.ok) {
return null;
}
const json = await res.json();
return json.title;
}
export function debounce(
func: any,
wait = 1000,
immediate = false
): () => void {
// '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: any;
// Calling debounce returns a new anonymous function
return function () {
// reference the context and args for the setTimeout function
var context = this,
args = arguments;
// Should the function be called now? If immediate is true
// and not already in a timeout then the answer is: Yes
var callNow = immediate && !timeout;
// This is the basic debounce behaviour where you can call this
// function several times, but it will only execute once
// [before or after imposing a delay].
// Each time the returned function is called, the timer starts over.
clearTimeout(timeout);
// Set the new timeout
timeout = setTimeout(function () {
// Inside the timeout function, clear the timeout variable
// which will let the next execution run when in 'immediate' mode
timeout = null;
// Check if the function already ran with the immediate flag
if (!immediate) {
// Call the original function with apply
// apply lets you define the 'this' object as well as the arguments
// (both captured before setTimeout)
func.apply(context, args);
}
}, wait);
// Immediate mode and no wait timer? Execute the function..
if (callNow) func.apply(context, args);
};
}
export function getLanguage(): string {
let user = UserService?.Instance?.user;
let lang = user && user.lang ? user.lang : 'browser';
if (lang == 'browser') {
return getBrowserLanguage();
} else {
return lang;
}
}
export function getBrowserLanguage(): string {
return navigator.language;
}
export function getMomentLanguage(): string {
let lang = getLanguage();
if (lang.startsWith('zh')) {
lang = 'zh-cn';
} else if (lang.startsWith('sv')) {
lang = 'sv';
} else if (lang.startsWith('fr')) {
lang = 'fr';
} else if (lang.startsWith('de')) {
lang = 'de';
} else if (lang.startsWith('ru')) {
lang = 'ru';
} else if (lang.startsWith('es')) {
lang = 'es';
} else if (lang.startsWith('eo')) {
lang = 'eo';
} else if (lang.startsWith('nl')) {
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('pl')) {
lang = 'pl';
} else if (lang.startsWith('pt')) {
lang = 'pt-br';
} else if (lang.startsWith('ja')) {
lang = 'ja';
} else if (lang.startsWith('ka')) {
lang = 'ka';
} else if (lang.startsWith('hi')) {
lang = 'hi';
} else if (lang.startsWith('el')) {
lang = 'el';
} else if (lang.startsWith('eu')) {
lang = 'eu';
} else if (lang.startsWith('gl')) {
lang = 'gl';
} else if (lang.startsWith('tr')) {
lang = 'tr';
} else if (lang.startsWith('hu')) {
lang = 'hu';
} else if (lang.startsWith('uk')) {
lang = 'uk';
} else if (lang.startsWith('sq')) {
lang = 'sq';
} else if (lang.startsWith('km')) {
lang = 'km';
} else if (lang.startsWith('ga')) {
lang = 'ga';
} else if (lang.startsWith('sr')) {
lang = 'sr';
} else {
lang = 'en';
}
return lang;
}
export function loadCss(id: string, loc: string): void {
if (!document.getElementById(id)) {
var head = document.getElementsByTagName('head')[0];
var link = document.createElement('link');
link.id = id;
link.rel = 'stylesheet';
link.type = 'text/css';
link.href = loc;
link.media = 'all';
head.prepend(link);
}
}
export function objectFlip(obj: any): any {
const ret = {};
Object.keys(obj).forEach(key => {
ret[obj[key]] = key;
});
return ret;
}
export function pictrsAvatarThumbnail(src: string): string {
// sample url: http://localhost:8535/pictrs/image/thumbnail256/gs7xuu.jpg
let split = src.split('/pictrs/image');
let out = `${split[0]}/pictrs/image/${
canUseWebP() ? 'webp/' : ''
}thumbnail96${split[1]}`;
return out;
}
export function showAvatars(): boolean {
return (
//(UserService.Instance.user && UserService.Instance.user.show_avatars) ||
!UserService.Instance.user
);
}
export function isCakeDay(published: string): boolean {
// moment(undefined) or moment.utc(undefined) returns the current date/time
// moment(null) or moment.utc(null) returns null
const userCreationDate = moment.utc(published || null).local();
const currentDate = moment(new Date());
return (
userCreationDate.date() === currentDate.date() &&
userCreationDate.month() === currentDate.month() &&
userCreationDate.year() !== currentDate.year()
);
}
// Converts to image thumbnail
export function pictrsImage(hash: string, thumbnail = false): string {
let root = `/pictrs/image`;
// Necessary for other servers / domains
if (hash.includes('pictrs')) {
let split = hash.split('/pictrs/image/');
root = `${split[0]}/pictrs/image`;
hash = split[1];
}
let out = `${root}/${canUseWebP() ? 'webp/' : ''}${
thumbnail ? 'thumbnail256/' : ''
}${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 = 'success'): void {
let backgroundColor = `var(--${background})`;
Toastify({
text,
backgroundColor,
gravity: 'bottom',
position: 'left',
}).showToast();
}
export function pictrsDeleteToast(
clickToDeleteText: string,
deletePictureText: string,
deleteUrl: string
): void {
let backgroundColor = `var(--light)`;
let toast = Toastify({
text: clickToDeleteText,
backgroundColor: backgroundColor,
gravity: 'top',
position: 'right',
duration: 10000,
onClick: () => {
if (toast) {
window.location.replace(deleteUrl);
alert(deletePictureText);
toast.hideToast();
}
},
close: true,
}).showToast();
}
export function messageToastify(
creator: string,
avatar: string,
body: string,
link: string,
router: any
): void {
let backgroundColor = `var(--light)`;
body = '<div className="notification-text-container">' + body + '</div>';
if (!UserService.Instance.user || !UserService.Instance.user.show_nsfw) {
body = replaceImageEmbeds(body);
}
let toast = Toastify({
text: `${body}${creator}`,
avatar: avatar,
backgroundColor: backgroundColor,
className: 'text-dark',
close: true,
gravity: 'top',
position: 'right',
duration: 5000,
onClick: () => {
if (toast) {
toast.hideToast();
router.history.push(link);
}
},
}).showToast();
}
export function testMessageToast(): void {
messageToastify(
'example-user',
null,
'<p>Example toast. <img src="https://www.hexbear.net/pictrs/image/JNmEXL1LM2.png" alt=""/> The quick brown fox jumped over the lazy dog. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.',
'www.hexbear.net',
null
);
}
// @ts-ignore
export function setupTribute(): Tribute {
return new Tribute({
noMatchTemplate: function () {
return '<span style:"visibility: hidden;"></span>';
},
collection: [
// Emojis
{
trigger: ':',
menuItemTemplate: (item: any) => {
let shortName = `:${item.original.key}:`;
return `${item.original.val} ${shortName}`;
},
selectTemplate: (item: any) => {
return `:${item.original.key}:`;
},
values: [
// ...(emojiPaths).map(val => {
// return { key: val.split('.')[0], val: val };
// }),
{
key: 'logo',
val: `<img className="icon icon-navbar icon-emoji" src="${BASE_PATH}logo.png" alt="vaporwave hammer and sickle logo, courtesy of ancestral potato">`,
},
// ...customEmojis,
...emojiReplacements,
],
allowSpaces: false,
autocompleteMode: true,
menuItemLimit: mentionDropdownFetchLimit,
menuShowMinLength: 2,
},
// Users
{
trigger: '@',
selectTemplate: (item: any) => {
let link = item.original.local
? `[${item.original.key}](/u/${item.original.name})`
: `[${item.original.key}](/user/${item.original.id})`;
return link;
},
values: (text: string, cb: any) => {
userSearch(text, (users: any) => cb(users));
},
allowSpaces: false,
autocompleteMode: true,
menuItemLimit: mentionDropdownFetchLimit,
menuShowMinLength: 2,
},
// Communities
{
trigger: '!',
selectTemplate: (item: any) => {
let link = item.original.local
? `[${item.original.key}](/c/${item.original.name})`
: `[${item.original.key}](/community/${item.original.id})`;
return link;
},
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(): void {
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 => {
const currentHost = hostname(window.location.href);
const userHost = hostname(u.actor_id);
return {
// don't show hostname if it's the current instance
key: `@${u.name}${
currentHost !== userHost ? `@${hostname(u.actor_id)}` : ''
}`,
name: u.name,
local: u.local,
id: u.id,
};
});
cb(users);
this.userSub.unsubscribe();
}
},
err => console.error(err),
() => console.log('complete')
);
} else {
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(c => {
return {
key: `!${c.name}@${hostname(c.actor_id)}`,
name: c.name,
local: c.local,
id: c.id,
};
});
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
? 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
? UserService.Instance.user.default_sort_type
: SortType.Active;
}
export function getPageFromProps(props: any): number {
return props.match.params.page ? Number(props.match.params.page) : 1;
}
export function editCommentRes(
data: CommentResponse,
comments: List<Comment>
): List<Comment> {
if (comments) {
let found = comments.findIndex(c => c.id == data.comment.id);
if (found !== -1) {
comments = comments.set(
found,
update(comments.get(found), {
content: { $set: data.comment.content },
updated: { $set: data.comment.updated },
removed: { $set: data.comment.removed },
deleted: { $set: data.comment.deleted },
score: { $set: data.comment.score },
})
);
}
}
return comments;
}
export function saveCommentRes(
data: CommentResponse,
comments: List<Comment>
): List<Comment> {
if (comments) {
let found = comments.findIndex(c => c.id == data.comment.id);
if (found !== -1) {
comments = comments.set(
found,
update(comments.get(found), {
saved: { $set: data.comment.saved },
})
);
}
}
return comments;
}
export function createCommentLikeRes(
data: CommentResponse,
comments: List<Comment>
): List<Comment> {
if (comments) {
let found = comments.findIndex(c => c.id === data.comment.id);
if (found !== -1) {
comments = comments.set(
found,
update(comments.get(found), {
score: { $set: data.comment.score },
my_vote: {
$apply: currVote =>
data.comment.my_vote !== null ? data.comment.my_vote : currVote,
},
})
);
}
}
return comments;
}
export function createPostLikeFindRes(
data: PostResponse,
posts: List<Post>
): List<Post> {
if (posts) {
let found = posts.findIndex(c => c.id == data.post.id);
if (found !== -1) {
posts = posts.set(found, createPostLikeRes(data, posts.get(found)));
}
}
return posts;
}
export function createPostLikeRes(data: PostResponse, post: Post): Post {
//immutability-helper is used so that post listings can be pure with nested props
return update(post, {
score: { $set: data.post.score },
saved: { $set: data.post.saved },
my_vote: {
$apply: currVote =>
data.post.my_vote !== null ? data.post.my_vote : currVote,
},
});
}
export function editPostFindRes(
data: PostResponse,
posts: List<Post>
): List<Post> {
if (posts) {
let found = posts.findIndex(c => c.id == data.post.id);
if (found !== -1) {
posts = posts.set(found, editPostRes(data, posts.get(found)));
}
}
return posts;
}
export function editPostRes(data: PostResponse, post: Post): Post {
return update(post, {
url: { $set: data.post.url },
name: { $set: data.post.name },
nsfw: { $set: data.post.nsfw },
deleted: { $set: data.post.deleted },
removed: { $set: data.post.removed },
stickied: { $set: data.post.stickied },
body: { $set: data.post.body },
locked: { $set: data.post.locked },
});
}
export function savePostFindRes(
data: PostResponse,
posts: List<Post>
): List<Post> {
if (posts) {
let found = posts.findIndex(c => c.id == data.post.id);
if (found !== -1) {
posts = posts.set(
found,
update(posts.get(found), { saved: { $set: data.post.saved } })
);
}
}
return posts;
}
export function setBannedPCList(
data: BanUserResponse,
list: List<Post | Comment>
): List<Post | Comment> {
if (!list) return list;
return list.map(p => {
if (p.user_id === data.user.id) {
return update(p, {
banned: { $set: data.banned },
});
}
return p;
});
}
export function commentsToFlatNodes(
comments: List<Comment>
): List<CommentNodeI> {
let nodes: List<CommentNodeI> = List();
for (let comment of comments) {
nodes = nodes.push({ comment: comment });
}
return nodes;
}
export function commentSort(
tree: List<CommentNodeI>,
sort: CommentSortType
): List<CommentNodeI> {
// First, put removed and deleted comments at the bottom, then do your other sorts
if (sort == CommentSortType.Top) {
tree = 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 = 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 = 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 = tree.sort(
(a, b) =>
+a.comment.removed - +b.comment.removed ||
+a.comment.deleted - +b.comment.deleted ||
hotRankComment(b.comment) - hotRankComment(a.comment)
);
} else if (sort == CommentSortType.Active) {
tree = tree.sort(
(a, b) =>
+a.comment.removed - +b.comment.removed ||
+a.comment.deleted - +b.comment.deleted ||
hotRankCommentActive(a.comment, b.comment)
);
}
// Go through the children recursively
return tree.map(node => {
if (node.children) {
return update(node, {
children: { $set: commentSort(node.children, sort) },
});
}
return node;
});
}
export function commentSortSortType(
tree: List<CommentNodeI>,
sort: SortType
): List<CommentNodeI> {
return 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.Active;
}
}
export function postSort(
posts: List<Post>,
sort: SortType,
communityType: boolean
): void {
// 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 ||
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) ||
b.hot_rank - a.hot_rank
);
}
}
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)`;
}
export function previewLines(text: string, lines = 3): string {
// Use lines * 2 because markdown requires 2 lines
return text
.split('\n')
.slice(0, lines * 2)
.join('\n');
}
export function hostname(url: string): string {
let cUrl = new URL(url);
return window.location.port
? `${cUrl.hostname}:${cUrl.port}`
: `${cUrl.hostname}`;
}
function canUseWebP() {
// TODO pictshare might have a webp conversion bug, try disabling this
return false;
// var elem = document.createElement('canvas');
// if (!!(elem.getContext && elem.getContext('2d'))) {
// var testString = !(window.mozInnerScreenX == null) ? 'png' : 'webp';
// // was able or not to get WebP representation
// return (
// elem.toDataURL('image/webp').startsWith('data:image/' + testString)
// );
// }
// // very old browser like IE 8, canvas not supported
// return false;
}
export function imagesDownsize(
html: string,
very_low: boolean,
can_expand: boolean
): string {
const imgPictrsRegex = new RegExp(
/<img src=(("https:\/\/.*?hexbear\.net\/pictrs\/image\/)(.{10})(.jpg"))( alt=".*?">)/g
);
const imgTagRegex = new RegExp(/<img((?!icon).)*$/g);
html = html.replace(
imgPictrsRegex,
(can_expand
? '<a target="_blank" rel="noopener noreferrer" href=$1>'
: '') +
'<img id="$3" src=$2thumbnail' +
(very_low ? '96' : '256') +
'/$3$4$5' +
(can_expand ? '</a>' : '')
);
html = html.replace(
imgTagRegex,
'$& className="' + (very_low ? 'notification-image' : 'comment-image') + '"'
);
return html;
}
export function replaceImageEmbeds(html: string): string {
const imgTagRegex = new RegExp(/<img.*?src="(.*?)" (tag="emote")?[^>]+>/g);
html = html.replace(imgTagRegex, function (match, captureTag, captureURL) {
if (captureTag) return match;
return `<a href="${captureURL}">Embedded image</a>`;
});
return html;
}
export function validTitle(title?: string): boolean {
// Initial title is null, minimum length is taken care of by textarea's minLength={3}
if (title === null || title.length < 3) return true;
const regex = new RegExp(/.*\S.*/, 'g');
return regex.test(title);
}
export const mapSiteModeratorsResponse = (
data: GetSiteModeratorsResponse
): CommunityModsState => {
return data.communities.reduce((agg, item) => {
agg[item.community.id] = item;
return agg;
}, {});
};
interface DoesUserModerateCommunityArgs {
moderatorId: number;
communityId: number;
siteModerators: CommunityModsState;
}
export const doesUserModerateCommunity = ({
moderatorId,
communityId,
siteModerators,
}: DoesUserModerateCommunityArgs): boolean => {
if (siteModerators[communityId] == null) return false;
return siteModerators[communityId].moderators.includes(moderatorId);
};
export const getAllUserModeratedCommunities = ({
moderatorId,
siteModerators,
}: {
moderatorId: number;
siteModerators: CommunityModsState | null;
}): any[] => {
return Object.keys(siteModerators).reduce((agg, communityId) => {
if (
doesUserModerateCommunity({
moderatorId,
communityId: +communityId,
siteModerators,
})
) {
agg.push(+communityId);
}
return agg;
}, []);
};
// any of these comment changes should trigger a re-render
export function isCommentChanged(operation: UserOperation): boolean {
return (
operation == UserOperation.EditComment ||
operation == UserOperation.MarkCommentAsRead ||
operation == UserOperation.RemoveComment ||
operation == UserOperation.DeleteComment
);
}
// any of these post changes should trigger a re-render
export function isPostChanged(operation: UserOperation): boolean {
return (
operation == UserOperation.EditPost ||
operation == UserOperation.DeletePost ||
operation == UserOperation.RemovePost ||
operation == UserOperation.LockPost ||
operation == UserOperation.StickyPost ||
operation == UserOperation.FeaturePost
);
}
// any of these message changes should trigger a re-render
export function isMessageChanged(operation: UserOperation): boolean {
return (
operation == UserOperation.EditPrivateMessage ||
operation == UserOperation.MarkPrivateMessageAsRead ||
operation == UserOperation.DeletePrivateMessage
);
}
export const siteName = isProduction ? 'Hexbear' : 'hexbear (dev)';
export const api = axios.create({
baseURL: isProduction ? '/api/v1/' : 'http://localhost:8536/api/v1/',
});
api.interceptors.response.use(undefined, error => {
if (error.response) {
const errName = error.response.data.error;
if (errName?.includes('site_ban')) {
const id = errName.replace('site_ban_', '');
error.response.data.error = 'site_ban';
localStorage.setItem('bid', id);
} else if (errName === 'invalid_bid') {
//remove bid and retry request
localStorage.removeItem('bid');
return axios.request(error.config);
}
}
return Promise.reject(error);
});
export const fetcher = (url: string): Promise<any> =>
api.get(url).then(res => res.data);
export function toQueryString(options: Record<string, any>): string {
const params = new URLSearchParams(options);
return params.toString();
}
/**
* @param {number} minimum A minimum value.
* @param {number} maximum A maximum value.
* @returns {number} A value in the specified range, inclusive.
*/
export function getRandomNumber(minimum: number, maximum: number): number {
return (Math.random() * (maximum - minimum + 1)) << 0;
}
export function getRandomItem<T>(items: T[]): any {
return items[getRandomNumber(0, items.length - 1)];
}
export function getMoscowTime(): string {
const localDate = new Date();
const utc = localDate.getTime() + localDate.getTimezoneOffset() * 60000;
// create new Date object for different city
// using supplied offset
const moscowTime = new Date(utc + 3600000 * 3);
return moscowTime.toLocaleString().split(', ')[1];
}
/**
* If a string is shorter than the max length, return it unchanged.
* Otherwise, cut it off at the next word and place an ellipsis (...).
* @param {string} input The string to process.
* @param {number} maxLength The maximum allowed length.
* @returns {string} The input string, truncated if necessary.
*/
export function truncateAtWord(input: string, maxLength = 256): string {
if (input.length < maxLength) return input;
return input.replace(new RegExp(`^(.{${maxLength}}[^ ]*).*`), '$1...');
}
export function unique<T = any>(input: T[]): T[] {
return input.filter((value, index, array) => array.indexOf(value) === index);
}