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 App from './ui/App.tsx'
import AppMobile from './ui/AppMobile.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 = () => { const onResize = () => {
document.body.style.setProperty('--whitesilk-widget-message-maxwidth', breakpoint().down('md') ? "80%" : "70%") 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 * as marked from 'marked'
import DOMPurify from 'dompurify' import DOMPurify from 'dompurify'
import randomUUID from "../../randomUUID.ts" import randomUUID from "../../randomUUID.ts"
import { time } from "node:console";
interface Args extends React.HTMLAttributes<HTMLElement> { interface Args extends React.HTMLAttributes<HTMLElement> {
target: string target: string
@@ -32,10 +33,17 @@ const markedInstance = new marked.Marked({
return `<span>${text}</span>` return `<span>${text}</span>`
}, },
image({ text, href }) { image({ text, href }) {
if (/uploaded_files\/[A-Za-z0-9]+$/.test(href)) const type = /^(Video|File)=.*/.exec(text)?.[1] || 'Image'
return `<chat-image src="${href}" alt="${text}"></chat-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 `` return ``
} },
} }
}) })
@@ -177,6 +185,8 @@ export default function ChatFragment({ target, showReturnButton, onReturnButtonC
cachedFileNamesCount.current[name] = 1 cachedFileNamesCount.current[name] = 1
if (type.startsWith('image/')) if (type.startsWith('image/'))
insertText(`![圖片](${name})`) insertText(`![圖片](${name})`)
else if (type.startsWith('video/'))
insertText(`![Video=${name}](${name})`)
else else
insertText(`![File=${name}](${name})`) insertText(`![File=${name}](${name})`)
} }
@@ -257,22 +267,55 @@ export default function ChatFragment({ target, showReturnButton, onReturnButtonC
flexGrow: '1', flexGrow: '1',
}}> }}>
{ {
messagesList.map((msg) => (() => {
<Element_Message let date = new Date(0)
key={msg.id} return messagesList.map((msg) => {
id={`chat_${target}_message_${msg.id}`} const rendeText = DOMPurify.sanitize(markedInstance.parse(msg.text) as string, {
userId={msg.user_id}> ALLOWED_TAGS: [
<div dangerouslySetInnerHTML={{ "chat-image",
__html: DOMPurify.sanitize(markedInstance.parse(msg.text) as string, { "span",
ALLOWED_TAGS: [ "chat-link",
"chat-image", ],
"span", ALLOWED_ATTR: [
"chat-link", 'src',
] 'alt',
}) 'href',
}}></div> ],
</Element_Message> }).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> </MessageContainer>
{ {

View File

@@ -1,18 +1,27 @@
import { alert, Dropdown } from "mdui"
import { $ } from "mdui/jq"
import Client from "../../api/Client.ts" import Client from "../../api/Client.ts"
import Data_Message from "../../api/client_data/Message.ts"
import DataCaches from "../../api/DataCaches.ts" import DataCaches from "../../api/DataCaches.ts"
import Avatar from "../Avatar.tsx" import Avatar from "../Avatar.tsx"
import copyToClipboard from "../copyToClipboard.ts"
import useAsyncEffect from "../useAsyncEffect.ts" import useAsyncEffect from "../useAsyncEffect.ts"
import React from "react" import React from "react"
import useEventListener from "../useEventListener.ts"
import isMobileUI from "../isMobileUI.ts"
interface Args extends React.HTMLAttributes<HTMLElement> { interface Args extends React.HTMLAttributes<HTMLElement> {
userId: string 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 isAtRight = Client.myUserProfile?.id == userId
const [ nickName, setNickName ] = React.useState("") const [nickName, setNickName] = React.useState("")
const [ avatarUrl, setAvatarUrl ] = React.useState<string | undefined>("") const [avatarUrl, setAvatarUrl] = React.useState<string | undefined>("")
useAsyncEffect(async () => { useAsyncEffect(async () => {
const user = await DataCaches.getUserProfile(userId) const user = await DataCaches.getUserProfile(userId)
@@ -20,6 +29,8 @@ export default function Message({ userId, children, ...props }: Args) {
setAvatarUrl(user?.avatar) setAvatarUrl(user?.avatar)
}, [userId]) }, [userId])
const dropDownRef = React.useRef<Dropdown>(null)
return ( return (
<div <div
slot="trigger" slot="trigger"
@@ -77,17 +88,29 @@ export default function Message({ userId, children, ...props }: Args) {
padding: "15px", padding: "15px",
alignSelf: isAtRight ? "flex-end" : "flex-start", alignSelf: isAtRight ? "flex-end" : "flex-start",
}}> }}>
<span <mdui-dropdown trigger={isMobileUI() ? 'click' : 'contextmenu'} ref={dropDownRef}>
id="msg" <span
style={{ slot="trigger"
fontSize: "94%" id="msg"
}}> style={{
{ fontSize: "94%"
// 消息内容 }}
children dangerouslySetInnerHTML={{
} __html: renderHTML
</span> }} />
<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> </mdui-card>
</div> </div>
) )
} }

View File

@@ -9,6 +9,6 @@ export default function copyToClipboard(text: string) {
input.select() input.select()
input.setSelectionRange(0, 1145141919810) input.setSelectionRange(0, 1145141919810)
document.execCommand('copy') document.execCommand('copy')
input.clearSelection() input.setSelectionRange(null, null)
} }
} }

View File

@@ -9,6 +9,7 @@ customElements.define('chat-image', class extends HTMLElement {
super() super()
} }
connectedCallback() { connectedCallback() {
this.style.display = 'block'
const e = new Image() const e = new Image()
e.style.maxWidth = "100%" e.style.maxWidth = "100%"
e.style.maxHeight = "90%" 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 ## 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 = { const msg = {
text: args.text as string, text: args.text as string,
time: Date.now(),
user_id: token.author, user_id: token.author,
} }
const id = MessagesManager.getInstanceForChat(chat).addMessage(msg) const id = MessagesManager.getInstanceForChat(chat).addMessage(msg)

View File

@@ -23,27 +23,26 @@ app.get('/uploaded_files/:hash', (req, res) => {
const hash = req.params.hash as string const hash = req.params.hash as string
res.setHeader('Content-Type', 'text/plain') res.setHeader('Content-Type', 'text/plain')
if (hash == null) { if (hash == null) {
res.send("404 Not Found", 404) res.status(404).send("404 Not Found")
return return
} }
const file = FileManager.findByHash(hash) const file = FileManager.findByHash(hash)
if (file.getChatId() != null) { if (file == null) {
const userToken = TokenManager.decode(req.cookies.token) res.status(404).send("404 Not Found")
console.log(userToken, req.cookies.device_id) return
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) { if (file.getChatId() != null) {
res.send("404 Not Found", 404) const userToken = TokenManager.decode(req.cookies.token)
return 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()) res.setHeader('Content-Type', file!.getMime())