Merge branch 'develop' into feat-12909
This commit is contained in:
		| @@ -16,6 +16,7 @@ | ||||
|  | ||||
| ### General | ||||
| - Feat: [mCaptcha](https://github.com/mCaptcha/mCaptcha)のサポートを追加 | ||||
| - Fix: リストライムラインの「リノートを表示」が正しく機能しない問題を修正 | ||||
|  | ||||
| ### Client | ||||
| - Feat: 新しいゲームを追加 | ||||
| @@ -26,6 +27,7 @@ | ||||
| ### Server | ||||
| - Enhance: 連合先のレートリミットに引っかかった際にリトライするようになりました | ||||
| - Enhance: ActivityPub Deliver queueでBodyを事前処理するように (#12916) | ||||
| - Enhance: クリップをエクスポートできるように | ||||
|  | ||||
| ## 2023.12.2 | ||||
|  | ||||
|   | ||||
							
								
								
									
										1
									
								
								locales/index.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								locales/index.d.ts
									
									
									
									
										vendored
									
									
								
							| @@ -2258,6 +2258,7 @@ export interface Locale { | ||||
|     "_exportOrImport": { | ||||
|         "allNotes": string; | ||||
|         "favoritedNotes": string; | ||||
|         "clips": string; | ||||
|         "followingList": string; | ||||
|         "muteList": string; | ||||
|         "blockingList": string; | ||||
|   | ||||
| @@ -2161,6 +2161,7 @@ _profile: | ||||
| _exportOrImport: | ||||
|   allNotes: "全てのノート" | ||||
|   favoritedNotes: "お気に入りにしたノート" | ||||
|   clips: "クリップ" | ||||
|   followingList: "フォロー" | ||||
|   muteList: "ミュート" | ||||
|   blockingList: "ブロック" | ||||
|   | ||||
| @@ -182,6 +182,16 @@ export class QueueService { | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public createExportClipsJob(user: ThinUser) { | ||||
| 		return this.dbQueue.add('exportClips', { | ||||
| 			user: { id: user.id }, | ||||
| 		}, { | ||||
| 			removeOnComplete: true, | ||||
| 			removeOnFail: true, | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public createExportFavoritesJob(user: ThinUser) { | ||||
| 		return this.dbQueue.add('exportFavorites', { | ||||
|   | ||||
| @@ -24,6 +24,7 @@ import { ExportCustomEmojisProcessorService } from './processors/ExportCustomEmo | ||||
| import { ExportFollowingProcessorService } from './processors/ExportFollowingProcessorService.js'; | ||||
| import { ExportMutingProcessorService } from './processors/ExportMutingProcessorService.js'; | ||||
| import { ExportNotesProcessorService } from './processors/ExportNotesProcessorService.js'; | ||||
| import { ExportClipsProcessorService } from './processors/ExportClipsProcessorService.js'; | ||||
| import { ExportUserListsProcessorService } from './processors/ExportUserListsProcessorService.js'; | ||||
| import { ExportAntennasProcessorService } from './processors/ExportAntennasProcessorService.js'; | ||||
| import { ImportBlockingProcessorService } from './processors/ImportBlockingProcessorService.js'; | ||||
| @@ -53,6 +54,7 @@ import { RelationshipProcessorService } from './processors/RelationshipProcessor | ||||
| 		DeleteDriveFilesProcessorService, | ||||
| 		ExportCustomEmojisProcessorService, | ||||
| 		ExportNotesProcessorService, | ||||
| 		ExportClipsProcessorService, | ||||
| 		ExportFavoritesProcessorService, | ||||
| 		ExportFollowingProcessorService, | ||||
| 		ExportMutingProcessorService, | ||||
|   | ||||
| @@ -16,6 +16,7 @@ import { InboxProcessorService } from './processors/InboxProcessorService.js'; | ||||
| import { DeleteDriveFilesProcessorService } from './processors/DeleteDriveFilesProcessorService.js'; | ||||
| import { ExportCustomEmojisProcessorService } from './processors/ExportCustomEmojisProcessorService.js'; | ||||
| import { ExportNotesProcessorService } from './processors/ExportNotesProcessorService.js'; | ||||
| import { ExportClipsProcessorService } from './processors/ExportClipsProcessorService.js'; | ||||
| import { ExportFollowingProcessorService } from './processors/ExportFollowingProcessorService.js'; | ||||
| import { ExportMutingProcessorService } from './processors/ExportMutingProcessorService.js'; | ||||
| import { ExportBlockingProcessorService } from './processors/ExportBlockingProcessorService.js'; | ||||
| @@ -91,6 +92,7 @@ export class QueueProcessorService implements OnApplicationShutdown { | ||||
| 		private deleteDriveFilesProcessorService: DeleteDriveFilesProcessorService, | ||||
| 		private exportCustomEmojisProcessorService: ExportCustomEmojisProcessorService, | ||||
| 		private exportNotesProcessorService: ExportNotesProcessorService, | ||||
| 		private exportClipsProcessorService: ExportClipsProcessorService, | ||||
| 		private exportFavoritesProcessorService: ExportFavoritesProcessorService, | ||||
| 		private exportFollowingProcessorService: ExportFollowingProcessorService, | ||||
| 		private exportMutingProcessorService: ExportMutingProcessorService, | ||||
| @@ -164,6 +166,7 @@ export class QueueProcessorService implements OnApplicationShutdown { | ||||
| 				case 'deleteDriveFiles': return this.deleteDriveFilesProcessorService.process(job); | ||||
| 				case 'exportCustomEmojis': return this.exportCustomEmojisProcessorService.process(job); | ||||
| 				case 'exportNotes': return this.exportNotesProcessorService.process(job); | ||||
| 				case 'exportClips': return this.exportClipsProcessorService.process(job); | ||||
| 				case 'exportFavorites': return this.exportFavoritesProcessorService.process(job); | ||||
| 				case 'exportFollowing': return this.exportFollowingProcessorService.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, | ||||
| 				}, | ||||
| 			}, | ||||
| 		}; | ||||
| 	} | ||||
| } | ||||
| @@ -208,6 +208,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_exportMute from './endpoints/i/export-mute.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_exportUserLists from './endpoints/i/export-user-lists.js'; | ||||
| import * as ep___i_exportAntennas from './endpoints/i/export-antennas.js'; | ||||
| @@ -569,6 +570,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_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_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_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 }; | ||||
| @@ -934,6 +936,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention | ||||
| 		$i_exportFollowing, | ||||
| 		$i_exportMute, | ||||
| 		$i_exportNotes, | ||||
| 		$i_exportClips, | ||||
| 		$i_exportFavorites, | ||||
| 		$i_exportUserLists, | ||||
| 		$i_exportAntennas, | ||||
| @@ -1293,6 +1296,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention | ||||
| 		$i_exportFollowing, | ||||
| 		$i_exportMute, | ||||
| 		$i_exportNotes, | ||||
| 		$i_exportClips, | ||||
| 		$i_exportFavorites, | ||||
| 		$i_exportUserLists, | ||||
| 		$i_exportAntennas, | ||||
|   | ||||
| @@ -3,8 +3,8 @@ | ||||
|  * SPDX-License-Identifier: AGPL-3.0-only | ||||
|  */ | ||||
|  | ||||
| import type { Schema } from '@/misc/json-schema.js'; | ||||
| import { permissions } from 'misskey-js'; | ||||
| import type { Schema } from '@/misc/json-schema.js'; | ||||
| import { RolePolicies } from '@/core/RoleService.js'; | ||||
|  | ||||
| import * as ep___admin_meta from './endpoints/admin/meta.js'; | ||||
| @@ -209,6 +209,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_exportMute from './endpoints/i/export-mute.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_exportUserLists from './endpoints/i/export-user-lists.js'; | ||||
| import * as ep___i_exportAntennas from './endpoints/i/export-antennas.js'; | ||||
| @@ -568,6 +569,7 @@ const eps = [ | ||||
| 	['i/export-following', ep___i_exportFollowing], | ||||
| 	['i/export-mute', ep___i_exportMute], | ||||
| 	['i/export-notes', ep___i_exportNotes], | ||||
| 	['i/export-clips', ep___i_exportClips], | ||||
| 	['i/export-favorites', ep___i_exportFavorites], | ||||
| 	['i/export-user-lists', ep___i_exportUserLists], | ||||
| 	['i/export-antennas', ep___i_exportAntennas], | ||||
|   | ||||
							
								
								
									
										35
									
								
								packages/backend/src/server/api/endpoints/i/export-clips.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										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 listUsersClock: NodeJS.Timeout; | ||||
| 	private withFiles: boolean; | ||||
| 	private withRenotes: boolean; | ||||
|  | ||||
| 	constructor( | ||||
| 		private userListsRepository: UserListsRepository, | ||||
| @@ -39,6 +40,7 @@ class UserListChannel extends Channel { | ||||
| 	public async init(params: any) { | ||||
| 		this.listId = params.listId as string; | ||||
| 		this.withFiles = params.withFiles ?? false; | ||||
| 		this.withRenotes = params.withRenotes ?? true; | ||||
|  | ||||
| 		// Check existence and owner | ||||
| 		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がミュートしているユーザーが関わるものだったら無視する | ||||
| 		if (isUserRelated(note, this.userIdsWhoMeMuting)) return; | ||||
| 		// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する | ||||
|   | ||||
							
								
								
									
										194
									
								
								packages/backend/test/e2e/exports.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										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 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 { | ||||
| 	token: string; | ||||
|   | ||||
							
								
								
									
										
											BIN
										
									
								
								packages/frontend/assets/drop-and-fusion/dropper.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								packages/frontend/assets/drop-and-fusion/dropper.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 32 KiB | 
							
								
								
									
										28
									
								
								packages/frontend/assets/drop-and-fusion/frame-dark.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								packages/frontend/assets/drop-and-fusion/frame-dark.svg
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							| After Width: | Height: | Size: 67 KiB | 
							
								
								
									
										28
									
								
								packages/frontend/assets/drop-and-fusion/frame-light.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								packages/frontend/assets/drop-and-fusion/frame-light.svg
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							| After Width: | Height: | Size: 66 KiB | 
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							| Before Width: | Height: | Size: 68 KiB | 
| @@ -132,6 +132,7 @@ function connectChannel() { | ||||
| 		connection.on('mention', onNote); | ||||
| 	} else if (props.src === 'list') { | ||||
| 		connection = stream.useChannel('userList', { | ||||
| 			withRenotes: props.withRenotes, | ||||
| 			withFiles: props.onlyFiles ? true : undefined, | ||||
| 			listId: props.list, | ||||
| 		}); | ||||
| @@ -198,6 +199,7 @@ function updatePaginationQuery() { | ||||
| 	} else if (props.src === 'list') { | ||||
| 		endpoint = 'notes/user-list-timeline'; | ||||
| 		query = { | ||||
| 			withRenotes: props.withRenotes, | ||||
| 			withFiles: props.onlyFiles ? true : undefined, | ||||
| 			listId: props.list, | ||||
| 		}; | ||||
| @@ -236,8 +238,9 @@ function refreshEndpointAndChannel() { | ||||
| 	updatePaginationQuery(); | ||||
| } | ||||
|  | ||||
| // デッキのリストカラムでwithRenotesを変更した場合に自動的に更新されるようにさせる | ||||
| // 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(); | ||||
|   | ||||
| @@ -7,11 +7,12 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| <MkStickyContainer> | ||||
| 	<template #header><MkPageHeader/></template> | ||||
| 	<MkSpacer :contentMax="800"> | ||||
| 		<div class="_gaps_s" :class="$style.root" style="margin: 0 auto;" :style="{ maxWidth: GAME_WIDTH + 'px' }"> | ||||
| 		<div class="_gaps_s" :class="$style.root" style="margin: 0 auto; max-width: 600px;"> | ||||
| 			<div style="display: flex;"> | ||||
| 				<div :class="$style.frame" style="flex: 1; margin-right: 10px;"> | ||||
| 					<div :class="$style.frameInner"> | ||||
| 						SCORE: <b><MkNumber :value="score"/></b> | ||||
| 						<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.stock]" style="margin-left: auto;"> | ||||
| @@ -33,7 +34,8 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| 			</div> | ||||
| 			<div :class="$style.main"> | ||||
| 				<div ref="containerEl" :class="[$style.container, { [$style.gameOver]: gameOver }]" @click.stop.prevent="onClick" @touchmove="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"/> | ||||
| 					<Transition | ||||
| 						:enterActiveClass="$style.transition_combo_enterActive" | ||||
| @@ -44,6 +46,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> | ||||
| 					</Transition> | ||||
| 					<img v-if="currentPick" src="/client-assets/drop-and-fusion/dropper.png" :class="$style.dropper" :style="{ left: mouseX + 'px' }"/> | ||||
| 					<Transition | ||||
| 						:enterActiveClass="$style.transition_picked_enterActive" | ||||
| 						:leaveActiveClass="$style.transition_picked_leaveActive" | ||||
| @@ -81,6 +84,8 @@ import * as os from '@/os.js'; | ||||
| import MkNumber from '@/components/MkNumber.vue'; | ||||
| import MkPlusOneEffect from '@/components/MkPlusOneEffect.vue'; | ||||
| import MkButton from '@/components/MkButton.vue'; | ||||
| import { defaultStore } from '@/store.js'; | ||||
| import { misskeyApi } from '@/scripts/misskey-api.js'; | ||||
|  | ||||
| const containerEl = shallowRef<HTMLElement>(); | ||||
| const canvasEl = shallowRef<HTMLCanvasElement>(); | ||||
| @@ -191,7 +196,7 @@ const FRUITS = [{ | ||||
|  | ||||
| const GAME_WIDTH = 450; | ||||
| const GAME_HEIGHT = 600; | ||||
| const PHYSICS_QUALITY_FACTOR = 32; // 低いほどパフォーマンスが高いがガタガタして安定しなくなる | ||||
| const PHYSICS_QUALITY_FACTOR = 16; // 低いほどパフォーマンスが高いがガタガタして安定しなくなる、逆に高すぎても何故か不安定になる | ||||
|  | ||||
| let viewScaleX = 1; | ||||
| let viewScaleY = 1; | ||||
| @@ -203,6 +208,7 @@ const comboPrev = ref(0); | ||||
| const dropReady = ref(true); | ||||
| const gameOver = ref(false); | ||||
| const gameStarted = ref(false); | ||||
| const highScore = ref<number | null>(null); | ||||
|  | ||||
| class Game extends EventEmitter<{ | ||||
| 	changeScore: (score: number) => void; | ||||
| @@ -215,10 +221,10 @@ class Game extends EventEmitter<{ | ||||
| 	private COMBO_INTERVAL = 1000; | ||||
| 	public readonly DROP_INTERVAL = 500; | ||||
| 	private PLAYAREA_MARGIN = 25; | ||||
| 	private STOCK_MAX = 4; | ||||
| 	private engine: Matter.Engine; | ||||
| 	private render: Matter.Render; | ||||
| 	private runner: Matter.Runner; | ||||
| 	private detector: Matter.Detector; | ||||
| 	private overflowCollider: Matter.Body; | ||||
| 	private isGameOver = false; | ||||
|  | ||||
| @@ -251,6 +257,8 @@ class Game extends EventEmitter<{ | ||||
| 		this.emit('changeScore', value); | ||||
| 	} | ||||
|  | ||||
| 	private comboIntervalId: number | null = null; | ||||
|  | ||||
| 	constructor() { | ||||
| 		super(); | ||||
|  | ||||
| @@ -278,7 +286,7 @@ class Game extends EventEmitter<{ | ||||
| 				wireframeBackground: 'transparent', // transparent to hide | ||||
| 				wireframes: false, | ||||
| 				showSleeping: false, | ||||
| 				pixelRatio: window.devicePixelRatio, | ||||
| 				pixelRatio: Math.max(2, window.devicePixelRatio), | ||||
| 			}, | ||||
| 		}); | ||||
|  | ||||
| @@ -287,13 +295,13 @@ class Game extends EventEmitter<{ | ||||
| 		this.runner = Matter.Runner.create(); | ||||
| 		Matter.Runner.run(this.runner, this.engine); | ||||
|  | ||||
| 		this.detector = Matter.Detector.create(); | ||||
|  | ||||
| 		this.engine.world.bodies = []; | ||||
|  | ||||
| 		//#region walls | ||||
| 		const WALL_OPTIONS: Matter.IChamferableBodyDefinition = { | ||||
| 			isStatic: true, | ||||
| 			friction: 0.7, | ||||
| 			slop: 1.0, | ||||
| 			render: { | ||||
| 				strokeStyle: 'transparent', | ||||
| 				fillStyle: 'transparent', | ||||
| @@ -308,7 +316,7 @@ class Game extends EventEmitter<{ | ||||
| 		]); | ||||
| 		//#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, | ||||
| 			isSensor: true, | ||||
| 			render: { | ||||
| @@ -328,11 +336,13 @@ class Game extends EventEmitter<{ | ||||
| 	private createBody(fruit: typeof FRUITS[number], x: number, y: number) { | ||||
| 		return Matter.Bodies.circle(x, y, fruit.size / 2, { | ||||
| 			label: fruit.id, | ||||
| 			density: 0.0005, | ||||
| 			//density: 0.0005, | ||||
| 			density: fruit.size / 1000, | ||||
| 			restitution: 0.2, | ||||
| 			frictionAir: 0.01, | ||||
| 			restitution: 0.4, | ||||
| 			friction: 0.5, | ||||
| 			friction: 0.7, | ||||
| 			frictionStatic: 5, | ||||
| 			slop: 1.0, | ||||
| 			//mass: 0, | ||||
| 			render: { | ||||
| 				sprite: { | ||||
| @@ -372,7 +382,7 @@ class Game extends EventEmitter<{ | ||||
| 				this.activeBodyIds.push(body.id); | ||||
| 			}, 100); | ||||
|  | ||||
| 			const additionalScore = Math.round(currentFruit.score * (1 + (this.combo / 3))); | ||||
| 			const additionalScore = Math.round(currentFruit.score * (1 + ((this.combo - 1) / 3))); | ||||
| 			this.score += additionalScore; | ||||
|  | ||||
| 			const pan = ((newX / GAME_WIDTH) - 0.5) * 2; | ||||
| @@ -400,7 +410,7 @@ class Game extends EventEmitter<{ | ||||
| 	} | ||||
|  | ||||
| 	public start() { | ||||
| 		for (let i = 0; i < 4; i++) { | ||||
| 		for (let i = 0; i < this.STOCK_MAX; i++) { | ||||
| 			this.stock.push({ | ||||
| 				id: Math.random().toString(), | ||||
| 				fruit: FRUITS.filter(x => x.available)[Math.floor(Math.random() * FRUITS.filter(x => x.available).length)], | ||||
| @@ -411,8 +421,8 @@ class Game extends EventEmitter<{ | ||||
| 		// TODO: fusion予約状態のアイテムは光らせるなどの演出をすると楽しそう | ||||
| 		let fusionReservedPairs: { bodyA: Matter.Body; bodyB: Matter.Body }[] = []; | ||||
|  | ||||
| 		const minCollisionDepthForSound = 2.5; | ||||
| 		const maxCollisionDepthForSound = 9; | ||||
| 		const minCollisionEnergyForSound = 2.5; | ||||
| 		const maxCollisionEnergyForSound = 9; | ||||
| 		const soundPitchMax = 4; | ||||
| 		const soundPitchMin = 0.5; | ||||
|  | ||||
| @@ -439,8 +449,8 @@ class Game extends EventEmitter<{ | ||||
| 					} | ||||
| 				} else { | ||||
| 					const energy = pairs.collision.depth; | ||||
| 					if (energy > minCollisionDepthForSound) { | ||||
| 						const vol = (Math.min(maxCollisionDepthForSound, energy - minCollisionDepthForSound) / maxCollisionDepthForSound) / 4; | ||||
| 					if (energy > minCollisionEnergyForSound) { | ||||
| 						const vol = (Math.min(maxCollisionEnergyForSound, energy - minCollisionEnergyForSound) / maxCollisionEnergyForSound) / 4; | ||||
| 						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))); | ||||
| 						sound.playRaw('syuilo/poi1', vol, pan, pitch); | ||||
| @@ -449,7 +459,7 @@ class Game extends EventEmitter<{ | ||||
| 			} | ||||
| 		}); | ||||
|  | ||||
| 		window.setInterval(() => { | ||||
| 		this.comboIntervalId = window.setInterval(() => { | ||||
| 			if (this.latestFusionedAt < Date.now() - this.COMBO_INTERVAL) { | ||||
| 				this.combo = 0; | ||||
| 			} | ||||
| @@ -469,7 +479,7 @@ class Game extends EventEmitter<{ | ||||
| 		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 body = this.createBody(st.fruit, x, st.fruit.size / 2); | ||||
| 		const body = this.createBody(st.fruit, x, 50 + st.fruit.size / 2); | ||||
| 		Matter.Composite.add(this.engine.world, body); | ||||
| 		this.activeBodyIds.push(body.id); | ||||
| 		this.latestDroppedBodyId = body.id; | ||||
| @@ -480,6 +490,7 @@ class Game extends EventEmitter<{ | ||||
| 	} | ||||
|  | ||||
| 	public dispose() { | ||||
| 		if (this.comboIntervalId) window.clearInterval(this.comboIntervalId); | ||||
| 		Matter.Render.stop(this.render); | ||||
| 		Matter.Runner.stop(this.runner); | ||||
| 		Matter.World.clear(this.engine.world, false); | ||||
| @@ -567,10 +578,28 @@ function attachGame() { | ||||
| 		currentPick.value = null; | ||||
| 		dropReady.value = false; | ||||
| 		gameOver.value = true; | ||||
|  | ||||
| 		if (score.value > (highScore.value ?? 0)) { | ||||
| 			highScore.value = score.value; | ||||
|  | ||||
| 			misskeyApi('i/registry/set', { | ||||
| 				scope: ['dropAndFusionGame'], | ||||
| 				key: 'highScore', | ||||
| 				value: highScore.value, | ||||
| 			}); | ||||
| 		} | ||||
| 	}); | ||||
| } | ||||
|  | ||||
| onMounted(() => { | ||||
| onMounted(async () => { | ||||
| 	try { | ||||
| 		highScore.value = await misskeyApi('i/registry/get', { | ||||
| 			scope: ['dropAndFusionGame'], | ||||
| 			key: 'highScore', | ||||
| 		}); | ||||
| 	} catch (err) { | ||||
| 	} | ||||
|  | ||||
| 	game = new Game(); | ||||
|  | ||||
| 	attachGame(); | ||||
| @@ -667,7 +696,8 @@ definePageMetadata({ | ||||
| 	top: 0; | ||||
| 	left: 0; | ||||
| 	width: 100%; | ||||
| 	filter: drop-shadow(0 6px 16px #0007); | ||||
| 	// なんかiOSでちらつく | ||||
| 	//filter: drop-shadow(0 6px 16px #0007); | ||||
| 	pointer-events: none; | ||||
| 	user-select: none; | ||||
| } | ||||
| @@ -677,7 +707,8 @@ definePageMetadata({ | ||||
| 	display: block; | ||||
| 	z-index: 1; | ||||
| 	margin-top: -50px; | ||||
| 	max-width: 100%; | ||||
| 	width: 100% !important; | ||||
| 	height: auto !important; | ||||
| 	pointer-events: none; | ||||
| 	user-select: none; | ||||
| } | ||||
| @@ -699,13 +730,28 @@ definePageMetadata({ | ||||
| 	text-align: center; | ||||
| 	font-weight: bold; | ||||
| 	font-style: oblique; | ||||
| 	color: #fff; | ||||
| 	-webkit-text-stroke: 1px rgb(255, 145, 0); | ||||
| 	text-shadow: 0 0 6px #0005; | ||||
| 	pointer-events: none; | ||||
| 	user-select: none; | ||||
| } | ||||
|  | ||||
| .currentFruit { | ||||
| 	position: absolute; | ||||
| 	margin-top: 20px; | ||||
| 	margin-top: 80px; | ||||
| 	z-index: 2; | ||||
| 	filter: drop-shadow(0 6px 16px #0007); | ||||
| 	pointer-events: none; | ||||
| 	user-select: none; | ||||
| } | ||||
|  | ||||
| .dropper { | ||||
| 	position: absolute; | ||||
| 	top: 0; | ||||
| 	width: 70px; | ||||
| 	margin-top: -10px; | ||||
| 	margin-left: -30px; | ||||
| 	z-index: 2; | ||||
| 	filter: drop-shadow(0 6px 16px #0007); | ||||
| 	pointer-events: none; | ||||
| @@ -714,7 +760,7 @@ definePageMetadata({ | ||||
|  | ||||
| .currentFruitArrow { | ||||
| 	position: absolute; | ||||
| 	margin-top: 20px; | ||||
| 	margin-top: 100px; | ||||
| 	z-index: 3; | ||||
| 	animation: currentFruitArrow 2s ease infinite; | ||||
| 	pointer-events: none; | ||||
| @@ -723,10 +769,10 @@ definePageMetadata({ | ||||
|  | ||||
| .dropGuide { | ||||
| 	position: absolute; | ||||
| 	top: 50px; | ||||
| 	top: 120px; | ||||
| 	z-index: 3; | ||||
| 	width: 3px; | ||||
| 	height: calc(100% - 50px); | ||||
| 	height: calc(100% - 120px); | ||||
| 	background: #f002; | ||||
| 	pointer-events: none; | ||||
| 	user-select: none; | ||||
|   | ||||
| @@ -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> | ||||
| 		</MkFolder> | ||||
| 	</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> | ||||
| 		<template #label><i class="ti ti-users"></i> {{ i18n.ts._exportOrImport.followingList }}</template> | ||||
| 		<div class="_gaps_s"> | ||||
| @@ -157,6 +165,10 @@ const exportFavorites = () => { | ||||
| 	misskeyApi('i/export-favorites', {}).then(onExportSuccess).catch(onError); | ||||
| }; | ||||
|  | ||||
| const exportClips = () => { | ||||
| 	misskeyApi('i/export-clips', {}).then(onExportSuccess).catch(onError); | ||||
| }; | ||||
|  | ||||
| const exportFollowing = () => { | ||||
| 	misskeyApi('i/export-following', { | ||||
| 		excludeMuting: excludeMutingUsers.value, | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 かっこかり
					かっこかり