Compare commits

...

8 Commits

Author SHA1 Message Date
CrescentLeaf
38c28c3fb6 我累了 2025-10-08 02:52:02 +08:00
CrescentLeaf
0df1149618 FEAT(灵车 WIP): CHAT SETTINGS 2025-10-08 02:51:58 +08:00
CrescentLeaf
aeafcb5b97 feat(WIP): 对话管理员 2025-10-08 02:51:25 +08:00
CrescentLeaf
324962b0fc refactor: 配置存储类泛型化 2025-10-08 02:50:58 +08:00
CrescentLeaf
f5f3774daf chore: 移除了 babel 编译流程
* 加快调试速度
* 旧版本浏览器本来也没办法支持了...
2025-10-08 02:50:31 +08:00
CrescentLeaf
e666dc573a chore: 移除分号 2025-10-08 01:12:05 +08:00
CrescentLeaf
11362a5689 chore: make lint happy 2025-10-08 00:55:09 +08:00
CrescentLeaf
7c7e641d1f fix: 遗漏的 return 2025-10-08 00:54:46 +08:00
19 changed files with 208 additions and 40 deletions

View File

@@ -21,6 +21,7 @@ const _data_cached = JSON.parse(_dec)
declare global {
interface Window {
data: {
refresh_token?: string
split_sizes: number[]
apply(): void
access_token?: string
@@ -29,6 +30,7 @@ declare global {
}
}
// @ts-ignore: 忽略...
// deno-lint-ignore no-window
(window.data == null) && (window.data = new Proxy({
apply() {}

View File

@@ -2,6 +2,7 @@ export type CallMethod =
"User.auth" |
"User.register" |
"User.login" |
"User.refreshAccessToken" |
"User.setAvatar" |
"User.updateProfile" |
@@ -17,6 +18,10 @@ export type CallMethod =
"Chat.getInfo" |
"Chat.updateSettings" |
"Chat.createGroup" |
"Chat.getIdForPrivate" |
"Chat.getAnotherUserIdFromPrivate" |

View File

@@ -26,7 +26,7 @@ class Client {
})
this.socket!.on("connect", async () => {
this.connected = true
const re = await this.auth(data.access_token)
const re = await this.auth(data.access_token as string)
if (re.code != 200)
checkApiSuccessOrSncakbar(re, "重连失败")
})
@@ -42,7 +42,7 @@ class Client {
}
})
}
static invoke(method: CallMethod, args: unknown = {}, timeout: number = 5000, refreshAndRetryLimit: number = 3, forceRefreshAndRetry: boolean = false): Promise<ApiCallbackMessage> {
static invoke(method: CallMethod, args: object = {}, timeout: number = 5000, refreshAndRetryLimit: number = 3, forceRefreshAndRetry: boolean = false): Promise<ApiCallbackMessage> {
// 在 未初始化 / 未建立连接且调用非可调用接口 的时候进行延迟
if (this.socket == null || (!this.connected && !CallableMethodBeforeAuth.includes(method))) {
return new Promise((reslove) => {
@@ -87,7 +87,7 @@ class Client {
const re = await this.invoke("User.refreshAccessToken", {
refresh_token: data.refresh_token
})
return re.data?.access_token
return re.data?.access_token as string
}
static async auth(token: string, timeout: number = 5000) {
const re = await this.invoke("User.auth", {

View File

@@ -5,6 +5,7 @@ export default class Chat {
declare id: string
declare title: string
declare avatar?: string
declare settings?: { [key: string]: unknown }
[key: string]: unknown
}

View File

@@ -0,0 +1,10 @@
interface GroupSettings {
allow_new_member_join?: boolean
allow_new_member_from_invitation?: boolean
new_member_join_method?: 'disabled' | 'allowed_by_admin' | 'answered_and_allowed_by_admin'
answered_and_allowed_by_admin_question?: string
[key: string]: unknown
}
export default GroupSettings

View File

@@ -19,8 +19,6 @@
"@types/react": "npm:@types/react@18.3.1",
"@types/react-dom": "npm:@types/react-dom@18.3.1",
"@vitejs/plugin-react": "npm:@vitejs/plugin-react@4.7.0",
"vite-plugin-babel": "npm:vite-plugin-babel@1.3.2",
"@babel/preset-env": "npm:@babel/preset-env@7.28.3",
"react": "npm:react@18.3.1",
"react-dom": "npm:react-dom@18.3.1",
"vite": "npm:vite@7.0.6",

View File

@@ -24,6 +24,7 @@ import SwitchPreference from '../preference/SwitchPreference.tsx'
import SelectPreference from '../preference/SelectPreference.tsx'
import TextFieldPreference from '../preference/TextFieldPreference.tsx'
import Preference from '../preference/Preference.tsx'
import GroupSettings from "../../api/client_data/GroupSettings.ts"
interface Args extends React.HTMLAttributes<HTMLElement> {
target: string
@@ -64,6 +65,7 @@ export default function ChatFragment({ target, showReturnButton, onReturnButtonC
} as Chat)
const [tabItemSelected, setTabItemSelected] = React.useState('None')
const [groupPreferenceDefaultValue, setGroupPreferenceDefaultValue] = React.useState<GroupSettings>({})
const tabRef = React.useRef<Tab>(null)
const chatPanelRef = React.useRef<HTMLElement>(null)
useEventListener(tabRef, 'change', () => {
@@ -82,6 +84,11 @@ export default function ChatFragment({ target, showReturnButton, onReturnButtonC
await loadMore()
setTabItemSelected("Chat")
if (re.data!.type == 'group') {
setGroupPreferenceDefaultValue(re.data!.settings as GroupSettings)
groupPreferenceStore.setter(re.data!.settings as GroupSettings)
console.log(re.data!.settings as GroupSettings)
}
setTimeout(() => {
chatPanelRef.current!.scrollTo({
top: 10000000000,
@@ -210,9 +217,22 @@ export default function ChatFragment({ target, showReturnButton, onReturnButtonC
addFile(file.type, file.name, file)
}
})
const groupPreferenceStore = new PreferenceStore()
const groupPreferenceStore = new PreferenceStore<GroupSettings>()
groupPreferenceStore.setOnUpdate(async (value) => {
const re = await Client.invoke("Chat.updateSettings", {
token: data.access_token,
target,
settings: value,
})
if (checkApiSuccessOrSncakbar(re, "更新设定失败")) return
setChatInfo(JSON.parse(JSON.stringify({
...chatInfo,
settings: value as object,
})))
})
return (
<div style={{
width: '100%',
@@ -448,14 +468,14 @@ export default function ChatFragment({ target, showReturnButton, onReturnButtonC
<SwitchPreference
title="允许入群"
icon="person_add"
defaultState={false}
defaultState={groupPreferenceDefaultValue.allow_new_member_join || false}
updater={groupPreferenceStore.updater('allow_new_member_join')} />
<SwitchPreference
title="允许成员邀请"
description="目前压根没有这项功能, 甚至还不能查看成员列表, 以后再说吧"
icon="_"
disabled={true}
defaultState={false}
defaultState={groupPreferenceDefaultValue.allow_new_member_from_invitation || false}
updater={groupPreferenceStore.updater('allow_new_member_from_invitation')} />
<SelectPreference
title="入群验证方式"
@@ -467,14 +487,14 @@ export default function ChatFragment({ target, showReturnButton, onReturnButtonC
}}
disabled={!groupPreferenceStore.value.allow_new_member_join}
updater={groupPreferenceStore.updater('new_member_join_method')}
defaultCheckedId="disabled" />
defaultCheckedId={groupPreferenceDefaultValue.new_member_join_method || 'disabled'} />
{
groupPreferenceStore.value.new_member_join_method == 'answered_and_allowed_by_admin'
&& <TextFieldPreference
title="设置问题"
icon="_"
description="WIP"
defaultState=""
defaultState={groupPreferenceDefaultValue.answered_and_allowed_by_admin_question || ''}
disabled={true}
updater={groupPreferenceStore.updater('answered_and_allowed_by_admin_question')} />
}

View File

@@ -1,11 +1,11 @@
import React from 'react'
export default class PreferenceStore {
declare value: { [key: string]: unknown }
declare setter: React.Dispatch<React.SetStateAction<{ [key: string]: unknown }>>
export default class PreferenceStore<T extends object> {
declare value: T
declare setter: React.Dispatch<React.SetStateAction<T>>
declare onUpdate: (value: unknown) => void
constructor() {
const _ = React.useState<{ [key: string]: unknown }>({})
const _ = React.useState<T>({} as T)
this.value = _[0]
this.setter = _[1]
}

View File

@@ -1,27 +1,12 @@
import { defineConfig } from 'vite'
import deno from '@deno/vite-plugin'
import react from '@vitejs/plugin-react'
import pluginBabel from 'vite-plugin-babel'
import config from '../server/config.ts'
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), pluginBabel({
babelConfig: {
presets: [
[
'@babel/preset-env',
{
targets: {
android: '70'
},
}
],
],
}
}), deno()],
plugins: [react(), deno()],
build: {
cssTarget: 'chrome70',
sourcemap: true,
outDir: "." + config.data_path + '/page_compiled',
},

View File

@@ -2,6 +2,7 @@ export type CallMethod =
"User.auth" |
"User.register" |
"User.login" |
"User.refreshAccessToken" |
"User.setAvatar" |
"User.updateProfile" |
@@ -16,6 +17,10 @@ export type CallMethod =
"User.getMyRecentChats" |
"Chat.getInfo" |
"Chat.updateSettings" |
"Chat.createGroup" |
"Chat.getIdForPrivate" |
"Chat.getAnotherUserIdFromPrivate" |

View File

@@ -9,6 +9,7 @@ import BaseApi from "./BaseApi.ts"
import TokenManager from "./TokenManager.ts"
import ChatPrivate from "../data/ChatPrivate.ts"
import ChatGroup from "../data/ChatGroup.ts"
import GroupSettingsBean from "../data/GroupSettingsBean.ts"
export default class ChatApi extends BaseApi {
override getName(): string {
@@ -53,7 +54,8 @@ export default class ChatApi extends BaseApi {
id: args.target as string,
type: chat.bean.type,
title: chat.getTitle(mine),
avatar: chat.getAvatarFileHash(mine) ? "uploaded_files/" + chat.getAvatarFileHash(mine) : undefined
avatar: chat.getAvatarFileHash(mine) ? "uploaded_files/" + chat.getAvatarFileHash(mine) : undefined,
settings: JSON.parse(chat.bean.settings),
}
}
}
@@ -65,7 +67,8 @@ export default class ChatApi extends BaseApi {
id: args.target as string,
type: chat.bean.type,
title: chat.getTitle(),
avatar: chat.getAvatarFileHash() ? "uploaded_files/" + chat.getAvatarFileHash() : undefined
avatar: chat.getAvatarFileHash() ? "uploaded_files/" + chat.getAvatarFileHash() : undefined,
settings: JSON.parse(chat.bean.settings),
}
}
}
@@ -267,7 +270,10 @@ export default class ChatApi extends BaseApi {
const user = User.findById(token.author) as User
const haveId = args.id && (args.id as string) != ''
if (haveId && Chat.findById(args.id as string) != null) return
if (haveId && Chat.findById(args.id as string) != null) return {
msg: "对话 ID 已被占用",
code: 403,
}
const chat = ChatGroup.createGroup(haveId ? undefined : args.id as string)
chat.setTitle(args.title as string)
@@ -284,6 +290,39 @@ export default class ChatApi extends BaseApi {
}
}
})
/**
* 更新设定
* @param token 令牌
* @param title 名称
* @param [id] 群组 ID
*/
this.registerEvent("Chat.updateSettings", (args, { deviceId }) => {
if (this.checkArgsMissing(args, ['token', 'target', 'settings'])) return {
msg: "参数缺失",
code: 400,
}
const token = TokenManager.decode(args.token as string)
if (!this.checkToken(token, deviceId)) return {
code: 401,
msg: "令牌无效",
}
const user = User.findById(token.author) as User
const chat = Chat.findById(args.target as string)
if (chat == null) return {
code: 404,
msg: "对话不存在",
}
if (chat.bean.type == 'group')
ChatGroup.fromChat(chat).getSettings().update(args.settings as GroupSettingsBean)
return {
code: 200,
msg: '成功',
}
})
/**
* 从私聊获取对方的 UserId
* @param token 令牌

View File

@@ -32,15 +32,15 @@ export default class TokenManager {
}
}
static make(user: User, time_: number | null | undefined, device_id: string, type: TokenType = "access_token") {
static make(user: User, time_: number | null | undefined, device_id: string, tokenType: TokenType = "access_token") {
const time = (time_ || Date.now())
return this.encode({
author: user.bean.id,
auth: this.makeAuth(user),
made_time: time,
expired_time: time + (type == 'access_token' ? (1000 * 60 * 60 * 2) : (40 * 1000 * 60 * 60 * 24)),
expired_time: time + (tokenType == 'access_token' ? (1000 * 60 * 60 * 2) : (40 * 1000 * 60 * 60 * 24)),
device_id: device_id,
type
type: tokenType
})
}
/**

View File

@@ -295,7 +295,7 @@ export default class UserApi extends BaseApi {
const chat = Chat.findById(id)
return {
id,
type: chat.bean.type,
type: chat?.bean.type,
title: chat?.getTitle(user) || "未知",
avatar: chat?.getAvatarFileHash(user) ? "uploaded_files/" + chat?.getAvatarFileHash(user) : undefined
}

View File

@@ -0,0 +1,6 @@
export default class AdminPermissions {
static readonly OWNER = "OWNER"
static readonly MANAGE_ADMIN = "MANAGE_ADMIN"
static readonly MUTE_MEMBERS = "MUTE_MEMBERS"
static readonly REMOVE_MEMBERS = "REMOVE_MEMBERS"
}

View File

@@ -10,6 +10,7 @@ import User from "./User.ts"
import ChatType from "./ChatType.ts"
import UserChatLinker from "./UserChatLinker.ts"
import DataWrongError from '../api/DataWrongError.ts'
import ChatAdminLinker from "./ChatAdminLinker.ts"
/**
* Chat.ts - Wrapper and manager
@@ -80,6 +81,10 @@ export default class Chat {
this.bean[key] = value
}
addAdmin(userId: string, permission: string[] | string) {
ChatAdminLinker.linkAdminAndChat(userId, this.bean.id)
ChatAdminLinker.updatePermissions(userId, this.bean.id, permission instanceof Array ? JSON.stringify(permission) : permission)
}
getMembersList() {
return UserChatLinker.getChatMembers(this.bean.id)
}

View File

@@ -0,0 +1,49 @@
import { DatabaseSync } from "node:sqlite"
import path from 'node:path'
import config from "../config.ts"
import { SQLInputValue } from "node:sqlite"
export default class ChatAdminLinker {
static database: DatabaseSync = this.init()
private static init(): DatabaseSync {
const db: DatabaseSync = new DatabaseSync(path.join(config.data_path, 'ChatAdminLinker.db'))
db.exec(`
CREATE TABLE IF NOT EXISTS ChatAdminLinker (
/* 序号 */ count INTEGER PRIMARY KEY AUTOINCREMENT,
/* 用戶 ID */ user_id TEXT NOT NULL,
/* Chat ID */ chat_id TEXT NOT NULL,
/* 管理权限 */ permissions TEXT NOT NULL
);
`)
return db
}
static linkAdminAndChat(userId: string, chatId: string) {
if (!this.checkAdminIsLinkedToChat(userId, chatId))
this.database.prepare(`INSERT INTO ChatAdminLinker (
user_id,
chat_id,
permissions
) VALUES (?, ?, ?);`).run(
userId,
chatId,
'[]'
)
}
static updatePermissions(userId: string, chatId: string, permissions: string) {
this.database.prepare(`UPDATE ChatAdminLinker SET permissions = ? WHERE user_id = ? AND chat_id = ?`).run(permissions, userId, chatId)
}
static unlinkAdminAndChat(userId: string, chatId: string) {
this.database.prepare(`DELETE FROM ChatAdminLinker WHERE user_id = ? AND chat_id = ?`).run(userId, chatId)
}
static checkAdminIsLinkedToChat(userId: string, chatId: string) {
return this.findAllByCondition('user_id = ? AND chat_id = ?', userId, chatId).length != 0
}
static getChatAdmins(chatId: string) {
return this.findAllByCondition('chat_id = ?', chatId).map((v) => v.user_id) as string[]
}
protected static findAllByCondition(condition: string, ...args: SQLInputValue[]) {
return this.database.prepare(`SELECT * FROM ChatAdminLinker WHERE ${condition}`).all(...args)
}
}

View File

@@ -1,8 +1,41 @@
import chalk from "chalk"
import Chat from "./Chat.ts"
import User from "./User.ts"
import GroupSettingsBean from "./GroupSettingsBean.ts"
class GroupSettings {
declare chat: ChatGroup
declare settings: GroupSettingsBean
constructor(chat: ChatGroup) {
this.chat = chat
this.settings = JSON.parse(chat.bean.settings)
}
update(bean: GroupSettingsBean) {
const updateValue = (key: string) => {
if (key in bean)
this.settings[key] = bean[key]
}
for (const k of [
'allow_new_member_join',
'allow_new_member_from_invitation',
'new_member_join_method',
'answered_and_allowed_by_admin_question',
])
updateValue(k)
this.apply()
}
apply() {
this.chat.setAttr('settings', JSON.stringify(this.settings))
}
}
export default class ChatGroup extends Chat {
getSettings() {
return new GroupSettings(this)
}
static fromChat(chat: Chat) {
return new ChatGroup(chat.bean)
}

View File

@@ -0,0 +1,10 @@
interface GroupSettingsBean {
allow_new_member_join?: boolean
allow_new_member_from_invitation?: boolean
new_member_join_method?: 'disabled' | 'allowed_by_admin' | 'answered_and_allowed_by_admin'
answered_and_allowed_by_admin_question?: string
[key: string]: unknown
}
export default GroupSettingsBean

View File

@@ -2,7 +2,7 @@ import { DatabaseSync } from "node:sqlite"
import path from 'node:path'
import config from "../config.ts"
import { SQLInputValue } from "node:sqlite";
import { SQLInputValue } from "node:sqlite"
export default class UserChatLinker {
static database: DatabaseSync = this.init()