From 75ffc016a5e4110aabb98e4ef1ba71722c40ae5a Mon Sep 17 00:00:00 2001 From: JohnyTheCarrot Date: Fri, 17 May 2024 13:36:51 +0200 Subject: [PATCH] feat: add poll support --- src/Content/Poll/Answer.tsx | 43 +++++++ src/Content/Poll/index.tsx | 57 ++++++++++ src/Content/Poll/style.ts | 126 +++++++++++++++++++++ src/Content/index.tsx | 5 +- src/Message/Components/ButtonComponent.tsx | 15 ++- src/Stitches/stitches.config.tsx | 6 + src/i18n/index.ts | 78 +++++++------ src/i18n/locales/en/translation.json | 14 +++ src/stories/Normal.stories.tsx | 71 ++++++++++++ src/utils/getEmojiUrl.ts | 8 ++ 10 files changed, 384 insertions(+), 39 deletions(-) create mode 100644 src/Content/Poll/Answer.tsx create mode 100644 src/Content/Poll/index.tsx create mode 100644 src/Content/Poll/style.ts create mode 100644 src/utils/getEmojiUrl.ts 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"}`; +}