diff --git a/app/components/ChatMenu/ChatEditPopup.tsx b/app/components/ChatMenu/ChatEditPopup.tsx new file mode 100644 index 0000000..fcadba6 --- /dev/null +++ b/app/components/ChatMenu/ChatEditPopup.tsx @@ -0,0 +1,230 @@ +import { AntDesign, FontAwesome } from '@expo/vector-icons' +import { Characters, Chats, Logger, saveStringToDownload, Style } from '@globals' +import { useFocusEffect } from 'expo-router' +import React, { useRef, useState } from 'react' +import { StyleSheet, TouchableOpacity, Text, BackHandler, Alert } from 'react-native' +import { + Menu, + MenuOption, + MenuOptions, + MenuOptionsCustomStyle, + MenuTrigger, + renderers, +} from 'react-native-popup-menu' + +const { Popover } = renderers + +type ListItem = { + id: number + character_id: number + create_date: Date + name: string + last_modified: null | number + entryCount: number +} + +type ChatEditPopupProps = { + item: ListItem + nowLoading: boolean + setNowLoading: (b: boolean) => void +} + +type PopupProps = { + onPress: () => void | Promise + label: string + iconName: 'copy' | 'download' | 'trash' + warning?: boolean +} + +const PopupOption: React.FC = ({ onPress, label, iconName, warning = false }) => { + return ( + + + + + {label} + + + + ) +} + +const ChatEditPopup: React.FC = ({ item, setNowLoading, nowLoading }) => { + const [showMenu, setShowMenu] = useState(false) + const menuRef: React.MutableRefObject = useRef(null) + + const { charName, charId } = Characters.useCharacterCard((state) => ({ + charId: state.id, + charName: state.card?.data?.name ?? 'Unknown', + })) + + const { deleteChat, loadChat, currentChatId, unloadChat } = Chats.useChat((state) => ({ + deleteChat: state.delete, + loadChat: state.load, + currentChatId: state.data?.id, + unloadChat: state.reset, + })) + + const handleDeleteChat = () => { + Alert.alert( + `Delete Character`, + `Are you sure you want to delete '${item.name}'? This cannot be undone.`, + [ + { text: 'Cancel', onPress: () => {}, style: 'cancel' }, + { + text: 'Confirm', + onPress: async () => { + await deleteChat(item.id) + if (charId && currentChatId === item.id) { + const returnedChatId = await Chats.db.query.chatNewestId(charId) + const chatId = returnedChatId + ? returnedChatId + : await Chats.db.mutate.createChat(charId) + chatId && (await loadChat(chatId)) + } else if (item.id === currentChatId) { + Logger.log(`Something went wrong with creating a default chat`, true) + unloadChat() + } + menuRef.current?.close() + }, + style: 'destructive', + }, + ], + { cancelable: true } + ) + } + + const handleCloneChat = () => { + Alert.alert( + `Clone Character`, + `Are you sure you want to clone '${item.name}'?`, + [ + { text: 'Cancel', onPress: () => {}, style: 'cancel' }, + { + text: 'Confirm', + onPress: async () => { + await Chats.db.mutate.cloneChat(item.id) + menuRef.current?.close() + }, + style: 'destructive', + }, + ], + { cancelable: true } + ) + } + + const handleExportChat = async () => { + const name = `Chatlogs-${charName}-${item.id}`.replaceAll(' ', '_') + saveStringToDownload(JSON.stringify(await Chats.db.query.chat(item.id)), name, 'utf8') + Logger.log(`File: ${name} saved to downloads!`, true) + menuRef.current?.close() + } + + const backAction = () => { + if (!menuRef.current || !menuRef.current?.isOpen()) return false + menuRef.current?.close() + return true + } + + useFocusEffect(() => { + BackHandler.removeEventListener('hardwareBackPress', backAction) + const handler = BackHandler.addEventListener('hardwareBackPress', backAction) + return () => handler.remove() + }) + + return ( + setShowMenu(true)} + onClose={() => setShowMenu(false)} + renderer={Popover} + rendererProps={{ + placement: 'left', + anchorStyle: styles.anchor, + openAnimationDuration: 150, + closeAnimationDuration: 0, + }}> + + + + + handleExportChat()} + label="Export" + iconName="download" + /> + { + handleCloneChat() + }} + label="Clone" + iconName="copy" + /> + handleDeleteChat()} + label="Delete" + iconName="trash" + warning + /> + + + ) +} + +export default ChatEditPopup + +const styles = StyleSheet.create({ + anchor: { + backgroundColor: Style.getColor('primary-surface3'), + padding: 4, + }, + + popupButton: { + flexDirection: 'row', + alignItems: 'center', + columnGap: 12, + paddingVertical: 12, + paddingRight: 32, + paddingLeft: 12, + borderRadius: 12, + }, + + headerButtonContainer: { + flexDirection: 'row', + }, + + optionLabel: { + color: Style.getColor('primary-text1'), + }, + + optionLabelWarning: { + fontWeight: '500', + color: '#d2574b', + }, + + triggerButton: { + paddingHorizontal: 12, + paddingVertical: 20, + }, +}) + +const menustyle: MenuOptionsCustomStyle = { + optionsContainer: { + backgroundColor: Style.getColor('primary-surface3'), + padding: 4, + borderRadius: 12, + }, + optionsWrapper: { + backgroundColor: Style.getColor('primary-surface3'), + }, +} diff --git a/app/components/ChatMenu/ChatsDrawer.tsx b/app/components/ChatMenu/ChatsDrawer.tsx index edcbfac..5490d82 100644 --- a/app/components/ChatMenu/ChatsDrawer.tsx +++ b/app/components/ChatMenu/ChatsDrawer.tsx @@ -1,7 +1,7 @@ -import { Ionicons, AntDesign } from '@expo/vector-icons' +import { Ionicons } from '@expo/vector-icons' import { Characters, Chats, Style } from '@globals' import { useLiveQuery } from 'drizzle-orm/expo-sqlite' -import { SetStateAction } from 'react' +import { SetStateAction, useState } from 'react' import { Text, GestureResponderEvent, @@ -9,7 +9,6 @@ import { StyleSheet, View, FlatList, - Alert, } from 'react-native' import Animated, { Easing, @@ -19,6 +18,8 @@ import Animated, { SlideOutRight, } from 'react-native-reanimated' +import ChatEditPopup from './ChatEditPopup' + type ChatsDrawerProps = { booleans: [boolean, (b: boolean | SetStateAction) => void] } @@ -34,7 +35,7 @@ type ListItem = { const ChatsDrawer: React.FC = ({ booleans: [showModal, setShowModal] }) => { const { charId } = Characters.useCharacterCard((state) => ({ charId: state.id })) - + const [nowLoading, setNowLoading] = useState(false) const { data } = useLiveQuery(Chats.db.query.chatListQuery(charId ?? 0)) const { deleteChat, loadChat, currentChatId } = Chats.useChat((state) => ({ @@ -59,34 +60,6 @@ const ChatsDrawer: React.FC = ({ booleans: [showModal, setShow }) } - const handleDeleteChat = (item: ListItem) => { - Alert.alert( - `Delete Chat`, - `Are you sure you want to delete this chat file: '${item.name}'?`, - [ - { - text: 'Cancel', - onPress: () => {}, - style: 'cancel', - }, - { - text: 'Confirm', - onPress: async () => { - await deleteChat(item.id) - if (charId && currentChatId === item.id) { - const returnedChatId = await Chats.db.query.chatNewestId(charId) - const chatId = returnedChatId - ? returnedChatId - : await Chats.db.mutate.createChat(charId) - chatId && (await loadChat(chatId)) - } - }, - style: 'destructive', - }, - ] - ) - } - const renderChat = (item: ListItem, index: number) => { const date = new Date(item.last_modified ?? 0) return ( @@ -106,9 +79,7 @@ const ChatsDrawer: React.FC = ({ booleans: [showModal, setShow {date.toLocaleTimeString()} - handleDeleteChat(item)}> - - + ) } diff --git a/app/constants/Chat.ts b/app/constants/Chat.ts index bf5b121..dfcb975 100644 --- a/app/constants/Chat.ts +++ b/app/constants/Chat.ts @@ -310,12 +310,11 @@ export namespace Chats { } export const chatNewestId = async (charId: number): Promise => { - const chatIds = await database.query.chats.findMany({ - limit: 1, - orderBy: chats.last_modified, + const result = await database.query.chats.findFirst({ + orderBy: desc(chats.last_modified), where: eq(chats.character_id, charId), }) - return chatIds?.[0]?.id + return result?.id } export const chatNewest = async () => { @@ -326,13 +325,6 @@ export namespace Chats { return result } - export const chatListOld = async (charId: number) => { - const chatIds = await database.query.chats.findMany({ - where: eq(chats.character_id, charId), - }) - return chatIds - } - export const chatList = async (charId: number) => { const result = await database .select({ @@ -508,6 +500,52 @@ export namespace Chats { await updateEntryModified(entryId) await database.delete(chatEntries).where(eq(chatEntries.id, entryId)) } + + export const cloneChat = async (chatId: number, limit?: number) => { + const result = await database.query.chats.findFirst({ + where: eq(chats.id, chatId), + columns: { id: false }, + with: { + messages: { + columns: { id: false }, + orderBy: chatEntries.order, + with: { + swipes: { + columns: { id: false }, + }, + }, + ...(limit && { limit: limit }), + }, + }, + }) + if (!result) return + + result.last_modified = new Date().getTime() + + const [{ newChatId }, ..._] = await database + .insert(chats) + .values(result) + .returning({ newChatId: chats.id }) + + result.messages.forEach((item) => { + item.chat_id = newChatId + }) + + const newEntryIds = await database + .insert(chatEntries) + .values(result.messages) + .returning({ newEntryId: chatEntries.id }) + + result.messages.forEach((item, index) => { + item.swipes.forEach((item2) => { + item2.entry_id = newEntryIds[index].newEntryId + }) + }) + + const swipes = result.messages.map((item) => item.swipes).flat() + + await database.insert(chatSwipes).values(swipes) + } } } diff --git a/app/constants/Global.ts b/app/constants/Global.ts index 178f163..326b868 100644 --- a/app/constants/Global.ts +++ b/app/constants/Global.ts @@ -1,3 +1,4 @@ +import { DownloadDirectoryPath, writeFile } from '@dr.pogodin/react-native-fs' import * as Crypto from 'expo-crypto' import * as FS from 'expo-file-system' import * as Sharing from 'expo-sharing' @@ -16,6 +17,7 @@ import { MarkdownStyle } from './Markdown' import { Presets } from './Presets' import { Style } from './Style' import { humanizedISO8601DateTime } from './Utils' + export { mmkv, Presets, @@ -39,8 +41,9 @@ export const resetEncryption = (value = 0) => { mmkv.recrypt(Crypto.getRandomBytes(16).toString()) } -// Exports a string to external storage, supports json - +/** Exports a string to external storage, supports json + * @deprecated + */ export const saveStringExternal = async ( filename: string, filedata: string, @@ -65,6 +68,23 @@ export const saveStringExternal = async ( } else if (Platform.OS === 'ios') Sharing.shareAsync(filename) } +/** + * + * @param data string data of file + * @param filename filename to be written, include extension + * @param encoding encoding of file + */ +export const saveStringToDownload = async ( + data: string, + filename: string, + encoding: 'ascii' | 'base64' | `utf8` +) => { + await writeFile(`${DownloadDirectoryPath}/${filename}`, data, encoding) +} + +/** + * Default settings on first install + */ const AppSettingsDefault: Record = { [AppSettings.AnimateEditor]: true, [AppSettings.AutoLoadLocal]: false, @@ -87,8 +107,6 @@ const loadChatOnInit = async () => { await Chats.useChat.getState().load(newestChat[0].id) } -// runs every startup to clear some MMKV values - const createDefaultUserData = async () => { await Characters.db.mutate.createCard('User', 'user').then((id: number) => { mmkv.set(Global.UserID, id) @@ -96,6 +114,9 @@ const createDefaultUserData = async () => { }) } +/** + * Runs every app start + */ export const startupApp = () => { console.log('[APP STARTED]: T1APT')