diff --git a/.editorconfig b/.editorconfig index ffe9a1f..ad95218 100644 --- a/.editorconfig +++ b/.editorconfig @@ -4,7 +4,7 @@ root = true end_of_line = lf insert_final_newline = true -[*.{js,json,yml}] +[*.{ts,tsx,css,json,yml}] charset = utf-8 indent_style = space indent_size = 4 diff --git a/.github/screenshot.png b/.github/screenshot.png new file mode 100644 index 0000000..6a1127f Binary files /dev/null and b/.github/screenshot.png differ diff --git a/README.md b/README.md index 70dba72..90a909d 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,16 @@ -# QQNTim 模板插件 +# QQNTim 消息增强 + +## 简介 + +本插件为 QQNT 提供复读消息、转发语音消息、撤回好友私聊消息等功能。需要安装 [QQNTim 3.0 及以上版本](https://github.com/Flysoft-Studio/QQNTim) 才能使用。 + +效果图: + +![截图](.github/screenshot.png) + +## 使用 + +安装此插件后对着消息右键即可看到添加的功能。 ## 开发 diff --git a/package.json b/package.json index bd04c3c..3f8ed81 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "qqntim-plugin-template", + "name": "qqntim-plugin-enhanced-messaging", "private": true, - "version": "2.0.0", + "version": "1.0.0", "packageManager": "yarn@3.6.1", "license": "MIT", "scripts": { diff --git a/publish/qqntim.json b/publish/qqntim.json index 8f560fc..2ff55e8 100644 --- a/publish/qqntim.json +++ b/publish/qqntim.json @@ -1,8 +1,8 @@ { "manifestVersion": "3.0", - "id": "template-plugin", - "name": "QQNTim 模板插件", - "description": "快速开始开发你自己的插件。", + "id": "enhanced-messaging", + "name": "消息增强", + "description": "提供复读消息、转发语音消息、撤回好友私聊消息等功能。", "version": "1.0.0", "author": "Flysoft", "injections": [ diff --git a/publish/style.css b/publish/style.css index 4ee87da..ae16cba 100644 --- a/publish/style.css +++ b/publish/style.css @@ -1,2 +1,13 @@ -/* Put your stylesheet here. */ +.enhanced-messaging-floating-ok { + position: fixed; + bottom: 30px; + right: 120px; + z-index: 10000; +} +.enhanced-messaging-floating-cancel { + position: fixed; + bottom: 30px; + right: 30px; + z-index: 10000; +} diff --git a/src/_renderer.tsx b/src/_renderer.tsx new file mode 100644 index 0000000..6274042 --- /dev/null +++ b/src/_renderer.tsx @@ -0,0 +1,218 @@ +import { dialog, env, nt, utils } from "qqntim/renderer"; +import { getPluginConfig } from "./config"; +import { createRoot } from "react-dom/client"; + +function selectPeer() { + return new Promise | undefined>((resolve) => { + const currentSelected = document.querySelector(".recent-contact-item.recent-contact-item--selected"); + currentSelected?.classList.remove("list-item--selected", "recent-contact-item--selected"); + const selectedPeers = new Map(); + const mouseDownHandler = (event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + }; + const clickHandler = (event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + const element = (event.target as HTMLElement | undefined)?.closest?.(".recent-contact-item"); + const itemData = element?.__VUE__?.[0]?.props?.itemData; + if (itemData) + if (!selectedPeers.has(itemData.uid)) { + selectedPeers.set(itemData.uid, { chatType: itemData.type == 1 ? "friend" : itemData.type == 2 ? "group" : "others", uid: itemData.uid }); + element?.classList.add("list-item--selected", "recent-contact-item--selected"); + } else { + selectedPeers.delete(itemData.uid); + element?.classList.remove("list-item--selected", "recent-contact-item--selected"); + } + }; + const elements = document.querySelectorAll(".recent-contact-item"); + for (const element of elements) { + (element?.firstElementChild as HTMLElement | null)?.addEventListener("mousedown", mouseDownHandler); + (element?.firstElementChild as HTMLElement | null)?.addEventListener("click", clickHandler); + } + const exitSelect = () => { + for (const element of elements) { + (element?.firstElementChild as HTMLElement | null)?.removeEventListener("mousedown", mouseDownHandler); + (element?.firstElementChild as HTMLElement | null)?.removeEventListener("click", clickHandler); + element.classList.remove("list-item--selected", "recent-contact-item--selected"); + } + currentSelected?.classList.add("list-item--selected", "recent-contact-item--selected"); + okBtn.remove(); + cancelBtn.remove(); + }; + const okBtn = document.createElement("button"); + okBtn.classList.add("q-button", "q-button--default", "q-button--primary", "enhanced-messaging-floating-ok"); + const okBtnText = document.createElement("span"); + okBtnText.classList.add("q-button__slot-warp"); + okBtnText.innerText = "确定"; + okBtn.appendChild(okBtnText); + okBtn.addEventListener("click", () => { + exitSelect(); + resolve(selectedPeers); + }); + const cancelBtn = document.createElement("button"); + cancelBtn.classList.add("q-button", "q-button--default", "q-button--secondary", "enhanced-messaging-floating-cancel"); + const cancelBtnText = document.createElement("span"); + cancelBtnText.classList.add("q-button__slot-warp"); + cancelBtnText.innerText = "取消"; + cancelBtn.appendChild(cancelBtnText); + cancelBtn.addEventListener("click", () => { + exitSelect(); + resolve(undefined); + }); + document.body.appendChild(okBtn); + document.body.appendChild(cancelBtn); + }); +} + +function encodeMsg(msg: any) { + return msg.elements.map((_element) => { + const element = JSON.parse(JSON.stringify(_element)); + return { + type: "raw", + raw: { + ...element, + elementId: "", + extBufForUI: undefined, + picElement: element.picElement && { + ...element.picElement, + fileSubId: undefined, + fileUuid: undefined, + progress: undefined, + thumbPath: undefined, + transferStatus: undefined, + }, + }, + }; + }); +} + +function forwardMsg(msg: any) { + selectPeer().then((peers) => { + if (!peers) return; + Promise.all(Array.from(peers.values()).map((peer) => nt.sendMessage(peer, encodeMsg(msg)).catch((reason) => console.error(reason)))) + .then((array) => array.filter(Boolean)) + .then((array) => { + dialog.alert(`成功向 ${array.length} 个对象转发此消息。`); + }); + }); +} + +function repeatMsg(msg: any) { + nt.sendMessage({ uid: msg.peerUid, chatType: msg.chatType == 1 ? "friend" : msg.chatType == 2 ? "group" : "others" }, encodeMsg(msg)); +} + +function forceRevokeMsg(msg: any) { + nt.revokeMessage({ uid: msg.peerUid, chatType: msg.chatType == 1 ? "friend" : msg.chatType == 2 ? "group" : "others" }, msg.msgId); +} + +function ShareIcon() { + return ( + + + + + + ); +} + +function RevokeIcon() { + return ( + + + + + ); +} + +function Menu({ msg, showForwardBtn, showPlusOneBtn, showForceRevokeBtn, blur }: { msg: any; showForwardBtn: boolean; showPlusOneBtn: boolean; showForceRevokeBtn: boolean; blur: Function }) { + return ( + <> + {(showForwardBtn || showPlusOneBtn) &&
} + {([showForwardBtn && ["转发", , () => forwardMsg(msg)], showForceRevokeBtn && ["强制撤回", , () => forceRevokeMsg(msg)], showPlusOneBtn && ["+1", undefined, () => repeatMsg(msg)]].filter(Boolean) as [string, React.ReactNode, Function][]).map( + ([title, icon, onClick], idx) => ( + + key={idx} + className="q-context-menu-item q-context-menu-item--normal" + role="item" + onClick={() => { + onClick(); + blur(); + }} + > + + {icon} + + {title} + + ), + )} + + ); +} + +export default class Entry implements QQNTim.Entry.Renderer { + constructor() { + const config = getPluginConfig(env.config.plugins.config); + } + + onWindowLoaded(): void { + const handler = (event: MouseEvent) => { + const msg = (event.target as HTMLElement | undefined)?.closest(".msg-content-container")?.closest(".message")?.__VUE__?.[0]?.props?.msgRecord; + let showForwardBtn = true; + let showForceRevokeBtn = msg.chatType == 1; + if (msg) { + utils.waitForElement(".q-context-menu").then((menu) => { + const items = document.querySelectorAll(".q-context-menu .q-context-menu-item .q-context-menu-item__text"); + for (const item of items) { + if (item.innerText == "转发") showForwardBtn = false; + if (item.innerText == "撤回") showForceRevokeBtn = false; + } + const pluginMenuElement = document.createElement("div"); + const pluginMenuRoot = createRoot(pluginMenuElement); + pluginMenuRoot.render( + { + menu.blur(); + const mouseEvent = new MouseEvent("click", { + view: window, + bubbles: true, + cancelable: true, + }); + document.querySelector("#app")?.dispatchEvent(mouseEvent); + }} + />, + ); + menu.appendChild(pluginMenuElement); + requestAnimationFrame(() => { + const rect = menu.getBoundingClientRect(); + if (rect.bottom + 20 > document.body.clientHeight) { + menu.style.top = ""; + menu.style.bottom = "20px"; + } + if (rect.right + 20 > document.body.clientWidth) { + menu.style.left = ""; + menu.style.right = "20px"; + } + }); + }); + } + }; + window.addEventListener("contextmenu", handler); + // 由于图片比较特殊,会阻止右键消息传达到 window,所以需要单独设置 listener + new MutationObserver(() => { + const elements = document.querySelectorAll(".image-content:not(.enhanced-messaging-img-patched)"); + for (const element of elements) { + element.classList.add("enhanced-messaging-img-patched"); + element.addEventListener("contextmenu", handler); + } + }).observe(document.body, { childList: true, subtree: true, attributes: true, characterData: true }); + } +} diff --git a/src/config.ts b/src/config.ts index 5c49c60..fcf1f48 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,21 +1,11 @@ -export const id = "my-template-plugin" as const; +export const id = "enhanced-messaging" as const; -export const defaults: PluginConfig = { - switchConfigItem: false, - anotherSwitchConfigItem: false, - inputConfigItem: "默认值", - dropdownConfigItem: "A", -}; +export const defaults: PluginConfig = {}; export function getPluginConfig(config: Config | undefined) { return Object.assign({}, defaults, config?.[id] || {}); } -export interface PluginConfig { - switchConfigItem: boolean; - anotherSwitchConfigItem: boolean; - inputConfigItem: string; - dropdownConfigItem: "A" | "B" | "C"; -} +export type PluginConfig = {}; export type Config = { [X in typeof id]?: Partial; }; diff --git a/src/main.ts b/src/main.ts index 5a1ec87..8109a45 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,10 +1 @@ -import { getPluginConfig } from "./config"; -import * as qqntim from "qqntim/main"; - -export default class Entry implements QQNTim.Entry.Main { - constructor() { - const config = getPluginConfig(qqntim.env.config.plugins.config); - console.log("[Template] Hello world!", qqntim); - console.log("[Template] 当前插件配置:", config); - } -} +export default class Entry implements QQNTim.Entry.Main {} diff --git a/src/renderer.ts b/src/renderer.ts index 9bfc233..1027ebd 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -1,10 +1,2 @@ -import { getPluginConfig } from "./config"; -import * as qqntim from "qqntim/renderer"; - -export default class Entry implements QQNTim.Entry.Renderer { - constructor() { - const config = getPluginConfig(qqntim.env.config.plugins.config); - console.log("[Template] Hello world!", qqntim); - console.log("[Template] 当前插件配置:", config); - } -} +import Entry from "./_renderer"; +export default Entry; diff --git a/src/settings.tsx b/src/settings.tsx index 53cdab9..3c4544c 100644 --- a/src/settings.tsx +++ b/src/settings.tsx @@ -1,51 +1 @@ -import { usePluginConfig } from "./utils/hooks"; -import { defineSettingsPanels } from "qqntim-settings"; -import { Dropdown, Input, SettingsBox, SettingsBoxItem, SettingsSection, Switch } from "qqntim-settings/components"; -import { env } from "qqntim/renderer"; -import { useMemo } from "react"; -import { getPluginConfig } from "./config"; - -export default class Entry implements QQNTim.Entry.Renderer { - constructor() { - // 如果不需要设置界面,将下一行注释掉即可;如果需要在设置项目旁边加一个小图标,请将 `undefined` 改为一段 HTML 代码(可以是 ``, `` 等等)。 - defineSettingsPanels(["模板插件设置", SettingsPanel, undefined]); - } -} - -function SettingsPanel({ config: _config, setConfig: _setConfig }: QQNTim.Settings.PanelProps) { - const [pluginConfig, setPluginConfig] = usePluginConfig(_config, _setConfig); - const currentPluginConfigString = useMemo(() => JSON.stringify(getPluginConfig(env.config.plugins.config)), []); - - return ( - <> - - - - - setPluginConfig("switchConfigItem", state)} /> - - {pluginConfig.switchConfigItem && ( - - setPluginConfig("anotherSwitchConfigItem", state)} /> - - )} - - setPluginConfig("dropdownConfigItem", state)} - width="150px" - /> - - - setPluginConfig("inputConfigItem", state)} /> - - - - - ); -} +export default class Entry implements QQNTim.Entry.Renderer {} diff --git a/yarn.lock b/yarn.lock index f1fbb19..453618a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1277,9 +1277,9 @@ __metadata: languageName: node linkType: hard -"qqntim-plugin-template@workspace:.": +"qqntim-plugin-enhanced-messaging@workspace:.": version: 0.0.0-use.local - resolution: "qqntim-plugin-template@workspace:." + resolution: "qqntim-plugin-enhanced-messaging@workspace:." dependencies: "@flysoftbeta/qqntim-typings": ^3.1.2 "@types/node": ^20.4.2