diff --git a/lang/default.json b/lang/default.json index a4ba6bcfe9..dc9ea818ab 100644 --- a/lang/default.json +++ b/lang/default.json @@ -2646,6 +2646,9 @@ "kCp8A9": { "defaultMessage": "After resignation, you will not be able to manage tags." }, + "kEDrXh": { + "defaultMessage": "liked your collection" + }, "kHMa3H": { "defaultMessage": "The new password and confirmation password do not match." }, diff --git a/lang/en.json b/lang/en.json index 626a15e6fd..0453b62d43 100644 --- a/lang/en.json +++ b/lang/en.json @@ -2646,6 +2646,9 @@ "kCp8A9": { "defaultMessage": "After resignation, you will not be able to manage tags." }, + "kEDrXh": { + "defaultMessage": "liked your collection" + }, "kHMa3H": { "defaultMessage": "The new password and confirmation password do not match." }, diff --git a/lang/zh-Hans.json b/lang/zh-Hans.json index d0512dc6b3..55825e3d80 100644 --- a/lang/zh-Hans.json +++ b/lang/zh-Hans.json @@ -2646,6 +2646,9 @@ "kCp8A9": { "defaultMessage": "如果辞去权限,你将无法继续管理标签。" }, + "kEDrXh": { + "defaultMessage": "喜欢你的选集" + }, "kHMa3H": { "defaultMessage": "密码不一致" }, diff --git a/lang/zh-Hant.json b/lang/zh-Hant.json index e8c41f0aba..13f4e0d671 100644 --- a/lang/zh-Hant.json +++ b/lang/zh-Hant.json @@ -2646,6 +2646,9 @@ "kCp8A9": { "defaultMessage": "如果辭去權限,你將無法繼續管理標籤。" }, + "kEDrXh": { + "defaultMessage": "喜歡你的選集" + }, "kHMa3H": { "defaultMessage": "密碼不一致" }, diff --git a/src/common/enums/test.ts b/src/common/enums/test.ts index e48b1adbea..fc50fcd7d5 100644 --- a/src/common/enums/test.ts +++ b/src/common/enums/test.ts @@ -138,6 +138,7 @@ export enum TEST_ID { NOTICE_OFFICIAL_ANNOUNCEMENT = 'notice/official-announcement', NOTICE_MOMENT_MENTIONED = 'notice/moment-mentioned', NOTICE_MOMENT_LIKED = 'notice/moment-liked', + NOTICE_COLLECTION_TITLE = 'notice/collection/title', // me ME_WALLET_TRANSACTIONS_ITEM = 'me/wallet/transactions/item', ME_WALLET_TRANSACTIONS_ITEM_AMOUNT = 'me/wallet/transactions/item/amount', diff --git a/src/common/utils/text/index.ts b/src/common/utils/text/index.ts index b047f8f1b0..a8d48fd2c7 100644 --- a/src/common/utils/text/index.ts +++ b/src/common/utils/text/index.ts @@ -1,5 +1,5 @@ export * from './article' -export * from './moment' +export * from './notice' export * from './tag' export * from './user' diff --git a/src/common/utils/text/moment.test.ts b/src/common/utils/text/moment.test.ts deleted file mode 100644 index 9d43350bb6..0000000000 --- a/src/common/utils/text/moment.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { UserLanguage } from '~/gql/graphql' - -import { truncateMomentTitle } from './moment' - -describe('utils/text/moment/truncateMomentTitle', () => { - it('should not truncate if under 10 characters', () => { - expect( - truncateMomentTitle('這篇文章真的很厲害!', 10, UserLanguage.ZhHans) - ).toBe('這篇文章真的很厲害!') - expect(truncateMomentTitle('很厲害!', 10, UserLanguage.ZhHant)).toBe( - '很厲害!' - ) - }) - - it('should truncate if over 10 characters', () => { - expect( - truncateMomentTitle( - '這篇文章真的很厲害,大家應該都來看一下!', - 10, - UserLanguage.ZhHant - ) - ).toBe('這篇文章真的很厲害,...') - }) - - it('should truncate when the title is over 10 characters and the mentions are at the end', () => { - expect( - truncateMomentTitle( - '這篇文章真的很厲害,大家應該都來看一下 @user1 @user2', - 10, - UserLanguage.ZhHant - ) - ).toBe('這篇文章真的很厲害,...@user1 @user2') - expect( - truncateMomentTitle( - '這篇文章真的很厲害,大家應該都來看一下! @user1 @user2', - 10, - UserLanguage.ZhHant - ) - ).toBe('這篇文章真的很厲害,...@user1 @user2') - expect( - truncateMomentTitle( - '這是一個時刻!!!!!!!@jj', - 10, - UserLanguage.ZhHant - ) - ).toBe('這是一個時刻!!!!...@jj') - }) - - it('should truncate if over 10 characters with tagged users in the middle or the beginning', () => { - expect( - truncateMomentTitle( - '我和 @zhangsan 在台北一起去吃吃吃!', - 10, - UserLanguage.ZhHans - ) - ).toBe('我和 @zhangsan 在台北一起去...') - expect( - truncateMomentTitle( - '@zhangsan 和我在台北一起去吃吃吃!', - 10, - UserLanguage.ZhHans - ) - ).toBe('@zhangsan 和我在台北一起去吃...') - }) - - it('should truncate characters to when the mention is a bit spread out', () => { - expect( - truncateMomentTitle( - '我和 @zhangsan 還有 @yp 在台北一起去吃吃吃!', - 10, - UserLanguage.ZhHans - ) - ).toBe('我和 @zhangsan 還有 @yp 在台...') - }) - - it('should truncate characters to under 10 words for english', () => { - expect(truncateMomentTitle('This is a very long sentence.')).toBe( - 'This is a...' - ) - expect(truncateMomentTitle('Hello, world.')).toBe('Hello,...') - }) - - it('should truncate if over 10 characters with tagged users and remaining length is 0 while having english characters', () => { - expect( - truncateMomentTitle('This is a craaaazy article here! @user1 @user2') - ).toBe('This is a...@user1 @user2') - }) -}) diff --git a/src/common/utils/text/notice.test.ts b/src/common/utils/text/notice.test.ts new file mode 100644 index 0000000000..c51d491a6b --- /dev/null +++ b/src/common/utils/text/notice.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from 'vitest' + +import { UserLanguage } from '~/gql/graphql' + +import { truncateNoticeTitle } from './notice' + +describe.concurrent('utils/text/collection/truncateNoticeTitle', () => { + describe('for Chinese', () => { + it('should truncate the title to the specified maximum number of words', () => { + const title = '这是一个标题这是一个标题这是一个标题' + const maxLength = 3 + const expected = '这是一...' + const result = truncateNoticeTitle(title, { + locale: UserLanguage.ZhHans, + maxLength, + }) + // Assert + expect(result).toEqual(expected) + }) + + it('should return the title as is if it has fewer words than the maximum', () => { + const title = '这是一个标题' + const maxLength = 7 + const result = truncateNoticeTitle(title, { + locale: UserLanguage.ZhHans, + maxLength, + }) + // Assert + expect(result).toEqual(title) + }) + + it('should return the title for the default length of 10 words', () => { + const title = '这是一个标题这是一个标题这是一个标题' + const expected = '这是一个标题这是一个...' + const result = truncateNoticeTitle(title, { locale: UserLanguage.ZhHans }) + // Assert + expect(result).toEqual(expected) + }) + }) + + describe('for English', () => { + it('should return the title as is if it has fewer words than the maximum', () => { + const title = 'The birds are chirping and the sun is shining' + const maxLength = 50 + const result = truncateNoticeTitle(title, { + locale: UserLanguage.En, + maxLength, + }) + // Assert + expect(result).toEqual(title) + }) + + it('should truncate the title to the specified maximum number of words', () => { + const title = 'The birds are chirping and the sun is shining' + const maxLength = 27 + const expected = 'The birds are chirping and...' + const result = truncateNoticeTitle(title, { + locale: UserLanguage.En, + maxLength, + }) + // Assert + expect(result).toEqual(expected) + }) + }) + + describe('for English with tagged users', () => { + it('should truncate characters to under 10 words for english', () => { + expect( + truncateNoticeTitle('This is a very long sentence.', { + includeAtSign: true, + }) + ).toBe('This is a...') + expect( + truncateNoticeTitle('Hello, world.', { includeAtSign: true }) + ).toBe('Hello,...') + }) + + it('should truncate if over 10 characters with tagged users and remaining length is 0 while having english characters', () => { + expect( + truncateNoticeTitle('This is a craaaazy article here! @user1 @user2', { + includeAtSign: true, + }) + ).toBe('This is a...@user1 @user2') + }) + }) + + describe('for Chinese with tagged users', () => { + it('should not truncate if under 10 characters', () => { + expect( + truncateNoticeTitle('這篇文章真的很厲害!', { + locale: UserLanguage.ZhHant, + maxLength: 10, + includeAtSign: true, + }) + ).toBe('這篇文章真的很厲害!') + expect( + truncateNoticeTitle('很厲害!', { + locale: UserLanguage.ZhHant, + maxLength: 10, + includeAtSign: true, + }) + ).toBe('很厲害!') + }) + + it('should truncate if over 10 characters', () => { + expect( + truncateNoticeTitle('這篇文章真的很厲害,大家應該都來看一下!', { + locale: UserLanguage.ZhHant, + maxLength: 10, + includeAtSign: true, + }) + ).toBe('這篇文章真的很厲害,...') + }) + + it('should truncate when the title is over 10 characters and the mentions are at the end', () => { + expect( + truncateNoticeTitle( + '這篇文章真的很厲害,大家應該都來看一下 @user1 @user2', + { locale: UserLanguage.ZhHant, maxLength: 10, includeAtSign: true } + ) + ).toBe('這篇文章真的很厲害,...@user1 @user2') + expect( + truncateNoticeTitle( + '這篇文章真的很厲害,大家應該都來看一下! @user1 @user2', + { locale: UserLanguage.ZhHant, maxLength: 10, includeAtSign: true } + ) + ).toBe('這篇文章真的很厲害,...@user1 @user2') + expect( + truncateNoticeTitle('這是一個時刻!!!!!!!@jj', { + locale: UserLanguage.ZhHant, + maxLength: 10, + includeAtSign: true, + }) + ).toBe('這是一個時刻!!!!...@jj') + }) + + it('should truncate if over 10 characters with tagged users in the middle or the beginning', () => { + expect( + truncateNoticeTitle('我和 @zhangsan 在台北一起去吃吃吃!', { + locale: UserLanguage.ZhHans, + maxLength: 10, + includeAtSign: true, + }) + ).toBe('我和 @zhangsan 在台北一起去...') + expect( + truncateNoticeTitle('@zhangsan 和我在台北一起去吃吃吃!', { + locale: UserLanguage.ZhHans, + maxLength: 10, + includeAtSign: true, + }) + ).toBe('@zhangsan 和我在台北一起去吃...') + }) + + it('should truncate characters to when the mention is a bit spread out', () => { + expect( + truncateNoticeTitle('我和 @zhangsan 還有 @yp 在台北一起去吃吃吃!', { + locale: UserLanguage.ZhHans, + maxLength: 10, + includeAtSign: true, + }) + ).toBe('我和 @zhangsan 還有 @yp 在台...') + }) + }) +}) diff --git a/src/common/utils/text/moment.ts b/src/common/utils/text/notice.ts similarity index 50% rename from src/common/utils/text/moment.ts rename to src/common/utils/text/notice.ts index 9c75d6f0a9..db101e79c2 100644 --- a/src/common/utils/text/moment.ts +++ b/src/common/utils/text/notice.ts @@ -1,5 +1,11 @@ import { UserLanguage } from '~/gql/graphql' +type TruncateNoticeTitleOptions = { + locale?: UserLanguage + maxLength?: number + includeAtSign?: boolean +} + /** * Truncates a title to a specified maximum length, while preserving tagged users. * @@ -8,16 +14,77 @@ import { UserLanguage } from '~/gql/graphql' * @param locale - The locale to determine the truncation rules. Defaults to 'en'. * @returns The truncated title with preserved tagged users. */ -export const truncateMomentTitle = ( +export const truncateNoticeTitle = ( title: string, - maxLength: number = 10, - locale: UserLanguage = UserLanguage.En + options: TruncateNoticeTitleOptions = {} ) => { - if (/^zh/.test(locale)) { - return truncateTitleForCJK(title, maxLength) + const DEFAULTS = { + locale: UserLanguage.En, + includeAtSign: false, + maxLength: 10, + } + let localOptions = { ...DEFAULTS, ...options } + + if (/^zh/.test(localOptions.locale)) { + return localOptions.includeAtSign + ? truncateTitleForChineseWithAtSign(title, localOptions) + : truncateTitleForChinese(title, localOptions) } else { - return truncateTitleForEnglish(title, maxLength) + return localOptions.includeAtSign + ? truncateTitleForEnglishWithAtSign(title, localOptions) + : truncateTitleForEnglish(title, localOptions) + } +} + +/** + * Truncates a title to a specified maximum length for Chinese (Simplified or traditional) text. + * + * @param text - The title to truncate. + * @param maxWords - The maximum number of words in the truncated title. Defaults to 10. + * @returns The truncated title. + */ +export function truncateTitleForChinese( + text: string, + { + maxLength, + }: { maxLength: NonNullable } +): string { + const chineseRegex = /[\u4e00-\u9fa5]/g + const chineseWords = text.match(chineseRegex) + if (chineseWords && chineseWords.length > maxLength) { + return chineseWords.slice(0, maxLength).join('') + '...' + } + return text +} + +/** + * Truncates a title to a specified maximum length for English text. + * + * @param text - The title to truncate. + * @param maxLength - The maximum length of the truncated title. Defaults to 50. + * @returns The truncated title. + */ +export function truncateTitleForEnglish( + text: string, + { + maxLength, + }: { maxLength: NonNullable } +): string { + if (text.length > maxLength) { + const words = text.split(' ') + let truncatedText = '' + let count = 0 + for (const word of words) { + if (count + word.length <= maxLength) { + truncatedText += word + ' ' + count += word.length + 1 + } else { + break + } + } + return truncatedText.trim() + '...' } + return text } /** @@ -27,7 +94,12 @@ export const truncateMomentTitle = ( * @param maxLength - The maximum length of the truncated title. * @returns The truncated title with preserved tagged users. */ -const truncateTitleForEnglish = (title: string, maxLength: number) => { +const truncateTitleForEnglishWithAtSign = ( + title: string, + { + maxLength, + }: { maxLength: NonNullable } +) => { const words = title.split(/\s+/) let hasTag = words.some((word) => word.startsWith('@')) let truncated = '' @@ -64,7 +136,12 @@ const truncateTitleForEnglish = (title: string, maxLength: number) => { * @param maxLength - The maximum length of the truncated title. * @returns The truncated title with preserved tagged users. */ -const truncateTitleForCJK = (title: string, maxLength: number) => { +const truncateTitleForChineseWithAtSign = ( + title: string, + { + maxLength, + }: { maxLength: NonNullable } +) => { const pattern = /(@\w+|[^\x00-\x7F]|\s)/gu const phrases = title.match(pattern)?.filter((s) => s !== ' ') || [] let hasTag = phrases.some((p) => p.startsWith('@')) diff --git a/src/components/Notice/CollectionNotice/CollectionLikeNotice.tsx b/src/components/Notice/CollectionNotice/CollectionLikeNotice.tsx new file mode 100644 index 0000000000..56aeec5dcc --- /dev/null +++ b/src/components/Notice/CollectionNotice/CollectionLikeNotice.tsx @@ -0,0 +1,50 @@ +import gql from 'graphql-tag' +import { FormattedMessage } from 'react-intl' + +import { TEST_ID } from '~/common/enums' +import { CollectionNoticeFragment } from '~/gql/graphql' + +import NoticeActorAvatar from '../NoticeActorAvatar' +import NoticeCollectionTitle from '../NoticeCollectionTitle' +import NoticeDate from '../NoticeDate' +import NoticeDigest from '../NoticeDigest' +import NoticeHeadActors from '../NoticeHeadActors' + +const CollectionNewLikeNotice = ({ + notice, +}: { + notice: CollectionNoticeFragment +}) => { + return ( + + } + title={} + testId={TEST_ID.NOTICE_USER_NEW_FOLLOWER} + /> + ) +} + +CollectionNewLikeNotice.fragments = { + notice: gql` + fragment CollectionNewLikeNotice on CollectionNotice { + id + ...NoticeDate + actors { + ...NoticeActorAvatarUser + ...NoticeHeadActorsUser + } + target { + ...NoticeCollectionTitle + } + } + ${NoticeCollectionTitle.fragments.collection} + ${NoticeActorAvatar.fragments.user} + ${NoticeHeadActors.fragments.user} + ${NoticeDate.fragments.notice} + `, +} + +export default CollectionNewLikeNotice diff --git a/src/components/Notice/CollectionNotice/index.tsx b/src/components/Notice/CollectionNotice/index.tsx new file mode 100644 index 0000000000..688420817f --- /dev/null +++ b/src/components/Notice/CollectionNotice/index.tsx @@ -0,0 +1,33 @@ +import gql from 'graphql-tag' + +import { CollectionNoticeFragment } from '~/gql/graphql' + +import CollectionNewLikeNotice from './CollectionLikeNotice' + +const CollectionNotice = ({ notice }: { notice: CollectionNoticeFragment }) => { + switch (notice.type) { + default: + return // so far just one type of notice + } +} + +CollectionNotice.fragments = { + notice: gql` + fragment CollectionNotice on CollectionNotice { + id + unread + type: __typename + target { + id + title + author { + userName + } + } + ...CollectionNewLikeNotice + } + ${CollectionNewLikeNotice.fragments.notice} + `, +} + +export default CollectionNotice diff --git a/src/components/Notice/NoticeCollectionTitle.tsx b/src/components/Notice/NoticeCollectionTitle.tsx new file mode 100644 index 0000000000..31eedbcb95 --- /dev/null +++ b/src/components/Notice/NoticeCollectionTitle.tsx @@ -0,0 +1,53 @@ +import gql from 'graphql-tag' +import Link from 'next/link' +import { useContext } from 'react' + +import { TEST_ID } from '~/common/enums' +import { toPath } from '~/common/utils' +import { truncateNoticeTitle } from '~/common/utils/text/notice' +import { CollectionNoticeFragment } from '~/gql/graphql' + +import { LanguageContext } from '../Context' +import styles from './styles.module.css' + +const NoticeCollectionTitle = ({ + notice, +}: { + notice: CollectionNoticeFragment | null +}) => { + const userId = notice?.target?.author.userName + const { lang } = useContext(LanguageContext) + + if (!notice || !userId) { + return null + } + + const path = toPath({ + page: 'collectionDetail', + collection: notice.target, + userName: userId, + }) + + return ( + + + {truncateNoticeTitle(notice.target.title, { locale: lang })} + + + ) +} + +NoticeCollectionTitle.fragments = { + collection: gql` + fragment NoticeCollectionTitle on Collection { + author { + userName + } + } + `, +} + +export default NoticeCollectionTitle diff --git a/src/components/Notice/NoticeDigest/index.tsx b/src/components/Notice/NoticeDigest/index.tsx index f89c0298e1..45fd76f673 100644 --- a/src/components/Notice/NoticeDigest/index.tsx +++ b/src/components/Notice/NoticeDigest/index.tsx @@ -11,6 +11,7 @@ import { CircleNewBroadcastNoticeFragment, CircleNewDiscussionCommentsFragment, CircleNewUserNoticeFragment, + CollectionNewLikeNoticeFragment, CommentMentionedYouNoticeFragment, CommentNewReplyNoticeFragment, MomentLikedNoticeFragment, @@ -47,6 +48,7 @@ type NoticeDigestProps = { | MomentNewCommentNoticeFragment | MomentLikedNoticeFragment | MomentMentionedYouNoticeFragment + | CollectionNewLikeNoticeFragment actors?: (NoticeActorAvatarUserFragment & NoticeHeadActorsUserFragment)[] action: string | ReactElement secondAction?: string | ReactElement diff --git a/src/components/Notice/NoticeMomentTitle.tsx b/src/components/Notice/NoticeMomentTitle.tsx index 5cd0a97dc4..aeea1cff37 100644 --- a/src/components/Notice/NoticeMomentTitle.tsx +++ b/src/components/Notice/NoticeMomentTitle.tsx @@ -4,7 +4,7 @@ import { useContext } from 'react' import { useIntl } from 'react-intl' import { TEST_ID } from '~/common/enums' -import { stripHtml, toPath, truncateMomentTitle } from '~/common/utils' +import { stripHtml, toPath, truncateNoticeTitle } from '~/common/utils' import { LanguageContext } from '~/components' import { NoticeMomentTitleFragment } from '~/gql/graphql' @@ -23,7 +23,10 @@ const NoticeMomentTitle = ({ moment, }) - const title = truncateMomentTitle(stripHtml(moment.content || ''), 10, lang) + const title = truncateNoticeTitle(stripHtml(moment.content || ''), { + maxLength: 10, + locale: lang, + }) const images = moment.assets.length ? intl .formatMessage({ defaultMessage: `[image]`, id: 'W3tqQO' }) diff --git a/src/components/Notice/index.tsx b/src/components/Notice/index.tsx index e848e0ad6a..588b992f55 100644 --- a/src/components/Notice/index.tsx +++ b/src/components/Notice/index.tsx @@ -6,6 +6,7 @@ import { DigestNoticeFragment } from '~/gql/graphql' import ArticleArticleNotice from './ArticleArticleNotice' import ArticleNotice from './ArticleNotice' import CircleNotice from './CircleNotice' +import CollectionNotice from './CollectionNotice' import CommentCommentNotice from './CommentCommentNotice' import CommentNotice from './CommentNotice' import MomentNotice from './MomentNotice' @@ -47,6 +48,9 @@ const fragments = { ... on MomentNotice { ...MomentNotice } + ... on CollectionNotice { + ...CollectionNotice + } } ${UserNotice.fragments.notice} ${ArticleArticleNotice.fragments.notice} @@ -57,6 +61,7 @@ const fragments = { ${CircleNotice.fragments.notice} ${OfficialAnnouncementNotice.fragments.notice} ${MomentNotice.fragments.notice} + ${CollectionNotice.fragments.notice} `, } @@ -82,6 +87,8 @@ export const Notice: React.FC & { return case 'MomentNotice': return + case 'CollectionNotice': + return default: return null } diff --git a/src/stories/components/Notices/mock.ts b/src/stories/components/Notices/mock.ts index 81ce1387fc..6ea0d8336b 100644 --- a/src/stories/components/Notices/mock.ts +++ b/src/stories/components/Notices/mock.ts @@ -3,6 +3,7 @@ import { MOCK_CIRCLE, MOCK_CIRCLE_ARTICLE, MOCK_CIRCLE_COMMENT, + MOCK_COLLECTION, MOCK_COMMENT, MOCK_MOMENT, MOCK_MOMENT_COMMENT, @@ -244,7 +245,17 @@ export const MOCK_NOTICE_LIST = [ comment: MOCK_PARENT_COMMENT, reply: MOCK_CIRCLE_COMMENT, }, - + /** + * Collection + */ + { + __typename: 'CollectionNotice' as any, + id: 'CollectionNewLike', + unread: false, + createdAt: '2024-07-24T07:29:17.682Z', + actors: [MOCK_USER], + target: MOCK_COLLECTION, + }, /** * Moment */ diff --git a/src/stories/mocks/index.ts b/src/stories/mocks/index.ts index 4416e73e20..d49e4ca11c 100644 --- a/src/stories/mocks/index.ts +++ b/src/stories/mocks/index.ts @@ -271,6 +271,8 @@ export const MOCK_COLLECTION = { totalCount: 1, edges: [{ node: MOCK_ARTILCE }], }, + liked: false, + likeCount: 12, } // Transaction