feat: support danmaku

This commit is contained in:
molvqingtai 2024-09-30 21:39:47 +08:00
parent 52cd203a53
commit 999a55c65f
31 changed files with 1116 additions and 595 deletions

View file

@ -48,6 +48,7 @@
"@lottiefiles/dotlottie-react": "^0.9.0",
"@perfsee/jsonr": "^1.13.0",
"@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-checkbox": "^1.1.1",
"@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-hover-card": "^1.1.1",
"@radix-ui/react-icons": "^1.3.0",
@ -64,8 +65,9 @@
"@webext-core/proxy-service": "^1.2.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"danmaku": "^2.0.7",
"date-fns": "^4.1.0",
"framer-motion": "^11.7.0",
"framer-motion": "^11.9.0",
"idb-keyval": "^6.2.1",
"lucide-react": "^0.446.0",
"nanoid": "^5.0.7",
@ -86,14 +88,13 @@
"tailwind-merge": "^2.5.2",
"trystero": "^0.20.0",
"type-fest": "^4.26.1",
"unstorage": "^1.12.0",
"valibot": "^0.42.1",
"webextension-polyfill": "^0.12.0"
"unstorage": "1.11.0",
"valibot": "^0.42.1"
},
"devDependencies": {
"@commitlint/cli": "^19.5.0",
"@commitlint/config-conventional": "^19.5.0",
"@eslint-react/eslint-plugin": "^1.14.2",
"@eslint-react/eslint-plugin": "^1.14.3",
"@eslint/js": "^9.11.1",
"@semantic-release/changelog": "^6.0.3",
"@semantic-release/exec": "^6.0.3",
@ -101,12 +102,11 @@
"@types/eslint": "^9.6.1",
"@types/eslint-plugin-tailwindcss": "^3.17.0",
"@types/eslint__js": "^8.42.3",
"@types/node": "^22.7.2",
"@types/react": "^18.3.9",
"@types/node": "^22.7.4",
"@types/react": "^18.3.10",
"@types/react-dom": "^18.3.0",
"@types/webextension-polyfill": "^0.12.1",
"@typescript-eslint/parser": "^8.7.0",
"@vitejs/plugin-react": "^4.3.1",
"@vitejs/plugin-react": "^4.3.2",
"autoprefixer": "^10.4.20",
"cross-env": "^7.0.3",
"eslint": "^9.11.1",
@ -122,7 +122,7 @@
"postcss-rem-to-responsive-pixel": "^6.0.2",
"prettier": "^3.3.3",
"rimraf": "^5.0.10",
"semantic-release": "^24.1.1",
"semantic-release": "^24.1.2",
"tailwindcss": "^3.4.13",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.6.2",

File diff suppressed because it is too large Load diff

View file

@ -8,9 +8,9 @@ import RoomDomain from '@/domain/Room'
import UserInfoDomain from '@/domain/UserInfo'
import Setup from '@/app/content/views/Setup'
import MessageListDomain from '@/domain/MessageList'
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { Toaster } from 'sonner'
import { indexDBStorage } from '@/domain/impls/Storage'
import { browserSyncStorage, indexDBStorage } from '@/domain/impls/Storage'
import { APP_OPEN_STATUS_STORAGE_KEY } from '@/constants/config'
import LogoIcon0 from '@/assets/images/logo-0.svg'
import LogoIcon1 from '@/assets/images/logo-1.svg'
@ -21,16 +21,21 @@ import LogoIcon5 from '@/assets/images/logo-5.svg'
import LogoIcon6 from '@/assets/images/logo-6.svg'
import { getDay } from 'date-fns'
import DanmakuContainer from './components/DanmakuContainer'
import DanmakuDomain from '@/domain/Danmaku'
import { browser } from 'wxt/browser'
export default function App() {
const send = useRemeshSend()
const roomDomain = useRemeshDomain(RoomDomain())
const userInfoDomain = useRemeshDomain(UserInfoDomain())
const messageListDomain = useRemeshDomain(MessageListDomain())
const danmakuDomain = useRemeshDomain(DanmakuDomain())
const danmakuIsEnabled = useRemeshQuery(danmakuDomain.query.IsEnabledQuery())
const userInfo = useRemeshQuery(userInfoDomain.query.UserInfoQuery())
const userInfoSetFinished = useRemeshQuery(userInfoDomain.query.UserInfoSetIsFinishedQuery())
const userInfoLoadFinished = useRemeshQuery(userInfoDomain.query.UserInfoLoadIsFinishedQuery())
const messageListLoadFinished = useRemeshQuery(messageListDomain.query.MessageListLoadIsFinishedQuery())
const messageListLoadFinished = useRemeshQuery(messageListDomain.query.LoadIsFinishedQuery())
const notUserInfo = userInfoLoadFinished && !userInfoSetFinished
const DayLogo = [LogoIcon0, LogoIcon1, LogoIcon2, LogoIcon3, LogoIcon4, LogoIcon5, LogoIcon6][getDay(Date())]
@ -63,6 +68,15 @@ export default function App() {
getAppOpenStatus()
}, [])
const danmakuContainerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
danmakuIsEnabled && send(danmakuDomain.command.MountCommand(danmakuContainerRef.current!))
return () => {
danmakuIsEnabled && send(danmakuDomain.command.DestroyCommand())
}
}, [danmakuIsEnabled])
return (
<>
<AppContainer open={appOpen}>
@ -75,6 +89,7 @@ export default function App() {
<AppButton onClick={handleToggleApp}>
<DayLogo className="max-h-full max-w-full"></DayLogo>
</AppButton>
<DanmakuContainer ref={danmakuContainerRef} />
</>
)
}

View file

@ -0,0 +1,19 @@
import { cn } from '@/utils'
import { forwardRef } from 'react'
export interface DanmakuContainerProps {
className?: string
}
const DanmakuContainer = forwardRef<HTMLDivElement, DanmakuContainerProps>(({ className }, ref) => {
return (
<div
className={cn('fixed left-0 top-20 z-infinity w-full h-full invisible pointer-events-none shadow-md', className)}
ref={ref}
></div>
)
})
DanmakuContainer.displayName = 'DanmakuContainer'
export default DanmakuContainer

View file

@ -0,0 +1,32 @@
import { Avatar, AvatarFallback } from '@/components/ui/Avatar'
import { Button } from '@/components/ui/Button'
import { TextMessage } from '@/domain/Room'
import { cn } from '@/utils'
import { AvatarImage } from '@radix-ui/react-avatar'
import { FC } from 'react'
export interface PromptItemProps {
data: TextMessage
className?: string
}
const DanmakuMessage: FC<PromptItemProps> = ({ data, className }) => {
return (
<Button
className={cn(
'flex justify-center pointer-events-auto visible gap-x-2 border px-2.5 py-0.5 rounded-full bg-primary/30 text-base font-medium text-white backdrop-blur-md',
className
)}
>
<Avatar className="size-5">
<AvatarImage src={data.userAvatar} alt="avatar" />
<AvatarFallback>{data.username.at(0)}</AvatarFallback>
</Avatar>
<div className="max-w-40 overflow-hidden text-ellipsis">{data.body}</div>
</Button>
)
}
DanmakuMessage.displayName = 'DanmakuMessage'
export default DanmakuMessage

View file

@ -1,6 +1,5 @@
import { type ChangeEvent, type KeyboardEvent } from 'react'
import { forwardRef, type ChangeEvent, type KeyboardEvent } from 'react'
import React from 'react'
import { Textarea } from '@/components/ui/Textarea'
import { Markdown } from '@/components/ui/Markdown'
import { cn } from '@/utils'
@ -17,7 +16,7 @@ export interface MessageInputProps {
onEnter?: (value: string) => void
}
const MessageInput = React.forwardRef<HTMLTextAreaElement, MessageInputProps>(
const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(
({ value = '', className, maxLength = 500, onInput, onEnter, preview, autoFocus, disabled }, ref) => {
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !(e.shiftKey || e.ctrlKey || e.altKey || e.metaKey)) {

View file

@ -7,8 +7,9 @@ import { defineContentScript } from 'wxt/sandbox'
import { createShadowRootUi } from 'wxt/client'
import App from './App'
import { IndexDBStorageImpl, BrowserSyncStorageImpl } from '@/domain/impls/Storage'
import { IndexDBStorageImpl, BrowserSyncStorageImpl, indexDBStorage } from '@/domain/impls/Storage'
import { PeerRoomImpl } from '@/domain/impls/PeerRoom'
import { DanmakuImpl } from '@/domain/impls/Danmaku'
// import { PeerRoomImpl } from '@/domain/impls/PeerRoom2'
import '@/assets/styles/tailwind.css'
import '@/assets/styles/sonner.css'
@ -22,7 +23,7 @@ export default defineContentScript({
excludeMatches: ['*://localhost/*', '*://127.0.0.1/*'],
async main(ctx) {
const store = Remesh.store({
externs: [IndexDBStorageImpl, BrowserSyncStorageImpl, PeerRoomImpl, ToastImpl]
externs: [IndexDBStorageImpl, BrowserSyncStorageImpl, PeerRoomImpl, ToastImpl, DanmakuImpl]
// inspectors: __DEV__ ? [RemeshLogger()] : []
})

View file

@ -1,4 +1,4 @@
import { type ReactNode, type FC, useState, type MouseEvent, useRef, useEffect } from 'react'
import { type ReactNode, type FC, useState, type MouseEvent, useRef } from 'react'
import { SettingsIcon, MoonIcon, SunIcon } from 'lucide-react'
import { motion, AnimatePresence } from 'framer-motion'

View file

@ -7,7 +7,7 @@ import PromptItem from '../../components/PromptItem'
import UserInfoDomain from '@/domain/UserInfo'
import RoomDomain, { MessageType } from '@/domain/Room'
import MessageListDomain from '@/domain/MessageList'
import BlurFade from '@/components/magicui/blur-fade'
import BlurFade from '@/components/magicui/BlurFade'
const Main: FC = () => {
const send = useRemeshSend()

View file

@ -9,9 +9,9 @@ import { FC, 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'
import WordPullUp from '@/components/magicui/word-pull-up'
import PulsatingButton from '@/components/magicui/PulsatingButton'
import BlurFade from '@/components/magicui/BlurFade'
import WordPullUp from '@/components/magicui/WordPullUp'
import { motion } from 'framer-motion'
const mockTextList = [
@ -41,7 +41,8 @@ const generateUserInfo = async (): Promise<UserInfo> => {
name: generateRandomName(),
avatar: await generateRandomAvatar(MAX_AVATAR_SIZE),
createTime: Date.now(),
themeMode: checkSystemDarkMode() ? 'dark' : 'system'
themeMode: checkSystemDarkMode() ? 'dark' : 'system',
danmakuEnabled: true
}
}

View file

@ -1,4 +1,4 @@
import Meteors from '@/components/magicui/meteors'
import Meteors from '@/components/magicui/Meteors'
import { FC, ReactNode } from 'react'
export interface LayoutProps {

View file

@ -15,15 +15,16 @@ import { Label } from '@/components/ui/Label'
import { RefreshCcwIcon } from 'lucide-react'
import { MAX_AVATAR_SIZE } from '@/constants/config'
import ToastDomain from '@/domain/Toast'
import BlurFade from '@/components/magicui/blur-fade'
import debounce from './../../../utils/debounce'
import BlurFade from '@/components/magicui/BlurFade'
import { Checkbox } from '@/components/ui/checkbox'
const defaultUserInfo: UserInfo = {
id: nanoid(),
name: '',
avatar: '',
createTime: Date.now(),
themeMode: checkSystemDarkMode() ? 'dark' : 'system'
themeMode: checkSystemDarkMode() ? 'dark' : 'system',
danmakuEnabled: true
}
const formSchema = v.object({
@ -49,7 +50,8 @@ const formSchema = v.object({
themeMode: v.pipe(
v.string(),
v.union([v.literal('system'), v.literal('light'), v.literal('dark')], 'Please select extension theme mode.')
)
),
danmakuEnabled: v.boolean()
})
const ProfileForm = () => {
@ -89,7 +91,7 @@ const ProfileForm = () => {
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} autoComplete="off" className="relative w-96 space-y-8 p-10">
<form onSubmit={form.handleSubmit(handleSubmit)} autoComplete="off" className="relative w-[450px] space-y-8 p-14">
<FormField
control={form.control}
name="avatar"
@ -128,6 +130,30 @@ const ProfileForm = () => {
</FormItem>
)}
/>
<FormField
control={form.control}
name="danmakuEnabled"
render={({ field }) => (
<FormItem>
{/* <FormLabel>Username</FormLabel> */}
<FormControl>
<div className="flex items-center space-x-2">
<Checkbox
defaultChecked={false}
id="enable-danmaku"
onCheckedChange={field.onChange}
checked={field.value}
/>
<FormLabel className="cursor-pointer" htmlFor="enable-danmaku">
Enable Danmaku
</FormLabel>
</div>
</FormControl>
<FormDescription>Danmaku messages will scroll across the screen.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="themeMode"

View file

@ -2,7 +2,6 @@ import React from 'react'
import ReactDOM from 'react-dom/client'
import { Remesh } from 'remesh'
import { RemeshRoot } from 'remesh-react'
import { RemeshLogger } from 'remesh-logger'
import App from './App'
import { BrowserSyncStorageImpl } from '@/domain/impls/Storage'
import '@/assets/styles/tailwind.css'
@ -10,8 +9,7 @@ import '@/assets/styles/tailwind.css'
import { ToastImpl } from '@/domain/impls/Toast'
const store = Remesh.store({
externs: [BrowserSyncStorageImpl, ToastImpl],
inspectors: [RemeshLogger()]
externs: [BrowserSyncStorageImpl, ToastImpl]
})
ReactDOM.createRoot(document.getElementById('root')!).render(

View file

@ -0,0 +1,28 @@
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"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<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",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<CheckIcon className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

145
src/domain/Danmaku.ts Normal file
View file

@ -0,0 +1,145 @@
import { Remesh } from 'remesh'
import { DanmakuExtern } from './externs/Danmaku'
import { TextMessage } from './Room'
import UserInfoDomain from './UserInfo'
import { map } from 'rxjs'
const DanmakuDomain = Remesh.domain({
name: 'DanmakuDomain',
impl: (domain) => {
const danmaku = domain.getExtern(DanmakuExtern)
const userInfoDomain = domain.getDomain(UserInfoDomain())
const MountState = domain.state({
name: 'Danmaku.MountState',
default: false
})
const DanmakuEnabledState = domain.state<boolean>({
name: 'Danmaku.EnabledState',
default: false
})
const IsEnabledQuery = domain.query({
name: 'Danmaku.IsOpenQuery',
impl: ({ get }) => {
return get(DanmakuEnabledState())
}
})
const EnableCommand = domain.command({
name: 'Danmaku.EnableCommand',
impl: () => {
return DanmakuEnabledState().new(true)
}
})
const DisableCommand = domain.command({
name: 'Danmaku.DisableCommand',
impl: () => {
return DanmakuEnabledState().new(false)
}
})
const IsMountedQuery = domain.query({
name: 'Danmaku.IsMountedQuery',
impl: ({ get }) => get(MountState())
})
const PushCommand = domain.command({
name: 'Danmaku.PushCommand',
impl: (_, message: TextMessage) => {
danmaku.push(message)
return [PushEvent(message)]
}
})
const UnshiftCommand = domain.command({
name: 'Danmaku.UnshiftCommand',
impl: (_, message: TextMessage) => {
danmaku.unshift(message)
return [UnshiftEvent(message)]
}
})
const ClearCommand = domain.command({
name: 'Danmaku.ClearCommand',
impl: () => {
danmaku.clear()
return [ClearEvent()]
}
})
const MountCommand = domain.command({
name: 'Danmaku.ClearCommand',
impl: (_, container: HTMLElement) => {
danmaku.mount(container)
return [MountEvent(container)]
}
})
const DestroyCommand = domain.command({
name: 'Danmaku.DestroyCommand',
impl: () => {
danmaku.destroy()
return [DestroyEvent()]
}
})
const PushEvent = domain.event<TextMessage>({
name: 'Danmaku.PushEvent'
})
const UnshiftEvent = domain.event<TextMessage>({
name: 'Danmaku.UnshiftEvent'
})
const ClearEvent = domain.event({
name: 'Danmaku.ClearEvent'
})
const MountEvent = domain.event<HTMLElement>({
name: 'Danmaku.MountEvent'
})
const DestroyEvent = domain.event({
name: 'Danmaku.DestroyEvent'
})
domain.effect({
name: 'Danmaku.OnUserInfoEffect',
impl: ({ fromEvent }) => {
const onUserInfo$ = fromEvent(userInfoDomain.event.UpdateUserInfoEvent)
return onUserInfo$.pipe(
map((userInfo) => {
return userInfo?.danmakuEnabled ? EnableCommand() : DisableCommand()
})
)
}
})
return {
query: {
IsMountedQuery,
IsEnabledQuery
},
command: {
EnableCommand,
DisableCommand,
PushCommand,
UnshiftCommand,
ClearCommand,
MountCommand,
DestroyCommand
},
event: {
PushEvent,
UnshiftEvent,
ClearEvent,
MountEvent,
DestroyEvent
}
}
}
})
export default DanmakuDomain

View file

@ -48,8 +48,8 @@ const MessageListDomain = Remesh.domain({
key: (message) => message.id
})
const MessageListLoadStatusModule = StatusModule(domain, {
name: 'MessageListLoadStatusModule'
const LoadStatusModule = StatusModule(domain, {
name: 'Message.ListLoadStatusModule'
})
const ListQuery = MessageListModule.query.ItemListQuery
@ -58,6 +58,8 @@ const MessageListDomain = Remesh.domain({
const HasItemQuery = MessageListModule.query.HasItemByKeyQuery
const LoadIsFinishedQuery = LoadStatusModule.query.IsFinishedQuery
const ChangeListEvent = domain.event({
name: 'MessageList.ChangeListEvent',
impl: ({ get }) => {
@ -144,16 +146,14 @@ const MessageListDomain = Remesh.domain({
storageEffect
.set(SyncToStorageEvent)
.get<
Message[]
>((value) => [SyncToStateCommand(value ?? []), MessageListLoadStatusModule.command.SetFinishedCommand()])
.get<Message[]>((value) => [SyncToStateCommand(value ?? []), LoadStatusModule.command.SetFinishedCommand()])
return {
query: {
HasItemQuery,
ItemQuery,
ListQuery,
MessageListLoadIsFinishedQuery: MessageListLoadStatusModule.query.IsFinishedQuery
LoadIsFinishedQuery
},
command: {
CreateItemCommand,

View file

@ -1,5 +1,5 @@
import { Remesh } from 'remesh'
import { map, merge, of, EMPTY, mergeMap, fromEvent, Observable, tap, fromEventPattern } from 'rxjs'
import { map, merge, of, EMPTY, mergeMap, fromEvent, fromEventPattern } from 'rxjs'
import { NormalMessage, type MessageUser } from './MessageList'
import { PeerRoomExtern } from '@/domain/externs/PeerRoom'
import MessageListDomain, { MessageType } from '@/domain/MessageList'
@ -7,7 +7,8 @@ import UserInfoDomain from '@/domain/UserInfo'
import { desert, upsert } from '@/utils'
import { nanoid } from 'nanoid'
import StatusModule from '@/domain/modules/Status'
import { ToastExtern } from './externs/Toast'
import { ToastExtern } from '@/domain/externs/Toast'
import DanmakuDomain from '@/domain/Danmaku'
export { MessageType }
@ -50,6 +51,7 @@ const RoomDomain = Remesh.domain({
impl: (domain) => {
const messageListDomain = domain.getDomain(MessageListDomain())
const userInfoDomain = domain.getDomain(UserInfoDomain())
const danmakuDomain = domain.getDomain(DanmakuDomain())
const peerRoom = domain.getExtern(PeerRoomExtern)
const toast = domain.getExtern(ToastExtern)
@ -65,12 +67,12 @@ const RoomDomain = Remesh.domain({
}
})
const RoomJoinStatusModule = StatusModule(domain, {
name: 'RoomJoinStatusModule'
const JoinStatusModule = StatusModule(domain, {
name: 'Room.JoinStatusModule'
})
const UserListState = domain.state<RoomUser[]>({
name: 'RoomUserListState',
name: 'Room.UserListState',
default: []
})
@ -81,8 +83,10 @@ const RoomDomain = Remesh.domain({
}
})
const JoinIsFinishedQuery = JoinStatusModule.query.IsFinishedQuery
const JoinRoomCommand = domain.command({
name: 'RoomJoinRoomCommand',
name: 'Room.JoinRoomCommand',
impl: ({ get }) => {
peerRoom.joinRoom()
const { id: userId, name: username, avatar: userAvatar } = get(userInfoDomain.query.UserInfoQuery())!
@ -101,14 +105,14 @@ const RoomDomain = Remesh.domain({
type: MessageType.Prompt,
date: Date.now()
}),
RoomJoinStatusModule.command.SetFinishedCommand(),
JoinStatusModule.command.SetFinishedCommand(),
JoinRoomEvent(peerRoom.roomId)
]
}
})
const LeaveRoomCommand = domain.command({
name: 'RoomLeaveRoomCommand',
name: 'Room.LeaveRoomCommand',
impl: ({ get }) => {
peerRoom.leaveRoom()
const { id: userId, name: username, avatar: userAvatar } = get(userInfoDomain.query.UserInfoQuery())!
@ -126,16 +130,21 @@ const RoomDomain = Remesh.domain({
type: 'delete',
user: { peerId: peerRoom.peerId, joinTime: Date.now(), userId, username, userAvatar }
}),
RoomJoinStatusModule.command.SetInitialCommand(),
JoinStatusModule.command.SetInitialCommand(),
LeaveRoomEvent(peerRoom.roomId)
]
}
})
const SendTextMessageCommand = domain.command({
name: 'RoomSendTextMessageCommand',
name: 'Room.SendTextMessageCommand',
impl: ({ get }, message: string) => {
const { id: userId, name: username, avatar: userAvatar } = get(userInfoDomain.query.UserInfoQuery())!
const {
id: userId,
name: username,
avatar: userAvatar,
danmakuEnabled
} = get(userInfoDomain.query.UserInfoQuery())!
const textMessage: TextMessage = {
id: nanoid(),
@ -154,13 +163,17 @@ const RoomDomain = Remesh.domain({
hateUsers: []
}
peerRoom.sendMessage<RoomMessage>(textMessage)
return [messageListDomain.command.CreateItemCommand(listMessage), SendTextMessageEvent(textMessage)]
peerRoom.sendMessage(textMessage)
return [
messageListDomain.command.CreateItemCommand(listMessage),
danmakuEnabled ? PushDanmakuCommand(textMessage) : null,
SendTextMessageEvent(textMessage)
]
}
})
const SendLikeMessageCommand = domain.command({
name: 'RoomSendLikeMessageCommand',
name: 'Room.SendLikeMessageCommand',
impl: ({ get }, messageId: string) => {
const { id: userId, name: username, avatar: userAvatar } = get(userInfoDomain.query.UserInfoQuery())!
const localMessage = get(messageListDomain.query.ItemQuery(messageId)) as NormalMessage
@ -176,13 +189,13 @@ const RoomDomain = Remesh.domain({
...localMessage,
likeUsers: desert(localMessage.likeUsers, likeMessage, 'userId')
}
peerRoom.sendMessage<RoomMessage>(likeMessage)
peerRoom.sendMessage(likeMessage)
return [messageListDomain.command.UpdateItemCommand(listMessage), SendLikeMessageEvent(likeMessage)]
}
})
const SendHateMessageCommand = domain.command({
name: 'RoomSendHateMessageCommand',
name: 'Room.SendHateMessageCommand',
impl: ({ get }, messageId: string) => {
const { id: userId, name: username, avatar: userAvatar } = get(userInfoDomain.query.UserInfoQuery())!
const localMessage = get(messageListDomain.query.ItemQuery(messageId)) as NormalMessage
@ -198,13 +211,13 @@ const RoomDomain = Remesh.domain({
...localMessage,
hateUsers: desert(localMessage.hateUsers, hateMessage, 'userId')
}
peerRoom.sendMessage<RoomMessage>(hateMessage)
peerRoom.sendMessage(hateMessage)
return [messageListDomain.command.UpdateItemCommand(listMessage), SendHateMessageEvent(hateMessage)]
}
})
const SendJoinMessageCommand = domain.command({
name: 'RoomSendJoinMessageCommand',
name: 'Room.SendJoinMessageCommand',
impl: ({ get }, targetPeerId: string) => {
const self = get(UserListQuery()).find((user) => user.peerId === peerRoom.peerId)!
@ -214,13 +227,13 @@ const RoomDomain = Remesh.domain({
type: SendType.Join
}
peerRoom.sendMessage<RoomMessage>(syncUserMessage, targetPeerId)
peerRoom.sendMessage(syncUserMessage, targetPeerId)
return [SendJoinMessageEvent(syncUserMessage)]
}
})
const UpdateUserListCommand = domain.command({
name: 'RoomUpdateUserListCommand',
name: 'Room.UpdateUserListCommand',
impl: ({ get }, action: { type: 'create' | 'delete'; user: RoomUser }) => {
const userList = get(UserListState())
if (action.type === 'create') {
@ -231,44 +244,58 @@ const RoomDomain = Remesh.domain({
}
})
const PushDanmakuCommand = domain.command({
name: 'Room.PushDanmakuCommand',
impl: (_, message: TextMessage) => {
return [danmakuDomain.command.PushCommand(message)]
}
})
// const UnshiftDanmakuCommand = domain.command({
// name: 'Room.PushDanmakuCommand',
// impl: (_, message: TextMessage) => {
// return [danmakuDomain.command.UnshiftCommand(message)]
// }
// })
const SendJoinMessageEvent = domain.event<SyncUserMessage>({
name: 'RoomSendJoinMessageEvent'
name: 'Room.SendJoinMessageEvent'
})
const SendTextMessageEvent = domain.event<TextMessage>({
name: 'RoomSendTextMessageEvent'
name: 'Room.SendTextMessageEvent'
})
const SendLikeMessageEvent = domain.event<LikeMessage>({
name: 'RoomSendLikeMessageEvent'
name: 'Room.SendLikeMessageEvent'
})
const SendHateMessageEvent = domain.event<HateMessage>({
name: 'RoomSendHateMessageEvent'
name: 'Room.SendHateMessageEvent'
})
const JoinRoomEvent = domain.event<string>({
name: 'RoomJoinRoomEvent'
name: 'Room.JoinRoomEvent'
})
const LeaveRoomEvent = domain.event<string>({
name: 'RoomLeaveRoomEvent'
name: 'Room.LeaveRoomEvent'
})
const OnMessageEvent = domain.event<RoomMessage>({
name: 'RoomOnMessageEvent'
name: 'Room.OnMessageEvent'
})
const OnJoinRoomEvent = domain.event<string>({
name: 'RoomOnJoinRoomEvent'
name: 'Room.OnJoinRoomEvent'
})
const OnLeaveRoomEvent = domain.event<string>({
name: 'RoomOnLeaveRoomEvent'
name: 'Room.OnLeaveRoomEvent'
})
domain.effect({
name: 'RoomOnJoinRoomEffect',
name: 'Room.OnJoinRoomEffect',
impl: () => {
const onJoinRoom$ = fromEventPattern<string>(peerRoom.onJoinRoom).pipe(
mergeMap((peerId) => {
@ -285,14 +312,16 @@ const RoomDomain = Remesh.domain({
})
domain.effect({
name: 'RoomOnMessageEffect',
name: 'Room.OnMessageEffect',
impl: ({ get }) => {
const onMessage$ = fromEventPattern<RoomMessage>(peerRoom.onMessage).pipe(
mergeMap((message) => {
// console.log('onMessage', message)
const { danmakuEnabled } = get(userInfoDomain.query.UserInfoQuery())!
const messageEvent$ = of(OnMessageEvent(message))
const commandEvent$ = (() => {
const messageCommand$ = (() => {
switch (message.type) {
case SendType.Join: {
const userList = get(UserListQuery())
@ -326,7 +355,8 @@ const RoomDomain = Remesh.domain({
date: Date.now(),
likeUsers: [],
hateUsers: []
})
}),
danmakuEnabled ? PushDanmakuCommand(message) : null
)
case SendType.Like:
case SendType.Hate: {
@ -355,7 +385,8 @@ const RoomDomain = Remesh.domain({
return EMPTY
}
})()
return merge(messageEvent$, commandEvent$)
return merge(messageEvent$, messageCommand$)
})
)
return onMessage$
@ -363,7 +394,7 @@ const RoomDomain = Remesh.domain({
})
domain.effect({
name: 'RoomOnLeaveRoomEffect',
name: 'Room.OnLeaveRoomEffect',
impl: ({ get }) => {
const onLeaveRoom$ = fromEventPattern<string>(peerRoom.onLeaveRoom).pipe(
map((peerId) => {
@ -392,7 +423,7 @@ const RoomDomain = Remesh.domain({
})
domain.effect({
name: 'RoomOnErrorEffect',
name: 'Room.OnErrorEffect',
impl: () => {
const onRoomError$ = fromEventPattern<Error>(peerRoom.onError).pipe(
map((error) => {
@ -407,11 +438,11 @@ const RoomDomain = Remesh.domain({
// TODO: Move this to a service worker in the future, so we don't need to send a leave room message every time the page refreshes
domain.effect({
name: 'RoomOnUnloadEffect',
name: 'Room.OnUnloadEffect',
impl: ({ get }) => {
const beforeUnload$ = fromEvent(window, 'beforeunload').pipe(
const beforeUnload$ = fromEvent(window, 'beforedestroy').pipe(
map(() => {
return get(RoomJoinStatusModule.query.IsFinishedQuery()) ? LeaveRoomCommand() : null
return get(JoinStatusModule.query.IsFinishedQuery()) ? LeaveRoomCommand() : null
})
)
return beforeUnload$
@ -422,7 +453,7 @@ const RoomDomain = Remesh.domain({
query: {
PeerIdQuery,
UserListQuery,
RoomJoinIsFinishedQuery: RoomJoinStatusModule.query.IsFinishedQuery
JoinIsFinishedQuery
},
command: {
JoinRoomCommand,

View file

@ -10,6 +10,7 @@ export interface UserInfo {
avatar: string
createTime: number
themeMode: 'system' | 'light' | 'dark'
danmakuEnabled: boolean
}
const UserInfoDomain = Remesh.domain({
@ -27,10 +28,10 @@ const UserInfoDomain = Remesh.domain({
})
const UserInfoLoadStatusModule = StatusModule(domain, {
name: 'UserInfoLoadStatusModule'
name: 'UserInfo.LoadStatusModule'
})
const UserInfoSetStatusModule = StatusModule(domain, {
name: 'UserInfoSetStatusModule'
name: 'UserInfo.SetStatusModule'
})
const UserInfoQuery = domain.query({
@ -43,8 +44,6 @@ const UserInfoDomain = Remesh.domain({
const UpdateUserInfoCommand = domain.command({
name: 'UserInfo.UpdateUserInfoCommand',
impl: (_, userInfo: UserInfo | null) => {
console.log('111', userInfo)
return [
UserInfoState().new(userInfo),
UpdateUserInfoEvent(),
@ -81,16 +80,16 @@ const UserInfoDomain = Remesh.domain({
UserInfoState().new(userInfo),
UpdateUserInfoEvent(),
SyncToStateEvent(userInfo),
userInfo && UserInfoSetStatusModule.command.SetFinishedCommand()
userInfo
? UserInfoSetStatusModule.command.SetFinishedCommand()
: UserInfoSetStatusModule.command.SetInitialCommand()
]
}
})
storageEffect
.set(SyncToStorageEvent)
.get<UserInfo>((value) => {
return [SyncToStateCommand(value), UserInfoLoadStatusModule.command.SetFinishedCommand()]
})
.get<UserInfo>((value) => [SyncToStateCommand(value), UserInfoLoadStatusModule.command.SetFinishedCommand()])
.watch<UserInfo>((value) => [SyncToStateCommand(value)])
return {

View file

@ -0,0 +1,30 @@
import { Remesh } from 'remesh'
import { TextMessage } from '../Room'
export interface Danmaku {
push: (message: TextMessage) => void
unshift: (message: TextMessage) => void
clear: () => void
mount: (root: HTMLElement) => void
destroy: () => void
}
export const DanmakuExtern = Remesh.extern<Danmaku>({
default: {
mount: () => {
throw new Error('"mount" not implemented.')
},
destroy() {
throw new Error('"destroy" not implemented.')
},
clear: () => {
throw new Error('"clear" not implemented.')
},
push: () => {
throw new Error('"push" not implemented.')
},
unshift: () => {
throw new Error('"unshift" not implemented.')
}
}
})

View file

@ -1,13 +1,12 @@
import { Remesh } from 'remesh'
export type PeerMessage = object | Blob | ArrayBuffer | ArrayBufferView
import { RoomMessage } from '../Room'
export interface PeerRoom {
readonly peerId: string
readonly roomId: string
joinRoom: () => PeerRoom
sendMessage: <T extends PeerMessage>(message: T, id?: string) => PeerRoom
onMessage: <T extends PeerMessage>(callback: (message: T) => void) => PeerRoom
sendMessage: (message: RoomMessage, id?: string) => PeerRoom
onMessage: (callback: (message: RoomMessage) => void) => PeerRoom
leaveRoom: () => PeerRoom
onJoinRoom: (callback: (id: string) => void) => PeerRoom
onLeaveRoom: (callback: (id: string) => void) => PeerRoom

View file

@ -1,10 +1,9 @@
import { Remesh } from 'remesh'
import { type Promisable } from 'type-fest'
export type StorageValue = null | string | number | boolean | object
export type WatchEvent = 'update' | 'remove'
export type WatchCallback = (event: WatchEvent, key: string) => any
export type Unwatch = () => Promisable<void>
export type Unwatch = () => Promise<void>
export interface Storage {
name: string
@ -31,10 +30,10 @@ export const IndexDBStorageExtern = Remesh.extern<Storage>({
clear: async () => {
throw new Error('"clear" not implemented.')
},
watch: () => {
watch: async () => {
throw new Error('"watch" not implemented.')
},
unwatch: () => {
unwatch: async () => {
throw new Error('"unwatch" not implemented.')
}
}
@ -55,10 +54,10 @@ export const BrowserSyncStorageExtern = Remesh.extern<Storage>({
clear: async () => {
throw new Error('"clear" not implemented.')
},
watch: () => {
watch: async () => {
throw new Error('"watch" not implemented.')
},
unwatch: () => {
unwatch: async () => {
throw new Error('"unwatch" not implemented.')
}
}

View file

@ -0,0 +1,80 @@
import { DanmakuExtern } from '@/domain/externs/Danmaku'
import { TextMessage } from '@/domain/Room'
import { createElement } from 'react'
import _Danmaku from 'danmaku'
import DanmakuMessage from '@/app/content/components/DanmakuMessage'
import { createRoot } from 'react-dom/client'
// import { create } from 'danmaku'
// const manager = create<TextMessage>({
// trackHeight: '20%',
// plugin: {
// init(manager) {
// 'shadow shadow-slate-200 bg-slate-100'.split(' ').forEach((c) => {
// manager.container.node.classList.add(c)
// })
// },
// $createNode(dm) {
// if (!dm.node) return
// createRoot(dm.node).render(createElement(DanmakuMessage, { data: dm.data }))
// }
// }
// })
// manager.mount(document.body)
// manager.startPlaying()
export class Danmaku {
private container?: Element
private _danmaku?: _Danmaku
mount(container: HTMLElement) {
this.container = container
this._danmaku = new _Danmaku({
container
})
}
destroy() {
if (!this.container) {
throw new Error('Danmaku not mounted')
}
this._danmaku!.destroy()
}
push(message: TextMessage) {
if (!this.container) {
throw new Error('Danmaku not mounted')
}
const root = document.createElement('div')
createRoot(root).render(createElement(DanmakuMessage, { data: message }))
// Wait for React render to complete
requestIdleCallback(() => {
this._danmaku!.emit({
render() {
return root.firstElementChild! as HTMLElement
}
})
})
}
unshift(message: TextMessage) {
if (!this.container) {
throw new Error('Danmaku not mounted')
}
// console.log(message)
}
clear() {
if (!this.container) {
throw new Error('Danmaku not mounted')
}
this._danmaku!.clear()
}
}
export const DanmakuImpl = DanmakuExtern.impl(new Danmaku())

View file

@ -2,9 +2,10 @@ import { type DataPayload, type Room, joinRoom, selfId } from 'trystero'
// import { joinRoom } from 'trystero/firebase'
import { PeerRoomExtern, type PeerMessage } from '@/domain/externs/PeerRoom'
import { PeerRoomExtern } from '@/domain/externs/PeerRoom'
import { stringToHex } from '@/utils'
import EventHub from '@resreq/event-hub'
import { RoomMessage } from '../Room'
export interface Config {
peerId?: string
@ -44,37 +45,37 @@ class PeerRoom extends EventHub {
return this
}
sendMessage<T extends PeerMessage>(message: T, id?: string) {
sendMessage(message: RoomMessage, id?: string) {
if (!this.room) {
this.once('action', () => {
if (!this.room) {
this.emit('error', new Error('Room not joined'))
} else {
const [send] = this.room.makeAction('MESSAGE')
send(message as DataPayload, id)
send(message as any as DataPayload, id)
}
})
} else {
const [send] = this.room.makeAction('MESSAGE')
send(message as DataPayload, id)
send(message as any as DataPayload, id)
}
return this
}
onMessage<T extends PeerMessage>(callback: (message: T) => void) {
onMessage(callback: (message: RoomMessage) => void) {
if (!this.room) {
this.once('action', () => {
if (!this.room) {
this.emit('error', new Error('Room not joined'))
} else {
const [, on] = this.room.makeAction('MESSAGE')
on((message) => callback(message as T))
on((message) => callback(message as any as RoomMessage))
}
})
} else {
const [, on] = this.room.makeAction('MESSAGE')
on((message) => callback(message as T))
on((message) => callback(message as any as RoomMessage))
}
return this
}

View file

@ -1,9 +1,10 @@
import { Artico, Room } from '@rtco/client'
import { PeerRoomExtern, type PeerMessage } from '@/domain/externs/PeerRoom'
import { PeerRoomExtern } from '@/domain/externs/PeerRoom'
import { stringToHex } from '@/utils'
import { nanoid } from 'nanoid'
import EventHub from '@resreq/event-hub'
import { RoomMessage } from '../Room'
export interface Config {
peerId?: string
roomId: string
@ -43,7 +44,7 @@ class PeerRoom extends EventHub {
return this
}
sendMessage<T extends PeerMessage>(message: T, id?: string) {
sendMessage(message: RoomMessage, id?: string) {
if (!this.room) {
this.once('action', () => {
if (!this.room) {
@ -58,17 +59,17 @@ class PeerRoom extends EventHub {
return this
}
onMessage<T extends PeerMessage>(callback: (message: T) => void) {
onMessage(callback: (message: RoomMessage) => void) {
if (!this.room) {
this.once('action', () => {
if (!this.room) {
this.emit('error', new Error('Room not joined'))
} else {
this.room.on('message', (message) => callback(JSON.parse(message) as T))
this.room.on('message', (message) => callback(JSON.parse(message) as RoomMessage))
}
})
} else {
this.room.on('message', (message) => callback(JSON.parse(message) as T))
this.room.on('message', (message) => callback(JSON.parse(message) as RoomMessage))
}
return this
}

View file

@ -3,6 +3,8 @@ import indexedDbDriver from 'unstorage/drivers/indexedb'
import { IndexDBStorageExtern, BrowserSyncStorageExtern } from '@/domain/externs/Storage'
import { STORAGE_NAME } from '@/constants/config'
import { webExtensionDriver } from '@/utils/webExtensionDriver'
import { browser } from 'wxt/browser'
import { Storage } from '@/domain/externs/Storage'
export const indexDBStorage = createStorage({
driver: indexedDbDriver({ base: `${STORAGE_NAME}:` })
@ -18,7 +20,7 @@ export const IndexDBStorageImpl = IndexDBStorageExtern.impl({
set: indexDBStorage.setItem,
remove: indexDBStorage.removeItem,
clear: indexDBStorage.clear,
watch: indexDBStorage.watch,
watch: indexDBStorage.watch as Storage['watch'],
unwatch: indexDBStorage.unwatch
})
@ -28,6 +30,26 @@ export const BrowserSyncStorageImpl = BrowserSyncStorageExtern.impl({
set: browserSyncStorage.setItem,
remove: browserSyncStorage.removeItem,
clear: browserSyncStorage.clear,
watch: browserSyncStorage.watch,
watch: browserSyncStorage.watch as Storage['watch'],
unwatch: browserSyncStorage.unwatch
})
// export const BrowserSyncStorageImpl = BrowserSyncStorageExtern.impl({
// name: STORAGE_NAME,
// get: async (key: string) => {
// const res = await browser.storage.sync.get(key)
// return res[key] ?? null
// },
// set: async (key, value) => {
// await browser.storage.sync.set({ [key]: value ?? null })
// },
// remove: browserSyncStorage.removeItem,
// clear: browserSyncStorage.clear,
// watch: async (callback) => {
// browser.storage.sync.onChanged.addListener(callback)
// return async () => {
// return browser.storage.sync.onChanged.removeListener(callback)
// }
// },
// unwatch: browserSyncStorage.unwatch
// })

View file

@ -1,18 +1,7 @@
import { type RemeshEvent, type RemeshAction, type RemeshDomainContext, type RemeshExtern } from 'remesh'
import { defer, from, fromEventPattern, map, Observable, switchMap } from 'rxjs'
import { type Promisable } from 'type-fest'
import { defer, from, map, Observable, switchMap } from 'rxjs'
export type StorageValue = null | string | number | boolean | object
export type WatchEvent = 'update' | 'remove'
export type WatchCallback = (event: WatchEvent, key: string) => any
export type Unwatch = () => Promisable<void>
export interface Storage {
get: <T extends StorageValue>(key: string) => Promise<T | null>
set: <T extends StorageValue>(key: string, value: T) => Promise<void>
watch: (callback: WatchCallback) => Promise<Unwatch>
unwatch?: Unwatch
}
import { Storage, StorageValue } from '@/domain/externs/Storage'
export interface Options {
domain: RemeshDomainContext
@ -60,20 +49,14 @@ export default class StorageEffect {
this.domain.effect({
name: 'WatchStorageToStateEffect',
impl: () => {
return defer(() => {
let unwatch: Unwatch
return new Observable<void>((observer) => {
this.storage
.watch(() => observer.next())
.then((_unwatch) => {
unwatch = _unwatch
})
return () => unwatch?.()
}).pipe(
switchMap(() => from(this.storage.get<T | null>(this.key))),
map(callback)
)
})
// TODO: Report the bug to https://github.com/unjs/unstorage
return new Observable((observer) => {
const unwatchPromise = this.storage.watch(() => observer.next())
return () => unwatchPromise.then((unwatch) => unwatch())
}).pipe(
switchMap(() => from(this.storage.get<T | null>(this.key))),
map(callback)
)
}
})
return this

View file

@ -1,5 +1,5 @@
import { type Driver, type WatchCallback, defineDriver } from 'unstorage'
import browser, { type Storage as BrowserStorage } from 'webextension-polyfill'
import { browser, type Storage as BrowserStorage } from 'wxt/browser'
export interface WebExtensionDriverOptions {
storageArea: 'sync' | 'local' | 'managed' | 'session'
@ -67,6 +67,7 @@ export const webExtensionDriver: (opts: WebExtensionDriverOptions) => Driver = d
watch(callback) {
checkPermission()
_listeners.add(callback)
if (_listeners.size === 1) {
browser.storage[opts.storageArea].onChanged.addListener(_storageListener)
}

View file

@ -11,7 +11,7 @@ export default defineConfig({
runner: {
startUrls: ['https://www.example.com/']
},
manifest: ({ browser, manifestVersion }) => {
manifest: ({ browser }) => {
const common = {
name: displayName,
permissions: ['storage'],