Merge remote-tracking branch 'misskey-original/develop' into develop
|
@ -16,16 +16,19 @@
|
||||||
|
|
||||||
### General
|
### General
|
||||||
- Feat: [mCaptcha](https://github.com/mCaptcha/mCaptcha)のサポートを追加
|
- Feat: [mCaptcha](https://github.com/mCaptcha/mCaptcha)のサポートを追加
|
||||||
|
- Fix: リストライムラインの「リノートを表示」が正しく機能しない問題を修正
|
||||||
|
|
||||||
### Client
|
### Client
|
||||||
- Feat: 新しいゲームを追加
|
- Feat: 新しいゲームを追加
|
||||||
- Enhance: ハッシュタグ入力時に、本文の末尾の行に何も書かれていない場合は新たにスペースを追加しないように
|
- Enhance: ハッシュタグ入力時に、本文の末尾の行に何も書かれていない場合は新たにスペースを追加しないように
|
||||||
|
- Fix: ネイティブモードの絵文字がモノクロにならないように
|
||||||
- Fix: v2023.12.0で追加された「モデレーターがユーザーのアイコンもしくはバナー画像を未設定状態にできる機能」が管理画面上で正しく表示されていない問題を修正
|
- Fix: v2023.12.0で追加された「モデレーターがユーザーのアイコンもしくはバナー画像を未設定状態にできる機能」が管理画面上で正しく表示されていない問題を修正
|
||||||
- Enhance: チャンネルノートのピン留めをノートのメニューからできるよ
|
- Enhance: チャンネルノートのピン留めをノートのメニューからできるよ
|
||||||
|
|
||||||
### Server
|
### Server
|
||||||
- Enhance: 連合先のレートリミットに引っかかった際にリトライするようになりました
|
- Enhance: 連合先のレートリミットに引っかかった際にリトライするようになりました
|
||||||
- Enhance: ActivityPub Deliver queueでBodyを事前処理するように (#12916)
|
- Enhance: ActivityPub Deliver queueでBodyを事前処理するように (#12916)
|
||||||
|
- Enhance: クリップをエクスポートできるように
|
||||||
|
|
||||||
## 2023.12.2
|
## 2023.12.2
|
||||||
|
|
||||||
|
|
2
locales/index.d.ts
vendored
|
@ -1235,6 +1235,7 @@ export interface Locale {
|
||||||
"decorate": string;
|
"decorate": string;
|
||||||
"addMfmFunction": string;
|
"addMfmFunction": string;
|
||||||
"enableQuickAddMfmFunction": string;
|
"enableQuickAddMfmFunction": string;
|
||||||
|
"bubbleGame": string;
|
||||||
"_announcement": {
|
"_announcement": {
|
||||||
"forExistingUsers": string;
|
"forExistingUsers": string;
|
||||||
"forExistingUsersDescription": string;
|
"forExistingUsersDescription": string;
|
||||||
|
@ -2318,6 +2319,7 @@ export interface Locale {
|
||||||
"_exportOrImport": {
|
"_exportOrImport": {
|
||||||
"allNotes": string;
|
"allNotes": string;
|
||||||
"favoritedNotes": string;
|
"favoritedNotes": string;
|
||||||
|
"clips": string;
|
||||||
"followingList": string;
|
"followingList": string;
|
||||||
"muteList": string;
|
"muteList": string;
|
||||||
"blockingList": string;
|
"blockingList": string;
|
||||||
|
|
|
@ -1232,6 +1232,7 @@ seasonalScreenEffect: "季節に応じた画面の演出"
|
||||||
decorate: "デコる"
|
decorate: "デコる"
|
||||||
addMfmFunction: "装飾を追加"
|
addMfmFunction: "装飾を追加"
|
||||||
enableQuickAddMfmFunction: "高度なMFMのピッカーを表示する"
|
enableQuickAddMfmFunction: "高度なMFMのピッカーを表示する"
|
||||||
|
bubbleGame: "バブルゲーム"
|
||||||
|
|
||||||
_announcement:
|
_announcement:
|
||||||
forExistingUsers: "既存ユーザーのみ"
|
forExistingUsers: "既存ユーザーのみ"
|
||||||
|
@ -2221,6 +2222,7 @@ _profile:
|
||||||
_exportOrImport:
|
_exportOrImport:
|
||||||
allNotes: "全てのノート"
|
allNotes: "全てのノート"
|
||||||
favoritedNotes: "お気に入りにしたノート"
|
favoritedNotes: "お気に入りにしたノート"
|
||||||
|
clips: "クリップ"
|
||||||
followingList: "フォロー"
|
followingList: "フォロー"
|
||||||
muteList: "ミュート"
|
muteList: "ミュート"
|
||||||
blockingList: "ブロック"
|
blockingList: "ブロック"
|
||||||
|
|
|
@ -183,6 +183,16 @@ export class QueueService {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public createExportClipsJob(user: ThinUser) {
|
||||||
|
return this.dbQueue.add('exportClips', {
|
||||||
|
user: { id: user.id },
|
||||||
|
}, {
|
||||||
|
removeOnComplete: true,
|
||||||
|
removeOnFail: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public createExportFavoritesJob(user: ThinUser) {
|
public createExportFavoritesJob(user: ThinUser) {
|
||||||
return this.dbQueue.add('exportFavorites', {
|
return this.dbQueue.add('exportFavorites', {
|
||||||
|
|
|
@ -37,7 +37,7 @@ export class ServerStatsService implements OnApplicationShutdown {
|
||||||
const log = [] as any[];
|
const log = [] as any[];
|
||||||
|
|
||||||
ev.on('requestServerStatsLog', x => {
|
ev.on('requestServerStatsLog', x => {
|
||||||
ev.emit(`serverStatsLog:${x.id}`, log.slice(0, x.length ?? 50));
|
ev.emit(`serverStatsLog:${x.id}`, log.slice(0, x.length));
|
||||||
});
|
});
|
||||||
|
|
||||||
const tick = async () => {
|
const tick = async () => {
|
||||||
|
|
|
@ -25,6 +25,7 @@ import { ExportCustomEmojisProcessorService } from './processors/ExportCustomEmo
|
||||||
import { ExportFollowingProcessorService } from './processors/ExportFollowingProcessorService.js';
|
import { ExportFollowingProcessorService } from './processors/ExportFollowingProcessorService.js';
|
||||||
import { ExportMutingProcessorService } from './processors/ExportMutingProcessorService.js';
|
import { ExportMutingProcessorService } from './processors/ExportMutingProcessorService.js';
|
||||||
import { ExportNotesProcessorService } from './processors/ExportNotesProcessorService.js';
|
import { ExportNotesProcessorService } from './processors/ExportNotesProcessorService.js';
|
||||||
|
import { ExportClipsProcessorService } from './processors/ExportClipsProcessorService.js';
|
||||||
import { ExportUserListsProcessorService } from './processors/ExportUserListsProcessorService.js';
|
import { ExportUserListsProcessorService } from './processors/ExportUserListsProcessorService.js';
|
||||||
import { ExportAntennasProcessorService } from './processors/ExportAntennasProcessorService.js';
|
import { ExportAntennasProcessorService } from './processors/ExportAntennasProcessorService.js';
|
||||||
import { ImportBlockingProcessorService } from './processors/ImportBlockingProcessorService.js';
|
import { ImportBlockingProcessorService } from './processors/ImportBlockingProcessorService.js';
|
||||||
|
@ -54,6 +55,7 @@ import { RelationshipProcessorService } from './processors/RelationshipProcessor
|
||||||
DeleteDriveFilesProcessorService,
|
DeleteDriveFilesProcessorService,
|
||||||
ExportCustomEmojisProcessorService,
|
ExportCustomEmojisProcessorService,
|
||||||
ExportNotesProcessorService,
|
ExportNotesProcessorService,
|
||||||
|
ExportClipsProcessorService,
|
||||||
ExportFavoritesProcessorService,
|
ExportFavoritesProcessorService,
|
||||||
ExportFollowingProcessorService,
|
ExportFollowingProcessorService,
|
||||||
ExportMutingProcessorService,
|
ExportMutingProcessorService,
|
||||||
|
|
|
@ -17,6 +17,7 @@ import { InboxProcessorService } from './processors/InboxProcessorService.js';
|
||||||
import { DeleteDriveFilesProcessorService } from './processors/DeleteDriveFilesProcessorService.js';
|
import { DeleteDriveFilesProcessorService } from './processors/DeleteDriveFilesProcessorService.js';
|
||||||
import { ExportCustomEmojisProcessorService } from './processors/ExportCustomEmojisProcessorService.js';
|
import { ExportCustomEmojisProcessorService } from './processors/ExportCustomEmojisProcessorService.js';
|
||||||
import { ExportNotesProcessorService } from './processors/ExportNotesProcessorService.js';
|
import { ExportNotesProcessorService } from './processors/ExportNotesProcessorService.js';
|
||||||
|
import { ExportClipsProcessorService } from './processors/ExportClipsProcessorService.js';
|
||||||
import { ExportFollowingProcessorService } from './processors/ExportFollowingProcessorService.js';
|
import { ExportFollowingProcessorService } from './processors/ExportFollowingProcessorService.js';
|
||||||
import { ExportMutingProcessorService } from './processors/ExportMutingProcessorService.js';
|
import { ExportMutingProcessorService } from './processors/ExportMutingProcessorService.js';
|
||||||
import { ExportBlockingProcessorService } from './processors/ExportBlockingProcessorService.js';
|
import { ExportBlockingProcessorService } from './processors/ExportBlockingProcessorService.js';
|
||||||
|
@ -94,6 +95,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
||||||
private deleteDriveFilesProcessorService: DeleteDriveFilesProcessorService,
|
private deleteDriveFilesProcessorService: DeleteDriveFilesProcessorService,
|
||||||
private exportCustomEmojisProcessorService: ExportCustomEmojisProcessorService,
|
private exportCustomEmojisProcessorService: ExportCustomEmojisProcessorService,
|
||||||
private exportNotesProcessorService: ExportNotesProcessorService,
|
private exportNotesProcessorService: ExportNotesProcessorService,
|
||||||
|
private exportClipsProcessorService: ExportClipsProcessorService,
|
||||||
private exportFavoritesProcessorService: ExportFavoritesProcessorService,
|
private exportFavoritesProcessorService: ExportFavoritesProcessorService,
|
||||||
private exportFollowingProcessorService: ExportFollowingProcessorService,
|
private exportFollowingProcessorService: ExportFollowingProcessorService,
|
||||||
private exportMutingProcessorService: ExportMutingProcessorService,
|
private exportMutingProcessorService: ExportMutingProcessorService,
|
||||||
|
@ -167,6 +169,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
||||||
case 'deleteDriveFiles': return this.deleteDriveFilesProcessorService.process(job);
|
case 'deleteDriveFiles': return this.deleteDriveFilesProcessorService.process(job);
|
||||||
case 'exportCustomEmojis': return this.exportCustomEmojisProcessorService.process(job);
|
case 'exportCustomEmojis': return this.exportCustomEmojisProcessorService.process(job);
|
||||||
case 'exportNotes': return this.exportNotesProcessorService.process(job);
|
case 'exportNotes': return this.exportNotesProcessorService.process(job);
|
||||||
|
case 'exportClips': return this.exportClipsProcessorService.process(job);
|
||||||
case 'exportFavorites': return this.exportFavoritesProcessorService.process(job);
|
case 'exportFavorites': return this.exportFavoritesProcessorService.process(job);
|
||||||
case 'exportFollowing': return this.exportFollowingProcessorService.process(job);
|
case 'exportFollowing': return this.exportFollowingProcessorService.process(job);
|
||||||
case 'exportMuting': return this.exportMutingProcessorService.process(job);
|
case 'exportMuting': return this.exportMutingProcessorService.process(job);
|
||||||
|
|
|
@ -0,0 +1,206 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as fs from 'node:fs';
|
||||||
|
import { Writable } from 'node:stream';
|
||||||
|
import { Inject, Injectable, StreamableFile } from '@nestjs/common';
|
||||||
|
import { MoreThan } from 'typeorm';
|
||||||
|
import { format as dateFormat } from 'date-fns';
|
||||||
|
import { DI } from '@/di-symbols.js';
|
||||||
|
import type { ClipNotesRepository, ClipsRepository, MiClip, MiClipNote, MiUser, NotesRepository, PollsRepository, UsersRepository } from '@/models/_.js';
|
||||||
|
import type Logger from '@/logger.js';
|
||||||
|
import { DriveService } from '@/core/DriveService.js';
|
||||||
|
import { createTemp } from '@/misc/create-temp.js';
|
||||||
|
import type { MiPoll } from '@/models/Poll.js';
|
||||||
|
import type { MiNote } from '@/models/Note.js';
|
||||||
|
import { bindThis } from '@/decorators.js';
|
||||||
|
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
|
||||||
|
import { Packed } from '@/misc/json-schema.js';
|
||||||
|
import { IdService } from '@/core/IdService.js';
|
||||||
|
import { QueueLoggerService } from '../QueueLoggerService.js';
|
||||||
|
import type * as Bull from 'bullmq';
|
||||||
|
import type { DbJobDataWithUser } from '../types.js';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ExportClipsProcessorService {
|
||||||
|
private logger: Logger;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(DI.usersRepository)
|
||||||
|
private usersRepository: UsersRepository,
|
||||||
|
|
||||||
|
@Inject(DI.pollsRepository)
|
||||||
|
private pollsRepository: PollsRepository,
|
||||||
|
|
||||||
|
@Inject(DI.clipsRepository)
|
||||||
|
private clipsRepository: ClipsRepository,
|
||||||
|
|
||||||
|
@Inject(DI.clipNotesRepository)
|
||||||
|
private clipNotesRepository: ClipNotesRepository,
|
||||||
|
|
||||||
|
private driveService: DriveService,
|
||||||
|
private queueLoggerService: QueueLoggerService,
|
||||||
|
private idService: IdService,
|
||||||
|
) {
|
||||||
|
this.logger = this.queueLoggerService.logger.createSubLogger('export-clips');
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public async process(job: Bull.Job<DbJobDataWithUser>): Promise<void> {
|
||||||
|
this.logger.info(`Exporting clips of ${job.data.user.id} ...`);
|
||||||
|
|
||||||
|
const user = await this.usersRepository.findOneBy({ id: job.data.user.id });
|
||||||
|
if (user == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create temp file
|
||||||
|
const [path, cleanup] = await createTemp();
|
||||||
|
|
||||||
|
this.logger.info(`Temp file is ${path}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stream = Writable.toWeb(fs.createWriteStream(path, { flags: 'a' }));
|
||||||
|
const writer = stream.getWriter();
|
||||||
|
writer.closed.catch(this.logger.error);
|
||||||
|
|
||||||
|
await writer.write('[');
|
||||||
|
|
||||||
|
await this.processClips(writer, user, job);
|
||||||
|
|
||||||
|
await writer.write(']');
|
||||||
|
await writer.close();
|
||||||
|
|
||||||
|
this.logger.succ(`Exported to: ${path}`);
|
||||||
|
|
||||||
|
const fileName = 'clips-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.json';
|
||||||
|
const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true, ext: 'json' });
|
||||||
|
|
||||||
|
this.logger.succ(`Exported to: ${driveFile.id}`);
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async processClips(writer: WritableStreamDefaultWriter, user: MiUser, job: Bull.Job<DbJobDataWithUser>) {
|
||||||
|
let exportedClipsCount = 0;
|
||||||
|
let cursor: MiClip['id'] | null = null;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const clips = await this.clipsRepository.find({
|
||||||
|
where: {
|
||||||
|
userId: user.id,
|
||||||
|
...(cursor ? { id: MoreThan(cursor) } : {}),
|
||||||
|
},
|
||||||
|
take: 100,
|
||||||
|
order: {
|
||||||
|
id: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (clips.length === 0) {
|
||||||
|
job.updateProgress(100);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor = clips.at(-1)?.id ?? null;
|
||||||
|
|
||||||
|
for (const clip of clips) {
|
||||||
|
// Stringify but remove the last `]}`
|
||||||
|
const content = JSON.stringify(this.serializeClip(clip)).slice(0, -2);
|
||||||
|
const isFirst = exportedClipsCount === 0;
|
||||||
|
await writer.write(isFirst ? content : ',\n' + content);
|
||||||
|
|
||||||
|
await this.processClipNotes(writer, clip.id);
|
||||||
|
|
||||||
|
await writer.write(']}');
|
||||||
|
exportedClipsCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = await this.clipsRepository.countBy({
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
job.updateProgress(exportedClipsCount / total);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async processClipNotes(writer: WritableStreamDefaultWriter, clipId: string): Promise<void> {
|
||||||
|
let exportedClipNotesCount = 0;
|
||||||
|
let cursor: MiClipNote['id'] | null = null;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const clipNotes = await this.clipNotesRepository.find({
|
||||||
|
where: {
|
||||||
|
clipId,
|
||||||
|
...(cursor ? { id: MoreThan(cursor) } : {}),
|
||||||
|
},
|
||||||
|
take: 100,
|
||||||
|
order: {
|
||||||
|
id: 1,
|
||||||
|
},
|
||||||
|
relations: ['note', 'note.user'],
|
||||||
|
}) as (MiClipNote & { note: MiNote & { user: MiUser } })[];
|
||||||
|
|
||||||
|
if (clipNotes.length === 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor = clipNotes.at(-1)?.id ?? null;
|
||||||
|
|
||||||
|
for (const clipNote of clipNotes) {
|
||||||
|
let poll: MiPoll | undefined;
|
||||||
|
if (clipNote.note.hasPoll) {
|
||||||
|
poll = await this.pollsRepository.findOneByOrFail({ noteId: clipNote.note.id });
|
||||||
|
}
|
||||||
|
const content = JSON.stringify(this.serializeClipNote(clipNote, poll));
|
||||||
|
const isFirst = exportedClipNotesCount === 0;
|
||||||
|
await writer.write(isFirst ? content : ',\n' + content);
|
||||||
|
|
||||||
|
exportedClipNotesCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private serializeClip(clip: MiClip): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
id: clip.id,
|
||||||
|
name: clip.name,
|
||||||
|
description: clip.description,
|
||||||
|
lastClippedAt: clip.lastClippedAt?.toISOString(),
|
||||||
|
clipNotes: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private serializeClipNote(clip: MiClipNote & { note: MiNote & { user: MiUser } }, poll: MiPoll | undefined): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
id: clip.id,
|
||||||
|
createdAt: this.idService.parse(clip.id).date.toISOString(),
|
||||||
|
note: {
|
||||||
|
id: clip.note.id,
|
||||||
|
text: clip.note.text,
|
||||||
|
createdAt: this.idService.parse(clip.note.id).date.toISOString(),
|
||||||
|
fileIds: clip.note.fileIds,
|
||||||
|
replyId: clip.note.replyId,
|
||||||
|
renoteId: clip.note.renoteId,
|
||||||
|
poll: poll,
|
||||||
|
cw: clip.note.cw,
|
||||||
|
visibility: clip.note.visibility,
|
||||||
|
visibleUserIds: clip.note.visibleUserIds,
|
||||||
|
localOnly: clip.note.localOnly,
|
||||||
|
reactionAcceptance: clip.note.reactionAcceptance,
|
||||||
|
uri: clip.note.uri,
|
||||||
|
url: clip.note.url,
|
||||||
|
user: {
|
||||||
|
id: clip.note.user.id,
|
||||||
|
name: clip.note.user.name,
|
||||||
|
username: clip.note.user.username,
|
||||||
|
host: clip.note.user.host,
|
||||||
|
uri: clip.note.user.uri,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
|
@ -213,6 +213,7 @@ import * as ep___i_exportBlocking from './endpoints/i/export-blocking.js';
|
||||||
import * as ep___i_exportFollowing from './endpoints/i/export-following.js';
|
import * as ep___i_exportFollowing from './endpoints/i/export-following.js';
|
||||||
import * as ep___i_exportMute from './endpoints/i/export-mute.js';
|
import * as ep___i_exportMute from './endpoints/i/export-mute.js';
|
||||||
import * as ep___i_exportNotes from './endpoints/i/export-notes.js';
|
import * as ep___i_exportNotes from './endpoints/i/export-notes.js';
|
||||||
|
import * as ep___i_exportClips from './endpoints/i/export-clips.js';
|
||||||
import * as ep___i_exportFavorites from './endpoints/i/export-favorites.js';
|
import * as ep___i_exportFavorites from './endpoints/i/export-favorites.js';
|
||||||
import * as ep___i_exportUserLists from './endpoints/i/export-user-lists.js';
|
import * as ep___i_exportUserLists from './endpoints/i/export-user-lists.js';
|
||||||
import * as ep___i_exportAntennas from './endpoints/i/export-antennas.js';
|
import * as ep___i_exportAntennas from './endpoints/i/export-antennas.js';
|
||||||
|
@ -584,6 +585,7 @@ const $i_exportBlocking: Provider = { provide: 'ep:i/export-blocking', useClass:
|
||||||
const $i_exportFollowing: Provider = { provide: 'ep:i/export-following', useClass: ep___i_exportFollowing.default };
|
const $i_exportFollowing: Provider = { provide: 'ep:i/export-following', useClass: ep___i_exportFollowing.default };
|
||||||
const $i_exportMute: Provider = { provide: 'ep:i/export-mute', useClass: ep___i_exportMute.default };
|
const $i_exportMute: Provider = { provide: 'ep:i/export-mute', useClass: ep___i_exportMute.default };
|
||||||
const $i_exportNotes: Provider = { provide: 'ep:i/export-notes', useClass: ep___i_exportNotes.default };
|
const $i_exportNotes: Provider = { provide: 'ep:i/export-notes', useClass: ep___i_exportNotes.default };
|
||||||
|
const $i_exportClips: Provider = { provide: 'ep:i/export-clips', useClass: ep___i_exportClips.default };
|
||||||
const $i_exportFavorites: Provider = { provide: 'ep:i/export-favorites', useClass: ep___i_exportFavorites.default };
|
const $i_exportFavorites: Provider = { provide: 'ep:i/export-favorites', useClass: ep___i_exportFavorites.default };
|
||||||
const $i_exportUserLists: Provider = { provide: 'ep:i/export-user-lists', useClass: ep___i_exportUserLists.default };
|
const $i_exportUserLists: Provider = { provide: 'ep:i/export-user-lists', useClass: ep___i_exportUserLists.default };
|
||||||
const $i_exportAntennas: Provider = { provide: 'ep:i/export-antennas', useClass: ep___i_exportAntennas.default };
|
const $i_exportAntennas: Provider = { provide: 'ep:i/export-antennas', useClass: ep___i_exportAntennas.default };
|
||||||
|
@ -960,6 +962,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
|
||||||
$i_exportFollowing,
|
$i_exportFollowing,
|
||||||
$i_exportMute,
|
$i_exportMute,
|
||||||
$i_exportNotes,
|
$i_exportNotes,
|
||||||
|
$i_exportClips,
|
||||||
$i_exportFavorites,
|
$i_exportFavorites,
|
||||||
$i_exportUserLists,
|
$i_exportUserLists,
|
||||||
$i_exportAntennas,
|
$i_exportAntennas,
|
||||||
|
@ -1329,6 +1332,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
|
||||||
$i_exportFollowing,
|
$i_exportFollowing,
|
||||||
$i_exportMute,
|
$i_exportMute,
|
||||||
$i_exportNotes,
|
$i_exportNotes,
|
||||||
|
$i_exportClips,
|
||||||
$i_exportFavorites,
|
$i_exportFavorites,
|
||||||
$i_exportUserLists,
|
$i_exportUserLists,
|
||||||
$i_exportAntennas,
|
$i_exportAntennas,
|
||||||
|
|
|
@ -3,8 +3,8 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Schema } from '@/misc/json-schema.js';
|
|
||||||
import { permissions } from 'misskey-js';
|
import { permissions } from 'misskey-js';
|
||||||
|
import type { Schema } from '@/misc/json-schema.js';
|
||||||
import { RolePolicies } from '@/core/RoleService.js';
|
import { RolePolicies } from '@/core/RoleService.js';
|
||||||
import * as ep___admin_emoji_setlocalOnlyBulk from './endpoints/admin/emoji/set-localonly-bulk.js';
|
import * as ep___admin_emoji_setlocalOnlyBulk from './endpoints/admin/emoji/set-localonly-bulk.js';
|
||||||
import * as ep___admin_emoji_setisSensitiveBulk from './endpoints/admin/emoji/set-issensitive-bulk.js';
|
import * as ep___admin_emoji_setisSensitiveBulk from './endpoints/admin/emoji/set-issensitive-bulk.js';
|
||||||
|
@ -213,6 +213,7 @@ import * as ep___i_exportBlocking from './endpoints/i/export-blocking.js';
|
||||||
import * as ep___i_exportFollowing from './endpoints/i/export-following.js';
|
import * as ep___i_exportFollowing from './endpoints/i/export-following.js';
|
||||||
import * as ep___i_exportMute from './endpoints/i/export-mute.js';
|
import * as ep___i_exportMute from './endpoints/i/export-mute.js';
|
||||||
import * as ep___i_exportNotes from './endpoints/i/export-notes.js';
|
import * as ep___i_exportNotes from './endpoints/i/export-notes.js';
|
||||||
|
import * as ep___i_exportClips from './endpoints/i/export-clips.js';
|
||||||
import * as ep___i_exportFavorites from './endpoints/i/export-favorites.js';
|
import * as ep___i_exportFavorites from './endpoints/i/export-favorites.js';
|
||||||
import * as ep___i_exportUserLists from './endpoints/i/export-user-lists.js';
|
import * as ep___i_exportUserLists from './endpoints/i/export-user-lists.js';
|
||||||
import * as ep___i_exportAntennas from './endpoints/i/export-antennas.js';
|
import * as ep___i_exportAntennas from './endpoints/i/export-antennas.js';
|
||||||
|
@ -582,6 +583,7 @@ const eps = [
|
||||||
['i/export-following', ep___i_exportFollowing],
|
['i/export-following', ep___i_exportFollowing],
|
||||||
['i/export-mute', ep___i_exportMute],
|
['i/export-mute', ep___i_exportMute],
|
||||||
['i/export-notes', ep___i_exportNotes],
|
['i/export-notes', ep___i_exportNotes],
|
||||||
|
['i/export-clips', ep___i_exportClips],
|
||||||
['i/export-favorites', ep___i_exportFavorites],
|
['i/export-favorites', ep___i_exportFavorites],
|
||||||
['i/export-user-lists', ep___i_exportUserLists],
|
['i/export-user-lists', ep___i_exportUserLists],
|
||||||
['i/export-antennas', ep___i_exportAntennas],
|
['i/export-antennas', ep___i_exportAntennas],
|
||||||
|
|
35
packages/backend/src/server/api/endpoints/i/export-clips.ts
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import ms from 'ms';
|
||||||
|
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||||
|
import { QueueService } from '@/core/QueueService.js';
|
||||||
|
|
||||||
|
export const meta = {
|
||||||
|
secure: true,
|
||||||
|
requireCredential: true,
|
||||||
|
limit: {
|
||||||
|
duration: ms('1day'),
|
||||||
|
max: 1,
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const paramDef = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {},
|
||||||
|
required: [],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||||
|
constructor(
|
||||||
|
private queueService: QueueService,
|
||||||
|
) {
|
||||||
|
super(meta, paramDef, async (ps, me) => {
|
||||||
|
this.queueService.createExportClipsJob(me);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -21,6 +21,7 @@ class UserListChannel extends Channel {
|
||||||
private membershipsMap: Record<string, Pick<MiUserListMembership, 'withReplies'> | undefined> = {};
|
private membershipsMap: Record<string, Pick<MiUserListMembership, 'withReplies'> | undefined> = {};
|
||||||
private listUsersClock: NodeJS.Timeout;
|
private listUsersClock: NodeJS.Timeout;
|
||||||
private withFiles: boolean;
|
private withFiles: boolean;
|
||||||
|
private withRenotes: boolean;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private userListsRepository: UserListsRepository,
|
private userListsRepository: UserListsRepository,
|
||||||
|
@ -39,6 +40,7 @@ class UserListChannel extends Channel {
|
||||||
public async init(params: any) {
|
public async init(params: any) {
|
||||||
this.listId = params.listId as string;
|
this.listId = params.listId as string;
|
||||||
this.withFiles = params.withFiles ?? false;
|
this.withFiles = params.withFiles ?? false;
|
||||||
|
this.withRenotes = params.withRenotes ?? true;
|
||||||
|
|
||||||
// Check existence and owner
|
// Check existence and owner
|
||||||
const listExist = await this.userListsRepository.exist({
|
const listExist = await this.userListsRepository.exist({
|
||||||
|
@ -104,6 +106,8 @@ class UserListChannel extends Channel {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (note.renote && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && !this.withRenotes) return;
|
||||||
|
|
||||||
// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する
|
// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する
|
||||||
if (isUserRelated(note, this.userIdsWhoMeMuting)) return;
|
if (isUserRelated(note, this.userIdsWhoMeMuting)) return;
|
||||||
// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する
|
// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する
|
||||||
|
|
194
packages/backend/test/e2e/exports.ts
Normal file
|
@ -0,0 +1,194 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
process.env.NODE_ENV = 'test';
|
||||||
|
|
||||||
|
import * as assert from 'assert';
|
||||||
|
import { signup, api, startServer, startJobQueue, port, post } from '../utils.js';
|
||||||
|
import type { INestApplicationContext } from '@nestjs/common';
|
||||||
|
import type * as misskey from 'misskey-js';
|
||||||
|
|
||||||
|
describe('export-clips', () => {
|
||||||
|
let app: INestApplicationContext;
|
||||||
|
let alice: misskey.entities.SignupResponse;
|
||||||
|
let bob: misskey.entities.SignupResponse;
|
||||||
|
|
||||||
|
// XXX: Any better way to get the result?
|
||||||
|
async function pollFirstDriveFile() {
|
||||||
|
while (true) {
|
||||||
|
const files = (await api('/drive/files', {}, alice)).body;
|
||||||
|
if (!files.length) {
|
||||||
|
await new Promise(r => setTimeout(r, 100));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (files.length > 1) {
|
||||||
|
throw new Error('Too many files?');
|
||||||
|
}
|
||||||
|
const file = (await api('/drive/files/show', { fileId: files[0].id }, alice)).body;
|
||||||
|
const res = await fetch(new URL(new URL(file.url).pathname, `http://127.0.0.1:${port}`));
|
||||||
|
return await res.json();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
app = await startServer();
|
||||||
|
await startJobQueue();
|
||||||
|
alice = await signup({ username: 'alice' });
|
||||||
|
bob = await signup({ username: 'bob' });
|
||||||
|
}, 1000 * 60 * 2);
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Clean all clips and files of alice
|
||||||
|
const clips = (await api('/clips/list', {}, alice)).body;
|
||||||
|
for (const clip of clips) {
|
||||||
|
const res = await api('/clips/delete', { clipId: clip.id }, alice);
|
||||||
|
if (res.status !== 204) {
|
||||||
|
throw new Error('Failed to delete clip');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const files = (await api('/drive/files', {}, alice)).body;
|
||||||
|
for (const file of files) {
|
||||||
|
const res = await api('/drive/files/delete', { fileId: file.id }, alice);
|
||||||
|
if (res.status !== 204) {
|
||||||
|
throw new Error('Failed to delete file');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('basic export', async () => {
|
||||||
|
let res = await api('/clips/create', {
|
||||||
|
name: 'foo',
|
||||||
|
description: 'bar',
|
||||||
|
}, alice);
|
||||||
|
assert.strictEqual(res.status, 200);
|
||||||
|
|
||||||
|
res = await api('/i/export-clips', {}, alice);
|
||||||
|
assert.strictEqual(res.status, 204);
|
||||||
|
|
||||||
|
const exported = await pollFirstDriveFile();
|
||||||
|
assert.strictEqual(exported[0].name, 'foo');
|
||||||
|
assert.strictEqual(exported[0].description, 'bar');
|
||||||
|
assert.strictEqual(exported[0].clipNotes.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('export with notes', async () => {
|
||||||
|
let res = await api('/clips/create', {
|
||||||
|
name: 'foo',
|
||||||
|
description: 'bar',
|
||||||
|
}, alice);
|
||||||
|
assert.strictEqual(res.status, 200);
|
||||||
|
const clip = res.body;
|
||||||
|
|
||||||
|
const note1 = await post(alice, {
|
||||||
|
text: 'baz1',
|
||||||
|
});
|
||||||
|
|
||||||
|
const note2 = await post(alice, {
|
||||||
|
text: 'baz2',
|
||||||
|
poll: {
|
||||||
|
choices: ['sakura', 'izumi', 'ako'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const note of [note1, note2]) {
|
||||||
|
res = await api('/clips/add-note', {
|
||||||
|
clipId: clip.id,
|
||||||
|
noteId: note.id,
|
||||||
|
}, alice);
|
||||||
|
assert.strictEqual(res.status, 204);
|
||||||
|
}
|
||||||
|
|
||||||
|
res = await api('/i/export-clips', {}, alice);
|
||||||
|
assert.strictEqual(res.status, 204);
|
||||||
|
|
||||||
|
const exported = await pollFirstDriveFile();
|
||||||
|
assert.strictEqual(exported[0].name, 'foo');
|
||||||
|
assert.strictEqual(exported[0].description, 'bar');
|
||||||
|
assert.strictEqual(exported[0].clipNotes.length, 2);
|
||||||
|
assert.strictEqual(exported[0].clipNotes[0].note.text, 'baz1');
|
||||||
|
assert.strictEqual(exported[0].clipNotes[1].note.text, 'baz2');
|
||||||
|
assert.deepStrictEqual(exported[0].clipNotes[1].note.poll.choices[0], 'sakura');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('multiple clips', async () => {
|
||||||
|
let res = await api('/clips/create', {
|
||||||
|
name: 'kawaii',
|
||||||
|
description: 'kawaii',
|
||||||
|
}, alice);
|
||||||
|
assert.strictEqual(res.status, 200);
|
||||||
|
const clip1 = res.body;
|
||||||
|
|
||||||
|
res = await api('/clips/create', {
|
||||||
|
name: 'yuri',
|
||||||
|
description: 'yuri',
|
||||||
|
}, alice);
|
||||||
|
assert.strictEqual(res.status, 200);
|
||||||
|
const clip2 = res.body;
|
||||||
|
|
||||||
|
const note1 = await post(alice, {
|
||||||
|
text: 'baz1',
|
||||||
|
});
|
||||||
|
|
||||||
|
const note2 = await post(alice, {
|
||||||
|
text: 'baz2',
|
||||||
|
});
|
||||||
|
|
||||||
|
res = await api('/clips/add-note', {
|
||||||
|
clipId: clip1.id,
|
||||||
|
noteId: note1.id,
|
||||||
|
}, alice);
|
||||||
|
assert.strictEqual(res.status, 204);
|
||||||
|
|
||||||
|
res = await api('/clips/add-note', {
|
||||||
|
clipId: clip2.id,
|
||||||
|
noteId: note2.id,
|
||||||
|
}, alice);
|
||||||
|
assert.strictEqual(res.status, 204);
|
||||||
|
|
||||||
|
res = await api('/i/export-clips', {}, alice);
|
||||||
|
assert.strictEqual(res.status, 204);
|
||||||
|
|
||||||
|
const exported = await pollFirstDriveFile();
|
||||||
|
assert.strictEqual(exported[0].name, 'kawaii');
|
||||||
|
assert.strictEqual(exported[0].clipNotes.length, 1);
|
||||||
|
assert.strictEqual(exported[0].clipNotes[0].note.text, 'baz1');
|
||||||
|
assert.strictEqual(exported[1].name, 'yuri');
|
||||||
|
assert.strictEqual(exported[1].clipNotes.length, 1);
|
||||||
|
assert.strictEqual(exported[1].clipNotes[0].note.text, 'baz2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Clipping other user\'s note', async () => {
|
||||||
|
let res = await api('/clips/create', {
|
||||||
|
name: 'kawaii',
|
||||||
|
description: 'kawaii',
|
||||||
|
}, alice);
|
||||||
|
assert.strictEqual(res.status, 200);
|
||||||
|
const clip = res.body;
|
||||||
|
|
||||||
|
const note = await post(bob, {
|
||||||
|
text: 'baz',
|
||||||
|
visibility: 'followers',
|
||||||
|
});
|
||||||
|
|
||||||
|
res = await api('/clips/add-note', {
|
||||||
|
clipId: clip.id,
|
||||||
|
noteId: note.id,
|
||||||
|
}, alice);
|
||||||
|
assert.strictEqual(res.status, 204);
|
||||||
|
|
||||||
|
res = await api('/i/export-clips', {}, alice);
|
||||||
|
assert.strictEqual(res.status, 204);
|
||||||
|
|
||||||
|
const exported = await pollFirstDriveFile();
|
||||||
|
assert.strictEqual(exported[0].name, 'kawaii');
|
||||||
|
assert.strictEqual(exported[0].clipNotes.length, 1);
|
||||||
|
assert.strictEqual(exported[0].clipNotes[0].note.text, 'baz');
|
||||||
|
assert.strictEqual(exported[0].clipNotes[0].note.user.username, 'bob');
|
||||||
|
});
|
||||||
|
});
|
|
@ -17,7 +17,7 @@ import { entities } from '../src/postgres.js';
|
||||||
import { loadConfig } from '../src/config.js';
|
import { loadConfig } from '../src/config.js';
|
||||||
import type * as misskey from 'misskey-js';
|
import type * as misskey from 'misskey-js';
|
||||||
|
|
||||||
export { server as startServer } from '@/boot/common.js';
|
export { server as startServer, jobQueue as startJobQueue } from '@/boot/common.js';
|
||||||
|
|
||||||
interface UserToken {
|
interface UserToken {
|
||||||
token: string;
|
token: string;
|
||||||
|
|
BIN
packages/frontend/assets/drop-and-fusion/dropper.png
Normal file
After Width: | Height: | Size: 32 KiB |
28
packages/frontend/assets/drop-and-fusion/frame-dark.svg
Normal file
After Width: | Height: | Size: 67 KiB |
28
packages/frontend/assets/drop-and-fusion/frame-light.svg
Normal file
After Width: | Height: | Size: 66 KiB |
Before Width: | Height: | Size: 68 KiB |
BIN
packages/frontend/assets/drop-and-fusion/gameover.png
Normal file
After Width: | Height: | Size: 66 KiB |
BIN
packages/frontend/assets/drop-and-fusion/keycap_1.png
Normal file
After Width: | Height: | Size: 28 KiB |
BIN
packages/frontend/assets/drop-and-fusion/keycap_10.png
Normal file
After Width: | Height: | Size: 33 KiB |
BIN
packages/frontend/assets/drop-and-fusion/keycap_2.png
Normal file
After Width: | Height: | Size: 32 KiB |
BIN
packages/frontend/assets/drop-and-fusion/keycap_3.png
Normal file
After Width: | Height: | Size: 32 KiB |
BIN
packages/frontend/assets/drop-and-fusion/keycap_4.png
Normal file
After Width: | Height: | Size: 30 KiB |
BIN
packages/frontend/assets/drop-and-fusion/keycap_5.png
Normal file
After Width: | Height: | Size: 32 KiB |
BIN
packages/frontend/assets/drop-and-fusion/keycap_6.png
Normal file
After Width: | Height: | Size: 31 KiB |
BIN
packages/frontend/assets/drop-and-fusion/keycap_7.png
Normal file
After Width: | Height: | Size: 31 KiB |
BIN
packages/frontend/assets/drop-and-fusion/keycap_8.png
Normal file
After Width: | Height: | Size: 32 KiB |
BIN
packages/frontend/assets/drop-and-fusion/keycap_9.png
Normal file
After Width: | Height: | Size: 32 KiB |
BIN
packages/frontend/assets/drop-and-fusion/logo.png
Normal file
After Width: | Height: | Size: 231 KiB |
|
@ -42,6 +42,7 @@ onMounted(() => {
|
||||||
|
|
||||||
<style lang="scss" module>
|
<style lang="scss" module>
|
||||||
.root {
|
.root {
|
||||||
|
user-select: none;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
width: 128px;
|
width: 128px;
|
||||||
|
|
|
@ -139,6 +139,7 @@ function connectChannel() {
|
||||||
connection.on('mention', onNote);
|
connection.on('mention', onNote);
|
||||||
} else if (props.src === 'list') {
|
} else if (props.src === 'list') {
|
||||||
connection = stream.useChannel('userList', {
|
connection = stream.useChannel('userList', {
|
||||||
|
withRenotes: props.withRenotes,
|
||||||
withFiles: props.onlyFiles ? true : undefined,
|
withFiles: props.onlyFiles ? true : undefined,
|
||||||
listId: props.list,
|
listId: props.list,
|
||||||
});
|
});
|
||||||
|
@ -212,6 +213,7 @@ function updatePaginationQuery() {
|
||||||
} else if (props.src === 'list') {
|
} else if (props.src === 'list') {
|
||||||
endpoint = 'notes/user-list-timeline';
|
endpoint = 'notes/user-list-timeline';
|
||||||
query = {
|
query = {
|
||||||
|
withRenotes: props.withRenotes,
|
||||||
withFiles: props.onlyFiles ? true : undefined,
|
withFiles: props.onlyFiles ? true : undefined,
|
||||||
listId: props.list,
|
listId: props.list,
|
||||||
};
|
};
|
||||||
|
@ -250,8 +252,9 @@ function refreshEndpointAndChannel() {
|
||||||
updatePaginationQuery();
|
updatePaginationQuery();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// デッキのリストカラムでwithRenotesを変更した場合に自動的に更新されるようにさせる
|
||||||
// IDが切り替わったら切り替え先のTLを表示させたい
|
// IDが切り替わったら切り替え先のTLを表示させたい
|
||||||
watch(() => [props.list, props.antenna, props.channel, props.role], refreshEndpointAndChannel);
|
watch(() => [props.list, props.antenna, props.channel, props.role, props.withRenotes], refreshEndpointAndChannel);
|
||||||
|
|
||||||
// 初回表示用
|
// 初回表示用
|
||||||
refreshEndpointAndChannel();
|
refreshEndpointAndChannel();
|
||||||
|
|
|
@ -5,15 +5,14 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<img v-if="!useOsNativeEmojis" :class="$style.root" :src="url" :alt="props.emoji" decoding="async" @pointerenter="computeTitle" @click="onClick"/>
|
<img v-if="!useOsNativeEmojis" :class="$style.root" :src="url" :alt="props.emoji" decoding="async" @pointerenter="computeTitle" @click="onClick"/>
|
||||||
<span v-else-if="useOsNativeEmojis" :alt="props.emoji" @pointerenter="computeTitle" @click="onClick">{{ props.emoji }}</span>
|
<span v-else :alt="props.emoji" @pointerenter="computeTitle" @click="onClick">{{ colorizedNativeEmoji }}</span>
|
||||||
<span v-else>{{ emoji }}</span>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, inject } from 'vue';
|
import { computed, inject } from 'vue';
|
||||||
import { char2twemojiFilePath, char2fluentEmojiFilePath } from '@/scripts/emoji-base.js';
|
import { char2twemojiFilePath, char2fluentEmojiFilePath } from '@/scripts/emoji-base.js';
|
||||||
import { defaultStore } from '@/store.js';
|
import { defaultStore } from '@/store.js';
|
||||||
import { getEmojiName } from '@/scripts/emojilist.js';
|
import { colorizeEmoji, getEmojiName } from '@/scripts/emojilist.js';
|
||||||
import * as os from '@/os.js';
|
import * as os from '@/os.js';
|
||||||
import copyToClipboard from '@/scripts/copy-to-clipboard.js';
|
import copyToClipboard from '@/scripts/copy-to-clipboard.js';
|
||||||
import * as sound from '@/scripts/sound.js';
|
import * as sound from '@/scripts/sound.js';
|
||||||
|
@ -30,9 +29,8 @@ const react = inject<((name: string) => void) | null>('react', null);
|
||||||
const char2path = defaultStore.state.emojiStyle === 'twemoji' ? char2twemojiFilePath : char2fluentEmojiFilePath;
|
const char2path = defaultStore.state.emojiStyle === 'twemoji' ? char2twemojiFilePath : char2fluentEmojiFilePath;
|
||||||
|
|
||||||
const useOsNativeEmojis = computed(() => defaultStore.state.emojiStyle === 'native');
|
const useOsNativeEmojis = computed(() => defaultStore.state.emojiStyle === 'native');
|
||||||
const url = computed(() => {
|
const url = computed(() => char2path(props.emoji));
|
||||||
return char2path(props.emoji);
|
const colorizedNativeEmoji = computed(() => colorizeEmoji(props.emoji));
|
||||||
});
|
|
||||||
|
|
||||||
// Searching from an array with 2000 items for every emoji felt like too energy-consuming, so I decided to do it lazily on pointerenter
|
// Searching from an array with 2000 items for every emoji felt like too energy-consuming, so I decided to do it lazily on pointerenter
|
||||||
function computeTitle(event: PointerEvent): void {
|
function computeTitle(event: PointerEvent): void {
|
||||||
|
|
|
@ -7,11 +7,32 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<MkStickyContainer>
|
<MkStickyContainer>
|
||||||
<template #header><MkPageHeader/></template>
|
<template #header><MkPageHeader/></template>
|
||||||
<MkSpacer :contentMax="800">
|
<MkSpacer :contentMax="800">
|
||||||
<div class="_gaps_s" :class="$style.root" style="margin: 0 auto;" :style="{ maxWidth: GAME_WIDTH + 'px' }">
|
<div v-show="!gameStarted" :class="$style.root">
|
||||||
|
<div style="text-align: center;" class="_gaps">
|
||||||
|
<div :class="$style.frame">
|
||||||
|
<div :class="$style.frameInner">
|
||||||
|
<img src="/client-assets/drop-and-fusion/logo.png" style="display: block; max-width: 100%; max-height: 200px; margin: auto;"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div :class="$style.frame">
|
||||||
|
<div :class="$style.frameInner">
|
||||||
|
<div class="_gaps" style="padding: 16px;">
|
||||||
|
<MkSelect v-model="gameMode">
|
||||||
|
<option value="normal">NORMAL</option>
|
||||||
|
<option value="square">SQUARE</option>
|
||||||
|
</MkSelect>
|
||||||
|
<MkButton primary gradate large rounded inline @click="start">{{ i18n.ts.start }}</MkButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-show="gameStarted" class="_gaps_s" :class="$style.root">
|
||||||
<div style="display: flex;">
|
<div style="display: flex;">
|
||||||
<div :class="$style.frame" style="flex: 1; margin-right: 10px;">
|
<div :class="$style.frame" style="flex: 1; margin-right: 10px;">
|
||||||
<div :class="$style.frameInner">
|
<div :class="$style.frameInner">
|
||||||
SCORE: <b><MkNumber :value="score"/></b>
|
<b>BUBBLE GAME</b>
|
||||||
|
<div>- {{ gameMode }} -</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div :class="[$style.frame, $style.stock]" style="margin-left: auto;">
|
<div :class="[$style.frame, $style.stock]" style="margin-left: auto;">
|
||||||
|
@ -25,15 +46,16 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
:moveClass="$style.transition_stock_move"
|
:moveClass="$style.transition_stock_move"
|
||||||
>
|
>
|
||||||
<div v-for="x in stock" :key="x.id" style="display: inline-block;">
|
<div v-for="x in stock" :key="x.id" style="display: inline-block;">
|
||||||
<img :src="x.fruit.img" style="width: 32px;"/>
|
<img :src="x.mono.img" style="width: 32px;"/>
|
||||||
</div>
|
</div>
|
||||||
</TransitionGroup>
|
</TransitionGroup>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div :class="$style.main">
|
<div :class="$style.main">
|
||||||
<div ref="containerEl" :class="[$style.container, { [$style.gameOver]: gameOver }]" @click.stop.prevent="onClick" @touchmove="onTouchmove" @touchend="onTouchend" @mousemove="onMousemove">
|
<div ref="containerEl" :class="[$style.container, { [$style.gameOver]: gameOver }]" @click.stop.prevent="onClick" @touchmove.stop.prevent="onTouchmove" @touchend="onTouchend" @mousemove="onMousemove">
|
||||||
<img src="/client-assets/drop-and-fusion/frame.svg" :class="$style.mainFrameImg"/>
|
<img v-if="defaultStore.state.darkMode" src="/client-assets/drop-and-fusion/frame-dark.svg" :class="$style.mainFrameImg"/>
|
||||||
|
<img v-else src="/client-assets/drop-and-fusion/frame-light.svg" :class="$style.mainFrameImg"/>
|
||||||
<canvas ref="canvasEl" :class="$style.canvas"/>
|
<canvas ref="canvasEl" :class="$style.canvas"/>
|
||||||
<Transition
|
<Transition
|
||||||
:enterActiveClass="$style.transition_combo_enterActive"
|
:enterActiveClass="$style.transition_combo_enterActive"
|
||||||
|
@ -44,6 +66,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
>
|
>
|
||||||
<div v-show="combo > 1" :class="$style.combo" :style="{ fontSize: `${100 + ((comboPrev - 2) * 15)}%` }">{{ comboPrev }} Chain!</div>
|
<div v-show="combo > 1" :class="$style.combo" :style="{ fontSize: `${100 + ((comboPrev - 2) * 15)}%` }">{{ comboPrev }} Chain!</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
<img v-if="currentPick" src="/client-assets/drop-and-fusion/dropper.png" :class="$style.dropper" :style="{ left: mouseX + 'px' }"/>
|
||||||
<Transition
|
<Transition
|
||||||
:enterActiveClass="$style.transition_picked_enterActive"
|
:enterActiveClass="$style.transition_picked_enterActive"
|
||||||
:leaveActiveClass="$style.transition_picked_leaveActive"
|
:leaveActiveClass="$style.transition_picked_leaveActive"
|
||||||
|
@ -52,19 +75,41 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
:moveClass="$style.transition_picked_move"
|
:moveClass="$style.transition_picked_move"
|
||||||
mode="out-in"
|
mode="out-in"
|
||||||
>
|
>
|
||||||
<img v-if="currentPick" :key="currentPick.id" :src="currentPick?.fruit.img" :class="$style.currentFruit" :style="{ top: -(currentPick?.fruit.size / 2) + 'px', left: (mouseX - (currentPick?.fruit.size / 2)) + 'px', width: `${currentPick?.fruit.size}px` }"/>
|
<img v-if="currentPick" :key="currentPick.id" :src="currentPick?.mono.img" :class="$style.currentMono" :style="{ top: -(currentPick?.mono.size / 2) + 'px', left: (mouseX - (currentPick?.mono.size / 2)) + 'px', width: `${currentPick?.mono.size}px` }"/>
|
||||||
</Transition>
|
</Transition>
|
||||||
<template v-if="dropReady">
|
<template v-if="dropReady">
|
||||||
<img src="/client-assets/drop-and-fusion/drop-arrow.svg" :class="$style.currentFruitArrow" :style="{ top: (currentPick?.fruit.size / 2) + 10 + 'px', left: (mouseX - 10) + 'px', width: `20px` }"/>
|
<img src="/client-assets/drop-and-fusion/drop-arrow.svg" :class="$style.currentMonoArrow" :style="{ top: (currentPick?.mono.size / 2) + 10 + 'px', left: (mouseX - 10) + 'px', width: `20px` }"/>
|
||||||
<div :class="$style.dropGuide" :style="{ left: (mouseX - 2) + 'px' }"/>
|
<div :class="$style.dropGuide" :style="{ left: (mouseX - 2) + 'px' }"/>
|
||||||
</template>
|
</template>
|
||||||
<div v-if="gameOver" :class="$style.gameOverLabel">
|
<div v-if="gameOver" :class="$style.gameOverLabel">
|
||||||
<div>GAME OVER!</div>
|
<div class="_gaps_s">
|
||||||
<div>SCORE: <MkNumber :value="score"/></div>
|
<img src="/client-assets/drop-and-fusion/gameover.png" style="width: 200px; max-width: 100%; display: block; margin: auto; margin-bottom: -5px;"/>
|
||||||
|
<div>SCORE: <MkNumber :value="score"/></div>
|
||||||
|
<div class="_buttonsCenter">
|
||||||
|
<MkButton primary rounded @click="restart">Restart</MkButton>
|
||||||
|
<MkButton primary rounded @click="share">Share</MkButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<MkButton @click="restart">Restart</MkButton>
|
<div style="display: flex;">
|
||||||
|
<div :class="$style.frame" style="flex: 1; margin-right: 10px;">
|
||||||
|
<div :class="$style.frameInner">
|
||||||
|
<div>SCORE: <b><MkNumber :value="score"/></b></div>
|
||||||
|
<div>HIGH SCORE: <b v-if="highScore"><MkNumber :value="highScore"/></b><b v-else>-</b></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div :class="[$style.frame]" style="margin-left: auto;">
|
||||||
|
<div :class="$style.frameInner" style="text-align: center;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div :class="$style.frame">
|
||||||
|
<div :class="$style.frameInner">
|
||||||
|
<MkButton @click="restart">Restart</MkButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</MkSpacer>
|
</MkSpacer>
|
||||||
</MkStickyContainer>
|
</MkStickyContainer>
|
||||||
|
@ -72,8 +117,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import * as Matter from 'matter-js';
|
import * as Matter from 'matter-js';
|
||||||
import { Ref, onMounted, ref, shallowRef } from 'vue';
|
import { onMounted, ref, shallowRef } from 'vue';
|
||||||
import { EventEmitter } from 'eventemitter3';
|
import { EventEmitter } from 'eventemitter3';
|
||||||
|
import * as Misskey from 'misskey-js';
|
||||||
import { definePageMetadata } from '@/scripts/page-metadata.js';
|
import { definePageMetadata } from '@/scripts/page-metadata.js';
|
||||||
import * as sound from '@/scripts/sound.js';
|
import * as sound from '@/scripts/sound.js';
|
||||||
import MkRippleEffect from '@/components/MkRippleEffect.vue';
|
import MkRippleEffect from '@/components/MkRippleEffect.vue';
|
||||||
|
@ -81,18 +127,39 @@ import * as os from '@/os.js';
|
||||||
import MkNumber from '@/components/MkNumber.vue';
|
import MkNumber from '@/components/MkNumber.vue';
|
||||||
import MkPlusOneEffect from '@/components/MkPlusOneEffect.vue';
|
import MkPlusOneEffect from '@/components/MkPlusOneEffect.vue';
|
||||||
import MkButton from '@/components/MkButton.vue';
|
import MkButton from '@/components/MkButton.vue';
|
||||||
|
import { defaultStore } from '@/store.js';
|
||||||
|
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||||
|
import { i18n } from '@/i18n.js';
|
||||||
|
import { useInterval } from '@/scripts/use-interval.js';
|
||||||
|
import MkSelect from '@/components/MkSelect.vue';
|
||||||
|
import { apiUrl } from '@/config.js';
|
||||||
|
import { $i } from '@/account.js';
|
||||||
|
|
||||||
|
type Mono = {
|
||||||
|
id: string;
|
||||||
|
level: number;
|
||||||
|
size: number;
|
||||||
|
shape: 'circle' | 'rectangle';
|
||||||
|
score: number;
|
||||||
|
dropCandidate: boolean;
|
||||||
|
sfxPitch: number;
|
||||||
|
img: string;
|
||||||
|
imgSize: number;
|
||||||
|
spriteScale: number;
|
||||||
|
};
|
||||||
|
|
||||||
const containerEl = shallowRef<HTMLElement>();
|
const containerEl = shallowRef<HTMLElement>();
|
||||||
const canvasEl = shallowRef<HTMLCanvasElement>();
|
const canvasEl = shallowRef<HTMLCanvasElement>();
|
||||||
const mouseX = ref(0);
|
const mouseX = ref(0);
|
||||||
|
|
||||||
const BASE_SIZE = 30;
|
const NORMAL_BASE_SIZE = 30;
|
||||||
const FRUITS = [{
|
const NORAML_MONOS: Mono[] = [{
|
||||||
id: '9377076d-c980-4d83-bdaf-175bc58275b7',
|
id: '9377076d-c980-4d83-bdaf-175bc58275b7',
|
||||||
level: 10,
|
level: 10,
|
||||||
size: BASE_SIZE * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25,
|
size: NORMAL_BASE_SIZE * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25,
|
||||||
|
shape: 'circle',
|
||||||
score: 512,
|
score: 512,
|
||||||
available: false,
|
dropCandidate: false,
|
||||||
sfxPitch: 0.25,
|
sfxPitch: 0.25,
|
||||||
img: '/client-assets/drop-and-fusion/exploding_head.png',
|
img: '/client-assets/drop-and-fusion/exploding_head.png',
|
||||||
imgSize: 256,
|
imgSize: 256,
|
||||||
|
@ -100,9 +167,10 @@ const FRUITS = [{
|
||||||
}, {
|
}, {
|
||||||
id: 'be9f38d2-b267-4b1a-b420-904e22e80568',
|
id: 'be9f38d2-b267-4b1a-b420-904e22e80568',
|
||||||
level: 9,
|
level: 9,
|
||||||
size: BASE_SIZE * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25,
|
size: NORMAL_BASE_SIZE * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25,
|
||||||
|
shape: 'circle',
|
||||||
score: 256,
|
score: 256,
|
||||||
available: false,
|
dropCandidate: false,
|
||||||
sfxPitch: 0.5,
|
sfxPitch: 0.5,
|
||||||
img: '/client-assets/drop-and-fusion/face_with_symbols_on_mouth.png',
|
img: '/client-assets/drop-and-fusion/face_with_symbols_on_mouth.png',
|
||||||
imgSize: 256,
|
imgSize: 256,
|
||||||
|
@ -110,9 +178,10 @@ const FRUITS = [{
|
||||||
}, {
|
}, {
|
||||||
id: 'beb30459-b064-4888-926b-f572e4e72e0c',
|
id: 'beb30459-b064-4888-926b-f572e4e72e0c',
|
||||||
level: 8,
|
level: 8,
|
||||||
size: BASE_SIZE * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25,
|
size: NORMAL_BASE_SIZE * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25,
|
||||||
|
shape: 'circle',
|
||||||
score: 128,
|
score: 128,
|
||||||
available: false,
|
dropCandidate: false,
|
||||||
sfxPitch: 0.75,
|
sfxPitch: 0.75,
|
||||||
img: '/client-assets/drop-and-fusion/cold_face.png',
|
img: '/client-assets/drop-and-fusion/cold_face.png',
|
||||||
imgSize: 256,
|
imgSize: 256,
|
||||||
|
@ -120,9 +189,10 @@ const FRUITS = [{
|
||||||
}, {
|
}, {
|
||||||
id: 'feab6426-d9d8-49ae-849c-048cdbb6cdf0',
|
id: 'feab6426-d9d8-49ae-849c-048cdbb6cdf0',
|
||||||
level: 7,
|
level: 7,
|
||||||
size: BASE_SIZE * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25,
|
size: NORMAL_BASE_SIZE * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25,
|
||||||
|
shape: 'circle',
|
||||||
score: 64,
|
score: 64,
|
||||||
available: false,
|
dropCandidate: false,
|
||||||
sfxPitch: 1,
|
sfxPitch: 1,
|
||||||
img: '/client-assets/drop-and-fusion/zany_face.png',
|
img: '/client-assets/drop-and-fusion/zany_face.png',
|
||||||
imgSize: 256,
|
imgSize: 256,
|
||||||
|
@ -130,9 +200,10 @@ const FRUITS = [{
|
||||||
}, {
|
}, {
|
||||||
id: 'd6d8fed6-6d18-4726-81a1-6cf2c974df8a',
|
id: 'd6d8fed6-6d18-4726-81a1-6cf2c974df8a',
|
||||||
level: 6,
|
level: 6,
|
||||||
size: BASE_SIZE * 1.25 * 1.25 * 1.25 * 1.25 * 1.25,
|
size: NORMAL_BASE_SIZE * 1.25 * 1.25 * 1.25 * 1.25 * 1.25,
|
||||||
|
shape: 'circle',
|
||||||
score: 32,
|
score: 32,
|
||||||
available: false,
|
dropCandidate: false,
|
||||||
sfxPitch: 1.5,
|
sfxPitch: 1.5,
|
||||||
img: '/client-assets/drop-and-fusion/pleading_face.png',
|
img: '/client-assets/drop-and-fusion/pleading_face.png',
|
||||||
imgSize: 256,
|
imgSize: 256,
|
||||||
|
@ -140,9 +211,10 @@ const FRUITS = [{
|
||||||
}, {
|
}, {
|
||||||
id: '249c728e-230f-4332-bbbf-281c271c75b2',
|
id: '249c728e-230f-4332-bbbf-281c271c75b2',
|
||||||
level: 5,
|
level: 5,
|
||||||
size: BASE_SIZE * 1.25 * 1.25 * 1.25 * 1.25,
|
size: NORMAL_BASE_SIZE * 1.25 * 1.25 * 1.25 * 1.25,
|
||||||
|
shape: 'circle',
|
||||||
score: 16,
|
score: 16,
|
||||||
available: true,
|
dropCandidate: true,
|
||||||
sfxPitch: 2,
|
sfxPitch: 2,
|
||||||
img: '/client-assets/drop-and-fusion/face_with_open_mouth.png',
|
img: '/client-assets/drop-and-fusion/face_with_open_mouth.png',
|
||||||
imgSize: 256,
|
imgSize: 256,
|
||||||
|
@ -150,9 +222,10 @@ const FRUITS = [{
|
||||||
}, {
|
}, {
|
||||||
id: '23d67613-d484-4a93-b71e-3e81b19d6186',
|
id: '23d67613-d484-4a93-b71e-3e81b19d6186',
|
||||||
level: 4,
|
level: 4,
|
||||||
size: BASE_SIZE * 1.25 * 1.25 * 1.25,
|
size: NORMAL_BASE_SIZE * 1.25 * 1.25 * 1.25,
|
||||||
|
shape: 'circle',
|
||||||
score: 8,
|
score: 8,
|
||||||
available: true,
|
dropCandidate: true,
|
||||||
sfxPitch: 2.5,
|
sfxPitch: 2.5,
|
||||||
img: '/client-assets/drop-and-fusion/smiling_face_with_sunglasses.png',
|
img: '/client-assets/drop-and-fusion/smiling_face_with_sunglasses.png',
|
||||||
imgSize: 256,
|
imgSize: 256,
|
||||||
|
@ -160,9 +233,10 @@ const FRUITS = [{
|
||||||
}, {
|
}, {
|
||||||
id: '3cbd0add-ad7d-4685-bad0-29f6dddc0b99',
|
id: '3cbd0add-ad7d-4685-bad0-29f6dddc0b99',
|
||||||
level: 3,
|
level: 3,
|
||||||
size: BASE_SIZE * 1.25 * 1.25,
|
size: NORMAL_BASE_SIZE * 1.25 * 1.25,
|
||||||
|
shape: 'circle',
|
||||||
score: 4,
|
score: 4,
|
||||||
available: true,
|
dropCandidate: true,
|
||||||
sfxPitch: 3,
|
sfxPitch: 3,
|
||||||
img: '/client-assets/drop-and-fusion/grinning_squinting_face.png',
|
img: '/client-assets/drop-and-fusion/grinning_squinting_face.png',
|
||||||
imgSize: 256,
|
imgSize: 256,
|
||||||
|
@ -170,9 +244,10 @@ const FRUITS = [{
|
||||||
}, {
|
}, {
|
||||||
id: '8f86d4f4-ee02-41bf-ad38-1ce0ae457fb5',
|
id: '8f86d4f4-ee02-41bf-ad38-1ce0ae457fb5',
|
||||||
level: 2,
|
level: 2,
|
||||||
size: BASE_SIZE * 1.25,
|
size: NORMAL_BASE_SIZE * 1.25,
|
||||||
|
shape: 'circle',
|
||||||
score: 2,
|
score: 2,
|
||||||
available: true,
|
dropCandidate: true,
|
||||||
sfxPitch: 3.5,
|
sfxPitch: 3.5,
|
||||||
img: '/client-assets/drop-and-fusion/smiling_face_with_hearts.png',
|
img: '/client-assets/drop-and-fusion/smiling_face_with_hearts.png',
|
||||||
imgSize: 256,
|
imgSize: 256,
|
||||||
|
@ -180,34 +255,150 @@ const FRUITS = [{
|
||||||
}, {
|
}, {
|
||||||
id: '64ec4add-ce39-42b4-96cb-33908f3f118d',
|
id: '64ec4add-ce39-42b4-96cb-33908f3f118d',
|
||||||
level: 1,
|
level: 1,
|
||||||
size: BASE_SIZE,
|
size: NORMAL_BASE_SIZE,
|
||||||
|
shape: 'circle',
|
||||||
score: 1,
|
score: 1,
|
||||||
available: true,
|
dropCandidate: true,
|
||||||
sfxPitch: 4,
|
sfxPitch: 4,
|
||||||
img: '/client-assets/drop-and-fusion/heart_suit.png',
|
img: '/client-assets/drop-and-fusion/heart_suit.png',
|
||||||
imgSize: 256,
|
imgSize: 256,
|
||||||
spriteScale: 1.12,
|
spriteScale: 1.12,
|
||||||
}] as const;
|
}];
|
||||||
|
|
||||||
|
const SQUARE_BASE_SIZE = 28;
|
||||||
|
const SQUARE_MONOS: Mono[] = [{
|
||||||
|
id: 'f75fd0ba-d3d4-40a4-9712-b470e45b0525',
|
||||||
|
level: 10,
|
||||||
|
size: SQUARE_BASE_SIZE * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25,
|
||||||
|
shape: 'rectangle',
|
||||||
|
score: 512,
|
||||||
|
dropCandidate: false,
|
||||||
|
sfxPitch: 0.25,
|
||||||
|
img: '/client-assets/drop-and-fusion/keycap_10.png',
|
||||||
|
imgSize: 256,
|
||||||
|
spriteScale: 1.12,
|
||||||
|
}, {
|
||||||
|
id: '7b70f4af-1c01-45fd-af72-61b1f01e03d1',
|
||||||
|
level: 9,
|
||||||
|
size: SQUARE_BASE_SIZE * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25,
|
||||||
|
shape: 'rectangle',
|
||||||
|
score: 256,
|
||||||
|
dropCandidate: false,
|
||||||
|
sfxPitch: 0.5,
|
||||||
|
img: '/client-assets/drop-and-fusion/keycap_9.png',
|
||||||
|
imgSize: 256,
|
||||||
|
spriteScale: 1.12,
|
||||||
|
}, {
|
||||||
|
id: '41607ef3-b6d6-4829-95b6-3737bf8bb956',
|
||||||
|
level: 8,
|
||||||
|
size: SQUARE_BASE_SIZE * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25,
|
||||||
|
shape: 'rectangle',
|
||||||
|
score: 128,
|
||||||
|
dropCandidate: false,
|
||||||
|
sfxPitch: 0.75,
|
||||||
|
img: '/client-assets/drop-and-fusion/keycap_8.png',
|
||||||
|
imgSize: 256,
|
||||||
|
spriteScale: 1.12,
|
||||||
|
}, {
|
||||||
|
id: '8a8310d2-0374-460f-bb50-ca9cd3ee3416',
|
||||||
|
level: 7,
|
||||||
|
size: SQUARE_BASE_SIZE * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25,
|
||||||
|
shape: 'rectangle',
|
||||||
|
score: 64,
|
||||||
|
dropCandidate: false,
|
||||||
|
sfxPitch: 1,
|
||||||
|
img: '/client-assets/drop-and-fusion/keycap_7.png',
|
||||||
|
imgSize: 256,
|
||||||
|
spriteScale: 1.12,
|
||||||
|
}, {
|
||||||
|
id: '1092e069-fe1a-450b-be97-b5d477ec398c',
|
||||||
|
level: 6,
|
||||||
|
size: SQUARE_BASE_SIZE * 1.25 * 1.25 * 1.25 * 1.25 * 1.25,
|
||||||
|
shape: 'rectangle',
|
||||||
|
score: 32,
|
||||||
|
dropCandidate: false,
|
||||||
|
sfxPitch: 1.5,
|
||||||
|
img: '/client-assets/drop-and-fusion/keycap_6.png',
|
||||||
|
imgSize: 256,
|
||||||
|
spriteScale: 1.12,
|
||||||
|
}, {
|
||||||
|
id: '2294734d-7bb8-4781-bb7b-ef3820abf3d0',
|
||||||
|
level: 5,
|
||||||
|
size: SQUARE_BASE_SIZE * 1.25 * 1.25 * 1.25 * 1.25,
|
||||||
|
shape: 'rectangle',
|
||||||
|
score: 16,
|
||||||
|
dropCandidate: true,
|
||||||
|
sfxPitch: 2,
|
||||||
|
img: '/client-assets/drop-and-fusion/keycap_5.png',
|
||||||
|
imgSize: 256,
|
||||||
|
spriteScale: 1.12,
|
||||||
|
}, {
|
||||||
|
id: 'ea8a61af-e350-45f7-ba6a-366fcd65692a',
|
||||||
|
level: 4,
|
||||||
|
size: SQUARE_BASE_SIZE * 1.25 * 1.25 * 1.25,
|
||||||
|
shape: 'rectangle',
|
||||||
|
score: 8,
|
||||||
|
dropCandidate: true,
|
||||||
|
sfxPitch: 2.5,
|
||||||
|
img: '/client-assets/drop-and-fusion/keycap_4.png',
|
||||||
|
imgSize: 256,
|
||||||
|
spriteScale: 1.12,
|
||||||
|
}, {
|
||||||
|
id: 'd0c74815-fc1c-4fbe-9953-c92e4b20f919',
|
||||||
|
level: 3,
|
||||||
|
size: SQUARE_BASE_SIZE * 1.25 * 1.25,
|
||||||
|
shape: 'rectangle',
|
||||||
|
score: 4,
|
||||||
|
dropCandidate: true,
|
||||||
|
sfxPitch: 3,
|
||||||
|
img: '/client-assets/drop-and-fusion/keycap_3.png',
|
||||||
|
imgSize: 256,
|
||||||
|
spriteScale: 1.12,
|
||||||
|
}, {
|
||||||
|
id: 'd8fbd70e-611d-402d-87da-1a7fd8cd2c8d',
|
||||||
|
level: 2,
|
||||||
|
size: SQUARE_BASE_SIZE * 1.25,
|
||||||
|
shape: 'rectangle',
|
||||||
|
score: 2,
|
||||||
|
dropCandidate: true,
|
||||||
|
sfxPitch: 3.5,
|
||||||
|
img: '/client-assets/drop-and-fusion/keycap_2.png',
|
||||||
|
imgSize: 256,
|
||||||
|
spriteScale: 1.12,
|
||||||
|
}, {
|
||||||
|
id: '35e476ee-44bd-4711-ad42-87be245d3efd',
|
||||||
|
level: 1,
|
||||||
|
size: SQUARE_BASE_SIZE,
|
||||||
|
shape: 'rectangle',
|
||||||
|
score: 1,
|
||||||
|
dropCandidate: true,
|
||||||
|
sfxPitch: 4,
|
||||||
|
img: '/client-assets/drop-and-fusion/keycap_1.png',
|
||||||
|
imgSize: 256,
|
||||||
|
spriteScale: 1.12,
|
||||||
|
}];
|
||||||
|
|
||||||
const GAME_WIDTH = 450;
|
const GAME_WIDTH = 450;
|
||||||
const GAME_HEIGHT = 600;
|
const GAME_HEIGHT = 600;
|
||||||
const PHYSICS_QUALITY_FACTOR = 32; // 低いほどパフォーマンスが高いがガタガタして安定しなくなる
|
const PHYSICS_QUALITY_FACTOR = 16; // 低いほどパフォーマンスが高いがガタガタして安定しなくなる、逆に高すぎても何故か不安定になる
|
||||||
|
|
||||||
let viewScaleX = 1;
|
let viewScaleX = 1;
|
||||||
let viewScaleY = 1;
|
let viewScaleY = 1;
|
||||||
const currentPick = shallowRef<{ id: string; fruit: typeof FRUITS[number] } | null>(null);
|
const currentPick = shallowRef<{ id: string; mono: Mono } | null>(null);
|
||||||
const stock = shallowRef<{ id: string; fruit: typeof FRUITS[number] }[]>([]);
|
const stock = shallowRef<{ id: string; mono: Mono }[]>([]);
|
||||||
const score = ref(0);
|
const score = ref(0);
|
||||||
const combo = ref(0);
|
const combo = ref(0);
|
||||||
const comboPrev = ref(0);
|
const comboPrev = ref(0);
|
||||||
const dropReady = ref(true);
|
const dropReady = ref(true);
|
||||||
|
const gameMode = ref<'normal' | 'square'>('normal');
|
||||||
const gameOver = ref(false);
|
const gameOver = ref(false);
|
||||||
const gameStarted = ref(false);
|
const gameStarted = ref(false);
|
||||||
|
const highScore = ref<number | null>(null);
|
||||||
|
|
||||||
class Game extends EventEmitter<{
|
class Game extends EventEmitter<{
|
||||||
changeScore: (score: number) => void;
|
changeScore: (score: number) => void;
|
||||||
changeCombo: (combo: number) => void;
|
changeCombo: (combo: number) => void;
|
||||||
changeStock: (stock: { id: string; fruit: typeof FRUITS[number] }[]) => void;
|
changeStock: (stock: { id: string; mono: Mono }[]) => void;
|
||||||
dropped: () => void;
|
dropped: () => void;
|
||||||
fusioned: (x: number, y: number, score: number) => void;
|
fusioned: (x: number, y: number, score: number) => void;
|
||||||
gameOver: () => void;
|
gameOver: () => void;
|
||||||
|
@ -215,13 +406,15 @@ class Game extends EventEmitter<{
|
||||||
private COMBO_INTERVAL = 1000;
|
private COMBO_INTERVAL = 1000;
|
||||||
public readonly DROP_INTERVAL = 500;
|
public readonly DROP_INTERVAL = 500;
|
||||||
private PLAYAREA_MARGIN = 25;
|
private PLAYAREA_MARGIN = 25;
|
||||||
|
private STOCK_MAX = 4;
|
||||||
private engine: Matter.Engine;
|
private engine: Matter.Engine;
|
||||||
private render: Matter.Render;
|
private render: Matter.Render;
|
||||||
private runner: Matter.Runner;
|
private runner: Matter.Runner;
|
||||||
private detector: Matter.Detector;
|
|
||||||
private overflowCollider: Matter.Body;
|
private overflowCollider: Matter.Body;
|
||||||
private isGameOver = false;
|
private isGameOver = false;
|
||||||
|
|
||||||
|
private monoDefinitions: Mono[] = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* フィールドに出ていて、かつ合体の対象となるアイテム
|
* フィールドに出ていて、かつ合体の対象となるアイテム
|
||||||
*/
|
*/
|
||||||
|
@ -231,7 +424,7 @@ class Game extends EventEmitter<{
|
||||||
|
|
||||||
private latestDroppedAt = 0;
|
private latestDroppedAt = 0;
|
||||||
private latestFusionedAt = 0;
|
private latestFusionedAt = 0;
|
||||||
private stock: { id: string; fruit: typeof FRUITS[number] }[] = [];
|
private stock: { id: string; mono: Mono }[] = [];
|
||||||
|
|
||||||
private _combo = 0;
|
private _combo = 0;
|
||||||
private get combo() {
|
private get combo() {
|
||||||
|
@ -251,9 +444,15 @@ class Game extends EventEmitter<{
|
||||||
this.emit('changeScore', value);
|
this.emit('changeScore', value);
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor() {
|
private comboIntervalId: number | null = null;
|
||||||
|
|
||||||
|
constructor(opts: {
|
||||||
|
monoDefinitions: Mono[];
|
||||||
|
}) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
|
this.monoDefinitions = opts.monoDefinitions;
|
||||||
|
|
||||||
this.engine = Matter.Engine.create({
|
this.engine = Matter.Engine.create({
|
||||||
constraintIterations: 2 * PHYSICS_QUALITY_FACTOR,
|
constraintIterations: 2 * PHYSICS_QUALITY_FACTOR,
|
||||||
positionIterations: 6 * PHYSICS_QUALITY_FACTOR,
|
positionIterations: 6 * PHYSICS_QUALITY_FACTOR,
|
||||||
|
@ -278,7 +477,7 @@ class Game extends EventEmitter<{
|
||||||
wireframeBackground: 'transparent', // transparent to hide
|
wireframeBackground: 'transparent', // transparent to hide
|
||||||
wireframes: false,
|
wireframes: false,
|
||||||
showSleeping: false,
|
showSleeping: false,
|
||||||
pixelRatio: window.devicePixelRatio,
|
pixelRatio: Math.max(2, window.devicePixelRatio),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -287,13 +486,13 @@ class Game extends EventEmitter<{
|
||||||
this.runner = Matter.Runner.create();
|
this.runner = Matter.Runner.create();
|
||||||
Matter.Runner.run(this.runner, this.engine);
|
Matter.Runner.run(this.runner, this.engine);
|
||||||
|
|
||||||
this.detector = Matter.Detector.create();
|
|
||||||
|
|
||||||
this.engine.world.bodies = [];
|
this.engine.world.bodies = [];
|
||||||
|
|
||||||
//#region walls
|
//#region walls
|
||||||
const WALL_OPTIONS: Matter.IChamferableBodyDefinition = {
|
const WALL_OPTIONS: Matter.IChamferableBodyDefinition = {
|
||||||
isStatic: true,
|
isStatic: true,
|
||||||
|
friction: 0.7,
|
||||||
|
slop: 1.0,
|
||||||
render: {
|
render: {
|
||||||
strokeStyle: 'transparent',
|
strokeStyle: 'transparent',
|
||||||
fillStyle: 'transparent',
|
fillStyle: 'transparent',
|
||||||
|
@ -308,7 +507,7 @@ class Game extends EventEmitter<{
|
||||||
]);
|
]);
|
||||||
//#endregion
|
//#endregion
|
||||||
|
|
||||||
this.overflowCollider = Matter.Bodies.rectangle(GAME_WIDTH / 2, 0, GAME_WIDTH, 125, {
|
this.overflowCollider = Matter.Bodies.rectangle(GAME_WIDTH / 2, 0, GAME_WIDTH, 200, {
|
||||||
isStatic: true,
|
isStatic: true,
|
||||||
isSensor: true,
|
isSensor: true,
|
||||||
render: {
|
render: {
|
||||||
|
@ -325,23 +524,32 @@ class Game extends EventEmitter<{
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private createBody(fruit: typeof FRUITS[number], x: number, y: number) {
|
private createBody(mono: Mono, x: number, y: number) {
|
||||||
return Matter.Bodies.circle(x, y, fruit.size / 2, {
|
const options: Matter.IBodyDefinition = {
|
||||||
label: fruit.id,
|
label: mono.id,
|
||||||
density: 0.0005,
|
//density: 0.0005,
|
||||||
|
density: mono.size / 1000,
|
||||||
|
restitution: 0.2,
|
||||||
frictionAir: 0.01,
|
frictionAir: 0.01,
|
||||||
restitution: 0.4,
|
friction: 0.7,
|
||||||
friction: 0.5,
|
|
||||||
frictionStatic: 5,
|
frictionStatic: 5,
|
||||||
|
slop: 1.0,
|
||||||
//mass: 0,
|
//mass: 0,
|
||||||
render: {
|
render: {
|
||||||
sprite: {
|
sprite: {
|
||||||
texture: fruit.img,
|
texture: mono.img,
|
||||||
xScale: (fruit.size / fruit.imgSize) * fruit.spriteScale,
|
xScale: (mono.size / mono.imgSize) * mono.spriteScale,
|
||||||
yScale: (fruit.size / fruit.imgSize) * fruit.spriteScale,
|
yScale: (mono.size / mono.imgSize) * mono.spriteScale,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
};
|
||||||
|
if (mono.shape === 'circle') {
|
||||||
|
return Matter.Bodies.circle(x, y, mono.size / 2, options);
|
||||||
|
} else if (mono.shape === 'rectangle') {
|
||||||
|
return Matter.Bodies.rectangle(x, y, mono.size, mono.size, options);
|
||||||
|
} else {
|
||||||
|
throw new Error('unrecognized shape');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fusion(bodyA: Matter.Body, bodyB: Matter.Body) {
|
private fusion(bodyA: Matter.Body, bodyB: Matter.Body) {
|
||||||
|
@ -360,11 +568,11 @@ class Game extends EventEmitter<{
|
||||||
Matter.Composite.remove(this.engine.world, [bodyA, bodyB]);
|
Matter.Composite.remove(this.engine.world, [bodyA, bodyB]);
|
||||||
this.activeBodyIds = this.activeBodyIds.filter(x => x !== bodyA.id && x !== bodyB.id);
|
this.activeBodyIds = this.activeBodyIds.filter(x => x !== bodyA.id && x !== bodyB.id);
|
||||||
|
|
||||||
const currentFruit = FRUITS.find(y => y.id === bodyA.label)!;
|
const currentMono = this.monoDefinitions.find(y => y.id === bodyA.label)!;
|
||||||
const nextFruit = FRUITS.find(x => x.level === currentFruit.level + 1);
|
const nextMono = this.monoDefinitions.find(x => x.level === currentMono.level + 1);
|
||||||
|
|
||||||
if (nextFruit) {
|
if (nextMono) {
|
||||||
const body = this.createBody(nextFruit, newX, newY);
|
const body = this.createBody(nextMono, newX, newY);
|
||||||
Matter.Composite.add(this.engine.world, body);
|
Matter.Composite.add(this.engine.world, body);
|
||||||
|
|
||||||
// 連鎖してfusionした場合の分かりやすさのため少し間を置いてからfusion対象になるようにする
|
// 連鎖してfusionした場合の分かりやすさのため少し間を置いてからfusion対象になるようにする
|
||||||
|
@ -372,11 +580,12 @@ class Game extends EventEmitter<{
|
||||||
this.activeBodyIds.push(body.id);
|
this.activeBodyIds.push(body.id);
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
const additionalScore = Math.round(currentFruit.score * (1 + (this.combo / 3)));
|
const comboBonus = 1 + ((this.combo - 1) / 5);
|
||||||
|
const additionalScore = Math.round(currentMono.score * comboBonus);
|
||||||
this.score += additionalScore;
|
this.score += additionalScore;
|
||||||
|
|
||||||
const pan = ((newX / GAME_WIDTH) - 0.5) * 2;
|
const pan = ((newX / GAME_WIDTH) - 0.5) * 2;
|
||||||
sound.playRaw('syuilo/bubble2', 1, pan, nextFruit.sfxPitch);
|
sound.playRaw('syuilo/bubble2', 1, pan, nextMono.sfxPitch);
|
||||||
|
|
||||||
this.emit('fusioned', newX, newY, additionalScore);
|
this.emit('fusioned', newX, newY, additionalScore);
|
||||||
} else {
|
} else {
|
||||||
|
@ -400,10 +609,10 @@ class Game extends EventEmitter<{
|
||||||
}
|
}
|
||||||
|
|
||||||
public start() {
|
public start() {
|
||||||
for (let i = 0; i < 4; i++) {
|
for (let i = 0; i < this.STOCK_MAX; i++) {
|
||||||
this.stock.push({
|
this.stock.push({
|
||||||
id: Math.random().toString(),
|
id: Math.random().toString(),
|
||||||
fruit: FRUITS.filter(x => x.available)[Math.floor(Math.random() * FRUITS.filter(x => x.available).length)],
|
mono: this.monoDefinitions.filter(x => x.dropCandidate)[Math.floor(Math.random() * this.monoDefinitions.filter(x => x.dropCandidate).length)],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
this.emit('changeStock', this.stock);
|
this.emit('changeStock', this.stock);
|
||||||
|
@ -411,8 +620,8 @@ class Game extends EventEmitter<{
|
||||||
// TODO: fusion予約状態のアイテムは光らせるなどの演出をすると楽しそう
|
// TODO: fusion予約状態のアイテムは光らせるなどの演出をすると楽しそう
|
||||||
let fusionReservedPairs: { bodyA: Matter.Body; bodyB: Matter.Body }[] = [];
|
let fusionReservedPairs: { bodyA: Matter.Body; bodyB: Matter.Body }[] = [];
|
||||||
|
|
||||||
const minCollisionDepthForSound = 2.5;
|
const minCollisionEnergyForSound = 2.5;
|
||||||
const maxCollisionDepthForSound = 9;
|
const maxCollisionEnergyForSound = 9;
|
||||||
const soundPitchMax = 4;
|
const soundPitchMax = 4;
|
||||||
const soundPitchMin = 0.5;
|
const soundPitchMin = 0.5;
|
||||||
|
|
||||||
|
@ -439,8 +648,8 @@ class Game extends EventEmitter<{
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const energy = pairs.collision.depth;
|
const energy = pairs.collision.depth;
|
||||||
if (energy > minCollisionDepthForSound) {
|
if (energy > minCollisionEnergyForSound) {
|
||||||
const vol = (Math.min(maxCollisionDepthForSound, energy - minCollisionDepthForSound) / maxCollisionDepthForSound) / 4;
|
const vol = (Math.min(maxCollisionEnergyForSound, energy - minCollisionEnergyForSound) / maxCollisionEnergyForSound) / 4;
|
||||||
const pan = ((((bodyA.position.x + bodyB.position.x) / 2) / GAME_WIDTH) - 0.5) * 2;
|
const pan = ((((bodyA.position.x + bodyB.position.x) / 2) / GAME_WIDTH) - 0.5) * 2;
|
||||||
const pitch = soundPitchMin + ((soundPitchMax - soundPitchMin) * (1 - (Math.min(10, energy) / 10)));
|
const pitch = soundPitchMin + ((soundPitchMax - soundPitchMin) * (1 - (Math.min(10, energy) / 10)));
|
||||||
sound.playRaw('syuilo/poi1', vol, pan, pitch);
|
sound.playRaw('syuilo/poi1', vol, pan, pitch);
|
||||||
|
@ -449,7 +658,7 @@ class Game extends EventEmitter<{
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
window.setInterval(() => {
|
this.comboIntervalId = window.setInterval(() => {
|
||||||
if (this.latestFusionedAt < Date.now() - this.COMBO_INTERVAL) {
|
if (this.latestFusionedAt < Date.now() - this.COMBO_INTERVAL) {
|
||||||
this.combo = 0;
|
this.combo = 0;
|
||||||
}
|
}
|
||||||
|
@ -464,12 +673,12 @@ class Game extends EventEmitter<{
|
||||||
const st = this.stock.shift()!;
|
const st = this.stock.shift()!;
|
||||||
this.stock.push({
|
this.stock.push({
|
||||||
id: Math.random().toString(),
|
id: Math.random().toString(),
|
||||||
fruit: FRUITS.filter(x => x.available)[Math.floor(Math.random() * FRUITS.filter(x => x.available).length)],
|
mono: this.monoDefinitions.filter(x => x.dropCandidate)[Math.floor(Math.random() * this.monoDefinitions.filter(x => x.dropCandidate).length)],
|
||||||
});
|
});
|
||||||
this.emit('changeStock', this.stock);
|
this.emit('changeStock', this.stock);
|
||||||
|
|
||||||
const x = Math.min(GAME_WIDTH - this.PLAYAREA_MARGIN - (st.fruit.size / 2), Math.max(this.PLAYAREA_MARGIN + (st.fruit.size / 2), _x));
|
const x = Math.min(GAME_WIDTH - this.PLAYAREA_MARGIN - (st.mono.size / 2), Math.max(this.PLAYAREA_MARGIN + (st.mono.size / 2), _x));
|
||||||
const body = this.createBody(st.fruit, x, st.fruit.size / 2);
|
const body = this.createBody(st.mono, x, 50 + st.mono.size / 2);
|
||||||
Matter.Composite.add(this.engine.world, body);
|
Matter.Composite.add(this.engine.world, body);
|
||||||
this.activeBodyIds.push(body.id);
|
this.activeBodyIds.push(body.id);
|
||||||
this.latestDroppedBodyId = body.id;
|
this.latestDroppedBodyId = body.id;
|
||||||
|
@ -480,6 +689,7 @@ class Game extends EventEmitter<{
|
||||||
}
|
}
|
||||||
|
|
||||||
public dispose() {
|
public dispose() {
|
||||||
|
if (this.comboIntervalId) window.clearInterval(this.comboIntervalId);
|
||||||
Matter.Render.stop(this.render);
|
Matter.Render.stop(this.render);
|
||||||
Matter.Runner.stop(this.runner);
|
Matter.Runner.stop(this.runner);
|
||||||
Matter.World.clear(this.engine.world, false);
|
Matter.World.clear(this.engine.world, false);
|
||||||
|
@ -490,7 +700,7 @@ class Game extends EventEmitter<{
|
||||||
let game: Game;
|
let game: Game;
|
||||||
|
|
||||||
function onClick(ev: MouseEvent) {
|
function onClick(ev: MouseEvent) {
|
||||||
const rect = containerEl.value.getBoundingClientRect();
|
const rect = containerEl.value!.getBoundingClientRect();
|
||||||
|
|
||||||
const x = (ev.clientX - rect.left) / viewScaleX;
|
const x = (ev.clientX - rect.left) / viewScaleX;
|
||||||
|
|
||||||
|
@ -498,7 +708,7 @@ function onClick(ev: MouseEvent) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function onTouchend(ev: TouchEvent) {
|
function onTouchend(ev: TouchEvent) {
|
||||||
const rect = containerEl.value.getBoundingClientRect();
|
const rect = containerEl.value!.getBoundingClientRect();
|
||||||
|
|
||||||
const x = (ev.changedTouches[0].clientX - rect.left) / viewScaleX;
|
const x = (ev.changedTouches[0].clientX - rect.left) / viewScaleX;
|
||||||
|
|
||||||
|
@ -506,11 +716,11 @@ function onTouchend(ev: TouchEvent) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function onMousemove(ev: MouseEvent) {
|
function onMousemove(ev: MouseEvent) {
|
||||||
mouseX.value = ev.clientX - containerEl.value.getBoundingClientRect().left;
|
mouseX.value = ev.clientX - containerEl.value!.getBoundingClientRect().left;
|
||||||
}
|
}
|
||||||
|
|
||||||
function onTouchmove(ev: TouchEvent) {
|
function onTouchmove(ev: TouchEvent) {
|
||||||
mouseX.value = ev.touches[0].clientX - containerEl.value.getBoundingClientRect().left;
|
mouseX.value = ev.touches[0].clientX - containerEl.value!.getBoundingClientRect().left;
|
||||||
}
|
}
|
||||||
|
|
||||||
function restart() {
|
function restart() {
|
||||||
|
@ -522,9 +732,7 @@ function restart() {
|
||||||
score.value = 0;
|
score.value = 0;
|
||||||
combo.value = 0;
|
combo.value = 0;
|
||||||
comboPrev.value = 0;
|
comboPrev.value = 0;
|
||||||
game = new Game();
|
gameStarted.value = false;
|
||||||
attachGame();
|
|
||||||
game.start();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function attachGame() {
|
function attachGame() {
|
||||||
|
@ -567,24 +775,91 @@ function attachGame() {
|
||||||
currentPick.value = null;
|
currentPick.value = null;
|
||||||
dropReady.value = false;
|
dropReady.value = false;
|
||||||
gameOver.value = true;
|
gameOver.value = true;
|
||||||
|
|
||||||
|
if (score.value > (highScore.value ?? 0)) {
|
||||||
|
highScore.value = score.value;
|
||||||
|
|
||||||
|
misskeyApi('i/registry/set', {
|
||||||
|
scope: ['dropAndFusionGame'],
|
||||||
|
key: 'highScore:' + gameMode.value,
|
||||||
|
value: highScore.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
async function start() {
|
||||||
game = new Game();
|
try {
|
||||||
|
highScore.value = await misskeyApi('i/registry/get', {
|
||||||
|
scope: ['dropAndFusionGame'],
|
||||||
|
key: 'highScore:' + gameMode.value,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
}
|
||||||
|
|
||||||
|
gameStarted.value = true;
|
||||||
|
game = new Game(gameMode.value === 'normal' ? {
|
||||||
|
monoDefinitions: NORAML_MONOS,
|
||||||
|
} : {
|
||||||
|
monoDefinitions: SQUARE_MONOS,
|
||||||
|
});
|
||||||
attachGame();
|
attachGame();
|
||||||
|
|
||||||
game.start();
|
game.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGameImageDriveFile() {
|
||||||
|
return new Promise<Misskey.entities.DriveFile | null>(res => {
|
||||||
|
canvasEl.value?.toBlob(blob => {
|
||||||
|
if (!blob) return res(null);
|
||||||
|
if ($i == null) return res(null);
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', blob);
|
||||||
|
formData.append('name', `bubble-game-${Date.now()}.png`);
|
||||||
|
formData.append('isSensitive', 'false');
|
||||||
|
formData.append('comment', 'null');
|
||||||
|
formData.append('i', $i.token);
|
||||||
|
if (defaultStore.state.uploadFolder) {
|
||||||
|
formData.append('folderId', defaultStore.state.uploadFolder);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.fetch(apiUrl + '/drive/files/create', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(f => {
|
||||||
|
res(f);
|
||||||
|
});
|
||||||
|
}, 'image/png');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function share() {
|
||||||
|
const uploading = getGameImageDriveFile();
|
||||||
|
os.promiseDialog(uploading);
|
||||||
|
const file = await uploading;
|
||||||
|
if (!file) return;
|
||||||
|
os.post({
|
||||||
|
initialText: `#BubbleGame
|
||||||
|
MODE: ${gameMode.value}
|
||||||
|
SCORE: ${score.value}`,
|
||||||
|
initialFiles: [file],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
useInterval(() => {
|
||||||
|
if (!canvasEl.value) return;
|
||||||
const actualCanvasWidth = canvasEl.value.getBoundingClientRect().width;
|
const actualCanvasWidth = canvasEl.value.getBoundingClientRect().width;
|
||||||
const actualCanvasHeight = canvasEl.value.getBoundingClientRect().height;
|
const actualCanvasHeight = canvasEl.value.getBoundingClientRect().height;
|
||||||
viewScaleX = actualCanvasWidth / GAME_WIDTH;
|
viewScaleX = actualCanvasWidth / GAME_WIDTH;
|
||||||
viewScaleY = actualCanvasHeight / GAME_HEIGHT;
|
viewScaleY = actualCanvasHeight / GAME_HEIGHT;
|
||||||
|
}, 1000, { immediate: false, afterMounted: true });
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
definePageMetadata({
|
definePageMetadata({
|
||||||
title: 'Drop & Fusion',
|
title: i18n.ts.bubbleGame,
|
||||||
icon: 'ti ti-apple',
|
icon: 'ti ti-apple',
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
@ -637,6 +912,8 @@ definePageMetadata({
|
||||||
}
|
}
|
||||||
|
|
||||||
.root {
|
.root {
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: 600px;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
|
||||||
* {
|
* {
|
||||||
|
@ -667,7 +944,8 @@ definePageMetadata({
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
filter: drop-shadow(0 6px 16px #0007);
|
// なんかiOSでちらつく
|
||||||
|
//filter: drop-shadow(0 6px 16px #0007);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
@ -677,7 +955,8 @@ definePageMetadata({
|
||||||
display: block;
|
display: block;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
margin-top: -50px;
|
margin-top: -50px;
|
||||||
max-width: 100%;
|
width: 100% !important;
|
||||||
|
height: auto !important;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
@ -699,34 +978,49 @@ definePageMetadata({
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
font-style: oblique;
|
font-style: oblique;
|
||||||
|
color: #fff;
|
||||||
|
-webkit-text-stroke: 1px rgb(255, 145, 0);
|
||||||
|
text-shadow: 0 0 6px #0005;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.currentFruit {
|
.currentMono {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
margin-top: 20px;
|
margin-top: 80px;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
filter: drop-shadow(0 6px 16px #0007);
|
filter: drop-shadow(0 6px 16px #0007);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.currentFruitArrow {
|
.dropper {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
margin-top: 20px;
|
top: 0;
|
||||||
|
width: 70px;
|
||||||
|
margin-top: -10px;
|
||||||
|
margin-left: -30px;
|
||||||
|
z-index: 2;
|
||||||
|
filter: drop-shadow(0 6px 16px #0007);
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.currentMonoArrow {
|
||||||
|
position: absolute;
|
||||||
|
margin-top: 100px;
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
animation: currentFruitArrow 2s ease infinite;
|
animation: currentMonoArrow 2s ease infinite;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropGuide {
|
.dropGuide {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50px;
|
top: 120px;
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
width: 3px;
|
width: 3px;
|
||||||
height: calc(100% - 50px);
|
height: calc(100% - 120px);
|
||||||
background: #f002;
|
background: #f002;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
@ -751,7 +1045,7 @@ definePageMetadata({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes currentFruitArrow {
|
@keyframes currentMonoArrow {
|
||||||
0% { transform: translateY(0); }
|
0% { transform: translateY(0); }
|
||||||
25% { transform: translateY(-8px); }
|
25% { transform: translateY(-8px); }
|
||||||
50% { transform: translateY(0); }
|
50% { transform: translateY(0); }
|
||||||
|
|
|
@ -21,6 +21,14 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<MkButton primary :class="$style.button" inline @click="exportFavorites()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton>
|
<MkButton primary :class="$style.button" inline @click="exportFavorites()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton>
|
||||||
</MkFolder>
|
</MkFolder>
|
||||||
</FormSection>
|
</FormSection>
|
||||||
|
<FormSection>
|
||||||
|
<template #label><i class="ti ti-star"></i> {{ i18n.ts._exportOrImport.clips }}</template>
|
||||||
|
<MkFolder>
|
||||||
|
<template #label>{{ i18n.ts.export }}</template>
|
||||||
|
<template #icon><i class="ti ti-download"></i></template>
|
||||||
|
<MkButton primary :class="$style.button" inline @click="exportClips()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton>
|
||||||
|
</MkFolder>
|
||||||
|
</FormSection>
|
||||||
<FormSection>
|
<FormSection>
|
||||||
<template #label><i class="ti ti-users"></i> {{ i18n.ts._exportOrImport.followingList }}</template>
|
<template #label><i class="ti ti-users"></i> {{ i18n.ts._exportOrImport.followingList }}</template>
|
||||||
<div class="_gaps_s">
|
<div class="_gaps_s">
|
||||||
|
@ -157,6 +165,10 @@ const exportFavorites = () => {
|
||||||
misskeyApi('i/export-favorites', {}).then(onExportSuccess).catch(onError);
|
misskeyApi('i/export-favorites', {}).then(onExportSuccess).catch(onError);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const exportClips = () => {
|
||||||
|
misskeyApi('i/export-clips', {}).then(onExportSuccess).catch(onError);
|
||||||
|
};
|
||||||
|
|
||||||
const exportFollowing = () => {
|
const exportFollowing = () => {
|
||||||
misskeyApi('i/export-following', {
|
misskeyApi('i/export-following', {
|
||||||
excludeMuting: excludeMutingUsers.value,
|
excludeMuting: excludeMutingUsers.value,
|
||||||
|
|
|
@ -532,7 +532,7 @@ export const routes = [{
|
||||||
component: page(() => import('./pages/clicker.vue')),
|
component: page(() => import('./pages/clicker.vue')),
|
||||||
loginRequired: true,
|
loginRequired: true,
|
||||||
}, {
|
}, {
|
||||||
path: '/drop-and-fusion',
|
path: '/bubble-game',
|
||||||
component: page(() => import('./pages/drop-and-fusion.vue')),
|
component: page(() => import('./pages/drop-and-fusion.vue')),
|
||||||
loginRequired: true,
|
loginRequired: true,
|
||||||
}, {
|
}, {
|
||||||
|
|
|
@ -218,7 +218,7 @@ function getTextOptions(def: values.Value | undefined): Omit<AsUiText, 'id' | 't
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMfmOptions(def: values.Value | undefined): Omit<AsUiMfm, 'id' | 'type'> {
|
function getMfmOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Omit<AsUiMfm, 'id' | 'type'> {
|
||||||
utils.assertObject(def);
|
utils.assertObject(def);
|
||||||
|
|
||||||
const text = def.value.get('text');
|
const text = def.value.get('text');
|
||||||
|
@ -241,7 +241,7 @@ function getMfmOptions(def: values.Value | undefined): Omit<AsUiMfm, 'id' | 'typ
|
||||||
color: color?.value,
|
color: color?.value,
|
||||||
font: font?.value,
|
font: font?.value,
|
||||||
onClickEv: (evId: string) => {
|
onClickEv: (evId: string) => {
|
||||||
if (onClickEv) call(onClickEv, values.STR(evId));
|
if (onClickEv) call(onClickEv, [values.STR(evId)]);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -36,7 +36,8 @@ for (let i = 0; i < emojilist.length; i++) {
|
||||||
export const emojiCharByCategory = _charGroupByCategory;
|
export const emojiCharByCategory = _charGroupByCategory;
|
||||||
|
|
||||||
export function getEmojiName(char: string): string | null {
|
export function getEmojiName(char: string): string | null {
|
||||||
const idx = _indexByChar.get(char);
|
// Colorize it because emojilist.json assumes that
|
||||||
|
const idx = _indexByChar.get(colorizeEmoji(char));
|
||||||
if (idx == null) {
|
if (idx == null) {
|
||||||
return null;
|
return null;
|
||||||
} else {
|
} else {
|
||||||
|
@ -44,6 +45,10 @@ export function getEmojiName(char: string): string | null {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function colorizeEmoji(char: string) {
|
||||||
|
return char.length === 1 ? `${char}\uFE0F` : char;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CustomEmojiFolderTree {
|
export interface CustomEmojiFolderTree {
|
||||||
value: string;
|
value: string;
|
||||||
category: string;
|
category: string;
|
||||||
|
|
|
@ -29,8 +29,8 @@ function toolsMenuItems(): MenuItem[] {
|
||||||
icon: 'ti ti-cookie',
|
icon: 'ti ti-cookie',
|
||||||
}, {
|
}, {
|
||||||
type: 'link',
|
type: 'link',
|
||||||
to: '/drop-and-fusion',
|
to: '/bubble-game',
|
||||||
text: 'Drop & Fusion',
|
text: i18n.ts.bubbleGame,
|
||||||
icon: 'ti ti-apple',
|
icon: 'ti ti-apple',
|
||||||
}, ($i && ($i.isAdmin || $i.policies.canManageCustomEmojis)) ? {
|
}, ($i && ($i.isAdmin || $i.policies.canManageCustomEmojis)) ? {
|
||||||
type: 'link',
|
type: 'link',
|
||||||
|
|
|
@ -80,13 +80,13 @@ import * as Misskey from 'misskey-js';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
connection: any,
|
connection: Misskey.ChannelConnection<Misskey.Channels['serverStats']>,
|
||||||
meta: Misskey.entities.ServerInfoResponse
|
meta: Misskey.entities.ServerInfoResponse
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const viewBoxX = ref<number>(50);
|
const viewBoxX = ref<number>(50);
|
||||||
const viewBoxY = ref<number>(30);
|
const viewBoxY = ref<number>(30);
|
||||||
const stats = ref<any[]>([]);
|
const stats = ref<Misskey.entities.ServerStats[]>([]);
|
||||||
const cpuGradientId = uuid();
|
const cpuGradientId = uuid();
|
||||||
const cpuMaskId = uuid();
|
const cpuMaskId = uuid();
|
||||||
const memGradientId = uuid();
|
const memGradientId = uuid();
|
||||||
|
@ -107,6 +107,7 @@ onMounted(() => {
|
||||||
props.connection.on('statsLog', onStatsLog);
|
props.connection.on('statsLog', onStatsLog);
|
||||||
props.connection.send('requestLog', {
|
props.connection.send('requestLog', {
|
||||||
id: Math.random().toString().substring(2, 10),
|
id: Math.random().toString().substring(2, 10),
|
||||||
|
length: 50,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -115,7 +116,7 @@ onBeforeUnmount(() => {
|
||||||
props.connection.off('statsLog', onStatsLog);
|
props.connection.off('statsLog', onStatsLog);
|
||||||
});
|
});
|
||||||
|
|
||||||
function onStats(connStats) {
|
function onStats(connStats: Misskey.entities.ServerStats) {
|
||||||
stats.value.push(connStats);
|
stats.value.push(connStats);
|
||||||
if (stats.value.length > 50) stats.value.shift();
|
if (stats.value.length > 50) stats.value.shift();
|
||||||
|
|
||||||
|
@ -136,8 +137,8 @@ function onStats(connStats) {
|
||||||
memP.value = (connStats.mem.active / props.meta.mem.total * 100).toFixed(0);
|
memP.value = (connStats.mem.active / props.meta.mem.total * 100).toFixed(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onStatsLog(statsLog) {
|
function onStatsLog(statsLog: Misskey.entities.ServerStatsLog) {
|
||||||
for (const revStats of [...statsLog].reverse()) {
|
for (const revStats of statsLog.reverse()) {
|
||||||
onStats(revStats);
|
onStats(revStats);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,13 +20,13 @@ import * as Misskey from 'misskey-js';
|
||||||
import XPie from './pie.vue';
|
import XPie from './pie.vue';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
connection: any,
|
connection: Misskey.ChannelConnection<Misskey.Channels['serverStats']>,
|
||||||
meta: Misskey.entities.ServerInfoResponse
|
meta: Misskey.entities.ServerInfoResponse
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const usage = ref<number>(0);
|
const usage = ref<number>(0);
|
||||||
|
|
||||||
function onStats(stats) {
|
function onStats(stats: Misskey.entities.ServerStats) {
|
||||||
usage.value = stats.cpu;
|
usage.value = stats.cpu;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { onUnmounted, ref } from 'vue';
|
import { onUnmounted, ref } from 'vue';
|
||||||
import * as Misskey from 'misskey-js';
|
import * as Misskey from 'misskey-js';
|
||||||
import { useWidgetPropsManager, Widget, WidgetComponentExpose } from '../widget.js';
|
import { useWidgetPropsManager, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from '../widget.js';
|
||||||
import XCpuMemory from './cpu-mem.vue';
|
import XCpuMemory from './cpu-mem.vue';
|
||||||
import XNet from './net.vue';
|
import XNet from './net.vue';
|
||||||
import XCpu from './cpu.vue';
|
import XCpu from './cpu.vue';
|
||||||
|
@ -54,11 +54,8 @@ const widgetPropsDef = {
|
||||||
|
|
||||||
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
|
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
|
||||||
|
|
||||||
// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
|
const props = defineProps<WidgetComponentProps<WidgetProps>>();
|
||||||
//const props = defineProps<WidgetComponentProps<WidgetProps>>();
|
const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
|
||||||
//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
|
|
||||||
const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
|
|
||||||
const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
|
|
||||||
|
|
||||||
const { widgetProps, configure, save } = useWidgetPropsManager(name,
|
const { widgetProps, configure, save } = useWidgetPropsManager(name,
|
||||||
widgetPropsDef,
|
widgetPropsDef,
|
||||||
|
|
|
@ -22,7 +22,7 @@ import XPie from './pie.vue';
|
||||||
import bytes from '@/filters/bytes.js';
|
import bytes from '@/filters/bytes.js';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
connection: any,
|
connection: Misskey.ChannelConnection<Misskey.Channels['serverStats']>,
|
||||||
meta: Misskey.entities.ServerInfoResponse
|
meta: Misskey.entities.ServerInfoResponse
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
@ -31,7 +31,7 @@ const total = ref<number>(0);
|
||||||
const used = ref<number>(0);
|
const used = ref<number>(0);
|
||||||
const free = ref<number>(0);
|
const free = ref<number>(0);
|
||||||
|
|
||||||
function onStats(stats) {
|
function onStats(stats: Misskey.entities.ServerStats) {
|
||||||
usage.value = stats.mem.active / props.meta.mem.total;
|
usage.value = stats.mem.active / props.meta.mem.total;
|
||||||
total.value = props.meta.mem.total;
|
total.value = props.meta.mem.total;
|
||||||
used.value = stats.mem.active;
|
used.value = stats.mem.active;
|
||||||
|
|
|
@ -54,13 +54,13 @@ import * as Misskey from 'misskey-js';
|
||||||
import bytes from '@/filters/bytes.js';
|
import bytes from '@/filters/bytes.js';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
connection: any,
|
connection: Misskey.ChannelConnection<Misskey.Channels['serverStats']>,
|
||||||
meta: Misskey.entities.ServerInfoResponse
|
meta: Misskey.entities.ServerInfoResponse
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const viewBoxX = ref<number>(50);
|
const viewBoxX = ref<number>(50);
|
||||||
const viewBoxY = ref<number>(30);
|
const viewBoxY = ref<number>(30);
|
||||||
const stats = ref<any[]>([]);
|
const stats = ref<Misskey.entities.ServerStats[]>([]);
|
||||||
const inPolylinePoints = ref<string>('');
|
const inPolylinePoints = ref<string>('');
|
||||||
const outPolylinePoints = ref<string>('');
|
const outPolylinePoints = ref<string>('');
|
||||||
const inPolygonPoints = ref<string>('');
|
const inPolygonPoints = ref<string>('');
|
||||||
|
@ -77,6 +77,7 @@ onMounted(() => {
|
||||||
props.connection.on('statsLog', onStatsLog);
|
props.connection.on('statsLog', onStatsLog);
|
||||||
props.connection.send('requestLog', {
|
props.connection.send('requestLog', {
|
||||||
id: Math.random().toString().substring(2, 10),
|
id: Math.random().toString().substring(2, 10),
|
||||||
|
length: 50,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -85,7 +86,7 @@ onBeforeUnmount(() => {
|
||||||
props.connection.off('statsLog', onStatsLog);
|
props.connection.off('statsLog', onStatsLog);
|
||||||
});
|
});
|
||||||
|
|
||||||
function onStats(connStats) {
|
function onStats(connStats: Misskey.entities.ServerStats) {
|
||||||
stats.value.push(connStats);
|
stats.value.push(connStats);
|
||||||
if (stats.value.length > 50) stats.value.shift();
|
if (stats.value.length > 50) stats.value.shift();
|
||||||
|
|
||||||
|
@ -109,8 +110,8 @@ function onStats(connStats) {
|
||||||
outRecent.value = connStats.net.tx;
|
outRecent.value = connStats.net.tx;
|
||||||
}
|
}
|
||||||
|
|
||||||
function onStatsLog(statsLog) {
|
function onStatsLog(statsLog: Misskey.entities.ServerStatsLog) {
|
||||||
for (const revStats of [...statsLog].reverse()) {
|
for (const revStats of statsLog.reverse()) {
|
||||||
onStats(revStats);
|
onStats(revStats);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
41
packages/frontend/test/emoji.test.ts
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, test, assert, afterEach } from 'vitest';
|
||||||
|
import { render, cleanup, type RenderResult } from '@testing-library/vue';
|
||||||
|
import { defaultStoreState } from './init.js';
|
||||||
|
import { getEmojiName } from '@/scripts/emojilist.js';
|
||||||
|
import { components } from '@/components/index.js';
|
||||||
|
import { directives } from '@/directives/index.js';
|
||||||
|
import MkEmoji from '@/components/global/MkEmoji.vue';
|
||||||
|
|
||||||
|
describe('Emoji', () => {
|
||||||
|
const renderEmoji = (emoji: string): RenderResult => {
|
||||||
|
return render(MkEmoji, {
|
||||||
|
props: { emoji },
|
||||||
|
global: { directives, components },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
defaultStoreState.emojiStyle = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MkEmoji', () => {
|
||||||
|
test('Should render selector-less heart with color in native mode', async () => {
|
||||||
|
defaultStoreState.emojiStyle = 'native';
|
||||||
|
const mkEmoji = await renderEmoji('\u2764'); // monochrome heart
|
||||||
|
assert.ok(mkEmoji.queryByText('\u2764\uFE0F')); // colored heart
|
||||||
|
assert.ok(!mkEmoji.queryByText('\u2764'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Emoji list', () => {
|
||||||
|
test('Should get the name of the heart', () => {
|
||||||
|
assert.strictEqual(getEmojiName('\u2764'), 'heart');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -17,21 +17,23 @@ updateI18n(locales['en-US']);
|
||||||
// XXX: misskey-js panics if WebSocket is not defined
|
// XXX: misskey-js panics if WebSocket is not defined
|
||||||
vi.stubGlobal('WebSocket', class WebSocket extends EventTarget { static CLOSING = 2; });
|
vi.stubGlobal('WebSocket', class WebSocket extends EventTarget { static CLOSING = 2; });
|
||||||
|
|
||||||
|
export const defaultStoreState: Record<string, unknown> = {
|
||||||
|
|
||||||
|
// なんかtestがうまいこと動かないのでここに書く
|
||||||
|
dataSaver: {
|
||||||
|
media: false,
|
||||||
|
avatar: false,
|
||||||
|
urlPreview: false,
|
||||||
|
code: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
// XXX: defaultStore somehow becomes undefined in vitest?
|
// XXX: defaultStore somehow becomes undefined in vitest?
|
||||||
vi.mock('@/store.js', () => {
|
vi.mock('@/store.js', () => {
|
||||||
return {
|
return {
|
||||||
defaultStore: {
|
defaultStore: {
|
||||||
state: {
|
state: defaultStoreState,
|
||||||
|
|
||||||
// なんかtestがうまいこと動かないのでここに書く
|
|
||||||
dataSaver: {
|
|
||||||
media: false,
|
|
||||||
avatar: false,
|
|
||||||
urlPreview: false,
|
|
||||||
code: false,
|
|
||||||
},
|
|
||||||
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
|
@ -2554,7 +2554,7 @@ type QueueStats = {
|
||||||
};
|
};
|
||||||
|
|
||||||
// @public (undocumented)
|
// @public (undocumented)
|
||||||
type QueueStatsLog = string[];
|
type QueueStatsLog = QueueStats[];
|
||||||
|
|
||||||
// @public (undocumented)
|
// @public (undocumented)
|
||||||
type RenoteMuteCreateRequest = operations['renote-mute/create']['requestBody']['content']['application/json'];
|
type RenoteMuteCreateRequest = operations['renote-mute/create']['requestBody']['content']['application/json'];
|
||||||
|
@ -2628,7 +2628,7 @@ type ServerStats = {
|
||||||
};
|
};
|
||||||
|
|
||||||
// @public (undocumented)
|
// @public (undocumented)
|
||||||
type ServerStatsLog = string[];
|
type ServerStatsLog = ServerStats[];
|
||||||
|
|
||||||
// @public (undocumented)
|
// @public (undocumented)
|
||||||
type Signin = components['schemas']['Signin'];
|
type Signin = components['schemas']['Signin'];
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
/*
|
/*
|
||||||
* version: 2023.12.2
|
* version: 2023.12.2
|
||||||
* generatedAt: 2024-01-04T18:10:15.096Z
|
* generatedAt: 2024-01-07T09:49:34.543Z
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { SwitchCaseResponseType } from '../api.js';
|
import type { SwitchCaseResponseType } from '../api.js';
|
||||||
|
@ -2249,6 +2249,18 @@ declare module '../api.js' {
|
||||||
credential?: string | null,
|
credential?: string | null,
|
||||||
): Promise<SwitchCaseResponseType<E, P>>;
|
): Promise<SwitchCaseResponseType<E, P>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* No description provided.
|
||||||
|
*
|
||||||
|
* **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties.
|
||||||
|
* **Credential required**: *Yes*
|
||||||
|
*/
|
||||||
|
request<E extends 'i/export-clips', P extends Endpoints[E]['req']>(
|
||||||
|
endpoint: E,
|
||||||
|
params: P,
|
||||||
|
credential?: string | null,
|
||||||
|
): Promise<SwitchCaseResponseType<E, P>>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* No description provided.
|
* No description provided.
|
||||||
*
|
*
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
/*
|
/*
|
||||||
* version: 2023.12.2
|
* version: 2023.12.2
|
||||||
* generatedAt: 2024-01-04T18:10:15.094Z
|
* generatedAt: 2024-01-07T09:49:34.533Z
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
@ -745,6 +745,7 @@ export type Endpoints = {
|
||||||
'i/export-following': { req: IExportFollowingRequest; res: EmptyResponse };
|
'i/export-following': { req: IExportFollowingRequest; res: EmptyResponse };
|
||||||
'i/export-mute': { req: EmptyRequest; res: EmptyResponse };
|
'i/export-mute': { req: EmptyRequest; res: EmptyResponse };
|
||||||
'i/export-notes': { req: EmptyRequest; res: EmptyResponse };
|
'i/export-notes': { req: EmptyRequest; res: EmptyResponse };
|
||||||
|
'i/export-clips': { req: EmptyRequest; res: EmptyResponse };
|
||||||
'i/export-favorites': { req: EmptyRequest; res: EmptyResponse };
|
'i/export-favorites': { req: EmptyRequest; res: EmptyResponse };
|
||||||
'i/export-user-lists': { req: EmptyRequest; res: EmptyResponse };
|
'i/export-user-lists': { req: EmptyRequest; res: EmptyResponse };
|
||||||
'i/export-antennas': { req: EmptyRequest; res: EmptyResponse };
|
'i/export-antennas': { req: EmptyRequest; res: EmptyResponse };
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
/*
|
/*
|
||||||
* version: 2023.12.2
|
* version: 2023.12.2
|
||||||
* generatedAt: 2024-01-04T18:10:15.093Z
|
* generatedAt: 2024-01-07T09:49:34.526Z
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { operations } from './types.js';
|
import { operations } from './types.js';
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
/*
|
/*
|
||||||
* version: 2023.12.2
|
* version: 2023.12.2
|
||||||
* generatedAt: 2024-01-04T18:10:15.091Z
|
* generatedAt: 2024-01-07T09:49:34.518Z
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { components } from './types.js';
|
import { components } from './types.js';
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* version: 2023.12.2
|
* version: 2023.12.2
|
||||||
* generatedAt: 2024-01-04T18:10:15.023Z
|
* generatedAt: 2024-01-07T09:49:34.268Z
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -1966,6 +1966,16 @@ export type paths = {
|
||||||
*/
|
*/
|
||||||
post: operations['i/export-notes'];
|
post: operations['i/export-notes'];
|
||||||
};
|
};
|
||||||
|
'/i/export-clips': {
|
||||||
|
/**
|
||||||
|
* i/export-clips
|
||||||
|
* @description No description provided.
|
||||||
|
*
|
||||||
|
* **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties.
|
||||||
|
* **Credential required**: *Yes*
|
||||||
|
*/
|
||||||
|
post: operations['i/export-clips'];
|
||||||
|
};
|
||||||
'/i/export-favorites': {
|
'/i/export-favorites': {
|
||||||
/**
|
/**
|
||||||
* i/export-favorites
|
* i/export-favorites
|
||||||
|
@ -16245,6 +16255,57 @@ export type operations = {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* i/export-clips
|
||||||
|
* @description No description provided.
|
||||||
|
*
|
||||||
|
* **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties.
|
||||||
|
* **Credential required**: *Yes*
|
||||||
|
*/
|
||||||
|
'i/export-clips': {
|
||||||
|
responses: {
|
||||||
|
/** @description OK (without any results) */
|
||||||
|
204: {
|
||||||
|
content: never;
|
||||||
|
};
|
||||||
|
/** @description Client error */
|
||||||
|
400: {
|
||||||
|
content: {
|
||||||
|
'application/json': components['schemas']['Error'];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Authentication error */
|
||||||
|
401: {
|
||||||
|
content: {
|
||||||
|
'application/json': components['schemas']['Error'];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Forbidden error */
|
||||||
|
403: {
|
||||||
|
content: {
|
||||||
|
'application/json': components['schemas']['Error'];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description I'm Ai */
|
||||||
|
418: {
|
||||||
|
content: {
|
||||||
|
'application/json': components['schemas']['Error'];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description To many requests */
|
||||||
|
429: {
|
||||||
|
content: {
|
||||||
|
'application/json': components['schemas']['Error'];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Internal server error */
|
||||||
|
500: {
|
||||||
|
content: {
|
||||||
|
'application/json': components['schemas']['Error'];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
/**
|
/**
|
||||||
* i/export-favorites
|
* i/export-favorites
|
||||||
* @description No description provided.
|
* @description No description provided.
|
||||||
|
|
|
@ -149,7 +149,7 @@ export type ServerStats = {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ServerStatsLog = string[];
|
export type ServerStatsLog = ServerStats[];
|
||||||
|
|
||||||
export type QueueStats = {
|
export type QueueStats = {
|
||||||
deliver: {
|
deliver: {
|
||||||
|
@ -166,7 +166,7 @@ export type QueueStats = {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type QueueStatsLog = string[];
|
export type QueueStatsLog = QueueStats[];
|
||||||
|
|
||||||
export type EmojiAdded = {
|
export type EmojiAdded = {
|
||||||
emoji: EmojiDetailed
|
emoji: EmojiDetailed
|
||||||
|
|