2024-02-14 20:50:49 -08:00

451 lines
14 KiB
TypeScript

import { basename, join, resolve } from "path";
import { useCallback, useEffect, useRef, useState } from "react";
import {
createDirectoryIndex,
type DirectoryEntries,
} from "components/apps/Browser/directoryIndex";
import { Arrow, Refresh, Stop } from "components/apps/Browser/NavigationIcons";
import StyledBrowser from "components/apps/Browser/StyledBrowser";
import {
DINO_GAME,
HOME_PAGE,
LOCAL_HOST,
NOT_FOUND,
bookmarks,
} from "components/apps/Browser/config";
import { type ComponentProcessProps } from "components/system/Apps/RenderComponent";
import useTitle from "components/system/Window/useTitle";
import { useFileSystem } from "contexts/fileSystem";
import { useProcesses } from "contexts/process";
import processDirectory from "contexts/process/directory";
import useHistory from "hooks/useHistory";
import Button from "styles/common/Button";
import Icon from "styles/common/Icon";
import {
FAVICON_BASE_PATH,
IFRAME_CONFIG,
ONE_TIME_PASSIVE_EVENT,
SHORTCUT_EXTENSION,
} from "utils/constants";
import {
GOOGLE_SEARCH_QUERY,
getExtension,
getUrlOrSearch,
label,
} from "utils/functions";
import {
getInfoWithExtension,
getShortcutInfo,
} from "components/system/Files/FileEntry/functions";
import { useSession } from "contexts/session";
const Browser: FC<ComponentProcessProps> = ({ id }) => {
const {
icon: setIcon,
linkElement,
url: changeUrl,
processes: { [id]: process },
open,
} = useProcesses();
const { setForegroundId } = useSession();
const { prependFileToTitle } = useTitle(id);
const { initialTitle = "", url = "" } = process || {};
const initialUrl = url || HOME_PAGE;
const { canGoBack, canGoForward, history, moveHistory, position } =
useHistory(initialUrl, id);
const { exists, fs, stat, readFile, readdir } = useFileSystem();
const inputRef = useRef<HTMLInputElement | null>(null);
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const [loading, setLoading] = useState(false);
const [srcDoc, setSrcDoc] = useState("");
const changeHistory = (step: number): void => {
moveHistory(step);
if (inputRef.current) inputRef.current.value = history[position + step];
};
const currentUrl = useRef("");
const changeIframeWindowLocation = (
newUrl: string,
contentWindow: Window
): void => {
let isSrcDoc = false;
try {
isSrcDoc = contentWindow.location?.pathname === "srcdoc";
} catch {
// Ignore failure to read iframe window path
}
if (isSrcDoc) {
iframeRef.current?.setAttribute("src", newUrl);
} else {
contentWindow.location?.replace(newUrl);
}
};
const goToLink = useCallback(
(newUrl: string): void => {
if (inputRef.current) {
inputRef.current.value = newUrl;
}
changeUrl(id, newUrl);
},
[changeUrl, id]
);
const setUrl = useCallback(
async (addressInput: string): Promise<void> => {
const { contentWindow } = iframeRef.current || {};
if (contentWindow?.location) {
const isHtml =
[".htm", ".html"].includes(getExtension(addressInput)) &&
(await exists(addressInput));
setLoading(true);
setSrcDoc("");
if (isHtml) setSrcDoc((await readFile(addressInput)).toString());
setIcon(id, processDirectory.Browser.icon);
if (addressInput.toLowerCase().startsWith(DINO_GAME.url)) {
changeIframeWindowLocation(
`${window.location.origin}${DINO_GAME.path}`,
contentWindow
);
prependFileToTitle(`${DINO_GAME.url}/`);
} else if (!isHtml) {
const processedUrl = await getUrlOrSearch(addressInput);
if (
LOCAL_HOST.has(processedUrl.host) ||
LOCAL_HOST.has(addressInput)
) {
const directory =
decodeURI(processedUrl.pathname).replace(/\/$/, "") || "/";
const searchParams = Object.fromEntries(
new URLSearchParams(
processedUrl.search.replace(";", "&")
).entries()
);
const { O: order, C: column } = searchParams;
const isAscending = !order || order === "A";
let newSrcDoc = NOT_FOUND;
let newTitle = "404 Not Found";
if (
(await exists(directory)) &&
(await stat(directory)).isDirectory()
) {
const dirStats = (
await Promise.all<DirectoryEntries>(
(await readdir(directory)).map(async (entry) => {
const href = join(directory, entry);
let description;
let shortcutUrl;
if (getExtension(entry) === SHORTCUT_EXTENSION) {
try {
({ comment: description, url: shortcutUrl } =
getShortcutInfo(await readFile(href)));
} catch {
// Ignore failure to read shortcut
}
}
const stats = await stat(
shortcutUrl && (await exists(shortcutUrl))
? shortcutUrl
: href
);
const isDir = stats.isDirectory();
return {
description,
href: isDir && shortcutUrl ? shortcutUrl : href,
icon: isDir ? "folder" : undefined,
modified: stats.mtime,
size: isDir || shortcutUrl ? undefined : stats.size,
};
})
)
)
.sort(
(a, b) =>
Number(b.icon === "folder") - Number(a.icon === "folder")
)
.sort((a, b) => {
const aIsFolder = a.icon === "folder";
const bIsFolder = b.icon === "folder";
if (aIsFolder === bIsFolder) {
const aName = basename(a.href);
const bName = basename(b.href);
if (isAscending) return aName < bName ? -1 : 1;
return aName > bName ? -1 : 1;
}
return 0;
})
.sort((a, b) => {
if (!column || column === "N") return 0;
const sortValue = (
getValue: (entry: DirectoryEntries) => number | string
): number => {
const aValue = getValue(a);
const bValue = getValue(b);
if (aValue === bValue) return 0;
if (isAscending) return aValue < bValue ? -1 : 1;
return aValue > bValue ? -1 : 1;
};
if (column === "S") {
return sortValue(({ size }) => size ?? 0);
}
if (column === "M") {
return sortValue(
({ modified }) => modified?.getTime() ?? 0
);
}
if (column === "D") {
return sortValue(({ description }) => description ?? "");
}
return 0;
})
.sort(
(a, b) =>
Number(b.icon === "folder") - Number(a.icon === "folder")
);
iframeRef.current?.addEventListener(
"load",
() => {
try {
contentWindow.document.body
.querySelectorAll("a")
.forEach((a) => {
a.addEventListener("click", (event) => {
event.preventDefault();
const target =
event.currentTarget as HTMLAnchorElement;
const isDir =
target.getAttribute("type") === "folder";
const { origin, pathname, search } = new URL(
target.href
);
if (search) {
goToLink(
`${origin}${encodeURI(directory)}${search}`
);
} else if (isDir) {
goToLink(target.href);
} else if (fs && target.href) {
getInfoWithExtension(
fs,
decodeURI(pathname),
getExtension(pathname),
({ pid, url: infoUrl }) =>
open(pid || "OpenWith", { url: infoUrl })
);
}
});
});
} catch {
// Ignore failure to add click event listeners
}
},
ONE_TIME_PASSIVE_EVENT
);
newSrcDoc = createDirectoryIndex(
directory,
processedUrl.origin,
searchParams,
directory === "/"
? dirStats
: [
{
href: resolve(directory, ".."),
icon: "back",
},
...dirStats,
]
);
newTitle = `Index of ${directory}`;
}
setSrcDoc(newSrcDoc);
prependFileToTitle(newTitle);
} else {
const addressUrl = processedUrl.href;
changeIframeWindowLocation(addressUrl, contentWindow);
if (addressUrl.startsWith(GOOGLE_SEARCH_QUERY)) {
prependFileToTitle(`${addressInput} - Google Search`);
} else {
const { name = initialTitle } =
bookmarks?.find(
({ url: bookmarkUrl }) => bookmarkUrl === addressInput
) || {};
prependFileToTitle(name);
}
if (addressInput.startsWith("ipfs://")) {
setIcon(id, "/System/Icons/Favicons/ipfs.webp");
} else {
const favicon = new Image();
const faviconUrl = `${
new URL(addressUrl).origin
}${FAVICON_BASE_PATH}`;
favicon.addEventListener(
"error",
() => {
const { icon } =
bookmarks?.find(
({ url: bookmarkUrl }) => bookmarkUrl === addressUrl
) || {};
if (icon) setIcon(id, icon);
},
ONE_TIME_PASSIVE_EVENT
);
favicon.addEventListener(
"load",
() => setIcon(id, faviconUrl),
ONE_TIME_PASSIVE_EVENT
);
favicon.src = faviconUrl;
}
}
}
}
},
[
exists,
fs,
goToLink,
id,
initialTitle,
open,
prependFileToTitle,
readFile,
readdir,
setIcon,
stat,
]
);
useEffect(() => {
if (process && history[position] !== currentUrl.current) {
currentUrl.current = history[position];
setUrl(history[position]);
}
}, [history, position, process, setUrl]);
useEffect(() => {
if (iframeRef.current) {
linkElement(id, "peekElement", iframeRef.current);
}
}, [id, linkElement]);
return (
<StyledBrowser $hasSrcDoc={Boolean(srcDoc)}>
<nav>
<div>
<Button
disabled={!canGoBack}
onClick={() => changeHistory(-1)}
{...label("Click to go back")}
>
<Arrow direction="left" />
</Button>
<Button
disabled={!canGoForward}
onClick={() => changeHistory(+1)}
{...label("Click to go forward")}
>
<Arrow direction="right" />
</Button>
<Button
disabled={loading}
onClick={() => setUrl(history[position])}
{...label("Reload this page")}
>
{loading ? <Stop /> : <Refresh />}
</Button>
</div>
<input
ref={inputRef}
defaultValue={initialUrl}
enterKeyHint="go"
onFocusCapture={() => inputRef.current?.select()}
onKeyDown={({ key }) => {
if (inputRef.current && key === "Enter") {
changeUrl(id, inputRef.current.value);
if (currentUrl.current === inputRef.current.value) {
setUrl(inputRef.current.value);
}
window.getSelection()?.removeAllRanges();
inputRef.current.blur();
}
}}
type="text"
/>
</nav>
<nav>
{bookmarks.map(({ name, icon, url: bookmarkUrl }) => (
<Button
key={name}
onClick={({ ctrlKey }) => {
if (ctrlKey) {
open("Browser", { url: bookmarkUrl });
} else {
goToLink(bookmarkUrl);
}
}}
{...label(
`${name}\n${bookmarkUrl
.replace(/^http:\/\//, "")
.replace(/\/$/, "")}`
)}
>
<Icon alt={name} imgSize={16} src={icon} />
</Button>
))}
</nav>
<iframe
ref={iframeRef}
onLoad={() => {
try {
iframeRef.current?.contentWindow?.addEventListener("focus", () =>
setForegroundId(id)
);
} catch {
// Ignore failure to add focus event listener
}
if (loading) setLoading(false);
}}
srcDoc={srcDoc || undefined}
title={id}
{...IFRAME_CONFIG}
/>
</StyledBrowser>
);
};
export default Browser;