feat: peer message working!

This commit is contained in:
molvqingtai 2024-09-16 13:44:16 +08:00
parent 477a6533e8
commit 6fb4035ac3
49 changed files with 9499 additions and 5867 deletions

View file

@ -1 +0,0 @@
dist

View file

@ -1,51 +0,0 @@
{
"root": true,
"env": {
"es2021": true,
"browser": true,
"node": true
},
"extends": [
"standard-with-typescript",
"plugin:react/recommended",
"plugin:react/jsx-runtime",
"plugin:react-hooks/recommended",
"plugin:tailwindcss/recommended",
"prettier"
],
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint", "react", "react-hooks", "prettier"],
"parserOptions": {
"ecmaFeatures": {
"jsx": true
},
"ecmaVersion": "latest",
"sourceType": "module",
"project": "./tsconfig.eslint.json",
"warnOnUnsupportedTypeScriptVersion": false
},
"settings": {
"react": {
"version": "detect"
}
},
"rules": {
"prettier/prettier": "error",
"react/prop-types": "off",
"import/order": "error",
"import/no-absolute-path": "off",
"n/no-callback-literal": "off",
"@typescript-eslint/naming-convention": "off",
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/no-confusing-void-expression": "off",
"@typescript-eslint/strict-boolean-expressions": "off",
"@typescript-eslint/no-floating-promises": "off",
"@typescript-eslint/restrict-template-expressions": "off",
"@typescript-eslint/no-misused-promises": "off",
"@typescript-eslint/consistent-type-assertions": "off",
"@typescript-eslint/no-base-to-string": "off",
"@typescript-eslint/no-unused-vars": "warn"
}
}

View file

@ -1,4 +1,2 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npx commitlint --edit "$1" npx commitlint --edit "$1"

View file

@ -1,4 +1,2 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
pnpm lint-staged && pnpm tsc:check pnpm lint-staged && pnpm tsc:check

2
.npmrc
View file

@ -1,3 +1,3 @@
engine-strict=true engine-strict=true
auto-install-peers=true auto-install-peers=true
shamefully-hoist=true shamefully-hoist=false

1
.nvmrc Normal file
View file

@ -0,0 +1 @@
>= 20

6
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,6 @@
{
"eslint.options": {
"flags": ["unstable_ts_config"]
},
"eslint.useFlatConfig": true
}

40
eslint.config.ts Normal file
View file

@ -0,0 +1,40 @@
// import type { Linter } from 'eslint'
import globals from 'globals'
import pluginJs from '@eslint/js'
import tseslint from 'typescript-eslint'
import reactPlugin from '@eslint-react/eslint-plugin'
import tailwindPlugin from 'eslint-plugin-tailwindcss'
import prettierPlugin from 'eslint-plugin-prettier/recommended'
import * as tsParser from '@typescript-eslint/parser'
export default [
{ files: ['**/*.{js,mjs,cjs,ts,jsx,tsx}'] },
{
languageOptions: { globals: globals.browser }
},
pluginJs.configs.recommended,
...tseslint.configs.recommended,
...tailwindPlugin.configs['flat/recommended'],
prettierPlugin,
{
files: ['**/*.{ts,tsx}'],
...reactPlugin.configs.recommended,
languageOptions: {
parser: tsParser
}
},
{
ignores: ['**/.output/*', '**/.wxt/*', '**/ui/**']
},
{
rules: {
'@typescript-eslint/no-unused-vars': 'warn',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-empty-object-type': 'off',
'@typescript-eslint/no-unused-expressions': 'off',
'@eslint-react/no-array-index-key': 'off',
'@eslint-react/hooks-extra/no-redundant-custom-hook': 'off'
}
}
]
// satisfies Linter.Config[]

View file

@ -13,10 +13,10 @@
"pack": "cross-env NODE_ENV=production run-p pack:*", "pack": "cross-env NODE_ENV=production run-p pack:*",
"pack:zip": "wxt zip", "pack:zip": "wxt zip",
"pack:firefox": "wxt zip -b firefox", "pack:firefox": "wxt zip -b firefox",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx --cache --fix", "lint": "eslint --fix --flag unstable_ts_config",
"clear": "rimraf .output", "clear": "rimraf .output",
"tsc:check": "tsc --noEmit", "tsc:check": "tsc --noEmit",
"prepare": "husky install", "prepare": "husky",
"postinstall": "wxt prepare" "postinstall": "wxt prepare"
}, },
"repository": { "repository": {
@ -63,7 +63,6 @@
"idb-keyval": "^6.2.1", "idb-keyval": "^6.2.1",
"lucide-react": "^0.350.0", "lucide-react": "^0.350.0",
"nanoid": "^5.0.6", "nanoid": "^5.0.6",
"peerjs": "^1.5.2",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-hook-form": "^7.51.0", "react-hook-form": "^7.51.0",
@ -72,12 +71,13 @@
"react-use": "^17.5.0", "react-use": "^17.5.0",
"remark-breaks": "^4.0.0", "remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.0", "remark-gfm": "^4.0.0",
"remesh": "^4.2.1", "remesh": "^4.2.2",
"remesh-logger": "^4.1.0", "remesh-logger": "^4.1.0",
"remesh-react": "^4.1.2", "remesh-react": "^4.1.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"sonner": "^1.4.3", "sonner": "^1.4.3",
"tailwind-merge": "^2.2.1", "tailwind-merge": "^2.2.1",
"trystero": "^0.20.0",
"type-fest": "^4.11.1", "type-fest": "^4.11.1",
"unstorage": "^1.10.1", "unstorage": "^1.10.1",
"valibot": "^0.30.0" "valibot": "^0.30.0"
@ -85,6 +85,11 @@
"devDependencies": { "devDependencies": {
"@commitlint/cli": "^19.0.3", "@commitlint/cli": "^19.0.3",
"@commitlint/config-conventional": "^19.0.3", "@commitlint/config-conventional": "^19.0.3",
"@eslint-react/eslint-plugin": "^1.14.1",
"@eslint/js": "^9.10.0",
"@types/eslint": "^9.6.1",
"@types/eslint-plugin-tailwindcss": "^3.17.0",
"@types/eslint__js": "^8.42.3",
"@types/node": "^20.11.25", "@types/node": "^20.11.25",
"@types/react": "^18.2.64", "@types/react": "^18.2.64",
"@types/react-dom": "^18.2.21", "@types/react-dom": "^18.2.21",
@ -92,17 +97,13 @@
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.18", "autoprefixer": "^10.4.18",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"eslint": "^8.57.0", "eslint": "^9.10.0",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"eslint-config-standard-with-typescript": "^43.0.1", "eslint-plugin-prettier": "^5.2.1",
"eslint-plugin-import": "^2.29.1", "eslint-plugin-tailwindcss": "^3.17.4",
"eslint-plugin-n": "^16.6.2", "globals": "^15.9.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-promise": "^6.1.1",
"eslint-plugin-react": "^7.34.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-tailwindcss": "^3.14.3",
"husky": "^9.0.11", "husky": "^9.0.11",
"jiti": "^1.21.6",
"lint-staged": "^15.2.2", "lint-staged": "^15.2.2",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"postcss": "^8.4.35", "postcss": "^8.4.35",
@ -110,14 +111,15 @@
"rimraf": "^5.0.5", "rimraf": "^5.0.5",
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"typescript": "^5.4.2", "typescript": "^5.6.2",
"typescript-eslint": "^8.5.0",
"webext-bridge": "^6.0.1", "webext-bridge": "^6.0.1",
"wxt": "^0.17.7" "wxt": "^0.17.7"
}, },
"lint-staged": { "lint-staged": {
"*.{js,jsx,ts,tsx}": "eslint --fix" "*.{js,jsx,ts,tsx}": "eslint --fix --flag unstable_ts_config"
}, },
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=20.0.0"
} }
} }

File diff suppressed because it is too large Load diff

View file

@ -7,11 +7,8 @@ export default defineBackground({
type: 'module', type: 'module',
main() { main() {
browser.runtime.onMessage.addListener(async (message, options) => { browser.runtime.onMessage.addListener(async () => {
console.log('Background recieved:', message, options)
console.log('Background sending:', 'pong')
browser.runtime.openOptionsPage() browser.runtime.openOptionsPage()
return 'pong'
}) })
} }
}) })

View file

@ -3,7 +3,7 @@ import { useState, type FC } from 'react'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
import { ScrollArea } from '@/components/ui/ScrollArea' import { ScrollArea } from '@/components/ui/ScrollArea'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
import { EMOJI_LIST } from '@/constants' import { EMOJI_LIST } from '@/constants/config'
import { chunk } from '@/utils' import { chunk } from '@/utils'
export interface EmojiButtonProps { export interface EmojiButtonProps {
@ -35,7 +35,7 @@ const EmojiButton: FC<EmojiButtonProps> = ({ onSelect }) => {
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="z-top w-72 px-0" onCloseAutoFocus={handleCloseAutoFocus}> <PopoverContent className="z-top w-72 px-0" onCloseAutoFocus={handleCloseAutoFocus}>
<ScrollArea className="h-72 w-72 px-3"> <ScrollArea className="size-72 px-3">
{emojiGroups.map((group, index) => { {emojiGroups.map((group, index) => {
return ( return (
<div key={index} className="grid grid-cols-8"> <div key={index} className="grid grid-cols-8">

View file

@ -0,0 +1,18 @@
import { format as formatDate } from 'date-fns'
import { type FC } from 'react'
import { Slot } from '@radix-ui/react-slot'
export interface FormatDateProps {
date: Date | number | string
format?: string
asChild?: boolean
className?: string
}
const FormatDate: FC<FormatDateProps> = ({ date, format = 'yyyy/MM/dd HH:mm:ss', asChild = false, ...props }) => {
const Comp = asChild ? Slot : 'div'
return <Comp {...props}>{formatDate(date, format)}</Comp>
}
FormatDate.displayName = 'FormatDate'
export default FormatDate

View file

@ -1,64 +1,60 @@
import { type FC, useState } from 'react' import { type FC } from 'react'
import { format } from 'date-fns'
import { FrownIcon, ThumbsUpIcon } from 'lucide-react' import { FrownIcon, ThumbsUpIcon } from 'lucide-react'
import LikeButton from './LikeButton' import LikeButton from './LikeButton'
import FormatDate from './FormatDate'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/Avatar' import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/Avatar'
import { Markdown } from '@/components/ui/Markdown' import { Markdown } from '@/components/ui/Markdown'
import { type Message } from '@/domain/MessageList'
export interface MessageItemProps { export interface MessageItemProps {
data: Message data: Message
index?: number index?: number
like: boolean
hate: boolean
onLikeChange?: (checked: boolean) => void
onHateChange?: (checked: boolean) => void
} }
const MessageItem: FC<MessageItemProps> = ({ data, index }) => { const MessageItem: FC<MessageItemProps> = (props) => {
const [formatData, setFormatData] = useState({ const handleLikeChange = (checked: boolean) => {
...data, props.onLikeChange?.(checked)
date: format(data.date, 'yyyy/MM/dd HH:mm:ss')
})
const handleLikeChange = (type: 'like' | 'hate', checked: boolean, count: number) => {
setFormatData((prev) => {
return {
...prev,
[`${type}Checked`]: checked,
[`${type}Count`]: count
} }
}) const handleHateChange = (checked: boolean) => {
props.onHateChange?.(checked)
} }
return ( return (
<div <div
data-index={index} data-index={props.index}
className="box-border grid grid-cols-[auto_1fr] gap-x-2 px-4 [content-visibility:auto] first:pt-4 last:pb-4" className="box-border grid grid-cols-[auto_1fr] gap-x-2 px-4 [content-visibility:auto] first:pt-4 last:pb-4"
> >
<Avatar> <Avatar>
<AvatarImage src={formatData.userAvatar} alt="avatar" /> <AvatarImage src={props.data.userAvatar} alt="avatar" />
<AvatarFallback>{formatData.username.at(0)}</AvatarFallback> <AvatarFallback>{props.data.username.at(0)}</AvatarFallback>
</Avatar> </Avatar>
<div className="overflow-hidden"> <div className="overflow-hidden">
<div className="grid grid-cols-[auto_1fr] items-baseline gap-x-2 leading-none"> <div className="grid grid-cols-[auto_1fr] items-baseline gap-x-2 leading-none">
<div className="text-sm font-medium text-slate-600">{formatData.username}</div> <div className="text-sm font-medium text-slate-600">{props.data.username}</div>
<div className="text-xs text-slate-400">{formatData.date}</div> <FormatDate className="text-xs text-slate-400" date={props.data.date}></FormatDate>
</div> </div>
<div> <div>
<div className="pb-2"> <div className="pb-2">
<Markdown>{formatData.body}</Markdown> <Markdown>{props.data.body}</Markdown>
</div> </div>
<div className="grid grid-flow-col justify-end gap-x-2 leading-none"> <div className="grid grid-flow-col justify-end gap-x-2 leading-none">
<LikeButton <LikeButton
checked={formatData.likeChecked} checked={props.like}
onChange={(...args) => handleLikeChange('like', ...args)} onChange={(checked) => handleLikeChange(checked)}
count={formatData.likeCount} count={props.data.likeUsers.length}
> >
<LikeButton.Icon> <LikeButton.Icon>
<ThumbsUpIcon size={14}></ThumbsUpIcon> <ThumbsUpIcon size={14}></ThumbsUpIcon>
</LikeButton.Icon> </LikeButton.Icon>
</LikeButton> </LikeButton>
<LikeButton <LikeButton
checked={formatData.hateChecked} checked={props.hate}
onChange={(...args) => handleLikeChange('hate', ...args)} onChange={(checked) => handleHateChange(checked)}
count={formatData.hateCount} count={props.data.hateUsers.length}
> >
<LikeButton.Icon> <LikeButton.Icon>
<FrownIcon size={14}></FrownIcon> <FrownIcon size={14}></FrownIcon>

View file

@ -7,8 +7,8 @@ import { defineContentScript } from 'wxt/sandbox'
import { createShadowRootUi } from 'wxt/client' import { createShadowRootUi } from 'wxt/client'
import App from './App' import App from './App'
import { IndexDBStorageImpl, BrowserSyncStorageImpl } from '@/impl/Storage' import { IndexDBStorageImpl, BrowserSyncStorageImpl } from '@/domain/impls/Storage'
import { PeerClientImpl } from '@/impl/PeerClient' import { PeerRoomImpl } from '@/domain/impls/PeerRoom'
import '@/assets/styles/tailwind.css' import '@/assets/styles/tailwind.css'
export default defineContentScript({ export default defineContentScript({
@ -16,7 +16,7 @@ export default defineContentScript({
matches: ['*://*.example.com/*', '*://*.google.com/*', '*://*.v2ex.com/*'], matches: ['*://*.example.com/*', '*://*.google.com/*', '*://*.v2ex.com/*'],
async main(ctx) { async main(ctx) {
const store = Remesh.store({ const store = Remesh.store({
externs: [IndexDBStorageImpl, BrowserSyncStorageImpl, PeerClientImpl], externs: [IndexDBStorageImpl, BrowserSyncStorageImpl, PeerRoomImpl],
inspectors: [RemeshLogger()] inspectors: [RemeshLogger()]
}) })

View file

@ -4,7 +4,7 @@ import { SettingsIcon, MoonIcon, SunIcon } from 'lucide-react'
import { browser } from 'wxt/browser' import { browser } from 'wxt/browser'
import { useRemeshDomain, useRemeshQuery, useRemeshSend } from 'remesh-react' import { useRemeshDomain, useRemeshQuery, useRemeshSend } from 'remesh-react'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
import { EVENTS } from '@/constants' import { EVENT } from '@/constants/event'
import UserInfoDomain from '@/domain/UserInfo' import UserInfoDomain from '@/domain/UserInfo'
import useClickAway from '@/hooks/useClickAway' import useClickAway from '@/hooks/useClickAway'
import { checkSystemDarkMode, cn } from '@/utils' import { checkSystemDarkMode, cn } from '@/utils'
@ -51,7 +51,7 @@ const AppButton: FC<AppButtonProps> = ({ children }) => {
} }
const handleOpenOptionsPage = () => { const handleOpenOptionsPage = () => {
browser.runtime.sendMessage(EVENTS.OPEN_OPTIONS_PAGE) browser.runtime.sendMessage(EVENT.OPEN_OPTIONS_PAGE)
} }
return ( return (
@ -62,7 +62,7 @@ const AppButton: FC<AppButtonProps> = ({ children }) => {
onClick={handleSwitchTheme} onClick={handleSwitchTheme}
variant="outline" variant="outline"
data-state={open ? 'open' : 'closed'} data-state={open ? 'open' : 'closed'}
className="pointer-events-auto relative h-10 w-10 overflow-hidden rounded-full p-0 shadow fill-mode-forwards data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:fade-in data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom" className="pointer-events-auto relative size-10 overflow-hidden rounded-full p-0 shadow fill-mode-forwards data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:fade-in data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom"
> >
<div <div
className={cn( className={cn(
@ -79,12 +79,12 @@ const AppButton: FC<AppButtonProps> = ({ children }) => {
onClick={handleOpenOptionsPage} onClick={handleOpenOptionsPage}
variant="outline" variant="outline"
data-state={open ? 'open' : 'closed'} data-state={open ? 'open' : 'closed'}
className="pointer-events-auto h-10 w-10 rounded-full p-0 shadow fill-mode-forwards data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:fade-in data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom" className="pointer-events-auto size-10 rounded-full p-0 shadow fill-mode-forwards data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:fade-in data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom"
> >
<SettingsIcon size={20} /> <SettingsIcon size={20} />
</Button> </Button>
</div> </div>
<Button onContextMenu={handleToggleMenu} className="relative z-10 h-10 w-10 rounded-full p-0 text-xs shadow"> <Button onContextMenu={handleToggleMenu} className="relative z-10 size-10 rounded-full p-0 text-xs shadow">
{children} {children}
</Button> </Button>
</div> </div>

View file

@ -5,14 +5,14 @@ import MessageInput from '../../components/MessageInput'
import EmojiButton from '../../components/EmojiButton' import EmojiButton from '../../components/EmojiButton'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
import MessageInputDomain from '@/domain/MessageInput' import MessageInputDomain from '@/domain/MessageInput'
import MessageListDomain from '@/domain/MessageList' import { MESSAGE_MAX_LENGTH } from '@/constants/config'
import { MESSAGE_MAX_LENGTH } from '@/constants' import RoomDomain from '@/domain/Room'
const Footer: FC = () => { const Footer: FC = () => {
const send = useRemeshSend() const send = useRemeshSend()
const messageListDomain = useRemeshDomain(MessageListDomain()) const roomDomain = useRemeshDomain(RoomDomain())
const messageInputDomain = useRemeshDomain(MessageInputDomain()) const messageInputDomain = useRemeshDomain(MessageInputDomain())
const messageBody = useRemeshQuery(messageInputDomain.query.MessageQuery()) const message = useRemeshQuery(messageInputDomain.query.MessageQuery())
const inputRef = useRef<HTMLTextAreaElement>(null) const inputRef = useRef<HTMLTextAreaElement>(null)
@ -20,28 +20,14 @@ const Footer: FC = () => {
send(messageInputDomain.command.InputCommand(value)) send(messageInputDomain.command.InputCommand(value))
} }
const message: Omit<Message, 'id'> = {
username: '墨绿青苔',
userId: '10251037',
userAvatar: 'https://avatars.githubusercontent.com/u/10251037?v=4',
body: messageBody.trim(),
date: Date.now(),
likeChecked: false,
likeCount: 0,
linkUsers: [],
hateChecked: false,
hateUsers: [],
hateCount: 0
}
const handleSend = () => { const handleSend = () => {
if (!message.body) return if (!message.trim()) return
send(messageListDomain.command.CreateItemCommand(message)) send(roomDomain.command.SendTextMessageCommand(message.trim()))
send(messageInputDomain.command.ClearCommand()) send(messageInputDomain.command.ClearCommand())
} }
const handleEmojiSelect = (emoji: string) => { const handleEmojiSelect = (emoji: string) => {
send(messageInputDomain.command.InputCommand(`${messageBody}${emoji}`)) send(messageInputDomain.command.InputCommand(`${message}${emoji}`))
inputRef.current?.focus() inputRef.current?.focus()
} }
@ -49,7 +35,7 @@ const Footer: FC = () => {
<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-4 before:h-4 before:bg-gradient-to-t before:from-slate-50 before:from-30% before:to-transparent"> <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-4 before:h-4 before:bg-gradient-to-t before:from-slate-50 before:from-30% before:to-transparent">
<MessageInput <MessageInput
ref={inputRef} ref={inputRef}
value={messageBody} value={message}
onEnter={handleSend} onEnter={handleSend}
onInput={handleInput} onInput={handleInput}
maxLength={MESSAGE_MAX_LENGTH} maxLength={MESSAGE_MAX_LENGTH}

View file

@ -10,7 +10,7 @@ const Header: FC = () => {
return ( return (
<div className="z-10 grid h-12 grid-flow-col items-center justify-between gap-x-4 rounded-t-xl bg-white px-4 backdrop-blur-lg"> <div className="z-10 grid h-12 grid-flow-col items-center justify-between gap-x-4 rounded-t-xl bg-white px-4 backdrop-blur-lg">
<Avatar className="h-8 w-8"> <Avatar className="size-8">
<AvatarImage src={siteInfo.icon} alt="favicon" /> <AvatarImage src={siteInfo.icon} alt="favicon" />
<AvatarFallback> <AvatarFallback>
<Globe2Icon size="100%" className="text-gray-400" /> <Globe2Icon size="100%" className="text-gray-400" />
@ -26,7 +26,7 @@ const Header: FC = () => {
</HoverCardTrigger> </HoverCardTrigger>
<HoverCardContent className="w-80"> <HoverCardContent className="w-80">
<div className="grid grid-cols-[auto_1fr] gap-x-4"> <div className="grid grid-cols-[auto_1fr] gap-x-4">
<Avatar className="h-14 w-14"> <Avatar className="size-14">
<AvatarImage src={siteInfo.icon} alt="favicon" /> <AvatarImage src={siteInfo.icon} alt="favicon" />
<AvatarFallback> <AvatarFallback>
<Globe2Icon size="100%" className="text-gray-400" /> <Globe2Icon size="100%" className="text-gray-400" />

View file

@ -1,17 +1,36 @@
import { useEffect, type FC, useRef } from 'react' import { useEffect, type FC, useRef } from 'react'
import { useRemeshDomain, useRemeshQuery } from 'remesh-react' import { useRemeshDomain, useRemeshQuery, useRemeshSend } from 'remesh-react'
import MessageList from '../../components/MessageList' import MessageList from '../../components/MessageList'
import MessageItem from '../../components/MessageItem' import MessageItem from '../../components/MessageItem'
import MessageListDomain from '@/domain/MessageList' import MessageListDomain from '@/domain/MessageList'
import UserInfoDomain from '@/domain/UserInfo'
import RoomDomain from '@/domain/Room'
const Main: FC = () => { const Main: FC = () => {
const send = useRemeshSend()
const roomDomain = useRemeshDomain(RoomDomain())
const userInfoDomain = useRemeshDomain(UserInfoDomain())
const messageListDomain = useRemeshDomain(MessageListDomain()) const messageListDomain = useRemeshDomain(MessageListDomain())
const messageList = useRemeshQuery(messageListDomain.query.ListQuery()) const _messageList = useRemeshQuery(messageListDomain.query.ListQuery())
const userInfo = useRemeshQuery(userInfoDomain.query.UserInfoQuery())
const messageList = _messageList.map((message) => ({
...message,
like: message.likeUsers.some((likeUser) => likeUser.userId === userInfo?.id),
hate: message.hateUsers.some((hateUser) => hateUser.userId === userInfo?.id)
}))
const messageListRef = useRef<HTMLDivElement>(null) const messageListRef = useRef<HTMLDivElement>(null)
const isUpdate = useRef(false) const isUpdate = useRef(false)
const handleLikeChange = (messageId: string) => {
send(roomDomain.command.SendLikeMessageCommand(messageId))
}
const handleHateChange = (messageId: string) => {
send(roomDomain.command.SendHateMessageCommand(messageId))
}
useEffect(() => { useEffect(() => {
const lastMessageRef = messageListRef.current?.querySelector('[data-index]:last-child') const lastMessageRef = messageListRef.current?.querySelector('[data-index]:last-child')
const timerId = setTimeout(() => { const timerId = setTimeout(() => {
@ -27,7 +46,15 @@ const Main: FC = () => {
return ( return (
<MessageList ref={messageListRef}> <MessageList ref={messageListRef}>
{messageList.map((message, index) => ( {messageList.map((message, index) => (
<MessageItem key={message.id} data={message} index={index}></MessageItem> <MessageItem
key={message.id}
data={message}
like={message.like}
hate={message.hate}
index={index}
onLikeChange={() => handleLikeChange(message.id)}
onHateChange={() => handleHateChange(message.id)}
></MessageItem>
))} ))}
</MessageList> </MessageList>
) )

View file

@ -9,7 +9,7 @@ import AvatarSelect from './AvatarSelect'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/Form' import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/Form'
import { Input } from '@/components/ui/Input' import { Input } from '@/components/ui/Input'
import UserInfoDomain from '@/domain/UserInfo' import UserInfoDomain, { type UserInfo } from '@/domain/UserInfo'
import { checkSystemDarkMode } from '@/utils' import { checkSystemDarkMode } from '@/utils'
import { RadioGroup, RadioGroupItem } from '@/components/ui/RadioGroup' import { RadioGroup, RadioGroupItem } from '@/components/ui/RadioGroup'
import { Label } from '@/components/ui/Label' import { Label } from '@/components/ui/Label'

View file

@ -4,7 +4,7 @@ import { Remesh } from 'remesh'
import { RemeshRoot } from 'remesh-react' import { RemeshRoot } from 'remesh-react'
import { RemeshLogger } from 'remesh-logger' import { RemeshLogger } from 'remesh-logger'
import App from './App' import App from './App'
import { BrowserSyncStorageImpl } from '@/impl/Storage' import { BrowserSyncStorageImpl } from '@/domain/impls/Storage'
import '@/assets/styles/tailwind.css' import '@/assets/styles/tailwind.css'
const store = Remesh.store({ const store = Remesh.store({

View file

@ -21,7 +21,7 @@ const buttonVariants = cva(
sm: 'h-8 rounded-md px-3 text-xs', sm: 'h-8 rounded-md px-3 text-xs',
xs: 'h-6 rounded-md px-2 text-xs', xs: 'h-6 rounded-md px-2 text-xs',
lg: 'h-10 rounded-md px-8', lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9' icon: 'size-9'
} }
}, },
defaultVariants: { defaultVariants: {

View file

@ -26,7 +26,7 @@ const RadioGroupItem = React.forwardRef<
{...props} {...props}
> >
<RadioGroupPrimitive.Indicator className="flex items-center justify-center"> <RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<CheckIcon className="h-3.5 w-3.5 fill-primary" /> <CheckIcon className="size-3.5 fill-primary" />
</RadioGroupPrimitive.Indicator> </RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item> </RadioGroupPrimitive.Item>
) )

View file

@ -8,7 +8,7 @@ const ScrollArea = React.forwardRef<
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => ( >(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root ref={ref} className={cn('relative overflow-hidden', className)} {...props}> <ScrollAreaPrimitive.Root ref={ref} className={cn('relative overflow-hidden', className)} {...props}>
<ScrollAreaPrimitive.Viewport className="h-full w-full overscroll-none rounded-[inherit]"> <ScrollAreaPrimitive.Viewport className="size-full overscroll-none rounded-[inherit]">
{children} {children}
</ScrollAreaPrimitive.Viewport> </ScrollAreaPrimitive.Viewport>
<ScrollBar /> <ScrollBar />

View file

@ -186,7 +186,3 @@ export const BREAKPOINTS = {
export const MESSAGE_MAX_LENGTH = 500 as const export const MESSAGE_MAX_LENGTH = 500 as const
export const STORAGE_NAME = 'WEB_CHAT' as const export const STORAGE_NAME = 'WEB_CHAT' as const
export enum EVENTS {
OPEN_OPTIONS_PAGE = 'OPEN_OPTIONS_PAGE'
}

3
src/constants/event.ts Normal file
View file

@ -0,0 +1,3 @@
export enum EVENT {
OPEN_OPTIONS_PAGE = 'OPEN_OPTIONS_PAGE'
}

View file

@ -1,20 +1,33 @@
import { Remesh } from 'remesh' import { Remesh } from 'remesh'
import { ListModule } from 'remesh/modules/list' import { ListModule } from 'remesh/modules/list'
import { nanoid } from 'nanoid' import { nanoid } from 'nanoid'
import { from, map, tap, merge } from 'rxjs' import { IndexDBStorageExtern } from '@/domain/externs/Storage'
import { IndexDBStorageExtern } from './externs/Storage' import StorageEffect from '@/domain/modules/StorageEffect'
import { PeerClientExtern } from './externs/PeerClient'
import { callbackToObservable, stringToHex } from '@/utils'
const hostRoomId = stringToHex(document.location.host) export interface MessageUser {
userId: string
username: string
userAvatar: string
}
export interface Message extends MessageUser {
id: string
body: string
date: number
likeUsers: MessageUser[]
hateUsers: MessageUser[]
}
export const STORAGE_KEY = `MESSAGE_LIST`
const MessageListDomain = Remesh.domain({ const MessageListDomain = Remesh.domain({
name: 'MessageListDomain', name: 'MessageListDomain',
impl: (domain) => { impl: (domain) => {
const storage = domain.getExtern(IndexDBStorageExtern) const storageEffect = new StorageEffect({
const peerClient = domain.getExtern(PeerClientExtern) domain,
const storageKey = `MESSAGE_LIST` as const extern: IndexDBStorageExtern,
peerClient.connect(hostRoomId) key: STORAGE_KEY
})
const MessageListModule = ListModule<Message>(domain, { const MessageListModule = ListModule<Message>(domain, {
name: 'MessageListModule', name: 'MessageListModule',
@ -40,7 +53,12 @@ const MessageListDomain = Remesh.domain({
name: 'MessageList.CreateItemCommand', name: 'MessageList.CreateItemCommand',
impl: (_, message: Omit<Message, 'id'>) => { impl: (_, message: Omit<Message, 'id'>) => {
const newMessage = { ...message, id: nanoid() } const newMessage = { ...message, id: nanoid() }
return [MessageListModule.command.AddItemCommand(newMessage), CreateItemEvent(newMessage), ChangeListEvent()] return [
MessageListModule.command.AddItemCommand(newMessage),
CreateItemEvent(newMessage),
ChangeListEvent(),
SyncToStorageEvent()
]
} }
}) })
@ -51,7 +69,12 @@ const MessageListDomain = Remesh.domain({
const UpdateItemCommand = domain.command({ const UpdateItemCommand = domain.command({
name: 'MessageList.UpdateItemCommand', name: 'MessageList.UpdateItemCommand',
impl: (_, message: Message) => { impl: (_, message: Message) => {
return [MessageListModule.command.UpdateItemCommand(message), UpdateItemEvent(message), ChangeListEvent()] return [
MessageListModule.command.UpdateItemCommand(message),
UpdateItemEvent(message),
ChangeListEvent(),
SyncToStorageEvent()
]
} }
}) })
@ -62,7 +85,12 @@ const MessageListDomain = Remesh.domain({
const DeleteItemCommand = domain.command({ const DeleteItemCommand = domain.command({
name: 'MessageList.DeleteItemCommand', name: 'MessageList.DeleteItemCommand',
impl: (_, id: string) => { impl: (_, id: string) => {
return [MessageListModule.command.DeleteItemCommand(id), DeleteItemEvent(id), ChangeListEvent()] return [
MessageListModule.command.DeleteItemCommand(id),
DeleteItemEvent(id),
ChangeListEvent(),
SyncToStorageEvent()
]
} }
}) })
@ -73,62 +101,32 @@ const MessageListDomain = Remesh.domain({
const ClearListCommand = domain.command({ const ClearListCommand = domain.command({
name: 'MessageList.ClearListCommand', name: 'MessageList.ClearListCommand',
impl: () => { impl: () => {
return [MessageListModule.command.DeleteAllCommand(), ClearListEvent(), ChangeListEvent()] return [MessageListModule.command.DeleteAllCommand(), ClearListEvent(), ChangeListEvent(), SyncToStorageEvent()]
} }
}) })
const InitListEvent = domain.event<Message[]>({ const SyncToStorageEvent = domain.event({
name: 'MessageList.InitListEvent' name: 'MessageList.SyncToStorageEvent',
impl: ({ get }) => {
return get(ListQuery())
}
}) })
const InitListCommand = domain.command({ const SyncToStateEvent = domain.event<Message[]>({
name: 'MessageList.InitListCommand', name: 'MessageList.SyncToStateEvent'
})
const SyncToStateCommand = domain.command({
name: 'MessageList.SyncToStateCommand',
impl: (_, messages: Message[]) => { impl: (_, messages: Message[]) => {
return [MessageListModule.command.SetListCommand(messages), InitListEvent(messages)] return [MessageListModule.command.SetListCommand(messages), SyncToStateEvent(messages)]
} }
}) })
domain.effect({ storageEffect
name: 'FormStorageToStateEffect', .set(SyncToStorageEvent)
impl: () => { .get<Message[]>((value) => SyncToStateCommand(value ?? []))
return from(storage.get<Message[]>(storageKey)).pipe(map((messages) => InitListCommand(messages ?? []))) .watch<Message[]>((value) => SyncToStateCommand(value ?? []))
}
})
domain.effect({
name: 'FormStateToStorageEffect',
impl: ({ fromEvent }) => {
const changeList$ = fromEvent(ChangeListEvent).pipe(
tap(async (messages) => await storage.set<Message[]>(storageKey, messages))
)
return merge(changeList$).pipe(map(() => null))
}
})
domain.effect({
name: 'FormStateToPeerClientEffect',
impl: ({ fromEvent }) => {
const createItem$ = fromEvent(CreateItemEvent).pipe(
tap(async (message) => {
await peerClient.sendMessage(JSON.stringify(message))
})
)
return merge(createItem$).pipe(map(() => null))
}
})
// domain.effect({
// name: 'FormPeerClientToStateEffect',
// impl: () => {
// return callbackToObservable(peerClient.onMessage.bind(peerClient)).pipe(
// map((message) => {
// console.log(message)
// // debugger
// return CreateItemCommand(message)
// })
// )
// }
// })
return { return {
query: { query: {
@ -142,10 +140,13 @@ const MessageListDomain = Remesh.domain({
ClearListCommand ClearListCommand
}, },
event: { event: {
ChangeListEvent,
CreateItemEvent, CreateItemEvent,
UpdateItemEvent, UpdateItemEvent,
DeleteItemEvent, DeleteItemEvent,
ClearListEvent ClearListEvent,
SyncToStateEvent,
SyncToStorageEvent
} }
} }
} }

210
src/domain/Room.ts Normal file
View file

@ -0,0 +1,210 @@
import { Remesh } from 'remesh'
import { map, merge, tap } from 'rxjs'
import { type MessageUser } from './MessageList'
import { PeerRoomExtern } from '@/domain/externs/PeerRoom'
import MessageListDomain from '@/domain/MessageList'
import UserInfoDomain from '@/domain/UserInfo'
import { callbackToObservable, desert, stringToHex } from '@/utils'
export enum MessageType {
Like = 'like',
Hate = 'hate',
Text = 'text'
}
export interface LikeMessage extends MessageUser {
type: MessageType.Like
messageId: string
}
export interface HateMessage extends MessageUser {
type: MessageType.Hate
messageId: string
}
export interface TextMessage extends MessageUser {
type: MessageType.Text
body: string
}
export type RoomMessage = LikeMessage | HateMessage | TextMessage
const hostRoomId = stringToHex(document.location.host)
const RoomDomain = Remesh.domain({
name: 'RoomDomain',
impl: (domain) => {
const messageListDomain = domain.getDomain(MessageListDomain())
const userInfoDomain = domain.getDomain(UserInfoDomain())
const peerRoom = domain.getExtern(PeerRoomExtern)
peerRoom.joinRoom(hostRoomId)
const SendTextMessageCommand = domain.command({
name: 'RoomSendTextMessageCommand',
impl: ({ get }, message: string) => {
const { id: userId, name: username, avatar: userAvatar } = get(userInfoDomain.query.UserInfoQuery())!
return [
messageListDomain.command.CreateItemCommand({
body: message,
date: Date.now(),
userId,
username,
userAvatar,
likeUsers: [],
hateUsers: []
}),
SendTextMessageEvent({ body: message, userId, username, userAvatar, type: MessageType.Text })
]
}
})
const SendTextMessageEvent = domain.event<RoomMessage>({
name: 'RoomSendTextMessageEvent'
})
const SendLikeMessageCommand = domain.command({
name: 'RoomSendLikeMessageCommand',
impl: ({ get }, messageId: string) => {
const { id: userId, name: username, avatar: userAvatar } = get(userInfoDomain.query.UserInfoQuery())!
const _message = get(messageListDomain.query.ItemQuery(messageId))
return [
messageListDomain.command.UpdateItemCommand({
..._message,
likeUsers: desert(_message.likeUsers, 'userId', {
userId,
username,
userAvatar
})
}),
SendLikeMessageEvent({ messageId, userId, username, userAvatar, type: MessageType.Like })
]
}
})
const SendLikeMessageEvent = domain.event<RoomMessage>({
name: 'RoomSendLikeMessageEvent'
})
const SendHateMessageCommand = domain.command({
name: 'RoomSendHateMessageCommand',
impl: ({ get }, messageId: string) => {
const { id: userId, name: username, avatar: userAvatar } = get(userInfoDomain.query.UserInfoQuery())!
const _message = get(messageListDomain.query.ItemQuery(messageId))
return [
messageListDomain.command.UpdateItemCommand({
..._message,
hateUsers: desert(_message.hateUsers, 'userId', {
userId,
username,
userAvatar
})
}),
SendHateMessageEvent({ messageId, userId, username, userAvatar, type: MessageType.Hate })
]
}
})
const SendHateMessageEvent = domain.event<RoomMessage>({
name: 'RoomSendHateMessageEvent'
})
domain.effect({
name: 'RoomSendTextMessageEffect',
impl: ({ fromEvent }) => {
const sendMessage$ = fromEvent(SendTextMessageEvent).pipe(
tap(async (message) => {
peerRoom.sendMessage<RoomMessage>(message)
})
)
return merge(sendMessage$).pipe(map(() => null))
}
})
domain.effect({
name: 'RoomSendLikeMessageEffect',
impl: ({ fromEvent }) => {
const likeMessage$ = fromEvent(SendLikeMessageEvent).pipe(
tap(async (message) => {
peerRoom.sendMessage<RoomMessage>(message)
})
)
return merge(likeMessage$).pipe(map(() => null))
}
})
domain.effect({
name: 'RoomSendHateMessageEffect',
impl: ({ fromEvent }) => {
const hateMessage$ = fromEvent(SendHateMessageEvent).pipe(
tap(async (message) => {
peerRoom.sendMessage<RoomMessage>(message)
})
)
return merge(hateMessage$).pipe(map(() => null))
}
})
domain.effect({
name: 'RoomOnMessageEffect',
impl: ({ get }) => {
const onMessage$ = callbackToObservable<RoomMessage>(peerRoom.onMessage.bind(peerRoom))
return onMessage$.pipe(
map((message) => {
switch (message.type) {
case 'text':
console.log(message)
return messageListDomain.command.CreateItemCommand({
...message,
date: Date.now(),
likeUsers: [],
hateUsers: []
})
case 'like': {
const _message = get(messageListDomain.query.ItemQuery(message.messageId))
return messageListDomain.command.UpdateItemCommand({
..._message,
likeUsers: desert(_message.likeUsers, 'userId', {
userId: message.userId,
username: message.username,
userAvatar: message.userAvatar
})
})
}
case 'hate': {
const _message = get(messageListDomain.query.ItemQuery(message.messageId))
return messageListDomain.command.UpdateItemCommand({
..._message,
hateUsers: desert(_message.hateUsers, 'userId', {
userId: message.userId,
username: message.username,
userAvatar: message.userAvatar
})
})
}
default:
console.warn('unknown message type', message)
return null
}
})
)
}
})
return {
event: {
SendTextMessageEvent,
SendLikeMessageEvent,
SendHateMessageEvent
},
command: {
SendTextMessageCommand,
SendLikeMessageCommand,
SendHateMessageCommand
}
}
}
})
export default RoomDomain

View file

@ -1,24 +1,37 @@
import { Remesh } from 'remesh' import { Remesh } from 'remesh'
import { forkJoin, from, map, merge, switchMap, tap } from 'rxjs' import { nanoid } from 'nanoid'
import { BrowserSyncStorageExtern } from './externs/Storage' import { BrowserSyncStorageExtern } from '@/domain/externs/Storage'
import { isNullish } from '@/utils' import StorageEffect from '@/domain/modules/StorageEffect'
import callbackToObservable from '@/utils/callbackToObservable'
export interface UserInfo {
id: string
name: string
avatar: string
createTime: number
themeMode: 'system' | 'light' | 'dark'
}
export const STORAGE_KEY = 'USER_INFO'
const UserInfoDomain = Remesh.domain({ const UserInfoDomain = Remesh.domain({
name: 'UserInfoDomain', name: 'UserInfoDomain',
impl: (domain) => { impl: (domain) => {
const storage = domain.getExtern(BrowserSyncStorageExtern) const storageEffect = new StorageEffect({
const storageKeys = { domain,
USER_INFO_ID: 'USER_INFO_ID', extern: BrowserSyncStorageExtern,
USER_INFO_NAME: 'USER_INFO_NAME', key: STORAGE_KEY
USER_INFO_AVATAR: 'USER_INFO_AVATAR', })
USER_INFO_CREATE_TIME: 'USER_INFO_CREATE_TIME',
USER_INFO_THEME_MODE: 'USER_INFO_THEME_MODE'
} as const
const UserInfoState = domain.state<UserInfo | null>({ const UserInfoState = domain.state<UserInfo | null>({
name: 'UserInfo.UserInfoState', name: 'UserInfo.UserInfoState',
default: null // defer: true
default: {
id: nanoid(),
name: '游客',
avatar: 'https://avatars.githubusercontent.com/u/10354233?v=4',
createTime: Date.now(),
themeMode: 'system'
}
}) })
const UserInfoQuery = domain.query({ const UserInfoQuery = domain.query({
@ -28,19 +41,32 @@ const UserInfoDomain = Remesh.domain({
} }
}) })
const UpdateUserInfoCommand = domain.command({ const IsLoginQuery = domain.query({
name: 'UserInfo.UpdateUserInfoCommand', name: 'UserInfo.IsLoginQuery',
impl: (_, userInfo: UserInfo | null) => { impl: ({ get }) => {
return [UserInfoState().new(userInfo), UpdateUserInfoEvent(userInfo), SyncToStorageEvent(userInfo)] return !!get(UserInfoState())?.id
} }
}) })
const UpdateUserInfoEvent = domain.event<UserInfo | null>({ const UpdateUserInfoCommand = domain.command({
name: 'UserInfo.UpdateUserInfoEvent' name: 'UserInfo.UpdateUserInfoCommand',
impl: (_, userInfo: UserInfo | null) => {
return [UserInfoState().new(userInfo), UpdateUserInfoEvent(), SyncToStorageEvent()]
}
}) })
const SyncToStorageEvent = domain.event<UserInfo | null>({ const UpdateUserInfoEvent = domain.event({
name: 'UserInfo.SyncToStorageEvent' name: 'UserInfo.UpdateUserInfoEvent',
impl: ({ get }) => {
return get(UserInfoState())
}
})
const SyncToStorageEvent = domain.event({
name: 'UserInfo.SyncToStorageEvent',
impl: ({ get }) => {
return get(UserInfoState())
}
}) })
const SyncToStateEvent = domain.event<UserInfo | null>({ const SyncToStateEvent = domain.event<UserInfo | null>({
@ -50,92 +76,19 @@ const UserInfoDomain = Remesh.domain({
const SyncToStateCommand = domain.command({ const SyncToStateCommand = domain.command({
name: 'UserInfo.SyncToStateCommand', name: 'UserInfo.SyncToStateCommand',
impl: (_, userInfo: UserInfo | null) => { impl: (_, userInfo: UserInfo | null) => {
return [UserInfoState().new(userInfo), UpdateUserInfoEvent(userInfo), SyncToStateEvent(userInfo)] return [UserInfoState().new(userInfo), UpdateUserInfoEvent(), SyncToStateEvent(userInfo)]
} }
}) })
domain.effect({ // storageEffect
name: 'FormStorageToStateEffect', // .set(SyncToStorageEvent)
impl: () => { // .get<UserInfo>((value) => SyncToStateCommand(value))
return forkJoin({ // .watch<UserInfo>((value) => SyncToStateCommand(value))
id: from(storage.get<UserInfo['id']>(storageKeys.USER_INFO_ID)),
name: from(storage.get<UserInfo['name']>(storageKeys.USER_INFO_NAME)),
avatar: from(storage.get<UserInfo['avatar']>(storageKeys.USER_INFO_AVATAR)),
createTime: from(storage.get<UserInfo['createTime']>(storageKeys.USER_INFO_CREATE_TIME)),
themeMode: from(storage.get<UserInfo['themeMode']>(storageKeys.USER_INFO_THEME_MODE))
}).pipe(
map((userInfo) => {
if (
!isNullish(userInfo.id) &&
!isNullish(userInfo.name) &&
!isNullish(userInfo.avatar) &&
!isNullish(userInfo.createTime) &&
!isNullish(userInfo.themeMode)
) {
return SyncToStateCommand(userInfo as UserInfo)
} else {
return SyncToStateCommand(null)
}
})
)
}
})
domain.effect({
name: 'FormStateToStorageEffect',
impl: ({ fromEvent }) => {
const changeUserInfo$ = fromEvent(SyncToStorageEvent).pipe(
tap(async (userInfo) => {
return await Promise.all([
storage.set<UserInfo['id'] | null>(storageKeys.USER_INFO_ID, userInfo?.id ?? null),
storage.set<UserInfo['name'] | null>(storageKeys.USER_INFO_NAME, userInfo?.name ?? null),
storage.set<UserInfo['avatar'] | null>(storageKeys.USER_INFO_AVATAR, userInfo?.avatar ?? null),
storage.set<UserInfo['createTime'] | null>(
storageKeys.USER_INFO_CREATE_TIME,
userInfo?.createTime ?? null
),
storage.set<UserInfo['themeMode'] | null>(storageKeys.USER_INFO_THEME_MODE, userInfo?.themeMode ?? null)
])
})
)
return merge(changeUserInfo$).pipe(map(() => null))
}
})
domain.effect({
name: 'WatchStorageToStateEffect',
impl: () => {
return callbackToObservable(storage.watch, storage.unwatch).pipe(
switchMap(() => {
return forkJoin({
id: from(storage.get<UserInfo['id']>(storageKeys.USER_INFO_ID)),
name: from(storage.get<UserInfo['name']>(storageKeys.USER_INFO_NAME)),
avatar: from(storage.get<UserInfo['avatar']>(storageKeys.USER_INFO_AVATAR)),
createTime: from(storage.get<UserInfo['createTime']>(storageKeys.USER_INFO_CREATE_TIME)),
themeMode: from(storage.get<UserInfo['themeMode']>(storageKeys.USER_INFO_THEME_MODE))
}).pipe(
map((userInfo) => {
if (
!isNullish(userInfo.id) &&
!isNullish(userInfo.name) &&
!isNullish(userInfo.avatar) &&
!isNullish(userInfo.createTime) &&
!isNullish(userInfo.themeMode)
) {
return SyncToStateCommand(userInfo as UserInfo)
} else {
return SyncToStateCommand(null)
}
})
)
})
)
}
})
return { return {
query: { query: {
UserInfoQuery UserInfoQuery,
IsLoginQuery
}, },
command: { command: {
UpdateUserInfoCommand UpdateUserInfoCommand

View file

@ -1,25 +0,0 @@
import { Remesh } from 'remesh'
export interface PeerClient {
connect: (id: string) => Promise<void>
sendMessage: (message: string) => Promise<void>
onMessage: (callback: (message: string) => void) => void
close: () => Promise<void> | void
}
export const PeerClientExtern = Remesh.extern<PeerClient>({
default: {
connect: async () => {
throw new Error('"connect" not implemented.')
},
sendMessage: async () => {
throw new Error('"sendMessage" not implemented.')
},
onMessage: () => {
throw new Error('"onMessage" not implemented.')
},
close: () => {
throw new Error('"close" not implemented.')
}
}
})

View file

@ -0,0 +1,28 @@
import { Remesh } from 'remesh'
import { type Promisable } from 'type-fest'
export type PeerMessage = object | Blob | ArrayBuffer | ArrayBufferView
export interface PeerRoom {
joinRoom: (roomId: string) => Promise<any>
sendMessage: <T extends PeerMessage>(message: T) => Promise<any>
onMessage: <T extends PeerMessage>(callback: (message: T) => void) => Promisable<void>
leaveRoom: () => Promisable<void>
}
export const PeerRoomExtern = Remesh.extern<PeerRoom>({
default: {
joinRoom: async () => {
throw new Error('"joinRoom" not implemented.')
},
sendMessage: async () => {
throw new Error('"sendMessage" not implemented.')
},
onMessage: () => {
throw new Error('"onMessage" not implemented.')
},
leaveRoom: () => {
throw new Error('"leaveRoom" not implemented.')
}
}
})

View file

@ -0,0 +1,45 @@
import { type DataPayload, type Room, joinRoom } from 'trystero'
import { PeerRoomExtern, type PeerMessage } from '@/domain/externs/PeerRoom'
import { stringToHex } from '@/utils'
export interface Config {
appId: string
}
class PeerRoom {
readonly appId: string
room: Room | null
constructor(config: Config) {
this.appId = config.appId
this.room = null
}
async joinRoom(roomId: string) {
this.room = joinRoom({ appId: this.appId }, roomId)
this.room?.onPeerJoin((peerId) => console.log(`${peerId} joined`))
console.log(this.room.getPeers())
return this.room
}
async sendMessage<T extends PeerMessage>(message: T) {
const [send] = this.room!.makeAction('MESSAGE')
return await send(message as DataPayload)
}
onMessage<T extends PeerMessage>(callback: (message: T) => void) {
const [, on] = this.room!.makeAction('MESSAGE')
on((message) => callback(message as T))
}
async leaveRoom() {
return await this.room?.leave()
}
}
const peerRoom = new PeerRoom({ appId: stringToHex(__NAME__) })
export const PeerRoomImpl = PeerRoomExtern.impl(peerRoom)

View file

@ -1,7 +1,7 @@
import { createStorage } from 'unstorage' import { createStorage } from 'unstorage'
import indexedDbDriver from 'unstorage/drivers/indexedb' import indexedDbDriver from 'unstorage/drivers/indexedb'
import { IndexDBStorageExtern, BrowserSyncStorageExtern } from '@/domain/externs/Storage' import { IndexDBStorageExtern, BrowserSyncStorageExtern } from '@/domain/externs/Storage'
import { STORAGE_NAME } from '@/constants' import { STORAGE_NAME } from '@/constants/config'
import { webExtensionDriver } from '@/utils/webExtensionDriver' import { webExtensionDriver } from '@/utils/webExtensionDriver'
const indexDBStorage = createStorage({ const indexDBStorage = createStorage({

View file

@ -0,0 +1,72 @@
import { type RemeshEvent, type RemeshAction, type RemeshDomainContext, type RemeshExtern } from 'remesh'
import { from, fromEventPattern, map, merge, switchMap, tap } from 'rxjs'
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 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
}
export interface Options {
domain: RemeshDomainContext
extern: RemeshExtern<Storage>
key: string
}
export default class StorageEffect {
domain: RemeshDomainContext
key: string
storage: Storage
constructor(options: Options) {
this.domain = options.domain
this.key = options.key
this.storage = options.domain.getExtern(options.extern)
}
get<T extends StorageValue>(callback: (value: T | null) => RemeshAction) {
this.domain.effect({
name: 'FormStorageToStateEffect',
impl: () => {
return from(this.storage.get<T>(this.key)).pipe(map((value) => callback(value)))
}
})
return this
}
set<T extends StorageValue, U extends RemeshEvent<any, T>>(event: U) {
this.domain.effect({
name: 'FormStateToStorageEffect',
impl: ({ fromEvent }) => {
const changeUserInfo$ = fromEvent(event).pipe(
tap(async (value) => {
return await this.storage.set(this.key, value)
})
)
return merge(changeUserInfo$).pipe(map(() => null))
}
})
return this
}
watch<T extends StorageValue>(callback: (value: T | null) => RemeshAction) {
this.domain.effect({
name: 'WatchStorageToStateEffect',
impl: () => {
return fromEventPattern(this.storage.watch, this.storage.unwatch).pipe(
switchMap(() => {
return from(this.storage.get<T>(this.key)).pipe(map((value) => callback(value)))
})
)
}
})
return this
}
}

View file

@ -1,5 +1,5 @@
import { createBreakpoint } from 'react-use' import { createBreakpoint } from 'react-use'
import { BREAKPOINTS } from '@/constants' import { BREAKPOINTS } from '@/constants/config'
const _useBreakpoint = createBreakpoint(BREAKPOINTS) const _useBreakpoint = createBreakpoint(BREAKPOINTS)

View file

@ -30,7 +30,9 @@ const useClickAway = <E extends Event = Event>(
!el.contains(event.target) && event.target.shadowRoot !== rootNode && savedCallback.current(event) !el.contains(event.target) && event.target.shadowRoot !== rootNode && savedCallback.current(event)
} }
for (const eventName of events) { for (const eventName of events) {
// eslint-disable-next-line @eslint-react/web-api/no-leaked-event-listener
document.addEventListener(eventName, handler) document.addEventListener(eventName, handler)
// eslint-disable-next-line @eslint-react/web-api/no-leaked-event-listener
isInShadow && rootNode.addEventListener(eventName, handler) isInShadow && rootNode.addEventListener(eventName, handler)
} }
return () => { return () => {

View file

@ -1,100 +0,0 @@
import Peer, { type DataConnection } from 'peerjs'
import { nanoid } from 'nanoid'
import { PeerClientExtern } from '../domain/externs/PeerClient'
class PeerClient {
private peer: Peer | undefined
private connection: DataConnection | undefined
async connect(id: string) {
const connect = (id: string) => {
this.peer = new Peer(nanoid())
this.peer.on('connection', (e) => {
console.log('connection2', e)
})
const connection = this.peer.connect(id)
connection.on('open', () => {
console.log('connection open')
this.connection = connection
})
connection.on('error', (error) => {
console.log('error', error)
})
}
this.peer = new Peer(id)
this.peer.on('connection', (e) => {
console.log('connection1', e)
})
this.peer.on('open', (e) => {
console.log('open', e)
this.peer!.on('connection', (e) => {
console.log('connection1', e)
})
})
this.peer.on('error', (error) => {
if (error.type === 'unavailable-id') {
console.log('unavailable-id')
connect(id)
}
})
// return await new Promise((resolve, reject) => {
// try {
// this.peer = new Peer(id)
// this.peer.on('connection', (e) => {
// console.log('connection1', e)
// })
// this.peer
// .once('open', (e) => {
// resolve(e)
// })
// .once('error', (error) => {
// if (error.type === 'unavailable-id') {
// const connection = this.peer!.connect(id)!
// connection
// .once('open', () => {
// console.log('open')
// console.log('connection', connection)
// this.connection = connection
// resolve(id)
// })
// .once('error', (error) => {
// reject(error)
// })
// } else {
// debugger
// reject(error)
// }
// })
// } catch (error) {
// reject(error)
// }
// })
}
async sendMessage(message: string) {
return await new Promise<void>((resolve, reject) => {
if (this.connection) {
this.connection.send(message)
resolve(undefined)
} else {
reject(new Error('Connection not established.'))
}
})
}
onMessage(callback: (message: string) => void) {
this.connection?.on('data', (data: any) => {
// callback(data)
})
}
close() {
this.connection?.close()
}
}
export const PeerClientImpl = PeerClientExtern.impl(new PeerClient())

26
src/types/global.d.ts vendored
View file

@ -1,25 +1,3 @@
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
declare type Message = {
id: string
userId: string
body: string
username: string
userAvatar: string
date: number
linkUsers: string[]
likeChecked: boolean
hateChecked: boolean
likeCount: number
hateUsers: string[]
hateCount: number
}
declare interface UserInfo {
id: string
name: string
avatar: string
createTime: number
themeMode: 'system' | 'light' | 'dark'
}
declare type SafeAny = any declare type SafeAny = any
declare type JsonValue = null | string | number | boolean | JsonValue[] | { [key: string]: JsonValue }

12
src/utils/array.ts Normal file
View file

@ -0,0 +1,12 @@
export const chunk = <T = any>(array: T[], size: number) =>
Array.from({ length: Math.ceil(array.length / size) }, (_v, i) => array.slice(i * size, i * size + size))
export const desert = <T extends object>(target: T[], key: keyof T, value: T) => {
const index = target.findIndex((item) => item[key] === value[key])
return index === -1 ? [...target, value] : target.toSpliced(index, 1)
}
export const upsert = <T extends object>(target: T[], key: keyof T, value: T) => {
const index = target.findIndex((item) => item[key] === value[key])
return index === -1 ? [...target, value] : target.toSpliced(index, 1, value)
}

View file

@ -3,7 +3,7 @@ import { Observable } from 'rxjs'
export type Subscribe<T> = (callback: (event: T) => void) => void export type Subscribe<T> = (callback: (event: T) => void) => void
const callbackToObservable = <T>(subscribe: Subscribe<T>, unsubscribe?: () => void) => { const callbackToObservable = <T>(subscribe: Subscribe<T>, unsubscribe?: () => void) => {
return new Observable((subscriber) => { return new Observable<T>((subscriber) => {
subscribe((event: T) => { subscribe((event: T) => {
subscriber.next(event) subscriber.next(event)
}) })

View file

@ -1,4 +0,0 @@
const chunk = <T = any>(array: T[], size: number) =>
Array.from({ length: Math.ceil(array.length / size) }, (_v, i) => array.slice(i * size, i * size + size))
export default chunk

View file

@ -1,2 +0,0 @@
const clamp = (number: number, min: number, max: number) => Math.min(Math.max(number, min), max)
export default clamp

15
src/utils/debounce.ts Normal file
View file

@ -0,0 +1,15 @@
const debounce = <F extends (...args: any[]) => any>(func: F, wait: number, immediate = false) => {
let timeout: ReturnType<typeof setTimeout> | null = null
return function (this: ThisParameterType<F>, ...args: Parameters<F>) {
timeout && clearTimeout(timeout)
if (immediate && !timeout) {
func.apply(this, args)
}
timeout = setTimeout(() => {
timeout = null
!immediate && func.apply(this, args)
}, wait)
}
}
export default debounce

View file

@ -1,11 +1,12 @@
export { default as cn } from './cn' export { default as cn } from './cn'
export { default as isInRange } from './isInRange' export { isInRange, clamp } from './number'
export { default as clamp } from './clamp'
export { default as createElement } from './createElement' export { default as createElement } from './createElement'
export { default as getSiteInfo } from './getSiteInfo' export { default as getSiteInfo } from './getSiteInfo'
export { default as chunk } from './chunk'
export { default as compressImage } from './compressImage' export { default as compressImage } from './compressImage'
export { default as isNullish } from './isNullish' export { default as isNullish } from './isNullish'
export { default as checkSystemDarkMode } from './checkSystemDarkMode' export { default as checkSystemDarkMode } from './checkSystemDarkMode'
export { default as callbackToObservable } from './callbackToObservable' export { default as callbackToObservable } from './callbackToObservable'
export { default as stringToHex } from './stringToHex' export { default as stringToHex } from './stringToHex'
export { default as debounce } from './debounce'
export { default as throttle } from './throttle'
export { chunk, desert, upsert } from './array'

17
src/utils/injectScript.ts Normal file
View file

@ -0,0 +1,17 @@
import { type PublicPath, browser } from 'wxt/browser'
import createElement from './createElement'
const injectScript = async (path: PublicPath) => {
const src = browser.runtime.getURL(path)
const script = createElement<HTMLScriptElement>(`<script src="${src}"></script>`)
script.async = false
script.defer = false
document.documentElement.appendChild(script)
document.documentElement.removeChild(script)
return await new Promise<Event>((resolve, reject) => {
script.onload = resolve
script.onerror = reject
})
}
export default injectScript

View file

@ -1,3 +0,0 @@
const isInRange = (number: number, min: number, max: number) => number >= min && number <= max
export default isInRange

2
src/utils/number.ts Normal file
View file

@ -0,0 +1,2 @@
export const clamp = (number: number, min: number, max: number) => Math.min(Math.max(number, min), max)
export const isInRange = (number: number, min: number, max: number) => number >= min && number <= max

View file

@ -1,5 +1,6 @@
const stringToHex = (string: string) => { const stringToHex = (input: string): string => {
return [...string].map((char) => char.charCodeAt(0).toString(16)).join('') if (input.length === 0) return ''
return [...input].map((char) => char.codePointAt(0)!.toString(16).padStart(4, '0')).join('')
} }
export default stringToHex export default stringToHex

16
src/utils/throttle.ts Normal file
View file

@ -0,0 +1,16 @@
const throttle = <F extends (...args: any[]) => any>(func: F, wait: number, immediate = false) => {
let lastTime = 0
let firstCall = true
return function (this: ThisParameterType<F>, ...args: Parameters<F>): void {
const nowTime = Date.now()
if ((firstCall && immediate) || nowTime - lastTime >= wait) {
func.apply(this, args)
lastTime = nowTime
firstCall = false
}
}
}
export default throttle