- Added report button to CommentItem for reporting comments and replies. - Integrated ReportDialog into PostDetailScreen for handling report submissions. - Enhanced ReportDialog with detailed report reasons and improved UI interactions. - Updated styles across various components for a more modern look and feel. Made-with: Cursor
474 lines
14 KiB
TypeScript
474 lines
14 KiB
TypeScript
import React, { useState, useCallback, useMemo } from 'react';
|
||
import {
|
||
View,
|
||
StyleSheet,
|
||
TextInput,
|
||
TouchableOpacity,
|
||
Alert,
|
||
ActivityIndicator,
|
||
Clipboard,
|
||
FlatList,
|
||
RefreshControl,
|
||
} from 'react-native';
|
||
import { useRouter } from 'expo-router';
|
||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||
|
||
import { spacing, borderRadius, fontSizes, useAppColors, type AppColors } from '../../theme';
|
||
import Avatar from '../../components/common/Avatar';
|
||
import Text from '../../components/common/Text';
|
||
import { groupService } from '../../services/groupService';
|
||
import { groupManager } from '../../stores/groupManager';
|
||
import { GroupResponse, JoinType } from '../../types/dto';
|
||
import { useCursorPagination } from '../../hooks/useCursorPagination';
|
||
import { EmptyState } from '../../components/common';
|
||
|
||
function createJoinGroupStyles(colors: AppColors) {
|
||
return StyleSheet.create({
|
||
container: {
|
||
flex: 1,
|
||
backgroundColor: colors.background.default,
|
||
padding: spacing.md,
|
||
},
|
||
heroCard: {
|
||
backgroundColor: colors.background.paper,
|
||
borderRadius: borderRadius.xl,
|
||
padding: spacing.lg,
|
||
marginBottom: spacing.md,
|
||
borderWidth: 0.5,
|
||
borderColor: colors.divider + '40',
|
||
},
|
||
heroIconWrap: {
|
||
width: 48,
|
||
height: 48,
|
||
borderRadius: 24,
|
||
backgroundColor: colors.primary.light + '15',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
marginBottom: spacing.md,
|
||
},
|
||
heroTitle: {
|
||
marginBottom: spacing.xs,
|
||
fontWeight: '800',
|
||
fontSize: fontSizes.xl + 1,
|
||
letterSpacing: 0.3,
|
||
},
|
||
tip: {
|
||
lineHeight: 22,
|
||
fontWeight: '400',
|
||
},
|
||
formCard: {
|
||
backgroundColor: colors.background.paper,
|
||
borderRadius: borderRadius.xl,
|
||
padding: spacing.lg,
|
||
flex: 1,
|
||
borderWidth: 0.5,
|
||
borderColor: colors.divider + '40',
|
||
},
|
||
label: {
|
||
marginBottom: spacing.xs,
|
||
fontWeight: '700',
|
||
fontSize: fontSizes.sm + 1,
|
||
},
|
||
input: {
|
||
flex: 1,
|
||
borderWidth: 1.5,
|
||
borderColor: colors.divider + '80',
|
||
borderRadius: borderRadius.lg,
|
||
backgroundColor: colors.background.default,
|
||
paddingHorizontal: spacing.md,
|
||
paddingVertical: spacing.md,
|
||
color: colors.text.primary,
|
||
fontSize: fontSizes.md,
|
||
},
|
||
searchRow: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
marginBottom: spacing.md,
|
||
},
|
||
searchBtn: {
|
||
width: 46,
|
||
height: 46,
|
||
borderRadius: borderRadius.lg,
|
||
backgroundColor: colors.primary.main,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
marginLeft: spacing.sm,
|
||
},
|
||
searchResultSection: {
|
||
marginBottom: spacing.md,
|
||
},
|
||
sectionTitle: {
|
||
marginBottom: spacing.sm,
|
||
fontWeight: '800',
|
||
fontSize: fontSizes.md + 1,
|
||
letterSpacing: 0.3,
|
||
},
|
||
listSection: {
|
||
flex: 1,
|
||
},
|
||
groupCard: {
|
||
borderWidth: 0.5,
|
||
borderColor: colors.divider + '40',
|
||
borderRadius: borderRadius.lg,
|
||
padding: spacing.md,
|
||
backgroundColor: colors.background.default,
|
||
marginBottom: spacing.md,
|
||
},
|
||
groupHeader: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
},
|
||
groupMeta: {
|
||
marginLeft: spacing.md,
|
||
flex: 1,
|
||
},
|
||
groupName: {
|
||
marginBottom: spacing.xs,
|
||
fontWeight: '700',
|
||
fontSize: fontSizes.md + 1,
|
||
letterSpacing: 0.3,
|
||
},
|
||
groupDesc: {
|
||
marginTop: spacing.sm,
|
||
lineHeight: 20,
|
||
fontWeight: '400',
|
||
},
|
||
groupInfoRow: {
|
||
marginTop: spacing.sm,
|
||
marginBottom: spacing.xs,
|
||
flexDirection: 'row',
|
||
justifyContent: 'space-between',
|
||
},
|
||
groupNoRow: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
marginBottom: spacing.md,
|
||
},
|
||
copyBtn: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
paddingHorizontal: spacing.sm,
|
||
paddingVertical: 4,
|
||
borderRadius: borderRadius.sm,
|
||
backgroundColor: colors.primary.light + '15',
|
||
},
|
||
copyBtnText: {
|
||
marginLeft: 4,
|
||
fontWeight: '600',
|
||
},
|
||
submitBtn: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
borderRadius: borderRadius.lg,
|
||
backgroundColor: colors.primary.main,
|
||
minHeight: 46,
|
||
},
|
||
submitBtnDisabled: {
|
||
opacity: 0.5,
|
||
},
|
||
submitText: {
|
||
marginLeft: spacing.xs,
|
||
fontWeight: '700',
|
||
fontSize: fontSizes.md,
|
||
},
|
||
emptyText: {
|
||
marginTop: spacing.sm,
|
||
textAlign: 'center',
|
||
fontWeight: '500',
|
||
},
|
||
loadingFooter: {
|
||
paddingVertical: spacing.md,
|
||
alignItems: 'center',
|
||
},
|
||
loadMoreBtn: {
|
||
paddingVertical: spacing.md,
|
||
alignItems: 'center',
|
||
},
|
||
noMoreText: {
|
||
textAlign: 'center',
|
||
paddingVertical: spacing.md,
|
||
fontWeight: '500',
|
||
color: colors.text.hint,
|
||
},
|
||
});
|
||
}
|
||
|
||
const JoinGroupScreen: React.FC = () => {
|
||
const colors = useAppColors();
|
||
const styles = useMemo(() => createJoinGroupStyles(colors), [colors]);
|
||
const router = useRouter();
|
||
const [keyword, setKeyword] = useState('');
|
||
const [searching, setSearching] = useState(false);
|
||
const [joiningGroupId, setJoiningGroupId] = useState<string | null>(null);
|
||
const [searchedGroup, setSearchedGroup] = useState<GroupResponse | null>(null);
|
||
const [searched, setSearched] = useState(false);
|
||
|
||
// 使用游标分页 Hook 管理群组列表
|
||
const {
|
||
list: groups,
|
||
isLoading,
|
||
isRefreshing,
|
||
hasMore,
|
||
loadMore,
|
||
refresh,
|
||
error,
|
||
} = useCursorPagination(
|
||
async ({ cursor, pageSize }) => {
|
||
return await groupService.getGroupsCursor({
|
||
cursor,
|
||
page_size: pageSize,
|
||
});
|
||
},
|
||
{ pageSize: 20 }
|
||
);
|
||
|
||
const getJoinTypeText = (joinType: JoinType) => {
|
||
if (joinType === 0) return '允许加入';
|
||
if (joinType === 1) return '需要审批';
|
||
return '禁止加入';
|
||
};
|
||
|
||
const handleSearch = async () => {
|
||
const trimmed = keyword.trim();
|
||
if (!trimmed) {
|
||
Alert.alert('提示', '请输入群ID进行搜索');
|
||
return;
|
||
}
|
||
|
||
setSearching(true);
|
||
setSearched(true);
|
||
try {
|
||
const result = await groupManager.getGroup(trimmed, true);
|
||
setSearchedGroup(result);
|
||
} catch (error: any) {
|
||
setSearchedGroup(null);
|
||
const message = error?.response?.data?.message || error?.message || '';
|
||
if (String(message).includes('不存在') || error?.response?.status === 404) {
|
||
Alert.alert('未找到', '未搜索到该群聊,请确认群ID是否正确');
|
||
} else {
|
||
Alert.alert('搜索失败', '请稍后重试');
|
||
}
|
||
} finally {
|
||
setSearching(false);
|
||
}
|
||
};
|
||
|
||
const handleJoin = async (group: GroupResponse) => {
|
||
if (!group?.id) return;
|
||
setJoiningGroupId(String(group.id));
|
||
try {
|
||
await groupService.joinGroup(group.id);
|
||
Alert.alert('成功', '操作已提交', [
|
||
{
|
||
text: '确定',
|
||
onPress: () => router.back(),
|
||
},
|
||
]);
|
||
} catch (error: any) {
|
||
const message =
|
||
error?.response?.data?.message ||
|
||
error?.message ||
|
||
'操作失败,请稍后重试';
|
||
Alert.alert('操作失败', String(message));
|
||
} finally {
|
||
setJoiningGroupId(null);
|
||
}
|
||
};
|
||
|
||
const handleCopyGroupId = (groupId: string) => {
|
||
Clipboard.setString(groupId);
|
||
Alert.alert('已复制', '群号已复制到剪贴板');
|
||
};
|
||
|
||
const formatGroupNo = (id: string) => {
|
||
const raw = id;
|
||
if (raw.length <= 12) return raw;
|
||
return `${raw.slice(0, 6)}...${raw.slice(-4)}`;
|
||
};
|
||
|
||
const renderGroupItem = ({ item: group }: { item: GroupResponse }) => {
|
||
const isJoining = joiningGroupId === String(group.id);
|
||
|
||
return (
|
||
<View style={styles.groupCard}>
|
||
<View style={styles.groupHeader}>
|
||
<Avatar source={group.avatar} size={52} name={group.name} />
|
||
<View style={styles.groupMeta}>
|
||
<Text variant="body" style={styles.groupName} numberOfLines={1}>
|
||
{group.name}
|
||
</Text>
|
||
</View>
|
||
</View>
|
||
{!!group.description && (
|
||
<Text variant="caption" color={colors.text.secondary} style={styles.groupDesc}>
|
||
{group.description}
|
||
</Text>
|
||
)}
|
||
<View style={styles.groupInfoRow}>
|
||
<Text variant="caption" color={colors.text.secondary}>
|
||
成员 {group.member_count}/{group.max_members}
|
||
</Text>
|
||
<Text variant="caption" color={colors.text.secondary}>
|
||
{getJoinTypeText(group.join_type)}
|
||
</Text>
|
||
</View>
|
||
<View style={styles.groupNoRow}>
|
||
<Text variant="caption" color={colors.text.secondary}>
|
||
群号:{formatGroupNo(group.id)}
|
||
</Text>
|
||
<TouchableOpacity style={styles.copyBtn} onPress={() => handleCopyGroupId(group.id)}>
|
||
<MaterialCommunityIcons name="content-copy" size={14} color={colors.primary.main} />
|
||
<Text variant="caption" color={colors.primary.main} style={styles.copyBtnText}>
|
||
复制
|
||
</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
<TouchableOpacity
|
||
style={[styles.submitBtn, isJoining && styles.submitBtnDisabled]}
|
||
onPress={() => handleJoin(group)}
|
||
disabled={isJoining}
|
||
>
|
||
{isJoining ? (
|
||
<ActivityIndicator color="#fff" />
|
||
) : (
|
||
<>
|
||
<MaterialCommunityIcons name="send-outline" size={18} color="#fff" />
|
||
<Text variant="body" color="#fff" style={styles.submitText}>
|
||
申请入群
|
||
</Text>
|
||
</>
|
||
)}
|
||
</TouchableOpacity>
|
||
</View>
|
||
);
|
||
};
|
||
|
||
const renderEmptyList = () => {
|
||
if (isLoading) return null;
|
||
return (
|
||
<EmptyState
|
||
title="暂无群组"
|
||
description="还没有可加入的群组"
|
||
icon="account-group-outline"
|
||
/>
|
||
);
|
||
};
|
||
|
||
const renderSearchResult = () => {
|
||
if (!searched) return null;
|
||
|
||
if (searchedGroup) {
|
||
return (
|
||
<View style={styles.searchResultSection}>
|
||
<Text variant="caption" color={colors.text.secondary} style={styles.sectionTitle}>
|
||
搜索结果
|
||
</Text>
|
||
{renderGroupItem({ item: searchedGroup })}
|
||
</View>
|
||
);
|
||
}
|
||
|
||
if (!searching) {
|
||
return (
|
||
<Text variant="caption" color={colors.text.secondary} style={styles.emptyText}>
|
||
暂无搜索结果,请检查群ID后重试
|
||
</Text>
|
||
);
|
||
}
|
||
|
||
return null;
|
||
};
|
||
|
||
return (
|
||
<View style={styles.container}>
|
||
<View style={styles.heroCard}>
|
||
<View style={styles.heroIconWrap}>
|
||
<MaterialCommunityIcons name="account-group-outline" size={28} color={colors.primary.main} />
|
||
</View>
|
||
<Text variant="h3" style={styles.heroTitle}>搜索群聊</Text>
|
||
<Text variant="body" color={colors.text.secondary} style={styles.tip}>
|
||
输入群 ID 搜索后,你可以先查看群资料,再决定是否申请加入。
|
||
</Text>
|
||
</View>
|
||
|
||
<View style={styles.formCard}>
|
||
<Text variant="caption" color={colors.text.secondary} style={styles.label}>
|
||
搜索群聊(群ID)
|
||
</Text>
|
||
<View style={styles.searchRow}>
|
||
<TextInput
|
||
value={keyword}
|
||
onChangeText={setKeyword}
|
||
placeholder="例如:7391234567890"
|
||
placeholderTextColor={colors.text.hint}
|
||
style={styles.input}
|
||
editable={!searching && !joiningGroupId}
|
||
autoCapitalize="none"
|
||
cursorColor={colors.primary.main}
|
||
selectionColor={`${colors.primary.main}40`}
|
||
/>
|
||
<TouchableOpacity
|
||
style={[styles.searchBtn, (!keyword.trim() || searching || !!joiningGroupId) && styles.submitBtnDisabled]}
|
||
onPress={handleSearch}
|
||
disabled={!keyword.trim() || searching || !!joiningGroupId}
|
||
>
|
||
{searching ? (
|
||
<ActivityIndicator color="#fff" />
|
||
) : (
|
||
<MaterialCommunityIcons name="magnify" size={20} color="#fff" />
|
||
)}
|
||
</TouchableOpacity>
|
||
</View>
|
||
|
||
{/* 搜索结果 */}
|
||
{renderSearchResult()}
|
||
|
||
{/* 群组列表 */}
|
||
<View style={styles.listSection}>
|
||
<Text variant="caption" color={colors.text.secondary} style={styles.sectionTitle}>
|
||
推荐群组
|
||
</Text>
|
||
<FlatList
|
||
data={groups}
|
||
renderItem={renderGroupItem}
|
||
keyExtractor={(item) => String(item.id)}
|
||
refreshControl={
|
||
<RefreshControl
|
||
refreshing={isRefreshing}
|
||
onRefresh={refresh}
|
||
colors={[colors.primary.main]}
|
||
tintColor={colors.primary.main}
|
||
/>
|
||
}
|
||
onEndReached={loadMore}
|
||
onEndReachedThreshold={0.3}
|
||
ListEmptyComponent={renderEmptyList}
|
||
ListFooterComponent={
|
||
isLoading ? (
|
||
<View style={styles.loadingFooter}>
|
||
<ActivityIndicator color={colors.primary.main} />
|
||
</View>
|
||
) : hasMore ? (
|
||
<TouchableOpacity style={styles.loadMoreBtn} onPress={loadMore}>
|
||
<Text variant="caption" color={colors.primary.main}>
|
||
加载更多
|
||
</Text>
|
||
</TouchableOpacity>
|
||
) : groups.length > 0 ? (
|
||
<Text variant="caption" color={colors.text.hint} style={styles.noMoreText}>
|
||
没有更多群组了
|
||
</Text>
|
||
) : null
|
||
}
|
||
showsVerticalScrollIndicator={false}
|
||
/>
|
||
</View>
|
||
</View>
|
||
</View>
|
||
);
|
||
};
|
||
|
||
export default JoinGroupScreen;
|