diff --git a/src/Content/Poll/Answer.tsx b/src/Content/Poll/Answer.tsx
new file mode 100644
index 0000000..44bfccd
--- /dev/null
+++ b/src/Content/Poll/Answer.tsx
@@ -0,0 +1,43 @@
+import React from "react";
+import type { APIPollAnswer } from "discord-api-types/v10";
+import * as Styles from "./style";
+import { getEmojiUrl } from "../../utils/getEmojiUrl";
+import { t } from "i18next";
+
+interface AnswerProps {
+ answer: APIPollAnswer;
+ votes: number;
+ percentage: number;
+}
+
+export function Answer({ answer, votes, percentage }: AnswerProps) {
+ return (
+
+
+ {answer.poll_media.emoji && (
+
+ )}
+ {answer.poll_media.text}
+
+ {t("polls.n_votes", { count: votes })}
+
+
+ {t("polls.vote_percentage", { percentage })}
+
+
+ );
+}
diff --git a/src/Content/Poll/index.tsx b/src/Content/Poll/index.tsx
new file mode 100644
index 0000000..a56d523
--- /dev/null
+++ b/src/Content/Poll/index.tsx
@@ -0,0 +1,57 @@
+import React, { useMemo } from "react";
+import type { APIPoll } from "discord-api-types/v10";
+import * as Styles from "./style";
+import { Answer } from "./Answer";
+import { t } from "i18next";
+
+interface PollProps {
+ poll: APIPoll;
+}
+
+export function Poll({ poll }: PollProps) {
+ const timeLeft =
+ (new Date(poll.expiry).getTime() - new Date().getTime()) / 1000;
+
+ const isClosed = timeLeft <= 0;
+
+ const nVotes = useMemo(() => {
+ if (!poll.results?.answer_counts) return 0;
+
+ return poll.results.answer_counts.reduce(
+ (acc, { count }) => acc + count,
+ 0
+ );
+ }, [poll.results?.answer_counts]);
+
+ const voteCountsPerAnswer = poll.results?.answer_counts
+ .map((answer) => ({
+ [answer.id]: {
+ count: answer.count,
+ percentage: Math.floor((answer.count / nVotes) * 100),
+ },
+ }))
+ .reduce((acc, answer) => ({ ...acc, ...answer }), {});
+
+ return (
+
+ {poll.question.text}
+
+ {poll.answers.map((answer) => (
+
+ ))}
+
+
+ {t("polls.n_votes", { count: nVotes })}
+ •
+ {isClosed ? t("polls.closed") : t("polls.time_left", { timeLeft })}
+
+
+ );
+}
diff --git a/src/Content/Poll/style.ts b/src/Content/Poll/style.ts
new file mode 100644
index 0000000..928209d
--- /dev/null
+++ b/src/Content/Poll/style.ts
@@ -0,0 +1,126 @@
+import {
+ commonComponentId,
+ styled,
+ theme,
+} from "../../Stitches/stitches.config";
+import Emoji from "../../Emoji";
+
+export const Poll = styled.withConfig({
+ displayName: "poll",
+ componentId: commonComponentId,
+})("div", {
+ padding: theme.space.xxl,
+ backgroundColor: theme.colors.backgroundSecondary,
+ borderRadius: theme.radii.sm,
+ maxWidth: 440,
+ minWidth: 270,
+ width: "100%",
+ boxSizing: "border-box",
+});
+
+export const Name = styled.withConfig({
+ displayName: "name",
+ componentId: commonComponentId,
+})("span", {
+ color: theme.colors.textNormal,
+ fontSize: theme.fontSizes.l,
+ fontWeight: 500,
+});
+
+export const Answers = styled.withConfig({
+ displayName: "answers",
+ componentId: commonComponentId,
+})("div", {
+ display: "flex",
+ flexDirection: "column",
+ gap: theme.space.large,
+ marginTop: theme.space.large,
+ marginBottom: theme.space.xxl,
+});
+
+export const Answer = styled.withConfig({
+ displayName: "answer",
+ componentId: commonComponentId,
+})("div", {
+ padding: `${theme.space.large} ${theme.space.xxl}`,
+ borderRadius: theme.radii.sm,
+ backgroundColor: theme.colors.pollBackground,
+ display: "flex",
+ gap: theme.space.large,
+ flex: "1 0 auto",
+ minHeight: 50,
+ boxSizing: "border-box",
+ alignItems: "center",
+ position: "relative",
+ overflow: "hidden",
+});
+
+const ANSWER_BAR_Z_INDEX = 1;
+const ANSWER_CONTENTS_Z_INDEX = ANSWER_BAR_Z_INDEX + 1;
+
+export const AnswerBar = styled.withConfig({
+ displayName: "answer-bar",
+ componentId: commonComponentId,
+})("div", {
+ position: "absolute",
+ top: 0,
+ bottom: 0,
+ left: 0,
+ backgroundColor: theme.colors.backgroundModifier,
+ zIndex: ANSWER_BAR_Z_INDEX,
+});
+
+export const AnswerName = styled.withConfig({
+ displayName: "answer-name",
+ componentId: commonComponentId,
+})("span", {
+ color: theme.colors.textNormal,
+ fontSize: theme.fontSizes.m,
+ fontWeight: 600,
+ marginRight: "auto",
+ zIndex: ANSWER_CONTENTS_Z_INDEX,
+});
+
+export const AnswerVotes = styled.withConfig({
+ displayName: "answer-votes",
+ componentId: commonComponentId,
+})("span", {
+ color: theme.colors.primaryOpacity100,
+ fontWeight: 600,
+ fontSize: theme.fontSizes.s,
+ zIndex: ANSWER_CONTENTS_Z_INDEX,
+});
+
+export const AnswerPercentage = styled.withConfig({
+ displayName: "answer-percentage",
+ componentId: commonComponentId,
+})("span", {
+ color: theme.colors.textNormal,
+ fontWeight: 600,
+ fontSize: theme.fontSizes.l,
+ zIndex: ANSWER_CONTENTS_Z_INDEX,
+});
+
+export const AnswerEmoji = styled.withConfig({
+ displayName: "answer-emoji",
+ componentId: commonComponentId,
+})(Emoji, {
+ width: 24,
+ height: 24,
+ zIndex: ANSWER_CONTENTS_Z_INDEX,
+});
+
+export const Footer = styled.withConfig({
+ displayName: "footer",
+ componentId: commonComponentId,
+})("div", {
+ fontSize: theme.fontSizes.m,
+ color: theme.colors.textMuted,
+});
+
+export const FooterSeparator = styled.withConfig({
+ displayName: "footer-separator",
+ componentId: commonComponentId,
+})("span", {
+ margin: `0 ${theme.space.medium}`,
+});
diff --git a/src/Content/index.tsx b/src/Content/index.tsx
index 0300576..21e8175 100644
--- a/src/Content/index.tsx
+++ b/src/Content/index.tsx
@@ -17,6 +17,7 @@ import Components from "../Message/Components";
import getDisplayName from "../utils/getDisplayName";
import { useTranslation } from "react-i18next";
import type { ChatMessage } from "../types";
+import { Poll } from "./Poll";
interface EditedProps {
editedAt: string;
@@ -245,9 +246,11 @@ function Content(props: ContentProps) {
(props.message.sticker_items?.length ?? 0) > 0 ||
props.message.thread !== undefined ||
props.message.embeds?.length > 0 ||
- (props.message.components?.length ?? 0) > 0
+ (props.message.components?.length ?? 0) > 0 ||
+ props.message.poll !== undefined
}
>
+ {props.message.poll && }
{props.message.attachments.map((attachment) => (
))}
diff --git a/src/Message/Components/ButtonComponent.tsx b/src/Message/Components/ButtonComponent.tsx
index 35e15f7..b15ae64 100644
--- a/src/Message/Components/ButtonComponent.tsx
+++ b/src/Message/Components/ButtonComponent.tsx
@@ -11,6 +11,7 @@ import Emoji from "../../Emoji";
import { useConfig } from "../../core/ConfigContext";
import ExternalLink from "../../ExternalLink";
import type { ChatMessage } from "../../types";
+import { getEmojiUrl } from "../../utils/getEmojiUrl";
const buttonStyleMap: Record<
ButtonStyle,
@@ -52,9 +53,10 @@ function ButtonComponent({ button, message }: ButtonComponentProps) {
emojiName={button.emoji.name}
src={
button.emoji.id &&
- `https://cdn.discordapp.com/emojis/${button.emoji.id}.${
- button.emoji.animated ? "gif" : "png"
- }`
+ getEmojiUrl({
+ id: button.emoji.id,
+ animated: button.emoji.animated ?? false,
+ })
}
/>
)}
@@ -76,9 +78,10 @@ function ButtonComponent({ button, message }: ButtonComponentProps) {
emojiName={button.emoji.name}
src={
button.emoji.id &&
- `https://cdn.discordapp.com/emojis/${button.emoji.id}.${
- button.emoji.animated ? "gif" : "png"
- }`
+ getEmojiUrl({
+ id: button.emoji.id,
+ animated: button.emoji.animated ?? false,
+ })
}
/>
)}
diff --git a/src/Stitches/stitches.config.tsx b/src/Stitches/stitches.config.tsx
index 259bbe3..288a048 100644
--- a/src/Stitches/stitches.config.tsx
+++ b/src/Stitches/stitches.config.tsx
@@ -16,6 +16,7 @@ const stitches = createStitches({
primaryDark: "#72767d",
systemMessageDark: "#999999",
textMuted: "rgb(163, 166, 170)",
+ textNormal: "rgb(219, 222, 225)",
interactiveNormal: "#dcddde",
accent: "#5865f2",
background: "#36393f",
@@ -49,6 +50,8 @@ const stitches = createStitches({
automodMatchedWord: "rgba(240, 177, 50, 0.3)",
automodMessageBackground: "rgb(43, 45, 49)",
automodDot: "rgba(78, 80, 88, 0.48)",
+ pollBackground: "rgba(78, 80, 88, 0.3)",
+ backgroundModifier: "rgba(77, 80, 88, 0.48)",
},
fonts: {
main: "Open Sans, sans-serif",
@@ -78,6 +81,9 @@ const stitches = createStitches({
borderWidths: {
spines: "2px",
},
+ radii: {
+ sm: "8px",
+ },
},
});
diff --git a/src/i18n/index.ts b/src/i18n/index.ts
index c887280..f0dba42 100644
--- a/src/i18n/index.ts
+++ b/src/i18n/index.ts
@@ -8,6 +8,46 @@ const resources = {
},
};
+function durationFormatter(
+ isShort: boolean,
+ value: unknown,
+ lng: unknown,
+ options: unknown
+) {
+ const numericValue = Number(value);
+ const key = isShort ? "duration_short" : "duration";
+
+ if (numericValue < 60)
+ return i18next.t(`${key}.seconds`, { count: numericValue }, options);
+
+ if (numericValue < 3600)
+ return i18next.t(
+ `${key}.minutes`,
+ { count: Math.floor(numericValue / 60) },
+ options
+ );
+
+ if (numericValue < 86400)
+ return i18next.t(
+ `${key}.hours`,
+ { count: Math.floor(numericValue / 3600) },
+ options
+ );
+
+ if (numericValue < 604800)
+ return i18next.t(
+ `${key}.days`,
+ { count: Math.floor(numericValue / 86400) },
+ options
+ );
+
+ return i18next.t(
+ `${key}.weeks`,
+ { count: Math.floor(numericValue / 604800) },
+ options
+ );
+}
+
void i18next
.use(initReactI18next)
.init({
@@ -20,37 +60,11 @@ void i18next
.then(console.log);
if (i18next.services.formatter) {
- i18next.services.formatter.add("duration", (value, lng, options) => {
- const numericValue = Number(value);
-
- if (numericValue < 60)
- return i18next.t("duration.seconds", { count: numericValue }, options);
-
- if (numericValue < 3600)
- return i18next.t(
- "duration.minutes",
- { count: Math.floor(numericValue / 60) },
- options
- );
-
- if (numericValue < 86400)
- return i18next.t(
- "duration.hours",
- { count: Math.floor(numericValue / 3600) },
- options
- );
-
- if (numericValue < 604800)
- return i18next.t(
- "duration.days",
- { count: Math.floor(numericValue / 86400) },
- options
- );
+ i18next.services.formatter.add("duration_short", (value, lng, options) =>
+ durationFormatter(true, value, lng, options)
+ );
- return i18next.t(
- "duration.weeks",
- { count: Math.floor(numericValue / 604800) },
- options
- );
- });
+ i18next.services.formatter.add("duration", (value, lng, options) =>
+ durationFormatter(false, value, lng, options)
+ );
}
diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json
index 879d2ea..5f92545 100644
--- a/src/i18n/locales/en/translation.json
+++ b/src/i18n/locales/en/translation.json
@@ -99,6 +99,13 @@
"reason": "Reason: {{reason}}"
}
},
+ "polls": {
+ "n_votes_one": "1 vote",
+ "n_votes_other": "{{count}} votes",
+ "time_left": "{{timeLeft, duration_short}} left",
+ "closed": "Poll closed",
+ "vote_percentage": "{{percentage}}%"
+ },
"duration": {
"seconds_one": "1 sec",
"seconds_other": "{{count}} secs",
@@ -110,5 +117,12 @@
"days_other": "{{count}} days",
"weeks_one": "1 week",
"weeks_other": "{{count}} weeks"
+ },
+ "duration_short": {
+ "seconds": "{{count}}s",
+ "minutes": "{{count}}m",
+ "hours": "{{count}}h",
+ "days": "{{count}}d",
+ "weeks": "{{count}}w"
}
}
diff --git a/src/stories/Normal.stories.tsx b/src/stories/Normal.stories.tsx
index 75dba3c..7a9d158 100644
--- a/src/stories/Normal.stories.tsx
+++ b/src/stories/Normal.stories.tsx
@@ -85,6 +85,77 @@ Basic.args = {
],
};
+export const Poll: StoryFn = Template.bind({});
+Poll.args = {
+ messages: [
+ {
+ type: 0,
+ channel_id: "859165227983568946",
+ content: "",
+ attachments: [],
+ embeds: [],
+ timestamp: "2024-05-17T11:31:43.796000+00:00",
+ edited_timestamp: null,
+ flags: 0,
+ components: [],
+ id: "1240990131351982191",
+ author: {
+ id: "132819036282159104",
+ username: "johnythecarrot",
+ avatar: "a_8eccef95181a9e5de97a5382452412ec",
+ discriminator: "0",
+ public_flags: 4457220,
+ flags: 4457220,
+ banner: "c060758efa0f2af537c74dfeb5dfadd8",
+ accent_color: null,
+ global_name: "JohnyTheCarrot",
+ },
+ mentions: [],
+ mention_roles: [],
+ pinned: false,
+ mention_everyone: false,
+ tts: false,
+ nonce: "1240990128596320256",
+ position: 0,
+ poll: {
+ question: {
+ text: "binger?",
+ },
+ answers: [
+ {
+ answer_id: 1,
+ poll_media: {
+ text: "binger",
+ emoji: {
+ id: null,
+ name: "✅",
+ },
+ },
+ },
+ {
+ answer_id: 2,
+ poll_media: {
+ text: "no binger :(",
+ emoji: {
+ id: null,
+ name: "❌",
+ },
+ },
+ },
+ ],
+ expiry: "2024-05-18T11:31:43.783059+00:00",
+ allow_multiselect: false,
+ layout_type: 1,
+ results: {
+ answer_counts: [],
+ is_finalized: false,
+ },
+ },
+ referenced_message: null,
+ },
+ ],
+};
+
export const Optimistic: StoryFn = Template.bind({});
Optimistic.args = {
messages: [
diff --git a/src/utils/getEmojiUrl.ts b/src/utils/getEmojiUrl.ts
new file mode 100644
index 0000000..779a0eb
--- /dev/null
+++ b/src/utils/getEmojiUrl.ts
@@ -0,0 +1,8 @@
+interface EmojiInfo {
+ id: string;
+ animated: boolean;
+}
+
+export function getEmojiUrl({ id, animated }: EmojiInfo): string {
+ return `https://cdn.discordapp.com/emojis/${id}.${animated ? "gif" : "png"}`;
+}