Merge branch 'develop'
This commit is contained in:
commit
62b96dcf10
21 changed files with 369 additions and 333 deletions
14
package.json
14
package.json
|
@ -65,11 +65,11 @@
|
|||
"@webext-core/proxy-service": "^1.2.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"danmaku": "^2.0.7",
|
||||
"danmu": "^0.12.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"framer-motion": "^11.11.1",
|
||||
"framer-motion": "^11.11.7",
|
||||
"idb-keyval": "^6.2.1",
|
||||
"lucide-react": "^0.449.0",
|
||||
"lucide-react": "^0.451.0",
|
||||
"nanoid": "^5.0.7",
|
||||
"next-themes": "^0.3.0",
|
||||
"react": "^18.3.1",
|
||||
|
@ -100,8 +100,8 @@
|
|||
"@semantic-release/exec": "^6.0.3",
|
||||
"@semantic-release/git": "^10.0.1",
|
||||
"@types/eslint": "^9.6.1",
|
||||
"@types/eslint-plugin-tailwindcss": "^3.17.0",
|
||||
"@types/eslint__js": "^8.42.3",
|
||||
"@types/eslint-plugin-tailwindcss": "^3.17.0",
|
||||
"@types/node": "^22.7.5",
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
|
@ -112,8 +112,8 @@
|
|||
"eslint": "^9.12.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"eslint-plugin-tailwindcss": "^3.17.4",
|
||||
"globals": "^15.10.0",
|
||||
"eslint-plugin-tailwindcss": "^3.17.5",
|
||||
"globals": "^15.11.0",
|
||||
"husky": "^9.1.6",
|
||||
"jiti": "^2.3.3",
|
||||
"lint-staged": "^15.2.10",
|
||||
|
@ -125,7 +125,7 @@
|
|||
"semantic-release": "^24.1.2",
|
||||
"tailwindcss": "^3.4.13",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "^5.6.2",
|
||||
"typescript": "^5.6.3",
|
||||
"typescript-eslint": "^8.8.1",
|
||||
"vite-plugin-svgr": "^4.2.0",
|
||||
"webext-bridge": "^6.0.1",
|
||||
|
|
485
pnpm-lock.yaml
485
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
|
@ -9,7 +9,7 @@ export default defineBackground({
|
|||
|
||||
main() {
|
||||
browser.runtime.onMessage.addListener(async (event: EVENT) => {
|
||||
if (event === EVENT.OPEN_OPTIONS_PAGE) {
|
||||
if (event === EVENT.OPTIONS_PAGE_OPEN) {
|
||||
browser.runtime.openOptionsPage()
|
||||
}
|
||||
})
|
||||
|
|
|
@ -52,7 +52,7 @@ export default function App() {
|
|||
useEffect(() => {
|
||||
danmakuIsEnabled && send(danmakuDomain.command.MountCommand(danmakuContainerRef.current!))
|
||||
return () => {
|
||||
danmakuIsEnabled && send(danmakuDomain.command.DestroyCommand())
|
||||
danmakuIsEnabled && send(danmakuDomain.command.UnmountCommand())
|
||||
}
|
||||
}, [danmakuIsEnabled])
|
||||
|
||||
|
|
|
@ -8,7 +8,7 @@ export interface DanmakuContainerProps {
|
|||
const DanmakuContainer = forwardRef<HTMLDivElement, DanmakuContainerProps>(({ className }, ref) => {
|
||||
return (
|
||||
<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}
|
||||
></div>
|
||||
)
|
||||
|
|
|
@ -3,23 +3,29 @@ import { Button } from '@/components/ui/Button'
|
|||
import { TextMessage } from '@/domain/Room'
|
||||
import { cn } from '@/utils'
|
||||
import { AvatarImage } from '@radix-ui/react-avatar'
|
||||
import { FC } from 'react'
|
||||
import { FC, MouseEvent } from 'react'
|
||||
|
||||
export interface PromptItemProps {
|
||||
data: TextMessage
|
||||
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 (
|
||||
<Button
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
onClick={onClick}
|
||||
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',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
</Avatar>
|
||||
<div className="max-w-40 overflow-hidden text-ellipsis">{data.body}</div>
|
||||
|
|
|
@ -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)}
|
||||
>
|
||||
<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>
|
||||
</Avatar>
|
||||
<div className="overflow-hidden">
|
||||
|
|
|
@ -13,9 +13,9 @@ export interface PromptItemProps {
|
|||
const PromptItem: FC<PromptItemProps> = ({ data, className }) => {
|
||||
return (
|
||||
<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">
|
||||
<AvatarImage src={data.userAvatar} alt="avatar" />
|
||||
<AvatarImage src={data.userAvatar} className="size-full" alt="avatar" />
|
||||
<AvatarFallback>{data.username.at(0)}</AvatarFallback>
|
||||
</Avatar>
|
||||
{data.body}
|
||||
|
|
|
@ -56,7 +56,7 @@ const AppButton: FC = () => {
|
|||
}
|
||||
|
||||
const handleOpenOptionsPage = () => {
|
||||
browser.runtime.sendMessage(EVENT.OPEN_OPTIONS_PAGE)
|
||||
browser.runtime.sendMessage(EVENT.OPTIONS_PAGE_OPEN)
|
||||
}
|
||||
|
||||
const handleToggleApp = () => {
|
||||
|
|
|
@ -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">
|
||||
<BlurFade key={userInfo?.avatar} inView>
|
||||
<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>
|
||||
<UserIcon size={30} className="text-slate-400" />
|
||||
</AvatarFallback>
|
||||
|
|
|
@ -58,7 +58,7 @@ const AvatarSelect = React.forwardRef<HTMLInputElement, AvatarSelectProps>(
|
|||
className
|
||||
)}
|
||||
>
|
||||
<AvatarImage src={value} alt="avatar" />
|
||||
<AvatarImage src={value} className="size-full" alt="avatar" />
|
||||
<AvatarFallback>
|
||||
<ImagePlusIcon size={30} className="text-slate-400 group-hover:text-slate-500" />
|
||||
</AvatarFallback>
|
||||
|
|
|
@ -92,13 +92,18 @@ const ProfileForm = () => {
|
|||
|
||||
return (
|
||||
<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
|
||||
control={form.control}
|
||||
name="avatar"
|
||||
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>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<BlurFade key={form.getValues().avatar} duration={0.1}>
|
||||
<AvatarSelect
|
||||
compressSize={MAX_AVATAR_SIZE}
|
||||
|
@ -108,15 +113,22 @@ const ProfileForm = () => {
|
|||
{...field}
|
||||
></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>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button type="button" size="xs" className="mx-auto flex items-center gap-x-2" onClick={handleRefreshAvatar}>
|
||||
<RefreshCcwIcon size={14} />
|
||||
Ugly Avatar
|
||||
</Button>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
|
|
|
@ -11,7 +11,8 @@ const buttonVariants = cva(
|
|||
variant: {
|
||||
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/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',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline'
|
||||
|
|
|
@ -8,7 +8,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(({ classNa
|
|||
return (
|
||||
<textarea
|
||||
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
|
||||
)}
|
||||
ref={ref}
|
||||
|
|
|
@ -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 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
|
||||
* Image is encoded as base64, and the size is increased by about 33%.
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
export enum EVENT {
|
||||
OPEN_OPTIONS_PAGE = 'OPEN_OPTIONS_PAGE'
|
||||
OPTIONS_PAGE_OPEN = `WEB_CHAT_OPTIONS_PAGE_OPEN`,
|
||||
APP_OPEN = 'WEB_CHAT_APP_OPEN'
|
||||
}
|
||||
|
|
|
@ -77,11 +77,11 @@ const DanmakuDomain = Remesh.domain({
|
|||
}
|
||||
})
|
||||
|
||||
const DestroyCommand = domain.command({
|
||||
name: 'Danmaku.DestroyCommand',
|
||||
const UnmountCommand = domain.command({
|
||||
name: 'Danmaku.UnmountCommand',
|
||||
impl: () => {
|
||||
danmaku.destroy()
|
||||
return [DestroyEvent()]
|
||||
danmaku.unmount()
|
||||
return [UnmountEvent()]
|
||||
}
|
||||
})
|
||||
|
||||
|
@ -101,8 +101,8 @@ const DanmakuDomain = Remesh.domain({
|
|||
name: 'Danmaku.MountEvent'
|
||||
})
|
||||
|
||||
const DestroyEvent = domain.event({
|
||||
name: 'Danmaku.DestroyEvent'
|
||||
const UnmountEvent = domain.event({
|
||||
name: 'Danmaku.UnmountEvent'
|
||||
})
|
||||
|
||||
domain.effect({
|
||||
|
@ -129,14 +129,14 @@ const DanmakuDomain = Remesh.domain({
|
|||
UnshiftCommand,
|
||||
ClearCommand,
|
||||
MountCommand,
|
||||
DestroyCommand
|
||||
UnmountCommand
|
||||
},
|
||||
event: {
|
||||
PushEvent,
|
||||
UnshiftEvent,
|
||||
ClearEvent,
|
||||
MountEvent,
|
||||
DestroyEvent
|
||||
UnmountEvent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,7 +6,7 @@ export interface Danmaku {
|
|||
unshift: (message: TextMessage) => void
|
||||
clear: () => void
|
||||
mount: (root: HTMLElement) => void
|
||||
destroy: () => void
|
||||
unmount: () => void
|
||||
}
|
||||
|
||||
export const DanmakuExtern = Remesh.extern<Danmaku>({
|
||||
|
@ -14,8 +14,8 @@ export const DanmakuExtern = Remesh.extern<Danmaku>({
|
|||
mount: () => {
|
||||
throw new Error('"mount" not implemented.')
|
||||
},
|
||||
destroy() {
|
||||
throw new Error('"destroy" not implemented.')
|
||||
unmount() {
|
||||
throw new Error('"unmount" not implemented.')
|
||||
},
|
||||
clear: () => {
|
||||
throw new Error('"clear" not implemented.')
|
||||
|
|
|
@ -1,8 +1,7 @@
|
|||
import { Remesh } from 'remesh'
|
||||
|
||||
export type StorageValue = null | string | number | boolean | object
|
||||
export type WatchEvent = 'update' | 'remove'
|
||||
export type WatchCallback = (event: WatchEvent, key: string) => any
|
||||
export type WatchCallback = () => any
|
||||
export type Unwatch = () => Promise<void>
|
||||
|
||||
export interface Storage {
|
||||
|
|
|
@ -2,78 +2,72 @@ import { DanmakuExtern } from '@/domain/externs/Danmaku'
|
|||
|
||||
import { TextMessage } from '@/domain/Room'
|
||||
import { createElement } from 'react'
|
||||
import _Danmaku from 'danmaku'
|
||||
import DanmakuMessage from '@/app/content/components/DanmakuMessage'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
|
||||
// import { create } from 'danmaku'
|
||||
// const manager = create<TextMessage>({
|
||||
// trackHeight: '20%',
|
||||
// plugin: {
|
||||
// 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()
|
||||
import { create, Manager } from 'danmu'
|
||||
import { LocalStorageImpl } from './Storage'
|
||||
import { AppStatus } from '../AppStatus'
|
||||
import { APP_STATUS_STORAGE_KEY } from '@/constants/config'
|
||||
import { EVENT } from '@/constants/event'
|
||||
|
||||
export class Danmaku {
|
||||
private container?: Element
|
||||
private _danmaku?: _Danmaku
|
||||
|
||||
mount(container: HTMLElement) {
|
||||
this.container = container
|
||||
|
||||
this._danmaku = new _Danmaku({
|
||||
container
|
||||
private manager?: Manager<TextMessage>
|
||||
constructor() {
|
||||
this.manager = create<TextMessage>({
|
||||
durationRange: [10000, 13000],
|
||||
plugin: {
|
||||
$createNode(manager) {
|
||||
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) {
|
||||
throw new Error('Danmaku not mounted')
|
||||
}
|
||||
this._danmaku!.destroy()
|
||||
this.manager!.unmount()
|
||||
}
|
||||
|
||||
push(message: TextMessage) {
|
||||
if (!this.container) {
|
||||
throw new Error('Danmaku not mounted')
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
})
|
||||
})
|
||||
this.manager!.push(message)
|
||||
}
|
||||
|
||||
unshift(message: TextMessage) {
|
||||
if (!this.container) {
|
||||
throw new Error('Danmaku not mounted')
|
||||
}
|
||||
// console.log(message)
|
||||
this.manager!.unshift(message)
|
||||
}
|
||||
|
||||
clear() {
|
||||
if (!this.container) {
|
||||
throw new Error('Danmaku not mounted')
|
||||
}
|
||||
this._danmaku!.clear()
|
||||
this.manager!.clear()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -6,6 +6,7 @@ import { STORAGE_NAME } from '@/constants/config'
|
|||
import { webExtensionDriver } from '@/utils/webExtensionDriver'
|
||||
import { browser } from 'wxt/browser'
|
||||
import { Storage } from '@/domain/externs/Storage'
|
||||
import { EVENT } from '@/constants/event'
|
||||
|
||||
export const localStorage = createStorage({
|
||||
driver: localStorageDriver({ base: `${STORAGE_NAME}:` })
|
||||
|
@ -25,7 +26,20 @@ export const LocalStorageImpl = LocalStorageExtern.impl({
|
|||
set: localStorage.setItem,
|
||||
remove: localStorage.removeItem,
|
||||
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
|
||||
})
|
||||
|
||||
|
|
Loading…
Reference in a new issue