Merge tag '13.6.1' into io

This commit is contained in:
Cookie Ramen 2023-02-13 21:57:39 +09:00
commit 74a2b92154
No known key found for this signature in database
GPG key ID: 82A8B5D42246F02D
41 changed files with 816 additions and 265 deletions

View file

@ -131,11 +131,20 @@ proxyBypassHosts:
# Media Proxy # Media Proxy
# Reference Implementation: https://github.com/misskey-dev/media-proxy # Reference Implementation: https://github.com/misskey-dev/media-proxy
# * Deliver a common cache between instances
# * Perform image compression (on a different server resource than the main process)
#mediaProxy: https://example.com/proxy #mediaProxy: https://example.com/proxy
# Proxy remote files (default: false) # Proxy remote files (default: false)
# Proxy remote files by this instance or mediaProxy to prevent remote files from running in remote domains.
#proxyRemoteFiles: true #proxyRemoteFiles: true
# Movie Thumbnail Generation URL
# There is no reference implementation.
# For example, Misskey will point to the following URL:
# https://example.com/thumbnail.webp?thumbnail=1&url=https%3A%2F%2Fstorage.example.com%2Fpath%2Fto%2Fvideo.mp4
#videoThumbnailGenerator: https://example.com
# Sign to ActivityPub GET request (default: true) # Sign to ActivityPub GET request (default: true)
signToActivityPubGet: true signToActivityPubGet: true

1
.devcontainer/Dockerfile Normal file
View file

@ -0,0 +1 @@
FROM mcr.microsoft.com/devcontainers/javascript-node:0-18

View file

@ -0,0 +1,11 @@
{
"name": "Misskey",
"dockerComposeFile": "docker-compose.yml",
"service": "app",
"workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",
"features": {
"ghcr.io/devcontainers-contrib/features/pnpm:2": {}
},
"forwardPorts": [3000],
"postCreateCommand": ".devcontainer/init.sh"
}

View file

@ -0,0 +1,146 @@
#━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Misskey configuration
#━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# ┌─────┐
#───┘ URL └─────────────────────────────────────────────────────
# Final accessible URL seen by a user.
url: http://127.0.0.1:3000/
# ONCE YOU HAVE STARTED THE INSTANCE, DO NOT CHANGE THE
# URL SETTINGS AFTER THAT!
# ┌───────────────────────┐
#───┘ Port and TLS settings └───────────────────────────────────
#
# Misskey requires a reverse proxy to support HTTPS connections.
#
# +----- https://example.tld/ ------------+
# +------+ |+-------------+ +----------------+|
# | User | ---> || Proxy (443) | ---> | Misskey (3000) ||
# +------+ |+-------------+ +----------------+|
# +---------------------------------------+
#
# You need to set up a reverse proxy. (e.g. nginx)
# An encrypted connection with HTTPS is highly recommended
# because tokens may be transferred in GET requests.
# The port that your Misskey server should listen on.
port: 3000
# ┌──────────────────────────┐
#───┘ PostgreSQL configuration └────────────────────────────────
db:
host: db
port: 5432
# Database name
db: misskey
# Auth
user: postgres
pass: postgres
# Whether disable Caching queries
#disableCache: true
# Extra Connection options
#extra:
# ssl: true
# ┌─────────────────────┐
#───┘ Redis configuration └─────────────────────────────────────
redis:
host: redis
port: 6379
#family: 0 # 0=Both, 4=IPv4, 6=IPv6
#pass: example-pass
#prefix: example-prefix
#db: 1
# ┌─────────────────────────────┐
#───┘ Elasticsearch configuration └─────────────────────────────
#elasticsearch:
# host: localhost
# port: 9200
# ssl: false
# user:
# pass:
# ┌───────────────┐
#───┘ ID generation └───────────────────────────────────────────
# You can select the ID generation method.
# You don't usually need to change this setting, but you can
# change it according to your preferences.
# Available methods:
# aid ... Short, Millisecond accuracy
# meid ... Similar to ObjectID, Millisecond accuracy
# ulid ... Millisecond accuracy
# objectid ... This is left for backward compatibility
# ONCE YOU HAVE STARTED THE INSTANCE, DO NOT CHANGE THE
# ID SETTINGS AFTER THAT!
id: 'aid'
# ┌─────────────────────┐
#───┘ Other configuration └─────────────────────────────────────
# Whether disable HSTS
#disableHsts: true
# Number of worker processes
#clusterLimit: 1
# Job concurrency per worker
# deliverJobConcurrency: 128
# inboxJobConcurrency: 16
# Job rate limiter
# deliverJobPerSec: 128
# inboxJobPerSec: 16
# Job attempts
# deliverJobMaxAttempts: 12
# inboxJobMaxAttempts: 8
# IP address family used for outgoing request (ipv4, ipv6 or dual)
#outgoingAddressFamily: ipv4
# Proxy for HTTP/HTTPS
#proxy: http://127.0.0.1:3128
proxyBypassHosts:
- api.deepl.com
- api-free.deepl.com
- www.recaptcha.net
- hcaptcha.com
- challenges.cloudflare.com
# Proxy for SMTP/SMTPS
#proxySmtp: http://127.0.0.1:3128 # use HTTP/1.1 CONNECT
#proxySmtp: socks4://127.0.0.1:1080 # use SOCKS4
#proxySmtp: socks5://127.0.0.1:1080 # use SOCKS5
# Media Proxy
#mediaProxy: https://example.com/proxy
# Proxy remote files (default: false)
#proxyRemoteFiles: true
# Sign to ActivityPub GET request (default: true)
signToActivityPubGet: true
allowedPrivateNetworks: [
'127.0.0.1/32'
]
# Upload or download file size limits (bytes)
#maxFileSize: 262144000

View file

@ -0,0 +1,52 @@
version: '3.8'
services:
app:
build:
context: .
dockerfile: Dockerfile
volumes:
- ../..:/workspaces:cached
command: sleep infinity
networks:
- internal_network
- external_network
redis:
restart: always
image: redis:7-alpine
networks:
- internal_network
volumes:
- ../redis:/data
healthcheck:
test: "redis-cli ping"
interval: 5s
retries: 20
db:
restart: unless-stopped
image: postgres:15-alpine
networks:
- internal_network
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: misskey
volumes:
- ../db:/var/lib/postgresql/data
healthcheck:
test: "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"
interval: 5s
retries: 20
volumes:
postgres-data:
networks:
internal_network:
internal: true
external_network:

9
.devcontainer/init.sh Executable file
View file

@ -0,0 +1,9 @@
#!/bin/bash
set -xe
git submodule update --init
pnpm install --frozen-lockfile
cp .devcontainer/devcontainer.yml .config/default.yml
pnpm build
pnpm migrate

1
.gitignore vendored
View file

@ -33,6 +33,7 @@ coverage
!/.config/docker_example.yml !/.config/docker_example.yml
!/.config/docker_example.env !/.config/docker_example.env
docker-compose.yml docker-compose.yml
!/.devcontainer/docker-compose.yml
# misskey # misskey
/build /build

View file

@ -8,6 +8,16 @@
You should also include the user name that made the change. You should also include the user name that made the change.
--> -->
## 13.6.1 (2023/02/12)
### Improvements
- アニメーションを少なくする設定の時、MkPageHeaderのタブアニメーションを無効化
- Backend: activitypub情報がcorsでブロックされないようヘッダーを追加
- enhance: レートリミットを0%にできるように
- チャンネル内Renoteを行えるように
### Bugfixes
- Client: ユーザーページでアクティビティを見ることができない問題を修正
## 13.6.0 (2023/02/11) ## 13.6.0 (2023/02/11)

View file

@ -111,6 +111,25 @@ command.
- Vite HMR (just the `vite` command) is available. The behavior may be different from production. - Vite HMR (just the `vite` command) is available. The behavior may be different from production.
- Service Worker is watched by esbuild. - Service Worker is watched by esbuild.
### Dev Container
Instead of running `pnpm` locally, you can use Dev Container to set up your development environment.
To use Dev Container, open the project directory on VSCode with Dev Containers installed.
It will run the following command automatically inside the container.
``` bash
git submodule update --init
pnpm install --frozen-lockfile
cp .devcontainer/devcontainer.yml .config/default.yml
pnpm build
pnpm migrate
```
After finishing the migration, run the `pnpm dev` command to start the development server.
``` bash
pnpm dev
```
## Testing ## Testing
- Test codes are located in [`/packages/backend/test`](/packages/backend/test). - Test codes are located in [`/packages/backend/test`](/packages/backend/test).

View file

@ -467,6 +467,8 @@ youHaveNoGroups: "You have no groups"
joinOrCreateGroup: "Get invited to a group or create your own." joinOrCreateGroup: "Get invited to a group or create your own."
noHistory: "No history available" noHistory: "No history available"
signinHistory: "Login history" signinHistory: "Login history"
enableAdvancedMfm: "Enable advanced MFM"
enableAnimatedMfm: "Enable MFM with animation"
doing: "Processing..." doing: "Processing..."
category: "Category" category: "Category"
tags: "Tags" tags: "Tags"
@ -945,6 +947,10 @@ selectFromPresets: "Choose from presets"
achievements: "Achievements" achievements: "Achievements"
gotInvalidResponseError: "Invalid server response" gotInvalidResponseError: "Invalid server response"
gotInvalidResponseErrorDescription: "The server may be unreachable or undergoing maintenance. Please try again later." gotInvalidResponseErrorDescription: "The server may be unreachable or undergoing maintenance. Please try again later."
thisPostMayBeAnnoying: "This note may annoy others."
thisPostMayBeAnnoyingHome: "Post to home timeline"
thisPostMayBeAnnoyingCancel: "Cancel"
thisPostMayBeAnnoyingIgnore: "Post anyway"
_achievements: _achievements:
earnedAt: "Unlocked at" earnedAt: "Unlocked at"
_types: _types:

View file

@ -103,6 +103,8 @@ renoted: "Renoteしました。"
cantRenote: "この投稿はRenoteできません。" cantRenote: "この投稿はRenoteできません。"
cantReRenote: "RenoteをRenoteすることはできません。" cantReRenote: "RenoteをRenoteすることはできません。"
quote: "引用" quote: "引用"
inChannelRenote: "チャンネル内Renote"
inChannelQuote: "チャンネル内引用"
pinnedNote: "ピン留めされたノート" pinnedNote: "ピン留めされたノート"
pinned: "ピン留め" pinned: "ピン留め"
you: "あなた" you: "あなた"
@ -951,6 +953,10 @@ thisPostMayBeAnnoying: "この投稿は迷惑になる可能性があります
thisPostMayBeAnnoyingHome: "ホームに投稿" thisPostMayBeAnnoyingHome: "ホームに投稿"
thisPostMayBeAnnoyingCancel: "やめる" thisPostMayBeAnnoyingCancel: "やめる"
thisPostMayBeAnnoyingIgnore: "このまま投稿" thisPostMayBeAnnoyingIgnore: "このまま投稿"
collapseRenotes: "見たことのあるRenoteを省略して表示"
internalServerError: "サーバー内部エラー"
internalServerErrorDescription: "サーバー内部で予期しないエラーが発生しました。"
copyErrorInfo: "エラー情報をコピー"
_achievements: _achievements:
earnedAt: "獲得日時" earnedAt: "獲得日時"

View file

@ -947,6 +947,8 @@ selectFromPresets: "プリセットから選ぶ"
achievements: "実績" achievements: "実績"
gotInvalidResponseError: "サーバー黙っとるわ、知らんけど" gotInvalidResponseError: "サーバー黙っとるわ、知らんけど"
gotInvalidResponseErrorDescription: "サーバーいま日曜日。またきて月曜日。" gotInvalidResponseErrorDescription: "サーバーいま日曜日。またきて月曜日。"
thisPostMayBeAnnoying: "この投稿は迷惑かもしらんで。"
collapseRenotes: "見たことあるRenoteは省略やで"
_achievements: _achievements:
earnedAt: "貰った日ぃ" earnedAt: "貰った日ぃ"
_types: _types:

View file

@ -129,6 +129,7 @@ unblockConfirm: "이 계정의 차단을 해제하시겠습니까?"
suspendConfirm: "이 계정을 정지하시겠습니까?" suspendConfirm: "이 계정을 정지하시겠습니까?"
unsuspendConfirm: "이 계정의 정지를 해제하시겠습니까?" unsuspendConfirm: "이 계정의 정지를 해제하시겠습니까?"
selectList: "리스트 선택" selectList: "리스트 선택"
selectChannel: "채널 선택"
selectAntenna: "안테나 선택" selectAntenna: "안테나 선택"
selectWidget: "위젯 선택" selectWidget: "위젯 선택"
editWidgets: "위젯 편집" editWidgets: "위젯 편집"
@ -256,6 +257,8 @@ noMoreHistory: "이것보다 과거의 기록이 없습니다"
startMessaging: "대화 시작하기" startMessaging: "대화 시작하기"
nUsersRead: "{n}명이 읽음" nUsersRead: "{n}명이 읽음"
agreeTo: "{0}에 동의" agreeTo: "{0}에 동의"
agreeBelow: "아래 내용에 동의합니다"
basicNotesBeforeCreateAccount: "기본적인 주의사항"
tos: "이용 약관" tos: "이용 약관"
start: "시작하기" start: "시작하기"
home: "홈" home: "홈"
@ -464,6 +467,8 @@ youHaveNoGroups: "그룹이 없습니다"
joinOrCreateGroup: "다른 그룹의 초대를 받거나, 직접 새 그룹을 만들어 보세요." joinOrCreateGroup: "다른 그룹의 초대를 받거나, 직접 새 그룹을 만들어 보세요."
noHistory: "기록이 없습니다" noHistory: "기록이 없습니다"
signinHistory: "로그인 기록" signinHistory: "로그인 기록"
enableAdvancedMfm: "고급 MFM을 활성화"
enableAnimatedMfm: "움직임이 있는 MFM을 활성화"
doing: "잠시만요" doing: "잠시만요"
category: "카테고리" category: "카테고리"
tags: "태그" tags: "태그"
@ -860,6 +865,8 @@ failedToFetchAccountInformation: "계정 정보를 가져오지 못했습니다"
rateLimitExceeded: "요청 제한 횟수를 초과하였습니다" rateLimitExceeded: "요청 제한 횟수를 초과하였습니다"
cropImage: "이미지 자르기" cropImage: "이미지 자르기"
cropImageAsk: "이미지를 자르시겠습니까?" cropImageAsk: "이미지를 자르시겠습니까?"
cropYes: "잘라내기"
cropNo: "그대로 사용"
file: "파일" file: "파일"
recentNHours: "최근 {n}시간" recentNHours: "최근 {n}시간"
recentNDays: "최근 {n}일" recentNDays: "최근 {n}일"
@ -938,6 +945,12 @@ cannotPerformTemporaryDescription: "조작 횟수 제한을 초과하여 일시
preset: "프리셋" preset: "프리셋"
selectFromPresets: "프리셋에서 선택" selectFromPresets: "프리셋에서 선택"
achievements: "도전 과제" achievements: "도전 과제"
gotInvalidResponseError: "서버의 응답이 올바르지 않습니다"
gotInvalidResponseErrorDescription: " 서버가 다운되었거나 점검중일 가능성이 있습니다. 잠시후에 다시 시도해 주십시오."
thisPostMayBeAnnoying: "이 게시물은 다른 유저에게 피해를 줄 가능성이 있습니다."
thisPostMayBeAnnoyingHome: "홈에 게시"
thisPostMayBeAnnoyingCancel: "그만두기"
thisPostMayBeAnnoyingIgnore: "이대로 게시"
_achievements: _achievements:
earnedAt: "달성 일시" earnedAt: "달성 일시"
_types: _types:
@ -1194,6 +1207,9 @@ _role:
baseRole: "기본 역할" baseRole: "기본 역할"
useBaseValue: "기본값 사용" useBaseValue: "기본값 사용"
chooseRoleToAssign: "할당할 역할 선택" chooseRoleToAssign: "할당할 역할 선택"
iconUrl: "아이콘 URL"
asBadge: "뱃지로 표시"
descriptionOfAsBadge: "활성화하면 유저명 옆에 역할의 아이콘이 표시됩니다."
canEditMembersByModerator: "모더레이터의 역할 수정 허용" canEditMembersByModerator: "모더레이터의 역할 수정 허용"
descriptionOfCanEditMembersByModerator: "이 옵션을 켜면 모더레이터도 이 역할에 사용자를 할당하거나 삭제할 수 있습니다. 꺼져 있으면 관리자만 할당이 가능합니다." descriptionOfCanEditMembersByModerator: "이 옵션을 켜면 모더레이터도 이 역할에 사용자를 할당하거나 삭제할 수 있습니다. 꺼져 있으면 관리자만 할당이 가능합니다."
priority: "우선순위" priority: "우선순위"
@ -1523,12 +1539,15 @@ _permissions:
"read:gallery-likes": "갤러리의 좋아요를 확인합니다" "read:gallery-likes": "갤러리의 좋아요를 확인합니다"
"write:gallery-likes": "갤러리에 좋아요를 추가하거나 취소합니다" "write:gallery-likes": "갤러리에 좋아요를 추가하거나 취소합니다"
_auth: _auth:
shareAccessTitle: "어플리케이션의 접근 허가"
shareAccess: "\"{name}\" 이 계정에 접근하는 것을 허용하시겠습니까?" shareAccess: "\"{name}\" 이 계정에 접근하는 것을 허용하시겠습니까?"
shareAccessAsk: "이 애플리케이션이 계정에 접근하는 것을 허용하시겠습니까?" shareAccessAsk: "이 애플리케이션이 계정에 접근하는 것을 허용하시겠습니까?"
permission: "{name}에서 다음 권한을 요청하였습니다"
permissionAsk: "이 앱은 다음의 권한을 요청합니다" permissionAsk: "이 앱은 다음의 권한을 요청합니다"
pleaseGoBack: "앱으로 돌아가서 시도해 주세요" pleaseGoBack: "앱으로 돌아가서 시도해 주세요"
callback: "앱으로 돌아갑니다" callback: "앱으로 돌아갑니다"
denied: "접근이 거부되었습니다" denied: "접근이 거부되었습니다"
pleaseLogin: "어플리케이션의 접근을 허가하려면 로그인하십시오."
_antennaSources: _antennaSources:
all: "모든 노트" all: "모든 노트"
homeTimeline: "팔로우중인 유저의 노트" homeTimeline: "팔로우중인 유저의 노트"

View file

@ -166,7 +166,7 @@ recipient: "Отримувач"
annotation: "Коментарі" annotation: "Коментарі"
federation: "Федіверс" federation: "Федіверс"
instances: "Інстанс" instances: "Інстанс"
registeredAt: "Приєднався(лась)" registeredAt: "Реєстрація"
latestRequestReceivedAt: "Останній запит прийнято" latestRequestReceivedAt: "Останній запит прийнято"
latestStatus: "Останній статус" latestStatus: "Останній статус"
storageUsage: "Використання простору" storageUsage: "Використання простору"
@ -263,7 +263,7 @@ activity: "Активність"
images: "Зображення" images: "Зображення"
birthday: "День народження" birthday: "День народження"
yearsOld: "{age} років" yearsOld: "{age} років"
registeredDate: "Приєднався(лась)" registeredDate: "Приєднання"
location: "Локація" location: "Локація"
theme: "Тема" theme: "Тема"
themeForLightMode: "Світла тема" themeForLightMode: "Світла тема"
@ -1086,6 +1086,9 @@ _achievements:
_outputHelloWorldOnScratchpad: _outputHelloWorldOnScratchpad:
title: "Hello, world!" title: "Hello, world!"
description: "Вивести \"hello world\" у Скретчпаді" description: "Вивести \"hello world\" у Скретчпаді"
_reactWithoutRead:
title: "Прочитали як слід?"
description: "Реакція на нотатку, що містить понад 100 символів, протягом 3 секунд після її публікації"
_clickedClickHere: _clickedClickHere:
title: "Натисніть тут" title: "Натисніть тут"
description: "Натиснуто тут" description: "Натиснуто тут"

View file

@ -467,6 +467,8 @@ youHaveNoGroups: "没有群组"
joinOrCreateGroup: "请加入一个现有的群组,或者创建新群组。" joinOrCreateGroup: "请加入一个现有的群组,或者创建新群组。"
noHistory: "没有历史记录" noHistory: "没有历史记录"
signinHistory: "登录历史" signinHistory: "登录历史"
enableAdvancedMfm: "启用扩展MFM"
enableAnimatedMfm: "启用MFM动画"
doing: "正在进行" doing: "正在进行"
category: "类别" category: "类别"
tags: "标签" tags: "标签"
@ -945,6 +947,10 @@ selectFromPresets: "從預設值中選擇"
achievements: "成就" achievements: "成就"
gotInvalidResponseError: "服务器无应答" gotInvalidResponseError: "服务器无应答"
gotInvalidResponseErrorDescription: "您的网络连接可能出现了问题, 或是远程服务器暂时不可用. 请稍后重试。" gotInvalidResponseErrorDescription: "您的网络连接可能出现了问题, 或是远程服务器暂时不可用. 请稍后重试。"
thisPostMayBeAnnoying: "这个帖子可能会让其他人感到困扰。"
thisPostMayBeAnnoyingHome: "发到首页"
thisPostMayBeAnnoyingCancel: "取消"
thisPostMayBeAnnoyingIgnore: "就这样发布"
_achievements: _achievements:
earnedAt: "达成时间" earnedAt: "达成时间"
_types: _types:

View file

@ -467,6 +467,8 @@ youHaveNoGroups: "找不到群組"
joinOrCreateGroup: "請加入現有群組,或創建新群組。" joinOrCreateGroup: "請加入現有群組,或創建新群組。"
noHistory: "沒有歷史紀錄" noHistory: "沒有歷史紀錄"
signinHistory: "登入歷史" signinHistory: "登入歷史"
enableAdvancedMfm: "啟用高級MFM"
enableAnimatedMfm: "啟用MFM動畫"
doing: "正在進行" doing: "正在進行"
category: "類別" category: "類別"
tags: "標籤" tags: "標籤"
@ -945,6 +947,11 @@ selectFromPresets: "從預設值中選擇"
achievements: "成就" achievements: "成就"
gotInvalidResponseError: "伺服器的回應無效" gotInvalidResponseError: "伺服器的回應無效"
gotInvalidResponseErrorDescription: "伺服器可能已關閉或者在維護中,請稍後再試。" gotInvalidResponseErrorDescription: "伺服器可能已關閉或者在維護中,請稍後再試。"
thisPostMayBeAnnoying: "這篇貼文可能會造成別人的困擾。"
thisPostMayBeAnnoyingHome: "發布到首頁"
thisPostMayBeAnnoyingCancel: "退出"
thisPostMayBeAnnoyingIgnore: "直接發布貼文"
collapseRenotes: "省略顯示已看過的轉發貼文"
_achievements: _achievements:
earnedAt: "獲得日期" earnedAt: "獲得日期"
_types: _types:

View file

@ -1,6 +1,6 @@
{ {
"name": "misskey", "name": "misskey",
"version": "13.6.0", "version": "13.6.1",
"codename": "nasubi", "codename": "nasubi",
"repository": { "repository": {
"type": "git", "type": "git",

View file

@ -67,6 +67,7 @@ export type Source = {
mediaProxy?: string; mediaProxy?: string;
proxyRemoteFiles?: boolean; proxyRemoteFiles?: boolean;
videoThumbnailGenerator?: string;
signToActivityPubGet?: boolean; signToActivityPubGet?: boolean;
}; };
@ -89,6 +90,7 @@ export type Mixin = {
clientManifestExists: boolean; clientManifestExists: boolean;
mediaProxy: string; mediaProxy: string;
externalMediaProxyEnabled: boolean; externalMediaProxyEnabled: boolean;
videoThumbnailGenerator: string | null;
}; };
export type Config = Source & Mixin; export type Config = Source & Mixin;
@ -144,6 +146,10 @@ export function loadConfig() {
mixin.mediaProxy = externalMediaProxy ?? internalMediaProxy; mixin.mediaProxy = externalMediaProxy ?? internalMediaProxy;
mixin.externalMediaProxyEnabled = externalMediaProxy !== null && externalMediaProxy !== internalMediaProxy; mixin.externalMediaProxyEnabled = externalMediaProxy !== null && externalMediaProxy !== internalMediaProxy;
mixin.videoThumbnailGenerator = config.videoThumbnailGenerator ?
config.videoThumbnailGenerator.endsWith('/') ? config.videoThumbnailGenerator.substring(0, config.videoThumbnailGenerator.length - 1) : config.videoThumbnailGenerator
: null;
if (!config.redis.prefix) config.redis.prefix = mixin.host; if (!config.redis.prefix) config.redis.prefix = mixin.host;
return Object.assign(config, mixin); return Object.assign(config, mixin);

View file

@ -250,6 +250,14 @@ export class DriveService {
@bindThis @bindThis
public async generateAlts(path: string, type: string, generateWeb: boolean) { public async generateAlts(path: string, type: string, generateWeb: boolean) {
if (type.startsWith('video/')) { if (type.startsWith('video/')) {
if (this.config.videoThumbnailGenerator != null) {
// videoThumbnailGeneratorが指定されていたら動画サムネイル生成はスキップ
return {
webpublic: null,
thumbnail: null,
}
}
try { try {
const thumbnail = await this.videoProcessingService.generateVideoThumbnail(path); const thumbnail = await this.videoProcessingService.generateVideoThumbnail(path);
return { return {

View file

@ -14,6 +14,8 @@ import { RoleService } from '@/core/RoleService.js';
@Injectable() @Injectable()
export class UserListService { export class UserListService {
public static TooManyUsersError = class extends Error {};
constructor( constructor(
@Inject(DI.usersRepository) @Inject(DI.usersRepository)
private usersRepository: UsersRepository, private usersRepository: UsersRepository,
@ -36,7 +38,7 @@ export class UserListService {
userListId: list.id, userListId: list.id,
}); });
if (currentCount > (await this.roleService.getUserPolicies(me.id)).userEachUserListsLimit) { if (currentCount > (await this.roleService.getUserPolicies(me.id)).userEachUserListsLimit) {
throw new Error('Too many users'); throw new UserListService.TooManyUsersError();
} }
await this.userListJoiningsRepository.insert({ await this.userListJoiningsRepository.insert({

View file

@ -6,6 +6,7 @@ import { ImageProcessingService } from '@/core/ImageProcessingService.js';
import type { IImage } from '@/core/ImageProcessingService.js'; import type { IImage } from '@/core/ImageProcessingService.js';
import { createTempDir } from '@/misc/create-temp.js'; import { createTempDir } from '@/misc/create-temp.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { appendQuery, query } from '@/misc/prelude/url.js';
@Injectable() @Injectable()
export class VideoProcessingService { export class VideoProcessingService {
@ -41,5 +42,18 @@ export class VideoProcessingService {
cleanup(); cleanup();
} }
} }
@bindThis
public getExternalVideoThumbnailUrl(url: string): string | null {
if (this.config.videoThumbnailGenerator == null) return null;
return appendQuery(
`${this.config.videoThumbnailGenerator}/thumbnail.webp`,
query({
thumbnail: '1',
url,
})
)
}
} }

View file

@ -13,6 +13,7 @@ import { deepClone } from '@/misc/clone.js';
import { UtilityService } from '../UtilityService.js'; import { UtilityService } from '../UtilityService.js';
import { UserEntityService } from './UserEntityService.js'; import { UserEntityService } from './UserEntityService.js';
import { DriveFolderEntityService } from './DriveFolderEntityService.js'; import { DriveFolderEntityService } from './DriveFolderEntityService.js';
import { VideoProcessingService } from '../VideoProcessingService.js';
type PackOptions = { type PackOptions = {
detail?: boolean, detail?: boolean,
@ -43,6 +44,7 @@ export class DriveFileEntityService {
private utilityService: UtilityService, private utilityService: UtilityService,
private driveFolderEntityService: DriveFolderEntityService, private driveFolderEntityService: DriveFolderEntityService,
private videoProcessingService: VideoProcessingService,
) { ) {
} }
@ -72,40 +74,63 @@ export class DriveFileEntityService {
} }
@bindThis @bindThis
public getPublicUrl(file: DriveFile, mode? : 'static' | 'avatar'): string | null { // static = thumbnail private getProxiedUrl(url: string, mode?: 'static' | 'avatar'): string | null {
const proxiedUrl = (url: string) => appendQuery( return appendQuery(
`${this.config.mediaProxy}/${mode ?? 'image'}.webp`, `${this.config.mediaProxy}/${mode ?? 'image'}.webp`,
query({ query({
url, url,
...(mode ? { [mode]: '1' } : {}), ...(mode ? { [mode]: '1' } : {}),
}) })
); )
}
@bindThis
public getThumbnailUrl(file: DriveFile): string | null {
if (file.type.startsWith('video')) {
if (file.thumbnailUrl) return file.thumbnailUrl;
if (this.config.videoThumbnailGenerator == null) {
return this.videoProcessingService.getExternalVideoThumbnailUrl(file.webpublicUrl ?? file.url ?? file.uri);
}
} else if (file.uri != null && file.userHost != null && this.config.externalMediaProxyEnabled) {
// 動画ではなくリモートかつメディアプロキシ
return this.getProxiedUrl(file.uri, 'static');
}
if (file.uri != null && file.isLink && this.config.proxyRemoteFiles) {
// リモートかつ期限切れはローカルプロキシを試みる
// 従来は/files/${thumbnailAccessKey}にアクセスしていたが、
// /filesはメディアプロキシにリダイレクトするようにしたため直接メディアプロキシを指定する
return this.getProxiedUrl(file.uri, 'static');
}
const url = file.webpublicUrl ?? file.url;
return file.thumbnailUrl ?? (isMimeImage(file.type, 'sharp-convertible-image') ? this.getProxiedUrl(url, 'static') : null);
}
@bindThis
public getPublicUrl(file: DriveFile, mode?: 'avatar'): string | null { // static = thumbnail
// リモートかつメディアプロキシ // リモートかつメディアプロキシ
if (file.uri != null && file.userHost != null && this.config.externalMediaProxyEnabled) { if (file.uri != null && file.userHost != null && this.config.externalMediaProxyEnabled) {
if (!(mode === 'static' && file.type.startsWith('video'))) { return this.getProxiedUrl(file.uri, mode);
return proxiedUrl(file.uri);
}
} }
// リモートかつ期限切れはローカルプロキシを試みる // リモートかつ期限切れはローカルプロキシを試みる
if (file.uri != null && file.isLink && this.config.proxyRemoteFiles) { if (file.uri != null && file.isLink && this.config.proxyRemoteFiles) {
const key = mode === 'static' ? file.thumbnailAccessKey : file.webpublicAccessKey; const key = file.webpublicAccessKey;
if (key && !key.match('/')) { // 古いものはここにオブジェクトストレージキーが入ってるので除外 if (key && !key.match('/')) { // 古いものはここにオブジェクトストレージキーが入ってるので除外
const url = `${this.config.url}/files/${key}`; const url = `${this.config.url}/files/${key}`;
if (mode === 'avatar') return proxiedUrl(file.uri); if (mode === 'avatar') return this.getProxiedUrl(file.uri, 'avatar');
return url; return url;
} }
} }
const url = file.webpublicUrl ?? file.url; const url = file.webpublicUrl ?? file.url;
if (mode === 'static') {
return file.thumbnailUrl ?? (isMimeImage(file.type, 'sharp-convertible-image') ? proxiedUrl(url) : null);
}
if (mode === 'avatar') { if (mode === 'avatar') {
return proxiedUrl(url); return this.getProxiedUrl(url, 'avatar');
} }
return url; return url;
} }
@ -183,7 +208,7 @@ export class DriveFileEntityService {
blurhash: file.blurhash, blurhash: file.blurhash,
properties: opts.self ? file.properties : this.getPublicProperties(file), properties: opts.self ? file.properties : this.getPublicProperties(file),
url: opts.self ? file.url : this.getPublicUrl(file), url: opts.self ? file.url : this.getPublicUrl(file),
thumbnailUrl: this.getPublicUrl(file, 'static'), thumbnailUrl: this.getThumbnailUrl(file),
comment: file.comment, comment: file.comment,
folderId: file.folderId, folderId: file.folderId,
folder: opts.detail && file.folderId ? this.driveFolderEntityService.pack(file.folderId, { folder: opts.detail && file.folderId ? this.driveFolderEntityService.pack(file.folderId, {
@ -218,7 +243,7 @@ export class DriveFileEntityService {
blurhash: file.blurhash, blurhash: file.blurhash,
properties: opts.self ? file.properties : this.getPublicProperties(file), properties: opts.self ? file.properties : this.getPublicProperties(file),
url: opts.self ? file.url : this.getPublicUrl(file), url: opts.self ? file.url : this.getPublicUrl(file),
thumbnailUrl: this.getPublicUrl(file, 'static'), thumbnailUrl: this.getThumbnailUrl(file),
comment: file.comment, comment: file.comment,
folderId: file.folderId, folderId: file.folderId,
folder: opts.detail && file.folderId ? this.driveFolderEntityService.pack(file.folderId, { folder: opts.detail && file.folderId ? this.driveFolderEntityService.pack(file.folderId, {

View file

@ -441,6 +441,14 @@ export class ActivityPubServerService {
fastify.addContentTypeParser('application/activity+json', { parseAs: 'string' }, fastify.getDefaultJsonParser('ignore', 'ignore')); fastify.addContentTypeParser('application/activity+json', { parseAs: 'string' }, fastify.getDefaultJsonParser('ignore', 'ignore'));
fastify.addContentTypeParser('application/ld+json', { parseAs: 'string' }, fastify.getDefaultJsonParser('ignore', 'ignore')); fastify.addContentTypeParser('application/ld+json', { parseAs: 'string' }, fastify.getDefaultJsonParser('ignore', 'ignore'));
fastify.addHook('onRequest', (request, reply, done) => {
reply.header('Access-Control-Allow-Headers', 'Accept');
reply.header('Access-Control-Allow-Methods', 'GET, OPTIONS');
reply.header('Access-Control-Allow-Origin', '*');
reply.header('Access-Control-Expose-Headers', 'Vary');
done();
});
//#region Routing //#region Routing
// inbox (limit: 64kb) // inbox (limit: 64kb)
fastify.post('/inbox', { bodyLimit: 1024 * 64 }, async (request, reply) => await this.inbox(request, reply)); fastify.post('/inbox', { bodyLimit: 1024 * 64 }, async (request, reply) => await this.inbox(request, reply));

View file

@ -150,6 +150,12 @@ export class FileServerService {
file.cleanup(); file.cleanup();
return await reply.redirect(301, url.toString()); return await reply.redirect(301, url.toString());
} else if (file.mime.startsWith('video/')) { } else if (file.mime.startsWith('video/')) {
const externalThumbnail = this.videoProcessingService.getExternalVideoThumbnailUrl(file.url);
if (externalThumbnail) {
file.cleanup();
return await reply.redirect(301, externalThumbnail);
}
image = await this.videoProcessingService.generateVideoThumbnail(file.path); image = await this.videoProcessingService.generateVideoThumbnail(file.path);
} }
} }

View file

@ -227,15 +227,17 @@ export class ApiCallService implements OnApplicationShutdown {
// TODO: 毎リクエスト計算するのもあれだしキャッシュしたい // TODO: 毎リクエスト計算するのもあれだしキャッシュしたい
const factor = user ? (await this.roleService.getUserPolicies(user.id)).rateLimitFactor : 1; const factor = user ? (await this.roleService.getUserPolicies(user.id)).rateLimitFactor : 1;
// Rate limit if (factor > 0) {
await this.rateLimiterService.limit(limit as IEndpointMeta['limit'] & { key: NonNullable<string> }, limitActor, factor).catch(err => { // Rate limit
throw new ApiError({ await this.rateLimiterService.limit(limit as IEndpointMeta['limit'] & { key: NonNullable<string> }, limitActor, factor).catch(err => {
message: 'Rate limit exceeded. Please try again later.', throw new ApiError({
code: 'RATE_LIMIT_EXCEEDED', message: 'Rate limit exceeded. Please try again later.',
id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef', code: 'RATE_LIMIT_EXCEEDED',
httpStatusCode: 429, id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef',
httpStatusCode: 429,
});
}); });
}); }
} }
if (ep.meta.requireCredential || ep.meta.requireModerator || ep.meta.requireAdmin) { if (ep.meta.requireCredential || ep.meta.requireModerator || ep.meta.requireAdmin) {

View file

@ -45,6 +45,12 @@ export const meta = {
code: 'YOU_HAVE_BEEN_BLOCKED', code: 'YOU_HAVE_BEEN_BLOCKED',
id: '990232c5-3f9d-4d83-9f3f-ef27b6332a4b', id: '990232c5-3f9d-4d83-9f3f-ef27b6332a4b',
}, },
tooManyUsers: {
message: 'You can not push users any more.',
code: 'TOO_MANY_USERS',
id: '2dd9752e-a338-413d-8eec-41814430989b',
},
}, },
} as const; } as const;
@ -110,8 +116,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
throw new ApiError(meta.errors.alreadyAdded); throw new ApiError(meta.errors.alreadyAdded);
} }
// Push the user try {
await this.userListService.push(user, userList, me); await this.userListService.push(user, userList, me);
} catch (err) {
if (err instanceof UserListService.TooManyUsersError) {
throw new ApiError(meta.errors.tooManyUsers);
}
throw err;
}
}); });
} }
} }

View file

@ -11,6 +11,9 @@ window.onload = async () => {
// Send request // Send request
fetch(endpoint.indexOf('://') > -1 ? endpoint : `/api/${endpoint}`, { fetch(endpoint.indexOf('://') > -1 ? endpoint : `/api/${endpoint}`, {
headers: {
'Content-Type': 'application/json'
},
method: 'POST', method: 'POST',
body: JSON.stringify(data), body: JSON.stringify(data),
credentials: 'omit', credentials: 'omit',

View file

@ -78,7 +78,7 @@
</div> </div>
<footer :class="$style.footer"> <footer :class="$style.footer">
<MkReactionsViewer :note="appearNote" :max-number="16"> <MkReactionsViewer :note="appearNote" :max-number="16">
<template v-slot:more> <template #more>
<button class="_button" :class="$style.reactionDetailsButton" @click="showReactions"> <button class="_button" :class="$style.reactionDetailsButton" @click="showReactions">
{{ i18n.ts.more }} {{ i18n.ts.more }}
</button> </button>
@ -156,6 +156,7 @@ import { useTooltip } from '@/scripts/use-tooltip';
import { claimAchievement } from '@/scripts/achievements'; import { claimAchievement } from '@/scripts/achievements';
import { getNoteSummary } from '@/scripts/get-note-summary'; import { getNoteSummary } from '@/scripts/get-note-summary';
import { shownNoteIds } from '@/os'; import { shownNoteIds } from '@/os';
import { MenuItem } from '@/types/menu';
const props = defineProps<{ const props = defineProps<{
note: misskey.entities.Note; note: misskey.entities.Note;
@ -206,7 +207,7 @@ const translation = ref<any>(null);
const translating = ref(false); const translating = ref(false);
const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.user.instance); const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.user.instance);
const canRenote = computed(() => ['public', 'home'].includes(appearNote.visibility) || appearNote.userId === $i.id); const canRenote = computed(() => ['public', 'home'].includes(appearNote.visibility) || appearNote.userId === $i.id);
let renoteCollapsed = $ref(isRenote && (($i && ($i.id === note.userId)) || shownNoteIds.has(appearNote.id))); let renoteCollapsed = $ref(defaultStore.state.collapseRenotes && isRenote && (($i && ($i.id === note.userId)) || shownNoteIds.has(appearNote.id)));
shownNoteIds.add(appearNote.id); shownNoteIds.add(appearNote.id);
@ -247,7 +248,32 @@ useTooltip(renoteButton, async (showing) => {
function renote(viaKeyboard = false) { function renote(viaKeyboard = false) {
pleaseLogin(); pleaseLogin();
os.popupMenu([{
let items = [] as MenuItem[];
if (appearNote.channel) {
items = items.concat([{
text: i18n.ts.inChannelRenote,
icon: 'ti ti-repeat',
action: () => {
os.api('notes/create', {
renoteId: appearNote.id,
channelId: appearNote.channelId,
});
},
}, {
text: i18n.ts.inChannelQuote,
icon: 'ti ti-quote',
action: () => {
os.post({
renote: appearNote,
channel: appearNote.channel,
});
},
}, null]);
}
items = items.concat([{
text: i18n.ts.renote, text: i18n.ts.renote,
icon: 'ti ti-repeat', icon: 'ti ti-repeat',
action: () => { action: () => {
@ -263,7 +289,9 @@ function renote(viaKeyboard = false) {
renote: appearNote, renote: appearNote,
}); });
}, },
}], renoteButton.value, { }]);
os.popupMenu(items, renoteButton.value, {
viaKeyboard, viaKeyboard,
}); });
} }
@ -704,6 +732,12 @@ function showReactions(): void {
} }
} }
@container (max-width: 250px) {
.quoteNote {
padding: 12px;
}
}
.muted { .muted {
padding: 8px; padding: 8px;
text-align: center; text-align: center;

View file

@ -160,6 +160,7 @@ import { useNoteCapture } from '@/scripts/use-note-capture';
import { deepClone } from '@/scripts/clone'; import { deepClone } from '@/scripts/clone';
import { useTooltip } from '@/scripts/use-tooltip'; import { useTooltip } from '@/scripts/use-tooltip';
import { claimAchievement } from '@/scripts/achievements'; import { claimAchievement } from '@/scripts/achievements';
import { MenuItem } from '@/types/menu';
const props = defineProps<{ const props = defineProps<{
note: misskey.entities.Note; note: misskey.entities.Note;
@ -241,7 +242,32 @@ useTooltip(renoteButton, async (showing) => {
function renote(viaKeyboard = false) { function renote(viaKeyboard = false) {
pleaseLogin(); pleaseLogin();
os.popupMenu([{
let items = [] as MenuItem[];
if (appearNote.channel) {
items = items.concat([{
text: i18n.ts.inChannelRenote,
icon: 'ti ti-repeat',
action: () => {
os.api('notes/create', {
renoteId: appearNote.id,
channelId: appearNote.channelId,
});
},
}, {
text: i18n.ts.inChannelQuote,
icon: 'ti ti-quote',
action: () => {
os.post({
renote: appearNote,
channel: appearNote.channel,
});
},
}, null]);
}
items = items.concat([{
text: i18n.ts.renote, text: i18n.ts.renote,
icon: 'ti ti-repeat', icon: 'ti ti-repeat',
action: () => { action: () => {
@ -257,7 +283,9 @@ function renote(viaKeyboard = false) {
renote: appearNote, renote: appearNote,
}); });
}, },
}], renoteButton.value, { }]);
os.popupMenu(items, renoteButton.value, {
viaKeyboard, viaKeyboard,
}); });
} }

View file

@ -44,8 +44,8 @@ const showContent = $ref(false);
flex-shrink: 0; flex-shrink: 0;
display: block; display: block;
margin: 0 10px 0 0; margin: 0 10px 0 0;
width: 40px; width: 34px;
height: 40px; height: 34px;
border-radius: 8px; border-radius: 8px;
} }
@ -72,6 +72,14 @@ const showContent = $ref(false);
padding: 0; padding: 0;
} }
@container (min-width: 250px) {
.avatar {
margin: 0 10px 0 0;
width: 40px;
height: 40px;
}
}
@container (min-width: 350px) { @container (min-width: 350px) {
.avatar { .avatar {
margin: 0 10px 0 0; margin: 0 10px 0 0;

View file

@ -21,7 +21,7 @@
<div v-else ref="rootEl"> <div v-else ref="rootEl">
<div v-show="pagination.reversed && more" key="_more_" class="_margin"> <div v-show="pagination.reversed && more" key="_more_" class="_margin">
<MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? fetchMore : null" :class="$style.more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary @click="fetchMore"> <MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? fetchMoreAhead : null" :class="$style.more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary @click="fetchMoreAhead">
{{ i18n.ts.loadMore }} {{ i18n.ts.loadMore }}
</MkButton> </MkButton>
<MkLoading v-else class="loading"/> <MkLoading v-else class="loading"/>

View file

@ -0,0 +1,234 @@
<template>
<div ref="el" :class="$style.tabs" @wheel="onTabWheel">
<div :class="$style.tabsInner">
<button v-for="t in tabs" :ref="(el) => tabRefs[t.key] = (el as HTMLElement)" v-tooltip.noDelay="t.title"
class="_button" :class="[$style.tab, { [$style.active]: t.key != null && t.key === props.tab, [$style.animate]: defaultStore.reactiveState.animation.value }]"
@mousedown="(ev) => onTabMousedown(t, ev)" @click="(ev) => onTabClick(t, ev)">
<div :class="$style.tabInner">
<i v-if="t.icon" :class="[$style.tabIcon, t.icon]"></i>
<div v-if="!t.iconOnly || (!defaultStore.reactiveState.animation.value && t.key === tab)"
:class="$style.tabTitle">{{ t.title }}</div>
<Transition v-else @enter="enter" @after-enter="afterEnter" @leave="leave" @after-leave="afterLeave"
mode="in-out">
<div v-show="t.key === tab" :class="[$style.tabTitle, $style.animate]">{{ t.title }}</div>
</Transition>
</div>
</button>
</div>
<div ref="tabHighlightEl"
:class="[$style.tabHighlight, { [$style.animate]: defaultStore.reactiveState.animation.value }]"></div>
</div>
</template>
<script lang="ts">
export type Tab = {
key: string;
title: string;
icon?: string;
iconOnly?: boolean;
onClick?: (ev: MouseEvent) => void;
} & {
iconOnly: true;
iccn: string;
};
</script>
<script lang="ts" setup>
import { onMounted, onUnmounted, watch, nextTick, Transition, shallowRef } from 'vue';
import { defaultStore } from '@/store';
const props = withDefaults(defineProps<{
tabs?: Tab[];
tab?: string;
rootEl?: HTMLElement;
}>(), {
tabs: () => ([] as Tab[]),
});
const emit = defineEmits<{
(ev: 'update:tab', key: string);
(ev: 'tabClick', key: string);
}>();
const el = shallowRef<HTMLElement | null>(null);
const tabRefs: Record<string, HTMLElement | null> = {};
const tabHighlightEl = shallowRef<HTMLElement | null>(null);
function onTabMousedown(tab: Tab, ev: MouseEvent): void {
// mousedownonClick
if (tab.key) {
emit('update:tab', tab.key);
}
}
function onTabClick(t: Tab, ev: MouseEvent): void {
emit('tabClick', t.key);
if (t.onClick) {
ev.preventDefault();
ev.stopPropagation();
t.onClick(ev);
}
if (t.key) {
emit('update:tab', t.key);
}
}
function renderTab() {
const tabEl = props.tab ? tabRefs[props.tab] : undefined;
if (tabEl && tabHighlightEl.value && tabHighlightEl.value.parentElement) {
// offsetWidth offsetLeft getBoundingClientRect 使
// https://developer.mozilla.org/ja/docs/Web/API/HTMLElement/offsetWidth#%E5%80%A4
const parentRect = tabHighlightEl.value.parentElement.getBoundingClientRect();
const rect = tabEl.getBoundingClientRect();
tabHighlightEl.value.style.width = rect.width + 'px';
tabHighlightEl.value.style.left = (rect.left - parentRect.left + tabHighlightEl.value.parentElement.scrollLeft) + 'px';
}
}
function onTabWheel(ev: WheelEvent) {
if (ev.deltaY !== 0 && ev.deltaX === 0) {
ev.preventDefault();
ev.stopPropagation();
(ev.currentTarget as HTMLElement).scrollBy({
left: ev.deltaY,
behavior: 'smooth',
});
}
return false;
}
let entering = false;
async function enter(el: HTMLElement) {
entering = true;
const elementWidth = el.getBoundingClientRect().width;
el.style.width = '0';
el.style.paddingLeft = '0';
el.offsetWidth; // force reflow
el.style.width = elementWidth + 'px';
el.style.paddingLeft = '';
nextTick(() => {
entering = false;
});
setTimeout(renderTab, 170);
}
function afterEnter(el: HTMLElement) {
//el.style.width = '';
}
async function leave(el: HTMLElement) {
const elementWidth = el.getBoundingClientRect().width;
el.style.width = elementWidth + 'px';
el.style.paddingLeft = '';
el.offsetWidth; // force reflow
el.style.width = '0';
el.style.paddingLeft = '0';
}
function afterLeave(el: HTMLElement) {
el.style.width = '';
}
let ro2: ResizeObserver | null;
onMounted(() => {
watch([() => props.tab, () => props.tabs], () => {
nextTick(() => {
if (entering) return;
renderTab();
});
}, {
immediate: true,
});
if (props.rootEl) {
ro2 = new ResizeObserver((entries, observer) => {
if (document.body.contains(el.value as HTMLElement)) {
nextTick(() => renderTab());
}
});
ro2.observe(props.rootEl);
}
});
onUnmounted(() => {
if (ro2) ro2.disconnect();
});
</script>
<style lang="scss" module>
.tabs {
display: block;
position: relative;
margin: 0;
height: var(--height);
font-size: 0.8em;
text-align: center;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
.tabsInner {
display: inline-block;
height: var(--height);
white-space: nowrap;
}
.tab {
display: inline-block;
position: relative;
padding: 0 10px;
height: 100%;
font-weight: normal;
opacity: 0.7;
&:hover {
opacity: 1;
}
&.active {
opacity: 1;
}
&.animate {
transition: opacity 0.2s ease;
}
}
.tabInner {
display: flex;
align-items: center;
}
.tabIcon+.tabTitle {
padding-left: 8px;
}
.tabTitle {
overflow: hidden;
&.animate {
transition: width .15s linear, padding-left .15s linear;
}
}
.tabHighlight {
position: absolute;
bottom: 0;
height: 3px;
background: var(--accent);
border-radius: 999px;
transition: none;
pointer-events: none;
&.animate {
transition: width 0.15s ease, left 0.15s ease;
}
}
</style>

View file

@ -1,10 +1,10 @@
<template> <template>
<div v-if="show" ref="el" :class="[$style.root]" :style="{ background: bg }"> <div v-if="show" ref="el" :class="[$style.root]" :style="{ background: bg }">
<div :class="[$style.upper, { [$style.slim]: narrow, [$style.thin]: thin_ }]"> <div :class="[$style.upper, { [$style.slim]: narrow, [$style.thin]: thin_ }]">
<div v-if="narrow && props.displayMyAvatar && $i" class="_button" :class="$style.buttonsLeft" @click="openAccountMenu"> <div v-if="!thin_ && narrow && props.displayMyAvatar && $i" class="_button" :class="$style.buttonsLeft" @click="openAccountMenu">
<MkAvatar :class="$style.avatar" :user="$i" /> <MkAvatar :class="$style.avatar" :user="$i" />
</div> </div>
<div v-else-if="narrow && !hideTitle" :class="$style.buttonsLeft" /> <div v-else-if="!thin_ && narrow && !hideTitle" :class="$style.buttonsLeft" />
<template v-if="metadata"> <template v-if="metadata">
<div v-if="!hideTitle" :class="$style.titleContainer" @click="top"> <div v-if="!hideTitle" :class="$style.titleContainer" @click="top">
@ -19,63 +19,28 @@
</div> </div>
</div> </div>
</div> </div>
<div v-if="!narrow || hideTitle" :class="$style.tabs" @wheel="onTabWheel"> <XTabs v-if="!narrow || hideTitle" :class="$style.tabs" :tab="tab" @update:tab="key => emit('update:tab', key)" :tabs="tabs" :root-el="el" @tab-click="onTabClick"/>
<div :class="$style.tabsInner">
<button v-for="t in tabs" :ref="(el) => tabRefs[t.key] = (el as HTMLElement)" v-tooltip.noDelay="t.title" class="_button" :class="[$style.tab, { [$style.active]: t.key != null && t.key === props.tab }]" @mousedown="(ev) => onTabMousedown(t, ev)" @click="(ev) => onTabClick(t, ev)">
<div :class="$style.tabInner">
<i v-if="t.icon" :class="[$style.tabIcon, t.icon]"></i>
<div v-if="!t.iconOnly" :class="$style.tabTitle">{{ t.title }}</div>
<Transition
v-else
@enter="enter"
@after-enter="afterEnter"
@leave="leave"
@after-leave="afterLeave"
mode="in-out"
>
<div v-if="t.key === tab" :class="$style.tabTitle">{{ t.title }}</div>
</Transition>
</div>
</button>
</div>
<div ref="tabHighlightEl" :class="$style.tabHighlight"></div>
</div>
</template> </template>
<div v-if="(narrow && !hideTitle) || (actions && actions.length > 0)" :class="$style.buttonsRight"> <div v-if="(!thin_ && narrow && !hideTitle) || (actions && actions.length > 0)" :class="$style.buttonsRight">
<template v-for="action in actions"> <template v-for="action in actions">
<button v-tooltip.noDelay="action.text" class="_button" :class="[$style.button, { [$style.highlighted]: action.highlighted }]" @click.stop="action.handler" @touchstart="preventDrag"><i :class="action.icon"></i></button> <button v-tooltip.noDelay="action.text" class="_button" :class="[$style.button, { [$style.highlighted]: action.highlighted }]" @click.stop="action.handler" @touchstart="preventDrag"><i :class="action.icon"></i></button>
</template> </template>
</div> </div>
</div> </div>
<div v-if="(narrow && !hideTitle) && hasTabs" :class="[$style.lower, { [$style.slim]: narrow, [$style.thin]: thin_ }]"> <div v-if="(narrow && !hideTitle) && hasTabs" :class="[$style.lower, { [$style.slim]: narrow, [$style.thin]: thin_ }]">
<div :class="$style.tabs" @wheel="onTabWheel"> <XTabs :class="$style.tabs" :tab="tab" @update:tab="key => emit('update:tab', key)" :tabs="tabs" :root-el="el" @tab-click="onTabClick"/>
<div :class="$style.tabsInner">
<button v-for="tab in tabs" :ref="(el) => tabRefs[tab.key] = (el as HTMLElement)" v-tooltip.noDelay="tab.title" class="_button" :class="[$style.tab, { [$style.active]: tab.key != null && tab.key === props.tab }]" @mousedown="(ev) => onTabMousedown(tab, ev)" @click="(ev) => onTabClick(tab, ev)">
<i v-if="tab.icon" :class="[$style.tabIcon, tab.icon]"></i>
<span v-if="!tab.iconOnly" :class="$style.tabTitle">{{ tab.title }}</span>
</button>
</div>
<div ref="tabHighlightEl" :class="$style.tabHighlight"></div>
</div>
</div> </div>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { onMounted, onUnmounted, ref, inject, watch, nextTick } from 'vue'; import { onMounted, onUnmounted, ref, inject } from 'vue';
import tinycolor from 'tinycolor2'; import tinycolor from 'tinycolor2';
import { scrollToTop } from '@/scripts/scroll'; import { scrollToTop } from '@/scripts/scroll';
import { globalEvents } from '@/events'; import { globalEvents } from '@/events';
import { injectPageMetadata } from '@/scripts/page-metadata'; import { injectPageMetadata } from '@/scripts/page-metadata';
import { $i, openAccountMenu as openAccountMenu_ } from '@/account'; import { $i, openAccountMenu as openAccountMenu_ } from '@/account';
import XTabs, { Tab } from './MkPageHeader.tabs.vue'
type Tab = {
key: string;
title: string;
icon?: string;
iconOnly?: boolean;
onClick?: (ev: MouseEvent) => void;
};
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
tabs?: Tab[]; tabs?: Tab[];
@ -102,8 +67,6 @@ const hideTitle = inject('shouldOmitHeaderTitle', false);
const thin_ = props.thin || inject('shouldHeaderThin', false); const thin_ = props.thin || inject('shouldHeaderThin', false);
let el = $shallowRef<HTMLElement | undefined>(undefined); let el = $shallowRef<HTMLElement | undefined>(undefined);
const tabRefs: Record<string, HTMLElement | null> = {};
let tabHighlightEl = $shallowRef<HTMLElement | null>(null);
const bg = ref<string | undefined>(undefined); const bg = ref<string | undefined>(undefined);
let narrow = $ref(false); let narrow = $ref(false);
const hasTabs = $computed(() => props.tabs.length > 0); const hasTabs = $computed(() => props.tabs.length > 0);
@ -128,25 +91,8 @@ function openAccountMenu(ev: MouseEvent) {
}, ev); }, ev);
} }
function onTabMousedown(tab: Tab, ev: MouseEvent): void { function onTabClick(): void {
// mousedownonClick top();
if (tab.key) {
emit('update:tab', tab.key);
}
}
function onTabClick(t: Tab, ev: MouseEvent): void {
if (t.key === props.tab) {
top();
} else if (t.onClick) {
ev.preventDefault();
ev.stopPropagation();
t.onClick(ev);
}
if (t.key) {
emit('update:tab', t.key);
}
} }
const calcBg = () => { const calcBg = () => {
@ -156,88 +102,26 @@ const calcBg = () => {
bg.value = tinyBg.toRgbString(); bg.value = tinyBg.toRgbString();
}; };
let ro1: ResizeObserver | null; let ro: ResizeObserver | null;
let ro2: ResizeObserver | null;
function renderTab() {
const tabEl = props.tab ? tabRefs[props.tab] : undefined;
if (tabEl && tabHighlightEl && tabHighlightEl.parentElement) {
// offsetWidth offsetLeft getBoundingClientRect 使
// https://developer.mozilla.org/ja/docs/Web/API/HTMLElement/offsetWidth#%E5%80%A4
const parentRect = tabHighlightEl.parentElement.getBoundingClientRect();
const rect = tabEl.getBoundingClientRect();
tabHighlightEl.style.width = rect.width + 'px';
tabHighlightEl.style.left = (rect.left - parentRect.left + tabHighlightEl.parentElement.scrollLeft) + 'px';
}
}
function onTabWheel(ev: WheelEvent) {
if (ev.deltaY !== 0 && ev.deltaX === 0) {
ev.preventDefault();
ev.stopPropagation();
(ev.currentTarget as HTMLElement).scrollBy({
left: ev.deltaY,
behavior: 'smooth',
});
}
return false;
}
function enter(el: HTMLElement) {
const elementWidth = el.getBoundingClientRect().width;
el.style.width = '0';
el.offsetWidth; // reflow
el.style.width = elementWidth + 'px';
setTimeout(renderTab, 70);
}
function afterEnter(el: HTMLElement) {
el.style.width = '';
nextTick(renderTab);
}
function leave(el: HTMLElement) {
const elementWidth = el.getBoundingClientRect().width;
el.style.width = elementWidth + 'px';
el.offsetWidth; // reflow
el.style.width = '0';
}
function afterLeave(el: HTMLElement) {
el.style.width = '';
}
onMounted(() => { onMounted(() => {
calcBg(); calcBg();
globalEvents.on('themeChanged', calcBg); globalEvents.on('themeChanged', calcBg);
watch([() => props.tab, () => props.tabs], () => {
nextTick(() => renderTab());
}, {
immediate: true,
});
if (el && el.parentElement) { if (el && el.parentElement) {
narrow = el.parentElement.offsetWidth < 500; narrow = el.parentElement.offsetWidth < 500;
ro1 = new ResizeObserver((entries, observer) => { ro = new ResizeObserver((entries, observer) => {
if (el && el.parentElement && document.body.contains(el as HTMLElement)) { if (el && el.parentElement && document.body.contains(el as HTMLElement)) {
narrow = el.parentElement.offsetWidth < 500; narrow = el.parentElement.offsetWidth < 500;
} }
}); });
ro1.observe(el.parentElement as HTMLElement); ro.observe(el.parentElement as HTMLElement);
}
if (el) {
ro2 = new ResizeObserver((entries, observer) => {
if (document.body.contains(el as HTMLElement)) {
nextTick(() => renderTab());
}
});
ro2.observe(el);
} }
}); });
onUnmounted(() => { onUnmounted(() => {
globalEvents.off('themeChanged', calcBg); globalEvents.off('themeChanged', calcBg);
if (ro1) ro1.disconnect(); if (ro) ro.disconnect();
if (ro2) ro2.disconnect();
}); });
</script> </script>
@ -258,6 +142,7 @@ onUnmounted(() => {
.upper { .upper {
--height: 50px; --height: 50px;
display: flex; display: flex;
gap: var(--margin);
height: var(--height); height: var(--height);
.tabs:first-child { .tabs:first-child {
@ -267,12 +152,9 @@ onUnmounted(() => {
padding-left: 16px; padding-left: 16px;
mask-image: linear-gradient(90deg, rgba(0,0,0,0), rgb(0,0,0) 16px, rgb(0,0,0) 100%); mask-image: linear-gradient(90deg, rgba(0,0,0,0), rgb(0,0,0) 16px, rgb(0,0,0) 100%);
} }
.tabs:last-child { .tabs {
margin-right: auto; margin-right: auto;
} }
.tabs:not(:last-child) {
margin-right: 0;
}
&.thin { &.thin {
--height: 42px; --height: 42px;
@ -286,19 +168,14 @@ onUnmounted(() => {
&.slim { &.slim {
text-align: center; text-align: center;
gap: 0;
.tabs:first-child {
margin-left: 0;
}
> .titleContainer { > .titleContainer {
flex: 1;
margin: 0 auto; margin: 0 auto;
max-width: 100%; max-width: 100%;
> *:first-child {
margin-left: auto;
}
> *:last-child {
margin-right: auto;
}
} }
} }
} }
@ -314,8 +191,6 @@ onUnmounted(() => {
align-items: center; align-items: center;
min-width: var(--height); min-width: var(--height);
height: var(--height); height: var(--height);
margin: 0 var(--margin);
&:empty { &:empty {
width: var(--height); width: var(--height);
} }
@ -323,12 +198,12 @@ onUnmounted(() => {
.buttonsLeft { .buttonsLeft {
composes: buttons; composes: buttons;
margin-right: auto; margin: 0 var(--margin) 0 0;
} }
.buttonsRight { .buttonsRight {
composes: buttons; composes: buttons;
margin-left: auto; margin: 0 0 0 var(--margin);
} }
.avatar { .avatar {
@ -373,7 +248,7 @@ onUnmounted(() => {
white-space: nowrap; white-space: nowrap;
text-align: left; text-align: left;
font-weight: bold; font-weight: bold;
flex-shrink: 0; flex-shrink: 1;
margin-left: 24px; margin-left: 24px;
} }
@ -418,68 +293,4 @@ onUnmounted(() => {
} }
} }
} }
.tabs {
display: block;
position: relative;
margin: 0;
height: var(--height);
font-size: 0.8em;
text-align: center;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
.tabsInner {
display: inline-block;
height: var(--height);
white-space: nowrap;
}
.tab {
display: inline-block;
position: relative;
padding: 0 10px;
height: 100%;
font-weight: normal;
opacity: 0.7;
transition: opacity 0.2s ease;
&:hover {
opacity: 1;
}
&.active {
opacity: 1;
}
}
.tabInner {
display: flex;
align-items: center;
}
.tabIcon + .tabTitle {
margin-left: 8px;
}
.tabTitle {
overflow: hidden;
transition: width 0.15s ease-in-out;
}
.tabHighlight {
position: absolute;
bottom: 0;
height: 3px;
background: var(--accent);
border-radius: 999px;
transition: width 0.15s ease, left 0.15s ease;
pointer-events: none;
}
</style> </style>

View file

@ -17,6 +17,7 @@ import MkEmojiPickerWindow from '@/components/MkEmojiPickerWindow.vue';
import MkPopupMenu from '@/components/MkPopupMenu.vue'; import MkPopupMenu from '@/components/MkPopupMenu.vue';
import MkContextMenu from '@/components/MkContextMenu.vue'; import MkContextMenu from '@/components/MkContextMenu.vue';
import { MenuItem } from '@/types/menu'; import { MenuItem } from '@/types/menu';
import copyToClipboard from './scripts/copy-to-clipboard';
export const openingWindowsCount = ref(0); export const openingWindowsCount = ref(0);
@ -26,10 +27,32 @@ export const apiWithDialog = ((
token?: string | null | undefined, token?: string | null | undefined,
) => { ) => {
const promise = api(endpoint, data, token); const promise = api(endpoint, data, token);
promiseDialog(promise, null, (err) => { promiseDialog(promise, null, async (err) => {
let title = null; let title = null;
let text = err.message + '\n' + (err as any).id; let text = err.message + '\n' + (err as any).id;
if (err.code === 'RATE_LIMIT_EXCEEDED') { if (err.code === 'INTERNAL_ERROR') {
title = i18n.ts.internalServerError;
text = i18n.ts.internalServerErrorDescription;
const date = new Date().toISOString();
const { result } = await actions({
type: 'error',
title,
text,
actions: [{
value: 'ok',
text: i18n.ts.gotIt,
primary: true,
}, {
value: 'copy',
text: i18n.ts.copyErrorInfo,
}],
});
if (result === 'copy') {
copyToClipboard(`Endpoint: ${endpoint}\nInfo: ${JSON.stringify(err.info)}\nDate: ${date}`);
success();
}
return;
} else if (err.code === 'RATE_LIMIT_EXCEEDED') {
title = i18n.ts.cannotPerformTemporary; title = i18n.ts.cannotPerformTemporary;
text = i18n.ts.cannotPerformTemporaryDescription; text = i18n.ts.cannotPerformTemporaryDescription;
} else if (err.code.startsWith('TOO_MANY')) { } else if (err.code.startsWith('TOO_MANY')) {

View file

@ -60,11 +60,17 @@ import { definePageMetadata } from '@/scripts/page-metadata';
let ads: any[] = $ref([]); let ads: any[] = $ref([]);
// ISOTZUTCTZ
const localTime = new Date();
const localTimeDiff = localTime.getTimezoneOffset() * 60 * 1000;
os.api('admin/ad/list').then(adsResponse => { os.api('admin/ad/list').then(adsResponse => {
ads = adsResponse.map(r => { ads = adsResponse.map(r => {
const date = new Date(r.expiresAt);
date.setMilliseconds(date.getMilliseconds() - localTimeDiff);
return { return {
...r, ...r,
expiresAt: new Date(r.expiresAt).toISOString().slice(0, 16), expiresAt: date.toISOString().slice(0, 16),
}; };
}); });
}); });

View file

@ -72,7 +72,7 @@
<MkSwitch v-model="policies.rateLimitFactor.useDefault" :readonly="readonly"> <MkSwitch v-model="policies.rateLimitFactor.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template> <template #label>{{ i18n.ts._role.useBaseValue }}</template>
</MkSwitch> </MkSwitch>
<MkRange :model-value="policies.rateLimitFactor.value * 100" :min="30" :max="300" :step="10" :text-converter="(v) => `${v}%`" @update:model-value="v => policies.rateLimitFactor.value = (v / 100)"> <MkRange :model-value="policies.rateLimitFactor.value * 100" :min="0" :max="400" :step="10" :text-converter="(v) => `${v}%`" @update:model-value="v => policies.rateLimitFactor.value = (v / 100)">
<template #label>{{ i18n.ts._role._options.rateLimitFactor }}</template> <template #label>{{ i18n.ts._role._options.rateLimitFactor }}</template>
<template #caption>{{ i18n.ts._role._options.descriptionOfRateLimitFactor }}</template> <template #caption>{{ i18n.ts._role._options.descriptionOfRateLimitFactor }}</template>
</MkRange> </MkRange>

View file

@ -45,6 +45,7 @@
<div class="_gaps_m"> <div class="_gaps_m">
<div class="_gaps_s"> <div class="_gaps_s">
<MkSwitch v-model="collapseRenotes">{{ i18n.ts.collapseRenotes }}</MkSwitch>
<MkSwitch v-model="advancedMfm">{{ i18n.ts.enableAdvancedMfm }}</MkSwitch> <MkSwitch v-model="advancedMfm">{{ i18n.ts.enableAdvancedMfm }}</MkSwitch>
<MkSwitch v-if="advancedMfm" v-model="animatedMfm">{{ i18n.ts.enableAnimatedMfm }}</MkSwitch> <MkSwitch v-if="advancedMfm" v-model="animatedMfm">{{ i18n.ts.enableAnimatedMfm }}</MkSwitch>
<MkSwitch v-model="reduceAnimation">{{ i18n.ts.reduceUiAnimation }}</MkSwitch> <MkSwitch v-model="reduceAnimation">{{ i18n.ts.reduceUiAnimation }}</MkSwitch>
@ -139,6 +140,7 @@ async function reloadAsk() {
const overridedDeviceKind = computed(defaultStore.makeGetterSetter('overridedDeviceKind')); const overridedDeviceKind = computed(defaultStore.makeGetterSetter('overridedDeviceKind'));
const serverDisconnectedBehavior = computed(defaultStore.makeGetterSetter('serverDisconnectedBehavior')); const serverDisconnectedBehavior = computed(defaultStore.makeGetterSetter('serverDisconnectedBehavior'));
const collapseRenotes = computed(defaultStore.makeGetterSetter('collapseRenotes'));
const reduceAnimation = computed(defaultStore.makeGetterSetter('animation', v => !v, v => !v)); const reduceAnimation = computed(defaultStore.makeGetterSetter('animation', v => !v, v => !v));
const useBlurEffectForModal = computed(defaultStore.makeGetterSetter('useBlurEffectForModal')); const useBlurEffectForModal = computed(defaultStore.makeGetterSetter('useBlurEffectForModal'));
const useBlurEffect = computed(defaultStore.makeGetterSetter('useBlurEffect')); const useBlurEffect = computed(defaultStore.makeGetterSetter('useBlurEffect'));

View file

@ -32,6 +32,7 @@ import { i18n } from '@/i18n';
import { instance } from '@/instance'; import { instance } from '@/instance';
import { $i } from '@/account'; import { $i } from '@/account';
import { definePageMetadata } from '@/scripts/page-metadata'; import { definePageMetadata } from '@/scripts/page-metadata';
import type { Tab } from '@/components/global/MkPageHeader.tabs.vue';
provide('shouldOmitHeaderTitle', true); provide('shouldOmitHeaderTitle', true);
@ -57,7 +58,7 @@ function queueUpdated(q: number): void {
} }
function top(): void { function top(): void {
scroll(rootEl, { top: 0 }); if (rootEl) scroll(rootEl, { top: 0 });
} }
async function chooseList(ev: MouseEvent): Promise<void> { async function chooseList(ev: MouseEvent): Promise<void> {
@ -150,7 +151,7 @@ const headerTabs = $computed(() => [{
title: i18n.ts.channel, title: i18n.ts.channel,
iconOnly: true, iconOnly: true,
onClick: chooseChannel, onClick: chooseChannel,
}]); }] as Tab[]);
const headerTabsWhenNotLogin = $computed(() => [ const headerTabsWhenNotLogin = $computed(() => [
...(isLocalTimelineAvailable ? [{ ...(isLocalTimelineAvailable ? [{
@ -165,7 +166,7 @@ const headerTabsWhenNotLogin = $computed(() => [
icon: 'ti ti-whirl', icon: 'ti ti-whirl',
iconOnly: true, iconOnly: true,
}] : []), }] : []),
]); ] as Tab[]);
definePageMetadata(computed(() => ({ definePageMetadata(computed(() => ({
title: i18n.ts.timeline, title: i18n.ts.timeline,

View file

@ -75,15 +75,15 @@
</dl> </dl>
</div> </div>
<div class="status"> <div class="status">
<MkA v-click-anime :to="userPage(user)" :class="{ active: page === 'index' }"> <MkA v-click-anime :to="userPage(user)">
<b>{{ number(user.notesCount) }}</b> <b>{{ number(user.notesCount) }}</b>
<span>{{ i18n.ts.notes }}</span> <span>{{ i18n.ts.notes }}</span>
</MkA> </MkA>
<MkA v-click-anime :to="userPage(user, 'following')" :class="{ active: page === 'following' }"> <MkA v-click-anime :to="userPage(user, 'following')">
<b>{{ number(user.followingCount) }}</b> <b>{{ number(user.followingCount) }}</b>
<span>{{ i18n.ts.following }}</span> <span>{{ i18n.ts.following }}</span>
</MkA> </MkA>
<MkA v-click-anime :to="userPage(user, 'followers')" :class="{ active: page === 'followers' }"> <MkA v-click-anime :to="userPage(user, 'followers')">
<b>{{ number(user.followersCount) }}</b> <b>{{ number(user.followersCount) }}</b>
<span>{{ i18n.ts.followers }}</span> <span>{{ i18n.ts.followers }}</span>
</MkA> </MkA>
@ -100,6 +100,7 @@
<XPhotos :key="user.id" :user="user"/> <XPhotos :key="user.id" :user="user"/>
<XActivity :key="user.id" :user="user"/> <XActivity :key="user.id" :user="user"/>
</template> </template>
<XNotes :no-gap="true" :pagination="pagination"/>
</div> </div>
</div> </div>
<div v-if="!narrow" class="sub _gaps" style="container-type: inline-size;"> <div v-if="!narrow" class="sub _gaps" style="container-type: inline-size;">
@ -132,6 +133,7 @@ import { i18n } from '@/i18n';
import { $i } from '@/account'; import { $i } from '@/account';
import { dateString } from '@/filters/date'; import { dateString } from '@/filters/date';
import { confetti } from '@/scripts/confetti'; import { confetti } from '@/scripts/confetti';
import XNotes from '@/components/MkNotes.vue';
const XPhotos = defineAsyncComponent(() => import('./index.photos.vue')); const XPhotos = defineAsyncComponent(() => import('./index.photos.vue'));
const XActivity = defineAsyncComponent(() => import('./index.activity.vue')); const XActivity = defineAsyncComponent(() => import('./index.activity.vue'));
@ -148,6 +150,14 @@ let narrow = $ref<null | boolean>(null);
let rootEl = $ref<null | HTMLElement>(null); let rootEl = $ref<null | HTMLElement>(null);
let bannerEl = $ref<null | HTMLElement>(null); let bannerEl = $ref<null | HTMLElement>(null);
const pagination = {
endpoint: 'users/notes' as const,
limit: 10,
params: computed(() => ({
userId: props.user.id,
})),
};
const style = $computed(() => { const style = $computed(() => {
if (props.user.bannerUrl == null) return {}; if (props.user.bannerUrl == null) return {};
return { return {

View file

@ -6,6 +6,7 @@
<div v-if="user"> <div v-if="user">
<XHome v-if="tab === 'home'" :user="user"/> <XHome v-if="tab === 'home'" :user="user"/>
<XTimeline v-else-if="tab === 'notes'" :user="user" /> <XTimeline v-else-if="tab === 'notes'" :user="user" />
<XActivity v-else-if="tab === 'activity'" :user="user"/>
<XAchievements v-else-if="tab === 'achievements'" :user="user"/> <XAchievements v-else-if="tab === 'achievements'" :user="user"/>
<XReactions v-else-if="tab === 'reactions'" :user="user"/> <XReactions v-else-if="tab === 'reactions'" :user="user"/>
<XClips v-else-if="tab === 'clips'" :user="user"/> <XClips v-else-if="tab === 'clips'" :user="user"/>
@ -20,13 +21,10 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { defineAsyncComponent, computed, inject, onMounted, onUnmounted, watch } from 'vue'; import { defineAsyncComponent, computed, watch } from 'vue';
import calcAge from 's-age';
import * as Acct from 'misskey-js/built/acct'; import * as Acct from 'misskey-js/built/acct';
import * as misskey from 'misskey-js'; import * as misskey from 'misskey-js';
import { getScrollPosition } from '@/scripts/scroll'; import { acct as getAcct } from '@/filters/user';
import number from '@/filters/number';
import { userPage, acct as getAcct } from '@/filters/user';
import * as os from '@/os'; import * as os from '@/os';
import { useRouter } from '@/router'; import { useRouter } from '@/router';
import { definePageMetadata } from '@/scripts/page-metadata'; import { definePageMetadata } from '@/scripts/page-metadata';
@ -49,8 +47,6 @@ const props = withDefaults(defineProps<{
page: 'home', page: 'home',
}); });
const router = useRouter();
let tab = $ref(props.page); let tab = $ref(props.page);
let user = $ref<null | misskey.entities.UserDetailed>(null); let user = $ref<null | misskey.entities.UserDetailed>(null);
let error = $ref(null); let error = $ref(null);

View file

@ -46,6 +46,10 @@ export const defaultStore = markRaw(new Storage('base', {
where: 'account', where: 'account',
default: false, default: false,
}, },
collapseRenotes: {
where: 'account',
default: true,
},
rememberNoteVisibility: { rememberNoteVisibility: {
where: 'account', where: 'account',
default: false, default: false,