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;
|
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
8
package-lock.json
generated
@ -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,
|
||||||
|
|||||||
@ -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} />;
|
||||||
}
|
}
|
||||||
@ -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>;
|
||||||
|
};
|
||||||
@ -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) {
|
||||||
|
|||||||
@ -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(() => {
|
||||||
|
|||||||
@ -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 },
|
|
||||||
})),
|
|
||||||
}));
|
}));
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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> {
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 (
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user