diff --git a/client/index.html b/client/index.html index b20d9a1..1c7c5f8 100644 --- a/client/index.html +++ b/client/index.html @@ -16,7 +16,7 @@ -
+
diff --git a/client/package.json b/client/package.json index 52f69f6..2775056 100644 --- a/client/package.json +++ b/client/package.json @@ -24,6 +24,7 @@ "@rollup/wasm-node": "4.48.0", "@types/react": "18.3.1", "@types/react-dom": "18.3.1", + "@types/split.js": "^1.4.0", "@vitejs/plugin-react": "4.7.0", "chalk": "5.4.1", "vite": "7.2.6", diff --git a/client/ui/Main.tsx b/client/ui/Main.tsx index 33a4cde..0e34cd8 100644 --- a/client/ui/Main.tsx +++ b/client/ui/Main.tsx @@ -3,15 +3,15 @@ import useEventListener from "../utils/useEventListener.ts" import AvatarMySelf from "./AvatarMySelf.tsx" import MainSharedContext from './MainSharedContext.ts' import * as React from 'react' -import { BrowserRouter, createBrowserRouter, Link, LoaderFunction, Outlet, Route, RouterProvider, Routes, useNavigate } from "react-router" +import { createBrowserRouter, Outlet, RouterProvider, useNavigate, useRouteError } from "react-router" import LoginDialog from "./main-page/LoginDialog.tsx" import useAsyncEffect from "../utils/useAsyncEffect.ts" import performAuth from "../performAuth.ts" -import { CallbackError, Chat, User, UserMySelf } from "lingchair-client-protocol" +import { CallbackError, Chat, UserMySelf } from "lingchair-client-protocol" import showCircleProgressDialog from "./showCircleProgressDialog.ts" import RegisterDialog from "./main-page/RegisterDialog.tsx" import sleep from "../utils/sleep.ts" -import { $, Dialog, NavigationDrawer } from "mdui" +import { $, dialog, NavigationDrawer } from "mdui" import getClient from "../getClient.ts" import showSnackbar from "../utils/showSnackbar.ts" import AllChatsList from "./main-page/AllChatsList.tsx" @@ -25,6 +25,10 @@ import EffectOnly from "./EffectOnly.tsx" import MainSharedReducer from "./MainSharedReducer.ts" import gotoUserInfo from "./routers/gotoUserInfo.ts" import EditMyProfileDialog from "./routers/EditMyProfileDialog.tsx" +import ProgressDialogFallback from "./ProgressDialogFallback.tsx" +import Split from 'split.js' +import data from "../data.ts" +import LazyChatFragment from "./chat-fragment/LazyChatFragment.tsx" function Root() { const [myProfileCache, setMyProfileCache] = React.useState() @@ -100,13 +104,27 @@ function Root() { waitingForAuth.open = false }) + React.useEffect(() => { + if (!isMobileUI()) { + const split = Split(['#SideBar', '#ChatFragment'], { + sizes: data.split_sizes ? data.split_sizes : [25, 75], + minSize: [200, 400], + gutterSize: 2, + onDragEnd: function () { + data.split_sizes = split.getSizes() + data.apply() + } + }) + } + }, []) + return (
{ @@ -128,7 +146,8 @@ function Root() { - 添加收藏对话 + 客户端设置 + setShowAddFavourtieChatDialog(true)}>添加收藏对话 创建新的群组 } + { +
+ { + (state.currentSelectedChatId && state.currentSelectedChatId != '') + ? + :
+ 选择以开始对话...... +
+ } +
+ } { /** * Mobile: 底部导航栏提供列表切换 @@ -218,16 +257,27 @@ function Root() { ) } +function SnackbarErrorBoundary() { + const error = useRouteError() + return { + const d = dialog({ + headline: "错误", + description: error instanceof Error ? ('[' + error.name + '] ' + (error.stack || error.message)) : error + '', + closeOnEsc: true, + closeOnOverlayClick: true, + }) + return () => { + d.open = false + } + }} deps={[]} /> +} + export default function Main() { const router = createBrowserRouter([{ path: "/", Component: Root, - hydrateFallbackElement: { - const wait = showCircleProgressDialog("请稍后...") - return () => { - wait.open = false - } - }} deps={[]} />, + hydrateFallbackElement: , + ErrorBoundary: SnackbarErrorBoundary, children: [ { path: 'info/:type', @@ -243,11 +293,10 @@ export default function Main() { } ], }, - /* { + { path: 'chat', Component: ChatFragmentDialog, - loader: UserOrChatInfoDialogLoader, - }, */ + }, ], }]) diff --git a/client/ui/ProgressDialogFallback.tsx b/client/ui/ProgressDialogFallback.tsx new file mode 100644 index 0000000..0f0336f --- /dev/null +++ b/client/ui/ProgressDialogFallback.tsx @@ -0,0 +1,11 @@ +import EffectOnly from "./EffectOnly" +import showCircleProgressDialog from "./showCircleProgressDialog" + +export default function ProgressDialogFallback({ text }: { text: string }) { + return { + const wait = showCircleProgressDialog(text) + return () => { + wait.open = false + } + }} deps={[]} /> +} diff --git a/client/ui/ProgressDialogInner.tsx b/client/ui/ProgressDialogInner.tsx deleted file mode 100644 index 2348ad2..0000000 --- a/client/ui/ProgressDialogInner.tsx +++ /dev/null @@ -1,14 +0,0 @@ -export default function ProgressDialogInner({ children, ...props }: React.HTMLAttributes) { - return
- - { children } -
-} diff --git a/client/ui/chat-fragment/ChatFragment.tsx b/client/ui/chat-fragment/ChatFragment.tsx index e69de29..7ad16b3 100644 --- a/client/ui/chat-fragment/ChatFragment.tsx +++ b/client/ui/chat-fragment/ChatFragment.tsx @@ -0,0 +1,320 @@ +import { $, Tab, TextField } from "mdui" +import useEventListener from "../../utils/useEventListener" +import useEffectRef from "../../utils/useEffectRef" +import isMobileUI from "../../utils/isMobileUI" +import { useLocation, useNavigate } from "react-router" +import { Chat } from "lingchair-client-protocol" +import gotoChatInfo from "../routers/gotoChatInfo" +import Preference from "../preference/Preference" +import PreferenceHeader from "../preference/PreferenceHeader" +import PreferenceLayout from "../preference/PreferenceLayout" +import PreferenceUpdater from "../preference/PreferenceUpdater" +import SwitchPreference from "../preference/SwitchPreference" +import TextFieldPreference from "../preference/TextFieldPreference" +import * as React from 'react' + +interface MduiTabFitSizeArgs extends React.HTMLAttributes { + value: string +} +function MduiTabFitSize({ children, ...props }: MduiTabFitSizeArgs) { + return + {children} + +} + +export default function ChatFragment({ + chatInfo, + openedWithRouter, +}: { + chatInfo: Chat + openedWithRouter: boolean +}) { + const nav = useNavigate() + + const [tabItemSelected, setTabItemSelected] = React.useState('None') + const tabRef = React.useRef() + useEventListener(tabRef, 'change', () => { + tabRef.current != null && setTabItemSelected(tabRef.current!.value as string) + }) + + const chatPanelRef = React.useRef() + const inputRef = React.useRef() + + + + return
+ ((ref) => { + $(ref.current!.shadowRoot).append(``) + $(tabRef.current!.shadowRoot).append(``) + ; (!isMobileUI()) && $(tabRef.current!.shadowRoot).append(``) + }, [])} style={{ + position: 'sticky', + display: "flex", + flexDirection: "column", + }}> + { + openedWithRouter && nav(-1)} style={{ + alignSelf: 'center', + marginLeft: '5px', + marginRight: '5px', + }}> + } + { + chatInfo.isMember() ? <> + {chatInfo.getTitle()} + {chatInfo.getType() == 'group' && chatInfo.isAdmin() && 加入请求} + {chatInfo.getType() == 'group' && 群组成员} + + : {chatInfo.getTitle()} + } + {chatInfo.getType() == 'group' && 设置} + +
+ { + window.open('/chat?id=' + chatInfo.getId(), '_blank') + + }} style={{ + alignSelf: 'center', + marginLeft: '5px', + marginRight: '5px', + }}> + { + + }} style={{ + alignSelf: 'center', + marginLeft: '5px', + marginRight: '5px', + }}> + gotoChatInfo(nav, chatInfo.getId())} style={{ + alignSelf: 'center', + marginLeft: '5px', + marginRight: '5px', + }}> +
+ +
+ {/* 非群成员 */} +
+
+ { + const scrollTop = (e.target as HTMLDivElement).scrollTop + if (scrollTop == 0) { + // 加载更多 + } + }}> +
+ {/* 这里显示一些提示 */} +
+
+ {/* 消息放在这里 */} +
+ { + // 输入框 + } +
{ + // 文件拽入 + }}> + { + if (inputRef.current?.value.trim() == '') { + // 清空缓存的文件 + } + }} onKeyDown={(event: KeyboardEvent) => { + if (event.ctrlKey && event.key == 'Enter') { + // 发送消息 + } + }} onPaste={(event: ClipboardEvent) => { + for (const item of event.clipboardData?.items || []) { + if (item.kind == 'file') { + event.preventDefault() + const file = item.getAsFile() as File + // 添加文件 + } + } + }} style={{ + marginRight: '10px', + marginTop: '3px', + marginBottom: '3px', + }}> + { + // 添加文件 + }}> + { + // 发送消息 + }}> +
+ +
+
+
+ { + chatInfo.getType() == 'group' && + {/* */} + + } + { + chatInfo.getType() == 'group' && + {/* {chatInfo.isAdmin() && } */} + + } + +
+ +
+ { + /* chatInfo.getType() == 'group' && + + + { + uploadChatAvatarRef.current!.click() + }} /> + + + + + + + { + groupPreferenceStore.state.new_member_join_method == 'answered_and_allowed_by_admin' + && + } + + */ + } + { + chatInfo.getType() == 'private' && ( +
+ 未制作 +
+ ) + } +
+ +
+ +
+
+
+} diff --git a/client/ui/chat-fragment/LazyChatFragment.tsx b/client/ui/chat-fragment/LazyChatFragment.tsx new file mode 100644 index 0000000..3d9ef74 --- /dev/null +++ b/client/ui/chat-fragment/LazyChatFragment.tsx @@ -0,0 +1,22 @@ +import { Chat } from "lingchair-client-protocol" +import { Await } from "react-router" +import getClient from "../../getClient" +import ChatFragment from "./ChatFragment" +import * as React from 'react' +import showSnackbar from "../../utils/showSnackbar" +import EffectOnly from "../EffectOnly" + +export default function LazyChatFragment({ chatId, openedWithRouter }: { chatId: string, openedWithRouter: boolean }) { + return { + const s = showSnackbar({ + message: '请稍后', + }) + return () => { + s.open = false + } + }} deps={[]} />}> + Chat.getByIdOrThrow(getClient(), chatId), [chatId])} + children={(chatInfo: Chat) => } /> + +} diff --git a/client/ui/main-page/AllChatsList.tsx b/client/ui/main-page/AllChatsList.tsx index e41a2b9..f24f4ba 100644 --- a/client/ui/main-page/AllChatsList.tsx +++ b/client/ui/main-page/AllChatsList.tsx @@ -58,7 +58,7 @@ export default function AllChatsList({ ...props }: React.HTMLAttributes { + title: string + description?: string + icon: string + disabled?: boolean +} + +export default function Preference({ title, icon, disabled, description, ...props }: Args) { + // @ts-ignore: 为什么 ...props 要说参数不兼容呢? + return + {title} + {description && {description}} + +} \ No newline at end of file diff --git a/client/ui/preference/PreferenceHeader.tsx b/client/ui/preference/PreferenceHeader.tsx new file mode 100644 index 0000000..d1e0959 --- /dev/null +++ b/client/ui/preference/PreferenceHeader.tsx @@ -0,0 +1,5 @@ +export default function PreferenceHeader({ title }: { + title: string +}) { + return {title} +} \ No newline at end of file diff --git a/client/ui/preference/PreferenceLayout.tsx b/client/ui/preference/PreferenceLayout.tsx new file mode 100644 index 0000000..4698f61 --- /dev/null +++ b/client/ui/preference/PreferenceLayout.tsx @@ -0,0 +1,8 @@ +export default function PreferenceLayout({ children, ...props }: React.HTMLAttributes) { + return + {children} + +} \ No newline at end of file diff --git a/client/ui/preference/PreferenceStore.ts b/client/ui/preference/PreferenceStore.ts new file mode 100644 index 0000000..b8353a0 --- /dev/null +++ b/client/ui/preference/PreferenceStore.ts @@ -0,0 +1,27 @@ +import React from 'react' + +export default class PreferenceStore { + declare onUpdate: (value: T, oldvalue: T) => void + declare state: T + declare setState: React.Dispatch> + constructor() { + const _ = React.useState({} as T) + this.state = _[0] + this.setState = _[1] + } + + createUpdater() { + return (key: string, value: unknown) => { + const oldvalue = this.state + const newValue = { + ...this.state, + [key]: value, + } + this.setState(newValue) + this.onUpdate?.(newValue, oldvalue) + } + } + setOnUpdate(onUpdate: (value: T, oldvalue: T) => void) { + this.onUpdate = onUpdate + } +} diff --git a/client/ui/preference/PreferenceUpdater.ts b/client/ui/preference/PreferenceUpdater.ts new file mode 100644 index 0000000..70b30c2 --- /dev/null +++ b/client/ui/preference/PreferenceUpdater.ts @@ -0,0 +1,6 @@ +import React from 'react' + +// deno-lint-ignore no-explicit-any +const PreferenceUpdater = React.createContext<(key: string, value: unknown) => void>(null as any) + +export default PreferenceUpdater diff --git a/client/ui/preference/SelectPreference.tsx b/client/ui/preference/SelectPreference.tsx new file mode 100644 index 0000000..3a5d417 --- /dev/null +++ b/client/ui/preference/SelectPreference.tsx @@ -0,0 +1,43 @@ +import React from 'react' +import { Dropdown } from 'mdui' +import PreferenceUpdater from "./PreferenceUpdater.ts" +import useEventListener from '../../utils/useEventListener.ts' + +interface Args extends React.HTMLAttributes { + title: string + icon: string + id: string + disabled?: boolean + selections: { [id: string]: string } + state: string +} + +export default function SelectPreference({ title, icon, id: preferenceId, selections, state, disabled }: Args) { + const updater = React.useContext(PreferenceUpdater) + + const dropDownRef = React.useRef(null) + const [isDropDownOpen, setDropDownOpen] = React.useState(false) + + useEventListener(dropDownRef, 'closed', () => { + setDropDownOpen(false) + }) + return setDropDownOpen(!isDropDownOpen)}> + + {title} + { + e.stopPropagation() + setDropDownOpen(false) + }}> + { + Object.keys(selections).map((id) => + // @ts-ignore: selected 确实存在, 但是并不对外公开使用 + { + updater(preferenceId, id) + }}>{selections[id]} + ) + } + + + {selections[state]} + +} \ No newline at end of file diff --git a/client/ui/preference/SwitchPreference.tsx b/client/ui/preference/SwitchPreference.tsx new file mode 100644 index 0000000..2d072d4 --- /dev/null +++ b/client/ui/preference/SwitchPreference.tsx @@ -0,0 +1,30 @@ +import { Switch } from 'mdui' +import React from 'react' +import PreferenceUpdater from "./PreferenceUpdater.ts" + +interface Args extends React.HTMLAttributes { + title: string + id: string + description?: string + icon: string + state: boolean + disabled?: boolean +} + +export default function SwitchPreference({ title, icon, id, disabled, description, state }: Args) { + const updater = React.useContext(PreferenceUpdater) + + const switchRef = React.useRef(null) + + React.useEffect(() => { + switchRef.current!.checked = state + }, [state]) + + return { + updater(id, !state) + }}> + {title} + {description && {description}} + e.preventDefault()}> + +} \ No newline at end of file diff --git a/client/ui/preference/TextFieldPreference.tsx b/client/ui/preference/TextFieldPreference.tsx new file mode 100644 index 0000000..ac8e61f --- /dev/null +++ b/client/ui/preference/TextFieldPreference.tsx @@ -0,0 +1,37 @@ +import React from 'react' +import { prompt } from 'mdui' +import PreferenceUpdater from "./PreferenceUpdater.ts" + +interface Args extends React.HTMLAttributes { + title: string + description?: string + icon: string + id: string + state: string + disabled?: boolean +} + +export default function TextFieldPreference({ title, icon, description, id, state, disabled }: Args) { + const updater = React.useContext(PreferenceUpdater) + + return { + prompt({ + headline: title, + confirmText: "确定", + cancelText: "取消", + onConfirm: (value) => { + updater(id, value) + }, + onCancel: () => { }, + textFieldOptions: { + label: description, + value: state, + }, + closeOnEsc: true, + closeOnOverlayClick: true, + }) + }}> + {title} + {description && {description}} + +} \ No newline at end of file diff --git a/client/ui/routers/ChatFragmentDialog.tsx b/client/ui/routers/ChatFragmentDialog.tsx index 5c89d91..d0bdad8 100644 --- a/client/ui/routers/ChatFragmentDialog.tsx +++ b/client/ui/routers/ChatFragmentDialog.tsx @@ -1,8 +1,27 @@ +import { useSearchParams } from "react-router" import useRouterDialogRef from "./useRouterDialogRef" import * as React from 'react' +import LazyChatFragment from "../chat-fragment/LazyChatFragment" export default function ChatFragmentDialog() { + const [searchParams] = useSearchParams() + const id = searchParams.get('id') + const dialogRef = useRouterDialogRef() - return + React.useEffect(() => { + const shadow = dialogRef.current!.shadowRoot as ShadowRoot + const panel = shadow.querySelector(".panel") as HTMLElement + panel.style.padding = '0' + panel.style.color = 'inherit' + panel.style.backgroundColor = 'rgb(var(--mdui-color-background))' + panel.style.setProperty('--mdui-color-background', 'inherit') + const body = shadow.querySelector(".body") as HTMLElement + body.style.height = '100%' + body.style.display = 'flex' + }, []) + + return + + } diff --git a/client/ui/routers/UserOrChatInfoDialog.tsx b/client/ui/routers/UserOrChatInfoDialog.tsx index 07db94d..c206127 100644 --- a/client/ui/routers/UserOrChatInfoDialog.tsx +++ b/client/ui/routers/UserOrChatInfoDialog.tsx @@ -8,14 +8,22 @@ import { useContextSelector } from "use-context-selector" import MainSharedContext, { Shared } from "../MainSharedContext" import * as React from 'react' import UserOrChatInfoDialogLoader from "./UserOrChatInfoDialogDataLoader" -import MainSharedReducer from "../MainSharedReducer" import ClientCache from "../../ClientCache" import getClient from "../../getClient" +import gotoChat from "./gotoChat" +import isMobileUI from "../../utils/isMobileUI" export default function UserOrChatInfoDialog() { - const shared = useContextSelector(MainSharedContext, (context: Shared) => ({ - state: context.state, - })) + const favouriteChats = useContextSelector( + MainSharedContext, + (context: Shared) => context.state.favouriteChats + ) + const setCurrentSelectedChatId = useContextSelector( + MainSharedContext, + (context: Shared) => context.setCurrentSelectedChatId + ) + + console.log(setCurrentSelectedChatId, favouriteChats) const nav = useNavigate() @@ -24,7 +32,7 @@ export default function UserOrChatInfoDialog() { const isMySelf = mySelf?.getId() == id - const favourited = React.useMemo(() => shared.state.favouriteChats.map((v) => v.getId()).indexOf(chat.getId() || '') != -1, [chat, shared.state.favouriteChats]) + const favourited = React.useMemo(() => favouriteChats.map((v) => v.getId()).indexOf(chat.getId() || '') != -1, [chat, favouriteChats]) return ( @@ -96,8 +104,15 @@ export default function UserOrChatInfoDialog() { ], })}>{favourited ? '取消收藏' : '收藏对话'} } - { - + { + await nav(-1) + gotoChat(isMobileUI() ? { + nav: nav, + id: chat.getId(), + } : { + setter: setCurrentSelectedChatId, + id: chat.getId(), + }) }}>打开对话 diff --git a/client/ui/routers/gotoChat.ts b/client/ui/routers/gotoChat.ts new file mode 100644 index 0000000..52ea772 --- /dev/null +++ b/client/ui/routers/gotoChat.ts @@ -0,0 +1,6 @@ +import { NavigateFunction } from "react-router" + +export default async function gotoChat({ nav, setter, id }: { nav?: NavigateFunction, setter?: (id: string) => void, id: string }) { + await nav?.('/chat?id=' + id) + setter?.(id) +} diff --git a/client/utils/useEffectRef.ts b/client/utils/useEffectRef.ts new file mode 100644 index 0000000..533b79e --- /dev/null +++ b/client/utils/useEffectRef.ts @@ -0,0 +1,11 @@ +import { Dialog } from "mdui" +import { BlockerFunction, useBlocker, useNavigate } from "react-router" +import * as React from 'react' + +export default function useEffectRef(effect: (ref: React.MutableRefObject) => void | (() => void), deps?: React.DependencyList) { + const ref = React.useRef() + React.useEffect(() => { + return effect(ref) + }, deps) + return ref +}