feat(content): add rich content rendering with @mentions and vote segments
Some checks failed
Frontend CI / build-and-push-web (push) Failing after 8m30s
Frontend CI / ota-android (push) Successful in 10m43s
Frontend CI / build-android-apk (push) Successful in 1h24m38s

- Add PostContentRenderer component for rendering posts/comments with segments
- Add PostMentionInput component for @mention support in post/comment creation
- Add VoteSegmentData and vote segment type for unified post creation API
- Update post and comment services to support segments in API requests
- Fix auth service to clear tokens before login/register
- Improve database initialization with proper close/retry logic
- Add verification check before allowing post creation
- Add metro web shims for react-native-webrtc full package and requireNativeComponent
- Update build config with git hash based version numbers
This commit is contained in:
lafay
2026-04-23 22:29:58 +08:00
parent eb4e1c080d
commit b2c3d5e54e
23 changed files with 797 additions and 127 deletions

View File

@@ -1,10 +1,21 @@
const appJson = require('./app.json'); const appJson = require('./app.json');
const { execSync } = require('child_process');
const isDevVariant = process.env.APP_VARIANT === 'dev'; const isDevVariant = process.env.APP_VARIANT === 'dev';
const releaseApiBaseUrl = 'https://withyou.littlelan.cn/api/v1'; const releaseApiBaseUrl = 'https://withyou.littlelan.cn/api/v1';
const releaseUpdatesBaseUrl = 'https://updates.littlelan.cn'; const releaseUpdatesBaseUrl = 'https://updates.littlelan.cn';
const devApiBaseUrl = process.env.EXPO_PUBLIC_API_BASE_URL || 'http://192.168.31.238:8080/api/v1'; const devApiBaseUrl = process.env.EXPO_PUBLIC_API_BASE_URL || 'http://192.168.31.238:8080/api/v1';
function getGitShortHash() {
try {
return execSync('git rev-parse --short=4 HEAD', { encoding: 'utf-8' }).trim();
} catch {
return '0000';
}
}
const gitBuildSuffix = getGitShortHash();
function toManifestUrl(baseUrl, portOverride) { function toManifestUrl(baseUrl, portOverride) {
const parsed = new URL(baseUrl); const parsed = new URL(baseUrl);
if (portOverride != null) { if (portOverride != null) {
@@ -58,10 +69,14 @@ module.exports = {
checkAutomatically: 'ON_LOAD', checkAutomatically: 'ON_LOAD',
fallbackToCacheTimeout: 0, fallbackToCacheTimeout: 0,
}, },
ios: expo.ios, ios: {
...expo.ios,
buildNumber: gitBuildSuffix,
},
android: { android: {
...expo.android, ...expo.android,
package: 'skin.carrot.bbs', package: 'cn.qczlit.withyou',
versionCode: parseInt(gitBuildSuffix, 16),
}, },
// Web 端使用过滤后的插件 // Web 端使用过滤后的插件
plugins: filteredPlugins, plugins: filteredPlugins,

View File

@@ -1,8 +1,8 @@
{ {
"expo": { "expo": {
"name": "威友", "name": "威友",
"slug": "qojo", "slug": "qojo",
"version": "0.1.sha", "version": "0.0.1",
"orientation": "default", "orientation": "default",
"icon": "./assets/icon.png", "icon": "./assets/icon.png",
"userInterfaceStyle": "automatic", "userInterfaceStyle": "automatic",

View File

@@ -29,13 +29,37 @@ const webShimPaths = [
'react-native/Libraries/Utilities/codegenNativeComponent', 'react-native/Libraries/Utilities/codegenNativeComponent',
'react-native/Libraries/Utilities/codegenNativeCommands', 'react-native/Libraries/Utilities/codegenNativeCommands',
'react-native/Libraries/Types/CodegenTypes', 'react-native/Libraries/Types/CodegenTypes',
'react-native/Libraries/ReactNative/requireNativeComponent',
]; ];
// Packages that should be entirely replaced with web shims on web platform.
// These packages import requireNativeComponent directly from 'react-native'
// (which resolves to react-native-web on web) and don't have web implementations.
const webPackageShims = {
'react-native-webrtc': path.resolve(__dirname, 'web-shims/react-native-webrtc/index.js'),
};
const originalResolveRequest = config.resolver.resolveRequest; const originalResolveRequest = config.resolver.resolveRequest;
const shimDir = path.resolve(__dirname, 'web-shims'); const shimDir = path.resolve(__dirname, 'web-shims');
config.resolver.resolveRequest = (context, moduleName, platform) => { config.resolver.resolveRequest = (context, moduleName, platform) => {
// Redirect entire native-only packages to web shims
if (platform === 'web' && webPackageShims[moduleName]) {
return {
type: 'sourceFile',
filePath: webPackageShims[moduleName],
};
}
// Redirect specific React Native internal modules to web shims
if (platform === 'web' && webShimPaths.some((p) => moduleName.endsWith(p))) { if (platform === 'web' && webShimPaths.some((p) => moduleName.endsWith(p))) {
// For requireNativeComponent, the path doesn't follow the Libraries/* pattern
if (moduleName.endsWith('react-native/Libraries/ReactNative/requireNativeComponent')) {
return {
type: 'sourceFile',
filePath: path.join(shimDir, 'react-native', 'Libraries', 'ReactNative', 'requireNativeComponent.js'),
};
}
const shimName = moduleName.replace('react-native/Libraries/', ''); const shimName = moduleName.replace('react-native/Libraries/', '');
return { return {
type: 'sourceFile', type: 'sourceFile',

2
package-lock.json generated
View File

@@ -6,7 +6,7 @@
"packages": { "packages": {
"": { "": {
"name": "with_you", "name": "with_you",
"version": "0.1.sha", "version": "0.0.1",
"dependencies": { "dependencies": {
"@expo/vector-icons": "^15.1.1", "@expo/vector-icons": "^15.1.1",
"@react-native-async-storage/async-storage": "^2.2.0", "@react-native-async-storage/async-storage": "^2.2.0",

View File

@@ -1,6 +1,6 @@
{ {
"name": "with_you", "name": "with_you",
"version": "0.1.sha", "version": "0.0.1",
"main": "expo-router/entry", "main": "expo-router/entry",
"scripts": { "scripts": {
"start": "expo start", "start": "expo start",

View File

@@ -13,6 +13,7 @@ import { Comment, CommentImage } from '../../types';
import Text from '../common/Text'; import Text from '../common/Text';
import Avatar from '../common/Avatar'; import Avatar from '../common/Avatar';
import { CompactImageGrid, ImageGridItem } from '../common'; import { CompactImageGrid, ImageGridItem } from '../common';
import PostContentRenderer from './PostContentRenderer';
interface CommentItemProps { interface CommentItemProps {
comment: Comment; comment: Comment;
@@ -614,9 +615,17 @@ const CommentItem: React.FC<CommentItemProps> = ({
{/* 评论文本 - 非气泡样式 */} {/* 评论文本 - 非气泡样式 */}
<View style={styles.commentContent}> <View style={styles.commentContent}>
{comment.segments && comment.segments.length > 0 ? (
<PostContentRenderer
content={comment.content}
segments={comment.segments}
textStyle={[styles.text, { color: colors.text.primary }]}
/>
) : (
<Text variant="body" color={colors.text.primary} style={styles.text}> <Text variant="body" color={colors.text.primary} style={styles.text}>
{comment.content} {comment.content}
</Text> </Text>
)}
</View> </View>
{/* 评论图片 */} {/* 评论图片 */}

View File

@@ -23,6 +23,7 @@ import Avatar from '../../common/Avatar';
import { spacing, borderRadius, fontSizes, useAppColors, type AppColors } from '../../../theme'; import { spacing, borderRadius, fontSizes, useAppColors, type AppColors } from '../../../theme';
import { getPreviewImageUrl } from '../../../utils/imageHelper'; import { getPreviewImageUrl } from '../../../utils/imageHelper';
import PostImages from './components/PostImages'; import PostImages from './components/PostImages';
import PostContentRenderer from '../PostContentRenderer';
import { useResponsive } from '../../../hooks/useResponsive'; import { useResponsive } from '../../../hooks/useResponsive';
import { formatPostCreatedAtString } from '../../../core/entities/Post'; import { formatPostCreatedAtString } from '../../../core/entities/Post';
@@ -208,6 +209,9 @@ function createPostCardStyles(colors: AppColors) {
marginBottom: spacing.xs, marginBottom: spacing.xs,
lineHeight: fontSizes.md * 1.45, lineHeight: fontSizes.md * 1.45,
}, },
contentRenderer: {
marginBottom: spacing.xs,
},
expandBtn: { expandBtn: {
marginBottom: spacing.sm, marginBottom: spacing.sm,
}, },
@@ -649,12 +653,22 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
{!isCompact && !!content && ( {!isCompact && !!content && (
<> <>
{post.segments && post.segments.length > 0 ? (
<PostContentRenderer
content={content}
segments={post.segments}
numberOfLines={isExpanded ? undefined : contentNumberOfLines}
style={styles.contentRenderer}
textStyle={styles.content}
/>
) : (
<Text <Text
style={styles.content} style={styles.content}
numberOfLines={isExpanded ? undefined : contentNumberOfLines} numberOfLines={isExpanded ? undefined : contentNumberOfLines}
> >
{highlightKeyword ? <HighlightText text={content} keyword={highlightKeyword} style={styles.highlight} /> : content} {highlightKeyword ? <HighlightText text={content} keyword={highlightKeyword} style={styles.highlight} /> : content}
</Text> </Text>
)}
{shouldTruncate && ( {shouldTruncate && (
<TouchableOpacity onPress={() => setIsExpanded((prev) => !prev)} style={styles.expandBtn}> <TouchableOpacity onPress={() => setIsExpanded((prev) => !prev)} style={styles.expandBtn}>
<Text style={styles.expandText}>{isExpanded ? '收起' : '展开全文'}</Text> <Text style={styles.expandText}>{isExpanded ? '收起' : '展开全文'}</Text>

View File

@@ -0,0 +1,153 @@
/**
* PostContentRenderer - 帖子内容渲染器
* 根据 segments 渲染富文本内容(@提及、投票等)
* 降级:如果 segments 为空但 content 非空,直接显示纯文本
*/
import React from 'react';
import { View, TouchableOpacity, StyleSheet } from 'react-native';
import Text from '../common/Text';
import { useAppColors } from '../../theme';
import { MessageSegment, AtSegmentData, VoteSegmentData } from '../../types';
interface PostContentRendererProps {
content?: string;
segments?: MessageSegment[];
numberOfLines?: number;
onMentionPress?: (userId: string) => void;
style?: any;
textStyle?: any;
}
const PostContentRenderer: React.FC<PostContentRendererProps> = ({
content,
segments,
numberOfLines,
onMentionPress,
style,
textStyle,
}) => {
const colors = useAppColors();
if (!segments || segments.length === 0) {
return (
<Text numberOfLines={numberOfLines} style={textStyle}>
{content || ''}
</Text>
);
}
const elements: React.ReactNode[] = [];
let keyIndex = 0;
for (const segment of segments) {
const key = `seg_${keyIndex++}`;
switch (segment.type) {
case 'text': {
const text = segment.data?.text || segment.data?.content || '';
if (text) {
elements.push(
<Text key={key} style={textStyle}>
{text}
</Text>
);
}
break;
}
case 'at': {
const atData = segment.data as AtSegmentData;
const userId = atData?.user_id;
const isAtAll = userId === 'all';
elements.push(
<Text
key={key}
style={[
textStyle,
{
color: colors.primary.main,
fontWeight: '500',
},
]}
>
{isAtAll ? '@所有人 ' : '@某人 '}
</Text>
);
break;
}
case 'vote': {
const voteData = segment.data as VoteSegmentData;
if (voteData?.options?.length) {
elements.push(
<View key={key} style={[styles.voteBlock, { borderColor: colors.divider }]}>
<Text style={[styles.voteTitle, { color: colors.text.secondary }]}>
📊 {voteData.options.length}
</Text>
{voteData.options.map((opt, i) => (
<Text key={`vote_opt_${i}`} style={[styles.voteOption, { color: colors.text.primary }]}>
{i + 1}. {opt.content}
</Text>
))}
</View>
);
}
break;
}
case 'image':
case 'link':
case 'face':
default:
break;
}
}
if (elements.length === 0) {
return (
<Text numberOfLines={numberOfLines} style={textStyle}>
{content || ''}
</Text>
);
}
if (numberOfLines) {
return (
<View style={style}>
<Text numberOfLines={numberOfLines} style={textStyle}>
{elements}
</Text>
</View>
);
}
return <View style={[styles.container, style]}>{elements}</View>;
};
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
flexWrap: 'wrap',
alignItems: 'flex-start',
},
voteBlock: {
width: '100%',
borderWidth: 1,
borderRadius: 8,
padding: 10,
marginTop: 4,
marginBottom: 4,
},
voteTitle: {
fontSize: 13,
marginBottom: 6,
},
voteOption: {
fontSize: 14,
paddingVertical: 2,
},
});
export default PostContentRenderer;

View File

@@ -0,0 +1,269 @@
/**
* PostMentionInput - 帖子内容输入框(支持 @提及)
* 复用关注列表加载逻辑
* 检测输入中的 @ 字符,弹出关注者列表供选择
*/
import React, { useState, useEffect, useCallback, useRef } from 'react';
import {
View,
TextInput,
FlatList,
TouchableOpacity,
StyleSheet,
Platform,
} from 'react-native';
import Text from '../common/Text';
import Avatar from '../common/Avatar';
import { useAppColors, spacing, fontSizes, borderRadius } from '../../theme';
import { useAuthStore } from '../../stores';
import { authService } from '../../services/auth';
import { MessageSegment, AtSegmentData } from '../../types';
interface MentionUser {
id: string;
nickname: string;
avatar: string | null;
}
interface PostMentionInputProps {
value: string;
onChangeText: (text: string) => void;
onSegmentsChange: (segments: MessageSegment[]) => void;
placeholder?: string;
maxLength?: number;
style?: any;
minHeight?: number;
}
const PostMentionInput: React.FC<PostMentionInputProps> = ({
value,
onChangeText,
onSegmentsChange,
placeholder = '说点什么...',
maxLength = 2000,
style,
minHeight = 100,
}) => {
const colors = useAppColors();
const currentUser = useAuthStore(s => s.currentUser);
const [followingUsers, setFollowingUsers] = useState<MentionUser[]>([]);
const [showMentions, setShowMentions] = useState(false);
const [mentionQuery, setMentionQuery] = useState('');
const [mentionStartIndex, setMentionStartIndex] = useState(-1);
const [loaded, setLoaded] = useState(false);
const inputRef = useRef<TextInput>(null);
useEffect(() => {
if (!loaded && currentUser?.id) {
loadFollowing();
setLoaded(true);
}
}, [currentUser?.id, loaded]);
const loadFollowing = async () => {
if (!currentUser?.id) return;
try {
const list = await authService.getFollowingList(currentUser.id, 1, 200);
setFollowingUsers(
list.map(u => ({
id: u.id,
nickname: u.nickname || u.username || '',
avatar: u.avatar || null,
}))
);
} catch (_) {
// ignore
}
};
const filteredUsers = followingUsers.filter(u =>
u.nickname.toLowerCase().includes(mentionQuery.toLowerCase())
);
const activeMentions = useRef(new Map<number, { endIndex: number; userId: string; nickname: string }>());
const segmentsRef = useRef<MessageSegment[]>([]);
const rebuildSegments = useCallback(
(text: string) => {
const mentions = activeMentions.current;
if (mentions.size === 0) {
onSegmentsChange(text.trim() ? [{ type: 'text', data: { text } }] : []);
return;
}
const segments: MessageSegment[] = [];
const sorted = Array.from(mentions.entries()).sort((a, b) => a[0] - b[0]);
let lastEnd = 0;
for (const [startIdx, mention] of sorted) {
if (startIdx > lastEnd) {
segments.push({ type: 'text', data: { text: text.slice(lastEnd, startIdx) } });
}
segments.push({
type: 'at',
data: { user_id: mention.userId, nickname: mention.nickname } as AtSegmentData,
});
lastEnd = mention.endIndex;
}
if (lastEnd < text.length) {
segments.push({ type: 'text', data: { text: text.slice(lastEnd) } });
}
onSegmentsChange(segments);
},
[onSegmentsChange]
);
const handleChangeText = useCallback(
(text: string) => {
onChangeText(text);
const cursorPos = text.length;
let atStart = -1;
for (let i = cursorPos - 1; i >= 0; i--) {
if (text[i] === '@') {
atStart = i;
break;
}
if (text[i] === ' ' || text[i] === '\n') {
break;
}
}
if (atStart >= 0) {
const query = text.slice(atStart + 1, cursorPos);
if (!query.includes(' ') && !query.includes('\n')) {
setMentionQuery(query);
setMentionStartIndex(atStart);
setShowMentions(true);
return;
}
}
setShowMentions(false);
rebuildSegments(text);
},
[onChangeText, rebuildSegments]
);
const handleSelectMention = useCallback(
(mentionUser: MentionUser) => {
if (mentionStartIndex < 0) return;
const before = value.slice(0, mentionStartIndex);
const mentionText = `@${mentionUser.nickname} `;
const newText = before + mentionText;
activeMentions.current.set(mentionStartIndex, {
endIndex: before.length + mentionText.length,
userId: mentionUser.id,
nickname: mentionUser.nickname,
});
onChangeText(newText);
setShowMentions(false);
setMentionQuery('');
setMentionStartIndex(-1);
rebuildSegments(newText);
inputRef.current?.focus();
},
[value, mentionStartIndex, onChangeText, rebuildSegments]
);
const renderMentionItem = ({ item }: { item: MentionUser }) => (
<TouchableOpacity
style={[styles.mentionItem, { borderBottomColor: colors.divider }]}
onPress={() => handleSelectMention(item)}
>
<Avatar source={item.avatar} size={32} name={item.nickname} />
<Text style={[styles.mentionName, { color: colors.text.primary }]}>
{item.nickname}
</Text>
</TouchableOpacity>
);
return (
<View style={style}>
<TextInput
ref={inputRef}
value={value}
onChangeText={handleChangeText}
placeholder={placeholder}
placeholderTextColor={colors.text.hint}
maxLength={maxLength}
multiline
style={[
styles.input,
{
color: colors.text.primary,
backgroundColor: colors.background.default,
},
]}
/>
{showMentions && filteredUsers.length > 0 && (
<View style={[styles.mentionPanel, { backgroundColor: colors.background.paper, borderColor: colors.divider }]}>
<FlatList
data={filteredUsers}
renderItem={renderMentionItem}
keyExtractor={item => item.id}
keyboardShouldPersistTaps="handled"
nestedScrollEnabled
style={styles.mentionList}
/>
</View>
)}
{showMentions && filteredUsers.length === 0 && (
<View style={[styles.mentionPanel, { backgroundColor: colors.background.paper, borderColor: colors.divider }]}>
<Text style={[styles.emptyText, { color: colors.text.hint }]}>
</Text>
</View>
)}
</View>
);
};
const styles = StyleSheet.create({
input: {
fontSize: fontSizes.md,
padding: spacing.sm,
borderRadius: borderRadius.md,
minHeight: 100,
textAlignVertical: 'top' as const,
},
mentionPanel: {
borderWidth: 1,
borderRadius: borderRadius.md,
maxHeight: 200,
marginTop: spacing.xs,
},
mentionList: {
maxHeight: 200,
},
mentionItem: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderBottomWidth: StyleSheet.hairlineWidth,
},
mentionName: {
fontSize: fontSizes.md,
marginLeft: spacing.sm,
},
emptyText: {
fontSize: fontSizes.sm,
padding: spacing.md,
textAlign: 'center',
},
});
export default PostMentionInput;

View File

@@ -22,5 +22,7 @@ export { default as TabBar } from './TabBar';
export { default as VoteCard } from './VoteCard'; export { default as VoteCard } from './VoteCard';
export { default as VoteEditor } from './VoteEditor'; export { default as VoteEditor } from './VoteEditor';
export { default as VotePreview } from './VotePreview'; export { default as VotePreview } from './VotePreview';
export { default as PostContentRenderer } from './PostContentRenderer';
export { default as PostMentionInput } from './PostMentionInput';
export { default as ReportDialog } from './ReportDialog'; export { default as ReportDialog } from './ReportDialog';
export { default as ShareSheet } from './ShareSheet'; export { default as ShareSheet } from './ShareSheet';

View File

@@ -1,5 +1,4 @@
import * as SQLite from 'expo-sqlite'; import * as SQLite from 'expo-sqlite';
import { Platform } from 'react-native';
import { getDbName } from './DatabaseConfig'; import { getDbName } from './DatabaseConfig';
import { runMigrations } from '../schema/MigrationManager'; import { runMigrations } from '../schema/MigrationManager';
@@ -43,34 +42,30 @@ class DatabaseManager {
private async doInitialize(targetUserId: string | null): Promise<void> { private async doInitialize(targetUserId: string | null): Promise<void> {
if (this.db) { if (this.db) {
this.forceClose(this.db); await this.closeAsync(this.db);
this.db = null; this.db = null;
this.currentUserId = null; this.currentUserId = null;
} }
const dbName = getDbName(targetUserId); const dbName = getDbName(targetUserId);
const isWeb = Platform.OS === 'web';
if (isWeb) {
try { try {
const db = await this.tryOpenDatabase(dbName); const db = await this.tryOpenDatabase(dbName);
await runMigrations(db); await runMigrations(db);
this.db = db; this.db = db;
this.currentUserId = targetUserId; this.currentUserId = targetUserId;
} catch (error) { } catch (error) {
console.warn('[DatabaseManager] Web数据库打开失败,使用内存数据库:', error); console.warn('[DatabaseManager] 数据库初始化失败,使用内存数据库:', error);
try {
const db = await SQLite.openDatabaseAsync(':memory:'); const db = await SQLite.openDatabaseAsync(':memory:');
await runMigrations(db); await runMigrations(db);
this.db = db; this.db = db;
this.currentUserId = targetUserId; this.currentUserId = targetUserId;
} catch (fallbackError) {
console.error('[DatabaseManager] 内存数据库也无法初始化:', fallbackError);
throw error;
} }
return;
} }
const db = await this.tryOpenDatabase(dbName);
await runMigrations(db);
this.db = db;
this.currentUserId = targetUserId;
} }
private async tryOpenDatabase(dbName: string): Promise<SQLiteDatabase> { private async tryOpenDatabase(dbName: string): Promise<SQLiteDatabase> {
@@ -87,12 +82,12 @@ class DatabaseManager {
} }
} }
private forceClose(db: SQLiteDatabase): void { private async closeAsync(db: SQLiteDatabase): Promise<void> {
try { try {
db.closeSync(); await db.closeAsync();
} catch { } catch {
try { try {
db.closeAsync().catch(() => {}); db.closeSync();
} catch {} } catch {}
} }
} }
@@ -103,7 +98,7 @@ class DatabaseManager {
} }
if (this.db) { if (this.db) {
this.forceClose(this.db); await this.closeAsync(this.db);
this.db = null; this.db = null;
this.currentUserId = null; this.currentUserId = null;
} }

View File

@@ -1,4 +1,4 @@
/** /**
* 发帖页 CreatePostScreen响应式适配 * 发帖页 CreatePostScreen响应式适配
* 威友 - 发布新帖子 * 威友 - 发布新帖子
* 参考微博发帖界面设计 * 参考微博发帖界面设计
@@ -38,8 +38,10 @@ import { channelService, postService, showPrompt, voteService } from '../../serv
import { ApiError } from '@/services/core'; import { ApiError } from '@/services/core';
import { uploadService } from '@/services/upload'; import { uploadService } from '@/services/upload';
import VoteEditor from '../../components/business/VoteEditor'; import VoteEditor from '../../components/business/VoteEditor';
import PostMentionInput from '../../components/business/PostMentionInput';
import { useResponsive, useResponsiveValue } from '../../hooks'; import { useResponsive, useResponsiveValue } from '../../hooks';
import * as hrefs from '../../navigation/hrefs'; import * as hrefs from '../../navigation/hrefs';
import { MessageSegment } from '../../types';
// Props 接口 // Props 接口
interface CreatePostScreenProps { interface CreatePostScreenProps {
@@ -106,6 +108,7 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
const [content, setContent] = useState(''); const [content, setContent] = useState('');
const [segments, setSegments] = useState<MessageSegment[]>([]);
const [images, setImages] = useState<{ uri: string; uploading?: boolean }[]>([]); const [images, setImages] = useState<{ uri: string; uploading?: boolean }[]>([]);
const [channelOptions, setChannelOptions] = useState<ChannelOption[]>([]); const [channelOptions, setChannelOptions] = useState<ChannelOption[]>([]);
const [selectedChannelId, setSelectedChannelId] = useState<string | null>(null); const [selectedChannelId, setSelectedChannelId] = useState<string | null>(null);
@@ -402,6 +405,7 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
const updated = await postService.updatePost(editPostID, { const updated = await postService.updatePost(editPostID, {
title: title.trim(), title: title.trim(),
content: content.trim(), content: content.trim(),
segments: segments.length > 0 ? segments : undefined,
images: imageUrls, images: imageUrls,
}); });
if (!updated) { if (!updated) {
@@ -415,10 +419,10 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
}); });
router.replace(hrefs.hrefHome()); router.replace(hrefs.hrefHome());
} else { } else {
// 创建普通帖子
await postService.createPost({ await postService.createPost({
title: title.trim(), title: title.trim(),
content: content.trim(), content: content.trim(),
segments: segments.length > 0 ? segments : undefined,
images: imageUrls, images: imageUrls,
channel_id: selectedChannelId || undefined, channel_id: selectedChannelId || undefined,
}); });
@@ -598,22 +602,18 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
maxLength={MAX_TITLE_LENGTH} maxLength={MAX_TITLE_LENGTH}
/> />
{/* 正文输入 */} {/* 正文输入(支持 @提及) */}
<TextInput <PostMentionInput
ref={contentInputRef} value={content}
onChangeText={setContent}
onSegmentsChange={setSegments}
placeholder="分享新鲜事..."
maxLength={MAX_CONTENT_LENGTH}
minHeight={contentInputMinHeight}
style={[ style={[
styles.contentInput, styles.contentInput,
isWideScreen && styles.contentInputWide, isWideScreen && styles.contentInputWide,
{ minHeight: contentInputMinHeight },
]} ]}
value={content}
onChangeText={setContent}
placeholder="分享新鲜事..."
placeholderTextColor={colors.text.hint}
multiline
textAlignVertical="top"
maxLength={MAX_CONTENT_LENGTH}
onSelectionChange={(e) => setSelection(e.nativeEvent.selection)}
/> />
{/* 作为正文容器的子模块,追加在文本后面 */} {/* 作为正文容器的子模块,追加在文本后面 */}

View File

@@ -1,4 +1,4 @@
/** /**
* 首页 HomeScreen * 首页 HomeScreen
* 威友 - 首页展示 * 威友 - 首页展示
* 支持列表和多列网格模式(响应式布局) * 支持列表和多列网格模式(响应式布局)
@@ -27,7 +27,7 @@ import { PagerView } from '../../components/common';
import { useAppColors, useResolvedColorScheme, spacing, borderRadius, shadows, type AppColors } from '../../theme'; import { useAppColors, useResolvedColorScheme, spacing, borderRadius, shadows, type AppColors } from '../../theme';
import { Post } from '../../types'; import { Post } from '../../types';
import { useUserStore, useHomeTabBarVisibilityStore, useHomeTabPressStore } from '../../stores'; import { useUserStore, useHomeTabBarVisibilityStore, useHomeTabPressStore } from '../../stores';
import { useCurrentUser } from '../../stores/auth'; import { useCurrentUser, useIsAuthenticated, useIsVerified, useVerificationStore } from '../../stores/auth';
import { channelService, postService } from '../../services'; import { channelService, postService } from '../../services';
import { PostCard, TabBar, SearchBar, ShareSheet } from '../../components/business'; import { PostCard, TabBar, SearchBar, ShareSheet } from '../../components/business';
import type { PostCardAction } from '../../components/business/PostCard'; import type { PostCardAction } from '../../components/business/PostCard';
@@ -169,6 +169,10 @@ export const HomeScreen: React.FC = () => {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { posts: storePosts } = useUserStore(); const { posts: storePosts } = useUserStore();
const currentUser = useCurrentUser(); const currentUser = useCurrentUser();
const isAuthenticated = useIsAuthenticated();
const isVerified = useIsVerified();
const fetchVerificationStatus = useVerificationStore((s) => s.fetchStatus);
const hasFetchedVerification = useVerificationStore((s) => s.hasFetchedStatus);
const colors = useAppColors(); const colors = useAppColors();
const resolvedScheme = useResolvedColorScheme(); const resolvedScheme = useResolvedColorScheme();
const styles = useMemo(() => createHomeStyles(colors), [colors]); const styles = useMemo(() => createHomeStyles(colors), [colors]);
@@ -207,6 +211,12 @@ export const HomeScreen: React.FC = () => {
const [showShareSheet, setShowShareSheet] = useState(false); const [showShareSheet, setShowShareSheet] = useState(false);
const [sharePost, setSharePost] = useState<Post | null>(null); const [sharePost, setSharePost] = useState<Post | null>(null);
useEffect(() => {
if (isAuthenticated && !hasFetchedVerification) {
fetchVerificationStatus();
}
}, [isAuthenticated, hasFetchedVerification, fetchVerificationStatus]);
useEffect(() => { useEffect(() => {
if (showCreatePost) { if (showCreatePost) {
blurActiveElement(); blurActiveElement();
@@ -656,6 +666,14 @@ export const HomeScreen: React.FC = () => {
// 跳转到发帖页面(使用 Modal 方式) // 跳转到发帖页面(使用 Modal 方式)
const handleCreatePost = () => { const handleCreatePost = () => {
if (!isAuthenticated) {
router.push('/login');
return;
}
if (!isVerified) {
router.push(hrefs.hrefVerificationGuide());
return;
}
setShowCreatePost(true); setShowCreatePost(true);
}; };

View File

@@ -1,4 +1,4 @@
/** /**
* 帖子详情页 PostDetailScreen - QQ频道风格响应式版本 * 帖子详情页 PostDetailScreen - QQ频道风格响应式版本
* 威友 - 显示完整帖子内容和评论 * 威友 - 显示完整帖子内容和评论
* 桌面端使用双栏布局,移动端使用单栏布局 * 桌面端使用双栏布局,移动端使用单栏布局
@@ -33,13 +33,13 @@ import {
useAppColors, useAppColors,
type AppColors, type AppColors,
} from '../../theme'; } from '../../theme';
import { Post, Comment, VoteResultDTO, VoteOptionDTO } from '../../types'; import { Post, Comment, VoteResultDTO, VoteOptionDTO, MessageSegment } from '../../types';
import { useUserStore } from '../../stores'; import { useUserStore } from '../../stores';
import { useCurrentUser } from '../../stores/auth'; import { useCurrentUser } from '../../stores/auth';
import { postService, commentService, uploadService, authService, showPrompt, voteService } from '../../services'; import { postService, commentService, uploadService, authService, showPrompt, voteService } from '../../services';
import { postSyncService } from '@/services/post'; import { postSyncService } from '@/services/post';
import { useCursorPagination } from '../../hooks/useCursorPagination'; import { useCursorPagination } from '../../hooks/useCursorPagination';
import { CommentItem, VoteCard, ReportDialog, ShareSheet } from '../../components/business'; import { CommentItem, VoteCard, ReportDialog, ShareSheet, PostContentRenderer, PostMentionInput } from '../../components/business';
import { Avatar, Button, Loading, EmptyState, Text, ImageGallery, ImageGrid, ImageGridItem, AdaptiveLayout, AppBackButton } from '../../components/common'; import { Avatar, Button, Loading, EmptyState, Text, ImageGallery, ImageGrid, ImageGridItem, AdaptiveLayout, AppBackButton } from '../../components/common';
import { useResponsive, useResponsiveValue, useResponsiveSpacing } from '../../hooks/useResponsive'; import { useResponsive, useResponsiveValue, useResponsiveSpacing } from '../../hooks/useResponsive';
import { handleError } from '@/services/core'; import { handleError } from '@/services/core';
@@ -191,6 +191,7 @@ export const PostDetailScreen: React.FC = () => {
} }
}, [commentsError, postId]); }, [commentsError, postId]);
const [commentText, setCommentText] = useState(''); const [commentText, setCommentText] = useState('');
const [commentSegments, setCommentSegments] = useState<MessageSegment[]>([]);
const [showImageModal, setShowImageModal] = useState(false); const [showImageModal, setShowImageModal] = useState(false);
const [selectedImageIndex, setSelectedImageIndex] = useState(0); const [selectedImageIndex, setSelectedImageIndex] = useState(0);
const [allImages, setAllImages] = useState<ImageGridItem[]>([]); const [allImages, setAllImages] = useState<ImageGridItem[]>([]);
@@ -741,6 +742,7 @@ export const PostDetailScreen: React.FC = () => {
root_id: rootId, root_id: rootId,
target_id: parentId, target_id: parentId,
content: commentText.trim(), content: commentText.trim(),
segments: commentSegments.length > 0 ? commentSegments : undefined,
images: uploadedImageUrls.map(url => ({ url })), images: uploadedImageUrls.map(url => ({ url })),
likes_count: 0, likes_count: 0,
replies_count: 0, replies_count: 0,
@@ -798,6 +800,7 @@ export const PostDetailScreen: React.FC = () => {
const createdComment = await commentService.createComment({ const createdComment = await commentService.createComment({
postId: post.id, postId: post.id,
content: commentText.trim(), content: commentText.trim(),
segments: commentSegments.length > 0 ? commentSegments : undefined,
parentId: parentId || undefined, parentId: parentId || undefined,
images: uploadedImageUrls, images: uploadedImageUrls,
}); });
@@ -1097,18 +1100,17 @@ export const PostDetailScreen: React.FC = () => {
{/* 内容 - 增大字体 */} {/* 内容 - 增大字体 */}
<View style={styles.contentContainer}> <View style={styles.contentContainer}>
<Text <PostContentRenderer
variant="body" content={post.content}
style={[ segments={post.segments}
textStyle={[
styles.content, styles.content,
{ {
fontSize: isDesktop ? fontSizes.xl : isTablet ? fontSizes.lg : fontSizes.lg, fontSize: isDesktop ? fontSizes.xl : isTablet ? fontSizes.lg : fontSizes.lg,
lineHeight: isDesktop ? 32 : isTablet ? 30 : 28 lineHeight: isDesktop ? 32 : isTablet ? 30 : 28
} }
]} ]}
> />
{post.content}
</Text>
</View> </View>
{/* 图片 - 详情页显示所有图片,不限制数量 */} {/* 图片 - 详情页显示所有图片,不限制数量 */}
@@ -1442,15 +1444,14 @@ export const PostDetailScreen: React.FC = () => {
)} )}
{/* Text Input */} {/* Text Input */}
<TextInput <PostMentionInput
style={styles.expandedTextInput}
placeholder="来说点什么吧!"
placeholderTextColor={colors.text.hint}
value={commentText} value={commentText}
onChangeText={setCommentText} onChangeText={setCommentText}
multiline onSegmentsChange={setCommentSegments}
autoFocus placeholder="来说点什么吧!"
maxLength={500} maxLength={500}
minHeight={40}
style={styles.expandedTextInput}
/> />
{/* Image preview */} {/* Image preview */}

View File

@@ -149,6 +149,9 @@ class AuthService {
// 用户登录(只管 API + TokenDB 操作交给 authStore // 用户登录(只管 API + TokenDB 操作交给 authStore
async login(data: LoginRequest): Promise<AuthResponse> { async login(data: LoginRequest): Promise<AuthResponse> {
await api.clearToken();
api.resetAuthState();
const response = await api.post<AuthResponse>('/auth/login', data); const response = await api.post<AuthResponse>('/auth/login', data);
if (!response.data) { if (!response.data) {
@@ -161,13 +164,15 @@ class AuthService {
if (refreshToken) { if (refreshToken) {
await api.setRefreshToken(refreshToken); await api.setRefreshToken(refreshToken);
} }
api.resetAuthState();
return response.data; return response.data;
} }
// 用户注册(只管 API + TokenDB 操作交给 authStore // 用户注册(只管 API + TokenDB 操作交给 authStore
async register(data: RegisterRequest): Promise<AuthResponse> { async register(data: RegisterRequest): Promise<AuthResponse> {
await api.clearToken();
api.resetAuthState();
const response = await api.post<AuthResponse>('/auth/register', data); const response = await api.post<AuthResponse>('/auth/register', data);
if (!response.data) { if (!response.data) {
@@ -180,7 +185,6 @@ class AuthService {
if (refreshToken) { if (refreshToken) {
await api.setRefreshToken(refreshToken); await api.setRefreshToken(refreshToken);
} }
api.resetAuthState();
return response.data; return response.data;
} }

View File

@@ -4,7 +4,7 @@
*/ */
import { api, PaginatedData } from '../core/api'; import { api, PaginatedData } from '../core/api';
import { Comment, CreateCommentInput } from '@/types'; import { Comment, CreateCommentInput, MessageSegment } from '@/types';
import { CursorPaginationRequest, CursorPaginationResponse, CommentDTO } from '@/types/dto'; import { CursorPaginationRequest, CursorPaginationResponse, CommentDTO } from '@/types/dto';
// 评论列表响应 // 评论列表响应
@@ -23,8 +23,9 @@ type CommentResponse = Comment;
interface CreateCommentRequest { interface CreateCommentRequest {
postId: string; postId: string;
content: string; content: string;
segments?: MessageSegment[];
parentId?: string; parentId?: string;
images?: string[]; // 图片URL列表 images?: string[];
replyToUserId?: string; replyToUserId?: string;
} }
@@ -130,12 +131,14 @@ class CommentService {
content: data.content, content: data.content,
}; };
// 如果有父评论ID才添加 parent_id 字段 if (data.segments && data.segments.length > 0) {
requestBody.segments = data.segments;
}
if (data.parentId) { if (data.parentId) {
requestBody.parent_id = data.parentId; requestBody.parent_id = data.parentId;
} }
// 如果有图片,添加 images 字段
if (data.images && data.images.length > 0) { if (data.images && data.images.length > 0) {
requestBody.images = data.images; requestBody.images = data.images;
} }

View File

@@ -5,7 +5,7 @@
import { api, PaginatedData } from '../core/api'; import { api, PaginatedData } from '../core/api';
import { Post, CreatePostInput } from '@/types'; import { Post, CreatePostInput } from '@/types';
import { CursorPaginationRequest, CursorPaginationResponse } from '@/types/dto'; import { CursorPaginationRequest, CursorPaginationResponse, MessageSegment } from '@/types/dto';
// 帖子列表响应 // 帖子列表响应
interface PostListResponse { interface PostListResponse {
@@ -22,7 +22,8 @@ type PostResponse = Post;
// 创建帖子请求 // 创建帖子请求
interface CreatePostRequest { interface CreatePostRequest {
title: string; title: string;
content: string; content?: string;
segments?: MessageSegment[];
images?: string[]; images?: string[];
channel_id?: string; channel_id?: string;
} }
@@ -31,6 +32,7 @@ interface CreatePostRequest {
interface UpdatePostRequest { interface UpdatePostRequest {
title?: string; title?: string;
content?: string; content?: string;
segments?: MessageSegment[];
images?: string[]; images?: string[];
} }

View File

@@ -4,7 +4,7 @@
*/ */
import { api } from '../core/api'; import { api } from '../core/api';
import { Post, VoteResultDTO, CreateVotePostRequest } from '@/types'; import { Post, VoteResultDTO, CreateVotePostRequest, MessageSegment } from '@/types';
// 投票响应 // 投票响应
interface VoteResponse { interface VoteResponse {
@@ -23,12 +23,9 @@ interface UpdateVoteOptionResponse {
*/ */
class VoteService { class VoteService {
/** /**
* 创建投票帖子 * 创建投票帖子(通过统一的 POST /posts 接口,投票选项作为 vote segment
* @param data 创建投票帖子请求数据
* @returns 创建的帖子或null
*/ */
async createVotePost(data: CreateVotePostRequest): Promise<Post | null> { async createVotePost(data: CreateVotePostRequest): Promise<Post | null> {
// 验证投票选项数量
if (!data.vote_options || data.vote_options.length < 2) { if (!data.vote_options || data.vote_options.length < 2) {
console.error('[VoteService] 投票选项至少需要2个'); console.error('[VoteService] 投票选项至少需要2个');
return null; return null;
@@ -38,7 +35,30 @@ class VoteService {
return null; return null;
} }
const response = await api.post<Post>('/posts/vote', data); // 构建 segments先放文本内容再放投票 segment
const segments: MessageSegment[] = [];
if (data.content) {
segments.push({ type: 'text', data: { text: data.content } });
}
segments.push({
type: 'vote',
data: {
options: data.vote_options.map((opt, i) => ({
id: `opt_${i + 1}`,
content: typeof opt === 'string' ? opt : opt.content,
})),
max_choices: 1,
is_multi_choice: false,
},
});
const response = await api.post<Post>('/posts', {
title: data.title,
content: data.content || '',
segments,
images: data.images,
channel_id: data.channel_id,
});
return response.data; return response.data;
} }

View File

@@ -141,8 +141,21 @@ export const useAuthStore = create<AuthState>((set) => ({
const userId = String(user.id); const userId = String(user.id);
// 2. 初始化用户专属数据库 // 2. 关闭旧数据库连接后再初始化用户专属数据库
try {
await closeDatabase();
} catch {}
try {
await switchDatabase(userId); await switchDatabase(userId);
} catch (dbErr) {
console.warn('[AuthStore] switchDatabase 失败,将重试:', dbErr);
try {
await closeDatabase();
await switchDatabase(userId);
} catch (retryErr) {
console.error('[AuthStore] switchDatabase 重试仍失败:', retryErr);
}
}
// 3. 持久化 userId供下次冷启动提前初始化 DB 用) // 3. 持久化 userId供下次冷启动提前初始化 DB 用)
await saveUserId(userId); await saveUserId(userId);
@@ -160,8 +173,10 @@ export const useAuthStore = create<AuthState>((set) => ({
error: null, error: null,
}); });
// 6. 启动 SSE // 6. 启动 SSE(不阻塞登录结果)
await startRealtime(); startRealtime().catch((err) => {
console.warn('[AuthStore] 启动实时服务失败:', err);
});
return true; return true;
} catch (error: any) { } catch (error: any) {
@@ -187,7 +202,21 @@ export const useAuthStore = create<AuthState>((set) => ({
const userId = String(user.id); const userId = String(user.id);
try {
await closeDatabase();
} catch {}
try {
await switchDatabase(userId); await switchDatabase(userId);
} catch (dbErr) {
console.warn('[AuthStore] register switchDatabase 失败,将重试:', dbErr);
try {
await closeDatabase();
await switchDatabase(userId);
} catch (retryErr) {
console.error('[AuthStore] register switchDatabase 重试仍失败:', retryErr);
}
}
await saveUserId(userId); await saveUserId(userId);
await cacheUser(user); await cacheUser(user);
@@ -201,7 +230,10 @@ export const useAuthStore = create<AuthState>((set) => ({
error: null, error: null,
}); });
await startRealtime(); // 启动 SSE不阻塞注册结果
startRealtime().catch((err) => {
console.warn('[AuthStore] 启动实时服务失败:', err);
});
return true; return true;
} catch (error: any) { } catch (error: any) {
@@ -275,8 +307,14 @@ export const useAuthStore = create<AuthState>((set) => ({
// 4. 确保 DB 已初始化(如果预初始化失败,这里会重试) // 4. 确保 DB 已初始化(如果预初始化失败,这里会重试)
const dbReady = isDbInitialized() && getCurrentDbUserId() === userId; const dbReady = isDbInitialized() && getCurrentDbUserId() === userId;
if (!dbReady) { if (!dbReady) {
await switchDatabase(userId); try {
await closeDatabase();
} catch {}
try {
await switchDatabase(userId);
} catch (dbErr) {
console.warn('[AuthStore] fetchCurrentUser switchDatabase 失败:', dbErr);
}
} }
// 5. 更新持久化的 userId // 5. 更新持久化的 userId
@@ -296,8 +334,10 @@ await switchDatabase(userId);
isLoading: false, isLoading: false,
}); });
// 8. 启动 SSE // 8. 启动 SSE(不阻塞状态设置)
await startRealtime(); startRealtime().catch((err) => {
console.warn('[AuthStore] 冷启动实时服务失败:', err);
});
} else { } else {
// Token 已失效或不存在 // Token 已失效或不存在
await clearUserId(); await clearUserId();

View File

@@ -68,6 +68,7 @@ export interface PostDTO {
user_id: string; user_id: string;
title: string; title: string;
content: string; content: string;
segments?: MessageSegment[];
images: PostImageDTO[]; images: PostImageDTO[];
status?: string; status?: string;
likes_count: number; likes_count: number;
@@ -111,6 +112,7 @@ export interface CommentDTO {
parent_id: string | null; parent_id: string | null;
root_id: string | null; root_id: string | null;
content: string; content: string;
segments?: MessageSegment[];
images: CommentImageDTO[]; images: CommentImageDTO[];
likes_count: number; likes_count: number;
replies_count: number; replies_count: number;
@@ -148,7 +150,7 @@ export type MessageStatus = 'normal' | 'recalled' | 'deleted';
// ==================== Message Segment Types ==================== // ==================== Message Segment Types ====================
// Segment类型 // Segment类型
export type SegmentType = 'text' | 'image' | 'voice' | 'video' | 'file' | 'at' | 'reply' | 'face' | 'link'; export type SegmentType = 'text' | 'image' | 'voice' | 'video' | 'file' | 'at' | 'reply' | 'face' | 'link' | 'vote';
// 文本Segment数据 // 文本Segment数据
export interface TextSegmentData { export interface TextSegmentData {
@@ -217,6 +219,19 @@ export interface LinkSegmentData {
image?: string; image?: string;
} }
// 投票选项数据
export interface VoteOptionData {
id: string;
content: string;
}
// 投票Segment数据
export interface VoteSegmentData {
options: VoteOptionData[];
max_choices?: number;
is_multi_choice?: boolean;
}
// MessageSegment 联合类型 - 使用 discriminated union // MessageSegment 联合类型 - 使用 discriminated union
export type MessageSegment = export type MessageSegment =
| { type: 'text'; data: TextSegmentData } | { type: 'text'; data: TextSegmentData }
@@ -227,7 +242,8 @@ export type MessageSegment =
| { type: 'at'; data: AtSegmentData } | { type: 'at'; data: AtSegmentData }
| { type: 'reply'; data: ReplySegmentData } | { type: 'reply'; data: ReplySegmentData }
| { type: 'face'; data: FaceSegmentData } | { type: 'face'; data: FaceSegmentData }
| { type: 'link'; data: LinkSegmentData }; | { type: 'link'; data: LinkSegmentData }
| { type: 'vote'; data: VoteSegmentData };
// 消息响应 // 消息响应
export interface MessageResponse { export interface MessageResponse {

View File

@@ -1,4 +1,4 @@
// ============================================ // ============================================
// WithYou - Core Type Definitions // WithYou - Core Type Definitions
// 使用后端DTO类型保持snake_case命名规范 // 使用后端DTO类型保持snake_case命名规范
// ============================================ // ============================================
@@ -37,43 +37,14 @@ import type {
VoiceSegmentData, VoiceSegmentData,
VideoSegmentData, VideoSegmentData,
FileSegmentData, FileSegmentData,
AtSegmentData, AtSegmentData,
ReplySegmentData, ReplySegmentData,
FaceSegmentData, FaceSegmentData,
LinkSegmentData, LinkSegmentData,
VoteSegmentData,
VoteOptionData,
} from './dto'; } from './dto';
// 重新导出新的聊天系统类型
export type {
ConversationType,
MessageContentType,
MessageStatus,
MessageResponse,
ConversationResponse,
ConversationParticipantResponse,
ConversationDetailResponse,
CreateConversationRequest,
SendMessageRequest,
MarkReadRequest,
UnreadCountResponse,
ConversationUnreadCountResponse,
ConversationListResponse,
MessageListResponse,
MessageSyncResponse,
// Segment 类型
SegmentType,
MessageSegment,
TextSegmentData,
ImageSegmentData,
VoiceSegmentData,
VideoSegmentData,
FileSegmentData,
AtSegmentData,
ReplySegmentData,
FaceSegmentData,
LinkSegmentData,
};
// ============================================ // ============================================
// User Types // User Types
// ============================================ // ============================================
@@ -101,8 +72,9 @@ export type { VoteOptionDTO as VoteOption, VoteResultDTO as VoteResult, CreateVo
export interface CreatePostInput { export interface CreatePostInput {
title: string; title: string;
content: string; content?: string;
images?: string[]; // 本地图片路径或 base64 segments?: MessageSegment[];
images?: string[];
channel_id?: string; channel_id?: string;
} }
@@ -122,6 +94,7 @@ export interface Comment {
parent_id: string | null; parent_id: string | null;
root_id?: string | null; root_id?: string | null;
content: string; content: string;
segments?: MessageSegment[];
images?: CommentImage[]; images?: CommentImage[];
likes_count: number; likes_count: number;
replies_count: number; replies_count: number;

94
web-shims/react-native-webrtc/index.js vendored Normal file
View File

@@ -0,0 +1,94 @@
const React = require('react');
const RTCView = React.forwardRef((props, ref) => {
const { streamURL, mirror, objectFit, style, ...rest } = props;
const src = streamURL || (props.stream && props.stream.toURL ? props.stream.toURL() : '');
if (src) {
return React.createElement('video', {
...rest,
ref,
src,
autoPlay: true,
playsInline: true,
muted: props.muted || false,
style: {
...style,
transform: mirror ? 'scaleX(-1)' : undefined,
objectFit: objectFit || 'cover',
width: '100%',
height: '100%',
},
});
}
return React.createElement('div', { ...rest, ref, style });
});
RTCView.displayName = 'RTCView';
const ScreenCapturePickerView = React.forwardRef((props, ref) => {
return React.createElement('div', { ...props, ref, 'data-native-component': 'ScreenCapturePickerView' });
});
ScreenCapturePickerView.displayName = 'ScreenCapturePickerView';
const mediaDevices = typeof navigator !== 'undefined' && navigator.mediaDevices
? navigator.mediaDevices
: {
getUserMedia: () => Promise.reject(new Error('mediaDevices not available')),
getDisplayMedia: () => Promise.reject(new Error('mediaDevices not available')),
enumerateDevices: () => Promise.resolve([]),
};
const RTCPeerConnection = typeof window !== 'undefined' ? window.RTCPeerConnection : function () {};
const RTCSessionDescription = typeof window !== 'undefined' ? window.RTCSessionDescription : function () {};
const RTCIceCandidate = typeof window !== 'undefined' ? window.RTCIceCandidate : function () {};
const MediaStream = typeof window !== 'undefined' ? window.MediaStream : function () {};
const MediaStreamTrack = typeof window !== 'undefined' ? window.MediaStreamTrack : function () {};
function registerGlobals() {}
const permissions = {
request: () => Promise.resolve(true),
check: () => Promise.resolve(true),
};
const RTCAudioSession = {
setCategory: () => {},
setActive: () => {},
setMode: () => {},
};
const RTCRtpReceiver = typeof window !== 'undefined' ? window.RTCRtpReceiver : function () {};
const RTCRtpSender = typeof window !== 'undefined' ? window.RTCRtpSender : function () {};
const RTCRtpTransceiver = typeof window !== 'undefined' ? window.RTCRtpTransceiver : function () {};
const RTCErrorEvent = typeof window !== 'undefined' ? window.RTCErrorEvent : function () {};
const MediaStreamTrackEvent = typeof window !== 'undefined' ? window.MediaStreamTrackEvent : function () {};
const RTCPIPView = React.forwardRef((props, ref) => {
return React.createElement('div', { ...props, ref, 'data-native-component': 'RTCPIPView' });
});
RTCPIPView.displayName = 'RTCPIPView';
function startIOSPIP() {}
function stopIOSPIP() {}
module.exports = {
RTCView,
ScreenCapturePickerView,
RTCPeerConnection,
RTCSessionDescription,
RTCIceCandidate,
MediaStream,
MediaStreamTrack,
MediaStreamTrackEvent,
mediaDevices,
permissions,
registerGlobals,
RTCAudioSession,
RTCRtpReceiver,
RTCRtpSender,
RTCRtpTransceiver,
RTCErrorEvent,
RTCPIPView,
startIOSPIP,
stopIOSPIP,
};

View File

@@ -0,0 +1,18 @@
const React = require('react');
function requireNativeComponent(componentName) {
const componentNameClean = componentName
.replace(/^RNC/, '')
.replace(/^RNS/, '')
.replace(/^RN/, '');
const NativeComponent = React.forwardRef((props, ref) => {
const { children, ...rest } = props;
return React.createElement('div', { ...rest, ref, 'data-native-component': componentNameClean }, children);
});
NativeComponent.displayName = componentName;
return NativeComponent;
}
module.exports = requireNativeComponent;