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"
|
||||
|
|
|
@ -1,4 +1,2 @@
|
|||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
pnpm lint-staged && pnpm tsc:check
|
||||
|
|
2
.npmrc
2
.npmrc
|
@ -1,3 +1,3 @@
|
|||
engine-strict=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:zip": "wxt zip",
|
||||
"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",
|
||||
"tsc:check": "tsc --noEmit",
|
||||
"prepare": "husky install",
|
||||
"prepare": "husky",
|
||||
"postinstall": "wxt prepare"
|
||||
},
|
||||
"repository": {
|
||||
|
@ -63,7 +63,6 @@
|
|||
"idb-keyval": "^6.2.1",
|
||||
"lucide-react": "^0.350.0",
|
||||
"nanoid": "^5.0.6",
|
||||
"peerjs": "^1.5.2",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.51.0",
|
||||
|
@ -72,12 +71,13 @@
|
|||
"react-use": "^17.5.0",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"remesh": "^4.2.1",
|
||||
"remesh": "^4.2.2",
|
||||
"remesh-logger": "^4.1.0",
|
||||
"remesh-react": "^4.1.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"sonner": "^1.4.3",
|
||||
"tailwind-merge": "^2.2.1",
|
||||
"trystero": "^0.20.0",
|
||||
"type-fest": "^4.11.1",
|
||||
"unstorage": "^1.10.1",
|
||||
"valibot": "^0.30.0"
|
||||
|
@ -85,6 +85,11 @@
|
|||
"devDependencies": {
|
||||
"@commitlint/cli": "^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/react": "^18.2.64",
|
||||
"@types/react-dom": "^18.2.21",
|
||||
|
@ -92,17 +97,13 @@
|
|||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.18",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint": "^9.10.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-config-standard-with-typescript": "^43.0.1",
|
||||
"eslint-plugin-import": "^2.29.1",
|
||||
"eslint-plugin-n": "^16.6.2",
|
||||
"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",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"eslint-plugin-tailwindcss": "^3.17.4",
|
||||
"globals": "^15.9.0",
|
||||
"husky": "^9.0.11",
|
||||
"jiti": "^1.21.6",
|
||||
"lint-staged": "^15.2.2",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"postcss": "^8.4.35",
|
||||
|
@ -110,14 +111,15 @@
|
|||
"rimraf": "^5.0.5",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "^5.4.2",
|
||||
"typescript": "^5.6.2",
|
||||
"typescript-eslint": "^8.5.0",
|
||||
"webext-bridge": "^6.0.1",
|
||||
"wxt": "^0.17.7"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,jsx,ts,tsx}": "eslint --fix"
|
||||
"*.{js,jsx,ts,tsx}": "eslint --fix --flag unstable_ts_config"
|
||||
},
|
||||
"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',
|
||||
|
||||
main() {
|
||||
browser.runtime.onMessage.addListener(async (message, options) => {
|
||||
console.log('Background recieved:', message, options)
|
||||
console.log('Background sending:', 'pong')
|
||||
browser.runtime.onMessage.addListener(async () => {
|
||||
browser.runtime.openOptionsPage()
|
||||
return 'pong'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
|
|
@ -3,7 +3,7 @@ import { useState, type FC } from 'react'
|
|||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
|
||||
import { ScrollArea } from '@/components/ui/ScrollArea'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { EMOJI_LIST } from '@/constants'
|
||||
import { EMOJI_LIST } from '@/constants/config'
|
||||
import { chunk } from '@/utils'
|
||||
|
||||
export interface EmojiButtonProps {
|
||||
|
@ -35,7 +35,7 @@ const EmojiButton: FC<EmojiButtonProps> = ({ onSelect }) => {
|
|||
</Button>
|
||||
</PopoverTrigger>
|
||||
<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) => {
|
||||
return (
|
||||
<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 { format } from 'date-fns'
|
||||
import { type FC } from 'react'
|
||||
import { FrownIcon, ThumbsUpIcon } from 'lucide-react'
|
||||
import LikeButton from './LikeButton'
|
||||
import FormatDate from './FormatDate'
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/Avatar'
|
||||
|
||||
import { Markdown } from '@/components/ui/Markdown'
|
||||
import { type Message } from '@/domain/MessageList'
|
||||
|
||||
export interface MessageItemProps {
|
||||
data: Message
|
||||
index?: number
|
||||
like: boolean
|
||||
hate: boolean
|
||||
onLikeChange?: (checked: boolean) => void
|
||||
onHateChange?: (checked: boolean) => void
|
||||
}
|
||||
|
||||
const MessageItem: FC<MessageItemProps> = ({ data, index }) => {
|
||||
const [formatData, setFormatData] = useState({
|
||||
...data,
|
||||
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 MessageItem: FC<MessageItemProps> = (props) => {
|
||||
const handleLikeChange = (checked: boolean) => {
|
||||
props.onLikeChange?.(checked)
|
||||
}
|
||||
})
|
||||
const handleHateChange = (checked: boolean) => {
|
||||
props.onHateChange?.(checked)
|
||||
}
|
||||
|
||||
return (
|
||||
<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"
|
||||
>
|
||||
<Avatar>
|
||||
<AvatarImage src={formatData.userAvatar} alt="avatar" />
|
||||
<AvatarFallback>{formatData.username.at(0)}</AvatarFallback>
|
||||
<AvatarImage src={props.data.userAvatar} alt="avatar" />
|
||||
<AvatarFallback>{props.data.username.at(0)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="overflow-hidden">
|
||||
<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-xs text-slate-400">{formatData.date}</div>
|
||||
<div className="text-sm font-medium text-slate-600">{props.data.username}</div>
|
||||
<FormatDate className="text-xs text-slate-400" date={props.data.date}></FormatDate>
|
||||
</div>
|
||||
<div>
|
||||
<div className="pb-2">
|
||||
<Markdown>{formatData.body}</Markdown>
|
||||
<Markdown>{props.data.body}</Markdown>
|
||||
</div>
|
||||
<div className="grid grid-flow-col justify-end gap-x-2 leading-none">
|
||||
<LikeButton
|
||||
checked={formatData.likeChecked}
|
||||
onChange={(...args) => handleLikeChange('like', ...args)}
|
||||
count={formatData.likeCount}
|
||||
checked={props.like}
|
||||
onChange={(checked) => handleLikeChange(checked)}
|
||||
count={props.data.likeUsers.length}
|
||||
>
|
||||
<LikeButton.Icon>
|
||||
<ThumbsUpIcon size={14}></ThumbsUpIcon>
|
||||
</LikeButton.Icon>
|
||||
</LikeButton>
|
||||
<LikeButton
|
||||
checked={formatData.hateChecked}
|
||||
onChange={(...args) => handleLikeChange('hate', ...args)}
|
||||
count={formatData.hateCount}
|
||||
checked={props.hate}
|
||||
onChange={(checked) => handleHateChange(checked)}
|
||||
count={props.data.hateUsers.length}
|
||||
>
|
||||
<LikeButton.Icon>
|
||||
<FrownIcon size={14}></FrownIcon>
|
||||
|
|
|
@ -7,8 +7,8 @@ import { defineContentScript } from 'wxt/sandbox'
|
|||
import { createShadowRootUi } from 'wxt/client'
|
||||
|
||||
import App from './App'
|
||||
import { IndexDBStorageImpl, BrowserSyncStorageImpl } from '@/impl/Storage'
|
||||
import { PeerClientImpl } from '@/impl/PeerClient'
|
||||
import { IndexDBStorageImpl, BrowserSyncStorageImpl } from '@/domain/impls/Storage'
|
||||
import { PeerRoomImpl } from '@/domain/impls/PeerRoom'
|
||||
import '@/assets/styles/tailwind.css'
|
||||
|
||||
export default defineContentScript({
|
||||
|
@ -16,7 +16,7 @@ export default defineContentScript({
|
|||
matches: ['*://*.example.com/*', '*://*.google.com/*', '*://*.v2ex.com/*'],
|
||||
async main(ctx) {
|
||||
const store = Remesh.store({
|
||||
externs: [IndexDBStorageImpl, BrowserSyncStorageImpl, PeerClientImpl],
|
||||
externs: [IndexDBStorageImpl, BrowserSyncStorageImpl, PeerRoomImpl],
|
||||
inspectors: [RemeshLogger()]
|
||||
})
|
||||
|
||||
|
|
|
@ -4,7 +4,7 @@ import { SettingsIcon, MoonIcon, SunIcon } from 'lucide-react'
|
|||
import { browser } from 'wxt/browser'
|
||||
import { useRemeshDomain, useRemeshQuery, useRemeshSend } from 'remesh-react'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { EVENTS } from '@/constants'
|
||||
import { EVENT } from '@/constants/event'
|
||||
import UserInfoDomain from '@/domain/UserInfo'
|
||||
import useClickAway from '@/hooks/useClickAway'
|
||||
import { checkSystemDarkMode, cn } from '@/utils'
|
||||
|
@ -51,7 +51,7 @@ const AppButton: FC<AppButtonProps> = ({ children }) => {
|
|||
}
|
||||
|
||||
const handleOpenOptionsPage = () => {
|
||||
browser.runtime.sendMessage(EVENTS.OPEN_OPTIONS_PAGE)
|
||||
browser.runtime.sendMessage(EVENT.OPEN_OPTIONS_PAGE)
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -62,7 +62,7 @@ const AppButton: FC<AppButtonProps> = ({ children }) => {
|
|||
onClick={handleSwitchTheme}
|
||||
variant="outline"
|
||||
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
|
||||
className={cn(
|
||||
|
@ -79,12 +79,12 @@ const AppButton: FC<AppButtonProps> = ({ children }) => {
|
|||
onClick={handleOpenOptionsPage}
|
||||
variant="outline"
|
||||
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} />
|
||||
</Button>
|
||||
</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}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
@ -5,14 +5,14 @@ import MessageInput from '../../components/MessageInput'
|
|||
import EmojiButton from '../../components/EmojiButton'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import MessageInputDomain from '@/domain/MessageInput'
|
||||
import MessageListDomain from '@/domain/MessageList'
|
||||
import { MESSAGE_MAX_LENGTH } from '@/constants'
|
||||
import { MESSAGE_MAX_LENGTH } from '@/constants/config'
|
||||
import RoomDomain from '@/domain/Room'
|
||||
|
||||
const Footer: FC = () => {
|
||||
const send = useRemeshSend()
|
||||
const messageListDomain = useRemeshDomain(MessageListDomain())
|
||||
const roomDomain = useRemeshDomain(RoomDomain())
|
||||
const messageInputDomain = useRemeshDomain(MessageInputDomain())
|
||||
const messageBody = useRemeshQuery(messageInputDomain.query.MessageQuery())
|
||||
const message = useRemeshQuery(messageInputDomain.query.MessageQuery())
|
||||
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
|
@ -20,28 +20,14 @@ const Footer: FC = () => {
|
|||
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 = () => {
|
||||
if (!message.body) return
|
||||
send(messageListDomain.command.CreateItemCommand(message))
|
||||
if (!message.trim()) return
|
||||
send(roomDomain.command.SendTextMessageCommand(message.trim()))
|
||||
send(messageInputDomain.command.ClearCommand())
|
||||
}
|
||||
|
||||
const handleEmojiSelect = (emoji: string) => {
|
||||
send(messageInputDomain.command.InputCommand(`${messageBody}${emoji}`))
|
||||
send(messageInputDomain.command.InputCommand(`${message}${emoji}`))
|
||||
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">
|
||||
<MessageInput
|
||||
ref={inputRef}
|
||||
value={messageBody}
|
||||
value={message}
|
||||
onEnter={handleSend}
|
||||
onInput={handleInput}
|
||||
maxLength={MESSAGE_MAX_LENGTH}
|
||||
|
|
|
@ -10,7 +10,7 @@ const Header: FC = () => {
|
|||
|
||||
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">
|
||||
<Avatar className="h-8 w-8">
|
||||
<Avatar className="size-8">
|
||||
<AvatarImage src={siteInfo.icon} alt="favicon" />
|
||||
<AvatarFallback>
|
||||
<Globe2Icon size="100%" className="text-gray-400" />
|
||||
|
@ -26,7 +26,7 @@ const Header: FC = () => {
|
|||
</HoverCardTrigger>
|
||||
<HoverCardContent className="w-80">
|
||||
<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" />
|
||||
<AvatarFallback>
|
||||
<Globe2Icon size="100%" className="text-gray-400" />
|
||||
|
|
|
@ -1,17 +1,36 @@
|
|||
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 MessageItem from '../../components/MessageItem'
|
||||
import MessageListDomain from '@/domain/MessageList'
|
||||
import UserInfoDomain from '@/domain/UserInfo'
|
||||
import RoomDomain from '@/domain/Room'
|
||||
|
||||
const Main: FC = () => {
|
||||
const send = useRemeshSend()
|
||||
const roomDomain = useRemeshDomain(RoomDomain())
|
||||
const userInfoDomain = useRemeshDomain(UserInfoDomain())
|
||||
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 isUpdate = useRef(false)
|
||||
|
||||
const handleLikeChange = (messageId: string) => {
|
||||
send(roomDomain.command.SendLikeMessageCommand(messageId))
|
||||
}
|
||||
|
||||
const handleHateChange = (messageId: string) => {
|
||||
send(roomDomain.command.SendHateMessageCommand(messageId))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const lastMessageRef = messageListRef.current?.querySelector('[data-index]:last-child')
|
||||
const timerId = setTimeout(() => {
|
||||
|
@ -27,7 +46,15 @@ const Main: FC = () => {
|
|||
return (
|
||||
<MessageList ref={messageListRef}>
|
||||
{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>
|
||||
)
|
||||
|
|
|
@ -9,7 +9,7 @@ import AvatarSelect from './AvatarSelect'
|
|||
import { Button } from '@/components/ui/Button'
|
||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/Form'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import UserInfoDomain from '@/domain/UserInfo'
|
||||
import UserInfoDomain, { type UserInfo } from '@/domain/UserInfo'
|
||||
import { checkSystemDarkMode } from '@/utils'
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/RadioGroup'
|
||||
import { Label } from '@/components/ui/Label'
|
||||
|
|
|
@ -4,7 +4,7 @@ import { Remesh } from 'remesh'
|
|||
import { RemeshRoot } from 'remesh-react'
|
||||
import { RemeshLogger } from 'remesh-logger'
|
||||
import App from './App'
|
||||
import { BrowserSyncStorageImpl } from '@/impl/Storage'
|
||||
import { BrowserSyncStorageImpl } from '@/domain/impls/Storage'
|
||||
import '@/assets/styles/tailwind.css'
|
||||
|
||||
const store = Remesh.store({
|
||||
|
|
|
@ -21,7 +21,7 @@ const buttonVariants = cva(
|
|||
sm: 'h-8 rounded-md px-3 text-xs',
|
||||
xs: 'h-6 rounded-md px-2 text-xs',
|
||||
lg: 'h-10 rounded-md px-8',
|
||||
icon: 'h-9 w-9'
|
||||
icon: 'size-9'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
|
|
|
@ -26,7 +26,7 @@ const RadioGroupItem = React.forwardRef<
|
|||
{...props}
|
||||
>
|
||||
<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.Item>
|
||||
)
|
||||
|
|
|
@ -8,7 +8,7 @@ const ScrollArea = React.forwardRef<
|
|||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<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}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
|
|
|
@ -186,7 +186,3 @@ export const BREAKPOINTS = {
|
|||
export const MESSAGE_MAX_LENGTH = 500 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 { ListModule } from 'remesh/modules/list'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { from, map, tap, merge } from 'rxjs'
|
||||
import { IndexDBStorageExtern } from './externs/Storage'
|
||||
import { PeerClientExtern } from './externs/PeerClient'
|
||||
import { callbackToObservable, stringToHex } from '@/utils'
|
||||
import { IndexDBStorageExtern } from '@/domain/externs/Storage'
|
||||
import StorageEffect from '@/domain/modules/StorageEffect'
|
||||
|
||||
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({
|
||||
name: 'MessageListDomain',
|
||||
impl: (domain) => {
|
||||
const storage = domain.getExtern(IndexDBStorageExtern)
|
||||
const peerClient = domain.getExtern(PeerClientExtern)
|
||||
const storageKey = `MESSAGE_LIST` as const
|
||||
peerClient.connect(hostRoomId)
|
||||
const storageEffect = new StorageEffect({
|
||||
domain,
|
||||
extern: IndexDBStorageExtern,
|
||||
key: STORAGE_KEY
|
||||
})
|
||||
|
||||
const MessageListModule = ListModule<Message>(domain, {
|
||||
name: 'MessageListModule',
|
||||
|
@ -40,7 +53,12 @@ const MessageListDomain = Remesh.domain({
|
|||
name: 'MessageList.CreateItemCommand',
|
||||
impl: (_, message: Omit<Message, 'id'>) => {
|
||||
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({
|
||||
name: 'MessageList.UpdateItemCommand',
|
||||
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({
|
||||
name: 'MessageList.DeleteItemCommand',
|
||||
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({
|
||||
name: 'MessageList.ClearListCommand',
|
||||
impl: () => {
|
||||
return [MessageListModule.command.DeleteAllCommand(), ClearListEvent(), ChangeListEvent()]
|
||||
return [MessageListModule.command.DeleteAllCommand(), ClearListEvent(), ChangeListEvent(), SyncToStorageEvent()]
|
||||
}
|
||||
})
|
||||
|
||||
const InitListEvent = domain.event<Message[]>({
|
||||
name: 'MessageList.InitListEvent'
|
||||
const SyncToStorageEvent = domain.event({
|
||||
name: 'MessageList.SyncToStorageEvent',
|
||||
impl: ({ get }) => {
|
||||
return get(ListQuery())
|
||||
}
|
||||
})
|
||||
|
||||
const InitListCommand = domain.command({
|
||||
name: 'MessageList.InitListCommand',
|
||||
const SyncToStateEvent = domain.event<Message[]>({
|
||||
name: 'MessageList.SyncToStateEvent'
|
||||
})
|
||||
|
||||
const SyncToStateCommand = domain.command({
|
||||
name: 'MessageList.SyncToStateCommand',
|
||||
impl: (_, messages: Message[]) => {
|
||||
return [MessageListModule.command.SetListCommand(messages), InitListEvent(messages)]
|
||||
return [MessageListModule.command.SetListCommand(messages), SyncToStateEvent(messages)]
|
||||
}
|
||||
})
|
||||
|
||||
domain.effect({
|
||||
name: 'FormStorageToStateEffect',
|
||||
impl: () => {
|
||||
return from(storage.get<Message[]>(storageKey)).pipe(map((messages) => InitListCommand(messages ?? [])))
|
||||
}
|
||||
})
|
||||
|
||||
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)
|
||||
// })
|
||||
// )
|
||||
// }
|
||||
// })
|
||||
storageEffect
|
||||
.set(SyncToStorageEvent)
|
||||
.get<Message[]>((value) => SyncToStateCommand(value ?? []))
|
||||
.watch<Message[]>((value) => SyncToStateCommand(value ?? []))
|
||||
|
||||
return {
|
||||
query: {
|
||||
|
@ -142,10 +140,13 @@ const MessageListDomain = Remesh.domain({
|
|||
ClearListCommand
|
||||
},
|
||||
event: {
|
||||
ChangeListEvent,
|
||||
CreateItemEvent,
|
||||
UpdateItemEvent,
|
||||
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 { forkJoin, from, map, merge, switchMap, tap } from 'rxjs'
|
||||
import { BrowserSyncStorageExtern } from './externs/Storage'
|
||||
import { isNullish } from '@/utils'
|
||||
import callbackToObservable from '@/utils/callbackToObservable'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { BrowserSyncStorageExtern } from '@/domain/externs/Storage'
|
||||
import StorageEffect from '@/domain/modules/StorageEffect'
|
||||
|
||||
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({
|
||||
name: 'UserInfoDomain',
|
||||
impl: (domain) => {
|
||||
const storage = domain.getExtern(BrowserSyncStorageExtern)
|
||||
const storageKeys = {
|
||||
USER_INFO_ID: 'USER_INFO_ID',
|
||||
USER_INFO_NAME: 'USER_INFO_NAME',
|
||||
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 storageEffect = new StorageEffect({
|
||||
domain,
|
||||
extern: BrowserSyncStorageExtern,
|
||||
key: STORAGE_KEY
|
||||
})
|
||||
|
||||
const UserInfoState = domain.state<UserInfo | null>({
|
||||
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({
|
||||
|
@ -28,19 +41,32 @@ const UserInfoDomain = Remesh.domain({
|
|||
}
|
||||
})
|
||||
|
||||
const UpdateUserInfoCommand = domain.command({
|
||||
name: 'UserInfo.UpdateUserInfoCommand',
|
||||
impl: (_, userInfo: UserInfo | null) => {
|
||||
return [UserInfoState().new(userInfo), UpdateUserInfoEvent(userInfo), SyncToStorageEvent(userInfo)]
|
||||
const IsLoginQuery = domain.query({
|
||||
name: 'UserInfo.IsLoginQuery',
|
||||
impl: ({ get }) => {
|
||||
return !!get(UserInfoState())?.id
|
||||
}
|
||||
})
|
||||
|
||||
const UpdateUserInfoEvent = domain.event<UserInfo | null>({
|
||||
name: 'UserInfo.UpdateUserInfoEvent'
|
||||
const UpdateUserInfoCommand = domain.command({
|
||||
name: 'UserInfo.UpdateUserInfoCommand',
|
||||
impl: (_, userInfo: UserInfo | null) => {
|
||||
return [UserInfoState().new(userInfo), UpdateUserInfoEvent(), SyncToStorageEvent()]
|
||||
}
|
||||
})
|
||||
|
||||
const SyncToStorageEvent = domain.event<UserInfo | null>({
|
||||
name: 'UserInfo.SyncToStorageEvent'
|
||||
const UpdateUserInfoEvent = domain.event({
|
||||
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>({
|
||||
|
@ -50,92 +76,19 @@ const UserInfoDomain = Remesh.domain({
|
|||
const SyncToStateCommand = domain.command({
|
||||
name: 'UserInfo.SyncToStateCommand',
|
||||
impl: (_, userInfo: UserInfo | null) => {
|
||||
return [UserInfoState().new(userInfo), UpdateUserInfoEvent(userInfo), SyncToStateEvent(userInfo)]
|
||||
return [UserInfoState().new(userInfo), UpdateUserInfoEvent(), SyncToStateEvent(userInfo)]
|
||||
}
|
||||
})
|
||||
|
||||
domain.effect({
|
||||
name: 'FormStorageToStateEffect',
|
||||
impl: () => {
|
||||
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)
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
)
|
||||
}
|
||||
})
|
||||
// storageEffect
|
||||
// .set(SyncToStorageEvent)
|
||||
// .get<UserInfo>((value) => SyncToStateCommand(value))
|
||||
// .watch<UserInfo>((value) => SyncToStateCommand(value))
|
||||
|
||||
return {
|
||||
query: {
|
||||
UserInfoQuery
|
||||
UserInfoQuery,
|
||||
IsLoginQuery
|
||||
},
|
||||
command: {
|
||||
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 indexedDbDriver from 'unstorage/drivers/indexedb'
|
||||
import { IndexDBStorageExtern, BrowserSyncStorageExtern } from '@/domain/externs/Storage'
|
||||
import { STORAGE_NAME } from '@/constants'
|
||||
import { STORAGE_NAME } from '@/constants/config'
|
||||
import { webExtensionDriver } from '@/utils/webExtensionDriver'
|
||||
|
||||
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 { BREAKPOINTS } from '@/constants'
|
||||
import { BREAKPOINTS } from '@/constants/config'
|
||||
|
||||
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)
|
||||
}
|
||||
for (const eventName of events) {
|
||||
// eslint-disable-next-line @eslint-react/web-api/no-leaked-event-listener
|
||||
document.addEventListener(eventName, handler)
|
||||
// eslint-disable-next-line @eslint-react/web-api/no-leaked-event-listener
|
||||
isInShadow && rootNode.addEventListener(eventName, handler)
|
||||
}
|
||||
return () => {
|
||||
|
|
|
@ -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 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
|
||||
|
||||
const callbackToObservable = <T>(subscribe: Subscribe<T>, unsubscribe?: () => void) => {
|
||||
return new Observable((subscriber) => {
|
||||
return new Observable<T>((subscriber) => {
|
||||
subscribe((event: T) => {
|
||||
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 isInRange } from './isInRange'
|
||||
export { default as clamp } from './clamp'
|
||||
export { isInRange, clamp } from './number'
|
||||
export { default as createElement } from './createElement'
|
||||
export { default as getSiteInfo } from './getSiteInfo'
|
||||
export { default as chunk } from './chunk'
|
||||
export { default as compressImage } from './compressImage'
|
||||
export { default as isNullish } from './isNullish'
|
||||
export { default as checkSystemDarkMode } from './checkSystemDarkMode'
|
||||
export { default as callbackToObservable } from './callbackToObservable'
|
||||
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) => {
|
||||
return [...string].map((char) => char.charCodeAt(0).toString(16)).join('')
|
||||
const stringToHex = (input: string): string => {
|
||||
if (input.length === 0) return ''
|
||||
return [...input].map((char) => char.codePointAt(0)!.toString(16).padStart(4, '0')).join('')
|
||||
}
|
||||
|
||||
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