Skip to content

Commit

Permalink
Merge branch 'develop'
Browse files Browse the repository at this point in the history
  • Loading branch information
molvqingtai committed Oct 29, 2024
2 parents 4cb74c9 + d44bceb commit 859a8f4
Show file tree
Hide file tree
Showing 11 changed files with 419 additions and 292 deletions.
14 changes: 7 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
},
"homepage": "https://github.com/molvqingtai/WebChat",
"dependencies": {
"@hookform/resolvers": "^3.9.0",
"@hookform/resolvers": "^3.9.1",
"@lottiefiles/dotlottie-react": "^0.9.2",
"@perfsee/jsonr": "^1.13.0",
"@radix-ui/react-avatar": "^1.1.1",
Expand Down Expand Up @@ -72,8 +72,8 @@
"date-fns": "^4.1.0",
"framer-motion": "^11.11.10",
"idb-keyval": "^6.2.1",
"lucide-react": "^0.453.0",
"nanoid": "^5.0.7",
"lucide-react": "^0.454.0",
"nanoid": "^5.0.8",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.53.1",
Expand All @@ -96,18 +96,18 @@
"devDependencies": {
"@commitlint/cli": "^19.5.0",
"@commitlint/config-conventional": "^19.5.0",
"@eslint-react/eslint-plugin": "^1.15.1",
"@eslint-react/eslint-plugin": "^1.15.2",
"@eslint/js": "^9.13.0",
"@semantic-release/changelog": "^6.0.3",
"@semantic-release/exec": "^6.0.3",
"@semantic-release/git": "^10.0.1",
"@types/eslint": "^9.6.1",
"@types/eslint__js": "^8.42.3",
"@types/eslint-plugin-tailwindcss": "^3.17.0",
"@types/node": "^22.8.1",
"@types/node": "^22.8.2",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@typescript-eslint/parser": "^8.11.0",
"@typescript-eslint/parser": "^8.12.1",
"@vitejs/plugin-react": "^4.3.3",
"autoprefixer": "^10.4.20",
"cross-env": "^7.0.3",
Expand All @@ -128,7 +128,7 @@
"tailwindcss": "^3.4.14",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.6.3",
"typescript-eslint": "^8.11.0",
"typescript-eslint": "^8.12.1",
"vite-plugin-svgr": "^4.2.0",
"webext-bridge": "^6.0.1",
"wxt": "^0.19.13"
Expand Down
528 changes: 264 additions & 264 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

34 changes: 34 additions & 0 deletions src/app/content/components/ImageButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { Button } from '@/components/ui/Button'
import { createElement } from '@/utils'
import { ImageIcon } from 'lucide-react'

export interface ImageButtonProps {
onSelect?: (file: File) => void
disabled?: boolean
}

const ImageButton = ({ onSelect, disabled }: ImageButtonProps) => {
const handleClick = () => {
const input = createElement<HTMLInputElement>(`<input type="file" accept="image/png,image/jpeg,image/webp" />`)

input.addEventListener(
'change',
async (e: Event) => {
onSelect?.((e.target as HTMLInputElement).files![0])
},
{ once: true }
)

input.click()
}

return (
<Button disabled={disabled} onClick={handleClick} variant="ghost" size="icon" className="dark:text-white">
<ImageIcon size={20} />
</Button>
)
}

ImageButton.displayName = 'ImageButton'

export default ImageButton
25 changes: 21 additions & 4 deletions src/app/content/components/MessageInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { forwardRef, type ChangeEvent, CompositionEvent, type KeyboardEvent } fr
import { cn } from '@/utils'
import { Textarea } from '@/components/ui/Textarea'
import { ScrollArea } from '@/components/ui/ScrollArea'
import LoadingIcon from '@/assets/images/loading.svg'

export interface MessageInputProps {
value?: string
Expand All @@ -11,6 +12,7 @@ export interface MessageInputProps {
preview?: boolean
autoFocus?: boolean
disabled?: boolean
loading?: boolean
onInput?: (e: ChangeEvent<HTMLTextAreaElement>) => void
onKeyDown?: (e: KeyboardEvent<HTMLTextAreaElement>) => void
onCompositionStart?: (e: CompositionEvent<HTMLTextAreaElement>) => void
Expand All @@ -33,7 +35,8 @@ const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(
onCompositionStart,
onCompositionEnd,
autoFocus,
disabled
disabled,
loading
},
ref
) => {
Expand All @@ -45,20 +48,34 @@ const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(
onKeyDown={onKeyDown}
autoFocus={autoFocus}
maxLength={maxLength}
className="box-border resize-none whitespace-pre-wrap break-words border-none bg-gray-50 pb-5 [field-sizing:content] [word-break:break-word] focus:ring-0 focus:ring-offset-0 dark:bg-slate-800"
className={cn(
'box-border resize-none whitespace-pre-wrap break-words border-none bg-slate-100 pb-5 [field-sizing:content] [word-break:break-word] focus:ring-0 focus:ring-offset-0 dark:bg-slate-800',
{
'disabled:opacity-100': loading
}
)}
rows={2}
value={value}
spellCheck={false}
onCompositionStart={onCompositionStart}
onCompositionEnd={onCompositionEnd}
placeholder="Type your message here."
onInput={onInput}
disabled={disabled}
disabled={disabled || loading}
/>
</ScrollArea>
<div className="absolute bottom-1 right-3 rounded-lg text-xs text-slate-400 dark:text-slate-50">
<div
className={cn('absolute bottom-1 right-3 rounded-lg text-xs text-slate-400', {
'opacity-50': disabled || loading
})}
>
{value?.length ?? 0}/{maxLength}
</div>
{loading && (
<div className="absolute inset-0 flex items-center justify-center text-slate-800 after:absolute after:inset-0 after:backdrop-blur-xs dark:text-slate-100">
<LoadingIcon className="relative z-10 size-10"></LoadingIcon>
</div>
)}
</div>
)
}
Expand Down
2 changes: 1 addition & 1 deletion src/app/content/views/AppButton/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ const AppButton: FC<AppButtonProps> = ({ className }) => {
>
<div
className={cn(
'absolute grid grid-rows-[repeat(2,minmax(0,2.5rem))] w-full justify-center items-center transition-all duration-500',
'absolute grid grid-rows-[repeat(2,minmax(0,2.5rem))] w-full justify-center items-center transition-all duration-300',
isDarkMode ? 'top-0' : '-top-10',
isDarkMode ? 'bg-slate-950 text-white' : 'bg-white text-orange-400'
)}
Expand Down
59 changes: 55 additions & 4 deletions src/app/content/views/Footer/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@ import useTriggerAway from '@/hooks/useTriggerAway'
import { ScrollArea } from '@/components/ui/ScrollArea'
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso'
import UserInfoDomain from '@/domain/UserInfo'
import { cn, getRootNode, getTextSimilarity } from '@/utils'
import { blobToBase64, cn, compressImage, getRootNode, getTextSimilarity } from '@/utils'
import { Avatar, AvatarFallback } from '@/components/ui/Avatar'
import { AvatarImage } from '@radix-ui/react-avatar'
import ToastDomain from '@/domain/Toast'
import ImageButton from '../../components/ImageButton'
import { nanoid } from 'nanoid'

const Footer: FC = () => {
const send = useRemeshSend()
Expand All @@ -40,13 +42,15 @@ const Footer: FC = () => {
const shareAutoCompleteListRef = useShareRef(setAutoCompleteListRef, autoCompleteListRef)
const isComposing = useRef(false)
const virtuosoRef = useRef<VirtuosoHandle>(null)
const [inputLoading, setInputLoading] = useState(false)

const shareRef = useShareRef(inputRef, setRef)

/**
* When inserting a username using the @ syntax, record the username's position information and the mapping relationship between the position information and userId to distinguish between users with the same name.
*/
const atUserRecord = useRef<Map<string, Set<[number, number]>>>(new Map())
const imageRecord = useRef<Map<string, string>>(new Map())

const updateAtUserAtRecord = useMemo(
() => (message: string, start: number, end: number, offset: number, atUserId?: string) => {
Expand Down Expand Up @@ -102,19 +106,37 @@ const Footer: FC = () => {

const selectedUser = autoCompleteList.find((_, index) => index === selectedUserIndex)!

const handleSend = () => {
// Replace the hash URL in ![Image](hash:${hash}) with base64 and update the atUserRecord.
const transformMessage = async (message: string) => {
let newMessage = message
const matchList = [...message.matchAll(/!\[Image\]\(hash:([^\s)]+)\)/g)]
matchList?.forEach((match) => {
const base64 = imageRecord.current.get(match[1])
if (base64) {
const base64Syntax = `![Image](${base64})`
const hashSyntax = match[0]
const startIndex = match.index
const endIndex = startIndex + base64Syntax.length - hashSyntax.length
newMessage = newMessage.replace(hashSyntax, base64Syntax)
updateAtUserAtRecord(newMessage, startIndex, endIndex, 0)
}
})
return newMessage
}

const handleSend = async () => {
if (!`${message}`.trim()) {
return send(toastDomain.command.WarningCommand('Message cannot be empty.'))
}

const transformedMessage = await transformMessage(message)
const atUsers = [...atUserRecord.current]
.map(([userId, positions]) => {
const user = userList.find((user) => user.userId === userId)
return (user ? { ...user, positions: [...positions] } : undefined)!
})
.filter(Boolean)

send(roomDomain.command.SendTextMessageCommand({ body: message, atUsers }))
send(roomDomain.command.SendTextMessageCommand({ body: transformedMessage, atUsers }))
send(messageInputDomain.command.ClearCommand())
}

Expand Down Expand Up @@ -211,6 +233,33 @@ const Footer: FC = () => {
})
}

const handleInjectImage = async (file: File) => {
try {
setInputLoading(true)
const blob = await compressImage({ input: file, targetSize: 30 * 1024, outputType: 'image/webp' })
const base64 = await blobToBase64(blob)
const hash = nanoid()
const newMessage = `${message.slice(0, selectionEnd)}![Image](hash:${hash})${message.slice(selectionEnd)}`

const start = selectionStart
const end = selectionEnd + newMessage.length - message.length

updateAtUserAtRecord(newMessage, start, end, 0)
send(messageInputDomain.command.InputCommand(newMessage))

imageRecord.current.set(hash, base64)

requestIdleCallback(() => {
inputRef.current?.setSelectionRange(end, end)
inputRef.current?.focus()
})
} catch (error) {
send(toastDomain.command.ErrorCommand((error as Error).message))
} finally {
setInputLoading(false)
}
}

const handleInjectAtSyntax = (username: string) => {
const atIndex = message.lastIndexOf('@', selectionEnd - 1)
// Determine if there is a space before @
Expand Down Expand Up @@ -285,11 +334,13 @@ const Footer: FC = () => {
ref={shareRef}
value={message}
onInput={handleInput}
loading={inputLoading}
onKeyDown={handleKeyDown}
maxLength={MESSAGE_MAX_LENGTH}
></MessageInput>
<div className="flex items-center">
<EmojiButton onSelect={handleInjectEmoji}></EmojiButton>
<ImageButton disabled={inputLoading} onSelect={handleInjectImage}></ImageButton>
<Button className="ml-auto" size="sm" onClick={handleSend}>
<span className="mr-2">Send</span>
<CornerDownLeftIcon className="text-slate-400" size={12}></CornerDownLeftIcon>
Expand Down
24 changes: 13 additions & 11 deletions src/app/options/components/AvatarSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { type ChangeEvent } from 'react'
import { ImagePlusIcon } from 'lucide-react'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/Avatar'
import { Label } from '@/components/ui/Label'
import { cn, compressImage } from '@/utils'
import { blobToBase64, cn, compressImage } from '@/utils'

export interface AvatarSelectProps {
value?: string
Expand Down Expand Up @@ -31,15 +31,10 @@ const AvatarSelect = React.forwardRef<HTMLInputElement, AvatarSelectProps>(
* In chrome storage.sync, each key-value pair supports a maximum storage of 8kb
* and all key-value pairs support a maximum storage of 100kb.
*/
const blob = await compressImage({ input: file, targetSize: compressSize })
const reader = new FileReader()
reader.onload = (e) => {
const base64 = e.target?.result as string
onSuccess?.(base64)
onChange?.(base64)
}
reader.onerror = () => onError?.(new Error('Failed to read image file.'))
reader.readAsDataURL(blob)
const blob = await compressImage({ input: file, targetSize: compressSize, outputType: 'image/webp' })
const base64 = await blobToBase64(blob)
onSuccess?.(base64)
onChange?.(base64)
} catch (error) {
onError?.(error as Error)
}
Expand All @@ -63,7 +58,14 @@ const AvatarSelect = React.forwardRef<HTMLInputElement, AvatarSelectProps>(
<ImagePlusIcon size={30} className="text-slate-400 group-hover:text-slate-500" />
</AvatarFallback>
</Avatar>
<input ref={ref} hidden disabled={disabled} type="file" accept="image/png,image/jpeg" onChange={handleChange} />
<input
ref={ref}
hidden
disabled={disabled}
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={handleChange}
/>
</Label>
)
}
Expand Down
12 changes: 12 additions & 0 deletions src/assets/images/loading.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 10 additions & 0 deletions src/utils/blobToBase64.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
const blobToBase64 = (blob: Blob) => {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onload = (e) => resolve(e.target?.result as string)
reader.onerror = () => reject(new Error('Failed to read file.'))
reader.readAsDataURL(blob)
})
}

export default blobToBase64
2 changes: 1 addition & 1 deletion src/utils/compressImage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ const compress = async (
const compressImage = async (options: Options) => {
const { input, targetSize, toleranceSize = -1024 } = options
if (!['image/jpeg', 'image/png', 'image/webp'].includes(input.type)) {
throw new Error('Invalid input type, only support image/jpeg, image/png, image/webp')
throw new Error('Only PNG, JPEG and WebP image are supported.')
}

if (input.size <= targetSize) {
Expand Down
1 change: 1 addition & 0 deletions src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@ export { default as generateRandomName } from './generateRandomName'
export { default as getCursorPosition } from './getCursorPosition'
export { default as getTextSimilarity } from './getTextSimilarity'
export { default as getRootNode } from './getRootNode'
export { default as blobToBase64 } from './blobToBase64'

0 comments on commit 859a8f4

Please sign in to comment.