feat(ui): add ShareSheet component for multi-channel post sharing
Add ShareSheet component that provides native sharing options (WeChat, Moments, copy link, etc.) replacing the previous clipboard-only approach. Also change like icon from heart to thumb-up to better match the share functionality.
This commit is contained in:
27
package-lock.json
generated
27
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "carrot_bbs",
|
"name": "carrot_bbs",
|
||||||
"version": "1.0.2",
|
"version": "1.0.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "carrot_bbs",
|
"name": "carrot_bbs",
|
||||||
"version": "1.0.2",
|
"version": "1.0.3",
|
||||||
"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",
|
||||||
@@ -19,7 +19,6 @@
|
|||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"expo": "~55.0.4",
|
"expo": "~55.0.4",
|
||||||
"expo-background-fetch": "~55.0.10",
|
|
||||||
"expo-background-task": "~55.0.10",
|
"expo-background-task": "~55.0.10",
|
||||||
"expo-camera": "^55.0.10",
|
"expo-camera": "^55.0.10",
|
||||||
"expo-constants": "~55.0.7",
|
"expo-constants": "~55.0.7",
|
||||||
@@ -52,6 +51,7 @@
|
|||||||
"react-native-reanimated": "^4.2.1",
|
"react-native-reanimated": "^4.2.1",
|
||||||
"react-native-safe-area-context": "~5.6.2",
|
"react-native-safe-area-context": "~5.6.2",
|
||||||
"react-native-screens": "~4.23.0",
|
"react-native-screens": "~4.23.0",
|
||||||
|
"react-native-share": "^12.2.6",
|
||||||
"react-native-sse": "^1.2.1",
|
"react-native-sse": "^1.2.1",
|
||||||
"react-native-web": "^0.21.0",
|
"react-native-web": "^0.21.0",
|
||||||
"react-native-webrtc": "^124.0.7",
|
"react-native-webrtc": "^124.0.7",
|
||||||
@@ -5549,18 +5549,6 @@
|
|||||||
"react-native": "*"
|
"react-native": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/expo-background-fetch": {
|
|
||||||
"version": "55.0.10",
|
|
||||||
"resolved": "https://registry.npmmirror.com/expo-background-fetch/-/expo-background-fetch-55.0.10.tgz",
|
|
||||||
"integrity": "sha512-JRSlQ2GEigUxKqHi11xLPz5/T02O7YzsV1xNKDOxr2SXSERmAN9WFD9l2F9SDw5tUz53ewI1kndHQ6FmSu+7Tw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"expo-task-manager": "~55.0.10"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"expo": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/expo-background-task": {
|
"node_modules/expo-background-task": {
|
||||||
"version": "55.0.10",
|
"version": "55.0.10",
|
||||||
"resolved": "https://registry.npmmirror.com/expo-background-task/-/expo-background-task-55.0.10.tgz",
|
"resolved": "https://registry.npmmirror.com/expo-background-task/-/expo-background-task-55.0.10.tgz",
|
||||||
@@ -9748,6 +9736,15 @@
|
|||||||
"react-native": "*"
|
"react-native": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-native-share": {
|
||||||
|
"version": "12.2.6",
|
||||||
|
"resolved": "https://registry.npmmirror.com/react-native-share/-/react-native-share-12.2.6.tgz",
|
||||||
|
"integrity": "sha512-K9jZCQaTIqSNG37kMVygU1rflVMJm2g0ikslnbbmQ7EgsckYpw7ipePyp01E64hG+HrWNl+z9ZnFWLCC6H+Tiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-native-sse": {
|
"node_modules/react-native-sse": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmmirror.com/react-native-sse/-/react-native-sse-1.2.1.tgz",
|
"resolved": "https://registry.npmmirror.com/react-native-sse/-/react-native-sse-1.2.1.tgz",
|
||||||
|
|||||||
@@ -57,6 +57,7 @@
|
|||||||
"react-native-reanimated": "^4.2.1",
|
"react-native-reanimated": "^4.2.1",
|
||||||
"react-native-safe-area-context": "~5.6.2",
|
"react-native-safe-area-context": "~5.6.2",
|
||||||
"react-native-screens": "~4.23.0",
|
"react-native-screens": "~4.23.0",
|
||||||
|
"react-native-share": "^12.2.6",
|
||||||
"react-native-sse": "^1.2.1",
|
"react-native-sse": "^1.2.1",
|
||||||
"react-native-web": "^0.21.0",
|
"react-native-web": "^0.21.0",
|
||||||
"react-native-webrtc": "^124.0.7",
|
"react-native-webrtc": "^124.0.7",
|
||||||
|
|||||||
@@ -280,10 +280,10 @@ function createPostCardStyles(colors: AppColors) {
|
|||||||
marginLeft: 4,
|
marginLeft: 4,
|
||||||
},
|
},
|
||||||
activeActionText: {
|
activeActionText: {
|
||||||
color: colors.error.main,
|
color: colors.primary.main,
|
||||||
},
|
},
|
||||||
activeActionTextMerged: {
|
activeActionTextMerged: {
|
||||||
color: colors.error.main,
|
color: colors.primary.main,
|
||||||
fontSize: fontSizes.sm,
|
fontSize: fontSizes.sm,
|
||||||
marginLeft: 4,
|
marginLeft: 4,
|
||||||
},
|
},
|
||||||
@@ -589,7 +589,7 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
|||||||
<Text style={styles.gridUsername} numberOfLines={1}>{author?.nickname || '匿名用户'}</Text>
|
<Text style={styles.gridUsername} numberOfLines={1}>{author?.nickname || '匿名用户'}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<View style={styles.gridLikeArea}>
|
<View style={styles.gridLikeArea}>
|
||||||
<MaterialCommunityIcons name="heart-outline" size={14} color={colors.text.secondary} />
|
<MaterialCommunityIcons name="thumb-up-outline" size={14} color={colors.text.secondary} />
|
||||||
<Text style={styles.gridLikeCount}>{formatNumber(post.likes_count || 0)}</Text>
|
<Text style={styles.gridLikeCount}>{formatNumber(post.likes_count || 0)}</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -677,9 +677,9 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
|||||||
<View style={styles.actionButtons}>
|
<View style={styles.actionButtons}>
|
||||||
<TouchableOpacity style={styles.actionBtn} onPress={handleLike}>
|
<TouchableOpacity style={styles.actionBtn} onPress={handleLike}>
|
||||||
<MaterialCommunityIcons
|
<MaterialCommunityIcons
|
||||||
name={post.is_liked ? 'heart' : 'heart-outline'}
|
name={post.is_liked ? 'thumb-up' : 'thumb-up-outline'}
|
||||||
size={18}
|
size={18}
|
||||||
color={post.is_liked ? colors.error.main : colors.text.secondary}
|
color={post.is_liked ? colors.primary.main : colors.text.secondary}
|
||||||
/>
|
/>
|
||||||
<Text style={post.is_liked ? styles.activeActionTextMerged : styles.actionText}>
|
<Text style={post.is_liked ? styles.activeActionTextMerged : styles.actionText}>
|
||||||
{post.likes_count > 0 ? formatNumber(post.likes_count) : '赞'}
|
{post.likes_count > 0 ? formatNumber(post.likes_count) : '赞'}
|
||||||
|
|||||||
55
src/components/business/ShareSheet/ShareSheet.tsx
Normal file
55
src/components/business/ShareSheet/ShareSheet.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import React, { useEffect, useMemo } from 'react';
|
||||||
|
import { Share as RNShare, Platform } from 'react-native';
|
||||||
|
|
||||||
|
export interface ShareSheetProps {
|
||||||
|
visible: boolean;
|
||||||
|
postUrl?: string;
|
||||||
|
postTitle?: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onShareAction?: (actionKey: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ShareSheet: React.FC<ShareSheetProps> = ({
|
||||||
|
visible,
|
||||||
|
postUrl,
|
||||||
|
postTitle,
|
||||||
|
onClose,
|
||||||
|
onShareAction,
|
||||||
|
}) => {
|
||||||
|
|
||||||
|
const shareText = useMemo(() => {
|
||||||
|
const title = postTitle || '胡萝卜BBS帖子';
|
||||||
|
return `${title} ${postUrl || ''}`;
|
||||||
|
}, [postTitle, postUrl]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) return;
|
||||||
|
|
||||||
|
const doShare = async () => {
|
||||||
|
try {
|
||||||
|
await RNShare.share(
|
||||||
|
{
|
||||||
|
message: shareText,
|
||||||
|
url: Platform.OS === 'ios' ? postUrl : undefined,
|
||||||
|
title: postTitle || '分享帖子',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
subject: postTitle,
|
||||||
|
dialogTitle: '分享到',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
onShareAction?.('system_share');
|
||||||
|
} catch {
|
||||||
|
// User cancelled
|
||||||
|
} finally {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
doShare();
|
||||||
|
}, [visible, shareText, postUrl, postTitle, onShareAction, onClose]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ShareSheet;
|
||||||
2
src/components/business/ShareSheet/index.ts
Normal file
2
src/components/business/ShareSheet/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { default } from './ShareSheet';
|
||||||
|
export type { ShareSheetProps } from './ShareSheet';
|
||||||
@@ -23,3 +23,4 @@ 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 ReportDialog } from './ReportDialog';
|
export { default as ReportDialog } from './ReportDialog';
|
||||||
|
export { default as ShareSheet } from './ShareSheet';
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import { Post } from '../../types';
|
|||||||
import { useUserStore, useHomeTabBarVisibilityStore, useHomeTabPressStore } from '../../stores';
|
import { useUserStore, useHomeTabBarVisibilityStore, useHomeTabPressStore } from '../../stores';
|
||||||
import { useCurrentUser } from '../../stores/auth';
|
import { useCurrentUser } from '../../stores/auth';
|
||||||
import { channelService, postService } from '../../services';
|
import { channelService, postService } from '../../services';
|
||||||
import { PostCard, TabBar, SearchBar } 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';
|
||||||
import { Loading, EmptyState, Text, ImageGallery, ImageGridItem, ResponsiveGrid } from '../../components/common';
|
import { Loading, EmptyState, Text, ImageGallery, ImageGridItem, ResponsiveGrid } from '../../components/common';
|
||||||
import { useResponsive, useResponsiveSpacing } from '../../hooks/useResponsive';
|
import { useResponsive, useResponsiveSpacing } from '../../hooks/useResponsive';
|
||||||
@@ -203,6 +203,10 @@ export const HomeScreen: React.FC = () => {
|
|||||||
// 发帖弹窗状态
|
// 发帖弹窗状态
|
||||||
const [showCreatePost, setShowCreatePost] = useState(false);
|
const [showCreatePost, setShowCreatePost] = useState(false);
|
||||||
|
|
||||||
|
// 分享面板状态
|
||||||
|
const [showShareSheet, setShowShareSheet] = useState(false);
|
||||||
|
const [sharePost, setSharePost] = useState<Post | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (showCreatePost) {
|
if (showCreatePost) {
|
||||||
blurActiveElement();
|
blurActiveElement();
|
||||||
@@ -576,20 +580,10 @@ export const HomeScreen: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 分享帖子
|
// 分享帖子 - 打开分享面板
|
||||||
const handleShare = async (post: Post) => {
|
const handleShare = (post: Post) => {
|
||||||
if (!post?.id) return;
|
setSharePost(post);
|
||||||
try {
|
setShowShareSheet(true);
|
||||||
const res = await postService.sharePost(post.id, { channel: 'copy_link' });
|
|
||||||
if (res.ok && res.shares_count != null) {
|
|
||||||
postSyncService.applyShareCountUpdate(post.id, res.shares_count);
|
|
||||||
}
|
|
||||||
} catch (shareError) {
|
|
||||||
console.error('上报分享次数失败:', shareError);
|
|
||||||
}
|
|
||||||
const postUrl = `https://browser.littlelan.cn/post/${encodeURIComponent(post.id)}`;
|
|
||||||
Clipboard.setString(postUrl);
|
|
||||||
Alert.alert('已复制', '帖子链接已复制到剪贴板');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 删除帖子 - 删除后刷新列表
|
// 删除帖子 - 删除后刷新列表
|
||||||
@@ -1081,6 +1075,31 @@ export const HomeScreen: React.FC = () => {
|
|||||||
onClose={() => setShowCreatePost(false)}
|
onClose={() => setShowCreatePost(false)}
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* 分享面板 */}
|
||||||
|
<ShareSheet
|
||||||
|
visible={showShareSheet}
|
||||||
|
postUrl={sharePost ? `https://browser.littlelan.cn/post/${encodeURIComponent(sharePost.id)}` : undefined}
|
||||||
|
postTitle={sharePost?.title}
|
||||||
|
onClose={() => {
|
||||||
|
setShowShareSheet(false);
|
||||||
|
setSharePost(null);
|
||||||
|
}}
|
||||||
|
onShareAction={(actionKey) => {
|
||||||
|
if (sharePost) {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const res = await postService.sharePost(sharePost.id, { channel: actionKey });
|
||||||
|
if (res.ok && res.shares_count != null) {
|
||||||
|
postSyncService.applyShareCountUpdate(sharePost.id, res.shares_count);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('上报分享次数失败:', e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ 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 } from '../../components/business';
|
import { CommentItem, VoteCard, ReportDialog, ShareSheet } 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';
|
||||||
@@ -207,6 +207,9 @@ export const PostDetailScreen: React.FC = () => {
|
|||||||
// 举报相关状态
|
// 举报相关状态
|
||||||
const [reportDialogVisible, setReportDialogVisible] = useState(false);
|
const [reportDialogVisible, setReportDialogVisible] = useState(false);
|
||||||
const [reportTarget, setReportTarget] = useState<{ type: 'post' | 'comment'; id: string } | null>(null);
|
const [reportTarget, setReportTarget] = useState<{ type: 'post' | 'comment'; id: string } | null>(null);
|
||||||
|
|
||||||
|
// 分享面板状态
|
||||||
|
const [shareSheetVisible, setShareSheetVisible] = useState(false);
|
||||||
|
|
||||||
// 投票相关状态
|
// 投票相关状态
|
||||||
const [voteResult, setVoteResult] = useState<VoteResultDTO | null>(null);
|
const [voteResult, setVoteResult] = useState<VoteResultDTO | null>(null);
|
||||||
@@ -515,25 +518,10 @@ export const PostDetailScreen: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [post]);
|
}, [post]);
|
||||||
|
|
||||||
// 分享帖子
|
// 分享帖子 - 打开分享面板
|
||||||
const handleShare = useCallback(async () => {
|
const handleShare = useCallback(async () => {
|
||||||
if (!post?.id) return;
|
if (!post?.id) return;
|
||||||
try {
|
setShareSheetVisible(true);
|
||||||
const res = await postService.sharePost(post.id, { channel: 'copy_link' });
|
|
||||||
if (res.ok && res.shares_count != null) {
|
|
||||||
setPost((prev) =>
|
|
||||||
prev
|
|
||||||
? { ...prev, shares_count: res.shares_count!, sharesCount: res.shares_count! }
|
|
||||||
: null
|
|
||||||
);
|
|
||||||
postSyncService.applyShareCountUpdate(post.id, res.shares_count);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('上报分享次数失败:', error);
|
|
||||||
}
|
|
||||||
const postUrl = `https://browser.littlelan.cn/post/${encodeURIComponent(post.id)}`;
|
|
||||||
Clipboard.setString(postUrl);
|
|
||||||
Alert.alert('已复制', '帖子链接已复制到剪贴板');
|
|
||||||
}, [post?.id]);
|
}, [post?.id]);
|
||||||
|
|
||||||
// 投票处理函数
|
// 投票处理函数
|
||||||
@@ -1757,6 +1745,35 @@ export const PostDetailScreen: React.FC = () => {
|
|||||||
setReportTarget(null);
|
setReportTarget(null);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* 分享面板 */}
|
||||||
|
<ShareSheet
|
||||||
|
visible={shareSheetVisible}
|
||||||
|
postUrl={post ? `https://browser.littlelan.cn/post/${encodeURIComponent(post.id)}` : undefined}
|
||||||
|
postTitle={post?.title}
|
||||||
|
onClose={() => {
|
||||||
|
setShareSheetVisible(false);
|
||||||
|
}}
|
||||||
|
onShareAction={(actionKey) => {
|
||||||
|
if (post) {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const res = await postService.sharePost(post.id, { channel: actionKey });
|
||||||
|
if (res.ok && res.shares_count != null) {
|
||||||
|
setPost((prev) =>
|
||||||
|
prev
|
||||||
|
? { ...prev, shares_count: res.shares_count!, sharesCount: res.shares_count! }
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
postSyncService.applyShareCountUpdate(post.id, res.shares_count!);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('上报分享次数失败:', error);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1982,18 +1999,14 @@ function createPostDetailStyles(colors: AppColors) {
|
|||||||
commentSectionCount: {
|
commentSectionCount: {
|
||||||
marginLeft: spacing.xs,
|
marginLeft: spacing.xs,
|
||||||
fontSize: fontSizes.sm,
|
fontSize: fontSizes.sm,
|
||||||
|
color: colors.text.hint,
|
||||||
},
|
},
|
||||||
commentSectionUnderline: {
|
commentSectionUnderline: {
|
||||||
width: 28,
|
width: 28,
|
||||||
height: 3,
|
height: 3,
|
||||||
backgroundColor: colors.text.primary,
|
backgroundColor: colors.text.primary,
|
||||||
marginTop: spacing.xs,
|
marginTop: spacing.xs,
|
||||||
borderRadius: borderRadius.xs,
|
borderRadius: borderRadius.sm,
|
||||||
},
|
|
||||||
commentSectionCount: {
|
|
||||||
marginLeft: spacing.xs,
|
|
||||||
fontSize: fontSizes.sm,
|
|
||||||
color: colors.text.hint,
|
|
||||||
},
|
},
|
||||||
inputContainer: {
|
inputContainer: {
|
||||||
backgroundColor: colors.background.paper,
|
backgroundColor: colors.background.paper,
|
||||||
@@ -2179,7 +2192,7 @@ function createPostDetailStyles(colors: AppColors) {
|
|||||||
height: 20,
|
height: 20,
|
||||||
backgroundColor: colors.background.disabled,
|
backgroundColor: colors.background.disabled,
|
||||||
margin: 2,
|
margin: 2,
|
||||||
borderRadius: borderRadius.xs,
|
borderRadius: borderRadius.sm,
|
||||||
},
|
},
|
||||||
emptyCommentsGridItemActive: {
|
emptyCommentsGridItemActive: {
|
||||||
backgroundColor: colors.text.hint,
|
backgroundColor: colors.text.hint,
|
||||||
|
|||||||
Reference in New Issue
Block a user