Merge branch 'develop' of codeberg.org:calckey/calckey into develop
This commit is contained in:
commit
02a8d3283e
25 changed files with 401 additions and 432 deletions
|
@ -1,6 +1,6 @@
|
||||||
_lang_: "简体中文"
|
_lang_: "简体中文"
|
||||||
headlineMisskey: "通过帖子连接在一起的网络"
|
headlineMisskey: "一个开源、去中心化的社交媒体平台,永远免费!🚀"
|
||||||
introMisskey: "欢迎!Misskey是一个开源的、去中心化的“微博客”服务。\n通过编写「帖文」来和大家分享你的以及你周围的事情吧!📡\n通过「回应」功能,可以让你快速地对大家的帖文表达反馈👍\n\
|
introMisskey: "欢迎!Calckey 是一个开源的、去中心化的“微博客”服务。\n通过编写「帖文」来和大家分享你的以及你周围的事情吧!📡\n通过「回应」功能,可以让你快速地对大家的帖文表达反馈👍\n\
|
||||||
来探索新的世界吧!🚀"
|
来探索新的世界吧!🚀"
|
||||||
monthAndDay: "{month}月 {day}日"
|
monthAndDay: "{month}月 {day}日"
|
||||||
search: "搜索"
|
search: "搜索"
|
||||||
|
@ -10,10 +10,10 @@ password: "密码"
|
||||||
forgotPassword: "忘记密码"
|
forgotPassword: "忘记密码"
|
||||||
fetchingAsApObject: "正在联邦宇宙查询中"
|
fetchingAsApObject: "正在联邦宇宙查询中"
|
||||||
ok: "OK"
|
ok: "OK"
|
||||||
gotIt: "我明白了"
|
gotIt: "知道了!"
|
||||||
cancel: "取消"
|
cancel: "取消"
|
||||||
enterUsername: "输入用户名"
|
enterUsername: "输入用户名"
|
||||||
renotedBy: "由 {user} 转贴"
|
renotedBy: "转发自 {user}"
|
||||||
noNotes: "没有帖子"
|
noNotes: "没有帖子"
|
||||||
noNotifications: "无通知"
|
noNotifications: "无通知"
|
||||||
instance: "服务器"
|
instance: "服务器"
|
||||||
|
@ -44,7 +44,7 @@ copyContent: "复制内容"
|
||||||
copyLink: "复制链接"
|
copyLink: "复制链接"
|
||||||
delete: "删除"
|
delete: "删除"
|
||||||
deleteAndEdit: "删除并编辑"
|
deleteAndEdit: "删除并编辑"
|
||||||
deleteAndEditConfirm: "要删除此帖并再次编辑吗?对此帖的所有回应、转发和回复也将被删除。"
|
deleteAndEditConfirm: "要删除此帖子并再次编辑吗?对此帖子的所有回应、转发和回复也将被删除。"
|
||||||
addToList: "添加至列表"
|
addToList: "添加至列表"
|
||||||
sendMessage: "发送"
|
sendMessage: "发送"
|
||||||
copyUsername: "复制用户名"
|
copyUsername: "复制用户名"
|
||||||
|
@ -78,7 +78,7 @@ followsYou: "正在关注你"
|
||||||
createList: "创建列表"
|
createList: "创建列表"
|
||||||
manageLists: "管理列表"
|
manageLists: "管理列表"
|
||||||
error: "错误"
|
error: "错误"
|
||||||
somethingHappened: "出现了一些问题!"
|
somethingHappened: "发生了一个错误"
|
||||||
retry: "重试"
|
retry: "重试"
|
||||||
pageLoadError: "页面加载失败。"
|
pageLoadError: "页面加载失败。"
|
||||||
pageLoadErrorDescription: "这通常是由于网络或浏览器缓存的原因。请清除缓存或等待片刻后重试。"
|
pageLoadErrorDescription: "这通常是由于网络或浏览器缓存的原因。请清除缓存或等待片刻后重试。"
|
||||||
|
@ -97,7 +97,7 @@ enterEmoji: "输入表情符号"
|
||||||
renote: "转发"
|
renote: "转发"
|
||||||
unrenote: "取消转发"
|
unrenote: "取消转发"
|
||||||
renoted: "已转发。"
|
renoted: "已转发。"
|
||||||
cantRenote: "该帖无法转发。"
|
cantRenote: "此帖子无法转发。"
|
||||||
cantReRenote: "转发无法被再次转发。"
|
cantReRenote: "转发无法被再次转发。"
|
||||||
quote: "引用"
|
quote: "引用"
|
||||||
pinnedNote: "已置顶的帖子"
|
pinnedNote: "已置顶的帖子"
|
||||||
|
@ -111,7 +111,7 @@ enableEmojiReaction: "启用表情符号回应"
|
||||||
showEmojisInReactionNotifications: "在回应通知中显示表情符号"
|
showEmojisInReactionNotifications: "在回应通知中显示表情符号"
|
||||||
reactionSetting: "在选择器中显示的回应"
|
reactionSetting: "在选择器中显示的回应"
|
||||||
reactionSettingDescription2: "拖动重新排序,单击删除,点击 + 添加。"
|
reactionSettingDescription2: "拖动重新排序,单击删除,点击 + 添加。"
|
||||||
rememberNoteVisibility: "保存上次设置的可见性"
|
rememberNoteVisibility: "保存帖子可见性设置"
|
||||||
attachCancel: "删除附件"
|
attachCancel: "删除附件"
|
||||||
markAsSensitive: "标记为敏感内容"
|
markAsSensitive: "标记为敏感内容"
|
||||||
unmarkAsSensitive: "取消标记为敏感内容"
|
unmarkAsSensitive: "取消标记为敏感内容"
|
||||||
|
@ -141,7 +141,7 @@ emojiUrl: "表情符号地址"
|
||||||
addEmoji: "添加表情符号"
|
addEmoji: "添加表情符号"
|
||||||
settingGuide: "推荐配置"
|
settingGuide: "推荐配置"
|
||||||
cacheRemoteFiles: "远程文件缓存"
|
cacheRemoteFiles: "远程文件缓存"
|
||||||
cacheRemoteFilesDescription: "当禁用此设定时远程文件将直接从远程实例载入。禁用后会减小储存空间需求,但是会增加流量,因为缩略图不会被生成。"
|
cacheRemoteFilesDescription: "当禁用此设定时远程文件将直接从远程服务器载入。禁用后会减小储存空间需求,但是会增加流量,因为缩略图不会被生成。"
|
||||||
flagAsBot: "这是一个机器人账号"
|
flagAsBot: "这是一个机器人账号"
|
||||||
flagAsBotDescription: "如果此帐户由程序控制,请启用此项。启用后,此标志可以帮助其他开发人员防止机器人之间产生无限互动的行为,并让Misskey的内部系统将此帐户识别为机器人。"
|
flagAsBotDescription: "如果此帐户由程序控制,请启用此项。启用后,此标志可以帮助其他开发人员防止机器人之间产生无限互动的行为,并让Misskey的内部系统将此帐户识别为机器人。"
|
||||||
flagAsCat: "将这个账户设定为一只猫"
|
flagAsCat: "将这个账户设定为一只猫"
|
||||||
|
@ -151,7 +151,7 @@ flagShowTimelineRepliesDescription: "启用时,时间线除了显示用户的
|
||||||
autoAcceptFollowed: "自动允许关注者的关注"
|
autoAcceptFollowed: "自动允许关注者的关注"
|
||||||
addAccount: "添加账户"
|
addAccount: "添加账户"
|
||||||
loginFailed: "登录失败"
|
loginFailed: "登录失败"
|
||||||
showOnRemote: "转到所在实例显示"
|
showOnRemote: "转到所在服务器显示"
|
||||||
general: "常规设置"
|
general: "常规设置"
|
||||||
wallpaper: "壁纸"
|
wallpaper: "壁纸"
|
||||||
setWallpaper: "设置壁纸"
|
setWallpaper: "设置壁纸"
|
||||||
|
@ -160,7 +160,7 @@ searchWith: "搜索:{q}"
|
||||||
youHaveNoLists: "列表为空"
|
youHaveNoLists: "列表为空"
|
||||||
followConfirm: "你确定要关注{name}吗?"
|
followConfirm: "你确定要关注{name}吗?"
|
||||||
proxyAccount: "代理账户"
|
proxyAccount: "代理账户"
|
||||||
proxyAccountDescription: "代理账户是在某些情况下充当用户的远程关注者的账户。 例如,当一个用户列出一个远程用户时,如果没有人跟随该列出的用户,则该活动将不会传递到该实例,因此将代之以代理账户。"
|
proxyAccountDescription: "代理账户是在某些情况下充当用户的远程关注者的账户。 例如,当一个用户列出一个远程用户时,如果没有人跟随该列出的用户,则该活动将不会传递到该服务器,因此将代之以代理账户。"
|
||||||
host: "主机名"
|
host: "主机名"
|
||||||
selectUser: "选择用户"
|
selectUser: "选择用户"
|
||||||
recipient: "收件人"
|
recipient: "收件人"
|
||||||
|
@ -176,7 +176,7 @@ charts: "图表"
|
||||||
perHour: "每小时"
|
perHour: "每小时"
|
||||||
perDay: "每天"
|
perDay: "每天"
|
||||||
stopActivityDelivery: "停止发送活动"
|
stopActivityDelivery: "停止发送活动"
|
||||||
blockThisInstance: "阻止此实例向本实例推流"
|
blockThisInstance: "屏蔽此服务器"
|
||||||
operations: "操作"
|
operations: "操作"
|
||||||
software: "软件"
|
software: "软件"
|
||||||
version: "版本"
|
version: "版本"
|
||||||
|
@ -194,15 +194,15 @@ clearQueueConfirmText: "未送达的帖子将不会送达。 通常,您不需
|
||||||
clearCachedFiles: "清除缓存"
|
clearCachedFiles: "清除缓存"
|
||||||
clearCachedFilesConfirm: "确定要清除缓存文件?"
|
clearCachedFilesConfirm: "确定要清除缓存文件?"
|
||||||
blockedInstances: "已屏蔽的服务器"
|
blockedInstances: "已屏蔽的服务器"
|
||||||
blockedInstancesDescription: "设定要阻拦的实例,以换行来进行分割。被阻拦的实例将无法与本实例进行交换通讯。"
|
blockedInstancesDescription: "设定要屏蔽的服务器,以换行来进行分割。被屏蔽的服务器将无法与本服务器进行交换通讯。"
|
||||||
muteAndBlock: "屏蔽/拉黑"
|
muteAndBlock: "屏蔽/拉黑"
|
||||||
mutedUsers: "已屏蔽用户"
|
mutedUsers: "已屏蔽用户"
|
||||||
blockedUsers: "被拉黑的用户"
|
blockedUsers: "被拉黑的用户"
|
||||||
noUsers: "无用户"
|
noUsers: "无用户"
|
||||||
editProfile: "编辑资料"
|
editProfile: "编辑资料"
|
||||||
noteDeleteConfirm: "要删除该帖子吗?"
|
noteDeleteConfirm: "要删除该帖子吗?"
|
||||||
pinLimitExceeded: "无法置顶更多了"
|
pinLimitExceeded: "无法置顶更多帖子了"
|
||||||
intro: "Misskey的部署结束啦!填写管理员账号吧!"
|
intro: "Calckey安装完成!请创建一个管理员用户。"
|
||||||
done: "完成"
|
done: "完成"
|
||||||
processing: "正在处理"
|
processing: "正在处理"
|
||||||
preview: "预览"
|
preview: "预览"
|
||||||
|
@ -217,15 +217,15 @@ all: "全部"
|
||||||
subscribing: "已订阅"
|
subscribing: "已订阅"
|
||||||
publishing: "直播中"
|
publishing: "直播中"
|
||||||
notResponding: "没有响应"
|
notResponding: "没有响应"
|
||||||
instanceFollowing: "关注实例"
|
instanceFollowing: "关注服务器"
|
||||||
instanceFollowers: "关注实例"
|
instanceFollowers: "服务器的关注者"
|
||||||
instanceUsers: "实例用户"
|
instanceUsers: "此服务器的用户"
|
||||||
changePassword: "修改密码"
|
changePassword: "修改密码"
|
||||||
security: "安全"
|
security: "安全"
|
||||||
retypedNotMatch: "两次输入不一致!"
|
retypedNotMatch: "两次输入不匹配。"
|
||||||
currentPassword: "现在的密码"
|
currentPassword: "现在的密码"
|
||||||
newPassword: "新密码"
|
newPassword: "新密码"
|
||||||
newPasswordRetype: "重新输入密码:"
|
newPasswordRetype: "重新输入新密码"
|
||||||
attachFile: "插入附件"
|
attachFile: "插入附件"
|
||||||
more: "更多!"
|
more: "更多!"
|
||||||
featured: "热门"
|
featured: "热门"
|
||||||
|
@ -340,7 +340,7 @@ basicInfo: "基本信息"
|
||||||
pinnedUsers: "置顶用户"
|
pinnedUsers: "置顶用户"
|
||||||
pinnedUsersDescription: "在「发现」页面中使用换行标记想要置顶的用户。"
|
pinnedUsersDescription: "在「发现」页面中使用换行标记想要置顶的用户。"
|
||||||
pinnedPages: "固定页面"
|
pinnedPages: "固定页面"
|
||||||
pinnedPagesDescription: "输入您要固定到实例首页的页面路径,以换行符分隔。"
|
pinnedPagesDescription: "输入您要固定到服务器首页的页面路径,以换行符分隔。"
|
||||||
pinnedClipId: "置顶的便签ID"
|
pinnedClipId: "置顶的便签ID"
|
||||||
pinnedNotes: "已置顶的帖子"
|
pinnedNotes: "已置顶的帖子"
|
||||||
hcaptcha: "hCaptcha"
|
hcaptcha: "hCaptcha"
|
||||||
|
@ -359,7 +359,7 @@ antennaSource: "接收来源"
|
||||||
antennaKeywords: "包含关键字"
|
antennaKeywords: "包含关键字"
|
||||||
antennaExcludeKeywords: "排除关键字"
|
antennaExcludeKeywords: "排除关键字"
|
||||||
antennaKeywordsDescription: "使用空格分隔会产生AND规范,并且使用换行符分隔会产生OR规范"
|
antennaKeywordsDescription: "使用空格分隔会产生AND规范,并且使用换行符分隔会产生OR规范"
|
||||||
notifyAntenna: "开启通知"
|
notifyAntenna: "新帖子通知"
|
||||||
withFileAntenna: "仅带有附件的帖子"
|
withFileAntenna: "仅带有附件的帖子"
|
||||||
enableServiceworker: "启用ServiceWorker"
|
enableServiceworker: "启用ServiceWorker"
|
||||||
antennaUsersDescription: "指定用户名,用换行符分隔"
|
antennaUsersDescription: "指定用户名,用换行符分隔"
|
||||||
|
@ -391,7 +391,7 @@ nUsersMentioned: "{n} 被提到"
|
||||||
securityKey: "安全密钥"
|
securityKey: "安全密钥"
|
||||||
securityKeyName: "密钥名称"
|
securityKeyName: "密钥名称"
|
||||||
registerSecurityKey: "注册硬件安全密钥"
|
registerSecurityKey: "注册硬件安全密钥"
|
||||||
lastUsed: "最后使用:"
|
lastUsed: "上次使用"
|
||||||
unregister: "删除账户"
|
unregister: "删除账户"
|
||||||
passwordLessLogin: "无密码登录"
|
passwordLessLogin: "无密码登录"
|
||||||
resetPassword: "重置密码"
|
resetPassword: "重置密码"
|
||||||
|
@ -424,7 +424,7 @@ text: "文本"
|
||||||
enable: "启用"
|
enable: "启用"
|
||||||
next: "下一个"
|
next: "下一个"
|
||||||
retype: "重新输入"
|
retype: "重新输入"
|
||||||
noteOf: "{user}的帖子"
|
noteOf: "{user} 的帖子"
|
||||||
inviteToGroup: "群组邀请"
|
inviteToGroup: "群组邀请"
|
||||||
quoteAttached: "已引用"
|
quoteAttached: "已引用"
|
||||||
quoteQuestion: "是否引用此链接内容?"
|
quoteQuestion: "是否引用此链接内容?"
|
||||||
|
@ -535,7 +535,7 @@ updateRemoteUser: "更新远程用户信息"
|
||||||
deleteAllFiles: "删除所有文件"
|
deleteAllFiles: "删除所有文件"
|
||||||
deleteAllFilesConfirm: "要删除所有文件吗?"
|
deleteAllFilesConfirm: "要删除所有文件吗?"
|
||||||
removeAllFollowing: "取消所有关注"
|
removeAllFollowing: "取消所有关注"
|
||||||
removeAllFollowingDescription: "取消{host}的所有关注者。当实例不存在时执行。"
|
removeAllFollowingDescription: "取消 {host} 的所有关注者。如果服务器已不存在,请执行它。"
|
||||||
userSuspended: "该用户已被冻结。"
|
userSuspended: "该用户已被冻结。"
|
||||||
userSilenced: "该用户已被禁言。"
|
userSilenced: "该用户已被禁言。"
|
||||||
yourAccountSuspendedTitle: "账户已被冻结"
|
yourAccountSuspendedTitle: "账户已被冻结"
|
||||||
|
@ -626,20 +626,20 @@ sample: "示例"
|
||||||
abuseReports: "举报"
|
abuseReports: "举报"
|
||||||
reportAbuse: "举报"
|
reportAbuse: "举报"
|
||||||
reportAbuseOf: "举报{name}"
|
reportAbuseOf: "举报{name}"
|
||||||
fillAbuseReportDescription: "请填写举报的详细原因。如果有对方发的帖子,请同时填写URL地址。"
|
fillAbuseReportDescription: "请填写举报的详细原因。如果有对方发的帖子,请同时填写 URL 地址。"
|
||||||
abuseReported: "内容已发送。感谢您提交信息。"
|
abuseReported: "内容已发送。感谢您提交信息。"
|
||||||
reporter: "举报者"
|
reporter: "举报者"
|
||||||
reporteeOrigin: "举报来源"
|
reporteeOrigin: "举报来源"
|
||||||
reporterOrigin: "举报者来源"
|
reporterOrigin: "举报者来源"
|
||||||
forwardReport: "将该举报信息转发给远程实例"
|
forwardReport: "将该举报信息转发给远程服务器"
|
||||||
forwardReportIsAnonymous: "勾选则在远程实例上显示的举报者是匿名的系统账号,而不是您的账号。"
|
forwardReportIsAnonymous: "勾选则在远程服务器上显示的举报者是匿名的系统账号,而不是您的账号。"
|
||||||
send: "发送"
|
send: "发送"
|
||||||
abuseMarkAsResolved: "处理完毕"
|
abuseMarkAsResolved: "处理完毕"
|
||||||
openInNewTab: "在新标签页中打开"
|
openInNewTab: "在新标签页中打开"
|
||||||
openInSideView: "在侧边栏中打开"
|
openInSideView: "在侧边栏中打开"
|
||||||
defaultNavigationBehaviour: "默认导航"
|
defaultNavigationBehaviour: "默认导航"
|
||||||
editTheseSettingsMayBreakAccount: "编辑这些设置可以会损坏您的账号"
|
editTheseSettingsMayBreakAccount: "编辑这些设置可以会损坏您的账号"
|
||||||
instanceTicker: "帖子的实例信息"
|
instanceTicker: "帖子的服务器信息"
|
||||||
waitingFor: "等待{x}"
|
waitingFor: "等待{x}"
|
||||||
random: "随机"
|
random: "随机"
|
||||||
system: "系统"
|
system: "系统"
|
||||||
|
@ -650,16 +650,16 @@ createNew: "新建"
|
||||||
optional: "可选"
|
optional: "可选"
|
||||||
createNewClip: "新建便签"
|
createNewClip: "新建便签"
|
||||||
unclip: "移除便签"
|
unclip: "移除便签"
|
||||||
confirmToUnclipAlreadyClippedNote: "本帖已包含在便签\"{name}\"里。您想要将本帖从该便签中移除吗?"
|
confirmToUnclipAlreadyClippedNote: "本帖已包含在便签 \"{name}\" 里。您想要将本帖从该便签中移除吗?"
|
||||||
public: "公开"
|
public: "公开"
|
||||||
i18nInfo: "Calckey已经被志愿者们翻译成了各种语言。如果你也有兴趣,可以通过{link}帮助翻译。"
|
i18nInfo: "Calckey已经被志愿者们翻译成了各种语言。如果你也有兴趣,可以通过{link}帮助翻译。"
|
||||||
manageAccessTokens: "管理 Access Tokens"
|
manageAccessTokens: "管理 Access Tokens"
|
||||||
accountInfo: "账户信息"
|
accountInfo: "账户信息"
|
||||||
notesCount: "帖子数量"
|
notesCount: "帖子数量"
|
||||||
repliesCount: "回复数量"
|
repliesCount: "回复数量"
|
||||||
renotesCount: "转帖数量"
|
renotesCount: "推贴数量"
|
||||||
repliedCount: "回复数"
|
repliedCount: "回复数"
|
||||||
renotedCount: "转发数"
|
renotedCount: "收到的推贴数"
|
||||||
followingCount: "正在关注数量"
|
followingCount: "正在关注数量"
|
||||||
followersCount: "关注者数量"
|
followersCount: "关注者数量"
|
||||||
sentReactionsCount: "发送回应数"
|
sentReactionsCount: "发送回应数"
|
||||||
|
@ -703,7 +703,7 @@ onlineUsersCount: "{n}人在线"
|
||||||
nUsers: "{n}用户"
|
nUsers: "{n}用户"
|
||||||
nNotes: "{n} 帖子"
|
nNotes: "{n} 帖子"
|
||||||
sendErrorReports: "发送错误报告"
|
sendErrorReports: "发送错误报告"
|
||||||
sendErrorReportsDescription: "启用后,如果出现问题,可以与Misskey共享详细的错误信息,从而帮助提高软件的质量。"
|
sendErrorReportsDescription: "启用后,如果出现问题,可以与 Misskey 共享详细的错误信息,从而帮助提高软件的质量。"
|
||||||
myTheme: "我的主题"
|
myTheme: "我的主题"
|
||||||
backgroundColor: "背景"
|
backgroundColor: "背景"
|
||||||
accentColor: "强调色"
|
accentColor: "强调色"
|
||||||
|
@ -727,7 +727,7 @@ capacity: "容量"
|
||||||
inUse: "已使用"
|
inUse: "已使用"
|
||||||
editCode: "编辑代码"
|
editCode: "编辑代码"
|
||||||
apply: "应用"
|
apply: "应用"
|
||||||
receiveAnnouncementFromInstance: "从实例接收通知"
|
receiveAnnouncementFromInstance: "从服务器接收通知"
|
||||||
emailNotification: "邮件通知"
|
emailNotification: "邮件通知"
|
||||||
publish: "发布"
|
publish: "发布"
|
||||||
inChannelSearch: "频道内搜索"
|
inChannelSearch: "频道内搜索"
|
||||||
|
@ -759,7 +759,7 @@ instanceBlocking: "联邦管理"
|
||||||
selectAccount: "选择账户"
|
selectAccount: "选择账户"
|
||||||
switchAccount: "切换账户"
|
switchAccount: "切换账户"
|
||||||
enabled: "已启用"
|
enabled: "已启用"
|
||||||
disabled: "已禁用 "
|
disabled: "已禁用"
|
||||||
quickAction: "快捷操作"
|
quickAction: "快捷操作"
|
||||||
user: "用户"
|
user: "用户"
|
||||||
administration: "管理"
|
administration: "管理"
|
||||||
|
@ -835,12 +835,12 @@ overridedDeviceKind: "设备类型"
|
||||||
smartphone: "智能手机"
|
smartphone: "智能手机"
|
||||||
tablet: "平板"
|
tablet: "平板"
|
||||||
auto: "自动"
|
auto: "自动"
|
||||||
themeColor: "主题颜色"
|
themeColor: "服务器滚动条颜色"
|
||||||
size: "大小"
|
size: "大小"
|
||||||
numberOfColumn: "列数"
|
numberOfColumn: "列数"
|
||||||
searchByGoogle: "Google"
|
searchByGoogle: "Google"
|
||||||
instanceDefaultLightTheme: "实例默认浅色主题"
|
instanceDefaultLightTheme: "服务器默认浅色主题"
|
||||||
instanceDefaultDarkTheme: "实例默认深色主题"
|
instanceDefaultDarkTheme: "服务器默认深色主题"
|
||||||
instanceDefaultThemeDescription: "以对象格式键入主题代码"
|
instanceDefaultThemeDescription: "以对象格式键入主题代码"
|
||||||
mutePeriod: "屏蔽期限"
|
mutePeriod: "屏蔽期限"
|
||||||
indefinitely: "永久"
|
indefinitely: "永久"
|
||||||
|
@ -863,7 +863,7 @@ check: "检查"
|
||||||
driveCapOverrideLabel: "變更此用戶的雲端硬碟容量上限"
|
driveCapOverrideLabel: "變更此用戶的雲端硬碟容量上限"
|
||||||
driveCapOverrideCaption: "设定为 0 以下则会解除此限制。"
|
driveCapOverrideCaption: "设定为 0 以下则会解除此限制。"
|
||||||
requireAdminForView: "需要使用管理员账户登录才能查看。"
|
requireAdminForView: "需要使用管理员账户登录才能查看。"
|
||||||
isSystemAccount: "该账号由系统自动创建和管理。"
|
isSystemAccount: "该账号由系统自动创建和管理。请不要修改、编辑、删除或以其他方式篡改这个账户,否则可能会破坏你的服务器。"
|
||||||
typeToConfirm: "输入 {x} 以确认操作。"
|
typeToConfirm: "输入 {x} 以确认操作。"
|
||||||
deleteAccount: "删除账户"
|
deleteAccount: "删除账户"
|
||||||
document: "文档"
|
document: "文档"
|
||||||
|
@ -875,7 +875,7 @@ statusbar: "状态栏"
|
||||||
pleaseSelect: "请选择"
|
pleaseSelect: "请选择"
|
||||||
reverse: "翻转"
|
reverse: "翻转"
|
||||||
colored: "彩色"
|
colored: "彩色"
|
||||||
refreshInterval: "刷新间隔"
|
refreshInterval: "更新间隔 "
|
||||||
label: "标签"
|
label: "标签"
|
||||||
type: "类型"
|
type: "类型"
|
||||||
speed: "速度"
|
speed: "速度"
|
||||||
|
@ -889,7 +889,7 @@ cannotUploadBecauseInappropriate: "因为可能含有不适宜的内容,无法
|
||||||
cannotUploadBecauseNoFreeSpace: "因为已无可用空间,无法上传。"
|
cannotUploadBecauseNoFreeSpace: "因为已无可用空间,无法上传。"
|
||||||
beta: "测试"
|
beta: "测试"
|
||||||
enableAutoSensitive: "自动 NSFW 识别"
|
enableAutoSensitive: "自动 NSFW 识别"
|
||||||
enableAutoSensitiveDescription: "如果可用,请使用机器学习在媒体上自动设置 NSFW 标志。即使关闭此功能,也可能会根据实例自动设置。"
|
enableAutoSensitiveDescription: "如果可用,请使用机器学习在媒体上自动设置 NSFW 标志。即使关闭此功能,也可能会根据服务器自动设置。"
|
||||||
activeEmailValidationDescription: "积极地验证用户的电子邮件地址,判断它是一次性的电子邮件地址,还是可以实际通信的地址。关闭时,则只检查字符串是否正确。"
|
activeEmailValidationDescription: "积极地验证用户的电子邮件地址,判断它是一次性的电子邮件地址,还是可以实际通信的地址。关闭时,则只检查字符串是否正确。"
|
||||||
navbar: "导航栏"
|
navbar: "导航栏"
|
||||||
shuffle: "随机"
|
shuffle: "随机"
|
||||||
|
@ -935,8 +935,8 @@ _ad:
|
||||||
reduceFrequencyOfThisAd: "减少此广告的频率"
|
reduceFrequencyOfThisAd: "减少此广告的频率"
|
||||||
_forgotPassword:
|
_forgotPassword:
|
||||||
enterEmail: "请输入您验证账号时用的电子邮箱地址,密码重置链接将发送至该邮箱上。"
|
enterEmail: "请输入您验证账号时用的电子邮箱地址,密码重置链接将发送至该邮箱上。"
|
||||||
ifNoEmail: "如果您没有使用电子邮件地址进行验证,请联系管理员。"
|
ifNoEmail: "如果您没有使用电子邮件地址进行验证,请联系服务器管理员。"
|
||||||
contactAdmin: "该实例不支持发送电子邮件。如果您想重设密码,请联系管理员。"
|
contactAdmin: "该服务器不支持发送电子邮件。如果您想重设密码,请联系管理员。"
|
||||||
_gallery:
|
_gallery:
|
||||||
my: "我的图库"
|
my: "我的图库"
|
||||||
liked: "喜欢的图片"
|
liked: "喜欢的图片"
|
||||||
|
@ -1109,10 +1109,10 @@ _wordMute:
|
||||||
hard: "硬屏蔽"
|
hard: "硬屏蔽"
|
||||||
mutedNotes: "已静音的帖子"
|
mutedNotes: "已静音的帖子"
|
||||||
_instanceMute:
|
_instanceMute:
|
||||||
instanceMuteDescription: "屏蔽配置实例中的所有帖子和转帖,包括实例的用户回复。"
|
instanceMuteDescription: "屏蔽列出服务器中的所有帖子和转帖,包括服务器的用户回复。"
|
||||||
instanceMuteDescription2: "设置时用换行符来分隔"
|
instanceMuteDescription2: "设置时用换行符来分隔"
|
||||||
title: "隐藏实例已设置的帖子。"
|
title: "隐藏服务器已设置的帖子。"
|
||||||
heading: "屏蔽实例"
|
heading: "要静音的服务器列表"
|
||||||
_theme:
|
_theme:
|
||||||
explore: "寻找主题"
|
explore: "寻找主题"
|
||||||
install: "安装主题"
|
install: "安装主题"
|
||||||
|
@ -1215,22 +1215,22 @@ _tutorial:
|
||||||
step1_1: "欢迎!"
|
step1_1: "欢迎!"
|
||||||
step1_2: "让我们把你安排好。你很快就会启动并运行!"
|
step1_2: "让我们把你安排好。你很快就会启动并运行!"
|
||||||
step2_1: "首先,请完成您的个人资料。"
|
step2_1: "首先,请完成您的个人资料。"
|
||||||
step2_2: "通过提供一些关于你自己的信息,其他人会更容易了解他们是否想看到你的帖子或关注你。"
|
step2_2: "提供一些关于你的信息,让其他人更容易知道他们是否想看你的帖子或关注你。"
|
||||||
step3_1: "现在是时候跟随一些人了!"
|
step3_1: "现在是时候关注一些人了!"
|
||||||
step3_2: "你的主页和社交馈送是基于你所关注的人,所以试着先关注几个账户。{n点击个人资料右上角的加号圈就可以关注它。"
|
step3_2: "你的主页和社交馈送是基于你所关注的人,所以试着先关注几个账户。{n点击个人资料右上角的加号圈就可以关注它。"
|
||||||
step4_1: "让我们出去找你。"
|
step4_1: "让我们出去找你。"
|
||||||
step4_2: "对于他们的第一条信息,有些人喜欢做{introduction}或一个简单的 \"hello world!\""
|
step4_2: "对于第一条帖子,可以做一个 {introduction} 或一个简单的 \"hello world!\""
|
||||||
step5_1: "时间限制,到处是时间限制!"
|
step5_1: "时间线,无处不在的时间线!"
|
||||||
step5_2: "您的实例已启用各种时间线的{timelines}。"
|
step5_2: "您的服务器已启用{timelines}种不同的时间线。"
|
||||||
step5_3: "主{icon}时间线是你可以看到你的订阅者的帖子的时间线。"
|
step5_3: "主页{icon}时间线是你可以看到你关注账户的帖子的时间线。"
|
||||||
step5_4: "本地{icon}时间线是你可以看到实例中所有其他用户的信息的时间线。"
|
step5_4: "本地{icon}时间线是你可以看到此服务器上其它用户的帖子的时间线。"
|
||||||
step5_5: "推荐的{icon}时间线 - 是时间轴,你可以看到管理员推荐的实例的信息"
|
step5_5: "社交{icon}时间线是主页和本地时间线的结合。"
|
||||||
step5_6: "社交{icon}时间线显示来自你的订阅者朋友的信息。"
|
step5_6: "推荐{icon}时间线是你可以看到管理员推荐服务器的帖子的时间线。"
|
||||||
step5_7: "全球{icon}时间线是你可以看到来自所有其他连接的实例的消息。"
|
step5_7: "全球{icon}时间线是你可以看到来自其它所有互联服务器的帖子的时间线。"
|
||||||
step6_1: "那么,这里是什么地方?"
|
step6_1: "那么,这里是什么地方?"
|
||||||
step6_2: "好吧,你不只是加入卡尔基。你已经加入了Fediverse的一个门户,这是一个由成千上万台服务器组成的互联网络,被称为 \"实例\""
|
step6_2: "好吧,你不只是加入Calckey。你已经加入了Fediverse的一个门户,这是一个由成千上万台服务器组成的互联网络。"
|
||||||
step6_3: "每个服务器的工作方式不同,并不是所有的服务器都运行Calckey。但这个人确实如此! 这有点复杂,但你很快就会明白的。"
|
step6_3: "每个服务器的工作方式不同,并不是所有的服务器都运行Calckey。但这个人确实如此! 这有点复杂,但你很快就会明白的。"
|
||||||
step6_4: "现在去学习并享受乐趣!"
|
step6_4: "现在,去吧,去探索,去享受乐趣吧!"
|
||||||
_2fa:
|
_2fa:
|
||||||
alreadyRegistered: "此设备已被注册"
|
alreadyRegistered: "此设备已被注册"
|
||||||
registerTOTP: "注册设备"
|
registerTOTP: "注册设备"
|
||||||
|
@ -1292,7 +1292,7 @@ _permissions:
|
||||||
_auth:
|
_auth:
|
||||||
shareAccess: "您要授权允许“{name}”访问您的帐户吗?"
|
shareAccess: "您要授权允许“{name}”访问您的帐户吗?"
|
||||||
shareAccessAsk: "您确定要授权此应用程序访问您的帐户吗?"
|
shareAccessAsk: "您确定要授权此应用程序访问您的帐户吗?"
|
||||||
permissionAsk: "这个应用程序需要以下权限"
|
permissionAsk: "此应用程序请求以下权限:"
|
||||||
pleaseGoBack: "请返回到应用程序"
|
pleaseGoBack: "请返回到应用程序"
|
||||||
callback: "回到应用程序"
|
callback: "回到应用程序"
|
||||||
denied: "拒绝访问"
|
denied: "拒绝访问"
|
||||||
|
@ -1341,6 +1341,8 @@ _widgets:
|
||||||
meiliIndexCount: 已索引的帖子
|
meiliIndexCount: 已索引的帖子
|
||||||
meiliSize: 索引大小
|
meiliSize: 索引大小
|
||||||
serverInfo: 服务器信息
|
serverInfo: 服务器信息
|
||||||
|
_userList:
|
||||||
|
chooseList: 选择一个列表
|
||||||
_cw:
|
_cw:
|
||||||
hide: "隐藏"
|
hide: "隐藏"
|
||||||
show: "查看更多"
|
show: "查看更多"
|
||||||
|
@ -1370,11 +1372,11 @@ _poll:
|
||||||
remainingSeconds: "{s}秒后截止"
|
remainingSeconds: "{s}秒后截止"
|
||||||
_visibility:
|
_visibility:
|
||||||
public: "公开"
|
public: "公开"
|
||||||
publicDescription: "您的帖子将出现在全局时间线上"
|
publicDescription: "您的帖子将出现在公共时间线上"
|
||||||
home: "不公开"
|
home: "不公开"
|
||||||
homeDescription: "仅发送至首页的时间线"
|
homeDescription: "仅发送至首页的时间线"
|
||||||
followers: "仅关注者"
|
followers: "仅关注者"
|
||||||
followersDescription: "仅发送至关注者"
|
followersDescription: "仅对你的关注者和提及的用户可见"
|
||||||
specified: "指定用户"
|
specified: "指定用户"
|
||||||
specifiedDescription: "仅发送至指定用户"
|
specifiedDescription: "仅发送至指定用户"
|
||||||
localOnly: "仅限本地"
|
localOnly: "仅限本地"
|
||||||
|
@ -1402,6 +1404,7 @@ _profile:
|
||||||
metadataContent: "内容"
|
metadataContent: "内容"
|
||||||
changeAvatar: "修改头像"
|
changeAvatar: "修改头像"
|
||||||
changeBanner: "修改横幅"
|
changeBanner: "修改横幅"
|
||||||
|
locationDescription: 如果你先输入你的城市,它将向其他用户显示您的当地时间。
|
||||||
_exportOrImport:
|
_exportOrImport:
|
||||||
allNotes: "所有帖子"
|
allNotes: "所有帖子"
|
||||||
followingList: "关注中"
|
followingList: "关注中"
|
||||||
|
@ -1430,7 +1433,7 @@ _instanceCharts:
|
||||||
usersTotal: "用户总计"
|
usersTotal: "用户总计"
|
||||||
notes: "帖子:增加/减少"
|
notes: "帖子:增加/减少"
|
||||||
notesTotal: "帖子总计"
|
notesTotal: "帖子总计"
|
||||||
ff: "关注/被关注:数量变化"
|
ff: "被关注用户/关注者的数量差异 "
|
||||||
ffTotal: "关注/被关注者总计"
|
ffTotal: "关注/被关注者总计"
|
||||||
cacheSize: "缓存大小:增加/减少"
|
cacheSize: "缓存大小:增加/减少"
|
||||||
cacheSizeTotal: "缓存大小总计"
|
cacheSizeTotal: "缓存大小总计"
|
||||||
|
@ -1519,7 +1522,7 @@ _pages:
|
||||||
note: "嵌入的帖子"
|
note: "嵌入的帖子"
|
||||||
_note:
|
_note:
|
||||||
id: "帖子ID"
|
id: "帖子ID"
|
||||||
idDescription: "您也可以通过粘贴帖子的URL来进行设置。"
|
idDescription: "你也可以将帖子 URL 粘贴到此处。"
|
||||||
detailed: "显示详细信息"
|
detailed: "显示详细信息"
|
||||||
switch: "开关"
|
switch: "开关"
|
||||||
_switch:
|
_switch:
|
||||||
|
@ -1768,6 +1771,9 @@ _notification:
|
||||||
followBack: "回关"
|
followBack: "回关"
|
||||||
reply: "回复"
|
reply: "回复"
|
||||||
renote: "转发"
|
renote: "转发"
|
||||||
|
reacted: 对你的帖子做出了回应
|
||||||
|
voted: 在你的投票中投了票
|
||||||
|
renoted: 推荐了你的帖子
|
||||||
_deck:
|
_deck:
|
||||||
alwaysShowMainColumn: "总是显示主列"
|
alwaysShowMainColumn: "总是显示主列"
|
||||||
columnAlign: "列对齐"
|
columnAlign: "列对齐"
|
||||||
|
@ -1804,6 +1810,8 @@ _messaging:
|
||||||
migration: 迁移
|
migration: 迁移
|
||||||
_experiments:
|
_experiments:
|
||||||
title: 实验性功能
|
title: 实验性功能
|
||||||
|
postImportsCaption: 允许用户从过去的 Calckey、Misskey、Mastodon、Akkoma 和 Pleroma 帐户导入帖子。如果您的队列出现拥堵,则可能会导致加载速度减慢。
|
||||||
|
enablePostImports: 启用帖子导入
|
||||||
license: 许可证
|
license: 许可证
|
||||||
flagSpeakAsCatDescription: 在猫模式下你的帖子会喵化
|
flagSpeakAsCatDescription: 在猫模式下你的帖子会喵化
|
||||||
allowedInstances: 白名单服务器
|
allowedInstances: 白名单服务器
|
||||||
|
@ -1813,11 +1821,11 @@ removeReaction: 移除你的回应
|
||||||
expandOnNoteClick: 点击打开帖子
|
expandOnNoteClick: 点击打开帖子
|
||||||
expandOnNoteClickDesc: 如果禁用,你仍然可以在右键菜单中或通过点击时间戳打开帖子。
|
expandOnNoteClickDesc: 如果禁用,你仍然可以在右键菜单中或通过点击时间戳打开帖子。
|
||||||
sendPushNotificationReadMessage: 删除已阅读的推送通知
|
sendPushNotificationReadMessage: 删除已阅读的推送通知
|
||||||
customMOTD: 自定义 MOTD(闪屏消息)
|
customMOTD: 自定义 MOTD(启动屏幕消息)
|
||||||
sendPushNotificationReadMessageCaption: 短暂显示 "{emptyPushNotificationMessage}" 的通知,如果启用,可能会增加你的设备的耗电量。
|
sendPushNotificationReadMessageCaption: 短暂显示 "{emptyPushNotificationMessage}" 的通知,如果启用,可能会增加你的设备的耗电量。
|
||||||
adminCustomCssWarn: 仅当你知道此设置的作用时才应使用它。输入不正确的值可能会导致每个人的客户端停止正常运行。请在用户设置中进行测试来确保您的 CSS
|
adminCustomCssWarn: 仅当你知道此设置的作用时才应使用它。输入不正确的值可能会导致每个人的客户端停止正常运行。请在用户设置中进行测试来确保您的 CSS
|
||||||
正常工作。
|
正常工作。
|
||||||
customMOTDDescription: 自定义MOTD(闪屏)消息,一行一个,每次用户加载/刷新页面时都会随机显示。
|
customMOTDDescription: 自定义 MOTD(启动屏幕)消息,一行一个,每次用户加载/刷新页面时都会随机显示。
|
||||||
customSplashIconsDescription: 用换行符隔开的自定义闪屏图标的URL,在用户每次加载/重新加载页面时随机显示。请确保图片是在一个静态的
|
customSplashIconsDescription: 用换行符隔开的自定义闪屏图标的URL,在用户每次加载/重新加载页面时随机显示。请确保图片是在一个静态的
|
||||||
URL 上,最好全部调整为 192x192 的大小。
|
URL 上,最好全部调整为 192x192 的大小。
|
||||||
recommendedInstancesDescription: 推荐的服务器以换行符分隔,它们将出现在推荐的时间线中。不要添加 "https://",仅添加域名。
|
recommendedInstancesDescription: 推荐的服务器以换行符分隔,它们将出现在推荐的时间线中。不要添加 "https://",仅添加域名。
|
||||||
|
@ -1889,6 +1897,8 @@ _skinTones:
|
||||||
light: 浅色
|
light: 浅色
|
||||||
yellow: 黄色
|
yellow: 黄色
|
||||||
dark: 深色
|
dark: 深色
|
||||||
|
mediumLight: 中等偏淡
|
||||||
|
mediumDark: 中等偏深
|
||||||
isModerator: 协作者
|
isModerator: 协作者
|
||||||
isAdmin: 管理员
|
isAdmin: 管理员
|
||||||
findOtherInstance: 寻找其它服务器
|
findOtherInstance: 寻找其它服务器
|
||||||
|
@ -1926,3 +1936,8 @@ _filters:
|
||||||
followersOnly: 仅关注者
|
followersOnly: 仅关注者
|
||||||
reactionPickerSkinTone: 首选的表情符号肤色
|
reactionPickerSkinTone: 首选的表情符号肤色
|
||||||
isPatron: Calckey 赞助
|
isPatron: Calckey 赞助
|
||||||
|
_dialog:
|
||||||
|
charactersExceeded: 超出了最大字符数!当前:{current} / 限制:{max}
|
||||||
|
charactersBelow: 没有足够的字符!当前:{current} / 限制:{min}
|
||||||
|
enableIdenticonGeneration: 启用Identicon生成
|
||||||
|
enableServerMachineStats: 启用服务器硬件统计
|
||||||
|
|
|
@ -34,6 +34,7 @@
|
||||||
"@koa/cors": "3.4.3",
|
"@koa/cors": "3.4.3",
|
||||||
"@koa/multer": "3.0.2",
|
"@koa/multer": "3.0.2",
|
||||||
"@koa/router": "9.0.1",
|
"@koa/router": "9.0.1",
|
||||||
|
"@msgpack/msgpack": "3.0.0-beta2",
|
||||||
"@peertube/http-signature": "1.7.0",
|
"@peertube/http-signature": "1.7.0",
|
||||||
"@redocly/openapi-core": "1.0.0-beta.120",
|
"@redocly/openapi-core": "1.0.0-beta.120",
|
||||||
"@sinonjs/fake-timers": "9.1.2",
|
"@sinonjs/fake-timers": "9.1.2",
|
||||||
|
@ -43,7 +44,6 @@
|
||||||
"ajv": "8.12.0",
|
"ajv": "8.12.0",
|
||||||
"archiver": "5.3.1",
|
"archiver": "5.3.1",
|
||||||
"argon2": "^0.30.3",
|
"argon2": "^0.30.3",
|
||||||
"async-mutex": "^0.4.0",
|
|
||||||
"autobind-decorator": "2.4.0",
|
"autobind-decorator": "2.4.0",
|
||||||
"autolinker": "4.0.0",
|
"autolinker": "4.0.0",
|
||||||
"autwh": "0.1.0",
|
"autwh": "0.1.0",
|
||||||
|
|
|
@ -1,43 +1,85 @@
|
||||||
|
import { redisClient } from "@/db/redis.js";
|
||||||
|
import { encode, decode } from "@msgpack/msgpack";
|
||||||
|
import { ChainableCommander } from "ioredis";
|
||||||
|
|
||||||
export class Cache<T> {
|
export class Cache<T> {
|
||||||
public cache: Map<string | null, { date: number; value: T }>;
|
private ttl: number;
|
||||||
private lifetime: number;
|
private prefix: string;
|
||||||
|
|
||||||
constructor(lifetime: Cache<never>["lifetime"]) {
|
constructor(name: string, ttlSeconds: number) {
|
||||||
this.cache = new Map();
|
this.ttl = ttlSeconds;
|
||||||
this.lifetime = lifetime;
|
this.prefix = `cache:${name}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
public set(key: string | null, value: T): void {
|
private prefixedKey(key: string | null): string {
|
||||||
this.cache.set(key, {
|
return key ? `${this.prefix}:${key}` : this.prefix;
|
||||||
date: Date.now(),
|
|
||||||
value,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public get(key: string | null): T | undefined {
|
public async set(
|
||||||
const cached = this.cache.get(key);
|
key: string | null,
|
||||||
if (cached == null) return undefined;
|
value: T,
|
||||||
if (Date.now() - cached.date > this.lifetime) {
|
transaction?: ChainableCommander,
|
||||||
this.cache.delete(key);
|
): Promise<void> {
|
||||||
return undefined;
|
const _key = this.prefixedKey(key);
|
||||||
|
const _value = Buffer.from(encode(value));
|
||||||
|
const commander = transaction ?? redisClient;
|
||||||
|
await commander.set(_key, _value, "EX", this.ttl);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async get(key: string | null, renew = false): Promise<T | undefined> {
|
||||||
|
const _key = this.prefixedKey(key);
|
||||||
|
const cached = await redisClient.getBuffer(_key);
|
||||||
|
if (cached === null) return undefined;
|
||||||
|
|
||||||
|
if (renew) await redisClient.expire(_key, this.ttl);
|
||||||
|
|
||||||
|
return decode(cached) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getAll(renew = false): Promise<Map<string, T>> {
|
||||||
|
const keys = await redisClient.keys(`${this.prefix}*`);
|
||||||
|
const map = new Map<string, T>();
|
||||||
|
if (keys.length === 0) {
|
||||||
|
return map;
|
||||||
}
|
}
|
||||||
return cached.value;
|
const values = await redisClient.mgetBuffer(keys);
|
||||||
|
|
||||||
|
for (const [i, key] of keys.entries()) {
|
||||||
|
const val = values[i];
|
||||||
|
if (val !== null) {
|
||||||
|
map.set(key, decode(val) as T);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (renew) {
|
||||||
|
const trans = redisClient.multi();
|
||||||
|
for (const key of map.keys()) {
|
||||||
|
trans.expire(key, this.ttl);
|
||||||
|
}
|
||||||
|
await trans.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
public delete(key: string | null) {
|
public async delete(...keys: (string | null)[]): Promise<void> {
|
||||||
this.cache.delete(key);
|
if (keys.length > 0) {
|
||||||
|
const _keys = keys.map(this.prefixedKey);
|
||||||
|
await redisClient.del(_keys);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* キャッシュがあればそれを返し、無ければfetcherを呼び出して結果をキャッシュ&返します
|
* Returns if cached value exists. Otherwise, calls fetcher and caches.
|
||||||
* optional: キャッシュが存在してもvalidatorでfalseを返すとキャッシュ無効扱いにします
|
* Overwrites cached value if invalidated by the optional validator.
|
||||||
*/
|
*/
|
||||||
public async fetch(
|
public async fetch(
|
||||||
key: string | null,
|
key: string | null,
|
||||||
fetcher: () => Promise<T>,
|
fetcher: () => Promise<T>,
|
||||||
|
renew = false,
|
||||||
validator?: (cachedValue: T) => boolean,
|
validator?: (cachedValue: T) => boolean,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const cachedValue = this.get(key);
|
const cachedValue = await this.get(key, renew);
|
||||||
if (cachedValue !== undefined) {
|
if (cachedValue !== undefined) {
|
||||||
if (validator) {
|
if (validator) {
|
||||||
if (validator(cachedValue)) {
|
if (validator(cachedValue)) {
|
||||||
|
@ -52,20 +94,21 @@ export class Cache<T> {
|
||||||
|
|
||||||
// Cache MISS
|
// Cache MISS
|
||||||
const value = await fetcher();
|
const value = await fetcher();
|
||||||
this.set(key, value);
|
await this.set(key, value);
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* キャッシュがあればそれを返し、無ければfetcherを呼び出して結果をキャッシュ&返します
|
* Returns if cached value exists. Otherwise, calls fetcher and caches if the fetcher returns a value.
|
||||||
* optional: キャッシュが存在してもvalidatorでfalseを返すとキャッシュ無効扱いにします
|
* Overwrites cached value if invalidated by the optional validator.
|
||||||
*/
|
*/
|
||||||
public async fetchMaybe(
|
public async fetchMaybe(
|
||||||
key: string | null,
|
key: string | null,
|
||||||
fetcher: () => Promise<T | undefined>,
|
fetcher: () => Promise<T | undefined>,
|
||||||
|
renew = false,
|
||||||
validator?: (cachedValue: T) => boolean,
|
validator?: (cachedValue: T) => boolean,
|
||||||
): Promise<T | undefined> {
|
): Promise<T | undefined> {
|
||||||
const cachedValue = this.get(key);
|
const cachedValue = await this.get(key, renew);
|
||||||
if (cachedValue !== undefined) {
|
if (cachedValue !== undefined) {
|
||||||
if (validator) {
|
if (validator) {
|
||||||
if (validator(cachedValue)) {
|
if (validator(cachedValue)) {
|
||||||
|
@ -81,7 +124,7 @@ export class Cache<T> {
|
||||||
// Cache MISS
|
// Cache MISS
|
||||||
const value = await fetcher();
|
const value = await fetcher();
|
||||||
if (value !== undefined) {
|
if (value !== undefined) {
|
||||||
this.set(key, value);
|
await this.set(key, value);
|
||||||
}
|
}
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,7 +11,7 @@ import * as Acct from "@/misc/acct.js";
|
||||||
import type { Packed } from "./schema.js";
|
import type { Packed } from "./schema.js";
|
||||||
import { Cache } from "./cache.js";
|
import { Cache } from "./cache.js";
|
||||||
|
|
||||||
const blockingCache = new Cache<User["id"][]>(1000 * 60 * 5);
|
const blockingCache = new Cache<User["id"][]>("blocking", 60 * 5);
|
||||||
|
|
||||||
// NOTE: フォローしているユーザーのノート、リストのユーザーのノート、グループのユーザーのノート指定はパフォーマンス上の理由で無効になっている
|
// NOTE: フォローしているユーザーのノート、リストのユーザーのノート、グループのユーザーのノート指定はパフォーマンス上の理由で無効になっている
|
||||||
|
|
||||||
|
|
|
@ -1,33 +1,41 @@
|
||||||
import probeImageSize from "probe-image-size";
|
import probeImageSize from "probe-image-size";
|
||||||
import { Mutex, withTimeout } from "async-mutex";
|
import { Mutex } from "redis-semaphore";
|
||||||
|
|
||||||
import { FILE_TYPE_BROWSERSAFE } from "@/const.js";
|
import { FILE_TYPE_BROWSERSAFE } from "@/const.js";
|
||||||
import Logger from "@/services/logger.js";
|
import Logger from "@/services/logger.js";
|
||||||
import { Cache } from "./cache.js";
|
import { Cache } from "./cache.js";
|
||||||
|
import { redisClient } from "@/db/redis.js";
|
||||||
|
|
||||||
export type Size = {
|
export type Size = {
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const cache = new Cache<boolean>(1000 * 60 * 10); // once every 10 minutes for the same url
|
const cache = new Cache<boolean>("emojiMeta", 60 * 10); // once every 10 minutes for the same url
|
||||||
const mutex = withTimeout(new Mutex(), 1000);
|
const logger = new Logger("emoji");
|
||||||
|
|
||||||
export async function getEmojiSize(url: string): Promise<Size> {
|
export async function getEmojiSize(url: string): Promise<Size> {
|
||||||
const logger = new Logger("emoji");
|
let attempted = true;
|
||||||
|
|
||||||
await mutex.runExclusive(() => {
|
const lock = new Mutex(redisClient, "getEmojiSize");
|
||||||
const attempted = cache.get(url);
|
await lock.acquire();
|
||||||
if (!attempted) {
|
|
||||||
cache.set(url, true);
|
|
||||||
} else {
|
|
||||||
logger.warn(`Attempt limit exceeded: ${url}`);
|
|
||||||
throw new Error("Too many attempts");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
logger.info(`Retrieving emoji size from ${url}`);
|
attempted = (await cache.get(url)) === true;
|
||||||
|
if (!attempted) {
|
||||||
|
await cache.set(url, true);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await lock.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempted) {
|
||||||
|
logger.warn(`Attempt limit exceeded: ${url}`);
|
||||||
|
throw new Error("Too many attempts");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.debug(`Retrieving emoji size from ${url}`);
|
||||||
const { width, height, mime } = await probeImageSize(url, {
|
const { width, height, mime } = await probeImageSize(url, {
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
});
|
});
|
||||||
|
|
|
@ -3,10 +3,12 @@ import type { User } from "@/models/entities/user.js";
|
||||||
import type { UserKeypair } from "@/models/entities/user-keypair.js";
|
import type { UserKeypair } from "@/models/entities/user-keypair.js";
|
||||||
import { Cache } from "./cache.js";
|
import { Cache } from "./cache.js";
|
||||||
|
|
||||||
const cache = new Cache<UserKeypair>(Infinity);
|
const cache = new Cache<UserKeypair>("keypairStore", 60 * 30);
|
||||||
|
|
||||||
export async function getUserKeypair(userId: User["id"]): Promise<UserKeypair> {
|
export async function getUserKeypair(userId: User["id"]): Promise<UserKeypair> {
|
||||||
return await cache.fetch(userId, () =>
|
return await cache.fetch(
|
||||||
UserKeypairs.findOneByOrFail({ userId: userId }),
|
userId,
|
||||||
|
() => UserKeypairs.findOneByOrFail({ userId: userId }),
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,8 +7,9 @@ import { isSelfHost, toPunyNullable } from "./convert-host.js";
|
||||||
import { decodeReaction } from "./reaction-lib.js";
|
import { decodeReaction } from "./reaction-lib.js";
|
||||||
import config from "@/config/index.js";
|
import config from "@/config/index.js";
|
||||||
import { query } from "@/prelude/url.js";
|
import { query } from "@/prelude/url.js";
|
||||||
|
import { redisClient } from "@/db/redis.js";
|
||||||
|
|
||||||
const cache = new Cache<Emoji | null>(1000 * 60 * 60 * 12);
|
const cache = new Cache<Emoji | null>("populateEmojis", 60 * 60 * 12);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 添付用絵文字情報
|
* 添付用絵文字情報
|
||||||
|
@ -75,7 +76,7 @@ export async function populateEmoji(
|
||||||
|
|
||||||
if (emoji && !(emoji.width && emoji.height)) {
|
if (emoji && !(emoji.width && emoji.height)) {
|
||||||
emoji = await queryOrNull();
|
emoji = await queryOrNull();
|
||||||
cache.set(cacheKey, emoji);
|
await cache.set(cacheKey, emoji);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (emoji == null) return null;
|
if (emoji == null) return null;
|
||||||
|
@ -150,7 +151,7 @@ export async function prefetchEmojis(
|
||||||
emojis: { name: string; host: string | null }[],
|
emojis: { name: string; host: string | null }[],
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const notCachedEmojis = emojis.filter(
|
const notCachedEmojis = emojis.filter(
|
||||||
(emoji) => cache.get(`${emoji.name} ${emoji.host}`) == null,
|
async (emoji) => !(await cache.get(`${emoji.name} ${emoji.host}`)),
|
||||||
);
|
);
|
||||||
const emojisQuery: any[] = [];
|
const emojisQuery: any[] = [];
|
||||||
const hosts = new Set(notCachedEmojis.map((e) => e.host));
|
const hosts = new Set(notCachedEmojis.map((e) => e.host));
|
||||||
|
@ -169,7 +170,9 @@ export async function prefetchEmojis(
|
||||||
select: ["name", "host", "originalUrl", "publicUrl"],
|
select: ["name", "host", "originalUrl", "publicUrl"],
|
||||||
})
|
})
|
||||||
: [];
|
: [];
|
||||||
|
const trans = redisClient.multi();
|
||||||
for (const emoji of _emojis) {
|
for (const emoji of _emojis) {
|
||||||
cache.set(`${emoji.name} ${emoji.host}`, emoji);
|
cache.set(`${emoji.name} ${emoji.host}`, emoji, trans);
|
||||||
}
|
}
|
||||||
|
await trans.exec();
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import { URL } from "url";
|
|
||||||
import { In, Not } from "typeorm";
|
import { In, Not } from "typeorm";
|
||||||
import Ajv from "ajv";
|
import Ajv from "ajv";
|
||||||
import type { ILocalUser, IRemoteUser } from "@/models/entities/user.js";
|
import type { ILocalUser, IRemoteUser } from "@/models/entities/user.js";
|
||||||
|
@ -40,7 +39,10 @@ import {
|
||||||
} from "../index.js";
|
} from "../index.js";
|
||||||
import type { Instance } from "../entities/instance.js";
|
import type { Instance } from "../entities/instance.js";
|
||||||
|
|
||||||
const userInstanceCache = new Cache<Instance | null>(1000 * 60 * 60 * 3);
|
const userInstanceCache = new Cache<Instance | null>(
|
||||||
|
"userInstance",
|
||||||
|
60 * 60 * 3,
|
||||||
|
);
|
||||||
|
|
||||||
type IsUserDetailed<Detailed extends boolean> = Detailed extends true
|
type IsUserDetailed<Detailed extends boolean> = Detailed extends true
|
||||||
? Packed<"UserDetailed">
|
? Packed<"UserDetailed">
|
||||||
|
|
|
@ -5,7 +5,6 @@ import type {
|
||||||
CacheableRemoteUser,
|
CacheableRemoteUser,
|
||||||
CacheableUser,
|
CacheableUser,
|
||||||
} from "@/models/entities/user.js";
|
} from "@/models/entities/user.js";
|
||||||
import { User, IRemoteUser } from "@/models/entities/user.js";
|
|
||||||
import type { UserPublickey } from "@/models/entities/user-publickey.js";
|
import type { UserPublickey } from "@/models/entities/user-publickey.js";
|
||||||
import type { MessagingMessage } from "@/models/entities/messaging-message.js";
|
import type { MessagingMessage } from "@/models/entities/messaging-message.js";
|
||||||
import {
|
import {
|
||||||
|
@ -20,8 +19,11 @@ import type { IObject } from "./type.js";
|
||||||
import { getApId } from "./type.js";
|
import { getApId } from "./type.js";
|
||||||
import { resolvePerson } from "./models/person.js";
|
import { resolvePerson } from "./models/person.js";
|
||||||
|
|
||||||
const publicKeyCache = new Cache<UserPublickey | null>(Infinity);
|
const publicKeyCache = new Cache<UserPublickey | null>("publicKey", 60 * 30);
|
||||||
const publicKeyByUserIdCache = new Cache<UserPublickey | null>(Infinity);
|
const publicKeyByUserIdCache = new Cache<UserPublickey | null>(
|
||||||
|
"publicKeyByUserId",
|
||||||
|
60 * 30,
|
||||||
|
);
|
||||||
|
|
||||||
export type UriParseResult =
|
export type UriParseResult =
|
||||||
| {
|
| {
|
||||||
|
@ -123,17 +125,23 @@ export default class DbResolver {
|
||||||
if (parsed.type !== "users") return null;
|
if (parsed.type !== "users") return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
(await userByIdCache.fetchMaybe(parsed.id, () =>
|
(await userByIdCache.fetchMaybe(
|
||||||
Users.findOneBy({
|
parsed.id,
|
||||||
id: parsed.id,
|
() =>
|
||||||
}).then((x) => x ?? undefined),
|
Users.findOneBy({
|
||||||
|
id: parsed.id,
|
||||||
|
}).then((x) => x ?? undefined),
|
||||||
|
true,
|
||||||
)) ?? null
|
)) ?? null
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return await uriPersonCache.fetch(parsed.uri, () =>
|
return await uriPersonCache.fetch(
|
||||||
Users.findOneBy({
|
parsed.uri,
|
||||||
uri: parsed.uri,
|
() =>
|
||||||
}),
|
Users.findOneBy({
|
||||||
|
uri: parsed.uri,
|
||||||
|
}),
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -156,14 +164,17 @@ export default class DbResolver {
|
||||||
|
|
||||||
return key;
|
return key;
|
||||||
},
|
},
|
||||||
|
true,
|
||||||
(key) => key != null,
|
(key) => key != null,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (key == null) return null;
|
if (key == null) return null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user: (await userByIdCache.fetch(key.userId, () =>
|
user: (await userByIdCache.fetch(
|
||||||
Users.findOneByOrFail({ id: key.userId }),
|
key.userId,
|
||||||
|
() => Users.findOneByOrFail({ id: key.userId }),
|
||||||
|
true,
|
||||||
)) as CacheableRemoteUser,
|
)) as CacheableRemoteUser,
|
||||||
key,
|
key,
|
||||||
};
|
};
|
||||||
|
@ -183,6 +194,7 @@ export default class DbResolver {
|
||||||
const key = await publicKeyByUserIdCache.fetch(
|
const key = await publicKeyByUserIdCache.fetch(
|
||||||
user.id,
|
user.id,
|
||||||
() => UserPublickeys.findOneBy({ userId: user.id }),
|
() => UserPublickeys.findOneBy({ userId: user.id }),
|
||||||
|
true,
|
||||||
(v) => v != null,
|
(v) => v != null,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -135,14 +135,14 @@ export async function fetchPerson(
|
||||||
): Promise<CacheableUser | null> {
|
): Promise<CacheableUser | null> {
|
||||||
if (typeof uri !== "string") throw new Error("uri is not string");
|
if (typeof uri !== "string") throw new Error("uri is not string");
|
||||||
|
|
||||||
const cached = uriPersonCache.get(uri);
|
const cached = await uriPersonCache.get(uri, true);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
|
|
||||||
// Fetch from the database if the URI points to this server
|
// Fetch from the database if the URI points to this server
|
||||||
if (uri.startsWith(`${config.url}/`)) {
|
if (uri.startsWith(`${config.url}/`)) {
|
||||||
const id = uri.split("/").pop();
|
const id = uri.split("/").pop();
|
||||||
const u = await Users.findOneBy({ id });
|
const u = await Users.findOneBy({ id });
|
||||||
if (u) uriPersonCache.set(uri, u);
|
if (u) await uriPersonCache.set(uri, u);
|
||||||
return u;
|
return u;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -150,7 +150,7 @@ export async function fetchPerson(
|
||||||
const exist = await Users.findOneBy({ uri });
|
const exist = await Users.findOneBy({ uri });
|
||||||
|
|
||||||
if (exist) {
|
if (exist) {
|
||||||
uriPersonCache.set(uri, exist);
|
await uriPersonCache.set(uri, exist);
|
||||||
return exist;
|
return exist;
|
||||||
}
|
}
|
||||||
//#endregion
|
//#endregion
|
||||||
|
|
|
@ -9,7 +9,7 @@ import {
|
||||||
localUserByNativeTokenCache,
|
localUserByNativeTokenCache,
|
||||||
} from "@/services/user-cache.js";
|
} from "@/services/user-cache.js";
|
||||||
|
|
||||||
const appCache = new Cache<App>(Infinity);
|
const appCache = new Cache<App>("app", 60 * 30);
|
||||||
|
|
||||||
export class AuthenticationError extends Error {
|
export class AuthenticationError extends Error {
|
||||||
constructor(message: string) {
|
constructor(message: string) {
|
||||||
|
@ -49,6 +49,7 @@ export default async (
|
||||||
const user = await localUserByNativeTokenCache.fetch(
|
const user = await localUserByNativeTokenCache.fetch(
|
||||||
token,
|
token,
|
||||||
() => Users.findOneBy({ token }) as Promise<ILocalUser | null>,
|
() => Users.findOneBy({ token }) as Promise<ILocalUser | null>,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (user == null) {
|
if (user == null) {
|
||||||
|
@ -82,11 +83,14 @@ export default async (
|
||||||
Users.findOneBy({
|
Users.findOneBy({
|
||||||
id: accessToken.userId,
|
id: accessToken.userId,
|
||||||
}) as Promise<ILocalUser>,
|
}) as Promise<ILocalUser>,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (accessToken.appId) {
|
if (accessToken.appId) {
|
||||||
const app = await appCache.fetch(accessToken.appId, () =>
|
const app = await appCache.fetch(
|
||||||
Apps.findOneByOrFail({ id: accessToken.appId! }),
|
accessToken.appId,
|
||||||
|
() => Apps.findOneByOrFail({ id: accessToken.appId! }),
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
|
@ -6,7 +6,7 @@ import { ApiError } from "../../../error.js";
|
||||||
import rndstr from "rndstr";
|
import rndstr from "rndstr";
|
||||||
import { publishBroadcastStream } from "@/services/stream.js";
|
import { publishBroadcastStream } from "@/services/stream.js";
|
||||||
import { db } from "@/db/postgre.js";
|
import { db } from "@/db/postgre.js";
|
||||||
import { type Size, getEmojiSize } from "@/misc/emoji-meta.js";
|
import { getEmojiSize } from "@/misc/emoji-meta.js";
|
||||||
|
|
||||||
export const meta = {
|
export const meta = {
|
||||||
tags: ["admin"],
|
tags: ["admin"],
|
||||||
|
@ -40,12 +40,7 @@ export default define(meta, paramDef, async (ps, me) => {
|
||||||
? file.name.split(".")[0]
|
? file.name.split(".")[0]
|
||||||
: `_${rndstr("a-z0-9", 8)}_`;
|
: `_${rndstr("a-z0-9", 8)}_`;
|
||||||
|
|
||||||
let size: Size = { width: 0, height: 0 };
|
const size = await getEmojiSize(file.url);
|
||||||
try {
|
|
||||||
size = await getEmojiSize(file.url);
|
|
||||||
} catch {
|
|
||||||
/* skip if any error happens */
|
|
||||||
}
|
|
||||||
|
|
||||||
const emoji = await Emojis.insert({
|
const emoji = await Emojis.insert({
|
||||||
id: genId(),
|
id: genId(),
|
||||||
|
|
|
@ -6,7 +6,7 @@ import type { DriveFile } from "@/models/entities/drive-file.js";
|
||||||
import { uploadFromUrl } from "@/services/drive/upload-from-url.js";
|
import { uploadFromUrl } from "@/services/drive/upload-from-url.js";
|
||||||
import { publishBroadcastStream } from "@/services/stream.js";
|
import { publishBroadcastStream } from "@/services/stream.js";
|
||||||
import { db } from "@/db/postgre.js";
|
import { db } from "@/db/postgre.js";
|
||||||
import { type Size, getEmojiSize } from "@/misc/emoji-meta.js";
|
import { getEmojiSize } from "@/misc/emoji-meta.js";
|
||||||
|
|
||||||
export const meta = {
|
export const meta = {
|
||||||
tags: ["admin"],
|
tags: ["admin"],
|
||||||
|
@ -65,12 +65,7 @@ export default define(meta, paramDef, async (ps, me) => {
|
||||||
throw new ApiError();
|
throw new ApiError();
|
||||||
}
|
}
|
||||||
|
|
||||||
let size: Size = { width: 0, height: 0 };
|
const size = await getEmojiSize(driveFile.url);
|
||||||
try {
|
|
||||||
size = await getEmojiSize(driveFile.url);
|
|
||||||
} catch {
|
|
||||||
/* skip if any error happens */
|
|
||||||
}
|
|
||||||
|
|
||||||
const copied = await Emojis.insert({
|
const copied = await Emojis.insert({
|
||||||
id: genId(),
|
id: genId(),
|
||||||
|
|
|
@ -100,7 +100,10 @@ const nodeinfo2 = async () => {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const cache = new Cache<Awaited<ReturnType<typeof nodeinfo2>>>(1000 * 60 * 10);
|
const cache = new Cache<Awaited<ReturnType<typeof nodeinfo2>>>(
|
||||||
|
"nodeinfo",
|
||||||
|
60 * 10,
|
||||||
|
);
|
||||||
|
|
||||||
router.get(nodeinfo2_1path, async (ctx) => {
|
router.get(nodeinfo2_1path, async (ctx) => {
|
||||||
const base = await cache.fetch(null, () => nodeinfo2());
|
const base = await cache.fetch(null, () => nodeinfo2());
|
||||||
|
|
|
@ -25,12 +25,12 @@ export default class ActiveUsersChart extends Chart<typeof schema> {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async read(user: {
|
public read(user: {
|
||||||
id: User["id"];
|
id: User["id"];
|
||||||
host: null;
|
host: null;
|
||||||
createdAt: User["createdAt"];
|
createdAt: User["createdAt"];
|
||||||
}): Promise<void> {
|
}) {
|
||||||
await this.commit({
|
this.commit({
|
||||||
read: [user.id],
|
read: [user.id],
|
||||||
registeredWithinWeek:
|
registeredWithinWeek:
|
||||||
Date.now() - user.createdAt.getTime() < week ? [user.id] : [],
|
Date.now() - user.createdAt.getTime() < week ? [user.id] : [],
|
||||||
|
|
|
@ -6,10 +6,10 @@ import { IsNull } from "typeorm";
|
||||||
|
|
||||||
const ACTOR_USERNAME = "instance.actor" as const;
|
const ACTOR_USERNAME = "instance.actor" as const;
|
||||||
|
|
||||||
const cache = new Cache<ILocalUser>(Infinity);
|
const cache = new Cache<ILocalUser>("instanceActor", 60 * 30);
|
||||||
|
|
||||||
export async function getInstanceActor(): Promise<ILocalUser> {
|
export async function getInstanceActor(): Promise<ILocalUser> {
|
||||||
const cached = cache.get(null);
|
const cached = await cache.get(null, true);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
|
|
||||||
const user = (await Users.findOneBy({
|
const user = (await Users.findOneBy({
|
||||||
|
@ -18,11 +18,11 @@ export async function getInstanceActor(): Promise<ILocalUser> {
|
||||||
})) as ILocalUser | undefined;
|
})) as ILocalUser | undefined;
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
cache.set(null, user);
|
await cache.set(null, user);
|
||||||
return user;
|
return user;
|
||||||
} else {
|
} else {
|
||||||
const created = (await createSystemUser(ACTOR_USERNAME)) as ILocalUser;
|
const created = (await createSystemUser(ACTOR_USERNAME)) as ILocalUser;
|
||||||
cache.set(null, created);
|
await cache.set(null, created);
|
||||||
return created;
|
return created;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -29,17 +29,14 @@ import {
|
||||||
Notes,
|
Notes,
|
||||||
Instances,
|
Instances,
|
||||||
UserProfiles,
|
UserProfiles,
|
||||||
Antennas,
|
|
||||||
Followings,
|
|
||||||
MutedNotes,
|
MutedNotes,
|
||||||
Channels,
|
Channels,
|
||||||
ChannelFollowings,
|
ChannelFollowings,
|
||||||
Blockings,
|
|
||||||
NoteThreadMutings,
|
NoteThreadMutings,
|
||||||
} from "@/models/index.js";
|
} from "@/models/index.js";
|
||||||
import type { DriveFile } from "@/models/entities/drive-file.js";
|
import type { DriveFile } from "@/models/entities/drive-file.js";
|
||||||
import type { App } from "@/models/entities/app.js";
|
import type { App } from "@/models/entities/app.js";
|
||||||
import { Not, In, IsNull } from "typeorm";
|
import { Not, In } from "typeorm";
|
||||||
import type { User, ILocalUser, IRemoteUser } from "@/models/entities/user.js";
|
import type { User, ILocalUser, IRemoteUser } from "@/models/entities/user.js";
|
||||||
import { genId } from "@/misc/gen-id.js";
|
import { genId } from "@/misc/gen-id.js";
|
||||||
import {
|
import {
|
||||||
|
@ -73,7 +70,7 @@ import { Mutex } from "redis-semaphore";
|
||||||
|
|
||||||
const mutedWordsCache = new Cache<
|
const mutedWordsCache = new Cache<
|
||||||
{ userId: UserProfile["userId"]; mutedWords: UserProfile["mutedWords"] }[]
|
{ userId: UserProfile["userId"]; mutedWords: UserProfile["mutedWords"] }[]
|
||||||
>(1000 * 60 * 5);
|
>("mutedWords", 60 * 5);
|
||||||
|
|
||||||
type NotificationType = "reply" | "renote" | "quote" | "mention";
|
type NotificationType = "reply" | "renote" | "quote" | "mention";
|
||||||
|
|
||||||
|
|
|
@ -4,30 +4,30 @@ import { genId } from "@/misc/gen-id.js";
|
||||||
import { toPuny } from "@/misc/convert-host.js";
|
import { toPuny } from "@/misc/convert-host.js";
|
||||||
import { Cache } from "@/misc/cache.js";
|
import { Cache } from "@/misc/cache.js";
|
||||||
|
|
||||||
const cache = new Cache<Instance>(1000 * 60 * 60);
|
const cache = new Cache<Instance>("registerOrFetchInstanceDoc", 60 * 60);
|
||||||
|
|
||||||
export async function registerOrFetchInstanceDoc(
|
export async function registerOrFetchInstanceDoc(
|
||||||
host: string,
|
host: string,
|
||||||
): Promise<Instance> {
|
): Promise<Instance> {
|
||||||
host = toPuny(host);
|
const _host = toPuny(host);
|
||||||
|
|
||||||
const cached = cache.get(host);
|
const cached = await cache.get(_host);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
|
|
||||||
const index = await Instances.findOneBy({ host });
|
const index = await Instances.findOneBy({ host: _host });
|
||||||
|
|
||||||
if (index == null) {
|
if (index == null) {
|
||||||
const i = await Instances.insert({
|
const i = await Instances.insert({
|
||||||
id: genId(),
|
id: genId(),
|
||||||
host,
|
host: _host,
|
||||||
caughtAt: new Date(),
|
caughtAt: new Date(),
|
||||||
lastCommunicatedAt: new Date(),
|
lastCommunicatedAt: new Date(),
|
||||||
}).then((x) => Instances.findOneByOrFail(x.identifiers[0]));
|
}).then((x) => Instances.findOneByOrFail(x.identifiers[0]));
|
||||||
|
|
||||||
cache.set(host, i);
|
await cache.set(_host, i);
|
||||||
return i;
|
return i;
|
||||||
} else {
|
} else {
|
||||||
cache.set(host, index);
|
await cache.set(_host, index);
|
||||||
return index;
|
return index;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,7 +15,7 @@ import { createSystemUser } from "./create-system-user.js";
|
||||||
|
|
||||||
const ACTOR_USERNAME = "relay.actor" as const;
|
const ACTOR_USERNAME = "relay.actor" as const;
|
||||||
|
|
||||||
const relaysCache = new Cache<Relay[]>(1000 * 60 * 10);
|
const relaysCache = new Cache<Relay[]>("relay", 60 * 10);
|
||||||
|
|
||||||
export async function getRelayActor(): Promise<ILocalUser> {
|
export async function getRelayActor(): Promise<ILocalUser> {
|
||||||
const user = await Users.findOneBy({
|
const user = await Users.findOneBy({
|
||||||
|
@ -90,7 +90,7 @@ async function updateRelaysCache() {
|
||||||
const relays = await Relays.findBy({
|
const relays = await Relays.findBy({
|
||||||
status: "accepted",
|
status: "accepted",
|
||||||
});
|
});
|
||||||
relaysCache.set(null, relays);
|
await relaysCache.set(null, relays);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function relayRejected(id: string) {
|
export async function relayRejected(id: string) {
|
||||||
|
|
|
@ -3,17 +3,23 @@ import type {
|
||||||
CacheableUser,
|
CacheableUser,
|
||||||
ILocalUser,
|
ILocalUser,
|
||||||
} from "@/models/entities/user.js";
|
} from "@/models/entities/user.js";
|
||||||
import { User } from "@/models/entities/user.js";
|
|
||||||
import { Users } from "@/models/index.js";
|
import { Users } from "@/models/index.js";
|
||||||
import { Cache } from "@/misc/cache.js";
|
import { Cache } from "@/misc/cache.js";
|
||||||
import { subscriber } from "@/db/redis.js";
|
import { redisClient, subscriber } from "@/db/redis.js";
|
||||||
|
|
||||||
export const userByIdCache = new Cache<CacheableUser>(Infinity);
|
export const userByIdCache = new Cache<CacheableUser>("userById", 60 * 30);
|
||||||
export const localUserByNativeTokenCache = new Cache<CacheableLocalUser | null>(
|
export const localUserByNativeTokenCache = new Cache<CacheableLocalUser | null>(
|
||||||
Infinity,
|
"localUserByNativeToken",
|
||||||
|
60 * 30,
|
||||||
|
);
|
||||||
|
export const localUserByIdCache = new Cache<CacheableLocalUser>(
|
||||||
|
"localUserByIdCache",
|
||||||
|
60 * 30,
|
||||||
|
);
|
||||||
|
export const uriPersonCache = new Cache<CacheableUser | null>(
|
||||||
|
"uriPerson",
|
||||||
|
60 * 30,
|
||||||
);
|
);
|
||||||
export const localUserByIdCache = new Cache<CacheableLocalUser>(Infinity);
|
|
||||||
export const uriPersonCache = new Cache<CacheableUser | null>(Infinity);
|
|
||||||
|
|
||||||
subscriber.on("message", async (_, data) => {
|
subscriber.on("message", async (_, data) => {
|
||||||
const obj = JSON.parse(data);
|
const obj = JSON.parse(data);
|
||||||
|
@ -22,13 +28,12 @@ subscriber.on("message", async (_, data) => {
|
||||||
const { type, body } = obj.message;
|
const { type, body } = obj.message;
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "localUserUpdated": {
|
case "localUserUpdated": {
|
||||||
userByIdCache.delete(body.id);
|
await userByIdCache.delete(body.id);
|
||||||
localUserByIdCache.delete(body.id);
|
await localUserByIdCache.delete(body.id);
|
||||||
localUserByNativeTokenCache.cache.forEach((v, k) => {
|
const toDelete = Array.from(await localUserByNativeTokenCache.getAll())
|
||||||
if (v.value?.id === body.id) {
|
.filter((v) => v[1]?.id === body.id)
|
||||||
localUserByNativeTokenCache.delete(k);
|
.map((v) => v[0]);
|
||||||
}
|
await localUserByNativeTokenCache.delete(...toDelete);
|
||||||
});
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "userChangeSuspendedState":
|
case "userChangeSuspendedState":
|
||||||
|
@ -36,15 +41,17 @@ subscriber.on("message", async (_, data) => {
|
||||||
case "userChangeModeratorState":
|
case "userChangeModeratorState":
|
||||||
case "remoteUserUpdated": {
|
case "remoteUserUpdated": {
|
||||||
const user = await Users.findOneByOrFail({ id: body.id });
|
const user = await Users.findOneByOrFail({ id: body.id });
|
||||||
userByIdCache.set(user.id, user);
|
await userByIdCache.set(user.id, user);
|
||||||
for (const [k, v] of uriPersonCache.cache.entries()) {
|
const trans = redisClient.multi();
|
||||||
if (v.value?.id === user.id) {
|
for (const [k, v] of (await uriPersonCache.getAll()).entries()) {
|
||||||
uriPersonCache.set(k, user);
|
if (v?.id === user.id) {
|
||||||
|
await uriPersonCache.set(k, user, trans);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
await trans.exec();
|
||||||
if (Users.isLocalUser(user)) {
|
if (Users.isLocalUser(user)) {
|
||||||
localUserByNativeTokenCache.set(user.token, user);
|
await localUserByNativeTokenCache.set(user.token, user);
|
||||||
localUserByIdCache.set(user.id, user);
|
await localUserByIdCache.set(user.id, user);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
@ -52,8 +59,8 @@ subscriber.on("message", async (_, data) => {
|
||||||
const user = (await Users.findOneByOrFail({
|
const user = (await Users.findOneByOrFail({
|
||||||
id: body.id,
|
id: body.id,
|
||||||
})) as ILocalUser;
|
})) as ILocalUser;
|
||||||
localUserByNativeTokenCache.delete(body.oldToken);
|
await localUserByNativeTokenCache.delete(body.oldToken);
|
||||||
localUserByNativeTokenCache.set(body.newToken, user);
|
await localUserByNativeTokenCache.set(body.newToken, user);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
|
|
|
@ -1,34 +1,67 @@
|
||||||
<template>
|
<template>
|
||||||
<button v-if="hide" class="qjewsnkg" @click="hide = false">
|
<div class="media" :class="{ mini: plyrMini }">
|
||||||
<ImgWithBlurhash
|
<button v-if="hide" class="hidden" @click="hide = false">
|
||||||
:hash="image.blurhash"
|
|
||||||
:title="image.comment"
|
|
||||||
:alt="image.comment"
|
|
||||||
/>
|
|
||||||
<div class="text">
|
|
||||||
<div class="wrapper">
|
|
||||||
<b style="display: block"
|
|
||||||
><i class="ph-warning ph-bold ph-lg"></i>
|
|
||||||
{{ i18n.ts.sensitive }}</b
|
|
||||||
>
|
|
||||||
<span style="display: block">{{ i18n.ts.clickToShow }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
<div v-else class="gqnyydlz">
|
|
||||||
<a :href="image.url">
|
|
||||||
<ImgWithBlurhash
|
<ImgWithBlurhash
|
||||||
:hash="image.blurhash"
|
:hash="media.blurhash"
|
||||||
:src="url"
|
:title="media.comment"
|
||||||
:alt="image.comment"
|
:alt="media.comment"
|
||||||
:type="image.type"
|
|
||||||
:cover="false"
|
|
||||||
/>
|
/>
|
||||||
<div v-if="image.type === 'image/gif'" class="gif">GIF</div>
|
<div class="text">
|
||||||
</a>
|
<div class="wrapper">
|
||||||
|
<b style="display: block"
|
||||||
|
><i class="ph-warning ph-bold ph-lg"></i>
|
||||||
|
{{ i18n.ts.sensitive }}</b
|
||||||
|
>
|
||||||
|
<span style="display: block">{{ i18n.ts.clickToShow }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<template v-else>
|
||||||
|
<a
|
||||||
|
v-if="media.type.startsWith('image')"
|
||||||
|
:href="media.url"
|
||||||
|
>
|
||||||
|
<ImgWithBlurhash
|
||||||
|
:hash="media.blurhash"
|
||||||
|
:src="url"
|
||||||
|
:alt="media.comment"
|
||||||
|
:type="media.type"
|
||||||
|
:cover="false"
|
||||||
|
/>
|
||||||
|
<div v-if="media.type === 'image/gif'" class="gif">GIF</div>
|
||||||
|
</a>
|
||||||
|
<VuePlyr
|
||||||
|
v-if="media.type.startsWith('video')"
|
||||||
|
ref="plyr"
|
||||||
|
:options="{
|
||||||
|
controls: [
|
||||||
|
'play-large',
|
||||||
|
'play',
|
||||||
|
'progress',
|
||||||
|
'current-time',
|
||||||
|
'mute',
|
||||||
|
'volume',
|
||||||
|
'pip',
|
||||||
|
'download',
|
||||||
|
'fullscreen',
|
||||||
|
],
|
||||||
|
disableContextMenu: false,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<video
|
||||||
|
:poster="media.thumbnailUrl"
|
||||||
|
:aria-label="media.comment"
|
||||||
|
preload="none"
|
||||||
|
controls
|
||||||
|
@contextmenu.stop
|
||||||
|
>
|
||||||
|
<source :src="media.url" :type="media.type" />
|
||||||
|
</video>
|
||||||
|
</VuePlyr>
|
||||||
|
</template>
|
||||||
<div class="buttons">
|
<div class="buttons">
|
||||||
<button
|
<button
|
||||||
v-if="image.comment"
|
v-if="media.comment"
|
||||||
v-tooltip="i18n.ts.alt"
|
v-tooltip="i18n.ts.alt"
|
||||||
class="_button"
|
class="_button"
|
||||||
@click.stop="captionPopup"
|
@click.stop="captionPopup"
|
||||||
|
@ -36,9 +69,10 @@
|
||||||
<i class="ph-subtitles ph-bold ph-lg"></i>
|
<i class="ph-subtitles ph-bold ph-lg"></i>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
v-if="!hide"
|
||||||
v-tooltip="i18n.ts.hide"
|
v-tooltip="i18n.ts.hide"
|
||||||
class="_button"
|
class="_button"
|
||||||
@click="hide = true"
|
@click.stop="hide = true"
|
||||||
>
|
>
|
||||||
<i class="ph-eye-slash ph-bold ph-lg"></i>
|
<i class="ph-eye-slash ph-bold ph-lg"></i>
|
||||||
</button>
|
</button>
|
||||||
|
@ -47,7 +81,9 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { watch } from "vue";
|
import { watch, ref, onMounted } from "vue";
|
||||||
|
import VuePlyr from "vue-plyr";
|
||||||
|
import "vue-plyr/dist/vue-plyr.css";
|
||||||
import type * as misskey from "calckey-js";
|
import type * as misskey from "calckey-js";
|
||||||
import { getStaticImageUrl } from "@/scripts/get-static-image-url";
|
import { getStaticImageUrl } from "@/scripts/get-static-image-url";
|
||||||
import ImgWithBlurhash from "@/components/MkImgWithBlurhash.vue";
|
import ImgWithBlurhash from "@/components/MkImgWithBlurhash.vue";
|
||||||
|
@ -56,34 +92,37 @@ import { i18n } from "@/i18n";
|
||||||
import * as os from "@/os";
|
import * as os from "@/os";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
image: misskey.entities.DriveFile;
|
media: misskey.entities.DriveFile;
|
||||||
raw?: boolean;
|
raw?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
let hide = $ref(true);
|
let hide = $ref(true);
|
||||||
|
|
||||||
|
const plyr = ref();
|
||||||
|
const plyrMini = ref(false);
|
||||||
|
|
||||||
const url =
|
const url =
|
||||||
props.raw || defaultStore.state.loadRawImages
|
props.raw || defaultStore.state.loadRawImages
|
||||||
? props.image.url
|
? props.media.url
|
||||||
: defaultStore.state.disableShowingAnimatedImages
|
: defaultStore.state.disableShowingAnimatedImages
|
||||||
? getStaticImageUrl(props.image.thumbnailUrl)
|
? getStaticImageUrl(props.media.thumbnailUrl)
|
||||||
: props.image.thumbnailUrl;
|
: props.media.thumbnailUrl;
|
||||||
|
|
||||||
function captionPopup() {
|
function captionPopup() {
|
||||||
os.alert({
|
os.alert({
|
||||||
type: "info",
|
type: "info",
|
||||||
text: props.image.comment,
|
text: props.media.comment,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Plugin:register_note_view_interruptor を使って書き換えられる可能性があるためwatchする
|
// Plugin:register_note_view_interruptor を使って書き換えられる可能性があるためwatchする
|
||||||
watch(
|
watch(
|
||||||
() => props.image,
|
() => props.media,
|
||||||
() => {
|
() => {
|
||||||
hide =
|
hide =
|
||||||
defaultStore.state.nsfw === "force"
|
defaultStore.state.nsfw === "force"
|
||||||
? true
|
? true
|
||||||
: props.image.isSensitive &&
|
: props.media.isSensitive &&
|
||||||
defaultStore.state.nsfw !== "ignore";
|
defaultStore.state.nsfw !== "ignore";
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -91,12 +130,25 @@ watch(
|
||||||
immediate: true,
|
immediate: true,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (props.media.type.startsWith('video')) {
|
||||||
|
plyrMini.value = plyr.value.player.media.scrollWidth < 300;
|
||||||
|
if (plyrMini.value) {
|
||||||
|
plyr.value.player.on("play", () => {
|
||||||
|
plyr.value.player.fullscreen.enter();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.qjewsnkg {
|
.hidden {
|
||||||
all: unset;
|
all: unset;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
> .text {
|
> .text {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
@ -123,7 +175,7 @@ watch(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.gqnyydlz {
|
.media {
|
||||||
position: relative;
|
position: relative;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
|
|
||||||
|
@ -175,5 +227,16 @@ watch(
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
&.mini {
|
||||||
|
:deep(.plyr:not(:fullscreen)) {
|
||||||
|
min-width: unset !important;
|
||||||
|
.plyr__control--overlaid,
|
||||||
|
.plyr__progress__container,
|
||||||
|
.plyr__volume,
|
||||||
|
[data-plyr="fullscreen"] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
|
@ -12,25 +12,16 @@
|
||||||
:class="{ dmWidth: inDm }"
|
:class="{ dmWidth: inDm }"
|
||||||
>
|
>
|
||||||
<div ref="gallery" @click.stop>
|
<div ref="gallery" @click.stop>
|
||||||
<template
|
<XMedia
|
||||||
v-for="media in mediaList.filter((media) =>
|
v-for="media in mediaList.filter((media) =>
|
||||||
previewable(media)
|
previewable(media)
|
||||||
)"
|
)"
|
||||||
>
|
:key="media.id"
|
||||||
<XVideo
|
:class="{ image: media.type.startsWith('image') }"
|
||||||
v-if="media.type.startsWith('video')"
|
:data-id="media.id"
|
||||||
:key="media.id"
|
:media="media"
|
||||||
:video="media"
|
:raw="raw"
|
||||||
/>
|
/>
|
||||||
<XImage
|
|
||||||
v-else-if="media.type.startsWith('image')"
|
|
||||||
:key="media.id"
|
|
||||||
class="image"
|
|
||||||
:data-id="media.id"
|
|
||||||
:image="media"
|
|
||||||
:raw="raw"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -43,8 +34,7 @@ import PhotoSwipeLightbox from "photoswipe/lightbox";
|
||||||
import PhotoSwipe from "photoswipe";
|
import PhotoSwipe from "photoswipe";
|
||||||
import "photoswipe/style.css";
|
import "photoswipe/style.css";
|
||||||
import XBanner from "@/components/MkMediaBanner.vue";
|
import XBanner from "@/components/MkMediaBanner.vue";
|
||||||
import XImage from "@/components/MkMediaImage.vue";
|
import XMedia from "@/components/MkMedia.vue";
|
||||||
import XVideo from "@/components/MkMediaVideo.vue";
|
|
||||||
import * as os from "@/os";
|
import * as os from "@/os";
|
||||||
import { FILE_TYPE_BROWSERSAFE } from "@/const";
|
import { FILE_TYPE_BROWSERSAFE } from "@/const";
|
||||||
import { defaultStore } from "@/store";
|
import { defaultStore } from "@/store";
|
||||||
|
|
|
@ -1,169 +0,0 @@
|
||||||
<template>
|
|
||||||
<div
|
|
||||||
v-if="hide"
|
|
||||||
class="icozogqfvdetwohsdglrbswgrejoxbdj"
|
|
||||||
@click="hide = false"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<b
|
|
||||||
><i class="ph-warning ph-bold ph-lg"></i>
|
|
||||||
{{ i18n.ts.sensitive }}</b
|
|
||||||
>
|
|
||||||
<span>{{ i18n.ts.clickToShow }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-else class="video" :class="{ mini }">
|
|
||||||
<VuePlyr
|
|
||||||
ref="plyr"
|
|
||||||
:options="{
|
|
||||||
controls: [
|
|
||||||
'play-large',
|
|
||||||
'play',
|
|
||||||
'progress',
|
|
||||||
'current-time',
|
|
||||||
'mute',
|
|
||||||
'volume',
|
|
||||||
'pip',
|
|
||||||
'download',
|
|
||||||
'fullscreen',
|
|
||||||
],
|
|
||||||
disableContextMenu: false,
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<video
|
|
||||||
:poster="video.thumbnailUrl"
|
|
||||||
:aria-label="video.comment"
|
|
||||||
preload="none"
|
|
||||||
controls
|
|
||||||
@contextmenu.stop
|
|
||||||
>
|
|
||||||
<source :src="video.url" :type="video.type" />
|
|
||||||
</video>
|
|
||||||
</VuePlyr>
|
|
||||||
<div class="buttons">
|
|
||||||
<button
|
|
||||||
v-if="video.comment"
|
|
||||||
v-tooltip="i18n.ts.alt"
|
|
||||||
class="_button"
|
|
||||||
@click.stop="captionPopup"
|
|
||||||
>
|
|
||||||
<i class="ph-subtitles ph-bold ph-lg"></i>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-tooltip="i18n.ts.hide"
|
|
||||||
class="_button"
|
|
||||||
@click="hide = true"
|
|
||||||
>
|
|
||||||
<i class="ph-eye-slash ph-bold ph-lg"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts" setup>
|
|
||||||
import { onMounted, ref } from "vue";
|
|
||||||
import VuePlyr from "vue-plyr";
|
|
||||||
import type * as misskey from "calckey-js";
|
|
||||||
import { defaultStore } from "@/store";
|
|
||||||
import "vue-plyr/dist/vue-plyr.css";
|
|
||||||
import { i18n } from "@/i18n";
|
|
||||||
import * as os from "@/os";
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
video: misskey.entities.DriveFile;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const plyr = ref();
|
|
||||||
const mini = ref(false);
|
|
||||||
|
|
||||||
const hide = ref(
|
|
||||||
defaultStore.state.nsfw === "force"
|
|
||||||
? true
|
|
||||||
: props.video.isSensitive && defaultStore.state.nsfw !== "ignore"
|
|
||||||
);
|
|
||||||
|
|
||||||
function captionPopup() {
|
|
||||||
os.alert({
|
|
||||||
type: "info",
|
|
||||||
text: props.video.comment,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
mini.value = plyr.value.player.media.scrollWidth < 300;
|
|
||||||
if (mini.value) {
|
|
||||||
plyr.value.player.on("play", () => {
|
|
||||||
plyr.value.player.fullscreen.enter();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.video {
|
|
||||||
position: relative;
|
|
||||||
--plyr-color-main: var(--accent);
|
|
||||||
|
|
||||||
> .buttons {
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
position: absolute;
|
|
||||||
border-radius: 6px;
|
|
||||||
overflow: hidden;
|
|
||||||
top: 12px;
|
|
||||||
right: 12px;
|
|
||||||
> * {
|
|
||||||
background-color: var(--accentedBg);
|
|
||||||
-webkit-backdrop-filter: var(--blur, blur(15px));
|
|
||||||
backdrop-filter: var(--blur, blur(15px));
|
|
||||||
color: var(--accent);
|
|
||||||
font-size: 0.8em;
|
|
||||||
padding: 6px 8px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
> video {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
font-size: 3.5em;
|
|
||||||
overflow: hidden;
|
|
||||||
background-position: center;
|
|
||||||
background-size: cover;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.mini {
|
|
||||||
:deep(.plyr:not(:fullscreen)) {
|
|
||||||
min-width: unset !important;
|
|
||||||
.plyr__control--overlaid,
|
|
||||||
.plyr__progress__container,
|
|
||||||
.plyr__volume,
|
|
||||||
[data-plyr="fullscreen"] {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.icozogqfvdetwohsdglrbswgrejoxbdj {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
background: #111;
|
|
||||||
color: #fff;
|
|
||||||
|
|
||||||
> div {
|
|
||||||
display: table-cell;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 12px;
|
|
||||||
|
|
||||||
> b {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -52,9 +52,9 @@
|
||||||
|
|
||||||
<MkKeyValue class="_formBlock">
|
<MkKeyValue class="_formBlock">
|
||||||
<template #key>{{ i18n.ts.description }}</template>
|
<template #key>{{ i18n.ts.description }}</template>
|
||||||
<template #value>{{
|
<template #value
|
||||||
$instance.description
|
><div v-html="$instance.description"></div
|
||||||
}}</template>
|
></template>
|
||||||
</MkKeyValue>
|
</MkKeyValue>
|
||||||
|
|
||||||
<FormSection>
|
<FormSection>
|
||||||
|
|
|
@ -105,6 +105,9 @@ importers:
|
||||||
'@koa/router':
|
'@koa/router':
|
||||||
specifier: 9.0.1
|
specifier: 9.0.1
|
||||||
version: 9.0.1
|
version: 9.0.1
|
||||||
|
'@msgpack/msgpack':
|
||||||
|
specifier: 3.0.0-beta2
|
||||||
|
version: 3.0.0-beta2
|
||||||
'@peertube/http-signature':
|
'@peertube/http-signature':
|
||||||
specifier: 1.7.0
|
specifier: 1.7.0
|
||||||
version: 1.7.0
|
version: 1.7.0
|
||||||
|
@ -132,9 +135,6 @@ importers:
|
||||||
argon2:
|
argon2:
|
||||||
specifier: ^0.30.3
|
specifier: ^0.30.3
|
||||||
version: 0.30.3
|
version: 0.30.3
|
||||||
async-mutex:
|
|
||||||
specifier: ^0.4.0
|
|
||||||
version: 0.4.0
|
|
||||||
autobind-decorator:
|
autobind-decorator:
|
||||||
specifier: 2.4.0
|
specifier: 2.4.0
|
||||||
version: 2.4.0
|
version: 2.4.0
|
||||||
|
@ -786,7 +786,7 @@ importers:
|
||||||
version: 2.30.0
|
version: 2.30.0
|
||||||
emojilib:
|
emojilib:
|
||||||
specifier: github:thatonecalculator/emojilib
|
specifier: github:thatonecalculator/emojilib
|
||||||
version: github.com/thatonecalculator/emojilib/542fcc1a25003afad78f3248ceee8ac6980ddeb8
|
version: github.com/thatonecalculator/emojilib/06944984a61ee799b7083894258f5fa318d932d1
|
||||||
escape-regexp:
|
escape-regexp:
|
||||||
specifier: 0.0.1
|
specifier: 0.0.1
|
||||||
version: 0.0.1
|
version: 0.0.1
|
||||||
|
@ -2277,6 +2277,11 @@ packages:
|
||||||
os-filter-obj: 2.0.0
|
os-filter-obj: 2.0.0
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/@msgpack/msgpack@3.0.0-beta2:
|
||||||
|
resolution: {integrity: sha512-y+l1PNV0XDyY8sM3YtuMLK5vE3/hkfId+Do8pLo/OPxfxuFAUwcGz3oiiUuV46/aBpwTzZ+mRWVMtlSKbradhw==}
|
||||||
|
engines: {node: '>= 14'}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.2:
|
/@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.2:
|
||||||
resolution: {integrity: sha512-9bfjwDxIDWmmOKusUcqdS4Rw+SETlp9Dy39Xui9BEGEk19dDwH0jhipwFzEff/pFg95NKymc6TOTbRKcWeRqyQ==}
|
resolution: {integrity: sha512-9bfjwDxIDWmmOKusUcqdS4Rw+SETlp9Dy39Xui9BEGEk19dDwH0jhipwFzEff/pFg95NKymc6TOTbRKcWeRqyQ==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
|
@ -4496,12 +4501,6 @@ packages:
|
||||||
stream-exhaust: 1.0.2
|
stream-exhaust: 1.0.2
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/async-mutex@0.4.0:
|
|
||||||
resolution: {integrity: sha512-eJFZ1YhRR8UN8eBLoNzcDPcy/jqjsg6I1AP+KvWQX80BqOSW1oJPJXDylPUEeMr2ZQvHgnQ//Lp6f3RQ1zI7HA==}
|
|
||||||
dependencies:
|
|
||||||
tslib: 2.6.0
|
|
||||||
dev: false
|
|
||||||
|
|
||||||
/async-settle@1.0.0:
|
/async-settle@1.0.0:
|
||||||
resolution: {integrity: sha512-VPXfB4Vk49z1LHHodrEQ6Xf7W4gg1w0dAPROHngx7qgDjqmIQ+fXmwgGXTW/ITLai0YLSvWepJOP9EVpMnEAcw==}
|
resolution: {integrity: sha512-VPXfB4Vk49z1LHHodrEQ6Xf7W4gg1w0dAPROHngx7qgDjqmIQ+fXmwgGXTW/ITLai0YLSvWepJOP9EVpMnEAcw==}
|
||||||
engines: {node: '>= 0.10'}
|
engines: {node: '>= 0.10'}
|
||||||
|
@ -15772,8 +15771,8 @@ packages:
|
||||||
url-polyfill: 1.1.12
|
url-polyfill: 1.1.12
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
github.com/thatonecalculator/emojilib/542fcc1a25003afad78f3248ceee8ac6980ddeb8:
|
github.com/thatonecalculator/emojilib/06944984a61ee799b7083894258f5fa318d932d1:
|
||||||
resolution: {tarball: https://codeload.github.com/thatonecalculator/emojilib/tar.gz/542fcc1a25003afad78f3248ceee8ac6980ddeb8}
|
resolution: {tarball: https://codeload.github.com/thatonecalculator/emojilib/tar.gz/06944984a61ee799b7083894258f5fa318d932d1}
|
||||||
name: emojilib
|
name: emojilib
|
||||||
version: 3.0.10
|
version: 3.0.10
|
||||||
dev: true
|
dev: true
|
||||||
|
|
Loading…
Reference in a new issue