Merge branch 'develop'

This commit is contained in:
molvqingtai 2024-10-10 16:45:46 +08:00
commit 62b96dcf10
21 changed files with 369 additions and 333 deletions

View file

@ -65,11 +65,11 @@
"@webext-core/proxy-service": "^1.2.0", "@webext-core/proxy-service": "^1.2.0",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"danmaku": "^2.0.7", "danmu": "^0.12.0",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"framer-motion": "^11.11.1", "framer-motion": "^11.11.7",
"idb-keyval": "^6.2.1", "idb-keyval": "^6.2.1",
"lucide-react": "^0.449.0", "lucide-react": "^0.451.0",
"nanoid": "^5.0.7", "nanoid": "^5.0.7",
"next-themes": "^0.3.0", "next-themes": "^0.3.0",
"react": "^18.3.1", "react": "^18.3.1",
@ -100,8 +100,8 @@
"@semantic-release/exec": "^6.0.3", "@semantic-release/exec": "^6.0.3",
"@semantic-release/git": "^10.0.1", "@semantic-release/git": "^10.0.1",
"@types/eslint": "^9.6.1", "@types/eslint": "^9.6.1",
"@types/eslint-plugin-tailwindcss": "^3.17.0",
"@types/eslint__js": "^8.42.3", "@types/eslint__js": "^8.42.3",
"@types/eslint-plugin-tailwindcss": "^3.17.0",
"@types/node": "^22.7.5", "@types/node": "^22.7.5",
"@types/react": "^18.3.11", "@types/react": "^18.3.11",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
@ -112,8 +112,8 @@
"eslint": "^9.12.0", "eslint": "^9.12.0",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1", "eslint-plugin-prettier": "^5.2.1",
"eslint-plugin-tailwindcss": "^3.17.4", "eslint-plugin-tailwindcss": "^3.17.5",
"globals": "^15.10.0", "globals": "^15.11.0",
"husky": "^9.1.6", "husky": "^9.1.6",
"jiti": "^2.3.3", "jiti": "^2.3.3",
"lint-staged": "^15.2.10", "lint-staged": "^15.2.10",
@ -125,7 +125,7 @@
"semantic-release": "^24.1.2", "semantic-release": "^24.1.2",
"tailwindcss": "^3.4.13", "tailwindcss": "^3.4.13",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"typescript": "^5.6.2", "typescript": "^5.6.3",
"typescript-eslint": "^8.8.1", "typescript-eslint": "^8.8.1",
"vite-plugin-svgr": "^4.2.0", "vite-plugin-svgr": "^4.2.0",
"webext-bridge": "^6.0.1", "webext-bridge": "^6.0.1",

File diff suppressed because it is too large Load diff

View file

@ -9,7 +9,7 @@ export default defineBackground({
main() { main() {
browser.runtime.onMessage.addListener(async (event: EVENT) => { browser.runtime.onMessage.addListener(async (event: EVENT) => {
if (event === EVENT.OPEN_OPTIONS_PAGE) { if (event === EVENT.OPTIONS_PAGE_OPEN) {
browser.runtime.openOptionsPage() browser.runtime.openOptionsPage()
} }
}) })

View file

@ -52,7 +52,7 @@ export default function App() {
useEffect(() => { useEffect(() => {
danmakuIsEnabled && send(danmakuDomain.command.MountCommand(danmakuContainerRef.current!)) danmakuIsEnabled && send(danmakuDomain.command.MountCommand(danmakuContainerRef.current!))
return () => { return () => {
danmakuIsEnabled && send(danmakuDomain.command.DestroyCommand()) danmakuIsEnabled && send(danmakuDomain.command.UnmountCommand())
} }
}, [danmakuIsEnabled]) }, [danmakuIsEnabled])

View file

@ -8,7 +8,7 @@ export interface DanmakuContainerProps {
const DanmakuContainer = forwardRef<HTMLDivElement, DanmakuContainerProps>(({ className }, ref) => { const DanmakuContainer = forwardRef<HTMLDivElement, DanmakuContainerProps>(({ className }, ref) => {
return ( return (
<div <div
className={cn('fixed left-0 top-20 z-infinity w-full h-full invisible pointer-events-none shadow-md', className)} className={cn('fixed left-0 top-0 z-infinity w-full h-full invisible pointer-events-none shadow-md', className)}
ref={ref} ref={ref}
></div> ></div>
) )

View file

@ -3,23 +3,29 @@ import { Button } from '@/components/ui/Button'
import { TextMessage } from '@/domain/Room' import { TextMessage } from '@/domain/Room'
import { cn } from '@/utils' import { cn } from '@/utils'
import { AvatarImage } from '@radix-ui/react-avatar' import { AvatarImage } from '@radix-ui/react-avatar'
import { FC } from 'react' import { FC, MouseEvent } from 'react'
export interface PromptItemProps { export interface PromptItemProps {
data: TextMessage data: TextMessage
className?: string className?: string
onClick?: (e: MouseEvent<HTMLButtonElement>) => void
onMouseEnter?: (e: MouseEvent<HTMLButtonElement>) => void
onMouseLeave?: (e: MouseEvent<HTMLButtonElement>) => void
} }
const DanmakuMessage: FC<PromptItemProps> = ({ data, className }) => { const DanmakuMessage: FC<PromptItemProps> = ({ data, className, onClick, onMouseEnter, onMouseLeave }) => {
return ( return (
<Button <Button
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onClick={onClick}
className={cn( className={cn(
'flex justify-center pointer-events-auto visible gap-x-2 border px-2.5 py-0.5 rounded-full bg-primary/30 text-base font-medium text-white backdrop-blur-md', 'flex justify-center pointer-events-auto visible gap-x-2 border px-2.5 py-0.5 rounded-full bg-primary/30 text-base font-medium text-white backdrop-blur-md',
className className
)} )}
> >
<Avatar className="size-5"> <Avatar className="size-5">
<AvatarImage src={data.userAvatar} alt="avatar" /> <AvatarImage src={data.userAvatar} className="size-full" alt="avatar" />
<AvatarFallback>{data.username.at(0)}</AvatarFallback> <AvatarFallback>{data.username.at(0)}</AvatarFallback>
</Avatar> </Avatar>
<div className="max-w-40 overflow-hidden text-ellipsis">{data.body}</div> <div className="max-w-40 overflow-hidden text-ellipsis">{data.body}</div>

View file

@ -32,7 +32,7 @@ const MessageItem: FC<MessageItemProps> = (props) => {
className={cn('box-border grid grid-cols-[auto_1fr] gap-x-2 px-4 first:pt-4 last:pb-4', props.className)} className={cn('box-border grid grid-cols-[auto_1fr] gap-x-2 px-4 first:pt-4 last:pb-4', props.className)}
> >
<Avatar> <Avatar>
<AvatarImage src={props.data.userAvatar} alt="avatar" /> <AvatarImage src={props.data.userAvatar} className="size-full" alt="avatar" />
<AvatarFallback>{props.data.username.at(0)}</AvatarFallback> <AvatarFallback>{props.data.username.at(0)}</AvatarFallback>
</Avatar> </Avatar>
<div className="overflow-hidden"> <div className="overflow-hidden">

View file

@ -13,9 +13,9 @@ export interface PromptItemProps {
const PromptItem: FC<PromptItemProps> = ({ data, className }) => { const PromptItem: FC<PromptItemProps> = ({ data, className }) => {
return ( return (
<div className={cn('flex justify-center py-1 px-4', className)}> <div className={cn('flex justify-center py-1 px-4', className)}>
<Badge variant="secondary" className="gap-x-2 rounded-full font-medium text-slate-400"> <Badge variant="secondary" className="gap-x-2 rounded-full px-2 font-medium text-slate-400">
<Avatar className="size-4"> <Avatar className="size-4">
<AvatarImage src={data.userAvatar} alt="avatar" /> <AvatarImage src={data.userAvatar} className="size-full" alt="avatar" />
<AvatarFallback>{data.username.at(0)}</AvatarFallback> <AvatarFallback>{data.username.at(0)}</AvatarFallback>
</Avatar> </Avatar>
{data.body} {data.body}

View file

@ -56,7 +56,7 @@ const AppButton: FC = () => {
} }
const handleOpenOptionsPage = () => { const handleOpenOptionsPage = () => {
browser.runtime.sendMessage(EVENT.OPEN_OPTIONS_PAGE) browser.runtime.sendMessage(EVENT.OPTIONS_PAGE_OPEN)
} }
const handleToggleApp = () => { const handleToggleApp = () => {

View file

@ -104,7 +104,7 @@ const Setup: FC = () => {
<div className="m-auto flex flex-col items-center justify-center gap-y-8 pb-40 drop-shadow-lg"> <div className="m-auto flex flex-col items-center justify-center gap-y-8 pb-40 drop-shadow-lg">
<BlurFade key={userInfo?.avatar} inView> <BlurFade key={userInfo?.avatar} inView>
<Avatar className="size-24 cursor-pointer border-4 border-white "> <Avatar className="size-24 cursor-pointer border-4 border-white ">
<AvatarImage src={userInfo?.avatar} alt="avatar" /> <AvatarImage src={userInfo?.avatar} className="size-full" alt="avatar" />
<AvatarFallback> <AvatarFallback>
<UserIcon size={30} className="text-slate-400" /> <UserIcon size={30} className="text-slate-400" />
</AvatarFallback> </AvatarFallback>

View file

@ -58,7 +58,7 @@ const AvatarSelect = React.forwardRef<HTMLInputElement, AvatarSelectProps>(
className className
)} )}
> >
<AvatarImage src={value} alt="avatar" /> <AvatarImage src={value} className="size-full" alt="avatar" />
<AvatarFallback> <AvatarFallback>
<ImagePlusIcon size={30} className="text-slate-400 group-hover:text-slate-500" /> <ImagePlusIcon size={30} className="text-slate-400 group-hover:text-slate-500" />
</AvatarFallback> </AvatarFallback>

View file

@ -92,31 +92,43 @@ const ProfileForm = () => {
return ( return (
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} autoComplete="off" className="relative w-[450px] space-y-8 p-14"> <form
onSubmit={form.handleSubmit(handleSubmit)}
autoComplete="off"
className="relative w-[450px] space-y-8 p-14 pt-20"
>
<FormField <FormField
control={form.control} control={form.control}
name="avatar" name="avatar"
render={({ field }) => ( render={({ field }) => (
<FormItem className="absolute inset-x-1 top-0 mx-auto grid w-fit -translate-y-1/2 justify-items-center"> <FormItem className="absolute inset-x-1 top-0 mx-auto grid w-fit -translate-y-1/3 justify-items-center">
<FormControl> <FormControl>
<BlurFade key={form.getValues().avatar} duration={0.1}> <div className="flex flex-col items-center gap-2">
<AvatarSelect <BlurFade key={form.getValues().avatar} duration={0.1}>
compressSize={MAX_AVATAR_SIZE} <AvatarSelect
onError={handleError} compressSize={MAX_AVATAR_SIZE}
onWarning={handleWarning} onError={handleError}
className="shadow-lg" onWarning={handleWarning}
{...field} className="shadow-lg"
></AvatarSelect> {...field}
</BlurFade> ></AvatarSelect>
</BlurFade>
<Button
type="button"
size="xs"
className="mx-auto flex items-center gap-x-2"
onClick={handleRefreshAvatar}
>
<RefreshCcwIcon size={14} />
Ugly Avatar
</Button>
</div>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<Button type="button" size="xs" className="mx-auto flex items-center gap-x-2" onClick={handleRefreshAvatar}>
<RefreshCcwIcon size={14} />
Ugly Avatar
</Button>
<FormField <FormField
control={form.control} control={form.control}
name="name" name="name"

View file

@ -11,7 +11,8 @@ const buttonVariants = cva(
variant: { variant: {
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90', default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90', destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
outline: 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground', outline:
'border border-input text-primary bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80', secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground', ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline' link: 'text-primary underline-offset-4 hover:underline'

View file

@ -8,7 +8,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(({ classNa
return ( return (
<textarea <textarea
className={cn( className={cn(
'flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50', 'flex min-h-[60px] w-full rounded-md border border-input text-primary bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
className className
)} )}
ref={ref} ref={ref}

View file

@ -191,7 +191,7 @@ export const MESSAGE_LIST_STORAGE_KEY = 'WEB_CHAT_MESSAGE_LIST' as const
export const USER_INFO_STORAGE_KEY = 'WEB_CHAT_USER_INFO' as const export const USER_INFO_STORAGE_KEY = 'WEB_CHAT_USER_INFO' as const
export const APP_STATUS_STORAGE_KEY = 'WEB_CHAT_APP_OPEN_STATUS' as const export const APP_STATUS_STORAGE_KEY = 'WEB_CHAT_APP_STATUS' as const
/** /**
* In chrome storage.sync, each key-value pair supports a maximum storage of 8kb * 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%. * Image is encoded as base64, and the size is increased by about 33%.

View file

@ -1,3 +1,4 @@
export enum EVENT { export enum EVENT {
OPEN_OPTIONS_PAGE = 'OPEN_OPTIONS_PAGE' OPTIONS_PAGE_OPEN = `WEB_CHAT_OPTIONS_PAGE_OPEN`,
APP_OPEN = 'WEB_CHAT_APP_OPEN'
} }

View file

@ -77,11 +77,11 @@ const DanmakuDomain = Remesh.domain({
} }
}) })
const DestroyCommand = domain.command({ const UnmountCommand = domain.command({
name: 'Danmaku.DestroyCommand', name: 'Danmaku.UnmountCommand',
impl: () => { impl: () => {
danmaku.destroy() danmaku.unmount()
return [DestroyEvent()] return [UnmountEvent()]
} }
}) })
@ -101,8 +101,8 @@ const DanmakuDomain = Remesh.domain({
name: 'Danmaku.MountEvent' name: 'Danmaku.MountEvent'
}) })
const DestroyEvent = domain.event({ const UnmountEvent = domain.event({
name: 'Danmaku.DestroyEvent' name: 'Danmaku.UnmountEvent'
}) })
domain.effect({ domain.effect({
@ -129,14 +129,14 @@ const DanmakuDomain = Remesh.domain({
UnshiftCommand, UnshiftCommand,
ClearCommand, ClearCommand,
MountCommand, MountCommand,
DestroyCommand UnmountCommand
}, },
event: { event: {
PushEvent, PushEvent,
UnshiftEvent, UnshiftEvent,
ClearEvent, ClearEvent,
MountEvent, MountEvent,
DestroyEvent UnmountEvent
} }
} }
} }

View file

@ -6,7 +6,7 @@ export interface Danmaku {
unshift: (message: TextMessage) => void unshift: (message: TextMessage) => void
clear: () => void clear: () => void
mount: (root: HTMLElement) => void mount: (root: HTMLElement) => void
destroy: () => void unmount: () => void
} }
export const DanmakuExtern = Remesh.extern<Danmaku>({ export const DanmakuExtern = Remesh.extern<Danmaku>({
@ -14,8 +14,8 @@ export const DanmakuExtern = Remesh.extern<Danmaku>({
mount: () => { mount: () => {
throw new Error('"mount" not implemented.') throw new Error('"mount" not implemented.')
}, },
destroy() { unmount() {
throw new Error('"destroy" not implemented.') throw new Error('"unmount" not implemented.')
}, },
clear: () => { clear: () => {
throw new Error('"clear" not implemented.') throw new Error('"clear" not implemented.')

View file

@ -1,8 +1,7 @@
import { Remesh } from 'remesh' import { Remesh } from 'remesh'
export type StorageValue = null | string | number | boolean | object export type StorageValue = null | string | number | boolean | object
export type WatchEvent = 'update' | 'remove' export type WatchCallback = () => any
export type WatchCallback = (event: WatchEvent, key: string) => any
export type Unwatch = () => Promise<void> export type Unwatch = () => Promise<void>
export interface Storage { export interface Storage {

View file

@ -2,78 +2,72 @@ import { DanmakuExtern } from '@/domain/externs/Danmaku'
import { TextMessage } from '@/domain/Room' import { TextMessage } from '@/domain/Room'
import { createElement } from 'react' import { createElement } from 'react'
import _Danmaku from 'danmaku'
import DanmakuMessage from '@/app/content/components/DanmakuMessage' import DanmakuMessage from '@/app/content/components/DanmakuMessage'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import { create, Manager } from 'danmu'
// import { create } from 'danmaku' import { LocalStorageImpl } from './Storage'
// const manager = create<TextMessage>({ import { AppStatus } from '../AppStatus'
// trackHeight: '20%', import { APP_STATUS_STORAGE_KEY } from '@/constants/config'
// plugin: { import { EVENT } from '@/constants/event'
// init(manager) {
// 'shadow shadow-slate-200 bg-slate-100'.split(' ').forEach((c) => {
// manager.container.node.classList.add(c)
// })
// },
// $createNode(dm) {
// if (!dm.node) return
// createRoot(dm.node).render(createElement(DanmakuMessage, { data: dm.data }))
// }
// }
// })
// manager.mount(document.body)
// manager.startPlaying()
export class Danmaku { export class Danmaku {
private container?: Element private container?: Element
private _danmaku?: _Danmaku private manager?: Manager<TextMessage>
constructor() {
mount(container: HTMLElement) { this.manager = create<TextMessage>({
this.container = container durationRange: [10000, 13000],
plugin: {
this._danmaku = new _Danmaku({ $createNode(manager) {
container if (!manager.node) return
createRoot(manager.node).render(
createElement(DanmakuMessage, {
data: manager.data,
onClick: async () => {
const appStatus = await LocalStorageImpl.value.get<AppStatus>(APP_STATUS_STORAGE_KEY)
LocalStorageImpl.value.set<AppStatus>(APP_STATUS_STORAGE_KEY, { ...appStatus!, open: true, unread: 0 })
dispatchEvent(new CustomEvent(EVENT.APP_OPEN))
},
onMouseEnter: () => manager.pause(),
onMouseLeave: () => manager.resume()
})
)
}
}
}) })
} }
destroy() { mount(container: HTMLElement) {
this.container = container
this.manager!.mount(container)
this.manager!.startPlaying()
}
unmount() {
if (!this.container) { if (!this.container) {
throw new Error('Danmaku not mounted') throw new Error('Danmaku not mounted')
} }
this._danmaku!.destroy() this.manager!.unmount()
} }
push(message: TextMessage) { push(message: TextMessage) {
if (!this.container) { if (!this.container) {
throw new Error('Danmaku not mounted') throw new Error('Danmaku not mounted')
} }
this.manager!.push(message)
const root = document.createElement('div')
createRoot(root).render(createElement(DanmakuMessage, { data: message }))
// Wait for React render to complete
requestIdleCallback(() => {
this._danmaku!.emit({
render() {
return root.firstElementChild! as HTMLElement
}
})
})
} }
unshift(message: TextMessage) { unshift(message: TextMessage) {
if (!this.container) { if (!this.container) {
throw new Error('Danmaku not mounted') throw new Error('Danmaku not mounted')
} }
// console.log(message) this.manager!.unshift(message)
} }
clear() { clear() {
if (!this.container) { if (!this.container) {
throw new Error('Danmaku not mounted') throw new Error('Danmaku not mounted')
} }
this._danmaku!.clear() this.manager!.clear()
} }
} }

View file

@ -6,6 +6,7 @@ import { STORAGE_NAME } from '@/constants/config'
import { webExtensionDriver } from '@/utils/webExtensionDriver' import { webExtensionDriver } from '@/utils/webExtensionDriver'
import { browser } from 'wxt/browser' import { browser } from 'wxt/browser'
import { Storage } from '@/domain/externs/Storage' import { Storage } from '@/domain/externs/Storage'
import { EVENT } from '@/constants/event'
export const localStorage = createStorage({ export const localStorage = createStorage({
driver: localStorageDriver({ base: `${STORAGE_NAME}:` }) driver: localStorageDriver({ base: `${STORAGE_NAME}:` })
@ -25,7 +26,20 @@ export const LocalStorageImpl = LocalStorageExtern.impl({
set: localStorage.setItem, set: localStorage.setItem,
remove: localStorage.removeItem, remove: localStorage.removeItem,
clear: localStorage.clear, clear: localStorage.clear,
watch: localStorage.watch as Storage['watch'], watch: async (callback) => {
const unwatch = await localStorage.watch(callback)
/**
* Because the storage event cannot be triggered in the same browsing context
* it is necessary to listen for click events from DanmukuMessage.
* @see https://developer.mozilla.org/en-US/docs/Web/API/Window/storage_event
*/
addEventListener(EVENT.APP_OPEN, callback)
return async () => {
removeEventListener(EVENT.APP_OPEN, callback)
return unwatch()
}
},
unwatch: localStorage.unwatch unwatch: localStorage.unwatch
}) })