feat: Add emoji avatar support with image upload and picker & support i18n language (#2473)

This commit is contained in:
Pin Studios 2025-03-01 21:33:47 +08:00 committed by GitHub
parent ae11490f87
commit 92ab67eb3d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 185 additions and 27 deletions

View File

@ -5,13 +5,14 @@ import { useAppDispatch } from '@renderer/store'
import { setAvatar } from '@renderer/store/runtime' import { setAvatar } from '@renderer/store/runtime'
import { setUserName } from '@renderer/store/settings' import { setUserName } from '@renderer/store/settings'
import { compressImage } from '@renderer/utils' import { compressImage } from '@renderer/utils'
import { Avatar, Input, Modal, Upload } from 'antd' import { Avatar , Input, Modal, Popover, Upload, Dropdown } from 'antd'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import styled from 'styled-components' import styled from 'styled-components'
import { Center, HStack } from '../Layout' import { Center, HStack, VStack } from '../Layout'
import { TopView } from '../TopView' import { TopView } from '../TopView'
import EmojiPicker from '../EmojiPicker'
interface Props { interface Props {
resolve: (data: any) => void resolve: (data: any) => void
@ -19,6 +20,8 @@ interface Props {
const PopupContainer: React.FC<Props> = ({ resolve }) => { const PopupContainer: React.FC<Props> = ({ resolve }) => {
const [open, setOpen] = useState(true) const [open, setOpen] = useState(true)
const [emojiPickerOpen, setEmojiPickerOpen] = useState(false)
const [dropdownOpen, setDropdownOpen] = useState(false)
const { t } = useTranslation() const { t } = useTranslation()
const { userName } = useSettings() const { userName } = useSettings()
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
@ -36,17 +39,28 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
resolve({}) resolve({})
} }
return ( const handleEmojiClick = async (emoji: string) => {
<Modal try {
width="300px" // set emoji string
open={open} await ImageStorage.set('avatar', emoji)
footer={null} // update avatar display
onOk={onOk} dispatch(setAvatar(emoji))
onCancel={onCancel} setEmojiPickerOpen(false)
afterClose={onClose} } catch (error: any) {
transitionName="ant-move-down" window.message.error(error.message)
centered> }
<Center mt="30px"> }
// modify the judgment function, more accurately detect Emoji
const isEmoji = (str: string) => {
// check if it is a string and is not base64 or URL format
return str && typeof str === 'string' && !str.startsWith('data:') && !str.startsWith('http');
}
const items = [
{
key: 'upload',
label: (
<Upload <Upload
customRequest={() => {}} customRequest={() => {}}
accept="image/png, image/jpeg, image/gif" accept="image/png, image/jpeg, image/gif"
@ -62,12 +76,72 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
await ImageStorage.set('avatar', compressedFile) await ImageStorage.set('avatar', compressedFile)
} }
dispatch(setAvatar(await ImageStorage.get('avatar'))) dispatch(setAvatar(await ImageStorage.get('avatar')))
setDropdownOpen(false)
} catch (error: any) { } catch (error: any) {
window.message.error(error.message) window.message.error(error.message)
} }
}}> }}>
<UserAvatar src={avatar} /> <div>{t('settings.general.image_upload')}</div>
</Upload> </Upload>
)
},
{
key: 'emoji',
label: (
<div onClick={(e) => {
e.stopPropagation()
setEmojiPickerOpen(true)
setDropdownOpen(false)
}}>
{t('settings.general.emoji_picker')}
</div>
)
}
]
return (
<Modal
width="300px"
open={open}
footer={null}
onOk={onOk}
onCancel={onCancel}
afterClose={onClose}
transitionName="ant-move-down"
centered>
<Center mt="30px">
<VStack alignItems="center" gap="10px">
<Dropdown
menu={{ items }}
trigger={['click']}
open={dropdownOpen}
align={{ offset: [0, 4] }}
placement="bottom"
onOpenChange={(visible) => {
setDropdownOpen(visible)
if (visible) {
setEmojiPickerOpen(false)
}
}}>
<Popover
content={<EmojiPicker onEmojiClick={handleEmojiClick} />}
trigger="click"
open={emojiPickerOpen}
onOpenChange={(visible) => {
setEmojiPickerOpen(visible)
if (visible) {
setDropdownOpen(false)
}
}}
placement="bottom">
{isEmoji(avatar) ? (
<EmojiAvatar>{avatar}</EmojiAvatar>
) : (
<UserAvatar src={avatar} />
)}
</Popover>
</Dropdown>
</VStack>
</Center> </Center>
<HStack alignItems="center" gap="10px" p="20px"> <HStack alignItems="center" gap="10px" p="20px">
<Input <Input
@ -92,6 +166,23 @@ const UserAvatar = styled(Avatar)`
} }
` `
const EmojiAvatar = styled.div`
cursor: pointer;
width: 80px;
height: 80px;
border-radius: 20%;
background-color: var(--color-background-soft);
display: flex;
align-items: center;
justify-content: center;
font-size: 40px;
transition: opacity 0.3s ease;
border: 0.5px solid var(--color-border);
&:hover {
opacity: 0.8;
}
`
export default class UserPopup { export default class UserPopup {
static topviewId = 0 static topviewId = 0
static hide() { static hide() {
@ -110,4 +201,4 @@ export default class UserPopup {
) )
}) })
} }
} }

View File

@ -57,6 +57,10 @@ const Sidebar: FC = () => {
}) })
} }
const isEmoji = (str: string) => {
return str && typeof str === 'string' && !str.startsWith('data:') && !str.startsWith('http');
}
return ( return (
<Container <Container
id="app-sidebar" id="app-sidebar"
@ -64,7 +68,11 @@ const Sidebar: FC = () => {
backgroundColor: sidebarBgColor, backgroundColor: sidebarBgColor,
zIndex: minappShow ? 10000 : 'initial' zIndex: minappShow ? 10000 : 'initial'
}}> }}>
<AvatarImg src={avatar || UserAvatar} draggable={false} className="nodrag" onClick={onEditUser} /> {isEmoji(avatar) ? (
<EmojiAvatarSidebar onClick={onEditUser}>{avatar}</EmojiAvatarSidebar>
) : (
<AvatarImg src={avatar || UserAvatar} draggable={false} className="nodrag" onClick={onEditUser} />
)}
<MainMenusContainer> <MainMenusContainer>
<Menus onClick={MinApp.onClose}> <Menus onClick={MinApp.onClose}>
<MainMenus /> <MainMenus />
@ -220,6 +228,22 @@ const AvatarImg = styled(Avatar)`
border: none; border: none;
cursor: pointer; cursor: pointer;
` `
const EmojiAvatarSidebar = styled.div`
width: 31px;
height: 31px;
background-color: var(--color-background-soft);
margin-bottom: ${isMac ? '12px' : '12px'};
margin-top: ${isMac ? '0px' : '2px'};
border-radius: 20%;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
cursor: pointer;
-webkit-app-region: none;
`
const MainMenusContainer = styled.div` const MainMenusContainer = styled.div`
display: flex; display: flex;
flex: 1; flex: 1;

View File

@ -697,6 +697,8 @@
"general.title": "General Settings", "general.title": "General Settings",
"general.user_name": "User Name", "general.user_name": "User Name",
"general.user_name.placeholder": "Enter your name", "general.user_name.placeholder": "Enter your name",
"general.image_upload": "Image Upload",
"general.emoji_picker": "Emoji Picker",
"general.view_webdav_settings": "View WebDAV settings", "general.view_webdav_settings": "View WebDAV settings",
"input.auto_translate_with_space": "Quickly translate with 3 spaces", "input.auto_translate_with_space": "Quickly translate with 3 spaces",
"input.target_language": "Target language", "input.target_language": "Target language",

View File

@ -697,6 +697,8 @@
"general.title": "一般設定", "general.title": "一般設定",
"general.user_name": "ユーザー名", "general.user_name": "ユーザー名",
"general.user_name.placeholder": "ユーザー名を入力", "general.user_name.placeholder": "ユーザー名を入力",
"general.image_upload": "画像アップロード",
"general.emoji_picker": "絵文字ピッカー",
"general.view_webdav_settings": "WebDAV設定を表示", "general.view_webdav_settings": "WebDAV設定を表示",
"input.auto_translate_with_space": "スペースを3回押して翻訳", "input.auto_translate_with_space": "スペースを3回押して翻訳",
"input.target_language": "目標言語", "input.target_language": "目標言語",

View File

@ -697,6 +697,8 @@
"general.title": "Общие настройки", "general.title": "Общие настройки",
"general.user_name": "Имя пользователя", "general.user_name": "Имя пользователя",
"general.user_name.placeholder": "Введите ваше имя", "general.user_name.placeholder": "Введите ваше имя",
"general.image_upload": "Загрузка изображений",
"general.emoji_picker": "Выбор эмодзи",
"general.view_webdav_settings": "Просмотр настроек WebDAV", "general.view_webdav_settings": "Просмотр настроек WebDAV",
"input.auto_translate_with_space": "Быстрый перевод с помощью 3-х пробелов", "input.auto_translate_with_space": "Быстрый перевод с помощью 3-х пробелов",
"input.target_language": "Целевой язык", "input.target_language": "Целевой язык",

View File

@ -697,6 +697,8 @@
"general.title": "常规设置", "general.title": "常规设置",
"general.user_name": "用户名", "general.user_name": "用户名",
"general.user_name.placeholder": "请输入用户名", "general.user_name.placeholder": "请输入用户名",
"general.image_upload": "图片上传",
"general.emoji_picker": "表情选择器",
"general.view_webdav_settings": "查看 WebDAV 设置", "general.view_webdav_settings": "查看 WebDAV 设置",
"input.auto_translate_with_space": "快速敲击3次空格翻译", "input.auto_translate_with_space": "快速敲击3次空格翻译",
"input.target_language": "目标语言", "input.target_language": "目标语言",

View File

@ -696,6 +696,8 @@
"general.title": "一般設定", "general.title": "一般設定",
"general.user_name": "使用者名稱", "general.user_name": "使用者名稱",
"general.user_name.placeholder": "輸入您的名稱", "general.user_name.placeholder": "輸入您的名稱",
"general.image_upload": "圖片上傳",
"general.emoji_picker": "表情選擇器",
"general.view_webdav_settings": "查看 WebDAV 設定", "general.view_webdav_settings": "查看 WebDAV 設定",
"input.auto_translate_with_space": "快速敲擊3次空格翻譯", "input.auto_translate_with_space": "快速敲擊3次空格翻譯",
"input.target_language": "目標語言", "input.target_language": "目標語言",

View File

@ -15,6 +15,10 @@ import { CSSProperties, FC, memo, useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import styled from 'styled-components' import styled from 'styled-components'
const isEmoji = (str: string) => {
return str && typeof str === 'string' && !str.startsWith('data:') && !str.startsWith('http');
}
interface Props { interface Props {
message: Message message: Message
assistant: Assistant assistant: Assistant
@ -81,12 +85,18 @@ const MessageHeader: FC<Props> = memo(({ assistant, model, message }) => {
{avatarName} {avatarName}
</Avatar> </Avatar>
) : ( ) : (
<Avatar <>
src={avatar} {isEmoji(avatar) ? (
size={35} <EmojiAvatar onClick={() => UserPopup.show()}>{avatar}</EmojiAvatar>
style={{ borderRadius: '20%', cursor: 'pointer' }} ) : (
onClick={() => UserPopup.show()} <Avatar
/> src={avatar}
size={35}
style={{ borderRadius: '20%', cursor: 'pointer' }}
onClick={() => UserPopup.show()}
/>
)}
</>
)} )}
<UserWrap> <UserWrap>
<UserName isBubbleStyle={isBubbleStyle} theme={theme}> <UserName isBubbleStyle={isBubbleStyle} theme={theme}>
@ -101,6 +111,19 @@ const MessageHeader: FC<Props> = memo(({ assistant, model, message }) => {
MessageHeader.displayName = 'MessageHeader' MessageHeader.displayName = 'MessageHeader'
const EmojiAvatar = styled.div`
width: 35px;
height: 35px;
background-color: var(--color-background-soft);
border-radius: 20%;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
cursor: pointer;
border: 0.5px solid var(--color-border);
`
const Container = styled.div` const Container = styled.div`
display: flex; display: flex;
flex-direction: row; flex-direction: row;

View File

@ -4,16 +4,26 @@ import { convertToBase64 } from '@renderer/utils'
const IMAGE_PREFIX = 'image://' const IMAGE_PREFIX = 'image://'
export default class ImageStorage { export default class ImageStorage {
static async set(key: string, file: File) { static async set(key: string, value: File | string) {
const id = IMAGE_PREFIX + key const id = IMAGE_PREFIX + key
try { try {
const base64Image = await convertToBase64(file) if (typeof value === 'string') {
if (typeof base64Image === 'string') { // stringemoji
if (await db.settings.get(id)) { if (await db.settings.get(id)) {
db.settings.update(id, { value: base64Image }) db.settings.update(id, { value })
return return
} }
await db.settings.add({ id, value: base64Image }) await db.settings.add({ id, value })
} else {
// file image
const base64Image = await convertToBase64(value)
if (typeof base64Image === 'string') {
if (await db.settings.get(id)) {
db.settings.update(id, { value: base64Image })
return
}
await db.settings.add({ id, value: base64Image })
}
} }
} catch (error) { } catch (error) {
console.error('Error storing the image', error) console.error('Error storing the image', error)