Skip to content

Commit

Permalink
feat: added chat edit submenu, chat export and cloning
Browse files Browse the repository at this point in the history
  • Loading branch information
Vali-98 committed Sep 24, 2024
1 parent 4c068e4 commit fd61107
Show file tree
Hide file tree
Showing 4 changed files with 310 additions and 50 deletions.
230 changes: 230 additions & 0 deletions app/components/ChatMenu/ChatEditPopup.tsx
Original file line number Diff line number Diff line change
@@ -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<void>
label: string
iconName: 'copy' | 'download' | 'trash'
warning?: boolean
}

const PopupOption: React.FC<PopupProps> = ({ onPress, label, iconName, warning = false }) => {
return (
<MenuOption>
<TouchableOpacity style={styles.popupButton} onPress={onPress}>
<FontAwesome
style={{ minWidth: 20 }}
name={iconName}
size={18}
color={Style.getColor(warning ? 'destructive-brand' : 'primary-text2')}
/>
<Text style={warning ? styles.optionLabelWarning : styles.optionLabel}>
{label}
</Text>
</TouchableOpacity>
</MenuOption>
)
}

const ChatEditPopup: React.FC<ChatEditPopupProps> = ({ item, setNowLoading, nowLoading }) => {
const [showMenu, setShowMenu] = useState<boolean>(false)
const menuRef: React.MutableRefObject<Menu | null> = 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 (
<Menu
ref={menuRef}
onOpen={() => setShowMenu(true)}
onClose={() => setShowMenu(false)}
renderer={Popover}
rendererProps={{
placement: 'left',
anchorStyle: styles.anchor,
openAnimationDuration: 150,
closeAnimationDuration: 0,
}}>
<MenuTrigger disabled={nowLoading}>
<AntDesign
style={styles.triggerButton}
color={Style.getColor(showMenu ? 'primary-text3' : 'primary-text2')}
name="edit"
size={26}
/>
</MenuTrigger>
<MenuOptions customStyles={menustyle}>
<PopupOption
onPress={() => handleExportChat()}
label="Export"
iconName="download"
/>
<PopupOption
onPress={() => {
handleCloneChat()
}}
label="Clone"
iconName="copy"
/>
<PopupOption
onPress={() => handleDeleteChat()}
label="Delete"
iconName="trash"
warning
/>
</MenuOptions>
</Menu>
)
}

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'),
},
}
41 changes: 6 additions & 35 deletions app/components/ChatMenu/ChatsDrawer.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
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,
TouchableOpacity,
StyleSheet,
View,
FlatList,
Alert,
} from 'react-native'
import Animated, {
Easing,
Expand All @@ -19,6 +18,8 @@ import Animated, {
SlideOutRight,
} from 'react-native-reanimated'

import ChatEditPopup from './ChatEditPopup'

type ChatsDrawerProps = {
booleans: [boolean, (b: boolean | SetStateAction<boolean>) => void]
}
Expand All @@ -34,7 +35,7 @@ type ListItem = {

const ChatsDrawer: React.FC<ChatsDrawerProps> = ({ booleans: [showModal, setShowModal] }) => {
const { charId } = Characters.useCharacterCard((state) => ({ charId: state.id }))

const [nowLoading, setNowLoading] = useState<boolean>(false)
const { data } = useLiveQuery(Chats.db.query.chatListQuery(charId ?? 0))

const { deleteChat, loadChat, currentChatId } = Chats.useChat((state) => ({
Expand All @@ -59,34 +60,6 @@ const ChatsDrawer: React.FC<ChatsDrawerProps> = ({ 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 (
Expand All @@ -106,9 +79,7 @@ const ChatsDrawer: React.FC<ChatsDrawerProps> = ({ booleans: [showModal, setShow
<Text style={styles.smallText}>{date.toLocaleTimeString()}</Text>
</View>
</TouchableOpacity>
<TouchableOpacity style={styles.editButton} onPress={() => handleDeleteChat(item)}>
<AntDesign color={Style.getColor('primary-text2')} name="edit" size={26} />
</TouchableOpacity>
<ChatEditPopup item={item} nowLoading={nowLoading} setNowLoading={setNowLoading} />
</View>
)
}
Expand Down
60 changes: 49 additions & 11 deletions app/constants/Chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,12 +310,11 @@ export namespace Chats {
}

export const chatNewestId = async (charId: number): Promise<number | undefined> => {
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 () => {
Expand All @@ -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({
Expand Down Expand Up @@ -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)
}
}
}

Expand Down
Loading

0 comments on commit fd61107

Please sign in to comment.