react-activity-feed
Version:
React components to create activity and notification feeds
197 lines (171 loc) • 6.39 kB
text/typescript
import React, { useMemo, MouseEvent, DetailedHTMLProps, HTMLAttributes } from 'react';
import URL from 'url-parse';
import Dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import minMax from 'dayjs/plugin/minMax';
import relativeTime from 'dayjs/plugin/relativeTime';
import { EnrichedUser, UR } from 'getstream';
import { TDateTimeParser } from '../i18n/Streami18n';
import { DefaultUT } from '../context/StreamApp';
Dayjs.extend(utc);
Dayjs.extend(minMax);
Dayjs.extend(relativeTime);
export function isTimezoneAwareTimestamp(timestamp: string) {
return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3,6}(Z$|[+-]\d{2}:\d{2}$)/.test(timestamp);
}
export function humanizeTimestamp(timestamp: string | number | Date, tDateTimeParser: TDateTimeParser) {
let time;
// Following calculation is based on assumption that tDateTimeParser()
// either returns momentjs or dayjs object.
// When timestamp is not timezone-aware, we are supposed to take it as UTC time.
// Ideally we need to adhere to RFC3339. Unfortunately this needs to be fixed on backend.
if (typeof timestamp === 'string' && isTimezoneAwareTimestamp(timestamp)) {
time = tDateTimeParser(timestamp);
} else {
time = tDateTimeParser(timestamp).add(Dayjs(timestamp).utcOffset(), 'minute'); // parse time as UTC
}
return time.fromNow();
}
type ErrorUser = { error: string };
function isErrorUser(user: unknown | ErrorUser): user is ErrorUser {
return !!user && typeof (user as ErrorUser).error === 'string';
}
export type UserOrDefaultReturnType<T extends UR = UR> =
| EnrichedUser<T>
| (EnrichedUser<{ name: 'Unknown'; profileImage: '' }> & { id: '!not-found' });
export function userOrDefault<T extends UR = UR>(
user?: EnrichedUser<T> | UserOrDefaultReturnType<T> | string | { error: string } | null,
): UserOrDefaultReturnType<T> {
if (!user || typeof user === 'string' || isErrorUser(user))
return {
id: '!not-found',
created_at: '',
updated_at: '',
data: { name: 'Unknown', profileImage: '' },
};
return user;
}
// https://stackoverflow.com/a/6860916/2570866
export function generateRandomId() {
// prettier-ignore
return (S4()+S4()+"-"+S4()+"-"+S4()+"-"+S4()+"-"+S4()+S4()+S4());
}
function S4() {
return (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1);
}
export function dataTransferItemsHaveFiles(items?: DataTransferItemList) {
if (!items || !items.length) return false;
for (let i = 0; i < items.length; i += 1) {
const item = items[i];
if (item.kind === 'file' || item.type === 'text/html') return true;
}
return false;
}
function getFileLikes(items: DataTransferItemList) {
const fileLikes: Array<Blob | File> = [];
for (let i = 0; i < items.length; i += 1) {
const item = items[i];
if (item.kind === 'file') {
const file = item.getAsFile();
if (file) fileLikes.push(file);
}
}
return fileLikes;
}
export async function dataTransferItemsToFiles(items?: DataTransferItemList) {
if (!items || !items.length) return [];
const fileLikes = getFileLikes(items);
// If there are files inside the DataTransferItem prefer those
if (fileLikes.length) return fileLikes;
// Otherwise extract images from html
const blobPromises = [];
const parser = new DOMParser();
for (let i = 0; i < items.length; i += 1) {
const item = items[i];
if (item.type === 'text/html') {
blobPromises.push(
new Promise((accept) => {
item.getAsString(async (s) => {
const doc = parser.parseFromString(s, 'text/html');
const imageTags = doc.getElementsByTagName('img');
const imagePromises = [];
for (let j = 0; j < imageTags.length; j++) {
const tag = imageTags[j];
if (!tag.src) {
continue;
}
imagePromises.push(
(async () => {
let res;
try {
res = await fetch(tag.src);
} catch (e) {
return;
}
const contentType = res.headers.get('Content-type') || 'application/octet-stream';
const buf = await res.arrayBuffer();
const blob = new Blob([buf], { type: contentType });
fileLikes.push(blob);
})(),
);
}
await Promise.all(imagePromises);
accept(true);
});
}),
);
}
}
await Promise.all(blobPromises);
return fileLikes;
}
export function inputValueFromEvent<T extends HTMLInputElement | HTMLTextAreaElement = HTMLInputElement>(
event: React.SyntheticEvent<T> | undefined = undefined,
targetFirst: boolean | undefined = false,
) {
try {
const target = (event?.[targetFirst ? 'target' : 'currentTarget'] ??
event?.[targetFirst ? 'currentTarget' : 'target']) as T;
return target?.value;
} catch (error) {
console.error(error);
return undefined;
}
}
export function sanitizeURL(url?: string) {
if (!url) return url;
const proto = URL(url).protocol;
// allow http, https, ftp
// IMPORTANT: Don't allow data: protocol because of:
// <a href="data:text/html;base64,PHNjcmlwdD5hbGVydCgneHNzJyk7PC9zY3JpcHQ+" target="_blank">here</a>
if (proto === 'https:' || proto === 'http:' || proto === 'ftp:') {
return url;
}
return undefined;
}
export const trimURL = (url?: string) =>
url
?.replace(/^(?:https?:\/\/)?(?:www\.)?/i, '')
.split('/')
.shift();
export type OnClickUserHandler<UT extends DefaultUT = DefaultUT> = (user: UserOrDefaultReturnType<UT>) => void;
export const useOnClickUser = <
UT extends DefaultUT = DefaultUT,
E extends HTMLElement | SVGGElement = HTMLImageElement | SVGSVGElement
>(
onClickUser?: OnClickUserHandler<UT>,
) =>
useMemo(
() =>
onClickUser
? (user?: EnrichedUser<UT> | UserOrDefaultReturnType<UT>) => (event: MouseEvent<E>) => {
event.stopPropagation();
onClickUser(userOrDefault<UT>(user));
}
: undefined,
[onClickUser],
);
export type PropsWithElementAttributes<T extends UR = UR, E extends HTMLElement = HTMLDivElement> = T &
Pick<DetailedHTMLProps<HTMLAttributes<E>, E>, 'className' | 'style'>;
export * from './textRenderer';
export * from './smartRender';