diff --git a/.dockerignore b/.dockerignore index 2fc3c4ef..7321a3e1 100644 --- a/.dockerignore +++ b/.dockerignore @@ -24,6 +24,8 @@ data/* !data/custom_public/ data/custom_public/* !data/custom_public/README.txt +!data/custom_public/templates/ +data/custom_public/templates/* !data/emoticons/ data/emoticons/* !data/plugins/ diff --git a/Dockerfile b/Dockerfile index 3cdf36ba..11fb9f8a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,7 +18,7 @@ RUN npm run build # 准备后端 # -FROM python:3.12.7-bookworm +FROM python:3.12.10-bookworm ARG BASE_PATH='/root/blivechat' ARG EXT_DATA_PATH='/mnt/data' WORKDIR "${BASE_PATH}" @@ -30,18 +30,17 @@ RUN pip3 install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple -r req # 数据目录 COPY . ./ -RUN mkdir -p "${EXT_DATA_PATH}/frontend/dist" \ +RUN sed 's/^host =.*$/host = 0.0.0.0/; s/^loader_url =.*$/loader_url =/' data/config.example.ini >> data/config.ini +RUN mkdir -p "${EXT_DATA_PATH}" \ && mv data "${EXT_DATA_PATH}/data" \ && ln -s "${EXT_DATA_PATH}/data" data \ && mv log "${EXT_DATA_PATH}/log" \ - && ln -s "${EXT_DATA_PATH}/log" log \ - && ln -s "${EXT_DATA_PATH}/frontend/dist" frontend/dist + && ln -s "${EXT_DATA_PATH}/log" log # 编译好的前端 -COPY --from=builder "${BASE_PATH}/frontend/dist" "${EXT_DATA_PATH}/frontend/dist/" +COPY --from=builder "${BASE_PATH}/frontend/dist" frontend/dist # 运行 VOLUME "${EXT_DATA_PATH}" EXPOSE 12450 ENTRYPOINT ["python3", "main.py"] -CMD ["--host", "0.0.0.0", "--port", "12450"] diff --git a/README.md b/README.md index 165f17c4..a2872c12 100644 --- a/README.md +++ b/README.md @@ -11,14 +11,15 @@ ## 特性 * 兼容YouTube直播评论栏的样式 -* 高亮舰队、 ~~房管~~ 、主播的用户名 +* 高亮舰队、房管、主播的用户名 * 自带两种样式生成器,经典YouTube风格和仿微信风格 * 支持屏蔽弹幕、合并礼物等设置 * 支持前端直连B站服务器或者通过后端转发 * 支持自动翻译弹幕、醒目留言到日语,可以在后台配置翻译目标语言 * 支持标注打赏用户名的读音,可选拼音或日文假名 * 支持配置自定义表情,不需要开通B站官方表情 -* 支持插件开发 +* 支持[自定义HTML模板](https://github.com/xfgryujk/blivechat/wiki/%E8%87%AA%E5%AE%9A%E4%B9%89HTML%E6%A8%A1%E6%9D%BF) +* 支持[插件开发](https://github.com/xfgryujk/blivechat/wiki/%E6%8F%92%E4%BB%B6%E7%B3%BB%E7%BB%9F) ## 使用方法 @@ -105,7 +106,7 @@ 服务器配置文件在`data/config.ini`,可以配置数据库和允许自动翻译等,编辑后要重启生效 -**自建服务器时强烈建议不使用加载器**,否则可能因为各种原因加载不出来 +**自建服务器时注意要删除loader_url配置**,否则加载不了房间页面 ## 常用链接 diff --git a/api/chat.py b/api/chat.py index 9bd979ec..5f76d05e 100644 --- a/api/chat.py +++ b/api/chat.py @@ -74,6 +74,7 @@ def make_text_message_data( content_type_params: list = None, uid: str = '', medal_name: str = '', + is_mirror: bool = False, ): # 为了节省带宽用list而不是dict return [ @@ -113,6 +114,8 @@ def make_text_message_data( uid, # 17: medalName medal_name, + # 18: isMirror + 1 if is_mirror else 0, ] @@ -327,34 +330,45 @@ async def _send_test_message(self): class RoomInfoHandler(api.base.ApiHandler): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._user_agent = '' + self._http_session: Optional[aiohttp.ClientSession] = None + async def get(self): room_id = int(self.get_query_argument('roomId')) logger.info('client=%s getting room info, room=%d', self.request.remote_ip, room_id) - (room_id, owner_uid), (host_server_list, host_server_token), buvid = await asyncio.gather( - self._get_room_info(room_id), - self._get_server_host_list_and_token(room_id), - self._get_buvid() - ) - - # 缓存1分钟 - self.set_header('Cache-Control', 'private, max-age=60') - self.write({ - 'roomId': room_id, - 'ownerUid': owner_uid, - 'hostServerList': host_server_list, - 'hostServerToken': host_server_token, - # 虽然没什么用但还是加上比较保险 - 'buvid': buvid, - }) - - @staticmethod - async def _get_room_info(room_id) -> Tuple[int, int]: + self._user_agent = self.request.headers.get('User-Agent', '') + # 因为UA会变,所以不能用公共的session了 + async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as http_session: + self._http_session = http_session + + # 要先初始化buvid才能进行后续请求,不然会-352 + buvid = await self._get_buvid() + (room_id, owner_uid), (host_server_list, host_server_token) = await asyncio.gather( + self._get_room_info(room_id), + self._get_server_host_list_and_token(room_id), + ) + + # 缓存1分钟 + self.set_header('Cache-Control', 'private, max-age=60') + self.write({ + 'roomId': room_id, + 'ownerUid': owner_uid, + 'hostServerList': host_server_list, + 'hostServerToken': host_server_token, + # 虽然没什么用但还是加上比较保险 + 'buvid': buvid, + }) + + async def _get_room_info(self, room_id) -> Tuple[int, int]: try: - async with utils.request.http_session.get( + async with self._http_session.get( dm_web_cli.ROOM_INIT_URL, headers={ **utils.request.BILIBILI_COMMON_HEADERS, + 'User-Agent': self._user_agent, 'Origin': 'https://live.bilibili.com', 'Referer': f'https://live.bilibili.com/{room_id}' }, @@ -375,21 +389,29 @@ async def _get_room_info(room_id) -> Tuple[int, int]: logger.warning('room=%d _get_room_info failed: %s', room_id, data['message']) return room_id, 0 - room_info = data['data']['room_info'] - return room_info['room_id'], room_info['uid'] + data = data['data'] + return data['room_id'], data['uid'] async def _get_server_host_list_and_token(self, room_id) -> Tuple[dict, Optional[str]]: + wbi_signer = dm_web_cli._get_wbi_signer(self._http_session) # noqa + if wbi_signer.need_refresh_wbi_key: + await wbi_signer.refresh_wbi_key() + # 如果没刷新成功先用旧的key + if wbi_signer.wbi_key == '': + logger.warning('room %d _get_server_host_list failed: no wbi key', room_id) + return dm_web_cli.DEFAULT_DANMAKU_SERVER_LIST, None + try: - async with utils.request.http_session.get( + async with self._http_session.get( dm_web_cli.DANMAKU_SERVER_CONF_URL, headers={ # token会对UA签名,要使用和客户端一样的UA - 'User-Agent': self.request.headers.get('User-Agent', '') + 'User-Agent': self._user_agent }, - params={ + params=wbi_signer.add_wbi_sign({ 'id': room_id, 'type': 0 - } + }) ) as res: if res.status != 200: logger.warning('room %d _get_server_host_list failed: %d %s', room_id, @@ -401,6 +423,9 @@ async def _get_server_host_list_and_token(self, room_id) -> Tuple[dict, Optional return dm_web_cli.DEFAULT_DANMAKU_SERVER_LIST, None if data['code'] != 0: + if data['code'] == -352: + # wbi签名错误 + wbi_signer.reset() logger.warning('room %d _get_server_host_list failed: %s', room_id, data['message']) return dm_web_cli.DEFAULT_DANMAKU_SERVER_LIST, None @@ -419,15 +444,20 @@ async def _get_buvid(self): return buvid try: - async with utils.request.http_session.get(dm_web_cli.BUVID_INIT_URL): + async with self._http_session.get( + dm_web_cli.BUVID_INIT_URL, + headers={ + # 要使用和后续请求一样的UA + 'User-Agent': self._user_agent + } + ): pass except (aiohttp.ClientConnectionError, asyncio.TimeoutError): pass return self._do_get_buvid() - @staticmethod - def _do_get_buvid(): - cookies = utils.request.http_session.cookie_jar.filter_cookies(yarl.URL(dm_web_cli.BUVID_INIT_URL)) + def _do_get_buvid(self): + cookies = self._http_session.cookie_jar.filter_cookies(yarl.URL(dm_web_cli.BUVID_INIT_URL)) buvid_cookie = cookies.get('buvid3', None) if buvid_cookie is None: return '' diff --git a/api/main.py b/api/main.py index 92085769..0b9ecb1d 100644 --- a/api/main.py +++ b/api/main.py @@ -1,10 +1,13 @@ # -*- coding: utf-8 -*- import asyncio import hashlib +import json import logging import os +import cachetools import tornado.web +import yarl import api.base import config @@ -15,6 +18,10 @@ EMOTICON_UPLOAD_PATH = os.path.join(config.DATA_PATH, 'emoticons') EMOTICON_BASE_URL = '/emoticons' CUSTOM_PUBLIC_PATH = os.path.join(config.DATA_PATH, 'custom_public') +TEMPLATE_PATH = os.path.join(CUSTOM_PUBLIC_PATH, 'templates') +TEMPLATE_BASE_URL = '/custom_public/templates' + +_templates_cache = cachetools.TTLCache(1, 10) class StaticHandler(tornado.web.StaticFileHandler): @@ -100,6 +107,75 @@ def _save_file(body, client): return f'{EMOTICON_BASE_URL}/{filename}' +class TemplatesHandler(api.base.ApiHandler): + async def get(self): + templates = _templates_cache.get('templates', None) + if templates is None: + templates = await asyncio.get_running_loop().run_in_executor(None, self._get_templates) + _templates_cache['templates'] = templates + + self.set_header('Cache-Control', 'private, max-age=10') + self.write({'templates': templates}) + + def _get_templates(self): + template_ids = [] + try: + with os.scandir(TEMPLATE_PATH) as it: + for entry in it: + if entry.is_dir() and os.path.isfile(os.path.join(entry.path, 'template.json')): + template_ids.append(entry.name) + except OSError: + logger.exception('Failed to discover templates:') + return [] + if not template_ids: + return [] + + templates = [] + for template_id in template_ids: + try: + template = self._load_template_config(template_id) + templates.append(template) + except (OSError, json.JSONDecodeError, TypeError, ValueError): + logger.exception('template_id=%s failed to load config:', template_id) + return templates + + @staticmethod + def _load_template_config(template_id): + config_path = os.path.join(TEMPLATE_PATH, template_id, 'template.json') + with open(config_path, encoding='utf-8') as f: + cfg = json.load(f) + if not isinstance(cfg, dict): + raise TypeError(f'Config type error, type={type(cfg)}') + + # 相对于模板目录 + base_url = yarl.URL(f'{TEMPLATE_BASE_URL}/{template_id}/') + + def ensure_abs_url(url_str_): + url_ = yarl.URL(url_str_) + if not url_.absolute: + url_ = base_url.join(url_) + url_str_ = str(url_) + return url_str_ + + thumbnail_url_str = str(cfg.get('thumbnail', '')) + if thumbnail_url_str != '': + thumbnail_url_str = ensure_abs_url(thumbnail_url_str) + + url_str = str(cfg.get('url', '')) + url_str = ensure_abs_url(url_str) + + template = { + 'id': template_id, + 'name': str(cfg.get('name', '')), + 'version': str(cfg.get('version', '')), + 'author': str(cfg.get('author', '')), + 'description': str(cfg.get('description', '')), + 'thumbnail': thumbnail_url_str, + 'url': url_str, + } + return template + + class NoCacheStaticFileHandler(tornado.web.StaticFileHandler): def set_extra_headers(self, path): self.set_header('Cache-Control', 'no-cache') @@ -110,6 +186,7 @@ def set_extra_headers(self, path): (r'/api/endpoints', ServiceDiscoveryHandler), (r'/api/ping', PingHandler), (r'/api/emoticon', UploadEmoticonHandler), + (r'/api/templates', TemplatesHandler), ] # 通配的放在最后 LAST_ROUTES = [ diff --git a/api/open_live.py b/api/open_live.py index 78f4eda1..d35d9496 100644 --- a/api/open_live.py +++ b/api/open_live.py @@ -275,6 +275,17 @@ class EndGamePublicHandler(_PublicHandlerBase): _OPEN_LIVE_URL = END_GAME_OPEN_LIVE_URL _COMMON_SERVER_URL = END_GAME_COMMON_SERVER_URL + def set_default_headers(self): + super().set_default_headers() + + if self._headers.get('Access-Control-Allow-Origin', None) is None: + return + + # 前端sendBeacon跨域发送JSON时会带凭证 + self.set_header('Access-Control-Allow-Credentials', 'true') + self.set_header('Access-Control-Allow-Methods', 'POST') + self.set_header('Access-Control-Allow-Headers', 'Content-Type') + class EndGamePrivateHandler(_PrivateHandlerBase): _OPEN_LIVE_URL = END_GAME_OPEN_LIVE_URL diff --git a/blcsdk/__init__.py b/blcsdk/__init__.py index 26062875..445475e0 100644 --- a/blcsdk/__init__.py +++ b/blcsdk/__init__.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -__version__ = '1.0.0' +__version__ = '1.0.1' from .handlers import * from .client import * diff --git a/blcsdk/models.py b/blcsdk/models.py index b71af774..efc5acf5 100644 --- a/blcsdk/models.py +++ b/blcsdk/models.py @@ -221,6 +221,8 @@ class AddTextMsg: """用户Open ID或ID""" medal_name: str = '' """勋章名""" + is_mirror: bool = False + """是否跨房弹幕,v1.10.2添加""" @classmethod def from_command(cls, data: list): @@ -229,6 +231,7 @@ def from_command(cls, data: list): if content_type == ContentType.EMOTICON: content_type_params = {'url': content_type_params[0]} + data_len = len(data) return cls( avatar_url=data[0], timestamp=data[1], @@ -247,6 +250,7 @@ def from_command(cls, data: list): content_type_params=content_type_params, uid=data[16], medal_name=data[17], + is_mirror=bool(data[18]) if data_len > 18 else False, ) diff --git a/blivedm b/blivedm index 7f400e9c..5e01cdfa 160000 --- a/blivedm +++ b/blivedm @@ -1 +1 @@ -Subproject commit 7f400e9c61d449a0e4212b47f45f0d1e4215157c +Subproject commit 5e01cdfa759a2fb5fdbe6db671cd8acd4e796d73 diff --git a/config.py b/config.py index 19b40d05..5b079416 100644 --- a/config.py +++ b/config.py @@ -174,7 +174,6 @@ def _load_translator_configs(self, config: configparser.ConfigParser): '%E7%BF%BB%E8%AF%91%E6%8E%A5%E5%8F%A3' ) logger.warning('%s is deprecated, please see %s', type_, doc_url) - continue elif type_ == 'TencentTranslate': translator_config['source_language'] = section['source_language'] translator_config['target_language'] = section['target_language'] @@ -187,11 +186,16 @@ def _load_translator_configs(self, config: configparser.ConfigParser): translator_config['app_id'] = section['app_id'] translator_config['secret'] = section['secret'] elif type_ == 'GeminiTranslate': - translator_config['proxy'] = section['proxy'] + logger.warning('%s is deprecated, please migrate to OpenAiApi', type_) + elif type_ == 'OpenAiApi': translator_config['api_key'] = section['api_key'] - translator_config['model_code'] = section['model_code'] + translator_config['base_url'] = section['base_url'] + translator_config['proxy'] = section['proxy'] + translator_config['model'] = section['model'] translator_config['prompt'] = section['prompt'].replace('\n', ' ').replace('\\n', '\n') + translator_config['max_tokens'] = section.getint('max_tokens') translator_config['temperature'] = section.getfloat('temperature') + translator_config['top_p'] = section.getfloat('top_p') else: raise ValueError(f'Invalid translator type: {type_}') except Exception: # noqa diff --git a/data/config.example.ini b/data/config.example.ini index 39f11cd5..3b84ad03 100644 --- a/data/config.example.ini +++ b/data/config.example.ini @@ -138,48 +138,36 @@ app_id = secret = -[gemini_translate] -# 文档:https://ai.google.dev/docs -# 定价:https://ai.google.dev/pricing -# * 目前只有免费版,最高QPS为1 -# * 收费版 输入 $0.000125 / 1000 个字符;输出 $0.000375 / 1000 个字符 -# 模型规格:https://ai.google.dev/models/gemini -# * Gemini Pro 最高QPS为1 +[openai_api] +# 大语言模型对话,支持任何兼容OpenAI API的平台,参考各平台的文档吧 -# 类型:Gemini翻译 -type = GeminiTranslate +# 类型:OpenAI API +type = OpenAiApi # 请求间隔时间(秒),等于 1 / QPS query_interval = 1.1 -# 代理地址,如果为空,不使用代理。注意必须要在指定地区才能使用:https://ai.google.dev/available_regions -proxy = http://127.0.0.1:7890 - # API密钥 api_key = - -# 模型代码 -model_code = models/gemini-1.0-pro -# 提示词 -prompt = As an advanced translation software, your role is to provide accurate translations that respect - the original content's meaning and tone. - Ensure that texts between square brackets "[]" are kept untranslated. - Aim to deliver the translated text in a polite and gentle tone, as per the preferences of the audience.\n\n - Below are some examples to guide you:\n\n - Input1:\n来拉(莱拉)设置一下吧\nOutput1:\nライラちゃん、設定してみましょうか\n\n - Input2:\n我来拉一下各位进队伍\nOutput2:\n皆さんをチームに入れさせていただきます\n\n - Input3:\n死了w\nOutput3:\n死んじゃったわw\n\n - Input4:\n每天来宝这里打个卡[比心]\nOutput4:\n毎日、ここに来て宝さんにチェックインしま[比心]\n\n - \n - Please keep in mind that accurate translation is not just about linguistic conversion; - it is about conveying the same sentiment and meaning as the original input. - Pay special attention to internet vernaculars and colloquial expressions to maintain the nuances of the - source language.\n\n - Now, proceed with the translation of the following Chinese comment text into Japanese, ensuring accuracy - and preservation of the original message:\n - Input:\n{original_text}\nOutput ONLY:\n -# 温度,用于控制令牌选择的随机性,较低的温度适合需要更具确定性或不够开放的回答的提示,而较高的温度可以产生更加多样化或更具创意的结果 -temperature = 0.4 +# API端点,“/chat/completions”之前的部分 +base_url = https://api.deepseek.com/v1 +# 代理地址,如果为空,不使用代理 +# Example: proxy = http://127.0.0.1:7890 +proxy = + +# 模型 +model = deepseek-chat +# 提示词,换行符会替换为空格,“\n”会替换为换行符 +prompt = 你是一个日本主播的翻译助手,精通中日文翻译。 + 用户会输入直播观众的聊天内容,请翻译成日文,并确保符合日文语言习惯。 + 你可以调整语气和风格,并考虑到某些词语的文化内涵和地区差异。 + 只回答翻译结果,不需要解释。 +# 最大输出token数 +max_tokens = 200 +# 采样温度,用于控制令牌选择的随机性。较低的温度适合需要更具确定性或不够开放的回答的提示,而较高的温度可以产生更加多样化或更具创意的结果 +temperature = 1.3 +# 核采样概率阈值,温度采样的替代方案。模型会考虑概率质量在top_p内的token结果 +top_p = 1.0 # 傻逼B站,获取表情都要登录,开放平台也不发文本表情的URL,我服了 diff --git a/data/custom_public/templates/.gitkeep b/data/custom_public/templates/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/frontend/.eslintignore b/frontend/.eslintignore index 96be2c3a..e684bb40 100644 --- a/frontend/.eslintignore +++ b/frontend/.eslintignore @@ -1,2 +1,3 @@ brotli_decode.js pronunciation/dict*.js +blcsdk.js diff --git a/frontend/package.json b/frontend/package.json index 762016f6..b9e119ac 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "blivechat", - "version": "1.9.3", + "version": "1.10.2", "private": true, "scripts": { "serve": "vue-cli-service serve", diff --git a/frontend/public/index.html b/frontend/public/index.html index 0bc75e6f..b7041dbb 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -15,21 +15,21 @@ if (process.env.LIB_USE_CDN) { if (process.env.NODE_ENV === 'production') { %> - - - - - - + + + + + + <% } else { %> - - - - - - + + + + + + <% } } diff --git a/frontend/src/api/base.js b/frontend/src/api/base.js index 3c6cb568..9f1d030e 100644 --- a/frontend/src/api/base.js +++ b/frontend/src/api/base.js @@ -9,9 +9,10 @@ export const apiClient = axios.create({ }) export let init +export let ensureBaseUrlInited export let getBaseUrl if (!process.env.BACKEND_DISCOVERY) { - init = async function() {} + init = function() {} const onRequest = config => { config.baseURL = getBaseUrl() @@ -24,16 +25,19 @@ if (!process.env.BACKEND_DISCOVERY) { apiClient.interceptors.request.use(onRequest, onRequestError, { synchronous: true }) + ensureBaseUrlInited = async function() {} + getBaseUrl = function() { return window.location.origin } } else { - init = async function() { - return updateBaseUrls() + init = function() { + updateBaseUrls() } - const onRequest = config => { + const onRequest = async config => { + await firstInitPromise let baseUrl = getBaseUrl() if (baseUrl === null) { throw new Error('No available endpoint') @@ -64,7 +68,7 @@ if (!process.env.BACKEND_DISCOVERY) { return promise } - apiClient.interceptors.request.use(onRequest, onRequestError, { synchronous: true }) + apiClient.interceptors.request.use(onRequest, onRequestError) apiClient.interceptors.response.use(onResponse, onResponseError) const DISCOVERY_URLS = process.env.NODE_ENV === 'production' ? [ @@ -85,6 +89,13 @@ if (!process.env.BACKEND_DISCOVERY) { let curBaseUrl = null let baseUrlToCircuitBreaker = new Map() + let firstInitResolve = null + let firstInitPromise = new Promise(resolve => { + firstInitResolve = resolve + }).then(() => { + firstInitResolve = null + }) + const doUpdateBaseUrls = async() => { async function requestGetUrls(discoveryUrl) { try { @@ -115,6 +126,14 @@ if (!process.env.BACKEND_DISCOVERY) { let url = `${baseUrl}/api/ping` await axios.get(url, { timeout: 3 * 1000 }) sortedBaseUrls.push(baseUrl) + + if (firstInitResolve) { + if (curBaseUrl === null) { + curBaseUrl = baseUrl + console.log('Switch server endpoint to', curBaseUrl) + } + firstInitResolve() + } } catch { errorBaseUrls.push(baseUrl) } @@ -124,14 +143,23 @@ if (!process.env.BACKEND_DISCOVERY) { sortedBaseUrls = sortedBaseUrls.concat(errorBaseUrls) baseUrls = sortedBaseUrls - if (baseUrls.indexOf(curBaseUrl) === -1) { + if (curBaseUrl !== null && baseUrls.indexOf(curBaseUrl) === -1) { curBaseUrl = null } + if (firstInitResolve) { + // 全失败了则在这里resolve + firstInitResolve() + } + console.log('Found server endpoints:', baseUrls) } const updateBaseUrls = _.throttle(doUpdateBaseUrls, 3 * 60 * 1000) + ensureBaseUrlInited = async function() { + return firstInitPromise + } + getBaseUrl = function() { updateBaseUrls() diff --git a/frontend/src/api/chat/ChatClientDirectOpenLive.js b/frontend/src/api/chat/ChatClientDirectOpenLive.js index 75a750f8..757f51da 100644 --- a/frontend/src/api/chat/ChatClientDirectOpenLive.js +++ b/frontend/src/api/chat/ChatClientDirectOpenLive.js @@ -1,4 +1,4 @@ -import { apiClient as axios } from '@/api/base' +import { apiClient as axios, getBaseUrl } from '@/api/base' import * as chat from '.' import * as chatModels from './models' import * as base from './ChatClientOfficialBase' @@ -13,6 +13,8 @@ export default class ChatClientDirectOpenLive extends ChatClientOfficialBase { this.roomOwnerAuthCode = roomOwnerAuthCode + this.boundEndGameBeforeUnload = this.endGameBeforeUnload.bind(this) + // 调用initRoom后初始化 this.roomOwnerOpenId = null this.hostServerUrlList = [] @@ -22,7 +24,15 @@ export default class ChatClientDirectOpenLive extends ChatClientOfficialBase { this.gameHeartbeatTimerId = null } + start() { + super.start() + + window.addEventListener('beforeunload', this.boundEndGameBeforeUnload) + } + stop() { + window.removeEventListener('beforeunload', this.boundEndGameBeforeUnload) + this.endGame() super.stop() @@ -99,6 +109,29 @@ export default class ChatClientDirectOpenLive extends ChatClientOfficialBase { return true } + endGameBeforeUnload() { + let baseUrl = getBaseUrl() + if (baseUrl === null) { + return + } + + this.needInitRoom = true + if (!this.gameId) { + return + } + let gameId = this.gameId + // 直接丢弃将要关闭的gameId + this.gameId = null + + let url = `${baseUrl}/api/open_live/end_game` + let body = { + app_id: 0, + game_id: gameId + } + body = new Blob([JSON.stringify(body)], { type: 'application/json' }) + window.navigator.sendBeacon(url, body) + } + onSendGameHeartbeat() { // 加上随机延迟,减少同时请求的概率 let sleepTime = GAME_HEARTBEAT_INTERVAL - (2 * 1000) + (Math.random() * 3 * 1000) @@ -181,18 +214,43 @@ export default class ChatClientDirectOpenLive extends ChatClientOfficialBase { super.delayReconnect() } - async dmCallback(command) { + interactionEndCallback(command) { + if (command.data.game_id !== this.gameId) { + return + } + // 服务器主动停止推送,可能是心跳超时,需要重新开启项目 + console.error(`Open Live session end by server, gameId=${this.gameId}`) + this.addDebugMsg('Open Live session end by server') + + this.gameId = null + this.needInitRoom = true + this.discardWebsocket() + } + + dmMirrorCallback(command) { + command._isMirror = true + this.dmCallback(command) + } + + dmCallback(command) { let data = command.data let authorType if (data.open_id === this.roomOwnerOpenId) { authorType = 3 + } else if (data.is_admin) { + authorType = 2 } else if (data.guard_level !== 0) { authorType = 1 } else { authorType = 0 } + let showContent = data.msg + if (data.reply_uname !== '') { + showContent = `@${data.reply_uname} ${showContent}` + } + let emoticon = null if (data.dm_type === 1) { emoticon = data.emoji_img_url @@ -203,19 +261,23 @@ export default class ChatClientDirectOpenLive extends ChatClientOfficialBase { timestamp: data.timestamp, authorName: data.uname, authorType: authorType, - content: data.msg, + content: showContent, privilegeType: data.guard_level, isGiftDanmaku: chat.isGiftDanmakuByContent(data.msg), medalLevel: data.fans_medal_wearing_status ? data.fans_medal_level : 0, id: data.msg_id, emoticon: emoticon, + isMirror: Boolean(command._isMirror), + // 给模板用的字段 + uid: data.open_id, + medalName: data.fans_medal_wearing_status ? data.fans_medal_name : '', }) this.msgHandler.onAddText(data) } sendGiftCallback(command) { let data = command.data - let totalCoin = data.price * data.gift_num + let totalCoin = data.r_price * data.gift_num data = new chatModels.AddGiftMsg({ id: data.msg_id, avatarUrl: chat.processAvatarUrl(data.uface), @@ -224,19 +286,33 @@ export default class ChatClientDirectOpenLive extends ChatClientOfficialBase { totalCoin: data.paid ? totalCoin : 0, totalFreeCoin: !data.paid ? totalCoin : 0, giftName: data.gift_name, - num: data.gift_num + num: data.gift_num, + // 给模板用的字段 + giftId: data.gift_id, + giftIconUrl: data.gift_icon, + uid: data.open_id, + privilegeType: data.guard_level, + medalLevel: data.fans_medal_wearing_status ? data.fans_medal_level : 0, + medalName: data.fans_medal_wearing_status ? data.fans_medal_name : '', }) this.msgHandler.onAddGift(data) } - async guardCallback(command) { + guardCallback(command) { let data = command.data data = new chatModels.AddMemberMsg({ id: data.msg_id, avatarUrl: chat.processAvatarUrl(data.user_info.uface), timestamp: data.timestamp, authorName: data.user_info.uname, - privilegeType: data.guard_level + privilegeType: data.guard_level, + // 给模板用的字段 + num: data.guard_num, + unit: data.guard_unit, + total_coin: data.price * data.guard_num, + uid: data.user_info.open_id, + medalLevel: data.fans_medal_wearing_status ? data.fans_medal_level : 0, + medalName: data.fans_medal_wearing_status ? data.fans_medal_name : '', }) this.msgHandler.onAddMember(data) } @@ -250,6 +326,11 @@ export default class ChatClientDirectOpenLive extends ChatClientOfficialBase { authorName: data.uname, price: data.rmb, content: data.message, + // 给模板用的字段 + uid: data.open_id, + privilegeType: data.guard_level, + medalLevel: data.fans_medal_wearing_status ? data.fans_medal_level : 0, + medalName: data.fans_medal_wearing_status ? data.fans_medal_name : '', }) this.msgHandler.onAddSuperChat(data) } @@ -265,7 +346,9 @@ export default class ChatClientDirectOpenLive extends ChatClientOfficialBase { } const CMD_CALLBACK_MAP = { + LIVE_OPEN_PLATFORM_INTERACTION_END: ChatClientDirectOpenLive.prototype.interactionEndCallback, LIVE_OPEN_PLATFORM_DM: ChatClientDirectOpenLive.prototype.dmCallback, + LIVE_OPEN_PLATFORM_DM_MIRROR: ChatClientDirectOpenLive.prototype.dmMirrorCallback, LIVE_OPEN_PLATFORM_SEND_GIFT: ChatClientDirectOpenLive.prototype.sendGiftCallback, LIVE_OPEN_PLATFORM_GUARD: ChatClientDirectOpenLive.prototype.guardCallback, LIVE_OPEN_PLATFORM_SUPER_CHAT: ChatClientDirectOpenLive.prototype.superChatCallback, diff --git a/frontend/src/api/chat/ChatClientDirectWeb.js b/frontend/src/api/chat/ChatClientDirectWeb.js index 7030f611..7c82138c 100644 --- a/frontend/src/api/chat/ChatClientDirectWeb.js +++ b/frontend/src/api/chat/ChatClientDirectWeb.js @@ -67,15 +67,28 @@ export default class ChatClientDirectWeb extends ChatClientOfficialBase { this.websocket.send(this.makePacket(authParams, base.OP_AUTH)) } + async danmuMsgMirrorCallback(command) { + command._isMirror = true + this.danmuMsgCallback(command) + } + async danmuMsgCallback(command) { let info = command.info - let roomId, medalLevel + let modeInfo = info[0][15] + let avatarUrl = modeInfo?.user?.base?.face ?? '' + if (!avatarUrl) { + avatarUrl = await chat.getAvatarUrl(uid, authorName) + } + + let medalRoomId, medalLevel, medalName if (info[3]) { - roomId = info[3][3] + medalRoomId = info[3][3] medalLevel = info[3][0] + medalName = info[3][1] } else { - roomId = medalLevel = 0 + medalRoomId = medalLevel = 0 + medalName = '' } let uid = info[2][0] @@ -92,21 +105,41 @@ export default class ChatClientDirectWeb extends ChatClientOfficialBase { authorType = 0 } - let authorName = info[2][1] + let extra + try { + extra = modeInfo.extra + if (typeof extra !== 'object') { + extra = JSON.parse(extra) + } + } catch { + extra = {} + } + let content = info[1] + let replyUname = extra.reply_uname ?? '' + let showContent = content + if (replyUname !== '') { + showContent = `@${replyUname} ${showContent}` + } + + let authorName = info[2][1] let data = new chatModels.AddTextMsg({ - avatarUrl: await chat.getAvatarUrl(uid, authorName), + avatarUrl: avatarUrl, timestamp: info[0][4] / 1000, authorName: authorName, authorType: authorType, - content: content, + content: showContent, privilegeType: privilegeType, isGiftDanmaku: Boolean(info[0][9]) || chat.isGiftDanmakuByContent(content), authorLevel: info[4][0], isNewbie: info[2][5] < 10000, isMobileVerified: Boolean(info[2][6]), - medalLevel: roomId === this.roomId ? medalLevel : 0, + medalLevel: medalRoomId === this.roomId ? medalLevel : 0, emoticon: info[0][13].url || null, + isMirror: Boolean(command._isMirror), + // 给模板用的字段 + uid: info[2][0] ? info[2][0].toString() : authorName, + medalName: medalRoomId === this.roomId ? medalName : '', }) this.msgHandler.onAddText(data) } @@ -114,6 +147,17 @@ export default class ChatClientDirectWeb extends ChatClientOfficialBase { sendGiftCallback(command) { let data = command.data let isPaidGift = data.coin_type === 'gold' + + let medalRuid, medalLevel, medalName + if (data.medal_info) { + medalRuid = data.medal_info.target_id + medalLevel = data.medal_info.medal_level + medalName = data.medal_info.medal_name + } else { + medalRuid = medalLevel = 0 + medalName = '' + } + data = new chatModels.AddGiftMsg({ avatarUrl: chat.processAvatarUrl(data.face), timestamp: data.timestamp, @@ -121,24 +165,71 @@ export default class ChatClientDirectWeb extends ChatClientOfficialBase { totalCoin: isPaidGift ? data.total_coin : 0, totalFreeCoin: !isPaidGift ? data.total_coin : 0, giftName: data.giftName, - num: data.num + num: data.num, + // 给模板用的字段 + giftId: data.giftId, + giftIconUrl: data.gift_info.img_basic, + uid: data.uid ? data.uid.toString() : data.uname, + privilegeType: data.guard_level, + medalLevel: medalRuid === this.roomOwnerUid ? medalLevel : 0, + medalName: medalRuid === this.roomOwnerUid ? medalName : '', }) this.msgHandler.onAddGift(data) } - async guardBuyCallback(command) { + async userToastV2Callback(command) { let data = command.data + // 官方的评论栏不会显示2的消息 + if (data.option.source == 2) { + return + } + + let { + sender_uinfo: { + uid, + base: { + name: username, + }, + }, + guard_info: { + start_time: timestamp, + guard_level: guardLevel, + }, + pay_info: { + num, + unit, + price, + }, + } = data data = new chatModels.AddMemberMsg({ - avatarUrl: await chat.getAvatarUrl(data.uid, data.username), - timestamp: data.start_time, - authorName: data.username, - privilegeType: data.guard_level + avatarUrl: await chat.getAvatarUrl(uid, username), + timestamp: timestamp, + authorName: username, + privilegeType: guardLevel, + // 给模板用的字段 + num: num, + unit: unit, + total_coin: price * num, + uid: uid ? uid.toString() : username, + medalLevel: 0, + medalName: '', }) this.msgHandler.onAddMember(data) } superChatMessageCallback(command) { let data = command.data + + let medalRoomId, medalLevel, medalName + if (data.medal_info) { + medalRoomId = data.medal_info.anchor_roomid + medalLevel = data.medal_info.medal_level + medalName = data.medal_info.medal_name + } else { + medalRoomId = medalLevel = 0 + medalName = '' + } + data = new chatModels.AddSuperChatMsg({ id: data.id.toString(), avatarUrl: chat.processAvatarUrl(data.user_info.face), @@ -146,6 +237,11 @@ export default class ChatClientDirectWeb extends ChatClientOfficialBase { authorName: data.user_info.uname, price: data.price, content: data.message, + // 给模板用的字段 + uid: data.uid ? data.uid.toString() : data.user_info.uname, + privilegeType: data.user_info.guard_level, + medalLevel: medalRoomId === this.roomId ? medalLevel : 0, + medalName: medalRoomId === this.roomId ? medalName : '', }) this.msgHandler.onAddSuperChat(data) } @@ -162,8 +258,9 @@ export default class ChatClientDirectWeb extends ChatClientOfficialBase { const CMD_CALLBACK_MAP = { DANMU_MSG: ChatClientDirectWeb.prototype.danmuMsgCallback, + DANMU_MSG_MIRROR: ChatClientDirectWeb.prototype.danmuMsgMirrorCallback, SEND_GIFT: ChatClientDirectWeb.prototype.sendGiftCallback, - GUARD_BUY: ChatClientDirectWeb.prototype.guardBuyCallback, + USER_TOAST_MSG_V2: ChatClientDirectWeb.prototype.userToastV2Callback, SUPER_CHAT_MESSAGE: ChatClientDirectWeb.prototype.superChatMessageCallback, SUPER_CHAT_MESSAGE_DELETE: ChatClientDirectWeb.prototype.superChatMessageDeleteCallback } diff --git a/frontend/src/api/chat/ChatClientRelay.js b/frontend/src/api/chat/ChatClientRelay.js index e5cfde19..cb16862e 100644 --- a/frontend/src/api/chat/ChatClientRelay.js +++ b/frontend/src/api/chat/ChatClientRelay.js @@ -1,4 +1,4 @@ -import { getBaseUrl } from '@/api/base' +import { ensureBaseUrlInited, getBaseUrl } from '@/api/base' import * as chat from '.' import * as chatModels from './models' @@ -46,13 +46,14 @@ export default class ChatClientRelay { this.msgHandler.onDebugMsg(new chatModels.DebugMsg({ content })) } - wsConnect() { + async wsConnect() { if (this.isDestroying) { return } this.addDebugMsg('Connecting') + await ensureBaseUrlInited() let baseUrl = getBaseUrl() if (baseUrl === null) { this.addDebugMsg('No available endpoint') @@ -180,7 +181,10 @@ export default class ChatClientRelay { medalLevel: data[10], id: data[11], translation: data[12], - emoticon: emoticon + emoticon: emoticon, + isMirror: Boolean(data[18]), + uid: data[16], + medalName: data[17], }) this.msgHandler.onAddText(data) break diff --git a/frontend/src/api/chat/ChatClientTest.js b/frontend/src/api/chat/ChatClientTest.js index e819e46f..759d7f29 100644 --- a/frontend/src/api/chat/ChatClientTest.js +++ b/frontend/src/api/chat/ChatClientTest.js @@ -3,59 +3,61 @@ import * as chat from '.' import * as chatModels from './models' const NAMES = [ - '光羊', - '黑炎', - '孤梦星影', + '成龙', + '杨戬', + '孙悟空', + '哈基米', + '大张伟', + '周冠宇', + '五条悟', '博丽灵梦', - '雾雨魔理沙', + '御剑侍伶', + '田所浩二', + '小岛秀夫', + '長崎そよ', + '柚木つばめ', '空條承太郎', + 'みもりあいの', 'ディオ・ブランドー', - 'ジョセフ・ジョースター', - 'ジョナサン・ジョースター', - 'Simon', - 'Misty', - 'Kinori', - 'shugen', - '3Shain', - 'yuyuyzl', + 'Dante', + 'xQcOW', + 'Makarov', 'xfgryujk', - 'Il Harper', + 'Jim Hacker', 'Rick Astley', + 'Tifa Lockhart', + 'Arthur Morgan', ] const CONTENTS = [ '草', + '会赢的', '让我看看', - '不要停下来啊', + '卑鄙的外乡人', '我不做人了,JOJO', + '想吃广东菜✋😭✋', '已经没有什么好怕的了', - '我柜子动了,我不玩了', - '老板大气,老板身体健康', - '我醉提酒游寒山,爽滑慢舔', + '你这猴子,真令我欢喜', '[dog]文本[比心]表情[喝彩]', + '阿祖,投降吧,外面全是警察', '無駄無駄無駄無駄無駄無駄無駄無駄', - '欧啦欧啦欧啦欧啦欧啦欧啦欧啦欧啦', - '所有没好全部康复呀,我的癌也全部康复呀', - '嚯,朝我走过来了吗,没有选择逃跑而是主动接近我么', - '有一说一,这件事大家懂的都懂,不懂的,说了你也不明白,不如不说', - '如来来了吗?如来嘛~他真来了吗?如~来~到底来没来?如来~如来他真来了吗?如来~你看看,来没来?如~来~', + '我衰咗三年,我等緊個機會,爭番口氣', + '因为你的缘故,我的心中萌生了多余的情感', '迷えば、敗れる', '逃げるんだよォ!', - '竜神の剣を喰らえ!', - '竜が我が敌を喰らう!', - '言いたいことがあるんだよ!', - '知らず知らず隠してた 本当の声を響かせてよほら', + '届かない恋をしていても', + 'なんで春日影やったの!?', 'kksk', + 'Y.M.C.A.', '8888888888', 'text[吃瓜]emoticon', + 'Remember... no Russian', 'Never gonna give you up', - 'Never gonna let you down', - '888888888888888888888888888888', - 'I am the storm that is approaching', - "I can eat glass, it doesn't hurt me", - 'The quick brown fox jumps over the lazy dog', + 'DU↗DU→DU↗DU↓ Max Verstappen', 'Farewell, ashen one. May the flame guide thee', - 'I am the bone of my sword. Steel is my body, and fire is my blood.', + 'Hey Vergil, your portal opening days are over. Give me the Yamato', + '', + '', ] const EMOTICONS = [ @@ -64,7 +66,13 @@ const EMOTICONS = [ 'lipu', 'huangdou_xihuan', 'sakaban_jiayu_yutou', -].map(name => `/static/img/emoticons/${name}.png`) +].map(name => `${window.location.origin}/static/img/emoticons/${name}.png`) + +const TRANSLATIONS = [ + '这是翻译', + 'これは翻訳です', + 'blah blah blah', +] const AUTHOR_TYPES = [ { weight: 10, value: constants.AUTHOR_TYPE_NORMAL }, @@ -73,6 +81,10 @@ const AUTHOR_TYPES = [ { weight: 1, value: constants.AUTHOR_TYPE_OWNER } ] +const GUARD_LEVEL_TO_PRICE = [ + 0, 19998, 1998, 198 +] + function randGuardInfo() { let authorType = randomChoose(AUTHOR_TYPES) let privilegeType @@ -86,14 +98,28 @@ function randGuardInfo() { return { authorType, privilegeType } } +const MEDAL_NAMES = [ + 'ikun', + 'K学姐', + '小孤独', + 'Go学长', + '不登校', +] + +function randMedalInfo() { + let medalLevel = randInt(1, 4) <= 1 ? 0 : randInt(1, 40) + let medalName = medalLevel === 0 ? '' : randomChoose(MEDAL_NAMES) + return { medalLevel, medalName } +} + const GIFT_INFO_LIST = [ - { giftName: '辣条', totalFreeCoin: 1000, num: 10 }, - { giftName: 'B坷垃', totalCoin: 9900 }, - { giftName: '礼花', totalCoin: 28000 }, - { giftName: '花式夸夸', totalCoin: 39000 }, - { giftName: '天空之翼', totalCoin: 100000 }, - { giftName: '摩天大楼', totalCoin: 450000 }, - { giftName: '小电视飞船', totalCoin: 1245000 } + { giftId: 1, giftName: '粉丝团灯牌', totalFreeCoin: 200, num: 10, giftIconUrl: '//s1.hdslb.com/bfs/live/e051dfd4557678f8edcac4993ed00a0935cbd9cc.png' }, + { giftId: 2, giftName: '可爱捏', totalCoin: 9900, giftIconUrl: '//s1.hdslb.com/bfs/live/6dab14826b531c731521345e00d6b56a6708a449.png' }, + { giftId: 3, giftName: '花式夸夸', totalCoin: 29900, giftIconUrl: '//s1.hdslb.com/bfs/live/28186596880db45a7b843f17d6ebb70feeac06f9.png' }, + { giftId: 4, giftName: '情书', totalCoin: 52000, num: 10, giftIconUrl: '//s1.hdslb.com/bfs/live/14dafbf217618f0931c08897e0b3eefc00d0da22.png' }, + { giftId: 5, giftName: '极速超跑', totalCoin: 100000, giftIconUrl: '//s1.hdslb.com/bfs/live/27b9734d1a5f77ea6fc94957e3fcbeb55505c6b9.png' }, + { giftId: 6, giftName: '为你摘星', totalCoin: 520000, giftIconUrl: '//s1.hdslb.com/bfs/live/5bd584b6fdfb03d66de56102e775582fb29ceab7.png' }, + { giftId: 7, giftName: '次元之城', totalCoin: 1245000, giftIconUrl: '//s1.hdslb.com/bfs/live/cdae8136b1ee767609aeec688bca8124651d4d01.png' } ] const SC_PRICES = [ @@ -109,13 +135,14 @@ const MESSAGE_GENERATORS = [ type: constants.MESSAGE_TYPE_TEXT, message: new chatModels.AddTextMsg({ ...randGuardInfo(), + ...randMedalInfo(), authorName: randomChoose(NAMES), content: randomChoose(CONTENTS), isGiftDanmaku: randInt(1, 10) <= 1, authorLevel: randInt(1, 60), isNewbie: randInt(1, 10) <= 1, isMobileVerified: randInt(1, 10) <= 9, - medalLevel: randInt(0, 40), + translation: randInt(1, 10) <= 1 ? randomChoose(TRANSLATIONS) : '', }) } } @@ -128,11 +155,11 @@ const MESSAGE_GENERATORS = [ type: constants.MESSAGE_TYPE_TEXT, message: new chatModels.AddTextMsg({ ...randGuardInfo(), + ...randMedalInfo(), authorName: randomChoose(NAMES), authorLevel: randInt(1, 60), isNewbie: randInt(1, 10) <= 1, isMobileVerified: randInt(1, 10) <= 9, - medalLevel: randInt(0, 40), emoticon: randomChoose(EMOTICONS), }) } @@ -145,8 +172,10 @@ const MESSAGE_GENERATORS = [ return { type: constants.MESSAGE_TYPE_GIFT, message: new chatModels.AddGiftMsg({ + ...randMedalInfo(), ...randomChoose(GIFT_INFO_LIST), authorName: randomChoose(NAMES), + privilegeType: randInt(0, 3), }) } } @@ -158,9 +187,12 @@ const MESSAGE_GENERATORS = [ return { type: constants.MESSAGE_TYPE_SUPER_CHAT, message: new chatModels.AddSuperChatMsg({ + ...randMedalInfo(), authorName: randomChoose(NAMES), price: randomChoose(SC_PRICES), content: randomChoose(CONTENTS), + translation: randInt(1, 10) <= 1 ? randomChoose(TRANSLATIONS) : '', + privilegeType: randInt(0, 3), }) } } @@ -169,11 +201,14 @@ const MESSAGE_GENERATORS = [ { weight: 1, value() { + let privilegeType = randInt(1, 3) return { type: constants.MESSAGE_TYPE_MEMBER, message: new chatModels.AddMemberMsg({ + ...randMedalInfo(), authorName: randomChoose(NAMES), - privilegeType: randInt(1, 3) + privilegeType: privilegeType, + total_coin: GUARD_LEVEL_TO_PRICE[privilegeType] * 1000, }) } } @@ -251,6 +286,7 @@ export default class ChatClientTest { switch (type) { case constants.MESSAGE_TYPE_TEXT: this.msgHandler.onAddText(message) + this.maybeTranslate(message) break case constants.MESSAGE_TYPE_GIFT: this.msgHandler.onAddGift(message) @@ -260,7 +296,34 @@ export default class ChatClientTest { break case constants.MESSAGE_TYPE_SUPER_CHAT: this.msgHandler.onAddSuperChat(message) + this.maybeTranslate(message) + this.maybeDeleteSc(message) break } } + + maybeTranslate(message) { + if (message.translation || randInt(1, 4) <= 1) { + return + } + window.setTimeout(() => { + let translateMessage = new chatModels.UpdateTranslationMsg({ + id: message.id, + translation: randomChoose(TRANSLATIONS), + }) + this.msgHandler.onUpdateTranslation(translateMessage) + }, randInt(1000, 3000)) + } + + maybeDeleteSc(message) { + if (randInt(1, 5) <= 4) { + return + } + window.setTimeout(() => { + let deleteMessage = new chatModels.DelSuperChatMsg({ + ids: [message.id], + }) + this.msgHandler.onDelSuperChat(deleteMessage) + }, randInt(1000, 3000)) + } } diff --git a/frontend/src/api/chat/models.js b/frontend/src/api/chat/models.js index 96d7e2a1..a4d05e37 100644 --- a/frontend/src/api/chat/models.js +++ b/frontend/src/api/chat/models.js @@ -18,6 +18,10 @@ export class AddTextMsg { id = getUuid4Hex(), translation = '', emoticon = null, + isMirror = false, + // 给模板用的字段 + uid = '', + medalName = '', } = {}) { this.avatarUrl = avatarUrl this.timestamp = timestamp @@ -33,6 +37,10 @@ export class AddTextMsg { this.id = id this.translation = translation this.emoticon = emoticon + this.isMirror = isMirror + // 给模板用的字段 + this.uid = uid + this.medalName = medalName } } @@ -46,6 +54,13 @@ export class AddGiftMsg { totalFreeCoin = 0, giftName = '', num = 1, + // 给模板用的字段 + giftId = 0, + giftIconUrl = '', + uid = '', + privilegeType = 0, + medalLevel = 0, + medalName = '', } = {}) { this.id = id this.avatarUrl = avatarUrl @@ -55,6 +70,13 @@ export class AddGiftMsg { this.totalFreeCoin = totalFreeCoin this.giftName = giftName this.num = num + // 给模板用的字段 + this.giftId = giftId + this.giftIconUrl = giftIconUrl + this.uid = uid + this.privilegeType = privilegeType + this.medalLevel = medalLevel + this.medalName = medalName } } @@ -65,12 +87,26 @@ export class AddMemberMsg { timestamp = new Date().getTime() / 1000, authorName = '', privilegeType = 1, + // 给模板用的字段 + num = 1, + unit = '月', + total_coin = 0, + uid = '', + medalLevel = 0, + medalName = '', } = {}) { this.id = id this.avatarUrl = avatarUrl this.timestamp = timestamp this.authorName = authorName this.privilegeType = privilegeType + // 给模板用的字段 + this.num = num + this.unit = unit + this.totalCoin = total_coin + this.uid = uid + this.medalLevel = medalLevel + this.medalName = medalName } } @@ -83,6 +119,11 @@ export class AddSuperChatMsg { price = 0, content = '', translation = '', + // 给模板用的字段 + uid = '', + privilegeType = 0, + medalLevel = 0, + medalName = '', } = {}) { this.id = id this.avatarUrl = avatarUrl @@ -91,6 +132,11 @@ export class AddSuperChatMsg { this.price = price this.content = content this.translation = translation + // 给模板用的字段 + this.uid = uid + this.privilegeType = privilegeType + this.medalLevel = medalLevel + this.medalName = medalName } } diff --git a/frontend/src/api/chatConfig.js b/frontend/src/api/chatConfig.js index f2fdf2fd..33efd120 100644 --- a/frontend/src/api/chatConfig.js +++ b/frontend/src/api/chatConfig.js @@ -12,6 +12,7 @@ export const DEFAULT_CONFIG = { maxNumber: 60, blockGiftDanmaku: true, + blockMirrorMessages: false, blockLevel: 0, blockNewbie: false, blockNotMobileVerified: false, @@ -25,7 +26,9 @@ export const DEFAULT_CONFIG = { giftUsernamePronunciation: '', importPresetCss: false, - emoticons: [] // [{ keyword: '', url: '' }, ...] + emoticons: [], // [{ keyword: '', url: '' }, ...] + + templateUrl: '', } export function deepCloneDefaultConfig() { diff --git a/frontend/src/api/main.js b/frontend/src/api/main.js index 4fd822a3..bec9d781 100644 --- a/frontend/src/api/main.js +++ b/frontend/src/api/main.js @@ -9,3 +9,7 @@ export async function uploadEmoticon(file) { body.set('file', file) return (await axios.post('/api/emoticon', body)).data } + +export async function getTemplates() { + return (await axios.get('/api/templates')).data +} diff --git a/frontend/src/assets/css/youtube/yt-html.css b/frontend/src/assets/css/youtube/yt-html.css index 75dc6fce..1c4a78ac 100644 --- a/frontend/src/assets/css/youtube/yt-html.css +++ b/frontend/src/assets/css/youtube/yt-html.css @@ -1,3 +1,5 @@ +@layer yt { + html:not(.style-scope) { --yt-live-chat-background-color: hsl(0, 0%, 100%); --yt-live-chat-action-panel-background-color: hsla(0, 0%, 93.3%, .4); @@ -360,3 +362,5 @@ html:not(.style-scope) { --layout-fixed-left_-_left: 0; ; } + +} diff --git a/frontend/src/assets/css/youtube/yt-icon.css b/frontend/src/assets/css/youtube/yt-icon.css index c21acf27..6af70c6e 100644 --- a/frontend/src/assets/css/youtube/yt-icon.css +++ b/frontend/src/assets/css/youtube/yt-icon.css @@ -1,3 +1,5 @@ +@layer yt { + canvas.yt-icon, caption.yt-icon, center.yt-icon, cite.yt-icon, code.yt-icon, dd.yt-icon, del.yt-icon, dfn.yt-icon, div.yt-icon, dl.yt-icon, dt.yt-icon, em.yt-icon, embed.yt-icon, fieldset.yt-icon, font.yt-icon, form.yt-icon, h1.yt-icon, h2.yt-icon, h3.yt-icon, h4.yt-icon, h5.yt-icon, h6.yt-icon, hr.yt-icon, i.yt-icon, iframe.yt-icon, img.yt-icon, ins.yt-icon, kbd.yt-icon, label.yt-icon, legend.yt-icon, li.yt-icon, menu.yt-icon, object.yt-icon, ol.yt-icon, p.yt-icon, pre.yt-icon, q.yt-icon, s.yt-icon, samp.yt-icon, small.yt-icon, span.yt-icon, strike.yt-icon, strong.yt-icon, sub.yt-icon, sup.yt-icon, table.yt-icon, tbody.yt-icon, td.yt-icon, tfoot.yt-icon, th.yt-icon, thead.yt-icon, tr.yt-icon, tt.yt-icon, u.yt-icon, ul.yt-icon, var.yt-icon { margin: 0; padding: 0; @@ -28,3 +30,5 @@ yt-icon, .yt-icon-container.yt-icon { yt-icon.external-container { display: none !important; } + +} diff --git a/frontend/src/assets/css/youtube/yt-img-shadow.css b/frontend/src/assets/css/youtube/yt-img-shadow.css index e4ea828e..eac9967d 100644 --- a/frontend/src/assets/css/youtube/yt-img-shadow.css +++ b/frontend/src/assets/css/youtube/yt-img-shadow.css @@ -1,3 +1,5 @@ +@layer yt { + canvas.yt-img-shadow, caption.yt-img-shadow, center.yt-img-shadow, cite.yt-img-shadow, code.yt-img-shadow, dd.yt-img-shadow, del.yt-img-shadow, dfn.yt-img-shadow, div.yt-img-shadow, dl.yt-img-shadow, dt.yt-img-shadow, em.yt-img-shadow, embed.yt-img-shadow, fieldset.yt-img-shadow, font.yt-img-shadow, form.yt-img-shadow, h1.yt-img-shadow, h2.yt-img-shadow, h3.yt-img-shadow, h4.yt-img-shadow, h5.yt-img-shadow, h6.yt-img-shadow, hr.yt-img-shadow, i.yt-img-shadow, iframe.yt-img-shadow, img.yt-img-shadow, ins.yt-img-shadow, kbd.yt-img-shadow, label.yt-img-shadow, legend.yt-img-shadow, li.yt-img-shadow, menu.yt-img-shadow, object.yt-img-shadow, ol.yt-img-shadow, p.yt-img-shadow, pre.yt-img-shadow, q.yt-img-shadow, s.yt-img-shadow, samp.yt-img-shadow, small.yt-img-shadow, span.yt-img-shadow, strike.yt-img-shadow, strong.yt-img-shadow, sub.yt-img-shadow, sup.yt-img-shadow, table.yt-img-shadow, tbody.yt-img-shadow, td.yt-img-shadow, tfoot.yt-img-shadow, th.yt-img-shadow, thead.yt-img-shadow, tr.yt-img-shadow, tt.yt-img-shadow, u.yt-img-shadow, ul.yt-img-shadow, var.yt-img-shadow { margin: 0; padding: 0; @@ -66,3 +68,5 @@ img.yt-img-shadow { max-width: 100%; border-radius: none; } + +} diff --git a/frontend/src/assets/css/youtube/yt-live-chat-author-badge-renderer.css b/frontend/src/assets/css/youtube/yt-live-chat-author-badge-renderer.css index 9a0b8409..59013081 100644 --- a/frontend/src/assets/css/youtube/yt-live-chat-author-badge-renderer.css +++ b/frontend/src/assets/css/youtube/yt-live-chat-author-badge-renderer.css @@ -1,3 +1,5 @@ +@layer yt { + canvas.yt-live-chat-author-badge-renderer, caption.yt-live-chat-author-badge-renderer, center.yt-live-chat-author-badge-renderer, cite.yt-live-chat-author-badge-renderer, code.yt-live-chat-author-badge-renderer, dd.yt-live-chat-author-badge-renderer, del.yt-live-chat-author-badge-renderer, dfn.yt-live-chat-author-badge-renderer, div.yt-live-chat-author-badge-renderer, dl.yt-live-chat-author-badge-renderer, dt.yt-live-chat-author-badge-renderer, em.yt-live-chat-author-badge-renderer, embed.yt-live-chat-author-badge-renderer, fieldset.yt-live-chat-author-badge-renderer, font.yt-live-chat-author-badge-renderer, form.yt-live-chat-author-badge-renderer, h1.yt-live-chat-author-badge-renderer, h2.yt-live-chat-author-badge-renderer, h3.yt-live-chat-author-badge-renderer, h4.yt-live-chat-author-badge-renderer, h5.yt-live-chat-author-badge-renderer, h6.yt-live-chat-author-badge-renderer, hr.yt-live-chat-author-badge-renderer, i.yt-live-chat-author-badge-renderer, iframe.yt-live-chat-author-badge-renderer, img.yt-live-chat-author-badge-renderer, ins.yt-live-chat-author-badge-renderer, kbd.yt-live-chat-author-badge-renderer, label.yt-live-chat-author-badge-renderer, legend.yt-live-chat-author-badge-renderer, li.yt-live-chat-author-badge-renderer, menu.yt-live-chat-author-badge-renderer, object.yt-live-chat-author-badge-renderer, ol.yt-live-chat-author-badge-renderer, p.yt-live-chat-author-badge-renderer, pre.yt-live-chat-author-badge-renderer, q.yt-live-chat-author-badge-renderer, s.yt-live-chat-author-badge-renderer, samp.yt-live-chat-author-badge-renderer, small.yt-live-chat-author-badge-renderer, span.yt-live-chat-author-badge-renderer, strike.yt-live-chat-author-badge-renderer, strong.yt-live-chat-author-badge-renderer, sub.yt-live-chat-author-badge-renderer, sup.yt-live-chat-author-badge-renderer, table.yt-live-chat-author-badge-renderer, tbody.yt-live-chat-author-badge-renderer, td.yt-live-chat-author-badge-renderer, tfoot.yt-live-chat-author-badge-renderer, th.yt-live-chat-author-badge-renderer, thead.yt-live-chat-author-badge-renderer, tr.yt-live-chat-author-badge-renderer, tt.yt-live-chat-author-badge-renderer, u.yt-live-chat-author-badge-renderer, ul.yt-live-chat-author-badge-renderer, var.yt-live-chat-author-badge-renderer { margin: 0; padding: 0; @@ -34,3 +36,5 @@ img.yt-live-chat-author-badge-renderer, yt-icon.yt-live-chat-author-badge-render width: 16px; height: 16px; } + +} diff --git a/frontend/src/assets/css/youtube/yt-live-chat-author-chip.css b/frontend/src/assets/css/youtube/yt-live-chat-author-chip.css index 2ce7ed44..ff3dbab4 100644 --- a/frontend/src/assets/css/youtube/yt-live-chat-author-chip.css +++ b/frontend/src/assets/css/youtube/yt-live-chat-author-chip.css @@ -1,3 +1,5 @@ +@layer yt { + canvas.yt-live-chat-author-chip, caption.yt-live-chat-author-chip, center.yt-live-chat-author-chip, cite.yt-live-chat-author-chip, code.yt-live-chat-author-chip, dd.yt-live-chat-author-chip, del.yt-live-chat-author-chip, dfn.yt-live-chat-author-chip, div.yt-live-chat-author-chip, dl.yt-live-chat-author-chip, dt.yt-live-chat-author-chip, em.yt-live-chat-author-chip, embed.yt-live-chat-author-chip, fieldset.yt-live-chat-author-chip, font.yt-live-chat-author-chip, form.yt-live-chat-author-chip, h1.yt-live-chat-author-chip, h2.yt-live-chat-author-chip, h3.yt-live-chat-author-chip, h4.yt-live-chat-author-chip, h5.yt-live-chat-author-chip, h6.yt-live-chat-author-chip, hr.yt-live-chat-author-chip, i.yt-live-chat-author-chip, iframe.yt-live-chat-author-chip, img.yt-live-chat-author-chip, ins.yt-live-chat-author-chip, kbd.yt-live-chat-author-chip, label.yt-live-chat-author-chip, legend.yt-live-chat-author-chip, li.yt-live-chat-author-chip, menu.yt-live-chat-author-chip, object.yt-live-chat-author-chip, ol.yt-live-chat-author-chip, p.yt-live-chat-author-chip, pre.yt-live-chat-author-chip, q.yt-live-chat-author-chip, s.yt-live-chat-author-chip, samp.yt-live-chat-author-chip, small.yt-live-chat-author-chip, span.yt-live-chat-author-chip, strike.yt-live-chat-author-chip, strong.yt-live-chat-author-chip, sub.yt-live-chat-author-chip, sup.yt-live-chat-author-chip, table.yt-live-chat-author-chip, tbody.yt-live-chat-author-chip, td.yt-live-chat-author-chip, tfoot.yt-live-chat-author-chip, th.yt-live-chat-author-chip, thead.yt-live-chat-author-chip, tr.yt-live-chat-author-chip, tt.yt-live-chat-author-chip, u.yt-live-chat-author-chip, ul.yt-live-chat-author-chip, var.yt-live-chat-author-chip { margin: 0; padding: 0; @@ -62,3 +64,5 @@ yt-live-chat-author-chip[is-highlighted] #chip-badges.yt-live-chat-author-chip y #chip-badges.yt-live-chat-author-chip yt-live-chat-author-badge-renderer.yt-live-chat-author-chip:last-of-type { margin-right: -2px; } + +} diff --git a/frontend/src/assets/css/youtube/yt-live-chat-item-list-renderer.css b/frontend/src/assets/css/youtube/yt-live-chat-item-list-renderer.css index 7eaad8b4..078270c4 100644 --- a/frontend/src/assets/css/youtube/yt-live-chat-item-list-renderer.css +++ b/frontend/src/assets/css/youtube/yt-live-chat-item-list-renderer.css @@ -1,3 +1,5 @@ +@layer yt { + canvas.yt-live-chat-item-list-renderer, caption.yt-live-chat-item-list-renderer, center.yt-live-chat-item-list-renderer, cite.yt-live-chat-item-list-renderer, code.yt-live-chat-item-list-renderer, dd.yt-live-chat-item-list-renderer, del.yt-live-chat-item-list-renderer, dfn.yt-live-chat-item-list-renderer, div.yt-live-chat-item-list-renderer, dl.yt-live-chat-item-list-renderer, dt.yt-live-chat-item-list-renderer, em.yt-live-chat-item-list-renderer, embed.yt-live-chat-item-list-renderer, fieldset.yt-live-chat-item-list-renderer, font.yt-live-chat-item-list-renderer, form.yt-live-chat-item-list-renderer, h1.yt-live-chat-item-list-renderer, h2.yt-live-chat-item-list-renderer, h3.yt-live-chat-item-list-renderer, h4.yt-live-chat-item-list-renderer, h5.yt-live-chat-item-list-renderer, h6.yt-live-chat-item-list-renderer, hr.yt-live-chat-item-list-renderer, i.yt-live-chat-item-list-renderer, iframe.yt-live-chat-item-list-renderer, img.yt-live-chat-item-list-renderer, ins.yt-live-chat-item-list-renderer, kbd.yt-live-chat-item-list-renderer, label.yt-live-chat-item-list-renderer, legend.yt-live-chat-item-list-renderer, li.yt-live-chat-item-list-renderer, menu.yt-live-chat-item-list-renderer, object.yt-live-chat-item-list-renderer, ol.yt-live-chat-item-list-renderer, p.yt-live-chat-item-list-renderer, pre.yt-live-chat-item-list-renderer, q.yt-live-chat-item-list-renderer, s.yt-live-chat-item-list-renderer, samp.yt-live-chat-item-list-renderer, small.yt-live-chat-item-list-renderer, span.yt-live-chat-item-list-renderer, strike.yt-live-chat-item-list-renderer, strong.yt-live-chat-item-list-renderer, sub.yt-live-chat-item-list-renderer, sup.yt-live-chat-item-list-renderer, table.yt-live-chat-item-list-renderer, tbody.yt-live-chat-item-list-renderer, td.yt-live-chat-item-list-renderer, tfoot.yt-live-chat-item-list-renderer, th.yt-live-chat-item-list-renderer, thead.yt-live-chat-item-list-renderer, tr.yt-live-chat-item-list-renderer, tt.yt-live-chat-item-list-renderer, u.yt-live-chat-item-list-renderer, ul.yt-live-chat-item-list-renderer, var.yt-live-chat-item-list-renderer { margin: 0; padding: 0; @@ -138,3 +140,5 @@ yt-live-chat-paid-sticker-renderer.yt-live-chat-item-list-renderer { yt-live-chat-paid-sticker-renderer.yt-live-chat-item-list-renderer[dashboard-money-feed] { padding: 8px 16px; } + +} diff --git a/frontend/src/assets/css/youtube/yt-live-chat-membership-item-renderer.css b/frontend/src/assets/css/youtube/yt-live-chat-membership-item-renderer.css index 6a1c2caa..b09a8cdd 100644 --- a/frontend/src/assets/css/youtube/yt-live-chat-membership-item-renderer.css +++ b/frontend/src/assets/css/youtube/yt-live-chat-membership-item-renderer.css @@ -1,3 +1,5 @@ +@layer yt { + #timestamp.yt-live-chat-membership-item-renderer { display: var(--yt-live-chat-item-timestamp-display, inline); margin: var(--yt-live-chat-item-timestamp-margin, 0 8px 0 0); @@ -360,3 +362,5 @@ yt-live-chat-membership-item-renderer[dashboard-money-feed] #menu.yt-live-chat-m margin-top: 8px; background: linear-gradient(to right, transparent, var(--yt-live-chat-background-color) 40%); } + +} diff --git a/frontend/src/assets/css/youtube/yt-live-chat-paid-message-renderer.css b/frontend/src/assets/css/youtube/yt-live-chat-paid-message-renderer.css index 639d609d..a6c51f10 100644 --- a/frontend/src/assets/css/youtube/yt-live-chat-paid-message-renderer.css +++ b/frontend/src/assets/css/youtube/yt-live-chat-paid-message-renderer.css @@ -1,3 +1,5 @@ +@layer yt { + canvas.yt-live-chat-paid-message-renderer, caption.yt-live-chat-paid-message-renderer, center.yt-live-chat-paid-message-renderer, cite.yt-live-chat-paid-message-renderer, code.yt-live-chat-paid-message-renderer, dd.yt-live-chat-paid-message-renderer, del.yt-live-chat-paid-message-renderer, dfn.yt-live-chat-paid-message-renderer, div.yt-live-chat-paid-message-renderer, dl.yt-live-chat-paid-message-renderer, dt.yt-live-chat-paid-message-renderer, em.yt-live-chat-paid-message-renderer, embed.yt-live-chat-paid-message-renderer, fieldset.yt-live-chat-paid-message-renderer, font.yt-live-chat-paid-message-renderer, form.yt-live-chat-paid-message-renderer, h1.yt-live-chat-paid-message-renderer, h2.yt-live-chat-paid-message-renderer, h3.yt-live-chat-paid-message-renderer, h4.yt-live-chat-paid-message-renderer, h5.yt-live-chat-paid-message-renderer, h6.yt-live-chat-paid-message-renderer, hr.yt-live-chat-paid-message-renderer, i.yt-live-chat-paid-message-renderer, iframe.yt-live-chat-paid-message-renderer, img.yt-live-chat-paid-message-renderer, ins.yt-live-chat-paid-message-renderer, kbd.yt-live-chat-paid-message-renderer, label.yt-live-chat-paid-message-renderer, legend.yt-live-chat-paid-message-renderer, li.yt-live-chat-paid-message-renderer, menu.yt-live-chat-paid-message-renderer, object.yt-live-chat-paid-message-renderer, ol.yt-live-chat-paid-message-renderer, p.yt-live-chat-paid-message-renderer, pre.yt-live-chat-paid-message-renderer, q.yt-live-chat-paid-message-renderer, s.yt-live-chat-paid-message-renderer, samp.yt-live-chat-paid-message-renderer, small.yt-live-chat-paid-message-renderer, span.yt-live-chat-paid-message-renderer, strike.yt-live-chat-paid-message-renderer, strong.yt-live-chat-paid-message-renderer, sub.yt-live-chat-paid-message-renderer, sup.yt-live-chat-paid-message-renderer, table.yt-live-chat-paid-message-renderer, tbody.yt-live-chat-paid-message-renderer, td.yt-live-chat-paid-message-renderer, tfoot.yt-live-chat-paid-message-renderer, th.yt-live-chat-paid-message-renderer, thead.yt-live-chat-paid-message-renderer, tr.yt-live-chat-paid-message-renderer, tt.yt-live-chat-paid-message-renderer, u.yt-live-chat-paid-message-renderer, ul.yt-live-chat-paid-message-renderer, var.yt-live-chat-paid-message-renderer { margin: 0; padding: 0; @@ -378,3 +380,5 @@ yt-live-chat-paid-message-renderer[show-footer-divider] #footer.yt-live-chat-pai yt-live-chat-paid-message-renderer[is-user-editable] #footer.yt-live-chat-paid-message-renderer:not(:empty) { padding-top: 8px; } + +} diff --git a/frontend/src/assets/css/youtube/yt-live-chat-renderer.css b/frontend/src/assets/css/youtube/yt-live-chat-renderer.css index 5e805f6c..0bc15fc5 100644 --- a/frontend/src/assets/css/youtube/yt-live-chat-renderer.css +++ b/frontend/src/assets/css/youtube/yt-live-chat-renderer.css @@ -1,3 +1,5 @@ +@layer yt { + yt-live-chat-renderer, yt-live-chat-item-list-renderer #item-scroller { height: 100%; } @@ -250,3 +252,5 @@ yt-live-chat-renderer[collapsed] .hide-on-collapse.yt-live-chat-renderer { background-color: rgba(0, 0, 0, 0.60); } } + +} diff --git a/frontend/src/assets/css/youtube/yt-live-chat-text-message-renderer.css b/frontend/src/assets/css/youtube/yt-live-chat-text-message-renderer.css index bf1ed283..12679ab8 100644 --- a/frontend/src/assets/css/youtube/yt-live-chat-text-message-renderer.css +++ b/frontend/src/assets/css/youtube/yt-live-chat-text-message-renderer.css @@ -1,3 +1,5 @@ +@layer yt { + canvas.yt-live-chat-text-message-renderer, caption.yt-live-chat-text-message-renderer, center.yt-live-chat-text-message-renderer, cite.yt-live-chat-text-message-renderer, code.yt-live-chat-text-message-renderer, dd.yt-live-chat-text-message-renderer, del.yt-live-chat-text-message-renderer, dfn.yt-live-chat-text-message-renderer, div.yt-live-chat-text-message-renderer, dl.yt-live-chat-text-message-renderer, dt.yt-live-chat-text-message-renderer, em.yt-live-chat-text-message-renderer, embed.yt-live-chat-text-message-renderer, fieldset.yt-live-chat-text-message-renderer, font.yt-live-chat-text-message-renderer, form.yt-live-chat-text-message-renderer, h1.yt-live-chat-text-message-renderer, h2.yt-live-chat-text-message-renderer, h3.yt-live-chat-text-message-renderer, h4.yt-live-chat-text-message-renderer, h5.yt-live-chat-text-message-renderer, h6.yt-live-chat-text-message-renderer, hr.yt-live-chat-text-message-renderer, i.yt-live-chat-text-message-renderer, iframe.yt-live-chat-text-message-renderer, img.yt-live-chat-text-message-renderer, ins.yt-live-chat-text-message-renderer, kbd.yt-live-chat-text-message-renderer, label.yt-live-chat-text-message-renderer, legend.yt-live-chat-text-message-renderer, li.yt-live-chat-text-message-renderer, menu.yt-live-chat-text-message-renderer, object.yt-live-chat-text-message-renderer, ol.yt-live-chat-text-message-renderer, p.yt-live-chat-text-message-renderer, pre.yt-live-chat-text-message-renderer, q.yt-live-chat-text-message-renderer, s.yt-live-chat-text-message-renderer, samp.yt-live-chat-text-message-renderer, small.yt-live-chat-text-message-renderer, span.yt-live-chat-text-message-renderer, strike.yt-live-chat-text-message-renderer, strong.yt-live-chat-text-message-renderer, sub.yt-live-chat-text-message-renderer, sup.yt-live-chat-text-message-renderer, table.yt-live-chat-text-message-renderer, tbody.yt-live-chat-text-message-renderer, td.yt-live-chat-text-message-renderer, tfoot.yt-live-chat-text-message-renderer, th.yt-live-chat-text-message-renderer, thead.yt-live-chat-text-message-renderer, tr.yt-live-chat-text-message-renderer, tt.yt-live-chat-text-message-renderer, u.yt-live-chat-text-message-renderer, ul.yt-live-chat-text-message-renderer, var.yt-live-chat-text-message-renderer { margin: 0; padding: 0; @@ -230,3 +232,5 @@ yt-live-chat-text-message-renderer[is-dimmed] #message.yt-live-chat-text-message yt-live-chat-text-message-renderer[is-dimmed]::before { background: var(--yt-live-chat-error-message-color, #f44336); } + +} diff --git a/frontend/src/assets/css/youtube/yt-live-chat-ticker-paid-message-item-renderer.css b/frontend/src/assets/css/youtube/yt-live-chat-ticker-paid-message-item-renderer.css index 4844b66c..d6b41918 100644 --- a/frontend/src/assets/css/youtube/yt-live-chat-ticker-paid-message-item-renderer.css +++ b/frontend/src/assets/css/youtube/yt-live-chat-ticker-paid-message-item-renderer.css @@ -1,3 +1,5 @@ +@layer yt { + canvas.yt-live-chat-ticker-paid-message-item-renderer, caption.yt-live-chat-ticker-paid-message-item-renderer, center.yt-live-chat-ticker-paid-message-item-renderer, cite.yt-live-chat-ticker-paid-message-item-renderer, code.yt-live-chat-ticker-paid-message-item-renderer, dd.yt-live-chat-ticker-paid-message-item-renderer, del.yt-live-chat-ticker-paid-message-item-renderer, dfn.yt-live-chat-ticker-paid-message-item-renderer, div.yt-live-chat-ticker-paid-message-item-renderer, dl.yt-live-chat-ticker-paid-message-item-renderer, dt.yt-live-chat-ticker-paid-message-item-renderer, em.yt-live-chat-ticker-paid-message-item-renderer, embed.yt-live-chat-ticker-paid-message-item-renderer, fieldset.yt-live-chat-ticker-paid-message-item-renderer, font.yt-live-chat-ticker-paid-message-item-renderer, form.yt-live-chat-ticker-paid-message-item-renderer, h1.yt-live-chat-ticker-paid-message-item-renderer, h2.yt-live-chat-ticker-paid-message-item-renderer, h3.yt-live-chat-ticker-paid-message-item-renderer, h4.yt-live-chat-ticker-paid-message-item-renderer, h5.yt-live-chat-ticker-paid-message-item-renderer, h6.yt-live-chat-ticker-paid-message-item-renderer, hr.yt-live-chat-ticker-paid-message-item-renderer, i.yt-live-chat-ticker-paid-message-item-renderer, iframe.yt-live-chat-ticker-paid-message-item-renderer, img.yt-live-chat-ticker-paid-message-item-renderer, ins.yt-live-chat-ticker-paid-message-item-renderer, kbd.yt-live-chat-ticker-paid-message-item-renderer, label.yt-live-chat-ticker-paid-message-item-renderer, legend.yt-live-chat-ticker-paid-message-item-renderer, li.yt-live-chat-ticker-paid-message-item-renderer, menu.yt-live-chat-ticker-paid-message-item-renderer, object.yt-live-chat-ticker-paid-message-item-renderer, ol.yt-live-chat-ticker-paid-message-item-renderer, p.yt-live-chat-ticker-paid-message-item-renderer, pre.yt-live-chat-ticker-paid-message-item-renderer, q.yt-live-chat-ticker-paid-message-item-renderer, s.yt-live-chat-ticker-paid-message-item-renderer, samp.yt-live-chat-ticker-paid-message-item-renderer, small.yt-live-chat-ticker-paid-message-item-renderer, span.yt-live-chat-ticker-paid-message-item-renderer, strike.yt-live-chat-ticker-paid-message-item-renderer, strong.yt-live-chat-ticker-paid-message-item-renderer, sub.yt-live-chat-ticker-paid-message-item-renderer, sup.yt-live-chat-ticker-paid-message-item-renderer, table.yt-live-chat-ticker-paid-message-item-renderer, tbody.yt-live-chat-ticker-paid-message-item-renderer, td.yt-live-chat-ticker-paid-message-item-renderer, tfoot.yt-live-chat-ticker-paid-message-item-renderer, th.yt-live-chat-ticker-paid-message-item-renderer, thead.yt-live-chat-ticker-paid-message-item-renderer, tr.yt-live-chat-ticker-paid-message-item-renderer, tt.yt-live-chat-ticker-paid-message-item-renderer, u.yt-live-chat-ticker-paid-message-item-renderer, ul.yt-live-chat-ticker-paid-message-item-renderer, var.yt-live-chat-ticker-paid-message-item-renderer { margin: 0; padding: 0; @@ -78,3 +80,5 @@ yt-img-shadow.yt-live-chat-ticker-paid-message-item-renderer { yt-live-chat-ticker-paid-message-item-renderer[is-deleted] #author-photo.yt-live-chat-ticker-paid-message-item-renderer { display: none; } + +} diff --git a/frontend/src/assets/css/youtube/yt-live-chat-ticker-renderer.css b/frontend/src/assets/css/youtube/yt-live-chat-ticker-renderer.css index 029720d1..f3b3cef9 100644 --- a/frontend/src/assets/css/youtube/yt-live-chat-ticker-renderer.css +++ b/frontend/src/assets/css/youtube/yt-live-chat-ticker-renderer.css @@ -1,3 +1,5 @@ +@layer yt { + canvas.yt-live-chat-ticker-renderer, caption.yt-live-chat-ticker-renderer, center.yt-live-chat-ticker-renderer, cite.yt-live-chat-ticker-renderer, code.yt-live-chat-ticker-renderer, dd.yt-live-chat-ticker-renderer, del.yt-live-chat-ticker-renderer, dfn.yt-live-chat-ticker-renderer, div.yt-live-chat-ticker-renderer, dl.yt-live-chat-ticker-renderer, dt.yt-live-chat-ticker-renderer, em.yt-live-chat-ticker-renderer, embed.yt-live-chat-ticker-renderer, fieldset.yt-live-chat-ticker-renderer, font.yt-live-chat-ticker-renderer, form.yt-live-chat-ticker-renderer, h1.yt-live-chat-ticker-renderer, h2.yt-live-chat-ticker-renderer, h3.yt-live-chat-ticker-renderer, h4.yt-live-chat-ticker-renderer, h5.yt-live-chat-ticker-renderer, h6.yt-live-chat-ticker-renderer, hr.yt-live-chat-ticker-renderer, i.yt-live-chat-ticker-renderer, iframe.yt-live-chat-ticker-renderer, img.yt-live-chat-ticker-renderer, ins.yt-live-chat-ticker-renderer, kbd.yt-live-chat-ticker-renderer, label.yt-live-chat-ticker-renderer, legend.yt-live-chat-ticker-renderer, li.yt-live-chat-ticker-renderer, menu.yt-live-chat-ticker-renderer, object.yt-live-chat-ticker-renderer, ol.yt-live-chat-ticker-renderer, p.yt-live-chat-ticker-renderer, pre.yt-live-chat-ticker-renderer, q.yt-live-chat-ticker-renderer, s.yt-live-chat-ticker-renderer, samp.yt-live-chat-ticker-renderer, small.yt-live-chat-ticker-renderer, span.yt-live-chat-ticker-renderer, strike.yt-live-chat-ticker-renderer, strong.yt-live-chat-ticker-renderer, sub.yt-live-chat-ticker-renderer, sup.yt-live-chat-ticker-renderer, table.yt-live-chat-ticker-renderer, tbody.yt-live-chat-ticker-renderer, td.yt-live-chat-ticker-renderer, tfoot.yt-live-chat-ticker-renderer, th.yt-live-chat-ticker-renderer, thead.yt-live-chat-ticker-renderer, tr.yt-live-chat-ticker-renderer, tt.yt-live-chat-ticker-renderer, u.yt-live-chat-ticker-renderer, ul.yt-live-chat-ticker-renderer, var.yt-live-chat-ticker-renderer { margin: 0; padding: 0; @@ -20,7 +22,9 @@ yt-live-chat-ticker-renderer { #items.yt-live-chat-ticker-renderer { height: 32px; - overflow: hidden; + /* 为了支持滚动 */ + /* overflow: hidden; */ + overflow: visible; white-space: nowrap; padding: 0 24px 8px 24px; } @@ -63,3 +67,5 @@ yt-icon.yt-live-chat-ticker-renderer { padding: 4px; width: 24px; } + +} diff --git a/frontend/src/blcsdk.js b/frontend/src/blcsdk.js new file mode 100644 index 00000000..c3181681 --- /dev/null +++ b/frontend/src/blcsdk.js @@ -0,0 +1,514 @@ +/** @module blcsdk */ + +(function(root, factory) { + if (typeof define === 'function' && define.amd) { + define([], factory) + } else { + root.blcsdk = factory() + } +}(typeof self !== 'undefined' ? self : this, function() { + const exports = {} + + const VERSION = '1.0.2' + /** + * 取SDK版本 + * @returns {string} "1.0.0" + */ + function getVersion() { + return VERSION + } + exports.getVersion = getVersion + + // 初始化消息的Promise {promise, resolve, reject} + let initPromise = null + // 初始化消息,包含版本、配置等信息 + let initMsg = null + + /** + * 消息处理器 + * @type {MsgHandler} + */ + let msgHandler = null + /** + * 用户设置的消息处理器 + * @type {MsgHandler} + */ + let rawMsgHandler = null + + /** + * @typedef InitOptions + * @property {boolean} noMsgDelay 去掉消息延迟,但会导致消息不平滑 + * @property {boolean} noCssInjection 不注入OBS的自定义CSS和blivechat服务器预设CSS + */ + + let initOptions = { + noCssInjection: false, + } + + /** + * 初始化SDK + * + * 在调用除了{@link setMsgHandler}以外的其他接口之前必须先调用这个 + */ + async function init( + /** @type {?InitOptions} */ + {noMsgDelay = false, noCssInjection = false} = {} + ) { + if (initPromise) { + throw new Error('Cannot call init() again') + } + // initPromise = Promise.withResolvers() + initPromise = {} + initPromise.promise = new Promise((resolve, reject) => { + initPromise.resolve = resolve + initPromise.reject = reject + }) + + if (window.parent === window) { + initPromise.reject(new Error('No parent window')) + return initPromise.promise + } + + initOptions.noCssInjection = noCssInjection + + msgHandler = noMsgDelay ? new SdkMsgHandler() : new SmoothedSdkMsgHandler() + window.addEventListener('message', onWindowMessage) + + // 连接blivechat + blcSendMsg('blcTemplateConnect') + setTimeout(() => initPromise.reject(new Error('Timed out waiting for blcInit message')), 10 * 1000) + + // 等待初始化消息 + initMsg = await initPromise.promise + console.debug('blcsdk initialized, initMsg=', initMsg) + } + exports.init = init + + /** + * 设置消息处理器 + * @param {?MsgHandler} handler 消息处理器 + */ + function setMsgHandler(handler) { + rawMsgHandler = handler + } + exports.setMsgHandler = setMsgHandler + + /** + * 取blivechat前端版本 + * @returns {string} "v1.10.0-dev" + */ + function getBlcVersion() { + if (!initMsg) { + throw new Error('Please call init() first') + } + return initMsg.blcVersion + } + exports.getBlcVersion = getBlcVersion + + /** + * 取blivechat前端用的SDK版本。是父窗口用的版本,不是这个包的{@link getVersion}返回值 + * @returns {string} "1.0.0" + */ + function getBlcSdkVersion() { + if (!initMsg) { + throw new Error('Please call init() first') + } + return initMsg.sdkVersion + } + exports.getBlcSdkVersion = getBlcSdkVersion + + /** + * @typedef Config + * @property {boolean} showGiftName 显示礼物名 + * @property {boolean} mergeSimilarDanmaku 合并相似弹幕 + * @property {boolean} mergeGift 合并礼物 + * @property {number} maxNumber 最大弹幕数 + */ + + /** + * 取blivechat前端房间部分配置 + * @returns {Config} + */ + function getConfig() { + if (!initMsg) { + throw new Error('Please call init() first') + } + return Object.freeze(initMsg.config) + } + exports.getConfig = getConfig + + function blcSendMsg(type, data = null) { + if (window.parent === window) { + return + } + let msg = { type, data } + window.parent.postMessage(msg, '*') + } + + function onWindowMessage(event) { + if (event.source !== window.parent) { + return + } + + let { type, data } = event.data + switch (type) { + case 'blcAddMsg': + msgHandler.addMsg(data) + break + case 'blcUpdateMsg': + msgHandler.updateMsg(data.id, data.newValuesObj) + break + case 'blcDelMsgs': + msgHandler.delMsgs(data.ids) + break + + case 'blcInit': + initPromise.resolve(data) + break + case 'blcInjectCss': + injectCss(data) + break + } + } + + function injectCss({injectCssUrls = [], injectCss = ''}) { + if (initOptions.noCssInjection) { + return + } + + for (let url of injectCssUrls) { + let el = document.createElement('link') + el.rel = 'stylesheet' + el.href = url + document.head.appendChild(el) + } + + if (injectCss !== '') { + let el = document.createElement('style') + el.textContent = injectCss + document.head.appendChild(el) + } + } + + /** 模板消息处理器接口 */ + class MsgHandler { + /** + * 添加消息 + * @param {AnyDisplayMsg} msg + */ + addMsg(msg) {} + + /** + * 删除消息,主要用于撤回醒目留言 + * @param {string[]} ids 要删除的消息ID + */ + delMsgs(ids) {} + + /** + * 更新消息字段,主要用于更新翻译结果 + * @param {string} id 要更新的消息ID + * @param {Object} newValuesObj 字段和对应的新值 + */ + updateMsg(id, newValuesObj) {} + } + exports.MsgHandler = MsgHandler + + class SdkMsgHandler extends MsgHandler { + addMsg(msg) { this._callRawHandler('addMsg', msg) } + delMsgs(ids) { this._callRawHandler('delMsgs', ids) } + updateMsg(id, newValuesObj) { this._callRawHandler('updateMsg', id, newValuesObj) } + _callRawHandler(...args) { doCallRawHandler(...args) } + } + + function doCallRawHandler(funcName, ...args) { + if (!rawMsgHandler) { + return + } + try { + let func = rawMsgHandler[funcName] + return func.call(rawMsgHandler, ...args) + } catch (e) { + console.error(e) + } + } + + // 发送消息时间间隔范围 + const MSG_MIN_INTERVAL = 80 + const MSG_MAX_INTERVAL = 1000 + + class SmoothedSdkMsgHandler extends SdkMsgHandler { + constructor() { + super() + // 消息队列 + this._queue = [] + // 消费消息队列的定时器ID + this._emitSmoothedMsgTimerId = null + // 最近进队列的时间间隔,用来估计下次进队列的时间 + this._enqueueIntervals = [] + // 上次进队列的时间 + this._lastEnqueueTime = null + // 估计的下次进队列时间间隔 + this._estimatedEnqueueInterval = null + + this._boundEmitSmoothedMsgs = this._emitSmoothedMsgs.bind(this) + } + + _callRawHandler(funcName, ...args) { + let msg = {funcName, args} + this._enqueueMsg(msg) + } + + _enqueueMsg(msg) { + // 估计进队列时间间隔 + if (!this._lastEnqueueTime) { + this._lastEnqueueTime = new Date() + } else { + let curTime = new Date() + let interval = curTime - this._lastEnqueueTime + // 真实的进队列时间间隔模式大概是这样:2500, 300, 300, 300, 2500, 300, ... + // B站消息有缓冲,会一次发多条消息。这里把波峰视为发送了一次真实的WS消息,所以要过滤掉间隔太小的 + if (interval > 1000 || this._enqueueIntervals.length < 5) { + this._enqueueIntervals.push(interval) + if (this._enqueueIntervals.length > 5) { + this._enqueueIntervals.splice(0, this._enqueueIntervals.length - 5) + } + // 这边估计得尽量大,只要不太早把消息缓冲发完就是平滑的。有MESSAGE_MAX_INTERVAL保底,不会让消息延迟太大 + // 其实可以用单调队列求最大值,偷懒不写了 + this._estimatedEnqueueInterval = Math.max(...this._enqueueIntervals) + } + // 上次入队时间还是要设置,否则会太早把消息缓冲发完,然后较长时间没有新消息 + this._lastEnqueueTime = curTime + } + + this._queue.push(msg) + + if (!this._emitSmoothedMsgTimerId) { + this._emitSmoothedMsgTimerId = setTimeout(this._boundEmitSmoothedMsgs) + } + } + + _emitSmoothedMsgs() { + this._emitSmoothedMsgTimerId = null + if (this._queue.length <= 0) { + return + } + + // 估计的下次进队列剩余时间 + let estimatedNextEnqueueRemainTime = 10 * 1000 + if (this._estimatedEnqueueInterval) { + estimatedNextEnqueueRemainTime = Math.max(this._lastEnqueueTime - new Date() + this._estimatedEnqueueInterval, 1) + } + // 计算发送的消息数,保证在下次进队列之前发完 + // 下次进队列之前应该发多少条消息 + let shouldEmitNum = Math.max(this._queue.length, 0) + // 下次进队列之前最多能发多少次 + let maxCanEmitCount = estimatedNextEnqueueRemainTime / MSG_MIN_INTERVAL + // 这次发多少条消息 + let numToEmit + if (shouldEmitNum < maxCanEmitCount) { + // 队列中消息数很少,每次发1条也能发完 + numToEmit = 1 + } else { + // 每次发1条以上,保证按最快速度能发完 + numToEmit = Math.ceil(shouldEmitNum / maxCanEmitCount) + } + + // 发消息 + let msgs = this._queue.splice(0, numToEmit) + for (let msg of msgs) { + doCallRawHandler(msg.funcName, ...msg.args) + } + + if (this._queue.length <= 0) { + return + } + // 消息没发完,计算下次发消息时间 + let sleepTime + if (numToEmit === 1) { + // 队列中消息数很少,随便定个[MESSAGE_MIN_INTERVAL, MESSAGE_MAX_INTERVAL]的时间 + sleepTime = estimatedNextEnqueueRemainTime / this._queue.length + sleepTime *= 0.5 + Math.random() + if (sleepTime > MSG_MAX_INTERVAL) { + sleepTime = MSG_MAX_INTERVAL + } else if (sleepTime < MSG_MIN_INTERVAL) { + sleepTime = MSG_MIN_INTERVAL + } + } else { + // 按最快速度发 + sleepTime = MSG_MIN_INTERVAL + } + this._emitSmoothedMsgTimerId = setTimeout(this._boundEmitSmoothedMsgs, sleepTime) + } + } + + /** + * 消息类型 + * @enum {number} + */ + const MsgType = Object.freeze({ + /** 评论 @see TextMsg */ + TEXT: 0, + /** 礼物 @see GiftMsg */ + GIFT: 1, + /** 上舰 @see MemberMsg */ + MEMBER: 2, + /** 醒目留言 @see SuperChatMsg */ + SUPER_CHAT: 3, + }) + exports.MsgType = MsgType + + /** + * 作者类型 + * @enum {number} + */ + const AuthorType = Object.freeze({ + NORMAL: 0, + /** 舰队 */ + MEMBER: 1, + /** 房管 */ + ADMIN: 2, + /** 主播 */ + OWNER: 3, + }) + exports.AuthorType = AuthorType + + /** + * 舰队等级。因为历史原因,消息里的字段名叫`privilegeType` + * @enum {number} + */ + const GuardLevel = Object.freeze({ + NONE: 0, + /** 总督 */ + LV3: 1, + /** 提督 */ + LV2: 2, + /** 舰长 */ + LV1: 3, + }) + exports.GuardLevel = GuardLevel + + /** + * 一段内容的类型 + * @enum {number} + */ + const ContentPartType = Object.freeze({ + /** 文本 */ + TEXT: 0, + /** 图片 */ + IMAGE: 1, + }) + exports.ContentPartType = ContentPartType + + // + // 以下只用于类型注解,运行时没什么用 + // + + /** + * 用于显示的消息 + * @typedef {TextMsg | GiftMsg | MemberMsg | SuperChatMsg} AnyDisplayMsg + */ + + /** + * 评论消息。因为历史原因叫TextMsg,实际上会包含表情图片 + * @typedef TextMsg + * @property {string} id 消息ID + * @property {MsgType} type 消息类型 + * @property {string} avatarUrl 用户头像URL + * @property {Date} time 时间 + * @property {string} authorName 用户名 + * @property {AuthorType} authorType 用户类型 + * @property {string} content 纯文本表示的内容 + * @property {AnyContentPart[]} contentParts 解析后的内容,包括文本、图片 + * @property {GuardLevel} privilegeType 舰队等级 + * @property {string} translation 内容的翻译,刚添加时一般是空的,之后通过更新消息赋值 + * @property {string} uid 用户Open ID或ID,使用房间ID连接时不保证是唯一的 + * @property {number} medalLevel 勋章等级,如果没戴当前房间勋章则为0 + * @property {string} medalName 勋章名,如果没戴当前房间勋章则为空字符串 + * @property {?boolean} isMirror 是否跨房弹幕,v1.10.2添加 + */ + exports.TextMsg = /** @type {TextMsg} */ (undefined) + + /** + * 一段内容 + * @typedef {TextContentPart | ImageContentPart} AnyContentPart + */ + /** + + * 一段文本内容 + * @typedef TextContentPart + * @property {ContentPartType} type 内容类型 + * @property {string} text 内容 + */ + + /** + * 一段图片内容 + * @typedef ImageContentPart + * @property {ContentPartType} type 内容类型 + * @property {string} text 纯文本表示的内容 + * @property {string} url 图片URL + * @property {number} width 宽度,加载失败则为0 + * @property {number} height 高度,加载失败则为0 + */ + + /** + * 礼物消息 + * @typedef GiftMsg + * @property {string} id 消息ID + * @property {MsgType} type 消息类型 + * @property {string} avatarUrl 用户头像URL + * @property {Date} time 时间 + * @property {string} authorName 用户名 + * @property {string} authorNamePronunciation 用户名读音 + * @property {number} price 总价(元),免费礼物则为0 + * @property {string} giftName 礼物名 + * @property {number} num 数量 + * @property {number} totalFreeCoin 免费礼物总价(银瓜子数),付费礼物则为0 + * @property {number} giftId 礼物ID + * @property {string} giftIconUrl 礼物图标URL + * @property {string} uid 用户Open ID或ID,使用房间ID连接时不保证是唯一的 + * @property {GuardLevel} privilegeType 舰队等级 + * @property {number} medalLevel 勋章等级,如果没戴当前房间勋章则为0 + * @property {string} medalName 勋章名,如果没戴当前房间勋章则为空字符串 + */ + exports.GiftMsg = /** @type {GiftMsg} */ (undefined) + + /** + * 上舰消息 + * @typedef MemberMsg + * @property {string} id 消息ID + * @property {MsgType} type 消息类型 + * @property {string} avatarUrl 用户头像URL + * @property {Date} time 时间 + * @property {string} authorName 用户名 + * @property {string} authorNamePronunciation 用户名读音 + * @property {GuardLevel} privilegeType 舰队等级 + * @property {number} num 数量 + * @property {string} unit 单位("月") + * @property {number} price 总价(元) + * @property {string} uid 用户Open ID或ID,使用房间ID连接时不保证是唯一的 + * @property {number} medalLevel 勋章等级,如果没戴当前房间勋章则为0 + * @property {string} medalName 勋章名,如果没戴当前房间勋章则为空字符串 + */ + exports.MemberMsg = /** @type {MemberMsg} */ (undefined) + + /** + * 醒目留言消息 + * @typedef SuperChatMsg + * @property {string} id 消息ID + * @property {MsgType} type 消息类型 + * @property {string} avatarUrl 用户头像URL + * @property {Date} time 时间 + * @property {string} authorName 用户名 + * @property {string} authorNamePronunciation 用户名读音 + * @property {number} price 价格(元) + * @property {string} content 内容 + * @property {string} translation 内容的翻译,刚添加时一般是空的,之后通过更新消息赋值 + * @property {string} uid 用户Open ID或ID,使用房间ID连接时不保证是唯一的 + * @property {GuardLevel} privilegeType 舰队等级 + * @property {number} medalLevel 勋章等级,如果没戴当前房间勋章则为0 + * @property {string} medalName 勋章名,如果没戴当前房间勋章则为空字符串 + */ + exports.SuperChatMsg = /** @type {SuperChatMsg} */ (undefined) + + return exports +})) diff --git a/frontend/src/components/ChatRenderer/TextMessage.vue b/frontend/src/components/ChatRenderer/TextMessage.vue index 266adbc7..39135dd8 100644 --- a/frontend/src/components/ChatRenderer/TextMessage.vue +++ b/frontend/src/components/ChatRenderer/TextMessage.vue @@ -9,10 +9,10 @@ :isInMemberMessage="false" :authorName="authorName" :authorType="authorType" :privilegeType="privilegeType" > -