Implement UnfurlsStore (#8920)

* Implement UnfurlsStore

* simplify lookup

* refetch unfurl after X elapsed time

* compute fetchedAt in client
This commit is contained in:
Hemachandar
2025-04-11 06:54:32 +05:30
committed by GitHub
parent e499881110
commit f7b00e72f1
5 changed files with 272 additions and 153 deletions

View File

@@ -1,4 +1,5 @@
import { m } from "framer-motion";
import { observer } from "mobx-react";
import * as React from "react";
import { Portal } from "react-portal";
import styled from "styled-components";
@@ -8,6 +9,7 @@ import useEventListener from "~/hooks/useEventListener";
import useKeyDown from "~/hooks/useKeyDown";
import useMobile from "~/hooks/useMobile";
import useOnClickOutside from "~/hooks/useOnClickOutside";
import useStores from "~/hooks/useStores";
import LoadingIndicator from "../LoadingIndicator";
import { CARD_MARGIN } from "./Components";
import HoverPreviewDocument from "./HoverPreviewDocument";
@@ -23,9 +25,9 @@ const POINTER_WIDTH = 22;
type Props = {
/** The HTML element that is being hovered over, or null if none. */
element: HTMLElement | null;
/** Data to be previewed */
data: Record<string, any> | null;
/** Whether the preview data is being loaded */
/** ID of the unfurl that will be shown in the hover preview. */
unfurlId: string | null;
/** Whether the preview data is being loaded. */
dataLoading: boolean;
/** A callback on close of the hover preview. */
onClose: () => void;
@@ -36,151 +38,155 @@ enum Direction {
DOWN,
}
function HoverPreviewDesktop({ element, data, dataLoading, onClose }: Props) {
const [isVisible, setVisible] = React.useState(false);
const timerClose = React.useRef<ReturnType<typeof setTimeout>>();
const cardRef = React.useRef<HTMLDivElement | null>(null);
const { cardLeft, cardTop, pointerLeft, pointerTop, pointerDir } =
useHoverPosition({
cardRef,
element,
isVisible,
});
const HoverPreviewDesktop = observer(
({ element, unfurlId, dataLoading, onClose }: Props) => {
const { unfurls } = useStores();
const [isVisible, setVisible] = React.useState(false);
const timerClose = React.useRef<ReturnType<typeof setTimeout>>();
const cardRef = React.useRef<HTMLDivElement | null>(null);
const { cardLeft, cardTop, pointerLeft, pointerTop, pointerDir } =
useHoverPosition({
cardRef,
element,
isVisible,
});
const data = unfurlId ? unfurls.get(unfurlId)?.data : undefined;
const closePreview = React.useCallback(() => {
setVisible(false);
onClose();
}, [onClose]);
const closePreview = React.useCallback(() => {
setVisible(false);
onClose();
}, [onClose]);
const stopCloseTimer = React.useCallback(() => {
if (timerClose.current) {
clearTimeout(timerClose.current);
timerClose.current = undefined;
}
}, []);
const startCloseTimer = React.useCallback(() => {
timerClose.current = setTimeout(closePreview, DELAY_CLOSE);
}, [closePreview]);
// Open and close the preview when the element changes.
React.useEffect(() => {
if (element && data && !dataLoading) {
setVisible(true);
} else {
startCloseTimer();
}
}, [startCloseTimer, element, data, dataLoading]);
// Close the preview on Escape, scroll, or click outside.
useOnClickOutside(cardRef, closePreview);
useKeyDown("Escape", closePreview);
useEventListener("scroll", closePreview, window, { capture: true });
// Ensure that the preview stays open while the user is hovering over the card.
React.useEffect(() => {
const card = cardRef.current;
if (isVisible) {
if (card) {
card.addEventListener("mouseenter", stopCloseTimer);
card.addEventListener("mouseleave", startCloseTimer);
const stopCloseTimer = React.useCallback(() => {
if (timerClose.current) {
clearTimeout(timerClose.current);
timerClose.current = undefined;
}
}
}, []);
return () => {
if (card) {
card.removeEventListener("mouseenter", stopCloseTimer);
card.removeEventListener("mouseleave", startCloseTimer);
const startCloseTimer = React.useCallback(() => {
timerClose.current = setTimeout(closePreview, DELAY_CLOSE);
}, [closePreview]);
// Open and close the preview when the element changes.
React.useEffect(() => {
if (element && data && !dataLoading) {
setVisible(true);
} else {
startCloseTimer();
}
}, [startCloseTimer, element, data, dataLoading]);
// Close the preview on Escape, scroll, or click outside.
useOnClickOutside(cardRef, closePreview);
useKeyDown("Escape", closePreview);
useEventListener("scroll", closePreview, window, { capture: true });
// Ensure that the preview stays open while the user is hovering over the card.
React.useEffect(() => {
const card = cardRef.current;
if (isVisible) {
if (card) {
card.addEventListener("mouseenter", stopCloseTimer);
card.addEventListener("mouseleave", startCloseTimer);
}
}
stopCloseTimer();
};
}, [element, startCloseTimer, isVisible, stopCloseTimer]);
return () => {
if (card) {
card.removeEventListener("mouseenter", stopCloseTimer);
card.removeEventListener("mouseleave", startCloseTimer);
}
if (dataLoading) {
return <LoadingIndicator />;
stopCloseTimer();
};
}, [element, startCloseTimer, isVisible, stopCloseTimer]);
if (dataLoading) {
return <LoadingIndicator />;
}
if (!data) {
return null;
}
return (
<Portal>
<Position top={cardTop} left={cardLeft} aria-hidden>
{isVisible ? (
<Animate
initial={{ opacity: 0, y: -20, pointerEvents: "none" }}
animate={{
opacity: 1,
y: 0,
transitionEnd: { pointerEvents: "auto" },
}}
>
{data.type === UnfurlResourceType.Mention ? (
<HoverPreviewMention
ref={cardRef}
name={data.name}
avatarUrl={data.avatarUrl}
color={data.color}
lastActive={data.lastActive}
email={data.email}
/>
) : data.type === UnfurlResourceType.Document ? (
<HoverPreviewDocument
ref={cardRef}
url={data.url}
id={data.id}
title={data.title}
summary={data.summary}
lastActivityByViewer={data.lastActivityByViewer}
/>
) : data.type === UnfurlResourceType.Issue ? (
<HoverPreviewIssue
ref={cardRef}
url={data.url}
id={data.id}
title={data.title}
description={data.description}
author={data.author}
labels={data.labels}
state={data.state}
createdAt={data.createdAt}
/>
) : data.type === UnfurlResourceType.PR ? (
<HoverPreviewPullRequest
ref={cardRef}
url={data.url}
id={data.id}
title={data.title}
description={data.description}
author={data.author}
createdAt={data.createdAt}
state={data.state}
/>
) : (
<HoverPreviewLink
ref={cardRef}
url={data.url}
thumbnailUrl={data.thumbnailUrl}
title={data.title}
description={data.description}
/>
)}
<Pointer
top={pointerTop}
left={pointerLeft}
direction={pointerDir}
/>
</Animate>
) : null}
</Position>
</Portal>
);
}
);
if (!data) {
return null;
}
return (
<Portal>
<Position top={cardTop} left={cardLeft} aria-hidden>
{isVisible ? (
<Animate
initial={{ opacity: 0, y: -20, pointerEvents: "none" }}
animate={{
opacity: 1,
y: 0,
transitionEnd: { pointerEvents: "auto" },
}}
>
{data.type === UnfurlResourceType.Mention ? (
<HoverPreviewMention
ref={cardRef}
name={data.name}
avatarUrl={data.avatarUrl}
color={data.color}
lastActive={data.lastActive}
email={data.email}
/>
) : data.type === UnfurlResourceType.Document ? (
<HoverPreviewDocument
ref={cardRef}
url={data.url}
id={data.id}
title={data.title}
summary={data.summary}
lastActivityByViewer={data.lastActivityByViewer}
/>
) : data.type === UnfurlResourceType.Issue ? (
<HoverPreviewIssue
ref={cardRef}
url={data.url}
id={data.id}
title={data.title}
description={data.description}
author={data.author}
labels={data.labels}
state={data.state}
createdAt={data.createdAt}
/>
) : data.type === UnfurlResourceType.PR ? (
<HoverPreviewPullRequest
ref={cardRef}
url={data.url}
id={data.id}
title={data.title}
description={data.description}
author={data.author}
createdAt={data.createdAt}
state={data.state}
/>
) : (
<HoverPreviewLink
ref={cardRef}
url={data.url}
thumbnailUrl={data.thumbnailUrl}
title={data.title}
description={data.description}
/>
)}
<Pointer
top={pointerTop}
left={pointerLeft}
direction={pointerDir}
/>
</Animate>
) : null}
</Position>
</Portal>
);
}
function HoverPreview({ element, data, dataLoading, ...rest }: Props) {
function HoverPreview({ element, unfurlId, dataLoading, ...rest }: Props) {
const isMobile = useMobile();
if (isMobile) {
return null;
@@ -190,7 +196,7 @@ function HoverPreview({ element, data, dataLoading, ...rest }: Props) {
<HoverPreviewDesktop
{...rest}
element={element}
data={data}
unfurlId={unfurlId}
dataLoading={dataLoading}
/>
);

View File

@@ -4,9 +4,9 @@ import { EditorView } from "prosemirror-view";
import * as React from "react";
import Extension from "@shared/editor/lib/Extension";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import stores from "~/stores";
import HoverPreview from "~/components/HoverPreview";
import env from "~/env";
import { client } from "~/utils/ApiClient";
interface HoverPreviewsOptions {
/** Delay before the target is considered "hovered" and callback is triggered. */
@@ -16,11 +16,11 @@ interface HoverPreviewsOptions {
export default class HoverPreviews extends Extension {
state: {
activeLinkElement: HTMLElement | null;
data: Record<string, any> | null;
unfurlId: string | null;
dataLoading: boolean;
} = observable({
activeLinkElement: null,
data: null,
unfurlId: null,
dataLoading: false,
});
@@ -62,19 +62,25 @@ export default class HoverPreviews extends Extension {
);
if (url) {
const transformedUrl = url.startsWith("/")
? env.URL + url
: url;
this.state.dataLoading = true;
try {
const data = await client.post("/urls.unfurl", {
url: url.startsWith("/") ? env.URL + url : url,
documentId,
});
const unfurl = await stores.unfurls.fetchUnfurl({
url: transformedUrl,
documentId,
});
if (unfurl) {
this.state.activeLinkElement = element;
this.state.data = data;
} catch (err) {
this.state.unfurlId = transformedUrl;
} else {
this.state.activeLinkElement = null;
} finally {
this.state.dataLoading = false;
}
this.state.dataLoading = false;
}
}),
this.options.delay
@@ -101,10 +107,11 @@ export default class HoverPreviews extends Extension {
widget = () => (
<HoverPreview
element={this.state.activeLinkElement}
data={this.state.data}
unfurlId={this.state.unfurlId}
dataLoading={this.state.dataLoading}
onClose={action(() => {
this.state.activeLinkElement = null;
this.state.unfurlId = null;
})}
/>
);

18
app/models/Unfurl.ts Normal file
View File

@@ -0,0 +1,18 @@
import { observable } from "mobx";
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
import Model from "./base/Model";
class Unfurl<UnfurlType extends UnfurlResourceType> extends Model {
static modelName = "Unfurl";
@observable
type: UnfurlType;
@observable
data: UnfurlResponse[UnfurlType];
@observable
fetchedAt: string;
}
export default Unfurl;

View File

@@ -26,6 +26,7 @@ import SharesStore from "./SharesStore";
import StarsStore from "./StarsStore";
import SubscriptionsStore from "./SubscriptionsStore";
import UiStore from "./UiStore";
import UnfurlsStore from "./UnfurlsStore";
import UserMembershipsStore from "./UserMembershipsStore";
import UsersStore from "./UsersStore";
import ViewsStore from "./ViewsStore";
@@ -55,6 +56,7 @@ export default class RootStore {
searches: SearchesStore;
shares: SharesStore;
ui: UiStore;
unfurls: UnfurlsStore;
stars: StarsStore;
subscriptions: SubscriptionsStore;
users: UsersStore;
@@ -85,6 +87,7 @@ export default class RootStore {
this.registerStore(SharesStore);
this.registerStore(StarsStore);
this.registerStore(SubscriptionsStore);
this.registerStore(UnfurlsStore);
this.registerStore(UsersStore);
this.registerStore(ViewsStore);
this.registerStore(FileOperationsStore);

View File

@@ -0,0 +1,85 @@
import { subMinutes } from "date-fns";
import { action } from "mobx";
import { UnfurlResourceType } from "@shared/types";
import Unfurl from "~/models/Unfurl";
import { client } from "~/utils/ApiClient";
import Logger from "~/utils/Logger";
import RootStore from "./RootStore";
import Store from "./base/Store";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
class UnfurlsStore extends Store<Unfurl<any>> {
actions = []; // no default actions allowed for unfurls.
constructor(rootStore: RootStore) {
super(rootStore, Unfurl);
}
fetchUnfurl = async <UnfurlType extends UnfurlResourceType>({
url,
documentId,
}: {
url: string;
documentId?: string;
}): Promise<Unfurl<UnfurlType> | undefined> => {
const unfurl = this.get(url);
if (unfurl) {
this.refetch({ unfurl: unfurl as Unfurl<UnfurlType>, documentId });
return unfurl;
}
return this.unfurl<UnfurlType>({ url, documentId });
};
private refetch = <UnfurlType extends UnfurlResourceType>({
unfurl,
documentId,
}: {
unfurl: Unfurl<UnfurlType>;
documentId?: string;
}) => {
const fiveMinutesAgo = subMinutes(new Date(), 5);
if (new Date(unfurl.fetchedAt) < fiveMinutesAgo) {
void this.unfurl({ url: unfurl.id, documentId });
}
};
@action
private unfurl = async <UnfurlType extends UnfurlResourceType>({
url,
documentId,
}: {
url: string;
documentId?: string;
}): Promise<Unfurl<UnfurlType> | undefined> => {
try {
this.isFetching = true;
const data = await client.post("/urls.unfurl", {
url,
documentId,
});
// unfurls can succeed with no data.
if (!data) {
return;
}
return this.add({
id: url,
type: data.type,
fetchedAt: new Date().toISOString(),
data,
} as Unfurl<UnfurlType>);
} catch (err) {
Logger.error(`Failed to unfurl url ${url}`, err);
return;
} finally {
this.isFetching = false;
}
};
}
export default UnfurlsStore;