react-activity-feed
Version:
React components to create activity and notification feeds
546 lines (476 loc) • 15.9 kB
text/typescript
import {
useRef,
useState,
useCallback,
SyntheticEvent,
ClipboardEvent,
FormEvent,
useEffect,
useLayoutEffect,
} from 'react';
import _uniq from 'lodash/uniq';
import _difference from 'lodash/difference';
import _includes from 'lodash/includes';
import { find as linkifyFind } from 'linkifyjs';
import { useDebouncedCallback } from 'use-debounce';
import { BaseEmoji, EmojiData } from 'emoji-mart';
import { UploadState } from 'react-file-utils';
import { NewActivity, OGAPIResponse, StreamClient, UR } from 'getstream';
import { DefaultAT, DefaultUT, useStreamContext } from '../../context';
import { StatusUpdateFormProps } from './StatusUpdateForm';
import {
generateRandomId,
dataTransferItemsToFiles,
dataTransferItemsHaveFiles,
inputValueFromEvent,
} from '../../utils';
import { NetworkRequestTypes } from 'utils/errors';
type Og = {
dismissed: boolean;
scrapingActive: boolean;
data?: OGAPIResponse;
};
export type FileUploadState = {
file: File | Blob;
id: string;
state: UploadState;
url?: string;
};
export type ImageUploadState = FileUploadState & { previewUri?: string };
type OgState = { activeUrl: string; data: Record<string, Og>; order: string[] };
type ImagesState = { data: Record<string, ImageUploadState>; order: string[] };
type FilesState = { data: Record<string, FileUploadState>; order: string[] };
type UseOgProps = { client: StreamClient; logErr: (e: Error | unknown, type: NetworkRequestTypes) => void };
type UseUploadProps = UseOgProps;
const defaultOgState = { activeUrl: '', data: {}, order: [] };
const defaultImageState = { data: {}, order: [] };
const defaultFileState = { data: {}, order: [] };
const useTextArea = () => {
const [text, setText] = useState('');
const [curser, setCurser] = useState<number | null>(null);
const textInputRef = useRef<HTMLTextAreaElement>();
const insertText = useCallback((insertedText: string) => {
setText((prevText) => {
const textareaElement = textInputRef.current;
if (!textareaElement) {
setCurser(null);
return prevText + insertedText;
}
// Insert emoji at previous cursor position
const { selectionStart, selectionEnd } = textareaElement;
setCurser(selectionStart + insertedText.length);
return prevText.slice(0, selectionStart) + insertedText + prevText.slice(selectionEnd);
});
}, []);
const onSelectEmoji = useCallback((emoji: EmojiData) => insertText((emoji as BaseEmoji).native), []);
useLayoutEffect(() => {
// Update cursorPosition after insertText is fired
const textareaElement = textInputRef.current;
if (textareaElement && curser !== null) {
textareaElement.selectionStart = curser;
textareaElement.selectionEnd = curser;
}
}, [curser]);
return { text, setText, insertText, onSelectEmoji, textInputRef };
};
const useOg = ({ client, logErr }: UseOgProps) => {
const [og, setOg] = useState<OgState>(defaultOgState);
const reqInProgress = useRef<Record<string, boolean>>({});
const activeOg = og.data[og.activeUrl]?.data;
const orderedOgStates = og.order.map((url) => og.data[url]).filter(Boolean);
const isOgScraping = orderedOgStates.some((state) => state.scrapingActive);
const availableOg = orderedOgStates.map((state) => state.data).filter(Boolean) as OGAPIResponse[];
const resetOg = useCallback(() => setOg(defaultOgState), []);
const setActiveOg = useCallback((url: string) => {
if (url) {
setOg((prevState) => {
prevState.data[url].dismissed = false;
return { ...prevState, activeUrl: url };
});
}
}, []);
const dismissOg = useCallback((e?: SyntheticEvent) => {
e?.preventDefault();
setOg((prevState) => {
for (const url in prevState.data) {
prevState.data[url].dismissed = true;
}
return { ...prevState, activeUrl: '' };
});
}, []);
const handleOG = useCallback((text: string) => {
const urls = _uniq(linkifyFind(text, 'url').map((info) => info.href));
// removed delete ogs from state and add the new urls
setOg((prevState) => {
const newUrls = _difference(urls, prevState.order);
const removedUrls = _difference(prevState.order, urls);
if (!_includes(urls, prevState.activeUrl)) {
prevState.activeUrl = '';
for (const url of urls) {
const og = prevState.data[url];
if (og?.data && !og.dismissed) {
prevState.activeUrl = url;
break;
}
}
}
for (const url of removedUrls) {
delete prevState.data[url];
}
for (const url of newUrls) {
prevState.data[url] = { scrapingActive: true, dismissed: false };
}
return { ...prevState, order: urls };
});
}, []);
const handleOgDebounced = useDebouncedCallback(handleOG, 750, { leading: true, trailing: true });
useEffect(() => {
og.order
.filter((url) => !reqInProgress.current[url] && og.data[url].scrapingActive)
.forEach(async (url) => {
reqInProgress.current[url] = true;
try {
const resp = await client.og(url);
resp.url = url;
setOg((prevState) => {
prevState.data[url] = { ...prevState.data[url], data: resp, scrapingActive: false, dismissed: false };
prevState.activeUrl = prevState.activeUrl || url;
return { ...prevState };
});
} catch (e) {
console.warn(e);
logErr(e, 'get-og');
setOg((prevState) => {
prevState.data[url] = { ...prevState.data[url], scrapingActive: false, dismissed: false };
return { ...prevState };
});
}
delete reqInProgress.current[url];
});
}, [og.order]);
return {
og,
activeOg,
setActiveOg,
resetOg,
availableOg,
orderedOgStates,
isOgScraping,
handleOgDebounced,
dismissOg,
ogActiveUrl: og.activeUrl,
};
};
const useUpload = ({ client, logErr }: UseUploadProps) => {
const [images, setImages] = useState<ImagesState>(defaultImageState);
const [files, setFiles] = useState<FilesState>(defaultFileState);
const reqInProgress = useRef<Record<string, boolean>>({});
const orderedImages = images.order.map((id) => images.data[id]);
const uploadedImages = orderedImages.filter((upload) => upload.url);
const orderedFiles = files.order.map((id) => files.data[id]);
const uploadedFiles = orderedFiles.filter((upload) => upload.url);
const resetUpload = useCallback(() => {
setImages(defaultImageState);
setFiles(defaultFileState);
}, []);
const uploadNewImage = useCallback((file: File | Blob) => {
const id = generateRandomId();
setImages(({ order, data }) => {
data[id] = { id, file, state: 'uploading' };
return { data: { ...data }, order: [...order, id] };
});
if (FileReader) {
// TODO: Possibly use URL.createObjectURL instead. However, then we need
// to release the previews when not used anymore though.
const reader = new FileReader();
reader.onload = (event) => {
const previewUri = event.target?.result as string;
if (!previewUri) return;
setImages((prevState) => {
if (!prevState.data[id]) return prevState;
prevState.data[id].previewUri = previewUri;
return { ...prevState, data: { ...prevState.data } };
});
};
reader.readAsDataURL(file);
}
}, []);
const uploadNewFile = useCallback((file: File) => {
const id = generateRandomId();
setFiles(({ order, data }) => {
data[id] = { id, file, state: 'uploading' };
return { data: { ...data }, order: [...order, id] };
});
}, []);
const uploadImage = useCallback(async (id: string, img: ImageUploadState) => {
setImages((prevState) => {
if (!prevState.data[id]) return prevState;
prevState.data[id].state = 'uploading';
return { ...prevState };
});
try {
const { file: url } = await client.images.upload(img.file as File);
setImages((prevState) => {
if (!prevState.data[id]) return prevState;
prevState.data[id].url = url;
prevState.data[id].state = 'finished';
return { ...prevState };
});
} catch (e) {
console.warn(e);
setImages((prevState) => {
if (!prevState.data[id]) return prevState;
logErr(e, 'upload-image');
prevState.data[id].state = 'failed';
return { ...prevState };
});
}
}, []);
const uploadFile = useCallback(async (id: string, file: FileUploadState) => {
setFiles((prevState) => {
if (!prevState.data[id]) return prevState;
prevState.data[id].state = 'uploading';
return { ...prevState, data: { ...prevState.data } };
});
try {
const { file: url } = await client.files.upload(file.file as File);
setFiles((prevState) => {
if (!prevState.data[id]) return prevState;
prevState.data[id].url = url;
prevState.data[id].state = 'finished';
return { ...prevState, data: { ...prevState.data } };
});
} catch (e) {
console.warn(e);
setFiles((prevState) => {
if (!prevState.data[id]) return prevState;
logErr(e, 'upload-file');
prevState.data[id].state = 'failed';
return { ...prevState, data: { ...prevState.data } };
});
}
}, []);
const uploadNewFiles = useCallback((files: Blob[] | File[] | FileList) => {
for (let i = 0; i < files.length; i += 1) {
const file = files[i];
if (file.type.startsWith('image/')) {
uploadNewImage(file);
} else if (file instanceof File) {
uploadNewFile(file);
}
}
}, []);
const removeImage = useCallback((id: string) => {
setImages((prevState) => {
prevState.order = prevState.order.filter((oid) => id !== oid);
delete prevState.data[id];
return { ...prevState };
});
}, []);
const removeFile = useCallback((id: string) => {
// eslint-disable-next-line sonarjs/no-identical-functions
setFiles((prevState) => {
prevState.order = prevState.order.filter((oid) => id !== oid);
delete prevState.data[id];
return { ...prevState };
});
}, []);
useEffect(() => {
images.order
.filter((id) => !reqInProgress.current[id] && images.data[id].state === 'uploading')
.forEach(async (id) => {
reqInProgress.current[id] = true;
await uploadImage(id, images.data[id]);
delete reqInProgress.current[id];
});
}, [images.order]);
useEffect(() => {
files.order
.filter((id) => !reqInProgress.current[id] && files.data[id].state === 'uploading')
.forEach(async (id) => {
reqInProgress.current[id] = true;
await uploadFile(id, files.data[id]);
delete reqInProgress.current[id];
});
}, [files.order]);
return {
images,
files,
orderedImages,
orderedFiles,
uploadedImages,
uploadedFiles,
resetUpload,
uploadNewFiles,
uploadFile,
uploadImage,
removeFile,
removeImage,
};
};
export function useStatusUpdateForm<
UT extends DefaultUT = DefaultUT,
AT extends DefaultAT = DefaultAT,
CT extends UR = UR,
RT extends UR = UR,
CRT extends UR = UR,
PT extends UR = UR
>({
activityVerb,
feedGroup,
modifyActivityData,
doRequest,
userId,
onSuccess,
}: { activityVerb: string; feedGroup: string } & Pick<
StatusUpdateFormProps<AT>,
'doRequest' | 'modifyActivityData' | 'onSuccess' | 'userId'
>) {
const [submitting, setSubmitting] = useState(false);
const appCtx = useStreamContext<UT, AT, CT, RT, CRT, PT>();
const client = appCtx.client as StreamClient<UT, AT, CT, RT, CRT, PT>;
const userData = (appCtx.user?.data || {}) as UT;
const logErr: UseOgProps['logErr'] = useCallback(
(e, type) => appCtx.errorHandler(e, type, { userId, feedGroup }),
[],
);
const { text, setText, insertText, onSelectEmoji, textInputRef } = useTextArea();
const {
resetOg,
setActiveOg,
ogActiveUrl,
activeOg,
dismissOg,
availableOg,
isOgScraping,
handleOgDebounced,
} = useOg({ client: client as StreamClient, logErr });
const {
images,
files,
orderedImages,
orderedFiles,
uploadedImages,
uploadedFiles,
resetUpload,
uploadNewFiles,
uploadFile,
uploadImage,
removeFile,
removeImage,
} = useUpload({ client: client as StreamClient, logErr });
const resetState = useCallback(() => {
setText('');
setSubmitting(false);
resetOg();
resetUpload();
}, []);
const object = () => {
for (const image of orderedImages) {
if (image.url) return image.url;
}
return text.trim();
};
const canSubmit = () =>
!submitting &&
Boolean(object()) &&
orderedImages.every((upload) => upload.state !== 'uploading') &&
orderedFiles.every((upload) => upload.state !== 'uploading') &&
!isOgScraping;
const addActivity = async () => {
// FIXME:
// @ts-expect-error
const activity: NewActivity<AT> = {
actor: client.currentUser?.ref() as string,
object: object(),
verb: activityVerb,
text: text.trim(),
attachments: {
og: activeOg,
images: uploadedImages.map((image) => image.url).filter(Boolean) as string[],
files: uploadedFiles.map((upload) => ({
// url will never actually be empty string because uploadedFiles
// filters those out.
url: upload.url as string,
name: (upload.file as File).name,
mimeType: upload.file.type,
})),
},
};
const modifiedActivity = modifyActivityData ? modifyActivityData(activity) : activity;
if (doRequest) {
return await doRequest(modifiedActivity);
} else {
return await client.feed(feedGroup, userId).addActivity(modifiedActivity);
}
};
const onSubmitForm = async (e: FormEvent) => {
e.preventDefault();
try {
setSubmitting(true);
const response = await addActivity();
resetState();
if (onSuccess) onSuccess(response);
} catch (e) {
setSubmitting(false);
logErr(e, 'add-activity');
}
};
const onChange = useCallback((event: SyntheticEvent<HTMLTextAreaElement>) => {
const text = inputValueFromEvent(event, true);
if (text === null || text === undefined) return;
setText(text);
handleOgDebounced(text);
}, []);
const onPaste = useCallback(async (event: ClipboardEvent<HTMLTextAreaElement>) => {
const { items } = event.clipboardData;
if (!dataTransferItemsHaveFiles(items)) return;
event.preventDefault();
// Get a promise for the plain text in case no files are
// found. This needs to be done here because chrome cleans
// up the DataTransferItems after resolving of a promise.
let plainTextPromise: Promise<string> | undefined;
for (let i = 0; i < items.length; i += 1) {
const item = items[i];
if (item.kind === 'string' && item.type === 'text/plain') {
plainTextPromise = new Promise((resolve) => item.getAsString(resolve));
break;
}
}
const fileLikes = await dataTransferItemsToFiles(items);
if (fileLikes.length) {
uploadNewFiles(fileLikes);
return;
}
// fallback to regular text paste
if (plainTextPromise) {
const s = await plainTextPromise;
insertText(s);
}
}, []);
return {
userData,
textInputRef,
text,
submitting,
files,
images,
activeOg,
availableOg,
isOgScraping,
ogActiveUrl,
onSubmitForm,
onSelectEmoji,
insertText,
onChange,
dismissOg,
setActiveOg,
canSubmit,
uploadNewFiles,
uploadFile,
uploadImage,
removeFile,
removeImage,
onPaste,
};
}