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/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/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/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..6cce4ab3da 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,57 @@ 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 +281,27 @@ const QuoteImageDialogContent: React.FC = ({ + {articleId && canPostToWall && ( + + ) : ( + + ) + } + color="green" + loading={isPosting} + disabled={isPosting || posted} + onClick={onPostToWall} + /> + )} } color="green" @@ -232,6 +316,27 @@ 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..d1d8bbe949 --- /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..33bfc3ed9e --- /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: first_Int_min_0_max_50 = 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..dd2e51bc47 --- /dev/null +++ b/src/views/CampaignDetail/QuoteWall/index.tsx @@ -0,0 +1,120 @@ +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< + NonNullable['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..bf5bda5682 --- /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); + 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'] { + 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 { + /* safety net: cap display at 3 lines even within the 80-char limit */ + display: -webkit-box; + 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 { + 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 && ( - + <> + + + )} 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) => {