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;
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
View File

@ -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,

View File

@ -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} />;
}

View File

@ -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>;
};

View File

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

View File

@ -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(() => {

View File

@ -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 } })),
}));

View File

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

View File

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

View File

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

View File

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

View File

@ -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 (