Merge branch 'develop' into pag-back

This commit is contained in:
tamaina 2023-07-21 11:02:18 +00:00
commit 20ae59756f
49 changed files with 2561 additions and 1549 deletions

View file

@ -82,6 +82,8 @@ redis:
#pass: example-pass #pass: example-pass
#prefix: example-prefix #prefix: example-prefix
#db: 1 #db: 1
# You can specify more ioredis options...
#username: example-username
#redisForPubsub: #redisForPubsub:
# host: localhost # host: localhost
@ -90,6 +92,8 @@ redis:
# #pass: example-pass # #pass: example-pass
# #prefix: example-prefix # #prefix: example-prefix
# #db: 1 # #db: 1
# # You can specify more ioredis options...
# #username: example-username
#redisForJobQueue: #redisForJobQueue:
# host: localhost # host: localhost
@ -98,6 +102,8 @@ redis:
# #pass: example-pass # #pass: example-pass
# #prefix: example-prefix # #prefix: example-prefix
# #db: 1 # #db: 1
# # You can specify more ioredis options...
# #username: example-username
# ┌───────────────────────────┐ # ┌───────────────────────────┐
#───┘ MeiliSearch configuration └───────────────────────────── #───┘ MeiliSearch configuration └─────────────────────────────

View file

@ -15,17 +15,19 @@
## 13.x.x (unreleased) ## 13.x.x (unreleased)
### General ### General
- identicon生成を無効にしてパフォーマンスを向上させることができるようになりました
- サーバーのマシン情報の公開を無効にしてパフォーマンスを向上させることができるようになりました
- 招待機能を改善しました - 招待機能を改善しました
* 過去に発行した招待コードを確認できるようになりました * 過去に発行した招待コードを確認できるようになりました
* ロールごとに招待コードの発行数制限と制限対象期間、有効期限を設定できるようになりました * ロールごとに招待コードの発行数制限と制限対象期間、有効期限を設定できるようになりました
* 招待コードを作成したユーザーと使用したユーザーを確認できるようになりました * 招待コードを作成したユーザーと使用したユーザーを確認できるようになりました
- ユーザーにロールが期限付きでアサインされている場合、その期限をユーザーのモデレーションページで確認できるようになりました
- identicon生成を無効にしてパフォーマンスを向上させることができるようになりました
- サーバーのマシン情報の公開を無効にしてパフォーマンスを向上させることができるようになりました
### Client ### Client
- deck UIのカラムのメニューからアンテナとリストの編集画面を開けるように - deck UIのカラムのメニューからアンテナとリストの編集画面を開けるように
- ドライブファイルのメニューで画像をクロップできるように - ドライブファイルのメニューで画像をクロップできるように
- 画像を動画と同様に簡単に隠せるように - 画像を動画と同様に簡単に隠せるように
- Enhance: ノートの埋め込みが複数画像と動画を表示されるように
- オリジナル画像を保持せずにアップロードする場合webpでアップロードされるように(Safari以外) - オリジナル画像を保持せずにアップロードする場合webpでアップロードされるように(Safari以外)
- 見たことのあるRenoteを省略して表示をオンのときに自分のnoteのrenoteを省略するように - 見たことのあるRenoteを省略して表示をオンのときに自分のnoteのrenoteを省略するように
- フォルダーやファイルに対しても開発者モード使用時、IDをコピーできるように - フォルダーやファイルに対しても開発者モード使用時、IDをコピーできるように
@ -41,7 +43,9 @@
- ロール設定画面でロールIDを確認できるように - ロール設定画面でロールIDを確認できるように
- コンテキストメニュー表示時のパフォーマンスを改善 - コンテキストメニュー表示時のパフォーマンスを改善
- フォロー/フォロワー非公開時の表示を改善 - フォロー/フォロワー非公開時の表示を改善
- AiScriptを0.14.0に更新 - 本文にMFMが含まれている場合に自動でたたまれる機能が、返信先や引用RNにも適用されるように
- position は対象外になりました
- AiScriptを0.15.0に更新
- Fix: サーバーメトリクスが90度傾いている - Fix: サーバーメトリクスが90度傾いている
- Fix: 非ログイン時にクレデンシャルが必要なページに行くとエラーが出る問題を修正 - Fix: 非ログイン時にクレデンシャルが必要なページに行くとエラーが出る問題を修正
- Fix: sparkle内にリンクを入れるとクリック不能になる問題の修正 - Fix: sparkle内にリンクを入れるとクリック不能になる問題の修正
@ -58,15 +62,19 @@
- nsfwjs のモデルロードを排他することで、重複ロードによってメモリ使用量が増加しないように - nsfwjs のモデルロードを排他することで、重複ロードによってメモリ使用量が増加しないように
- 連合の配送ジョブのパフォーマンスを向上ロック機構の見直し、Redisキャッシュの活用 - 連合の配送ジョブのパフォーマンスを向上ロック機構の見直し、Redisキャッシュの活用
- featuredートのsignedGet回数を減らしました - featuredートのsignedGet回数を減らしました
- リモートサーバーからのNSFW映像のキャッシュだけを無効化できるオプションを追加 - ActivityPubの署名用鍵長を2048bitに変更しパフォーマンスを向上(新規アカウントのみ)
- リモートサーバーのセンシティブなファイルのキャッシュだけを無効化できるオプションを追加
- MeilisearchにIndexするートの範囲を設定できるように - MeilisearchにIndexするートの範囲を設定できるように
- Export notes with file detail - Export notes with file detail
- Add unix socket support - Add unix socket support
- 設定ファイルでioredisの全てのオプションを指定可能に
- Fix: エクスポートしたカスタム絵文字のzipが大きいと読み込めない問題を修正
- Fix: リモートサーバーに無意味なActivityPubの配信を行うことがあるのを修正 - Fix: リモートサーバーに無意味なActivityPubの配信を行うことがあるのを修正
- Fix: Remove Meilisearch index when notes are deleted - Fix: Remove Meilisearch index when notes are deleted
- Fix: 非英語環境でのPostgreSQLのエラーハンドリングを修正 - Fix: 非英語環境でのPostgreSQLのエラーハンドリングを修正
- Fix: インスタンスのアイコンがbase64の場合の挙動を修正 - Fix: インスタンスのアイコンがbase64の場合の挙動を修正
- Fix: ローカルの `Person` を指す `acct` URI を解析するときのバグを修正しました - Fix: ローカルの `Person` を指す `acct` URI を解析するときのバグを修正しました
- Fix: 無効化されたアンテナが再度有効化されないことがある問題を修正
## 13.13.2 ## 13.13.2

View file

@ -214,30 +214,13 @@ Misskey uses [Storybook](https://storybook.js.org/) for UI development.
### Setup & Run ### Setup & Run
#### Universal #### Setup
##### Setup
```bash
pnpm --filter misskey-js build
pnpm --filter frontend tsc -p .storybook && (node packages/frontend/.storybook/preload-locale.js & node packages/frontend/.storybook/preload-theme.js)
```
##### Run
```bash
node packages/frontend/.storybook/generate.js && pnpm --filter frontend storybook dev
```
#### macOS & Linux
##### Setup
```bash ```bash
pnpm --filter misskey-js build pnpm --filter misskey-js build
``` ```
##### Run #### Run
```bash ```bash
pnpm --filter frontend storybook-dev pnpm --filter frontend storybook-dev

View file

@ -54,6 +54,7 @@ describe('After setup instance', () => {
cy.get('[data-cy-signup]').click(); cy.get('[data-cy-signup]').click();
cy.get('[data-cy-signup-rules-continue]').should('be.disabled'); cy.get('[data-cy-signup-rules-continue]').should('be.disabled');
cy.get('[data-cy-signup-rules-notes-agree] [data-cy-switch-toggle]').click(); cy.get('[data-cy-signup-rules-notes-agree] [data-cy-switch-toggle]').click();
cy.get('[data-cy-modal-dialog-ok]').click();
cy.get('[data-cy-signup-rules-continue]').should('not.be.disabled'); cy.get('[data-cy-signup-rules-continue]').should('not.be.disabled');
cy.get('[data-cy-signup-rules-continue]').click(); cy.get('[data-cy-signup-rules-continue]').click();
@ -78,6 +79,7 @@ describe('After setup instance', () => {
cy.get('[data-cy-signup]').click(); cy.get('[data-cy-signup]').click();
cy.get('[data-cy-signup-rules-continue]').should('be.disabled'); cy.get('[data-cy-signup-rules-continue]').should('be.disabled');
cy.get('[data-cy-signup-rules-notes-agree] [data-cy-switch-toggle]').click(); cy.get('[data-cy-signup-rules-notes-agree] [data-cy-switch-toggle]').click();
cy.get('[data-cy-modal-dialog-ok]').click();
cy.get('[data-cy-signup-rules-continue]').should('not.be.disabled'); cy.get('[data-cy-signup-rules-continue]').should('not.be.disabled');
cy.get('[data-cy-signup-rules-continue]').click(); cy.get('[data-cy-signup-rules-continue]').click();

View file

@ -1091,6 +1091,9 @@ usedAt: "Benutzt am"
unused: "Unbenutzt" unused: "Unbenutzt"
used: "Benutzt" used: "Benutzt"
expired: "Abgelaufen" expired: "Abgelaufen"
doYouAgree: "Zustimmen?"
beSureToReadThisAsItIsImportant: "Lies bitte diese wichtige Informationen."
iHaveReadXCarefullyAndAgree: "Ich habe den Text \"{x}\" gelesen und stimme zu."
_initialAccountSetting: _initialAccountSetting:
accountCreated: "Dein Konto wurde erfolgreich erstellt!" accountCreated: "Dein Konto wurde erfolgreich erstellt!"
letsStartAccountSetup: "Lass uns nun dein Konto einrichten." letsStartAccountSetup: "Lass uns nun dein Konto einrichten."

View file

@ -1091,6 +1091,9 @@ usedAt: "Used at"
unused: "Unused" unused: "Unused"
used: "Used" used: "Used"
expired: "Expired" expired: "Expired"
doYouAgree: "Agree?"
beSureToReadThisAsItIsImportant: "Please read this important information."
iHaveReadXCarefullyAndAgree: "I have read the text \"{x}\" and agree."
_initialAccountSetting: _initialAccountSetting:
accountCreated: "Your account was successfully created!" accountCreated: "Your account was successfully created!"
letsStartAccountSetup: "For starters, let's set up your profile." letsStartAccountSetup: "For starters, let's set up your profile."

3
locales/index.d.ts vendored
View file

@ -1094,6 +1094,9 @@ export interface Locale {
"unused": string; "unused": string;
"used": string; "used": string;
"expired": string; "expired": string;
"doYouAgree": string;
"beSureToReadThisAsItIsImportant": string;
"iHaveReadXCarefullyAndAgree": string;
"_initialAccountSetting": { "_initialAccountSetting": {
"accountCreated": string; "accountCreated": string;
"letsStartAccountSetup": string; "letsStartAccountSetup": string;

View file

@ -1042,7 +1042,7 @@ vertical: "縦"
horizontal: "横" horizontal: "横"
position: "位置" position: "位置"
serverRules: "サーバールール" serverRules: "サーバールール"
pleaseConfirmBelowBeforeSignup: "このサーバーに登録する前に、以下を確認してください。" pleaseConfirmBelowBeforeSignup: "このサーバーに登録するには、以下の内容を確認し同意する必要があります。"
pleaseAgreeAllToContinue: "続けるには、全ての「同意する」にチェックが入っている必要があります。" pleaseAgreeAllToContinue: "続けるには、全ての「同意する」にチェックが入っている必要があります。"
continue: "続ける" continue: "続ける"
preservedUsernames: "予約ユーザー名" preservedUsernames: "予約ユーザー名"
@ -1091,6 +1091,9 @@ usedAt: "使用日時"
unused: "未使用" unused: "未使用"
used: "使用済み" used: "使用済み"
expired: "期限切れ" expired: "期限切れ"
doYouAgree: "同意しますか?"
beSureToReadThisAsItIsImportant: "重要ですので必ずお読みください。"
iHaveReadXCarefullyAndAgree: "「{x}」の内容をよく読み、同意します。"
_initialAccountSetting: _initialAccountSetting:
accountCreated: "アカウントの作成が完了しました!" accountCreated: "アカウントの作成が完了しました!"

View file

@ -1067,6 +1067,9 @@ branding: "あ"
enableServerMachineStats: "サーバーのマシン情報見せびらかすで" enableServerMachineStats: "サーバーのマシン情報見せびらかすで"
enableIdenticonGeneration: "ユーザーごとのIdenticon生成を有効にする" enableIdenticonGeneration: "ユーザーごとのIdenticon生成を有効にする"
turnOffToImprovePerformance: "オフにしたらえらい軽うなるで。" turnOffToImprovePerformance: "オフにしたらえらい軽うなるで。"
inviteCodeCreated: "招待コード作ったで"
inviteLimitExceeded: "招待コード作りすぎやで。"
createLimitRemaining: "作成できる招待コード: 残り {limit} 個やで"
unused: "つこてへん" unused: "つこてへん"
used: "もうつこてる" used: "もうつこてる"
_initialAccountSetting: _initialAccountSetting:

View file

@ -40,7 +40,7 @@ favorites: "즐겨찾기"
unfavorite: "즐겨찾기에서 제거" unfavorite: "즐겨찾기에서 제거"
favorited: "즐겨찾기에 등록했습니다" favorited: "즐겨찾기에 등록했습니다"
alreadyFavorited: "이미 즐겨찾기에 등록되어 있습니다" alreadyFavorited: "이미 즐겨찾기에 등록되어 있습니다"
cantFavorite: "즐겨찾기에 등록하지 못했습니다." cantFavorite: "즐겨찾기에 등록하지 못했습니다"
pin: "프로필에 고정" pin: "프로필에 고정"
unpin: "프로필에서 고정 해제" unpin: "프로필에서 고정 해제"
copyContent: "내용 복사" copyContent: "내용 복사"
@ -108,7 +108,7 @@ renote: "리노트"
unrenote: "리노트 취소" unrenote: "리노트 취소"
renoted: "리노트했습니다" renoted: "리노트했습니다"
cantRenote: "이 게시물은 리노트 할 수 없습니다." cantRenote: "이 게시물은 리노트 할 수 없습니다."
cantReRenote: "리노트를 리노트 할 수 없습니다." cantReRenote: "리노트를 리노트할 수 없습니다."
quote: "인용" quote: "인용"
inChannelRenote: "채널 내 리노트" inChannelRenote: "채널 내 리노트"
inChannelQuote: "채널 내 인용" inChannelQuote: "채널 내 인용"
@ -116,7 +116,7 @@ pinnedNote: "고정해놓은 노트"
pinned: "프로필에 고정" pinned: "프로필에 고정"
you: "당신" you: "당신"
clickToShow: "클릭하여 보기" clickToShow: "클릭하여 보기"
sensitive: "열람주의" sensitive: "열람 주의"
add: "추가" add: "추가"
reaction: "리액션" reaction: "리액션"
reactions: "리액션" reactions: "리액션"
@ -161,7 +161,7 @@ cacheRemoteSensitiveFilesDescription: "이 설정을 비활성화하면 리모
flagAsBot: "나는 봇입니다" flagAsBot: "나는 봇입니다"
flagAsBotDescription: "이 계정을 자동화된 수단으로 운용할 경우에 활성화해 주세요. 이 플래그를 활성화하면, 다른 봇이 이를 참고하여 봇 끼리의 무한 연쇄 반응을 회피하거나, 이 계정의 시스템 상에서의 취급이 Bot 운영에 최적화되는 등의 변화가 생깁니다." flagAsBotDescription: "이 계정을 자동화된 수단으로 운용할 경우에 활성화해 주세요. 이 플래그를 활성화하면, 다른 봇이 이를 참고하여 봇 끼리의 무한 연쇄 반응을 회피하거나, 이 계정의 시스템 상에서의 취급이 Bot 운영에 최적화되는 등의 변화가 생깁니다."
flagAsCat: "나는 고양이다냥" flagAsCat: "나는 고양이다냥"
flagAsCatDescription: "이 계정이 고양이라면 활성화 해주세요." flagAsCatDescription: "이 계정이 고양이라면 활성화 주세요."
flagShowTimelineReplies: "타임라인에 노트의 답글을 표시하기" flagShowTimelineReplies: "타임라인에 노트의 답글을 표시하기"
flagShowTimelineRepliesDescription: "이 설정을 활성화하면 타임라인에 다른 유저 간의 답글을 표시합니다." flagShowTimelineRepliesDescription: "이 설정을 활성화하면 타임라인에 다른 유저 간의 답글을 표시합니다."
autoAcceptFollowed: "팔로우 중인 유저로부터의 팔로우 요청을 자동 수락" autoAcceptFollowed: "팔로우 중인 유저로부터의 팔로우 요청을 자동 수락"
@ -207,7 +207,7 @@ instanceInfo: "서버 정보"
statistics: "통계" statistics: "통계"
clearQueue: "대기열 비우기" clearQueue: "대기열 비우기"
clearQueueConfirmTitle: "대기열을 비우시겠습니까?" clearQueueConfirmTitle: "대기열을 비우시겠습니까?"
clearQueueConfirmText: "대기열에 남아 있는 노트는 더이상 연합되지 않습니다. 보통의 경우 이 작업은 필요하지 않습니다." clearQueueConfirmText: "대기열에 남아 있는 노트는 더 이상 연합되지 않습니다. 보통의 경우 이 작업은 필요하지 않습니다."
clearCachedFiles: "캐시 비우기" clearCachedFiles: "캐시 비우기"
clearCachedFilesConfirm: "캐시된 리모트 파일을 모두 삭제하시겠습니까?" clearCachedFilesConfirm: "캐시된 리모트 파일을 모두 삭제하시겠습니까?"
blockedInstances: "차단된 서버" blockedInstances: "차단된 서버"

File diff suppressed because it is too large Load diff

View file

@ -1,12 +1,12 @@
{ {
"name": "misskey", "name": "misskey",
"version": "13.14.0-beta.6", "version": "13.14.0-beta.7",
"codename": "nasubi", "codename": "nasubi",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/misskey-dev/misskey.git" "url": "https://github.com/misskey-dev/misskey.git"
}, },
"packageManager": "pnpm@8.6.0", "packageManager": "pnpm@8.6.9",
"workspaces": [ "workspaces": [
"packages/frontend", "packages/frontend",
"packages/backend", "packages/backend",

View file

@ -2,14 +2,7 @@ import Redis from 'ioredis';
import { loadConfig } from './built/config.js'; import { loadConfig } from './built/config.js';
const config = loadConfig(); const config = loadConfig();
const redis = new Redis({ const redis = new Redis(config.redis);
port: config.redis.port,
host: config.redis.host,
family: config.redis.family == null ? 0 : config.redis.family,
password: config.redis.pass,
keyPrefix: `${config.redis.prefix}:`,
db: config.redis.db ?? 0,
});
redis.on('connect', () => redis.disconnect()); redis.on('connect', () => redis.disconnect());
redis.on('error', (e) => { redis.on('error', (e) => {

View file

@ -79,7 +79,6 @@
"ajv": "8.12.0", "ajv": "8.12.0",
"archiver": "5.3.1", "archiver": "5.3.1",
"async-mutex": "^0.4.0", "async-mutex": "^0.4.0",
"autwh": "0.1.0",
"bcryptjs": "2.4.3", "bcryptjs": "2.4.3",
"blurhash": "2.0.5", "blurhash": "2.0.5",
"bullmq": "4.4.0", "bullmq": "4.4.0",
@ -93,7 +92,6 @@
"content-disposition": "0.5.4", "content-disposition": "0.5.4",
"date-fns": "2.30.0", "date-fns": "2.30.0",
"deep-email-validator": "0.1.21", "deep-email-validator": "0.1.21",
"escape-regexp": "0.0.1",
"fastify": "4.20.0", "fastify": "4.20.0",
"feed": "4.2.2", "feed": "4.2.2",
"file-type": "18.5.0", "file-type": "18.5.0",
@ -139,7 +137,6 @@
"rename": "1.0.4", "rename": "1.0.4",
"rss-parser": "3.13.0", "rss-parser": "3.13.0",
"rxjs": "7.8.1", "rxjs": "7.8.1",
"s-age": "1.1.2",
"sanitize-html": "2.11.0", "sanitize-html": "2.11.0",
"semver": "7.5.4", "semver": "7.5.4",
"sharp": "0.32.3", "sharp": "0.32.3",
@ -157,7 +154,6 @@
"typeorm": "0.3.17", "typeorm": "0.3.17",
"typescript": "5.1.6", "typescript": "5.1.6",
"ulid": "2.3.0", "ulid": "2.3.0",
"unzipper": "0.10.14",
"vary": "1.1.2", "vary": "1.1.2",
"web-push": "3.6.3", "web-push": "3.6.3",
"ws": "8.13.0", "ws": "8.13.0",
@ -172,7 +168,6 @@
"@types/cbor": "6.0.0", "@types/cbor": "6.0.0",
"@types/color-convert": "2.0.0", "@types/color-convert": "2.0.0",
"@types/content-disposition": "0.5.5", "@types/content-disposition": "0.5.5",
"@types/escape-regexp": "0.0.1",
"@types/fluent-ffmpeg": "2.1.21", "@types/fluent-ffmpeg": "2.1.21",
"@types/jest": "29.5.3", "@types/jest": "29.5.3",
"@types/js-yaml": "4.0.5", "@types/js-yaml": "4.0.5",
@ -191,7 +186,6 @@
"@types/qrcode": "1.5.1", "@types/qrcode": "1.5.1",
"@types/random-seed": "0.3.3", "@types/random-seed": "0.3.3",
"@types/ratelimiter": "3.4.4", "@types/ratelimiter": "3.4.4",
"@types/redis": "4.0.11",
"@types/rename": "1.0.4", "@types/rename": "1.0.4",
"@types/sanitize-html": "2.9.0", "@types/sanitize-html": "2.9.0",
"@types/semver": "7.5.0", "@types/semver": "7.5.0",
@ -199,10 +193,8 @@
"@types/sinonjs__fake-timers": "8.1.2", "@types/sinonjs__fake-timers": "8.1.2",
"@types/tinycolor2": "1.4.3", "@types/tinycolor2": "1.4.3",
"@types/tmp": "0.2.3", "@types/tmp": "0.2.3",
"@types/unzipper": "0.10.6",
"@types/vary": "1.1.0", "@types/vary": "1.1.0",
"@types/web-push": "3.3.2", "@types/web-push": "3.3.2",
"@types/websocket": "1.0.5",
"@types/ws": "8.5.5", "@types/ws": "8.5.5",
"@typescript-eslint/eslint-plugin": "5.61.0", "@typescript-eslint/eslint-plugin": "5.61.0",
"@typescript-eslint/parser": "5.61.0", "@typescript-eslint/parser": "5.61.0",

View file

@ -41,14 +41,7 @@ const $meilisearch: Provider = {
const $redis: Provider = { const $redis: Provider = {
provide: DI.redis, provide: DI.redis,
useFactory: (config: Config) => { useFactory: (config: Config) => {
return new Redis.Redis({ return new Redis.Redis(config.redis);
port: config.redis.port,
host: config.redis.host,
family: config.redis.family == null ? 0 : config.redis.family,
password: config.redis.pass,
keyPrefix: `${config.redis.prefix}:`,
db: config.redis.db ?? 0,
});
}, },
inject: [DI.config], inject: [DI.config],
}; };
@ -56,14 +49,7 @@ const $redis: Provider = {
const $redisForPub: Provider = { const $redisForPub: Provider = {
provide: DI.redisForPub, provide: DI.redisForPub,
useFactory: (config: Config) => { useFactory: (config: Config) => {
const redis = new Redis.Redis({ const redis = new Redis.Redis(config.redisForPubsub);
port: config.redisForPubsub.port,
host: config.redisForPubsub.host,
family: config.redisForPubsub.family == null ? 0 : config.redisForPubsub.family,
password: config.redisForPubsub.pass,
keyPrefix: `${config.redisForPubsub.prefix}:`,
db: config.redisForPubsub.db ?? 0,
});
return redis; return redis;
}, },
inject: [DI.config], inject: [DI.config],
@ -72,14 +58,7 @@ const $redisForPub: Provider = {
const $redisForSub: Provider = { const $redisForSub: Provider = {
provide: DI.redisForSub, provide: DI.redisForSub,
useFactory: (config: Config) => { useFactory: (config: Config) => {
const redis = new Redis.Redis({ const redis = new Redis.Redis(config.redisForPubsub);
port: config.redisForPubsub.port,
host: config.redisForPubsub.host,
family: config.redisForPubsub.family == null ? 0 : config.redisForPubsub.family,
password: config.redisForPubsub.pass,
keyPrefix: `${config.redisForPubsub.prefix}:`,
db: config.redisForPubsub.db ?? 0,
});
redis.subscribe(config.host); redis.subscribe(config.host);
return redis; return redis;
}, },

View file

@ -6,6 +6,16 @@ import * as fs from 'node:fs';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path'; import { dirname, resolve } from 'node:path';
import * as yaml from 'js-yaml'; import * as yaml from 'js-yaml';
import type { RedisOptions } from 'ioredis';
type RedisOptionsSource = Partial<RedisOptions> & {
host: string;
port: number;
family?: number;
pass: string;
db?: number;
prefix?: string;
};
/** /**
* *
@ -35,30 +45,9 @@ export type Source = {
user: string; user: string;
pass: string; pass: string;
}[]; }[];
redis: { redis: RedisOptionsSource;
host: string; redisForPubsub?: RedisOptionsSource;
port: number; redisForJobQueue?: RedisOptionsSource;
family?: number;
pass: string;
db?: number;
prefix?: string;
};
redisForPubsub?: {
host: string;
port: number;
family?: number;
pass: string;
db?: number;
prefix?: string;
};
redisForJobQueue?: {
host: string;
port: number;
family?: number;
pass: string;
db?: number;
prefix?: string;
};
meilisearch?: { meilisearch?: {
host: string; host: string;
port: string; port: string;
@ -119,8 +108,9 @@ export type Mixin = {
mediaProxy: string; mediaProxy: string;
externalMediaProxyEnabled: boolean; externalMediaProxyEnabled: boolean;
videoThumbnailGenerator: string | null; videoThumbnailGenerator: string | null;
redisForPubsub: NonNullable<Source['redisForPubsub']>; redis: RedisOptions & RedisOptionsSource;
redisForJobQueue: NonNullable<Source['redisForJobQueue']>; redisForPubsub: RedisOptions & RedisOptionsSource;
redisForJobQueue: RedisOptions & RedisOptionsSource;
}; };
export type Config = Source & Mixin; export type Config = Source & Mixin;
@ -182,9 +172,9 @@ export function loadConfig() {
config.videoThumbnailGenerator.endsWith('/') ? config.videoThumbnailGenerator.substring(0, config.videoThumbnailGenerator.length - 1) : config.videoThumbnailGenerator config.videoThumbnailGenerator.endsWith('/') ? config.videoThumbnailGenerator.substring(0, config.videoThumbnailGenerator.length - 1) : config.videoThumbnailGenerator
: null; : null;
if (!config.redis.prefix) config.redis.prefix = mixin.host; mixin.redis = convertRedisOptions(config.redis, mixin.host);
if (config.redisForPubsub == null) config.redisForPubsub = config.redis; mixin.redisForPubsub = config.redisForPubsub ? convertRedisOptions(config.redisForPubsub, mixin.host) : mixin.redis;
if (config.redisForJobQueue == null) config.redisForJobQueue = config.redis; mixin.redisForJobQueue = config.redisForJobQueue ? convertRedisOptions(config.redisForJobQueue, mixin.host) : mixin.redis;
return Object.assign(config, mixin); return Object.assign(config, mixin);
} }
@ -196,3 +186,14 @@ function tryCreateUrl(url: string) {
throw new Error(`url="${url}" is not a valid URL.`); throw new Error(`url="${url}" is not a valid URL.`);
} }
} }
function convertRedisOptions(options: RedisOptionsSource, host: string): RedisOptions & RedisOptionsSource {
return {
...options,
password: options.pass,
prefix: options.prefix ?? host,
family: options.family == null ? 0 : options.family,
keyPrefix: `${options.prefix ?? host}:`,
db: options.db ?? 0,
};
}

View file

@ -33,7 +33,7 @@ export class CreateSystemUserService {
// Generate secret // Generate secret
const secret = generateNativeUserToken(); const secret = generateNativeUserToken();
const keyPair = await genRsaKeyPair(4096); const keyPair = await genRsaKeyPair();
let account!: User; let account!: User;

View file

@ -108,7 +108,7 @@ export class QueueService {
removeOnFail: true, removeOnFail: true,
}; };
await this.deliverQueue.addBulk(Array.from(inboxes.entries()).map(d => ({ await this.deliverQueue.addBulk(Array.from(inboxes.entries(), d => ({
name: d[0], name: d[0],
data: { data: {
user, user,

View file

@ -220,14 +220,19 @@ export class RoleService implements OnApplicationShutdown {
} }
@bindThis @bindThis
public async getUserRoles(userId: User['id']) { public async getUserAssigns(userId: User['id']) {
const now = Date.now(); const now = Date.now();
let assigns = await this.roleAssignmentByUserIdCache.fetch(userId, () => this.roleAssignmentsRepository.findBy({ userId })); let assigns = await this.roleAssignmentByUserIdCache.fetch(userId, () => this.roleAssignmentsRepository.findBy({ userId }));
// 期限切れのロールを除外 // 期限切れのロールを除外
assigns = assigns.filter(a => a.expiresAt == null || (a.expiresAt.getTime() > now)); assigns = assigns.filter(a => a.expiresAt == null || (a.expiresAt.getTime() > now));
const assignedRoleIds = assigns.map(x => x.roleId); return assigns;
}
@bindThis
public async getUserRoles(userId: User['id']) {
const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({})); const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({}));
const assignedRoles = roles.filter(r => assignedRoleIds.includes(r.id)); const assigns = await this.getUserAssigns(userId);
const assignedRoles = roles.filter(r => assigns.map(x => x.roleId).includes(r.id));
const user = roles.some(r => r.target === 'conditional') ? await this.cacheService.findUserById(userId) : null; const user = roles.some(r => r.target === 'conditional') ? await this.cacheService.findUserById(userId) : null;
const matchedCondRoles = roles.filter(r => r.target === 'conditional' && this.evalCond(user!, r.condFormula)); const matchedCondRoles = roles.filter(r => r.target === 'conditional' && this.evalCond(user!, r.condFormula));
return [...assignedRoles, ...matchedCondRoles]; return [...assignedRoles, ...matchedCondRoles];

View file

@ -92,7 +92,7 @@ export class SignupService {
const keyPair = await new Promise<string[]>((res, rej) => const keyPair = await new Promise<string[]>((res, rej) =>
generateKeyPair('rsa', { generateKeyPair('rsa', {
modulusLength: 4096, modulusLength: 2048,
publicKeyEncoding: { publicKeyEncoding: {
type: 'spki', type: 'spki',
format: 'pem', format: 'pem',

View file

@ -220,6 +220,23 @@ export class ApPersonService implements OnModuleInit {
return null; return null;
} }
private async resolveAvatarAndBanner(user: RemoteUser, icon: any, image: any): Promise<Pick<RemoteUser, 'avatarId' | 'bannerId' | 'avatarUrl' | 'bannerUrl' | 'avatarBlurhash' | 'bannerBlurhash'>> {
const [avatar, banner] = await Promise.all([icon, image].map(img => {
if (img == null) return null;
if (user == null) throw new Error('failed to create user: user is null');
return this.apImageService.resolveImage(user, img).catch(() => null);
}));
return {
avatarId: avatar?.id ?? null,
bannerId: banner?.id ?? null,
avatarUrl: avatar ? this.driveFileEntityService.getPublicUrl(avatar, 'avatar') : null,
bannerUrl: banner ? this.driveFileEntityService.getPublicUrl(banner) : null,
avatarBlurhash: avatar?.blurhash ?? null,
bannerBlurhash: banner?.blurhash ?? null,
};
}
/** /**
* Personを作成します * Personを作成します
*/ */
@ -259,6 +276,16 @@ export class ApPersonService implements OnModuleInit {
// Create user // Create user
let user: RemoteUser | null = null; let user: RemoteUser | null = null;
//#region カスタム絵文字取得
const emojis = await this.apNoteService.extractEmojis(person.tag ?? [], host)
.then(_emojis => _emojis.map(emoji => emoji.name))
.catch(err => {
this.logger.error(`error occured while fetching user emojis`, { stack: err });
return [];
});
//#endregion
try { try {
// Start transaction // Start transaction
await this.db.transaction(async transactionalEntityManager => { await this.db.transaction(async transactionalEntityManager => {
@ -285,6 +312,7 @@ export class ApPersonService implements OnModuleInit {
tags, tags,
isBot, isBot,
isCat: (person as any).isCat === true, isCat: (person as any).isCat === true,
emojis,
})) as RemoteUser; })) as RemoteUser;
await transactionalEntityManager.save(new UserProfile({ await transactionalEntityManager.save(new UserProfile({
@ -321,6 +349,9 @@ export class ApPersonService implements OnModuleInit {
if (user == null) throw new Error('failed to create user: user is null'); if (user == null) throw new Error('failed to create user: user is null');
// Register to the cache
this.cacheService.uriPersonCache.set(user.uri, user);
// Register host // Register host
this.federatedInstanceService.fetch(host).then(async i => { this.federatedInstanceService.fetch(host).then(async i => {
this.instancesRepository.increment({ id: i.id }, 'usersCount', 1); this.instancesRepository.increment({ id: i.id }, 'usersCount', 1);
@ -336,45 +367,16 @@ export class ApPersonService implements OnModuleInit {
this.hashtagService.updateUsertags(user, tags); this.hashtagService.updateUsertags(user, tags);
//#region アバターとヘッダー画像をフェッチ //#region アバターとヘッダー画像をフェッチ
const [avatar, banner] = await Promise.all([person.icon, person.image].map(img => { try {
if (img == null) return null; const updates = await this.resolveAvatarAndBanner(user, person.icon, person.image);
if (user == null) throw new Error('failed to create user: user is null'); await this.usersRepository.update(user.id, updates);
return this.apImageService.resolveImage(user, img).catch(() => null); user = { ...user, ...updates };
}));
const avatarId = avatar?.id ?? null; // Register to the cache
const bannerId = banner?.id ?? null; this.cacheService.uriPersonCache.set(user.uri, user);
const avatarUrl = avatar ? this.driveFileEntityService.getPublicUrl(avatar, 'avatar') : null; } catch (err) {
const bannerUrl = banner ? this.driveFileEntityService.getPublicUrl(banner) : null; this.logger.error('error occured while fetching user avatar/banner', { stack: err });
const avatarBlurhash = avatar?.blurhash ?? null; }
const bannerBlurhash = banner?.blurhash ?? null;
await this.usersRepository.update(user.id, {
avatarId,
bannerId,
avatarUrl,
bannerUrl,
avatarBlurhash,
bannerBlurhash,
});
user.avatarId = avatarId;
user.bannerId = bannerId;
user.avatarUrl = avatarUrl;
user.bannerUrl = bannerUrl;
user.avatarBlurhash = avatarBlurhash;
user.bannerBlurhash = bannerBlurhash;
//#endregion
//#region カスタム絵文字取得
const emojis = await this.apNoteService.extractEmojis(person.tag ?? [], host).catch(err => {
this.logger.info(`extractEmojis: ${err}`);
return [];
});
const emojiNames = emojis.map(emoji => emoji.name);
await this.usersRepository.update(user.id, { emojis: emojiNames });
//#endregion //#endregion
await this.updateFeatured(user.id, resolver).catch(err => this.logger.error(err)); await this.updateFeatured(user.id, resolver).catch(err => this.logger.error(err));
@ -400,7 +402,7 @@ export class ApPersonService implements OnModuleInit {
if (uri.startsWith(`${this.config.url}/`)) return; if (uri.startsWith(`${this.config.url}/`)) return;
//#region このサーバーに既に登録されているか //#region このサーバーに既に登録されているか
const exist = await this.usersRepository.findOneBy({ uri }) as RemoteUser | null; const exist = await this.fetchPerson(uri) as RemoteUser | null;
if (exist === null) return; if (exist === null) return;
//#endregion //#endregion
@ -413,12 +415,6 @@ export class ApPersonService implements OnModuleInit {
this.logger.info(`Updating the Person: ${person.id}`); this.logger.info(`Updating the Person: ${person.id}`);
// アバターとヘッダー画像をフェッチ
const [avatar, banner] = await Promise.all([person.icon, person.image].map(img => {
if (img == null) return null;
return this.apImageService.resolveImage(exist, img).catch(() => null);
}));
// カスタム絵文字取得 // カスタム絵文字取得
const emojis = await this.apNoteService.extractEmojis(person.tag ?? [], exist.host).catch(e => { const emojis = await this.apNoteService.extractEmojis(person.tag ?? [], exist.host).catch(e => {
this.logger.info(`extractEmojis: ${e}`); this.logger.info(`extractEmojis: ${e}`);
@ -454,6 +450,7 @@ export class ApPersonService implements OnModuleInit {
movedToUri: person.movedTo ?? null, movedToUri: person.movedTo ?? null,
alsoKnownAs: person.alsoKnownAs ?? null, alsoKnownAs: person.alsoKnownAs ?? null,
isExplorable: person.discoverable, isExplorable: person.discoverable,
...(await this.resolveAvatarAndBanner(exist, person.icon, person.image).catch(() => ({}))),
} as Partial<RemoteUser> & Pick<RemoteUser, 'isBot' | 'isCat' | 'isLocked' | 'movedToUri' | 'alsoKnownAs' | 'isExplorable'>; } as Partial<RemoteUser> & Pick<RemoteUser, 'isBot' | 'isCat' | 'isLocked' | 'movedToUri' | 'alsoKnownAs' | 'isExplorable'>;
const moving = ((): boolean => { const moving = ((): boolean => {
@ -476,18 +473,6 @@ export class ApPersonService implements OnModuleInit {
if (moving) updates.movedAt = new Date(); if (moving) updates.movedAt = new Date();
if (avatar) {
updates.avatarId = avatar.id;
updates.avatarUrl = this.driveFileEntityService.getPublicUrl(avatar, 'avatar');
updates.avatarBlurhash = avatar.blurhash;
}
if (banner) {
updates.bannerId = banner.id;
updates.bannerUrl = this.driveFileEntityService.getPublicUrl(banner);
updates.bannerBlurhash = banner.blurhash;
}
// Update user // Update user
await this.usersRepository.update(exist.id, updates); await this.usersRepository.update(exist.id, updates);

View file

@ -15,11 +15,8 @@ export const QUEUE = {
export function baseQueueOptions(config: Config, queueName: typeof QUEUE[keyof typeof QUEUE]): Bull.QueueOptions { export function baseQueueOptions(config: Config, queueName: typeof QUEUE[keyof typeof QUEUE]): Bull.QueueOptions {
return { return {
connection: { connection: {
port: config.redisForJobQueue.port, ...config.redisForJobQueue,
host: config.redisForJobQueue.host, keyPrefix: undefined
family: config.redisForJobQueue.family == null ? 0 : config.redisForJobQueue.family,
password: config.redisForJobQueue.pass,
db: config.redisForJobQueue.db ?? 0,
}, },
prefix: config.redisForJobQueue.prefix ? `${config.redisForJobQueue.prefix}:queue:${queueName}` : `queue:${queueName}`, prefix: config.redisForJobQueue.prefix ? `${config.redisForJobQueue.prefix}:queue:${queueName}` : `queue:${queueName}`,
}; };

View file

@ -1,7 +1,7 @@
import * as fs from 'node:fs'; import * as fs from 'node:fs';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { ZipReader } from 'slacc';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import unzipper from 'unzipper';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { EmojisRepository, DriveFilesRepository, UsersRepository } from '@/models/index.js'; import type { EmojisRepository, DriveFilesRepository, UsersRepository } from '@/models/index.js';
import type { Config } from '@/config.js'; import type { Config } from '@/config.js';
@ -72,9 +72,9 @@ export class ImportCustomEmojisProcessorService {
} }
const outputPath = path + '/emojis'; const outputPath = path + '/emojis';
const unzipStream = fs.createReadStream(destPath); try {
const extractor = unzipper.Extract({ path: outputPath }); this.logger.succ(`Unzipping to ${outputPath}`);
extractor.on('close', async () => { ZipReader.withDestinationPath(outputPath).viaBuffer(await fs.promises.readFile(destPath));
const metaRaw = fs.readFileSync(outputPath + '/meta.json', 'utf-8'); const metaRaw = fs.readFileSync(outputPath + '/meta.json', 'utf-8');
const meta = JSON.parse(metaRaw); const meta = JSON.parse(metaRaw);
@ -115,8 +115,12 @@ export class ImportCustomEmojisProcessorService {
cleanup(); cleanup();
this.logger.succ('Imported'); this.logger.succ('Imported');
}); } catch (e) {
unzipStream.pipe(extractor); if (e instanceof Error || typeof e === 'string') {
this.logger.succ(`Unzipping to ${outputPath}`); this.logger.error(e);
}
cleanup();
throw e;
}
} }
} }

View file

@ -4,6 +4,7 @@ import type { DriveFilesRepository } from '@/models/index.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { CustomEmojiService } from '@/core/CustomEmojiService.js'; import { CustomEmojiService } from '@/core/CustomEmojiService.js';
import { ModerationLogService } from '@/core/ModerationLogService.js'; import { ModerationLogService } from '@/core/ModerationLogService.js';
import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js';
import { ApiError } from '../../../error.js'; import { ApiError } from '../../../error.js';
export const meta = { export const meta = {
@ -55,6 +56,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
private customEmojiService: CustomEmojiService, private customEmojiService: CustomEmojiService,
private emojiEntityService: EmojiEntityService,
private moderationLogService: ModerationLogService, private moderationLogService: ModerationLogService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
@ -77,9 +79,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
emojiId: emoji.id, emojiId: emoji.id,
}); });
return { return this.emojiEntityService.packDetailed(emoji);
id: emoji.id,
};
}); });
} }
} }

View file

@ -61,6 +61,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
const signins = await this.signinsRepository.findBy({ userId: user.id }); const signins = await this.signinsRepository.findBy({ userId: user.id });
const roleAssigns = await this.roleService.getUserAssigns(user.id);
const roles = await this.roleService.getUserRoles(user.id); const roles = await this.roleService.getUserRoles(user.id);
return { return {
@ -85,6 +86,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
signins, signins,
policies: await this.roleService.getUserPolicies(user.id), policies: await this.roleService.getUserPolicies(user.id),
roles: await this.roleEntityService.packMany(roles, me), roles: await this.roleEntityService.packMany(roles, me),
roleAssigns: roleAssigns.map(a => ({
createdAt: a.createdAt.toISOString(),
expiresAt: a.expiresAt ? a.expiresAt.toISOString() : null,
roleId: a.roleId,
})),
}; };
}); });
} }

View file

@ -76,6 +76,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
throw new ApiError(meta.errors.noSuchAntenna); throw new ApiError(meta.errors.noSuchAntenna);
} }
this.antennasRepository.update(antenna.id, {
isActive: true,
lastUsedAt: new Date(),
});
const limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1 const limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1
const noteIdsRes = await this.redisClient.xrevrange( const noteIdsRes = await this.redisClient.xrevrange(
`antennaTimeline:${antenna.id}`, `antennaTimeline:${antenna.id}`,
@ -112,11 +117,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
this.noteReadService.read(me.id, notes); this.noteReadService.read(me.id, notes);
} }
this.antennasRepository.update(antenna.id, {
isActive: true,
lastUsedAt: new Date(),
});
return await this.noteEntityService.packMany(notes, me); return await this.noteEntityService.packMany(notes, me);
}); });
} }

View file

@ -112,6 +112,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
withReplies: ps.withReplies, withReplies: ps.withReplies,
withFile: ps.withFile, withFile: ps.withFile,
notify: ps.notify, notify: ps.notify,
isActive: true,
lastUsedAt: new Date(),
}); });
this.globalEventService.publishInternalEvent('antennaUpdated', await this.antennasRepository.findOneByOrFail({ id: antenna.id })); this.globalEventService.publishInternalEvent('antennaUpdated', await this.antennasRepository.findOneByOrFail({ id: antenna.id }));

View file

@ -103,7 +103,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
const procedures = this.twoFactorAuthenticationService.getProcedures(); const procedures = this.twoFactorAuthenticationService.getProcedures();
if (!(procedures as any)[attestation.fmt]) { if (!(procedures as any)[attestation.fmt]) {
throw new Error('unsupported fmt'); throw new Error(`unsupported fmt: ${attestation.fmt}. Supported ones: ${Object.keys(procedures)}`);
} }
const verificationData = (procedures as any)[attestation.fmt].verify({ const verificationData = (procedures as any)[attestation.fmt].verify({

View file

@ -5,8 +5,8 @@ block vars
- const title = user.name ? `${user.name} (@${user.username})` : `@${user.username}`; - const title = user.name ? `${user.name} (@${user.username})` : `@${user.username}`;
- const url = `${config.url}/notes/${note.id}`; - const url = `${config.url}/notes/${note.id}`;
- const isRenote = note.renote && note.text == null && note.fileIds.length == 0 && note.poll == null; - const isRenote = note.renote && note.text == null && note.fileIds.length == 0 && note.poll == null;
- const image = (note.files || []).find(file => file.type.startsWith('image/') && !file.isSensitive) - const images = (note.files || []).filter(file => file.type.startsWith('image/') && !file.isSensitive)
- const video = (note.files || []).find(file => file.type.startsWith('video/') && !file.isSensitive) - const videos = (note.files || []).filter(file => file.type.startsWith('video/') && !file.isSensitive)
block title block title
= `${title} | ${instanceName}` = `${title} | ${instanceName}`
@ -19,15 +19,17 @@ block og
meta(property='og:title' content= title) meta(property='og:title' content= title)
meta(property='og:description' content= summary) meta(property='og:description' content= summary)
meta(property='og:url' content= url) meta(property='og:url' content= url)
if video if videos.length
meta(property='og:video:url' content= video.url) each video in videos
meta(property='og:video:secure_url' content= video.url) meta(property='og:video:url' content= video.url)
meta(property='og:video:type' content= video.type) meta(property='og:video:secure_url' content= video.url)
// FIXME: add width and height meta(property='og:video:type' content= video.type)
// FIXME: add embed player for Twitter // FIXME: add width and height
if image // FIXME: add embed player for Twitter
if images.length
meta(property='twitter:card' content='summary_large_image') meta(property='twitter:card' content='summary_large_image')
meta(property='og:image' content= image.url) each image in images
meta(property='og:image' content= image.url)
else else
meta(property='twitter:card' content='summary') meta(property='twitter:card' content='summary')
meta(property='og:image' content= avatarUrl) meta(property='og:image' content= avatarUrl)

View file

@ -4,8 +4,9 @@
"scripts": { "scripts": {
"watch": "vite", "watch": "vite",
"build": "vite build", "build": "vite build",
"storybook-dev": "chokidar 'src/**/*.{mdx,ts,vue}' -d 1000 -t 1000 --initial -i '**/*.stories.ts' -c 'pkill -f node_modules/storybook/index.js; node_modules/.bin/tsc -p .storybook && node .storybook/generate.js && node .storybook/preload-locale.js && node .storybook/preload-theme.js && node_modules/.bin/storybook dev -p 6006 --ci'", "storybook-dev": "nodemon --verbose --watch src --ext \"mdx,ts,vue\" --ignore \"*.stories.ts\" --exec \"pnpm build-storybook-pre && pnpm exec storybook dev -p 6006 --ci\"",
"build-storybook": "tsc -p .storybook && node .storybook/generate.js && node .storybook/preload-locale.js && node .storybook/preload-theme.js && storybook build", "build-storybook-pre": "tsc -p .storybook && node .storybook/generate.js && node .storybook/preload-locale.js && node .storybook/preload-theme.js",
"build-storybook": "pnpm build-storybook-pre && storybook build",
"chromatic": "chromatic", "chromatic": "chromatic",
"test": "vitest --run", "test": "vitest --run",
"test-and-coverage": "vitest --run --coverage", "test-and-coverage": "vitest --run --coverage",
@ -19,7 +20,7 @@
"@rollup/plugin-json": "6.0.0", "@rollup/plugin-json": "6.0.0",
"@rollup/plugin-replace": "5.0.2", "@rollup/plugin-replace": "5.0.2",
"@rollup/pluginutils": "5.0.2", "@rollup/pluginutils": "5.0.2",
"@syuilo/aiscript": "0.14.0", "@syuilo/aiscript": "0.15.0",
"@tabler/icons-webfont": "2.25.0", "@tabler/icons-webfont": "2.25.0",
"@vitejs/plugin-vue": "4.2.3", "@vitejs/plugin-vue": "4.2.3",
"@vue-macros/reactivity-transform": "0.3.15", "@vue-macros/reactivity-transform": "0.3.15",
@ -77,24 +78,24 @@
"vuedraggable": "next" "vuedraggable": "next"
}, },
"devDependencies": { "devDependencies": {
"@storybook/addon-actions": "7.1.0", "@storybook/addon-actions": "7.0.27",
"@storybook/addon-essentials": "7.1.0", "@storybook/addon-essentials": "7.0.27",
"@storybook/addon-interactions": "7.1.0", "@storybook/addon-interactions": "7.0.27",
"@storybook/addon-links": "7.1.0", "@storybook/addon-links": "7.0.27",
"@storybook/addon-storysource": "7.1.0", "@storybook/addon-storysource": "7.0.27",
"@storybook/addons": "7.1.0", "@storybook/addons": "7.0.27",
"@storybook/blocks": "7.1.0", "@storybook/blocks": "7.0.27",
"@storybook/core-events": "7.1.0", "@storybook/core-events": "7.0.27",
"@storybook/jest": "0.1.0", "@storybook/jest": "0.1.0",
"@storybook/manager-api": "7.1.0", "@storybook/manager-api": "7.0.27",
"@storybook/preview-api": "7.1.0", "@storybook/preview-api": "7.0.27",
"@storybook/react": "7.1.0", "@storybook/react": "7.0.27",
"@storybook/react-vite": "7.1.0", "@storybook/react-vite": "7.0.27",
"@storybook/testing-library": "0.2.0", "@storybook/testing-library": "0.2.0",
"@storybook/theming": "7.1.0", "@storybook/theming": "7.0.27",
"@storybook/types": "7.1.0", "@storybook/types": "7.0.27",
"@storybook/vue3": "7.1.0", "@storybook/vue3": "7.0.27",
"@storybook/vue3-vite": "7.1.0", "@storybook/vue3-vite": "7.0.27",
"@testing-library/jest-dom": "5.16.5", "@testing-library/jest-dom": "5.16.5",
"@testing-library/vue": "7.0.0", "@testing-library/vue": "7.0.0",
"@types/escape-regexp": "0.0.1", "@types/escape-regexp": "0.0.1",
@ -117,7 +118,6 @@
"@vitest/coverage-v8": "0.33.0", "@vitest/coverage-v8": "0.33.0",
"@vue/runtime-core": "3.3.4", "@vue/runtime-core": "3.3.4",
"acorn": "8.10.0", "acorn": "8.10.0",
"chokidar-cli": "3.0.0",
"cross-env": "7.0.3", "cross-env": "7.0.3",
"cypress": "12.17.1", "cypress": "12.17.1",
"eslint": "8.45.0", "eslint": "8.45.0",
@ -128,11 +128,12 @@
"micromatch": "4.0.5", "micromatch": "4.0.5",
"msw": "1.2.2", "msw": "1.2.2",
"msw-storybook-addon": "1.8.0", "msw-storybook-addon": "1.8.0",
"nodemon": "3.0.1",
"prettier": "3.0.0", "prettier": "3.0.0",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"start-server-and-test": "2.0.0", "start-server-and-test": "2.0.0",
"storybook": "7.1.0", "storybook": "7.0.27",
"storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme", "storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",
"summaly": "github:misskey-dev/summaly", "summaly": "github:misskey-dev/summaly",
"vite-plugin-turbosnap": "1.0.2", "vite-plugin-turbosnap": "1.0.2",

View file

@ -19,7 +19,7 @@
</div> </div>
<div v-if="file.isSensitive" :class="[$style.label, $style.red]"> <div v-if="file.isSensitive" :class="[$style.label, $style.red]">
<img :class="$style.labelImg" src="/client-assets/label-red.svg"/> <img :class="$style.labelImg" src="/client-assets/label-red.svg"/>
<p :class="$style.labelText">NSFW</p> <p :class="$style.labelText">{{ i18n.ts.sensitive }}</p>
</div> </div>
<MkDriveFileThumbnail :class="$style.thumbnail" :file="file" fit="contain"/> <MkDriveFileThumbnail :class="$style.thumbnail" :file="file" fit="contain"/>

View file

@ -20,7 +20,7 @@
<template v-if="hide"> <template v-if="hide">
<div :class="$style.hiddenText"> <div :class="$style.hiddenText">
<div :class="$style.hiddenTextWrapper"> <div :class="$style.hiddenTextWrapper">
<b v-if="image.isSensitive" style="display: block;"><i class="ti ti-alert-triangle"></i> {{ i18n.ts.sensitive }}{{ defaultStore.state.enableDataSaverMode ? ` (${i18n.ts.image}${image.size ? ' ' + bytes(image.size) : ''})` : '' }}</b> <b v-if="image.isSensitive" style="display: block;"><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}{{ defaultStore.state.enableDataSaverMode ? ` (${i18n.ts.image}${image.size ? ' ' + bytes(image.size) : ''})` : '' }}</b>
<b v-else style="display: block;"><i class="ti ti-photo"></i> {{ defaultStore.state.enableDataSaverMode && image.size ? bytes(image.size) : i18n.ts.image }}</b> <b v-else style="display: block;"><i class="ti ti-photo"></i> {{ defaultStore.state.enableDataSaverMode && image.size ? bytes(image.size) : i18n.ts.image }}</b>
<span style="display: block;">{{ i18n.ts.clickToShow }}</span> <span style="display: block;">{{ i18n.ts.clickToShow }}</span>
</div> </div>
@ -30,7 +30,7 @@
<div :class="$style.indicators"> <div :class="$style.indicators">
<div v-if="['image/gif', 'image/apng'].includes(image.type)" :class="$style.indicator">GIF</div> <div v-if="['image/gif', 'image/apng'].includes(image.type)" :class="$style.indicator">GIF</div>
<div v-if="image.comment" :class="$style.indicator">ALT</div> <div v-if="image.comment" :class="$style.indicator">ALT</div>
<div v-if="image.isSensitive" :class="$style.indicator" style="color: var(--warn);">NSFW</div> <div v-if="image.isSensitive" :class="$style.indicator" style="color: var(--warn);" :title="i18n.ts.sensitive"><i class="ti ti-eye-exclamation"></i></div>
</div> </div>
<button :class="$style.menu" class="_button" @click.stop="showMenu"><i class="ti ti-dots" style="vertical-align: middle;"></i></button> <button :class="$style.menu" class="_button" @click.stop="showMenu"><i class="ti ti-dots" style="vertical-align: middle;"></i></button>
<i class="ti ti-eye-off" :class="$style.hide" @click.stop="hide = true"></i> <i class="ti ti-eye-off" :class="$style.hide" @click.stop="hide = true"></i>

View file

@ -165,6 +165,7 @@ import { getNoteSummary } from '@/scripts/get-note-summary';
import { MenuItem } from '@/types/menu'; import { MenuItem } from '@/types/menu';
import MkRippleEffect from '@/components/MkRippleEffect.vue'; import MkRippleEffect from '@/components/MkRippleEffect.vue';
import { showMovedDialog } from '@/scripts/show-moved-dialog'; import { showMovedDialog } from '@/scripts/show-moved-dialog';
import { shouldCollapsed } from '@/scripts/collapsed';
const props = defineProps<{ const props = defineProps<{
note: misskey.entities.Note; note: misskey.entities.Note;
@ -204,17 +205,7 @@ let appearNote = $computed(() => isRenote ? note.renote as misskey.entities.Note
const isMyRenote = $i && ($i.id === note.userId); const isMyRenote = $i && ($i.id === note.userId);
const showContent = ref(false); const showContent = ref(false);
const urls = appearNote.text ? extractUrlFromMfm(mfm.parse(appearNote.text)) : null; const urls = appearNote.text ? extractUrlFromMfm(mfm.parse(appearNote.text)) : null;
const isLong = (appearNote.cw == null && appearNote.text != null && ( const isLong = shouldCollapsed(appearNote);
(appearNote.text.includes('$[x2')) ||
(appearNote.text.includes('$[x3')) ||
(appearNote.text.includes('$[x4')) ||
(appearNote.text.includes('$[scale')) ||
(appearNote.text.includes('$[position')) ||
(appearNote.text.split('\n').length > 9) ||
(appearNote.text.length > 500) ||
(appearNote.files.length >= 5) ||
(urls && urls.length >= 4)
));
const collapsed = ref(appearNote.cw == null && isLong); const collapsed = ref(appearNote.cw == null && isLong);
const isDeleted = ref(false); const isDeleted = ref(false);
const muted = ref(checkWordMute(appearNote, $i, defaultStore.state.mutedWords)); const muted = ref(checkWordMute(appearNote, $i, defaultStore.state.mutedWords));

View file

@ -5,7 +5,7 @@
<div :class="$style.file" @click="showFileMenu(element, $event)" @contextmenu.prevent="showFileMenu(element, $event)"> <div :class="$style.file" @click="showFileMenu(element, $event)" @contextmenu.prevent="showFileMenu(element, $event)">
<MkDriveFileThumbnail :data-id="element.id" :class="$style.thumbnail" :file="element" fit="cover"/> <MkDriveFileThumbnail :data-id="element.id" :class="$style.thumbnail" :file="element" fit="cover"/>
<div v-if="element.isSensitive" :class="$style.sensitive"> <div v-if="element.isSensitive" :class="$style.sensitive">
<i class="ti ti-alert-triangle" style="margin: auto;"></i> <i class="ti ti-eye-exclamation" style="margin: auto;"></i>
</div> </div>
</div> </div>
</template> </template>

View file

@ -9,7 +9,10 @@
<MkInfo warn>{{ i18n.ts.invitationRequiredToRegister }}</MkInfo> <MkInfo warn>{{ i18n.ts.invitationRequiredToRegister }}</MkInfo>
</div> </div>
<div style="text-align: center;">{{ i18n.ts.pleaseConfirmBelowBeforeSignup }}</div> <div style="text-align: center;">
<div>{{ i18n.ts.pleaseConfirmBelowBeforeSignup }}</div>
<div style="font-weight: bold; margin-top: 0.5em;">{{ i18n.ts.beSureToReadThisAsItIsImportant }}</div>
</div>
<MkFolder v-if="availableServerRules" :defaultOpen="true"> <MkFolder v-if="availableServerRules" :defaultOpen="true">
<template #label>{{ i18n.ts.serverRules }}</template> <template #label>{{ i18n.ts.serverRules }}</template>
@ -19,7 +22,7 @@
<li v-for="item in instance.serverRules" :class="$style.rule"><div :class="$style.ruleText" v-html="item"></div></li> <li v-for="item in instance.serverRules" :class="$style.rule"><div :class="$style.ruleText" v-html="item"></div></li>
</ol> </ol>
<MkSwitch v-model="agreeServerRules" style="margin-top: 16px;">{{ i18n.ts.agree }}</MkSwitch> <MkSwitch :modelValue="agreeServerRules" style="margin-top: 16px;" @update:modelValue="updateAgreeServerRules">{{ i18n.ts.agree }}</MkSwitch>
</MkFolder> </MkFolder>
<MkFolder v-if="availableTos" :defaultOpen="true"> <MkFolder v-if="availableTos" :defaultOpen="true">
@ -28,7 +31,7 @@
<a :href="instance.tosUrl" class="_link" target="_blank">{{ i18n.ts.termsOfService }} <i class="ti ti-external-link"></i></a> <a :href="instance.tosUrl" class="_link" target="_blank">{{ i18n.ts.termsOfService }} <i class="ti ti-external-link"></i></a>
<MkSwitch v-model="agreeTos" style="margin-top: 16px;">{{ i18n.ts.agree }}</MkSwitch> <MkSwitch :modelValue="agreeTos" style="margin-top: 16px;" @update:modelValue="updateAgreeTos">{{ i18n.ts.agree }}</MkSwitch>
</MkFolder> </MkFolder>
<MkFolder :defaultOpen="true"> <MkFolder :defaultOpen="true">
@ -37,7 +40,7 @@
<a href="https://misskey-hub.net/docs/notes.html" class="_link" target="_blank">{{ i18n.ts.basicNotesBeforeCreateAccount }} <i class="ti ti-external-link"></i></a> <a href="https://misskey-hub.net/docs/notes.html" class="_link" target="_blank">{{ i18n.ts.basicNotesBeforeCreateAccount }} <i class="ti ti-external-link"></i></a>
<MkSwitch v-model="agreeNote" style="margin-top: 16px;" data-cy-signup-rules-notes-agree>{{ i18n.ts.agree }}</MkSwitch> <MkSwitch :modelValue="agreeNote" style="margin-top: 16px;" data-cy-signup-rules-notes-agree @update:modelValue="updateAgreeNote">{{ i18n.ts.agree }}</MkSwitch>
</MkFolder> </MkFolder>
<div v-if="!agreed" style="text-align: center;">{{ i18n.ts.pleaseAgreeAllToContinue }}</div> <div v-if="!agreed" style="text-align: center;">{{ i18n.ts.pleaseAgreeAllToContinue }}</div>
@ -52,13 +55,14 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, ref } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import { instance } from '@/instance'; import { instance } from '@/instance';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import MkFolder from '@/components/MkFolder.vue'; import MkFolder from '@/components/MkFolder.vue';
import MkSwitch from '@/components/MkSwitch.vue'; import MkSwitch from '@/components/MkSwitch.vue';
import MkInfo from '@/components/MkInfo.vue'; import MkInfo from '@/components/MkInfo.vue';
import * as os from '@/os';
const availableServerRules = instance.serverRules.length > 0; const availableServerRules = instance.serverRules.length > 0;
const availableTos = instance.tosUrl != null; const availableTos = instance.tosUrl != null;
@ -75,6 +79,48 @@ const emit = defineEmits<{
(ev: 'cancel'): void; (ev: 'cancel'): void;
(ev: 'done'): void; (ev: 'done'): void;
}>(); }>();
async function updateAgreeServerRules(v: boolean) {
if (v) {
const confirm = await os.confirm({
type: 'question',
title: i18n.ts.doYouAgree,
text: i18n.t('iHaveReadXCarefullyAndAgree', { x: i18n.ts.serverRules }),
});
if (confirm.canceled) return;
agreeServerRules.value = true;
} else {
agreeServerRules.value = false;
}
}
async function updateAgreeTos(v: boolean) {
if (v) {
const confirm = await os.confirm({
type: 'question',
title: i18n.ts.doYouAgree,
text: i18n.t('iHaveReadXCarefullyAndAgree', { x: i18n.ts.termsOfService }),
});
if (confirm.canceled) return;
agreeTos.value = true;
} else {
agreeTos.value = false;
}
}
async function updateAgreeNote(v: boolean) {
if (v) {
const confirm = await os.confirm({
type: 'question',
title: i18n.ts.doYouAgree,
text: i18n.t('iHaveReadXCarefullyAndAgree', { x: i18n.ts.basicNotesBeforeCreateAccount }),
});
if (confirm.canceled) return;
agreeNote.value = true;
} else {
agreeNote.value = false;
}
}
</script> </script>
<style lang="scss" module> <style lang="scss" module>

View file

@ -31,16 +31,13 @@ import MkMediaList from '@/components/MkMediaList.vue';
import MkPoll from '@/components/MkPoll.vue'; import MkPoll from '@/components/MkPoll.vue';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
import { $i } from '@/account'; import { $i } from '@/account';
import { shouldCollapsed } from '@/scripts/collapsed';
const props = defineProps<{ const props = defineProps<{
note: misskey.entities.Note; note: misskey.entities.Note;
}>(); }>();
const isLong = const isLong = shouldCollapsed(props.note);
props.note.cw == null && props.note.text != null && (
(props.note.text.split('\n').length > 9) ||
(props.note.text.length > 500)
);
const collapsed = $ref(isLong); const collapsed = $ref(isLong);
</script> </script>

View file

@ -52,19 +52,21 @@
</footer> </footer>
</article> </article>
</component> </component>
<div v-if="tweetId" :class="$style.action"> <template v-if="showActions">
<MkButton :small="true" inline @click="tweetExpanded = true"> <div v-if="tweetId" :class="$style.action">
<i class="ti ti-brand-twitter"></i> {{ i18n.ts.expandTweet }} <MkButton :small="true" inline @click="tweetExpanded = true">
</MkButton> <i class="ti ti-brand-twitter"></i> {{ i18n.ts.expandTweet }}
</div> </MkButton>
<div v-if="!playerEnabled && player.url" :class="$style.action"> </div>
<MkButton :small="true" inline @click="playerEnabled = true"> <div v-if="!playerEnabled && player.url" :class="$style.action">
<i class="ti ti-player-play"></i> {{ i18n.ts.enablePlayer }} <MkButton :small="true" inline @click="playerEnabled = true">
</MkButton> <i class="ti ti-player-play"></i> {{ i18n.ts.enablePlayer }}
<MkButton v-if="!isMobile" :small="true" inline @click="openPlayer()"> </MkButton>
<i class="ti ti-picture-in-picture"></i> {{ i18n.ts.openInWindow }} <MkButton v-if="!isMobile" :small="true" inline @click="openPlayer()">
</MkButton> <i class="ti ti-picture-in-picture"></i> {{ i18n.ts.openInWindow }}
</div> </MkButton>
</div>
</template>
</div> </div>
</template> </template>
@ -85,9 +87,11 @@ const props = withDefaults(defineProps<{
url: string; url: string;
detail?: boolean; detail?: boolean;
compact?: boolean; compact?: boolean;
showActions?: boolean;
}>(), { }>(), {
detail: false, detail: false,
compact: false, compact: false,
showActions: true,
}); });
const MOBILE_THRESHOLD = 500; const MOBILE_THRESHOLD = 500;

View file

@ -1,7 +1,7 @@
<template> <template>
<div :class="$style.root" :style="{ zIndex, top: top + 'px', left: left + 'px' }"> <div :class="$style.root" :style="{ zIndex, top: top + 'px', left: left + 'px' }">
<Transition :name="defaultStore.state.animation ? '_transition_zoom' : ''" @afterLeave="emit('closed')"> <Transition :name="defaultStore.state.animation ? '_transition_zoom' : ''" @afterLeave="emit('closed')">
<MkUrlPreview v-if="showing" class="_popup _shadow" :url="url"/> <MkUrlPreview v-if="showing" class="_popup _shadow" :url="url" :showActions="false"/>
</Transition> </Transition>
</div> </div>
</template> </template>

View file

@ -4,6 +4,9 @@ import { userEvent, waitFor, within } from '@storybook/testing-library';
import { StoryObj } from '@storybook/vue3'; import { StoryObj } from '@storybook/vue3';
import MkAd from './MkAd.vue'; import MkAd from './MkAd.vue';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
let lock: Promise<undefined> | undefined;
const common = { const common = {
render(args) { render(args) {
return { return {
@ -26,39 +29,53 @@ const common = {
}; };
}, },
async play({ canvasElement, args }) { async play({ canvasElement, args }) {
const canvas = within(canvasElement); if (lock) {
const a = canvas.getByRole<HTMLAnchorElement>('link'); console.warn('This test is unexpectedly running twice in parallel, fix it!');
await expect(a.href).toMatch(/^https?:\/\/.*#test$/); console.warn('See also: https://github.com/misskey-dev/misskey/issues/11267');
const img = within(a).getByRole('img'); await lock;
await expect(img).toBeInTheDocument();
let buttons = canvas.getAllByRole<HTMLButtonElement>('button');
await expect(buttons).toHaveLength(1);
const i = buttons[0];
await expect(i).toBeInTheDocument();
await userEvent.click(i);
await waitFor(() => expect(canvasElement).toHaveTextContent(i18n.ts._ad.back));
await expect(a).not.toBeInTheDocument();
await expect(i).not.toBeInTheDocument();
buttons = canvas.getAllByRole<HTMLButtonElement>('button');
await expect(buttons).toHaveLength(args.__hasReduce ? 2 : 1);
const reduce = args.__hasReduce ? buttons[0] : null;
const back = buttons[args.__hasReduce ? 1 : 0];
if (reduce) {
await expect(reduce).toBeInTheDocument();
await expect(reduce).toHaveTextContent(i18n.ts._ad.reduceFrequencyOfThisAd);
} }
await expect(back).toBeInTheDocument();
await expect(back).toHaveTextContent(i18n.ts._ad.back); let resolve: (value?: any) => void;
await userEvent.click(back); lock = new Promise(r => resolve = r);
await waitFor(() => expect(canvas.queryByRole('img')).toBeTruthy());
if (reduce) { try {
await expect(reduce).not.toBeInTheDocument(); const canvas = within(canvasElement);
const a = canvas.getByRole<HTMLAnchorElement>('link');
await expect(a.href).toMatch(/^https?:\/\/.*#test$/);
const img = within(a).getByRole('img');
await expect(img).toBeInTheDocument();
let buttons = canvas.getAllByRole<HTMLButtonElement>('button');
await expect(buttons).toHaveLength(1);
const i = buttons[0];
await expect(i).toBeInTheDocument();
await userEvent.click(i);
await waitFor(() => expect(canvasElement).toHaveTextContent(i18n.ts._ad.back));
await expect(a).not.toBeInTheDocument();
await expect(i).not.toBeInTheDocument();
buttons = canvas.getAllByRole<HTMLButtonElement>('button');
await expect(buttons).toHaveLength(args.__hasReduce ? 2 : 1);
const reduce = args.__hasReduce ? buttons[0] : null;
const back = buttons[args.__hasReduce ? 1 : 0];
if (reduce) {
await expect(reduce).toBeInTheDocument();
await expect(reduce).toHaveTextContent(i18n.ts._ad.reduceFrequencyOfThisAd);
}
await expect(back).toBeInTheDocument();
await expect(back).toHaveTextContent(i18n.ts._ad.back);
await userEvent.click(back);
await waitFor(() => expect(canvas.queryByRole('img')).toBeTruthy());
if (reduce) {
await expect(reduce).not.toBeInTheDocument();
}
await expect(back).not.toBeInTheDocument();
const aAgain = canvas.getByRole<HTMLAnchorElement>('link');
await expect(aAgain).toBeInTheDocument();
const imgAgain = within(aAgain).getByRole('img');
await expect(imgAgain).toBeInTheDocument();
} finally {
resolve!();
lock = undefined;
} }
await expect(back).not.toBeInTheDocument();
const aAgain = canvas.getByRole<HTMLAnchorElement>('link');
await expect(aAgain).toBeInTheDocument();
const imgAgain = within(aAgain).getByRole('img');
await expect(imgAgain).toBeInTheDocument();
}, },
args: { args: {
prefer: [], prefer: [],

View file

@ -179,6 +179,9 @@ const patronsWithIcon = [{
}, { }, {
name: 'カガミ', name: 'カガミ',
icon: 'https://misskey-hub.net/patrons/226ea3a4617749548580ec2d9a263e24.jpg', icon: 'https://misskey-hub.net/patrons/226ea3a4617749548580ec2d9a263e24.jpg',
}, {
name: 'フランギ・シュウ',
icon: 'https://misskey-hub.net/patrons/3016d37e35f3430b90420176c912d304.jpg',
}]; }];
const patrons = [ const patrons = [
@ -276,6 +279,7 @@ const patrons = [
'ぷーざ', 'ぷーざ',
'越貝鯛丸', '越貝鯛丸',
'Nick / pprmint.', 'Nick / pprmint.',
'kino3277',
]; ];
let thereIsTreasure = $ref($i && !claimedAchievements.includes('foundTreasure')); let thereIsTreasure = $ref($i && !claimedAchievements.includes('foundTreasure'));

View file

@ -32,7 +32,7 @@
<MkUserCardMini :user="file.user"/> <MkUserCardMini :user="file.user"/>
</MkA> </MkA>
<div> <div>
<MkSwitch v-model="isSensitive" @update:modelValue="toggleIsSensitive">NSFW</MkSwitch> <MkSwitch v-model="isSensitive" @update:modelValue="toggleIsSensitive">{{ i18n.ts.sensitive }}</MkSwitch>
</div> </div>
<div> <div>

View file

@ -40,7 +40,7 @@
</div> </div>
<div v-if="expandedItems.includes(item.id)" :class="$style.userItemSub"> <div v-if="expandedItems.includes(item.id)" :class="$style.userItemSub">
<div>Assigned: <MkTime :time="item.createdAt" mode="detail"/></div> <div>Assigned: <MkTime :time="item.createdAt" mode="detail"/></div>
<div v-if="item.expiresAt">Period: {{ item.expiresAt.toLocaleString() }}</div> <div v-if="item.expiresAt">Period: {{ new Date(item.expiresAt).toLocaleString() }}</div>
<div v-else>Period: {{ i18n.ts.indefinitely }}</div> <div v-else>Period: {{ i18n.ts.indefinitely }}</div>
</div> </div>
</div> </div>

View file

@ -26,7 +26,7 @@
</div> </div>
</div> </div>
<MkButton rounded style="margin: 0 auto;" @click="changeImage">{{ i18n.ts.selectFile }}</MkButton> <MkButton rounded style="margin: 0 auto;" @click="changeImage">{{ i18n.ts.selectFile }}</MkButton>
<MkInput v-model="name"> <MkInput v-model="name" pattern="[a-z0-9_]">
<template #label>{{ i18n.ts.name }}</template> <template #label>{{ i18n.ts.name }}</template>
</MkInput> </MkInput>
<MkInput v-model="category" :datalist="customEmojiCategories"> <MkInput v-model="category" :datalist="customEmojiCategories">
@ -70,6 +70,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, watch } from 'vue'; import { computed, watch } from 'vue';
import * as misskey from 'misskey-js';
import MkModalWindow from '@/components/MkModalWindow.vue'; import MkModalWindow from '@/components/MkModalWindow.vue';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue'; import MkInput from '@/components/MkInput.vue';
@ -95,7 +96,7 @@ let isSensitive = $ref(props.emoji ? props.emoji.isSensitive : false);
let localOnly = $ref(props.emoji ? props.emoji.localOnly : false); let localOnly = $ref(props.emoji ? props.emoji.localOnly : false);
let roleIdsThatCanBeUsedThisEmojiAsReaction = $ref(props.emoji ? props.emoji.roleIdsThatCanBeUsedThisEmojiAsReaction : []); let roleIdsThatCanBeUsedThisEmojiAsReaction = $ref(props.emoji ? props.emoji.roleIdsThatCanBeUsedThisEmojiAsReaction : []);
let rolesThatCanBeUsedThisEmojiAsReaction = $ref([]); let rolesThatCanBeUsedThisEmojiAsReaction = $ref([]);
let file = $ref(); let file = $ref<misskey.entities.DriveFile>();
watch($$(roleIdsThatCanBeUsedThisEmojiAsReaction), async () => { watch($$(roleIdsThatCanBeUsedThisEmojiAsReaction), async () => {
rolesThatCanBeUsedThisEmojiAsReaction = (await Promise.all(roleIdsThatCanBeUsedThisEmojiAsReaction.map((id) => os.api('admin/roles/show', { roleId: id }).catch(() => null)))).filter(x => x != null); rolesThatCanBeUsedThisEmojiAsReaction = (await Promise.all(roleIdsThatCanBeUsedThisEmojiAsReaction.map((id) => os.api('admin/roles/show', { roleId: id }).catch(() => null)))).filter(x => x != null);
@ -110,6 +111,10 @@ const emit = defineEmits<{
async function changeImage(ev) { async function changeImage(ev) {
file = await selectFile(ev.currentTarget ?? ev.target, null); file = await selectFile(ev.currentTarget ?? ev.target, null);
const candidate = file.name.replace(/\.(.+)$/, '');
if (candidate.match(/^[a-z0-9_]+$/)) {
name = candidate;
}
} }
async function addRole() { async function addRole() {

View file

@ -33,7 +33,7 @@ import MkTextarea from '@/components/MkTextarea.vue';
import MkInput from '@/components/MkInput.vue'; import MkInput from '@/components/MkInput.vue';
import { useRouter } from '@/router'; import { useRouter } from '@/router';
const PRESET_DEFAULT = `/// @ 0.14.0 const PRESET_DEFAULT = `/// @ 0.15.0
var name = "" var name = ""
@ -51,7 +51,7 @@ Ui:render([
]) ])
`; `;
const PRESET_OMIKUJI = `/// @ 0.14.0 const PRESET_OMIKUJI = `/// @ 0.15.0
// //
// //
@ -94,7 +94,7 @@ Ui:render([
]) ])
`; `;
const PRESET_SHUFFLE = `/// @ 0.14.0 const PRESET_SHUFFLE = `/// @ 0.15.0
// //
let string = "ペペロンチーノ" let string = "ペペロンチーノ"
@ -173,7 +173,7 @@ var cursor = 0
do() do()
`; `;
const PRESET_QUIZ = `/// @ 0.14.0 const PRESET_QUIZ = `/// @ 0.15.0
let title = '地理クイズ' let title = '地理クイズ'
let qas = [{ let qas = [{
@ -286,7 +286,7 @@ qaEls.push(Ui:C:container({
Ui:render(qaEls) Ui:render(qaEls)
`; `;
const PRESET_TIMELINE = `/// @ 0.14.0 const PRESET_TIMELINE = `/// @ 0.15.0
// API // API
@fetch() { @fetch() {

View file

@ -236,6 +236,7 @@ definePageMetadata(computed(() => post ? {
border-top: solid 0.5px var(--divider); border-top: solid 0.5px var(--divider);
display: flex; display: flex;
align-items: center; align-items: center;
flex-wrap: wrap;
> .avatar { > .avatar {
width: 52px; width: 52px;

View file

@ -55,7 +55,7 @@
</div> </div>
<div v-if="expandedMuteItems.includes(item.id)" :class="$style.userItemSub"> <div v-if="expandedMuteItems.includes(item.id)" :class="$style.userItemSub">
<div>Muted at: <MkTime :time="item.createdAt" mode="detail"/></div> <div>Muted at: <MkTime :time="item.createdAt" mode="detail"/></div>
<div v-if="item.expiresAt">Period: {{ item.expiresAt.toLocaleString() }}</div> <div v-if="item.expiresAt">Period: {{ new Date(item.expiresAt).toLocaleString() }}</div>
<div v-else>Period: {{ i18n.ts.indefinitely }}</div> <div v-else>Period: {{ i18n.ts.indefinitely }}</div>
</div> </div>
</div> </div>
@ -85,7 +85,7 @@
</div> </div>
<div v-if="expandedBlockItems.includes(item.id)" :class="$style.userItemSub"> <div v-if="expandedBlockItems.includes(item.id)" :class="$style.userItemSub">
<div>Blocked at: <MkTime :time="item.createdAt" mode="detail"/></div> <div>Blocked at: <MkTime :time="item.createdAt" mode="detail"/></div>
<div v-if="item.expiresAt">Period: {{ item.expiresAt.toLocaleString() }}</div> <div v-if="item.expiresAt">Period: {{ new Date(item.expiresAt).toLocaleString() }}</div>
<div v-else>Period: {{ i18n.ts.indefinitely }}</div> <div v-else>Period: {{ i18n.ts.indefinitely }}</div>
</div> </div>
</div> </div>

View file

@ -112,9 +112,17 @@
<MkButton v-if="user.host == null && iAmModerator" primary rounded @click="assignRole"><i class="ti ti-plus"></i> {{ i18n.ts.assign }}</MkButton> <MkButton v-if="user.host == null && iAmModerator" primary rounded @click="assignRole"><i class="ti ti-plus"></i> {{ i18n.ts.assign }}</MkButton>
<div v-for="role in info.roles" :key="role.id" :class="$style.roleItem"> <div v-for="role in info.roles" :key="role.id" :class="$style.roleItem">
<MkRolePreview :class="$style.role" :role="role" :forModeration="true"/> <div :class="$style.roleItemMain">
<button v-if="role.target === 'manual'" class="_button" :class="$style.roleUnassign" @click="unassignRole(role, $event)"><i class="ti ti-x"></i></button> <MkRolePreview :class="$style.role" :role="role" :forModeration="true"/>
<button v-else class="_button" :class="$style.roleUnassign" disabled><i class="ti ti-ban"></i></button> <button class="_button" :class="$style.roleToggle" @click="toggleRoleItem(role)"><i class="ti ti-chevron-down"></i></button>
<button v-if="role.target === 'manual'" class="_button" :class="$style.roleUnassign" @click="unassignRole(role, $event)"><i class="ti ti-x"></i></button>
<button v-else class="_button" :class="$style.roleUnassign" disabled><i class="ti ti-ban"></i></button>
</div>
<div v-if="expandedRoles.includes(role.id)" :class="$style.roleItemSub">
<div>Assigned: <MkTime :time="info.roleAssigns.find(a => a.roleId === role.id).createdAt" mode="detail"/></div>
<div v-if="info.roleAssigns.find(a => a.roleId === role.id).expiresAt">Period: {{ new Date(info.roleAssigns.find(a => a.roleId === role.id).expiresAt).toLocaleString() }}</div>
<div v-else>Period: {{ i18n.ts.indefinitely }}</div>
</div>
</div> </div>
</div> </div>
</MkFolder> </MkFolder>
@ -220,6 +228,7 @@ const filesPagination = {
userId: props.userId, userId: props.userId,
})), })),
}; };
let expandedRoles = $ref([]);
function createFetcher() { function createFetcher() {
if (iAmModerator) { if (iAmModerator) {
@ -384,6 +393,14 @@ async function unassignRole(role, ev) {
}], ev.currentTarget ?? ev.target); }], ev.currentTarget ?? ev.target);
} }
function toggleRoleItem(role) {
if (expandedRoles.includes(role.id)) {
expandedRoles = expandedRoles.filter(x => x !== role.id);
} else {
expandedRoles.push(role.id);
}
}
watch(() => props.userId, () => { watch(() => props.userId, () => {
init = createFetcher(); init = createFetcher();
}, { }, {
@ -523,11 +540,22 @@ definePageMetadata(computed(() => ({
} }
.roleItem { .roleItem {
}
.roleItemMain {
display: flex; display: flex;
} }
.role { .role {
flex: 1; flex: 1;
min-width: 0;
margin-right: 8px;
}
.roleItemSub {
padding: 6px 12px;
font-size: 85%;
color: var(--fgTransparentWeak);
} }
.roleUnassign { .roleUnassign {

View file

@ -0,0 +1,19 @@
import * as mfm from 'mfm-js';
import * as misskey from 'misskey-js';
import { extractUrlFromMfm } from './extract-url-from-mfm';
export function shouldCollapsed(note: misskey.entities.Note): boolean {
const urls = note.text ? extractUrlFromMfm(mfm.parse(note.text)) : null;
const collapsed = note.cw == null && note.text != null && (
(note.text.includes('$[x2')) ||
(note.text.includes('$[x3')) ||
(note.text.includes('$[x4')) ||
(note.text.includes('$[scale')) ||
(note.text.split('\n').length > 9) ||
(note.text.length > 500) ||
(note.files.length >= 5) ||
(!!urls && urls.length >= 4)
);
return collapsed;
}

File diff suppressed because it is too large Load diff