Skip to content

Commit

Permalink
chat: working attachments
Browse files Browse the repository at this point in the history
  • Loading branch information
natew committed Dec 29, 2024
1 parent e6eb44e commit 7d9fc86
Show file tree
Hide file tree
Showing 9 changed files with 114 additions and 53 deletions.
95 changes: 66 additions & 29 deletions apps/chat/src/interface/messages/MessageInput.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,29 @@
import type { RefMDEditor } from '@uiw/react-md-editor'
import { startTransition, useEffect, useRef, useState } from 'react'
import { Input, YStack, XStack, Progress, Button } from 'tamagui'
import { Image } from '@tamagui/image-next'
import { X } from '@tamagui/lucide-icons'
import { useEffect, useRef, useState } from 'react'
import { Button, Progress, XStack, YStack } from 'tamagui'
import { useAuth } from '~/better-auth/authClient'
import { Editor, type EditorRef } from '~/editor/Editor'
import { randomID } from '~/helpers/randomID'
import { useCurrentChannel, useCurrentServer } from '~/state/server'
import { getDerivedUserState, updateUserCurrentChannel, useCurrentThread } from '~/state/user'
import { zero } from '~/zero'
import { messagesListEmitter } from './MessagesList'
import { attachmentEmitter } from '../upload/DragDropFile'
import type { FileUpload } from '../upload/uploadImage'
import { X } from '@tamagui/lucide-icons'
import { messagesListEmitter } from './MessagesList'
import { createEmitter } from '@vxrn/emitter'

let mainInputRef: EditorRef | null = null

export const messageInputEmitter = createEmitter<{ type: 'submit' }>()

export const MessageInput = ({ inThread }: { inThread?: boolean }) => {
const inputRef = useRef<EditorRef>(null)
const channel = useCurrentChannel()
const server = useCurrentServer()
const thread = useCurrentThread()
const { user } = useAuth()
const disabled = !user
const disabled = !user || !channel

// on channel change, focus input
useEffect(() => {
Expand All @@ -32,22 +34,31 @@ export const MessageInput = ({ inThread }: { inThread?: boolean }) => {
mainInputRef?.textarea?.focus()
})
}
} else {
setTimeout(() => {
inputRef.current?.textarea?.focus()
})
}

return () => {
// focus events cause layout shifts which can make animations bad
setTimeout(() => {
mainInputRef?.textarea?.focus()
}, 50)
}
setTimeout(() => {
inputRef.current?.textarea?.focus()
})

return () => {
// focus events cause layout shifts which can make animations bad
setTimeout(() => {
mainInputRef?.textarea?.focus()
}, 50)
}
}, [channel, inThread])

return (
<YStack btw={1} bc="$color4" p="$2" gap="$2">
<YStack
btw={1}
bc="$color4"
p="$2"
gap="$2"
{...(disabled && {
opacity: 0.5,
pointerEvents: 'none',
})}
>
<Editor
ref={inputRef}
onKeyDown={(e) => {
Expand Down Expand Up @@ -92,9 +103,9 @@ export const MessageInput = ({ inThread }: { inThread?: boolean }) => {
}
}
}}
onSubmit={(content) => {
if (!user) {
console.error('no user')
onSubmit={async (content) => {
if (!user || !channel) {
console.warn('missing', { user, channel })
return
}
if (!content) {
Expand All @@ -103,17 +114,37 @@ export const MessageInput = ({ inThread }: { inThread?: boolean }) => {

inputRef.current?.clear?.()

zero.mutate.message.insert({
id: randomID(),
channelID: channel.id,
threadID: thread?.id,
isThreadReply: !!thread,
content,
deleted: false,
creatorID: user!.id,
serverID: server.id,
await zero.mutateBatch((tx) => {
const messageID = randomID()

tx.message.insert({
id: messageID,
channelID: channel.id,
threadID: thread?.id,
isThreadReply: !!thread,
content,
deleted: false,
creatorID: user!.id,
serverID: server.id,
})

const attachments = attachmentEmitter.value
if (attachments) {
for (const attachment of attachments) {
tx.attachment.insert({
id: randomID(),
type: attachment.type,
userID: user.id,
channelID: channel.id,
url: attachment.url,
messageID,
})
}
}
})

messageInputEmitter.emit({ type: 'submit' })

setTimeout(() => {
inputRef.current?.textarea?.focus()
}, 40)
Expand All @@ -133,6 +164,12 @@ const MessageInputAttachments = () => {
setAttachments(value)
})

messageInputEmitter.use((value) => {
if (value.type === 'submit') {
setAttachments([])
}
})

return (
<XStack gap="$2">
{attachments.map((attachment) => {
Expand Down
13 changes: 13 additions & 0 deletions apps/chat/src/interface/messages/MessageItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { zero } from '~/zero'
import { MessageActionBar } from './MessageActionBar'
import { MessageReactions } from './MessageReactions'
import { messageHover } from './constants'
import { Image } from '@tamagui/image-next'

export const MessageItem = memo(
({
Expand Down Expand Up @@ -110,6 +111,18 @@ export const MessageItem = memo(
)}
</SizableText>

{!!message.attachments?.length && (
<XStack gap="$4">
{message.attachments.map((attachment) => {
if (!attachment.url) {
return null
}

return <Image key={attachment.id} src={attachment.url} width={50} height={50} />
})}
</XStack>
)}

{thread && (
<YStack>
<XStack
Expand Down
26 changes: 3 additions & 23 deletions apps/chat/src/interface/upload/DragDropFile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ import { exists, readFile } from '@tauri-apps/plugin-fs'
import { createEmitter } from '@vxrn/emitter'
import { useCallback, useEffect, useState } from 'react'
import { isWeb } from 'tamagui'
import { useUploadImages, type FileUpload } from './uploadImage'
import { isTauri } from '~/tauri/constants'
import { getFileType, isImageFile } from './helpers'
import { useUploadImages, type FileUpload } from './uploadImage'

export const attachmentEmitter = createEmitter<FileUpload[]>()

Expand Down Expand Up @@ -44,6 +45,7 @@ export const DragDropFile = (props: { children: any }) => {
}
return {
file: new File([contents], name),
type: getFileType(name),
name,
progress: 0,
status: 'uploading' as const,
Expand Down Expand Up @@ -246,28 +248,6 @@ async function fileLikeToDataURI(
return arrayBufferToDataURL(contents, getFileType(file.name))
}

export function isImageFile(filename: string): boolean {
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp']
return imageExtensions.some((ext) => filename.toLowerCase().endsWith(ext))
}

function getFileType(filename: string): string {
const ext = filename.toLowerCase().split('.').pop()
switch (ext) {
case 'jpg':
case 'jpeg':
return 'image/jpeg'
case 'png':
return 'image/png'
case 'gif':
return 'image/gif'
case 'webp':
return 'image/webp'
default:
return 'application/octet-stream'
}
}

function arrayBufferToDataURL(buffer: Uint8Array, mimeType: string): string {
const chunkSize = 65536
let base64 = ''
Expand Down
23 changes: 23 additions & 0 deletions apps/chat/src/interface/upload/helpers.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
export function getFileType(filename: string) {
const ext = filename.toLowerCase().split('.').pop()
switch (ext) {
case 'jpg':
case 'jpeg':
return 'image/jpeg' as const
case 'png':
return 'image/png' as const
case 'gif':
return 'image/gif' as const
case 'webp':
return 'image/webp' as const
default:
return 'application/octet-stream' as const
}
}

export type FileType = ReturnType<typeof getFileType>

export function isImageFile(filename: string): boolean {
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp']
return imageExtensions.some((ext) => filename.toLowerCase().endsWith(ext))
}
3 changes: 3 additions & 0 deletions apps/chat/src/interface/upload/uploadImage.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type React from 'react'
import { useState } from 'react'
import { getFileType, type FileType } from './helpers'

interface UploadResponse {
url?: string
Expand All @@ -12,6 +13,7 @@ export type FileUpload = {
name: string
progress: number
file: File
type: FileType
// uploaded url
url?: string
// data-uri string
Expand Down Expand Up @@ -76,6 +78,7 @@ export const useUploadImages = ({
const handleUpload = (files: File[]) => {
const newUploads = files.map((file) => ({
name: file.name,
type: getFileType(file.name),
file,
progress: 0,
status: 'uploading' as const,
Expand Down
1 change: 1 addition & 0 deletions apps/chat/src/state/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ export const useCurrentChannelMessages = () => {
.related('reactions')
.related('thread')
.related('sender')
.related('attachments')
)
)
)[0][0]?.channels?.[0]?.messages || []
Expand Down
2 changes: 1 addition & 1 deletion apps/chat/src/state/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export const useCurrentThread = () => {
q.thread
.where('id', activeThread?.openedThreadId || '')
.related('messages', (q) =>
q.orderBy('createdAt', 'asc').related('reactions').related('sender')
q.orderBy('createdAt', 'asc').related('reactions').related('sender').related('attachments')
)
)
return thread[0]
Expand Down
1 change: 1 addition & 0 deletions apps/chat/src/zero/tables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,7 @@ export type MessageWithRelations = Message & {
reactions: readonly Reaction[]
thread?: readonly Thread[]
sender: readonly User[]
attachments: readonly Attachment[]
}
export type ThreadWithRelations = Thread & { messages: readonly Message[] }
export type RoleWithRelations = Role & { members: readonly User[] }
3 changes: 3 additions & 0 deletions packages/emitter/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { useEffect, useState } from 'react'
export class Emitter<const T> {
private disposables = new Set<(cb: any) => void>()

value?: T

constructor(public defaultValue?: T) {}

listen = (disposable: (cb: T) => void) => {
Expand All @@ -13,6 +15,7 @@ export class Emitter<const T> {
}

emit = (next: T) => {
this.value = next
this.disposables.forEach((cb) => cb(next))
}

Expand Down

0 comments on commit 7d9fc86

Please sign in to comment.