From d129a87a3972fcd7fe5131861f6d3ddb5d798d6d Mon Sep 17 00:00:00 2001 From: murdle Date: Tue, 2 Dec 2025 01:37:14 +0200 Subject: [PATCH] add attachments and fix bugs --- .../com/rscordmobile/AppNativePackage.java | 25 ++++ .../com/rscordmobile/FilePickerModule.java | 104 ++++++++++++++++ .../com/rscordmobile/MainApplication.java | 5 +- package-lock.json | 8 +- src/components/ui/icon-symbol.tsx | 9 +- src/lib/fs.ts | 13 +- src/lib/network/api.tsx | 67 +++++++--- src/lib/network/ws.tsx | 81 ++++++------ src/lib/stores/chats.ts | 43 +++---- src/lib/types/opcodes.ts | 1 + src/lib/types/types.ts | 3 +- src/screens/conversation.tsx | 115 +++++++++++++----- src/screens/tabs/chats.tsx | 1 - src/screens/tabs/settings.tsx | 2 +- 14 files changed, 348 insertions(+), 129 deletions(-) create mode 100644 android/app/src/main/java/com/rscordmobile/AppNativePackage.java create mode 100644 android/app/src/main/java/com/rscordmobile/FilePickerModule.java diff --git a/android/app/src/main/java/com/rscordmobile/AppNativePackage.java b/android/app/src/main/java/com/rscordmobile/AppNativePackage.java new file mode 100644 index 0000000..8a6a509 --- /dev/null +++ b/android/app/src/main/java/com/rscordmobile/AppNativePackage.java @@ -0,0 +1,25 @@ +package com.rscordmobile; + +import com.facebook.react.ReactPackage; +import com.facebook.react.bridge.NativeModule; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.uimanager.ViewManager; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public class AppNativePackage implements ReactPackage { + + @Override + public List createNativeModules(ReactApplicationContext reactContext) { + List modules = new ArrayList<>(); + modules.add(new FilePickerModule(reactContext)); + return modules; + } + + @Override + public List createViewManagers(ReactApplicationContext reactContext) { + return Collections.emptyList(); + } +} \ No newline at end of file diff --git a/android/app/src/main/java/com/rscordmobile/FilePickerModule.java b/android/app/src/main/java/com/rscordmobile/FilePickerModule.java new file mode 100644 index 0000000..8453c8b --- /dev/null +++ b/android/app/src/main/java/com/rscordmobile/FilePickerModule.java @@ -0,0 +1,104 @@ +package com.rscordmobile; + +import android.app.Activity; +import android.content.Intent; +import android.database.Cursor; +import android.net.Uri; +import android.provider.OpenableColumns; +import android.webkit.MimeTypeMap; + +import androidx.annotation.NonNull; + +import com.facebook.react.bridge.ActivityEventListener; +import com.facebook.react.bridge.Arguments; +import com.facebook.react.bridge.Promise; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.ReactContextBaseJavaModule; +import com.facebook.react.bridge.ReactMethod; +import com.facebook.react.bridge.WritableMap; + +public class FilePickerModule extends ReactContextBaseJavaModule implements ActivityEventListener { + private static final int PICK_FILE = 1234; + private Promise pickerPromise; + + public FilePickerModule(ReactApplicationContext reactContext) { + super(reactContext); + reactContext.addActivityEventListener(this); + } + + @NonNull + @Override + public String getName() { + return "FilePicker"; + } + + @ReactMethod + public void open(Promise promise) { + Activity activity = getCurrentActivity(); + if (activity == null) { + promise.reject("NO_ACTIVITY", "No activity attached"); + return; + } + + pickerPromise = promise; + + Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); + intent.addCategory(Intent.CATEGORY_OPENABLE); + intent.setType("*/*"); + + activity.startActivityForResult(intent, PICK_FILE); + } + + @Override + public void onActivityResult(Activity activity, int requestCode, int resultCode, Intent data) { + if (requestCode == PICK_FILE && pickerPromise != null) { + if (resultCode == Activity.RESULT_OK) { + Uri uri = data.getData(); + String fileName = getFileName(uri); + String mimeType = getMimeType(uri); + + WritableMap map = Arguments.createMap(); + map.putString("uri", uri.toString()); + map.putString("name", fileName); + map.putString("type", mimeType); + + pickerPromise.resolve(map); + } else { + pickerPromise.reject("CANCELLED", "User cancelled"); + } + pickerPromise = null; + } + } + + private String getFileName(Uri uri) { + String result = null; + if ("content".equals(uri.getScheme())) { + Cursor cursor = getReactApplicationContext().getContentResolver() + .query(uri, null, null, null, null); + try { + if (cursor != null && cursor.moveToFirst()) { + int index = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME); + if (index >= 0) result = cursor.getString(index); + } + } finally { + if (cursor != null) cursor.close(); + } + } + if (result == null) { + result = uri.getLastPathSegment(); // fallback + } + return result; + } + + private String getMimeType(Uri uri) { + String type = getReactApplicationContext().getContentResolver().getType(uri); + if (type == null) { + String extension = MimeTypeMap.getFileExtensionFromUrl(uri.toString()); + type = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension); + } + return type != null ? type : "application/octet-stream"; + } + + @Override + public void onNewIntent(Intent intent) {} +} \ No newline at end of file diff --git a/android/app/src/main/java/com/rscordmobile/MainApplication.java b/android/app/src/main/java/com/rscordmobile/MainApplication.java index da478d8..deb1ef5 100644 --- a/android/app/src/main/java/com/rscordmobile/MainApplication.java +++ b/android/app/src/main/java/com/rscordmobile/MainApplication.java @@ -1,5 +1,6 @@ package com.rscordmobile; +import com.rscordmobile.AppNativePackage; import android.app.Application; import android.content.Context; import android.os.Build; @@ -23,10 +24,8 @@ public class MainApplication extends Application implements ReactApplication { @Override protected List getPackages() { - @SuppressWarnings("UnnecessaryLocalVariable") List packages = new PackageList(this).getPackages(); - // Packages that cannot be autolinked yet can be added manually here, for example: - // packages.add(new MyReactNativePackage()); + packages.add(new AppNativePackage()); return packages; } diff --git a/package-lock.json b/package-lock.json index 87b5220..2050036 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,7 +40,7 @@ "jest": "^25.1.0", "metro-react-native-babel-preset": "^0.59.0", "react-test-renderer": "16.13.1", - "typescript": "^3.8.3" + "typescript": "^4.9.5" } }, "node_modules/@babel/code-frame": { @@ -14900,9 +14900,9 @@ } }, "node_modules/typescript": { - "version": "3.9.10", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.9.10.tgz", - "integrity": "sha512-w6fIxVE/H1PkLKcCPsFqKE7Kv7QUwhU8qQY2MueZXWx5cPZdwFupLgKK3vntcK98BtNHZtAF4LA/yl2a7k8R6Q==", + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", "dev": true, "license": "Apache-2.0", "peer": true, diff --git a/src/components/ui/icon-symbol.tsx b/src/components/ui/icon-symbol.tsx index 339dbe2..1397dad 100644 --- a/src/components/ui/icon-symbol.tsx +++ b/src/components/ui/icon-symbol.tsx @@ -1,6 +1,7 @@ +import { OpaqueColorValue, type StyleProp, type TextStyle } from 'react-native'; import MaterialIcons from 'react-native-vector-icons/MaterialIcons'; import React from 'react'; -import { OpaqueColorValue, type StyleProp, type TextStyle } from 'react-native'; +import { useThemeColor } from '../../lib/hooks/use-theme-color'; export type IconSymbolName = | 'home' @@ -8,6 +9,7 @@ export type IconSymbolName = | 'send' | 'code' | 'chevron-right' + | 'file-upload' | 'settings'; export function IconSymbol({ @@ -18,8 +20,9 @@ export function IconSymbol({ }: { name: IconSymbolName; size?: number; - color: string | OpaqueColorValue; + color?: string | OpaqueColorValue; style?: StyleProp; }) { - return ; + const iconColor = color ?? useThemeColor("text") + return ; } \ No newline at end of file diff --git a/src/lib/fs.ts b/src/lib/fs.ts index ca31bfa..236e676 100644 --- a/src/lib/fs.ts +++ b/src/lib/fs.ts @@ -1,10 +1,17 @@ import RNFetchBlob from "rn-fetch-blob"; +import { NativeModules } from "react-native"; export enum SystemDir { Pictures, Downloads } +export interface OpenResult { + name: string; + type: string; + uri: string; +} + export const getSystemDir = (type: SystemDir) => { let directory; switch (type) { @@ -33,4 +40,8 @@ export const downloadFile = (fileName: string, url: string, directory: string) = .catch(err => { console.error("Download failed:", err); }); -} \ No newline at end of file +} + +export const FilePicker = NativeModules.FilePicker as { + open: () => Promise; +}; \ No newline at end of file diff --git a/src/lib/network/api.tsx b/src/lib/network/api.tsx index c02a792..1c3040b 100644 --- a/src/lib/network/api.tsx +++ b/src/lib/network/api.tsx @@ -5,23 +5,24 @@ import { Message } from "../types/discord"; import { HttpResponse, LoginResponse } from "../types/types"; import { URLSearchParams } from "react-native-url-polyfill"; import { ImageSourcePropType } from "react-native"; +import { OpenResult } from "../fs"; interface ApiContextType { login: (email: string, password: string, instanceUrl: string) => Promise>; http: (url: string, method?: "GET" | "POST", body?: unknown) => Promise>; getAvatarUrl: (id?: string, hash?: string | null) => ImageSourcePropType; - populateMessages: (channelId: string, opts?: { before?: string; limit?: number }) => Promise; - sendMessage: (channelId: string, content: string) => Promise> + populateMessages: (channelId: string, opts?: { before?: string; limit?: number }) => Promise; + sendMessage: (channelId: string, content: string, file?: OpenResult) => Promise> } const ApiContext = createContext(null); export const ApiProvider = ({ children }: { children: React.ReactNode }) => { - const setToken = useBaseStore((s) => s.setToken); - const setBaseUrl = useBaseStore((s) => s.setBaseUrl); - const token = useBaseStore((s) => s.token); + const setToken = useBaseStore((s) => s.setToken); + const baseUrl = useBaseStore((s) => s.baseUrl); + const setBaseUrl = useBaseStore((s) => s.setBaseUrl); const http = async ( url: string, @@ -35,27 +36,40 @@ export const ApiProvider = ({ children }: { children: React.ReactNode }) => { const newUrl = isAbsolute ? url : `${baseUrl}${url}`; try { + const headers: HeadersInit_ = { + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }; + + let requestBody; + + if (body instanceof FormData) { + requestBody = body; + } else if (body) { + headers["Content-Type"] = "application/json"; + requestBody = JSON.stringify(body); + } + const res = await fetch(newUrl, { method, - headers: { - "Content-Type": "application/json", - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, - body: body ? JSON.stringify(body) : undefined, + headers, + body: requestBody, signal: controller.signal, }); const contentType = res.headers.get("content-type") || ""; - const data: T | null = contentType.includes("application/json") ? await res.json() : null; + const data: T | null = contentType.includes("application/json") + ? await res.json() + : null; - if (res.ok) + if (res.ok) { return { ok: true, status: res.status, data }; + } return { ok: false, status: res.status, data, - error: (data as any)?.message || `HTTP ${res.status}` + error: (data as any)?.message || `HTTP ${res.status}`, }; } catch (err: any) { const error = err.name === "AbortError" ? "timeout" : err?.message ?? String(err); @@ -99,22 +113,39 @@ export const ApiProvider = ({ children }: { children: React.ReactNode }) => { if (!res.ok || !res.data) throw new Error(res.error ?? `Failed to fetch messages for channel ${channelId}`); - const fetched: Message[] = res.data ?? []; + const fetched: Message[] = res.data; useChatStore.getState().appendOlder(channelId, fetched); + + return fetched; }; const sendMessage = async ( channelId: string, - content: string + content: string, + file?: OpenResult ) => { const nonce = Math.floor(Math.random() * 10 ** 8) + ""; - const res = await http( + + if (file) { + const formData = new FormData(); + formData.append("content", content); + formData.append("nonce", nonce); + formData.append("tts", "false"); + formData.append("file", file); + + return await http( + `/api/v9/channels/${channelId}/messages`, + "POST", + formData + ); + } + + return await http( `/api/v9/channels/${channelId}/messages`, "POST", { content, nonce, tts: false } ); - return res; - } + }; const getAvatarUrl = (id?: string, hash?: string | null): ImageSourcePropType => { if (id && hash) { diff --git a/src/lib/network/ws.tsx b/src/lib/network/ws.tsx index 5a6d8e2..88adde0 100644 --- a/src/lib/network/ws.tsx +++ b/src/lib/network/ws.tsx @@ -8,17 +8,18 @@ import { Message } from "../types/discord"; import { URL } from "react-native-url-polyfill"; export const WebSocketProvider = ({ children }: { children: React.ReactNode }) => { - const setConnected = useWsStore((s) => s.setConnected); - const setUser = useBaseStore((s) => s.setUser); - const setPrivateChannels = useChatStore((s) => s.setPrivateChannels) - const addMessage = useChatStore((s) => s.addMessage) - const token = useBaseStore((s) => s.token); const baseUrl = useBaseStore((s) => s.baseUrl); - + const setUser = useBaseStore((s) => s.setUser); + const clearAuth = useBaseStore((s) => s.clearAuth); + + const setConnected = useWsStore((s) => s.setConnected); + const setPrivateChannels = useChatStore((s) => s.setPrivateChannels); + const addMessage = useChatStore((s) => s.addMessage); + const tokenRef = useRef(token); const baseUrlRef = useRef(baseUrl); - + const wsRef = useRef(null); const heartbeatRef = useRef | null>(null); const retryTimeoutRef = useRef | null>(null); @@ -31,6 +32,7 @@ export const WebSocketProvider = ({ children }: { children: React.ReactNode }) = const cleanup = () => { shouldConnectRef.current = false; + if (retryTimeoutRef.current) { clearTimeout(retryTimeoutRef.current); retryTimeoutRef.current = null; @@ -38,6 +40,7 @@ export const WebSocketProvider = ({ children }: { children: React.ReactNode }) = if (wsRef.current) { try { + stopHeartbeat(); wsRef.current.onopen = null; wsRef.current.onmessage = null; wsRef.current.onerror = null; @@ -49,11 +52,6 @@ export const WebSocketProvider = ({ children }: { children: React.ReactNode }) = wsRef.current = null; } - if (heartbeatRef.current) { - clearInterval(heartbeatRef.current); - heartbeatRef.current = null; - } - setConnected(false); }; @@ -64,12 +62,28 @@ export const WebSocketProvider = ({ children }: { children: React.ReactNode }) = return url.toString(); }; + const startHeartbeat = (interval: number) => { + if (heartbeatRef.current) clearInterval(heartbeatRef.current); + heartbeatRef.current = setInterval(() => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ op: Opcode.HEARTBEAT, d: Date.now() })); + } + }, interval); + }; + + const stopHeartbeat = () => { + if (heartbeatRef.current) { + clearInterval(heartbeatRef.current); + heartbeatRef.current = null; + } + }; + const handleDispatch = (eventType: string, payload: any) => { switch (eventType) { case "READY": { const readyPayload = payload as ReadyOp; if (!readyPayload) return; - + setUser(readyPayload.user); setPrivateChannels(readyPayload.private_channels); break; @@ -77,6 +91,7 @@ export const WebSocketProvider = ({ children }: { children: React.ReactNode }) = case "MESSAGE_CREATE": { const msg = payload as Message; addMessage(msg.channel_id, msg); + break; } default: console.debug("Unhandled dispatch:", eventType, payload); @@ -95,24 +110,16 @@ export const WebSocketProvider = ({ children }: { children: React.ReactNode }) = const { op, d, t } = parsed; switch (op) { - case Opcode.HELLO: { + case Opcode.HELLO: const interval = d?.heartbeat_interval ?? 30000; - if (heartbeatRef.current) clearInterval(heartbeatRef.current); - - heartbeatRef.current = setInterval(() => { - if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return; - wsRef.current.send(JSON.stringify({ op: Opcode.HEARTBEAT, d: Date.now() })); - }, interval); + startHeartbeat(interval); break; - } case Opcode.DISPATCH: if (t) handleDispatch(t, d); break; - case Opcode.RECONNECT: - wsRef.current?.close(); - break; case Opcode.INVALID_SESSION: - useBaseStore.getState().clearAuth(); + clearAuth(); + useChatStore.getState().resetStore() break; default: console.debug("Unhandled op:", op, d); @@ -124,7 +131,7 @@ export const WebSocketProvider = ({ children }: { children: React.ReactNode }) = try { url = getWebSocketUrlFrom(baseUrl); } catch (err) { - console.warn("Invalid baseUrl, skipping WebSocket connect", err); + console.warn("Invalid baseUrl", err); return; } @@ -137,7 +144,11 @@ export const WebSocketProvider = ({ children }: { children: React.ReactNode }) = return; } setConnected(true); - ws.send(JSON.stringify({ op: Opcode.IDENTIFY, d: { token } })); + try { + ws.send(JSON.stringify({ op: Opcode.IDENTIFY, d: { token } })); + } catch (err) { + console.warn("Failed to send IDENTIFY", err); + } }; ws.onmessage = (ev) => { @@ -146,23 +157,19 @@ export const WebSocketProvider = ({ children }: { children: React.ReactNode }) = handleMessage(data); }; - ws.onerror = (ev) => { - console.warn("WebSocket error", ev); + const handleDisconnect = () => { + stopHeartbeat(); + useChatStore.getState().resetStore(); setConnected(false); - }; - ws.onclose = () => { - setConnected(false); - if (heartbeatRef.current) { - clearInterval(heartbeatRef.current); - heartbeatRef.current = null; - } - if (!shouldConnectRef.current) return; retryTimeoutRef.current = setTimeout(() => { connect(tokenRef.current!, baseUrlRef.current!); }, 1000 + Math.random() * 1000); }; + + ws.onerror = handleDisconnect; + ws.onclose = handleDisconnect; }; useEffect(() => { diff --git a/src/lib/stores/chats.ts b/src/lib/stores/chats.ts index cee6f43..6c8039d 100644 --- a/src/lib/stores/chats.ts +++ b/src/lib/stores/chats.ts @@ -7,14 +7,20 @@ interface ChatStore { setPrivateChannels: (value: PrivateChannel[]) => void; addMessage: (channelId: string, message: Message) => void; appendOlder: (channelId: string, olderMessages: Message[]) => void; - clearChannel: (channelId: string) => void; noMoreOlder: Record; setNoMoreOlder: (channelId: string, value: boolean) => void; + resetStore: () => void; } -export const useChatStore = create((set) => ({ +const initialState = { privateChannels: {}, messages: {}, + noMoreOlder: {}, +}; + +export const useChatStore = create((set, get) => ({ + ...initialState, + resetStore: () => set({ ...initialState }), setPrivateChannels: (channels) => { const record: Record = {}; channels.forEach((ch) => { @@ -25,40 +31,19 @@ export const useChatStore = create((set) => ({ addMessage: (channelId, message) => set((state) => { const existing = state.messages[channelId] ?? []; - if (message?.id && existing.some((m) => m.id === message.id)) - return state; - return { - messages: { - ...state.messages, - [channelId]: [message, ...existing], - }, - }; + if (message?.id && existing.some((m) => m.id === message.id)) return state; + return { messages: { ...state.messages, [channelId]: [message, ...existing] } }; }), appendOlder: (channelId, olderMessages) => set((state) => { const existing = state.messages[channelId] ?? []; - if (!Array.isArray(olderMessages) || olderMessages.length === 0) { - return state; - } + if (!Array.isArray(olderMessages) || olderMessages.length === 0) return state; const existingIds = new Set(existing.map((m) => m.id)); const filteredOlder = olderMessages.filter((m) => m?.id && !existingIds.has(m.id)); if (filteredOlder.length === 0) return state; - return { - messages: { - ...state.messages, - [channelId]: [...existing, ...filteredOlder], - }, - }; + return { messages: { ...state.messages, [channelId]: [...existing, ...filteredOlder] } }; }), - clearChannel: (channelId) => - set((state) => { - const copy = { ...state.messages }; - delete copy[channelId]; - return { messages: copy }; - }), - noMoreOlder: {}, + clearMessages: () => set({ messages: {} }), setNoMoreOlder: (channelId, value) => - set((state) => ({ - noMoreOlder: { ...state.noMoreOlder, [channelId]: value }, - })), + set((state) => ({ noMoreOlder: { ...state.noMoreOlder, [channelId]: value } })), })); \ No newline at end of file diff --git a/src/lib/types/opcodes.ts b/src/lib/types/opcodes.ts index 9b159c2..2cc9716 100644 --- a/src/lib/types/opcodes.ts +++ b/src/lib/types/opcodes.ts @@ -4,6 +4,7 @@ export enum Opcode { DISPATCH = 0, HEARTBEAT = 1, IDENTIFY = 2, + RESUME = 6, RECONNECT = 7, INVALID_SESSION = 9, HELLO = 10, diff --git a/src/lib/types/types.ts b/src/lib/types/types.ts index 135d631..d5b186e 100644 --- a/src/lib/types/types.ts +++ b/src/lib/types/types.ts @@ -1,7 +1,8 @@ export interface WsMessage { op: number; d: any; - t?: string + t?: string; + s?: number; } export interface HttpResponse { diff --git a/src/screens/conversation.tsx b/src/screens/conversation.tsx index f53cdd1..8ebdee2 100644 --- a/src/screens/conversation.tsx +++ b/src/screens/conversation.tsx @@ -4,6 +4,7 @@ import { FlatList, Image, StyleSheet, + TouchableOpacity, View, } from "react-native"; import { RouteProp } from "@react-navigation/native"; @@ -15,16 +16,27 @@ import { useThemeColor } from "../lib/hooks/use-theme-color"; import { ThemedText } from "../components/themed-text"; import { ThemedView } from "../components/themed-view"; import { InputField } from "../components/ui/input-field"; -import { Button } from "../components/ui/button"; import { Separator } from "../components/ui/separator"; import Attachments from "../components/chat/attachments"; +import { IconSymbol } from "../components/ui/icon-symbol"; +import { FilePicker, OpenResult } from "../lib/fs"; const PAGE_SIZE = 50; -type ConversationRouteProp = RouteProp; +interface MessageRowItem { + message: Message; + prevMessage?: Message; + isLast: boolean; +} + +type ConversationRouteProp = RouteProp< + RootStackParamList, + "Conversation" +>; export default function Conversation({ route }: { route: ConversationRouteProp }) { const channelId = route.params.id; + const primaryColor = useThemeColor("primary"); const accentColor = useThemeColor("secondary"); const api = useApi(); @@ -33,6 +45,8 @@ export default function Conversation({ route }: { route: ConversationRouteProp } const setNoMoreOlder = useChatStore((s) => s.setNoMoreOlder); const [message, setMessage] = useState(""); + const [uploadingFiles, setUploadingFiles] = useState([]); + const [initialLoading, setInitialLoading] = useState(!(messages.length > 1)); const [loadingOlder, setLoadingOlder] = useState(false); const loadingRef = useRef(false); // used to prevent race conditions @@ -65,14 +79,13 @@ export default function Conversation({ route }: { route: ConversationRouteProp } try { loadingRef.current = true; setLoadingOlder(true); - await api.populateMessages(channelId, { before: oldest.id, limit: PAGE_SIZE }); - - // If server sent nothing new, no more old messages - const updated = getCurrentMessages(); - if ( - updated.length === current.length || - updated.length - current.length < PAGE_SIZE - ) { + + const fetched = await api.populateMessages( + channelId, + { before: oldest.id, limit: PAGE_SIZE } + ); + + if (!fetched || fetched.length === 0) { setNoMoreOlder(channelId, true); } } catch (err) { @@ -84,19 +97,35 @@ export default function Conversation({ route }: { route: ConversationRouteProp } }, [api, channelId, loadingOlder, noMoreOlder]); const handleSend = useCallback(async () => { - const msg = await api.sendMessage(channelId, message); - if (msg.ok) setMessage(""); - }, [api, channelId, message]) + if (!message.trim()) return; + + const content = message; + setMessage(""); + + const res = await api.sendMessage(channelId, content); + if (!res.ok) { + setMessage(content); + console.warn("Failed to send message", res.error); + } + }, [api, channelId, message]); + + const handleUpload = useCallback(async () => { + const res = await FilePicker.open(); + setUploadingFiles((prev) => [...prev, res]); + try { + await api.sendMessage(channelId, "", res); + } catch (err) { + console.warn("Upload failed", err); + } finally { + setUploadingFiles((prev) => prev.filter((f) => f.uri !== res.uri)); + } + }, [channelId]); const MessageRow = React.memo(({ message, prevMessage, isLast - }: { - message: Message; - prevMessage?: Message; - isLast: boolean; - }) => { + }: MessageRowItem) => { const combine = prevMessage?.author?.id === message.author?.id && !message.message_reference; @@ -121,7 +150,7 @@ export default function Conversation({ route }: { route: ConversationRouteProp } {message.author?.username ?? "Unknown"} )} - {message.content != "" && ( + {message.content !== "" && ( {message.content} @@ -135,7 +164,7 @@ export default function Conversation({ route }: { route: ConversationRouteProp } const renderItem = useCallback( ({ item, index }: { item: Message; index: number }) => { const prevMessage = index < messages.length - 1 ? messages[index + 1] : undefined; - return ; + return ; }, [messages] ); @@ -149,7 +178,7 @@ export default function Conversation({ route }: { route: ConversationRouteProp } {initialLoading ? ( - + ) : ( <> @@ -160,14 +189,24 @@ export default function Conversation({ route }: { route: ConversationRouteProp } inverted={true} onEndReached={fetchOlder} onEndReachedThreshold={0.2} - ListFooterComponent={loadingOlder ? : null} + ListFooterComponent={loadingOlder ? : null} + ListHeaderComponent={uploadingFiles.length > 0 ? ( + + Uploading {uploadingFiles.length} file(s)... + + ) : null} contentContainerStyle={styles.messageList} - initialNumToRender={20} - maxToRenderPerBatch={20} - windowSize={5} /> + + + + setMessage(s)} /> - + + + )} @@ -195,7 +238,6 @@ const styles = StyleSheet.create({ marginLeft: 10 }, messageContainer: { - display: "flex", flexDirection: "row", borderRadius: 8, maxWidth: "83%" @@ -218,13 +260,24 @@ const styles = StyleSheet.create({ paddingVertical: 8, paddingHorizontal: 12, backgroundColor: "transparent", - display: "flex", flexDirection: "row" }, + messageButton: { + alignItems: "center", + justifyContent: "center", + }, messageInput: { flex: 1, - marginRight: 10, + marginRight: 14, + marginLeft: 10, borderWidth: 0, paddingVertical: 5 + }, + uploadInfo: { + flexDirection: "row", + justifyContent: "center", + textAlign: "center", + opacity: 0.5, + marginBottom: 6 } }); \ No newline at end of file diff --git a/src/screens/tabs/chats.tsx b/src/screens/tabs/chats.tsx index 519a749..35522c7 100644 --- a/src/screens/tabs/chats.tsx +++ b/src/screens/tabs/chats.tsx @@ -119,7 +119,6 @@ const styles = StyleSheet.create({ paddingTop: 15, paddingBottom: 15, marginBottom: 5, - display: "flex", flexDirection: "row", alignItems: "center" } diff --git a/src/screens/tabs/settings.tsx b/src/screens/tabs/settings.tsx index deef27f..5bb4124 100644 --- a/src/screens/tabs/settings.tsx +++ b/src/screens/tabs/settings.tsx @@ -62,7 +62,7 @@ export default function Settings() { const secondGroup: SettingsItem[] = [ { id: 4, title: 'Switch Account' }, - { id: 5, title: 'Log Out', onPress: () => clearAuth() }, + { id: 5, title: 'Log Out', onPress: clearAuth }, ]; return (