Compare commits

...

3 Commits

Author SHA1 Message Date
CrescentLeaf
56f651f084 这一大坨玩意我不想写说明了 2025-12-14 16:34:45 +08:00
CrescentLeaf
6a1ae692f9 资料卡标注我 2025-12-14 10:05:16 +08:00
CrescentLeaf
8fad24ecb4 修改对话框动画 2025-12-14 10:05:05 +08:00
23 changed files with 715 additions and 52 deletions

View File

@@ -16,7 +16,7 @@
</head> </head>
<body> <body>
<div id="app"></div> <div id="app" style="display: flex; width: 100%;"></div>
<script type="module" src="./init.ts"></script> <script type="module" src="./init.ts"></script>
</body> </body>

View File

@@ -24,6 +24,7 @@
"@rollup/wasm-node": "4.48.0", "@rollup/wasm-node": "4.48.0",
"@types/react": "18.3.1", "@types/react": "18.3.1",
"@types/react-dom": "18.3.1", "@types/react-dom": "18.3.1",
"@types/split.js": "^1.4.0",
"@vitejs/plugin-react": "4.7.0", "@vitejs/plugin-react": "4.7.0",
"chalk": "5.4.1", "chalk": "5.4.1",
"vite": "7.2.6", "vite": "7.2.6",

View File

@@ -3,15 +3,15 @@ import useEventListener from "../utils/useEventListener.ts"
import AvatarMySelf from "./AvatarMySelf.tsx" import AvatarMySelf from "./AvatarMySelf.tsx"
import MainSharedContext from './MainSharedContext.ts' import MainSharedContext from './MainSharedContext.ts'
import * as React from 'react' 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 LoginDialog from "./main-page/LoginDialog.tsx"
import useAsyncEffect from "../utils/useAsyncEffect.ts" import useAsyncEffect from "../utils/useAsyncEffect.ts"
import performAuth from "../performAuth.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 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 { $, dialog, 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"
@@ -25,6 +25,10 @@ import EffectOnly from "./EffectOnly.tsx"
import MainSharedReducer from "./MainSharedReducer.ts" import MainSharedReducer from "./MainSharedReducer.ts"
import gotoUserInfo from "./routers/gotoUserInfo.ts" import gotoUserInfo from "./routers/gotoUserInfo.ts"
import EditMyProfileDialog from "./routers/EditMyProfileDialog.tsx" 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() { function Root() {
const [myProfileCache, setMyProfileCache] = React.useState<UserMySelf>() const [myProfileCache, setMyProfileCache] = React.useState<UserMySelf>()
@@ -100,13 +104,27 @@ function Root() {
waitingForAuth.open = false 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 ( return (
<MainSharedContext.Provider value={sharedContext}> <MainSharedContext.Provider value={sharedContext}>
<div style={{ <div style={{
display: "flex", display: "flex",
position: 'relative', position: 'relative',
flexDirection: isMobileUI() ? 'column' : 'row', flexDirection: isMobileUI() ? 'column' : 'row',
width: `calc(var(--whitesilk-window-width))${isMobileUI() ? '' : ' - 80px'}`, width: '100%',
height: 'var(--whitesilk-window-height)', height: 'var(--whitesilk-window-height)',
}}> }}>
{ {
@@ -128,7 +146,8 @@ function Root() {
<mdui-divider style={{ <mdui-divider style={{
margin: '10px', margin: '10px',
}}></mdui-divider> }}></mdui-divider>
<mdui-list-item rounded icon="person_add"></mdui-list-item> <mdui-list-item rounded icon="settings"></mdui-list-item>
<mdui-list-item rounded icon="person_add" onClick={() => setShowAddFavourtieChatDialog(true)}></mdui-list-item>
<mdui-list-item rounded icon="group_add"></mdui-list-item> <mdui-list-item rounded icon="group_add"></mdui-list-item>
</mdui-list> </mdui-list>
<div style={{ <div style={{
@@ -187,7 +206,9 @@ function Root() {
display: 'flex', display: 'flex',
height: 'calc(100% - 80px - 67px)', height: 'calc(100% - 80px - 67px)',
width: '100%', width: '100%',
} : {}} id="SideBar"> } : {
paddingRight: '8px',
}} id="SideBar">
<RecentChatsList style={{ <RecentChatsList style={{
display: currentShowPage == 'Recents' ? undefined : 'none' display: currentShowPage == 'Recents' ? undefined : 'none'
}} /> }} />
@@ -199,6 +220,24 @@ function Root() {
}} /> }} />
</div> </div>
} }
{
<div id="ChatFragment" style={{
display: "flex",
width: '100%'
}}>
{
(state.currentSelectedChatId && state.currentSelectedChatId != '')
? <LazyChatFragment openedWithRouter={false} chatId={state.currentSelectedChatId!} />
: <div style={{
width: '100%',
textAlign: 'center',
alignSelf: 'center',
}}>
......
</div>
}
</div>
}
{ {
/** /**
* Mobile: 底部导航栏提供列表切换 * Mobile: 底部导航栏提供列表切换
@@ -218,16 +257,27 @@ function Root() {
) )
} }
function SnackbarErrorBoundary() {
const error = useRouteError()
return <EffectOnly effect={() => {
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() { export default function Main() {
const router = createBrowserRouter([{ const router = createBrowserRouter([{
path: "/", path: "/",
Component: Root, Component: Root,
hydrateFallbackElement: <EffectOnly effect={() => { hydrateFallbackElement: <ProgressDialogFallback text="请稍后..." />,
const wait = showCircleProgressDialog("请稍后...") ErrorBoundary: SnackbarErrorBoundary,
return () => {
wait.open = false
}
}} deps={[]} />,
children: [ children: [
{ {
path: 'info/:type', path: 'info/:type',
@@ -243,11 +293,10 @@ export default function Main() {
} }
], ],
}, },
/* { {
path: 'chat', path: 'chat',
Component: ChatFragmentDialog, Component: ChatFragmentDialog,
loader: UserOrChatInfoDialogLoader, },
}, */
], ],
}]) }])

View File

@@ -0,0 +1,11 @@
import EffectOnly from "./EffectOnly"
import showCircleProgressDialog from "./showCircleProgressDialog"
export default function ProgressDialogFallback({ text }: { text: string }) {
return <EffectOnly effect={() => {
const wait = showCircleProgressDialog(text)
return () => {
wait.open = false
}
}} deps={[]} />
}

View File

@@ -1,14 +0,0 @@
export default function ProgressDialogInner({ children, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div {...props} style={{
display: 'flex',
alignItems: 'center',
...props.style
}} >
<mdui-circular-progress style={{
marginLeft: '3px',
}}></mdui-circular-progress>
<span style={{
marginLeft: '20px',
}}>{ children }</span>
</div>
}

View File

@@ -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<HTMLElement & Tab> {
value: string
}
function MduiTabFitSize({ children, ...props }: MduiTabFitSizeArgs) {
return <mdui-tab {...props} style={{
...props?.style,
minWidth: 'fit-content',
}}>
{children}
</mdui-tab>
}
export default function ChatFragment({
chatInfo,
openedWithRouter,
}: {
chatInfo: Chat
openedWithRouter: boolean
}) {
const nav = useNavigate()
const [tabItemSelected, setTabItemSelected] = React.useState('None')
const tabRef = React.useRef<Tab>()
useEventListener(tabRef, 'change', () => {
tabRef.current != null && setTabItemSelected(tabRef.current!.value as string)
})
const chatPanelRef = React.useRef<HTMLElement>()
const inputRef = React.useRef<TextField>()
return <div style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
overflowY: 'auto',
}}>
<mdui-tabs ref={useEffectRef<HTMLElement>((ref) => {
$(ref.current!.shadowRoot).append(`<style>.container::after { height: 0 !important; }</style>`)
$(tabRef.current!.shadowRoot).append(`<style>.container::after { height: 0 !important; }</style>`)
; (!isMobileUI()) && $(tabRef.current!.shadowRoot).append(`<style>.no-scroll-bar::-webkit-scrollbar{width:0px !important}*::-webkit-scrollbar{width:7px;height:10px}*::-webkit-scrollbar-track{width:6px;background:rgba(#101f1c,0.1);-webkit-border-radius:2em;-moz-border-radius:2em;border-radius:2em}*::-webkit-scrollbar-thumb{background-color:rgba(144,147,153,0.5);background-clip:padding-box;min-height:28px;-webkit-border-radius:2em;-moz-border-radius:2em;border-radius:2em;transition:background-color 0.3s;cursor:pointer}*::-webkit-scrollbar-thumb:hover{background-color:rgba(144,147,153,0.3)}</style>`)
}, [])} style={{
position: 'sticky',
display: "flex",
flexDirection: "column",
}}>
{
openedWithRouter && <mdui-button-icon icon="arrow_back" onClick={() => nav(-1)} style={{
alignSelf: 'center',
marginLeft: '5px',
marginRight: '5px',
}}></mdui-button-icon>
}
<mdui-tabs ref={tabRef} value={tabItemSelected} style={{
position: 'sticky',
display: "flex",
flexDirection: "column",
height: "100%",
overflowX: 'auto',
}}></mdui-tabs>{
chatInfo.isMember() ? <>
<MduiTabFitSize value="Chat">{chatInfo.getTitle()}</MduiTabFitSize>
{chatInfo.getType() == 'group' && chatInfo.isAdmin() && <MduiTabFitSize value="NewMemberRequests"></MduiTabFitSize>}
{chatInfo.getType() == 'group' && <MduiTabFitSize value="GroupMembers"></MduiTabFitSize>}
</>
: <MduiTabFitSize value="RequestJoin">{chatInfo.getTitle()}</MduiTabFitSize>
}
{chatInfo.getType() == 'group' && <MduiTabFitSize value="Settings"></MduiTabFitSize>}
<MduiTabFitSize value="None" style={{ display: 'none' }}></MduiTabFitSize>
<div style={{
flexGrow: '1',
}}></div>
<mdui-button-icon icon="open_in_new" onClick={() => {
window.open('/chat?id=' + chatInfo.getId(), '_blank')
}} style={{
alignSelf: 'center',
marginLeft: '5px',
marginRight: '5px',
}}></mdui-button-icon>
<mdui-button-icon icon="refresh" onClick={() => {
}} style={{
alignSelf: 'center',
marginLeft: '5px',
marginRight: '5px',
}}></mdui-button-icon>
<mdui-button-icon icon="info" onClick={() => gotoChatInfo(nav, chatInfo.getId())} style={{
alignSelf: 'center',
marginLeft: '5px',
marginRight: '5px',
}}></mdui-button-icon>
</mdui-tabs>
<mdui-tab-panel slot="panel" value="RequestJoin" style={{
display: tabItemSelected == "RequestJoin" ? "flex" : "none",
flexDirection: "column",
height: "100%",
justifyContent: 'center',
alignItems: 'center',
}}>
<div>
{/* 非群成员 */}
</div>
</mdui-tab-panel>
<mdui-tab-panel slot="panel" value="Chat" ref={chatPanelRef} style={{
display: tabItemSelected == "Chat" ? "flex" : "none",
flexDirection: "column",
height: "100%",
}} onScroll={async (e: WheelEvent) => {
const scrollTop = (e.target as HTMLDivElement).scrollTop
if (scrollTop == 0) {
// 加载更多
}
}}>
<div style={{
display: 'flex',
justifyContent: "center",
paddingTop: "15px",
}}>
{/* 这里显示一些提示 */}
</div>
<div style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-end',
alignItems: 'center',
marginBottom: '20px',
paddingTop: "15px",
flexGrow: '1',
}}>
{/* 消息放在这里 */}
</div>
{
// 输入框
}
<div style={{
display: 'flex',
alignItems: 'center',
paddingBottom: '2px',
paddingTop: '0.1rem',
position: 'sticky',
bottom: '0',
paddingLeft: '5px',
paddingRight: '4px',
backgroundColor: 'rgb(var(--mdui-color-surface))',
}} onDrop={(e) => {
// 文件拽入
}}>
<mdui-text-field variant="outlined" placeholder="(。・ω・。)" autosize ref={inputRef} max-rows={6} onChange={() => {
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',
}}></mdui-text-field>
<mdui-button-icon slot="end-icon" icon="attach_file" style={{
marginRight: '6px',
}} onClick={() => {
// 添加文件
}}></mdui-button-icon>
<mdui-button-icon icon="send" style={{
marginRight: '7px',
}} onClick={() => {
// 发送消息
}}></mdui-button-icon>
<div style={{
display: 'none'
}}>
<input accept="*/*" type="file" name="添加文件" multiple ></input>
</div>
</div>
</mdui-tab-panel>
{
chatInfo.getType() == 'group' && <mdui-tab-panel slot="panel" value="GroupMembers" style={{
display: tabItemSelected == "GroupMembers" ? "flex" : "none",
flexDirection: "column",
height: "100%",
}}>
{/* <GroupMembersList chat={chatInfo} /> */}
</mdui-tab-panel>
}
{
chatInfo.getType() == 'group' && <mdui-tab-panel slot="panel" value="NewMemberRequests" style={{
display: tabItemSelected == "NewMemberRequests" ? "flex" : "none",
flexDirection: "column",
height: "100%",
}}>
{/* {chatInfo.isAdmin() && <JoinRequestsList chat={chatInfo} />} */}
</mdui-tab-panel>
}
<mdui-tab-panel slot="panel" value="Settings" style={{
display: tabItemSelected == "Settings" ? "flex" : "none",
flexDirection: "column",
height: "100%",
}}>
<div style={{
display: 'none'
}}>
<input accept="image/*" type="file" name="上传对话头像"></input>
</div>
{
/* chatInfo.getType() == 'group' && <PreferenceLayout>
<PreferenceUpdater.Provider value={groupPreferenceStore.createUpdater()}>
<PreferenceHeader
title="群组资料" />
<Preference
title="上传新的头像"
icon="image"
disabled={!chatInfo.isAdmin()}
onClick={() => {
uploadChatAvatarRef.current!.click()
}} />
<TextFieldPreference
title="设置群名称"
icon="edit"
id="group_title"
state={groupPreferenceStore.state.group_title || ''}
disabled={!chatInfo.isAdmin()} />
<TextFieldPreference
title="设置群别名"
icon="edit"
id="group_name"
description="以便于添加, 可留空"
state={groupPreferenceStore.state.group_name || ''}
disabled={!chatInfo.isAdmin()} />
<PreferenceHeader
title="入群设定" />
<SwitchPreference
title="允许入群"
icon="person_add"
id="allow_new_member_join"
disabled={!chatInfo.isAdmin()}
state={groupPreferenceStore.state.allow_new_member_join || false} />
<SwitchPreference
title="允许成员邀请"
description="目前压根没有这项功能, 甚至还不能查看成员列表, 以后再说吧"
id="allow_new_member_from_invitation"
icon="_"
disabled={true || !chatInfo.isAdmin()}
state={groupPreferenceStore.state.allow_new_member_from_invitation || false} />
<SelectPreference
title="入群验证方式"
icon="_"
id="new_member_join_method"
selections={{
disabled: "无需验证",
allowed_by_admin: "只需要管理员批准 (WIP)",
answered_and_allowed_by_admin: "需要回答问题并获得管理员批准 (WIP)",
}}
disabled={!chatInfo.isAdmin() || !groupPreferenceStore.state.allow_new_member_join}
state={groupPreferenceStore.state.new_member_join_method || 'disabled'} />
{
groupPreferenceStore.state.new_member_join_method == 'answered_and_allowed_by_admin'
&& <TextFieldPreference
title="设置问题"
icon="_"
id="answered_and_allowed_by_admin_question"
description="WIP"
state={groupPreferenceStore.state.answered_and_allowed_by_admin_question || ''}
disabled={true || !chatInfo.isAdmin()} />
}
</PreferenceUpdater.Provider>
</PreferenceLayout> */
}
{
chatInfo.getType() == 'private' && (
<div>
</div>
)
}
</mdui-tab-panel>
<mdui-tab-panel slot="panel" value="None" style={{
display: tabItemSelected == "None" ? "flex" : "none",
flexDirection: "column",
height: "100%",
}}>
<div style={{
display: 'flex',
width: '100%',
height: '100%',
alignItems: "center",
justifyContent: "center",
}}>
<mdui-circular-progress></mdui-circular-progress>
</div>
</mdui-tab-panel>
</div >
}

View File

@@ -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 <React.Suspense fallback={<EffectOnly effect={() => {
const s = showSnackbar({
message: '请稍后',
})
return () => {
s.open = false
}
}} deps={[]} />}>
<Await
resolve={React.useMemo(() => Chat.getByIdOrThrow(getClient(), chatId), [chatId])}
children={(chatInfo: Chat) => <ChatFragment chatInfo={chatInfo} openedWithRouter={openedWithRouter} />} />
</React.Suspense>
}

View File

@@ -58,7 +58,7 @@ export default function AllChatsList({ ...props }: React.HTMLAttributes<HTMLElem
...props?.style, ...props?.style,
}} {...props}> }} {...props}>
<mdui-text-field icon="search" type="search" clearable ref={searchRef} variant="outlined" placeholder="搜索..." style={{ <mdui-text-field icon="search" type="search" clearable ref={searchRef} variant="outlined" placeholder="搜索..." style={{
paddingTop: '12px', paddingTop: '4px',
paddingBottom: '13px', paddingBottom: '13px',
position: 'sticky', position: 'sticky',
top: '0', top: '0',

View File

@@ -69,7 +69,7 @@ export default function FavouriteChatsList({ ...props }: React.HTMLAttributes<HT
zIndex: '10', zIndex: '10',
}}> }}>
<mdui-text-field icon="search" type="search" clearable ref={searchRef} variant="outlined" placeholder="搜索..." style={{ <mdui-text-field icon="search" type="search" clearable ref={searchRef} variant="outlined" placeholder="搜索..." style={{
paddingTop: '12px', paddingTop: '4px',
}}></mdui-text-field> }}></mdui-text-field>
<mdui-list-item rounded style={{ <mdui-list-item rounded style={{
marginTop: '13px', marginTop: '13px',

View File

@@ -58,7 +58,7 @@ export default function RecentChatsList({ ...props }: React.HTMLAttributes<HTMLE
...props?.style, ...props?.style,
}} {...props}> }} {...props}>
<mdui-text-field icon="search" type="search" clearable ref={searchRef} variant="outlined" placeholder="搜索..." style={{ <mdui-text-field icon="search" type="search" clearable ref={searchRef} variant="outlined" placeholder="搜索..." style={{
paddingTop: '12px', paddingTop: '4px',
marginBottom: '13px', marginBottom: '13px',
position: 'sticky', position: 'sticky',
top: '0', top: '0',

View File

@@ -0,0 +1,16 @@
import { ListItem } from "mdui"
interface Args extends React.HTMLAttributes<ListItem> {
title: string
description?: string
icon: string
disabled?: boolean
}
export default function Preference({ title, icon, disabled, description, ...props }: Args) {
// @ts-ignore: 为什么 ...props 要说参数不兼容呢?
return <mdui-list-item disabled={disabled ? true : undefined} rounded icon={icon} {...props}>
{title}
{description && <span slot="description">{description}</span>}
</mdui-list-item>
}

View File

@@ -0,0 +1,5 @@
export default function PreferenceHeader({ title }: {
title: string
}) {
return <mdui-list-subheader>{title}</mdui-list-subheader>
}

View File

@@ -0,0 +1,8 @@
export default function PreferenceLayout({ children, ...props }: React.HTMLAttributes<HTMLElement>) {
return <mdui-list style={{
marginLeft: '15px',
marginRight: '15px',
}} {...props}>
{children}
</mdui-list>
}

View File

@@ -0,0 +1,27 @@
import React from 'react'
export default class PreferenceStore<T extends object> {
declare onUpdate: (value: T, oldvalue: T) => void
declare state: T
declare setState: React.Dispatch<React.SetStateAction<T>>
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
}
}

View File

@@ -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

View File

@@ -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<HTMLElement> {
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<Dropdown>(null)
const [isDropDownOpen, setDropDownOpen] = React.useState(false)
useEventListener(dropDownRef, 'closed', () => {
setDropDownOpen(false)
})
return <mdui-list-item icon={icon} rounded disabled={disabled ? true : undefined} onClick={() => setDropDownOpen(!isDropDownOpen)}>
<mdui-dropdown ref={dropDownRef} trigger="manual" open={isDropDownOpen}>
<span slot="trigger">{title}</span>
<mdui-menu onClick={(e: MouseEvent) => {
e.stopPropagation()
setDropDownOpen(false)
}}>
{
Object.keys(selections).map((id) =>
// @ts-ignore: selected 确实存在, 但是并不对外公开使用
<mdui-menu-item key={id} selected={state == id ? true : undefined} onClick={() => {
updater(preferenceId, id)
}}>{selections[id]}</mdui-menu-item>
)
}
</mdui-menu>
</mdui-dropdown>
<span slot="description">{selections[state]}</span>
</mdui-list-item>
}

View File

@@ -0,0 +1,30 @@
import { Switch } from 'mdui'
import React from 'react'
import PreferenceUpdater from "./PreferenceUpdater.ts"
interface Args extends React.HTMLAttributes<HTMLElement> {
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<Switch>(null)
React.useEffect(() => {
switchRef.current!.checked = state
}, [state])
return <mdui-list-item disabled={disabled ? true : undefined} rounded icon={icon} onClick={() => {
updater(id, !state)
}}>
{title}
{description && <span slot="description">{description}</span>}
<mdui-switch slot="end-icon" checked-icon="" ref={switchRef} onClick={(e) => e.preventDefault()}></mdui-switch>
</mdui-list-item>
}

View File

@@ -0,0 +1,37 @@
import React from 'react'
import { prompt } from 'mdui'
import PreferenceUpdater from "./PreferenceUpdater.ts"
interface Args extends React.HTMLAttributes<HTMLElement> {
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 <mdui-list-item icon={icon} rounded disabled={disabled ? true : undefined} onClick={() => {
prompt({
headline: title,
confirmText: "确定",
cancelText: "取消",
onConfirm: (value) => {
updater(id, value)
},
onCancel: () => { },
textFieldOptions: {
label: description,
value: state,
},
closeOnEsc: true,
closeOnOverlayClick: true,
})
}}>
{title}
{description && <span slot="description">{description}</span>}
</mdui-list-item>
}

View File

@@ -1,8 +1,27 @@
import { useSearchParams } from "react-router"
import useRouterDialogRef from "./useRouterDialogRef" import useRouterDialogRef from "./useRouterDialogRef"
import * as React from 'react' import * as React from 'react'
import LazyChatFragment from "../chat-fragment/LazyChatFragment"
export default function ChatFragmentDialog() { export default function ChatFragmentDialog() {
const [searchParams] = useSearchParams()
const id = searchParams.get('id')
const dialogRef = useRouterDialogRef() const dialogRef = useRouterDialogRef()
return <mdui-dialog fullscreen ref={dialogRef}></mdui-dialog> 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 <mdui-dialog fullscreen ref={dialogRef}>
<LazyChatFragment chatId={id!} openedWithRouter={true} />
</mdui-dialog>
} }

View File

@@ -8,14 +8,22 @@ import { useContextSelector } from "use-context-selector"
import MainSharedContext, { Shared } from "../MainSharedContext" import MainSharedContext, { Shared } from "../MainSharedContext"
import * as React from 'react' import * as React from 'react'
import UserOrChatInfoDialogLoader from "./UserOrChatInfoDialogDataLoader" import UserOrChatInfoDialogLoader from "./UserOrChatInfoDialogDataLoader"
import MainSharedReducer from "../MainSharedReducer"
import ClientCache from "../../ClientCache" import ClientCache from "../../ClientCache"
import getClient from "../../getClient" import getClient from "../../getClient"
import gotoChat from "./gotoChat"
import isMobileUI from "../../utils/isMobileUI"
export default function UserOrChatInfoDialog() { export default function UserOrChatInfoDialog() {
const shared = useContextSelector(MainSharedContext, (context: Shared) => ({ const favouriteChats = useContextSelector(
state: context.state, MainSharedContext,
})) (context: Shared) => context.state.favouriteChats
)
const setCurrentSelectedChatId = useContextSelector(
MainSharedContext,
(context: Shared) => context.setCurrentSelectedChatId
)
console.log(setCurrentSelectedChatId, favouriteChats)
const nav = useNavigate() const nav = useNavigate()
@@ -24,7 +32,7 @@ export default function UserOrChatInfoDialog() {
const isMySelf = mySelf?.getId() == id 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 ( return (
<mdui-dialog close-on-overlay-click close-on-esc ref={dialogRef}> <mdui-dialog close-on-overlay-click close-on-esc ref={dialogRef}>
@@ -46,7 +54,7 @@ export default function UserOrChatInfoDialog() {
}}> }}>
<span style={{ <span style={{
fontSize: '16.5px' fontSize: '16.5px'
}}>{chat.getTitle()}</span> }}>{chat.getTitle() + (isMySelf ? ' (我)' : '')}</span>
<span style={{ <span style={{
fontSize: '10.5px', fontSize: '10.5px',
marginTop: '3px', marginTop: '3px',
@@ -96,8 +104,15 @@ export default function UserOrChatInfoDialog() {
], ],
})}>{favourited ? '取消收藏' : '收藏对话'}</mdui-list-item> })}>{favourited ? '取消收藏' : '收藏对话'}</mdui-list-item>
} }
<mdui-list-item icon="chat" rounded onClick={() => { <mdui-list-item icon="chat" rounded onClick={async () => {
await nav(-1)
gotoChat(isMobileUI() ? {
nav: nav,
id: chat.getId(),
} : {
setter: setCurrentSelectedChatId,
id: chat.getId(),
})
}}></mdui-list-item> }}></mdui-list-item>
</mdui-list> </mdui-list>
</mdui-dialog> </mdui-dialog>

View File

@@ -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)
}

View File

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

View File

@@ -103,6 +103,7 @@ let Dialog = class Dialog extends MduiElement {
]); ]);
// 打开 // 打开
// 要区分是否首次渲染,首次渲染不触发事件,不执行动画;非首次渲染,触发事件,执行动画 // 要区分是否首次渲染,首次渲染不触发事件,不执行动画;非首次渲染,触发事件,执行动画
this.panelRef.value.style.transformOrigin = 'center center';
if (this.open) { if (this.open) {
if (hasUpdated) { if (hasUpdated) {
const eventProceeded = this.emit('open', { cancelable: true }); const eventProceeded = this.emit('open', { cancelable: true });
@@ -136,8 +137,34 @@ let Dialog = class Dialog extends MduiElement {
this.panelRef.value.focus({ preventScroll: true }); this.panelRef.value.focus({ preventScroll: true });
} }
}); });
const duration = getDuration(this, 'medium4'); // const duration = getDuration(this, 'medium4');
const duration = hasUpdated ? getDuration(this, 'medium4') : 0;
await Promise.all([ await Promise.all([
// 遮罩层淡入
animateTo(this.overlayRef.value, [
{ opacity: 0 },
{ opacity: 1 }
], {
duration: hasUpdated ? duration * 0.7 : 0,
easing: easingLinear,
}),
// 面板缩放淡入
animateTo(this.panelRef.value, [
{
opacity: 0,
transform: 'scale(0.8)',
},
{
opacity: 1,
transform: 'scale(1)'
}
], {
duration: hasUpdated ? duration : 0,
easing: easingEmphasizedDecelerate,
}),
]);
/* await Promise.all([
animateTo(this.overlayRef.value, [{ opacity: 0 }, { opacity: 1, offset: 0.3 }, { opacity: 1 }], { animateTo(this.overlayRef.value, [{ opacity: 0 }, { opacity: 1, offset: 0.3 }, { opacity: 1 }], {
duration: hasUpdated ? duration : 0, duration: hasUpdated ? duration : 0,
easing: easingLinear, easing: easingLinear,
@@ -162,7 +189,7 @@ let Dialog = class Dialog extends MduiElement {
duration: hasUpdated ? duration : 0, duration: hasUpdated ? duration : 0,
easing: easingLinear, easing: easingLinear,
})), })),
]); ]); */
if (hasUpdated) { if (hasUpdated) {
this.emit('opened'); this.emit('opened');
} }
@@ -174,8 +201,32 @@ let Dialog = class Dialog extends MduiElement {
} }
this.modalHelper.deactivate(); this.modalHelper.deactivate();
await stopAnimation(); await stopAnimation();
const duration = getDuration(this, 'short4'); // const duration = getDuration(this, 'short4');
const duration = getDuration(this, 'short3');
await Promise.all([ await Promise.all([
// 遮罩层淡出
animateTo(this.overlayRef.value, [
{ opacity: 1 },
{ opacity: 0 }
], {
duration,
easing: easingLinear,
}),
// 面板缩放淡出
animateTo(this.panelRef.value, [
{
opacity: 1,
},
{
opacity: 0,
}
], {
duration,
easing: easingEmphasizedAccelerate,
}),
]);
/* await Promise.all([
animateTo(this.overlayRef.value, [{ opacity: 1 }, { opacity: 0 }], { animateTo(this.overlayRef.value, [{ opacity: 1 }, { opacity: 0 }], {
duration, duration,
easing: easingLinear, easing: easingLinear,
@@ -186,7 +237,7 @@ let Dialog = class Dialog extends MduiElement {
], { duration, easing: easingEmphasizedAccelerate }), ], { duration, easing: easingEmphasizedAccelerate }),
animateTo(this.panelRef.value, [{ opacity: 1 }, { opacity: 1, offset: 0.75 }, { opacity: 0 }], { duration, easing: easingLinear }), animateTo(this.panelRef.value, [{ opacity: 1 }, { opacity: 1, offset: 0.75 }, { opacity: 0 }], { duration, easing: easingLinear }),
...children.map((child) => animateTo(child, [{ opacity: 1 }, { opacity: 0, offset: 0.75 }, { opacity: 0 }], { duration, easing: easingLinear })), ...children.map((child) => animateTo(child, [{ opacity: 1 }, { opacity: 0, offset: 0.75 }, { opacity: 0 }], { duration, easing: easingLinear })),
]); ]); */
this.style.display = 'none'; this.style.display = 'none';
unlockScreen(this); unlockScreen(this);
// 对话框关闭后,恢复焦点到原有的元素上 // 对话框关闭后,恢复焦点到原有的元素上
@@ -222,11 +273,11 @@ let Dialog = class Dialog extends MduiElement {
const hasHeader = hasIcon || hasHeadline || this.hasSlotController.test('header'); const hasHeader = hasIcon || hasHeadline || this.hasSlotController.test('header');
const hasBody = hasDescription || hasDefaultSlot; const hasBody = hasDescription || hasDefaultSlot;
// modify: 移除了 tabindex="0", 换为 tabindex // modify: 移除了 tabindex="0", 换为 tabindex
return html `<div ${ref(this.overlayRef)} part="overlay" class="overlay" @click="${this.onOverlayClick}" tabindex="-1"></div><div ${ref(this.panelRef)} part="panel" class="panel ${classMap({ return html`<div ${ref(this.overlayRef)} part="overlay" class="overlay" @click="${this.onOverlayClick}" tabindex="-1"></div><div ${ref(this.panelRef)} part="panel" class="panel ${classMap({
'has-icon': hasIcon, 'has-icon': hasIcon,
'has-description': hasDescription, 'has-description': hasDescription,
'has-default': hasDefaultSlot, 'has-default': hasDefaultSlot,
})}" tabindex>${when(hasHeader, () => html `<slot name="header" part="header" class="header">${when(hasIcon, () => this.renderIcon())} ${when(hasHeadline, () => this.renderHeadline())}</slot>`)} ${when(hasBody, () => html `<div ${ref(this.bodyRef)} part="body" class="body">${when(hasDescription, () => this.renderDescription())}<slot></slot></div>`)} ${when(hasActionSlot, () => html `<slot name="action" part="action" class="action"></slot>`)}</div>`; })}" tabindex>${when(hasHeader, () => html`<slot name="header" part="header" class="header">${when(hasIcon, () => this.renderIcon())} ${when(hasHeadline, () => this.renderHeadline())}</slot>`)} ${when(hasBody, () => html`<div ${ref(this.bodyRef)} part="body" class="body">${when(hasDescription, () => this.renderDescription())}<slot></slot></div>`)} ${when(hasActionSlot, () => html`<slot name="action" part="action" class="action"></slot>`)}</div>`;
} }
onOverlayClick() { onOverlayClick() {
this.emit('overlay-click'); this.emit('overlay-click');
@@ -236,15 +287,15 @@ let Dialog = class Dialog extends MduiElement {
this.open = false; this.open = false;
} }
renderIcon() { renderIcon() {
return html `<slot name="icon" part="icon" class="icon">${this.icon return html`<slot name="icon" part="icon" class="icon">${this.icon
? html `<mdui-icon name="${this.icon}"></mdui-icon>` ? html`<mdui-icon name="${this.icon}"></mdui-icon>`
: nothingTemplate}</slot>`; : nothingTemplate}</slot>`;
} }
renderHeadline() { renderHeadline() {
return html `<slot name="headline" part="headline" class="headline">${this.headline}</slot>`; return html`<slot name="headline" part="headline" class="headline">${this.headline}</slot>`;
} }
renderDescription() { renderDescription() {
return html `<slot name="description" part="description" class="description">${this.description}</slot>`; return html`<slot name="description" part="description" class="description">${this.description}</slot>`;
} }
}; };
Dialog.styles = [componentStyle, style]; Dialog.styles = [componentStyle, style];