add attachments and fix bugs

This commit is contained in:
murdle 2025-12-02 01:37:14 +02:00
parent ef891041f7
commit d129a87a39
14 changed files with 348 additions and 129 deletions

View File

@ -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<NativeModule> createNativeModules(ReactApplicationContext reactContext) {
List<NativeModule> modules = new ArrayList<>();
modules.add(new FilePickerModule(reactContext));
return modules;
}
@Override
public List<ViewManager> createViewManagers(ReactApplicationContext reactContext) {
return Collections.emptyList();
}
}

View File

@ -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) {}
}

View File

@ -1,5 +1,6 @@
package com.rscordmobile; package com.rscordmobile;
import com.rscordmobile.AppNativePackage;
import android.app.Application; import android.app.Application;
import android.content.Context; import android.content.Context;
import android.os.Build; import android.os.Build;
@ -23,10 +24,8 @@ public class MainApplication extends Application implements ReactApplication {
@Override @Override
protected List<ReactPackage> getPackages() { protected List<ReactPackage> getPackages() {
@SuppressWarnings("UnnecessaryLocalVariable")
List<ReactPackage> packages = new PackageList(this).getPackages(); List<ReactPackage> packages = new PackageList(this).getPackages();
// Packages that cannot be autolinked yet can be added manually here, for example: packages.add(new AppNativePackage());
// packages.add(new MyReactNativePackage());
return packages; return packages;
} }

8
package-lock.json generated
View File

@ -40,7 +40,7 @@
"jest": "^25.1.0", "jest": "^25.1.0",
"metro-react-native-babel-preset": "^0.59.0", "metro-react-native-babel-preset": "^0.59.0",
"react-test-renderer": "16.13.1", "react-test-renderer": "16.13.1",
"typescript": "^3.8.3" "typescript": "^4.9.5"
} }
}, },
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
@ -14900,9 +14900,9 @@
} }
}, },
"node_modules/typescript": { "node_modules/typescript": {
"version": "3.9.10", "version": "4.9.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-3.9.10.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
"integrity": "sha512-w6fIxVE/H1PkLKcCPsFqKE7Kv7QUwhU8qQY2MueZXWx5cPZdwFupLgKK3vntcK98BtNHZtAF4LA/yl2a7k8R6Q==", "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true, "peer": true,

View File

@ -1,6 +1,7 @@
import { OpaqueColorValue, type StyleProp, type TextStyle } from 'react-native';
import MaterialIcons from 'react-native-vector-icons/MaterialIcons'; import MaterialIcons from 'react-native-vector-icons/MaterialIcons';
import React from 'react'; import React from 'react';
import { OpaqueColorValue, type StyleProp, type TextStyle } from 'react-native'; import { useThemeColor } from '../../lib/hooks/use-theme-color';
export type IconSymbolName = export type IconSymbolName =
| 'home' | 'home'
@ -8,6 +9,7 @@ export type IconSymbolName =
| 'send' | 'send'
| 'code' | 'code'
| 'chevron-right' | 'chevron-right'
| 'file-upload'
| 'settings'; | 'settings';
export function IconSymbol({ export function IconSymbol({
@ -18,8 +20,9 @@ export function IconSymbol({
}: { }: {
name: IconSymbolName; name: IconSymbolName;
size?: number; size?: number;
color: string | OpaqueColorValue; color?: string | OpaqueColorValue;
style?: StyleProp<TextStyle>; style?: StyleProp<TextStyle>;
}) { }) {
return <MaterialIcons color={color} size={size} name={name} style={style} />; const iconColor = color ?? useThemeColor("text")
return <MaterialIcons color={iconColor} size={size} name={name} style={style} />;
} }

View File

@ -1,10 +1,17 @@
import RNFetchBlob from "rn-fetch-blob"; import RNFetchBlob from "rn-fetch-blob";
import { NativeModules } from "react-native";
export enum SystemDir { export enum SystemDir {
Pictures, Pictures,
Downloads Downloads
} }
export interface OpenResult {
name: string;
type: string;
uri: string;
}
export const getSystemDir = (type: SystemDir) => { export const getSystemDir = (type: SystemDir) => {
let directory; let directory;
switch (type) { switch (type) {
@ -33,4 +40,8 @@ export const downloadFile = (fileName: string, url: string, directory: string) =
.catch(err => { .catch(err => {
console.error("Download failed:", err); console.error("Download failed:", err);
}); });
} }
export const FilePicker = NativeModules.FilePicker as {
open: () => Promise<OpenResult>;
};

View File

@ -5,23 +5,24 @@ import { Message } from "../types/discord";
import { HttpResponse, LoginResponse } from "../types/types"; import { HttpResponse, LoginResponse } from "../types/types";
import { URLSearchParams } from "react-native-url-polyfill"; import { URLSearchParams } from "react-native-url-polyfill";
import { ImageSourcePropType } from "react-native"; import { ImageSourcePropType } from "react-native";
import { OpenResult } from "../fs";
interface ApiContextType { interface ApiContextType {
login: (email: string, password: string, instanceUrl: string) => Promise<HttpResponse<LoginResponse>>; login: (email: string, password: string, instanceUrl: string) => Promise<HttpResponse<LoginResponse>>;
http: <T = any>(url: string, method?: "GET" | "POST", body?: unknown) => Promise<HttpResponse<T>>; http: <T = any>(url: string, method?: "GET" | "POST", body?: unknown) => Promise<HttpResponse<T>>;
getAvatarUrl: (id?: string, hash?: string | null) => ImageSourcePropType; getAvatarUrl: (id?: string, hash?: string | null) => ImageSourcePropType;
populateMessages: (channelId: string, opts?: { before?: string; limit?: number }) => Promise<void>; populateMessages: (channelId: string, opts?: { before?: string; limit?: number }) => Promise<Message[]>;
sendMessage: (channelId: string, content: string) => Promise<HttpResponse<Message>> sendMessage: (channelId: string, content: string, file?: OpenResult) => Promise<HttpResponse<Message>>
} }
const ApiContext = createContext<ApiContextType | null>(null); const ApiContext = createContext<ApiContextType | null>(null);
export const ApiProvider = ({ children }: { children: React.ReactNode }) => { 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 token = useBaseStore((s) => s.token);
const setToken = useBaseStore((s) => s.setToken);
const baseUrl = useBaseStore((s) => s.baseUrl); const baseUrl = useBaseStore((s) => s.baseUrl);
const setBaseUrl = useBaseStore((s) => s.setBaseUrl);
const http = async <T = any>( const http = async <T = any>(
url: string, url: string,
@ -35,27 +36,40 @@ export const ApiProvider = ({ children }: { children: React.ReactNode }) => {
const newUrl = isAbsolute ? url : `${baseUrl}${url}`; const newUrl = isAbsolute ? url : `${baseUrl}${url}`;
try { 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, { const res = await fetch(newUrl, {
method, method,
headers: { headers,
"Content-Type": "application/json", body: requestBody,
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: body ? JSON.stringify(body) : undefined,
signal: controller.signal, signal: controller.signal,
}); });
const contentType = res.headers.get("content-type") || ""; 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: true, status: res.status, data };
}
return { return {
ok: false, ok: false,
status: res.status, status: res.status,
data, data,
error: (data as any)?.message || `HTTP ${res.status}` error: (data as any)?.message || `HTTP ${res.status}`,
}; };
} catch (err: any) { } catch (err: any) {
const error = err.name === "AbortError" ? "timeout" : err?.message ?? String(err); 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) if (!res.ok || !res.data)
throw new Error(res.error ?? `Failed to fetch messages for channel ${channelId}`); 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); useChatStore.getState().appendOlder(channelId, fetched);
return fetched;
}; };
const sendMessage = async ( const sendMessage = async (
channelId: string, channelId: string,
content: string content: string,
file?: OpenResult
) => { ) => {
const nonce = Math.floor(Math.random() * 10 ** 8) + ""; const nonce = Math.floor(Math.random() * 10 ** 8) + "";
const res = await http<Message>(
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<Message>(
`/api/v9/channels/${channelId}/messages`,
"POST",
formData
);
}
return await http<Message>(
`/api/v9/channels/${channelId}/messages`, `/api/v9/channels/${channelId}/messages`,
"POST", "POST",
{ content, nonce, tts: false } { content, nonce, tts: false }
); );
return res; };
}
const getAvatarUrl = (id?: string, hash?: string | null): ImageSourcePropType => { const getAvatarUrl = (id?: string, hash?: string | null): ImageSourcePropType => {
if (id && hash) { if (id && hash) {

View File

@ -8,17 +8,18 @@ import { Message } from "../types/discord";
import { URL } from "react-native-url-polyfill"; import { URL } from "react-native-url-polyfill";
export const WebSocketProvider = ({ children }: { children: React.ReactNode }) => { 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 token = useBaseStore((s) => s.token);
const baseUrl = useBaseStore((s) => s.baseUrl); 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 tokenRef = useRef(token);
const baseUrlRef = useRef(baseUrl); const baseUrlRef = useRef(baseUrl);
const wsRef = useRef<WebSocket | null>(null); const wsRef = useRef<WebSocket | null>(null);
const heartbeatRef = useRef<ReturnType<typeof setInterval> | null>(null); const heartbeatRef = useRef<ReturnType<typeof setInterval> | null>(null);
const retryTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); const retryTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@ -31,6 +32,7 @@ export const WebSocketProvider = ({ children }: { children: React.ReactNode }) =
const cleanup = () => { const cleanup = () => {
shouldConnectRef.current = false; shouldConnectRef.current = false;
if (retryTimeoutRef.current) { if (retryTimeoutRef.current) {
clearTimeout(retryTimeoutRef.current); clearTimeout(retryTimeoutRef.current);
retryTimeoutRef.current = null; retryTimeoutRef.current = null;
@ -38,6 +40,7 @@ export const WebSocketProvider = ({ children }: { children: React.ReactNode }) =
if (wsRef.current) { if (wsRef.current) {
try { try {
stopHeartbeat();
wsRef.current.onopen = null; wsRef.current.onopen = null;
wsRef.current.onmessage = null; wsRef.current.onmessage = null;
wsRef.current.onerror = null; wsRef.current.onerror = null;
@ -49,11 +52,6 @@ export const WebSocketProvider = ({ children }: { children: React.ReactNode }) =
wsRef.current = null; wsRef.current = null;
} }
if (heartbeatRef.current) {
clearInterval(heartbeatRef.current);
heartbeatRef.current = null;
}
setConnected(false); setConnected(false);
}; };
@ -64,12 +62,28 @@ export const WebSocketProvider = ({ children }: { children: React.ReactNode }) =
return url.toString(); 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) => { const handleDispatch = (eventType: string, payload: any) => {
switch (eventType) { switch (eventType) {
case "READY": { case "READY": {
const readyPayload = payload as ReadyOp; const readyPayload = payload as ReadyOp;
if (!readyPayload) return; if (!readyPayload) return;
setUser(readyPayload.user); setUser(readyPayload.user);
setPrivateChannels(readyPayload.private_channels); setPrivateChannels(readyPayload.private_channels);
break; break;
@ -77,6 +91,7 @@ export const WebSocketProvider = ({ children }: { children: React.ReactNode }) =
case "MESSAGE_CREATE": { case "MESSAGE_CREATE": {
const msg = payload as Message; const msg = payload as Message;
addMessage(msg.channel_id, msg); addMessage(msg.channel_id, msg);
break;
} }
default: default:
console.debug("Unhandled dispatch:", eventType, payload); console.debug("Unhandled dispatch:", eventType, payload);
@ -95,24 +110,16 @@ export const WebSocketProvider = ({ children }: { children: React.ReactNode }) =
const { op, d, t } = parsed; const { op, d, t } = parsed;
switch (op) { switch (op) {
case Opcode.HELLO: { case Opcode.HELLO:
const interval = d?.heartbeat_interval ?? 30000; const interval = d?.heartbeat_interval ?? 30000;
if (heartbeatRef.current) clearInterval(heartbeatRef.current); startHeartbeat(interval);
heartbeatRef.current = setInterval(() => {
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return;
wsRef.current.send(JSON.stringify({ op: Opcode.HEARTBEAT, d: Date.now() }));
}, interval);
break; break;
}
case Opcode.DISPATCH: case Opcode.DISPATCH:
if (t) handleDispatch(t, d); if (t) handleDispatch(t, d);
break; break;
case Opcode.RECONNECT:
wsRef.current?.close();
break;
case Opcode.INVALID_SESSION: case Opcode.INVALID_SESSION:
useBaseStore.getState().clearAuth(); clearAuth();
useChatStore.getState().resetStore()
break; break;
default: default:
console.debug("Unhandled op:", op, d); console.debug("Unhandled op:", op, d);
@ -124,7 +131,7 @@ export const WebSocketProvider = ({ children }: { children: React.ReactNode }) =
try { try {
url = getWebSocketUrlFrom(baseUrl); url = getWebSocketUrlFrom(baseUrl);
} catch (err) { } catch (err) {
console.warn("Invalid baseUrl, skipping WebSocket connect", err); console.warn("Invalid baseUrl", err);
return; return;
} }
@ -137,7 +144,11 @@ export const WebSocketProvider = ({ children }: { children: React.ReactNode }) =
return; return;
} }
setConnected(true); 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) => { ws.onmessage = (ev) => {
@ -146,23 +157,19 @@ export const WebSocketProvider = ({ children }: { children: React.ReactNode }) =
handleMessage(data); handleMessage(data);
}; };
ws.onerror = (ev) => { const handleDisconnect = () => {
console.warn("WebSocket error", ev); stopHeartbeat();
useChatStore.getState().resetStore();
setConnected(false); setConnected(false);
};
ws.onclose = () => {
setConnected(false);
if (heartbeatRef.current) {
clearInterval(heartbeatRef.current);
heartbeatRef.current = null;
}
if (!shouldConnectRef.current) return; if (!shouldConnectRef.current) return;
retryTimeoutRef.current = setTimeout(() => { retryTimeoutRef.current = setTimeout(() => {
connect(tokenRef.current!, baseUrlRef.current!); connect(tokenRef.current!, baseUrlRef.current!);
}, 1000 + Math.random() * 1000); }, 1000 + Math.random() * 1000);
}; };
ws.onerror = handleDisconnect;
ws.onclose = handleDisconnect;
}; };
useEffect(() => { useEffect(() => {

View File

@ -7,14 +7,20 @@ interface ChatStore {
setPrivateChannels: (value: PrivateChannel[]) => void; setPrivateChannels: (value: PrivateChannel[]) => void;
addMessage: (channelId: string, message: Message) => void; addMessage: (channelId: string, message: Message) => void;
appendOlder: (channelId: string, olderMessages: Message[]) => void; appendOlder: (channelId: string, olderMessages: Message[]) => void;
clearChannel: (channelId: string) => void;
noMoreOlder: Record<string, boolean>; noMoreOlder: Record<string, boolean>;
setNoMoreOlder: (channelId: string, value: boolean) => void; setNoMoreOlder: (channelId: string, value: boolean) => void;
resetStore: () => void;
} }
export const useChatStore = create<ChatStore>((set) => ({ const initialState = {
privateChannels: {}, privateChannels: {},
messages: {}, messages: {},
noMoreOlder: {},
};
export const useChatStore = create<ChatStore>((set, get) => ({
...initialState,
resetStore: () => set({ ...initialState }),
setPrivateChannels: (channels) => { setPrivateChannels: (channels) => {
const record: Record<string, PrivateChannel> = {}; const record: Record<string, PrivateChannel> = {};
channels.forEach((ch) => { channels.forEach((ch) => {
@ -25,40 +31,19 @@ export const useChatStore = create<ChatStore>((set) => ({
addMessage: (channelId, message) => addMessage: (channelId, message) =>
set((state) => { set((state) => {
const existing = state.messages[channelId] ?? []; const existing = state.messages[channelId] ?? [];
if (message?.id && existing.some((m) => m.id === message.id)) if (message?.id && existing.some((m) => m.id === message.id)) return state;
return state; return { messages: { ...state.messages, [channelId]: [message, ...existing] } };
return {
messages: {
...state.messages,
[channelId]: [message, ...existing],
},
};
}), }),
appendOlder: (channelId, olderMessages) => appendOlder: (channelId, olderMessages) =>
set((state) => { set((state) => {
const existing = state.messages[channelId] ?? []; const existing = state.messages[channelId] ?? [];
if (!Array.isArray(olderMessages) || olderMessages.length === 0) { if (!Array.isArray(olderMessages) || olderMessages.length === 0) return state;
return state;
}
const existingIds = new Set(existing.map((m) => m.id)); const existingIds = new Set(existing.map((m) => m.id));
const filteredOlder = olderMessages.filter((m) => m?.id && !existingIds.has(m.id)); const filteredOlder = olderMessages.filter((m) => m?.id && !existingIds.has(m.id));
if (filteredOlder.length === 0) return state; if (filteredOlder.length === 0) return state;
return { return { messages: { ...state.messages, [channelId]: [...existing, ...filteredOlder] } };
messages: {
...state.messages,
[channelId]: [...existing, ...filteredOlder],
},
};
}), }),
clearChannel: (channelId) => clearMessages: () => set({ messages: {} }),
set((state) => {
const copy = { ...state.messages };
delete copy[channelId];
return { messages: copy };
}),
noMoreOlder: {},
setNoMoreOlder: (channelId, value) => setNoMoreOlder: (channelId, value) =>
set((state) => ({ set((state) => ({ noMoreOlder: { ...state.noMoreOlder, [channelId]: value } })),
noMoreOlder: { ...state.noMoreOlder, [channelId]: value },
})),
})); }));

View File

@ -4,6 +4,7 @@ export enum Opcode {
DISPATCH = 0, DISPATCH = 0,
HEARTBEAT = 1, HEARTBEAT = 1,
IDENTIFY = 2, IDENTIFY = 2,
RESUME = 6,
RECONNECT = 7, RECONNECT = 7,
INVALID_SESSION = 9, INVALID_SESSION = 9,
HELLO = 10, HELLO = 10,

View File

@ -1,7 +1,8 @@
export interface WsMessage { export interface WsMessage {
op: number; op: number;
d: any; d: any;
t?: string t?: string;
s?: number;
} }
export interface HttpResponse<T> { export interface HttpResponse<T> {

View File

@ -4,6 +4,7 @@ import {
FlatList, FlatList,
Image, Image,
StyleSheet, StyleSheet,
TouchableOpacity,
View, View,
} from "react-native"; } from "react-native";
import { RouteProp } from "@react-navigation/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 { ThemedText } from "../components/themed-text";
import { ThemedView } from "../components/themed-view"; import { ThemedView } from "../components/themed-view";
import { InputField } from "../components/ui/input-field"; import { InputField } from "../components/ui/input-field";
import { Button } from "../components/ui/button";
import { Separator } from "../components/ui/separator"; import { Separator } from "../components/ui/separator";
import Attachments from "../components/chat/attachments"; import Attachments from "../components/chat/attachments";
import { IconSymbol } from "../components/ui/icon-symbol";
import { FilePicker, OpenResult } from "../lib/fs";
const PAGE_SIZE = 50; const PAGE_SIZE = 50;
type ConversationRouteProp = RouteProp<RootStackParamList, "Conversation">; interface MessageRowItem {
message: Message;
prevMessage?: Message;
isLast: boolean;
}
type ConversationRouteProp = RouteProp<
RootStackParamList,
"Conversation"
>;
export default function Conversation({ route }: { route: ConversationRouteProp }) { export default function Conversation({ route }: { route: ConversationRouteProp }) {
const channelId = route.params.id; const channelId = route.params.id;
const primaryColor = useThemeColor("primary");
const accentColor = useThemeColor("secondary"); const accentColor = useThemeColor("secondary");
const api = useApi(); const api = useApi();
@ -33,6 +45,8 @@ export default function Conversation({ route }: { route: ConversationRouteProp }
const setNoMoreOlder = useChatStore((s) => s.setNoMoreOlder); const setNoMoreOlder = useChatStore((s) => s.setNoMoreOlder);
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
const [uploadingFiles, setUploadingFiles] = useState<OpenResult[]>([]);
const [initialLoading, setInitialLoading] = useState(!(messages.length > 1)); const [initialLoading, setInitialLoading] = useState(!(messages.length > 1));
const [loadingOlder, setLoadingOlder] = useState(false); const [loadingOlder, setLoadingOlder] = useState(false);
const loadingRef = useRef(false); // used to prevent race conditions const loadingRef = useRef(false); // used to prevent race conditions
@ -65,14 +79,13 @@ export default function Conversation({ route }: { route: ConversationRouteProp }
try { try {
loadingRef.current = true; loadingRef.current = true;
setLoadingOlder(true); setLoadingOlder(true);
await api.populateMessages(channelId, { before: oldest.id, limit: PAGE_SIZE });
const fetched = await api.populateMessages(
// If server sent nothing new, no more old messages channelId,
const updated = getCurrentMessages(); { before: oldest.id, limit: PAGE_SIZE }
if ( );
updated.length === current.length ||
updated.length - current.length < PAGE_SIZE if (!fetched || fetched.length === 0) {
) {
setNoMoreOlder(channelId, true); setNoMoreOlder(channelId, true);
} }
} catch (err) { } catch (err) {
@ -84,19 +97,35 @@ export default function Conversation({ route }: { route: ConversationRouteProp }
}, [api, channelId, loadingOlder, noMoreOlder]); }, [api, channelId, loadingOlder, noMoreOlder]);
const handleSend = useCallback(async () => { const handleSend = useCallback(async () => {
const msg = await api.sendMessage(channelId, message); if (!message.trim()) return;
if (msg.ok) setMessage("");
}, [api, channelId, message]) 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(({ const MessageRow = React.memo(({
message, message,
prevMessage, prevMessage,
isLast isLast
}: { }: MessageRowItem) => {
message: Message;
prevMessage?: Message;
isLast: boolean;
}) => {
const combine = const combine =
prevMessage?.author?.id === message.author?.id prevMessage?.author?.id === message.author?.id
&& !message.message_reference; && !message.message_reference;
@ -121,7 +150,7 @@ export default function Conversation({ route }: { route: ConversationRouteProp }
{message.author?.username ?? "Unknown"} {message.author?.username ?? "Unknown"}
</ThemedText> </ThemedText>
)} )}
{message.content != "" && ( {message.content !== "" && (
<ThemedText type="default" style={styles.messageText} selectable={true}> <ThemedText type="default" style={styles.messageText} selectable={true}>
{message.content} {message.content}
</ThemedText> </ThemedText>
@ -135,7 +164,7 @@ export default function Conversation({ route }: { route: ConversationRouteProp }
const renderItem = useCallback( const renderItem = useCallback(
({ item, index }: { item: Message; index: number }) => { ({ item, index }: { item: Message; index: number }) => {
const prevMessage = index < messages.length - 1 ? messages[index + 1] : undefined; const prevMessage = index < messages.length - 1 ? messages[index + 1] : undefined;
return <MessageRow message={item} prevMessage={prevMessage} isLast={index == 0} />; return <MessageRow message={item} prevMessage={prevMessage} isLast={index === 0} />;
}, },
[messages] [messages]
); );
@ -149,7 +178,7 @@ export default function Conversation({ route }: { route: ConversationRouteProp }
<ThemedView style={{ flex: 1 }}> <ThemedView style={{ flex: 1 }}>
{initialLoading ? ( {initialLoading ? (
<View style={styles.loadingScreen}> <View style={styles.loadingScreen}>
<ActivityIndicator size="large" color={accentColor as string} /> <ActivityIndicator size="large" color={accentColor} />
</View> </View>
) : ( ) : (
<> <>
@ -160,14 +189,24 @@ export default function Conversation({ route }: { route: ConversationRouteProp }
inverted={true} inverted={true}
onEndReached={fetchOlder} onEndReached={fetchOlder}
onEndReachedThreshold={0.2} onEndReachedThreshold={0.2}
ListFooterComponent={loadingOlder ? <ActivityIndicator color={accentColor as string} /> : null} ListFooterComponent={loadingOlder ? <ActivityIndicator color={accentColor} /> : null}
ListHeaderComponent={uploadingFiles.length > 0 ? (
<View style={styles.uploadInfo}>
<ThemedText>Uploading {uploadingFiles.length} file(s)...</ThemedText>
</View>
) : null}
contentContainerStyle={styles.messageList} contentContainerStyle={styles.messageList}
initialNumToRender={20}
maxToRenderPerBatch={20}
windowSize={5}
/> />
<Separator /> <Separator />
<View style={styles.addMessage}> <View style={styles.addMessage}>
<TouchableOpacity
activeOpacity={0.5}
style={styles.messageButton}
onPress={handleUpload}
>
<IconSymbol name="file-upload" size={28} />
</TouchableOpacity>
<InputField <InputField
placeholder="Type a message..." placeholder="Type a message..."
style={[styles.messageInput]} style={[styles.messageInput]}
@ -175,9 +214,13 @@ export default function Conversation({ route }: { route: ConversationRouteProp }
value={message} value={message}
onChangeText={(s) => setMessage(s)} onChangeText={(s) => setMessage(s)}
/> />
<Button onPress={handleSend}> <TouchableOpacity
Send activeOpacity={0.5}
</Button> style={[styles.messageButton, { marginRight: 3 }]}
onPress={handleSend}
>
<IconSymbol name="send" color={primaryColor} />
</TouchableOpacity>
</View> </View>
</> </>
)} )}
@ -195,7 +238,6 @@ const styles = StyleSheet.create({
marginLeft: 10 marginLeft: 10
}, },
messageContainer: { messageContainer: {
display: "flex",
flexDirection: "row", flexDirection: "row",
borderRadius: 8, borderRadius: 8,
maxWidth: "83%" maxWidth: "83%"
@ -218,13 +260,24 @@ const styles = StyleSheet.create({
paddingVertical: 8, paddingVertical: 8,
paddingHorizontal: 12, paddingHorizontal: 12,
backgroundColor: "transparent", backgroundColor: "transparent",
display: "flex",
flexDirection: "row" flexDirection: "row"
}, },
messageButton: {
alignItems: "center",
justifyContent: "center",
},
messageInput: { messageInput: {
flex: 1, flex: 1,
marginRight: 10, marginRight: 14,
marginLeft: 10,
borderWidth: 0, borderWidth: 0,
paddingVertical: 5 paddingVertical: 5
},
uploadInfo: {
flexDirection: "row",
justifyContent: "center",
textAlign: "center",
opacity: 0.5,
marginBottom: 6
} }
}); });

View File

@ -119,7 +119,6 @@ const styles = StyleSheet.create({
paddingTop: 15, paddingTop: 15,
paddingBottom: 15, paddingBottom: 15,
marginBottom: 5, marginBottom: 5,
display: "flex",
flexDirection: "row", flexDirection: "row",
alignItems: "center" alignItems: "center"
} }

View File

@ -62,7 +62,7 @@ export default function Settings() {
const secondGroup: SettingsItem[] = [ const secondGroup: SettingsItem[] = [
{ id: 4, title: 'Switch Account' }, { id: 4, title: 'Switch Account' },
{ id: 5, title: 'Log Out', onPress: () => clearAuth() }, { id: 5, title: 'Log Out', onPress: clearAuth },
]; ];
return ( return (