Merge branch 'develop'

This commit is contained in:
molvqingtai
2025-10-01 22:47:26 +08:00
32 changed files with 483 additions and 350 deletions

View File

@@ -61,19 +61,20 @@
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@resreq/event-hub": "^1.6.0",
"@resreq/timer": "^1.3.2",
"@rtco/client": "^0.3.6",
"@tailwindcss/typography": "^0.5.19",
"@webcomponents/custom-elements": "^1.6.0",
"@webext-core/messaging": "^2.3.0",
"@webext-core/proxy-service": "^1.2.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cobe": "^0.6.5",
"comctx": "^1.4.3",
"danmu": "^0.18.1",
"date-fns": "^4.1.0",
"framer-motion": "^12.23.22",
"hash-it": "^6.0.0",
"idb-keyval": "^6.2.2",
"imgcap": "^1.0.2",
"lucide-react": "^0.544.0",
"motion": "^12.23.22",
"nanoid": "^5.1.6",

37
pnpm-lock.yaml generated
View File

@@ -59,9 +59,6 @@ importers:
'@resreq/event-hub':
specifier: ^1.6.0
version: 1.6.0
'@resreq/timer':
specifier: ^1.3.2
version: 1.3.2
'@rtco/client':
specifier: ^0.3.6
version: 0.3.6
@@ -71,9 +68,6 @@ importers:
'@webcomponents/custom-elements':
specifier: ^1.6.0
version: 1.6.0
'@webext-core/messaging':
specifier: ^2.3.0
version: 2.3.0
'@webext-core/proxy-service':
specifier: ^1.2.1
version: 1.2.1(@webext-core/messaging@2.3.0)(webextension-polyfill@0.12.0)
@@ -86,6 +80,9 @@ importers:
cobe:
specifier: ^0.6.5
version: 0.6.5
comctx:
specifier: ^1.4.3
version: 1.4.3
danmu:
specifier: ^0.18.1
version: 0.18.1
@@ -95,9 +92,15 @@ importers:
framer-motion:
specifier: ^12.23.22
version: 12.23.22(@emotion/is-prop-valid@1.3.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
hash-it:
specifier: ^6.0.0
version: 6.0.0
idb-keyval:
specifier: ^6.2.2
version: 6.2.2
imgcap:
specifier: ^1.0.2
version: 1.0.2
lucide-react:
specifier: ^0.544.0
version: 0.544.0(react@19.1.1)
@@ -1266,9 +1269,6 @@ packages:
'@resreq/event-hub@1.6.0':
resolution: {integrity: sha512-SEzPuAc2bQdMV9cKrBIjW3EvntpU8NcBxdgdEo9yLKAeRfvdM4oM/sG8pFPRZOpYwOytaJqWGqwjc+RSRFFTvQ==}
'@resreq/timer@1.3.2':
resolution: {integrity: sha512-KTtm5WBnu26R+z1Xd34zn/cUhagVHNwfUbyJfQyqf2F0JtNmdVgA6PL1JHvNW1C2TXHgKkBNSsfp1vNGg/DngQ==}
'@rolldown/pluginutils@1.0.0-beta.38':
resolution: {integrity: sha512-N/ICGKleNhA5nc9XXQG/kkKHJ7S55u0x0XUJbbkmdCnFuoRkM1Il12q9q0eX19+M7KKUEPw/daUPIRnxhcxAIw==}
@@ -2183,6 +2183,9 @@ packages:
colorette@2.0.20:
resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==}
comctx@1.4.3:
resolution: {integrity: sha512-Z1fisV3l6kucW+GZDg3AI2+ffWGd+Q/RCBKGbpQ8oe6U3p6wmTM/NciP4RRgLYYDW4aZgZIXRUQz1YnFX1QsjQ==}
comma-separated-tokens@2.0.3:
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
@@ -3069,6 +3072,9 @@ packages:
resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
engines: {node: '>= 0.4'}
hash-it@6.0.0:
resolution: {integrity: sha512-KHzmSFx1KwyMPw0kXeeUD752q/Kfbzhy6dAZrjXV9kAIXGqzGvv8vhkUqj+2MGZldTo0IBpw6v7iWE7uxsvH0w==}
hasown@2.0.2:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
@@ -3154,6 +3160,9 @@ packages:
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
engines: {node: '>= 4'}
imgcap@1.0.2:
resolution: {integrity: sha512-KY04sGVRupPrRBIruSBDnBBEe/5iwyivnukzmhOBclIkdyMJcbfrlqZrSZjQi1254NJXxOtpB3ddmHUjdKNydg==}
immediate@3.0.6:
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
@@ -6813,10 +6822,6 @@ snapshots:
'@resreq/event-hub@1.6.0': {}
'@resreq/timer@1.3.2':
dependencies:
'@resreq/event-hub': 1.6.0
'@rolldown/pluginutils@1.0.0-beta.38': {}
'@rollup/pluginutils@5.3.0(rollup@4.52.3)':
@@ -7796,6 +7801,8 @@ snapshots:
colorette@2.0.20: {}
comctx@1.4.3: {}
comma-separated-tokens@2.0.3: {}
commander@14.0.1: {}
@@ -8868,6 +8875,8 @@ snapshots:
dependencies:
has-symbols: 1.1.0
hash-it@6.0.0: {}
hasown@2.0.2:
dependencies:
function-bind: 1.1.2
@@ -8959,6 +8968,8 @@ snapshots:
ignore@7.0.5: {}
imgcap@1.0.2: {}
immediate@3.0.6: {}
import-fresh@3.3.1:

View File

@@ -1,69 +1,23 @@
import { EVENT } from '@/constants/event'
import { messenger } from '@/messenger'
import { browser, defineBackground } from '#imports'
import type { Browser } from 'wxt/browser'
import { ProvideAdapter } from '@/service/adapter/runtimeMessage'
import { defineProxy } from 'comctx'
import { AppAction } from '@/service/AppAction'
import { Notification } from '@/service/Notification'
export default defineBackground({
type: 'module',
main() {
browser.action.onClicked.addListener(() => {
browser.runtime.openOptionsPage()
const [provideNotification] = defineProxy(() => new Notification(), {
namespace: browser.runtime.id
})
const [provideAppAction] = defineProxy(() => new AppAction(), {
namespace: browser.runtime.id
})
const historyNotificationTabs = new Map<string, Browser.tabs.Tab>()
messenger.onMessage(EVENT.OPTIONS_PAGE_OPEN, () => {
browser.runtime.openOptionsPage()
})
provideNotification(new ProvideAdapter())
messenger.onMessage(EVENT.NOTIFICATION_PUSH, async ({ data: message, sender }) => {
// Check if there is an active tab on the same site
const tabs = await browser.tabs.query({ active: true })
const hasActiveSomeSiteTab = tabs.some((tab) => {
return new URL(tab.url!).origin === new URL(sender.tab!.url!).origin
})
const appAction = provideAppAction(new ProvideAdapter())
if (hasActiveSomeSiteTab) return
browser.notifications.create(message.id, {
type: 'basic',
iconUrl: message.userAvatar,
title: message.username,
message: message.body,
contextMessage: sender.tab!.url!
})
historyNotificationTabs.set(message.id, sender.tab! as Browser.tabs.Tab)
})
messenger.onMessage(EVENT.NOTIFICATION_CLEAR, async ({ data: id }) => {
browser.notifications.clear(id)
})
browser.notifications.onButtonClicked.addListener(async (id) => {
const fromTab = historyNotificationTabs.get(id)
if (fromTab?.id) {
try {
const tab = await browser.tabs.get(fromTab.id)
browser.tabs.update(tab.id!, { active: true, highlighted: true })
browser.windows.update(tab.windowId!, { focused: true })
} catch {
browser.tabs.create({ url: fromTab.url })
}
}
})
browser.notifications.onClicked.addListener(async (id) => {
const fromTab = historyNotificationTabs.get(id)
if (fromTab?.id) {
try {
const tab = await browser.tabs.get(fromTab.id)
browser.tabs.update(tab.id!, { active: true })
} catch {
browser.tabs.create({ url: fromTab.url })
}
}
})
browser.notifications.onClosed.addListener(async (id) => {
historyNotificationTabs.delete(id)
})
browser.action.onClicked.addListener(() => appAction.openOptionsPage())
}
})

View File

@@ -9,7 +9,7 @@ import ChatRoomDomain from '@/domain/ChatRoom'
import UserInfoDomain from '@/domain/UserInfo'
import Setup from '@/app/content/views/setup'
import MessageListDomain from '@/domain/MessageList'
import { useEffect, useRef } from 'react'
import { useEffect, useLayoutEffect, useRef } from 'react'
import { Toaster } from 'sonner'
import DanmakuContainer from './components/danmaku-container'
@@ -75,7 +75,7 @@ export default function App() {
}
}, [danmakuIsEnabled])
useEffect(() => {
useLayoutEffect(() => {
window.addEventListener('beforeunload', leaveRoom)
return () => {
window.removeEventListener('beforeunload', leaveRoom)

View File

@@ -1,10 +1,9 @@
import { type FC, useState, type MouseEvent, useEffect } from 'react'
import { type FC, useState, type MouseEvent, useEffect, useMemo } from 'react'
import { SettingsIcon, MoonIcon, SunIcon, HandIcon } from 'lucide-react'
import { motion, AnimatePresence } from 'framer-motion'
import { useRemeshDomain, useRemeshQuery, useRemeshSend } from 'remesh-react'
import { Button } from '@/components/ui/button'
import { EVENT } from '@/constants/event'
import UserInfoDomain from '@/domain/UserInfo'
import useTriggerAway from '@/hooks/useTriggerAway'
import { checkDarkMode, cn } from '@/utils'
@@ -17,9 +16,9 @@ import LogoIcon5 from '@/assets/images/logo-5.svg'
import LogoIcon6 from '@/assets/images/logo-6.svg'
import AppStatusDomain from '@/domain/AppStatus'
import { getDay } from 'date-fns'
import { messenger } from '@/messenger'
import useDraggable from '@/hooks/useDraggable'
import useWindowResize from '@/hooks/useWindowResize'
import { AppActionImpl } from '@/domain/impls/AppAction'
export interface AppButtonProps {
className?: string
@@ -80,13 +79,80 @@ const AppButton: FC<AppButtonProps> = ({ className }) => {
}
const handleOpenOptionsPage = () => {
messenger.sendMessage(EVENT.OPTIONS_PAGE_OPEN, undefined)
AppActionImpl.value.openOptionsPage()
}
const handleToggleApp = () => {
send(appStatusDomain.command.UpdateOpenCommand(!appOpenStatus))
}
// Memoize menu buttons to prevent re-render when position changes
const menuButtons = useMemo(
() => (
<>
<Button
onClick={handleSwitchTheme}
variant="outline"
className="relative size-10 overflow-hidden rounded-full p-0 shadow dark:border-slate-600"
>
<div
className={cn(
'absolute grid grid-rows-[repeat(2,minmax(0,2.5rem))] w-full justify-center items-center transition-all duration-300 hover:bg-accent dark:hover:bg-accent',
isDarkMode ? 'top-0' : '-top-10',
isDarkMode ? 'bg-slate-950 text-white' : 'bg-white text-orange-400'
)}
>
<MoonIcon className="size-5" />
<SunIcon className="size-5" />
</div>
</Button>
<Button
onClick={handleOpenOptionsPage}
variant="outline"
className="size-10 rounded-full p-0 dark:bg-background shadow dark:text-foreground dark:border-slate-600 dark:hover:bg-accent"
>
<SettingsIcon className="size-5" />
</Button>
<Button
ref={appButtonRef}
variant="outline"
className="size-10 cursor-grab dark:bg-background rounded-full p-0 dark:text-foreground shadow dark:border-slate-600 dark:hover:bg-accent"
>
<HandIcon className="size-5" />
</Button>
</>
),
[isDarkMode, handleSwitchTheme, handleOpenOptionsPage, appButtonRef]
)
// Memoize main button content to prevent re-render when position changes
const mainButtonContent = useMemo(
() => (
<>
<AnimatePresence>
{hasUnreadQuery && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.1 }}
className="absolute -right-1 -top-1 z-30 flex size-5 items-center justify-center"
>
<span
className={cn('absolute inline-flex size-full animate-ping rounded-full opacity-75', 'bg-orange-400')}
></span>
<span className={cn('relative inline-flex size-3 rounded-full', 'bg-orange-500')}></span>
</motion.div>
)}
</AnimatePresence>
<DayLogo className="relative z-20 max-h-full max-w-full overflow-hidden size-full"></DayLogo>
</>
),
[hasUnreadQuery, DayLogo]
)
return (
<div
ref={appMenuRef}
@@ -106,37 +172,7 @@ const AppButton: FC<AppButtonProps> = ({ className }) => {
exit={{ opacity: 0, y: 12 }}
transition={{ duration: 0.1 }}
>
<Button
onClick={handleSwitchTheme}
variant="outline"
className="relative size-10 overflow-hidden rounded-full p-0 shadow dark:border-slate-600"
>
<div
className={cn(
'absolute grid grid-rows-[repeat(2,minmax(0,2.5rem))] w-full justify-center items-center transition-all duration-300 hover:bg-accent dark:hover:bg-accent',
isDarkMode ? 'top-0' : '-top-10',
isDarkMode ? 'bg-slate-950 text-white' : 'bg-white text-orange-400'
)}
>
<MoonIcon className="size-5" />
<SunIcon className="size-5" />
</div>
</Button>
<Button
onClick={handleOpenOptionsPage}
variant="outline"
className="size-10 rounded-full p-0 dark:bg-background shadow dark:text-foreground dark:border-slate-600 dark:hover:bg-accent"
>
<SettingsIcon className="size-5" />
</Button>
<Button
ref={appButtonRef}
variant="outline"
className="size-10 cursor-grab dark:bg-background rounded-full p-0 dark:text-foreground shadow dark:border-slate-600 dark:hover:bg-accent"
>
<HandIcon className="size-5" />
</Button>
{menuButtons}
</motion.div>
)}
</AnimatePresence>
@@ -145,24 +181,7 @@ const AppButton: FC<AppButtonProps> = ({ className }) => {
onContextMenu={handleToggleMenu}
className="relative z-20 size-11 rounded-full has-[>svg]:p-0 text-xs shadow-lg shadow-slate-500/50 after:absolute after:-inset-0.5 after:z-10 after:animate-[shimmer_2s_linear_infinite] after:rounded-full after:bg-[conic-gradient(from_var(--shimmer-angle),theme(colors.slate.500)_0%,theme(colors.white)_10%,theme(colors.slate.500)_20%)]"
>
<AnimatePresence>
{hasUnreadQuery && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.1 }}
className="absolute -right-1 -top-1 z-30 flex size-5 items-center justify-center"
>
<span
className={cn('absolute inline-flex size-full animate-ping rounded-full opacity-75', 'bg-orange-400')}
></span>
<span className={cn('relative inline-flex size-3 rounded-full', 'bg-orange-500')}></span>
</motion.div>
)}
</AnimatePresence>
<DayLogo className="relative z-20 max-h-full max-w-full overflow-hidden size-full"></DayLogo>
{mainButtonContent}
</Button>
</div>
)

View File

@@ -1,4 +1,4 @@
import { type ReactNode, type FC, useState } from 'react'
import { type ReactNode, type FC, useState, useMemo } from 'react'
import useResizable from '@/hooks/useResizable'
import { motion, AnimatePresence } from 'framer-motion'
import AppStatusDomain from '@/domain/AppStatus'
@@ -34,6 +34,9 @@ const AppMain: FC<AppMainProps> = ({ children, className }) => {
const [isAnimationComplete, setAnimationComplete] = useState(false)
// Memoize children to prevent unnecessary re-renders when position changes
const memoizedChildren = useMemo(() => children, [children])
return (
<AnimatePresence>
{appOpenStatus && (
@@ -55,7 +58,7 @@ const AppMain: FC<AppMainProps> = ({ children, className }) => {
{ 'transition-transform': isAnimationComplete }
)}
>
{children}
{memoizedChildren}
<div
ref={setRef}
className={cn(

View File

@@ -10,6 +10,7 @@ import { MESSAGE_MAX_LENGTH, WEB_RTC_MAX_MESSAGE_SIZE } from '@/constants/config
import ChatRoomDomain from '@/domain/ChatRoom'
import useCursorPosition from '@/hooks/useCursorPosition'
import useShareRef from '@/hooks/useShareRef'
import useThrottle from '@/hooks/useThrottle'
import { Presence } from '@radix-ui/react-presence'
import { Portal } from '@radix-ui/react-portal'
import useTriggerAway from '@/hooks/useTriggerAway'
@@ -17,12 +18,13 @@ import { ScrollArea } from '@/components/ui/scroll-area'
import type { VirtuosoHandle } from 'react-virtuoso'
import { Virtuoso } from 'react-virtuoso'
import UserInfoDomain from '@/domain/UserInfo'
import { blobToBase64, cn, compressImage, getRootNode, getTextByteSize, getTextSimilarity } from '@/utils'
import { blobToBase64, cn, getRootNode, getTextByteSize, getTextSimilarity } from '@/utils'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { AvatarImage } from '@radix-ui/react-avatar'
import ToastDomain from '@/domain/Toast'
import ImageButton from '../../components/image-button'
import { nanoid } from 'nanoid'
import imgcap from 'imgcap'
const Footer: FC = () => {
const send = useRemeshSend()
@@ -128,7 +130,7 @@ const Footer: FC = () => {
return newMessage
}
const handleSend = async () => {
const handleSendMessage = async () => {
if (!`${message}`.trim()) {
inputRef.current?.focus()
return
@@ -154,6 +156,8 @@ const Footer: FC = () => {
])
}
const handleSend = useThrottle(handleSendMessage, 1000)
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (autoCompleteListShow && autoCompleteList.length) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
@@ -237,6 +241,10 @@ const Footer: FC = () => {
}
}
const handleToggleComposing = (composing: boolean) => {
isComposing.current = composing
}
const handleInjectEmoji = (emoji: string) => {
const newMessage = `${message.slice(0, selectionEnd)}${emoji}${message.slice(selectionEnd)}`
@@ -258,8 +266,7 @@ const Footer: FC = () => {
try {
setInputLoading(true)
const blob = await compressImage({
input: file,
const blob = await imgcap(file, {
targetSize: 30 * 1024,
outputType: file.size > 30 * 1024 ? 'image/webp' : undefined
})
@@ -364,6 +371,8 @@ const Footer: FC = () => {
loading={inputLoading}
onPaste={handlePaste}
onKeyDown={handleKeyDown}
onCompositionStart={() => handleToggleComposing(true)}
onCompositionEnd={() => handleToggleComposing(false)}
maxLength={MESSAGE_MAX_LENGTH}
></MessageInput>
<div className="flex items-center">

View File

@@ -7,6 +7,7 @@ import PromptItem from '../../components/prompt-item'
import UserInfoDomain from '@/domain/UserInfo'
import ChatRoomDomain, { MessageType } from '@/domain/ChatRoom'
import MessageListDomain from '@/domain/MessageList'
import useDataId from '@/hooks/useDataId'
const Main: FC = () => {
const send = useRemeshSend()
@@ -16,6 +17,8 @@ const Main: FC = () => {
const userInfo = useRemeshQuery(userInfoDomain.query.UserInfoQuery())
const _messageList = useRemeshQuery(messageListDomain.query.ListQuery())
const messageListId = useDataId(_messageList)
const messageList = useMemo(
() =>
_messageList
@@ -30,7 +33,7 @@ const Main: FC = () => {
return message
})
.toSorted((a, b) => a.sendTime - b.sendTime),
[_messageList, userInfo?.id]
[messageListId, userInfo?.id]
)
const handleLikeChange = (messageId: string) => {

View File

@@ -4,13 +4,12 @@ import type { Message } from '@/domain/MessageList'
import MessageListDomain, { MessageType } from '@/domain/MessageList'
import type { UserInfo } from '@/domain/UserInfo'
import UserInfoDomain from '@/domain/UserInfo'
import { generateRandomAvatar, generateRandomName } from '@/utils'
import { generateRandomAvatar, generateRandomName, setIntervalImmediate } from '@/utils'
import { UserIcon } from 'lucide-react'
import { nanoid } from 'nanoid'
import type { FC } from 'react'
import { useEffect, useState } from 'react'
import { useRemeshDomain, useRemeshSend } from 'remesh-react'
import Timer from '@resreq/timer'
import ExampleImage from '@/assets/images/example.jpg'
import { PulsatingButton } from '@/components/magicui/pulsating-button'
import { BlurFade } from '@/components/magicui/blur-fade'
@@ -78,56 +77,50 @@ const Setup: FC = () => {
send(userInfoDomain.command.UpdateUserInfoCommand(userInfo!))
}
const refreshUserInfo = async () => {
const userInfo = await generateUserInfo()
setUserInfo(userInfo)
return userInfo
}
const refreshUserInfo = async () => generateUserInfo()
const createMessage = async (userInfo: UserInfo) => {
const message = await generateMessage(userInfo!)
const message = await generateMessage(userInfo)
setUserInfo(userInfo)
send(messageListDomain.command.CreateItemCommand(message))
}
useEffect(() => {
const timer = new Timer(
async () => {
if (timer.status !== 'stopped') {
await createMessage(await refreshUserInfo())
}
},
{ interval: 2000, immediate: true, limit: mockTextList.length }
)
timer.start()
const clearTimer = setIntervalImmediate(async () => {
mockTextList.length ? await createMessage(await refreshUserInfo()) : clearTimer()
}, 2000)
return () => {
timer.stop()
clearTimer()
send(messageListDomain.command.ClearListCommand())
}
}, [])
return (
<div className="absolute inset-0 z-50 flex rounded-xl bg-black/10 shadow-2xl backdrop-blur-sm">
<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} className="size-full" alt="avatar" />
<AvatarFallback>
<UserIcon size={30} className="text-slate-400" />
</AvatarFallback>
</Avatar>
</BlurFade>
<div className="flex items-center" key={userInfo?.name}>
<motion.div
className="text-2xl font-bold text-primary"
initial={{ x: -10, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
transition={{ duration: 0.5 }}
>
@
</motion.div>
<WordRotate className="text-2xl font-bold text-primary" words={[`${userInfo?.name || ''.padEnd(10, ' ')}`]} />
{userInfo && (
<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} className="size-full" alt="avatar" />
<AvatarFallback>
<UserIcon size={30} className="text-slate-400" />
</AvatarFallback>
</Avatar>
</BlurFade>
<div className="flex items-center" key={userInfo.name}>
<motion.div
className="text-2xl font-bold text-primary"
initial={{ x: -10, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
transition={{ duration: 0.5 }}
>
@
</motion.div>
<WordRotate className="text-2xl font-bold text-primary" words={[userInfo.name]} />
</div>
<PulsatingButton onClick={handleSetup}>Start chatting</PulsatingButton>
</div>
<PulsatingButton onClick={handleSetup}>Start chatting</PulsatingButton>
</div>
)}
</div>
)
}

View File

@@ -4,7 +4,8 @@ import { type ChangeEvent } from 'react'
import { ImagePlusIcon } from 'lucide-react'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Label } from '@/components/ui/label'
import { blobToBase64, cn, compressImage } from '@/utils'
import { blobToBase64, cn } from '@/utils'
import imgcap from 'imgcap'
export interface AvatarSelectProps {
value?: string
@@ -41,7 +42,7 @@ const AvatarSelect = ({
* 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({ input: file, targetSize: compressSize, outputType: 'image/webp' })
const blob = await imgcap(file, { targetSize: compressSize, outputType: 'image/webp' })
const base64 = await blobToBase64(blob)
onSuccess?.(base64)
onChange?.(base64)

View File

@@ -1,6 +1,3 @@
export enum EVENT {
OPTIONS_PAGE_OPEN = `WEB_CHAT_OPTIONS_PAGE_OPEN`,
APP_OPEN = 'WEB_CHAT_APP_OPEN',
NOTIFICATION_PUSH = 'WEB_CHAT_NOTIFICATION_PUSH',
NOTIFICATION_CLEAR = 'WEB_CHAT_NOTIFICATION_CLEAR'
APP_OPEN = 'WEB_CHAT_APP_OPEN'
}

View File

@@ -10,6 +10,7 @@ import { nanoid } from 'nanoid'
import StatusModule from '@/domain/modules/Status'
import { SYNC_HISTORY_MAX_DAYS, WEB_RTC_MAX_MESSAGE_SIZE } from '@/constants/config'
import * as v from 'valibot'
import hash from 'hash-it'
export { MessageType }
@@ -514,6 +515,10 @@ const ChatRoomDomain = Remesh.domain({
name: 'Room.OnSyncHistoryMessageEvent'
})
const OnSyncMessageEvent = domain.event<SyncHistoryMessage[]>({
name: 'Room.OnSyncMessageEvent'
})
const OnLikeMessageEvent = domain.event<LikeMessage>({
name: 'Room.OnLikeMessageEvent'
})
@@ -653,7 +658,7 @@ const ChatRoomDomain = Remesh.domain({
domain.effect({
name: 'Room.OnSyncHistoryMessageEffect',
impl: ({ fromEvent }) => {
impl: ({ get, fromEvent }) => {
return fromEvent(OnSyncHistoryMessageEvent).pipe(
bufferTime(300), // Collect messages within 300ms time window
filter((messages) => messages.length > 0),
@@ -666,8 +671,23 @@ const ChatRoomDomain = Remesh.domain({
...allMessages.reduce((map, msg) => map.set(msg.id, msg), new Map<string, NormalMessage>()).values()
]
// Return batched upsert commands
return of(...uniqueMessages.map((message) => messageListDomain.command.UpsertItemCommand(message)))
// Filter out messages that haven't changed
const changedMessages = uniqueMessages.filter((message) => {
const hasMessage = get(messageListDomain.query.HasItemQuery(message.id))
if (!hasMessage) {
return true
} else {
return hash(message) !== hash(get(messageListDomain.query.ItemQuery(message.id)))
}
})
// Return batched upsert commands and single OnSyncMessageEvent for all sync messages
return changedMessages.length
? of(
...changedMessages.map((message) => messageListDomain.command.UpsertItemCommand(message)),
OnSyncMessageEvent(syncMessages)
)
: EMPTY
})
)
}
@@ -805,6 +825,7 @@ const ChatRoomDomain = Remesh.domain({
SelfLeaveRoomEvent,
OnMessageEvent,
OnTextMessageEvent,
OnSyncMessageEvent,
OnJoinRoomEvent,
OnLeaveRoomEvent,
OnErrorEvent

View File

@@ -1,8 +1,8 @@
import { Remesh } from 'remesh'
import ToastModule from './modules/Toast'
import ChatRoomDomain, { SendType } from './ChatRoom'
import ChatRoomDomain from './ChatRoom'
import VirtualRoomDomain from './VirtualRoom'
import { filter, map, merge } from 'rxjs'
import { map, merge } from 'rxjs'
const ToastDomain = Remesh.domain({
name: 'ToastDomain',
@@ -41,8 +41,7 @@ const ToastDomain = Remesh.domain({
domain.effect({
name: 'Toast.OnSyncHistoryEffect',
impl: ({ fromEvent }) => {
const onSyncHistory$ = fromEvent(chatRoomDomain.event.OnMessageEvent).pipe(
filter((message) => message.type === SendType.SyncHistory),
const onSyncHistory$ = fromEvent(chatRoomDomain.event.OnSyncMessageEvent).pipe(
map(() => toastModule.command.SuccessCommand('Syncing history messages.'))
)

View File

@@ -0,0 +1,13 @@
import { Remesh } from 'remesh'
export interface AppAction {
openOptionsPage: () => Promise<void>
}
export const AppActionExtern = Remesh.extern<AppAction>({
default: {
openOptionsPage: () => {
throw new Error('"openOptionsPage" not implemented.')
}
}
})

View File

@@ -2,7 +2,7 @@ import { Remesh } from 'remesh'
import type { TextMessage } from '@/domain/ChatRoom'
export interface Notification {
push: (message: TextMessage) => Promise<string>
push: (message: TextMessage) => Promise<string | void>
}
export const NotificationExtern = Remesh.extern<Notification>({

View File

@@ -0,0 +1,13 @@
import { browser } from '#imports'
import { AppActionExtern, type AppAction } from '@/domain/externs/AppAction'
import { InjectAdapter } from '@/service/adapter/runtimeMessage'
import { defineProxy } from 'comctx'
const [, injectAppAction] = defineProxy(() => ({}) as AppAction, {
namespace: browser.runtime.id
})
const appAction = injectAppAction(new InjectAdapter())
export const AppActionImpl = AppActionExtern.impl(appAction)

View File

@@ -1,13 +1,10 @@
import { NotificationExtern } from '@/domain/externs/Notification'
import type { TextMessage } from '@/domain/ChatRoom'
import { EVENT } from '@/constants/event'
import { messenger } from '@/messenger'
import { NotificationExtern, type Notification } from '@/domain/externs/Notification'
class Notification {
async push(message: TextMessage) {
await messenger.sendMessage(EVENT.NOTIFICATION_PUSH, message)
return message.id
}
}
import { InjectAdapter } from '@/service/adapter/runtimeMessage'
import { defineProxy } from 'comctx'
export const NotificationImpl = NotificationExtern.impl(new Notification())
const [, injectNotification] = defineProxy(() => ({}) as Notification)
const notification = injectNotification(new InjectAdapter())
export const NotificationImpl = NotificationExtern.impl(notification)

6
src/hooks/useDataId.ts Normal file
View File

@@ -0,0 +1,6 @@
import hash from 'hash-it'
import { useMemo } from 'react'
const useDataId = (data: any) => useMemo(() => hash(data), [data])
export default useDataId

23
src/hooks/useDebounce.ts Normal file
View File

@@ -0,0 +1,23 @@
import { useCallback, useRef } from 'react'
/**
* Debounce hook that delays function execution until after delay has elapsed
* @param callback The function to debounce
* @param delay Delay in milliseconds before execution
* @returns Debounced function
*/
const useDebounce = <T extends (...args: any[]) => any>(callback: T, delay: number) => {
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
return useCallback(
(...args: Parameters<T>) => {
timerRef.current && clearTimeout(timerRef.current)
timerRef.current = setTimeout(() => {
callback(...args)
}, delay)
},
[callback, delay]
)
}
export default useDebounce

View File

@@ -50,33 +50,42 @@ const useDraggable = (options: DargOptions) => {
}, [initX, initY, maxX, minX, maxY, minY, reverse])
const isMove = useRef(false)
const rafRef = useRef<number | null>(null)
const latestMousePosition = useRef({ x: 0, y: 0 })
const handleMove = useCallback(
(e: MouseEvent) => {
if (isMove.current) {
const { clientX, clientY } = e
const prev = positionRef.current
const delta = {
x: prev.x + clientX - mousePosition.current.x,
y: prev.y + clientY - mousePosition.current.y
}
latestMousePosition.current = { x: clientX, y: clientY }
const hasChanged = delta.x !== prev.x || delta.y !== prev.y
// Cancel previous frame to ensure only one update per frame
rafRef.current && cancelAnimationFrame(rafRef.current)
if (isInRange(delta.x, minX, maxX)) {
mousePosition.current.x = clientX
}
if (isInRange(delta.y, minY, maxY)) {
mousePosition.current.y = clientY
}
if (hasChanged) {
const x = clamp(delta.x, minX, maxX)
const y = clamp(delta.y, minY, maxY)
startTransition(() => {
positionRef.current = { x, y }
setPosition(fromInternal(x, y))
})
}
rafRef.current = requestAnimationFrame(() => {
const prev = positionRef.current
const delta = {
x: prev.x + latestMousePosition.current.x - mousePosition.current.x,
y: prev.y + latestMousePosition.current.y - mousePosition.current.y
}
const hasChanged = delta.x !== prev.x || delta.y !== prev.y
if (isInRange(delta.x, minX, maxX)) {
mousePosition.current.x = latestMousePosition.current.x
}
if (isInRange(delta.y, minY, maxY)) {
mousePosition.current.y = latestMousePosition.current.y
}
if (hasChanged) {
const x = clamp(delta.x, minX, maxX)
const y = clamp(delta.y, minY, maxY)
startTransition(() => {
positionRef.current = { x, y }
setPosition(fromInternal(x, y))
})
}
})
}
},
[minX, maxX, minY, maxY, reverse]
@@ -86,6 +95,7 @@ const useDraggable = (options: DargOptions) => {
isMove.current = false
document.documentElement.style.cursor = ''
document.documentElement.style.userSelect = ''
rafRef.current && cancelAnimationFrame(rafRef.current)
}, [])
const handleStart = useCallback((e: MouseEvent) => {

View File

@@ -26,6 +26,8 @@ const useResizable = (options: ResizableOptions) => {
const position = useRef(0)
const isMove = useRef(false)
const rafRef = useRef<number | null>(null)
const latestMousePosition = useRef({ x: 0, y: 0 })
const isHorizontal = direction === 'left' || direction === 'right'
@@ -33,30 +35,39 @@ const useResizable = (options: ResizableOptions) => {
(e: MouseEvent) => {
if (isMove.current) {
const { screenY, screenX } = e
let delta = 0
switch (direction) {
case 'left':
delta = position.current - screenX
break
case 'right':
delta = screenX - position.current
break
case 'top':
delta = position.current - screenY
break
case 'bottom':
delta = screenY - position.current
break
}
const newSize = size + delta
latestMousePosition.current = { x: screenX, y: screenY }
startTransition(() => {
if (isInRange(newSize, minSize, maxSize)) {
position.current = isHorizontal ? screenX : screenY
}
if (newSize !== size) {
setSize(clamp(newSize, minSize, maxSize))
// Cancel previous frame to ensure only one update per frame
rafRef.current && cancelAnimationFrame(rafRef.current)
rafRef.current = requestAnimationFrame(() => {
const screenX = latestMousePosition.current.x
const screenY = latestMousePosition.current.y
let delta = 0
switch (direction) {
case 'left':
delta = position.current - screenX
break
case 'right':
delta = screenX - position.current
break
case 'top':
delta = position.current - screenY
break
case 'bottom':
delta = screenY - position.current
break
}
const newSize = size + delta
startTransition(() => {
if (isInRange(newSize, minSize, maxSize)) {
position.current = isHorizontal ? screenX : screenY
}
if (newSize !== size) {
setSize(clamp(newSize, minSize, maxSize))
}
})
})
}
},
@@ -67,6 +78,7 @@ const useResizable = (options: ResizableOptions) => {
isMove.current = false
document.documentElement.style.cursor = ''
document.documentElement.style.userSelect = ''
rafRef.current && cancelAnimationFrame(rafRef.current)
}, [])
const handleStart = useCallback(

26
src/hooks/useThrottle.ts Normal file
View File

@@ -0,0 +1,26 @@
import { useCallback, useRef } from 'react'
/**
* Throttle hook that limits function execution rate
* @param callback The function to throttle
* @param delay Minimum time between executions in milliseconds
* @returns Throttled function
*/
const useThrottle = <T extends (...args: any[]) => any>(callback: T, delay: number) => {
const lastExecutionTime = useRef<number>(0)
return useCallback(
(...args: Parameters<T>) => {
const now = Date.now()
const timeSinceLastExecution = now - lastExecutionTime.current
if (timeSinceLastExecution >= delay) {
lastExecutionTime.current = now
return callback(...args)
}
},
[callback, delay]
)
}
export default useThrottle

View File

@@ -1,22 +1,29 @@
import { startTransition, useEffect, useState } from 'react'
import { startTransition, useEffect, useState, useRef } from 'react'
const useWindowResize = (callback?: ({ width, height }: { width: number; height: number }) => void) => {
const [size, setSize] = useState({ width: window.innerWidth, height: window.innerHeight })
const rafRef = useRef<number | null>(null)
useEffect(() => {
const handler = () => {
const width = window.innerWidth
const height = window.innerHeight
startTransition(() => {
setSize({ width, height })
callback?.({ width, height })
// Cancel previous frame to ensure only one update per frame
rafRef.current && cancelAnimationFrame(rafRef.current)
rafRef.current = requestAnimationFrame(() => {
const width = window.innerWidth
const height = window.innerHeight
startTransition(() => {
setSize({ width, height })
callback?.({ width, height })
})
})
}
window.addEventListener('resize', handler)
return () => {
window.removeEventListener('resize', handler)
rafRef.current && cancelAnimationFrame(rafRef.current)
}
}, [])
}, [callback])
return size
}

View File

@@ -1,11 +0,0 @@
import type { EVENT } from '@/constants/event'
import { defineExtensionMessaging } from '@webext-core/messaging'
import type { TextMessage } from '@/domain/ChatRoom'
interface ProtocolMap {
[EVENT.OPTIONS_PAGE_OPEN]: () => void
[EVENT.NOTIFICATION_PUSH]: (message: TextMessage) => void
[EVENT.NOTIFICATION_CLEAR]: (id: string) => void
}
export const messenger = defineExtensionMessaging<ProtocolMap>()

View File

@@ -0,0 +1,8 @@
import type { AppAction as AppActionExternType } from '@/domain/externs/AppAction'
import { browser } from '#imports'
export class AppAction implements AppActionExternType {
async openOptionsPage() {
await browser.runtime.openOptionsPage()
}
}

View File

@@ -0,0 +1,49 @@
import type { Notification as NotificationExternType } from '@/domain/externs/Notification'
import type { TextMessage } from '@/domain/ChatRoom'
import { browser } from '#imports'
import type { MessageTab } from '@/service/adapter/runtimeMessage'
export class Notification implements NotificationExternType {
historyNotificationTabs = new Map<string, MessageTab>()
constructor() {
browser.notifications.onButtonClicked.addListener(async (id) => {
const formTab = this.historyNotificationTabs.get(id)
if (formTab?.id) {
try {
const tab = await browser.tabs.get(formTab.id)
browser.tabs.update(tab.id!, { active: true, highlighted: true })
browser.windows.update(tab.windowId!, { focused: true })
} catch {
browser.tabs.create({ url: formTab.url })
}
}
})
browser.notifications.onClicked.addListener(async (id) => {
const fromTab = this.historyNotificationTabs.get(id)
if (fromTab?.id) {
try {
const tab = await browser.tabs.get(fromTab.id)
browser.tabs.update(tab.id!, { active: true })
} catch {
browser.tabs.create({ url: fromTab.url })
}
}
})
browser.notifications.onClosed.addListener(async (id) => {
this.historyNotificationTabs.delete(id)
})
}
async push(message: TextMessage & { meta?: { tab?: MessageTab } }) {
const tab = message.meta?.tab
console.log(tab)
const id = await browser.notifications.create({
type: 'basic',
iconUrl: message.userAvatar,
title: message.username,
message: message.body,
contextMessage: tab?.url
})
tab && this.historyNotificationTabs.set(id, tab)
}
}

View File

@@ -0,0 +1,46 @@
import { browser } from '#imports'
import type { Browser } from '#imports'
import type { Adapter, Message, SendMessage, OnMessage } from 'comctx'
export interface MessageTab {
id?: number
url?: string
}
export interface MessageMeta {
tab?: MessageTab
}
export class ProvideAdapter implements Adapter<MessageMeta> {
sendMessage: SendMessage<MessageMeta> = async (message) => {
const tabs = await browser.tabs.query({ url: message.meta.tab?.url })
tabs.map((tab) => browser.tabs.sendMessage(tab.id!, message))
browser.runtime.sendMessage(message)
}
onMessage: OnMessage<MessageMeta> = (callback) => {
const handler = (message: Partial<Message<MessageMeta>>, sender: Browser.runtime.MessageSender) => {
callback({ ...message, meta: { tab: { id: sender.tab?.id, url: sender.tab?.url } } })
}
browser.runtime.onMessage.addListener(handler)
return () => browser.runtime.onMessage.removeListener(handler)
}
}
export class InjectAdapter implements Adapter<MessageMeta> {
sendMessage: SendMessage<MessageMeta> = (message) => {
browser.runtime.sendMessage(browser.runtime.id, {
...message,
meta: { tab: { url: document.location.href } }
})
}
onMessage: OnMessage<MessageMeta> = (callback) => {
const handler = (message?: Partial<Message<MessageMeta>>) => {
callback(message)
}
browser.runtime.onMessage.addListener(handler)
return () => browser.runtime.onMessage.removeListener(handler)
}
}

View File

@@ -1,86 +0,0 @@
export type ImageType = 'image/jpeg' | 'image/png' | 'image/webp'
export interface Options {
input: Blob
targetSize: number
toleranceSize?: number
outputType?: ImageType
}
const compress = async (
imageBitmap: ImageBitmap,
targetSize: number,
low: number,
high: number,
toleranceSize: number,
outputType: ImageType
): Promise<Blob> => {
// Calculate the middle quality value
const mid = (low + high) / 2
// Calculate the width and height after scaling
const width = Math.round(imageBitmap.width * mid)
const height = Math.round(imageBitmap.height * mid)
const offscreenCanvas = new OffscreenCanvas(width, height)
const offscreenContext = offscreenCanvas.getContext('2d')!
// Draw the scaled image
offscreenContext.drawImage(imageBitmap, 0, 0, imageBitmap.width, imageBitmap.height, 0, 0, width, height)
const outputBlob = await offscreenCanvas.convertToBlob({ type: outputType, quality: mid })
const currentSize = outputBlob.size
// Adjust the logic based on the positive or negative value of toleranceSize
if (toleranceSize < 0) {
// Negative value: allow results smaller than the target value
if (currentSize <= targetSize && currentSize >= targetSize + toleranceSize) {
return outputBlob
}
} else {
// Positive value: allow results larger than the target value
if (currentSize >= targetSize && currentSize <= targetSize + toleranceSize) {
return outputBlob
}
}
// Use relative error
if ((high - low) / high < 0.01) {
return outputBlob
}
if (currentSize > targetSize) {
return await compress(imageBitmap, targetSize, low, mid, toleranceSize, outputType)
} else {
return await compress(imageBitmap, targetSize, mid, high, toleranceSize, outputType)
}
}
const compressImage = async (options: Options) => {
const { input, targetSize, toleranceSize = -1024 } = options
if (!['image/jpeg', 'image/png', 'image/webp'].includes(input.type)) {
throw new Error('Only PNG, JPEG and WebP image are supported.')
}
if (toleranceSize % 1024 !== 0) {
throw new Error('Tolerance size must be a multiple of 1024.')
}
const outputType = options.outputType || (input.type as ImageType)
if (input.size <= targetSize && input.type === outputType) {
return input
}
const imageBitmap = await createImageBitmap(input)
// Initialize quality range
const low = 0
const high = 1
return await compress(imageBitmap, targetSize, low, high, toleranceSize, outputType)
}
export default compressImage

View File

@@ -1,6 +1,5 @@
import generateUglyAvatar from '@/lib/uglyAvatar'
import type { ImageType } from './compressImage'
import compressImage from './compressImage'
import imgcap, { type ImageType } from 'imgcap'
const generateRandomAvatar = async (targetSize: number, outputType: ImageType = 'image/webp') => {
const svgBlob = generateUglyAvatar()
@@ -18,7 +17,7 @@ const generateRandomAvatar = async (targetSize: number, outputType: ImageType =
image.onerror = () => reject(new Error('Failed to load SVG'))
image.src = URL.createObjectURL(svgBlob)
})
const miniAvatarBlob = await compressImage({ input: imageBlob, targetSize, outputType })
const miniAvatarBlob = await imgcap(imageBlob, { targetSize, outputType })
const miniAvatarBase64 = await new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onload = (e) => resolve(e.target?.result as string)

View File

@@ -2,7 +2,6 @@ export { default as cn } from './cn'
export { isInRange, clamp } from './number'
export { default as createElement } from './createElement'
export { default as getSiteInfo } from './getSiteInfo'
export { default as compressImage } from './compressImage'
export { default as isNullish } from './isNullish'
export { default as checkDarkMode } from './checkDarkMode'
export { default as stringToHex } from './stringToHex'
@@ -18,4 +17,5 @@ export { default as blobToBase64 } from './blobToBase64'
export * as JSONR from './jsonr'
export { getTextByteSize } from './getTextByteSize'
export { default as isEqual } from './isEqual'
export { default as setIntervalImmediate } from './setIntervalImmediate'
export { cleanURL, isAbsoluteURL, assembleURL, buildFullURL } from './url'

View File

@@ -0,0 +1,10 @@
const setIntervalImmediate = <T extends any[] = any[]>(handler: (...args: T) => void, delay?: number, ...args: T) => {
let timer = setTimeout(() => {
clearTimeout(timer)
handler(...args)
timer = setInterval(handler, delay, ...args)
}, 0)
return () => clearInterval(timer)
}
export default setIntervalImmediate

View File

@@ -10,7 +10,7 @@ export default defineConfig({
imports: false,
entrypointsDir: 'app',
webExt: {
startUrls: ['https://www.google.com/'],
startUrls: ['http://www.example.com/'],
openDevtools: true
},
manifest: ({ browser }) => {