feat(business): implement block-based content editing and rich text rendering
All checks were successful
Frontend CI / ota-android (push) Successful in 1m18s
Frontend CI / ota-ios (push) Successful in 1m31s
Frontend CI / build-and-push-web (push) Successful in 2m49s
Frontend CI / build-android-apk (push) Successful in 1h18m20s

Introduce a new `BlockEditor` component and upgrade the post/message
rendering system to support rich text segments (images, @mentions,
votes, and post references).

- Implement `BlockEditor` for long-form post creation with image
  embedding support.
- Upgrade `PostContentRenderer` and `SegmentRenderer` to handle
  complex segment types including inline images and block elements.
- Refactor `PostCard` to use segment-based content and image
  signatures for optimized memoization.
- Centralize segment partitioning logic in `segmentUtils.ts` to ensure
  consistent rendering across chat and post modules.
- Update `PostRepository` and `Post` entity to support the new
  `segments` data structure.
This commit is contained in:
2026-05-08 01:57:05 +08:00
parent ea9e51b0b0
commit d4c3e1f268
18 changed files with 1416 additions and 234 deletions

View File

@@ -39,43 +39,7 @@ const IMAGE_MAX_WIDTH = 200;
const IMAGE_MAX_HEIGHT = 260;
const IMAGE_FALLBACK_SIZE = { width: 200, height: 150 };
/** 将非 reply 的 segments 分成:行内文案/@、连续图片组、其它块级(音视频文件等) */
type ContentChunk =
| { kind: 'inline'; parts: MessageSegment[] }
| { kind: 'images'; parts: MessageSegment[] }
| { kind: 'block'; segment: MessageSegment };
function partitionMessageSegments(segments: MessageSegment[]): ContentChunk[] {
const out: ContentChunk[] = [];
let inlineBuf: MessageSegment[] = [];
const flushInline = () => {
if (inlineBuf.length) {
out.push({ kind: 'inline', parts: [...inlineBuf] });
inlineBuf = [];
}
};
for (const s of segments) {
if (s.type === 'image') {
flushInline();
const last = out[out.length - 1];
if (last?.kind === 'images') {
last.parts.push(s);
} else {
out.push({ kind: 'images', parts: [s] });
}
} else if (s.type === 'video' || s.type === 'file' || s.type === 'link' || s.type === 'voice') {
flushInline();
out.push({ kind: 'block', segment: s });
} else if (s.type === 'text' || s.type === 'at' || s.type === 'face') {
inlineBuf.push(s);
} else {
inlineBuf.push(s);
}
}
flushInline();
return out;
}
import { partitionSegments, type ContentChunk } from '../../../../types/dto';
// Segment 渲染器 Props
export interface SegmentRendererProps {
@@ -741,7 +705,7 @@ const MessageSegmentsRendererInner: React.FC<{
const replySegment = useMemo(() => segments.find(s => s.type === 'reply'), [segments]);
const otherSegments = useMemo(() => segments.filter(s => s.type !== 'reply'), [segments]);
const chunks = useMemo(() => partitionMessageSegments(otherSegments), [otherSegments]);
const chunks = useMemo(() => partitionSegments(otherSegments), [otherSegments]);
const renderProps = useMemo(() => ({
isMe,

View File

@@ -296,11 +296,11 @@ export function createChatScreenStyles(colors: AppColors, dynamicStyles?: {
alignItems: 'flex-start',
},
senderName: {
fontSize: 14,
color: textPrimary,
fontSize: 12,
color: textSecondary,
marginBottom: 4,
marginLeft: 4,
fontWeight: '600',
fontWeight: '500',
},
mySenderName: {
marginLeft: 0,