Compare commits

...

7 Commits

Author SHA1 Message Date
CrescentLeaf
05d2779922 服务端不必检查密码
因为被哈希的空字符不空
2026-02-02 22:46:11 +08:00
CrescentLeaf
a9dbb9655b 消息和用户头像右键菜单查看原始数据 2026-01-25 16:04:48 +08:00
CrescentLeaf
2aa9425334 docs: readme 2026-01-25 00:59:22 +08:00
CrescentLeaf
ec527bafc6 refactor!: 重新实现最近对话和收藏对话的逻辑 (破坏性变更) 2026-01-25 00:50:14 +08:00
CrescentLeaf
44ada8206d feat(client): creat Group 2026-01-25 00:29:45 +08:00
CrescentLeaf
3044cabcaa chore: 修复可能潜在的空指针 2026-01-24 23:26:10 +08:00
CrescentLeaf
7e6cbbdce4 chore: 统一使用 import from xxx.ts 导入, 删去无用依赖引用 2026-01-24 23:25:54 +08:00
30 changed files with 366 additions and 168 deletions

View File

@@ -1,5 +1,5 @@
import { Chat, User, UserMySelf } from "lingchair-client-protocol" import { Chat, User, UserMySelf } from "lingchair-client-protocol"
import getClient from "./getClient" import getClient from "./getClient.ts"
type CouldCached = User | Chat | null type CouldCached = User | Chat | null
export default class ClientCache { export default class ClientCache {

View File

@@ -2,7 +2,6 @@ import { LingChairClient } from 'lingchair-client-protocol'
import data from "./data.ts" import data from "./data.ts"
import { UAParser } from 'ua-parser-js' import { UAParser } from 'ua-parser-js'
import { randomUUID } from 'lingchair-internal-shared' import { randomUUID } from 'lingchair-internal-shared'
import performAuth from './performAuth.ts'
if (!data.device_id) { if (!data.device_id) {
const ua = new UAParser(navigator.userAgent) const ua = new UAParser(navigator.userAgent)

View File

@@ -10,7 +10,7 @@ import { CallbackError, Chat, UserMySelf } from "lingchair-client-protocol"
import showCircleProgressDialog from "./showCircleProgressDialog.ts" import showCircleProgressDialog from "./showCircleProgressDialog.ts"
import RegisterDialog from "./main-page/RegisterDialog.tsx" import RegisterDialog from "./main-page/RegisterDialog.tsx"
import sleep from "../utils/sleep.ts" import sleep from "../utils/sleep.ts"
import { $, dialog, NavigationDrawer } from "mdui" import { $, NavigationDrawer } from "mdui"
import getClient from "../getClient.ts" import getClient from "../getClient.ts"
import showSnackbar from "../utils/showSnackbar.ts" import showSnackbar from "../utils/showSnackbar.ts"
import AllChatsList from "./main-page/AllChatsList.tsx" import AllChatsList from "./main-page/AllChatsList.tsx"
@@ -135,7 +135,7 @@ function Root() {
}}></mdui-divider> }}></mdui-divider>
<mdui-list-item rounded icon="settings"></mdui-list-item> <mdui-list-item rounded icon="settings"></mdui-list-item>
<mdui-list-item rounded icon="person_add" onClick={() => AppStateRef.current!.openAddFavouriteChat()}></mdui-list-item> <mdui-list-item rounded icon="person_add" onClick={() => AppStateRef.current!.openAddFavouriteChat()}></mdui-list-item>
<mdui-list-item rounded icon="group_add"></mdui-list-item> <mdui-list-item rounded icon="group_add" onClick={() => AppStateRef.current!.openCreateGroup()}></mdui-list-item>
</mdui-list> </mdui-list>
<div style={{ <div style={{
flexGrow: 1, flexGrow: 1,

View File

@@ -1,6 +1,6 @@
import { Chat, UserMySelf } from "lingchair-client-protocol" import { Chat } from "lingchair-client-protocol"
import { createContext } from "use-context-selector" import { createContext } from "use-context-selector"
import { SharedState } from "./MainSharedReducer" import { SharedState } from "./MainSharedReducer.ts"
type Shared = { type Shared = {
functions_lazy: React.MutableRefObject<{ functions_lazy: React.MutableRefObject<{

View File

@@ -1,4 +1,4 @@
import { Chat, UserMySelf } from "lingchair-client-protocol" import { Chat } from "lingchair-client-protocol"
export interface SharedState { export interface SharedState {
favouriteChats: Chat[] favouriteChats: Chat[]

View File

@@ -1,5 +1,5 @@
import EffectOnly from "./EffectOnly" import EffectOnly from "./EffectOnly.tsx"
import showCircleProgressDialog from "./showCircleProgressDialog" import showCircleProgressDialog from "./showCircleProgressDialog.ts"
export default function ProgressDialogFallback({ text }: { text: string }) { export default function ProgressDialogFallback({ text }: { text: string }) {
return <EffectOnly effect={() => { return <EffectOnly effect={() => {

View File

@@ -1,13 +1,9 @@
import * as React from 'react' import * as React from 'react'
import { Button, Dialog, snackbar, TextField } from "mdui" import { Dialog, TextField } from "mdui"
import { data, useNavigate } from 'react-router' import showSnackbar from '../../utils/showSnackbar.ts'
import { useContextSelector } from 'use-context-selector'
import MainSharedContext, { Shared } from '../MainSharedContext'
import showSnackbar from '../../utils/showSnackbar'
import { CallbackError } from 'lingchair-client-protocol' import { CallbackError } from 'lingchair-client-protocol'
import useEventListener from '../../utils/useEventListener' import useEventListener from '../../utils/useEventListener.ts'
import ClientCache from '../../ClientCache' import ClientCache from '../../ClientCache.ts'
import AppStateContext from './AppStateContext'
export default function AddFavourtieChatDialog({ useRef }: { useRef: React.MutableRefObject<Dialog | undefined> }) { export default function AddFavourtieChatDialog({ useRef }: { useRef: React.MutableRefObject<Dialog | undefined> }) {
const inputTargetRef = React.useRef<TextField>(null) const inputTargetRef = React.useRef<TextField>(null)

View File

@@ -1,14 +1,14 @@
import { Chat, User } from 'lingchair-client-protocol' import { Chat, User } from 'lingchair-client-protocol'
import { Dialog } from 'mdui'
import * as React from 'react' import * as React from 'react'
type AppState = { type AppState = {
openChatInfo: (chat: Chat | string) => void, openChatInfo: (chat: Chat | string) => void
openUserInfo: (user: Chat | User | string) => void, openUserInfo: (user: Chat | User | string) => void
openEditMyProfile: () => void, openEditMyProfile: () => void
openAddFavouriteChat: () => void, openAddFavouriteChat: () => void
openChat: (chat: string | Chat, inDialog?: boolean) => void, openCreateGroup: () => void
closeChat: () => void, openChat: (chat: string | Chat, inDialog?: boolean) => void
closeChat: () => void
} }
const AppStateContext = React.createContext<AppState>({ const AppStateContext = React.createContext<AppState>({
@@ -16,6 +16,7 @@ const AppStateContext = React.createContext<AppState>({
openUserInfo: () => {}, openUserInfo: () => {},
openEditMyProfile: () => {}, openEditMyProfile: () => {},
openAddFavouriteChat: () => {}, openAddFavouriteChat: () => {},
openCreateGroup: () => {},
openChat: () => {}, openChat: () => {},
closeChat: () => {}, closeChat: () => {},
}) })

View File

@@ -1,18 +1,18 @@
import { $, Dialog } from "mdui" import { $, Dialog } from "mdui"
import AppStateContext, { AppState } from "./AppStateContext" import AppStateContext, { AppState } from "./AppStateContext.ts"
import { Chat, User } from "lingchair-client-protocol" import { Chat, User } from "lingchair-client-protocol"
import getClient from "../../getClient" import getClient from "../../getClient.ts"
import UserOrChatInfoDialog from "./UserOrChatInfoDialog" import UserOrChatInfoDialog from "./UserOrChatInfoDialog.tsx"
import useEffectRef from "../../utils/useEffectRef" import useEffectRef from "../../utils/useEffectRef.ts"
import EditMyProfileDialog from "./EditMyProfileDialog" import EditMyProfileDialog from "./EditMyProfileDialog.tsx"
import AddFavourtieChatDialog from "./AddFavourtieChatDialog" import AddFavourtieChatDialog from "./AddFavourtieChatDialog.tsx"
import * as React from 'react' import * as React from 'react'
import { useContextSelector } from "use-context-selector" import { useContextSelector } from "use-context-selector"
import MainSharedContext, { Shared } from "../MainSharedContext" import MainSharedContext, { Shared } from "../MainSharedContext.ts"
import ChatFragmentDialog from "./ChatFragmentDialog" import ChatFragmentDialog from "./ChatFragmentDialog.tsx"
import useAsyncEffect from "../../utils/useAsyncEffect" import useAsyncEffect from "../../utils/useAsyncEffect.ts"
import ClientCache from "../../ClientCache" import ClientCache from "../../ClientCache.ts"
import isMobileUI from "../../utils/isMobileUI" import CreateGroupDialog from "./CreateGroupDialog.tsx"
const config = await fetch('/config.json').then((re) => re.json()) const config = await fetch('/config.json').then((re) => re.json())
@@ -34,6 +34,7 @@ export default function DialogContextWrapper({ children, useRef }: { children: R
const editMyProfileDialogRef = React.useRef<Dialog>() const editMyProfileDialogRef = React.useRef<Dialog>()
const addFavouriteChatDialogRef = React.useRef<Dialog>() const addFavouriteChatDialogRef = React.useRef<Dialog>()
const createGroupDialogRef = React.useRef<Dialog>()
const setCurrentSelectedChatId = useContextSelector( const setCurrentSelectedChatId = useContextSelector(
MainSharedContext, MainSharedContext,
@@ -67,6 +68,9 @@ export default function DialogContextWrapper({ children, useRef }: { children: R
static openAddFavouriteChat() { static openAddFavouriteChat() {
addFavouriteChatDialogRef.current!.open = true addFavouriteChatDialogRef.current!.open = true
} }
static openCreateGroup() {
createGroupDialogRef.current!.open = true
}
static async openChat(chat: string | Chat, inDialog?: boolean) { static async openChat(chat: string | Chat, inDialog?: boolean) {
if (chat instanceof Chat) chat = chat.getId() if (chat instanceof Chat) chat = chat.getId()
@@ -88,6 +92,7 @@ export default function DialogContextWrapper({ children, useRef }: { children: R
<UserOrChatInfoDialog chat={userOrChatInfoDialogState[userOrChatInfoDialogState.length - 1] || lastUserOrChatInfoDialogStateRef.current} useRef={userOrChatInfoDialogRef} /> <UserOrChatInfoDialog chat={userOrChatInfoDialogState[userOrChatInfoDialogState.length - 1] || lastUserOrChatInfoDialogStateRef.current} useRef={userOrChatInfoDialogRef} />
<EditMyProfileDialog useRef={editMyProfileDialogRef} /> <EditMyProfileDialog useRef={editMyProfileDialogRef} />
<AddFavourtieChatDialog useRef={addFavouriteChatDialogRef} /> <AddFavourtieChatDialog useRef={addFavouriteChatDialogRef} />
<CreateGroupDialog useRef={createGroupDialogRef} />
{children} {children}
</AppStateContext.Provider> </AppStateContext.Provider>
} }

View File

@@ -1,7 +1,7 @@
import { Dialog } from "mdui" import { Dialog } from "mdui"
import * as React from 'react' import * as React from 'react'
import LazyChatFragment from "../chat-fragment/LazyChatFragment" import LazyChatFragment from "../chat-fragment/LazyChatFragment.tsx"
import useEventListener from "../../utils/useEventListener" import useEventListener from "../../utils/useEventListener.ts"
export default function ChatFragmentDialog({ chatId, useRef }: { chatId: string, useRef: React.MutableRefObject<Dialog | undefined> }) { export default function ChatFragmentDialog({ chatId, useRef }: { chatId: string, useRef: React.MutableRefObject<Dialog | undefined> }) {
useEventListener(useRef, 'open', () => { useEventListener(useRef, 'open', () => {

View File

@@ -0,0 +1,41 @@
import * as React from 'react'
import { Dialog, TextField } from "mdui"
import showSnackbar from '../../utils/showSnackbar'
import { CallbackError, Chat } from 'lingchair-client-protocol'
import useEventListener from '../../utils/useEventListener.ts'
import getClient from '../../getClient.ts'
export default function CreateGroupDialog({ useRef }: { useRef: React.MutableRefObject<Dialog | undefined> }) {
const inputTargetRef = React.useRef<TextField>(null)
useEventListener(useRef, 'closed', () => {
inputTargetRef.current!.value = ''
})
async function createGroup() {
try {
await Chat.createGroupOrThrow(getClient(), inputTargetRef.current!.value)
inputTargetRef.current!.value = ''
showSnackbar({
message: '创建成功!'
})
useRef.current!.open = false
} catch (e) {
if (e instanceof CallbackError)
showSnackbar({
message: '创建群组失败: ' + e.message
})
}
}
return (
<mdui-dialog close-on-overlay-click close-on-esc headline="创建群组" ref={useRef}>
<mdui-text-field clearable label="群组标题" ref={inputTargetRef} onKeyDown={(event: KeyboardEvent) => {
if (event.key == 'Enter')
createGroup()
}}></mdui-text-field>
<mdui-button slot="action" variant="text" onClick={() => useRef.current!.open = false}></mdui-button>
<mdui-button slot="action" variant="text" onClick={() => createGroup()}></mdui-button>
</mdui-dialog>
)
}

View File

@@ -1,11 +1,10 @@
import { CallbackError, UserMySelf } from "lingchair-client-protocol" import { CallbackError, UserMySelf } from "lingchair-client-protocol"
import ClientCache from "../../ClientCache" import ClientCache from "../../ClientCache.ts"
import AvatarMySelf from "../AvatarMySelf" import AvatarMySelf from "../AvatarMySelf.tsx"
import useAsyncEffect from "../../utils/useAsyncEffect" import useAsyncEffect from "../../utils/useAsyncEffect.ts"
import getClient from "../../getClient" import getClient from "../../getClient.ts"
import { useNavigate } from "react-router" import showSnackbar from "../../utils/showSnackbar.ts"
import showSnackbar from "../../utils/showSnackbar" import useEventListener from "../../utils/useEventListener.ts"
import useEventListener from "../../utils/useEventListener"
import { Dialog, TextField } from "mdui" import { Dialog, TextField } from "mdui"
import * as React from 'react' import * as React from 'react'

View File

@@ -1,28 +1,22 @@
import { Dialog, dialog } from "mdui" import { Dialog, dialog } from "mdui"
import { useLoaderData, useNavigate } from "react-router"
import { CallbackError, Chat } from "lingchair-client-protocol" import { CallbackError, Chat } from "lingchair-client-protocol"
import showSnackbar from "../../utils/showSnackbar" import showSnackbar from "../../utils/showSnackbar.ts"
import Avatar from "../Avatar" import Avatar from "../Avatar.tsx"
import { useContextSelector } from "use-context-selector" import { useContextSelector } from "use-context-selector"
import MainSharedContext, { Shared } from "../MainSharedContext" import MainSharedContext, { Shared } from "../MainSharedContext.ts"
import * as React from 'react' import * as React from 'react'
import ClientCache from "../../ClientCache" import ClientCache from "../../ClientCache.ts"
import getClient from "../../getClient" import getClient from "../../getClient.ts"
import isMobileUI from "../../utils/isMobileUI" import isMobileUI from "../../utils/isMobileUI.ts"
import useEffectRef from "../../utils/useEffectRef" import useAsyncEffect from "../../utils/useAsyncEffect.ts"
import useAsyncEffect from "../../utils/useAsyncEffect" import AppStateContext from "./AppStateContext.ts"
import AppStateContext from "./AppStateContext"
export default function UserOrChatInfoDialog({ chat, useRef }: { chat?: Chat, useRef: React.MutableRefObject<Dialog | undefined> }) { export default function UserOrChatInfoDialog({ chat, useRef }: { chat?: Chat, useRef: React.MutableRefObject<Dialog | undefined> }) {
const favouriteChats = useContextSelector( const favouriteChats = useContextSelector(
MainSharedContext, MainSharedContext,
(context: Shared) => context.state.favouriteChats (context: Shared) => context.state.favouriteChats
) )
const setCurrentSelectedChatId = useContextSelector(
MainSharedContext,
(context: Shared) => context.setCurrentSelectedChatId
)
const AppState = React.useContext(AppStateContext) const AppState = React.useContext(AppStateContext)
const [isMySelf, setIsMySelf] = React.useState(false) const [isMySelf, setIsMySelf] = React.useState(false)

View File

@@ -29,9 +29,9 @@ customElements.define('chat-text', class extends HTMLElement {
// 避免不同的消息类型之间的换行符导致显示异常 // 避免不同的消息类型之间的换行符导致显示异常
if (isFirstElementInParent) if (isFirstElementInParent)
this.span.textContent = this.textContent.trimStart() this.span.textContent = (this.textContent || '').trimStart()
else if (isLastElementInParent) else if (isLastElementInParent)
this.span.textContent = this.textContent.trimEnd() this.span.textContent = (this.textContent || '').trimEnd()
else else
this.span.textContent = this.textContent this.span.textContent = this.textContent
this.span.style.textDecoration = $(this).attr('underline') ? 'underline' : '' this.span.style.textDecoration = $(this).attr('underline') ? 'underline' : ''

View File

@@ -1,18 +1,18 @@
import { $, Tab, TextField } from "mdui" import { $, Tab, TextField } from "mdui"
import useEventListener from "../../utils/useEventListener" import useEventListener from "../../utils/useEventListener.ts"
import useEffectRef from "../../utils/useEffectRef" import useEffectRef from "../../utils/useEffectRef.ts"
import isMobileUI from "../../utils/isMobileUI" import isMobileUI from "../../utils/isMobileUI.ts"
import { Chat } from "lingchair-client-protocol" import { Chat } from "lingchair-client-protocol"
import Preference from "../preference/Preference" import Preference from "../preference/Preference.tsx"
import PreferenceHeader from "../preference/PreferenceHeader" import PreferenceHeader from "../preference/PreferenceHeader.tsx"
import PreferenceLayout from "../preference/PreferenceLayout" import PreferenceLayout from "../preference/PreferenceLayout.tsx"
import PreferenceUpdater from "../preference/PreferenceUpdater" import PreferenceUpdater from "../preference/PreferenceUpdater.tsx"
import SwitchPreference from "../preference/SwitchPreference" import SwitchPreference from "../preference/SwitchPreference.tsx"
import TextFieldPreference from "../preference/TextFieldPreference" import TextFieldPreference from "../preference/TextFieldPreference.tsx"
import * as React from 'react' import * as React from 'react'
import ChatMessageContainer from "./ChatMessageContainer" import ChatMessageContainer from "./ChatMessageContainer"
import AppStateContext from "../app-state/AppStateContext" import AppStateContext from "../app-state/AppStateContext.ts"
import ChatPanel, { ChatPanelRef } from "./ChatPanel" import ChatPanel, { ChatPanelRef } from "./ChatPanel.tsx"
interface MduiTabFitSizeArgs extends React.HTMLAttributes<HTMLElement & Tab> { interface MduiTabFitSizeArgs extends React.HTMLAttributes<HTMLElement & Tab> {
value: string value: string

View File

@@ -1,15 +1,15 @@
import { ChatParserTransformers, Message } from "lingchair-client-protocol" import { ChatParserTransformers, Message } from "lingchair-client-protocol"
import isMobileUI from "../../utils/isMobileUI" import isMobileUI from "../../utils/isMobileUI.ts"
import useAsyncEffect from "../../utils/useAsyncEffect" import useAsyncEffect from "../../utils/useAsyncEffect.ts"
import ClientCache from "../../ClientCache" import ClientCache from "../../ClientCache.ts"
import getClient from "../../getClient" import getClient from "../../getClient.ts"
import Avatar from "../Avatar" import Avatar from "../Avatar.tsx"
import AppStateContext from "../app-state/AppStateContext" import AppStateContext from "../app-state/AppStateContext.ts"
import { $, dialog, Dropdown } from "mdui" import { Dropdown } from "mdui"
import useEventListener from "../../utils/useEventListener" import useEventListener from "../../utils/useEventListener.ts"
import DOMPurify from 'dompurify' import DOMPurify from 'dompurify'
import * as React from 'react' import * as React from 'react'
import ChatMentionElement from "../chat-elements/chat-mention" import ChatMentionElement from "../chat-elements/chat-mention.ts"
function escapeHTML(str: string) { function escapeHTML(str: string) {
const div = document.createElement('div') const div = document.createElement('div')

View File

@@ -1,6 +1,7 @@
import { Chat, Message } from 'lingchair-client-protocol' import { Message } from 'lingchair-client-protocol'
import * as React from 'react' import * as React from 'react'
import ChatMessage from './ChatMessage' import ChatMessage from './ChatMessage.tsx'
import { dialog } from 'mdui'
export default function ChatMessageContainer({ messages }: { messages: Message[] }) { export default function ChatMessageContainer({ messages }: { messages: Message[] }) {
return ( return (
@@ -53,7 +54,51 @@ export default function ChatMessageContainer({ messages }: { messages: Message[]
</div> </div>
</mdui-tooltip> </mdui-tooltip>
} }
<ChatMessage message={msg} noUserDisplay={lastUser == user && !shouldShowTime} /> <ChatMessage
message={msg}
noUserDisplay={lastUser == user && !shouldShowTime}
avatarMenuItems={[
<mdui-menu-item icon="info" onClick={async () => {
const user = await msg.getUser().then((re) => re?.bean) || {}
dialog({
headline: "Info",
body: `<span style="word-break: break-word;">${Object.keys(user)
// @ts-ignore 懒
.map((k) => `${k} = ${user[k]}`)
.join('<br><br>')}<span>`,
closeOnEsc: true,
closeOnOverlayClick: true,
actions: [
{
text: "关闭",
onClick: () => {
return true
},
}
]
}).addEventListener('click', (e) => e.stopPropagation())
}}>JSON</mdui-menu-item>
]}
messageMenuItems={[
<mdui-menu-item icon="info" onClick={() => dialog({
headline: "Info",
body: `<span style="word-break: break-word;">${Object.keys(msg.bean)
// @ts-ignore 懒
.map((k) => `${k} = ${msg.bean[k]}`)
.join('<br><br>')}<span>`,
closeOnEsc: true,
closeOnOverlayClick: true,
actions: [
{
text: "关闭",
onClick: () => {
return true
},
}
]
}).addEventListener('click', (e) => e.stopPropagation())}>Info</mdui-menu-item>
]}
/>
</> </>
}) })
})() })()

View File

@@ -1,6 +1,6 @@
import { Chat, Message } from "lingchair-client-protocol" import { Chat, Message } from "lingchair-client-protocol"
import ChatMessageContainer from "./ChatMessageContainer" import ChatMessageContainer from "./ChatMessageContainer.tsx"
import useAsyncEffect from "../../utils/useAsyncEffect" import useAsyncEffect from "../../utils/useAsyncEffect.ts"
import * as React from 'react' import * as React from 'react'
function ChatPanelInner({ chat }: { chat: Chat }, ref: React.ForwardedRef<any>) { function ChatPanelInner({ chat }: { chat: Chat }, ref: React.ForwardedRef<any>) {

View File

@@ -1,8 +1,8 @@
import { Chat } from "lingchair-client-protocol" import { Chat } from "lingchair-client-protocol"
import getClient from "../../getClient" import getClient from "../../getClient.ts"
import ChatFragment from "./ChatFragment" import ChatFragment from "./ChatFragment.tsx"
import * as React from 'react' import * as React from 'react'
import useAsyncEffect from "../../utils/useAsyncEffect" import useAsyncEffect from "../../utils/useAsyncEffect.ts"
export default function LazyChatFragment({ chatId, openedInDialog }: { chatId: string, openedInDialog: boolean }) { export default function LazyChatFragment({ chatId, openedInDialog }: { chatId: string, openedInDialog: boolean }) {
const [child, setChild] = React.useState<React.ReactNode>() const [child, setChild] = React.useState<React.ReactNode>()

View File

@@ -9,7 +9,6 @@ import { useContextSelector } from "use-context-selector"
import MainSharedContext, { Shared } from "../MainSharedContext.ts" import MainSharedContext, { Shared } from "../MainSharedContext.ts"
import isMobileUI from "../../utils/isMobileUI.ts" import isMobileUI from "../../utils/isMobileUI.ts"
import ClientCache from "../../ClientCache.ts" import ClientCache from "../../ClientCache.ts"
import { useNavigate } from "react-router"
import AppStateContext from "../app-state/AppStateContext.ts" import AppStateContext from "../app-state/AppStateContext.ts"
export default function FavouriteChatsList({ ...props }: React.HTMLAttributes<HTMLElement>) { export default function FavouriteChatsList({ ...props }: React.HTMLAttributes<HTMLElement>) {

View File

@@ -2,7 +2,6 @@ import { TextField } from "mdui"
import RecentsListItem from "./RecentsListItem.tsx" import RecentsListItem from "./RecentsListItem.tsx"
import React from "react" import React from "react"
import RecentChat from "lingchair-client-protocol/RecentChat.ts" import RecentChat from "lingchair-client-protocol/RecentChat.ts"
import { data } from "react-router"
import isMobileUI from "../../utils/isMobileUI.ts" import isMobileUI from "../../utils/isMobileUI.ts"
import useAsyncEffect from "../../utils/useAsyncEffect.ts" import useAsyncEffect from "../../utils/useAsyncEffect.ts"
import useEventListener from "../../utils/useEventListener.ts" import useEventListener from "../../utils/useEventListener.ts"

View File

@@ -1,12 +1,12 @@
import * as React from 'react' import * as React from 'react'
import { Button, Dialog, TextField } from "mdui" import { Dialog, TextField } from "mdui"
import MainSharedContext, { Shared } from '../MainSharedContext' import MainSharedContext, { Shared } from '../MainSharedContext.ts'
import showSnackbar from '../../utils/showSnackbar' import showSnackbar from '../../utils/showSnackbar.ts'
import showCircleProgressDialog from '../showCircleProgressDialog' import showCircleProgressDialog from '../showCircleProgressDialog.ts'
import getClient from '../../getClient' import getClient from '../../getClient.ts'
import performAuth from '../../performAuth' import performAuth from '../../performAuth.ts'
import { useContextSelector } from 'use-context-selector' import { useContextSelector } from 'use-context-selector'
import useEventListener from '../../utils/useEventListener' import useEventListener from '../../utils/useEventListener.ts'
export default function RegisterDialog({ ...props }: { open: boolean } & React.HTMLAttributes<Dialog>) { export default function RegisterDialog({ ...props }: { open: boolean } & React.HTMLAttributes<Dialog>) {
const shared = useContextSelector(MainSharedContext, (context: Shared) => ({ const shared = useContextSelector(MainSharedContext, (context: Shared) => ({

View File

@@ -1,4 +1,4 @@
import data from "../data" import data from "../data.ts"
const searchParams = new URL(location.href).searchParams const searchParams = new URL(location.href).searchParams

View File

@@ -1,5 +1,3 @@
import { Dialog } from "mdui"
import { BlockerFunction, useBlocker, useNavigate } from "react-router"
import * as React from 'react' import * as React from 'react'
export default function useEffectRef<T = undefined>(effect: (ref: React.MutableRefObject<T | undefined>) => void | (() => void), deps?: React.DependencyList) { export default function useEffectRef<T = undefined>(effect: (ref: React.MutableRefObject<T | undefined>) => void | (() => void), deps?: React.DependencyList) {

View File

@@ -6,31 +6,27 @@
铃之椅, 一个普通的即时通讯项目——简单, 轻量, 纯粹, 时而天真 铃之椅, 一个普通的即时通讯项目——简单, 轻量, 纯粹, 时而天真
*目前还没有发布正式版本, 仍在积极开发中* ***仍在开发阶段, 随时都可能有破坏性变更!***
项目代号: TheWhiteSilk ### 目前的功能
### 基本功能
<details> <details>
<summary>客户端</summary> <summary>客户端</summary>
*: 重构中
- 消息 - 消息
- [x] 收发消息 - [ ] *收发消息
- [x] 富文本 (based on Marked) - [x] 富文本
- [x] 图片
- [x] 视频
- [x] 文件
- [ ] 测试其他 Markdown 语法的可用性
- [ ] 撤回消息 - [ ] 撤回消息
- [ ] 修改消息 - [ ] 修改消息
- 对话 - 对话
- [x] 最近对话 - [x] 最近对话
- [x] 添加对话 - [x] 添加收藏对话
- [x] 添加用户 - [x] 添加用户
- [x] 添加群组 - [x] 添加群组
- [ ] 群组管理 - [ ] *群组管理
- 帐号 - 帐号
- [x] 登录注册 - [x] 登录注册
@@ -64,46 +60,48 @@
- [x] 登录注册 - [x] 登录注册
- [x] 资料编辑 - [x] 资料编辑
- [ ] 帐号管理 - [ ] 帐号管理
- [ ] 重设密码 - [x] 重设密码 (不够好!)
- [ ] 绑定邮箱 - [ ] 绑定邮箱
</details> </details>
### 快速上手 ### 部署
```bash ```bash
git clone https://codeberg.org/CrescentLeaf/LingChair git clone https://codeberg.org/CrescentLeaf/LingChair
cd LingChair cd LingChair
# 编译前端 npm run install-dependencies
deno task build npm run build-client
# 运行服务 npm run server
deno task server
``` ```
#### 配置 #### 配置
[thewhitesilk_config.json 是什么?](./server/config.ts) thewhitesilk_config.json [请见此处](./server/config.ts)
### 使用的项目 / 技术栈 ### 使用的项目 /
本项目由 Deno 强力驱动 Deno 存在的严重问题, 已重新迁移到 Node.js
*当然, 由于没有使用 Deno Api, 只有 Node Api, 因此理论上 Node.js 也能运行, 但需要另外安装依赖* - 客户端协议
- crypto-browserify
- 前端 - 前端
- 编译 - 编译
- vite - vite
- vite-plugin-babel
- react - react
- socket.io-client - socket.io-client
- mdui - mdui
- split.js - split.js
- react-json-view - ua-parser-js
- pinch-zoom
- use-context-selector
- dompurify - dompurify
- marked - marked
- 后端 - 后端
- express - express
- express-fileupload
- socket.io - socket.io
- chalk - chalk
- file-type - file-type

View File

@@ -140,7 +140,12 @@ export default class UserApi extends BaseApi {
}) })
// 注冊 // 注冊
this.registerEvent("User.register", (args, { deviceId }) => { this.registerEvent("User.register", (args, { deviceId }) => {
if (this.checkArgsMissing(args, ['nickname', 'password'])) return { // 判断密码是否为空已经没有意义
// 因为空字符的哈希值不为空
// 后续会修缮关于注册的机制
// 比如限制 IP, 需要邮箱
// 虽然我知道这没有什么意义
if (this.checkArgsEmpty(args, ['nickname'])) return {
msg: "参数缺失", msg: "参数缺失",
code: 400, code: 400,
} }
@@ -318,11 +323,11 @@ export default class UserApi extends BaseApi {
const user = User.findById(token.author) as User const user = User.findById(token.author) as User
const recentChats = user.getRecentChats() const recentChats = user.getRecentChats()
const recentChatsList: any[] = [] const recentChatsList: any[] = []
for (const [chatId, content] of recentChats) { for (const {chat_id, content} of recentChats) {
const chat = Chat.findById(chatId) const chat = Chat.findById(chat_id)
recentChatsList.push({ recentChatsList.push({
content, content,
id: chatId, id: chat_id,
title: chat?.getTitle(user) || "未知", title: chat?.getTitle(user) || "未知",
avatar_file_hash: chat?.getAvatarFileHash(user) ? chat?.getAvatarFileHash(user) : undefined avatar_file_hash: chat?.getAvatarFileHash(user) ? chat?.getAvatarFileHash(user) : undefined
}) })

View File

@@ -0,0 +1,10 @@
export default class RecentChatBean {
declare count: number
/** 最近对话所关联的用户 */
declare user_id: string
declare chat_id: string
declare content: string
declare updated_time: number
[key: string]: unknown
}

View File

@@ -11,11 +11,10 @@ import UserBean from './UserBean.ts'
import FileManager from './FileManager.ts' import FileManager from './FileManager.ts'
import { SQLInputValue } from "node:sqlite" import { SQLInputValue } from "node:sqlite"
import ChatPrivate from "./ChatPrivate.ts" import ChatPrivate from "./ChatPrivate.ts"
import Chat from "./Chat.ts"
import ChatBean from "./ChatBean.ts"
import MapJson from "../MapJson.ts"
import DataWrongError from '../api/DataWrongError.ts' import DataWrongError from '../api/DataWrongError.ts'
import UserChatLinker from "./UserChatLinker.ts"; import UserChatLinker from "./UserChatLinker.ts"
import UserFavouriteChatLinker from "./UserFavouriteChatLinker.ts"
import UserRecentChatLinker from "./UserRecentChatLinker.ts"
type UserBeanKey = keyof UserBean type UserBeanKey = keyof UserBean
@@ -38,8 +37,6 @@ export default class User {
/* 用户名 */ username TEXT, /* 用户名 */ username TEXT,
/* 昵称 */ nickname TEXT NOT NULL, /* 昵称 */ nickname TEXT NOT NULL,
/* 头像, 可选 */ avatar_file_hash TEXT, /* 头像, 可选 */ avatar_file_hash TEXT,
/* 对话列表 */ favourite_chats TEXT NOT NULL,
/* 最近对话 */ recent_chats TEXT NOT NULL,
/* 设置 */ settings TEXT NOT NULL /* 设置 */ settings TEXT NOT NULL
); );
`) `)
@@ -69,18 +66,14 @@ export default class User {
username, username,
nickname, nickname,
avatar_file_hash, avatar_file_hash,
favourite_chats,
recent_chats,
settings settings
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);`).run( ) VALUES (?, ?, ?, ?, ?, ?, ?);`).run(
crypto.randomUUID(), crypto.randomUUID(),
password, password,
Date.now(), Date.now(),
userName, userName,
nickName, nickName,
null, null,
'[]',
JSON.stringify(new Map(), MapJson.replacer),
"{}" "{}"
).lastInsertRowid ).lastInsertRowid
)[0] )[0]
@@ -136,37 +129,25 @@ export default class User {
this.setAttr("username", userName) this.setAttr("username", userName)
} }
updateRecentChat(chatId: string, content: string) { updateRecentChat(chatId: string, content: string) {
const map = JSON.parse(this.bean.recent_chats, MapJson.reviver) as Map<string, string> UserRecentChatLinker.updateOrAddRecentChat(this.bean.id, chatId, content)
map.delete(chatId)
map.set(chatId, content)
this.setAttr("recent_chats", JSON.stringify(map, MapJson.replacer))
} }
getRecentChats(): Map<string, string> { getRecentChats() {
try { return UserRecentChatLinker.getUserRecentChatBeans(this.bean.id)
return JSON.parse(this.bean.recent_chats, MapJson.reviver) }
} catch (e) {
console.log(chalk.yellow(`警告: 最近对话列表解析失敗: ${(e as Error).message}`)) getFavouriteChats() {
return new Map() return UserFavouriteChatLinker.getUserFavouriteChats(this.bean.id)
} }
addFavouriteChats(chatIds: string[]) {
chatIds.forEach((v) => UserFavouriteChatLinker.linkUserAndChat(this.bean.id, v))
}
removeFavouriteChats(chatIds: string[]) {
chatIds.forEach((v) => UserFavouriteChatLinker.unlinkUserAndChat(this.bean.id, v))
} }
addFavouriteChat(chatId: string) { addFavouriteChat(chatId: string) {
const ls = this.getFavouriteChats() this.addFavouriteChats([chatId])
if (ls.indexOf(chatId) != -1 || ChatPrivate.getChatIdByUsersId(this.bean.id, this.bean.id) == chatId) return
ls.push(chatId)
this.setAttr("favourite_chats", JSON.stringify(ls))
}
removeFavouriteChats(contacts: string[]) {
const ls = this.getFavouriteChats().filter((v) => !contacts.includes(v))
this.setAttr("favourite_chats", JSON.stringify(ls))
}
getFavouriteChats() {
try {
return [...(JSON.parse(this.bean.favourite_chats) as string[]), ChatPrivate.findOrCreateForPrivate(this, this).bean.id]
} catch (e) {
console.log(chalk.yellow(`警告: 收藏对话解析失败: ${(e as Error).message}`))
return []
}
} }
getAllChatsList() { getAllChatsList() {
return UserChatLinker.getUserChats(this.bean.id) return UserChatLinker.getUserChats(this.bean.id)
} }

View File

@@ -0,0 +1,57 @@
import { DatabaseSync } from "node:sqlite"
import path from 'node:path'
import config from "../config.ts"
import { SQLInputValue } from "node:sqlite"
export default class UserFavouriteChatLinker {
static database: DatabaseSync = this.init()
private static init(): DatabaseSync {
const db: DatabaseSync = new DatabaseSync(path.join(config.data_path, 'UserFavouriteChatLinker.db'))
db.exec(`
CREATE TABLE IF NOT EXISTS UserFavouriteChatLinker (
/* 序号 */ count INTEGER PRIMARY KEY AUTOINCREMENT,
/* 用戶 ID */ user_id TEXT NOT NULL,
/* Chat ID */ chat_id TEXT NOT NULL
);
`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_user_id ON UserFavouriteChatLinker(user_id);`)
return db
}
/**
* 若用户和对话未关联, 则进行关联
*/
static linkUserAndChat(userId: string, chatId: string) {
if (!this.checkUserIsLinkedToChat(userId, chatId))
this.database.prepare(`INSERT INTO UserFavouriteChatLinker (
user_id,
chat_id
) VALUES (?, ?);`).run(
userId,
chatId
)
}
/**
* 解除用户和对话的关联
*/
static unlinkUserAndChat(userId: string, chatId: string) {
this.database.prepare(`DELETE FROM UserFavouriteChatLinker WHERE user_id = ? AND chat_id = ?`).run(userId, chatId)
}
/**
* 检测用户和对话的关联
*/
static checkUserIsLinkedToChat(userId: string, chatId: string) {
return this.findAllByCondition('user_id = ? AND chat_id = ?', userId, chatId).length != 0
}
/**
* 获取用户所有关联的对话
*/
static getUserFavouriteChats(userId: string) {
return this.findAllByCondition('user_id = ?', userId).map((v) => v.chat_id) as string[]
}
protected static findAllByCondition(condition: string, ...args: SQLInputValue[]) {
return this.database.prepare(`SELECT * FROM UserFavouriteChatLinker WHERE ${condition}`).all(...args)
}
}

View File

@@ -0,0 +1,71 @@
import { DatabaseSync } from "node:sqlite"
import path from 'node:path'
import RecentChatBean from './RecentChatBean.ts'
import config from "../config.ts"
import { SQLInputValue } from "node:sqlite"
export default class UserRecentChatLinker {
static database: DatabaseSync = this.init()
private static init(): DatabaseSync {
const db: DatabaseSync = new DatabaseSync(path.join(config.data_path, 'UserRecentChatLinker.db'))
db.exec(`
CREATE TABLE IF NOT EXISTS UserRecentChatLinker (
/* 序号 */ count INTEGER PRIMARY KEY AUTOINCREMENT,
/* 用戶 ID */ user_id TEXT NOT NULL,
/* Chat ID */ chat_id TEXT NOT NULL,
/* Last Message Content */ content TEXT NOT NULL,
/* Last Update Time */ updated_time INT8 NOT NULL
);
`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_user_id ON UserRecentChatLinker(user_id);`)
return db
}
/**
* 若用户和对话未关联, 则进行关联
*/
static updateOrAddRecentChat(userId: string, chatId: string, content: string) {
if (!this.checkUserIsLinkedToChat(userId, chatId))
this.database.prepare(`INSERT INTO UserRecentChatLinker (
user_id,
chat_id,
content,
updated_time
) VALUES (?, ?, ?, ?);`).run(
userId,
chatId,
content,
Date.now()
)
else
this.database.prepare('UPDATE UserRecentChatLinker SET content = ?, updated_time = ? WHERE count = ?').run(
content,
Date.now(),
/* 既然已经 bind 了, 那么就不需要判断了? */
this.findAllByCondition('user_id = ? AND chat_id = ?', userId, chatId)[0].count
)
}
/**
* 解除用户和对话的关联
*/
static removeRecentChat(userId: string, chatId: string) {
this.database.prepare(`DELETE FROM UserRecentChatLinker WHERE user_id = ? AND chat_id = ?`).run(userId, chatId)
}
/**
* 检测用户和对话的关联
*/
static checkUserIsLinkedToChat(userId: string, chatId: string) {
return this.findAllByCondition('user_id = ? AND chat_id = ?', userId, chatId).length != 0
}
/**
* 获取用户所有关联的对话
*/
static getUserRecentChatBeans(userId: string) {
return this.findAllByCondition('user_id = ? ORDER BY updated_time DESC', userId) as unknown as RecentChatBean[]
}
protected static findAllByCondition(condition: string, ...args: SQLInputValue[]) {
return this.database.prepare(`SELECT * FROM UserRecentChatLinker WHERE ${condition}`).all(...args)
}
}