From 76d296256788f9af54b45e2101683a3d5c697136 Mon Sep 17 00:00:00 2001 From: yingshinlee Date: Fri, 12 Jun 2026 01:42:19 +0800 Subject: [PATCH 1/2] feat(quote-wall): post-to-wall button + campaign quote wall display - QuoteImageDialog: 'Post to wall' button (campaign articles only) via putQuote; gentle quota-reached toast on ACTION_LIMIT_EXCEEDED - CampaignDetail/QuoteWall: compact module (desktop right aside) + chip entry (mobile) + full-wall dialog with shuffle (random refetch) - QuoteCard: sticky-note card linking back to the source article; retract action for the poster or the source article's author (soft delete) - museum link (freewriting.matters.town/memo-wall) in EXTERNAL_LINKS - mount quote wall above discussion in the campaign aside / mobile chips Co-Authored-By: Claude Fable 5 --- src/common/enums/externalLinks.ts | 1 + src/components/GQL/mutations/putQuote.ts | 16 +++ src/components/QuoteImageDialog/Content.tsx | 94 +++++++++++- src/components/TextSelectionPopover/index.tsx | 3 + src/views/CampaignDetail/QuoteWall/Dialog.tsx | 125 ++++++++++++++++ .../CampaignDetail/QuoteWall/QuoteCard.tsx | 97 +++++++++++++ src/views/CampaignDetail/QuoteWall/gql.ts | 47 ++++++ src/views/CampaignDetail/QuoteWall/index.tsx | 125 ++++++++++++++++ .../QuoteWall/styles.module.css | 136 ++++++++++++++++++ src/views/CampaignDetail/index.tsx | 38 +++-- 10 files changed, 668 insertions(+), 14 deletions(-) create mode 100644 src/components/GQL/mutations/putQuote.ts create mode 100644 src/views/CampaignDetail/QuoteWall/Dialog.tsx create mode 100644 src/views/CampaignDetail/QuoteWall/QuoteCard.tsx create mode 100644 src/views/CampaignDetail/QuoteWall/gql.ts create mode 100644 src/views/CampaignDetail/QuoteWall/index.tsx create mode 100644 src/views/CampaignDetail/QuoteWall/styles.module.css diff --git a/src/common/enums/externalLinks.ts b/src/common/enums/externalLinks.ts index e5ff12bc70..62112e7561 100644 --- a/src/common/enums/externalLinks.ts +++ b/src/common/enums/externalLinks.ts @@ -14,6 +14,7 @@ export const EXTERNAL_LINKS = { isProd ? `https://liker.land/${likerId}/civic?utm_source=Matters` : `https://rinkeby.liker.land/${likerId}/civic?utm_source=Matters`, + SEVEN_DAY_BOOK_QUOTE_WALL: 'https://freewriting.matters.town/memo-wall', PLANET: 'https://www.planetable.xyz/', ENS_DOCS: 'https://docs.ens.domains/', METAMASK: 'https://metamask.io/download/', diff --git a/src/components/GQL/mutations/putQuote.ts b/src/components/GQL/mutations/putQuote.ts new file mode 100644 index 0000000000..58beb9f64c --- /dev/null +++ b/src/components/GQL/mutations/putQuote.ts @@ -0,0 +1,16 @@ +import gql from 'graphql-tag' + +export const PUT_QUOTE = gql` + mutation PutQuote($input: PutQuoteInput!) { + putQuote(input: $input) { + id + content + } + } +` + +export const DELETE_QUOTE = gql` + mutation DeleteQuote($input: DeleteQuoteInput!) { + deleteQuote(input: $input) + } +` diff --git a/src/components/QuoteImageDialog/Content.tsx b/src/components/QuoteImageDialog/Content.tsx index d8609f55e6..f2f62d4ef1 100644 --- a/src/components/QuoteImageDialog/Content.tsx +++ b/src/components/QuoteImageDialog/Content.tsx @@ -3,8 +3,11 @@ import QRCode from 'qrcode' import { useEffect, useMemo, useRef, useState } from 'react' import { defineMessages, FormattedMessage, useIntl } from 'react-intl' +import { ERROR_CODES } from '~/common/enums' import { analytics, isMobile } from '~/common/utils' -import { Dialog } from '~/components' +import { Dialog, toast, useMutation } from '~/components' +import { PUT_QUOTE } from '~/components/GQL/mutations/putQuote' +import { PutQuoteMutation } from '~/gql/graphql' import { QuoteCard } from './Card' import { clampQuote, MAX_QUOTE_LEN, QUOTE_SIZES, QUOTE_STYLES } from './presets' @@ -41,6 +44,10 @@ export type QuoteImageDialogContentProps = { /** 文章連結,用於產生 QR Code */ shareLink: string isSevenDayBook: boolean + /** 文章 id;與 canPostToWall 一起提供時顯示「上牆」按鈕 */ + articleId?: string + /** 文章屬於活動(campaign)才可上牆;伺服器端會再驗證 */ + canPostToWall?: boolean } const QuoteImageDialogContent: React.FC = ({ @@ -50,6 +57,8 @@ const QuoteImageDialogContent: React.FC = ({ title, shareLink, isSevenDayBook, + articleId, + canPostToWall, }) => { const intl = useIntl() const cardRef = useRef(null) @@ -57,6 +66,9 @@ const QuoteImageDialogContent: React.FC = ({ const [styleId, setStyleId] = useState('pine') const [sizeId, setSizeId] = useState('portrait') const [qrDataUrl, setQrDataUrl] = useState('') + const [putQuote] = useMutation(PUT_QUOTE) + const [isPosting, setPosting] = useState(false) + const [posted, setPosted] = useState(false) const style = QUOTE_STYLES.find((s) => s.id === styleId) || QUOTE_STYLES[0] const size = QUOTE_SIZES.find((s) => s.id === sizeId) || QUOTE_SIZES[1] @@ -103,6 +115,56 @@ const QuoteImageDialogContent: React.FC = ({ a.click() } + const onPostToWall = async () => { + if (!articleId || isPosting || posted) return + setPosting(true) + analytics.trackEvent('click_button', { + type: 'quote_post_to_wall', + pageType: 'article_detail', + }) + + // wall stores plain text (no trailing ellipsis — the server verifies the + // quote is an excerpt of the article) + const wallText = (quote || '') + .trim() + .replace(/\s+/g, ' ') + .slice(0, MAX_QUOTE_LEN) + + try { + await putQuote({ + variables: { input: { articleId, content: wallText } }, + }) + setPosted(true) + toast.info({ + message: ( + + ), + }) + } catch (error) { + const code = (error as { graphQLErrors?: { extensions?: { code?: string } }[] }) + ?.graphQLErrors?.[0]?.extensions?.code + toast.error({ + message: + code === ERROR_CODES.ACTION_LIMIT_EXCEEDED ? ( + + ) : ( + + ), + }) + } finally { + setPosting(false) + } + } + const onShare = async () => { const url = await generate() if (!url) return @@ -218,6 +280,21 @@ const QuoteImageDialogContent: React.FC = ({ + {articleId && canPostToWall && ( + + ) : ( + + ) + } + color="green" + loading={isPosting} + disabled={isPosting || posted} + onClick={onPostToWall} + /> + )} } color="green" @@ -232,6 +309,21 @@ const QuoteImageDialogContent: React.FC = ({ } smUpBtns={ <> + {articleId && canPostToWall && ( + + ) : ( + + ) + } + color="green" + loading={isPosting} + disabled={isPosting || posted} + onClick={onPostToWall} + /> + )} } color="green" diff --git a/src/components/TextSelectionPopover/index.tsx b/src/components/TextSelectionPopover/index.tsx index 3b8e03b14d..ee11071d13 100644 --- a/src/components/TextSelectionPopover/index.tsx +++ b/src/components/TextSelectionPopover/index.tsx @@ -233,6 +233,9 @@ export const TextSelectionPopover = ({ title={article?.title || ''} shareLink={typeof window !== 'undefined' ? window.location.href : ''} isSevenDayBook={isSevenDayBookArticle(article)} + articleId={article?.id} + // 只有活動文章可上牆(伺服器會再驗證) + canPostToWall={!!article?.campaigns?.length} > {({ openDialog }) => ( + } + /> + + +
+ + + + +
+ + {loading && } + + {!loading && quotes.length > 0 && ( +
+ {quotes.map((quote, i) => ( + + ))} +
+ )} +
+ + + ) +} + +const QuoteWallDialog = (props: QuoteWallDialogProps) => ( + }> + {({ openDialog }) => <>{props.children({ openDialog })}} + +) + +export default QuoteWallDialog diff --git a/src/views/CampaignDetail/QuoteWall/QuoteCard.tsx b/src/views/CampaignDetail/QuoteWall/QuoteCard.tsx new file mode 100644 index 0000000000..ccbaea6c68 --- /dev/null +++ b/src/views/CampaignDetail/QuoteWall/QuoteCard.tsx @@ -0,0 +1,97 @@ +import Link from 'next/link' +import { useContext, useState } from 'react' +import { FormattedMessage } from 'react-intl' + +import { toPath } from '~/common/utils' +import { Button, toast, useMutation, ViewerContext } from '~/components' +import { DELETE_QUOTE } from '~/components/GQL/mutations/putQuote' +import { DeleteQuoteMutation, QuoteWallQuoteFragment } from '~/gql/graphql' + +import styles from './styles.module.css' + +interface QuoteCardProps { + quote: QuoteWallQuoteFragment + // index drives the rotation / background variety on the wall + index?: number + afterRetract?: () => void +} + +const QuoteCard = ({ quote, index = 0, afterRetract }: QuoteCardProps) => { + const viewer = useContext(ViewerContext) + const [deleteQuote] = useMutation(DELETE_QUOTE) + const [confirming, setConfirming] = useState(false) + const [retracted, setRetracted] = useState(false) + + // the poster, or the source article's author (the words are theirs), may retract + const canRetract = + !!viewer.id && + (viewer.id === quote.poster.id || viewer.id === quote.article.author.id) + + const path = toPath({ page: 'articleDetail', article: quote.article }) + + const onRetract = async () => { + try { + await deleteQuote({ variables: { input: { id: quote.id } } }) + setRetracted(true) + toast.info({ + message: ( + + ), + }) + afterRetract?.() + } catch { + toast.error({ + message: ( + + ), + }) + } + } + + if (retracted) { + return null + } + + return ( +
+ +
{quote.content}
+ + +
+ + — {quote.article.author.userName} + {quote.article.title ? `《${quote.article.title}》` : ''} + + + + +
+ + {canRetract && + (confirming ? ( + + + + + ) : ( + + ))} +
+ ) +} + +export default QuoteCard diff --git a/src/views/CampaignDetail/QuoteWall/gql.ts b/src/views/CampaignDetail/QuoteWall/gql.ts new file mode 100644 index 0000000000..0c78087164 --- /dev/null +++ b/src/views/CampaignDetail/QuoteWall/gql.ts @@ -0,0 +1,47 @@ +import gql from 'graphql-tag' + +// public: anyone can read a campaign's quote wall. +// `random: true` returns a random sample — the "shuffle" button refetches. +export const CAMPAIGN_QUOTES = gql` + query CampaignQuotes( + $shortHash: String! + $first: Int = 12 + $random: Boolean + ) { + campaign(input: { shortHash: $shortHash }) { + id + ... on WritingChallenge { + id + quoteCount + quotes(input: { first: $first, random: $random }) { + totalCount + edges { + node { + ...QuoteWallQuote + } + } + } + } + } + } + + fragment QuoteWallQuote on Quote { + id + content + createdAt + poster { + id + userName + displayName + } + article { + id + title + shortHash + author { + id + userName + } + } + } +` diff --git a/src/views/CampaignDetail/QuoteWall/index.tsx b/src/views/CampaignDetail/QuoteWall/index.tsx new file mode 100644 index 0000000000..3ebf755f27 --- /dev/null +++ b/src/views/CampaignDetail/QuoteWall/index.tsx @@ -0,0 +1,125 @@ +import { useContext } from 'react' +import { FormattedMessage } from 'react-intl' + +import { EXTERNAL_LINKS } from '~/common/enums' +import { Button, TextIcon, usePublicQuery, ViewerContext } from '~/components' +import { CampaignQuotesQuery } from '~/gql/graphql' + +import QuoteWallDialog from './Dialog' +import { CAMPAIGN_QUOTES } from './gql' +import QuoteCard from './QuoteCard' +import styles from './styles.module.css' + +type QuotesConnection = NonNullable< + Extract< + NonNullable, + { __typename: 'WritingChallenge' } + >['quotes'] +> +type Quote = NonNullable[0]['node'] + +// how many quotes to preview in the compact module +const PREVIEW_COUNT = 3 + +interface QuoteWallProps { + shortHash: string + // 'module': compact wall (desktop right aside). 'chip': one-line entry + // (mobile main column) that opens the full wall dialog. + entry?: 'module' | 'chip' +} + +const QuoteWall = ({ shortHash, entry = 'module' }: QuoteWallProps) => { + const viewer = useContext(ViewerContext) + + const { data } = usePublicQuery( + CAMPAIGN_QUOTES, + { variables: { shortHash, first: PREVIEW_COUNT, random: true } }, + { publicQuery: !viewer.isAuthed } + ) + + const campaign = + data?.campaign?.__typename === 'WritingChallenge' + ? data.campaign + : undefined + const totalCount = campaign?.quoteCount ?? 0 + const quotes: Quote[] = (campaign?.quotes?.edges || []) + .map(({ node }) => node) + .slice(0, PREVIEW_COUNT) + + // nothing on the wall yet → hide the module entirely + if (totalCount <= 0) { + return null + } + + // mobile: one-line entry that opens the full wall dialog + if (entry === 'chip') { + return ( + + {({ openDialog }) => ( + + )} + + ) + } + + return ( +
+
+

+ ✨{' '} + +

+ + + +
+ +
+ {quotes.map((quote, i) => ( + + ))} +
+ + {totalCount > quotes.length && ( + + {({ openDialog }) => ( + + )} + + )} +
+ ) +} + +export default QuoteWall diff --git a/src/views/CampaignDetail/QuoteWall/styles.module.css b/src/views/CampaignDetail/QuoteWall/styles.module.css new file mode 100644 index 0000000000..1f3811f467 --- /dev/null +++ b/src/views/CampaignDetail/QuoteWall/styles.module.css @@ -0,0 +1,136 @@ +.quoteWall { + padding: var(--sp16) 0; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--sp12); +} + +.title { + font-size: var(--text16); + font-weight: var(--font-medium); +} + +.count { + margin-left: var(--sp4); + font-weight: var(--font-normal); + color: var(--color-grey); +} + +.museum { + font-size: var(--text12); + font-weight: var(--font-medium); + color: var(--color-matters-green); +} + +/* compact preview: a vertical stack of memo cards */ +.previewWall { + display: flex; + flex-direction: column; + gap: var(--sp8); + margin-bottom: var(--sp8); +} + +/* full wall (dialog): two-column masonry-ish grid */ +.wall { + column-count: 2; + column-gap: var(--sp12); +} + +.dialogTop { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--sp16); +} + +.shuffle { + font-size: var(--text14); + font-weight: var(--font-medium); + color: var(--color-matters-green); +} + +/* sticky-note card */ +.card { + display: flex; + flex-direction: column; + gap: var(--sp4); + padding: var(--sp12); + margin: 0 0 var(--sp12); + break-inside: avoid; + background: var(--color-yellow-lighter); + border-radius: var(--sp8); + box-shadow: 0 1px 3px rgb(0 0 0 / 8%); +} + +.card[data-variant='1'] { + background: var(--color-green-lighter); +} + +.card[data-variant='2'] { + background: var(--color-red-lighter); +} + +.card[data-variant='3'] { + background: var(--color-grey-lighter); +} + +.quoteLink { + color: inherit; +} + +.quote { + margin: 0; + /* safety net: cap display at 3 lines even within the 80-char limit */ + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + overflow: hidden; + font-size: var(--text14); + line-height: 1.5; + color: var(--color-grey-darkest); +} + +.meta { + display: flex; + flex-direction: column; + gap: var(--sp4); +} + +.author { + font-size: var(--text12); + color: var(--color-grey-dark); +} + +.back { + font-size: var(--text12); + color: var(--color-matters-green); +} + +.retractRow { + display: flex; + gap: var(--sp8); +} + +/* mobile one-line entry */ +.chip { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: var(--sp12) var(--sp16); + font-size: var(--text14); + font-weight: var(--font-medium); + color: var(--color-warn-yellow); + cursor: pointer; + background: var(--color-top-up-yellow); + border: 1px solid var(--color-warn-yellow); + border-radius: var(--sp8); +} + +.chevron { + color: var(--color-grey); +} diff --git a/src/views/CampaignDetail/index.tsx b/src/views/CampaignDetail/index.tsx index 2bca9a105b..938aa3a95e 100644 --- a/src/views/CampaignDetail/index.tsx +++ b/src/views/CampaignDetail/index.tsx @@ -33,6 +33,11 @@ const DynamicDiscussion = dynamic(() => import('./Discussion'), { ssr: false, }) +const DynamicQuoteWall = dynamic(() => import('./QuoteWall'), { + loading: () => , + ssr: false, +}) + const DynamicSideParticipants = dynamic(() => import('./SideParticipants'), { loading: () => , ssr: false, @@ -104,12 +109,16 @@ const CampaignDetail = () => { <> {campaign.showOther && } - {/* desktop: discussion sits in the right aside, below the avatars */} + {/* desktop: quote wall + discussion sit in the right aside, below + the avatars (quote wall first — it's the lighter "trailer") */} {isMdUp && ( - + <> + + + )} {campaign.showAd && } @@ -140,15 +149,18 @@ const CampaignDetail = () => { - {/* mobile: the aside is hidden, so the discussion shrinks to a one-line - entry under the header (still on the first screen); tapping it opens - the full discussion dialog */} + {/* mobile: the aside is hidden, so the quote wall + discussion shrink to + one-line entries under the header (still on the first screen); + tapping either opens its full dialog */} {!isMdUp && ( - + <> + + + )} From e9c7745d826c0bc970d6483804568fe4c9c48188 Mon Sep 17 00:00:00 2001 From: mashbean Date: Sat, 13 Jun 2026 20:58:53 +0800 Subject: [PATCH 2/2] fix(quote-wall): pass type checks, lint, and unit tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - route.ts: support campaignDiscussion comment type (widened CommentType enum from backend) — add to CommentArgs union, add campaign field to commentDetail args, and resolve its href - QuoteWall gql: type $first as first_Int_min_0_max_50 to match the schema's constrained scalar (codegen validation) - QuoteWall/Discussion: drop Extract<..., WritingChallenge> wrapper that resolved to never (campaign is already the single fragment shape in generated types) - analytics: add quote_post_to_wall click_button type - ArticleDetail/gql: import QuoteImageDialog fragments from the leaf gql module instead of the React index, breaking a circular import that left UserDigest undefined and failed the unit test - QuoteImageDialog: drop unused closeDialog prop destructuring - regenerate i18n message ids (enforce-id) and apply lint/format Co-Authored-By: Claude Fable 5 --- lang/default.json | 45 +++++++++++++++++++ lang/en.json | 45 +++++++++++++++++++ lang/zh-Hans.json | 45 +++++++++++++++++++ lang/zh-Hant.json | 45 +++++++++++++++++++ src/common/utils/analytics.ts | 1 + .../Forms/CircleCommentForm/index.tsx | 3 +- src/components/QuoteImageDialog/Content.tsx | 31 +++++++++---- src/views/CampaignDetail/QuoteWall/Dialog.tsx | 15 +++---- .../CampaignDetail/QuoteWall/QuoteCard.tsx | 10 ++--- src/views/CampaignDetail/QuoteWall/gql.ts | 2 +- src/views/CampaignDetail/QuoteWall/index.tsx | 15 +++---- .../QuoteWall/styles.module.css | 8 ++-- .../Me/Settings/Misc/FederationSetting.tsx | 10 +---- tests/helpers/utils.ts | 6 ++- 14 files changed, 233 insertions(+), 48 deletions(-) diff --git a/lang/default.json b/lang/default.json index cb0003209f..213dc27f94 100644 --- a/lang/default.json +++ b/lang/default.json @@ -248,6 +248,9 @@ "1EYCdR": { "defaultMessage": "Tags" }, + "1HLo+Y": { + "defaultMessage": "Quote wall" + }, "1PORwh": { "defaultMessage": "Archived article.", "description": "src/views/ArticleDetail/StickyTopBanner/index.tsx" @@ -413,6 +416,9 @@ "3cxMQp": { "defaultMessage": "Violet" }, + "3jmniZ": { + "defaultMessage": "Retract" + }, "3kbIhS": { "defaultMessage": "Untitled" }, @@ -493,6 +499,9 @@ "5IS+ui": { "defaultMessage": "Support Setting" }, + "5IlTNw": { + "defaultMessage": "Failed to post to the wall" + }, "5JN+nl": { "defaultMessage": "Check your inbox", "description": "src/components/Forms/Verification/LinkSent.tsx" @@ -1028,6 +1037,9 @@ "defaultMessage": "Comment deleted", "description": "src/components/Notice/NoticeComment.tsx/moment" }, + "Cmc/He": { + "defaultMessage": "On the wall ✓" + }, "CnPG8j": { "defaultMessage": "Featured" }, @@ -1051,6 +1063,9 @@ "D3idYv": { "defaultMessage": "Settings" }, + "D8FJf9": { + "defaultMessage": "Wall quota reached for today — come back tomorrow!" + }, "D9/QIR": { "defaultMessage": "Register ISCN" }, @@ -1113,6 +1128,9 @@ "defaultMessage": "Unpin Broadcast", "description": "src/components/CircleComment/DropdownActions/PinButton.tsx" }, + "Ds+7ro": { + "defaultMessage": "Full wall →" + }, "DtO278": { "defaultMessage": "We’ve detected that several of your recent works have been recommended to related channels. They may not appear at the same time" }, @@ -1246,6 +1264,9 @@ "FuYW4i": { "defaultMessage": "Subscribe circle and chat together!" }, + "Fx9x/w": { + "defaultMessage": "Full wall / Museum →" + }, "FxrSCh": { "defaultMessage": "This ID cannot be modified. Are you sure you want to use {id} as your Matters ID?", "description": "src/components/Dialogs/SetUserNameDialog/ConfirmStep.tsx" @@ -1424,6 +1445,9 @@ "IW6zQv": { "defaultMessage": "Select Time" }, + "IWLb33": { + "defaultMessage": "Posted to the quote wall" + }, "IXycMo": { "defaultMessage": "Resend" }, @@ -1451,6 +1475,9 @@ "J7hiLV": { "defaultMessage": "The author has not bound the LikeCoin wallet yet" }, + "JBnAOd": { + "defaultMessage": "🔀 Shuffle" + }, "JCZFqh": { "defaultMessage": "Sign up now and start writing the annual questionnaire. Please check the announcement for event details." }, @@ -2064,6 +2091,9 @@ "TF1OhT": { "defaultMessage": "This login code has expired, please try to resend" }, + "TIWVxK": { + "defaultMessage": "Quote retracted from the wall" + }, "TInwt3": { "defaultMessage": "Disable Responses" }, @@ -2095,6 +2125,9 @@ "defaultMessage": "Optimism is a standalone blockchain. If you have USDT on other chains, you need to transfer them to Optimism. See details in the {tutorial}.", "description": "src/components/Forms/PaymentForm/SwitchNetwork/index.tsx" }, + "Thr8QX": { + "defaultMessage": "To article ↩" + }, "TjWWxF": { "defaultMessage": "Broadcast sent", "description": "src/views/Circle/Broadcast/Broadcast.tsx" @@ -2417,6 +2450,9 @@ "defaultMessage": "Archived for violation.", "description": "src/views/ArticleDetail/StickyTopBanner/index.tsx" }, + "Z82+dw": { + "defaultMessage": "Confirm retract" + }, "ZAoAcG": { "defaultMessage": "Threads, Mastodon, Misskey 這些地方的粉絲也會看到" }, @@ -2781,6 +2817,9 @@ "eov+J2": { "defaultMessage": "Custom URL Name" }, + "epZb9X": { + "defaultMessage": "View all {count} quotes" + }, "erE5/4": { "defaultMessage": "Followed", "description": "src/components/Buttons/FollowUser/FollowState.tsx" @@ -3010,6 +3049,9 @@ "iSM+et": { "defaultMessage": "All rights reserved" }, + "iSjuti": { + "defaultMessage": "Failed to retract" + }, "iTcMqz": { "defaultMessage": "Under the moonlight, dreams are about to come true. The Moonlight Dream badge signifies your participation in the Nomad Matters.", "description": "src/views/User/UserProfile/BadgeNomadLabel/index.tsx" @@ -3357,6 +3399,9 @@ "defaultMessage": "濫發廣告", "description": "src/components/Comment/DropdownActions/CommunityWatchRemoveComment.tsx" }, + "oCQmLu": { + "defaultMessage": "Post to wall" + }, "oEHAIT": { "defaultMessage": "Cancel schedule", "description": "confirm cancel schedule button" diff --git a/lang/en.json b/lang/en.json index c6b88a1273..8da21da987 100644 --- a/lang/en.json +++ b/lang/en.json @@ -248,6 +248,9 @@ "1EYCdR": { "defaultMessage": "Tags" }, + "1HLo+Y": { + "defaultMessage": "Quote wall" + }, "1PORwh": { "defaultMessage": "Archived article.", "description": "src/views/ArticleDetail/StickyTopBanner/index.tsx" @@ -413,6 +416,9 @@ "3cxMQp": { "defaultMessage": "Violet" }, + "3jmniZ": { + "defaultMessage": "Retract" + }, "3kbIhS": { "defaultMessage": "Untitled" }, @@ -493,6 +499,9 @@ "5IS+ui": { "defaultMessage": "Support Setting" }, + "5IlTNw": { + "defaultMessage": "Failed to post to the wall" + }, "5JN+nl": { "defaultMessage": "Check your inbox", "description": "src/components/Forms/Verification/LinkSent.tsx" @@ -1028,6 +1037,9 @@ "defaultMessage": "Comment deleted", "description": "src/components/Notice/NoticeComment.tsx/moment" }, + "Cmc/He": { + "defaultMessage": "On the wall ✓" + }, "CnPG8j": { "defaultMessage": "Featured" }, @@ -1051,6 +1063,9 @@ "D3idYv": { "defaultMessage": "Settings" }, + "D8FJf9": { + "defaultMessage": "Wall quota reached for today — come back tomorrow!" + }, "D9/QIR": { "defaultMessage": "Register ISCN" }, @@ -1113,6 +1128,9 @@ "defaultMessage": "Unpin Broadcast", "description": "src/components/CircleComment/DropdownActions/PinButton.tsx" }, + "Ds+7ro": { + "defaultMessage": "Full wall →" + }, "DtO278": { "defaultMessage": "We’ve detected that several of your recent works have been recommended to related channels. They may not appear at the same time" }, @@ -1246,6 +1264,9 @@ "FuYW4i": { "defaultMessage": "Subscribe circle and chat together!" }, + "Fx9x/w": { + "defaultMessage": "Full wall / Museum →" + }, "FxrSCh": { "defaultMessage": "This ID cannot be modified. Are you sure you want to use {id} as your Matters ID?", "description": "src/components/Dialogs/SetUserNameDialog/ConfirmStep.tsx" @@ -1424,6 +1445,9 @@ "IW6zQv": { "defaultMessage": "Select Time" }, + "IWLb33": { + "defaultMessage": "Posted to the quote wall" + }, "IXycMo": { "defaultMessage": "Resend" }, @@ -1451,6 +1475,9 @@ "J7hiLV": { "defaultMessage": "The author has not bound the LikeCoin wallet yet" }, + "JBnAOd": { + "defaultMessage": "🔀 Shuffle" + }, "JCZFqh": { "defaultMessage": "Sign up now and start writing the annual questionnaire. Please check the announcement for event details." }, @@ -2064,6 +2091,9 @@ "TF1OhT": { "defaultMessage": "This login code has expired, please try to resend" }, + "TIWVxK": { + "defaultMessage": "Quote retracted from the wall" + }, "TInwt3": { "defaultMessage": "Disable Responses" }, @@ -2095,6 +2125,9 @@ "defaultMessage": "Optimism is a standalone blockchain. If you have USDT on other chains, you need to transfer them to Optimism. See details in the {tutorial}.", "description": "src/components/Forms/PaymentForm/SwitchNetwork/index.tsx" }, + "Thr8QX": { + "defaultMessage": "To article ↩" + }, "TjWWxF": { "defaultMessage": "Broadcast sent", "description": "src/views/Circle/Broadcast/Broadcast.tsx" @@ -2417,6 +2450,9 @@ "defaultMessage": "Archived for violation.", "description": "src/views/ArticleDetail/StickyTopBanner/index.tsx" }, + "Z82+dw": { + "defaultMessage": "Confirm retract" + }, "ZAoAcG": { "defaultMessage": "Threads, Mastodon, Misskey 這些地方的粉絲也會看到" }, @@ -2781,6 +2817,9 @@ "eov+J2": { "defaultMessage": "Custom URL Name" }, + "epZb9X": { + "defaultMessage": "View all {count} quotes" + }, "erE5/4": { "defaultMessage": "Followed", "description": "src/components/Buttons/FollowUser/FollowState.tsx" @@ -3010,6 +3049,9 @@ "iSM+et": { "defaultMessage": "All rights reserved" }, + "iSjuti": { + "defaultMessage": "Failed to retract" + }, "iTcMqz": { "defaultMessage": "Under the moonlight, dreams are about to come true. The Moonlight Dream badge signifies your participation in the Nomad Matters.", "description": "src/views/User/UserProfile/BadgeNomadLabel/index.tsx" @@ -3357,6 +3399,9 @@ "defaultMessage": "濫發廣告", "description": "src/components/Comment/DropdownActions/CommunityWatchRemoveComment.tsx" }, + "oCQmLu": { + "defaultMessage": "Post to wall" + }, "oEHAIT": { "defaultMessage": "Cancel schedule", "description": "confirm cancel schedule button" diff --git a/lang/zh-Hans.json b/lang/zh-Hans.json index 2cc439435f..ed09f401d5 100644 --- a/lang/zh-Hans.json +++ b/lang/zh-Hans.json @@ -248,6 +248,9 @@ "1EYCdR": { "defaultMessage": "标签" }, + "1HLo+Y": { + "defaultMessage": "Quote wall" + }, "1PORwh": { "defaultMessage": "仅作者本人可见封存作品,", "description": "src/views/ArticleDetail/StickyTopBanner/index.tsx" @@ -413,6 +416,9 @@ "3cxMQp": { "defaultMessage": "Violet" }, + "3jmniZ": { + "defaultMessage": "Retract" + }, "3kbIhS": { "defaultMessage": "未命名" }, @@ -493,6 +499,9 @@ "5IS+ui": { "defaultMessage": "支持设置" }, + "5IlTNw": { + "defaultMessage": "Failed to post to the wall" + }, "5JN+nl": { "defaultMessage": "请检查邮件", "description": "src/components/Forms/Verification/LinkSent.tsx" @@ -1028,6 +1037,9 @@ "defaultMessage": "留言已删除", "description": "src/components/Notice/NoticeComment.tsx/moment" }, + "Cmc/He": { + "defaultMessage": "On the wall ✓" + }, "CnPG8j": { "defaultMessage": "精选" }, @@ -1051,6 +1063,9 @@ "D3idYv": { "defaultMessage": "设定" }, + "D8FJf9": { + "defaultMessage": "Wall quota reached for today — come back tomorrow!" + }, "D9/QIR": { "defaultMessage": "注册 ISCN" }, @@ -1113,6 +1128,9 @@ "defaultMessage": "取消置顶", "description": "src/components/CircleComment/DropdownActions/PinButton.tsx" }, + "Ds+7ro": { + "defaultMessage": "Full wall →" + }, "DtO278": { "defaultMessage": "检测到近期你的多篇文章被推荐到相关频道,他们有可能不会同时出现" }, @@ -1246,6 +1264,9 @@ "FuYW4i": { "defaultMessage": "成为围炉一员,一起谈天说地" }, + "Fx9x/w": { + "defaultMessage": "Full wall / Museum →" + }, "FxrSCh": { "defaultMessage": "ID 设置后无法修改,确认使用 {id} 作为 Matters ID 吗?", "description": "src/components/Dialogs/SetUserNameDialog/ConfirmStep.tsx" @@ -1424,6 +1445,9 @@ "IW6zQv": { "defaultMessage": "选择时间" }, + "IWLb33": { + "defaultMessage": "Posted to the quote wall" + }, "IXycMo": { "defaultMessage": "重新发送" }, @@ -1451,6 +1475,9 @@ "J7hiLV": { "defaultMessage": "作者尚未绑定 LikeCoin 钱包" }, + "JBnAOd": { + "defaultMessage": "🔀 Shuffle" + }, "JCZFqh": { "defaultMessage": "现在报名,即可开始书写年度问卷,活动细则请查看公告" }, @@ -2064,6 +2091,9 @@ "TF1OhT": { "defaultMessage": "临时密码已过期,请尝试重新发送" }, + "TIWVxK": { + "defaultMessage": "Quote retracted from the wall" + }, "TInwt3": { "defaultMessage": "关闭评论" }, @@ -2095,6 +2125,9 @@ "defaultMessage": "Optimism 是独立运行的区块链,若你在其他链上已有 USDT 货币,需要将它们转移到 Optimism 网络才能使用,详情参考 {tutorial}.", "description": "src/components/Forms/PaymentForm/SwitchNetwork/index.tsx" }, + "Thr8QX": { + "defaultMessage": "To article ↩" + }, "TjWWxF": { "defaultMessage": "广播已送出", "description": "src/views/Circle/Broadcast/Broadcast.tsx" @@ -2417,6 +2450,9 @@ "defaultMessage": "因违反用户协定而被封存,", "description": "src/views/ArticleDetail/StickyTopBanner/index.tsx" }, + "Z82+dw": { + "defaultMessage": "Confirm retract" + }, "ZAoAcG": { "defaultMessage": "Threads, Mastodon, Misskey 這些地方的粉絲也會看到" }, @@ -2781,6 +2817,9 @@ "eov+J2": { "defaultMessage": "自定义网址名称" }, + "epZb9X": { + "defaultMessage": "View all {count} quotes" + }, "erE5/4": { "defaultMessage": "互相关注", "description": "src/components/Buttons/FollowUser/FollowState.tsx" @@ -3010,6 +3049,9 @@ "iSM+et": { "defaultMessage": "作者保留所有权利" }, + "iSjuti": { + "defaultMessage": "Failed to retract" + }, "iTcMqz": { "defaultMessage": "月色之下,梦想即将实现。月之梦徽章纪念你曾参与「游牧者计划」。", "description": "src/views/User/UserProfile/BadgeNomadLabel/index.tsx" @@ -3357,6 +3399,9 @@ "defaultMessage": "濫發廣告", "description": "src/components/Comment/DropdownActions/CommunityWatchRemoveComment.tsx" }, + "oCQmLu": { + "defaultMessage": "Post to wall" + }, "oEHAIT": { "defaultMessage": "取消发布", "description": "confirm cancel schedule button" diff --git a/lang/zh-Hant.json b/lang/zh-Hant.json index d7e62d67f1..9a9ac00cd3 100644 --- a/lang/zh-Hant.json +++ b/lang/zh-Hant.json @@ -248,6 +248,9 @@ "1EYCdR": { "defaultMessage": "標籤" }, + "1HLo+Y": { + "defaultMessage": "Quote wall" + }, "1PORwh": { "defaultMessage": "僅作者本人可見封存作品,", "description": "src/views/ArticleDetail/StickyTopBanner/index.tsx" @@ -413,6 +416,9 @@ "3cxMQp": { "defaultMessage": "Violet" }, + "3jmniZ": { + "defaultMessage": "Retract" + }, "3kbIhS": { "defaultMessage": "未命名" }, @@ -493,6 +499,9 @@ "5IS+ui": { "defaultMessage": "支持設置" }, + "5IlTNw": { + "defaultMessage": "Failed to post to the wall" + }, "5JN+nl": { "defaultMessage": "請檢查郵件", "description": "src/components/Forms/Verification/LinkSent.tsx" @@ -1028,6 +1037,9 @@ "defaultMessage": "留言已刪除", "description": "src/components/Notice/NoticeComment.tsx/moment" }, + "Cmc/He": { + "defaultMessage": "On the wall ✓" + }, "CnPG8j": { "defaultMessage": "精選" }, @@ -1051,6 +1063,9 @@ "D3idYv": { "defaultMessage": "設定" }, + "D8FJf9": { + "defaultMessage": "Wall quota reached for today — come back tomorrow!" + }, "D9/QIR": { "defaultMessage": "註冊 ISCN" }, @@ -1113,6 +1128,9 @@ "defaultMessage": "取消置頂", "description": "src/components/CircleComment/DropdownActions/PinButton.tsx" }, + "Ds+7ro": { + "defaultMessage": "Full wall →" + }, "DtO278": { "defaultMessage": "檢測到近期你的多篇文章被推薦到相關頻道,它們有可能不會同時出現" }, @@ -1246,6 +1264,9 @@ "FuYW4i": { "defaultMessage": "成為圍爐一員,一起談天說地" }, + "Fx9x/w": { + "defaultMessage": "Full wall / Museum →" + }, "FxrSCh": { "defaultMessage": "ID 設置後無法修改,確認使用 {id} 作為 Matters ID 嗎?", "description": "src/components/Dialogs/SetUserNameDialog/ConfirmStep.tsx" @@ -1424,6 +1445,9 @@ "IW6zQv": { "defaultMessage": "選擇時間" }, + "IWLb33": { + "defaultMessage": "Posted to the quote wall" + }, "IXycMo": { "defaultMessage": "重新發送" }, @@ -1451,6 +1475,9 @@ "J7hiLV": { "defaultMessage": "作者尚未綁定 LikeCoin 錢包" }, + "JBnAOd": { + "defaultMessage": "🔀 Shuffle" + }, "JCZFqh": { "defaultMessage": "現在報名,即可開始書寫年度問卷,活動細則可查看公告" }, @@ -2064,6 +2091,9 @@ "TF1OhT": { "defaultMessage": "臨時密碼已過期,請嘗試重新發送" }, + "TIWVxK": { + "defaultMessage": "Quote retracted from the wall" + }, "TInwt3": { "defaultMessage": "關閉評論" }, @@ -2095,6 +2125,9 @@ "defaultMessage": "Optimism 是獨立運行的區塊鏈,若你在其他鏈上已有 USDT 貨幣,需要將它們轉移到 Optimism 網絡才能使用,詳情參考 {tutorial}.", "description": "src/components/Forms/PaymentForm/SwitchNetwork/index.tsx" }, + "Thr8QX": { + "defaultMessage": "To article ↩" + }, "TjWWxF": { "defaultMessage": "廣播已送出", "description": "src/views/Circle/Broadcast/Broadcast.tsx" @@ -2417,6 +2450,9 @@ "defaultMessage": "因違反用戶協定而被封存,", "description": "src/views/ArticleDetail/StickyTopBanner/index.tsx" }, + "Z82+dw": { + "defaultMessage": "Confirm retract" + }, "ZAoAcG": { "defaultMessage": "Threads, Mastodon, Misskey 這些地方的粉絲也會看到" }, @@ -2781,6 +2817,9 @@ "eov+J2": { "defaultMessage": "自定義網址名稱" }, + "epZb9X": { + "defaultMessage": "View all {count} quotes" + }, "erE5/4": { "defaultMessage": "互相追蹤", "description": "src/components/Buttons/FollowUser/FollowState.tsx" @@ -3010,6 +3049,9 @@ "iSM+et": { "defaultMessage": "作者保留所有權利" }, + "iSjuti": { + "defaultMessage": "Failed to retract" + }, "iTcMqz": { "defaultMessage": "月色之下,夢想即將實現。月之夢徽章紀念你曾參與「遊牧者計畫」。", "description": "src/views/User/UserProfile/BadgeNomadLabel/index.tsx" @@ -3357,6 +3399,9 @@ "defaultMessage": "濫發廣告", "description": "src/components/Comment/DropdownActions/CommunityWatchRemoveComment.tsx" }, + "oCQmLu": { + "defaultMessage": "Post to wall" + }, "oEHAIT": { "defaultMessage": "取消排程", "description": "confirm cancel schedule button" diff --git a/src/common/utils/analytics.ts b/src/common/utils/analytics.ts index dafa666254..10a7e47565 100644 --- a/src/common/utils/analytics.ts +++ b/src/common/utils/analytics.ts @@ -93,6 +93,7 @@ export interface ClickButtonProp { | 'article_content_quote_image' | 'quote_image_download' | 'quote_image_share' + | 'quote_post_to_wall' | 'comment_open' | 'comment_close' | 'comment_placeholder' diff --git a/src/components/Forms/CircleCommentForm/index.tsx b/src/components/Forms/CircleCommentForm/index.tsx index b3b7b35781..f5f7813559 100644 --- a/src/components/Forms/CircleCommentForm/index.tsx +++ b/src/components/Forms/CircleCommentForm/index.tsx @@ -88,8 +88,7 @@ export const CircleCommentForm: React.FC = ({ const maxLength = type === 'campaignDiscussion' ? MAX_CAMPAIGN_COMMENT_LENGTH : undefined const contentLength = stripHtml(content).length - const isOverLength = - maxLength !== undefined && contentLength > maxLength + const isOverLength = maxLength !== undefined && contentLength > maxLength const isValid = contentLength > 0 && !isOverLength const handleSubmit = async (event?: React.FormEvent) => { diff --git a/src/components/QuoteImageDialog/Content.tsx b/src/components/QuoteImageDialog/Content.tsx index f2f62d4ef1..6cce4ab3da 100644 --- a/src/components/QuoteImageDialog/Content.tsx +++ b/src/components/QuoteImageDialog/Content.tsx @@ -139,24 +139,25 @@ const QuoteImageDialogContent: React.FC = ({ message: ( ), }) } catch (error) { - const code = (error as { graphQLErrors?: { extensions?: { code?: string } }[] }) - ?.graphQLErrors?.[0]?.extensions?.code + const code = ( + error as { graphQLErrors?: { extensions?: { code?: string } }[] } + )?.graphQLErrors?.[0]?.extensions?.code toast.error({ message: code === ERROR_CODES.ACTION_LIMIT_EXCEEDED ? ( ) : ( ), }) @@ -284,9 +285,15 @@ const QuoteImageDialogContent: React.FC = ({ + ) : ( - + ) } color="green" @@ -313,9 +320,15 @@ const QuoteImageDialogContent: React.FC = ({ + ) : ( - + ) } color="green" diff --git a/src/views/CampaignDetail/QuoteWall/Dialog.tsx b/src/views/CampaignDetail/QuoteWall/Dialog.tsx index 4c7e93041e..5413f46e8a 100644 --- a/src/views/CampaignDetail/QuoteWall/Dialog.tsx +++ b/src/views/CampaignDetail/QuoteWall/Dialog.tsx @@ -18,10 +18,7 @@ import QuoteCard from './QuoteCard' import styles from './styles.module.css' type QuotesConnection = NonNullable< - Extract< - NonNullable, - { __typename: 'WritingChallenge' } - >['quotes'] + NonNullable['quotes'] > type Quote = NonNullable[0]['node'] @@ -51,7 +48,9 @@ const BaseQuoteWallDialog = ({ data?.campaign?.__typename === 'WritingChallenge' ? data.campaign : undefined - const quotes: Quote[] = (campaign?.quotes?.edges || []).map(({ node }) => node) + const quotes: Quote[] = (campaign?.quotes?.edges || []).map( + ({ node }) => node + ) // "shuffle" = refetch a fresh random sample const shuffle = () => refetch({ shortHash, first: WALL_TAKE, random: true }) @@ -64,7 +63,7 @@ const BaseQuoteWallDialog = ({ - {' '} + {' '} {totalCount > 0 && ( {totalCount} )} @@ -81,7 +80,7 @@ const BaseQuoteWallDialog = ({
diff --git a/src/views/CampaignDetail/QuoteWall/QuoteCard.tsx b/src/views/CampaignDetail/QuoteWall/QuoteCard.tsx index ccbaea6c68..d1d8bbe949 100644 --- a/src/views/CampaignDetail/QuoteWall/QuoteCard.tsx +++ b/src/views/CampaignDetail/QuoteWall/QuoteCard.tsx @@ -37,7 +37,7 @@ const QuoteCard = ({ quote, index = 0, afterRetract }: QuoteCardProps) => { message: ( ), }) @@ -45,7 +45,7 @@ const QuoteCard = ({ quote, index = 0, afterRetract }: QuoteCardProps) => { } catch { toast.error({ message: ( - + ), }) } @@ -67,7 +67,7 @@ const QuoteCard = ({ quote, index = 0, afterRetract }: QuoteCardProps) => { {quote.article.title ? `《${quote.article.title}》` : ''} - + @@ -78,7 +78,7 @@ const QuoteCard = ({ quote, index = 0, afterRetract }: QuoteCardProps) => { ) : ( @@ -87,7 +87,7 @@ const QuoteCard = ({ quote, index = 0, afterRetract }: QuoteCardProps) => { spacing={[0, 0]} onClick={() => setConfirming(true)} > - + ))} diff --git a/src/views/CampaignDetail/QuoteWall/gql.ts b/src/views/CampaignDetail/QuoteWall/gql.ts index 0c78087164..33bfc3ed9e 100644 --- a/src/views/CampaignDetail/QuoteWall/gql.ts +++ b/src/views/CampaignDetail/QuoteWall/gql.ts @@ -5,7 +5,7 @@ import gql from 'graphql-tag' export const CAMPAIGN_QUOTES = gql` query CampaignQuotes( $shortHash: String! - $first: Int = 12 + $first: first_Int_min_0_max_50 = 12 $random: Boolean ) { campaign(input: { shortHash: $shortHash }) { diff --git a/src/views/CampaignDetail/QuoteWall/index.tsx b/src/views/CampaignDetail/QuoteWall/index.tsx index 3ebf755f27..dd2e51bc47 100644 --- a/src/views/CampaignDetail/QuoteWall/index.tsx +++ b/src/views/CampaignDetail/QuoteWall/index.tsx @@ -11,10 +11,7 @@ import QuoteCard from './QuoteCard' import styles from './styles.module.css' type QuotesConnection = NonNullable< - Extract< - NonNullable, - { __typename: 'WritingChallenge' } - >['quotes'] + NonNullable['quotes'] > type Quote = NonNullable[0]['node'] @@ -63,8 +60,7 @@ const QuoteWall = ({ shortHash, entry = 'module' }: QuoteWallProps) => { aria-haspopup="dialog" > - ✨{' '} - + ✨ {totalCount} @@ -78,8 +74,7 @@ const QuoteWall = ({ shortHash, entry = 'module' }: QuoteWallProps) => {

- ✨{' '} - + ✨

{ target="_blank" rel="noreferrer" > - +
@@ -110,7 +105,7 @@ const QuoteWall = ({ shortHash, entry = 'module' }: QuoteWallProps) => { diff --git a/src/views/CampaignDetail/QuoteWall/styles.module.css b/src/views/CampaignDetail/QuoteWall/styles.module.css index 1f3811f467..bf5bda5682 100644 --- a/src/views/CampaignDetail/QuoteWall/styles.module.css +++ b/src/views/CampaignDetail/QuoteWall/styles.module.css @@ -60,10 +60,10 @@ gap: var(--sp4); padding: var(--sp12); margin: 0 0 var(--sp12); - break-inside: avoid; background: var(--color-yellow-lighter); border-radius: var(--sp8); box-shadow: 0 1px 3px rgb(0 0 0 / 8%); + break-inside: avoid; } .card[data-variant='1'] { @@ -83,15 +83,15 @@ } .quote { - margin: 0; /* safety net: cap display at 3 lines even within the 80-char limit */ display: -webkit-box; - -webkit-box-orient: vertical; - -webkit-line-clamp: 3; + margin: 0; overflow: hidden; + -webkit-line-clamp: 3; font-size: var(--text14); line-height: 1.5; color: var(--color-grey-darkest); + -webkit-box-orient: vertical; } .meta { diff --git a/src/views/Me/Settings/Misc/FederationSetting.tsx b/src/views/Me/Settings/Misc/FederationSetting.tsx index 6fe2c5a756..6b5f2e5bf0 100644 --- a/src/views/Me/Settings/Misc/FederationSetting.tsx +++ b/src/views/Me/Settings/Misc/FederationSetting.tsx @@ -103,10 +103,7 @@ const FederationSetting = () => { return ( + } subtitle={ { + } checked={enabled} loading={loading || saving} diff --git a/tests/helpers/utils.ts b/tests/helpers/utils.ts index a63229363d..0a26fc6395 100644 --- a/tests/helpers/utils.ts +++ b/tests/helpers/utils.ts @@ -3,7 +3,11 @@ import { Page } from '@playwright/test' export const pageGoto = async ( page: Page, path: string, - waitUntil: 'load' | 'domcontentloaded' | 'networkidle' | 'commit' = 'networkidle' + waitUntil: + | 'load' + | 'domcontentloaded' + | 'networkidle' + | 'commit' = 'networkidle' ) => await page.goto(path, { waitUntil }) export const sleep = async (ms: number) => {