)
diff --git a/src/app/content/views/Header/index.tsx b/src/app/content/views/Header/index.tsx
index 1c39af8..2a70687 100644
--- a/src/app/content/views/Header/index.tsx
+++ b/src/app/content/views/Header/index.tsx
@@ -11,9 +11,6 @@ const Header: FC = () => {
const siteInfo = getSiteInfo()
const roomDomain = useRemeshDomain(RoomDomain())
const peerList = useRemeshQuery(roomDomain.query.PeerListQuery())
- // const peerList = ['1', '2', '3']
- console.log('peerList', peerList)
- console.log(111)
return (
diff --git a/src/app/content/views/Main/index.tsx b/src/app/content/views/Main/index.tsx
index f4ff9c2..62e9c44 100644
--- a/src/app/content/views/Main/index.tsx
+++ b/src/app/content/views/Main/index.tsx
@@ -1,4 +1,4 @@
-import { useEffect, type FC, useRef } from 'react'
+import { type FC } from 'react'
import { useRemeshDomain, useRemeshQuery, useRemeshSend } from 'remesh-react'
import MessageList from '../../components/MessageList'
diff --git a/src/app/content/views/Setup/index.tsx b/src/app/content/views/Setup/index.tsx
new file mode 100644
index 0000000..6d5efe6
--- /dev/null
+++ b/src/app/content/views/Setup/index.tsx
@@ -0,0 +1,108 @@
+import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/Avatar'
+import { Button } from '@/components/ui/Button'
+import { MAX_AVATAR_SIZE } from '@/constants/config'
+import MessageListDomain, { Message } from '@/domain/MessageList'
+import UserInfoDomain, { UserInfo } from '@/domain/UserInfo'
+import { checkSystemDarkMode, generateRandomAvatar, generateRandomName } from '@/utils'
+import { UserIcon } from 'lucide-react'
+import { nanoid } from 'nanoid'
+import { FC, useEffect, useState } from 'react'
+import { useRemeshDomain, useRemeshSend } from 'remesh-react'
+import Timer from '@resreq/Timer'
+import ExampleImage from '@/assets/images/example.jpg'
+
+const mockTextList = [
+ `你問我支持不支持,我說我支持`,
+ `我就明確告訴你,你們啊,我感覺你們新聞界還要學習一個,你們非常熟悉西方的那一套`,
+ `你們畢竟還 too young`,
+ `明白我的意思吧?`,
+ `我告訴你們我是身經百戰了,見得多了`,
+ `西方的那個國家我沒去過?`,
+ `媒體他們...你們要知道美國的華萊士,比你們不知道高到哪裏去了,我跟他談笑風生`,
+ `其實媒體呀,還是要提高自己的知識水平,識得唔識得呀?`,
+ `你們有一個好,全世界跑到什么地方,你們比其他的西方記者跑得還快`,
+ `但是呢問來問去的問題呀`,
+ `都 too simple sometimes naive`,
+ `懂了沒啊,識得唔識得呀?`,
+ `我很抱歉,我今天是作爲一個長者給你們講`,
+ `我不是新聞工作者,但是我見得太多了`,
+ `我有這個必要好告訴你們一點人生的經驗`,
+ `![too young too simple sometimes naive](${ExampleImage})`
+]
+
+const generateUserInfo = async (): Promise
=> {
+ return {
+ id: nanoid(),
+ name: generateRandomName(),
+ avatar: await generateRandomAvatar(MAX_AVATAR_SIZE),
+ createTime: Date.now(),
+ themeMode: checkSystemDarkMode() ? 'dark' : 'system'
+ }
+}
+
+const generateMessage = async (): Promise => {
+ const userAvatar = await generateRandomAvatar(MAX_AVATAR_SIZE)
+ const username = generateRandomName()
+ const userId = nanoid()
+ return {
+ id: nanoid(),
+ body: mockTextList.shift()!,
+ date: Date.now(),
+ userId,
+ username,
+ userAvatar,
+ likeUsers: mockTextList.length ? [] : [{ userId, username, userAvatar }],
+ hateUsers: []
+ }
+}
+
+const Setup: FC = () => {
+ const send = useRemeshSend()
+ const userInfoDomain = useRemeshDomain(UserInfoDomain())
+ const messageListDomain = useRemeshDomain(MessageListDomain())
+
+ const [userInfo, setUserInfo] = useState()
+ const handleSetup = () => {
+ send(userInfoDomain.command.UpdateUserInfoCommand(userInfo!))
+ send(messageListDomain.command.ClearListCommand())
+ }
+
+ const refreshUserInfo = async () => setUserInfo(await generateUserInfo())
+ const createMessage = async () => {
+ const message = await generateMessage()
+ send(messageListDomain.command.CreateItemCommand(message))
+ }
+
+ useEffect(() => {
+ send(messageListDomain.command.ClearListCommand())
+ const userInfoTimer = new Timer(refreshUserInfo, { delay: 2000, immediate: true })
+ const messageTimer = new Timer(createMessage, { delay: 2000, immediate: true, limit: mockTextList.length })
+
+ userInfoTimer.start()
+ messageTimer.start()
+ return () => {
+ userInfoTimer.stop()
+ messageTimer.stop()
+ }
+ }, [])
+ return (
+
+
+
+
+
+
+
+
+
@{userInfo?.name}
+
+
+
+ )
+}
+
+Setup.displayName = 'Setup'
+
+export default Setup
diff --git a/src/app/options/components/AvatarSelect.tsx b/src/app/options/components/AvatarSelect.tsx
index e992cf6..2d7a150 100644
--- a/src/app/options/components/AvatarSelect.tsx
+++ b/src/app/options/components/AvatarSelect.tsx
@@ -31,7 +31,7 @@ const AvatarSelect = React.forwardRef(
* In chrome storage.sync, each key-value pair supports a maximum storage of 8kb
* and all key-value pairs support a maximum storage of 100kb.
*/
- const blob = await compressImage(file, compressSize)
+ const blob = await compressImage({ input: file, targetSize: compressSize })
const reader = new FileReader()
reader.onload = (e) => {
const base64 = e.target?.result as string
diff --git a/src/app/options/components/ProfileForm.tsx b/src/app/options/components/ProfileForm.tsx
index 13f87f6..15c4e04 100644
--- a/src/app/options/components/ProfileForm.tsx
+++ b/src/app/options/components/ProfileForm.tsx
@@ -10,15 +10,11 @@ import { Button } from '@/components/ui/Button'
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/Form'
import { Input } from '@/components/ui/Input'
import UserInfoDomain, { type UserInfo } from '@/domain/UserInfo'
-import { checkSystemDarkMode, compressImage } from '@/utils'
+import { checkSystemDarkMode, generateRandomAvatar } from '@/utils'
import { RadioGroup, RadioGroupItem } from '@/components/ui/RadioGroup'
import { Label } from '@/components/ui/Label'
import { RefreshCcwIcon } from 'lucide-react'
-import generateUglyAvatar from '@/lib/uglyAvatar'
-
-// In chrome storage.sync, each key-value pair supports a maximum storage of 8kb
-// Image is encoded as base64, and the size is increased by about 33%.
-const COMPRESS_SIZE = 8 * 1024 * (1 - 0.33)
+import { MAX_AVATAR_SIZE } from '@/constants/config'
const defaultUserInfo: UserInfo = {
id: nanoid(),
@@ -82,32 +78,9 @@ const ProfileForm = () => {
toast.error(error.message)
}
- const handleRandomAvatar = async () => {
- const svgBlob = generateUglyAvatar()
-
- // compressImage can't directly compress svg, need to convert to jpeg first
- const jpegBlob = await new Promise((resolve, reject) => {
- const image = new Image()
- image.onload = async () => {
- const canvas = new OffscreenCanvas(image.width, image.height)
- const ctx = canvas.getContext('2d')
- ctx?.drawImage(image, 0, 0)
- const blob = await canvas.convertToBlob({ type: 'image/jpeg' })
- resolve(blob)
- }
- image.onerror = () => reject(new Error('Failed to load SVG'))
- image.src = URL.createObjectURL(svgBlob)
- })
- const miniAvatarBlob = await compressImage(jpegBlob, COMPRESS_SIZE)
- const miniAvatarBase64 = await new Promise((resolve, reject) => {
- const reader = new FileReader()
- reader.onload = (e) => resolve(e.target?.result as string)
- reader.onerror = () => reject(new Error('Failed to convert Blob to Base64'))
- reader.readAsDataURL(miniAvatarBlob)
- })
- console.log('kb', miniAvatarBase64.length / 1024)
-
- form.setValue('avatar', miniAvatarBase64)
+ const handleRefreshAvatar = async () => {
+ const avatarBase64 = await generateRandomAvatar(MAX_AVATAR_SIZE)
+ form.setValue('avatar', avatarBase64)
}
return (
@@ -121,7 +94,7 @@ const ProfileForm = () => {
{
type="button"
size="xs"
className="mx-auto flex items-center gap-x-2"
- onClick={handleRandomAvatar}
+ onClick={handleRefreshAvatar}
>
Ugly Avatar
diff --git a/src/assets/images/example.jpg b/src/assets/images/example.jpg
new file mode 100644
index 0000000..ed20e47
Binary files /dev/null and b/src/assets/images/example.jpg differ
diff --git a/src/assets/react.svg b/src/assets/react.svg
deleted file mode 100644
index 8e0e0f1..0000000
--- a/src/assets/react.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/src/assets/you.jpg b/src/assets/you.jpg
deleted file mode 100644
index db1b91b..0000000
Binary files a/src/assets/you.jpg and /dev/null differ
diff --git a/src/components/ui/Markdown.tsx b/src/components/ui/Markdown.tsx
index a9f2772..b876098 100644
--- a/src/components/ui/Markdown.tsx
+++ b/src/components/ui/Markdown.tsx
@@ -10,9 +10,40 @@ export interface MarkdownProps {
className?: string
}
+const safeProtocol = /^(https?|ircs?|mailto|xmpp|data)$/i
+
+/**
+ * https://github.com/remarkjs/react-markdown/blob/baad6c53764e34c4ead41e2eaba176acfc87538a/lib/index.js#L293
+ */
+const urlTransform = (value: string) => {
+ // Same as:
+ //
+ // But without the `encode` part.
+ const colon = value.indexOf(':')
+ const questionMark = value.indexOf('?')
+ const numberSign = value.indexOf('#')
+ const slash = value.indexOf('/')
+
+ if (
+ // If there is no protocol, it’s relative.
+ colon < 0 ||
+ // If the first colon is after a `?`, `#`, or `/`, it’s not a protocol.
+ (slash > -1 && colon > slash) ||
+ (questionMark > -1 && colon > questionMark) ||
+ (numberSign > -1 && colon > numberSign) ||
+ // It is a protocol, it should be allowed.
+ safeProtocol.test(value.slice(0, colon))
+ ) {
+ return value
+ }
+
+ return ''
+}
+
const Markdown: FC = ({ children = '', className }) => {
return (
(
@@ -60,6 +91,12 @@ const Markdown: FC = ({ children = '', className }) => {
)
},
pre: ({ className, ...props }) => ,
+ /**
+ * TODO: Code highlight
+ * @see https://github.com/remarkjs/react-markdown/issues/680
+ * @see https://shiki.style/guide/install#usage
+ *
+ */
code: ({ className, ...props }) => (
diff --git a/src/constants/config.ts b/src/constants/config.ts
index 447e06b..04458d0 100644
--- a/src/constants/config.ts
+++ b/src/constants/config.ts
@@ -1,5 +1,7 @@
// https://www.webfx.com/tools/emoji-cheat-sheet/
+import { Message } from '@/domain/MessageList'
+
export const EMOJI_LIST = [
'😀',
'😃',
@@ -186,3 +188,10 @@ export const BREAKPOINTS = {
export const MESSAGE_MAX_LENGTH = 500 as const
export const STORAGE_NAME = 'WEB_CHAT' as const
+
+/**
+ * In chrome storage.sync, each key-value pair supports a maximum storage of 8kb
+ * Image is encoded as base64, and the size is increased by about 33%.
+ * 8kb * (1 - 0.33) = 5488 bytes
+ */
+export const MAX_AVATAR_SIZE = 5488 as const
diff --git a/src/domain/Room.ts b/src/domain/Room.ts
index 678f2f6..4a9ad47 100644
--- a/src/domain/Room.ts
+++ b/src/domain/Room.ts
@@ -48,14 +48,13 @@ const RoomDomain = Remesh.domain({
const PeerListQuery = domain.query({
name: 'Room.PeerListQuery',
impl: ({ get }) => {
- console.log('PeerListQuery')
return get(PeerListState())
}
})
const JoinRoomCommand = domain.command({
name: 'RoomJoinRoomCommand',
- impl: ({ get }, roomId: string) => {
+ impl: (_, roomId: string) => {
peerRoom.joinRoom(roomId)
return [JoinRoomEvent(peerRoom.selfId)]
}
@@ -63,7 +62,7 @@ const RoomDomain = Remesh.domain({
const LeaveRoomCommand = domain.command({
name: 'RoomLeaveRoomCommand',
- impl: ({ get }) => {
+ impl: (_) => {
peerRoom.leaveRoom()
return [LeaveRoomEvent(peerRoom.selfId)]
}
@@ -162,16 +161,9 @@ const RoomDomain = Remesh.domain({
impl: ({ get }, action: { type: 'create' | 'delete'; peerId: string }) => {
const peerList = get(PeerListState())
if (action.type === 'create') {
- console.log('create', [...new Set(peerList).add(action.peerId)])
-
return [PeerListState().new([...new Set(peerList).add(action.peerId)])]
}
if (action.type === 'delete') {
- console.log(
- 'delete',
- peerList.filter((peerId) => peerId == action.peerId)
- )
-
return [PeerListState().new(peerList.filter((peerId) => peerId == action.peerId))]
}
return null
diff --git a/src/domain/UserInfo.ts b/src/domain/UserInfo.ts
index 881b034..42cda85 100644
--- a/src/domain/UserInfo.ts
+++ b/src/domain/UserInfo.ts
@@ -1,9 +1,6 @@
import { Remesh } from 'remesh'
-import { nanoid } from 'nanoid'
import { BrowserSyncStorageExtern } from '@/domain/externs/Storage'
import StorageEffect from '@/domain/modules/StorageEffect'
-import generateUglyAvatar from '@/lib/uglyAvatar'
-import generateRandomName from '@/utils/generateRandomName'
export interface UserInfo {
id: string
diff --git a/src/utils/compressImage.ts b/src/utils/compressImage.ts
index 68bcf4b..f7dfefd 100644
--- a/src/utils/compressImage.ts
+++ b/src/utils/compressImage.ts
@@ -1,60 +1,80 @@
+export type ImageType = 'image/jpeg' | 'image/png' | 'image/webp'
+
+export interface Options {
+ input: Blob
+ targetSize: number
+ toleranceSize?: number
+ outputType?: ImageType
+}
+
const compress = async (
imageBitmap: ImageBitmap,
targetSize: number,
low: number,
high: number,
- bestBlob: Blob,
- threshold: number
+ toleranceSize: number,
+ outputType: ImageType
): Promise => {
- // Calculate the middle value of quality
+ // Calculate the middle quality value
const mid = (low + high) / 2
// Calculate the width and height after scaling
- const width = imageBitmap.width * mid
- const height = imageBitmap.height * mid
+ const width = Math.round(imageBitmap.width * mid)
+ const height = Math.round(imageBitmap.height * mid)
const offscreenCanvas = new OffscreenCanvas(width, height)
const offscreenContext = offscreenCanvas.getContext('2d')!
- offscreenContext.drawImage(imageBitmap, 0, 0, width, height)
+ // Draw the scaled image
+ offscreenContext.drawImage(imageBitmap, 0, 0, imageBitmap.width, imageBitmap.height, 0, 0, width, height)
- const outputBlob = await offscreenCanvas.convertToBlob({ type: 'image/jpeg', quality: mid })
+ const outputBlob = await offscreenCanvas.convertToBlob({ type: outputType, quality: mid })
- // Calculate the current size based on the current quality
const currentSize = outputBlob.size
- // If the current size is close to the target size, update the bestBlob
- if (currentSize <= targetSize && Math.abs(currentSize - targetSize) < Math.abs(bestBlob.size - targetSize)) {
- bestBlob = outputBlob
- }
-
- // If the current size is between -1024 ~ 0, return the result
- if ((currentSize - targetSize <= 0 && currentSize - targetSize >= -threshold) || high - low < 0.01) {
- return bestBlob
- }
-
- // Adjust the range for recursion based on the current quality and size
- if (currentSize > targetSize) {
- return await compress(imageBitmap, targetSize, low, mid, bestBlob, threshold)
+ // Adjust the logic based on the positive or negative value of toleranceSize
+ if (toleranceSize < 0) {
+ // Negative value: allow results smaller than the target value
+ if (currentSize <= targetSize && currentSize >= targetSize + toleranceSize) {
+ return outputBlob
+ }
} else {
- return await compress(imageBitmap, targetSize, mid, high, bestBlob, threshold)
+ // Positive value: allow results larger than the target value
+ if (currentSize >= targetSize && currentSize <= targetSize + toleranceSize) {
+ return outputBlob
+ }
+ }
+
+ // Use relative error
+ if ((high - low) / high < 0.01) {
+ return outputBlob
+ }
+
+ if (currentSize > targetSize) {
+ return await compress(imageBitmap, targetSize, low, mid, toleranceSize, outputType)
+ } else {
+ return await compress(imageBitmap, targetSize, mid, high, toleranceSize, outputType)
}
}
-const compressImage = async (inputBlob: Blob, targetSize: number, threshold: number = 1024) => {
- // If the original size already meets the target size, return the original Blob
- if (inputBlob.size <= targetSize) {
- return inputBlob
+const compressImage = async (options: Options) => {
+ const { input, targetSize, toleranceSize = -1024 } = options
+ if (!['image/jpeg', 'image/png', 'image/webp'].includes(input.type)) {
+ throw new Error('Invalid input type, only support image/jpeg, image/png, image/webp')
}
- // Initialize the range of quality
+ if (input.size <= targetSize) {
+ return input
+ }
+
+ const outputType = options.outputType || (input.type as ImageType)
+ const imageBitmap = await createImageBitmap(input)
+
+ // Initialize quality range
const low = 0
const high = 1
- const imageBitmap = await createImageBitmap(inputBlob)
-
- // Call the recursive function
- return await compress(imageBitmap, targetSize, low, high, inputBlob, threshold)
+ return await compress(imageBitmap, targetSize, low, high, toleranceSize, outputType)
}
export default compressImage
diff --git a/src/utils/generateRandomAvatar.ts b/src/utils/generateRandomAvatar.ts
new file mode 100644
index 0000000..b34c610
--- /dev/null
+++ b/src/utils/generateRandomAvatar.ts
@@ -0,0 +1,30 @@
+import generateUglyAvatar from '@/lib/uglyAvatar'
+import compressImage from './compressImage'
+
+const generateRandomAvatar = async (idealSize: number) => {
+ const svgBlob = generateUglyAvatar()
+
+ // compressImage can't directly compress svg, need to convert to jpeg first
+ const jpegBlob = await new Promise((resolve, reject) => {
+ const image = new Image()
+ image.onload = async () => {
+ const canvas = new OffscreenCanvas(image.width, image.height)
+ const ctx = canvas.getContext('2d')
+ ctx?.drawImage(image, 0, 0)
+ const blob = await canvas.convertToBlob({ type: 'image/jpeg' })
+ resolve(blob)
+ }
+ image.onerror = () => reject(new Error('Failed to load SVG'))
+ image.src = URL.createObjectURL(svgBlob)
+ })
+ const miniAvatarBlob = await compressImage({ input: jpegBlob, targetSize: idealSize })
+ const miniAvatarBase64 = await new Promise((resolve, reject) => {
+ const reader = new FileReader()
+ reader.onload = (e) => resolve(e.target?.result as string)
+ reader.onerror = () => reject(new Error('Failed to convert Blob to Base64'))
+ reader.readAsDataURL(miniAvatarBlob)
+ })
+ return miniAvatarBase64
+}
+
+export default generateRandomAvatar
diff --git a/src/utils/generateRandomName.ts b/src/utils/generateRandomName.ts
index 3716eb7..9c7d8dd 100644
--- a/src/utils/generateRandomName.ts
+++ b/src/utils/generateRandomName.ts
@@ -43,7 +43,8 @@ const generateRandomName = (): string => {
'Bella',
'Charlie',
'Daisy',
- 'Ella'
+ 'Ella',
+ 'JinPing'
]
const lastNames = [
@@ -90,7 +91,8 @@ const generateRandomName = (): string => {
'Lin',
'Lu',
'Zheng',
- 'Huang'
+ 'Huang',
+ 'Xi'
]
const randomFirstName = firstNames[Math.floor(Math.random() * firstNames.length)]
diff --git a/src/utils/index.ts b/src/utils/index.ts
index 18a9c72..a95194a 100644
--- a/src/utils/index.ts
+++ b/src/utils/index.ts
@@ -10,3 +10,5 @@ export { default as stringToHex } from './stringToHex'
export { default as debounce } from './debounce'
export { default as throttle } from './throttle'
export { chunk, desert, upsert } from './array'
+export { default as generateRandomAvatar } from './generateRandomAvatar'
+export { default as generateRandomName } from './generateRandomName'
diff --git a/tailwind.config.ts b/tailwind.config.ts
index 50f825d..6500d2f 100644
--- a/tailwind.config.ts
+++ b/tailwind.config.ts
@@ -12,7 +12,7 @@ export default {
},
extend: {
zIndex: {
- top: '2147483647'
+ infinity: 'calc(infinity)'
},
colors: {
border: 'hsl(var(--border))',
@@ -52,8 +52,8 @@ export default {
minWidth: {
screen: '100vw'
},
- maxWidth: {
- layer: 'revert-layer'
+ backdropBlur: {
+ xs: '2px'
},
borderRadius: {
lg: 'var(--radius)',