perf: optimize app positioning and message sending performance

- Change app button positioning from left-top to bottom-right anchor
- Add reverse mode to useDraggable for bottom-right coordinate system
- Reset button position to default on window resize
- Use useMemo to cache message list computation
- Use startTransition to prevent UI blocking when sending messages
- Add generic type parameters to useShareRef and useTriggerAway
- Add CLAUDE.md for repository guidance

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
molvqingtai
2025-09-30 17:48:36 +08:00
parent bc9f9a3dc3
commit 0bfaa07258
9 changed files with 327 additions and 62 deletions

218
CLAUDE.md Normal file
View File

@@ -0,0 +1,218 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Overview
WebChat is a decentralized, serverless browser extension that enables anonymous P2P chat on any website using WebRTC. Built with WXT framework for cross-browser support (Chrome, Firefox, Edge).
## Key Technologies
- **WXT**: Browser extension framework (config: `wxt.config.ts`)
- **Remesh**: DDD framework for domain logic with true UI/logic separation (RxJS-based reactive state management)
- **Artico (@rtco/client)**: WebRTC P2P communication library (replaces previous trystero dependency)
- **React 19** with TypeScript
- **Tailwind CSS v4** with shadcn/ui components
- **Valibot**: Runtime schema validation
## Development Commands
```bash
# Development
npm run dev # Chrome dev mode with hot reload
npm run dev:firefox # Firefox dev mode
# Type checking
npm run check # Run TypeScript compiler without emitting files
# Linting
npm run lint # ESLint with auto-fix and cache
# Building
npm run build # Production build for all browsers
npm run build:chrome # Chrome production build only
npm run build:firefox # Firefox production build only
# Packaging
npm run pack # Create zip files for all browsers
npm run pack:chrome # Create Chrome zip only
npm run pack:firefox # Create Firefox zip only
# Maintenance
npm run clear # Remove .output directory
npm run prepare # Setup husky git hooks
npm run postinstall # WXT preparation (auto-runs after install)
```
## Architecture
### Extension Structure
WebChat uses WXT's app-based structure (not entrypoints):
- **src/app/content/** - Content script injected into web pages (main chat UI)
- **src/app/background/** - Service worker handling notifications and extension actions
- **src/app/options/** - Options page UI for user profile settings
- Entry files: `index.ts` or `index.tsx` in each app directory
### Domain-Driven Design (Remesh)
Business logic is fully decoupled from UI using Remesh domains:
**Core Domains** (`src/domain/`):
- `ChatRoom.ts` - Site-specific P2P chat room (messages, users, sync)
- `VirtualRoom.ts` - Global virtual room for cross-site user discovery
- `MessageList.ts` - Message management and persistence
- `UserInfo.ts` - User profile state
- `AppStatus.ts` - Application state (open/minimized)
- `Danmaku.ts` - Danmaku/bullet comments display
- `Notification.ts` - Browser notifications
- `Toast.ts` - In-app toast messages
**Domain Pattern**:
- `domain/` - Remesh domain definitions (pure logic, queries, commands, events)
- `domain/externs/` - External dependency interfaces (define contracts)
- `domain/impls/` - Concrete implementations of externs (WebRTC, storage, etc.)
- `domain/modules/` - Reusable domain sub-modules
### P2P Communication Architecture
**Two-Layer Room System**:
1. **ChatRoom** (Site-specific):
- RoomId: Hash of current page's origin
- Users on same site chat in isolated rooms
- Message types: Text, Like, Hate, SyncUser, SyncHistory
- History sync: Last 90 days (`SYNC_HISTORY_MAX_DAYS`)
- Message size limit: 256KiB (`WEB_RTC_MAX_MESSAGE_SIZE`)
2. **VirtualRoom** (Global):
- RoomId: `WEB_CHAT_VIRTUAL_ROOM` constant
- Cross-site user discovery
- Shares online user presence across different websites
- Message types: SyncUser only
**Connection Flow**:
1. User joins VirtualRoom (global presence)
2. User joins ChatRoom (site-specific, based on `location.origin`)
3. On peer join: Exchange SyncUser messages
4. Sync message history if peer's lastMessageTime is older
5. WebRTC data channels handle all message transport
### Storage Strategy
Three-tier storage implemented in `src/domain/impls/Storage.ts`:
- **LocalStorage** - Fast, synchronous access (volatile)
- **IndexDB** - Large data persistence (message history)
- **BrowserSyncStorage** - Cross-device user profile sync (8kb limit per key)
Key storage keys in `src/constants/config.ts`:
- `USER_INFO_STORAGE_KEY` - User profile (synced)
- `MESSAGE_LIST_STORAGE_KEY` - Message history (IndexDB)
- `APP_STATUS_STORAGE_KEY` - App UI state (local)
### Message Sync Logic
**Important sync behavior** (documented in ChatRoom.ts:337-355):
- New peer joins → existing peers with newer messages push history
- Only messages newer than peer's `lastMessageTime` are synced
- Messages chunked to respect WebRTC size limits
- Incremental sync (not full 90-day diff) - may result in incomplete history for peers joining at different times
## Code Organization
```
src/
├── app/ # WXT applications (content, background, options)
├── domain/ # Remesh domains (business logic)
│ ├── externs/ # External dependency interfaces
│ ├── impls/ # Concrete implementations
│ └── modules/ # Reusable domain modules
├── components/ # React UI components
│ ├── ui/ # shadcn/ui base components
│ └── magicui/ # Magic UI animated components
├── utils/ # Pure utility functions
├── constants/ # App constants and config
├── hooks/ # React hooks
├── messenger/ # Extension messaging (webext-bridge)
├── lib/ # Third-party library integrations
└── assets/ # Static assets (images, styles)
```
## Path Aliases
TypeScript paths configured in `tsconfig.json`:
- `@/*``./src/*`
Import example: `import { ChatRoomDomain } from '@/domain/ChatRoom'`
## Important Constants
In `src/constants/config.ts`:
- `MESSAGE_MAX_LENGTH = 500` - Max message length
- `MAX_AVATAR_SIZE = 5120` - Max avatar size (bytes) for sync storage
- `SYNC_HISTORY_MAX_DAYS = 90` - Message history retention
- `WEB_RTC_MAX_MESSAGE_SIZE = 262144` - 256KiB WebRTC limit
- `VIRTUAL_ROOM_ID = 'WEB_CHAT_VIRTUAL_ROOM'` - Global room identifier
## Browser Extension Specifics
**Manifest configuration** (`wxt.config.ts`):
- Permissions: `storage`, `notifications`, `tabs`
- Matches: `https://*/*`
- Excludes: localhost, 127.0.0.1, csdn.net, csdn.com
- Browser-specific manifests for Chrome and Firefox
**Content Script Injection**:
- Shadow DOM mode: `open`
- Position: `inline` in body (appended last)
- CSS isolation with `cssInjectionMode: 'ui'`
- Event isolation: keyup, keydown, keypress
## Working with Remesh Domains
**Creating/Using Domains**:
```typescript
// In content script
const store = Remesh.store({
externs: [
LocalStorageImpl,
IndexDBStorageImpl,
BrowserSyncStorageImpl,
ChatRoomImpl,
VirtualRoomImpl,
// ... other implementations
]
})
// In React component
const send = domain.useRemeshSend()
const userList = domain.useRemeshQuery(chatRoomDomain.query.UserListQuery())
// Send command
send(chatRoomDomain.command.SendTextMessageCommand('Hello'))
// Subscribe to event
domain.useRemeshEvent(chatRoomDomain.event.OnTextMessageEvent, (message) => {
console.log('New message:', message)
})
```
## Validation with Valibot
All runtime message validation uses Valibot (not Zod):
```typescript
import * as v from 'valibot'
const schema = v.object({ /* ... */ })
const isValid = v.safeParse(schema, data).success
```
## Linting & Git Hooks
- **Husky** pre-commit hooks configured
- **lint-staged** auto-fixes JS/TS files on commit
- **commitlint** enforces conventional commit messages
## Node Version
Minimum Node.js version: `>=20.0.0` (see `engines` in package.json)

View File

@@ -40,6 +40,12 @@ const AppButton: FC<AppButtonProps> = ({ className }) => {
const [menuOpen, setMenuOpen] = useState(false)
// Get current window size to recalculate position on resize
const windowSize = useWindowResize(({ width, height }) => {
// Reset to default position when window resizes
send(appStatusDomain.command.UpdatePositionCommand({ x: 50, y: 22 }))
})
const {
x,
y,
@@ -48,13 +54,10 @@ const AppButton: FC<AppButtonProps> = ({ className }) => {
initX: appPosition.x,
initY: appPosition.y,
minX: 50,
maxX: window.innerWidth - 50,
maxY: window.innerHeight - 22,
minY: 750
})
useWindowResize(({ width, height }) => {
send(appStatusDomain.command.UpdatePositionCommand({ x: width - 50, y: height - 22 }))
maxX: windowSize.width - 50,
maxY: windowSize.height - 22,
minY: 750,
reverse: true
})
useEffect(() => {
@@ -87,11 +90,11 @@ const AppButton: FC<AppButtonProps> = ({ className }) => {
return (
<div
ref={appMenuRef}
className={cn('fixed bottom-5 right-5 z-infinity grid w-min select-none justify-center gap-y-3', className)}
className={cn('fixed z-infinity grid w-min select-none justify-center gap-y-3', className)}
style={{
left: `calc(${appPosition.x}px)`,
bottom: `calc(100vh - ${appPosition.y}px)`,
transform: 'translateX(-50%)'
right: `${x}px`,
bottom: `${y}px`,
transform: 'translateX(50%)'
}}
>
<AnimatePresence>

View File

@@ -16,9 +16,14 @@ const AppMain: FC<AppMainProps> = ({ children, className }) => {
const appOpenStatus = useRemeshQuery(appStatusDomain.query.OpenQuery())
const { x, y } = useRemeshQuery(appStatusDomain.query.PositionQuery())
const { width } = useWindowResize()
const { width, height } = useWindowResize()
const isOnRightSide = x >= width / 2 + 50
// Position x,y is offset from bottom-right corner
// Convert to absolute position from left for comparison
const absoluteX = width - x
const absoluteY = height - y
const isOnRightSide = absoluteX >= width / 2 + 50
const { size, setRef } = useResizable({
initSize: Math.max(375, width / 6),
@@ -41,8 +46,8 @@ const AppMain: FC<AppMainProps> = ({ children, className }) => {
onAnimationStart={() => setAnimationComplete(false)}
style={{
width: `${size}px`,
left: `${x}px`,
bottom: `calc(100vh - ${y}px + 22px)`
left: `${absoluteX}px`,
bottom: `calc(100vh - ${absoluteY}px + 22px)`
}}
className={cn(
`fixed inset-y-10 right-10 z-infinity mb-0 mt-auto box-border grid max-h-[min(calc(100vh_-60px),_1000px)] min-h-[375px] grid-flow-col grid-rows-[auto_1fr_auto] rounded-xl bg-slate-50 dark:bg-slate-950 font-sans shadow-2xl`,

View File

@@ -1,5 +1,5 @@
import type { ChangeEvent, KeyboardEvent, ClipboardEvent } from 'react'
import { useMemo, useRef, useState, type FC } from 'react'
import { useMemo, useRef, useState, startTransition, type FC } from 'react'
import { CornerDownLeftIcon } from 'lucide-react'
import { useRemeshDomain, useRemeshQuery, useRemeshSend } from 'remesh-react'
import MessageInput from '../../components/message-input'
@@ -40,13 +40,15 @@ const Footer: FC = () => {
const [autoCompleteListShow, setAutoCompleteListShow] = useState(false)
const [scrollParentRef, setScrollParentRef] = useState<HTMLDivElement | null>(null)
const autoCompleteListRef = useRef<HTMLDivElement>(null)
const { setRef: setAutoCompleteListRef } = useTriggerAway(['click'], () => setAutoCompleteListShow(false))
const shareAutoCompleteListRef = useShareRef(setAutoCompleteListRef, autoCompleteListRef)
const { setRef: setAutoCompleteListRef } = useTriggerAway<HTMLDivElement>(['click'], () =>
setAutoCompleteListShow(false)
)
const shareAutoCompleteListRef = useShareRef<HTMLDivElement>(setAutoCompleteListRef, autoCompleteListRef)
const isComposing = useRef(false)
const virtuosoRef = useRef<VirtuosoHandle>(null)
const [inputLoading, setInputLoading] = useState(false)
const shareRef = useShareRef(inputRef, setRef)
const shareRef = useShareRef<HTMLTextAreaElement | null>(inputRef, setRef)
/**
* When inserting a username using the @ syntax, record the username's position information and the mapping relationship between the position information and userId to distinguish between users with the same name.
@@ -146,8 +148,13 @@ const Footer: FC = () => {
return send(toastDomain.command.WarningCommand('Message size cannot exceed 256KiB.'))
}
send(chatRoomDomain.command.SendTextMessageCommand({ body: transformedMessage, atUsers }))
send(messageInputDomain.command.ClearCommand())
// Send message with lower priority to prevent blocking UI
startTransition(() => {
send([
chatRoomDomain.command.SendTextMessageCommand({ body: transformedMessage, atUsers }),
messageInputDomain.command.ClearCommand()
])
})
}
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {

View File

@@ -1,4 +1,4 @@
import { type FC } from 'react'
import { type FC, useMemo } from 'react'
import { useRemeshDomain, useRemeshQuery, useRemeshSend } from 'remesh-react'
import MessageList from '../../components/message-list'
@@ -15,18 +15,23 @@ const Main: FC = () => {
const userInfoDomain = useRemeshDomain(UserInfoDomain())
const userInfo = useRemeshQuery(userInfoDomain.query.UserInfoQuery())
const _messageList = useRemeshQuery(messageListDomain.query.ListQuery())
const messageList = _messageList
.map((message) => {
if (message.type === MessageType.Normal) {
return {
...message,
like: message.likeUsers.some((likeUser) => likeUser.userId === userInfo?.id),
hate: message.hateUsers.some((hateUser) => hateUser.userId === userInfo?.id)
}
}
return message
})
.toSorted((a, b) => a.sendTime - b.sendTime)
const messageList = useMemo(
() =>
_messageList
.map((message) => {
if (message.type === MessageType.Normal) {
return {
...message,
like: message.likeUsers.some((likeUser) => likeUser.userId === userInfo?.id),
hate: message.hateUsers.some((hateUser) => hateUser.userId === userInfo?.id)
}
}
return message
})
.toSorted((a, b) => a.sendTime - b.sendTime),
[_messageList, userInfo?.id]
)
const handleLikeChange = (messageId: string) => {
send(chatRoomDomain.command.SendLikeMessageCommand(messageId))

View File

@@ -12,10 +12,11 @@ export interface AppStatus {
position: { x: number; y: number }
}
// Position is stored as offset from bottom-right corner
export const defaultStatusState = {
open: false,
unread: 0,
position: { x: window.innerWidth - 50, y: window.innerHeight - 22 }
position: { x: 50, y: 22 }
}
const AppStatusDomain = Remesh.domain({

View File

@@ -8,24 +8,46 @@ export interface DargOptions {
minX: number
maxY: number
minY: number
reverse?: boolean // If true, position is calculated from bottom-right corner
}
const useDraggable = (options: DargOptions) => {
const { initX, initY, maxX = 0, minX = 0, maxY = 0, minY = 0 } = options
const { initX, initY, maxX = 0, minX = 0, maxY = 0, minY = 0, reverse = false } = options
const mousePosition = useRef({ x: 0, y: 0 })
const positionRef = useRef({ x: clamp(initX, minX, maxX), y: clamp(initY, minY, maxY) })
const [position, setPosition] = useState(positionRef.current)
// Convert to internal coordinates if reverse mode
const toInternal = (x: number, y: number) => {
if (!reverse) return { x, y }
return {
x: window.innerWidth - x,
y: window.innerHeight - y
}
}
// Convert from internal coordinates if reverse mode
const fromInternal = (x: number, y: number) => {
if (!reverse) return { x, y }
return {
x: window.innerWidth - x,
y: window.innerHeight - y
}
}
const internalInit = toInternal(initX, initY)
const positionRef = useRef({ x: clamp(internalInit.x, minX, maxX), y: clamp(internalInit.y, minY, maxY) })
const [position, setPosition] = useState(() => fromInternal(positionRef.current.x, positionRef.current.y))
useEffect(() => {
const newPosition = { x: clamp(initX, minX, maxX), y: clamp(initY, minY, maxY) }
if (JSON.stringify(newPosition) !== JSON.stringify(position)) {
const internal = toInternal(initX, initY)
const newPosition = { x: clamp(internal.x, minX, maxX), y: clamp(internal.y, minY, maxY) }
if (JSON.stringify(newPosition) !== JSON.stringify(positionRef.current)) {
startTransition(() => {
positionRef.current = newPosition
setPosition(newPosition)
setPosition(fromInternal(newPosition.x, newPosition.y))
})
}
}, [initX, initY, maxX, minX, maxY, minY])
}, [initX, initY, maxX, minX, maxY, minY, reverse])
const isMove = useRef(false)
@@ -52,12 +74,12 @@ const useDraggable = (options: DargOptions) => {
const y = clamp(delta.y, minY, maxY)
startTransition(() => {
positionRef.current = { x, y }
setPosition({ x, y })
setPosition(fromInternal(x, y))
})
}
}
},
[minX, maxX, minY, maxY]
[minX, maxX, minY, maxY, reverse]
)
const handleEnd = useCallback(() => {

View File

@@ -1,22 +1,23 @@
import type { ForwardedRef, MutableRefObject, RefCallback } from 'react'
import type { Ref } from 'react'
import { useCallback } from 'react'
const useShareRef = <T extends HTMLElement | null>(
...refs: (MutableRefObject<T> | ForwardedRef<T> | RefCallback<T>)[]
) => {
const setRef = useCallback(
(node: T) =>
refs.forEach((ref) => {
if (typeof ref === 'function') {
ref(node)
} else if (ref) {
ref.current = node
}
}),
export const setRef = <T>(ref: Ref<T> | undefined, value: T) => {
if (typeof ref === 'function') {
return ref(value)
} else if (ref !== null && ref !== undefined) {
ref.current = value
}
}
const useShareRef = <T>(...refs: (Ref<T> | undefined)[]) => {
return useCallback(
(node: T) => {
const cleanups = refs.map((ref) => setRef(ref, node))
return () =>
cleanups.forEach((cleanup, index) => (typeof cleanup === 'function' ? cleanup() : setRef(refs[index], null)))
},
[...refs]
)
return setRef
}
export default useShareRef

View File

@@ -6,8 +6,11 @@ export type Events = Array<keyof GlobalEventHandlersEventMap>
/**
* @see https://github.com/streamich/react-use/pull/2528
*/
const useTriggerAway = <E extends Event = Event>(events: Events, callback: (event: E) => void) => {
const handleRef = useRef<HTMLElement | null>(null)
const useTriggerAway = <T extends Element = Element, E extends Event = Event>(
events: Events,
callback: (event: E) => void
) => {
const handleRef = useRef<T | null>(null)
const handler = (event: SafeAny) => {
const rootNode = handleRef.current?.getRootNode()
@@ -22,7 +25,7 @@ const useTriggerAway = <E extends Event = Event>(events: Events, callback: (even
* | | |
* |- on(document) -|- on(shadowRoot) -|
*/
const setRef: RefCallback<HTMLElement | null> = useCallback(
const setRef: RefCallback<T> = useCallback(
(node) => {
if (handleRef.current) {
const rootNode = handleRef.current.getRootNode()