Compare commits
12 Commits
1fec2bba06
...
f436f84696
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f436f84696 | ||
|
|
73e795e29f | ||
|
|
a709ac7ee0 | ||
|
|
beab35a25e | ||
|
|
aa0d0e86a5 | ||
|
|
f8a043e59f | ||
|
|
441fb5b5be | ||
|
|
29ea6f9142 | ||
|
|
cd22f62d60 | ||
|
|
b3ffdf8469 | ||
|
|
6c17a0e4eb | ||
|
|
8163100559 |
@@ -21,7 +21,8 @@ urlParams.get('debug') == 'true' && window.addEventListener('error', ({ message,
|
||||
|
||||
import App from './ui/App.tsx'
|
||||
import AppMobile from './ui/AppMobile.tsx'
|
||||
ReactDOM.createRoot(document.getElementById('app') as HTMLElement).render(React.createElement(urlParams.get('mobile') == 'true' ? AppMobile : App, null))
|
||||
import isMobileUI from "./ui/isMobileUI.ts"
|
||||
ReactDOM.createRoot(document.getElementById('app') as HTMLElement).render(React.createElement(isMobileUI() ? AppMobile : App, null))
|
||||
|
||||
const onResize = () => {
|
||||
document.body.style.setProperty('--whitesilk-widget-message-maxwidth', breakpoint().down('md') ? "80%" : "70%")
|
||||
|
||||
@@ -14,6 +14,7 @@ import useAsyncEffect from "../useAsyncEffect.ts"
|
||||
import * as marked from 'marked'
|
||||
import DOMPurify from 'dompurify'
|
||||
import randomUUID from "../../randomUUID.ts"
|
||||
import { time } from "node:console";
|
||||
|
||||
interface Args extends React.HTMLAttributes<HTMLElement> {
|
||||
target: string
|
||||
@@ -32,10 +33,17 @@ const markedInstance = new marked.Marked({
|
||||
return `<span>${text}</span>`
|
||||
},
|
||||
image({ text, href }) {
|
||||
if (/uploaded_files\/[A-Za-z0-9]+$/.test(href))
|
||||
return `<chat-image src="${href}" alt="${text}"></chat-image>`
|
||||
const type = /^(Video|File)=.*/.exec(text)?.[1] || 'Image'
|
||||
|
||||
if (/uploaded_files\/[A-Za-z0-9]+$/.test(href)) {
|
||||
return ({
|
||||
Image: `<chat-image src="${href}" alt="${text}"></chat-image>`,
|
||||
Video: `<chat-video src="${href}" alt="${text}"></chat-video>`,
|
||||
File: `<chat-file src="${href}" alt="${text}"></chat-file>`,
|
||||
})?.[type] || ``
|
||||
}
|
||||
return ``
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
@@ -177,6 +185,8 @@ export default function ChatFragment({ target, showReturnButton, onReturnButtonC
|
||||
cachedFileNamesCount.current[name] = 1
|
||||
if (type.startsWith('image/'))
|
||||
insertText(``)
|
||||
else if (type.startsWith('video/'))
|
||||
insertText(``)
|
||||
else
|
||||
insertText(``)
|
||||
}
|
||||
@@ -257,22 +267,55 @@ export default function ChatFragment({ target, showReturnButton, onReturnButtonC
|
||||
flexGrow: '1',
|
||||
}}>
|
||||
{
|
||||
messagesList.map((msg) =>
|
||||
<Element_Message
|
||||
key={msg.id}
|
||||
id={`chat_${target}_message_${msg.id}`}
|
||||
userId={msg.user_id}>
|
||||
<div dangerouslySetInnerHTML={{
|
||||
__html: DOMPurify.sanitize(markedInstance.parse(msg.text) as string, {
|
||||
ALLOWED_TAGS: [
|
||||
"chat-image",
|
||||
"span",
|
||||
"chat-link",
|
||||
]
|
||||
})
|
||||
}}></div>
|
||||
</Element_Message>
|
||||
)
|
||||
(() => {
|
||||
let date = new Date(0)
|
||||
return messagesList.map((msg) => {
|
||||
const rendeText = DOMPurify.sanitize(markedInstance.parse(msg.text) as string, {
|
||||
ALLOWED_TAGS: [
|
||||
"chat-image",
|
||||
"span",
|
||||
"chat-link",
|
||||
],
|
||||
ALLOWED_ATTR: [
|
||||
'src',
|
||||
'alt',
|
||||
'href',
|
||||
],
|
||||
}).replaceAll('\n', '<br>')
|
||||
const lastDate = date
|
||||
date = new Date(msg.time)
|
||||
|
||||
const msgElement = <Element_Message
|
||||
rawData={msg.text}
|
||||
renderHTML={rendeText}
|
||||
message={msg}
|
||||
key={msg.id}
|
||||
slot="trigger"
|
||||
id={`chat_${target}_message_${msg.id}`}
|
||||
userId={msg.user_id} />
|
||||
|
||||
return (
|
||||
<>
|
||||
{
|
||||
(date.getMinutes() != lastDate.getMinutes() || date.getDate() != lastDate.getDate() || date.getMonth() != lastDate.getMonth() || date.getFullYear() != lastDate.getFullYear())
|
||||
&& <mdui-tooltip content={`${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日 ${date.getHours()}:${date.getMinutes()}:${date.getSeconds()}`}>
|
||||
<div>
|
||||
{
|
||||
(date.getFullYear() != lastDate.getFullYear() ? `${date.getFullYear()}年` : '')
|
||||
+ (date.getMonth() != lastDate.getMonth() ? `${date.getMonth() + 1}月` : '')
|
||||
+ (date.getDate() != lastDate.getDate() ? `${date.getDate()}日` : '')
|
||||
+ ` ${date.getHours()}:${date.getMinutes()}`
|
||||
}
|
||||
</div>
|
||||
</mdui-tooltip>
|
||||
}
|
||||
{
|
||||
msgElement
|
||||
}
|
||||
</>
|
||||
)
|
||||
})
|
||||
})()
|
||||
}
|
||||
</MessageContainer>
|
||||
{
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
import { alert, Dropdown } from "mdui"
|
||||
import { $ } from "mdui/jq"
|
||||
import Client from "../../api/Client.ts"
|
||||
import Data_Message from "../../api/client_data/Message.ts"
|
||||
import DataCaches from "../../api/DataCaches.ts"
|
||||
import Avatar from "../Avatar.tsx"
|
||||
import copyToClipboard from "../copyToClipboard.ts"
|
||||
import useAsyncEffect from "../useAsyncEffect.ts"
|
||||
import React from "react"
|
||||
import useEventListener from "../useEventListener.ts"
|
||||
import isMobileUI from "../isMobileUI.ts"
|
||||
|
||||
interface Args extends React.HTMLAttributes<HTMLElement> {
|
||||
userId: string
|
||||
rawData: string
|
||||
renderHTML: string
|
||||
message: Data_Message
|
||||
}
|
||||
|
||||
export default function Message({ userId, children, ...props }: Args) {
|
||||
export default function Message({ userId, rawData, renderHTML, message, ...props }: Args) {
|
||||
const isAtRight = Client.myUserProfile?.id == userId
|
||||
|
||||
const [ nickName, setNickName ] = React.useState("")
|
||||
const [ avatarUrl, setAvatarUrl ] = React.useState<string | undefined>("")
|
||||
const [nickName, setNickName] = React.useState("")
|
||||
const [avatarUrl, setAvatarUrl] = React.useState<string | undefined>("")
|
||||
|
||||
useAsyncEffect(async () => {
|
||||
const user = await DataCaches.getUserProfile(userId)
|
||||
@@ -20,6 +29,8 @@ export default function Message({ userId, children, ...props }: Args) {
|
||||
setAvatarUrl(user?.avatar)
|
||||
}, [userId])
|
||||
|
||||
const dropDownRef = React.useRef<Dropdown>(null)
|
||||
|
||||
return (
|
||||
<div
|
||||
slot="trigger"
|
||||
@@ -77,17 +88,29 @@ export default function Message({ userId, children, ...props }: Args) {
|
||||
padding: "15px",
|
||||
alignSelf: isAtRight ? "flex-end" : "flex-start",
|
||||
}}>
|
||||
<span
|
||||
id="msg"
|
||||
style={{
|
||||
fontSize: "94%"
|
||||
}}>
|
||||
{
|
||||
// 消息内容
|
||||
children
|
||||
}
|
||||
</span>
|
||||
<mdui-dropdown trigger={isMobileUI() ? 'click' : 'contextmenu'} ref={dropDownRef}>
|
||||
<span
|
||||
slot="trigger"
|
||||
id="msg"
|
||||
style={{
|
||||
fontSize: "94%"
|
||||
}}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: renderHTML
|
||||
}} />
|
||||
<mdui-menu>
|
||||
<mdui-menu-item icon="content_copy" onClick={() => copyToClipboard($(dropDownRef.current as HTMLElement).find('#msg').text())}>複製文字</mdui-menu-item>
|
||||
<mdui-menu-item icon="content_copy" onClick={() => copyToClipboard(rawData)}>複製原文</mdui-menu-item>
|
||||
<mdui-menu-item icon="info" onClick={() => alert({
|
||||
headline: "消息详情",
|
||||
description: JSON.stringify(message),
|
||||
confirmText: "关闭",
|
||||
onConfirm: () => { },
|
||||
})}>查看詳情</mdui-menu-item>
|
||||
</mdui-menu>
|
||||
</mdui-dropdown>
|
||||
</mdui-card>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,6 @@ export default function copyToClipboard(text: string) {
|
||||
input.select()
|
||||
input.setSelectionRange(0, 1145141919810)
|
||||
document.execCommand('copy')
|
||||
input.clearSelection()
|
||||
input.setSelectionRange(null, null)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ customElements.define('chat-image', class extends HTMLElement {
|
||||
super()
|
||||
}
|
||||
connectedCallback() {
|
||||
this.style.display = 'block'
|
||||
const e = new Image()
|
||||
e.style.maxWidth = "100%"
|
||||
e.style.maxHeight = "90%"
|
||||
|
||||
3
client/ui/isMobileUI.ts
Normal file
3
client/ui/isMobileUI.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function isMobileUI() {
|
||||
return new URL(location.href).searchParams.get('mobile') == 'true'
|
||||
}
|
||||
7
license
Normal file
7
license
Normal file
@@ -0,0 +1,7 @@
|
||||
Copyright 2025 月有陰晴圓缺(CrescentLeaf/MoonLeeeaf)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
84
readme.md
84
readme.md
@@ -1,11 +1,81 @@
|
||||
## TheWhiteSilk
|
||||
|
||||
Under developing...
|
||||
這一個即時通訊項目————簡單, 輕量, 純粹, 時而天真
|
||||
|
||||
### TODO
|
||||
後續會考慮改名為月之鴿
|
||||
|
||||
* [x] 接口完備
|
||||
* [ ] 對外接口
|
||||
* [ ] 單元測試
|
||||
* [ ] 基本可用
|
||||
* [ ] 負載均衡
|
||||
### 目前實現了什麽?
|
||||
|
||||
<details>
|
||||
<summary>客戶端實現</summary>
|
||||
|
||||
- 消息
|
||||
- [x] 收發消息
|
||||
- [x] 富文本 (based on Marked)
|
||||
- [x] 圖片
|
||||
- [ ] 視頻
|
||||
- [ ] 文件
|
||||
- [ ] 撤回消息
|
||||
- [ ] 修改消息
|
||||
|
||||
- 對話
|
||||
- [ ] _**最近對話**_
|
||||
- [x] 添加對話
|
||||
- [x] 添加用戶
|
||||
- [ ] 添加群組 (伺服器端群組都還沒做, 想什麽呢)
|
||||
|
||||
- 賬號
|
||||
- [x] 登錄注冊 (廢話)
|
||||
- [x] 資料編輯
|
||||
- [x] 用戶名
|
||||
- [x] 昵稱
|
||||
- [x] 頭像
|
||||
- [ ] 賬號管理
|
||||
- [ ] 重設密碼
|
||||
- [ ] 綁定郵箱
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>伺服器端實現</summary>
|
||||
|
||||
- 基本對話類型
|
||||
- [x] 雙用戶私聊
|
||||
- [ ] 群組
|
||||
|
||||
- 消息
|
||||
- [x] 收發消息
|
||||
- [ ] 撤回消息
|
||||
- [ ] 修改消息
|
||||
|
||||
- 對話
|
||||
- [ ] _**最近對話**_
|
||||
- [x] 添加對話
|
||||
|
||||
- 賬號
|
||||
- [x] 登錄注冊
|
||||
- [x] 資料編輯
|
||||
- [ ] 賬號管理
|
||||
- [ ] 重設密碼
|
||||
- [ ] 綁定郵箱
|
||||
|
||||
</details>
|
||||
|
||||
### 伺服器端運行
|
||||
|
||||
```bash
|
||||
git clone https://codeberg.org/CrescentLeaf/TheWhiteSilk
|
||||
cd TheWhiteSilk
|
||||
# 編譯前端網頁
|
||||
deno task build
|
||||
# 運行服務
|
||||
deno task server
|
||||
```
|
||||
|
||||
#### 配置
|
||||
|
||||
[thewhitesilk_config.json 是怎麽來的, 又有什麽用?](./server/config.ts)
|
||||
|
||||
### License
|
||||
|
||||
[MIT License](./license)
|
||||
|
||||
@@ -86,6 +86,7 @@ export default class ChatApi extends BaseApi {
|
||||
|
||||
const msg = {
|
||||
text: args.text as string,
|
||||
time: Date.now(),
|
||||
user_id: token.author,
|
||||
}
|
||||
const id = MessagesManager.getInstanceForChat(chat).addMessage(msg)
|
||||
|
||||
@@ -23,27 +23,26 @@ app.get('/uploaded_files/:hash', (req, res) => {
|
||||
const hash = req.params.hash as string
|
||||
res.setHeader('Content-Type', 'text/plain')
|
||||
if (hash == null) {
|
||||
res.send("404 Not Found", 404)
|
||||
res.status(404).send("404 Not Found")
|
||||
return
|
||||
}
|
||||
const file = FileManager.findByHash(hash)
|
||||
|
||||
if (file.getChatId() != null) {
|
||||
const userToken = TokenManager.decode(req.cookies.token)
|
||||
console.log(userToken, req.cookies.device_id)
|
||||
if (!TokenManager.checkToken(userToken, req.cookies.device_id)) {
|
||||
res.send("401 UnAuthorized", 401)
|
||||
return
|
||||
}
|
||||
if (!UserChatLinker.checkUserIsLinkedToChat(userToken.author, file.getChatId())) {
|
||||
res.send("403 Forbidden", 403)
|
||||
return
|
||||
}
|
||||
if (file == null) {
|
||||
res.status(404).send("404 Not Found")
|
||||
return
|
||||
}
|
||||
|
||||
if (file == null) {
|
||||
res.send("404 Not Found", 404)
|
||||
return
|
||||
if (file.getChatId() != null) {
|
||||
const userToken = TokenManager.decode(req.cookies.token)
|
||||
if (!TokenManager.checkToken(userToken, req.cookies.device_id)) {
|
||||
res.status(401).send("401 UnAuthorized")
|
||||
return
|
||||
}
|
||||
if (!UserChatLinker.checkUserIsLinkedToChat(userToken.author, file.getChatId() as string)) {
|
||||
res.status(403).send("403 Forbidden")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', file!.getMime())
|
||||
|
||||
Reference in New Issue
Block a user