Compare commits

...

12 Commits

Author SHA1 Message Date
CrescentLeaf
f436f84696 feat(wip): 支持視頻和文件 2025-10-01 01:08:52 +08:00
CrescentLeaf
73e795e29f fix: Marked 不解析 \n, 手動解析以使換行符正常使用 2025-10-01 01:03:39 +08:00
CrescentLeaf
a709ac7ee0 ui: 修繕圖片顯示, 不再為下方文字説明占位 (aka inline -> block) 2025-10-01 01:02:49 +08:00
CrescentLeaf
beab35a25e chore: make lint happy 2025-10-01 00:57:34 +08:00
CrescentLeaf
aa0d0e86a5 docs: readme 2025-10-01 00:36:41 +08:00
CrescentLeaf
f8a043e59f docs: readme 2025-10-01 00:20:16 +08:00
CrescentLeaf
441fb5b5be docs: license 2025-10-01 00:20:11 +08:00
CrescentLeaf
29ea6f9142 chore: 迁移工具方法 isMobileUI 2025-10-01 00:02:20 +08:00
CrescentLeaf
cd22f62d60 fix(wip): 复制文字到剪贴板 2025-10-01 00:01:52 +08:00
CrescentLeaf
b3ffdf8469 feat: 消息菜单 2025-10-01 00:01:39 +08:00
CrescentLeaf
6c17a0e4eb feat: 显示消息时间 2025-10-01 00:01:27 +08:00
CrescentLeaf
8163100559 feat: 上报消息时间戳 2025-09-30 23:47:49 +08:00
10 changed files with 200 additions and 52 deletions

View File

@@ -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%")

View File

@@ -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(`![圖片](${name})`)
else if (type.startsWith('video/'))
insertText(`![Video=${name}](${name})`)
else
insertText(`![File=${name}](${name})`)
}
@@ -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>
{

View File

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

View File

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

View File

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

@@ -0,0 +1,3 @@
export default function isMobileUI() {
return new URL(location.href).searchParams.get('mobile') == 'true'
}

7
license Normal file
View 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.

View File

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

View File

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

View File

@@ -23,28 +23,27 @@ 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 == null) {
res.status(404).send("404 Not Found")
return
}
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)
res.status(401).send("401 UnAuthorized")
return
}
if (!UserChatLinker.checkUserIsLinkedToChat(userToken.author, file.getChatId())) {
res.send("403 Forbidden", 403)
if (!UserChatLinker.checkUserIsLinkedToChat(userToken.author, file.getChatId() as string)) {
res.status(403).send("403 Forbidden")
return
}
}
if (file == null) {
res.send("404 Not Found", 404)
return
}
res.setHeader('Content-Type', file!.getMime())
res.sendFile(path.resolve(file!.getFilePath()))