Merge branch 'develop' into feature/emoji-grid

# Conflicts:
#	packages/misskey-js/src/api.types.ts
#	packages/misskey-js/src/autogen/apiClientJSDoc.ts
#	packages/misskey-js/src/autogen/endpoint.ts
#	packages/misskey-js/src/autogen/entities.ts
#	packages/misskey-js/src/autogen/models.ts
#	packages/misskey-js/src/autogen/types.ts
This commit is contained in:
samunohito 2024-01-20 17:41:45 +09:00
commit feeafad523
195 changed files with 14510 additions and 1772 deletions

1
.gitignore vendored
View File

@ -58,6 +58,7 @@ api-docs.json
ormconfig.json ormconfig.json
temp temp
/packages/frontend/src/**/*.stories.ts /packages/frontend/src/**/*.stories.ts
tsdoc-metadata.json
# blender backups # blender backups
*.blend1 *.blend1

View File

@ -1,5 +1,5 @@
<!-- <!--
## 2023.x.x (unreleased) ## 202x.x.x (unreleased)
### General ### General
- -
@ -14,9 +14,13 @@
## 202x.x.x (Unreleased) ## 202x.x.x (Unreleased)
### Note
- 外部サイトからプラグインをインストールする場合のパスが`/install-extentions`から`/install-extensions`に変わります。現時点では以前のパスも利用できますが、非推奨です。
### General ### General
- Feat: [mCaptcha](https://github.com/mCaptcha/mCaptcha)のサポートを追加 - Feat: [mCaptcha](https://github.com/mCaptcha/mCaptcha)のサポートを追加
- Fix: リストライムラインの「リノートを表示」が正しく機能しない問題を修正 - Fix: リストライムラインの「リノートを表示」が正しく機能しない問題を修正
- Feat: Add support for TrueMail
### Client ### Client
- Feat: 新しいゲームを追加 - Feat: 新しいゲームを追加
@ -24,6 +28,8 @@
- Feat: 絵文字の詳細ダイアログを追加 - Feat: 絵文字の詳細ダイアログを追加
- Feat: 枠線をつけるMFM`$[border.width=1,style=solid,color=fff,radius=0 ...]`を追加 - Feat: 枠線をつけるMFM`$[border.width=1,style=solid,color=fff,radius=0 ...]`を追加
- デフォルトで枠線からはみ出る部分が隠されるようにしました。初期と同じ挙動にするには`$[border.noclip`が必要です - デフォルトで枠線からはみ出る部分が隠されるようにしました。初期と同じ挙動にするには`$[border.noclip`が必要です
- Feat: スワイプでタブを切り替えられるように
- Enhance: MFM等のコードブロックに全文コピー用のボタンを追加
- Enhance: ハッシュタグ入力時に、本文の末尾の行に何も書かれていない場合は新たにスペースを追加しないように - Enhance: ハッシュタグ入力時に、本文の末尾の行に何も書かれていない場合は新たにスペースを追加しないように
- Enhance: チャンネルノートのピン留めをノートのメニューからできるように - Enhance: チャンネルノートのピン留めをノートのメニューからできるように
- Enhance: 管理者の場合はAPI tokenの発行画面で管理機能に関する権限を付与できるように - Enhance: 管理者の場合はAPI tokenの発行画面で管理機能に関する権限を付与できるように
@ -32,6 +38,7 @@
- Enhance: 絵文字ピッカー・オートコンプリートで、完全一致した絵文字を優先的に表示するように - Enhance: 絵文字ピッカー・オートコンプリートで、完全一致した絵文字を優先的に表示するように
- Enhance: Playの説明欄にMFMを使えるように - Enhance: Playの説明欄にMFMを使えるように
- Enhance: チャンネルノートの場合は詳細ページからその前後のノートを見れるように - Enhance: チャンネルノートの場合は詳細ページからその前後のノートを見れるように
- Enhance: MFMの属性でオートコンプリートが使用できるように #12735
- Fix: ネイティブモードの絵文字がモノクロにならないように - Fix: ネイティブモードの絵文字がモノクロにならないように
- Fix: v2023.12.0で追加された「モデレーターがユーザーのアイコンもしくはバナー画像を未設定状態にできる機能」が管理画面上で正しく表示されていない問題を修正 - Fix: v2023.12.0で追加された「モデレーターがユーザーのアイコンもしくはバナー画像を未設定状態にできる機能」が管理画面上で正しく表示されていない問題を修正
- Fix: AiScriptの`readline`関数が不正な値を返すことがある問題のv2023.12.0時点での修正がPlay以外に適用されていないのを修正 - Fix: AiScriptの`readline`関数が不正な値を返すことがある問題のv2023.12.0時点での修正がPlay以外に適用されていないのを修正

View File

@ -6,54 +6,176 @@ import ts from 'typescript';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); const __dirname = dirname(__filename);
const parameterRegExp = /\{(\w+)\}/g;
function createMemberType(item) {
if (typeof item !== 'string') {
return ts.factory.createTypeLiteralNode(createMembers(item));
}
const parameters = Array.from(
item.matchAll(parameterRegExp),
([, parameter]) => parameter,
);
return parameters.length
? ts.factory.createTypeReferenceNode(
ts.factory.createIdentifier('ParameterizedString'),
[
ts.factory.createUnionTypeNode(
parameters.map((parameter) =>
ts.factory.createStringLiteral(parameter),
),
),
],
)
: ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword);
}
function createMembers(record) { function createMembers(record) {
return Object.entries(record) return Object.entries(record).map(([k, v]) => {
.map(([k, v]) => ts.factory.createPropertySignature( const node = ts.factory.createPropertySignature(
undefined, undefined,
ts.factory.createStringLiteral(k), ts.factory.createStringLiteral(k),
undefined, undefined,
typeof v === 'string' createMemberType(v),
? ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword) );
: ts.factory.createTypeLiteralNode(createMembers(v)), if (typeof v === 'string') {
)); ts.addSyntheticLeadingComment(
node,
ts.SyntaxKind.MultiLineCommentTrivia,
`*
* ${v.replace(/\n/g, '\n * ')}
`,
true,
);
}
return node;
});
} }
export default function generateDTS() { export default function generateDTS() {
const locale = yaml.load(fs.readFileSync(`${__dirname}/ja-JP.yml`, 'utf-8')); const locale = yaml.load(fs.readFileSync(`${__dirname}/ja-JP.yml`, 'utf-8'));
const members = createMembers(locale); const members = createMembers(locale);
const elements = [ const elements = [
ts.factory.createVariableStatement(
[ts.factory.createToken(ts.SyntaxKind.DeclareKeyword)],
ts.factory.createVariableDeclarationList(
[
ts.factory.createVariableDeclaration(
ts.factory.createIdentifier('kParameters'),
undefined,
ts.factory.createTypeOperatorNode(
ts.SyntaxKind.UniqueKeyword,
ts.factory.createKeywordTypeNode(ts.SyntaxKind.SymbolKeyword),
),
undefined,
),
],
ts.NodeFlags.Const,
),
),
ts.factory.createInterfaceDeclaration(
[ts.factory.createToken(ts.SyntaxKind.ExportKeyword)],
ts.factory.createIdentifier('ParameterizedString'),
[
ts.factory.createTypeParameterDeclaration(
undefined,
ts.factory.createIdentifier('T'),
ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword),
ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword),
),
],
undefined,
[
ts.factory.createPropertySignature(
undefined,
ts.factory.createComputedPropertyName(
ts.factory.createIdentifier('kParameters'),
),
undefined,
ts.factory.createTypeReferenceNode(
ts.factory.createIdentifier('T'),
undefined,
),
),
],
),
ts.factory.createInterfaceDeclaration(
[ts.factory.createToken(ts.SyntaxKind.ExportKeyword)],
ts.factory.createIdentifier('ILocale'),
undefined,
undefined,
[
ts.factory.createIndexSignature(
undefined,
[
ts.factory.createParameterDeclaration(
undefined,
undefined,
ts.factory.createIdentifier('_'),
undefined,
ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword),
undefined,
),
],
ts.factory.createUnionTypeNode([
ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword),
ts.factory.createTypeReferenceNode(
ts.factory.createIdentifier('ParameterizedString'),
),
ts.factory.createTypeReferenceNode(
ts.factory.createIdentifier('ILocale'),
undefined,
),
]),
),
],
),
ts.factory.createInterfaceDeclaration( ts.factory.createInterfaceDeclaration(
[ts.factory.createToken(ts.SyntaxKind.ExportKeyword)], [ts.factory.createToken(ts.SyntaxKind.ExportKeyword)],
ts.factory.createIdentifier('Locale'), ts.factory.createIdentifier('Locale'),
undefined, undefined,
undefined, [
ts.factory.createHeritageClause(ts.SyntaxKind.ExtendsKeyword, [
ts.factory.createExpressionWithTypeArguments(
ts.factory.createIdentifier('ILocale'),
undefined,
),
]),
],
members, members,
), ),
ts.factory.createVariableStatement( ts.factory.createVariableStatement(
[ts.factory.createToken(ts.SyntaxKind.DeclareKeyword)], [ts.factory.createToken(ts.SyntaxKind.DeclareKeyword)],
ts.factory.createVariableDeclarationList( ts.factory.createVariableDeclarationList(
[ts.factory.createVariableDeclaration( [
ts.factory.createIdentifier('locales'), ts.factory.createVariableDeclaration(
undefined, ts.factory.createIdentifier('locales'),
ts.factory.createTypeLiteralNode([ts.factory.createIndexSignature(
undefined, undefined,
[ts.factory.createParameterDeclaration( ts.factory.createTypeLiteralNode([
undefined, ts.factory.createIndexSignature(
undefined, undefined,
ts.factory.createIdentifier('lang'), [
undefined, ts.factory.createParameterDeclaration(
ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), undefined,
undefined, undefined,
)], ts.factory.createIdentifier('lang'),
ts.factory.createTypeReferenceNode( undefined,
ts.factory.createIdentifier('Locale'), ts.factory.createKeywordTypeNode(
undefined, ts.SyntaxKind.StringKeyword,
), ),
)]), undefined,
undefined, ),
)], ],
ts.NodeFlags.Const | ts.NodeFlags.Ambient | ts.NodeFlags.ContextFlags, ts.factory.createTypeReferenceNode(
ts.factory.createIdentifier('Locale'),
undefined,
),
),
]),
undefined,
),
],
ts.NodeFlags.Const,
), ),
), ),
ts.factory.createFunctionDeclaration( ts.factory.createFunctionDeclaration(
@ -70,16 +192,39 @@ export default function generateDTS() {
), ),
ts.factory.createExportDefault(ts.factory.createIdentifier('locales')), ts.factory.createExportDefault(ts.factory.createIdentifier('locales')),
]; ];
const printed = ts.createPrinter({ ts.addSyntheticLeadingComment(
newLine: ts.NewLineKind.LineFeed, elements[0],
}).printList( ts.SyntaxKind.MultiLineCommentTrivia,
ts.ListFormat.MultiLine, ' eslint-disable ',
ts.factory.createNodeArray(elements), true,
ts.createSourceFile('index.d.ts', '', ts.ScriptTarget.ESNext, true, ts.ScriptKind.TS),
); );
ts.addSyntheticLeadingComment(
elements[0],
ts.SyntaxKind.SingleLineCommentTrivia,
' This file is generated by locales/generateDTS.js',
true,
);
ts.addSyntheticLeadingComment(
elements[0],
ts.SyntaxKind.SingleLineCommentTrivia,
' Do not edit this file directly.',
true,
);
const printed = ts
.createPrinter({
newLine: ts.NewLineKind.LineFeed,
})
.printList(
ts.ListFormat.MultiLine,
ts.factory.createNodeArray(elements),
ts.createSourceFile(
'index.d.ts',
'',
ts.ScriptTarget.ESNext,
true,
ts.ScriptKind.TS,
),
);
fs.writeFileSync(`${__dirname}/index.d.ts`, `/* eslint-disable */ fs.writeFileSync(`${__dirname}/index.d.ts`, printed, 'utf-8');
// This file is generated by locales/generateDTS.js
// Do not edit this file directly.
${printed}`, 'utf-8');
} }

7139
locales/index.d.ts vendored

File diff suppressed because it is too large Load Diff

View File

@ -1201,6 +1201,7 @@ replaying: "リプレイ中"
ranking: "ランキング" ranking: "ランキング"
lastNDays: "直近{n}日" lastNDays: "直近{n}日"
backToTitle: "タイトルへ" backToTitle: "タイトルへ"
enableHorizontalSwipe: "スワイプしてタブを切り替える"
_bubbleGame: _bubbleGame:
howToPlay: "遊び方" howToPlay: "遊び方"
@ -2505,3 +2506,38 @@ _dataSaver:
_code: _code:
title: "コードハイライト" title: "コードハイライト"
description: "MFMなどでコードハイライト記法が使われている場合、タップするまで読み込まれなくなります。コードハイライトではハイライトする言語ごとにその定義ファイルを読み込む必要がありますが、それらが自動で読み込まれなくなるため、通信量の削減が見込めます。" description: "MFMなどでコードハイライト記法が使われている場合、タップするまで読み込まれなくなります。コードハイライトではハイライトする言語ごとにその定義ファイルを読み込む必要がありますが、それらが自動で読み込まれなくなるため、通信量の削減が見込めます。"
_reversi:
reversi: "リバーシ"
gameSettings: "対局の設定"
chooseBoard: "ボードを選択"
blackOrWhite: "先行/後攻"
blackIs: "{name}が黒(先行)"
rules: "ルール"
thisGameIsStartedSoon: "対局はまもなく開始されます"
waitingForOther: "相手の準備が完了するのを待っています"
waitingForMe: "あなたの準備が完了するのを待っています"
waitingBoth: "準備してください"
ready: "準備完了"
cancelReady: "準備を再開"
opponentTurn: "相手のターンです"
myTurn: "あなたのターンです"
turnOf: "{name}のターンです"
pastTurnOf: "{name}のターン"
surrender: "投了"
surrendered: "投了により"
drawn: "引き分け"
won: "{name}の勝ち"
black: "黒"
white: "白"
total: "合計"
turnCount: "{count}ターン目"
myGames: "自分の対局"
allGames: "みんなの対局"
ended: "終了"
playing: "対局中"
isLlotheo: "石の少ない方が勝ち(ロセオ)"
loopedMap: "ループマップ"
canPutEverywhere: "どこでも置けるモード"
freeMatch: "フリーマッチ"
lookingForPlayer: "対戦相手を探しています"

View File

@ -10,7 +10,10 @@
"workspaces": [ "workspaces": [
"packages/frontend", "packages/frontend",
"packages/backend", "packages/backend",
"packages/sw" "packages/sw",
"packages/misskey-js",
"packages/misskey-reversi",
"packages/misskey-bubble-game"
], ],
"private": true, "private": true,
"scripts": { "scripts": {

View File

@ -0,0 +1,22 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class Reversi1705475608437 {
name = 'Reversi1705475608437'
async up(queryRunner) {
await queryRunner.query(`DROP INDEX "public"."IDX_b46ec40746efceac604142be1c"`);
await queryRunner.query(`DROP INDEX "public"."IDX_b604d92d6c7aec38627f6eaf16"`);
await queryRunner.query(`ALTER TABLE "reversi_game" DROP COLUMN "createdAt"`);
await queryRunner.query(`ALTER TABLE "reversi_matching" DROP COLUMN "createdAt"`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "reversi_matching" ADD "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL`);
await queryRunner.query(`ALTER TABLE "reversi_game" ADD "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL`);
await queryRunner.query(`CREATE INDEX "IDX_b604d92d6c7aec38627f6eaf16" ON "reversi_matching" ("createdAt") `);
await queryRunner.query(`CREATE INDEX "IDX_b46ec40746efceac604142be1c" ON "reversi_game" ("createdAt") `);
}
}

View File

@ -0,0 +1,18 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class Reversi21705654039457 {
name = 'Reversi21705654039457'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "reversi_game" RENAME COLUMN "user1Accepted" TO "user1Ready"`);
await queryRunner.query(`ALTER TABLE "reversi_game" RENAME COLUMN "user2Accepted" TO "user2Ready"`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "reversi_game" RENAME COLUMN "user1Ready" TO "user1Accepted"`);
await queryRunner.query(`ALTER TABLE "reversi_game" RENAME COLUMN "user2Ready" TO "user2Accepted"`);
}
}

View File

@ -107,6 +107,7 @@
"cli-highlight": "2.1.11", "cli-highlight": "2.1.11",
"color-convert": "2.0.1", "color-convert": "2.0.1",
"content-disposition": "0.5.4", "content-disposition": "0.5.4",
"crc-32": "^1.2.2",
"date-fns": "2.30.0", "date-fns": "2.30.0",
"deep-email-validator": "0.1.21", "deep-email-validator": "0.1.21",
"fastify": "4.24.3", "fastify": "4.24.3",
@ -133,6 +134,7 @@
"microformats-parser": "2.0.2", "microformats-parser": "2.0.2",
"mime-types": "2.1.35", "mime-types": "2.1.35",
"misskey-js": "workspace:*", "misskey-js": "workspace:*",
"misskey-reversi": "workspace:*",
"ms": "3.0.0-canary.1", "ms": "3.0.0-canary.1",
"nanoid": "5.0.4", "nanoid": "5.0.4",
"nested-property": "4.0.0", "nested-property": "4.0.0",

View File

@ -66,6 +66,8 @@ import { FeaturedService } from './FeaturedService.js';
import { FanoutTimelineService } from './FanoutTimelineService.js'; import { FanoutTimelineService } from './FanoutTimelineService.js';
import { ChannelFollowingService } from './ChannelFollowingService.js'; import { ChannelFollowingService } from './ChannelFollowingService.js';
import { RegistryApiService } from './RegistryApiService.js'; import { RegistryApiService } from './RegistryApiService.js';
import { ReversiService } from './ReversiService.js';
import { ChartLoggerService } from './chart/ChartLoggerService.js'; import { ChartLoggerService } from './chart/ChartLoggerService.js';
import FederationChart from './chart/charts/federation.js'; import FederationChart from './chart/charts/federation.js';
import NotesChart from './chart/charts/notes.js'; import NotesChart from './chart/charts/notes.js';
@ -80,6 +82,7 @@ import PerUserFollowingChart from './chart/charts/per-user-following.js';
import PerUserDriveChart from './chart/charts/per-user-drive.js'; import PerUserDriveChart from './chart/charts/per-user-drive.js';
import ApRequestChart from './chart/charts/ap-request.js'; import ApRequestChart from './chart/charts/ap-request.js';
import { ChartManagementService } from './chart/ChartManagementService.js'; import { ChartManagementService } from './chart/ChartManagementService.js';
import { AbuseUserReportEntityService } from './entities/AbuseUserReportEntityService.js'; import { AbuseUserReportEntityService } from './entities/AbuseUserReportEntityService.js';
import { AntennaEntityService } from './entities/AntennaEntityService.js'; import { AntennaEntityService } from './entities/AntennaEntityService.js';
import { AppEntityService } from './entities/AppEntityService.js'; import { AppEntityService } from './entities/AppEntityService.js';
@ -112,6 +115,8 @@ import { UserListEntityService } from './entities/UserListEntityService.js';
import { FlashEntityService } from './entities/FlashEntityService.js'; import { FlashEntityService } from './entities/FlashEntityService.js';
import { FlashLikeEntityService } from './entities/FlashLikeEntityService.js'; import { FlashLikeEntityService } from './entities/FlashLikeEntityService.js';
import { RoleEntityService } from './entities/RoleEntityService.js'; import { RoleEntityService } from './entities/RoleEntityService.js';
import { ReversiGameEntityService } from './entities/ReversiGameEntityService.js';
import { ApAudienceService } from './activitypub/ApAudienceService.js'; import { ApAudienceService } from './activitypub/ApAudienceService.js';
import { ApDbResolverService } from './activitypub/ApDbResolverService.js'; import { ApDbResolverService } from './activitypub/ApDbResolverService.js';
import { ApDeliverManagerService } from './activitypub/ApDeliverManagerService.js'; import { ApDeliverManagerService } from './activitypub/ApDeliverManagerService.js';
@ -199,6 +204,7 @@ const $FanoutTimelineService: Provider = { provide: 'FanoutTimelineService', use
const $FanoutTimelineEndpointService: Provider = { provide: 'FanoutTimelineEndpointService', useExisting: FanoutTimelineEndpointService }; const $FanoutTimelineEndpointService: Provider = { provide: 'FanoutTimelineEndpointService', useExisting: FanoutTimelineEndpointService };
const $ChannelFollowingService: Provider = { provide: 'ChannelFollowingService', useExisting: ChannelFollowingService }; const $ChannelFollowingService: Provider = { provide: 'ChannelFollowingService', useExisting: ChannelFollowingService };
const $RegistryApiService: Provider = { provide: 'RegistryApiService', useExisting: RegistryApiService }; const $RegistryApiService: Provider = { provide: 'RegistryApiService', useExisting: RegistryApiService };
const $ReversiService: Provider = { provide: 'ReversiService', useExisting: ReversiService };
const $ChartLoggerService: Provider = { provide: 'ChartLoggerService', useExisting: ChartLoggerService }; const $ChartLoggerService: Provider = { provide: 'ChartLoggerService', useExisting: ChartLoggerService };
const $FederationChart: Provider = { provide: 'FederationChart', useExisting: FederationChart }; const $FederationChart: Provider = { provide: 'FederationChart', useExisting: FederationChart };
@ -247,6 +253,7 @@ const $UserListEntityService: Provider = { provide: 'UserListEntityService', use
const $FlashEntityService: Provider = { provide: 'FlashEntityService', useExisting: FlashEntityService }; const $FlashEntityService: Provider = { provide: 'FlashEntityService', useExisting: FlashEntityService };
const $FlashLikeEntityService: Provider = { provide: 'FlashLikeEntityService', useExisting: FlashLikeEntityService }; const $FlashLikeEntityService: Provider = { provide: 'FlashLikeEntityService', useExisting: FlashLikeEntityService };
const $RoleEntityService: Provider = { provide: 'RoleEntityService', useExisting: RoleEntityService }; const $RoleEntityService: Provider = { provide: 'RoleEntityService', useExisting: RoleEntityService };
const $ReversiGameEntityService: Provider = { provide: 'ReversiGameEntityService', useExisting: ReversiGameEntityService };
const $ApAudienceService: Provider = { provide: 'ApAudienceService', useExisting: ApAudienceService }; const $ApAudienceService: Provider = { provide: 'ApAudienceService', useExisting: ApAudienceService };
const $ApDbResolverService: Provider = { provide: 'ApDbResolverService', useExisting: ApDbResolverService }; const $ApDbResolverService: Provider = { provide: 'ApDbResolverService', useExisting: ApDbResolverService };
@ -336,6 +343,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
FanoutTimelineEndpointService, FanoutTimelineEndpointService,
ChannelFollowingService, ChannelFollowingService,
RegistryApiService, RegistryApiService,
ReversiService,
ChartLoggerService, ChartLoggerService,
FederationChart, FederationChart,
NotesChart, NotesChart,
@ -350,6 +359,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
PerUserDriveChart, PerUserDriveChart,
ApRequestChart, ApRequestChart,
ChartManagementService, ChartManagementService,
AbuseUserReportEntityService, AbuseUserReportEntityService,
AntennaEntityService, AntennaEntityService,
AppEntityService, AppEntityService,
@ -382,6 +392,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
FlashEntityService, FlashEntityService,
FlashLikeEntityService, FlashLikeEntityService,
RoleEntityService, RoleEntityService,
ReversiGameEntityService,
ApAudienceService, ApAudienceService,
ApDbResolverService, ApDbResolverService,
ApDeliverManagerService, ApDeliverManagerService,
@ -466,6 +478,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$FanoutTimelineEndpointService, $FanoutTimelineEndpointService,
$ChannelFollowingService, $ChannelFollowingService,
$RegistryApiService, $RegistryApiService,
$ReversiService,
$ChartLoggerService, $ChartLoggerService,
$FederationChart, $FederationChart,
$NotesChart, $NotesChart,
@ -480,6 +494,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$PerUserDriveChart, $PerUserDriveChart,
$ApRequestChart, $ApRequestChart,
$ChartManagementService, $ChartManagementService,
$AbuseUserReportEntityService, $AbuseUserReportEntityService,
$AntennaEntityService, $AntennaEntityService,
$AppEntityService, $AppEntityService,
@ -512,6 +527,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$FlashEntityService, $FlashEntityService,
$FlashLikeEntityService, $FlashLikeEntityService,
$RoleEntityService, $RoleEntityService,
$ReversiGameEntityService,
$ApAudienceService, $ApAudienceService,
$ApDbResolverService, $ApDbResolverService,
$ApDeliverManagerService, $ApDeliverManagerService,
@ -597,6 +614,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
FanoutTimelineEndpointService, FanoutTimelineEndpointService,
ChannelFollowingService, ChannelFollowingService,
RegistryApiService, RegistryApiService,
ReversiService,
FederationChart, FederationChart,
NotesChart, NotesChart,
UsersChart, UsersChart,
@ -610,6 +629,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
PerUserDriveChart, PerUserDriveChart,
ApRequestChart, ApRequestChart,
ChartManagementService, ChartManagementService,
AbuseUserReportEntityService, AbuseUserReportEntityService,
AntennaEntityService, AntennaEntityService,
AppEntityService, AppEntityService,
@ -642,6 +662,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
FlashEntityService, FlashEntityService,
FlashLikeEntityService, FlashLikeEntityService,
RoleEntityService, RoleEntityService,
ReversiGameEntityService,
ApAudienceService, ApAudienceService,
ApDbResolverService, ApDbResolverService,
ApDeliverManagerService, ApDeliverManagerService,
@ -726,6 +748,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$FanoutTimelineEndpointService, $FanoutTimelineEndpointService,
$ChannelFollowingService, $ChannelFollowingService,
$RegistryApiService, $RegistryApiService,
$ReversiService,
$FederationChart, $FederationChart,
$NotesChart, $NotesChart,
$UsersChart, $UsersChart,
@ -739,6 +763,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$PerUserDriveChart, $PerUserDriveChart,
$ApRequestChart, $ApRequestChart,
$ChartManagementService, $ChartManagementService,
$AbuseUserReportEntityService, $AbuseUserReportEntityService,
$AntennaEntityService, $AntennaEntityService,
$AppEntityService, $AppEntityService,
@ -771,6 +796,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$FlashEntityService, $FlashEntityService,
$FlashLikeEntityService, $FlashLikeEntityService,
$RoleEntityService, $RoleEntityService,
$ReversiGameEntityService,
$ApAudienceService, $ApAudienceService,
$ApDbResolverService, $ApDbResolverService,
$ApDeliverManagerService, $ApDeliverManagerService,

View File

@ -165,10 +165,17 @@ export class EmailService {
email: emailAddress, email: emailAddress,
}); });
if (exist !== 0) {
return {
available: false,
reason: 'used',
};
}
let validated: { let validated: {
valid: boolean, valid: boolean,
reason?: string | null, reason?: string | null,
}; } = { valid: true, reason: null };
if (meta.enableActiveEmailValidation) { if (meta.enableActiveEmailValidation) {
if (meta.enableVerifymailApi && meta.verifymailAuthKey != null) { if (meta.enableVerifymailApi && meta.verifymailAuthKey != null) {
@ -185,27 +192,37 @@ export class EmailService {
validateSMTP: false, // 日本だと25ポートが殆どのプロバイダーで塞がれていてタイムアウトになるので validateSMTP: false, // 日本だと25ポートが殆どのプロバイダーで塞がれていてタイムアウトになるので
}); });
} }
} else { }
validated = { valid: true, reason: null };
if (!validated.valid) {
const formatReason: Record<string, 'format' | 'disposable' | 'mx' | 'smtp' | 'network' | 'blacklist' | undefined> = {
regex: 'format',
disposable: 'disposable',
mx: 'mx',
smtp: 'smtp',
network: 'network',
blacklist: 'blacklist',
};
return {
available: false,
reason: validated.reason ? formatReason[validated.reason] ?? null : null,
};
} }
const emailDomain: string = emailAddress.split('@')[1]; const emailDomain: string = emailAddress.split('@')[1];
const isBanned = this.utilityService.isBlockedHost(meta.bannedEmailDomains, emailDomain); const isBanned = this.utilityService.isBlockedHost(meta.bannedEmailDomains, emailDomain);
const available = exist === 0 && validated.valid && !isBanned; if (isBanned) {
return {
available: false,
reason: 'banned',
};
}
return { return {
available, available: true,
reason: available ? null : reason: null,
exist !== 0 ? 'used' :
isBanned ? 'banned' :
validated.reason === 'regex' ? 'format' :
validated.reason === 'disposable' ? 'disposable' :
validated.reason === 'mx' ? 'mx' :
validated.reason === 'smtp' ? 'smtp' :
validated.reason === 'network' ? 'network' :
validated.reason === 'blacklist' ? 'blacklist' :
null,
}; };
} }
@ -222,7 +239,8 @@ export class EmailService {
}, },
}); });
const json = (await res.json()) as { const json = (await res.json()) as Partial<{
message: string;
block: boolean; block: boolean;
catch_all: boolean; catch_all: boolean;
deliverable_email: boolean; deliverable_email: boolean;
@ -237,8 +255,15 @@ export class EmailService {
mx_priority: { [key: string]: number }; mx_priority: { [key: string]: number };
privacy: boolean; privacy: boolean;
related_domains: string[]; related_domains: string[];
}; }>;
/* api error: when there is only one `message` attribute in the returned result */
if (Object.keys(json).length === 1 && Reflect.has(json, 'message')) {
return {
valid: false,
reason: null,
};
}
if (json.email_address === undefined) { if (json.email_address === undefined) {
return { return {
valid: false, valid: false,
@ -281,25 +306,26 @@ export class EmailService {
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Accept: 'application/json', Accept: 'application/json',
Authorization: truemailAuthKey Authorization: truemailAuthKey,
}, },
}); });
const json = (await res.json()) as { const json = (await res.json()) as {
email: string; email: string;
success: boolean; success: boolean;
errors?: { error?: string;
errors?: {
list_match?: string; list_match?: string;
regex?: string; regex?: string;
mx?: string; mx?: string;
smtp?: string; smtp?: string;
} | null; } | null;
}; };
if (json.email === undefined || (json.email !== undefined && json.errors?.regex)) { if (json.email === undefined || json.errors?.regex) {
return { return {
valid: false, valid: false,
reason: 'format', reason: 'format',
}; };
} }
if (json.errors?.smtp) { if (json.errors?.smtp) {
@ -320,7 +346,7 @@ export class EmailService {
reason: json.errors?.list_match as T || 'blacklist', reason: json.errors?.list_match as T || 'blacklist',
}; };
} }
return { return {
valid: true, valid: true,
reason: null, reason: null,

View File

@ -5,6 +5,7 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis'; import * as Redis from 'ioredis';
import * as Reversi from 'misskey-reversi';
import type { MiChannel } from '@/models/Channel.js'; import type { MiChannel } from '@/models/Channel.js';
import type { MiUser } from '@/models/User.js'; import type { MiUser } from '@/models/User.js';
import type { MiUserProfile } from '@/models/UserProfile.js'; import type { MiUserProfile } from '@/models/UserProfile.js';
@ -18,7 +19,7 @@ import type { MiSignin } from '@/models/Signin.js';
import type { MiPage } from '@/models/Page.js'; import type { MiPage } from '@/models/Page.js';
import type { MiWebhook } from '@/models/Webhook.js'; import type { MiWebhook } from '@/models/Webhook.js';
import type { MiMeta } from '@/models/Meta.js'; import type { MiMeta } from '@/models/Meta.js';
import { MiAvatarDecoration, MiRole, MiRoleAssignment } from '@/models/_.js'; import { MiAvatarDecoration, MiReversiGame, MiRole, MiRoleAssignment } from '@/models/_.js';
import type { Packed } from '@/misc/json-schema.js'; import type { Packed } from '@/misc/json-schema.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js'; import type { Config } from '@/config.js';
@ -159,6 +160,38 @@ export interface AdminEventTypes {
comment: string; comment: string;
}; };
} }
export interface ReversiEventTypes {
matched: {
game: Packed<'ReversiGameDetailed'>;
};
invited: {
user: Packed<'User'>;
};
}
export interface ReversiGameEventTypes {
changeReadyStates: {
user1: boolean;
user2: boolean;
};
updateSettings: {
userId: MiUser['id'];
key: string;
value: any;
};
log: Reversi.Serializer.Log & { id: string | null };
syncState: {
crc32: string;
};
started: {
game: Packed<'ReversiGameDetailed'>;
};
ended: {
winnerId: MiUser['id'] | null;
game: Packed<'ReversiGameDetailed'>;
};
}
//#endregion //#endregion
// 辞書(interface or type)から{ type, body }ユニオンを定義 // 辞書(interface or type)から{ type, body }ユニオンを定義
@ -249,6 +282,14 @@ export type GlobalEvents = {
name: 'notesStream'; name: 'notesStream';
payload: Serialized<Packed<'Note'>>; payload: Serialized<Packed<'Note'>>;
}; };
reversi: {
name: `reversiStream:${MiUser['id']}`;
payload: EventUnionFromDictionary<SerializedAll<ReversiEventTypes>>;
};
reversiGame: {
name: `reversiGameStream:${MiReversiGame['id']}`;
payload: EventUnionFromDictionary<SerializedAll<ReversiGameEventTypes>>;
};
}; };
// API event definitions // API event definitions
@ -338,4 +379,14 @@ export class GlobalEventService {
public publishAdminStream<K extends keyof AdminEventTypes>(userId: MiUser['id'], type: K, value?: AdminEventTypes[K]): void { public publishAdminStream<K extends keyof AdminEventTypes>(userId: MiUser['id'], type: K, value?: AdminEventTypes[K]): void {
this.publish(`adminStream:${userId}`, type, typeof value === 'undefined' ? null : value); this.publish(`adminStream:${userId}`, type, typeof value === 'undefined' ? null : value);
} }
@bindThis
public publishReversiStream<K extends keyof ReversiEventTypes>(userId: MiUser['id'], type: K, value?: ReversiEventTypes[K]): void {
this.publish(`reversiStream:${userId}`, type, typeof value === 'undefined' ? null : value);
}
@bindThis
public publishReversiGameStream<K extends keyof ReversiGameEventTypes>(gameId: MiReversiGame['id'], type: K, value?: ReversiGameEventTypes[K]): void {
this.publish(`reversiGameStream:${gameId}`, type, typeof value === 'undefined' ? null : value);
}
} }

View File

@ -0,0 +1,416 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis';
import CRC32 from 'crc-32';
import { ModuleRef } from '@nestjs/core';
import * as Reversi from 'misskey-reversi';
import { IsNull } from 'typeorm';
import type {
MiReversiGame,
ReversiGamesRepository,
UsersRepository,
} from '@/models/_.js';
import type { MiUser } from '@/models/User.js';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
import { MetaService } from '@/core/MetaService.js';
import { CacheService } from '@/core/CacheService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import type { GlobalEvents } from '@/core/GlobalEventService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { IdService } from '@/core/IdService.js';
import type { Packed } from '@/misc/json-schema.js';
import { NotificationService } from '@/core/NotificationService.js';
import { ReversiGameEntityService } from './entities/ReversiGameEntityService.js';
import type { OnApplicationShutdown, OnModuleInit } from '@nestjs/common';
const MATCHING_TIMEOUT_MS = 1000 * 15; // 15sec
@Injectable()
export class ReversiService implements OnApplicationShutdown, OnModuleInit {
private notificationService: NotificationService;
constructor(
private moduleRef: ModuleRef,
@Inject(DI.redis)
private redisClient: Redis.Redis,
@Inject(DI.reversiGamesRepository)
private reversiGamesRepository: ReversiGamesRepository,
private cacheService: CacheService,
private userEntityService: UserEntityService,
private globalEventService: GlobalEventService,
private reversiGameEntityService: ReversiGameEntityService,
private idService: IdService,
) {
}
async onModuleInit() {
this.notificationService = this.moduleRef.get(NotificationService.name);
}
@bindThis
public async matchSpecificUser(me: MiUser, targetUser: MiUser): Promise<MiReversiGame | null> {
if (targetUser.id === me.id) {
throw new Error('You cannot match yourself.');
}
const invitations = await this.redisClient.zrange(
`reversi:matchSpecific:${me.id}`,
Date.now() - MATCHING_TIMEOUT_MS,
'+inf',
'BYSCORE');
if (invitations.includes(targetUser.id)) {
await this.redisClient.zrem(`reversi:matchSpecific:${me.id}`, targetUser.id);
const game = await this.reversiGamesRepository.insert({
id: this.idService.gen(),
user1Id: targetUser.id,
user2Id: me.id,
user1Ready: false,
user2Ready: false,
isStarted: false,
isEnded: false,
logs: [],
map: Reversi.maps.eighteight.data,
bw: 'random',
isLlotheo: false,
}).then(x => this.reversiGamesRepository.findOneByOrFail(x.identifiers[0]));
const packed = await this.reversiGameEntityService.packDetail(game, { id: targetUser.id });
this.globalEventService.publishReversiStream(targetUser.id, 'matched', { game: packed });
return game;
} else {
this.redisClient.zadd(`reversi:matchSpecific:${targetUser.id}`, Date.now(), me.id);
this.globalEventService.publishReversiStream(targetUser.id, 'invited', {
user: await this.userEntityService.pack(me, targetUser),
});
return null;
}
}
@bindThis
public async matchAnyUser(me: MiUser): Promise<MiReversiGame | null> {
//#region まず自分宛ての招待を探す
const invitations = await this.redisClient.zrange(
`reversi:matchSpecific:${me.id}`,
Date.now() - MATCHING_TIMEOUT_MS,
'+inf',
'BYSCORE');
if (invitations.length > 0) {
const invitorId = invitations[Math.floor(Math.random() * invitations.length)];
await this.redisClient.zrem(`reversi:matchSpecific:${me.id}`, invitorId);
const game = await this.reversiGamesRepository.insert({
id: this.idService.gen(),
user1Id: invitorId,
user2Id: me.id,
user1Ready: false,
user2Ready: false,
isStarted: false,
isEnded: false,
logs: [],
map: Reversi.maps.eighteight.data,
bw: 'random',
isLlotheo: false,
}).then(x => this.reversiGamesRepository.findOneByOrFail(x.identifiers[0]));
const packed = await this.reversiGameEntityService.packDetail(game, { id: invitorId });
this.globalEventService.publishReversiStream(invitorId, 'matched', { game: packed });
return game;
}
//#endregion
const matchings = await this.redisClient.zrange(
'reversi:matchAny',
Date.now() - MATCHING_TIMEOUT_MS,
'+inf',
'BYSCORE');
const userIds = matchings.filter(id => id !== me.id);
if (userIds.length > 0) {
// pick random
const matchedUserId = userIds[Math.floor(Math.random() * userIds.length)];
await this.redisClient.zrem('reversi:matchAny', me.id, matchedUserId);
const game = await this.reversiGamesRepository.insert({
id: this.idService.gen(),
user1Id: matchedUserId,
user2Id: me.id,
user1Ready: false,
user2Ready: false,
isStarted: false,
isEnded: false,
logs: [],
map: Reversi.maps.eighteight.data,
bw: 'random',
isLlotheo: false,
}).then(x => this.reversiGamesRepository.findOneByOrFail(x.identifiers[0]));
const packed = await this.reversiGameEntityService.packDetail(game, { id: matchedUserId });
this.globalEventService.publishReversiStream(matchedUserId, 'matched', { game: packed });
return game;
} else {
await this.redisClient.zadd('reversi:matchAny', Date.now(), me.id);
return null;
}
}
@bindThis
public async matchSpecificUserCancel(user: MiUser, targetUserId: MiUser['id']) {
await this.redisClient.zrem(`reversi:matchSpecific:${targetUserId}`, user.id);
}
@bindThis
public async matchAnyUserCancel(user: MiUser) {
await this.redisClient.zrem('reversi:matchAny', user.id);
}
@bindThis
public async gameReady(game: MiReversiGame, user: MiUser, ready: boolean) {
if (game.isStarted) return;
let isBothReady = false;
if (game.user1Id === user.id) {
await this.reversiGamesRepository.update(game.id, {
user1Ready: ready,
});
this.globalEventService.publishReversiGameStream(game.id, 'changeReadyStates', {
user1: ready,
user2: game.user2Ready,
});
if (ready && game.user2Ready) isBothReady = true;
} else if (game.user2Id === user.id) {
await this.reversiGamesRepository.update(game.id, {
user2Ready: ready,
});
this.globalEventService.publishReversiGameStream(game.id, 'changeReadyStates', {
user1: game.user1Ready,
user2: ready,
});
if (ready && game.user1Ready) isBothReady = true;
} else {
return;
}
if (isBothReady) {
// 3秒後、両者readyならゲーム開始
setTimeout(async () => {
const freshGame = await this.reversiGamesRepository.findOneBy({ id: game.id });
if (freshGame == null || freshGame.isStarted || freshGame.isEnded) return;
if (!freshGame.user1Ready || !freshGame.user2Ready) return;
let bw: number;
if (freshGame.bw === 'random') {
bw = Math.random() > 0.5 ? 1 : 2;
} else {
bw = parseInt(freshGame.bw, 10);
}
function getRandomMap() {
const mapCount = Object.entries(Reversi.maps).length;
const rnd = Math.floor(Math.random() * mapCount);
return Object.values(Reversi.maps)[rnd].data;
}
const map = freshGame.map != null ? freshGame.map : getRandomMap();
const crc32 = CRC32.str(JSON.stringify(freshGame.logs)).toString();
await this.reversiGamesRepository.update(game.id, {
startedAt: new Date(),
isStarted: true,
black: bw,
map: map,
crc32,
});
//#region 盤面に最初から石がないなどして始まった瞬間に勝敗が決定する場合があるのでその処理
const o = new Reversi.Game(map, {
isLlotheo: freshGame.isLlotheo,
canPutEverywhere: freshGame.canPutEverywhere,
loopedBoard: freshGame.loopedBoard,
});
if (o.isEnded) {
let winner;
if (o.winner === true) {
winner = freshGame.black === 1 ? freshGame.user1Id : freshGame.user2Id;
} else if (o.winner === false) {
winner = freshGame.black === 1 ? freshGame.user2Id : freshGame.user1Id;
} else {
winner = null;
}
await this.reversiGamesRepository.update(game.id, {
isEnded: true,
winnerId: winner,
});
this.globalEventService.publishReversiGameStream(game.id, 'ended', {
winnerId: winner,
game: await this.reversiGameEntityService.packDetail(game.id, user),
});
}
//#endregion
this.globalEventService.publishReversiGameStream(game.id, 'started', {
game: await this.reversiGameEntityService.packDetail(game.id, user),
});
}, 3000);
}
}
@bindThis
public async getInvitations(user: MiUser): Promise<MiUser['id'][]> {
const invitations = await this.redisClient.zrange(
`reversi:matchSpecific:${user.id}`,
Date.now() - MATCHING_TIMEOUT_MS,
'+inf',
'BYSCORE');
return invitations;
}
@bindThis
public async updateSettings(game: MiReversiGame, user: MiUser, key: string, value: any) {
if (game.isStarted) return;
if ((game.user1Id !== user.id) && (game.user2Id !== user.id)) return;
if ((game.user1Id === user.id) && game.user1Ready) return;
if ((game.user2Id === user.id) && game.user2Ready) return;
if (!['map', 'bw', 'isLlotheo', 'canPutEverywhere', 'loopedBoard'].includes(key)) return;
await this.reversiGamesRepository.update(game.id, {
[key]: value,
});
this.globalEventService.publishReversiGameStream(game.id, 'updateSettings', {
userId: user.id,
key: key,
value: value,
});
}
@bindThis
public async putStoneToGame(game: MiReversiGame, user: MiUser, pos: number, id?: string | null) {
if (!game.isStarted) return;
if (game.isEnded) return;
if ((game.user1Id !== user.id) && (game.user2Id !== user.id)) return;
const myColor =
((game.user1Id === user.id) && game.black === 1) || ((game.user2Id === user.id) && game.black === 2)
? true
: false;
const engine = Reversi.Serializer.restoreGame({
map: game.map,
isLlotheo: game.isLlotheo,
canPutEverywhere: game.canPutEverywhere,
loopedBoard: game.loopedBoard,
logs: game.logs,
});
if (engine.turn !== myColor) return;
if (!engine.canPut(myColor, pos)) return;
engine.putStone(pos);
let winner;
if (engine.isEnded) {
if (engine.winner === true) {
winner = game.black === 1 ? game.user1Id : game.user2Id;
} else if (engine.winner === false) {
winner = game.black === 1 ? game.user2Id : game.user1Id;
} else {
winner = null;
}
}
const logs = Reversi.Serializer.deserializeLogs(game.logs);
const log = {
time: Date.now(),
player: myColor,
operation: 'put',
pos,
} as const;
logs.push(log);
const serializeLogs = Reversi.Serializer.serializeLogs(logs);
const crc32 = CRC32.str(JSON.stringify(serializeLogs)).toString();
await this.reversiGamesRepository.update(game.id, {
crc32,
isEnded: engine.isEnded,
winnerId: winner,
logs: serializeLogs,
});
this.globalEventService.publishReversiGameStream(game.id, 'log', {
...log,
id: id ?? null,
});
if (engine.isEnded) {
this.globalEventService.publishReversiGameStream(game.id, 'ended', {
winnerId: winner ?? null,
game: await this.reversiGameEntityService.packDetail(game.id, user),
});
}
}
@bindThis
public async surrender(game: MiReversiGame, user: MiUser) {
if (game.isEnded) return;
if ((game.user1Id !== user.id) && (game.user2Id !== user.id)) return;
const winnerId = game.user1Id === user.id ? game.user2Id : game.user1Id;
await this.reversiGamesRepository.update(game.id, {
surrendered: user.id,
isEnded: true,
winnerId: winnerId,
});
this.globalEventService.publishReversiGameStream(game.id, 'ended', {
winnerId: winnerId,
game: await this.reversiGameEntityService.packDetail(game.id, user),
});
}
@bindThis
public async get(id: MiReversiGame['id']) {
return this.reversiGamesRepository.findOneBy({ id });
}
@bindThis
public dispose(): void {
}
@bindThis
public onApplicationShutdown(signal?: string | undefined): void {
this.dispose();
}
}

View File

@ -0,0 +1,111 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { DI } from '@/di-symbols.js';
import type { ReversiGamesRepository } from '@/models/_.js';
import { awaitAll } from '@/misc/prelude/await-all.js';
import type { Packed } from '@/misc/json-schema.js';
import type { } from '@/models/Blocking.js';
import type { MiUser } from '@/models/User.js';
import type { MiReversiGame } from '@/models/ReversiGame.js';
import { bindThis } from '@/decorators.js';
import { IdService } from '@/core/IdService.js';
import { UserEntityService } from './UserEntityService.js';
@Injectable()
export class ReversiGameEntityService {
constructor(
@Inject(DI.reversiGamesRepository)
private reversiGamesRepository: ReversiGamesRepository,
private userEntityService: UserEntityService,
private idService: IdService,
) {
}
@bindThis
public async packDetail(
src: MiReversiGame['id'] | MiReversiGame,
me?: { id: MiUser['id'] } | null | undefined,
): Promise<Packed<'ReversiGameDetailed'>> {
const game = typeof src === 'object' ? src : await this.reversiGamesRepository.findOneByOrFail({ id: src });
return await awaitAll({
id: game.id,
createdAt: this.idService.parse(game.id).date.toISOString(),
startedAt: game.startedAt && game.startedAt.toISOString(),
isStarted: game.isStarted,
isEnded: game.isEnded,
form1: game.form1,
form2: game.form2,
user1Ready: game.user1Ready,
user2Ready: game.user2Ready,
user1Id: game.user1Id,
user2Id: game.user2Id,
user1: this.userEntityService.pack(game.user1Id, me),
user2: this.userEntityService.pack(game.user2Id, me),
winnerId: game.winnerId,
winner: game.winnerId ? this.userEntityService.pack(game.winnerId, me) : null,
surrendered: game.surrendered,
black: game.black,
bw: game.bw,
isLlotheo: game.isLlotheo,
canPutEverywhere: game.canPutEverywhere,
loopedBoard: game.loopedBoard,
logs: game.logs,
map: game.map,
});
}
@bindThis
public packDetailMany(
xs: MiReversiGame[],
me?: { id: MiUser['id'] } | null | undefined,
) {
return Promise.all(xs.map(x => this.packDetail(x, me)));
}
@bindThis
public async packLite(
src: MiReversiGame['id'] | MiReversiGame,
me?: { id: MiUser['id'] } | null | undefined,
): Promise<Packed<'ReversiGameLite'>> {
const game = typeof src === 'object' ? src : await this.reversiGamesRepository.findOneByOrFail({ id: src });
return await awaitAll({
id: game.id,
createdAt: this.idService.parse(game.id).date.toISOString(),
startedAt: game.startedAt && game.startedAt.toISOString(),
isStarted: game.isStarted,
isEnded: game.isEnded,
form1: game.form1,
form2: game.form2,
user1Ready: game.user1Ready,
user2Ready: game.user2Ready,
user1Id: game.user1Id,
user2Id: game.user2Id,
user1: this.userEntityService.pack(game.user1Id, me),
user2: this.userEntityService.pack(game.user2Id, me),
winnerId: game.winnerId,
winner: game.winnerId ? this.userEntityService.pack(game.winnerId, me) : null,
surrendered: game.surrendered,
black: game.black,
bw: game.bw,
isLlotheo: game.isLlotheo,
canPutEverywhere: game.canPutEverywhere,
loopedBoard: game.loopedBoard,
});
}
@bindThis
public packLiteMany(
xs: MiReversiGame[],
me?: { id: MiUser['id'] } | null | undefined,
) {
return Promise.all(xs.map(x => this.packLite(x, me)));
}
}

View File

@ -79,5 +79,6 @@ export const DI = {
flashLikesRepository: Symbol('flashLikesRepository'), flashLikesRepository: Symbol('flashLikesRepository'),
userMemosRepository: Symbol('userMemosRepository'), userMemosRepository: Symbol('userMemosRepository'),
bubbleGameRecordsRepository: Symbol('bubbleGameRecordsRepository'), bubbleGameRecordsRepository: Symbol('bubbleGameRecordsRepository'),
reversiGamesRepository: Symbol('reversiGamesRepository'),
//#endregion //#endregion
}; };

View File

@ -39,6 +39,7 @@ import { packedAnnouncementSchema } from '@/models/json-schema/announcement.js';
import { packedSigninSchema } from '@/models/json-schema/signin.js'; import { packedSigninSchema } from '@/models/json-schema/signin.js';
import { packedRoleLiteSchema, packedRoleSchema } from '@/models/json-schema/role.js'; import { packedRoleLiteSchema, packedRoleSchema } from '@/models/json-schema/role.js';
import { packedAdSchema } from '@/models/json-schema/ad.js'; import { packedAdSchema } from '@/models/json-schema/ad.js';
import { packedReversiGameLiteSchema, packedReversiGameDetailedSchema } from '@/models/json-schema/reversi-game.js';
export const refs = { export const refs = {
UserLite: packedUserLiteSchema, UserLite: packedUserLiteSchema,
@ -78,6 +79,8 @@ export const refs = {
Signin: packedSigninSchema, Signin: packedSigninSchema,
RoleLite: packedRoleLiteSchema, RoleLite: packedRoleLiteSchema,
Role: packedRoleSchema, Role: packedRoleSchema,
ReversiGameLite: packedReversiGameLiteSchema,
ReversiGameDetailed: packedReversiGameDetailedSchema,
}; };
export type Packed<x extends keyof typeof refs> = SchemaType<typeof refs[x]>; export type Packed<x extends keyof typeof refs> = SchemaType<typeof refs[x]>;

View File

@ -5,7 +5,7 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { MiAbuseUserReport, MiAccessToken, MiAd, MiAnnouncement, MiAnnouncementRead, MiAntenna, MiApp, MiAuthSession, MiAvatarDecoration, MiBlocking, MiChannel, MiChannelFavorite, MiChannelFollowing, MiClip, MiClipFavorite, MiClipNote, MiDriveFile, MiDriveFolder, MiEmoji, MiFlash, MiFlashLike, MiFollowRequest, MiFollowing, MiGalleryLike, MiGalleryPost, MiHashtag, MiInstance, MiMeta, MiModerationLog, MiMuting, MiNote, MiNoteFavorite, MiNoteReaction, MiNoteThreadMuting, MiNoteUnread, MiPage, MiPageLike, MiPasswordResetRequest, MiPoll, MiPollVote, MiPromoNote, MiPromoRead, MiRegistrationTicket, MiRegistryItem, MiRelay, MiRenoteMuting, MiRetentionAggregation, MiRole, MiRoleAssignment, MiSignin, MiSwSubscription, MiUsedUsername, MiUser, MiUserIp, MiUserKeypair, MiUserList, MiUserListFavorite, MiUserListMembership, MiUserMemo, MiUserNotePining, MiUserPending, MiUserProfile, MiUserPublickey, MiUserSecurityKey, MiWebhook, MiBubbleGameRecord } from './_.js'; import { MiAbuseUserReport, MiAccessToken, MiAd, MiAnnouncement, MiAnnouncementRead, MiAntenna, MiApp, MiAuthSession, MiAvatarDecoration, MiBlocking, MiChannel, MiChannelFavorite, MiChannelFollowing, MiClip, MiClipFavorite, MiClipNote, MiDriveFile, MiDriveFolder, MiEmoji, MiFlash, MiFlashLike, MiFollowRequest, MiFollowing, MiGalleryLike, MiGalleryPost, MiHashtag, MiInstance, MiMeta, MiModerationLog, MiMuting, MiNote, MiNoteFavorite, MiNoteReaction, MiNoteThreadMuting, MiNoteUnread, MiPage, MiPageLike, MiPasswordResetRequest, MiPoll, MiPollVote, MiPromoNote, MiPromoRead, MiRegistrationTicket, MiRegistryItem, MiRelay, MiRenoteMuting, MiRetentionAggregation, MiRole, MiRoleAssignment, MiSignin, MiSwSubscription, MiUsedUsername, MiUser, MiUserIp, MiUserKeypair, MiUserList, MiUserListFavorite, MiUserListMembership, MiUserMemo, MiUserNotePining, MiUserPending, MiUserProfile, MiUserPublickey, MiUserSecurityKey, MiWebhook, MiBubbleGameRecord, MiReversiGame } from './_.js';
import type { DataSource } from 'typeorm'; import type { DataSource } from 'typeorm';
import type { Provider } from '@nestjs/common'; import type { Provider } from '@nestjs/common';
@ -399,12 +399,18 @@ const $userMemosRepository: Provider = {
inject: [DI.db], inject: [DI.db],
}; };
export const $bubbleGameRecordsRepository: Provider = { const $bubbleGameRecordsRepository: Provider = {
provide: DI.bubbleGameRecordsRepository, provide: DI.bubbleGameRecordsRepository,
useFactory: (db: DataSource) => db.getRepository(MiBubbleGameRecord), useFactory: (db: DataSource) => db.getRepository(MiBubbleGameRecord),
inject: [DI.db], inject: [DI.db],
}; };
const $reversiGamesRepository: Provider = {
provide: DI.reversiGamesRepository,
useFactory: (db: DataSource) => db.getRepository(MiReversiGame),
inject: [DI.db],
};
@Module({ @Module({
imports: [ imports: [
], ],
@ -475,6 +481,7 @@ export const $bubbleGameRecordsRepository: Provider = {
$flashLikesRepository, $flashLikesRepository,
$userMemosRepository, $userMemosRepository,
$bubbleGameRecordsRepository, $bubbleGameRecordsRepository,
$reversiGamesRepository,
], ],
exports: [ exports: [
$usersRepository, $usersRepository,
@ -543,6 +550,7 @@ export const $bubbleGameRecordsRepository: Provider = {
$flashLikesRepository, $flashLikesRepository,
$userMemosRepository, $userMemosRepository,
$bubbleGameRecordsRepository, $bubbleGameRecordsRepository,
$reversiGamesRepository,
], ],
}) })
export class RepositoryModule {} export class RepositoryModule {}

View File

@ -0,0 +1,120 @@
import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
import { id } from './util/id.js';
import { MiUser } from './User.js';
@Entity('reversi_game')
export class MiReversiGame {
@PrimaryColumn(id())
public id: string;
@Column('timestamp with time zone', {
nullable: true,
comment: 'The started date of the ReversiGame.',
})
public startedAt: Date | null;
@Column(id())
public user1Id: MiUser['id'];
@ManyToOne(type => MiUser, {
onDelete: 'CASCADE',
})
@JoinColumn()
public user1: MiUser | null;
@Column(id())
public user2Id: MiUser['id'];
@ManyToOne(type => MiUser, {
onDelete: 'CASCADE',
})
@JoinColumn()
public user2: MiUser | null;
@Column('boolean', {
default: false,
})
public user1Ready: boolean;
@Column('boolean', {
default: false,
})
public user2Ready: boolean;
/**
* ()
* 1 ... user1
* 2 ... user2
*/
@Column('integer', {
nullable: true,
})
public black: number | null;
@Column('boolean', {
default: false,
})
public isStarted: boolean;
@Column('boolean', {
default: false,
})
public isEnded: boolean;
@Column({
...id(),
nullable: true,
})
public winnerId: MiUser['id'] | null;
@Column({
...id(),
nullable: true,
})
public surrendered: MiUser['id'] | null;
@Column('jsonb', {
default: [],
})
public logs: number[][];
@Column('varchar', {
array: true, length: 64,
})
public map: string[];
@Column('varchar', {
length: 32,
})
public bw: string;
@Column('boolean', {
default: false,
})
public isLlotheo: boolean;
@Column('boolean', {
default: false,
})
public canPutEverywhere: boolean;
@Column('boolean', {
default: false,
})
public loopedBoard: boolean;
@Column('jsonb', {
nullable: true, default: null,
})
public form1: any | null;
@Column('jsonb', {
nullable: true, default: null,
})
public form2: any | null;
@Column('varchar', {
length: 32, nullable: true,
})
public crc32: string | null;
}

View File

@ -69,6 +69,8 @@ import { MiFlash } from '@/models/Flash.js';
import { MiFlashLike } from '@/models/FlashLike.js'; import { MiFlashLike } from '@/models/FlashLike.js';
import { MiUserListFavorite } from '@/models/UserListFavorite.js'; import { MiUserListFavorite } from '@/models/UserListFavorite.js';
import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js'; import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js';
import { MiReversiGame } from '@/models/ReversiGame.js';
import type { Repository } from 'typeorm'; import type { Repository } from 'typeorm';
export { export {
@ -138,6 +140,7 @@ export {
MiFlashLike, MiFlashLike,
MiUserMemo, MiUserMemo,
MiBubbleGameRecord, MiBubbleGameRecord,
MiReversiGame,
}; };
export type AbuseUserReportsRepository = Repository<MiAbuseUserReport>; export type AbuseUserReportsRepository = Repository<MiAbuseUserReport>;
@ -206,3 +209,4 @@ export type FlashsRepository = Repository<MiFlash>;
export type FlashLikesRepository = Repository<MiFlashLike>; export type FlashLikesRepository = Repository<MiFlashLike>;
export type UserMemoRepository = Repository<MiUserMemo>; export type UserMemoRepository = Repository<MiUserMemo>;
export type BubbleGameRecordsRepository = Repository<MiBubbleGameRecord>; export type BubbleGameRecordsRepository = Repository<MiBubbleGameRecord>;
export type ReversiGamesRepository = Repository<MiReversiGame>;

View File

@ -0,0 +1,220 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
export const packedReversiGameLiteSchema = {
type: 'object',
properties: {
id: {
type: 'string',
optional: false, nullable: false,
format: 'id',
},
createdAt: {
type: 'string',
optional: false, nullable: false,
format: 'date-time',
},
startedAt: {
type: 'string',
optional: false, nullable: true,
format: 'date-time',
},
isStarted: {
type: 'boolean',
optional: false, nullable: false,
},
isEnded: {
type: 'boolean',
optional: false, nullable: false,
},
form1: {
type: 'any',
optional: false, nullable: true,
},
form2: {
type: 'any',
optional: false, nullable: true,
},
user1Ready: {
type: 'boolean',
optional: false, nullable: false,
},
user2Ready: {
type: 'boolean',
optional: false, nullable: false,
},
user1Id: {
type: 'string',
optional: false, nullable: false,
format: 'id',
},
user2Id: {
type: 'string',
optional: false, nullable: false,
format: 'id',
},
user1: {
type: 'object',
optional: false, nullable: false,
ref: 'User',
},
user2: {
type: 'object',
optional: false, nullable: false,
ref: 'User',
},
winnerId: {
type: 'string',
optional: false, nullable: true,
format: 'id',
},
winner: {
type: 'object',
optional: false, nullable: true,
ref: 'User',
},
surrendered: {
type: 'string',
optional: false, nullable: true,
format: 'id',
},
black: {
type: 'number',
optional: false, nullable: true,
},
bw: {
type: 'string',
optional: false, nullable: false,
},
isLlotheo: {
type: 'boolean',
optional: false, nullable: false,
},
canPutEverywhere: {
type: 'boolean',
optional: false, nullable: false,
},
loopedBoard: {
type: 'boolean',
optional: false, nullable: false,
},
},
} as const;
export const packedReversiGameDetailedSchema = {
type: 'object',
properties: {
id: {
type: 'string',
optional: false, nullable: false,
format: 'id',
},
createdAt: {
type: 'string',
optional: false, nullable: false,
format: 'date-time',
},
startedAt: {
type: 'string',
optional: false, nullable: true,
format: 'date-time',
},
isStarted: {
type: 'boolean',
optional: false, nullable: false,
},
isEnded: {
type: 'boolean',
optional: false, nullable: false,
},
form1: {
type: 'any',
optional: false, nullable: true,
},
form2: {
type: 'any',
optional: false, nullable: true,
},
user1Ready: {
type: 'boolean',
optional: false, nullable: false,
},
user2Ready: {
type: 'boolean',
optional: false, nullable: false,
},
user1Id: {
type: 'string',
optional: false, nullable: false,
format: 'id',
},
user2Id: {
type: 'string',
optional: false, nullable: false,
format: 'id',
},
user1: {
type: 'object',
optional: false, nullable: false,
ref: 'User',
},
user2: {
type: 'object',
optional: false, nullable: false,
ref: 'User',
},
winnerId: {
type: 'string',
optional: false, nullable: true,
format: 'id',
},
winner: {
type: 'object',
optional: false, nullable: true,
ref: 'User',
},
surrendered: {
type: 'string',
optional: false, nullable: true,
format: 'id',
},
black: {
type: 'number',
optional: false, nullable: true,
},
bw: {
type: 'string',
optional: false, nullable: false,
},
isLlotheo: {
type: 'boolean',
optional: false, nullable: false,
},
canPutEverywhere: {
type: 'boolean',
optional: false, nullable: false,
},
loopedBoard: {
type: 'boolean',
optional: false, nullable: false,
},
logs: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'array',
optional: false, nullable: false,
},
},
map: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'string',
optional: false, nullable: false,
},
},
},
} as const;

View File

@ -77,6 +77,7 @@ import { MiFlash } from '@/models/Flash.js';
import { MiFlashLike } from '@/models/FlashLike.js'; import { MiFlashLike } from '@/models/FlashLike.js';
import { MiUserMemo } from '@/models/UserMemo.js'; import { MiUserMemo } from '@/models/UserMemo.js';
import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js'; import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js';
import { MiReversiGame } from '@/models/ReversiGame.js';
import { Config } from '@/config.js'; import { Config } from '@/config.js';
import MisskeyLogger from '@/logger.js'; import MisskeyLogger from '@/logger.js';
@ -192,6 +193,7 @@ export const entities = [
MiFlashLike, MiFlashLike,
MiUserMemo, MiUserMemo,
MiBubbleGameRecord, MiBubbleGameRecord,
MiReversiGame,
...charts, ...charts,
]; ];

View File

@ -22,9 +22,13 @@ import { SigninApiService } from './api/SigninApiService.js';
import { SigninService } from './api/SigninService.js'; import { SigninService } from './api/SigninService.js';
import { SignupApiService } from './api/SignupApiService.js'; import { SignupApiService } from './api/SignupApiService.js';
import { StreamingApiServerService } from './api/StreamingApiServerService.js'; import { StreamingApiServerService } from './api/StreamingApiServerService.js';
import { OpenApiServerService } from './api/openapi/OpenApiServerService.js';
import { ClientServerService } from './web/ClientServerService.js'; import { ClientServerService } from './web/ClientServerService.js';
import { FeedService } from './web/FeedService.js'; import { FeedService } from './web/FeedService.js';
import { UrlPreviewService } from './web/UrlPreviewService.js'; import { UrlPreviewService } from './web/UrlPreviewService.js';
import { ClientLoggerService } from './web/ClientLoggerService.js';
import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js';
import { MainChannelService } from './api/stream/channels/main.js'; import { MainChannelService } from './api/stream/channels/main.js';
import { AdminChannelService } from './api/stream/channels/admin.js'; import { AdminChannelService } from './api/stream/channels/admin.js';
import { AntennaChannelService } from './api/stream/channels/antenna.js'; import { AntennaChannelService } from './api/stream/channels/antenna.js';
@ -38,10 +42,9 @@ import { LocalTimelineChannelService } from './api/stream/channels/local-timelin
import { QueueStatsChannelService } from './api/stream/channels/queue-stats.js'; import { QueueStatsChannelService } from './api/stream/channels/queue-stats.js';
import { ServerStatsChannelService } from './api/stream/channels/server-stats.js'; import { ServerStatsChannelService } from './api/stream/channels/server-stats.js';
import { UserListChannelService } from './api/stream/channels/user-list.js'; import { UserListChannelService } from './api/stream/channels/user-list.js';
import { OpenApiServerService } from './api/openapi/OpenApiServerService.js';
import { ClientLoggerService } from './web/ClientLoggerService.js';
import { RoleTimelineChannelService } from './api/stream/channels/role-timeline.js'; import { RoleTimelineChannelService } from './api/stream/channels/role-timeline.js';
import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js'; import { ReversiChannelService } from './api/stream/channels/reversi.js';
import { ReversiGameChannelService } from './api/stream/channels/reversi-game.js';
@Module({ @Module({
imports: [ imports: [
@ -77,6 +80,8 @@ import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js';
GlobalTimelineChannelService, GlobalTimelineChannelService,
HashtagChannelService, HashtagChannelService,
RoleTimelineChannelService, RoleTimelineChannelService,
ReversiChannelService,
ReversiGameChannelService,
HomeTimelineChannelService, HomeTimelineChannelService,
HybridTimelineChannelService, HybridTimelineChannelService,
LocalTimelineChannelService, LocalTimelineChannelService,

View File

@ -366,6 +366,12 @@ import * as ep___fetchExternalResources from './endpoints/fetch-external-resourc
import * as ep___retention from './endpoints/retention.js'; import * as ep___retention from './endpoints/retention.js';
import * as ep___bubbleGame_register from './endpoints/bubble-game/register.js'; import * as ep___bubbleGame_register from './endpoints/bubble-game/register.js';
import * as ep___bubbleGame_ranking from './endpoints/bubble-game/ranking.js'; import * as ep___bubbleGame_ranking from './endpoints/bubble-game/ranking.js';
import * as ep___reversi_cancelMatch from './endpoints/reversi/cancel-match.js';
import * as ep___reversi_games from './endpoints/reversi/games.js';
import * as ep___reversi_match from './endpoints/reversi/match.js';
import * as ep___reversi_invitations from './endpoints/reversi/invitations.js';
import * as ep___reversi_showGame from './endpoints/reversi/show-game.js';
import * as ep___reversi_surrender from './endpoints/reversi/surrender.js';
import { GetterService } from './GetterService.js'; import { GetterService } from './GetterService.js';
import { ApiLoggerService } from './ApiLoggerService.js'; import { ApiLoggerService } from './ApiLoggerService.js';
import type { Provider } from '@nestjs/common'; import type { Provider } from '@nestjs/common';
@ -730,6 +736,12 @@ const $fetchExternalResources: Provider = { provide: 'ep:fetch-external-resource
const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention.default }; const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention.default };
const $bubbleGame_register: Provider = { provide: 'ep:bubble-game/register', useClass: ep___bubbleGame_register.default }; const $bubbleGame_register: Provider = { provide: 'ep:bubble-game/register', useClass: ep___bubbleGame_register.default };
const $bubbleGame_ranking: Provider = { provide: 'ep:bubble-game/ranking', useClass: ep___bubbleGame_ranking.default }; const $bubbleGame_ranking: Provider = { provide: 'ep:bubble-game/ranking', useClass: ep___bubbleGame_ranking.default };
const $reversi_cancelMatch: Provider = { provide: 'ep:reversi/cancel-match', useClass: ep___reversi_cancelMatch.default };
const $reversi_games: Provider = { provide: 'ep:reversi/games', useClass: ep___reversi_games.default };
const $reversi_match: Provider = { provide: 'ep:reversi/match', useClass: ep___reversi_match.default };
const $reversi_invitations: Provider = { provide: 'ep:reversi/invitations', useClass: ep___reversi_invitations.default };
const $reversi_showGame: Provider = { provide: 'ep:reversi/show-game', useClass: ep___reversi_showGame.default };
const $reversi_surrender: Provider = { provide: 'ep:reversi/surrender', useClass: ep___reversi_surrender.default };
@Module({ @Module({
imports: [ imports: [
@ -1098,6 +1110,12 @@ const $bubbleGame_ranking: Provider = { provide: 'ep:bubble-game/ranking', useCl
$retention, $retention,
$bubbleGame_register, $bubbleGame_register,
$bubbleGame_ranking, $bubbleGame_ranking,
$reversi_cancelMatch,
$reversi_games,
$reversi_match,
$reversi_invitations,
$reversi_showGame,
$reversi_surrender,
], ],
exports: [ exports: [
$admin_meta, $admin_meta,
@ -1457,6 +1475,12 @@ const $bubbleGame_ranking: Provider = { provide: 'ep:bubble-game/ranking', useCl
$retention, $retention,
$bubbleGame_register, $bubbleGame_register,
$bubbleGame_ranking, $bubbleGame_ranking,
$reversi_cancelMatch,
$reversi_games,
$reversi_match,
$reversi_invitations,
$reversi_showGame,
$reversi_surrender,
], ],
}) })
export class EndpointsModule {} export class EndpointsModule {}

View File

@ -367,6 +367,12 @@ import * as ep___fetchExternalResources from './endpoints/fetch-external-resourc
import * as ep___retention from './endpoints/retention.js'; import * as ep___retention from './endpoints/retention.js';
import * as ep___bubbleGame_register from './endpoints/bubble-game/register.js'; import * as ep___bubbleGame_register from './endpoints/bubble-game/register.js';
import * as ep___bubbleGame_ranking from './endpoints/bubble-game/ranking.js'; import * as ep___bubbleGame_ranking from './endpoints/bubble-game/ranking.js';
import * as ep___reversi_cancelMatch from './endpoints/reversi/cancel-match.js';
import * as ep___reversi_games from './endpoints/reversi/games.js';
import * as ep___reversi_match from './endpoints/reversi/match.js';
import * as ep___reversi_invitations from './endpoints/reversi/invitations.js';
import * as ep___reversi_showGame from './endpoints/reversi/show-game.js';
import * as ep___reversi_surrender from './endpoints/reversi/surrender.js';
const eps = [ const eps = [
['admin/meta', ep___admin_meta], ['admin/meta', ep___admin_meta],
@ -729,6 +735,12 @@ const eps = [
['retention', ep___retention], ['retention', ep___retention],
['bubble-game/register', ep___bubbleGame_register], ['bubble-game/register', ep___bubbleGame_register],
['bubble-game/ranking', ep___bubbleGame_ranking], ['bubble-game/ranking', ep___bubbleGame_ranking],
['reversi/cancel-match', ep___reversi_cancelMatch],
['reversi/games', ep___reversi_games],
['reversi/match', ep___reversi_match],
['reversi/invitations', ep___reversi_invitations],
['reversi/show-game', ep___reversi_showGame],
['reversi/surrender', ep___reversi_surrender],
]; ];
interface IEndpointMetaBase { interface IEndpointMetaBase {

View File

@ -63,8 +63,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.invalidSeed); throw new ApiError(meta.errors.invalidSeed);
} }
// シードが古すぎる(1時間以上前)のも弾く // シードが古すぎる(5時間以上前)のも弾く
if (seedDate.getTime() < now.getTime() - 1000 * 60 * 60) { if (seedDate.getTime() < now.getTime() - 1000 * 60 * 60 * 5) {
throw new ApiError(meta.errors.invalidSeed); throw new ApiError(meta.errors.invalidSeed);
} }

View File

@ -73,7 +73,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
} }
// Get mutee // Get mutee
const mutee = await getterService.getUser(ps.userId).catch(err => { const mutee = await this.getterService.getUser(ps.userId).catch(err => {
if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser);
throw err; throw err;
}); });

View File

@ -0,0 +1,44 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { ReversiService } from '@/core/ReversiService.js';
export const meta = {
requireCredential: true,
kind: 'write:account',
errors: {
},
res: {
},
} as const;
export const paramDef = {
type: 'object',
properties: {
userId: { type: 'string', format: 'misskey:id', nullable: true },
},
required: [],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
private reversiService: ReversiService,
) {
super(meta, paramDef, async (ps, me) => {
if (ps.userId) {
await this.reversiService.matchSpecificUserCancel(me, ps.userId);
return;
} else {
await this.reversiService.matchAnyUserCancel(me);
}
});
}
}

View File

@ -0,0 +1,61 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { Brackets } from 'typeorm';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { ReversiGameEntityService } from '@/core/entities/ReversiGameEntityService.js';
import { DI } from '@/di-symbols.js';
import type { ReversiGamesRepository } from '@/models/_.js';
import { QueryService } from '@/core/QueryService.js';
export const meta = {
requireCredential: false,
res: {
type: 'array',
optional: false, nullable: false,
items: { ref: 'ReversiGameLite' },
},
} as const;
export const paramDef = {
type: 'object',
properties: {
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' },
my: { type: 'boolean', default: false },
},
required: [],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.reversiGamesRepository)
private reversiGamesRepository: ReversiGamesRepository,
private reversiGameEntityService: ReversiGameEntityService,
private queryService: QueryService,
) {
super(meta, paramDef, async (ps, me) => {
const query = this.queryService.makePaginationQuery(this.reversiGamesRepository.createQueryBuilder('game'), ps.sinceId, ps.untilId)
.andWhere('game.isStarted = TRUE');
if (ps.my && me) {
query.andWhere(new Brackets(qb => {
qb
.where('game.user1Id = :userId', { userId: me.id })
.orWhere('game.user2Id = :userId', { userId: me.id });
}));
}
const games = await query.take(ps.limit).getMany();
return await this.reversiGameEntityService.packLiteMany(games, me);
});
}
}

View File

@ -0,0 +1,39 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { ReversiService } from '@/core/ReversiService.js';
export const meta = {
requireCredential: true,
kind: 'read:account',
res: {
type: 'array',
optional: false, nullable: false,
items: { ref: 'UserLite' },
},
} as const;
export const paramDef = {
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
private userEntityService: UserEntityService,
private reversiService: ReversiService,
) {
super(meta, paramDef, async (ps, me) => {
const invitations = await this.reversiService.getInvitations(me);
return await this.userEntityService.packMany(invitations, me);
});
}
}

View File

@ -0,0 +1,66 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { ReversiService } from '@/core/ReversiService.js';
import { ReversiGameEntityService } from '@/core/entities/ReversiGameEntityService.js';
import { ApiError } from '../../error.js';
import { GetterService } from '../../GetterService.js';
export const meta = {
requireCredential: true,
kind: 'write:account',
errors: {
noSuchUser: {
message: 'No such user.',
code: 'NO_SUCH_USER',
id: '0b4f0559-b484-4e31-9581-3f73cee89b28',
},
isYourself: {
message: 'Target user is yourself.',
code: 'TARGET_IS_YOURSELF',
id: '96fd7bd6-d2bc-426c-a865-d055dcd2828e',
},
},
res: {
},
} as const;
export const paramDef = {
type: 'object',
properties: {
userId: { type: 'string', format: 'misskey:id', nullable: true },
},
required: [],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
private getterService: GetterService,
private reversiService: ReversiService,
private reversiGameEntityService: ReversiGameEntityService,
) {
super(meta, paramDef, async (ps, me) => {
if (ps.userId === me.id) throw new ApiError(meta.errors.isYourself);
const target = ps.userId ? await this.getterService.getUser(ps.userId).catch(err => {
if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser);
throw err;
}) : null;
const game = target ? await this.reversiService.matchSpecificUser(me, target) : await this.reversiService.matchAnyUser(me);
if (game == null) return;
return await this.reversiGameEntityService.packDetail(game, me);
});
}
}

View File

@ -0,0 +1,54 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { ReversiService } from '@/core/ReversiService.js';
import { ReversiGameEntityService } from '@/core/entities/ReversiGameEntityService.js';
import { ApiError } from '../../error.js';
export const meta = {
requireCredential: false,
errors: {
noSuchGame: {
message: 'No such game.',
code: 'NO_SUCH_GAME',
id: 'f13a03db-fae1-46c9-87f3-43c8165419e1',
},
},
res: {
type: 'object',
optional: false, nullable: false,
ref: 'ReversiGameDetailed',
},
} as const;
export const paramDef = {
type: 'object',
properties: {
gameId: { type: 'string', format: 'misskey:id' },
},
required: ['gameId'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
private reversiService: ReversiService,
private reversiGameEntityService: ReversiGameEntityService,
) {
super(meta, paramDef, async (ps, me) => {
const game = await this.reversiService.get(ps.gameId);
if (game == null) {
throw new ApiError(meta.errors.noSuchGame);
}
return await this.reversiGameEntityService.packDetail(game, me);
});
}
}

View File

@ -0,0 +1,68 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { ReversiService } from '@/core/ReversiService.js';
import { ApiError } from '../../error.js';
export const meta = {
requireCredential: true,
kind: 'write:account',
errors: {
noSuchGame: {
message: 'No such game.',
code: 'NO_SUCH_GAME',
id: 'ace0b11f-e0a6-4076-a30d-e8284c81b2df',
},
alreadyEnded: {
message: 'That game has already ended.',
code: 'ALREADY_ENDED',
id: '6c2ad4a6-cbf1-4a5b-b187-b772826cfc6d',
},
accessDenied: {
message: 'Access denied.',
code: 'ACCESS_DENIED',
id: '6e04164b-a992-4c93-8489-2123069973e1',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
gameId: { type: 'string', format: 'misskey:id' },
},
required: ['gameId'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
private reversiService: ReversiService,
) {
super(meta, paramDef, async (ps, me) => {
const game = await this.reversiService.get(ps.gameId);
if (game == null) {
throw new ApiError(meta.errors.noSuchGame);
}
if (game.isEnded) {
throw new ApiError(meta.errors.alreadyEnded);
}
if ((game.user1Id !== me.id) && (game.user2Id !== me.id)) {
throw new ApiError(meta.errors.accessDenied);
}
await this.reversiService.surrender(game, me);
});
}
}

View File

@ -19,6 +19,8 @@ import { AntennaChannelService } from './channels/antenna.js';
import { DriveChannelService } from './channels/drive.js'; import { DriveChannelService } from './channels/drive.js';
import { HashtagChannelService } from './channels/hashtag.js'; import { HashtagChannelService } from './channels/hashtag.js';
import { RoleTimelineChannelService } from './channels/role-timeline.js'; import { RoleTimelineChannelService } from './channels/role-timeline.js';
import { ReversiChannelService } from './channels/reversi.js';
import { ReversiGameChannelService } from './channels/reversi-game.js';
import { type MiChannelService } from './channel.js'; import { type MiChannelService } from './channel.js';
@Injectable() @Injectable()
@ -38,6 +40,8 @@ export class ChannelsService {
private serverStatsChannelService: ServerStatsChannelService, private serverStatsChannelService: ServerStatsChannelService,
private queueStatsChannelService: QueueStatsChannelService, private queueStatsChannelService: QueueStatsChannelService,
private adminChannelService: AdminChannelService, private adminChannelService: AdminChannelService,
private reversiChannelService: ReversiChannelService,
private reversiGameChannelService: ReversiGameChannelService,
) { ) {
} }
@ -58,6 +62,8 @@ export class ChannelsService {
case 'serverStats': return this.serverStatsChannelService; case 'serverStats': return this.serverStatsChannelService;
case 'queueStats': return this.queueStatsChannelService; case 'queueStats': return this.queueStatsChannelService;
case 'admin': return this.adminChannelService; case 'admin': return this.adminChannelService;
case 'reversi': return this.reversiChannelService;
case 'reversiGame': return this.reversiGameChannelService;
default: default:
throw new Error(`no such channel: ${name}`); throw new Error(`no such channel: ${name}`);

View File

@ -0,0 +1,130 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import type { MiReversiGame, ReversiGamesRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
import { ReversiService } from '@/core/ReversiService.js';
import { ReversiGameEntityService } from '@/core/entities/ReversiGameEntityService.js';
import Channel, { type MiChannelService } from '../channel.js';
class ReversiGameChannel extends Channel {
public readonly chName = 'reversiGame';
public static shouldShare = false;
public static requireCredential = false as const;
private gameId: MiReversiGame['id'] | null = null;
constructor(
private reversiService: ReversiService,
private reversiGamesRepository: ReversiGamesRepository,
private reversiGameEntityService: ReversiGameEntityService,
id: string,
connection: Channel['connection'],
) {
super(id, connection);
}
@bindThis
public async init(params: any) {
this.gameId = params.gameId as string;
const game = await this.reversiGamesRepository.findOneBy({
id: this.gameId,
});
if (game == null) return;
this.subscriber.on(`reversiGameStream:${this.gameId}`, this.send);
}
@bindThis
public onMessage(type: string, body: any) {
switch (type) {
case 'ready': this.ready(body); break;
case 'updateSettings': this.updateSettings(body.key, body.value); break;
case 'putStone': this.putStone(body.pos, body.id); break;
case 'syncState': this.syncState(body.crc32); break;
}
}
@bindThis
private async updateSettings(key: string, value: any) {
if (this.user == null) return;
// TODO: キャッシュしたい
const game = await this.reversiGamesRepository.findOneBy({ id: this.gameId! });
if (game == null) throw new Error('game not found');
this.reversiService.updateSettings(game, this.user, key, value);
}
@bindThis
private async ready(ready: boolean) {
if (this.user == null) return;
const game = await this.reversiGamesRepository.findOneBy({ id: this.gameId! });
if (game == null) throw new Error('game not found');
this.reversiService.gameReady(game, this.user, ready);
}
@bindThis
private async putStone(pos: number, id: string) {
if (this.user == null) return;
// TODO: キャッシュしたい
const game = await this.reversiGamesRepository.findOneBy({ id: this.gameId! });
if (game == null) throw new Error('game not found');
this.reversiService.putStoneToGame(game, this.user, pos, id);
}
@bindThis
private async syncState(crc32: string | number) {
// TODO: キャッシュしたい
const game = await this.reversiGamesRepository.findOneBy({ id: this.gameId! });
if (game == null) throw new Error('game not found');
if (!game.isStarted) return;
if (crc32.toString() !== game.crc32) {
this.send('rescue', await this.reversiGameEntityService.packDetail(game, this.user));
}
}
@bindThis
public dispose() {
// Unsubscribe events
this.subscriber.off(`reversiGameStream:${this.gameId}`, this.send);
}
}
@Injectable()
export class ReversiGameChannelService implements MiChannelService<false> {
public readonly shouldShare = ReversiGameChannel.shouldShare;
public readonly requireCredential = ReversiGameChannel.requireCredential;
public readonly kind = ReversiGameChannel.kind;
constructor(
@Inject(DI.reversiGamesRepository)
private reversiGamesRepository: ReversiGamesRepository,
private reversiService: ReversiService,
private reversiGameEntityService: ReversiGameEntityService,
) {
}
@bindThis
public create(id: string, connection: Channel['connection']): ReversiGameChannel {
return new ReversiGameChannel(
this.reversiService,
this.reversiGamesRepository,
this.reversiGameEntityService,
id,
connection,
);
}
}

View File

@ -0,0 +1,52 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Injectable } from '@nestjs/common';
import { bindThis } from '@/decorators.js';
import Channel, { type MiChannelService } from '../channel.js';
class ReversiChannel extends Channel {
public readonly chName = 'reversi';
public static shouldShare = true;
public static requireCredential = true as const;
public static kind = 'read:account';
constructor(
id: string,
connection: Channel['connection'],
) {
super(id, connection);
}
@bindThis
public async init(params: any) {
this.subscriber.on(`reversiStream:${this.user!.id}`, this.send);
}
@bindThis
public dispose() {
// Unsubscribe events
this.subscriber.off(`reversiStream:${this.user!.id}`, this.send);
}
}
@Injectable()
export class ReversiChannelService implements MiChannelService<true> {
public readonly shouldShare = ReversiChannel.shouldShare;
public readonly requireCredential = ReversiChannel.requireCredential;
public readonly kind = ReversiChannel.kind;
constructor(
) {
}
@bindThis
public create(id: string, connection: Channel['connection']): ReversiChannel {
return new ReversiChannel(
id,
connection,
);
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@ -41,6 +41,7 @@
"chartjs-plugin-zoom": "2.0.1", "chartjs-plugin-zoom": "2.0.1",
"chromatic": "10.1.0", "chromatic": "10.1.0",
"compare-versions": "6.1.0", "compare-versions": "6.1.0",
"crc-32": "^1.2.2",
"cropperjs": "2.0.0-beta.4", "cropperjs": "2.0.0-beta.4",
"date-fns": "2.30.0", "date-fns": "2.30.0",
"escape-regexp": "0.0.1", "escape-regexp": "0.0.1",
@ -53,12 +54,13 @@
"matter-js": "0.19.0", "matter-js": "0.19.0",
"mfm-js": "0.24.0", "mfm-js": "0.24.0",
"misskey-js": "workspace:*", "misskey-js": "workspace:*",
"misskey-reversi": "workspace:*",
"misskey-bubble-game": "workspace:*",
"photoswipe": "5.4.3", "photoswipe": "5.4.3",
"punycode": "2.3.1", "punycode": "2.3.1",
"rollup": "4.9.1", "rollup": "4.9.1",
"sanitize-html": "2.11.0", "sanitize-html": "2.11.0",
"sass": "1.69.5", "sass": "1.69.5",
"seedrandom": "^3.0.5",
"shiki": "0.14.7", "shiki": "0.14.7",
"strict-event-emitter-types": "2.0.0", "strict-event-emitter-types": "2.0.0",
"textarea-caret": "3.1.0", "textarea-caret": "3.1.0",

View File

@ -205,7 +205,7 @@ export async function mainBoot() {
const lastUsedDate = parseInt(lastUsed, 10); const lastUsedDate = parseInt(lastUsed, 10);
// 二時間以上前なら // 二時間以上前なら
if (Date.now() - lastUsedDate > 1000 * 60 * 60 * 2) { if (Date.now() - lastUsedDate > 1000 * 60 * 60 * 2) {
toast(i18n.t('welcomeBackWithName', { toast(i18n.tsx.welcomeBackWithName({
name: $i.name || $i.username, name: $i.name || $i.username,
})); }));
} }

View File

@ -44,7 +44,7 @@ async function ok() {
const confirm = await os.confirm({ const confirm = await os.confirm({
type: 'question', type: 'question',
title: i18n.ts._announcement.readConfirmTitle, title: i18n.ts._announcement.readConfirmTitle,
text: i18n.t('_announcement.readConfirmText', { title: props.announcement.title }), text: i18n.tsx._announcement.readConfirmText({ title: props.announcement.title }),
}); });
if (confirm.canceled) return; if (confirm.canceled) return;
} }

View File

@ -35,6 +35,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<span>{{ tag }}</span> <span>{{ tag }}</span>
</li> </li>
</ol> </ol>
<ol v-else-if="mfmParams.length > 0" ref="suggests" :class="$style.list">
<li v-for="param in mfmParams" tabindex="-1" :class="$style.item" @click="complete(type, q.params.toSpliced(-1, 1, param).join(','))" @keydown="onKeydown">
<span>{{ param }}</span>
</li>
</ol>
</div> </div>
</template> </template>
@ -51,7 +56,7 @@ import { emojilist, getEmojiName } from '@/scripts/emojilist.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { miLocalStorage } from '@/local-storage.js'; import { miLocalStorage } from '@/local-storage.js';
import { customEmojis } from '@/custom-emojis.js'; import { customEmojis } from '@/custom-emojis.js';
import { MFM_TAGS } from '@/const.js'; import { MFM_TAGS, MFM_PARAMS } from '@/const.js';
type EmojiDef = { type EmojiDef = {
emoji: string; emoji: string;
@ -130,7 +135,7 @@ export default {
<script lang="ts" setup> <script lang="ts" setup>
const props = defineProps<{ const props = defineProps<{
type: string; type: string;
q: string | null; q: any;
textarea: HTMLTextAreaElement; textarea: HTMLTextAreaElement;
close: () => void; close: () => void;
x: number; x: number;
@ -151,6 +156,7 @@ const hashtags = ref<any[]>([]);
const emojis = ref<(EmojiDef)[]>([]); const emojis = ref<(EmojiDef)[]>([]);
const items = ref<Element[] | HTMLCollection>([]); const items = ref<Element[] | HTMLCollection>([]);
const mfmTags = ref<string[]>([]); const mfmTags = ref<string[]>([]);
const mfmParams = ref<string[]>([]);
const select = ref(-1); const select = ref(-1);
const zIndex = os.claimZIndex('high'); const zIndex = os.claimZIndex('high');
@ -251,6 +257,13 @@ function exec() {
} }
mfmTags.value = MFM_TAGS.filter(tag => tag.startsWith(props.q ?? '')); mfmTags.value = MFM_TAGS.filter(tag => tag.startsWith(props.q ?? ''));
} else if (props.type === 'mfmParam') {
if (props.q.params.at(-1) === '') {
mfmParams.value = MFM_PARAMS[props.q.tag] ?? [];
return;
}
mfmParams.value = MFM_PARAMS[props.q.tag].filter(param => param.startsWith(props.q.params.at(-1) ?? ''));
} }
} }

View File

@ -4,48 +4,64 @@ SPDX-License-Identifier: AGPL-3.0-only
--> -->
<template> <template>
<Suspense> <div :class="$style.codeBlockRoot">
<template #fallback> <button :class="$style.codeBlockCopyButton" class="_button" @click="copy">
<MkLoading v-if="!inline ?? true"/> <i class="ti ti-copy"></i>
</template>
<code v-if="inline" :class="$style.codeInlineRoot">{{ code }}</code>
<XCode v-else-if="show && lang" :code="code" :lang="lang"/>
<pre v-else-if="show" :class="$style.codeBlockFallbackRoot"><code :class="$style.codeBlockFallbackCode">{{ code }}</code></pre>
<button v-else :class="$style.codePlaceholderRoot" @click="show = true">
<div :class="$style.codePlaceholderContainer">
<div><i class="ti ti-code"></i> {{ i18n.ts.code }}</div>
<div>{{ i18n.ts.clickToShow }}</div>
</div>
</button> </button>
</Suspense> <Suspense>
<template #fallback>
<MkLoading />
</template>
<XCode v-if="show && lang" :code="code" :lang="lang"/>
<pre v-else-if="show" :class="$style.codeBlockFallbackRoot"><code :class="$style.codeBlockFallbackCode">{{ code }}</code></pre>
<button v-else :class="$style.codePlaceholderRoot" @click="show = true">
<div :class="$style.codePlaceholderContainer">
<div><i class="ti ti-code"></i> {{ i18n.ts.code }}</div>
<div>{{ i18n.ts.clickToShow }}</div>
</div>
</button>
</Suspense>
</div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { defineAsyncComponent, ref } from 'vue'; import { defineAsyncComponent, ref } from 'vue';
import * as os from '@/os.js';
import MkLoading from '@/components/global/MkLoading.vue'; import MkLoading from '@/components/global/MkLoading.vue';
import { defaultStore } from '@/store.js'; import { defaultStore } from '@/store.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import copyToClipboard from '@/scripts/copy-to-clipboard.js';
defineProps<{ const props = defineProps<{
code: string; code: string;
lang?: string; lang?: string;
inline?: boolean;
}>(); }>();
const show = ref(!defaultStore.state.dataSaver.code); const show = ref(!defaultStore.state.dataSaver.code);
const XCode = defineAsyncComponent(() => import('@/components/MkCode.core.vue')); const XCode = defineAsyncComponent(() => import('@/components/MkCode.core.vue'));
function copy() {
copyToClipboard(props.code);
os.success();
}
</script> </script>
<style module lang="scss"> <style module lang="scss">
.codeInlineRoot { .codeBlockRoot {
display: inline-block; position: relative;
font-family: Consolas, Monaco, Andale Mono, Ubuntu Mono, monospace; }
overflow-wrap: anywhere;
.codeBlockCopyButton {
color: #D4D4D4; color: #D4D4D4;
background: #1E1E1E; position: absolute;
padding: .1em; top: 8px;
border-radius: .3em; right: 8px;
opacity: 0.5;
&:hover {
opacity: 0.8;
}
} }
.codeBlockFallbackRoot { .codeBlockFallbackRoot {

View File

@ -0,0 +1,26 @@
<!--
SPDX-FileCopyrightText: syuilo and other misskey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<code :class="$style.root">{{ code }}</code>
</template>
<script lang="ts" setup>
const props = defineProps<{
code: string;
}>();
</script>
<style module lang="scss">
.root {
display: inline-block;
font-family: Consolas, Monaco, Andale Mono, Ubuntu Mono, monospace;
overflow-wrap: anywhere;
color: #D4D4D4;
background: #1E1E1E;
padding: .1em;
border-radius: .3em;
}
</style>

View File

@ -41,9 +41,9 @@ const emit = defineEmits<{
const label = computed(() => { const label = computed(() => {
return concat([ return concat([
props.text ? [i18n.t('_cw.chars', { count: props.text.length })] : [], props.text ? [i18n.tsx._cw.chars({ count: props.text.length })] : [],
props.renote ? [i18n.ts.quote] : [], props.renote ? [i18n.ts.quote] : [],
props.files.length !== 0 ? [i18n.t('_cw.files', { count: props.files.length })] : [], props.files.length !== 0 ? [i18n.tsx._cw.files({ count: props.files.length })] : [],
props.poll != null ? [i18n.ts.poll] : [], props.poll != null ? [i18n.ts.poll] : [],
] as string[][]).join(' / '); ] as string[][]).join(' / ');
}); });

View File

@ -46,7 +46,7 @@ export default defineComponent({
function getDateText(time: string) { function getDateText(time: string) {
const date = new Date(time).getDate(); const date = new Date(time).getDate();
const month = new Date(time).getMonth() + 1; const month = new Date(time).getMonth() + 1;
return i18n.t('monthAndDay', { return i18n.tsx.monthAndDay({
month: month.toString(), month: month.toString(),
day: date.toString(), day: date.toString(),
}); });

View File

@ -30,8 +30,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkInput v-if="input" v-model="inputValue" autofocus :type="input.type || 'text'" :placeholder="input.placeholder || undefined" :autocomplete="input.autocomplete" @keydown="onInputKeydown"> <MkInput v-if="input" v-model="inputValue" autofocus :type="input.type || 'text'" :placeholder="input.placeholder || undefined" :autocomplete="input.autocomplete" @keydown="onInputKeydown">
<template v-if="input.type === 'password'" #prefix><i class="ti ti-lock"></i></template> <template v-if="input.type === 'password'" #prefix><i class="ti ti-lock"></i></template>
<template #caption> <template #caption>
<span v-if="okButtonDisabledReason === 'charactersExceeded'" v-text="i18n.t('_dialog.charactersExceeded', { current: (inputValue as string).length, max: input.maxLength ?? 'NaN' })"/> <span v-if="okButtonDisabledReason === 'charactersExceeded'" v-text="i18n.tsx._dialog.charactersExceeded({ current: (inputValue as string).length, max: input.maxLength ?? 'NaN' })"/>
<span v-else-if="okButtonDisabledReason === 'charactersBelow'" v-text="i18n.t('_dialog.charactersBelow', { current: (inputValue as string).length, min: input.minLength ?? 'NaN' })"/> <span v-else-if="okButtonDisabledReason === 'charactersBelow'" v-text="i18n.tsx._dialog.charactersBelow({ current: (inputValue as string).length, min: input.minLength ?? 'NaN' })"/>
</template> </template>
</MkInput> </MkInput>
<MkSelect v-if="select" v-model="selectedValue" autofocus> <MkSelect v-if="select" v-model="selectedValue" autofocus>

View File

@ -82,8 +82,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkButton v-show="moreFiles" ref="loadMoreFiles" @click="fetchMoreFiles">{{ i18n.ts.loadMore }}</MkButton> <MkButton v-show="moreFiles" ref="loadMoreFiles" @click="fetchMoreFiles">{{ i18n.ts.loadMore }}</MkButton>
</div> </div>
<div v-if="files.length == 0 && folders.length == 0 && !fetching" :class="$style.empty"> <div v-if="files.length == 0 && folders.length == 0 && !fetching" :class="$style.empty">
<div v-if="draghover">{{ i18n.t('empty-draghover') }}</div> <div v-if="draghover">{{ i18n.ts['empty-draghover'] }}</div>
<div v-if="!draghover && folder == null"><strong>{{ i18n.ts.emptyDrive }}</strong><br/>{{ i18n.t('empty-drive-description') }}</div> <div v-if="!draghover && folder == null"><strong>{{ i18n.ts.emptyDrive }}</strong><br/>{{ i18n.ts['empty-drive-description'] }}</div>
<div v-if="!draghover && folder != null">{{ i18n.ts.emptyFolder }}</div> <div v-if="!draghover && folder != null">{{ i18n.ts.emptyFolder }}</div>
</div> </div>
</div> </div>

View File

@ -84,7 +84,7 @@ async function onClick() {
if (isFollowing.value) { if (isFollowing.value) {
const { canceled } = await os.confirm({ const { canceled } = await os.confirm({
type: 'warning', type: 'warning',
text: i18n.t('unfollowConfirm', { name: props.user.name || props.user.username }), text: i18n.tsx.unfollowConfirm({ name: props.user.name || props.user.username }),
}); });
if (canceled) return; if (canceled) return;

View File

@ -0,0 +1,210 @@
<!--
SPDX-FileCopyrightText: syuilo and other misskey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div
ref="rootEl"
:class="[$style.transitionRoot, (defaultStore.state.animation && $style.enableAnimation)]"
@touchstart="touchStart"
@touchmove="touchMove"
@touchend="touchEnd"
>
<Transition
:class="[$style.transitionChildren, { [$style.swiping]: isSwipingForClass }]"
:enterActiveClass="$style.swipeAnimation_enterActive"
:leaveActiveClass="$style.swipeAnimation_leaveActive"
:enterFromClass="transitionName === 'swipeAnimationLeft' ? $style.swipeAnimationLeft_enterFrom : $style.swipeAnimationRight_enterFrom"
:leaveToClass="transitionName === 'swipeAnimationLeft' ? $style.swipeAnimationLeft_leaveTo : $style.swipeAnimationRight_leaveTo"
:style="`--swipe: ${pullDistance}px;`"
>
<!-- 注意slot内の最上位要素に動的にkeyを設定すること -->
<!-- 各最上位要素にユニークなkeyの指定がないとTransitionがうまく動きません -->
<slot></slot>
</Transition>
</div>
</template>
<script lang="ts" setup>
import { ref, shallowRef, computed, nextTick, watch } from 'vue';
import type { Tab } from '@/components/global/MkPageHeader.tabs.vue';
import { defaultStore } from '@/store.js';
const rootEl = shallowRef<HTMLDivElement>();
// eslint-disable-next-line no-undef
const tabModel = defineModel<string>('tab');
const props = defineProps<{
tabs: Tab[];
}>();
const emit = defineEmits<{
(ev: 'swiped', newKey: string, direction: 'left' | 'right'): void;
}>();
// //
//
const MIN_SWIPE_DISTANCE = 50;
//
const SWIPE_DISTANCE_THRESHOLD = 125;
// Y
const SWIPE_ABORT_Y_THRESHOLD = 75;
//
const MAX_SWIPE_DISTANCE = 150;
// //
let startScreenX: number | null = null;
let startScreenY: number | null = null;
const currentTabIndex = computed(() => props.tabs.findIndex(tab => tab.key === tabModel.value));
const pullDistance = ref(0);
const isSwiping = ref(false);
const isSwipingForClass = ref(false);
let swipeAborted = false;
function touchStart(event: TouchEvent) {
if (!defaultStore.reactiveState.enableHorizontalSwipe.value) return;
if (event.touches.length !== 1) return;
startScreenX = event.touches[0].screenX;
startScreenY = event.touches[0].screenY;
}
function touchMove(event: TouchEvent) {
if (!defaultStore.reactiveState.enableHorizontalSwipe.value) return;
if (event.touches.length !== 1) return;
if (startScreenX == null || startScreenY == null) return;
if (swipeAborted) return;
let distanceX = event.touches[0].screenX - startScreenX;
let distanceY = event.touches[0].screenY - startScreenY;
if (Math.abs(distanceY) > SWIPE_ABORT_Y_THRESHOLD) {
swipeAborted = true;
pullDistance.value = 0;
isSwiping.value = false;
setTimeout(() => {
isSwipingForClass.value = false;
}, 400);
return;
}
if (Math.abs(distanceX) < MIN_SWIPE_DISTANCE) return;
if (Math.abs(distanceX) > MAX_SWIPE_DISTANCE) return;
if (currentTabIndex.value === 0 || props.tabs[currentTabIndex.value - 1].onClick) {
distanceX = Math.min(distanceX, 0);
}
if (currentTabIndex.value === props.tabs.length - 1 || props.tabs[currentTabIndex.value + 1].onClick) {
distanceX = Math.max(distanceX, 0);
}
if (distanceX === 0) return;
isSwiping.value = true;
isSwipingForClass.value = true;
nextTick(() => {
// 1.5px
if (Math.abs(distanceX - pullDistance.value) < 1.5) return;
pullDistance.value = distanceX;
});
}
function touchEnd(event: TouchEvent) {
if (swipeAborted) {
swipeAborted = false;
return;
}
if (!defaultStore.reactiveState.enableHorizontalSwipe.value) return;
if (event.touches.length !== 0) return;
if (startScreenX == null) return;
if (!isSwiping.value) return;
const distance = event.changedTouches[0].screenX - startScreenX;
if (Math.abs(distance) > SWIPE_DISTANCE_THRESHOLD) {
if (distance > 0) {
if (props.tabs[currentTabIndex.value - 1] && !props.tabs[currentTabIndex.value - 1].onClick) {
tabModel.value = props.tabs[currentTabIndex.value - 1].key;
emit('swiped', props.tabs[currentTabIndex.value - 1].key, 'right');
}
} else {
if (props.tabs[currentTabIndex.value + 1] && !props.tabs[currentTabIndex.value + 1].onClick) {
tabModel.value = props.tabs[currentTabIndex.value + 1].key;
emit('swiped', props.tabs[currentTabIndex.value + 1].key, 'left');
}
}
}
pullDistance.value = 0;
isSwiping.value = false;
setTimeout(() => {
isSwipingForClass.value = false;
}, 400);
}
const transitionName = ref<'swipeAnimationLeft' | 'swipeAnimationRight' | undefined>(undefined);
watch(tabModel, (newTab, oldTab) => {
const newIndex = props.tabs.findIndex(tab => tab.key === newTab);
const oldIndex = props.tabs.findIndex(tab => tab.key === oldTab);
if (oldIndex >= 0 && newIndex && oldIndex < newIndex) {
transitionName.value = 'swipeAnimationLeft';
} else {
transitionName.value = 'swipeAnimationRight';
}
window.setTimeout(() => {
transitionName.value = undefined;
}, 400);
});
</script>
<style lang="scss" module>
.transitionRoot.enableAnimation {
display: grid;
grid-template-columns: 100%;
overflow: clip;
.transitionChildren {
grid-area: 1 / 1 / 2 / 2;
transform: translateX(var(--swipe));
&.swipeAnimation_enterActive,
&.swipeAnimation_leaveActive {
transition: transform .3s cubic-bezier(0.65, 0.05, 0.36, 1);
}
&.swipeAnimationRight_leaveTo,
&.swipeAnimationLeft_enterFrom {
transform: translateX(calc(100% + 24px));
}
&.swipeAnimationRight_enterFrom,
&.swipeAnimationLeft_leaveTo {
transform: translateX(calc(-100% - 24px));
}
}
}
.swiping {
transition: transform .2s ease-out;
}
</style>

View File

@ -22,7 +22,6 @@ SPDX-License-Identifier: AGPL-3.0-only
<audio <audio
ref="audioEl" ref="audioEl"
preload="metadata" preload="metadata"
:class="$style.audio"
> >
<source :src="audio.url"> <source :src="audio.url">
</audio> </audio>
@ -138,7 +137,7 @@ const rangePercent = computed({
audioEl.value.currentTime = to * durationMs.value / 1000; audioEl.value.currentTime = to * durationMs.value / 1000;
}, },
}); });
const volume = ref(.5); const volume = ref(.25);
const bufferedEnd = ref(0); const bufferedEnd = ref(0);
const bufferedDataRatio = computed(() => { const bufferedDataRatio = computed(() => {
if (!audioEl.value) return 0; if (!audioEl.value) return 0;
@ -161,7 +160,7 @@ function togglePlayPause() {
function toggleMute() { function toggleMute() {
if (volume.value === 0) { if (volume.value === 0) {
volume.value = .5; volume.value = .25;
} else { } else {
volume.value = 0; volume.value = 0;
} }
@ -207,7 +206,7 @@ function init() {
isActuallyPlaying.value = false; isActuallyPlaying.value = false;
isPlaying.value = false; isPlaying.value = false;
}); });
durationMs.value = audioEl.value.duration * 1000; durationMs.value = audioEl.value.duration * 1000;
audioEl.value.addEventListener('durationchange', () => { audioEl.value.addEventListener('durationchange', () => {
if (audioEl.value) { if (audioEl.value) {

View File

@ -5,9 +5,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<!-- Media系専用のinput range --> <!-- Media系専用のinput range -->
<template> <template>
<div :class="$style.controlsSeekbar" :style="sliderBgWhite ? '--sliderBg: rgba(255,255,255,.25);' : '--sliderBg: var(--scrollbarHandle);'"> <div :style="sliderBgWhite ? '--sliderBg: rgba(255,255,255,.25);' : '--sliderBg: var(--scrollbarHandle);'">
<progress v-if="buffer !== undefined" :class="$style.buffer" :value="isNaN(buffer) ? 0 : buffer" min="0" max="1">{{ Math.round(buffer * 100) }}% buffered</progress> <div :class="$style.controlsSeekbar">
<input v-model="model" :class="$style.seek" :style="`--value: ${modelValue * 100}%;`" type="range" min="0" max="1" step="any" @change="emit('dragEnded', modelValue)"/> <progress v-if="buffer !== undefined" :class="$style.buffer" :value="isNaN(buffer) ? 0 : buffer" min="0" max="1">{{ Math.round(buffer * 100) }}% buffered</progress>
<input v-model="model" :class="$style.seek" :style="`--value: ${modelValue * 100}%;`" type="range" min="0" max="1" step="any" @change="emit('dragEnded', modelValue)"/>
</div>
</div> </div>
</template> </template>

View File

@ -176,7 +176,7 @@ const rangePercent = computed({
videoEl.value.currentTime = to * durationMs.value / 1000; videoEl.value.currentTime = to * durationMs.value / 1000;
}, },
}); });
const volume = ref(.5); const volume = ref(.25);
const bufferedEnd = ref(0); const bufferedEnd = ref(0);
const bufferedDataRatio = computed(() => { const bufferedDataRatio = computed(() => {
if (!videoEl.value) return 0; if (!videoEl.value) return 0;
@ -236,7 +236,7 @@ function toggleFullscreen() {
function toggleMute() { function toggleMute() {
if (volume.value === 0) { if (volume.value === 0) {
volume.value = .5; volume.value = .25;
} else { } else {
volume.value = 0; volume.value = 0;
} }
@ -535,6 +535,9 @@ onDeactivated(() => {
.seekbarRoot { .seekbarRoot {
grid-area: seekbar; grid-area: seekbar;
/* ▼シークバー操作をやりやすくするためにクリックイベントが伝播されないエリアを拡張する */
margin: -10px;
padding: 10px;
} }
@container (min-width: 500px) { @container (min-width: 500px) {

View File

@ -73,7 +73,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-if="translating || translation" :class="$style.translation"> <div v-if="translating || translation" :class="$style.translation">
<MkLoading v-if="translating" mini/> <MkLoading v-if="translating" mini/>
<div v-else> <div v-else>
<b>{{ i18n.t('translatedFrom', { x: translation.sourceLang }) }}: </b> <b>{{ i18n.tsx.translatedFrom({ x: translation.sourceLang }) }}: </b>
<Mfm :text="translation.text" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis"/> <Mfm :text="translation.text" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis"/>
</div> </div>
</div> </div>

View File

@ -87,7 +87,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-if="translating || translation" :class="$style.translation"> <div v-if="translating || translation" :class="$style.translation">
<MkLoading v-if="translating" mini/> <MkLoading v-if="translating" mini/>
<div v-else> <div v-else>
<b>{{ i18n.t('translatedFrom', { x: translation.sourceLang }) }}: </b> <b>{{ i18n.tsx.translatedFrom({ x: translation.sourceLang }) }}: </b>
<Mfm :text="translation.text" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis"/> <Mfm :text="translation.text" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis"/>
</div> </div>
</div> </div>

View File

@ -56,8 +56,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<span v-else-if="notification.type === 'achievementEarned'">{{ i18n.ts._notification.achievementEarned }}</span> <span v-else-if="notification.type === 'achievementEarned'">{{ i18n.ts._notification.achievementEarned }}</span>
<span v-else-if="notification.type === 'test'">{{ i18n.ts._notification.testNotification }}</span> <span v-else-if="notification.type === 'test'">{{ i18n.ts._notification.testNotification }}</span>
<MkA v-else-if="notification.user" v-user-preview="notification.user.id" :class="$style.headerName" :to="userPage(notification.user)"><MkUserName :user="notification.user"/></MkA> <MkA v-else-if="notification.user" v-user-preview="notification.user.id" :class="$style.headerName" :to="userPage(notification.user)"><MkUserName :user="notification.user"/></MkA>
<span v-else-if="notification.type === 'reaction:grouped'">{{ i18n.t('_notification.reactedBySomeUsers', { n: notification.reactions.length }) }}</span> <span v-else-if="notification.type === 'reaction:grouped'">{{ i18n.tsx._notification.reactedBySomeUsers({ n: notification.reactions.length }) }}</span>
<span v-else-if="notification.type === 'renote:grouped'">{{ i18n.t('_notification.renotedBySomeUsers', { n: notification.users.length }) }}</span> <span v-else-if="notification.type === 'renote:grouped'">{{ i18n.tsx._notification.renotedBySomeUsers({ n: notification.users.length }) }}</span>
<span v-else>{{ notification.header }}</span> <span v-else>{{ notification.header }}</span>
<MkTime v-if="withTime" :time="notification.createdAt" :class="$style.headerTime"/> <MkTime v-if="withTime" :time="notification.createdAt" :class="$style.headerTime"/>
</header> </header>

View File

@ -23,7 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkButton inline @click="disableAll">{{ i18n.ts.disableAll }}</MkButton> <MkButton inline @click="disableAll">{{ i18n.ts.disableAll }}</MkButton>
<MkButton inline @click="enableAll">{{ i18n.ts.enableAll }}</MkButton> <MkButton inline @click="enableAll">{{ i18n.ts.enableAll }}</MkButton>
</div> </div>
<MkSwitch v-for="ntype in notificationTypes" :key="ntype" v-model="typesMap[ntype].value">{{ i18n.t(`_notification._types.${ntype}`) }}</MkSwitch> <MkSwitch v-for="ntype in notificationTypes" :key="ntype" v-model="typesMap[ntype].value">{{ i18n.ts._notification._types[ntype] }}</MkSwitch>
</div> </div>
</MkSpacer> </MkSpacer>
</MkModalWindow> </MkModalWindow>

View File

@ -11,12 +11,12 @@ SPDX-License-Identifier: AGPL-3.0-only
<span :class="$style.fg"> <span :class="$style.fg">
<template v-if="choice.isVoted"><i class="ti ti-check" style="margin-right: 4px; color: var(--accent);"></i></template> <template v-if="choice.isVoted"><i class="ti ti-check" style="margin-right: 4px; color: var(--accent);"></i></template>
<Mfm :text="choice.text" :plain="true"/> <Mfm :text="choice.text" :plain="true"/>
<span v-if="showResult" style="margin-left: 4px; opacity: 0.7;">({{ i18n.t('_poll.votesCount', { n: choice.votes }) }})</span> <span v-if="showResult" style="margin-left: 4px; opacity: 0.7;">({{ i18n.tsx._poll.votesCount({ n: choice.votes }) }})</span>
</span> </span>
</li> </li>
</ul> </ul>
<p v-if="!readOnly" :class="$style.info"> <p v-if="!readOnly" :class="$style.info">
<span>{{ i18n.t('_poll.totalVotes', { n: total }) }}</span> <span>{{ i18n.tsx._poll.totalVotes({ n: total }) }}</span>
<span> · </span> <span> · </span>
<a v-if="!closed && !isVoted" style="color: inherit;" @click="showResult = !showResult">{{ showResult ? i18n.ts._poll.vote : i18n.ts._poll.showResult }}</a> <a v-if="!closed && !isVoted" style="color: inherit;" @click="showResult = !showResult">{{ showResult ? i18n.ts._poll.vote : i18n.ts._poll.showResult }}</a>
<span v-if="isVoted">{{ i18n.ts._poll.voted }}</span> <span v-if="isVoted">{{ i18n.ts._poll.voted }}</span>
@ -47,10 +47,11 @@ const remaining = ref(-1);
const total = computed(() => sum(props.note.poll.choices.map(x => x.votes))); const total = computed(() => sum(props.note.poll.choices.map(x => x.votes)));
const closed = computed(() => remaining.value === 0); const closed = computed(() => remaining.value === 0);
const isVoted = computed(() => !props.note.poll.multiple && props.note.poll.choices.some(c => c.isVoted)); const isVoted = computed(() => !props.note.poll.multiple && props.note.poll.choices.some(c => c.isVoted));
const timer = computed(() => i18n.t( const timer = computed(() => i18n.tsx._poll[
remaining.value >= 86400 ? '_poll.remainingDays' : remaining.value >= 86400 ? 'remainingDays' :
remaining.value >= 3600 ? '_poll.remainingHours' : remaining.value >= 3600 ? 'remainingHours' :
remaining.value >= 60 ? '_poll.remainingMinutes' : '_poll.remainingSeconds', { remaining.value >= 60 ? 'remainingMinutes' : 'remainingSeconds'
]({
s: Math.floor(remaining.value % 60), s: Math.floor(remaining.value % 60),
m: Math.floor(remaining.value / 60) % 60, m: Math.floor(remaining.value / 60) % 60,
h: Math.floor(remaining.value / 3600) % 24, h: Math.floor(remaining.value / 3600) % 24,
@ -81,7 +82,7 @@ const vote = async (id) => {
const { canceled } = await os.confirm({ const { canceled } = await os.confirm({
type: 'question', type: 'question',
text: i18n.t('voteConfirm', { choice: props.note.poll.choices[id].text }), text: i18n.tsx.voteConfirm({ choice: props.note.poll.choices[id].text }),
}); });
if (canceled) return; if (canceled) return;

View File

@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</p> </p>
<ul> <ul>
<li v-for="(choice, i) in choices" :key="i"> <li v-for="(choice, i) in choices" :key="i">
<MkInput class="input" small :modelValue="choice" :placeholder="i18n.t('_poll.choiceN', { n: i + 1 })" @update:modelValue="onInput(i, $event)"> <MkInput class="input" small :modelValue="choice" :placeholder="i18n.tsx._poll.choiceN({ n: i + 1 })" @update:modelValue="onInput(i, $event)">
</MkInput> </MkInput>
<button class="_button" @click="remove(i)"> <button class="_button" @click="remove(i)">
<i class="ti ti-x"></i> <i class="ti ti-x"></i>

View File

@ -18,6 +18,9 @@ export default defineComponent({
watch(value, () => { watch(value, () => {
context.emit('update:modelValue', value.value); context.emit('update:modelValue', value.value);
}); });
watch(() => props.modelValue, v => {
value.value = v;
});
if (!context.slots.default) return null; if (!context.slots.default) return null;
let options = context.slots.default(); let options = context.slots.default();
const label = context.slots.label && context.slots.label(); const label = context.slots.label && context.slots.label();

View File

@ -52,7 +52,7 @@ const props = defineProps<{
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
(ev: 'change', _ev: KeyboardEvent): void; (ev: 'changeByUser'): void;
(ev: 'update:modelValue', value: string | null): void; (ev: 'update:modelValue', value: string | null): void;
}>(); }>();
@ -77,7 +77,6 @@ const height =
const focus = () => inputEl.value.focus(); const focus = () => inputEl.value.focus();
const onInput = (ev) => { const onInput = (ev) => {
changed.value = true; changed.value = true;
emit('change', ev);
}; };
const updated = () => { const updated = () => {
@ -136,6 +135,7 @@ function show(ev: MouseEvent) {
active: computed(() => v.value === option.props.value), active: computed(() => v.value === option.props.value),
action: () => { action: () => {
v.value = option.props.value; v.value = option.props.value;
emit('changeByUser', v.value);
}, },
}); });
}; };

View File

@ -263,7 +263,7 @@ async function onSubmit(): Promise<void> {
os.alert({ os.alert({
type: 'success', type: 'success',
title: i18n.ts._signup.almostThere, title: i18n.ts._signup.almostThere,
text: i18n.t('_signup.emailSent', { email: email.value }), text: i18n.tsx._signup.emailSent({ email: email.value }),
}); });
emit('signupEmailPending'); emit('signupEmailPending');
} else { } else {

View File

@ -105,7 +105,7 @@ async function updateAgreeServerRules(v: boolean) {
const confirm = await os.confirm({ const confirm = await os.confirm({
type: 'question', type: 'question',
title: i18n.ts.doYouAgree, title: i18n.ts.doYouAgree,
text: i18n.t('iHaveReadXCarefullyAndAgree', { x: i18n.ts.serverRules }), text: i18n.tsx.iHaveReadXCarefullyAndAgree({ x: i18n.ts.serverRules }),
}); });
if (confirm.canceled) return; if (confirm.canceled) return;
agreeServerRules.value = true; agreeServerRules.value = true;
@ -119,7 +119,7 @@ async function updateAgreeTosAndPrivacyPolicy(v: boolean) {
const confirm = await os.confirm({ const confirm = await os.confirm({
type: 'question', type: 'question',
title: i18n.ts.doYouAgree, title: i18n.ts.doYouAgree,
text: i18n.t('iHaveReadXCarefullyAndAgree', { text: i18n.tsx.iHaveReadXCarefullyAndAgree({
x: tosPrivacyPolicyLabel.value, x: tosPrivacyPolicyLabel.value,
}), }),
}); });
@ -135,7 +135,7 @@ async function updateAgreeNote(v: boolean) {
const confirm = await os.confirm({ const confirm = await os.confirm({
type: 'question', type: 'question',
title: i18n.ts.doYouAgree, title: i18n.ts.doYouAgree,
text: i18n.t('iHaveReadXCarefullyAndAgree', { x: i18n.ts.basicNotesBeforeCreateAccount }), text: i18n.tsx.iHaveReadXCarefullyAndAgree({ x: i18n.ts.basicNotesBeforeCreateAccount }),
}); });
if (confirm.canceled) return; if (confirm.canceled) return;
agreeNote.value = true; agreeNote.value = true;

View File

@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkA v-if="note.renoteId" :class="$style.rp" :to="`/notes/${note.renoteId}`">RN: ...</MkA> <MkA v-if="note.renoteId" :class="$style.rp" :to="`/notes/${note.renoteId}`">RN: ...</MkA>
</div> </div>
<details v-if="note.files.length > 0"> <details v-if="note.files.length > 0">
<summary>({{ i18n.t('withNFiles', { n: note.files.length }) }})</summary> <summary>({{ i18n.tsx.withNFiles({ n: note.files.length }) }})</summary>
<MkMediaList :mediaList="note.files"/> <MkMediaList :mediaList="note.files"/>
</details> </details>
<details v-if="note.poll"> <details v-if="note.poll">

View File

@ -33,12 +33,12 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkButton inline @click="enableAll">{{ i18n.ts.enableAll }}</MkButton> <MkButton inline @click="enableAll">{{ i18n.ts.enableAll }}</MkButton>
</div> </div>
<div class="_gaps_s"> <div class="_gaps_s">
<MkSwitch v-for="kind in Object.keys(permissionSwitches)" :key="kind" v-model="permissionSwitches[kind]">{{ i18n.t(`_permissions.${kind}`) }}</MkSwitch> <MkSwitch v-for="kind in Object.keys(permissionSwitches)" :key="kind" v-model="permissionSwitches[kind]">{{ i18n.ts._permissions[kind] }}</MkSwitch>
</div> </div>
<div v-if="iAmAdmin" :class="$style.adminPermissions"> <div v-if="iAmAdmin" :class="$style.adminPermissions">
<div :class="$style.adminPermissionsHeader"><b>{{ i18n.ts.adminPermission }}</b></div> <div :class="$style.adminPermissionsHeader"><b>{{ i18n.ts.adminPermission }}</b></div>
<div class="_gaps_s"> <div class="_gaps_s">
<MkSwitch v-for="kind in Object.keys(permissionSwitchesForAdmin)" :key="kind" v-model="permissionSwitchesForAdmin[kind]">{{ i18n.t(`_permissions.${kind}`) }}</MkSwitch> <MkSwitch v-for="kind in Object.keys(permissionSwitchesForAdmin)" :key="kind" v-model="permissionSwitchesForAdmin[kind]">{{ i18n.ts._permissions[kind] }}</MkSwitch>
</div> </div>
</div> </div>
</div> </div>

View File

@ -133,7 +133,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<a href="https://misskey-hub.net/docs/for-users/" target="_blank" class="_link">{{ i18n.ts.help }}</a> <a href="https://misskey-hub.net/docs/for-users/" target="_blank" class="_link">{{ i18n.ts.help }}</a>
</template> </template>
</I18n> </I18n>
<div>{{ i18n.t('_initialAccountSetting.haveFun', { name: instance.name ?? host }) }}</div> <div>{{ i18n.tsx._initialAccountSetting.haveFun({ name: instance.name ?? host }) }}</div>
<div class="_buttonsCenter" style="margin-top: 16px;"> <div class="_buttonsCenter" style="margin-top: 16px;">
<MkButton v-if="initialPage !== 4" rounded @click="page--"><i class="ti ti-arrow-left"></i> {{ i18n.ts.goBack }}</MkButton> <MkButton v-if="initialPage !== 4" rounded @click="page--"><i class="ti ti-arrow-left"></i> {{ i18n.ts.goBack }}</MkButton>
<MkButton rounded primary gradate @click="close(false)">{{ i18n.ts.close }}</MkButton> <MkButton rounded primary gradate @click="close(false)">{{ i18n.ts.close }}</MkButton>

View File

@ -118,7 +118,7 @@ async function done() {
async function del() { async function del() {
const { canceled } = await os.confirm({ const { canceled } = await os.confirm({
type: 'warning', type: 'warning',
text: i18n.t('removeAreYouSure', { x: title.value }), text: i18n.tsx.removeAreYouSure({ x: title.value }),
}); });
if (canceled) return; if (canceled) return;

View File

@ -85,7 +85,7 @@ const recentUsers = ref<Misskey.entities.UserDetailed[]>([]);
const selected = ref<Misskey.entities.UserDetailed | null>(null); const selected = ref<Misskey.entities.UserDetailed | null>(null);
const dialogEl = ref(); const dialogEl = ref();
const search = () => { function search() {
if (username.value === '' && host.value === '') { if (username.value === '' && host.value === '') {
users.value = []; users.value = [];
return; return;
@ -98,9 +98,9 @@ const search = () => {
}).then(_users => { }).then(_users => {
users.value = _users; users.value = _users;
}); });
}; }
const ok = () => { function ok() {
if (selected.value == null) return; if (selected.value == null) return;
emit('ok', selected.value); emit('ok', selected.value);
dialogEl.value.close(); dialogEl.value.close();
@ -110,12 +110,12 @@ const ok = () => {
recents = recents.filter(x => x !== selected.value.id); recents = recents.filter(x => x !== selected.value.id);
recents.unshift(selected.value.id); recents.unshift(selected.value.id);
defaultStore.set('recentlyUsedUsers', recents.splice(0, 16)); defaultStore.set('recentlyUsedUsers', recents.splice(0, 16));
}; }
const cancel = () => { function cancel() {
emit('cancel'); emit('cancel');
dialogEl.value.close(); dialogEl.value.close();
}; }
onMounted(() => { onMounted(() => {
misskeyApi('users/show', { misskeyApi('users/show', {

View File

@ -68,7 +68,7 @@ function setAvatar(ev) {
const { canceled } = await os.confirm({ const { canceled } = await os.confirm({
type: 'question', type: 'question',
text: i18n.t('cropImageAsk'), text: i18n.ts.cropImageAsk,
okText: i18n.ts.cropYes, okText: i18n.ts.cropYes,
cancelText: i18n.ts.cropNo, cancelText: i18n.ts.cropNo,
}); });

View File

@ -93,7 +93,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="_gaps" style="text-align: center;"> <div class="_gaps" style="text-align: center;">
<i class="ti ti-bell-ringing-2" style="display: block; margin: auto; font-size: 3em; color: var(--accent);"></i> <i class="ti ti-bell-ringing-2" style="display: block; margin: auto; font-size: 3em; color: var(--accent);"></i>
<div style="font-size: 120%;">{{ i18n.ts.pushNotification }}</div> <div style="font-size: 120%;">{{ i18n.ts.pushNotification }}</div>
<div style="padding: 0 16px;">{{ i18n.t('_initialAccountSetting.pushNotificationDescription', { name: instance.name ?? host }) }}</div> <div style="padding: 0 16px;">{{ i18n.tsx._initialAccountSetting.pushNotificationDescription({ name: instance.name ?? host }) }}</div>
<MkPushNotificationAllowButton primary showOnlyToRegister style="margin: 0 auto;"/> <MkPushNotificationAllowButton primary showOnlyToRegister style="margin: 0 auto;"/>
<div class="_buttonsCenter" style="margin-top: 16px;"> <div class="_buttonsCenter" style="margin-top: 16px;">
<MkButton rounded data-cy-user-setup-back @click="page--"><i class="ti ti-arrow-left"></i> {{ i18n.ts.goBack }}</MkButton> <MkButton rounded data-cy-user-setup-back @click="page--"><i class="ti ti-arrow-left"></i> {{ i18n.ts.goBack }}</MkButton>
@ -110,7 +110,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="_gaps" style="text-align: center;"> <div class="_gaps" style="text-align: center;">
<i class="ti ti-check" style="display: block; margin: auto; font-size: 3em; color: var(--accent);"></i> <i class="ti ti-check" style="display: block; margin: auto; font-size: 3em; color: var(--accent);"></i>
<div style="font-size: 120%;">{{ i18n.ts._initialAccountSetting.initialAccountSettingCompleted }}</div> <div style="font-size: 120%;">{{ i18n.ts._initialAccountSetting.initialAccountSettingCompleted }}</div>
<div>{{ i18n.t('_initialAccountSetting.youCanContinueTutorial', { name: instance.name ?? host }) }}</div> <div>{{ i18n.tsx._initialAccountSetting.youCanContinueTutorial({ name: instance.name ?? host }) }}</div>
<div class="_buttonsCenter" style="margin-top: 16px;"> <div class="_buttonsCenter" style="margin-top: 16px;">
<MkButton rounded primary gradate data-cy-user-setup-continue @click="launchTutorial()">{{ i18n.ts._initialAccountSetting.startTutorial }} <i class="ti ti-arrow-right"></i></MkButton> <MkButton rounded primary gradate data-cy-user-setup-continue @click="launchTutorial()">{{ i18n.ts._initialAccountSetting.startTutorial }} <i class="ti ti-arrow-right"></i></MkButton>
</div> </div>

View File

@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<header :class="$style.editHeader"> <header :class="$style.editHeader">
<MkSelect v-model="widgetAdderSelected" style="margin-bottom: var(--margin)" data-cy-widget-select> <MkSelect v-model="widgetAdderSelected" style="margin-bottom: var(--margin)" data-cy-widget-select>
<template #label>{{ i18n.ts.selectWidget }}</template> <template #label>{{ i18n.ts.selectWidget }}</template>
<option v-for="widget in widgetDefs" :key="widget" :value="widget">{{ i18n.t(`_widgets.${widget}`) }}</option> <option v-for="widget in widgetDefs" :key="widget" :value="widget">{{ i18n.ts._widgets[widget] }}</option>
</MkSelect> </MkSelect>
<MkButton inline primary data-cy-widget-add @click="addWidget"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton> <MkButton inline primary data-cy-widget-add @click="addWidget"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton>
<MkButton inline @click="$emit('exit')">{{ i18n.ts.close }}</MkButton> <MkButton inline @click="$emit('exit')">{{ i18n.ts.close }}</MkButton>
@ -109,7 +109,7 @@ function onContextmenu(widget: Widget, ev: MouseEvent) {
os.contextMenu([{ os.contextMenu([{
type: 'label', type: 'label',
text: i18n.t(`_widgets.${widget.name}`), text: i18n.ts._widgets[widget.name],
}, { }, {
icon: 'ti ti-settings', icon: 'ti ti-settings',
text: i18n.ts.settings, text: i18n.ts.settings,

View File

@ -0,0 +1,46 @@
<template>
<render/>
</template>
<script setup lang="ts" generic="T extends string | ParameterizedString">
import { computed, h } from 'vue';
import type { ParameterizedString } from '../../../../../locales/index.js';
const props = withDefaults(defineProps<{
src: T;
tag?: string;
// eslint-disable-next-line vue/require-default-prop
textTag?: string;
}>(), {
tag: 'span',
});
const slots = defineSlots<T extends ParameterizedString<infer R> ? { [K in R]: () => unknown } : NonNullable<unknown>>();
const parsed = computed(() => {
let str = props.src as string;
const value: (string | { arg: string; })[] = [];
for (;;) {
const nextBracketOpen = str.indexOf('{');
const nextBracketClose = str.indexOf('}');
if (nextBracketOpen === -1) {
value.push(str);
break;
} else {
if (nextBracketOpen > 0) value.push(str.substring(0, nextBracketOpen));
value.push({
arg: str.substring(nextBracketOpen + 1, nextBracketClose),
});
}
str = str.substring(nextBracketClose + 1);
}
return value;
});
const render = () => {
return h(props.tag, parsed.value.map(x => typeof x === 'string' ? (props.textTag ? h(props.textTag, x) : x) : slots[x.arg]()));
};
</script>

View File

@ -13,6 +13,7 @@ import MkMention from '@/components/MkMention.vue';
import MkEmoji from '@/components/global/MkEmoji.vue'; import MkEmoji from '@/components/global/MkEmoji.vue';
import MkCustomEmoji from '@/components/global/MkCustomEmoji.vue'; import MkCustomEmoji from '@/components/global/MkCustomEmoji.vue';
import MkCode from '@/components/MkCode.vue'; import MkCode from '@/components/MkCode.vue';
import MkCodeInline from '@/components/MkCodeInline.vue';
import MkGoogle from '@/components/MkGoogle.vue'; import MkGoogle from '@/components/MkGoogle.vue';
import MkSparkle from '@/components/MkSparkle.vue'; import MkSparkle from '@/components/MkSparkle.vue';
import MkA from '@/components/global/MkA.vue'; import MkA from '@/components/global/MkA.vue';
@ -373,10 +374,9 @@ export default function(props: MfmProps, context: SetupContext<MfmEvents>) {
} }
case 'inlineCode': { case 'inlineCode': {
return [h(MkCode, { return [h(MkCodeInline, {
key: Math.random(), key: Math.random(),
code: token.props.code, code: token.props.code,
inline: true,
})]; })];
} }

View File

@ -123,7 +123,7 @@ export const DetailNow = {
export const RelativeOneHourAgo = { export const RelativeOneHourAgo = {
...Empty, ...Empty,
async play({ canvasElement }) { async play({ canvasElement }) {
await expect(canvasElement).toHaveTextContent(i18n.t('_ago.hoursAgo', { n: 1 })); await expect(canvasElement).toHaveTextContent(i18n.tsx._ago.hoursAgo({ n: 1 }));
}, },
args: { args: {
...Empty.args, ...Empty.args,
@ -162,7 +162,7 @@ export const DetailOneHourAgo = {
export const RelativeOneDayAgo = { export const RelativeOneDayAgo = {
...Empty, ...Empty,
async play({ canvasElement }) { async play({ canvasElement }) {
await expect(canvasElement).toHaveTextContent(i18n.t('_ago.daysAgo', { n: 1 })); await expect(canvasElement).toHaveTextContent(i18n.tsx._ago.daysAgo({ n: 1 }));
}, },
args: { args: {
...Empty.args, ...Empty.args,
@ -201,7 +201,7 @@ export const DetailOneDayAgo = {
export const RelativeOneWeekAgo = { export const RelativeOneWeekAgo = {
...Empty, ...Empty,
async play({ canvasElement }) { async play({ canvasElement }) {
await expect(canvasElement).toHaveTextContent(i18n.t('_ago.weeksAgo', { n: 1 })); await expect(canvasElement).toHaveTextContent(i18n.tsx._ago.weeksAgo({ n: 1 }));
}, },
args: { args: {
...Empty.args, ...Empty.args,
@ -240,7 +240,7 @@ export const DetailOneWeekAgo = {
export const RelativeOneMonthAgo = { export const RelativeOneMonthAgo = {
...Empty, ...Empty,
async play({ canvasElement }) { async play({ canvasElement }) {
await expect(canvasElement).toHaveTextContent(i18n.t('_ago.monthsAgo', { n: 1 })); await expect(canvasElement).toHaveTextContent(i18n.tsx._ago.monthsAgo({ n: 1 }));
}, },
args: { args: {
...Empty.args, ...Empty.args,
@ -279,7 +279,7 @@ export const DetailOneMonthAgo = {
export const RelativeOneYearAgo = { export const RelativeOneYearAgo = {
...Empty, ...Empty,
async play({ canvasElement }) { async play({ canvasElement }) {
await expect(canvasElement).toHaveTextContent(i18n.t('_ago.yearsAgo', { n: 1 })); await expect(canvasElement).toHaveTextContent(i18n.tsx._ago.yearsAgo({ n: 1 }));
}, },
args: { args: {
...Empty.args, ...Empty.args,

View File

@ -55,21 +55,21 @@ const relative = computed<string>(() => {
if (invalid) return i18n.ts._ago.invalid; if (invalid) return i18n.ts._ago.invalid;
return ( return (
ago.value >= 31536000 ? i18n.t('_ago.yearsAgo', { n: Math.round(ago.value / 31536000).toString() }) : ago.value >= 31536000 ? i18n.tsx._ago.yearsAgo({ n: Math.round(ago.value / 31536000).toString() }) :
ago.value >= 2592000 ? i18n.t('_ago.monthsAgo', { n: Math.round(ago.value / 2592000).toString() }) : ago.value >= 2592000 ? i18n.tsx._ago.monthsAgo({ n: Math.round(ago.value / 2592000).toString() }) :
ago.value >= 604800 ? i18n.t('_ago.weeksAgo', { n: Math.round(ago.value / 604800).toString() }) : ago.value >= 604800 ? i18n.tsx._ago.weeksAgo({ n: Math.round(ago.value / 604800).toString() }) :
ago.value >= 86400 ? i18n.t('_ago.daysAgo', { n: Math.round(ago.value / 86400).toString() }) : ago.value >= 86400 ? i18n.tsx._ago.daysAgo({ n: Math.round(ago.value / 86400).toString() }) :
ago.value >= 3600 ? i18n.t('_ago.hoursAgo', { n: Math.round(ago.value / 3600).toString() }) : ago.value >= 3600 ? i18n.tsx._ago.hoursAgo({ n: Math.round(ago.value / 3600).toString() }) :
ago.value >= 60 ? i18n.t('_ago.minutesAgo', { n: (~~(ago.value / 60)).toString() }) : ago.value >= 60 ? i18n.tsx._ago.minutesAgo({ n: (~~(ago.value / 60)).toString() }) :
ago.value >= 10 ? i18n.t('_ago.secondsAgo', { n: (~~(ago.value % 60)).toString() }) : ago.value >= 10 ? i18n.tsx._ago.secondsAgo({ n: (~~(ago.value % 60)).toString() }) :
ago.value >= -3 ? i18n.ts._ago.justNow : ago.value >= -3 ? i18n.ts._ago.justNow :
ago.value < -31536000 ? i18n.t('_timeIn.years', { n: Math.round(-ago.value / 31536000).toString() }) : ago.value < -31536000 ? i18n.tsx._timeIn.years({ n: Math.round(-ago.value / 31536000).toString() }) :
ago.value < -2592000 ? i18n.t('_timeIn.months', { n: Math.round(-ago.value / 2592000).toString() }) : ago.value < -2592000 ? i18n.tsx._timeIn.months({ n: Math.round(-ago.value / 2592000).toString() }) :
ago.value < -604800 ? i18n.t('_timeIn.weeks', { n: Math.round(-ago.value / 604800).toString() }) : ago.value < -604800 ? i18n.tsx._timeIn.weeks({ n: Math.round(-ago.value / 604800).toString() }) :
ago.value < -86400 ? i18n.t('_timeIn.days', { n: Math.round(-ago.value / 86400).toString() }) : ago.value < -86400 ? i18n.tsx._timeIn.days({ n: Math.round(-ago.value / 86400).toString() }) :
ago.value < -3600 ? i18n.t('_timeIn.hours', { n: Math.round(-ago.value / 3600).toString() }) : ago.value < -3600 ? i18n.tsx._timeIn.hours({ n: Math.round(-ago.value / 3600).toString() }) :
ago.value < -60 ? i18n.t('_timeIn.minutes', { n: (~~(-ago.value / 60)).toString() }) : ago.value < -60 ? i18n.tsx._timeIn.minutes({ n: (~~(-ago.value / 60)).toString() }) :
i18n.t('_timeIn.seconds', { n: (~~(-ago.value % 60)).toString() }) i18n.tsx._timeIn.seconds({ n: (~~(-ago.value % 60)).toString() })
); );
}); });

View File

@ -1,29 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { h } from 'vue';
export default function(props: { src: string; tag?: string; textTag?: string; }, { slots }) {
let str = props.src;
const parsed = [] as (string | { arg: string; })[];
while (true) {
const nextBracketOpen = str.indexOf('{');
const nextBracketClose = str.indexOf('}');
if (nextBracketOpen === -1) {
parsed.push(str);
break;
} else {
if (nextBracketOpen > 0) parsed.push(str.substring(0, nextBracketOpen));
parsed.push({
arg: str.substring(nextBracketOpen + 1, nextBracketClose),
});
}
str = str.substring(nextBracketClose + 1);
}
return h(props.tag ?? 'span', parsed.map(x => typeof x === 'string' ? (props.textTag ? h(props.textTag, x) : x) : slots[x.arg]()));
}

View File

@ -16,7 +16,7 @@ import MkUserName from './global/MkUserName.vue';
import MkEllipsis from './global/MkEllipsis.vue'; import MkEllipsis from './global/MkEllipsis.vue';
import MkTime from './global/MkTime.vue'; import MkTime from './global/MkTime.vue';
import MkUrl from './global/MkUrl.vue'; import MkUrl from './global/MkUrl.vue';
import I18n from './global/i18n.js'; import I18n from './global/I18n.vue';
import RouterView from './global/RouterView.vue'; import RouterView from './global/RouterView.vue';
import MkLoading from './global/MkLoading.vue'; import MkLoading from './global/MkLoading.vue';
import MkError from './global/MkError.vue'; import MkError from './global/MkError.vue';

View File

@ -109,3 +109,27 @@ export const DEFAULT_NOT_FOUND_IMAGE_URL = 'https://xn--931a.moe/assets/not-foun
export const DEFAULT_INFO_IMAGE_URL = 'https://xn--931a.moe/assets/info.jpg'; export const DEFAULT_INFO_IMAGE_URL = 'https://xn--931a.moe/assets/info.jpg';
export const MFM_TAGS = ['tada', 'jelly', 'twitch', 'shake', 'spin', 'jump', 'bounce', 'flip', 'x2', 'x3', 'x4', 'scale', 'position', 'fg', 'bg', 'border', 'font', 'blur', 'rainbow', 'sparkle', 'rotate', 'ruby', 'unixtime']; export const MFM_TAGS = ['tada', 'jelly', 'twitch', 'shake', 'spin', 'jump', 'bounce', 'flip', 'x2', 'x3', 'x4', 'scale', 'position', 'fg', 'bg', 'border', 'font', 'blur', 'rainbow', 'sparkle', 'rotate', 'ruby', 'unixtime'];
export const MFM_PARAMS: Record<typeof MFM_TAGS[number], string[]> = {
tada: ['speed=', 'delay='],
jelly: ['speed=', 'delay='],
twitch: ['speed=', 'delay='],
shake: ['speed=', 'delay='],
spin: ['speed=', 'delay=', 'left', 'alternate', 'x', 'y'],
jump: ['speed=', 'delay='],
bounce: ['speed=', 'delay='],
flip: ['h', 'v'],
x2: [],
x3: [],
x4: [],
scale: ['x=', 'y='],
position: ['x=', 'y='],
fg: ['color='],
bg: ['color='],
border: ['width=', 'style=', 'color=', 'radius=', 'noclip'],
font: ['serif', 'monospace', 'cursive', 'fantasy', 'emoji', 'math'],
blur: [],
rainbow: ['speed=', 'delay='],
rotate: ['deg='],
ruby: [],
unixtime: [],
};

View File

@ -5,7 +5,7 @@
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
export function hms(ms: number, options: { export function hms(ms: number, options?: {
textFormat?: 'colon' | 'locale'; textFormat?: 'colon' | 'locale';
enableSeconds?: boolean; enableSeconds?: boolean;
enableMs?: boolean; enableMs?: boolean;

View File

@ -15,6 +15,7 @@ const page = (loader: AsyncComponentLoader<any>) => defineAsyncComponent({
loadingComponent: MkLoading, loadingComponent: MkLoading,
errorComponent: MkError, errorComponent: MkError,
}); });
const routes = [{ const routes = [{
path: '/@:initUser/pages/:initPageName/view-source', path: '/@:initUser/pages/:initPageName/view-source',
component: page(() => import('@/pages/page-editor/page-editor.vue')), component: page(() => import('@/pages/page-editor/page-editor.vue')),
@ -336,7 +337,12 @@ const routes = [{
component: page(() => import('@/pages/registry.vue')), component: page(() => import('@/pages/registry.vue')),
}, { }, {
path: '/install-extentions', path: '/install-extentions',
component: page(() => import('@/pages/install-extentions.vue')), // Note: This path is kept for compatibility. It may be deleted.
component: page(() => import('@/pages/install-extensions.vue')),
loginRequired: true,
}, {
path: '/install-extensions',
component: page(() => import('@/pages/install-extensions.vue')),
loginRequired: true, loginRequired: true,
}, { }, {
path: '/admin/user/:userId', path: '/admin/user/:userId',
@ -531,18 +537,26 @@ const routes = [{
path: '/timeline/antenna/:antennaId', path: '/timeline/antenna/:antennaId',
component: page(() => import('@/pages/antenna-timeline.vue')), component: page(() => import('@/pages/antenna-timeline.vue')),
loginRequired: true, loginRequired: true,
}, {
path: '/games',
component: page(() => import('@/pages/games.vue')),
loginRequired: true,
}, { }, {
path: '/clicker', path: '/clicker',
component: page(() => import('@/pages/clicker.vue')), component: page(() => import('@/pages/clicker.vue')),
loginRequired: true, loginRequired: true,
}, {
path: '/games',
component: page(() => import('@/pages/games.vue')),
loginRequired: false,
}, { }, {
path: '/bubble-game', path: '/bubble-game',
component: page(() => import('@/pages/drop-and-fusion.vue')), component: page(() => import('@/pages/drop-and-fusion.vue')),
loginRequired: true, loginRequired: true,
}, {
path: '/reversi',
component: page(() => import('@/pages/reversi/index.vue')),
loginRequired: false,
}, {
path: '/reversi/g/:gameId',
component: page(() => import('@/pages/reversi/game.vue')),
loginRequired: false,
}, { }, {
path: '/timeline', path: '/timeline',
component: page(() => import('@/pages/timeline.vue')), component: page(() => import('@/pages/timeline.vue')),

View File

@ -10,6 +10,7 @@ import { I18n } from '@/scripts/i18n.js';
export const i18n = markRaw(new I18n<Locale>(locale)); export const i18n = markRaw(new I18n<Locale>(locale));
export function updateI18n(newLocale) { export function updateI18n(newLocale: Locale) {
i18n.ts = newLocale; // @ts-expect-error -- private field
i18n.locale = newLocale;
} }

View File

@ -419,7 +419,7 @@ export function form(title, form) {
}); });
} }
export async function selectUser(opts: { includeSelf?: boolean } = {}) { export async function selectUser(opts: { includeSelf?: boolean } = {}): Promise<Misskey.entities.UserLite> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
popup(defineAsyncComponent(() => import('@/components/MkUserSelectDialog.vue')), { popup(defineAsyncComponent(() => import('@/components/MkUserSelectDialog.vue')), {
includeSelf: opts.includeSelf, includeSelf: opts.includeSelf,

View File

@ -6,98 +6,100 @@ SPDX-License-Identifier: AGPL-3.0-only
<template> <template>
<MkStickyContainer> <MkStickyContainer>
<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer v-if="tab === 'overview'" :contentMax="600" :marginMin="20"> <MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs">
<div class="_gaps_m"> <MkSpacer v-if="tab === 'overview'" :contentMax="600" :marginMin="20">
<div :class="$style.banner" :style="{ backgroundImage: `url(${ instance.bannerUrl })` }"> <div class="_gaps_m">
<div style="overflow: clip;"> <div :class="$style.banner" :style="{ backgroundImage: `url(${ instance.bannerUrl })` }">
<img :src="instance.iconUrl ?? instance.faviconUrl ?? '/favicon.ico'" alt="" :class="$style.bannerIcon"/> <div style="overflow: clip;">
<div :class="$style.bannerName"> <img :src="instance.iconUrl ?? instance.faviconUrl ?? '/favicon.ico'" alt="" :class="$style.bannerIcon"/>
<b>{{ instance.name ?? host }}</b> <div :class="$style.bannerName">
<b>{{ instance.name ?? host }}</b>
</div>
</div> </div>
</div> </div>
</div>
<MkKeyValue> <MkKeyValue>
<template #key>{{ i18n.ts.description }}</template> <template #key>{{ i18n.ts.description }}</template>
<template #value><div v-html="instance.description"></div></template> <template #value><div v-html="instance.description"></div></template>
</MkKeyValue> </MkKeyValue>
<FormSection>
<div class="_gaps_m">
<MkKeyValue :copy="version">
<template #key>Misskey</template>
<template #value>{{ version }}</template>
</MkKeyValue>
<div v-html="i18n.t('poweredByMisskeyDescription', { name: instance.name ?? host })">
</div>
<FormLink to="/about-misskey">{{ i18n.ts.aboutMisskey }}</FormLink>
</div>
</FormSection>
<FormSection>
<div class="_gaps_m">
<FormSplit>
<MkKeyValue>
<template #key>{{ i18n.ts.administrator }}</template>
<template #value>{{ instance.maintainerName }}</template>
</MkKeyValue>
<MkKeyValue>
<template #key>{{ i18n.ts.contact }}</template>
<template #value>{{ instance.maintainerEmail }}</template>
</MkKeyValue>
</FormSplit>
<FormLink v-if="instance.impressumUrl" :to="instance.impressumUrl" external>{{ i18n.ts.impressum }}</FormLink>
<div class="_gaps_s">
<MkFolder v-if="instance.serverRules.length > 0">
<template #label>{{ i18n.ts.serverRules }}</template>
<ol class="_gaps_s" :class="$style.rules">
<li v-for="item, index in instance.serverRules" :key="index" :class="$style.rule"><div :class="$style.ruleText" v-html="item"></div></li>
</ol>
</MkFolder>
<FormLink v-if="instance.tosUrl" :to="instance.tosUrl" external>{{ i18n.ts.termsOfService }}</FormLink>
<FormLink v-if="instance.privacyPolicyUrl" :to="instance.privacyPolicyUrl" external>{{ i18n.ts.privacyPolicy }}</FormLink>
</div>
</div>
</FormSection>
<FormSuspense :p="initStats">
<FormSection> <FormSection>
<template #label>{{ i18n.ts.statistics }}</template> <div class="_gaps_m">
<FormSplit> <MkKeyValue :copy="version">
<MkKeyValue> <template #key>Misskey</template>
<template #key>{{ i18n.ts.users }}</template> <template #value>{{ version }}</template>
<template #value>{{ number(stats.originalUsersCount) }}</template>
</MkKeyValue> </MkKeyValue>
<MkKeyValue> <div v-html="i18n.tsx.poweredByMisskeyDescription({ name: instance.name ?? host })">
<template #key>{{ i18n.ts.notes }}</template> </div>
<template #value>{{ number(stats.originalNotesCount) }}</template> <FormLink to="/about-misskey">{{ i18n.ts.aboutMisskey }}</FormLink>
</MkKeyValue> </div>
</FormSplit>
</FormSection> </FormSection>
</FormSuspense>
<FormSection> <FormSection>
<template #label>Well-known resources</template> <div class="_gaps_m">
<div class="_gaps_s"> <FormSplit>
<FormLink :to="`/.well-known/host-meta`" external>host-meta</FormLink> <MkKeyValue>
<FormLink :to="`/.well-known/host-meta.json`" external>host-meta.json</FormLink> <template #key>{{ i18n.ts.administrator }}</template>
<FormLink :to="`/.well-known/nodeinfo`" external>nodeinfo</FormLink> <template #value>{{ instance.maintainerName }}</template>
<FormLink :to="`/robots.txt`" external>robots.txt</FormLink> </MkKeyValue>
<FormLink :to="`/manifest.json`" external>manifest.json</FormLink> <MkKeyValue>
</div> <template #key>{{ i18n.ts.contact }}</template>
</FormSection> <template #value>{{ instance.maintainerEmail }}</template>
</div> </MkKeyValue>
</MkSpacer> </FormSplit>
<MkSpacer v-else-if="tab === 'emojis'" :contentMax="1000" :marginMin="20"> <FormLink v-if="instance.impressumUrl" :to="instance.impressumUrl" external>{{ i18n.ts.impressum }}</FormLink>
<XEmojis/> <div class="_gaps_s">
</MkSpacer> <MkFolder v-if="instance.serverRules.length > 0">
<MkSpacer v-else-if="tab === 'federation'" :contentMax="1000" :marginMin="20"> <template #label>{{ i18n.ts.serverRules }}</template>
<XFederation/>
</MkSpacer> <ol class="_gaps_s" :class="$style.rules">
<MkSpacer v-else-if="tab === 'charts'" :contentMax="1000" :marginMin="20"> <li v-for="item, index in instance.serverRules" :key="index" :class="$style.rule"><div :class="$style.ruleText" v-html="item"></div></li>
<MkInstanceStats/> </ol>
</MkSpacer> </MkFolder>
<FormLink v-if="instance.tosUrl" :to="instance.tosUrl" external>{{ i18n.ts.termsOfService }}</FormLink>
<FormLink v-if="instance.privacyPolicyUrl" :to="instance.privacyPolicyUrl" external>{{ i18n.ts.privacyPolicy }}</FormLink>
</div>
</div>
</FormSection>
<FormSuspense :p="initStats">
<FormSection>
<template #label>{{ i18n.ts.statistics }}</template>
<FormSplit>
<MkKeyValue>
<template #key>{{ i18n.ts.users }}</template>
<template #value>{{ number(stats.originalUsersCount) }}</template>
</MkKeyValue>
<MkKeyValue>
<template #key>{{ i18n.ts.notes }}</template>
<template #value>{{ number(stats.originalNotesCount) }}</template>
</MkKeyValue>
</FormSplit>
</FormSection>
</FormSuspense>
<FormSection>
<template #label>Well-known resources</template>
<div class="_gaps_s">
<FormLink :to="`/.well-known/host-meta`" external>host-meta</FormLink>
<FormLink :to="`/.well-known/host-meta.json`" external>host-meta.json</FormLink>
<FormLink :to="`/.well-known/nodeinfo`" external>nodeinfo</FormLink>
<FormLink :to="`/robots.txt`" external>robots.txt</FormLink>
<FormLink :to="`/manifest.json`" external>manifest.json</FormLink>
</div>
</FormSection>
</div>
</MkSpacer>
<MkSpacer v-else-if="tab === 'emojis'" :contentMax="1000" :marginMin="20">
<XEmojis/>
</MkSpacer>
<MkSpacer v-else-if="tab === 'federation'" :contentMax="1000" :marginMin="20">
<XFederation/>
</MkSpacer>
<MkSpacer v-else-if="tab === 'charts'" :contentMax="1000" :marginMin="20">
<MkInstanceStats/>
</MkSpacer>
</MkHorizontalSwipe>
</MkStickyContainer> </MkStickyContainer>
</template> </template>
@ -114,6 +116,7 @@ import FormSplit from '@/components/form/split.vue';
import MkFolder from '@/components/MkFolder.vue'; import MkFolder from '@/components/MkFolder.vue';
import MkKeyValue from '@/components/MkKeyValue.vue'; import MkKeyValue from '@/components/MkKeyValue.vue';
import MkInstanceStats from '@/components/MkInstanceStats.vue'; import MkInstanceStats from '@/components/MkInstanceStats.vue';
import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
import { misskeyApi } from '@/scripts/misskey-api.js'; import { misskeyApi } from '@/scripts/misskey-api.js';
import number from '@/filters/number.js'; import number from '@/filters/number.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';

View File

@ -104,7 +104,7 @@ fetch();
async function del() { async function del() {
const { canceled } = await os.confirm({ const { canceled } = await os.confirm({
type: 'warning', type: 'warning',
text: i18n.t('removeAreYouSure', { x: file.value.name }), text: i18n.tsx.removeAreYouSure({ x: file.value.name }),
}); });
if (canceled) return; if (canceled) return;

View File

@ -182,9 +182,9 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkSelect> </MkSelect>
</div> </div>
<div class="charts"> <div class="charts">
<div class="label">{{ i18n.t('recentNHours', { n: 90 }) }}</div> <div class="label">{{ i18n.tsx.recentNHours({ n: 90 }) }}</div>
<MkChart class="chart" :src="chartSrc" span="hour" :limit="90" :args="{ user, withoutAll: true }" :detailed="true"></MkChart> <MkChart class="chart" :src="chartSrc" span="hour" :limit="90" :args="{ user, withoutAll: true }" :detailed="true"></MkChart>
<div class="label">{{ i18n.t('recentNDays', { n: 90 }) }}</div> <div class="label">{{ i18n.tsx.recentNDays({ n: 90 }) }}</div>
<MkChart class="chart" :src="chartSrc" span="day" :limit="90" :args="{ user, withoutAll: true }" :detailed="true"></MkChart> <MkChart class="chart" :src="chartSrc" span="day" :limit="90" :args="{ user, withoutAll: true }" :detailed="true"></MkChart>
</div> </div>
</div> </div>
@ -307,7 +307,7 @@ async function resetPassword() {
}); });
os.alert({ os.alert({
type: 'success', type: 'success',
text: i18n.t('newPasswordIs', { password }), text: i18n.tsx.newPasswordIs({ password }),
}); });
} }
} }
@ -390,7 +390,7 @@ async function deleteAccount() {
if (confirm.canceled) return; if (confirm.canceled) return;
const typed = await os.inputText({ const typed = await os.inputText({
text: i18n.t('typeToConfirm', { x: user.value?.username }), text: i18n.tsx.typeToConfirm({ x: user.value?.username }),
}); });
if (typed.canceled) return; if (typed.canceled) return;

View File

@ -160,7 +160,7 @@ function add() {
function remove(ad) { function remove(ad) {
os.confirm({ os.confirm({
type: 'warning', type: 'warning',
text: i18n.t('removeAreYouSure', { x: ad.url }), text: i18n.tsx.removeAreYouSure({ x: ad.url }),
}).then(({ canceled }) => { }).then(({ canceled }) => {
if (canceled) return; if (canceled) return;
ads.value = ads.value.filter(x => x !== ad); ads.value = ads.value.filter(x => x !== ad);

View File

@ -54,7 +54,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSwitch v-model="announcement.needConfirmationToRead" :helpText="i18n.ts._announcement.needConfirmationToReadDescription"> <MkSwitch v-model="announcement.needConfirmationToRead" :helpText="i18n.ts._announcement.needConfirmationToReadDescription">
{{ i18n.ts._announcement.needConfirmationToRead }} {{ i18n.ts._announcement.needConfirmationToRead }}
</MkSwitch> </MkSwitch>
<p v-if="announcement.reads">{{ i18n.t('nUsersRead', { n: announcement.reads }) }}</p> <p v-if="announcement.reads">{{ i18n.tsx.nUsersRead({ n: announcement.reads }) }}</p>
<div class="buttons _buttons"> <div class="buttons _buttons">
<MkButton class="button" inline primary @click="save(announcement)"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton> <MkButton class="button" inline primary @click="save(announcement)"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
<MkButton v-if="announcement.id != null" class="button" inline @click="archive(announcement)"><i class="ti ti-check"></i> {{ i18n.ts._announcement.end }} ({{ i18n.ts.archive }})</MkButton> <MkButton v-if="announcement.id != null" class="button" inline @click="archive(announcement)"><i class="ti ti-check"></i> {{ i18n.ts._announcement.end }} ({{ i18n.ts.archive }})</MkButton>
@ -109,7 +109,7 @@ function add() {
function del(announcement) { function del(announcement) {
os.confirm({ os.confirm({
type: 'warning', type: 'warning',
text: i18n.t('deleteAreYouSure', { x: announcement.title }), text: i18n.tsx.deleteAreYouSure({ x: announcement.title }),
}).then(({ canceled }) => { }).then(({ canceled }) => {
if (canceled) return; if (canceled) return;
announcements.value = announcements.value.filter(x => x !== announcement); announcements.value = announcements.value.filter(x => x !== announcement);

View File

@ -19,10 +19,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #prefix><i class="ti ti-link"></i></template> <template #prefix><i class="ti ti-link"></i></template>
<template #label>{{ i18n.ts._serverSettings.iconUrl }} (App/192px)</template> <template #label>{{ i18n.ts._serverSettings.iconUrl }} (App/192px)</template>
<template #caption> <template #caption>
<div>{{ i18n.t('_serverSettings.appIconDescription', { host: instance.name ?? host }) }}</div> <div>{{ i18n.tsx._serverSettings.appIconDescription({ host: instance.name ?? host }) }}</div>
<div>({{ i18n.ts._serverSettings.appIconUsageExample }})</div> <div>({{ i18n.ts._serverSettings.appIconUsageExample }})</div>
<div>{{ i18n.ts._serverSettings.appIconStyleRecommendation }}</div> <div>{{ i18n.ts._serverSettings.appIconStyleRecommendation }}</div>
<div><strong>{{ i18n.t('_serverSettings.appIconResolutionMustBe', { resolution: '192x192px' }) }}</strong></div> <div><strong>{{ i18n.tsx._serverSettings.appIconResolutionMustBe({ resolution: '192x192px' }) }}</strong></div>
</template> </template>
</MkInput> </MkInput>
@ -30,10 +30,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #prefix><i class="ti ti-link"></i></template> <template #prefix><i class="ti ti-link"></i></template>
<template #label>{{ i18n.ts._serverSettings.iconUrl }} (App/512px)</template> <template #label>{{ i18n.ts._serverSettings.iconUrl }} (App/512px)</template>
<template #caption> <template #caption>
<div>{{ i18n.t('_serverSettings.appIconDescription', { host: instance.name ?? host }) }}</div> <div>{{ i18n.tsx._serverSettings.appIconDescription({ host: instance.name ?? host }) }}</div>
<div>({{ i18n.ts._serverSettings.appIconUsageExample }})</div> <div>({{ i18n.ts._serverSettings.appIconUsageExample }})</div>
<div>{{ i18n.ts._serverSettings.appIconStyleRecommendation }}</div> <div>{{ i18n.ts._serverSettings.appIconStyleRecommendation }}</div>
<div><strong>{{ i18n.t('_serverSettings.appIconResolutionMustBe', { resolution: '512x512px' }) }}</strong></div> <div><strong>{{ i18n.tsx._serverSettings.appIconResolutionMustBe({ resolution: '512x512px' }) }}</strong></div>
</template> </template>
</MkInput> </MkInput>

View File

@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<i v-if="relay.status === 'accepted'" class="ti ti-check" :class="$style.icon" style="color: var(--success);"></i> <i v-if="relay.status === 'accepted'" class="ti ti-check" :class="$style.icon" style="color: var(--success);"></i>
<i v-else-if="relay.status === 'rejected'" class="ti ti-ban" :class="$style.icon" style="color: var(--error);"></i> <i v-else-if="relay.status === 'rejected'" class="ti ti-ban" :class="$style.icon" style="color: var(--error);"></i>
<i v-else class="ti ti-clock" :class="$style.icon"></i> <i v-else class="ti ti-clock" :class="$style.icon"></i>
<span>{{ i18n.t(`_relayStatus.${relay.status}`) }}</span> <span>{{ i18n.ts._relayStatus[relay.status] }}</span>
</div> </div>
<MkButton class="button" inline danger @click="remove(relay.inbox)"><i class="ti ti-trash"></i> {{ i18n.ts.remove }}</MkButton> <MkButton class="button" inline danger @click="remove(relay.inbox)"><i class="ti ti-trash"></i> {{ i18n.ts.remove }}</MkButton>
</div> </div>

View File

@ -104,7 +104,7 @@ function edit() {
async function del() { async function del() {
const { canceled } = await os.confirm({ const { canceled } = await os.confirm({
type: 'warning', type: 'warning',
text: i18n.t('deleteAreYouSure', { x: role.name }), text: i18n.tsx.deleteAreYouSure({ x: role.name }),
}); });
if (canceled) return; if (canceled) return;

View File

@ -71,27 +71,28 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="_gaps_m"> <div class="_gaps_m">
<span>{{ i18n.ts.activeEmailValidationDescription }}</span> <span>{{ i18n.ts.activeEmailValidationDescription }}</span>
<MkSwitch v-model="enableActiveEmailValidation" @update:modelValue="save"> <MkSwitch v-model="enableActiveEmailValidation">
<template #label>Enable</template> <template #label>Enable</template>
</MkSwitch> </MkSwitch>
<MkSwitch v-model="enableVerifymailApi" @update:modelValue="save"> <MkSwitch v-model="enableVerifymailApi">
<template #label>Use Verifymail.io API</template> <template #label>Use Verifymail.io API</template>
</MkSwitch> </MkSwitch>
<MkInput v-model="verifymailAuthKey" @update:modelValue="save"> <MkInput v-model="verifymailAuthKey">
<template #prefix><i class="ti ti-key"></i></template> <template #prefix><i class="ti ti-key"></i></template>
<template #label>Verifymail.io API Auth Key</template> <template #label>Verifymail.io API Auth Key</template>
</MkInput> </MkInput>
<MkSwitch v-model="enableTruemailApi" @update:modelValue="save"> <MkSwitch v-model="enableTruemailApi">
<template #label>Use TrueMail API</template> <template #label>Use TrueMail API</template>
</MkSwitch> </MkSwitch>
<MkInput v-model="truemailInstance" @update:modelValue="save"> <MkInput v-model="truemailInstance">
<template #prefix><i class="ti ti-key"></i></template> <template #prefix><i class="ti ti-key"></i></template>
<template #label>TrueMail API Instance</template> <template #label>TrueMail API Instance</template>
</MkInput> </MkInput>
<MkInput v-model="truemailAuthKey" @update:modelValue="save"> <MkInput v-model="truemailAuthKey">
<template #prefix><i class="ti ti-key"></i></template> <template #prefix><i class="ti ti-key"></i></template>
<template #label>TrueMail API Auth Key</template> <template #label>TrueMail API Auth Key</template>
</MkInput> </MkInput>
<MkButton primary @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
</div> </div>
</MkFolder> </MkFolder>
@ -192,7 +193,10 @@ async function init() {
enableActiveEmailValidation.value = meta.enableActiveEmailValidation; enableActiveEmailValidation.value = meta.enableActiveEmailValidation;
enableVerifymailApi.value = meta.enableVerifymailApi; enableVerifymailApi.value = meta.enableVerifymailApi;
verifymailAuthKey.value = meta.verifymailAuthKey; verifymailAuthKey.value = meta.verifymailAuthKey;
bannedEmailDomains.value = meta.bannedEmailDomains.join('\n'); enableTruemailApi.value = meta.enableTruemailApi;
truemailInstance.value = meta.truemailInstance;
truemailAuthKey.value = meta.truemailAuthKey;
bannedEmailDomains.value = meta.bannedEmailDomains?.join('\n') || "";
} }
function save() { function save() {

View File

@ -7,34 +7,36 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkStickyContainer> <MkStickyContainer>
<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :contentMax="800"> <MkSpacer :contentMax="800">
<div class="_gaps"> <MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs">
<MkInfo v-if="$i && $i.hasUnreadAnnouncement && tab === 'current'" warn>{{ i18n.ts.youHaveUnreadAnnouncements }}</MkInfo> <div :key="tab" class="_gaps">
<MkPagination ref="paginationEl" :key="tab" v-slot="{items}" :pagination="tab === 'current' ? paginationCurrent : paginationPast" class="_gaps"> <MkInfo v-if="$i && $i.hasUnreadAnnouncement && tab === 'current'" warn>{{ i18n.ts.youHaveUnreadAnnouncements }}</MkInfo>
<section v-for="announcement in items" :key="announcement.id" class="_panel" :class="$style.announcement"> <MkPagination ref="paginationEl" :key="tab" v-slot="{items}" :pagination="tab === 'current' ? paginationCurrent : paginationPast" class="_gaps">
<div v-if="announcement.forYou" :class="$style.forYou"><i class="ti ti-pin"></i> {{ i18n.ts.forYou }}</div> <section v-for="announcement in items" :key="announcement.id" class="_panel" :class="$style.announcement">
<div :class="$style.header"> <div v-if="announcement.forYou" :class="$style.forYou"><i class="ti ti-pin"></i> {{ i18n.ts.forYou }}</div>
<span v-if="$i && !announcement.silence && !announcement.isRead" style="margin-right: 0.5em;">🆕</span> <div :class="$style.header">
<span style="margin-right: 0.5em;"> <span v-if="$i && !announcement.silence && !announcement.isRead" style="margin-right: 0.5em;">🆕</span>
<i v-if="announcement.icon === 'info'" class="ti ti-info-circle"></i> <span style="margin-right: 0.5em;">
<i v-else-if="announcement.icon === 'warning'" class="ti ti-alert-triangle" style="color: var(--warn);"></i> <i v-if="announcement.icon === 'info'" class="ti ti-info-circle"></i>
<i v-else-if="announcement.icon === 'error'" class="ti ti-circle-x" style="color: var(--error);"></i> <i v-else-if="announcement.icon === 'warning'" class="ti ti-alert-triangle" style="color: var(--warn);"></i>
<i v-else-if="announcement.icon === 'success'" class="ti ti-check" style="color: var(--success);"></i> <i v-else-if="announcement.icon === 'error'" class="ti ti-circle-x" style="color: var(--error);"></i>
</span> <i v-else-if="announcement.icon === 'success'" class="ti ti-check" style="color: var(--success);"></i>
<span>{{ announcement.title }}</span> </span>
</div> <span>{{ announcement.title }}</span>
<div :class="$style.content">
<Mfm :text="announcement.text"/>
<img v-if="announcement.imageUrl" :src="announcement.imageUrl"/>
<div style="opacity: 0.7; font-size: 85%;">
<MkTime :time="announcement.updatedAt ?? announcement.createdAt" mode="detail"/>
</div> </div>
</div> <div :class="$style.content">
<div v-if="tab !== 'past' && $i && !announcement.silence && !announcement.isRead" :class="$style.footer"> <Mfm :text="announcement.text"/>
<MkButton primary @click="read(announcement)"><i class="ti ti-check"></i> {{ i18n.ts.gotIt }}</MkButton> <img v-if="announcement.imageUrl" :src="announcement.imageUrl"/>
</div> <div style="opacity: 0.7; font-size: 85%;">
</section> <MkTime :time="announcement.updatedAt ?? announcement.createdAt" mode="detail"/>
</MkPagination> </div>
</div> </div>
<div v-if="tab !== 'past' && $i && !announcement.silence && !announcement.isRead" :class="$style.footer">
<MkButton primary @click="read(announcement)"><i class="ti ti-check"></i> {{ i18n.ts.gotIt }}</MkButton>
</div>
</section>
</MkPagination>
</div>
</MkHorizontalSwipe>
</MkSpacer> </MkSpacer>
</MkStickyContainer> </MkStickyContainer>
</template> </template>
@ -44,6 +46,7 @@ import { ref, computed } from 'vue';
import MkPagination from '@/components/MkPagination.vue'; import MkPagination from '@/components/MkPagination.vue';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import MkInfo from '@/components/MkInfo.vue'; import MkInfo from '@/components/MkInfo.vue';
import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js'; import { misskeyApi } from '@/scripts/misskey-api.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
@ -75,7 +78,7 @@ async function read(announcement) {
const confirm = await os.confirm({ const confirm = await os.confirm({
type: 'question', type: 'question',
title: i18n.ts._announcement.readConfirmTitle, title: i18n.ts._announcement.readConfirmTitle,
text: i18n.t('_announcement.readConfirmText', { title: announcement.title }), text: i18n.tsx._announcement.readConfirmText({ title: announcement.title }),
}); });
if (confirm.canceled) return; if (confirm.canceled) return;
} }

View File

@ -6,12 +6,12 @@ SPDX-License-Identifier: AGPL-3.0-only
<template> <template>
<section> <section>
<div v-if="app.permission.length > 0"> <div v-if="app.permission.length > 0">
<p>{{ i18n.t('_auth.permission', { name }) }}</p> <p>{{ i18n.tsx._auth.permission({ name }) }}</p>
<ul> <ul>
<li v-for="p in app.permission" :key="p">{{ i18n.t(`_permissions.${p}`) }}</li> <li v-for="p in app.permission" :key="p">{{ i18n.ts._permissions[p] }}</li>
</ul> </ul>
</div> </div>
<div>{{ i18n.t('_auth.shareAccess', { name: `${name} (${app.id})` }) }}</div> <div>{{ i18n.tsx._auth.shareAccess({ name: `${name} (${app.id})` }) }}</div>
<div :class="$style.buttons"> <div :class="$style.buttons">
<MkButton inline @click="cancel">{{ i18n.ts.cancel }}</MkButton> <MkButton inline @click="cancel">{{ i18n.ts.cancel }}</MkButton>
<MkButton inline primary @click="accept">{{ i18n.ts.accept }}</MkButton> <MkButton inline primary @click="accept">{{ i18n.ts.accept }}</MkButton>

View File

@ -25,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<h1>{{ i18n.ts._auth.denied }}</h1> <h1>{{ i18n.ts._auth.denied }}</h1>
</div> </div>
<div v-if="state == 'accepted' && session"> <div v-if="state == 'accepted' && session">
<h1>{{ session.app.isAuthorized ? i18n.t('already-authorized') : i18n.ts.allowed }}</h1> <h1>{{ session.app.isAuthorized ? i18n.ts['already-authorized'] : i18n.ts.allowed }}</h1>
<p v-if="session.app.callbackUrl"> <p v-if="session.app.callbackUrl">
{{ i18n.ts._auth.callback }} {{ i18n.ts._auth.callback }}
<MkEllipsis/> <MkEllipsis/>

View File

@ -60,7 +60,7 @@ function add() {
function del(avatarDecoration) { function del(avatarDecoration) {
os.confirm({ os.confirm({
type: 'warning', type: 'warning',
text: i18n.t('deleteAreYouSure', { x: avatarDecoration.name }), text: i18n.tsx.deleteAreYouSure({ x: avatarDecoration.name }),
}).then(({ canceled }) => { }).then(({ canceled }) => {
if (canceled) return; if (canceled) return;
avatarDecorations.value = avatarDecorations.value.filter(x => x !== avatarDecoration); avatarDecorations.value = avatarDecorations.value.filter(x => x !== avatarDecoration);

View File

@ -174,7 +174,7 @@ function save() {
async function archive() { async function archive() {
const { canceled } = await os.confirm({ const { canceled } = await os.confirm({
type: 'warning', type: 'warning',
title: i18n.t('channelArchiveConfirmTitle', { name: name.value }), title: i18n.tsx.channelArchiveConfirmTitle({ name: name.value }),
text: i18n.ts.channelArchiveConfirmDescription, text: i18n.ts.channelArchiveConfirmDescription,
}); });

View File

@ -7,53 +7,55 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkStickyContainer> <MkStickyContainer>
<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :contentMax="700" :class="$style.main"> <MkSpacer :contentMax="700" :class="$style.main">
<div v-if="channel && tab === 'overview'" class="_gaps"> <MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs">
<div class="_panel" :class="$style.bannerContainer"> <div v-if="channel && tab === 'overview'" key="overview" class="_gaps">
<XChannelFollowButton :channel="channel" :full="true" :class="$style.subscribe"/> <div class="_panel" :class="$style.bannerContainer">
<MkButton v-if="favorited" v-tooltip="i18n.ts.unfavorite" asLike class="button" rounded primary :class="$style.favorite" @click="unfavorite()"><i class="ti ti-star"></i></MkButton> <XChannelFollowButton :channel="channel" :full="true" :class="$style.subscribe"/>
<MkButton v-else v-tooltip="i18n.ts.favorite" asLike class="button" rounded :class="$style.favorite" @click="favorite()"><i class="ti ti-star"></i></MkButton> <MkButton v-if="favorited" v-tooltip="i18n.ts.unfavorite" asLike class="button" rounded primary :class="$style.favorite" @click="unfavorite()"><i class="ti ti-star"></i></MkButton>
<div :style="{ backgroundImage: channel.bannerUrl ? `url(${channel.bannerUrl})` : undefined }" :class="$style.banner"> <MkButton v-else v-tooltip="i18n.ts.favorite" asLike class="button" rounded :class="$style.favorite" @click="favorite()"><i class="ti ti-star"></i></MkButton>
<div :class="$style.bannerStatus"> <div :style="{ backgroundImage: channel.bannerUrl ? `url(${channel.bannerUrl})` : undefined }" :class="$style.banner">
<div><i class="ti ti-users ti-fw"></i><I18n :src="i18n.ts._channel.usersCount" tag="span" style="margin-left: 4px;"><template #n><b>{{ channel.usersCount }}</b></template></I18n></div> <div :class="$style.bannerStatus">
<div><i class="ti ti-pencil ti-fw"></i><I18n :src="i18n.ts._channel.notesCount" tag="span" style="margin-left: 4px;"><template #n><b>{{ channel.notesCount }}</b></template></I18n></div> <div><i class="ti ti-users ti-fw"></i><I18n :src="i18n.ts._channel.usersCount" tag="span" style="margin-left: 4px;"><template #n><b>{{ channel.usersCount }}</b></template></I18n></div>
<div><i class="ti ti-pencil ti-fw"></i><I18n :src="i18n.ts._channel.notesCount" tag="span" style="margin-left: 4px;"><template #n><b>{{ channel.notesCount }}</b></template></I18n></div>
</div>
<div v-if="channel.isSensitive" :class="$style.sensitiveIndicator">{{ i18n.ts.sensitive }}</div>
<div :class="$style.bannerFade"></div>
</div>
<div v-if="channel.description" :class="$style.description">
<Mfm :text="channel.description" :isNote="false"/>
</div> </div>
<div v-if="channel.isSensitive" :class="$style.sensitiveIndicator">{{ i18n.ts.sensitive }}</div>
<div :class="$style.bannerFade"></div>
</div> </div>
<div v-if="channel.description" :class="$style.description">
<Mfm :text="channel.description" :isNote="false"/> <MkFoldableSection>
<template #header><i class="ti ti-pin ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.pinnedNotes }}</template>
<div v-if="channel.pinnedNotes && channel.pinnedNotes.length > 0" class="_gaps">
<MkNote v-for="note in channel.pinnedNotes" :key="note.id" class="_panel" :note="note"/>
</div>
</MkFoldableSection>
</div>
<div v-if="channel && tab === 'timeline'" key="timeline" class="_gaps">
<MkInfo v-if="channel.isArchived" warn>{{ i18n.ts.thisChannelArchived }}</MkInfo>
<!-- スマホタブレットの場合キーボードが表示されると投稿が見づらくなるのでデスクトップ場合のみ自動でフォーカスを当てる -->
<MkPostForm v-if="$i && defaultStore.reactiveState.showFixedPostFormInChannel.value" :channel="channel" class="post-form _panel" fixed :autofocus="deviceKind === 'desktop'"/>
<MkTimeline :key="channelId" src="channel" :channel="channelId" @before="before" @after="after" @note="miLocalStorage.setItemAsJson(`channelLastReadedAt:${channel.id}`, Date.now())"/>
</div>
<div v-else-if="tab === 'featured'" key="featured">
<MkNotes :pagination="featuredPagination"/>
</div>
<div v-else-if="tab === 'search'" key="search">
<div class="_gaps">
<div>
<MkInput v-model="searchQuery" @enter="search()">
<template #prefix><i class="ti ti-search"></i></template>
</MkInput>
<MkButton primary rounded style="margin-top: 8px;" @click="search()">{{ i18n.ts.search }}</MkButton>
</div>
<MkNotes v-if="searchPagination" :key="searchKey" :pagination="searchPagination"/>
</div> </div>
</div> </div>
</MkHorizontalSwipe>
<MkFoldableSection>
<template #header><i class="ti ti-pin ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.pinnedNotes }}</template>
<div v-if="channel.pinnedNotes && channel.pinnedNotes.length > 0" class="_gaps">
<MkNote v-for="note in channel.pinnedNotes" :key="note.id" class="_panel" :note="note"/>
</div>
</MkFoldableSection>
</div>
<div v-if="channel && tab === 'timeline'" class="_gaps">
<MkInfo v-if="channel.isArchived" warn>{{ i18n.ts.thisChannelArchived }}</MkInfo>
<!-- スマホタブレットの場合キーボードが表示されると投稿が見づらくなるのでデスクトップ場合のみ自動でフォーカスを当てる -->
<MkPostForm v-if="$i && defaultStore.reactiveState.showFixedPostFormInChannel.value" :channel="channel" class="post-form _panel" fixed :autofocus="deviceKind === 'desktop'"/>
<MkTimeline :key="channelId" src="channel" :channel="channelId" @before="before" @after="after" @note="miLocalStorage.setItemAsJson(`channelLastReadedAt:${channel.id}`, Date.now())"/>
</div>
<div v-else-if="tab === 'featured'">
<MkNotes :pagination="featuredPagination"/>
</div>
<div v-else-if="tab === 'search'">
<div class="_gaps">
<div>
<MkInput v-model="searchQuery" @enter="search()">
<template #prefix><i class="ti ti-search"></i></template>
</MkInput>
<MkButton primary rounded style="margin-top: 8px;" @click="search()">{{ i18n.ts.search }}</MkButton>
</div>
<MkNotes v-if="searchPagination" :key="searchKey" :pagination="searchPagination"/>
</div>
</div>
</MkSpacer> </MkSpacer>
<template #footer> <template #footer>
<div :class="$style.footer"> <div :class="$style.footer">
@ -87,6 +89,7 @@ import { defaultStore } from '@/store.js';
import MkNote from '@/components/MkNote.vue'; import MkNote from '@/components/MkNote.vue';
import MkInfo from '@/components/MkInfo.vue'; import MkInfo from '@/components/MkInfo.vue';
import MkFoldableSection from '@/components/MkFoldableSection.vue'; import MkFoldableSection from '@/components/MkFoldableSection.vue';
import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
import { PageHeaderItem } from '@/types/page-header.js'; import { PageHeaderItem } from '@/types/page-header.js';
import { isSupportShare } from '@/scripts/navigator.js'; import { isSupportShare } from '@/scripts/navigator.js';
import copyToClipboard from '@/scripts/copy-to-clipboard.js'; import copyToClipboard from '@/scripts/copy-to-clipboard.js';
@ -100,6 +103,7 @@ const props = defineProps<{
}>(); }>();
const tab = ref('overview'); const tab = ref('overview');
const channel = ref<Misskey.entities.Channel | null>(null); const channel = ref<Misskey.entities.Channel | null>(null);
const favorited = ref(false); const favorited = ref(false);
const searchQuery = ref(''); const searchQuery = ref('');

View File

@ -7,44 +7,46 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkStickyContainer> <MkStickyContainer>
<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :contentMax="700"> <MkSpacer :contentMax="700">
<div v-if="tab === 'search'"> <MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs">
<div class="_gaps"> <div v-if="tab === 'search'" key="search">
<MkInput v-model="searchQuery" :large="true" :autofocus="true" type="search" @enter="search"> <div class="_gaps">
<template #prefix><i class="ti ti-search"></i></template> <MkInput v-model="searchQuery" :large="true" :autofocus="true" type="search" @enter="search">
</MkInput> <template #prefix><i class="ti ti-search"></i></template>
<MkRadios v-model="searchType" @update:modelValue="search()"> </MkInput>
<option value="nameAndDescription">{{ i18n.ts._channel.nameAndDescription }}</option> <MkRadios v-model="searchType" @update:modelValue="search()">
<option value="nameOnly">{{ i18n.ts._channel.nameOnly }}</option> <option value="nameAndDescription">{{ i18n.ts._channel.nameAndDescription }}</option>
</MkRadios> <option value="nameOnly">{{ i18n.ts._channel.nameOnly }}</option>
<MkButton large primary gradate rounded @click="search">{{ i18n.ts.search }}</MkButton> </MkRadios>
</div> <MkButton large primary gradate rounded @click="search">{{ i18n.ts.search }}</MkButton>
</div>
<MkFoldableSection v-if="channelPagination"> <MkFoldableSection v-if="channelPagination">
<template #header>{{ i18n.ts.searchResult }}</template> <template #header>{{ i18n.ts.searchResult }}</template>
<MkChannelList :key="key" :pagination="channelPagination"/> <MkChannelList :key="key" :pagination="channelPagination"/>
</MkFoldableSection> </MkFoldableSection>
</div> </div>
<div v-if="tab === 'featured'"> <div v-if="tab === 'featured'" key="featured">
<MkPagination v-slot="{items}" :pagination="featuredPagination"> <MkPagination v-slot="{items}" :pagination="featuredPagination">
<MkChannelPreview v-for="channel in items" :key="channel.id" class="_margin" :channel="channel"/> <MkChannelPreview v-for="channel in items" :key="channel.id" class="_margin" :channel="channel"/>
</MkPagination> </MkPagination>
</div> </div>
<div v-else-if="tab === 'favorites'"> <div v-else-if="tab === 'favorites'" key="favorites">
<MkPagination v-slot="{items}" :pagination="favoritesPagination"> <MkPagination v-slot="{items}" :pagination="favoritesPagination">
<MkChannelPreview v-for="channel in items" :key="channel.id" class="_margin" :channel="channel"/> <MkChannelPreview v-for="channel in items" :key="channel.id" class="_margin" :channel="channel"/>
</MkPagination> </MkPagination>
</div> </div>
<div v-else-if="tab === 'following'"> <div v-else-if="tab === 'following'" key="following">
<MkPagination v-slot="{items}" :pagination="followingPagination"> <MkPagination v-slot="{items}" :pagination="followingPagination">
<MkChannelPreview v-for="channel in items" :key="channel.id" class="_margin" :channel="channel"/> <MkChannelPreview v-for="channel in items" :key="channel.id" class="_margin" :channel="channel"/>
</MkPagination> </MkPagination>
</div> </div>
<div v-else-if="tab === 'owned'"> <div v-else-if="tab === 'owned'" key="owned">
<MkButton class="new" @click="create()"><i class="ti ti-plus"></i></MkButton> <MkButton class="new" @click="create()"><i class="ti ti-plus"></i></MkButton>
<MkPagination v-slot="{items}" :pagination="ownedPagination"> <MkPagination v-slot="{items}" :pagination="ownedPagination">
<MkChannelPreview v-for="channel in items" :key="channel.id" class="_margin" :channel="channel"/> <MkChannelPreview v-for="channel in items" :key="channel.id" class="_margin" :channel="channel"/>
</MkPagination> </MkPagination>
</div> </div>
</MkHorizontalSwipe>
</MkSpacer> </MkSpacer>
</MkStickyContainer> </MkStickyContainer>
</template> </template>
@ -58,6 +60,7 @@ import MkInput from '@/components/MkInput.vue';
import MkRadios from '@/components/MkRadios.vue'; import MkRadios from '@/components/MkRadios.vue';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import MkFoldableSection from '@/components/MkFoldableSection.vue'; import MkFoldableSection from '@/components/MkFoldableSection.vue';
import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
import { definePageMetadata } from '@/scripts/page-metadata.js'; import { definePageMetadata } from '@/scripts/page-metadata.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { useRouter } from '@/global/router/supplier.js'; import { useRouter } from '@/global/router/supplier.js';

View File

@ -145,7 +145,7 @@ const headerActions = computed(() => clip.value && isOwned.value ? [{
handler: async (): Promise<void> => { handler: async (): Promise<void> => {
const { canceled } = await os.confirm({ const { canceled } = await os.confirm({
type: 'warning', type: 'warning',
text: i18n.t('deleteAreYouSure', { x: clip.value.name }), text: i18n.tsx.deleteAreYouSure({ x: clip.value.name }),
}); });
if (canceled) return; if (canceled) return;

Some files were not shown because too many files have changed in this diff Show More