20260626221809
This commit is contained in:
1 parent
5983d29128
commit
a45accd2de
12 files changed
+306
-92
No files matched your search
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,12 @@
|
|||||||
|
# 2026-06-26
|
||||||
|
|
||||||
|
## chat/index.tsx 乱码修复
|
||||||
|
- 文件中所有中文字符(注释、placeholder、UI 文案)因编码问题出现 mojibake(乱码)
|
||||||
|
- 文件是 UTF-8 with BOM(EF BB BF),BOM 可能导致了编码识别问题
|
||||||
|
- 修复了所有 14 处乱码字符串,同时清除了文件头的 BOM
|
||||||
|
- 修复内容:在线栏、消息气泡、未知、来自、撤回、删除、页面、成员映射、加载历史、加载更多、合并消息(旧→新)、加载中...、说点什么...、暂无消息,开始聊天吧
|
||||||
|
|
||||||
|
## chat/index.tsx 输入框获焦滚动优化
|
||||||
|
- 将 `keyboardWillShow` 改为 `keyboardDidShow`,确保键盘动画完成后再滚动
|
||||||
|
- 添加 `InteractionManager.runAfterInteractions` 用于无延迟的滚动调用
|
||||||
|
- 为 FlatList 添加 `maintainVisibleContentPosition={{ minIndexForVisible: 0, autoscrollToTopThreshold: 10 }}`,让 FlatList 在 viewport 变化时(键盘弹起/收起)自动保持可见内容位置
|
||||||
@@ -2,6 +2,7 @@ package ws
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"allapp-go/internal/httpx"
|
"allapp-go/internal/httpx"
|
||||||
|
"allapp-go/internal/middleware"
|
||||||
"allapp-go/pkg/db"
|
"allapp-go/pkg/db"
|
||||||
"allapp-go/pkg/jwtx"
|
"allapp-go/pkg/jwtx"
|
||||||
"allapp-go/pkg/uniqueid"
|
"allapp-go/pkg/uniqueid"
|
||||||
@@ -195,7 +196,7 @@ func (c *Client) readPump() {
|
|||||||
|
|
||||||
// handleSend 处理发送消息
|
// handleSend 处理发送消息
|
||||||
func (c *Client) handleSend(msg clientSendMsg) {
|
func (c *Client) handleSend(msg clientSendMsg) {
|
||||||
ctx := context.Background()
|
ctx := context.WithValue(context.Background(), middleware.CtxUserIDKey, c.userID)
|
||||||
dbClient := db.New()
|
dbClient := db.New()
|
||||||
|
|
||||||
msgID := uniqueid.NextId()
|
msgID := uniqueid.NextId()
|
||||||
@@ -293,7 +294,7 @@ func (c *Client) handleSend(msg clientSendMsg) {
|
|||||||
|
|
||||||
// handleRecall 处理撤回(发送者 2 分钟内)
|
// handleRecall 处理撤回(发送者 2 分钟内)
|
||||||
func (c *Client) handleRecall(msg clientSendMsg) {
|
func (c *Client) handleRecall(msg clientSendMsg) {
|
||||||
ctx := context.Background()
|
ctx := context.WithValue(context.Background(), middleware.CtxUserIDKey, c.userID)
|
||||||
dbClient := db.New()
|
dbClient := db.New()
|
||||||
|
|
||||||
rows, err := dbClient.LoadData(ctx, "b_chat",
|
rows, err := dbClient.LoadData(ctx, "b_chat",
|
||||||
@@ -337,7 +338,7 @@ func (c *Client) handleDelete(msg clientSendMsg) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.WithValue(context.Background(), middleware.CtxUserIDKey, c.userID)
|
||||||
dbClient := db.New()
|
dbClient := db.New()
|
||||||
|
|
||||||
if err := dbClient.Update(ctx, "b_chat", "id", map[string]any{
|
if err := dbClient.Update(ctx, "b_chat", "id", map[string]any{
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"backgroundColor": "#E6F4FE",
|
"backgroundColor": "#F0FDF4",
|
||||||
"foregroundImage": "./assets/images/android-icon-foreground.png",
|
"foregroundImage": "./assets/images/android-icon-foreground.png",
|
||||||
"backgroundImage": "./assets/images/android-icon-background.png",
|
"backgroundImage": "./assets/images/android-icon-background.png",
|
||||||
"monochromeImage": "./assets/images/android-icon-monochrome.png"
|
"monochromeImage": "./assets/images/android-icon-monochrome.png"
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
[
|
[
|
||||||
"expo-splash-screen",
|
"expo-splash-screen",
|
||||||
{
|
{
|
||||||
"backgroundColor": "#208AEF",
|
"backgroundColor": "#15803D",
|
||||||
"android": {
|
"android": {
|
||||||
"image": "./assets/images/splash-icon.png",
|
"image": "./assets/images/splash-icon.png",
|
||||||
"imageWidth": 76
|
"imageWidth": 76
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useCallback, useEffect, useRef, useState, memo } from "react";
|
import { useCallback, useEffect, useRef, useState, memo, useMemo } from "react";
|
||||||
import { Image, Text, TextInput, View, Keyboard } from "react-native";
|
import { Image, Text, TextInput, View, Keyboard, Animated } from "react-native";
|
||||||
import { KeyboardAvoidingView } from "react-native-keyboard-controller";
|
import { KeyboardAvoidingView } from "react-native-keyboard-controller";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { Menu, PressableFeedback, Spinner } from "heroui-native";
|
import { Menu, PressableFeedback, Spinner } from "heroui-native";
|
||||||
import { SendHorizonalIcon, SearchIcon } from "lucide-react-native";
|
import { AlertCircleIcon, PaperclipIcon, PlayIcon, SearchIcon, SendHorizonalIcon } from "lucide-react-native";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
import LayoutView from "@/components/layout/view";
|
import LayoutView from "@/components/layout/view";
|
||||||
import EmptyState from "@/components/EmptyStatus";
|
import EmptyState from "@/components/EmptyStatus";
|
||||||
import { loadDataApi, loadDataPageApi } from "@/request/api";
|
import { loadDataApi, loadDataPageApi } from "@/request/api";
|
||||||
@@ -27,20 +28,20 @@ function ChatOnlineBar({ users, onSearch }: { users: OnlineUser[]; onSearch: ()
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
return (
|
return (
|
||||||
<View className="flex-row items-center justify-between px-4 h-12 bg-white/90 rounded-2xl shadow-sm">
|
<View className="flex-row items-center justify-between px-4 h-12 bg-white/90 rounded-2xl shadow-sm">
|
||||||
<View className="flex-row items-center flex-1">
|
<View className="flex-row items-center flex-1 gap-2">
|
||||||
{users.slice(0, 6).map((u, i) => (
|
{users.slice(0, 6).map((u) => (
|
||||||
<PressableFeedback
|
<PressableFeedback
|
||||||
key={u.user_id}
|
key={u.user_id}
|
||||||
onPress={() => router.navigate(`/family/member/${u.user_id}` as any)}
|
onPress={() => router.navigate(`/family/member/${u.user_id}` as any)}
|
||||||
>
|
>
|
||||||
<View className={cn(i > 0 && "-ml-2")}>
|
<View>
|
||||||
{u.avatar ? (
|
{u.avatar ? (
|
||||||
<Image
|
<Image
|
||||||
source={{ uri: buildFileUrl(u.avatar) }}
|
source={{ uri: buildFileUrl(u.avatar) }}
|
||||||
className="w-8 h-8 rounded-full bg-gray-200 border-2 border-white"
|
className="w-8 h-8 rounded-full bg-gray-200"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<View className="w-8 h-8 rounded-full bg-accent/20 items-center justify-center border-2 border-white">
|
<View className="w-8 h-8 rounded-full bg-accent/20 items-center justify-center">
|
||||||
<Text className="text-xs font-medium text-accent">{u.nickname?.charAt(0) || "?"}</Text>
|
<Text className="text-xs font-medium text-accent">{u.nickname?.charAt(0) || "?"}</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -50,7 +51,7 @@ function ChatOnlineBar({ users, onSearch }: { users: OnlineUser[]; onSearch: ()
|
|||||||
</PressableFeedback>
|
</PressableFeedback>
|
||||||
))}
|
))}
|
||||||
{users.length > 6 && (
|
{users.length > 6 && (
|
||||||
<View className="-ml-2 w-8 h-8 rounded-full bg-gray-100 items-center justify-center border-2 border-white">
|
<View className="w-8 h-8 rounded-full bg-gray-100 items-center justify-center">
|
||||||
<Text className="text-[10px] font-medium text-gray-500">+{users.length - 6}</Text>
|
<Text className="text-[10px] font-medium text-gray-500">+{users.length - 6}</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -133,7 +134,7 @@ const ChatMessageItem = memo(function ChatMessageItem({
|
|||||||
isSelf ? "bg-accent rounded-br-md" : "bg-white rounded-bl-md shadow-sm",
|
isSelf ? "bg-accent rounded-br-md" : "bg-white rounded-bl-md shadow-sm",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Text className={cn("text-[15px] leading-[22px]", isSelf ? "text-white text-right" : "text-gray-900 text-left")}>
|
<Text className={cn("text-[15px] leading-[22px] text-left", isSelf ? "text-white" : "text-gray-900")}>
|
||||||
{item.content}
|
{item.content}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -155,7 +156,7 @@ const ChatMessageItem = memo(function ChatMessageItem({
|
|||||||
{item.msg_type === ChatMsgType.Video && item.attachments?.length && (
|
{item.msg_type === ChatMsgType.Video && item.attachments?.length && (
|
||||||
<View className="w-48 h-32 rounded-xl bg-gray-800/90 items-center justify-center overflow-hidden">
|
<View className="w-48 h-32 rounded-xl bg-gray-800/90 items-center justify-center overflow-hidden">
|
||||||
<View className="w-10 h-10 rounded-full bg-white/20 items-center justify-center">
|
<View className="w-10 h-10 rounded-full bg-white/20 items-center justify-center">
|
||||||
<Text className="text-white text-lg ml-0.5">▶</Text>
|
<PlayIcon size={20} color="white" fill="white" />
|
||||||
</View>
|
</View>
|
||||||
{item.attachments[0].duration ? (
|
{item.attachments[0].duration ? (
|
||||||
<Text className="text-white/80 text-[10px] absolute bottom-2 right-2">
|
<Text className="text-white/80 text-[10px] absolute bottom-2 right-2">
|
||||||
@@ -173,16 +174,16 @@ const ChatMessageItem = memo(function ChatMessageItem({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<View className={cn("w-9 h-9 rounded-lg items-center justify-center", isSelf ? "bg-white/20" : "bg-gray-100")}>
|
<View className={cn("w-9 h-9 rounded-lg items-center justify-center", isSelf ? "bg-white/20" : "bg-gray-100")}>
|
||||||
<Text className={cn("text-sm", isSelf ? "text-white" : "text-gray-500")}>📎</Text>
|
<PaperclipIcon size={17} color={isSelf ? "white" : "#6B7280"} />
|
||||||
</View>
|
</View>
|
||||||
<View className="flex-1">
|
<View className="flex-1">
|
||||||
<Text
|
<Text
|
||||||
className={cn("text-sm font-medium", isSelf ? "text-white" : "text-gray-900")}
|
className={cn("text-sm font-medium text-left", isSelf ? "text-white" : "text-gray-900")}
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
>
|
>
|
||||||
{item.attachments[0].file_name}
|
{item.attachments[0].file_name}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className={cn("text-[11px]", isSelf ? "text-white/70" : "text-gray-400")}>
|
<Text className={cn("text-[11px] text-left", isSelf ? "text-white/70" : "text-gray-400")}>
|
||||||
{item.attachments[0].file_size
|
{item.attachments[0].file_size
|
||||||
? (item.attachments[0].file_size / 1024).toFixed(0) + " KB"
|
? (item.attachments[0].file_size / 1024).toFixed(0) + " KB"
|
||||||
: ""}
|
: ""}
|
||||||
@@ -195,7 +196,7 @@ const ChatMessageItem = memo(function ChatMessageItem({
|
|||||||
<View className="max-w-[60%] rounded-xl overflow-hidden bg-white shadow-sm border border-gray-100">
|
<View className="max-w-[60%] rounded-xl overflow-hidden bg-white shadow-sm border border-gray-100">
|
||||||
<View className="px-3 py-2">
|
<View className="px-3 py-2">
|
||||||
<Text className="text-[11px] text-gray-400">来自 {item.source_table}</Text>
|
<Text className="text-[11px] text-gray-400">来自 {item.source_table}</Text>
|
||||||
<Text className="text-sm text-gray-700 mt-1" numberOfLines={3}>
|
<Text className="text-sm text-gray-700 mt-1 text-left" numberOfLines={3}>
|
||||||
{(() => {
|
{(() => {
|
||||||
try {
|
try {
|
||||||
const d = JSON.parse(item.source_data);
|
const d = JSON.parse(item.source_data);
|
||||||
@@ -212,7 +213,7 @@ const ChatMessageItem = memo(function ChatMessageItem({
|
|||||||
</View>
|
</View>
|
||||||
{item.content ? (
|
{item.content ? (
|
||||||
<View className="px-3 pb-2 border-t border-gray-50">
|
<View className="px-3 pb-2 border-t border-gray-50">
|
||||||
<Text className="text-sm text-gray-900">{item.content}</Text>
|
<Text className="text-sm text-gray-900 text-left">{item.content}</Text>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
@@ -274,15 +275,142 @@ export default function ChatPage() {
|
|||||||
const [inputText, setInputText] = useState("");
|
const [inputText, setInputText] = useState("");
|
||||||
const [adminRole, setAdminRole] = useState(0);
|
const [adminRole, setAdminRole] = useState(0);
|
||||||
const listRef = useRef<any>(null);
|
const listRef = useRef<any>(null);
|
||||||
|
const opacity = useRef(new Animated.Value(0)).current;
|
||||||
|
const revealed = useRef(false);
|
||||||
|
|
||||||
// 键盘弹起时自动滚到底部
|
// ---- optimistic send ----
|
||||||
useEffect(() => {
|
type PendingMsg = { localId: string; content: string; status: "sending" | "failed"; createTime: string };
|
||||||
const show = Keyboard.addListener("keyboardWillShow", () => {
|
const [pending, setPending] = useState<PendingMsg[]>([]);
|
||||||
setTimeout(() => listRef.current?.scrollToEnd?.({ animated: true }), 100);
|
const pendingTimers = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
|
||||||
});
|
|
||||||
return () => show.remove();
|
const removePending = useCallback((localId: string) => {
|
||||||
|
setPending((prev) => prev.filter((p) => p.localId !== localId));
|
||||||
|
const t = pendingTimers.current[localId];
|
||||||
|
if (t) { clearTimeout(t); delete pendingTimers.current[localId]; }
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Watch ws.messages — when a new message from self appears, resolve matching pending
|
||||||
|
const resolvedRef = useRef<Set<string>>(new Set());
|
||||||
|
useEffect(() => {
|
||||||
|
for (const m of ws.messages) {
|
||||||
|
if (m.user_id !== userId) continue;
|
||||||
|
const key = `${m.user_id}:${m.content}`;
|
||||||
|
if (resolvedRef.current.has(key)) continue;
|
||||||
|
resolvedRef.current.add(key);
|
||||||
|
const match = pending.find((p) => p.content === m.content && p.status === "sending");
|
||||||
|
if (match) {
|
||||||
|
removePending(match.localId);
|
||||||
|
toast.success("发送成功", { id: match.localId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [ws.messages, userId, pending, removePending]);
|
||||||
|
|
||||||
|
// ---- toast: online / offline ----
|
||||||
|
const onlineIdsRef = useRef<Set<number>>(new Set());
|
||||||
|
useEffect(() => {
|
||||||
|
const curr = new Set(ws.onlineUsers.map((u) => u.user_id));
|
||||||
|
const prev = onlineIdsRef.current;
|
||||||
|
// joined
|
||||||
|
for (const id of curr) {
|
||||||
|
if (!prev.has(id) && prev.size > 0) {
|
||||||
|
const u = ws.onlineUsers.find((x) => x.user_id === id);
|
||||||
|
if (u && u.user_id !== userId) toast(`${u.nickname} 上线了`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// left
|
||||||
|
for (const id of prev) {
|
||||||
|
if (!curr.has(id)) {
|
||||||
|
// look up nickname from members (offline event only gives user_id)
|
||||||
|
const m = members[id];
|
||||||
|
const name = m?.nickname || `用户${id}`;
|
||||||
|
toast(`${name} 下线了`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onlineIdsRef.current = curr;
|
||||||
|
}, [ws.onlineUsers, userId, members]);
|
||||||
|
|
||||||
|
// ---- toast: new message from others ----
|
||||||
|
const seenMsgIds = useRef<Set<number>>(new Set());
|
||||||
|
// Sync history IDs into seen set so we don't toast on initial load
|
||||||
|
useEffect(() => {
|
||||||
|
for (const m of history) seenMsgIds.current.add(m.id);
|
||||||
|
}, [history]);
|
||||||
|
// Watch ws.messages for new arrivals from others
|
||||||
|
useEffect(() => {
|
||||||
|
if (loading) return; // skip during initial load
|
||||||
|
for (const m of ws.messages) {
|
||||||
|
if (seenMsgIds.current.has(m.id)) continue;
|
||||||
|
seenMsgIds.current.add(m.id);
|
||||||
|
if (m.user_id !== userId) {
|
||||||
|
const name = m.nickname || members[m.user_id]?.nickname || "用户";
|
||||||
|
const preview = m.content.length > 20 ? m.content.slice(0, 20) + "…" : m.content;
|
||||||
|
toast(`${name}: ${preview}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [ws.messages, userId, members, loading]);
|
||||||
|
|
||||||
|
// Merge pending messages into the display list
|
||||||
|
const allMessages = useMemo(() => {
|
||||||
|
const ids = new Set(history.map((m) => m.id));
|
||||||
|
const merged: (ChatMessage & { _pending?: PendingMsg })[] = [...history];
|
||||||
|
for (const m of ws.messages) {
|
||||||
|
if (!ids.has(m.id)) { merged.push(m); ids.add(m.id); }
|
||||||
|
}
|
||||||
|
// Append pending (sending/failed) at the end
|
||||||
|
for (const p of pending) {
|
||||||
|
merged.push({
|
||||||
|
id: -(Date.now() % 100000), // negative ID for local-only items
|
||||||
|
family_id: familyId ?? 0,
|
||||||
|
user_id: userId,
|
||||||
|
nickname: "",
|
||||||
|
avatar: "",
|
||||||
|
content: p.content,
|
||||||
|
msg_type: ChatMsgType.Text,
|
||||||
|
source_table: null,
|
||||||
|
source_id: null,
|
||||||
|
source_data: null,
|
||||||
|
attachments: null,
|
||||||
|
recalled: 0,
|
||||||
|
deleted: 0,
|
||||||
|
create_time: p.createTime,
|
||||||
|
_pending: p,
|
||||||
|
} as any);
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}, [history, ws.messages, pending, familyId, userId]);
|
||||||
|
|
||||||
|
// Non-inverted: scrollToEnd = latest messages
|
||||||
|
const scrollToBottom = useCallback(
|
||||||
|
(animated = true) => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
listRef.current?.scrollToEnd?.({ animated });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const revealAtBottom = useCallback(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
listRef.current?.scrollToEnd?.({ animated: false });
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
listRef.current?.scrollToEnd?.({ animated: false });
|
||||||
|
opacity.setValue(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [opacity]);
|
||||||
|
|
||||||
|
// Keyboard appears → double-tap scroll: first to catch the new viewport,
|
||||||
|
// second as a safety net after FlatList internal measurements settle.
|
||||||
|
useEffect(() => {
|
||||||
|
const sub = Keyboard.addListener("keyboardDidShow", () => {
|
||||||
|
setTimeout(() => scrollToBottom(true), 200);
|
||||||
|
setTimeout(() => scrollToBottom(true), 400);
|
||||||
|
});
|
||||||
|
return () => sub.remove();
|
||||||
|
}, [scrollToBottom]);
|
||||||
|
|
||||||
// 成员映射
|
// 成员映射
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!familyId) return;
|
if (!familyId) return;
|
||||||
@@ -364,27 +492,54 @@ export default function ChatPage() {
|
|||||||
});
|
});
|
||||||
}, [loadingMore, hasMore, page, loadHistory, runLoadMore]);
|
}, [loadingMore, hasMore, page, loadHistory, runLoadMore]);
|
||||||
|
|
||||||
// 合并消息 (旧→新)
|
// Send the current message draft (optimistic).
|
||||||
const allMessages = (() => {
|
function handleSend() {
|
||||||
const ids = new Set(history.map((m) => m.id));
|
|
||||||
const merged = [...history];
|
|
||||||
for (const m of ws.messages) {
|
|
||||||
if (!ids.has(m.id)) {
|
|
||||||
merged.push(m);
|
|
||||||
ids.add(m.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return merged;
|
|
||||||
})();
|
|
||||||
|
|
||||||
// 发送
|
|
||||||
const handleSend = useCallback(() => {
|
|
||||||
const text = inputText.trim();
|
const text = inputText.trim();
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
setInputText("");
|
setInputText("");
|
||||||
Keyboard.dismiss();
|
Keyboard.dismiss();
|
||||||
|
|
||||||
|
const localId = `${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
// Show immediately in the list
|
||||||
|
setPending((prev) => [...prev, { localId, content: text, status: "sending", createTime: now }]);
|
||||||
|
toast.loading("正在发送", { id: localId });
|
||||||
|
|
||||||
|
// Send via WebSocket
|
||||||
ws.send({ type: "send", content: text });
|
ws.send({ type: "send", content: text });
|
||||||
}, [inputText, ws]);
|
|
||||||
|
// 10-second timeout → mark as failed
|
||||||
|
pendingTimers.current[localId] = setTimeout(() => {
|
||||||
|
setPending((prev) => prev.map((p) => (p.localId === localId ? { ...p, status: "failed" } : p)));
|
||||||
|
toast.error("发送失败", { id: localId });
|
||||||
|
}, 10_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retry a failed message
|
||||||
|
function handleRetry(p: PendingMsg) {
|
||||||
|
setPending((prev) => prev.map((x) => (x.localId === p.localId ? { ...x, status: "sending" } : x)));
|
||||||
|
toast.loading("正在发送", { id: p.localId });
|
||||||
|
ws.send({ type: "send", content: p.content });
|
||||||
|
pendingTimers.current[p.localId] = setTimeout(() => {
|
||||||
|
setPending((prev) => prev.map((x) => (x.localId === p.localId ? { ...x, status: "failed" } : x)));
|
||||||
|
toast.error("发送失败", { id: p.localId });
|
||||||
|
}, 10_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-scroll when a new message arrives (WebSocket push)
|
||||||
|
const msgCountRef = useRef(0);
|
||||||
|
useEffect(() => {
|
||||||
|
if (msgCountRef.current === 0 && allMessages.length > 0) {
|
||||||
|
// Initial data just arrived — seed count, don't scroll
|
||||||
|
msgCountRef.current = allMessages.length;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (allMessages.length > msgCountRef.current) {
|
||||||
|
scrollToBottom(true);
|
||||||
|
}
|
||||||
|
msgCountRef.current = allMessages.length;
|
||||||
|
}, [allMessages.length, scrollToBottom]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -400,9 +555,10 @@ export default function ChatPage() {
|
|||||||
behavior="padding"
|
behavior="padding"
|
||||||
keyboardVerticalOffset={-60}
|
keyboardVerticalOffset={-60}
|
||||||
>
|
>
|
||||||
<LayoutView>
|
<Animated.View style={{ flex: 1, opacity }}>
|
||||||
<LayoutView.FlatList
|
<LayoutView>
|
||||||
ref={listRef}
|
<LayoutView.FlatList
|
||||||
|
ref={listRef}
|
||||||
header={
|
header={
|
||||||
<ChatOnlineBar
|
<ChatOnlineBar
|
||||||
users={ws.onlineUsers}
|
users={ws.onlineUsers}
|
||||||
@@ -410,24 +566,32 @@ export default function ChatPage() {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
footer={
|
footer={
|
||||||
<View className="flex-row items-center gap-2 px-3 py-2.5 bg-white/95 rounded-2xl shadow-sm">
|
<View className="flex-row items-center gap-2 px-2.5 py-1.5 bg-white/95 rounded-2xl shadow-sm">
|
||||||
<TextInput
|
<View className="flex-1 min-h-9 max-h-20 rounded-xl bg-background px-3 justify-center">
|
||||||
className="flex-1 h-10 bg-gray-100 rounded-full px-4 text-[15px]"
|
<TextInput
|
||||||
placeholder="输入消息..."
|
className="min-h-8 max-h-20 text-[15px] leading-[20px] text-foreground"
|
||||||
placeholderTextColor="#9CA3AF"
|
placeholder="说点什么..."
|
||||||
value={inputText}
|
placeholderTextColor="#86A19C"
|
||||||
onChangeText={setInputText}
|
value={inputText}
|
||||||
onSubmitEditing={handleSend}
|
onChangeText={setInputText}
|
||||||
returnKeyType="send"
|
onFocus={() => scrollToBottom(true)}
|
||||||
/>
|
onSubmitEditing={handleSend}
|
||||||
|
returnKeyType="send"
|
||||||
|
multiline
|
||||||
|
maxLength={800}
|
||||||
|
textAlignVertical="center"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
<PressableFeedback onPress={handleSend} isDisabled={!inputText.trim()}>
|
<PressableFeedback onPress={handleSend} isDisabled={!inputText.trim()}>
|
||||||
<View
|
<View
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-10 h-10 rounded-full items-center justify-center",
|
"w-9 h-9 rounded-full items-center justify-center",
|
||||||
inputText.trim() ? "bg-accent" : "bg-gray-200",
|
inputText.trim()
|
||||||
|
? "bg-accent shadow-sm"
|
||||||
|
: "bg-background",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<SendHorizonalIcon size={18} color={inputText.trim() ? "white" : "#9CA3AF"} />
|
<SendHorizonalIcon size={17} color={inputText.trim() ? "white" : "#86A19C"} />
|
||||||
</View>
|
</View>
|
||||||
<PressableFeedback.Ripple />
|
<PressableFeedback.Ripple />
|
||||||
</PressableFeedback>
|
</PressableFeedback>
|
||||||
@@ -435,6 +599,7 @@ export default function ChatPage() {
|
|||||||
}
|
}
|
||||||
data={allMessages}
|
data={allMessages}
|
||||||
keyExtractor={(item) => String(item.id)}
|
keyExtractor={(item) => String(item.id)}
|
||||||
|
contentContainerStyle={{ paddingBottom: 12 }}
|
||||||
onScroll={({ nativeEvent }) => {
|
onScroll={({ nativeEvent }) => {
|
||||||
if (nativeEvent.contentOffset.y < 30 && hasMore && !loadingMore) {
|
if (nativeEvent.contentOffset.y < 30 && hasMore && !loadingMore) {
|
||||||
handleLoadMore();
|
handleLoadMore();
|
||||||
@@ -442,8 +607,12 @@ export default function ChatPage() {
|
|||||||
}}
|
}}
|
||||||
scrollEventThrottle={200}
|
scrollEventThrottle={200}
|
||||||
onContentSizeChange={() => {
|
onContentSizeChange={() => {
|
||||||
if (loadingMore) return;
|
if (!revealed.current) {
|
||||||
listRef.current?.scrollToEnd({ animated: false });
|
revealed.current = true;
|
||||||
|
revealAtBottom();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!loadingMore) scrollToBottom(false);
|
||||||
}}
|
}}
|
||||||
ListHeaderComponent={
|
ListHeaderComponent={
|
||||||
loadingMore ? (
|
loadingMore ? (
|
||||||
@@ -457,18 +626,45 @@ export default function ChatPage() {
|
|||||||
<Text className="text-gray-400 text-sm">暂无消息,开始聊天吧</Text>
|
<Text className="text-gray-400 text-sm">暂无消息,开始聊天吧</Text>
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
renderItem={({ item }) => (
|
renderItem={({ item }: { item: any }) => {
|
||||||
<ChatMessageItem
|
const pending = item._pending as PendingMsg | undefined;
|
||||||
item={item}
|
if (pending) {
|
||||||
isSelf={item.user_id === userId}
|
return (
|
||||||
members={members}
|
<View className="flex-row items-center px-3 py-1.5 justify-end">
|
||||||
isAdmin={adminRole === 0}
|
{pending.status === "sending" ? (
|
||||||
onRecall={ws.recall}
|
<View className="mr-1">
|
||||||
onDelete={ws.deleteMessage}
|
<Spinner size="sm" color="#9CA3AF" />
|
||||||
/>
|
</View>
|
||||||
)}
|
) : (
|
||||||
|
<PressableFeedback onPress={() => handleRetry(pending)}>
|
||||||
|
<View className="mr-1 p-1">
|
||||||
|
<AlertCircleIcon size={16} color="#EF4444" />
|
||||||
|
</View>
|
||||||
|
<PressableFeedback.Ripple />
|
||||||
|
</PressableFeedback>
|
||||||
|
)}
|
||||||
|
<View className="max-w-[60%] rounded-2xl rounded-br-md px-3.5 py-2 bg-accent">
|
||||||
|
<Text className="text-[15px] leading-[22px] text-white">
|
||||||
|
{pending.content}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ChatMessageItem
|
||||||
|
item={item}
|
||||||
|
isSelf={item.user_id === userId}
|
||||||
|
members={members}
|
||||||
|
isAdmin={adminRole === 0}
|
||||||
|
onRecall={ws.recall}
|
||||||
|
onDelete={ws.deleteMessage}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</LayoutView>
|
</LayoutView>
|
||||||
|
</Animated.View>
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import LayoutView from "@/components/layout/view";
|
import LayoutView from "@/components/layout/view";
|
||||||
import { SearchField, CloseButton, PressableFeedback } from "heroui-native";
|
import { SearchField, CloseButton, PressableFeedback, useThemeColor } from "heroui-native";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
import { View, Text } from "react-native";
|
import { View, Text } from "react-native";
|
||||||
import { Stack, useRouter } from "expo-router";
|
import { Stack, useRouter } from "expo-router";
|
||||||
@@ -14,6 +14,7 @@ import { Heart } from "lucide-react-native";
|
|||||||
export default function Tools() {
|
export default function Tools() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const safeAreaInsets = useSafeAreaInsets();
|
const safeAreaInsets = useSafeAreaInsets();
|
||||||
|
const accentColor = useThemeColor("accent");
|
||||||
const scrollRef = useRef<any>(null);
|
const scrollRef = useRef<any>(null);
|
||||||
const headerPositions = useRef<Record<string, number>>({});
|
const headerPositions = useRef<Record<string, number>>({});
|
||||||
|
|
||||||
@@ -120,7 +121,7 @@ export default function Tools() {
|
|||||||
{/* 收藏分组特殊标记 */}
|
{/* 收藏分组特殊标记 */}
|
||||||
{item.title === "收藏" && (
|
{item.title === "收藏" && (
|
||||||
<View className="w-5 h-5 rounded-full bg-accent/15 items-center justify-center mr-1.5">
|
<View className="w-5 h-5 rounded-full bg-accent/15 items-center justify-center mr-1.5">
|
||||||
<Heart size={10} fill="#0d9488" color="#0d9488" />
|
<Heart size={10} fill={accentColor} color={accentColor} />
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
{/* 普通分组标记 */}
|
{/* 普通分组标记 */}
|
||||||
@@ -175,8 +176,8 @@ export default function Tools() {
|
|||||||
>
|
>
|
||||||
<Heart
|
<Heart
|
||||||
size={18}
|
size={18}
|
||||||
fill={isFav ? "#0d9488" : "none"}
|
fill={isFav ? accentColor : "none"}
|
||||||
color={isFav ? "#0d9488" : "#9ca3af"}
|
color={isFav ? accentColor : "#9ca3af"}
|
||||||
/>
|
/>
|
||||||
<PressableFeedback.Ripple />
|
<PressableFeedback.Ripple />
|
||||||
</PressableFeedback>
|
</PressableFeedback>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import DateTimePicker from "react-native-dates-picker";
|
import DateTimePicker from "react-native-dates-picker";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import { BottomSheet, Button, Spinner } from "heroui-native";
|
import { BottomSheet, Button, Spinner, useThemeColor } from "heroui-native";
|
||||||
import { useState, useEffect, useCallback, useRef } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import { CheckIcon } from "lucide-react-native";
|
import { CheckIcon } from "lucide-react-native";
|
||||||
@@ -49,6 +49,7 @@ export default function DateTime({
|
|||||||
format,
|
format,
|
||||||
}: DatePickerProps) {
|
}: DatePickerProps) {
|
||||||
const finalFormat = format ?? defaultFormatMap[mode];
|
const finalFormat = format ?? defaultFormatMap[mode];
|
||||||
|
const accentColor = useThemeColor("accent");
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [tempDate, setTempDate] = useState<dayjs.Dayjs | undefined>(dayjs());
|
const [tempDate, setTempDate] = useState<dayjs.Dayjs | undefined>(dayjs());
|
||||||
@@ -100,7 +101,7 @@ export default function DateTime({
|
|||||||
date={tempDate}
|
date={tempDate}
|
||||||
initialView={modeViewMap[mode]}
|
initialView={modeViewMap[mode]}
|
||||||
timePicker={mode === "time" || mode === "datetime"}
|
timePicker={mode === "time" || mode === "datetime"}
|
||||||
selectedItemColor="#0d9488"
|
selectedItemColor={accentColor}
|
||||||
onChange={({ date }) => {
|
onChange={({ date }) => {
|
||||||
if (date) setTempDate(dayjs(date));
|
if (date) setTempDate(dayjs(date));
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import DateTimePicker, { DateType } from "react-native-dates-picker";
|
import DateTimePicker, { DateType } from "react-native-dates-picker";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import { BottomSheet, Button, Spinner } from "heroui-native";
|
import { BottomSheet, Button, Spinner, useThemeColor } from "heroui-native";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import { useState, useEffect, useCallback, useRef } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import { CheckIcon } from "lucide-react-native";
|
import { CheckIcon } from "lucide-react-native";
|
||||||
@@ -21,6 +21,8 @@ export default function DateTimeRange({
|
|||||||
onClose,
|
onClose,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
}: DateRangePickerProps) {
|
}: DateRangePickerProps) {
|
||||||
|
const accentColor = useThemeColor("accent");
|
||||||
|
const accentSoftColor = useThemeColor("accent-soft");
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [range, setRange] = useState<{
|
const [range, setRange] = useState<{
|
||||||
startDate: DateType;
|
startDate: DateType;
|
||||||
@@ -75,8 +77,8 @@ export default function DateTimeRange({
|
|||||||
mode="range"
|
mode="range"
|
||||||
startDate={range.startDate}
|
startDate={range.startDate}
|
||||||
endDate={range.endDate}
|
endDate={range.endDate}
|
||||||
selectedItemColor="#0d9488"
|
selectedItemColor={accentColor}
|
||||||
selectedRangeBackgroundColor="rgba(13,148,136,0.15)"
|
selectedRangeBackgroundColor={accentSoftColor}
|
||||||
onChange={(params) => setRange(params)}
|
onChange={(params) => setRange(params)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -7,13 +7,13 @@ import Animated, {
|
|||||||
withTiming,
|
withTiming,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
import { useThemeColor } from "heroui-native";
|
||||||
|
|
||||||
// ── 视觉常量 ──────────────────────────────────────
|
// ── 视觉常量 ──────────────────────────────────────
|
||||||
const ICON_SIZE = 20;
|
const ICON_SIZE = 20;
|
||||||
const LABEL_SIZE = 11;
|
const LABEL_SIZE = 11;
|
||||||
const BAR_HEIGHT = 56;
|
const BAR_HEIGHT = 56;
|
||||||
const BAR_RADIUS = 16;
|
const BAR_RADIUS = 16;
|
||||||
const COLOR_ACTIVE = "#10B981";
|
|
||||||
const COLOR_IDLE = "#9CA3AF";
|
const COLOR_IDLE = "#9CA3AF";
|
||||||
const BAR_BG = "#FFFFFF";
|
const BAR_BG = "#FFFFFF";
|
||||||
|
|
||||||
@@ -62,6 +62,7 @@ function TabItem({
|
|||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
}) {
|
}) {
|
||||||
const Icon = tab.icon;
|
const Icon = tab.icon;
|
||||||
|
const activeColor = useThemeColor("accent");
|
||||||
const scale = useSharedValue(1);
|
const scale = useSharedValue(1);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -86,7 +87,7 @@ function TabItem({
|
|||||||
<Animated.View style={[s.itemInner, animatedStyle]}>
|
<Animated.View style={[s.itemInner, animatedStyle]}>
|
||||||
<Icon
|
<Icon
|
||||||
size={ICON_SIZE}
|
size={ICON_SIZE}
|
||||||
color={active ? COLOR_ACTIVE : COLOR_IDLE}
|
color={active ? activeColor : COLOR_IDLE}
|
||||||
strokeWidth={active ? 2.2 : 1.8}
|
strokeWidth={active ? 2.2 : 1.8}
|
||||||
/>
|
/>
|
||||||
{tab.title ? (
|
{tab.title ? (
|
||||||
@@ -94,7 +95,7 @@ function TabItem({
|
|||||||
style={[
|
style={[
|
||||||
s.label,
|
s.label,
|
||||||
{
|
{
|
||||||
color: active ? COLOR_ACTIVE : COLOR_IDLE,
|
color: active ? activeColor : COLOR_IDLE,
|
||||||
fontWeight: active ? "600" : "400",
|
fontWeight: active ? "600" : "400",
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
ViewStyle,
|
ViewStyle,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { KeyboardAwareScrollView } from "react-native-keyboard-controller";
|
import { KeyboardAwareScrollView } from "react-native-keyboard-controller";
|
||||||
|
|
||||||
import { ScrollShadow } from "heroui-native";
|
import { ScrollShadow } from "heroui-native";
|
||||||
import { LinearGradient } from "expo-linear-gradient";
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
import { LegendList } from "@legendapp/list";
|
import { LegendList } from "@legendapp/list";
|
||||||
@@ -134,14 +133,13 @@ LayoutView.FlatList = forwardRef(
|
|||||||
paddingTop: padding.paddingTop,
|
paddingTop: padding.paddingTop,
|
||||||
paddingHorizontal: padding.paddingHorizontal,
|
paddingHorizontal: padding.paddingHorizontal,
|
||||||
}}
|
}}
|
||||||
|
className="z-[999]"
|
||||||
>
|
>
|
||||||
{header}
|
{header}
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
<Wrapper>
|
<Wrapper>
|
||||||
<Shadow>
|
<FlatList ref={ref} {...props} contentContainerStyle={contentContainerStyle} />
|
||||||
<FlatList ref={ref} {...props} contentContainerStyle={contentContainerStyle} />
|
|
||||||
</Shadow>
|
|
||||||
</Wrapper>
|
</Wrapper>
|
||||||
{footer ? (
|
{footer ? (
|
||||||
<View
|
<View
|
||||||
|
|||||||
@@ -6,10 +6,12 @@
|
|||||||
|
|
||||||
@layer theme {
|
@layer theme {
|
||||||
:root {
|
:root {
|
||||||
--background: oklch(0.98 0 0);
|
@variant light {
|
||||||
--foreground: oklch(0.15 0 0);
|
--background: oklch(0.98 0 0);
|
||||||
|
--foreground: oklch(0.15 0 0);
|
||||||
|
|
||||||
--accent: oklch(0.76 0.12 170);
|
--accent: oklch(0.76 0.12 170);
|
||||||
--accent-foreground: oklch(0.98 0 0);
|
--accent-foreground: oklch(0.98 0 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user