Skip to content

Commit

Permalink
feat(rss): add basic message preview
Browse files Browse the repository at this point in the history
  • Loading branch information
ZRunner committed Jan 3, 2025
1 parent 4459bd2 commit 44b0c38
Show file tree
Hide file tree
Showing 7 changed files with 366 additions and 5 deletions.
2 changes: 1 addition & 1 deletion server.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ const defaultSrcPolicy = isProduction ? "default-src https:" : "";
const scriptSrcPolicy = isProduction ? `script-src-elem ${env.PUBLIC_URL} ${env.VITE_API_URL} https://static.cloudflareinsights.com https://zrunner.me` : "";
const styleSrcPolicy = isProduction ? "style-src 'sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU='" : "";
const headers = {
"Content-Security-Policy": `frame-ancestors 'none'; upgrade-insecure-requests; ${defaultSrcPolicy}; ${scriptSrcPolicy}; ${styleSrcPolicy}; img-src 'self' https://cdn.discordapp.com`,
"Content-Security-Policy": `frame-ancestors 'none'; upgrade-insecure-requests; ${defaultSrcPolicy}; ${scriptSrcPolicy}; ${styleSrcPolicy}; img-src https:`,
"Cross-Origin-Embedder-Policy": "credentialless",
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Resource-Policy": "same-site",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ExpandLess, ExpandMore, InfoOutlined, WarningAmberRounded } from "@mui/icons-material";
import { Autocomplete, Collapse, IconButton, Link, Stack, styled, Switch, TextField, Tooltip, Typography } from "@mui/material";
import { Autocomplete, Box, Collapse, IconButton, Link, Stack, styled, Switch, TextField, Tooltip, Typography } from "@mui/material";
import { ChannelType } from "discord-api-types/v10";
import { CSSProperties, PropsWithChildren, useEffect, useRef, useState } from "react";

Expand All @@ -10,6 +10,7 @@ import { GuildChannel } from "../../../../repository/types/guild";
import { ExternalRoutesURLs } from "../../../../router/router";
import ChannelMention from "../../../common/ChannelMention";
import { ReadonlyChannelPicker } from "../../ConfigComponents/shared/TextChannelPicker";
import FeedPreviewButton from "./FeedPreviewButton";
import FeedToggle from "./FeedToggle";
import RssFeedMention from "./RssFeedMention";

Expand All @@ -30,6 +31,7 @@ export default function FeedComponent({ feed, editFeed }: FeedComponentProps) {

const isTwitter = feed.type === "tw";
const isMinecraft = feed.type === "mc";
const canPreview = ["yt", "web"].includes(feed.type);
const displayRecentErrors = !isTwitter && feed.recentErrors >= RECENT_ERRORS_THRESHOLD;

return (
Expand Down Expand Up @@ -65,6 +67,11 @@ export default function FeedComponent({ feed, editFeed }: FeedComponentProps) {
<FeedTextEditor feed={feed} editFeed={editFeed} />
</SimpleParameterColumn>
)}
{canPreview && (
<Box my={2}>
<FeedPreviewButton feed={feed} />
</Box>
)}
</Stack>
</Collapse>
</FeedRowContainer>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { Box, Button, Collapse, Paper, Typography } from "@mui/material";
import { ComponentProps, useState } from "react";
import { Fragment } from "react/jsx-runtime";

import { useLazyTestRssFeedQuery } from "../../../../repository/redux/api/api";
import { RssFeed, RssFeedParsedEntry } from "../../../../repository/types/api";
import DiscordMessagePreview from "../../../common/DiscordMessagePreview";

interface FeedPreviewButtonProps {
feed: RssFeed;
}
export default function FeedPreviewButton({ feed }: FeedPreviewButtonProps) {
const [isOpen, setIsOpen] = useState(false);
const [fetchFeed, { data, isLoading }] = useLazyTestRssFeedQuery();

const handleOpen = async () => {
setIsOpen(true);
if (!data && !isLoading) {
await fetchFeed({ type: feed.type, url: feed.link });
}
};

return (
<Fragment>
<Button color="secondary" variant="outlined" onClick={handleOpen}>
Preview this feed
</Button>
<Collapse in={isOpen}>
<Box>
{isLoading && (<Typography color="textSecondary">Fetching the latest data...</Typography>)}
{(!isLoading && !data) && (<Typography color="error">Oops, something went wrong while fetching your feed.</Typography>)}
{(!isLoading && data) && <FeedPreview feed={feed} data={data} />}
</Box>
</Collapse>
</Fragment>
);
}

function FeedPreview({ feed, data }: { feed: RssFeed; data: RssFeedParsedEntry }) {
const discordMessage = useBuildDiscordMessageFromFeed({ feed, feedData: data });
return (
<Paper elevation={3} sx={{ mt: 2, px: 2, py: 1 }}>
<DiscordMessagePreview {...discordMessage} />
</Paper>
);
}


type DiscordMessageInput = ComponentProps<typeof DiscordMessagePreview>;
function useBuildDiscordMessageFromFeed({ feed, feedData }: { feed: RssFeed; feedData: RssFeedParsedEntry }): DiscordMessageInput {
const msgFormat = feed.structure.replaceAll("\\n", "\n");
const variables = useVariablesDict(feed, feedData);
const text = formatStringPythonLike(msgFormat, variables, feed.useEmbed ? 3900 : 2000);

if (!feed.useEmbed) {
return {
content: text,
};
}

const embed: DiscordMessageInput["embed"] = {
description: text,
color: feed.embed.color || 0x979C9F,
};

if (feed.embed.authorText) {
embed.author = {
name: formatStringPythonLike(feed.embed.authorText || "{author}", variables, 256),
};
}
if (feed.embed.footerText) {
embed.footer = {
text: formatStringPythonLike(feed.embed.footerText, variables, 2048),
};
}
if (feed.embed.showDateInFooter !== false) {
const parsedDate = parseDate(feedData.pubDate);
if (parsedDate !== null) {
embed.timestamp = parsedDate.getTime();
}
}
if (feed.embed.title) {
embed.title = formatStringPythonLike(feed.embed.title, variables, 256);
} else {
embed.title = feedData.title.substring(0, 256);
}
if (feedData.image) {
if (feed.embed.imageLocation === undefined || feed.embed.imageLocation === "thumbnail") {
embed.thumbnail = feedData.image;
} else if (feed.embed.imageLocation === "banner") {
embed.image = feedData.image;
}
}
if (feed.embed.enableLinkInTitle) {
embed.url = feedData.url;
}

return { embed };
}

function useVariablesDict(feed: RssFeed, feedData: RssFeedParsedEntry): Record<string, string> {
const result: Record<string, string> = {
"channel": feedData.channel || "?",
"title": feedData.title,
"url": feedData.url,
"link": feedData.url,
"author": feedData.author || "?",
"logo": "📰",
"full_text": feedData.postText || "",
"description": feedData.postDescription || "",
};
const parsedDate = parseDate(feedData.pubDate);
if (parsedDate !== null) {
const timestamp = Math.round(parsedDate.getTime() / 1000);
result["date"] = `<t:${timestamp}>`;
result["long_date"] = parsedDate.toLocaleString("en-GB", {
weekday: "long", year: "numeric", month: "2-digit", day: "numeric",
hour: "numeric", minute: "numeric",
});
result["timestamp"] = timestamp.toString();
} else {
result["date"] = feedData.pubDate;
result["long_date"] = feedData.pubDate;
result["timestamp"] = "";
}
result["mentions"] = feed.roles.map((role) => `<@&${role}>`).join(", ");

return result;
}

function formatStringPythonLike(str: string, variables: Record<string, string>, maxLength: number) {
return str.replace(/{([^}]+)}/gi, (_, key) => variables[key] || "").substring(0, maxLength);
}

function parseDate(isoString: string) {
const parsedDate = new Date(isoString);
if (parsedDate.toString() === "Invalid Date") {
return null;
}
return parsedDate;
}
192 changes: 192 additions & 0 deletions src/components/common/DiscordMessagePreview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import { Box, createTheme, Stack, styled, Theme, ThemeProvider, Typography } from "@mui/material";
import { useMemo } from "react";

interface DiscordMessagePreviewProps {
content?: string;
timestamp?: string;
embed?: {
title?: string;
description?: string;
url?: string;
timestamp?: number;
color: number;
footer?: {
text?: string;
iconUrl?: string;
};
image?: string;
thumbnail?: string;
author?: {
name?: string;
url?: string;
iconUrl?: string;
};
};
}

export default function DiscordMessagePreview(props: DiscordMessagePreviewProps) {
const overrideTheme = (outerTheme: Theme) => createTheme({
...outerTheme,
typography: {
...outerTheme.typography,
body1: {
...outerTheme.typography.body1,
fontFamily: "\"Helvetica Neue\", Helvetica, Arial, sans-serif",
},
},
});

return (
<ThemeProvider theme={overrideTheme}>
<Stack direction="column" position="relative" py="0.125rem" pl="72px" pr="48px" minHeight="2.75rem">
<MessageAuthorAndContent {...props} />
{props.embed && <MessageEmbed embed={props.embed} />}
</Stack>
</ThemeProvider>
);
}

function MessageAuthorAndContent(props: Pick<DiscordMessagePreviewProps, "content" | "timestamp">) {
return (
<Box>
<img
src="/assets/logo96.webp"
srcSet="/assets/logo96.webp 96w, /assets/logo64.webp 64w, /assets/logo128.webp 128w"
alt="Axobot avatar"
style={{
userSelect: "none",
position: "absolute",
left: "16px",
marginTop: "calc(4px - 0.125rem)",
width: "40px",
height: "40px",
borderRadius: "50%",
}}
/>
<Typography variant="h3" fontSize="1rem" lineHeight="1.375rem">
<MessageAuthorAndTimestamp timestamp={props.timestamp} />
</Typography>
<Typography component="span" whiteSpace="pre-wrap" color="#dbdee1">
{props.content?.trim()}
</Typography>
</Box>
);
}

function MessageAuthorAndTimestamp(props: Pick<DiscordMessagePreviewProps, "timestamp">) {
return (
<Typography component="span">
<Typography component="span" fontWeight={500} mr=".25rem" lineHeight="1.375rem">
Axobot
</Typography>
<Typography component="span" display="inline-block" ml=".25rem" fontSize=".75rem" height="1.25rem" color="#949ba4" sx={{ verticalAlign: "baseline" }}>
{props.timestamp ?? "In the near future"}
</Typography>
</Typography>
);
}

function MessageEmbed({ embed }: { embed: Exclude<DiscordMessagePreviewProps["embed"], undefined> }) {
const hexEmbedColor = "#" + embed.color.toString(16).padStart(6, "0");

const timestamp = useMemo(() => {
if (!embed.timestamp) return "";
return formatRelativeTimestamp(embed.timestamp);
}, [embed.timestamp]);

return (
<Box display="grid" maxWidth="516px" my=".125rem" borderRadius="4px" borderLeft={`4px solid ${hexEmbedColor}`} bgcolor="#2b2d31">
<Box sx={{
overflow: "hidden",
padding: ".5rem 1rem 1rem .75rem",
display: "grid",
gridTemplateColumns: "auto",
gridTemplateRows: embed.thumbnail ? "auto min-content" : "auto",
}}
>
{embed.author?.name && (
<Stack direction="row" mt="8px" alignItems="center" spacing={1} gridColumn="1 / 1">
{embed.author?.iconUrl && (
<img style={{ width: "24px", height: "24px", borderRadius: "50%" }} alt="Author icon" src={embed.author.iconUrl}></img>
)}
<Typography component="span" lineHeight="1.375rem" fontSize="0.875rem" fontWeight={600}>
{embed.author.url
? (
<DiscordLink color="#f2f3f5" href={embed.author.url}>{embed.author.name}</DiscordLink>
)
: (
embed.author.name
)}
</Typography>
</Stack>
)}
{embed.title && (
<Typography component="div" mt="8px" lineHeight="1.375rem" fontWeight={700} color="#f2f3f5" gridColumn="1 / 1">
{embed.url
? (
<DiscordLink href={embed.url}>{embed.title}</DiscordLink>
)
: (
embed.title
)}
</Typography>
)}
{embed.description && (
<Typography component="div" mt="8px" fontSize="0.875rem" lineHeight="1.125rem" fontWeight={400} color="#dbdee1" whiteSpace="pre-wrap" gridColumn="1 / 1">
{embed.description.trim()}
</Typography>
)}
{embed.image && (
<Box mt="16px" maxWidth="400px" maxHeight="225px">
<img style={{ userSelect: "none", maxWidth: "100%", maxHeight: "100%", borderRadius: "4px" }} alt="Image" src={embed.image}></img>
</Box>
)}
{(embed.footer || embed.timestamp) && (
<Typography component="span" mt="8px" fontSize="0.75rem" lineHeight="1rem" fontWeight={500} color="#dbdee1" gridColumn="1 / 1">
{embed.footer?.text}
{embed.footer && timestamp && " • "}
{timestamp}
</Typography>
)}
{embed.thumbnail && (
<Box gridRow="1 / 8" gridColumn="2 / 2" ml="16px" mt="8px" flexShrink={0} justifySelf="end">
<img style={{ userSelect: "none", maxWidth: "80px", maxHeight: "80px", borderRadius: "4px" }} alt="Thumbnail" src={embed.thumbnail}></img>
</Box>
)}
</Box>
</Box>
);
}

function formatRelativeTimestamp(timestamp: number) {
const diff = Math.round((timestamp - Date.now()));
const units: Record<string, number> = {
day: 24 * 60 * 60 * 1000,
hour: 60 * 60 * 1000,
minute: 60 * 1000,
second: 1000,
};

if (Math.abs(diff) <= units.day) {
const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
for (const unitName in units) {
if (Math.abs(diff) > units[unitName] || unitName === "second") {
return rtf.format(Math.round(diff / units[unitName]), unitName as Intl.RelativeTimeFormatUnit);
}
}
}

return new Date(timestamp).toLocaleString("en-GB", {
year: "numeric", month: "numeric", day: "numeric",
hour: "numeric", minute: "numeric",
});
}

const DiscordLink = styled("a")(({ color }) => ({
color: color || "#00aafc",
textDecoration: "none",
"&:hover": {
textDecoration: "underline",
},
}));

Loading

0 comments on commit 44b0c38

Please sign in to comment.