mirror of
https://github.com/molvqingtai/WebChat.git
synced 2025-11-25 11:18:33 +08:00
Merge branch 'develop'
This commit is contained in:
@@ -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
37
pnpm-lock.yaml
generated
@@ -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:
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,32 +79,17 @@ const AppButton: FC<AppButtonProps> = ({ className }) => {
|
||||
}
|
||||
|
||||
const handleOpenOptionsPage = () => {
|
||||
messenger.sendMessage(EVENT.OPTIONS_PAGE_OPEN, undefined)
|
||||
AppActionImpl.value.openOptionsPage()
|
||||
}
|
||||
|
||||
const handleToggleApp = () => {
|
||||
send(appStatusDomain.command.UpdateOpenCommand(!appOpenStatus))
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={appMenuRef}
|
||||
className={cn('fixed z-infinity grid w-min select-none justify-center gap-y-3', className)}
|
||||
style={{
|
||||
right: `${x}px`,
|
||||
bottom: `${y}px`,
|
||||
transform: 'translateX(50%)'
|
||||
}}
|
||||
>
|
||||
<AnimatePresence>
|
||||
{menuOpen && (
|
||||
<motion.div
|
||||
className="z-10 grid gap-y-3"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 12 }}
|
||||
transition={{ duration: 0.1 }}
|
||||
>
|
||||
// Memoize menu buttons to prevent re-render when position changes
|
||||
const menuButtons = useMemo(
|
||||
() => (
|
||||
<>
|
||||
<Button
|
||||
onClick={handleSwitchTheme}
|
||||
variant="outline"
|
||||
@@ -137,14 +121,15 @@ const AppButton: FC<AppButtonProps> = ({ className }) => {
|
||||
>
|
||||
<HandIcon className="size-5" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<Button
|
||||
onClick={handleToggleApp}
|
||||
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%)]"
|
||||
>
|
||||
</>
|
||||
),
|
||||
[isDarkMode, handleSwitchTheme, handleOpenOptionsPage, appButtonRef]
|
||||
)
|
||||
|
||||
// Memoize main button content to prevent re-render when position changes
|
||||
const mainButtonContent = useMemo(
|
||||
() => (
|
||||
<>
|
||||
<AnimatePresence>
|
||||
{hasUnreadQuery && (
|
||||
<motion.div
|
||||
@@ -163,6 +148,40 @@ const AppButton: FC<AppButtonProps> = ({ className }) => {
|
||||
</AnimatePresence>
|
||||
|
||||
<DayLogo className="relative z-20 max-h-full max-w-full overflow-hidden size-full"></DayLogo>
|
||||
</>
|
||||
),
|
||||
[hasUnreadQuery, DayLogo]
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={appMenuRef}
|
||||
className={cn('fixed z-infinity grid w-min select-none justify-center gap-y-3', className)}
|
||||
style={{
|
||||
right: `${x}px`,
|
||||
bottom: `${y}px`,
|
||||
transform: 'translateX(50%)'
|
||||
}}
|
||||
>
|
||||
<AnimatePresence>
|
||||
{menuOpen && (
|
||||
<motion.div
|
||||
className="z-10 grid gap-y-3"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 12 }}
|
||||
transition={{ duration: 0.1 }}
|
||||
>
|
||||
{menuButtons}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<Button
|
||||
onClick={handleToggleApp}
|
||||
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%)]"
|
||||
>
|
||||
{mainButtonContent}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,44 +77,37 @@ 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">
|
||||
{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>
|
||||
<BlurFade key={userInfo.avatar} inView>
|
||||
<Avatar className="size-24 cursor-pointer border-4 border-white ">
|
||||
<AvatarImage src={userInfo?.avatar} className="size-full" alt="avatar" />
|
||||
<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}>
|
||||
<div className="flex items-center" key={userInfo.name}>
|
||||
<motion.div
|
||||
className="text-2xl font-bold text-primary"
|
||||
initial={{ x: -10, opacity: 0 }}
|
||||
@@ -124,10 +116,11 @@ const Setup: FC = () => {
|
||||
>
|
||||
@
|
||||
</motion.div>
|
||||
<WordRotate className="text-2xl font-bold text-primary" words={[`${userInfo?.name || ''.padEnd(10, ' ')}`]} />
|
||||
<WordRotate className="text-2xl font-bold text-primary" words={[userInfo.name]} />
|
||||
</div>
|
||||
<PulsatingButton onClick={handleSetup}>Start chatting</PulsatingButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.'))
|
||||
)
|
||||
|
||||
|
||||
13
src/domain/externs/AppAction.ts
Normal file
13
src/domain/externs/AppAction.ts
Normal 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.')
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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>({
|
||||
|
||||
13
src/domain/impls/AppAction.ts
Normal file
13
src/domain/impls/AppAction.ts
Normal 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)
|
||||
@@ -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
6
src/hooks/useDataId.ts
Normal 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
23
src/hooks/useDebounce.ts
Normal 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
|
||||
@@ -50,24 +50,32 @@ 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
|
||||
latestMousePosition.current = { x: clientX, y: clientY }
|
||||
|
||||
// Cancel previous frame to ensure only one update per frame
|
||||
rafRef.current && cancelAnimationFrame(rafRef.current)
|
||||
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
const prev = positionRef.current
|
||||
const delta = {
|
||||
x: prev.x + clientX - mousePosition.current.x,
|
||||
y: prev.y + clientY - mousePosition.current.y
|
||||
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 = clientX
|
||||
mousePosition.current.x = latestMousePosition.current.x
|
||||
}
|
||||
if (isInRange(delta.y, minY, maxY)) {
|
||||
mousePosition.current.y = clientY
|
||||
mousePosition.current.y = latestMousePosition.current.y
|
||||
}
|
||||
if (hasChanged) {
|
||||
const x = clamp(delta.x, minX, maxX)
|
||||
@@ -77,6 +85,7 @@ const useDraggable = (options: DargOptions) => {
|
||||
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) => {
|
||||
|
||||
@@ -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,6 +35,14 @@ const useResizable = (options: ResizableOptions) => {
|
||||
(e: MouseEvent) => {
|
||||
if (isMove.current) {
|
||||
const { screenY, screenX } = e
|
||||
latestMousePosition.current = { x: screenX, y: screenY }
|
||||
|
||||
// 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':
|
||||
@@ -58,6 +68,7 @@ const useResizable = (options: ResizableOptions) => {
|
||||
setSize(clamp(newSize, minSize, maxSize))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
[direction, isHorizontal, maxSize, minSize, size]
|
||||
@@ -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
26
src/hooks/useThrottle.ts
Normal 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
|
||||
@@ -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 = () => {
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -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>()
|
||||
8
src/service/AppAction/index.ts
Normal file
8
src/service/AppAction/index.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
49
src/service/Notification/index.ts
Normal file
49
src/service/Notification/index.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
46
src/service/adapter/runtimeMessage.ts
Normal file
46
src/service/adapter/runtimeMessage.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
10
src/utils/setIntervalImmediate.ts
Normal file
10
src/utils/setIntervalImmediate.ts
Normal 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
|
||||
@@ -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 }) => {
|
||||
|
||||
Reference in New Issue
Block a user