summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.config/example.yml9
-rw-r--r--.devcontainer/Dockerfile1
-rw-r--r--.devcontainer/devcontainer.json11
-rw-r--r--.devcontainer/devcontainer.yml146
-rw-r--r--.devcontainer/docker-compose.yml52
-rwxr-xr-x.devcontainer/init.sh9
-rw-r--r--.gitignore1
-rw-r--r--CHANGELOG.md10
-rw-r--r--CONTRIBUTING.md19
-rw-r--r--locales/en-US.yml6
-rw-r--r--locales/ja-JP.yml6
-rw-r--r--locales/ja-KS.yml2
-rw-r--r--locales/ko-KR.yml19
-rw-r--r--locales/uk-UA.yml7
-rw-r--r--locales/zh-CN.yml6
-rw-r--r--locales/zh-TW.yml7
-rw-r--r--package.json2
-rw-r--r--packages/backend/src/config.ts6
-rw-r--r--packages/backend/src/core/DriveService.ts8
-rw-r--r--packages/backend/src/core/UserListService.ts4
-rw-r--r--packages/backend/src/core/VideoProcessingService.ts14
-rw-r--r--packages/backend/src/core/entities/DriveFileEntityService.ts53
-rw-r--r--packages/backend/src/server/ActivityPubServerService.ts8
-rw-r--r--packages/backend/src/server/FileServerService.ts6
-rw-r--r--packages/backend/src/server/api/ApiCallService.ts18
-rw-r--r--packages/backend/src/server/api/endpoints/users/lists/push.ts17
-rw-r--r--packages/backend/src/server/web/cli.js3
-rw-r--r--packages/frontend/src/components/MkNote.vue42
-rw-r--r--packages/frontend/src/components/MkNoteDetailed.vue32
-rw-r--r--packages/frontend/src/components/MkNoteSimple.vue12
-rw-r--r--packages/frontend/src/components/MkPagination.vue2
-rw-r--r--packages/frontend/src/components/global/MkPageHeader.tabs.vue234
-rw-r--r--packages/frontend/src/components/global/MkPageHeader.vue233
-rw-r--r--packages/frontend/src/os.ts27
-rw-r--r--packages/frontend/src/pages/admin/ads.vue8
-rw-r--r--packages/frontend/src/pages/admin/roles.editor.vue2
-rw-r--r--packages/frontend/src/pages/settings/general.vue2
-rw-r--r--packages/frontend/src/pages/timeline.vue7
-rw-r--r--packages/frontend/src/pages/user/home.vue16
-rw-r--r--packages/frontend/src/pages/user/index.vue10
-rw-r--r--packages/frontend/src/store.ts4
41 files changed, 816 insertions, 265 deletions
diff --git a/.config/example.yml b/.config/example.yml
index a19b5d04e8..92b8726623 100644
--- a/.config/example.yml
+++ b/.config/example.yml
@@ -131,11 +131,20 @@ proxyBypassHosts:
# 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
# Proxy remote files (default: false)
+# Proxy remote files by this instance or mediaProxy to prevent remote files from running in remote domains.
#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)
signToActivityPubGet: true
diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile
new file mode 100644
index 0000000000..b6ebcf6ad3
--- /dev/null
+++ b/.devcontainer/Dockerfile
@@ -0,0 +1 @@
+FROM mcr.microsoft.com/devcontainers/javascript-node:0-18
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
new file mode 100644
index 0000000000..e92f9dff78
--- /dev/null
+++ b/.devcontainer/devcontainer.json
@@ -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"
+}
diff --git a/.devcontainer/devcontainer.yml b/.devcontainer/devcontainer.yml
new file mode 100644
index 0000000000..8a363a15dc
--- /dev/null
+++ b/.devcontainer/devcontainer.yml
@@ -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
diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml
new file mode 100644
index 0000000000..6cb21844ac
--- /dev/null
+++ b/.devcontainer/docker-compose.yml
@@ -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:
diff --git a/.devcontainer/init.sh b/.devcontainer/init.sh
new file mode 100755
index 0000000000..552b229fa5
--- /dev/null
+++ b/.devcontainer/init.sh
@@ -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
diff --git a/.gitignore b/.gitignore
index f532cdaa7e..62b818c629 100644
--- a/.gitignore
+++ b/.gitignore
@@ -33,6 +33,7 @@ coverage
!/.config/docker_example.yml
!/.config/docker_example.env
docker-compose.yml
+!/.devcontainer/docker-compose.yml
# misskey
/build
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1b650e7de9..3b65932162 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,16 @@
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)
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index e539926789..de0a1abb45 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -111,6 +111,25 @@ command.
- Vite HMR (just the `vite` command) is available. The behavior may be different from production.
- 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
- Test codes are located in [`/packages/backend/test`](/packages/backend/test).
diff --git a/locales/en-US.yml b/locales/en-US.yml
index b18e57af8e..beb5242b1c 100644
--- a/locales/en-US.yml
+++ b/locales/en-US.yml
@@ -467,6 +467,8 @@ youHaveNoGroups: "You have no groups"
joinOrCreateGroup: "Get invited to a group or create your own."
noHistory: "No history available"
signinHistory: "Login history"
+enableAdvancedMfm: "Enable advanced MFM"
+enableAnimatedMfm: "Enable MFM with animation"
doing: "Processing..."
category: "Category"
tags: "Tags"
@@ -945,6 +947,10 @@ selectFromPresets: "Choose from presets"
achievements: "Achievements"
gotInvalidResponseError: "Invalid server response"
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:
earnedAt: "Unlocked at"
_types:
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 5c919c3032..99ce82ce4c 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -103,6 +103,8 @@ renoted: "Renoteしました。"
cantRenote: "この投稿はRenoteできません。"
cantReRenote: "RenoteをRenoteすることはできません。"
quote: "引用"
+inChannelRenote: "チャンネル内Renote"
+inChannelQuote: "チャンネル内引用"
pinnedNote: "ピン留めされたノート"
pinned: "ピン留め"
you: "あなた"
@@ -951,6 +953,10 @@ thisPostMayBeAnnoying: "この投稿は迷惑になる可能性があります
thisPostMayBeAnnoyingHome: "ホームに投稿"
thisPostMayBeAnnoyingCancel: "やめる"
thisPostMayBeAnnoyingIgnore: "このまま投稿"
+collapseRenotes: "見たことのあるRenoteを省略して表示"
+internalServerError: "サーバー内部エラー"
+internalServerErrorDescription: "サーバー内部で予期しないエラーが発生しました。"
+copyErrorInfo: "エラー情報をコピー"
_achievements:
earnedAt: "獲得日時"
diff --git a/locales/ja-KS.yml b/locales/ja-KS.yml
index ced47e1175..cbbd928b2c 100644
--- a/locales/ja-KS.yml
+++ b/locales/ja-KS.yml
@@ -947,6 +947,8 @@ selectFromPresets: "プリセットから選ぶ"
achievements: "実績"
gotInvalidResponseError: "サーバー黙っとるわ、知らんけど"
gotInvalidResponseErrorDescription: "サーバーいま日曜日。またきて月曜日。"
+thisPostMayBeAnnoying: "この投稿は迷惑かもしらんで。"
+collapseRenotes: "見たことあるRenoteは省略やで"
_achievements:
earnedAt: "貰った日ぃ"
_types:
diff --git a/locales/ko-KR.yml b/locales/ko-KR.yml
index 2def444095..5096a5d316 100644
--- a/locales/ko-KR.yml
+++ b/locales/ko-KR.yml
@@ -129,6 +129,7 @@ unblockConfirm: "이 계정의 차단을 해제하시겠습니까?"
suspendConfirm: "이 계정을 정지하시겠습니까?"
unsuspendConfirm: "이 계정의 정지를 해제하시겠습니까?"
selectList: "리스트 선택"
+selectChannel: "채널 선택"
selectAntenna: "안테나 선택"
selectWidget: "위젯 선택"
editWidgets: "위젯 편집"
@@ -256,6 +257,8 @@ noMoreHistory: "이것보다 과거의 기록이 없습니다"
startMessaging: "대화 시작하기"
nUsersRead: "{n}명이 읽음"
agreeTo: "{0}에 동의"
+agreeBelow: "아래 내용에 동의합니다"
+basicNotesBeforeCreateAccount: "기본적인 주의사항"
tos: "이용 약관"
start: "시작하기"
home: "홈"
@@ -464,6 +467,8 @@ youHaveNoGroups: "그룹이 없습니다"
joinOrCreateGroup: "다른 그룹의 초대를 받거나, 직접 새 그룹을 만들어 보세요."
noHistory: "기록이 없습니다"
signinHistory: "로그인 기록"
+enableAdvancedMfm: "고급 MFM을 활성화"
+enableAnimatedMfm: "움직임이 있는 MFM을 활성화"
doing: "잠시만요"
category: "카테고리"
tags: "태그"
@@ -860,6 +865,8 @@ failedToFetchAccountInformation: "계정 정보를 가져오지 못했습니다"
rateLimitExceeded: "요청 제한 횟수를 초과하였습니다"
cropImage: "이미지 자르기"
cropImageAsk: "이미지를 자르시겠습니까?"
+cropYes: "잘라내기"
+cropNo: "그대로 사용"
file: "파일"
recentNHours: "최근 {n}시간"
recentNDays: "최근 {n}일"
@@ -938,6 +945,12 @@ cannotPerformTemporaryDescription: "조작 횟수 제한을 초과하여 일시
preset: "프리셋"
selectFromPresets: "프리셋에서 선택"
achievements: "도전 과제"
+gotInvalidResponseError: "서버의 응답이 올바르지 않습니다"
+gotInvalidResponseErrorDescription: " 서버가 다운되었거나 점검중일 가능성이 있습니다. 잠시후에 다시 시도해 주십시오."
+thisPostMayBeAnnoying: "이 게시물은 다른 유저에게 피해를 줄 가능성이 있습니다."
+thisPostMayBeAnnoyingHome: "홈에 게시"
+thisPostMayBeAnnoyingCancel: "그만두기"
+thisPostMayBeAnnoyingIgnore: "이대로 게시"
_achievements:
earnedAt: "달성 일시"
_types:
@@ -1194,6 +1207,9 @@ _role:
baseRole: "기본 역할"
useBaseValue: "기본값 사용"
chooseRoleToAssign: "할당할 역할 선택"
+ iconUrl: "아이콘 URL"
+ asBadge: "뱃지로 표시"
+ descriptionOfAsBadge: "활성화하면 유저명 옆에 역할의 아이콘이 표시됩니다."
canEditMembersByModerator: "모더레이터의 역할 수정 허용"
descriptionOfCanEditMembersByModerator: "이 옵션을 켜면 모더레이터도 이 역할에 사용자를 할당하거나 삭제할 수 있습니다. 꺼져 있으면 관리자만 할당이 가능합니다."
priority: "우선순위"
@@ -1523,12 +1539,15 @@ _permissions:
"read:gallery-likes": "갤러리의 좋아요를 확인합니다"
"write:gallery-likes": "갤러리에 좋아요를 추가하거나 취소합니다"
_auth:
+ shareAccessTitle: "어플리케이션의 접근 허가"
shareAccess: "\"{name}\" 이 계정에 접근하는 것을 허용하시겠습니까?"
shareAccessAsk: "이 애플리케이션이 계정에 접근하는 것을 허용하시겠습니까?"
+ permission: "{name}에서 다음 권한을 요청하였습니다"
permissionAsk: "이 앱은 다음의 권한을 요청합니다"
pleaseGoBack: "앱으로 돌아가서 시도해 주세요"
callback: "앱으로 돌아갑니다"
denied: "접근이 거부되었습니다"
+ pleaseLogin: "어플리케이션의 접근을 허가하려면 로그인하십시오."
_antennaSources:
all: "모든 노트"
homeTimeline: "팔로우중인 유저의 노트"
diff --git a/locales/uk-UA.yml b/locales/uk-UA.yml
index 2305fcab1b..27882e50f6 100644
--- a/locales/uk-UA.yml
+++ b/locales/uk-UA.yml
@@ -166,7 +166,7 @@ recipient: "Отримувач"
annotation: "Коментарі"
federation: "Федіверс"
instances: "Інстанс"
-registeredAt: "Приєднався(лась)"
+registeredAt: "Реєстрація"
latestRequestReceivedAt: "Останній запит прийнято"
latestStatus: "Останній статус"
storageUsage: "Використання простору"
@@ -263,7 +263,7 @@ activity: "Активність"
images: "Зображення"
birthday: "День народження"
yearsOld: "{age} років"
-registeredDate: "Приєднався(лась)"
+registeredDate: "Приєднання"
location: "Локація"
theme: "Тема"
themeForLightMode: "Світла тема"
@@ -1086,6 +1086,9 @@ _achievements:
_outputHelloWorldOnScratchpad:
title: "Hello, world!"
description: "Вивести \"hello world\" у Скретчпаді"
+ _reactWithoutRead:
+ title: "Прочитали як слід?"
+ description: "Реакція на нотатку, що містить понад 100 символів, протягом 3 секунд після її публікації"
_clickedClickHere:
title: "Натисніть тут"
description: "Натиснуто тут"
diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml
index 16b824b975..71ca55d9e5 100644
--- a/locales/zh-CN.yml
+++ b/locales/zh-CN.yml
@@ -467,6 +467,8 @@ youHaveNoGroups: "没有群组"
joinOrCreateGroup: "请加入一个现有的群组,或者创建新群组。"
noHistory: "没有历史记录"
signinHistory: "登录历史"
+enableAdvancedMfm: "启用扩展MFM"
+enableAnimatedMfm: "启用MFM动画"
doing: "正在进行"
category: "类别"
tags: "标签"
@@ -945,6 +947,10 @@ selectFromPresets: "從預設值中選擇"
achievements: "成就"
gotInvalidResponseError: "服务器无应答"
gotInvalidResponseErrorDescription: "您的网络连接可能出现了问题, 或是远程服务器暂时不可用. 请稍后重试。"
+thisPostMayBeAnnoying: "这个帖子可能会让其他人感到困扰。"
+thisPostMayBeAnnoyingHome: "发到首页"
+thisPostMayBeAnnoyingCancel: "取消"
+thisPostMayBeAnnoyingIgnore: "就这样发布"
_achievements:
earnedAt: "达成时间"
_types:
diff --git a/locales/zh-TW.yml b/locales/zh-TW.yml
index ac0e38cf0c..f99ca91e63 100644
--- a/locales/zh-TW.yml
+++ b/locales/zh-TW.yml
@@ -467,6 +467,8 @@ youHaveNoGroups: "找不到群組"
joinOrCreateGroup: "請加入現有群組,或創建新群組。"
noHistory: "沒有歷史紀錄"
signinHistory: "登入歷史"
+enableAdvancedMfm: "啟用高級MFM"
+enableAnimatedMfm: "啟用MFM動畫"
doing: "正在進行"
category: "類別"
tags: "標籤"
@@ -945,6 +947,11 @@ selectFromPresets: "從預設值中選擇"
achievements: "成就"
gotInvalidResponseError: "伺服器的回應無效"
gotInvalidResponseErrorDescription: "伺服器可能已關閉或者在維護中,請稍後再試。"
+thisPostMayBeAnnoying: "這篇貼文可能會造成別人的困擾。"
+thisPostMayBeAnnoyingHome: "發布到首頁"
+thisPostMayBeAnnoyingCancel: "退出"
+thisPostMayBeAnnoyingIgnore: "直接發布貼文"
+collapseRenotes: "省略顯示已看過的轉發貼文"
_achievements:
earnedAt: "獲得日期"
_types:
diff --git a/package.json b/package.json
index 7cc3f6cf5c..5a6b807d66 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "misskey",
- "version": "13.6.0",
+ "version": "13.6.1",
"codename": "nasubi",
"repository": {
"type": "git",
diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts
index aa98ef1d22..73f45e92e1 100644
--- a/packages/backend/src/config.ts
+++ b/packages/backend/src/config.ts
@@ -67,6 +67,7 @@ export type Source = {
mediaProxy?: string;
proxyRemoteFiles?: boolean;
+ videoThumbnailGenerator?: string;
signToActivityPubGet?: boolean;
};
@@ -89,6 +90,7 @@ export type Mixin = {
clientManifestExists: boolean;
mediaProxy: string;
externalMediaProxyEnabled: boolean;
+ videoThumbnailGenerator: string | null;
};
export type Config = Source & Mixin;
@@ -144,6 +146,10 @@ export function loadConfig() {
mixin.mediaProxy = 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;
return Object.assign(config, mixin);
diff --git a/packages/backend/src/core/DriveService.ts b/packages/backend/src/core/DriveService.ts
index 598a457e83..42a430ea75 100644
--- a/packages/backend/src/core/DriveService.ts
+++ b/packages/backend/src/core/DriveService.ts
@@ -250,6 +250,14 @@ export class DriveService {
@bindThis
public async generateAlts(path: string, type: string, generateWeb: boolean) {
if (type.startsWith('video/')) {
+ if (this.config.videoThumbnailGenerator != null) {
+ // videoThumbnailGeneratorが指定されていたら動画サムネイル生成はスキップ
+ return {
+ webpublic: null,
+ thumbnail: null,
+ }
+ }
+
try {
const thumbnail = await this.videoProcessingService.generateVideoThumbnail(path);
return {
diff --git a/packages/backend/src/core/UserListService.ts b/packages/backend/src/core/UserListService.ts
index c174394999..bc726a1feb 100644
--- a/packages/backend/src/core/UserListService.ts
+++ b/packages/backend/src/core/UserListService.ts
@@ -14,6 +14,8 @@ import { RoleService } from '@/core/RoleService.js';
@Injectable()
export class UserListService {
+ public static TooManyUsersError = class extends Error {};
+
constructor(
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@@ -36,7 +38,7 @@ export class UserListService {
userListId: list.id,
});
if (currentCount > (await this.roleService.getUserPolicies(me.id)).userEachUserListsLimit) {
- throw new Error('Too many users');
+ throw new UserListService.TooManyUsersError();
}
await this.userListJoiningsRepository.insert({
diff --git a/packages/backend/src/core/VideoProcessingService.ts b/packages/backend/src/core/VideoProcessingService.ts
index ea5701decc..dd6c51c217 100644
--- a/packages/backend/src/core/VideoProcessingService.ts
+++ b/packages/backend/src/core/VideoProcessingService.ts
@@ -6,6 +6,7 @@ import { ImageProcessingService } from '@/core/ImageProcessingService.js';
import type { IImage } from '@/core/ImageProcessingService.js';
import { createTempDir } from '@/misc/create-temp.js';
import { bindThis } from '@/decorators.js';
+import { appendQuery, query } from '@/misc/prelude/url.js';
@Injectable()
export class VideoProcessingService {
@@ -41,5 +42,18 @@ export class VideoProcessingService {
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,
+ })
+ )
+ }
}
diff --git a/packages/backend/src/core/entities/DriveFileEntityService.ts b/packages/backend/src/core/entities/DriveFileEntityService.ts
index 9dd115d45a..b8550cd73e 100644
--- a/packages/backend/src/core/entities/DriveFileEntityService.ts
+++ b/packages/backend/src/core/entities/DriveFileEntityService.ts
@@ -13,6 +13,7 @@ import { deepClone } from '@/misc/clone.js';
import { UtilityService } from '../UtilityService.js';
import { UserEntityService } from './UserEntityService.js';
import { DriveFolderEntityService } from './DriveFolderEntityService.js';
+import { VideoProcessingService } from '../VideoProcessingService.js';
type PackOptions = {
detail?: boolean,
@@ -43,6 +44,7 @@ export class DriveFileEntityService {
private utilityService: UtilityService,
private driveFolderEntityService: DriveFolderEntityService,
+ private videoProcessingService: VideoProcessingService,
) {
}
@@ -72,40 +74,63 @@ export class DriveFileEntityService {
}
@bindThis
- public getPublicUrl(file: DriveFile, mode? : 'static' | 'avatar'): string | null { // static = thumbnail
- const proxiedUrl = (url: string) => appendQuery(
+ private getProxiedUrl(url: string, mode?: 'static' | 'avatar'): string | null {
+ return appendQuery(
`${this.config.mediaProxy}/${mode ?? 'image'}.webp`,
query({
url,
...(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 (!(mode === 'static' && file.type.startsWith('video'))) {
- return proxiedUrl(file.uri);
- }
+ return this.getProxiedUrl(file.uri, mode);
}
// リモートかつ期限切れはローカルプロキシを試みる
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('/')) { // 古いものはここにオブジェクトストレージキーが入ってるので除外
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;
}
}
const url = file.webpublicUrl ?? file.url;
- if (mode === 'static') {
- return file.thumbnailUrl ?? (isMimeImage(file.type, 'sharp-convertible-image') ? proxiedUrl(url) : null);
- }
if (mode === 'avatar') {
- return proxiedUrl(url);
+ return this.getProxiedUrl(url, 'avatar');
}
return url;
}
@@ -183,7 +208,7 @@ export class DriveFileEntityService {
blurhash: file.blurhash,
properties: opts.self ? file.properties : this.getPublicProperties(file),
url: opts.self ? file.url : this.getPublicUrl(file),
- thumbnailUrl: this.getPublicUrl(file, 'static'),
+ thumbnailUrl: this.getThumbnailUrl(file),
comment: file.comment,
folderId: file.folderId,
folder: opts.detail && file.folderId ? this.driveFolderEntityService.pack(file.folderId, {
@@ -218,7 +243,7 @@ export class DriveFileEntityService {
blurhash: file.blurhash,
properties: opts.self ? file.properties : this.getPublicProperties(file),
url: opts.self ? file.url : this.getPublicUrl(file),
- thumbnailUrl: this.getPublicUrl(file, 'static'),
+ thumbnailUrl: this.getThumbnailUrl(file),
comment: file.comment,
folderId: file.folderId,
folder: opts.detail && file.folderId ? this.driveFolderEntityService.pack(file.folderId, {
diff --git a/packages/backend/src/server/ActivityPubServerService.ts b/packages/backend/src/server/ActivityPubServerService.ts
index 186d3822d8..5480395eeb 100644
--- a/packages/backend/src/server/ActivityPubServerService.ts
+++ b/packages/backend/src/server/ActivityPubServerService.ts
@@ -441,6 +441,14 @@ export class ActivityPubServerService {
fastify.addContentTypeParser('application/activity+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
// inbox (limit: 64kb)
fastify.post('/inbox', { bodyLimit: 1024 * 64 }, async (request, reply) => await this.inbox(request, reply));
diff --git a/packages/backend/src/server/FileServerService.ts b/packages/backend/src/server/FileServerService.ts
index 49ded6c28e..f4bc568fdc 100644
--- a/packages/backend/src/server/FileServerService.ts
+++ b/packages/backend/src/server/FileServerService.ts
@@ -150,6 +150,12 @@ export class FileServerService {
file.cleanup();
return await reply.redirect(301, url.toString());
} 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);
}
}
diff --git a/packages/backend/src/server/api/ApiCallService.ts b/packages/backend/src/server/api/ApiCallService.ts
index 395a1c468a..2f3e7a44a9 100644
--- a/packages/backend/src/server/api/ApiCallService.ts
+++ b/packages/backend/src/server/api/ApiCallService.ts
@@ -227,15 +227,17 @@ export class ApiCallService implements OnApplicationShutdown {
// TODO: 毎リクエスト計算するのもあれだしキャッシュしたい
const factor = user ? (await this.roleService.getUserPolicies(user.id)).rateLimitFactor : 1;
- // Rate limit
- await this.rateLimiterService.limit(limit as IEndpointMeta['limit'] & { key: NonNullable<string> }, limitActor, factor).catch(err => {
- throw new ApiError({
- message: 'Rate limit exceeded. Please try again later.',
- code: 'RATE_LIMIT_EXCEEDED',
- id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef',
- httpStatusCode: 429,
+ if (factor > 0) {
+ // Rate limit
+ await this.rateLimiterService.limit(limit as IEndpointMeta['limit'] & { key: NonNullable<string> }, limitActor, factor).catch(err => {
+ throw new ApiError({
+ message: 'Rate limit exceeded. Please try again later.',
+ code: 'RATE_LIMIT_EXCEEDED',
+ id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef',
+ httpStatusCode: 429,
+ });
});
- });
+ }
}
if (ep.meta.requireCredential || ep.meta.requireModerator || ep.meta.requireAdmin) {
diff --git a/packages/backend/src/server/api/endpoints/users/lists/push.ts b/packages/backend/src/server/api/endpoints/users/lists/push.ts
index 3a079ee1ab..1c1fdc23f1 100644
--- a/packages/backend/src/server/api/endpoints/users/lists/push.ts
+++ b/packages/backend/src/server/api/endpoints/users/lists/push.ts
@@ -45,6 +45,12 @@ export const meta = {
code: 'YOU_HAVE_BEEN_BLOCKED',
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;
@@ -110,8 +116,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
throw new ApiError(meta.errors.alreadyAdded);
}
- // Push the user
- await this.userListService.push(user, userList, me);
+ try {
+ await this.userListService.push(user, userList, me);
+ } catch (err) {
+ if (err instanceof UserListService.TooManyUsersError) {
+ throw new ApiError(meta.errors.tooManyUsers);
+ }
+
+ throw err;
+ }
});
}
}
diff --git a/packages/backend/src/server/web/cli.js b/packages/backend/src/server/web/cli.js
index 3dff1d4860..3467f7ac2a 100644
--- a/packages/backend/src/server/web/cli.js
+++ b/packages/backend/src/server/web/cli.js
@@ -11,6 +11,9 @@ window.onload = async () => {
// Send request
fetch(endpoint.indexOf('://') > -1 ? endpoint : `/api/${endpoint}`, {
+ headers: {
+ 'Content-Type': 'application/json'
+ },
method: 'POST',
body: JSON.stringify(data),
credentials: 'omit',
diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue
index e910fbab01..c9c512c36e 100644
--- a/packages/frontend/src/components/MkNote.vue
+++ b/packages/frontend/src/components/MkNote.vue
@@ -78,7 +78,7 @@
</div>
<footer :class="$style.footer">
<MkReactionsViewer :note="appearNote" :max-number="16">
- <template v-slot:more>
+ <template #more>
<button class="_button" :class="$style.reactionDetailsButton" @click="showReactions">
{{ i18n.ts.more }}
</button>
@@ -156,6 +156,7 @@ import { useTooltip } from '@/scripts/use-tooltip';
import { claimAchievement } from '@/scripts/achievements';
import { getNoteSummary } from '@/scripts/get-note-summary';
import { shownNoteIds } from '@/os';
+import { MenuItem } from '@/types/menu';
const props = defineProps<{
note: misskey.entities.Note;
@@ -206,7 +207,7 @@ const translation = ref<any>(null);
const translating = ref(false);
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);
-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);
@@ -247,7 +248,32 @@ useTooltip(renoteButton, async (showing) => {
function renote(viaKeyboard = false) {
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,
icon: 'ti ti-repeat',
action: () => {
@@ -263,7 +289,9 @@ function renote(viaKeyboard = false) {
renote: appearNote,
});
},
- }], renoteButton.value, {
+ }]);
+
+ os.popupMenu(items, renoteButton.value, {
viaKeyboard,
});
}
@@ -704,6 +732,12 @@ function showReactions(): void {
}
}
+@container (max-width: 250px) {
+ .quoteNote {
+ padding: 12px;
+ }
+}
+
.muted {
padding: 8px;
text-align: center;
diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue
index 0da06c4f14..92bdadc562 100644
--- a/packages/frontend/src/components/MkNoteDetailed.vue
+++ b/packages/frontend/src/components/MkNoteDetailed.vue
@@ -160,6 +160,7 @@ import { useNoteCapture } from '@/scripts/use-note-capture';
import { deepClone } from '@/scripts/clone';
import { useTooltip } from '@/scripts/use-tooltip';
import { claimAchievement } from '@/scripts/achievements';
+import { MenuItem } from '@/types/menu';
const props = defineProps<{
note: misskey.entities.Note;
@@ -241,7 +242,32 @@ useTooltip(renoteButton, async (showing) => {
function renote(viaKeyboard = false) {
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,
icon: 'ti ti-repeat',
action: () => {
@@ -257,7 +283,9 @@ function renote(viaKeyboard = false) {
renote: appearNote,
});
},
- }], renoteButton.value, {
+ }]);
+
+ os.popupMenu(items, renoteButton.value, {
viaKeyboard,
});
}
diff --git a/packages/frontend/src/components/MkNoteSimple.vue b/packages/frontend/src/components/MkNoteSimple.vue
index 757b325a06..b38a4afa8b 100644
--- a/packages/frontend/src/components/MkNoteSimple.vue
+++ b/packages/frontend/src/components/MkNoteSimple.vue
@@ -44,8 +44,8 @@ const showContent = $ref(false);
flex-shrink: 0;
display: block;
margin: 0 10px 0 0;
- width: 40px;
- height: 40px;
+ width: 34px;
+ height: 34px;
border-radius: 8px;
}
@@ -72,6 +72,14 @@ const showContent = $ref(false);
padding: 0;
}
+@container (min-width: 250px) {
+ .avatar {
+ margin: 0 10px 0 0;
+ width: 40px;
+ height: 40px;
+ }
+}
+
@container (min-width: 350px) {
.avatar {
margin: 0 10px 0 0;
diff --git a/packages/frontend/src/components/MkPagination.vue b/packages/frontend/src/components/MkPagination.vue
index 04c8616c9a..224a42cdc2 100644
--- a/packages/frontend/src/components/MkPagination.vue
+++ b/packages/frontend/src/components/MkPagination.vue
@@ -21,7 +21,7 @@
<div v-else ref="rootEl">
<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 }}
</MkButton>
<MkLoading v-else class="loading"/>
diff --git a/packages/frontend/src/components/global/MkPageHeader.tabs.vue b/packages/frontend/src/components/global/MkPageHeader.tabs.vue
new file mode 100644
index 0000000000..dae68c7e9c
--- /dev/null
+++ b/packages/frontend/src/components/global/MkPageHeader.tabs.vue
@@ -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 {
+ // ユーザビリティの観点からmousedown時にはonClickは呼ばない
+ 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>
diff --git a/packages/frontend/src/components/global/MkPageHeader.vue b/packages/frontend/src/components/global/MkPageHeader.vue
index 23a39b9ac9..803efb1690 100644
--- a/packages/frontend/src/components/global/MkPageHeader.vue
+++ b/packages/frontend/src/components/global/MkPageHeader.vue
@@ -1,10 +1,10 @@
<template>
<div v-if="show" ref="el" :class="[$style.root]" :style="{ background: bg }">
<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" />
</div>
- <div v-else-if="narrow && !hideTitle" :class="$style.buttonsLeft" />
+ <div v-else-if="!thin_ && narrow && !hideTitle" :class="$style.buttonsLeft" />
<template v-if="metadata">
<div v-if="!hideTitle" :class="$style.titleContainer" @click="top">
@@ -19,63 +19,28 @@
</div>
</div>
</div>
- <div v-if="!narrow || hideTitle" :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 }]" @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>
+ <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"/>
</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">
<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>
</div>
</div>
<div v-if="(narrow && !hideTitle) && hasTabs" :class="[$style.lower, { [$style.slim]: narrow, [$style.thin]: thin_ }]">
- <div :class="$style.tabs" @wheel="onTabWheel">
- <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>
+ <XTabs :class="$style.tabs" :tab="tab" @update:tab="key => emit('update:tab', key)" :tabs="tabs" :root-el="el" @tab-click="onTabClick"/>
</div>
</div>
</template>
<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 { scrollToTop } from '@/scripts/scroll';
import { globalEvents } from '@/events';
import { injectPageMetadata } from '@/scripts/page-metadata';
import { $i, openAccountMenu as openAccountMenu_ } from '@/account';
-
-type Tab = {
- key: string;
- title: string;
- icon?: string;
- iconOnly?: boolean;
- onClick?: (ev: MouseEvent) => void;
-};
+import XTabs, { Tab } from './MkPageHeader.tabs.vue'
const props = withDefaults(defineProps<{
tabs?: Tab[];
@@ -102,8 +67,6 @@ const hideTitle = inject('shouldOmitHeaderTitle', false);
const thin_ = props.thin || inject('shouldHeaderThin', false);
let el = $shallowRef<HTMLElement | undefined>(undefined);
-const tabRefs: Record<string, HTMLElement | null> = {};
-let tabHighlightEl = $shallowRef<HTMLElement | null>(null);
const bg = ref<string | undefined>(undefined);
let narrow = $ref(false);
const hasTabs = $computed(() => props.tabs.length > 0);
@@ -128,25 +91,8 @@ function openAccountMenu(ev: MouseEvent) {
}, ev);
}
-function onTabMousedown(tab: Tab, ev: MouseEvent): void {
- // ユーザビリティの観点からmousedown時にはonClickは呼ばない
- 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);
- }
+function onTabClick(): void {
+ top();
}
const calcBg = () => {
@@ -156,88 +102,26 @@ const calcBg = () => {
bg.value = tinyBg.toRgbString();
};
-let ro1: 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 = '';
-}
+let ro: ResizeObserver | null;
onMounted(() => {
calcBg();
globalEvents.on('themeChanged', calcBg);
- watch([() => props.tab, () => props.tabs], () => {
- nextTick(() => renderTab());
- }, {
- immediate: true,
- });
-
if (el && el.parentElement) {
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)) {
narrow = el.parentElement.offsetWidth < 500;
}
});
- ro1.observe(el.parentElement as HTMLElement);
- }
-
- if (el) {
- ro2 = new ResizeObserver((entries, observer) => {
- if (document.body.contains(el as HTMLElement)) {
- nextTick(() => renderTab());
- }
- });
- ro2.observe(el);
+ ro.observe(el.parentElement as HTMLElement);
}
});
onUnmounted(() => {
globalEvents.off('themeChanged', calcBg);
- if (ro1) ro1.disconnect();
- if (ro2) ro2.disconnect();
+ if (ro) ro.disconnect();
});
</script>
@@ -258,6 +142,7 @@ onUnmounted(() => {
.upper {
--height: 50px;
display: flex;
+ gap: var(--margin);
height: var(--height);
.tabs:first-child {
@@ -267,12 +152,9 @@ onUnmounted(() => {
padding-left: 16px;
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;
}
- .tabs:not(:last-child) {
- margin-right: 0;
- }
&.thin {
--height: 42px;
@@ -286,19 +168,14 @@ onUnmounted(() => {
&.slim {
text-align: center;
+ gap: 0;
+ .tabs:first-child {
+ margin-left: 0;
+ }
> .titleContainer {
- flex: 1;
margin: 0 auto;
max-width: 100%;
-
- > *:first-child {
- margin-left: auto;
- }
-
- > *:last-child {
- margin-right: auto;
- }
}
}
}
@@ -314,8 +191,6 @@ onUnmounted(() => {
align-items: center;
min-width: var(--height);
height: var(--height);
- margin: 0 var(--margin);
-
&:empty {
width: var(--height);
}
@@ -323,12 +198,12 @@ onUnmounted(() => {
.buttonsLeft {
composes: buttons;
- margin-right: auto;
+ margin: 0 var(--margin) 0 0;
}
.buttonsRight {
composes: buttons;
- margin-left: auto;
+ margin: 0 0 0 var(--margin);
}
.avatar {
@@ -373,7 +248,7 @@ onUnmounted(() => {
white-space: nowrap;
text-align: left;
font-weight: bold;
- flex-shrink: 0;
+ flex-shrink: 1;
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>
diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts
index 639f4eaf17..6bff12661f 100644
--- a/packages/frontend/src/os.ts
+++ b/packages/frontend/src/os.ts
@@ -17,6 +17,7 @@ import MkEmojiPickerWindow from '@/components/MkEmojiPickerWindow.vue';
import MkPopupMenu from '@/components/MkPopupMenu.vue';
import MkContextMenu from '@/components/MkContextMenu.vue';
import { MenuItem } from '@/types/menu';
+import copyToClipboard from './scripts/copy-to-clipboard';
export const openingWindowsCount = ref(0);
@@ -26,10 +27,32 @@ export const apiWithDialog = ((
token?: string | null | undefined,
) => {
const promise = api(endpoint, data, token);
- promiseDialog(promise, null, (err) => {
+ promiseDialog(promise, null, async (err) => {
let title = null;
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;
text = i18n.ts.cannotPerformTemporaryDescription;
} else if (err.code.startsWith('TOO_MANY')) {
diff --git a/packages/frontend/src/pages/admin/ads.vue b/packages/frontend/src/pages/admin/ads.vue
index 4d6f32f9a9..701ec31b65 100644
--- a/packages/frontend/src/pages/admin/ads.vue
+++ b/packages/frontend/src/pages/admin/ads.vue
@@ -60,11 +60,17 @@ import { definePageMetadata } from '@/scripts/page-metadata';
let ads: any[] = $ref([]);
+// ISO形式はTZがUTCになってしまうので、TZ分ずらして時間を初期化
+const localTime = new Date();
+const localTimeDiff = localTime.getTimezoneOffset() * 60 * 1000;
+
os.api('admin/ad/list').then(adsResponse => {
ads = adsResponse.map(r => {
+ const date = new Date(r.expiresAt);
+ date.setMilliseconds(date.getMilliseconds() - localTimeDiff);
return {
...r,
- expiresAt: new Date(r.expiresAt).toISOString().slice(0, 16),
+ expiresAt: date.toISOString().slice(0, 16),
};
});
});
diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue
index 086537a94a..d89a68f982 100644
--- a/packages/frontend/src/pages/admin/roles.editor.vue
+++ b/packages/frontend/src/pages/admin/roles.editor.vue
@@ -72,7 +72,7 @@
<MkSwitch v-model="policies.rateLimitFactor.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
</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 #caption>{{ i18n.ts._role._options.descriptionOfRateLimitFactor }}</template>
</MkRange>
diff --git a/packages/frontend/src/pages/settings/general.vue b/packages/frontend/src/pages/settings/general.vue
index b4851df176..1b492b15cf 100644
--- a/packages/frontend/src/pages/settings/general.vue
+++ b/packages/frontend/src/pages/settings/general.vue
@@ -45,6 +45,7 @@
<div class="_gaps_m">
<div class="_gaps_s">
+ <MkSwitch v-model="collapseRenotes">{{ i18n.ts.collapseRenotes }}</MkSwitch>
<MkSwitch v-model="advancedMfm">{{ i18n.ts.enableAdvancedMfm }}</MkSwitch>
<MkSwitch v-if="advancedMfm" v-model="animatedMfm">{{ i18n.ts.enableAnimatedMfm }}</MkSwitch>
<MkSwitch v-model="reduceAnimation">{{ i18n.ts.reduceUiAnimation }}</MkSwitch>
@@ -139,6 +140,7 @@ async function reloadAsk() {
const overridedDeviceKind = computed(defaultStore.makeGetterSetter('overridedDeviceKind'));
const serverDisconnectedBehavior = computed(defaultStore.makeGetterSetter('serverDisconnectedBehavior'));
+const collapseRenotes = computed(defaultStore.makeGetterSetter('collapseRenotes'));
const reduceAnimation = computed(defaultStore.makeGetterSetter('animation', v => !v, v => !v));
const useBlurEffectForModal = computed(defaultStore.makeGetterSetter('useBlurEffectForModal'));
const useBlurEffect = computed(defaultStore.makeGetterSetter('useBlurEffect'));
diff --git a/packages/frontend/src/pages/timeline.vue b/packages/frontend/src/pages/timeline.vue
index 31f4793dc4..a071361150 100644
--- a/packages/frontend/src/pages/timeline.vue
+++ b/packages/frontend/src/pages/timeline.vue
@@ -32,6 +32,7 @@ import { i18n } from '@/i18n';
import { instance } from '@/instance';
import { $i } from '@/account';
import { definePageMetadata } from '@/scripts/page-metadata';
+import type { Tab } from '@/components/global/MkPageHeader.tabs.vue';
provide('shouldOmitHeaderTitle', true);
@@ -57,7 +58,7 @@ function queueUpdated(q: number): void {
}
function top(): void {
- scroll(rootEl, { top: 0 });
+ if (rootEl) scroll(rootEl, { top: 0 });
}
async function chooseList(ev: MouseEvent): Promise<void> {
@@ -150,7 +151,7 @@ const headerTabs = $computed(() => [{
title: i18n.ts.channel,
iconOnly: true,
onClick: chooseChannel,
-}]);
+}] as Tab[]);
const headerTabsWhenNotLogin = $computed(() => [
...(isLocalTimelineAvailable ? [{
@@ -165,7 +166,7 @@ const headerTabsWhenNotLogin = $computed(() => [
icon: 'ti ti-whirl',
iconOnly: true,
}] : []),
-]);
+] as Tab[]);
definePageMetadata(computed(() => ({
title: i18n.ts.timeline,
diff --git a/packages/frontend/src/pages/user/home.vue b/packages/frontend/src/pages/user/home.vue
index fab47d09e2..6d942d4391 100644
--- a/packages/frontend/src/pages/user/home.vue
+++ b/packages/frontend/src/pages/user/home.vue
@@ -75,15 +75,15 @@
</dl>
</div>
<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>
<span>{{ i18n.ts.notes }}</span>
</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>
<span>{{ i18n.ts.following }}</span>
</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>
<span>{{ i18n.ts.followers }}</span>
</MkA>
@@ -100,6 +100,7 @@
<XPhotos :key="user.id" :user="user"/>
<XActivity :key="user.id" :user="user"/>
</template>
+ <XNotes :no-gap="true" :pagination="pagination"/>
</div>
</div>
<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 { dateString } from '@/filters/date';
import { confetti } from '@/scripts/confetti';
+import XNotes from '@/components/MkNotes.vue';
const XPhotos = defineAsyncComponent(() => import('./index.photos.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 bannerEl = $ref<null | HTMLElement>(null);
+const pagination = {
+ endpoint: 'users/notes' as const,
+ limit: 10,
+ params: computed(() => ({
+ userId: props.user.id,
+ })),
+};
+
const style = $computed(() => {
if (props.user.bannerUrl == null) return {};
return {
diff --git a/packages/frontend/src/pages/user/index.vue b/packages/frontend/src/pages/user/index.vue
index 84909f72c2..29aef21859 100644
--- a/packages/frontend/src/pages/user/index.vue
+++ b/packages/frontend/src/pages/user/index.vue
@@ -6,6 +6,7 @@
<div v-if="user">
<XHome v-if="tab === 'home'" :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"/>
<XReactions v-else-if="tab === 'reactions'" :user="user"/>
<XClips v-else-if="tab === 'clips'" :user="user"/>
@@ -20,13 +21,10 @@
</template>
<script lang="ts" setup>
-import { defineAsyncComponent, computed, inject, onMounted, onUnmounted, watch } from 'vue';
-import calcAge from 's-age';
+import { defineAsyncComponent, computed, watch } from 'vue';
import * as Acct from 'misskey-js/built/acct';
import * as misskey from 'misskey-js';
-import { getScrollPosition } from '@/scripts/scroll';
-import number from '@/filters/number';
-import { userPage, acct as getAcct } from '@/filters/user';
+import { acct as getAcct } from '@/filters/user';
import * as os from '@/os';
import { useRouter } from '@/router';
import { definePageMetadata } from '@/scripts/page-metadata';
@@ -49,8 +47,6 @@ const props = withDefaults(defineProps<{
page: 'home',
});
-const router = useRouter();
-
let tab = $ref(props.page);
let user = $ref<null | misskey.entities.UserDetailed>(null);
let error = $ref(null);
diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts
index 80bd22a813..46e55900cd 100644
--- a/packages/frontend/src/store.ts
+++ b/packages/frontend/src/store.ts
@@ -46,6 +46,10 @@ export const defaultStore = markRaw(new Storage('base', {
where: 'account',
default: false,
},
+ collapseRenotes: {
+ where: 'account',
+ default: true,
+ },
rememberNoteVisibility: {
where: 'account',
default: false,