feat: support @user syntax
This commit is contained in:
parent
4eba638a36
commit
bef576a77b
31 changed files with 722 additions and 202 deletions
2
.github/workflows/cd.yml
vendored
2
.github/workflows/cd.yml
vendored
|
@ -21,7 +21,7 @@ jobs:
|
|||
- run: pnpm install --ignore-scripts
|
||||
- run: pnpm wxt prepare
|
||||
- run: pnpm run lint
|
||||
- run: pnpm run tsc
|
||||
- run: pnpm run check
|
||||
|
||||
release:
|
||||
needs: linter
|
||||
|
|
|
@ -1,2 +1,2 @@
|
|||
|
||||
pnpm lint-staged && pnpm tsc
|
||||
pnpm lint-staged && pnpm check
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import type { Linter } from 'eslint'
|
||||
// import type { Linter } from 'eslint'
|
||||
import globals from 'globals'
|
||||
import pluginJs from '@eslint/js'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
|
|
@ -15,7 +15,7 @@
|
|||
"pack:firefox": "wxt zip -b firefox",
|
||||
"lint": "eslint --fix --flag unstable_ts_config",
|
||||
"clear": "rimraf .output",
|
||||
"tsc": "tsc --noEmit",
|
||||
"check": "tsc --noEmit",
|
||||
"prepare": "husky",
|
||||
"postinstall": "wxt prepare"
|
||||
},
|
||||
|
@ -54,6 +54,8 @@
|
|||
"@radix-ui/react-icons": "^1.3.0",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-popover": "^1.1.2",
|
||||
"@radix-ui/react-portal": "^1.1.2",
|
||||
"@radix-ui/react-presence": "^1.1.1",
|
||||
"@radix-ui/react-radio-group": "^1.2.1",
|
||||
"@radix-ui/react-scroll-area": "^1.2.0",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
|
|
|
@ -38,6 +38,12 @@ importers:
|
|||
'@radix-ui/react-popover':
|
||||
specifier: ^1.1.2
|
||||
version: 1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-portal':
|
||||
specifier: ^1.1.2
|
||||
version: 1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-presence':
|
||||
specifier: ^1.1.1
|
||||
version: 1.1.1(@types/react-dom@18.3.1)(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-radio-group':
|
||||
specifier: ^1.2.1
|
||||
version: 1.2.1(@types/react-dom@18.3.1)(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
|
|
|
@ -1,8 +1,7 @@
|
|||
import { forwardRef, type ChangeEvent, CompositionEvent, type KeyboardEvent } from 'react'
|
||||
|
||||
import { Textarea } from '@/components/ui/Textarea'
|
||||
import { Markdown } from '@/components/Markdown'
|
||||
import { cn } from '@/utils'
|
||||
import { Textarea } from '@/components/ui/Textarea'
|
||||
import { ScrollArea } from '@/components/ui/ScrollArea'
|
||||
|
||||
export interface MessageInputProps {
|
||||
|
@ -13,11 +12,16 @@ export interface MessageInputProps {
|
|||
autoFocus?: boolean
|
||||
disabled?: boolean
|
||||
onInput?: (e: ChangeEvent<HTMLTextAreaElement>) => void
|
||||
onEnter?: (e: KeyboardEvent<HTMLTextAreaElement>) => void
|
||||
onKeyDown?: (e: KeyboardEvent<HTMLTextAreaElement>) => void
|
||||
onCompositionStart?: (e: CompositionEvent<HTMLTextAreaElement>) => void
|
||||
onCompositionEnd?: (e: CompositionEvent<HTMLTextAreaElement>) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Need @ syntax highlighting? Waiting for textarea to support Highlight API
|
||||
*
|
||||
* @see https://github.com/w3c/csswg-drafts/issues/4603
|
||||
*/
|
||||
const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(
|
||||
(
|
||||
{
|
||||
|
@ -25,44 +29,33 @@ const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(
|
|||
className,
|
||||
maxLength = 500,
|
||||
onInput,
|
||||
onEnter,
|
||||
onKeyDown,
|
||||
onCompositionStart,
|
||||
onCompositionEnd,
|
||||
preview,
|
||||
autoFocus,
|
||||
disabled
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !(e.shiftKey || e.ctrlKey || e.altKey || e.metaKey)) {
|
||||
e.preventDefault()
|
||||
onEnter?.(e)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('relative', className)}>
|
||||
{preview ? (
|
||||
<Markdown className="max-h-28 rounded-lg border border-input bg-gray-50 2xl:max-h-40">{value}</Markdown>
|
||||
) : (
|
||||
<ScrollArea className="box-border max-h-28 w-full rounded-lg border border-input bg-background ring-offset-background focus-within:ring-1 focus-within:ring-ring 2xl:max-h-40">
|
||||
<Textarea
|
||||
ref={ref}
|
||||
onKeyDown={handleKeyDown}
|
||||
autoFocus={autoFocus}
|
||||
maxLength={maxLength}
|
||||
className="box-border resize-none whitespace-pre-wrap break-words border-none bg-gray-50 pb-5 [field-sizing:content] focus:ring-0 focus:ring-offset-0"
|
||||
rows={2}
|
||||
value={value}
|
||||
onCompositionStart={onCompositionStart}
|
||||
onCompositionEnd={onCompositionEnd}
|
||||
placeholder="Type your message here."
|
||||
onInput={onInput}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</ScrollArea>
|
||||
)}
|
||||
<ScrollArea className="box-border max-h-28 w-full rounded-lg border border-input bg-background ring-offset-background focus-within:ring-1 focus-within:ring-ring 2xl:max-h-40">
|
||||
<Textarea
|
||||
ref={ref}
|
||||
onKeyDown={onKeyDown}
|
||||
autoFocus={autoFocus}
|
||||
maxLength={maxLength}
|
||||
className="box-border resize-none whitespace-pre-wrap break-words border-none bg-gray-50 pb-5 [field-sizing:content] [word-break:break-word] focus:ring-0 focus:ring-offset-0"
|
||||
rows={2}
|
||||
value={value}
|
||||
spellCheck={false}
|
||||
onCompositionStart={onCompositionStart}
|
||||
onCompositionEnd={onCompositionEnd}
|
||||
placeholder="Type your message here."
|
||||
onInput={onInput}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</ScrollArea>
|
||||
<div className="absolute bottom-1 right-3 rounded-lg text-xs text-slate-400">
|
||||
{value?.length ?? 0}/{maxLength}
|
||||
</div>
|
||||
|
|
|
@ -25,6 +25,24 @@ const MessageItem: FC<MessageItemProps> = (props) => {
|
|||
const handleHateChange = (checked: boolean) => {
|
||||
props.onHateChange?.(checked)
|
||||
}
|
||||
|
||||
let content = props.data.body
|
||||
|
||||
// Check if the field exists, compatible with old data
|
||||
if (props.data.atUsers) {
|
||||
const atUserPositions = props.data.atUsers.flatMap((user) =>
|
||||
user.positions.map((position) => ({ username: user.username, userId: user.userId, position }))
|
||||
)
|
||||
|
||||
// Replace from back to front according to position to avoid affecting previous indices
|
||||
atUserPositions
|
||||
.sort((a, b) => b.position[0] - a.position[0])
|
||||
.forEach(({ position, username }) => {
|
||||
const [start, end] = position
|
||||
content = `${content.slice(0, start)} **@${username}** ${content.slice(end + 1)}`
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-index={props.index}
|
||||
|
@ -41,7 +59,7 @@ const MessageItem: FC<MessageItemProps> = (props) => {
|
|||
</div>
|
||||
<div>
|
||||
<div className="pb-2">
|
||||
<Markdown>{props.data.body}</Markdown>
|
||||
<Markdown>{content}</Markdown>
|
||||
</div>
|
||||
<div className="grid grid-flow-col justify-end gap-x-2 leading-none">
|
||||
<LikeButton
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { type FC, useState, type MouseEvent, useRef, useEffect, useLayoutEffect } from 'react'
|
||||
import { type FC, useState, type MouseEvent, useLayoutEffect } from 'react'
|
||||
import { SettingsIcon, MoonIcon, SunIcon, HandIcon } from 'lucide-react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
|
||||
|
@ -6,7 +6,7 @@ import { useRemeshDomain, useRemeshQuery, useRemeshSend } from 'remesh-react'
|
|||
import { Button } from '@/components/ui/Button'
|
||||
import { EVENT } from '@/constants/event'
|
||||
import UserInfoDomain from '@/domain/UserInfo'
|
||||
import useClickAway from '@/hooks/useClickAway'
|
||||
import useTriggerAway from '@/hooks/useTriggerAway'
|
||||
import { checkSystemDarkMode, cn } from '@/utils'
|
||||
import ToastDomain from '@/domain/Toast'
|
||||
import LogoIcon0 from '@/assets/images/logo-0.svg'
|
||||
|
@ -40,11 +40,13 @@ const AppButton: FC = () => {
|
|||
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const { width, height } = useWindowSize()
|
||||
|
||||
const { x, y, ref } = useDarg({
|
||||
const {
|
||||
x,
|
||||
y,
|
||||
setRef: appButtonRef
|
||||
} = useDarg({
|
||||
initX: appPosition.x,
|
||||
initY: appPosition.y,
|
||||
minX: 44,
|
||||
|
@ -57,9 +59,7 @@ const AppButton: FC = () => {
|
|||
appStatusLoadIsFinished && send(appStatusDomain.command.UpdatePositionCommand({ x, y }))
|
||||
}, [x, y])
|
||||
|
||||
useClickAway(menuRef, () => {
|
||||
setMenuOpen(false)
|
||||
}, ['click'])
|
||||
const { setRef: appMenuRef } = useTriggerAway(['click'], () => setMenuOpen(false))
|
||||
|
||||
const handleToggleMenu = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault()
|
||||
|
@ -85,7 +85,7 @@ const AppButton: FC = () => {
|
|||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
ref={appMenuRef}
|
||||
className="fixed bottom-5 right-5 z-infinity grid w-min select-none justify-center gap-y-3"
|
||||
style={{
|
||||
left: `calc(${appPosition.x}px)`,
|
||||
|
@ -122,7 +122,7 @@ const AppButton: FC = () => {
|
|||
<Button onClick={handleOpenOptionsPage} variant="outline" className="size-10 rounded-full p-0 shadow">
|
||||
<SettingsIcon size={20} />
|
||||
</Button>
|
||||
<Button ref={ref} variant="outline" className="size-10 cursor-grab rounded-full p-0 shadow">
|
||||
<Button ref={appButtonRef} variant="outline" className="size-10 cursor-grab rounded-full p-0 shadow">
|
||||
<HandIcon size={20} />
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
|
|
@ -19,7 +19,7 @@ const AppMain: FC<AppMainProps> = ({ children }) => {
|
|||
|
||||
const isOnRightSide = x >= width / 2 + 44
|
||||
|
||||
const { size, ref } = useResizable({
|
||||
const { size, setRef } = useResizable({
|
||||
initSize: Math.max(375, width / 6),
|
||||
maxSize: Math.max(Math.min(750, width / 3), 375),
|
||||
minSize: Math.max(375, width / 6),
|
||||
|
@ -50,7 +50,7 @@ const AppMain: FC<AppMainProps> = ({ children }) => {
|
|||
>
|
||||
{children}
|
||||
<div
|
||||
ref={ref}
|
||||
ref={setRef}
|
||||
className={cn(
|
||||
'absolute inset-y-3 z-20 w-1 cursor-ew-resize rounded-xl bg-slate-100 opacity-0 shadow transition-opacity duration-200 ease-in hover:opacity-100',
|
||||
isOnRightSide ? '-left-0.5' : '-right-0.5'
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { ChangeEvent, useRef, type FC } from 'react'
|
||||
import { ChangeEvent, useMemo, useRef, useState, KeyboardEvent, type FC } from 'react'
|
||||
import { CornerDownLeftIcon } from 'lucide-react'
|
||||
import { useRemeshDomain, useRemeshQuery, useRemeshSend } from 'remesh-react'
|
||||
import MessageInput from '../../components/MessageInput'
|
||||
|
@ -7,48 +7,289 @@ import { Button } from '@/components/ui/Button'
|
|||
import MessageInputDomain from '@/domain/MessageInput'
|
||||
import { MESSAGE_MAX_LENGTH } from '@/constants/config'
|
||||
import RoomDomain from '@/domain/Room'
|
||||
import useCursorPosition from '@/hooks/useCursorPosition'
|
||||
import useShareRef from '@/hooks/useShareRef'
|
||||
import { Presence } from '@radix-ui/react-presence'
|
||||
import { Portal } from '@radix-ui/react-portal'
|
||||
import useTriggerAway from '@/hooks/useTriggerAway'
|
||||
import { ScrollArea } from '@/components/ui/ScrollArea'
|
||||
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso'
|
||||
import UserInfoDomain from '@/domain/UserInfo'
|
||||
import { cn, getTextSimilarity } from '@/utils'
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/Avatar'
|
||||
import { AvatarImage } from '@radix-ui/react-avatar'
|
||||
import ToastDomain from '@/domain/Toast'
|
||||
|
||||
const Footer: FC = () => {
|
||||
const send = useRemeshSend()
|
||||
const toastDomain = useRemeshDomain(ToastDomain())
|
||||
const roomDomain = useRemeshDomain(RoomDomain())
|
||||
const messageInputDomain = useRemeshDomain(MessageInputDomain())
|
||||
const message = useRemeshQuery(messageInputDomain.query.MessageQuery())
|
||||
const userInfoDomain = useRemeshDomain(UserInfoDomain())
|
||||
const userInfo = useRemeshQuery(userInfoDomain.query.UserInfoQuery())
|
||||
const userList = useRemeshQuery(roomDomain.query.UserListQuery())
|
||||
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null)
|
||||
const isComposing = useRef(false)
|
||||
const { x, y, selectionStart, selectionEnd, setRef } = useCursorPosition()
|
||||
|
||||
const handleInput = (e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
send(messageInputDomain.command.InputCommand(e.target.value))
|
||||
}
|
||||
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 isComposing = useRef(false)
|
||||
const virtuosoRef = useRef<VirtuosoHandle>(null)
|
||||
|
||||
const shareRef = useShareRef(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.
|
||||
*/
|
||||
const atUserRecord = useRef<Map<string, Set<[number, number]>>>(new Map())
|
||||
|
||||
const updateAtUserAtRecord = useMemo(
|
||||
() => (message: string, start: number, end: number, offset: number, atUserId?: string) => {
|
||||
const positions: [number, number] = [start, end]
|
||||
|
||||
// If the editing position is before the end position of @user, update the editing position.
|
||||
// "@user" => "E@user"
|
||||
// "@user" => "@useEr"
|
||||
// "@user" => "@user @user"
|
||||
atUserRecord.current.forEach((item, userId) => {
|
||||
const positionList = [...item].map<[number, number]>((item) => {
|
||||
const inBefore = Math.min(start, end) <= item[1]
|
||||
return inBefore ? [item[0] + offset + (end - start), item[1] + offset + (end - start)] : item
|
||||
})
|
||||
atUserRecord.current.set(userId, new Set(positionList))
|
||||
})
|
||||
|
||||
// Insert a new @user record
|
||||
if (atUserId) {
|
||||
atUserRecord.current.set(atUserId, atUserRecord.current.get(atUserId)?.add(positions) ?? new Set([positions]))
|
||||
}
|
||||
|
||||
// After moving, check if the @user in the message matches the saved position record. If not, it means the @user has been edited, so delete that record.
|
||||
// Filter out records where the stored position does not match the actual position.
|
||||
atUserRecord.current.forEach((item, userId) => {
|
||||
// Pre-calculate the offset after InputCommand
|
||||
const positionList = [...item].filter((item) => {
|
||||
const username = message.slice(item[0], item[1] + 1)
|
||||
return username === `@${userList.find((user) => user.userId === userId)?.username}`
|
||||
})
|
||||
if (positionList.length) {
|
||||
atUserRecord.current.set(userId, new Set(positionList))
|
||||
} else {
|
||||
atUserRecord.current.delete(userId)
|
||||
}
|
||||
})
|
||||
},
|
||||
[userList]
|
||||
)
|
||||
|
||||
const [selectedUserIndex, setSelectedUserIndex] = useState(0)
|
||||
const [searchNameKeyword, setSearchNameKeyword] = useState('')
|
||||
|
||||
const autoCompleteList = useMemo(() => {
|
||||
return userList
|
||||
.filter((user) => user.userId !== userInfo?.id)
|
||||
.map((item) => ({
|
||||
...item,
|
||||
similarity: getTextSimilarity(searchNameKeyword.toLowerCase(), item.username.toLowerCase())
|
||||
}))
|
||||
.toSorted((a, b) => b.similarity - a.similarity)
|
||||
}, [searchNameKeyword, userList, userInfo])
|
||||
|
||||
const selectedUser = autoCompleteList.find((_, index) => index === selectedUserIndex)!
|
||||
|
||||
const handleSend = () => {
|
||||
if (isComposing.current) return
|
||||
if (!message.trim()) return
|
||||
send(roomDomain.command.SendTextMessageCommand(message.trim()))
|
||||
if (!`${message}`.trim()) {
|
||||
return send(toastDomain.command.WarningCommand('Message cannot be empty.'))
|
||||
}
|
||||
|
||||
const atUsers = [...atUserRecord.current]
|
||||
.map(([userId, positions]) => {
|
||||
const user = userList.find((user) => user.userId === userId)
|
||||
return (user ? { ...user, positions: [...positions] } : undefined)!
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
send(roomDomain.command.SendTextMessageCommand({ body: message, atUsers }))
|
||||
send(messageInputDomain.command.ClearCommand())
|
||||
}
|
||||
|
||||
const handleEmojiSelect = (emoji: string) => {
|
||||
send(messageInputDomain.command.InputCommand(`${message}${emoji}`))
|
||||
inputRef.current?.focus()
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (autoCompleteListShow && autoCompleteList.length) {
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
const length = autoCompleteList.length
|
||||
const prevIndex = selectedUserIndex
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
const index = (prevIndex + 1) % length
|
||||
setSelectedUserIndex(index)
|
||||
virtuosoRef.current?.scrollIntoView({ index })
|
||||
e.preventDefault()
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
const index = (prevIndex - 1 + length) % length
|
||||
setSelectedUserIndex(index)
|
||||
virtuosoRef.current?.scrollIntoView({ index })
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
if (['Escape', 'Backspace', 'Delete', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
|
||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
const isDeleteAt = message.at(selectionStart - 1) === '@'
|
||||
setAutoCompleteListShow(!isDeleteAt)
|
||||
} else {
|
||||
setAutoCompleteListShow(false)
|
||||
}
|
||||
setSelectedUserIndex(0)
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === 'Enter' && !(e.shiftKey || e.ctrlKey || e.altKey || e.metaKey)) {
|
||||
if (isComposing.current) return
|
||||
|
||||
if (autoCompleteListShow && autoCompleteList.length) {
|
||||
handleInjectAtSyntax(selectedUser.username)
|
||||
} else {
|
||||
handleSend()
|
||||
}
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
const handleInput = (e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const currentMessage = e.target.value
|
||||
|
||||
if (autoCompleteListShow) {
|
||||
const target = e.target as HTMLTextAreaElement
|
||||
if (target.value) {
|
||||
const atIndex = target.value.lastIndexOf('@', selectionEnd - 1)
|
||||
if (atIndex !== -1) {
|
||||
const keyword = target.value.slice(atIndex + 1, selectionEnd)
|
||||
setSearchNameKeyword(keyword)
|
||||
setSelectedUserIndex(0)
|
||||
virtuosoRef.current?.scrollIntoView({ index: 0 })
|
||||
}
|
||||
} else {
|
||||
setAutoCompleteListShow(false)
|
||||
}
|
||||
}
|
||||
|
||||
const event = e.nativeEvent as InputEvent
|
||||
|
||||
if (event.data === '@' && autoCompleteList.length) {
|
||||
setAutoCompleteListShow(true)
|
||||
}
|
||||
|
||||
// Pre-calculate the offset after InputCommand
|
||||
const start = selectionStart
|
||||
const end = selectionStart + currentMessage.length - message.length
|
||||
|
||||
updateAtUserAtRecord(currentMessage, start, end, 0)
|
||||
|
||||
send(messageInputDomain.command.InputCommand(currentMessage))
|
||||
}
|
||||
|
||||
const handleInjectEmoji = (emoji: string) => {
|
||||
const newMessage = `${message.slice(0, selectionEnd)}${emoji}${message.slice(selectionEnd)}`
|
||||
|
||||
// Pre-calculate the offset after InputCommand
|
||||
const start = selectionStart
|
||||
const end = selectionEnd + newMessage.length - message.length
|
||||
|
||||
updateAtUserAtRecord(newMessage, start, end, 0)
|
||||
|
||||
send(messageInputDomain.command.InputCommand(newMessage))
|
||||
|
||||
requestIdleCallback(() => {
|
||||
inputRef.current?.setSelectionRange(end, end)
|
||||
inputRef.current?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
const handleInjectAtSyntax = (username: string) => {
|
||||
const atIndex = message.lastIndexOf('@', selectionEnd - 1)
|
||||
// Determine if there is a space before @
|
||||
const hasBeforeSpace = message.slice(atIndex - 1, atIndex) === ' '
|
||||
const hasAfterSpace = message.slice(selectionEnd, selectionEnd + 1) === ' '
|
||||
|
||||
const atText = `${hasBeforeSpace ? '' : ' '}@${username}${hasAfterSpace ? '' : ' '}`
|
||||
const newMessage = message.slice(0, atIndex) + `${atText}` + message.slice(selectionEnd)
|
||||
|
||||
setAutoCompleteListShow(false)
|
||||
|
||||
// Pre-calculate the offset after InputCommand
|
||||
const start = atIndex
|
||||
const end = selectionStart + newMessage.length - message.length
|
||||
|
||||
const atUserPosition: [number, number] = [start + (hasBeforeSpace ? 0 : +1), end - 1 + (hasAfterSpace ? 0 : -1)]
|
||||
|
||||
// Calculate the difference after replacing @text with @user
|
||||
const offset = newMessage.length - message.length - (atUserPosition[1] - atUserPosition[0])
|
||||
|
||||
updateAtUserAtRecord(newMessage, ...atUserPosition, offset, selectedUser.userId)
|
||||
|
||||
send(messageInputDomain.command.InputCommand(newMessage))
|
||||
requestIdleCallback(() => {
|
||||
inputRef.current!.setSelectionRange(end, end)
|
||||
inputRef.current!.focus()
|
||||
})
|
||||
}
|
||||
|
||||
const root = document.querySelector(__NAME__)?.shadowRoot
|
||||
|
||||
return (
|
||||
<div className="relative z-10 grid gap-y-2 px-4 pb-4 pt-2 before:pointer-events-none before:absolute before:inset-x-4 before:-top-2 before:h-2 before:bg-gradient-to-t before:from-slate-50 before:from-30% before:to-transparent">
|
||||
<Presence present={autoCompleteListShow}>
|
||||
<Portal
|
||||
container={root}
|
||||
ref={shareAutoCompleteListRef}
|
||||
className="fixed z-infinity w-36 -translate-y-full rounded-lg border bg-popover text-popover-foreground shadow-md duration-300 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95"
|
||||
style={{ left: `min(${x}px, 100vw - 212px)`, top: `${y}px` }}
|
||||
>
|
||||
<ScrollArea className="max-h-[204px] min-h-9 p-1" ref={setScrollParentRef}>
|
||||
<Virtuoso
|
||||
ref={virtuosoRef}
|
||||
data={autoCompleteList}
|
||||
defaultItemHeight={28}
|
||||
context={{ currentItemIndex: selectedUserIndex }}
|
||||
customScrollParent={scrollParentRef!}
|
||||
itemContent={(index, user) => (
|
||||
<div
|
||||
key={user.userId}
|
||||
onClick={() => handleInjectAtSyntax(user.username)}
|
||||
onMouseEnter={() => setSelectedUserIndex(index)}
|
||||
className={cn(
|
||||
'flex cursor-pointer select-none items-center gap-x-2 rounded-md px-2 py-1.5 outline-none',
|
||||
{
|
||||
'bg-accent text-accent-foreground': index === selectedUserIndex
|
||||
}
|
||||
)}
|
||||
>
|
||||
<Avatar className="size-4 shrink-0">
|
||||
<AvatarImage className="size-full" src={user.userAvatar} alt="avatar" />
|
||||
<AvatarFallback>{user.username.at(0)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 truncate text-xs text-slate-500">{user.username}</div>
|
||||
</div>
|
||||
)}
|
||||
></Virtuoso>
|
||||
</ScrollArea>
|
||||
</Portal>
|
||||
</Presence>
|
||||
<MessageInput
|
||||
ref={inputRef}
|
||||
ref={shareRef}
|
||||
value={message}
|
||||
onEnter={handleSend}
|
||||
onInput={handleInput}
|
||||
onCompositionEnd={() => (isComposing.current = false)}
|
||||
onCompositionStart={() => (isComposing.current = true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
maxLength={MESSAGE_MAX_LENGTH}
|
||||
></MessageInput>
|
||||
<div className="flex items-center">
|
||||
<EmojiButton onSelect={handleEmojiSelect}></EmojiButton>
|
||||
{/* <Button variant="ghost" size="icon">
|
||||
<ImageIcon size={20} />
|
||||
</Button> */}
|
||||
<EmojiButton onSelect={handleInjectEmoji}></EmojiButton>
|
||||
<Button className="ml-auto" size="sm" onClick={handleSend}>
|
||||
<span className="mr-2">Send</span>
|
||||
<CornerDownLeftIcon className="text-slate-400" size={12}></CornerDownLeftIcon>
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { type FC } from 'react'
|
||||
import { useState, type FC } from 'react'
|
||||
import { Globe2Icon } from 'lucide-react'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/Avatar'
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/HoverCard'
|
||||
|
@ -7,6 +7,7 @@ import { cn, getSiteInfo } from '@/utils'
|
|||
import { useRemeshDomain, useRemeshQuery } from 'remesh-react'
|
||||
import RoomDomain from '@/domain/Room'
|
||||
import { ScrollArea } from '@/components/ui/ScrollArea'
|
||||
import { Virtuoso } from 'react-virtuoso'
|
||||
|
||||
const Header: FC = () => {
|
||||
const siteInfo = getSiteInfo()
|
||||
|
@ -14,6 +15,8 @@ const Header: FC = () => {
|
|||
const userList = useRemeshQuery(roomDomain.query.UserListQuery())
|
||||
const onlineCount = userList.length
|
||||
|
||||
const [scrollParentRef, setScrollParentRef] = useState<HTMLDivElement | null>(null)
|
||||
|
||||
return (
|
||||
<div className="z-10 grid h-12 grid-flow-col grid-cols-[theme('spacing.20')_auto_theme('spacing.20')] items-center justify-between rounded-t-xl bg-white px-4 backdrop-blur-lg">
|
||||
<Avatar className="size-8">
|
||||
|
@ -69,17 +72,22 @@ const Header: FC = () => {
|
|||
</div>
|
||||
</Button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="w-44 rounded-lg px-0 py-2">
|
||||
<ScrollArea className="max-h-80">
|
||||
{userList.map((user) => (
|
||||
<div className="flex items-center gap-x-2 px-4 py-2 [content-visibility:auto]" key={user.userId}>
|
||||
<Avatar className="size-6 shrink-0">
|
||||
<AvatarImage src={user.userAvatar} alt="avatar" />
|
||||
<AvatarFallback>{user.username.at(0)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 truncate text-sm text-slate-500">{user.username}</div>
|
||||
</div>
|
||||
))}
|
||||
<HoverCardContent className="w-36 rounded-lg p-0">
|
||||
<ScrollArea className="max-h-[204px] min-h-9 p-1" ref={setScrollParentRef}>
|
||||
<Virtuoso
|
||||
data={userList}
|
||||
defaultItemHeight={28}
|
||||
customScrollParent={scrollParentRef!}
|
||||
itemContent={(index, user) => (
|
||||
<div className={cn('flex items-center gap-x-2 rounded-md px-2 py-1.5 outline-none')}>
|
||||
<Avatar className="size-4 shrink-0">
|
||||
<AvatarImage className="size-full" src={user.userAvatar} alt="avatar" />
|
||||
<AvatarFallback>{user.username.at(0)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 truncate text-xs text-slate-500">{user.username}</div>
|
||||
</div>
|
||||
)}
|
||||
></Virtuoso>
|
||||
</ScrollArea>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
|
|
|
@ -43,7 +43,8 @@ const generateUserInfo = async (): Promise<UserInfo> => {
|
|||
createTime: Date.now(),
|
||||
themeMode: checkSystemDarkMode() ? 'dark' : 'system',
|
||||
danmakuEnabled: true,
|
||||
notificationEnabled: false
|
||||
notificationEnabled: true,
|
||||
notificationType: 'all'
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -58,7 +59,8 @@ const generateMessage = async (userInfo: UserInfo): Promise<Message> => {
|
|||
username,
|
||||
userAvatar,
|
||||
likeUsers: mockTextList.length ? [] : [{ userId, username, userAvatar }],
|
||||
hateUsers: []
|
||||
hateUsers: [],
|
||||
atUsers: []
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -9,14 +9,14 @@ import { Button } from '@/components/ui/Button'
|
|||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/Form'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import UserInfoDomain, { type UserInfo } from '@/domain/UserInfo'
|
||||
import { checkSystemDarkMode, generateRandomAvatar } from '@/utils'
|
||||
import { checkSystemDarkMode, cn, generateRandomAvatar } from '@/utils'
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/RadioGroup'
|
||||
import { Label } from '@/components/ui/Label'
|
||||
import { RefreshCcwIcon } from 'lucide-react'
|
||||
import { MAX_AVATAR_SIZE } from '@/constants/config'
|
||||
import { ToastImpl } from '@/domain/impls/Toast'
|
||||
import BlurFade from '@/components/magicui/BlurFade'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Checkbox } from '@/components/ui/Checkbox'
|
||||
import Link from '@/components/Link'
|
||||
|
||||
const defaultUserInfo: UserInfo = {
|
||||
|
@ -26,7 +26,8 @@ const defaultUserInfo: UserInfo = {
|
|||
createTime: Date.now(),
|
||||
themeMode: checkSystemDarkMode() ? 'dark' : 'system',
|
||||
danmakuEnabled: true,
|
||||
notificationEnabled: false
|
||||
notificationEnabled: true,
|
||||
notificationType: 'all'
|
||||
}
|
||||
|
||||
const formSchema = v.object({
|
||||
|
@ -34,13 +35,9 @@ const formSchema = v.object({
|
|||
createTime: v.number(),
|
||||
// Pure numeric strings will be converted to number
|
||||
// Issues: https://github.com/unjs/unstorage/issues/277
|
||||
// name: v.string([
|
||||
// // toTrimmed(),
|
||||
// v.minBytes(1, 'Please enter your username.'),
|
||||
// v.maxBytes(20, 'Your username cannot exceed 20 bytes.')
|
||||
// ]),
|
||||
name: v.pipe(
|
||||
v.string(),
|
||||
v.trim(),
|
||||
v.minBytes(1, 'Please enter your username.'),
|
||||
v.maxBytes(20, 'Your username cannot exceed 20 bytes.')
|
||||
),
|
||||
|
@ -54,7 +51,8 @@ const formSchema = v.object({
|
|||
v.union([v.literal('system'), v.literal('light'), v.literal('dark')], 'Please select extension theme mode.')
|
||||
),
|
||||
danmakuEnabled: v.boolean(),
|
||||
notificationEnabled: v.boolean()
|
||||
notificationEnabled: v.boolean(),
|
||||
notificationType: v.pipe(v.string(), v.union([v.literal('all'), v.literal('at')], 'Please select notification type.'))
|
||||
})
|
||||
|
||||
const ProfileForm = () => {
|
||||
|
@ -136,7 +134,7 @@ const ProfileForm = () => {
|
|||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormLabel className="font-semibold">Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Please enter your username" {...field} />
|
||||
</FormControl>
|
||||
|
@ -150,7 +148,6 @@ const ProfileForm = () => {
|
|||
name="danmakuEnabled"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
{/* <FormLabel>Username</FormLabel> */}
|
||||
<FormControl>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
|
@ -159,7 +156,7 @@ const ProfileForm = () => {
|
|||
onCheckedChange={field.onChange}
|
||||
checked={field.value}
|
||||
/>
|
||||
<FormLabel className="cursor-pointer" htmlFor="enable-danmaku">
|
||||
<FormLabel className="cursor-pointer font-semibold" htmlFor="enable-danmaku">
|
||||
Enable Danmaku
|
||||
</FormLabel>
|
||||
</div>
|
||||
|
@ -174,24 +171,66 @@ const ProfileForm = () => {
|
|||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="notificationEnabled"
|
||||
name="notificationType"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
{/* <FormLabel>Username</FormLabel> */}
|
||||
<FormControl>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
defaultChecked={false}
|
||||
id="notification-enabled"
|
||||
onCheckedChange={field.onChange}
|
||||
checked={field.value}
|
||||
/>
|
||||
<FormLabel className="cursor-pointer" htmlFor="notification-enabled">
|
||||
Enable Notification
|
||||
</FormLabel>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="notificationEnabled"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
defaultChecked={false}
|
||||
id="enable-notification"
|
||||
onCheckedChange={field.onChange}
|
||||
checked={field.value}
|
||||
/>
|
||||
<FormLabel className="cursor-pointer font-semibold" htmlFor="enable-notification">
|
||||
Enable Notification
|
||||
</FormLabel>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormControl className="pl-6">
|
||||
<RadioGroup
|
||||
disabled={!form.getValues('notificationEnabled')}
|
||||
className="flex gap-x-4"
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="all" id="all" />
|
||||
<Label
|
||||
className={cn(
|
||||
'cursor-pointer',
|
||||
!form.getValues('notificationEnabled') && 'cursor-not-allowed opacity-50'
|
||||
)}
|
||||
htmlFor="all"
|
||||
>
|
||||
All message
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="at" id="at" />
|
||||
<Label
|
||||
className={cn(
|
||||
'cursor-pointer',
|
||||
!form.getValues('notificationEnabled') && 'cursor-not-allowed opacity-50'
|
||||
)}
|
||||
htmlFor="at"
|
||||
>
|
||||
Only @self
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<FormDescription>Enabling this option will display desktop notifications for messages.</FormDescription>
|
||||
<FormMessage />
|
||||
|
@ -203,20 +242,26 @@ const ProfileForm = () => {
|
|||
name="themeMode"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Theme Mode</FormLabel>
|
||||
<FormLabel className="font-semibold">Theme Mode</FormLabel>
|
||||
<FormControl>
|
||||
<RadioGroup className="flex gap-x-4" onValueChange={field.onChange} value={field.value}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="system" id="r1" />
|
||||
<Label htmlFor="r1">System</Label>
|
||||
<RadioGroupItem value="system" id="system" />
|
||||
<Label className="cursor-pointer" htmlFor="system">
|
||||
System
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="light" id="r2" />
|
||||
<Label htmlFor="r2">Light</Label>
|
||||
<RadioGroupItem value="light" id="light" />
|
||||
<Label className="cursor-pointer" htmlFor="light">
|
||||
Light
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="dark" id="r3" />
|
||||
<Label htmlFor="r3">Dark</Label>
|
||||
<RadioGroupItem value="dark" id="dark" />
|
||||
<Label className="cursor-pointer" htmlFor="dark">
|
||||
Dark
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
|
|
|
@ -673,12 +673,15 @@ section:has([data-sonner-toaster]) {
|
|||
|
||||
/* Custom styles */
|
||||
:where([data-sonner-toaster]) {
|
||||
width: 200px;
|
||||
max-width: 300px;
|
||||
position: absolute;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
:where([data-sonner-toast][data-styled='true']) {
|
||||
width: 200px;
|
||||
max-width: 300px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 9999px;
|
||||
width: fit-content;
|
||||
}
|
||||
|
|
|
@ -81,11 +81,4 @@
|
|||
all: initial !important;
|
||||
direction: ltr !important;
|
||||
}
|
||||
/**
|
||||
* Fix: scroll area dispay: table
|
||||
* @see https://github.com/radix-ui/primitives/issues/3129
|
||||
*/
|
||||
[data-radix-scroll-area-viewport] > div {
|
||||
display: block !important;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,9 +10,10 @@ const PopoverContent = React.forwardRef<
|
|||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => {
|
||||
const shadowRoot = document.querySelector(__NAME__)!.shadowRoot! as unknown as HTMLElement
|
||||
const root = document.querySelector(__NAME__)?.shadowRoot
|
||||
|
||||
return (
|
||||
<PopoverPrimitive.Portal container={shadowRoot}>
|
||||
<PopoverPrimitive.Portal container={root}>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
|
|
|
@ -8,7 +8,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(({ classNa
|
|||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
'flex min-h-[60px] w-full rounded-md border border-input text-primary bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'flex min-h-[60px] w-full rounded-md border border-input text-primary bg-transparent p-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { CheckIcon } from "@radix-ui/react-icons"
|
||||
import * as React from 'react'
|
||||
import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
|
||||
import { CheckIcon } from '@radix-ui/react-icons'
|
||||
|
||||
import { cn } from "@/utils/index"
|
||||
import { cn } from '@/utils/index'
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
|
@ -11,14 +11,12 @@ const Checkbox = React.forwardRef<
|
|||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
'peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
className={cn("flex items-center justify-center text-current")}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator className={cn('flex items-center justify-center text-current')}>
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
|
|
|
@ -16,6 +16,10 @@ export interface MessageUser {
|
|||
userAvatar: string
|
||||
}
|
||||
|
||||
export interface AtUser extends MessageUser {
|
||||
positions: [number, number][]
|
||||
}
|
||||
|
||||
export interface NormalMessage extends MessageUser {
|
||||
type: MessageType.Normal
|
||||
id: string
|
||||
|
@ -23,6 +27,7 @@ export interface NormalMessage extends MessageUser {
|
|||
date: number
|
||||
likeUsers: MessageUser[]
|
||||
hateUsers: MessageUser[]
|
||||
atUsers: AtUser[]
|
||||
}
|
||||
|
||||
export interface PromptMessage extends MessageUser {
|
||||
|
|
|
@ -69,11 +69,22 @@ const NotificationDomain = Remesh.domain({
|
|||
name: 'Notification.OnRoomMessageEffect',
|
||||
impl: ({ fromEvent, get }) => {
|
||||
const onTextMessage$ = fromEvent(roomDomain.event.OnTextMessageEvent)
|
||||
|
||||
const onMessage$ = merge(onTextMessage$).pipe(
|
||||
map((message) => {
|
||||
const notificationEnabled = get(IsEnabledQuery())
|
||||
return notificationEnabled ? PushCommand(message) : null
|
||||
if (notificationEnabled) {
|
||||
const userInfo = get(userInfoDomain.query.UserInfoQuery())
|
||||
const hasAtSelf = message.atUsers.find((user) => user.userId === userInfo?.id)
|
||||
if (userInfo?.notificationType === 'all') {
|
||||
return PushCommand(message)
|
||||
}
|
||||
if (userInfo?.notificationType === 'at' && hasAtSelf) {
|
||||
return PushCommand(message)
|
||||
}
|
||||
return null
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { Remesh } from 'remesh'
|
||||
import { map, merge, of, EMPTY, mergeMap, fromEvent, fromEventPattern } from 'rxjs'
|
||||
import { NormalMessage, type MessageUser } from './MessageList'
|
||||
import { AtUser, NormalMessage, type MessageUser } from './MessageList'
|
||||
import { PeerRoomExtern } from '@/domain/externs/PeerRoom'
|
||||
import MessageListDomain, { MessageType } from '@/domain/MessageList'
|
||||
import UserInfoDomain from '@/domain/UserInfo'
|
||||
|
@ -38,6 +38,7 @@ export interface TextMessage extends MessageUser {
|
|||
type: SendType.Text
|
||||
id: string
|
||||
body: string
|
||||
atUsers: AtUser[]
|
||||
}
|
||||
|
||||
export type RoomMessage = SyncUserMessage | LikeMessage | HateMessage | TextMessage
|
||||
|
@ -134,16 +135,17 @@ const RoomDomain = Remesh.domain({
|
|||
|
||||
const SendTextMessageCommand = domain.command({
|
||||
name: 'Room.SendTextMessageCommand',
|
||||
impl: ({ get }, message: string) => {
|
||||
impl: ({ get }, message: string | { body: string; atUsers: AtUser[] }) => {
|
||||
const { id: userId, name: username, avatar: userAvatar } = get(userInfoDomain.query.UserInfoQuery())!
|
||||
|
||||
const textMessage: TextMessage = {
|
||||
id: nanoid(),
|
||||
type: SendType.Text,
|
||||
body: message,
|
||||
body: typeof message === 'string' ? message : message.body,
|
||||
userId,
|
||||
username,
|
||||
userAvatar
|
||||
userAvatar,
|
||||
atUsers: typeof message === 'string' ? [] : message.atUsers
|
||||
}
|
||||
|
||||
const listMessage: NormalMessage = {
|
||||
|
@ -151,7 +153,8 @@ const RoomDomain = Remesh.domain({
|
|||
type: MessageType.Normal,
|
||||
date: Date.now(),
|
||||
likeUsers: [],
|
||||
hateUsers: []
|
||||
hateUsers: [],
|
||||
atUsers: typeof message === 'string' ? [] : message.atUsers
|
||||
}
|
||||
|
||||
peerRoom.sendMessage(textMessage)
|
||||
|
|
|
@ -12,6 +12,7 @@ export interface UserInfo {
|
|||
themeMode: 'system' | 'light' | 'dark'
|
||||
danmakuEnabled: boolean
|
||||
notificationEnabled: boolean
|
||||
notificationType: 'all' | 'at'
|
||||
}
|
||||
|
||||
const UserInfoDomain = Remesh.domain({
|
||||
|
|
|
@ -1,51 +0,0 @@
|
|||
import { type RefObject, useEffect, useRef } from 'react'
|
||||
|
||||
export type Events = Array<keyof GlobalEventHandlersEventMap>
|
||||
|
||||
/**
|
||||
* Waiting for PR merge
|
||||
* @see https://github.com/streamich/react-use/pull/2528
|
||||
*/
|
||||
const useClickAway = <E extends Event = Event>(
|
||||
ref: RefObject<HTMLElement | null>,
|
||||
onClickAway: (event: E) => void,
|
||||
events: Events = ['mousedown', 'touchstart']
|
||||
) => {
|
||||
const savedCallback = useRef(onClickAway)
|
||||
useEffect(() => {
|
||||
savedCallback.current = onClickAway
|
||||
}, [onClickAway])
|
||||
useEffect(() => {
|
||||
const { current: el } = ref
|
||||
if (!el) return
|
||||
|
||||
const rootNode = el.getRootNode()
|
||||
const isInShadow = rootNode instanceof ShadowRoot
|
||||
|
||||
/**
|
||||
* When events are captured outside the component, events that occur in shadow DOM will target the host element
|
||||
* so additional event listeners need to be added for shadowDom
|
||||
*
|
||||
* document shadowDom target
|
||||
* | | |
|
||||
* |- on(document) -|- on(shadowRoot) -|
|
||||
*/
|
||||
const handler = (event: SafeAny) => {
|
||||
!el.contains(event.target) && event.target.shadowRoot !== rootNode && savedCallback.current(event)
|
||||
}
|
||||
for (const eventName of events) {
|
||||
// eslint-disable-next-line @eslint-react/web-api/no-leaked-event-listener
|
||||
document.addEventListener(eventName, handler)
|
||||
// eslint-disable-next-line @eslint-react/web-api/no-leaked-event-listener
|
||||
isInShadow && rootNode.addEventListener(eventName, handler)
|
||||
}
|
||||
return () => {
|
||||
for (const eventName of events) {
|
||||
document.removeEventListener(eventName, handler)
|
||||
isInShadow && rootNode.removeEventListener(eventName, handler)
|
||||
}
|
||||
}
|
||||
}, [events, ref])
|
||||
}
|
||||
|
||||
export default useClickAway
|
41
src/hooks/useCursorPosition.ts
Normal file
41
src/hooks/useCursorPosition.ts
Normal file
|
@ -0,0 +1,41 @@
|
|||
import { RefCallback, useCallback, useRef, useState } from 'react'
|
||||
import getCursorPosition, { Position } from '@/utils/getCursorPosition'
|
||||
|
||||
const useCursorPosition = () => {
|
||||
const [position, setPosition] = useState<Position>({ x: 0, y: 0, selectionStart: 0, selectionEnd: 0 })
|
||||
|
||||
const handler = async (e: Event) => {
|
||||
const newPosition = await getCursorPosition(e.target as HTMLInputElement | HTMLTextAreaElement)
|
||||
if (JSON.stringify(newPosition) !== JSON.stringify(position)) {
|
||||
setPosition(newPosition)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRef = useRef<HTMLInputElement | HTMLTextAreaElement | null>(null)
|
||||
|
||||
const setRef: RefCallback<HTMLInputElement | HTMLTextAreaElement | null> = useCallback(
|
||||
(node) => {
|
||||
if (handleRef.current) {
|
||||
handleRef.current.removeEventListener('click', handler)
|
||||
handleRef.current.removeEventListener('input', handler)
|
||||
handleRef.current.removeEventListener('keydown', handler)
|
||||
handleRef.current.removeEventListener('keyup', handler)
|
||||
}
|
||||
if (node) {
|
||||
node.addEventListener('click', handler)
|
||||
node.addEventListener('input', handler)
|
||||
node.addEventListener('keydown', handler)
|
||||
node.addEventListener('keyup', handler)
|
||||
}
|
||||
handleRef.current = node
|
||||
},
|
||||
[handler]
|
||||
)
|
||||
|
||||
return {
|
||||
...position,
|
||||
setRef
|
||||
}
|
||||
}
|
||||
|
||||
export default useCursorPosition
|
|
@ -18,7 +18,10 @@ const useDarg = (options: DargOptions) => {
|
|||
const [position, setPosition] = useState({ x: clamp(initX, minX, maxX), y: clamp(initY, minY, maxY) })
|
||||
|
||||
useLayoutEffect(() => {
|
||||
setPosition({ x: clamp(initX, minX, maxX), y: clamp(initY, minY, maxY) })
|
||||
const newPosition = { x: clamp(initX, minX, maxX), y: clamp(initY, minY, maxY) }
|
||||
if (JSON.stringify(newPosition) !== JSON.stringify(position)) {
|
||||
setPosition(newPosition)
|
||||
}
|
||||
}, [initX, initY, maxX, minX, maxY, minY])
|
||||
|
||||
const isMove = useRef(false)
|
||||
|
@ -68,7 +71,7 @@ const useDarg = (options: DargOptions) => {
|
|||
|
||||
const handleRef = useRef<HTMLElement | null>(null)
|
||||
|
||||
const setHandleRef = useCallback(
|
||||
const setRef = useCallback(
|
||||
(node: HTMLElement | null) => {
|
||||
if (handleRef.current) {
|
||||
handleRef.current.removeEventListener('mousedown', handleStart)
|
||||
|
@ -85,7 +88,7 @@ const useDarg = (options: DargOptions) => {
|
|||
[handleEnd, handleMove, handleStart]
|
||||
)
|
||||
|
||||
return { ref: setHandleRef, ...position }
|
||||
return { setRef, ...position }
|
||||
}
|
||||
|
||||
export default useDarg
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { RefCallback, useCallback, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { clamp, isInRange } from '@/utils'
|
||||
|
||||
export interface ResizableOptions {
|
||||
|
@ -14,8 +14,11 @@ const useResizable = (options: ResizableOptions) => {
|
|||
const [size, setSize] = useState(clamp(initSize, minSize, maxSize))
|
||||
|
||||
useLayoutEffect(() => {
|
||||
setSize(clamp(initSize, minSize, maxSize))
|
||||
}, [initSize, minSize, maxSize])
|
||||
const newSize = clamp(initSize, minSize, maxSize)
|
||||
if (newSize !== size) {
|
||||
setSize(newSize)
|
||||
}
|
||||
}, [initSize, minSize, maxSize, size])
|
||||
|
||||
const position = useRef(0)
|
||||
|
||||
|
@ -74,8 +77,8 @@ const useResizable = (options: ResizableOptions) => {
|
|||
const handlerRef = useRef<HTMLElement | null>(null)
|
||||
|
||||
// Watch ref: https://medium.com/@teh_builder/ref-objects-inside-useeffect-hooks-eb7c15198780
|
||||
const setHandleRef = useCallback(
|
||||
(node: HTMLElement | null) => {
|
||||
const setRef: RefCallback<HTMLElement | null> = useCallback(
|
||||
(node) => {
|
||||
if (handlerRef.current) {
|
||||
handlerRef.current.removeEventListener('mousedown', handleStart)
|
||||
document.removeEventListener('mouseup', handleEnd)
|
||||
|
@ -91,7 +94,7 @@ const useResizable = (options: ResizableOptions) => {
|
|||
[handleEnd, handleMove, handleStart]
|
||||
)
|
||||
|
||||
return { size, ref: setHandleRef }
|
||||
return { size, setRef }
|
||||
}
|
||||
|
||||
export default useResizable
|
||||
|
|
21
src/hooks/useShareRef.ts
Normal file
21
src/hooks/useShareRef.ts
Normal file
|
@ -0,0 +1,21 @@
|
|||
import { ForwardedRef, MutableRefObject, RefCallback, 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
|
||||
}
|
||||
}),
|
||||
[...refs]
|
||||
)
|
||||
|
||||
return setRef
|
||||
}
|
||||
|
||||
export default useShareRef
|
52
src/hooks/useTriggerAway.ts
Normal file
52
src/hooks/useTriggerAway.ts
Normal file
|
@ -0,0 +1,52 @@
|
|||
import { RefCallback, useCallback, useRef } from 'react'
|
||||
|
||||
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 handler = (event: SafeAny) => {
|
||||
const rootNode = handleRef.current?.getRootNode()
|
||||
!handleRef.current?.contains(event.target) && event.target.shadowRoot !== rootNode && callback(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* When events are captured outside the component, events that occur in shadow DOM will target the host element
|
||||
* so additional event listeners need to be added for shadowDom
|
||||
*
|
||||
* document shadowDom target
|
||||
* | | |
|
||||
* |- on(document) -|- on(shadowRoot) -|
|
||||
*/
|
||||
const setRef: RefCallback<HTMLElement | null> = useCallback(
|
||||
(node) => {
|
||||
if (handleRef.current) {
|
||||
const rootNode = handleRef.current.getRootNode()
|
||||
const isInShadow = rootNode instanceof ShadowRoot
|
||||
events.forEach(() => {
|
||||
for (const eventName of events) {
|
||||
document.removeEventListener(eventName, handler)
|
||||
isInShadow && rootNode.removeEventListener(eventName, handler)
|
||||
}
|
||||
})
|
||||
}
|
||||
if (node) {
|
||||
const rootNode = node.getRootNode()
|
||||
const isInShadow = rootNode instanceof ShadowRoot
|
||||
events.forEach((eventName) => {
|
||||
document.addEventListener(eventName, handler)
|
||||
isInShadow && rootNode.addEventListener(eventName, handler)
|
||||
})
|
||||
}
|
||||
handleRef.current = node
|
||||
},
|
||||
[handler]
|
||||
)
|
||||
|
||||
return { setRef }
|
||||
}
|
||||
|
||||
export default useTriggerAway
|
74
src/utils/getCursorPosition.ts
Normal file
74
src/utils/getCursorPosition.ts
Normal file
|
@ -0,0 +1,74 @@
|
|||
import { createElement } from '@/utils'
|
||||
|
||||
export interface Position {
|
||||
x: number
|
||||
y: number
|
||||
selectionStart: number
|
||||
selectionEnd: number
|
||||
}
|
||||
|
||||
const getCursorPosition = (target: HTMLInputElement | HTMLTextAreaElement) => {
|
||||
return new Promise<Position>((resolve, reject) =>
|
||||
requestIdleCallback(() => {
|
||||
try {
|
||||
const value = target.value
|
||||
|
||||
const inputWrapper = createElement<HTMLDivElement>(
|
||||
`<div style="position: fixed; z-index: calc(-infinity); width: 0; height: 0; overflow: hidden; visibility: hidden; pointer-events: none;"></div>`
|
||||
// `<div id="input-wrapper" style="position: fixed"></div>`
|
||||
)
|
||||
const copyInput = createElement<HTMLDivElement>(`<div contenteditable></div>`)
|
||||
|
||||
inputWrapper.appendChild(copyInput)
|
||||
target.ownerDocument.body.appendChild(inputWrapper)
|
||||
|
||||
const { left, top, width, height } = target.getBoundingClientRect()
|
||||
|
||||
const isEmptyOrBreakEnd = /(\n|\s*$)/.test(value)
|
||||
copyInput.textContent = isEmptyOrBreakEnd ? `${value}\u200b` : value
|
||||
|
||||
const copyStyle = getComputedStyle(target)
|
||||
|
||||
for (const key of copyStyle) {
|
||||
Reflect.set(copyInput.style, key, copyStyle[key as keyof CSSStyleDeclaration])
|
||||
}
|
||||
|
||||
if (target.tagName === 'INPUT') {
|
||||
copyInput.style.lineHeight = copyStyle.height
|
||||
}
|
||||
|
||||
copyInput.style.overflow = 'auto'
|
||||
|
||||
copyInput.style.width = `${width}px`
|
||||
copyInput.style.height = `${height}px`
|
||||
copyInput.style.boxSizing = 'border-box'
|
||||
copyInput.style.margin = '0'
|
||||
copyInput.style.position = 'fixed'
|
||||
copyInput.style.top = `${top}px`
|
||||
copyInput.style.left = `${left}px`
|
||||
copyInput.style.pointerEvents = 'none'
|
||||
|
||||
// sync scroll
|
||||
copyInput.scrollTop = target.scrollTop
|
||||
copyInput.scrollLeft = target.scrollLeft
|
||||
|
||||
const selectionStart = target.selectionStart!
|
||||
const selectionEnd = target.selectionEnd!
|
||||
|
||||
const range = new Range()
|
||||
range.setStart(copyInput.childNodes[0], selectionStart)
|
||||
range.setEnd(copyInput.childNodes[0], isEmptyOrBreakEnd ? selectionEnd + 1 : selectionEnd)
|
||||
|
||||
const { x, y } = range.getBoundingClientRect()
|
||||
|
||||
target.ownerDocument.body.removeChild(inputWrapper)
|
||||
|
||||
resolve({ x, y, selectionStart, selectionEnd })
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export default getCursorPosition
|
45
src/utils/getTextSimilarity.ts
Normal file
45
src/utils/getTextSimilarity.ts
Normal file
|
@ -0,0 +1,45 @@
|
|||
/**
|
||||
* Calculates the length of the Longest Common Subsequence (LCS) between two strings.
|
||||
* @param a - The first string.
|
||||
* @param b - The second string.
|
||||
* @returns The length of the longest common subsequence.
|
||||
* @see https://en.wikipedia.org/wiki/Longest_common_subsequence
|
||||
*/
|
||||
const getTextLCS = (a: string, b: string): number => {
|
||||
// Create a 2D array to store the lengths of longest common subsequences
|
||||
const dp: number[][] = Array.from({ length: a.length + 1 }, () => Array(b.length + 1).fill(0))
|
||||
|
||||
// Fill the dp array
|
||||
for (let i = 1; i <= a.length; i++) {
|
||||
for (let j = 1; j <= b.length; j++) {
|
||||
// If characters match, increment the length of the LCS found so far
|
||||
if (a[i - 1] === b[j - 1]) {
|
||||
dp[i][j] = dp[i - 1][j - 1] + 1
|
||||
} else {
|
||||
// If characters do not match, take the maximum length from the previous computations
|
||||
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The length of the longest common subsequence is found in the bottom-right cell of the dp array
|
||||
return dp[a.length][b.length]
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the similarity between two strings based on their longest common subsequence.
|
||||
* @param a - The first string.
|
||||
* @param b - The second string.
|
||||
* @returns A number representing the similarity between the two strings (0 to 1).
|
||||
*/
|
||||
const getTextSimilarity = (a: string, b: string): number => {
|
||||
// Get the length of the longest common subsequence
|
||||
const lcsLength: number = getTextLCS(a, b)
|
||||
// Get the maximum length of the two strings
|
||||
const maxLength: number = Math.max(a.length, b.length)
|
||||
|
||||
// Calculate similarity based on the length of the LCS
|
||||
return maxLength === 0 ? 0 : lcsLength / maxLength
|
||||
}
|
||||
|
||||
export default getTextSimilarity
|
|
@ -11,3 +11,5 @@ export { default as throttle } from './throttle'
|
|||
export { chunk, desert, upsert } from './array'
|
||||
export { default as generateRandomAvatar } from './generateRandomAvatar'
|
||||
export { default as generateRandomName } from './generateRandomName'
|
||||
export { default as getCursorPosition } from './getCursorPosition'
|
||||
export { default as getTextSimilarity } from './getTextSimilarity'
|
||||
|
|
Loading…
Reference in a new issue