From 77c876ff0c6348ffd2aba56eaef6d101edf7f0d8 Mon Sep 17 00:00:00 2001 From: Cma <33190610+cma2819@users.noreply.github.com> Date: Fri, 20 Dec 2024 02:04:57 +0900 Subject: [PATCH] Dev for w2024 (#2) * feat: Confirmation dialog * feat: Disable submit on empty content * feat: Quotes * feat: Limit file number * feat: Template --- app/api/bluesky.server.ts | 72 ++++++-- app/api/runs.server.ts | 36 ++++ app/api/twitter.server.ts | 22 ++- app/env.server.ts | 2 + app/i18next/translation/en.json | 14 +- app/i18next/translation/ja.json | 14 +- app/routes/_index/quote-store.ts | 15 ++ app/routes/_index/route.tsx | 28 ++- app/routes/_index/templates.tsx | 66 +++++++ app/routes/_index/tweet-form.tsx | 290 ++++++++++++++++++++++++++++++- app/routes/_index/tweet-list.tsx | 12 ++ 11 files changed, 543 insertions(+), 28 deletions(-) create mode 100644 app/api/runs.server.ts create mode 100644 app/routes/_index/quote-store.ts create mode 100644 app/routes/_index/templates.tsx diff --git a/app/api/bluesky.server.ts b/app/api/bluesky.server.ts index 4afc3a2..1a7cab7 100644 --- a/app/api/bluesky.server.ts +++ b/app/api/bluesky.server.ts @@ -1,11 +1,14 @@ -import { BskyAgent } from "@atproto/api"; +import { BlobRef, BskyAgent } from "@atproto/api"; import { env } from "../env.server"; import fs from "node:fs/promises"; import path from "node:path"; import sharp from "sharp"; import { tmpDir } from "../tmp-dir.server"; import { prisma } from "../prisma.server"; -import { isThreadViewPost } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; +import { + isThreadViewPost, + type PostView, +} from "@atproto/api/dist/client/types/app/bsky/feed/defs"; let blueskyEnabled = false; export const getBlueskyEnabled = () => blueskyEnabled; @@ -82,18 +85,71 @@ const uploadFile = async (filePath: string) => { return res.data.blob; }; -export const post = async (text: string, files: string[], replyTo?: string) => { +const makeEmbed = (uploads: BlobRef[], quote: PostView | null) => { + if (quote && uploads.length > 0) { + return { + $type: "app.bsky.embed.recordWithMedia", + media: { + $type: "app.bsky.embed.images", + images: uploads.map((result) => ({ + image: result, + alt: "", // TODO: allow setting alt text + })), + }, + record: { + $type: "app.bsky.embed.record", + record: { + uri: quote.uri, + cid: quote.cid, + }, + }, + }; + } + + if (quote && uploads.length === 0) { + return { + $type: "app.bsky.embed.record", + record: { + uri: quote.uri, + cid: quote.cid, + }, + }; + } + + return { + $type: "app.bsky.embed.images", + images: uploads.map((result) => ({ + image: result, + alt: "", // TODO: allow setting alt text + })), + }; +}; + +export const post = async ( + text: string, + files: string[], + replyTo?: string, + quotePostId?: string +) => { const replyPostThreadData = replyTo ? await agent.getPostThread({ uri: replyTo }) : null; // console.log(JSON.stringify(replyPost?.data, null, 2)); // throw new Error("tmp!!!"); - const replyPost = replyPostThreadData && isThreadViewPost(replyPostThreadData.data.thread) ? replyPostThreadData.data.thread.post : null; + const quotePostThreadData = quotePostId + ? await agent.getPostThread({ uri: quotePostId }) + : null; + + const quotePost = + quotePostThreadData && isThreadViewPost(quotePostThreadData.data.thread) + ? quotePostThreadData.data.thread.post + : null; + const replyPostRecord = replyPost?.record as | { reply?: { root?: { uri: string; cid: string } } } | undefined; @@ -102,13 +158,7 @@ export const post = async (text: string, files: string[], replyTo?: string) => { const uploadResults = await Promise.all(files.map(uploadFile)); await agent.post({ text, - embed: { - $type: "app.bsky.embed.images", - images: uploadResults.map((result) => ({ - image: result, - alt: "", // TODO: allow setting alt text - })), - }, + embed: makeEmbed(uploadResults, quotePost), reply: replyPost && rootPost ? { diff --git a/app/api/runs.server.ts b/app/api/runs.server.ts new file mode 100644 index 0000000..dcb8113 --- /dev/null +++ b/app/api/runs.server.ts @@ -0,0 +1,36 @@ +import { env } from "../env.server"; + +export interface Run { + id: number; + gamename: string; + category: string; + runner: { + username: string; + twitterid: string; + }[]; + commentary: { + username: string; + twitterid: string; + }[]; +} + +export const getRuns = async (): Promise => { + const apiUrl = env.RUNDATA_API_URL; + if (!apiUrl) { + return []; + } + + try { + const response = await fetch(apiUrl); + + if (!response.ok) { + console.error("Failed to load runs"); + return []; + } + const json = (await response.json()) as { data: Run[] }; + return json.data; + } catch (e) { + console.error(e); + return []; + } +}; diff --git a/app/api/twitter.server.ts b/app/api/twitter.server.ts index ecd13f9..0e070d0 100644 --- a/app/api/twitter.server.ts +++ b/app/api/twitter.server.ts @@ -219,15 +219,23 @@ export const getTweets = async () => { } }; -export const tweet = async (text: string, files: string[]) => { +export const tweet = async ( + text: string, + files: string[], + quoteTweetId?: string +) => { const page = await newPage("https://x.com/"); + const fixedText = ( + quoteTweetId + ? `${text}\n\nhttps://x.com/${username}/status/${quoteTweetId}` + : text + ).replace(/\r\n|\r/g, "\n"); try { const input = await page.waitForSelector(textAreaSelector); if (!input) { throw new Error("No tweet input label"); } await input.click({ count: 3 }); - const fixedText = text.replace(/\r\n|\r/g, "\n"); await input.type(fixedText); if (files.length >= 1) { @@ -300,9 +308,15 @@ export const deleteTweet = async (tweetId: string) => { export const sendReply = async ( tweetId: string, text: string, - files: string[] + files: string[], + quoteTweetId?: string ) => { const page = await newPage(`https://x.com/${username}/status/${tweetId}`); + const fixedText = ( + quoteTweetId + ? `${text}\n\nhttps://x.com/${username}/status/${quoteTweetId}` + : text + ).replace(/\r\n|\r/g, "\n"); try { const replyButton = await page.waitForSelector( "button[data-testid=reply]:not([aria-disabled=true])" @@ -326,7 +340,7 @@ export const sendReply = async ( throw new Error("No tweet input label"); } await label.click({ count: 3 }); - await label.type(text); + await label.type(fixedText); const tweetButton = await page.waitForSelector( 'button[data-testid="tweetButton"]:not([aria-disabled="true"])' ); diff --git a/app/env.server.ts b/app/env.server.ts index d85391b..3eda4a4 100644 --- a/app/env.server.ts +++ b/app/env.server.ts @@ -19,6 +19,8 @@ const envSchema = z.object({ BLUESKY_USERNAME: z.string().optional(), BLUESKY_PASSWORD: z.string().optional(), + + RUNDATA_API_URL: z.string().optional(), }); export const env = envSchema.parse(process.env); diff --git a/app/i18next/translation/en.json b/app/i18next/translation/en.json index f187409..a946e96 100644 --- a/app/i18next/translation/en.json +++ b/app/i18next/translation/en.json @@ -9,5 +9,17 @@ "signOut": "Sign out", "delete": "Delete", "reply": "Reply", - "signInWithDiscord": "Sign in with Discord" + "signInWithDiscord": "Sign in with Discord", + "confirmTweetQuestion": "Confirm tweet content", + "cancel": "Cancel", + "quote": "Quote", + "apply": "Apply", + "template": "Template", + "templateTitle": "Select template", + "template-setup": "Next game", + "template-finishWithTime": "Result with finished time", + "template-finishWithoutTime": "Result without finished time", + "template-finishOnlyTime": "Result", + "template-running": "During run", + "template-backup": "Add backup run" } diff --git a/app/i18next/translation/ja.json b/app/i18next/translation/ja.json index ae2d021..4c445c4 100644 --- a/app/i18next/translation/ja.json +++ b/app/i18next/translation/ja.json @@ -9,5 +9,17 @@ "signOut": "ログアウト", "delete": "削除", "reply": "リプライ", - "signInWithDiscord": "Discordでログイン" + "signInWithDiscord": "Discordでログイン", + "confirmTweetQuestion": "以下のツイートを送信します。よろしいですか?", + "cancel": "キャンセル", + "quote": "引用", + "apply": "反映", + "template": "テンプレート", + "templateTitle": "テンプレート選択", + "template-setup": "次のゲーム", + "template-finishWithTime": "ゲーム終了 クリアタイム有", + "template-finishWithoutTime": "ゲーム終了 クリアタイム無", + "template-finishOnlyTime": "ゲーム終了 クリアタイムだけ別でツイート", + "template-running": "ゲーム中", + "template-backup": "バックアップ挿入" } diff --git a/app/routes/_index/quote-store.ts b/app/routes/_index/quote-store.ts new file mode 100644 index 0000000..63dcc2f --- /dev/null +++ b/app/routes/_index/quote-store.ts @@ -0,0 +1,15 @@ +import { create } from "zustand"; + +export const useQuoteStore = create<{ + twitterId?: string; + blueskyId?: string; + setQuote: (args: { twitterId?: string; blueskyId?: string }) => void; + clearQuote: () => void; +}>((set) => ({ + setQuote: ({ twitterId, blueskyId }) => { + set({ twitterId, blueskyId }); + }, + clearQuote: () => { + set({ twitterId: undefined, blueskyId: undefined }); + }, +})); diff --git a/app/routes/_index/route.tsx b/app/routes/_index/route.tsx index 76f3f81..5744d2e 100644 --- a/app/routes/_index/route.tsx +++ b/app/routes/_index/route.tsx @@ -22,6 +22,7 @@ import { useTranslation } from "react-i18next"; import { SignOutButton } from "./sign-out-button"; import { getBlueskyEnabled, post } from "../../api/bluesky.server"; import fs from "node:fs/promises"; +import { getRuns } from "../../api/runs.server"; interface Post { twitterId?: string; @@ -76,11 +77,14 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { posts.sort((a, b) => b.postedAt.getTime() - a.postedAt.getTime()); + const rundata = await getRuns(); + return json({ session, posts, twitterUsername: env.TWITTER_USERNAME, blueskyUsername: env.BLUESKY_USERNAME, + runs: rundata, }); }; @@ -131,6 +135,8 @@ const actionSchema = zfd.formData({ service: zfd.repeatableOfType(zfd.text(z.enum(["twitter", "bluesky"]))), replyTwitterId: zfd.text(z.string().optional()), replyBlueskyId: zfd.text(z.string().optional()), + quoteTwitterId: zfd.text(z.string().optional()), + quoteBlueskyId: zfd.text(z.string().optional()), }); export const action = async ({ request }: ActionFunctionArgs) => { @@ -148,8 +154,14 @@ export const action = async ({ request }: ActionFunctionArgs) => { ) ); - const { text, service, replyTwitterId, replyBlueskyId } = - actionSchema.parse(formData); + const { + text, + service, + replyTwitterId, + replyBlueskyId, + quoteTwitterId, + quoteBlueskyId, + } = actionSchema.parse(formData); const postOnTwitter = service.includes("twitter"); const postOnBluesky = service.includes("bluesky"); @@ -170,16 +182,20 @@ export const action = async ({ request }: ActionFunctionArgs) => { postOnTwitter && replyTwitterId && getTwitterEnabled() && - sendReply(replyTwitterId, text ?? "", filePaths), + sendReply(replyTwitterId, text ?? "", filePaths, quoteTwitterId), postOnBluesky && replyBlueskyId && getBlueskyEnabled() && - post(text ?? "", filePaths, replyBlueskyId), + post(text ?? "", filePaths, replyBlueskyId, quoteBlueskyId), ]); } else { await Promise.all([ - postOnTwitter && getTwitterEnabled() && tweet(text ?? "", filePaths), - postOnBluesky && getBlueskyEnabled() && post(text ?? "", filePaths), + postOnTwitter && + getTwitterEnabled() && + tweet(text ?? "", filePaths, quoteTwitterId), + postOnBluesky && + getBlueskyEnabled() && + post(text ?? "", filePaths, undefined, quoteBlueskyId), ]); } diff --git a/app/routes/_index/templates.tsx b/app/routes/_index/templates.tsx new file mode 100644 index 0000000..e8f602a --- /dev/null +++ b/app/routes/_index/templates.tsx @@ -0,0 +1,66 @@ +import type { Run } from "../../api/runs.server"; + +interface Template { + label: + | "setup" + | "finishWithTime" + | "finishWithoutTime" + | "finishOnlyTime" + | "running" + | "backup"; + apply: (run: Run) => string; +} + +const presentRunners = (runners: Run["runner"]) => { + return runners + .map((runner) => { + const name = runner.username.replaceAll("@", "@ "); + return `${name}さん(@${runner.twitterid})`; + }) + .join("、"); +}; + +export const templates: Template[] = [ + { + label: "setup", + apply: (run) => + `次のタイムアタックは『${run.gamename}』\nカテゴリーは「${ + run.category + }」\n走者は${presentRunners( + run.runner + )}です。\n\n配信はこちらから⇒https://www.twitch.tv/rtainjapan\n#RTAinJapan`, + }, + { + label: "finishWithTime", + apply: (run) => + `『${run.gamename}』のカテゴリー「${ + run.category + }」RTA\n走者の${presentRunners( + run.runner + )}お疲れさまでした。\nクリアタイムはxx:xx:xxでした。\n\n配信はこちらから⇒https://www.twitch.tv/rtainjapan\n#RTAinJapan`, + }, + { + label: "finishWithoutTime", + apply: (run) => + `『${run.gamename}』のカテゴリー「${ + run.category + }」RTA\n走者の${presentRunners( + run.runner + )}お疲れさまでした。\n\n配信はこちらから⇒https://www.twitch.tv/rtainjapan\n#RTAinJapan`, + }, + { + label: "finishOnlyTime", + apply: (run) => + `『${run.gamename}』\nクリアタイムは次の通りです\n\n\n配信はこちらから⇒https://www.twitch.tv/rtainjapan\n#RTAinJapan`, + }, + { + label: "running", + apply: (run) => + `現在のタイムアタックは『${run.gamename}』\n\n\n\n配信はこちらから⇒https://www.twitch.tv/rtainjapan\n#RTAinJapan`, + }, + { + label: "backup", + apply: (run) => + `【バックアップ追加のお知らせ】\n「xxx」の終了後、バックアップとして『${run.gamename}』を追加いたします。\n\n\n配信はこちらから⇒https://www.twitch.tv/rtainjapan\n#RTAinJapan`, + }, +] as const; diff --git a/app/routes/_index/tweet-form.tsx b/app/routes/_index/tweet-form.tsx index 4a27935..f166e3b 100644 --- a/app/routes/_index/tweet-form.tsx +++ b/app/routes/_index/tweet-form.tsx @@ -1,29 +1,48 @@ import { useReplyStore } from "./reply-store"; import { useFetcher, useLoaderData } from "@remix-run/react"; -import { useEffect, useId, useState } from "react"; -import { Button, CheckboxGroup, Link, TextArea } from "@radix-ui/themes"; +import { useEffect, useId, useRef, useState } from "react"; +import { + Avatar, + Badge, + Button, + CheckboxGroup, + Dialog, + Flex, + Link, + Select, + TextArea, +} from "@radix-ui/themes"; import twitterText from "twitter-text"; import { css } from "../../../styled-system/css"; import type { loader, action } from "./route"; import { useTranslation } from "react-i18next"; import { FullscreenSpinner } from "../../components/fullscreen-spinner"; +import { useQuoteStore } from "./quote-store"; +import twitterLogo from "./twitter-logo.png"; +import blueskyLogo from "./bluesky-logo.png"; +import { templates } from "./templates"; const imageFileTypes = ["image/jpeg", "image/png", "image/webp", "image/gif"]; const videoFileTypes = ["video/mp4", "video/quicktime"]; const TweetTextInput = () => { const [tweetLength, setTweetLength] = useState(0); + const quoteExists = Boolean(useQuoteStore((store) => store.twitterId)); const suggestLengthOver = tweetLength > 280; + const textArea = useRef(null); return ( <>