add attachments and fix bugs
This commit is contained in:
parent
ef891041f7
commit
d129a87a39
@ -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();
|
||||
}
|
||||
}
|
||||
104
android/app/src/main/java/com/rscordmobile/FilePickerModule.java
Normal file
104
android/app/src/main/java/com/rscordmobile/FilePickerModule.java
Normal 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) {}
|
||||
}
|
||||
@ -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<ReactPackage> getPackages() {
|
||||
@SuppressWarnings("UnnecessaryLocalVariable")
|
||||
List<ReactPackage> 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;
|
||||
}
|
||||
|
||||
|
||||
8
package-lock.json
generated
8
package-lock.json
generated
@ -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,
|
||||
|
||||
@ -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<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} />;
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const FilePicker = NativeModules.FilePicker as {
|
||||
open: () => Promise<OpenResult>;
|
||||
};
|
||||
@ -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<HttpResponse<LoginResponse>>;
|
||||
http: <T = any>(url: string, method?: "GET" | "POST", body?: unknown) => Promise<HttpResponse<T>>;
|
||||
getAvatarUrl: (id?: string, hash?: string | null) => ImageSourcePropType;
|
||||
populateMessages: (channelId: string, opts?: { before?: string; limit?: number }) => Promise<void>;
|
||||
sendMessage: (channelId: string, content: string) => Promise<HttpResponse<Message>>
|
||||
populateMessages: (channelId: string, opts?: { before?: string; limit?: number }) => Promise<Message[]>;
|
||||
sendMessage: (channelId: string, content: string, file?: OpenResult) => Promise<HttpResponse<Message>>
|
||||
}
|
||||
|
||||
const ApiContext = createContext<ApiContextType | null>(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 <T = any>(
|
||||
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<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`,
|
||||
"POST",
|
||||
{ content, nonce, tts: false }
|
||||
);
|
||||
return res;
|
||||
}
|
||||
};
|
||||
|
||||
const getAvatarUrl = (id?: string, hash?: string | null): ImageSourcePropType => {
|
||||
if (id && hash) {
|
||||
|
||||
@ -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<WebSocket | null>(null);
|
||||
const heartbeatRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const retryTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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(() => {
|
||||
|
||||
@ -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<string, boolean>;
|
||||
setNoMoreOlder: (channelId: string, value: boolean) => void;
|
||||
resetStore: () => void;
|
||||
}
|
||||
|
||||
export const useChatStore = create<ChatStore>((set) => ({
|
||||
const initialState = {
|
||||
privateChannels: {},
|
||||
messages: {},
|
||||
noMoreOlder: {},
|
||||
};
|
||||
|
||||
export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
...initialState,
|
||||
resetStore: () => set({ ...initialState }),
|
||||
setPrivateChannels: (channels) => {
|
||||
const record: Record<string, PrivateChannel> = {};
|
||||
channels.forEach((ch) => {
|
||||
@ -25,40 +31,19 @@ export const useChatStore = create<ChatStore>((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 } })),
|
||||
}));
|
||||
@ -4,6 +4,7 @@ export enum Opcode {
|
||||
DISPATCH = 0,
|
||||
HEARTBEAT = 1,
|
||||
IDENTIFY = 2,
|
||||
RESUME = 6,
|
||||
RECONNECT = 7,
|
||||
INVALID_SESSION = 9,
|
||||
HELLO = 10,
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
export interface WsMessage {
|
||||
op: number;
|
||||
d: any;
|
||||
t?: string
|
||||
t?: string;
|
||||
s?: number;
|
||||
}
|
||||
|
||||
export interface HttpResponse<T> {
|
||||
|
||||
@ -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<RootStackParamList, "Conversation">;
|
||||
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<OpenResult[]>([]);
|
||||
|
||||
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"}
|
||||
</ThemedText>
|
||||
)}
|
||||
{message.content != "" && (
|
||||
{message.content !== "" && (
|
||||
<ThemedText type="default" style={styles.messageText} selectable={true}>
|
||||
{message.content}
|
||||
</ThemedText>
|
||||
@ -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 <MessageRow message={item} prevMessage={prevMessage} isLast={index == 0} />;
|
||||
return <MessageRow message={item} prevMessage={prevMessage} isLast={index === 0} />;
|
||||
},
|
||||
[messages]
|
||||
);
|
||||
@ -149,7 +178,7 @@ export default function Conversation({ route }: { route: ConversationRouteProp }
|
||||
<ThemedView style={{ flex: 1 }}>
|
||||
{initialLoading ? (
|
||||
<View style={styles.loadingScreen}>
|
||||
<ActivityIndicator size="large" color={accentColor as string} />
|
||||
<ActivityIndicator size="large" color={accentColor} />
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
@ -160,14 +189,24 @@ export default function Conversation({ route }: { route: ConversationRouteProp }
|
||||
inverted={true}
|
||||
onEndReached={fetchOlder}
|
||||
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}
|
||||
initialNumToRender={20}
|
||||
maxToRenderPerBatch={20}
|
||||
windowSize={5}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
<View style={styles.addMessage}>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.5}
|
||||
style={styles.messageButton}
|
||||
onPress={handleUpload}
|
||||
>
|
||||
<IconSymbol name="file-upload" size={28} />
|
||||
</TouchableOpacity>
|
||||
<InputField
|
||||
placeholder="Type a message..."
|
||||
style={[styles.messageInput]}
|
||||
@ -175,9 +214,13 @@ export default function Conversation({ route }: { route: ConversationRouteProp }
|
||||
value={message}
|
||||
onChangeText={(s) => setMessage(s)}
|
||||
/>
|
||||
<Button onPress={handleSend}>
|
||||
Send
|
||||
</Button>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.5}
|
||||
style={[styles.messageButton, { marginRight: 3 }]}
|
||||
onPress={handleSend}
|
||||
>
|
||||
<IconSymbol name="send" color={primaryColor} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
@ -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
|
||||
}
|
||||
});
|
||||
@ -119,7 +119,6 @@ const styles = StyleSheet.create({
|
||||
paddingTop: 15,
|
||||
paddingBottom: 15,
|
||||
marginBottom: 5,
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
alignItems: "center"
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user