diff --git a/api/internal/application/tweet/service.go b/api/internal/application/tweet/service.go index 35409293..2ae96881 100644 --- a/api/internal/application/tweet/service.go +++ b/api/internal/application/tweet/service.go @@ -87,27 +87,29 @@ type UserProfileDTO struct { // QuotedTweetDTO 被引用推文读模型。 type QuotedTweetDTO struct { - ID string `json:"id"` - Author AuthorDTO `json:"author"` - Content string `json:"content"` - Images []string `json:"images"` - QuoteCount int `json:"quote_count"` - CreatedAt string `json:"created_at"` + ID string `json:"id"` + Author AuthorDTO `json:"author"` + Content string `json:"content"` + Images []string `json:"images"` + Emote map[string]EmojiRef `json:"emote,omitempty"` + QuoteCount int `json:"quote_count"` + CreatedAt string `json:"created_at"` } // TweetDTO 推文读模型(序列化跨层传输)。 type TweetDTO struct { - ID string `json:"id"` - Author AuthorDTO `json:"author"` - Content string `json:"content"` - Images []string `json:"images"` - LikeCount int `json:"like_count"` - IsLiked bool `json:"is_liked"` - CommentCount int `json:"comment_count"` - QuoteCount int `json:"quote_count"` - QuoteOf *string `json:"quote_of,omitempty"` - QuotedTweet *QuotedTweetDTO `json:"quoted_tweet,omitempty"` - CreatedAt string `json:"created_at"` + ID string `json:"id"` + Author AuthorDTO `json:"author"` + Content string `json:"content"` + Images []string `json:"images"` + Emote map[string]EmojiRef `json:"emote,omitempty"` + LikeCount int `json:"like_count"` + IsLiked bool `json:"is_liked"` + CommentCount int `json:"comment_count"` + QuoteCount int `json:"quote_count"` + QuoteOf *string `json:"quote_of,omitempty"` + QuotedTweet *QuotedTweetDTO `json:"quoted_tweet,omitempty"` + CreatedAt string `json:"created_at"` } // --- 写用例 --- @@ -435,6 +437,29 @@ func (s *Service) toDTOs(ctx context.Context, tweets []*domaintweet.Tweet) []Twe } } + // 批量查表构建 emote 表情映射(推文正文与被引用推文正文) + allEmoteMap := make(map[string]EmojiRef) + if s.emojiLookup != nil && (len(tweets) > 0 || len(quotedTweetsMap) > 0) { + nameSet := make(map[string]bool) + for _, tw := range tweets { + collectEmojiNames(tw.Content(), nameSet) + } + for _, qt := range quotedTweetsMap { + collectEmojiNames(qt.Content(), nameSet) + } + if len(nameSet) > 0 { + names := make([]string, 0, len(nameSet)) + for n := range nameSet { + names = append(names, n) + } + if em, err := s.emojiLookup.FindByNames(ctx, names); err == nil { + allEmoteMap = em + } else { + log.Warn().Err(err).Msg("推文表情批量查询失败,降级为空表情") + } + } + } + dtos := make([]TweetDTO, 0, len(tweets)) for _, tw := range tweets { var quoteOfStr *string @@ -448,6 +473,7 @@ func (s *Service) toDTOs(ctx context.Context, tweets []*domaintweet.Tweet) []Twe Author: authors[qt.AuthorID().String()], Content: qt.Content(), Images: qt.Images(), + Emote: filterEmoteForBody(qt.Content(), allEmoteMap), QuoteCount: int(quoteCountMap[qt.ID().String()]), CreatedAt: qt.CreatedAt().UTC().Format(time.RFC3339), } @@ -459,6 +485,7 @@ func (s *Service) toDTOs(ctx context.Context, tweets []*domaintweet.Tweet) []Twe Author: authors[tw.AuthorID().String()], Content: tw.Content(), Images: tw.Images(), + Emote: filterEmoteForBody(tw.Content(), allEmoteMap), LikeCount: tw.LikeCount(), IsLiked: likedMap[tw.ID().String()], CommentCount: int(commentCountMap[tw.ID().String()]), @@ -789,9 +816,15 @@ func collectEmojiNames(body string, set map[string]bool) { // filterEmoteForBody 从全量 emote 表中筛出 body 实际用到的表情。 func filterEmoteForBody(body string, all map[string]EmojiRef) map[string]EmojiRef { - result := make(map[string]EmojiRef) + if len(all) == 0 { + return nil + } + var result map[string]EmojiRef for _, m := range emojiBodyPattern.FindAllString(body, -1) { if ref, ok := all[m]; ok { + if result == nil { + result = make(map[string]EmojiRef) + } result[m] = ref } } diff --git a/api/internal/application/tweet/service_test.go b/api/internal/application/tweet/service_test.go index a7eea84e..da2286a5 100644 --- a/api/internal/application/tweet/service_test.go +++ b/api/internal/application/tweet/service_test.go @@ -1152,3 +1152,47 @@ func TestCreateComment_EmoteEnriched(t *testing.T) { require.Contains(t, dto.Emote, "[dog]", "创建返回的单条 DTO 也应富化 emote") assert.Equal(t, "https://emoji/dog.png", dto.Emote["[dog]"].URL) } + +func TestTweet_EmoteEnriched(t *testing.T) { + author := newTestUser(t, "author") + authorID := author.GetID() + lookup := &fakeEmojiLookup{refs: map[string]EmojiRef{ + "[doge]": {URL: "https://emoji/doge.png", GifURL: "https://emoji/doge.gif", Size: 2}, + "[cat]": {URL: "https://emoji/cat.png", Size: 1}, + }} + users := &fakeUserRepo{byIDs: map[string]*domainuser.User{authorID.String(): author}} + + quotedID := shared.NewID() + quoted := domaintweet.ReconstructTweet(quotedID, authorID, "原推 [cat]", []string{}, nil, 0, time.Now(), time.Now()) + + mainID := shared.NewID() + mainTw := domaintweet.ReconstructTweet(mainID, authorID, "转推 [doge]", []string{}, "edID, 0, time.Now(), time.Now()) + + repo := &fakeTweetRepo{ + findByIDData: map[string]*domaintweet.Tweet{ + mainID.String(): mainTw, + quotedID.String(): quoted, + }, + tweets: []*domaintweet.Tweet{mainTw}, + } + + svc := NewService(repo, nil, users, nil, nil, lookup, appshared.NoopEventBus{}) + + // 1. GetByID 富化 + dto, err := svc.GetByID(context.Background(), mainID.String()) + require.NoError(t, err) + require.Contains(t, dto.Emote, "[doge]") + assert.Equal(t, "https://emoji/doge.png", dto.Emote["[doge]"].URL) + assert.NotContains(t, dto.Emote, "[cat]", "主推文不包含未引用的 [cat]") + require.NotNil(t, dto.QuotedTweet) + require.Contains(t, dto.QuotedTweet.Emote, "[cat]") + assert.Equal(t, "https://emoji/cat.png", dto.QuotedTweet.Emote["[cat]"].URL) + + // 2. ListTimeline 富化 + dtos, _, err := svc.ListTimeline(context.Background(), "", 10) + require.NoError(t, err) + require.Len(t, dtos, 1) + require.Contains(t, dtos[0].Emote, "[doge]") + require.NotNil(t, dtos[0].QuotedTweet) + require.Contains(t, dtos[0].QuotedTweet.Emote, "[cat]") +} diff --git a/web/src/entities/tweet/model/types.ts b/web/src/entities/tweet/model/types.ts index e62bd397..dbc7833e 100644 --- a/web/src/entities/tweet/model/types.ts +++ b/web/src/entities/tweet/model/types.ts @@ -36,6 +36,8 @@ export interface QuotedTweet { content: string; /** 图片 URL 列表 */ images: string[]; + /** 表情映射表。key 为 [name](含方括号),渲染时查表替换为 img。对应后端 QuotedTweetDTO.emote。 */ + emote?: Record; /** 引用计数 */ quote_count: number; /** 创建时间,RFC3339 字符串 */ @@ -56,6 +58,8 @@ export interface Tweet { content: string; /** 图片 URL 列表(/uploads/...),≤4 张;纯文本推文为空数组 */ images: string[]; + /** 表情映射表。key 为 [name](含方括号),渲染时查表替换为 img。对应后端 TweetDTO.emote。 */ + emote?: Record; /** 赞数(冗余计数,列表页性能用;点赞数据源见 tweet_likes) */ like_count: number; /** 当前登录用户是否已点赞 */ @@ -71,6 +75,9 @@ export interface Tweet { created_at: string; } +/** TweetEmoteRef - 推文表情映射值别名 */ +export type TweetEmoteRef = TweetCommentEmoteRef; + /** * TweetCommentPicture - 推文评论附图 * diff --git a/web/src/features/tweets/ui/TweetCard.tsx b/web/src/features/tweets/ui/TweetCard.tsx index dd91d9a6..cef26384 100644 --- a/web/src/features/tweets/ui/TweetCard.tsx +++ b/web/src/features/tweets/ui/TweetCard.tsx @@ -205,6 +205,7 @@ const TweetCard = ({ tweet, variant = "timeline", onDeleted }: TweetCardProps) = {hasContent && ( )} diff --git a/web/src/features/tweets/ui/TweetComposer.tsx b/web/src/features/tweets/ui/TweetComposer.tsx index f377108b..77677448 100644 --- a/web/src/features/tweets/ui/TweetComposer.tsx +++ b/web/src/features/tweets/ui/TweetComposer.tsx @@ -1,14 +1,17 @@ /** TweetComposer - 推文发布框(登录态:文本 ≤500 rune + ≤4 图,前端拦截边界) */ +import type { Emoji } from "@entities/emoji/model/types"; import type { QuotedTweet, Tweet } from "@entities/tweet/model/types"; import { useMe } from "@features/auth/api/queries"; +import { EmojiPicker } from "@features/emojis/ui/EmojiPicker"; import { useChunkedUpload } from "@features/upload/hooks/use-chunked-upload"; import { ApiError } from "@shared/api/error"; import { avatarUrl, contentImageUrl } from "@shared/lib/image-url"; +import { isImageURL } from "@shared/lib/url"; import { Button } from "@shared/ui/base/button"; import { formatDistanceToNow } from "date-fns"; import { zhCN } from "date-fns/locale"; -import { AlertCircle, ImagePlus, Loader2, Send, X } from "lucide-react"; +import { AlertCircle, ImagePlus, Loader2, Send, Smile, X } from "lucide-react"; import { useRef, useState } from "react"; import { toast } from "sonner"; import { useCreateTweet } from "../api/mutations"; @@ -44,6 +47,7 @@ export function TweetComposer({ quotedTweet, onSuccess, onCancelQuote }: TweetCo const me = useMe(); const [content, setContent] = useState(""); const [images, setImages] = useState([]); + const textareaRef = useRef(null); const inputRef = useRef(null); const idRef = useRef(0); const createTweet = useCreateTweet(); @@ -116,6 +120,29 @@ export function TweetComposer({ quotedTweet, onSuccess, onCancelQuote }: TweetCo setImages((prev) => prev.filter((i) => i.id !== id)); }; + const handleEmojiSelect = (emoji: Emoji) => { + const imageUrl = emoji.gif_url || emoji.url; + const emojiText = + imageUrl && isImageURL(imageUrl) ? `[${emoji.name}]` : emoji.text_content || emoji.name; + + const textarea = textareaRef.current; + if (!textarea) { + setContent((prev) => prev + emojiText); + return; + } + + const start = textarea.selectionStart ?? content.length; + const end = textarea.selectionEnd ?? content.length; + const nextContent = content.slice(0, start) + emojiText + content.slice(end); + setContent(nextContent); + + const nextCursor = start + emojiText.length; + requestAnimationFrame(() => { + textarea.focus(); + textarea.setSelectionRange(nextCursor, nextCursor); + }); + }; + const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (overLimit) { @@ -155,6 +182,7 @@ export function TweetComposer({ quotedTweet, onSuccess, onCancelQuote }: TweetCo )}