Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 51 additions & 18 deletions api/internal/application/tweet/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}

// --- 写用例 ---
Expand Down Expand Up @@ -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
Expand All @@ -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),
}
Expand All @@ -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()]),
Expand Down Expand Up @@ -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
}
}
Expand Down
44 changes: 44 additions & 0 deletions api/internal/application/tweet/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}, &quotedID, 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]")
}
7 changes: 7 additions & 0 deletions web/src/entities/tweet/model/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ export interface QuotedTweet {
content: string;
/** 图片 URL 列表 */
images: string[];
/** 表情映射表。key 为 [name](含方括号),渲染时查表替换为 img。对应后端 QuotedTweetDTO.emote。 */
emote?: Record<string, TweetCommentEmoteRef>;
/** 引用计数 */
quote_count: number;
/** 创建时间,RFC3339 字符串 */
Expand All @@ -56,6 +58,8 @@ export interface Tweet {
content: string;
/** 图片 URL 列表(/uploads/...),≤4 张;纯文本推文为空数组 */
images: string[];
/** 表情映射表。key 为 [name](含方括号),渲染时查表替换为 img。对应后端 TweetDTO.emote。 */
emote?: Record<string, TweetCommentEmoteRef>;
/** 赞数(冗余计数,列表页性能用;点赞数据源见 tweet_likes) */
like_count: number;
/** 当前登录用户是否已点赞 */
Expand All @@ -71,6 +75,9 @@ export interface Tweet {
created_at: string;
}

/** TweetEmoteRef - 推文表情映射值别名 */

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[轻微] 导出类型别名 TweetEmoteRef 上方的 JSDoc "TweetEmoteRef - 推文表情映射值别名" 仅是类型名语义复述,未补充代码自表达之外的信息(如 key 格式、与 TweetCommentEmoteRef 的关系、来源字段等)。

💡 修复计划 (Coding Plan)
删除该 JSDoc;如需保留说明,建议补充代码无法自表达的信息,例如它对应 TweetDTO.emote / QuotedTweetDTO.emote 的 value 类型,key 为 [name] 形式的占位符。

export type TweetEmoteRef = TweetCommentEmoteRef;

/**
* TweetCommentPicture - 推文评论附图
*
Expand Down
2 changes: 2 additions & 0 deletions web/src/features/tweets/ui/TweetCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ const TweetCard = ({ tweet, variant = "timeline", onDeleted }: TweetCardProps) =
{hasContent && (
<TweetContent
content={tweet.content}
emote={tweet.emote}
className={
isDetail
? "text-base sm:text-lg leading-relaxed my-1"
Expand Down Expand Up @@ -273,6 +274,7 @@ const TweetCard = ({ tweet, variant = "timeline", onDeleted }: TweetCardProps) =
{tweet.quoted_tweet.content && (
<TweetContent
content={tweet.quoted_tweet.content}
emote={tweet.quoted_tweet.emote}
className="line-clamp-3 text-xs leading-normal text-foreground/90"
/>
)}
Expand Down
47 changes: 46 additions & 1 deletion web/src/features/tweets/ui/TweetComposer.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -44,6 +47,7 @@ export function TweetComposer({ quotedTweet, onSuccess, onCancelQuote }: TweetCo
const me = useMe();
const [content, setContent] = useState("");
const [images, setImages] = useState<ImageItem[]>([]);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const idRef = useRef(0);
const createTweet = useCreateTweet();
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -155,6 +182,7 @@ export function TweetComposer({ quotedTweet, onSuccess, onCancelQuote }: TweetCo
)}
<div className="flex-1 min-w-0 flex flex-col">
<textarea
ref={textareaRef}
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="有什么新鲜事?"
Expand Down Expand Up @@ -263,6 +291,23 @@ export function TweetComposer({ quotedTweet, onSuccess, onCancelQuote }: TweetCo
>
<ImagePlus className="size-4" />
</Button>
<EmojiPicker
onSelect={handleEmojiSelect}
align="start"
closeOnSelect={false}
trigger={
<Button
type="button"
variant="ghost"
size="icon"
className="size-8 text-muted-foreground hover:text-foreground"
aria-label="添加表情"
title="添加表情"
>
<Smile className="size-4" />
</Button>
}
/>
<span
className={
overLimit
Expand Down
23 changes: 13 additions & 10 deletions web/src/features/tweets/ui/TweetContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,24 @@
* TweetContent - 推文正文组件
*
* 将正文中的 #话题# 解析为跳转到 /tweets/topics/$tag 的 Clickable Link,
* 阻止冒泡避免触发整卡点击进入详情页。
* 将正文中的 [name] 占位符解析为内联表情图片,阻止冒泡避免触发整卡点击进入详情页。
*/

import type { TweetEmoteRef } from "@entities/tweet/model/types";
import { EmojiText } from "@shared/ui/emoji-text";
import { Link } from "@tanstack/react-router";
import type React from "react";

const HASHTAG_REGEX = /#([^#\r\n]{1,50})#/g;

export interface TweetContentProps {
/** 推文正文 */

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[轻微] TweetContentProps 中 content: string 字段上的 /** 推文正文 */ 仅是字段名语义复述(父组件名 TweetContent 已暗示这是正文内容),不携带任何额外约束或来源说明。

💡 修复计划 (Coding Plan)
删除该 JSDoc;若仍有保留必要,写出代码无法自表达的内容(如"长度上限 500 rune,原样透传 TweetDTO.content")而非字段名直译。

content: string;
/** 表情映射表,key 为 [name],value 为表情图片 URL */
emote?: Record<string, TweetEmoteRef>;
className?: string;
}

export function TweetContent({ content, className }: TweetContentProps) {
export function TweetContent({ content, emote, className }: TweetContentProps) {
if (!content) return null;

const elements: React.ReactNode[] = [];
Expand All @@ -33,12 +37,11 @@ export function TweetContent({ content, className }: TweetContentProps) {
const fullMatch = match[0];
const tagName = match[1];

// 匹配前的普通文本
// 匹配前的普通文本(支持表情)
if (matchStart > lastIndex) {
elements.push(content.slice(lastIndex, matchStart));
const textChunk = content.slice(lastIndex, matchStart);
elements.push(<EmojiText key={`text-${lastIndex}`} text={textChunk} emote={emote} />);
}

// 话题标签 Link
elements.push(
<Link
key={`${matchStart}-${tagName}`}
Expand All @@ -54,11 +57,11 @@ export function TweetContent({ content, className }: TweetContentProps) {
lastIndex = matchEnd;
}

// 剩余文本
// 剩余文本(支持表情)
if (lastIndex < content.length) {
elements.push(content.slice(lastIndex));
const textChunk = content.slice(lastIndex);
elements.push(<EmojiText key={`text-${lastIndex}`} text={textChunk} emote={emote} />);
}

return (
<p
className={`whitespace-pre-wrap wrap-break-word text-sm leading-relaxed text-foreground ${className ?? ""}`}
Expand Down
Loading
Loading