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",
"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",

File diff suppressed because it is too large Load diff

View file

@ -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()
}
})

View file

@ -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])

View file

@ -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>
)

View file

@ -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>

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)}
>
<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">

View file

@ -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}

View file

@ -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 = () => {

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">
<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>

View file

@ -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>

View file

@ -92,31 +92,43 @@ 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>
<BlurFade key={form.getValues().avatar} duration={0.1}>
<AvatarSelect
compressSize={MAX_AVATAR_SIZE}
onError={handleError}
onWarning={handleWarning}
className="shadow-lg"
{...field}
></AvatarSelect>
</BlurFade>
<div className="flex flex-col items-center gap-2">
<BlurFade key={form.getValues().avatar} duration={0.1}>
<AvatarSelect
compressSize={MAX_AVATAR_SIZE}
onError={handleError}
onWarning={handleWarning}
className="shadow-lg"
{...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"

View file

@ -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'

View file

@ -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}

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 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%.

View file

@ -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'
}

View file

@ -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
}
}
}

View file

@ -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.')

View file

@ -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 {

View file

@ -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()
}
}

View file

@ -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
})