chore(form): store user info

This commit is contained in:
molvqingtai 2023-12-01 13:33:49 +08:00
parent 6645eda390
commit f55a7f479d
10 changed files with 149 additions and 28 deletions

View file

@ -5,7 +5,6 @@ import LikeButton from './LikeButton'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/Avatar' import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/Avatar'
import { Markdown } from '@/components/ui/Markdown' import { Markdown } from '@/components/ui/Markdown'
import { type Message } from '@/types/global'
export interface MessageItemProps { export interface MessageItemProps {
data: Message data: Message

View file

@ -7,7 +7,6 @@ import { Button } from '@/components/ui/Button'
import MessageInputDomain from '@/domain/MessageInput' import MessageInputDomain from '@/domain/MessageInput'
import MessageListDomain from '@/domain/MessageList' import MessageListDomain from '@/domain/MessageList'
import { MESSAGE_MAX_LENGTH } from '@/constants' import { MESSAGE_MAX_LENGTH } from '@/constants'
import { type Message } from '@/types/global'
const Footer: FC = () => { const Footer: FC = () => {
const send = useRemeshSend() const send = useRemeshSend()

View file

@ -9,6 +9,7 @@ export interface AvatarSelectProps {
value?: string value?: string
className?: string className?: string
disabled?: boolean disabled?: boolean
compressSize?: number
onSuccess?: (blob: Blob) => void onSuccess?: (blob: Blob) => void
onWarning?: (error: Error) => void onWarning?: (error: Error) => void
onError?: (error: Error) => void onError?: (error: Error) => void
@ -16,7 +17,7 @@ export interface AvatarSelectProps {
} }
const AvatarSelect = React.forwardRef<HTMLInputElement, AvatarSelectProps>( const AvatarSelect = React.forwardRef<HTMLInputElement, AvatarSelectProps>(
({ onChange, value, onError, onWarning, onSuccess, className, disabled }, ref) => { ({ onChange, value, onError, onWarning, onSuccess, className, compressSize = 8 * 1024, disabled }, ref) => {
const handleChange = async (e: ChangeEvent<HTMLInputElement>) => { const handleChange = async (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0] const file = e.target.files?.[0]
if (file) { if (file) {
@ -26,8 +27,11 @@ const AvatarSelect = React.forwardRef<HTMLInputElement, AvatarSelectProps>(
} }
try { try {
// Compress to 10kb /**
const blob = await compressImage(file, 10 * 1024) * 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(file, compressSize)
const reader = new FileReader() const reader = new FileReader()
reader.onload = (e) => { reader.onload = (e) => {
onSuccess?.(blob) onSuccess?.(blob)

View file

@ -3,35 +3,52 @@ import { useForm } from 'react-hook-form'
import { valibotResolver } from '@hookform/resolvers/valibot' import { valibotResolver } from '@hookform/resolvers/valibot'
import { toast } from 'sonner' import { toast } from 'sonner'
import { useRemeshDomain, useRemeshQuery, useRemeshSend } from 'remesh-react'
import { nanoid } from 'nanoid'
import { useEffect } from 'react'
import AvatarSelect from './AvatarSelect' import AvatarSelect from './AvatarSelect'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/Form' import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/Form'
import { Input } from '@/components/ui/Input' import { Input } from '@/components/ui/Input'
import { Switch } from '@/components/ui/Switch' import { Switch } from '@/components/ui/Switch'
import UserInfoDomain from '@/domain/UserInfo'
// In chrome storage.sync, each key-value pair supports a maximum storage of 8kb
// Image is encoded as base64, and the size is increased by about 33%.
const COMPRESS_SIZE = 8 * 1024 - 8 * 1024 * 0.33
const formSchema = object({ const formSchema = object({
username: string([ id: string(),
name: string([
toTrimmed(), toTrimmed(),
minBytes(1, 'Please enter your username.'), minBytes(1, 'Please enter your username.'),
maxBytes(20, 'Your username cannot exceed 20 bytes.') maxBytes(20, 'Your username cannot exceed 20 bytes.')
]), ]),
avatar: string([notLength(0, 'Please select your avatar.')]), avatar: string([notLength(0, 'Please select your avatar.'), maxBytes(8 * 1024, 'Your avatar cannot exceed 8kb.')]),
darkMode: boolean() darkMode: boolean()
}) })
const ProfileForm = () => { const ProfileForm = () => {
const send = useRemeshSend()
const userInfoDomain = useRemeshDomain(UserInfoDomain())
const userInfo = useRemeshQuery(userInfoDomain.query.UserInfoQuery())
const form = useForm({ const form = useForm({
resolver: valibotResolver(formSchema), resolver: valibotResolver(formSchema),
defaultValues: { defaultValues: userInfo ?? {
username: '', id: nanoid(),
name: '',
avatar: '', avatar: '',
darkMode: window.matchMedia('(prefers-color-scheme: dark)').matches darkMode: window.matchMedia('(prefers-color-scheme: dark)').matches
} }
}) })
const handleSubmit = (data: Output<typeof formSchema>) => { useEffect(() => {
console.log(data) userInfo && form.reset(userInfo)
console.log(data.avatar.length * 0.001) }, [userInfo, form])
const handleSubmit = (userInfo: Output<typeof formSchema>) => {
send(userInfoDomain.command.SetUserInfoCommand(userInfo))
toast.success('Saved successfully!') toast.success('Saved successfully!')
} }
@ -53,6 +70,7 @@ const ProfileForm = () => {
<FormItem className="absolute left-1/2 top-0 grid -translate-x-1/2 -translate-y-1/2 justify-items-center"> <FormItem className="absolute left-1/2 top-0 grid -translate-x-1/2 -translate-y-1/2 justify-items-center">
<FormControl> <FormControl>
<AvatarSelect <AvatarSelect
compressSize={COMPRESS_SIZE}
onError={handleError} onError={handleError}
onWarning={handleWarning} onWarning={handleWarning}
className="shadow-lg" className="shadow-lg"
@ -65,7 +83,7 @@ const ProfileForm = () => {
/> />
<FormField <FormField
control={form.control} control={form.control}
name="username" name="name"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Username</FormLabel> <FormLabel>Username</FormLabel>

View file

@ -1,8 +1,6 @@
import { Remesh } from 'remesh' import { Remesh } from 'remesh'
import InputModule from './modules/Input' import InputModule from './modules/Input'
export const MESSAGE_INPUT_STORAGE_KEY = 'MESSAGE_INPUT'
const MessageInputDomain = Remesh.domain({ const MessageInputDomain = Remesh.domain({
name: 'MessageInputDomain', name: 'MessageInputDomain',
impl: (domain) => { impl: (domain) => {

View file

@ -3,13 +3,12 @@ import { ListModule } from 'remesh/modules/list'
import { nanoid } from 'nanoid' import { nanoid } from 'nanoid'
import { from, map, tap, merge } from 'rxjs' import { from, map, tap, merge } from 'rxjs'
import Storage from './externs/Storage' import Storage from './externs/Storage'
import { type Message } from '@/types/global'
const MessageListDomain = Remesh.domain({ const MessageListDomain = Remesh.domain({
name: 'MessageListDomain', name: 'MessageListDomain',
impl: (domain) => { impl: (domain) => {
const storage = domain.getExtern(Storage) const storage = domain.getExtern(Storage)
const storageKey = `MESSAGE_LIST` const storageKey = `MESSAGE_LIST` as const
const MessageListModule = ListModule<Message>(domain, { const MessageListModule = ListModule<Message>(domain, {
name: 'MessageListModule', name: 'MessageListModule',
@ -93,10 +92,10 @@ const MessageListDomain = Remesh.domain({
domain.effect({ domain.effect({
name: 'FormStateToStorageEffect', name: 'FormStateToStorageEffect',
impl: ({ fromEvent }) => { impl: ({ fromEvent }) => {
const createItem$ = fromEvent(ChangeListEvent).pipe( const changeList$ = fromEvent(ChangeListEvent).pipe(
tap(async (messages) => await storage.set<Message[]>(storageKey, messages)) tap(async (messages) => await storage.set<Message[]>(storageKey, messages))
) )
return merge(createItem$).pipe(map(() => null)) return merge(changeList$).pipe(map(() => null))
} }
}) })

94
src/domain/UserInfo.ts Normal file
View file

@ -0,0 +1,94 @@
import { Remesh } from 'remesh'
import { forkJoin, from, map, merge, tap } from 'rxjs'
import Storage from './externs/Storage'
const UserInfoDomain = Remesh.domain({
name: 'UserInfoDomain',
impl: (domain) => {
const storage = domain.getExtern(Storage)
const storageKeys = {
USER_INFO_ID: 'USER_INFO_ID',
USER_INFO_NAME: 'USER_INFO_NAME',
USER_INFO_AVATAR: 'USER_INFO_AVATAR',
USER_INFO_DARK_MODE: 'USER_INFO_DARK_MODE'
} as const
const UserInfoState = domain.state<UserInfo | null>({
name: 'UserInfo.UserInfoState',
default: null
})
const UserInfoQuery = domain.query({
name: 'UserInfo.UserInfoQuery',
impl: ({ get }) => {
return get(UserInfoState())
}
})
const SetUserInfoCommand = domain.command({
name: 'UserInfo.SetUserInfoCommand',
impl: (_, userInfo: UserInfo | null) => {
return [UserInfoState().new(userInfo), ChangeUserInfoEvent()]
}
})
const ChangeUserInfoEvent = domain.event({
name: 'UserInfo.ChangeUserInfoEvent',
impl: ({ get }) => {
return get(UserInfoQuery())
}
})
domain.effect({
name: 'FormStorageToStateEffect',
impl: () => {
return forkJoin({
id: from(storage.get<UserInfo['id']>(storageKeys.USER_INFO_ID)),
name: from(storage.get<UserInfo['name']>(storageKeys.USER_INFO_NAME)),
avatar: from(storage.get<UserInfo['avatar']>(storageKeys.USER_INFO_AVATAR)),
darkMode: from(storage.get<UserInfo['darkMode']>(storageKeys.USER_INFO_DARK_MODE))
}).pipe(
map((userInfo) => {
if (userInfo.id && userInfo.name && userInfo.avatar && userInfo.darkMode) {
return SetUserInfoCommand(userInfo as UserInfo)
} else {
return SetUserInfoCommand(null)
}
})
)
}
})
domain.effect({
name: 'FormStateToStorageEffect',
impl: ({ fromEvent }) => {
const changeUserInfo$ = fromEvent(ChangeUserInfoEvent).pipe(
tap(async (userInfo) => {
return await Promise.all([
storage.set<UserInfo['id'] | null>(storageKeys.USER_INFO_ID, userInfo?.id ?? null),
storage.set<UserInfo['name'] | null>(storageKeys.USER_INFO_NAME, userInfo?.name ?? null),
storage.set<UserInfo['avatar'] | null>(storageKeys.USER_INFO_AVATAR, userInfo?.avatar ?? null),
storage.set<UserInfo['darkMode'] | null>(storageKeys.USER_INFO_DARK_MODE, userInfo?.darkMode ?? null)
])
})
)
return merge(changeUserInfo$).pipe(map(() => null))
}
})
return {
query: {
UserInfoQuery
},
command: {
SetUserInfoCommand
},
event: {
ChangeUserInfoEvent
}
}
}
})
export default UserInfoDomain

View file

@ -1,4 +1,4 @@
export interface Message { declare interface Message {
id: string id: string
userId: string userId: string
body: string body: string
@ -12,3 +12,10 @@ export interface Message {
hateUsers: string[] hateUsers: string[]
hateCount: number hateCount: number
} }
declare interface UserInfo {
id: string
name: string
avatar: string
darkMode: boolean
}

View file

@ -1,6 +1,6 @@
const compress = async ( const compress = async (
imageBitmap: ImageBitmap, imageBitmap: ImageBitmap,
size: number, targetSize: number,
low: number, low: number,
high: number, high: number,
bestBlob: Blob bestBlob: Blob
@ -22,21 +22,21 @@ const compress = async (
// Calculate the current size based on the current quality // Calculate the current size based on the current quality
const currentSize = outputBlob.size const currentSize = outputBlob.size
// If the current size is close to the target size, update the bestResult // If the current size is close to the target size, update the bestBlob
if (Math.abs(currentSize - size) < Math.abs(bestBlob.size - size)) { if (currentSize <= targetSize && Math.abs(currentSize - targetSize) < Math.abs(bestBlob.size - targetSize)) {
bestBlob = outputBlob bestBlob = outputBlob
} }
// If the current size is close to the target size or the range of low and high is too small, return the result // If the current size is between -1024 ~ 0, return the result
if (Math.abs(currentSize - size) < 100 || high - low < 0.01) { if ((currentSize - targetSize <= 0 && currentSize - targetSize >= -1024) || high - low < 0.01) {
return bestBlob return bestBlob
} }
// Adjust the range for recursion based on the current quality and size // Adjust the range for recursion based on the current quality and size
if (currentSize > size) { if (currentSize > targetSize) {
return await compress(imageBitmap, size, low, mid, bestBlob) return await compress(imageBitmap, targetSize, low, mid, bestBlob)
} else { } else {
return await compress(imageBitmap, size, mid, high, bestBlob) return await compress(imageBitmap, targetSize, mid, high, bestBlob)
} }
} }

View file

@ -12,6 +12,9 @@ export default defineConfig({
runner: { runner: {
startUrls: ['https://www.example.com/'] startUrls: ['https://www.example.com/']
}, },
manifest: {
permissions: ['storage']
},
vite: () => ({ vite: () => ({
define: { define: {
__DEV__: isDev, __DEV__: isDev,