feat: peer message working!
This commit is contained in:
parent
477a6533e8
commit
6fb4035ac3
49 changed files with 9499 additions and 5867 deletions
|
@ -1 +0,0 @@
|
||||||
dist
|
|
51
.eslintrc
51
.eslintrc
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,4 +1,2 @@
|
||||||
#!/usr/bin/env sh
|
|
||||||
. "$(dirname -- "$0")/_/husky.sh"
|
|
||||||
|
|
||||||
npx commitlint --edit "$1"
|
npx commitlint --edit "$1"
|
||||||
|
|
|
@ -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
2
.npmrc
|
@ -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
1
.nvmrc
Normal file
|
@ -0,0 +1 @@
|
||||||
|
>= 20
|
6
.vscode/settings.json
vendored
Normal file
6
.vscode/settings.json
vendored
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"eslint.options": {
|
||||||
|
"flags": ["unstable_ts_config"]
|
||||||
|
},
|
||||||
|
"eslint.useFlatConfig": true
|
||||||
|
}
|
40
eslint.config.ts
Normal file
40
eslint.config.ts
Normal 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[]
|
34
package.json
34
package.json
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
13869
pnpm-lock.yaml
13869
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
|
@ -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'
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
@ -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">
|
||||||
|
|
18
src/app/content/components/FormatDate.tsx
Normal file
18
src/app/content/components/FormatDate.tsx
Normal 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
|
|
@ -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>
|
||||||
|
|
|
@ -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()]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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" />
|
||||||
|
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
@ -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'
|
||||||
|
|
|
@ -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({
|
||||||
|
|
|
@ -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: {
|
||||||
|
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
@ -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 />
|
||||||
|
|
|
@ -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
3
src/constants/event.ts
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
export enum EVENT {
|
||||||
|
OPEN_OPTIONS_PAGE = 'OPEN_OPTIONS_PAGE'
|
||||||
|
}
|
|
@ -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
210
src/domain/Room.ts
Normal 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
|
|
@ -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
|
||||||
|
|
|
@ -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.')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
28
src/domain/externs/PeerRoom.ts
Normal file
28
src/domain/externs/PeerRoom.ts
Normal 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.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
45
src/domain/impls/PeerRoom.ts
Normal file
45
src/domain/impls/PeerRoom.ts
Normal 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)
|
|
@ -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({
|
72
src/domain/modules/StorageEffect.ts
Normal file
72
src/domain/modules/StorageEffect.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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 () => {
|
||||||
|
|
|
@ -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
26
src/types/global.d.ts
vendored
|
@ -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
12
src/utils/array.ts
Normal 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)
|
||||||
|
}
|
|
@ -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)
|
||||||
})
|
})
|
||||||
|
|
|
@ -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
|
|
|
@ -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
15
src/utils/debounce.ts
Normal 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
|
|
@ -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
17
src/utils/injectScript.ts
Normal 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
|
|
@ -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
2
src/utils/number.ts
Normal 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
|
|
@ -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
16
src/utils/throttle.ts
Normal 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
|
Loading…
Reference in a new issue