新着ノートをサウンドで通知する機能をdeck UIに追加 (#13867)
* feat(deck-ui): implement note notification * chore: remove notify in antenna * docs(changelog): 新着ノートをサウンドで通知する機能をdeck UIに追加 * fix: type error in test * lint: key order * fix: remove notify column * test: remove test for notify * chore: make sound selectable * fix: add license header * fix: add license header again * Unnecessary await Co-authored-by: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com> * ファイルを選択してください -> ファイルが選択されていません * fix: i18n忘れ * fix: i18n忘れ * pleaseSelectFile > fileNotSelected --------- Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com> Co-authored-by: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com>
This commit is contained in:
		
							parent
							
								
									d7982e471c
								
							
						
					
					
						commit
						4579be0f54
					
				|  | @ -49,6 +49,7 @@ | |||
| - Enhance: AiScriptを0.18.0にバージョンアップ | ||||
| - Enhance: 通常のノートでも、お気に入りに登録したチャンネルにリノートできるように | ||||
| - Enhance: 長いテキストをペーストした際にテキストファイルとして添付するかどうかを選択できるように | ||||
| - Enhance: 新着ノートをサウンドで通知する機能をdeck UIに追加しました | ||||
| - Enhance: コントロールパネルのクイックアクションからファイルを照会できるように | ||||
| - Enhance: コントロールパネルのクイックアクションから通常の照会を行えるように | ||||
| - Fix: 一部のページ内リンクが正しく動作しない問題を修正 | ||||
|  |  | |||
|  | @ -1280,6 +1280,10 @@ export interface Locale extends ILocale { | |||
|      * フォルダーを選択 | ||||
|      */ | ||||
|     "selectFolders": string; | ||||
|     /** | ||||
|      * ファイルが選択されていません | ||||
|      */ | ||||
|     "fileNotSelected": string; | ||||
|     /** | ||||
|      * ファイル名を変更 | ||||
|      */ | ||||
|  | @ -9143,6 +9147,10 @@ export interface Locale extends ILocale { | |||
|          * カラムを追加 | ||||
|          */ | ||||
|         "addColumn": string; | ||||
|         /** | ||||
|          * 新着ノート通知の設定 | ||||
|          */ | ||||
|         "newNoteNotificationSettings": string; | ||||
|         /** | ||||
|          * カラムの設定 | ||||
|          */ | ||||
|  |  | |||
|  | @ -316,6 +316,7 @@ selectFile: "ファイルを選択" | |||
| selectFiles: "ファイルを選択" | ||||
| selectFolder: "フォルダーを選択" | ||||
| selectFolders: "フォルダーを選択" | ||||
| fileNotSelected: "ファイルが選択されていません" | ||||
| renameFile: "ファイル名を変更" | ||||
| folderName: "フォルダー名" | ||||
| createFolder: "フォルダーを作成" | ||||
|  | @ -2420,6 +2421,7 @@ _deck: | |||
|   alwaysShowMainColumn: "常にメインカラムを表示" | ||||
|   columnAlign: "カラムの寄せ" | ||||
|   addColumn: "カラムを追加" | ||||
|   newNoteNotificationSettings: "新着ノート通知の設定" | ||||
|   configureColumn: "カラムの設定" | ||||
|   swapLeft: "左に移動" | ||||
|   swapRight: "右に移動" | ||||
|  |  | |||
|  | @ -0,0 +1,16 @@ | |||
| /* | ||||
|  * SPDX-FileCopyrightText: syuilo and misskey-project | ||||
|  * SPDX-License-Identifier: AGPL-3.0-only | ||||
|  */ | ||||
| 
 | ||||
| export class RemoveAntennaNotify1716450883149 { | ||||
|     name = 'RemoveAntennaNotify1716450883149' | ||||
| 
 | ||||
|     async up(queryRunner) { | ||||
|         await queryRunner.query(`ALTER TABLE "antenna" DROP COLUMN "notify"`); | ||||
|     } | ||||
| 
 | ||||
|     async down(queryRunner) { | ||||
|         await queryRunner.query(`ALTER TABLE "antenna" ADD "notify" boolean NOT NULL`); | ||||
|     } | ||||
| } | ||||
|  | @ -38,7 +38,6 @@ export class AntennaEntityService { | |||
| 			users: antenna.users, | ||||
| 			caseSensitive: antenna.caseSensitive, | ||||
| 			localOnly: antenna.localOnly, | ||||
| 			notify: antenna.notify, | ||||
| 			excludeBots: antenna.excludeBots, | ||||
| 			withReplies: antenna.withReplies, | ||||
| 			withFile: antenna.withFile, | ||||
|  |  | |||
|  | @ -90,9 +90,6 @@ export class MiAntenna { | |||
| 	}) | ||||
| 	public expression: string | null; | ||||
| 
 | ||||
| 	@Column('boolean') | ||||
| 	public notify: boolean; | ||||
| 
 | ||||
| 	@Index() | ||||
| 	@Column('boolean', { | ||||
| 		default: true, | ||||
|  |  | |||
|  | @ -72,10 +72,6 @@ export const packedAntennaSchema = { | |||
| 			optional: false, nullable: false, | ||||
| 			default: false, | ||||
| 		}, | ||||
| 		notify: { | ||||
| 			type: 'boolean', | ||||
| 			optional: false, nullable: false, | ||||
| 		}, | ||||
| 		excludeBots: { | ||||
| 			type: 'boolean', | ||||
| 			optional: false, nullable: false, | ||||
|  |  | |||
|  | @ -84,7 +84,6 @@ export class ExportAntennasProcessorService { | |||
| 					excludeBots: antenna.excludeBots, | ||||
| 					withReplies: antenna.withReplies, | ||||
| 					withFile: antenna.withFile, | ||||
| 					notify: antenna.notify, | ||||
| 				})); | ||||
| 				if (antennas.length - 1 !== index) { | ||||
| 					write(', '); | ||||
|  |  | |||
|  | @ -47,9 +47,8 @@ const validate = new Ajv().compile({ | |||
| 		excludeBots: { type: 'boolean' }, | ||||
| 		withReplies: { type: 'boolean' }, | ||||
| 		withFile: { type: 'boolean' }, | ||||
| 		notify: { type: 'boolean' }, | ||||
| 	}, | ||||
| 	required: ['name', 'src', 'keywords', 'excludeKeywords', 'users', 'caseSensitive', 'withReplies', 'withFile', 'notify'], | ||||
| 	required: ['name', 'src', 'keywords', 'excludeKeywords', 'users', 'caseSensitive', 'withReplies', 'withFile'], | ||||
| }); | ||||
| 
 | ||||
| @Injectable() | ||||
|  | @ -92,7 +91,6 @@ export class ImportAntennasProcessorService { | |||
| 					excludeBots: antenna.excludeBots, | ||||
| 					withReplies: antenna.withReplies, | ||||
| 					withFile: antenna.withFile, | ||||
| 					notify: antenna.notify, | ||||
| 				}).then(x => this.antennasRepository.findOneByOrFail(x.identifiers[0])); | ||||
| 				this.logger.succ('Antenna created: ' + result.id); | ||||
| 				this.globalEventService.publishInternalEvent('antennaCreated', result); | ||||
|  |  | |||
|  | @ -67,9 +67,8 @@ export const paramDef = { | |||
| 		excludeBots: { type: 'boolean' }, | ||||
| 		withReplies: { type: 'boolean' }, | ||||
| 		withFile: { type: 'boolean' }, | ||||
| 		notify: { type: 'boolean' }, | ||||
| 	}, | ||||
| 	required: ['name', 'src', 'keywords', 'excludeKeywords', 'users', 'caseSensitive', 'withReplies', 'withFile', 'notify'], | ||||
| 	required: ['name', 'src', 'keywords', 'excludeKeywords', 'users', 'caseSensitive', 'withReplies', 'withFile'], | ||||
| } as const; | ||||
| 
 | ||||
| @Injectable() | ||||
|  | @ -128,7 +127,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- | |||
| 				excludeBots: ps.excludeBots, | ||||
| 				withReplies: ps.withReplies, | ||||
| 				withFile: ps.withFile, | ||||
| 				notify: ps.notify, | ||||
| 			}).then(x => this.antennasRepository.findOneByOrFail(x.identifiers[0])); | ||||
| 
 | ||||
| 			this.globalEventService.publishInternalEvent('antennaCreated', antenna); | ||||
|  |  | |||
|  | @ -66,7 +66,6 @@ export const paramDef = { | |||
| 		excludeBots: { type: 'boolean' }, | ||||
| 		withReplies: { type: 'boolean' }, | ||||
| 		withFile: { type: 'boolean' }, | ||||
| 		notify: { type: 'boolean' }, | ||||
| 	}, | ||||
| 	required: ['antennaId'], | ||||
| } as const; | ||||
|  | @ -124,7 +123,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- | |||
| 				excludeBots: ps.excludeBots, | ||||
| 				withReplies: ps.withReplies, | ||||
| 				withFile: ps.withFile, | ||||
| 				notify: ps.notify, | ||||
| 				isActive: true, | ||||
| 				lastUsedAt: new Date(), | ||||
| 			}); | ||||
|  |  | |||
|  | @ -38,7 +38,6 @@ describe('アンテナ', () => { | |||
| 		excludeKeywords: [['']], | ||||
| 		keywords: [['keyword']], | ||||
| 		name: 'test', | ||||
| 		notify: false, | ||||
| 		src: 'all' as const, | ||||
| 		userListId: null, | ||||
| 		users: [''], | ||||
|  | @ -151,7 +150,6 @@ describe('アンテナ', () => { | |||
| 			isActive: true, | ||||
| 			keywords: [['keyword']], | ||||
| 			name: 'test', | ||||
| 			notify: false, | ||||
| 			src: 'all', | ||||
| 			userListId: null, | ||||
| 			users: [''], | ||||
|  | @ -219,8 +217,6 @@ describe('アンテナ', () => { | |||
| 		{ parameters: () => ({ withReplies: true }) }, | ||||
| 		{ parameters: () => ({ withFile: false }) }, | ||||
| 		{ parameters: () => ({ withFile: true }) }, | ||||
| 		{ parameters: () => ({ notify: false }) }, | ||||
| 		{ parameters: () => ({ notify: true }) }, | ||||
| 	]; | ||||
| 	test.each(antennaParamPattern)('を作成できること($#)', async ({ parameters }) => { | ||||
| 		const response = await successfulApiCall({ | ||||
|  |  | |||
|  | @ -191,7 +191,6 @@ describe('Account Move', () => { | |||
| 				localOnly: false, | ||||
| 				withReplies: false, | ||||
| 				withFile: false, | ||||
| 				notify: false, | ||||
| 			}, alice); | ||||
| 			antennaId = antenna.body.id; | ||||
| 
 | ||||
|  | @ -435,7 +434,6 @@ describe('Account Move', () => { | |||
| 				localOnly: false, | ||||
| 				withReplies: false, | ||||
| 				withFile: false, | ||||
| 				notify: false, | ||||
| 			}, alice); | ||||
| 
 | ||||
| 			assert.strictEqual(res.status, 403); | ||||
|  |  | |||
|  | @ -0,0 +1,71 @@ | |||
| <!-- | ||||
| SPDX-FileCopyrightText: syuilo and misskey-project | ||||
| SPDX-License-Identifier: AGPL-3.0-only | ||||
| --> | ||||
| 
 | ||||
| <template> | ||||
| <div> | ||||
| 	<MkButton inline rounded primary @click="selectButton($event)">{{ i18n.ts.selectFile }}</MkButton> | ||||
| 	<div :class="['_nowrap', !fileName && $style.fileNotSelected]">{{ friendlyFileName }}</div> | ||||
| </div> | ||||
| </template> | ||||
| 
 | ||||
| <script setup lang="ts"> | ||||
| import * as Misskey from 'misskey-js'; | ||||
| import { computed, ref } from 'vue'; | ||||
| import { i18n } from '@/i18n.js'; | ||||
| import MkButton from '@/components/MkButton.vue'; | ||||
| import { selectFile } from '@/scripts/select-file.js'; | ||||
| import { misskeyApi } from '@/scripts/misskey-api.js'; | ||||
| 
 | ||||
| const props = defineProps<{ | ||||
| 	fileId?: string | null; | ||||
| 	validate?: (file: Misskey.entities.DriveFile) => Promise<boolean>; | ||||
| }>(); | ||||
| 
 | ||||
| const emit = defineEmits<{ | ||||
| 	(ev: 'update', result: Misskey.entities.DriveFile): void; | ||||
| }>(); | ||||
| 
 | ||||
| const fileUrl = ref(''); | ||||
| const fileName = ref<string>(''); | ||||
| 
 | ||||
| const friendlyFileName = computed<string>(() => { | ||||
| 	if (fileName.value) { | ||||
| 		return fileName.value; | ||||
| 	} | ||||
| 	if (fileUrl.value) { | ||||
| 		return fileUrl.value; | ||||
| 	} | ||||
| 
 | ||||
| 	return i18n.ts.fileNotSelected; | ||||
| }); | ||||
| 
 | ||||
| if (props.fileId) { | ||||
| 	misskeyApi('drive/files/show', { | ||||
| 		fileId: props.fileId, | ||||
| 	}).then((apiRes) => { | ||||
| 		fileName.value = apiRes.name; | ||||
| 		fileUrl.value = apiRes.url; | ||||
| 	}); | ||||
| } | ||||
| 
 | ||||
| function selectButton(ev: MouseEvent) { | ||||
| 	selectFile(ev.currentTarget ?? ev.target).then(async (file) => { | ||||
| 		if (!file) return; | ||||
| 		if (props.validate && !await props.validate(file)) return; | ||||
| 
 | ||||
| 		emit('update', file); | ||||
| 		fileName.value = file.name; | ||||
| 		fileUrl.value = file.url; | ||||
| 	}); | ||||
| } | ||||
| 
 | ||||
| </script> | ||||
| 
 | ||||
| <style module> | ||||
| .fileNotSelected { | ||||
| 	font-weight: 700; | ||||
| 	color: var(--infoWarnFg); | ||||
| } | ||||
| </style> | ||||
|  | @ -21,8 +21,9 @@ SPDX-License-Identifier: AGPL-3.0-only | |||
| 
 | ||||
| 	<MkSpacer :marginMin="20" :marginMax="32"> | ||||
| 		<div v-if="Object.keys(form).filter(item => !form[item].hidden).length > 0" class="_gaps_m"> | ||||
| 			<template v-for="(v, k) in Object.fromEntries(Object.entries(form).filter(([_, v]) => !('hidden' in v) || 'hidden' in v && !v.hidden))"> | ||||
| 				<MkInput v-if="v.type === 'number'" v-model="values[k]" type="number" :step="v.step || 1"> | ||||
| 			<template v-for="(v, k) in Object.fromEntries(Object.entries(form))"> | ||||
| 				<template v-if="typeof v.hidden == 'function' ? v.hidden(values) : v.hidden"></template> | ||||
| 				<MkInput v-else-if="v.type === 'number'" v-model="values[k]" type="number" :step="v.step || 1"> | ||||
| 					<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template> | ||||
| 					<template v-if="v.description" #caption>{{ v.description }}</template> | ||||
| 				</MkInput> | ||||
|  | @ -53,6 +54,12 @@ SPDX-License-Identifier: AGPL-3.0-only | |||
| 				<MkButton v-else-if="v.type === 'button'" @click="v.action($event, values)"> | ||||
| 					<span v-text="v.content || k"></span> | ||||
| 				</MkButton> | ||||
| 				<XFile | ||||
| 					v-else-if="v.type === 'drive-file'" | ||||
| 					:fileId="v.defaultFileId" | ||||
| 					:validate="async f => !v.validate || await v.validate(f)" | ||||
| 					@update="f => values[k] = f" | ||||
| 				/> | ||||
| 			</template> | ||||
| 		</div> | ||||
| 		<div v-else class="_fullinfo"> | ||||
|  | @ -72,6 +79,7 @@ import MkSelect from './MkSelect.vue'; | |||
| import MkRange from './MkRange.vue'; | ||||
| import MkButton from './MkButton.vue'; | ||||
| import MkRadios from './MkRadios.vue'; | ||||
| import XFile from './MkFormDialog.file.vue'; | ||||
| import type { Form } from '@/scripts/form.js'; | ||||
| import MkModalWindow from '@/components/MkModalWindow.vue'; | ||||
| import { i18n } from '@/i18n.js'; | ||||
|  |  | |||
|  | @ -518,7 +518,7 @@ export function waiting(): Promise<void> { | |||
| 	}); | ||||
| } | ||||
| 
 | ||||
| export function form<F extends Form>(title: string, f: F): Promise<{ canceled: true } | { result: GetFormResultType<F> }> { | ||||
| export function form<F extends Form>(title: string, f: F): Promise<{ canceled: true, result?: undefined } | { canceled?: false, result: GetFormResultType<F> }> { | ||||
| 	return new Promise(resolve => { | ||||
| 		popup(defineAsyncComponent(() => import('@/components/MkFormDialog.vue')), { title, form: f }, { | ||||
| 			done: result => { | ||||
|  |  | |||
|  | @ -39,7 +39,6 @@ SPDX-License-Identifier: AGPL-3.0-only | |||
| 			<MkSwitch v-model="localOnly">{{ i18n.ts.localOnly }}</MkSwitch> | ||||
| 			<MkSwitch v-model="caseSensitive">{{ i18n.ts.caseSensitive }}</MkSwitch> | ||||
| 			<MkSwitch v-model="withFile">{{ i18n.ts.withFileAntenna }}</MkSwitch> | ||||
| 			<MkSwitch v-model="notify">{{ i18n.ts.notifyAntenna }}</MkSwitch> | ||||
| 		</div> | ||||
| 		<div :class="$style.actions"> | ||||
| 			<MkButton inline primary @click="saveAntenna()"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton> | ||||
|  | @ -82,7 +81,6 @@ const localOnly = ref<boolean>(props.antenna.localOnly); | |||
| const excludeBots = ref<boolean>(props.antenna.excludeBots); | ||||
| const withReplies = ref<boolean>(props.antenna.withReplies); | ||||
| const withFile = ref<boolean>(props.antenna.withFile); | ||||
| const notify = ref<boolean>(props.antenna.notify); | ||||
| const userLists = ref<Misskey.entities.UserList[] | null>(null); | ||||
| 
 | ||||
| watch(() => src.value, async () => { | ||||
|  | @ -99,7 +97,6 @@ async function saveAntenna() { | |||
| 		excludeBots: excludeBots.value, | ||||
| 		withReplies: withReplies.value, | ||||
| 		withFile: withFile.value, | ||||
| 		notify: notify.value, | ||||
| 		caseSensitive: caseSensitive.value, | ||||
| 		localOnly: localOnly.value, | ||||
| 		users: users.value.trim().split('\n').map(x => x.trim()), | ||||
|  |  | |||
|  | @ -3,18 +3,22 @@ | |||
|  * SPDX-License-Identifier: AGPL-3.0-only | ||||
|  */ | ||||
| 
 | ||||
| import * as Misskey from 'misskey-js'; | ||||
| 
 | ||||
| type EnumItem = string | { | ||||
| 	label: string; | ||||
| 	value: string; | ||||
| }; | ||||
| 
 | ||||
| type Hidden = boolean | ((v: any) => boolean); | ||||
| 
 | ||||
| export type FormItem = { | ||||
| 	label?: string; | ||||
| 	type: 'string'; | ||||
| 	default: string | null; | ||||
| 	description?: string; | ||||
| 	required?: boolean; | ||||
| 	hidden?: boolean; | ||||
| 	hidden?: Hidden; | ||||
| 	multiline?: boolean; | ||||
| 	treatAsMfm?: boolean; | ||||
| } | { | ||||
|  | @ -23,27 +27,27 @@ export type FormItem = { | |||
| 	default: number | null; | ||||
| 	description?: string; | ||||
| 	required?: boolean; | ||||
| 	hidden?: boolean; | ||||
| 	hidden?: Hidden; | ||||
| 	step?: number; | ||||
| } | { | ||||
| 	label?: string; | ||||
| 	type: 'boolean'; | ||||
| 	default: boolean | null; | ||||
| 	description?: string; | ||||
| 	hidden?: boolean; | ||||
| 	hidden?: Hidden; | ||||
| } | { | ||||
| 	label?: string; | ||||
| 	type: 'enum'; | ||||
| 	default: string | null; | ||||
| 	required?: boolean; | ||||
| 	hidden?: boolean; | ||||
| 	hidden?: Hidden; | ||||
| 	enum: EnumItem[]; | ||||
| } | { | ||||
| 	label?: string; | ||||
| 	type: 'radio'; | ||||
| 	default: unknown | null; | ||||
| 	required?: boolean; | ||||
| 	hidden?: boolean; | ||||
| 	hidden?: Hidden; | ||||
| 	options: { | ||||
| 		label: string; | ||||
| 		value: unknown; | ||||
|  | @ -58,20 +62,27 @@ export type FormItem = { | |||
| 	min: number; | ||||
| 	max: number; | ||||
| 	textConverter?: (value: number) => string; | ||||
| 	hidden?: Hidden; | ||||
| } | { | ||||
| 	label?: string; | ||||
| 	type: 'object'; | ||||
| 	default: Record<string, unknown> | null; | ||||
| 	hidden: boolean; | ||||
| 	hidden: Hidden; | ||||
| } | { | ||||
| 	label?: string; | ||||
| 	type: 'array'; | ||||
| 	default: unknown[] | null; | ||||
| 	hidden: boolean; | ||||
| 	hidden: Hidden; | ||||
| } | { | ||||
| 	type: 'button'; | ||||
| 	content?: string; | ||||
| 	hidden?: Hidden; | ||||
| 	action: (ev: MouseEvent, v: any) => void; | ||||
| } | { | ||||
| 	type: 'drive-file'; | ||||
| 	defaultFileId?: string | null; | ||||
| 	hidden?: Hidden; | ||||
| 	validate?: (v: Misskey.entities.DriveFile) => Promise<boolean>; | ||||
| }; | ||||
| 
 | ||||
| export type Form = Record<string, FormItem>; | ||||
|  | @ -84,8 +95,9 @@ type GetItemType<Item extends FormItem> = | |||
| 	Item['type'] extends 'range' ? number : | ||||
| 	Item['type'] extends 'enum' ? string : | ||||
| 	Item['type'] extends 'array' ? unknown[] : | ||||
| 	Item['type'] extends 'object' ? Record<string, unknown> | ||||
| 	: never; | ||||
| 	Item['type'] extends 'object' ? Record<string, unknown> : | ||||
| 	Item['type'] extends 'drive-file' ? Misskey.entities.DriveFile | undefined : | ||||
| 	never; | ||||
| 
 | ||||
| export type GetFormResultType<F extends Form> = { | ||||
| 	[P in keyof F]: GetItemType<F[P]>; | ||||
|  |  | |||
|  | @ -9,18 +9,22 @@ SPDX-License-Identifier: AGPL-3.0-only | |||
| 		<i class="ti ti-antenna"></i><span style="margin-left: 8px;">{{ column.name }}</span> | ||||
| 	</template> | ||||
| 
 | ||||
| 	<MkTimeline v-if="column.antennaId" ref="timeline" src="antenna" :antenna="column.antennaId"/> | ||||
| 	<MkTimeline v-if="column.antennaId" ref="timeline" src="antenna" :antenna="column.antennaId" @note="onNote"/> | ||||
| </XColumn> | ||||
| </template> | ||||
| 
 | ||||
| <script lang="ts" setup> | ||||
| import { onMounted, shallowRef } from 'vue'; | ||||
| import { onMounted, ref, shallowRef, watch } from 'vue'; | ||||
| import XColumn from './column.vue'; | ||||
| import { updateColumn, Column } from './deck-store.js'; | ||||
| import MkTimeline from '@/components/MkTimeline.vue'; | ||||
| import * as os from '@/os.js'; | ||||
| import { misskeyApi } from '@/scripts/misskey-api.js'; | ||||
| import { i18n } from '@/i18n.js'; | ||||
| import { MenuItem } from '@/types/menu.js'; | ||||
| import { SoundStore } from '@/store.js'; | ||||
| import { soundSettingsButton } from '@/ui/deck/tl-note-notification.js'; | ||||
| import * as sound from '@/scripts/sound.js'; | ||||
| 
 | ||||
| const props = defineProps<{ | ||||
| 	column: Column; | ||||
|  | @ -28,6 +32,7 @@ const props = defineProps<{ | |||
| }>(); | ||||
| 
 | ||||
| const timeline = shallowRef<InstanceType<typeof MkTimeline>>(); | ||||
| const soundSetting = ref<SoundStore>(props.column.soundSetting ?? { type: null, volume: 1 }); | ||||
| 
 | ||||
| onMounted(() => { | ||||
| 	if (props.column.antennaId == null) { | ||||
|  | @ -35,6 +40,10 @@ onMounted(() => { | |||
| 	} | ||||
| }); | ||||
| 
 | ||||
| watch(soundSetting, v => { | ||||
| 	updateColumn(props.column.id, { soundSetting: v }); | ||||
| }); | ||||
| 
 | ||||
| async function setAntenna() { | ||||
| 	const antennas = await misskeyApi('antennas/list'); | ||||
| 	const { canceled, result: antenna } = await os.select({ | ||||
|  | @ -54,7 +63,11 @@ function editAntenna() { | |||
| 	os.pageWindow('my/antennas/' + props.column.antennaId); | ||||
| } | ||||
| 
 | ||||
| const menu = [ | ||||
| function onNote() { | ||||
| 	sound.playMisskeySfxFile(soundSetting.value); | ||||
| } | ||||
| 
 | ||||
| const menu: MenuItem[] = [ | ||||
| 	{ | ||||
| 		icon: 'ti ti-pencil', | ||||
| 		text: i18n.ts.selectAntenna, | ||||
|  | @ -65,6 +78,11 @@ const menu = [ | |||
| 		text: i18n.ts.editAntenna, | ||||
| 		action: editAntenna, | ||||
| 	}, | ||||
| 	{ | ||||
| 		icon: 'ti ti-bell', | ||||
| 		text: i18n.ts._deck.newNoteNotificationSettings, | ||||
| 		action: () => soundSettingsButton(soundSetting), | ||||
| 	}, | ||||
| ]; | ||||
| 
 | ||||
| /* | ||||
|  |  | |||
|  | @ -13,13 +13,13 @@ SPDX-License-Identifier: AGPL-3.0-only | |||
| 		<div style="padding: 8px; text-align: center;"> | ||||
| 			<MkButton primary gradate rounded inline small @click="post"><i class="ti ti-pencil"></i></MkButton> | ||||
| 		</div> | ||||
| 		<MkTimeline ref="timeline" src="channel" :channel="column.channelId"/> | ||||
| 		<MkTimeline ref="timeline" src="channel" :channel="column.channelId" @note="onNote"/> | ||||
| 	</template> | ||||
| </XColumn> | ||||
| </template> | ||||
| 
 | ||||
| <script lang="ts" setup> | ||||
| import { shallowRef } from 'vue'; | ||||
| import { ref, shallowRef, watch } from 'vue'; | ||||
| import * as Misskey from 'misskey-js'; | ||||
| import XColumn from './column.vue'; | ||||
| import { updateColumn, Column } from './deck-store.js'; | ||||
|  | @ -29,6 +29,10 @@ import * as os from '@/os.js'; | |||
| import { favoritedChannelsCache } from '@/cache.js'; | ||||
| import { misskeyApi } from '@/scripts/misskey-api.js'; | ||||
| import { i18n } from '@/i18n.js'; | ||||
| import { MenuItem } from '@/types/menu.js'; | ||||
| import { SoundStore } from '@/store.js'; | ||||
| import { soundSettingsButton } from '@/ui/deck/tl-note-notification.js'; | ||||
| import * as sound from '@/scripts/sound.js'; | ||||
| 
 | ||||
| const props = defineProps<{ | ||||
| 	column: Column; | ||||
|  | @ -37,11 +41,16 @@ const props = defineProps<{ | |||
| 
 | ||||
| const timeline = shallowRef<InstanceType<typeof MkTimeline>>(); | ||||
| const channel = shallowRef<Misskey.entities.Channel>(); | ||||
| const soundSetting = ref<SoundStore>(props.column.soundSetting ?? { type: null, volume: 1 }); | ||||
| 
 | ||||
| if (props.column.channelId == null) { | ||||
| 	setChannel(); | ||||
| } | ||||
| 
 | ||||
| watch(soundSetting, v => { | ||||
| 	updateColumn(props.column.id, { soundSetting: v }); | ||||
| }); | ||||
| 
 | ||||
| async function setChannel() { | ||||
| 	const channels = await favoritedChannelsCache.fetch(); | ||||
| 	const { canceled, result: chosenChannel } = await os.select({ | ||||
|  | @ -70,9 +79,17 @@ async function post() { | |||
| 	}); | ||||
| } | ||||
| 
 | ||||
| const menu = [{ | ||||
| function onNote() { | ||||
| 	sound.playMisskeySfxFile(soundSetting.value); | ||||
| } | ||||
| 
 | ||||
| const menu: MenuItem[] = [{ | ||||
| 	icon: 'ti ti-pencil', | ||||
| 	text: i18n.ts.selectChannel, | ||||
| 	action: setChannel, | ||||
| }, { | ||||
| 	icon: 'ti ti-bell', | ||||
| 	text: i18n.ts._deck.newNoteNotificationSettings, | ||||
| 	action: () => soundSettingsButton(soundSetting), | ||||
| }]; | ||||
| </script> | ||||
|  |  | |||
|  | @ -9,6 +9,7 @@ import { notificationTypes } from 'misskey-js'; | |||
| import { Storage } from '@/pizzax.js'; | ||||
| import { misskeyApi } from '@/scripts/misskey-api.js'; | ||||
| import { deepClone } from '@/scripts/clone.js'; | ||||
| import { SoundStore } from '@/store.js'; | ||||
| 
 | ||||
| type ColumnWidget = { | ||||
| 	name: string; | ||||
|  | @ -33,6 +34,7 @@ export type Column = { | |||
| 	withRenotes?: boolean; | ||||
| 	withReplies?: boolean; | ||||
| 	onlyFiles?: boolean; | ||||
| 	soundSetting: SoundStore; | ||||
| }; | ||||
| 
 | ||||
| export const deckStore = markRaw(new Storage('deck', { | ||||
|  |  | |||
|  | @ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only | |||
| 		<i class="ti ti-list"></i><span style="margin-left: 8px;">{{ column.name }}</span> | ||||
| 	</template> | ||||
| 
 | ||||
| 	<MkTimeline v-if="column.listId" ref="timeline" src="list" :list="column.listId" :withRenotes="withRenotes"/> | ||||
| 	<MkTimeline v-if="column.listId" ref="timeline" src="list" :list="column.listId" :withRenotes="withRenotes" @note="onNote"/> | ||||
| </XColumn> | ||||
| </template> | ||||
| 
 | ||||
|  | @ -21,6 +21,10 @@ import MkTimeline from '@/components/MkTimeline.vue'; | |||
| import * as os from '@/os.js'; | ||||
| import { misskeyApi } from '@/scripts/misskey-api.js'; | ||||
| import { i18n } from '@/i18n.js'; | ||||
| import { MenuItem } from '@/types/menu.js'; | ||||
| import { SoundStore } from '@/store.js'; | ||||
| import { soundSettingsButton } from '@/ui/deck/tl-note-notification.js'; | ||||
| import * as sound from '@/scripts/sound.js'; | ||||
| 
 | ||||
| const props = defineProps<{ | ||||
| 	column: Column; | ||||
|  | @ -29,6 +33,7 @@ const props = defineProps<{ | |||
| 
 | ||||
| const timeline = shallowRef<InstanceType<typeof MkTimeline>>(); | ||||
| const withRenotes = ref(props.column.withRenotes ?? true); | ||||
| const soundSetting = ref<SoundStore>(props.column.soundSetting ?? { type: null, volume: 1 }); | ||||
| 
 | ||||
| if (props.column.listId == null) { | ||||
| 	setList(); | ||||
|  | @ -40,6 +45,10 @@ watch(withRenotes, v => { | |||
| 	}); | ||||
| }); | ||||
| 
 | ||||
| watch(soundSetting, v => { | ||||
| 	updateColumn(props.column.id, { soundSetting: v }); | ||||
| }); | ||||
| 
 | ||||
| async function setList() { | ||||
| 	const lists = await misskeyApi('users/lists/list'); | ||||
| 	const { canceled, result: list } = await os.select({ | ||||
|  | @ -59,7 +68,11 @@ function editList() { | |||
| 	os.pageWindow('my/lists/' + props.column.listId); | ||||
| } | ||||
| 
 | ||||
| const menu = [ | ||||
| function onNote() { | ||||
| 	sound.playMisskeySfxFile(soundSetting.value); | ||||
| } | ||||
| 
 | ||||
| const menu: MenuItem[] = [ | ||||
| 	{ | ||||
| 		icon: 'ti ti-pencil', | ||||
| 		text: i18n.ts.selectList, | ||||
|  | @ -75,5 +88,10 @@ const menu = [ | |||
| 		text: i18n.ts.showRenotes, | ||||
| 		ref: withRenotes, | ||||
| 	}, | ||||
| 	{ | ||||
| 		icon: 'ti ti-bell', | ||||
| 		text: i18n.ts._deck.newNoteNotificationSettings, | ||||
| 		action: () => soundSettingsButton(soundSetting), | ||||
| 	}, | ||||
| ]; | ||||
| </script> | ||||
|  |  | |||
|  | @ -9,18 +9,22 @@ SPDX-License-Identifier: AGPL-3.0-only | |||
| 		<i class="ti ti-badge"></i><span style="margin-left: 8px;">{{ column.name }}</span> | ||||
| 	</template> | ||||
| 
 | ||||
| 	<MkTimeline v-if="column.roleId" ref="timeline" src="role" :role="column.roleId"/> | ||||
| 	<MkTimeline v-if="column.roleId" ref="timeline" src="role" :role="column.roleId" @note="onNote"/> | ||||
| </XColumn> | ||||
| </template> | ||||
| 
 | ||||
| <script lang="ts" setup> | ||||
| import { onMounted, shallowRef } from 'vue'; | ||||
| import { onMounted, ref, shallowRef, watch } from 'vue'; | ||||
| import XColumn from './column.vue'; | ||||
| import { updateColumn, Column } from './deck-store.js'; | ||||
| import MkTimeline from '@/components/MkTimeline.vue'; | ||||
| import * as os from '@/os.js'; | ||||
| import { misskeyApi } from '@/scripts/misskey-api.js'; | ||||
| import { i18n } from '@/i18n.js'; | ||||
| import { MenuItem } from '@/types/menu.js'; | ||||
| import { SoundStore } from '@/store.js'; | ||||
| import { soundSettingsButton } from '@/ui/deck/tl-note-notification.js'; | ||||
| import * as sound from '@/scripts/sound.js'; | ||||
| 
 | ||||
| const props = defineProps<{ | ||||
| 	column: Column; | ||||
|  | @ -28,6 +32,7 @@ const props = defineProps<{ | |||
| }>(); | ||||
| 
 | ||||
| const timeline = shallowRef<InstanceType<typeof MkTimeline>>(); | ||||
| const soundSetting = ref<SoundStore>(props.column.soundSetting ?? { type: null, volume: 1 }); | ||||
| 
 | ||||
| onMounted(() => { | ||||
| 	if (props.column.roleId == null) { | ||||
|  | @ -35,6 +40,10 @@ onMounted(() => { | |||
| 	} | ||||
| }); | ||||
| 
 | ||||
| watch(soundSetting, v => { | ||||
| 	updateColumn(props.column.id, { soundSetting: v }); | ||||
| }); | ||||
| 
 | ||||
| async function setRole() { | ||||
| 	const roles = (await misskeyApi('roles/list')).filter(x => x.isExplorable); | ||||
| 	const { canceled, result: role } = await os.select({ | ||||
|  | @ -50,10 +59,18 @@ async function setRole() { | |||
| 	}); | ||||
| } | ||||
| 
 | ||||
| const menu = [{ | ||||
| function onNote() { | ||||
| 	sound.playMisskeySfxFile(soundSetting.value); | ||||
| } | ||||
| 
 | ||||
| const menu: MenuItem[] = [{ | ||||
| 	icon: 'ti ti-pencil', | ||||
| 	text: i18n.ts.role, | ||||
| 	action: setRole, | ||||
| }, { | ||||
| 	icon: 'ti ti-bell', | ||||
| 	text: i18n.ts._deck.newNoteNotificationSettings, | ||||
| 	action: () => soundSettingsButton(soundSetting), | ||||
| }]; | ||||
| 
 | ||||
| /* | ||||
|  |  | |||
|  | @ -28,6 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only | |||
| 		:withRenotes="withRenotes" | ||||
| 		:withReplies="withReplies" | ||||
| 		:onlyFiles="onlyFiles" | ||||
| 		@note="onNote" | ||||
| 	/> | ||||
| </XColumn> | ||||
| </template> | ||||
|  | @ -41,6 +42,10 @@ import * as os from '@/os.js'; | |||
| import { $i } from '@/account.js'; | ||||
| import { i18n } from '@/i18n.js'; | ||||
| import { instance } from '@/instance.js'; | ||||
| import { MenuItem } from '@/types/menu.js'; | ||||
| import { SoundStore } from '@/store.js'; | ||||
| import { soundSettingsButton } from '@/ui/deck/tl-note-notification.js'; | ||||
| import * as sound from '@/scripts/sound.js'; | ||||
| 
 | ||||
| const props = defineProps<{ | ||||
| 	column: Column; | ||||
|  | @ -52,6 +57,7 @@ const timeline = shallowRef<InstanceType<typeof MkTimeline>>(); | |||
| 
 | ||||
| const isLocalTimelineAvailable = (($i == null && instance.policies.ltlAvailable) || ($i != null && $i.policies.ltlAvailable)); | ||||
| const isGlobalTimelineAvailable = (($i == null && instance.policies.gtlAvailable) || ($i != null && $i.policies.gtlAvailable)); | ||||
| const soundSetting = ref<SoundStore>(props.column.soundSetting ?? { type: null, volume: 1 }); | ||||
| const withRenotes = ref(props.column.withRenotes ?? true); | ||||
| const withReplies = ref(props.column.withReplies ?? false); | ||||
| const onlyFiles = ref(props.column.onlyFiles ?? false); | ||||
|  | @ -74,6 +80,10 @@ watch(onlyFiles, v => { | |||
| 	}); | ||||
| }); | ||||
| 
 | ||||
| watch(soundSetting, v => { | ||||
| 	updateColumn(props.column.id, { soundSetting: v }); | ||||
| }); | ||||
| 
 | ||||
| onMounted(() => { | ||||
| 	if (props.column.tl == null) { | ||||
| 		setType(); | ||||
|  | @ -108,10 +118,18 @@ async function setType() { | |||
| 	}); | ||||
| } | ||||
| 
 | ||||
| const menu = [{ | ||||
| function onNote() { | ||||
| 	sound.playMisskeySfxFile(soundSetting.value); | ||||
| } | ||||
| 
 | ||||
| const menu: MenuItem[] = [{ | ||||
| 	icon: 'ti ti-pencil', | ||||
| 	text: i18n.ts.timeline, | ||||
| 	action: setType, | ||||
| }, { | ||||
| 	icon: 'ti ti-bell', | ||||
| 	text: i18n.ts._deck.newNoteNotificationSettings, | ||||
| 	action: () => soundSettingsButton(soundSetting), | ||||
| }, { | ||||
| 	type: 'switch', | ||||
| 	text: i18n.ts.showRenotes, | ||||
|  |  | |||
|  | @ -0,0 +1,107 @@ | |||
| /* | ||||
|  * SPDX-FileCopyrightText: syuilo and misskey-project | ||||
|  * SPDX-License-Identifier: AGPL-3.0-only | ||||
|  */ | ||||
| 
 | ||||
| import * as Misskey from 'misskey-js'; | ||||
| import { Ref } from 'vue'; | ||||
| import { SoundStore } from '@/store.js'; | ||||
| import { getSoundDuration, playMisskeySfxFile, soundsTypes, SoundType } from '@/scripts/sound.js'; | ||||
| import { i18n } from '@/i18n.js'; | ||||
| import * as os from '@/os.js'; | ||||
| 
 | ||||
| export async function soundSettingsButton(soundSetting: Ref<SoundStore>): Promise<void> { | ||||
| 	function getSoundTypeName(f: SoundType): string { | ||||
| 		switch (f) { | ||||
| 			case null: | ||||
| 				return i18n.ts.none; | ||||
| 			case '_driveFile_': | ||||
| 				return i18n.ts._soundSettings.driveFile; | ||||
| 			default: | ||||
| 				return f; | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	const { canceled, result } = await os.form(i18n.ts.sound, { | ||||
| 		type: { | ||||
| 			type: 'enum', | ||||
| 			label: i18n.ts.sound, | ||||
| 			default: soundSetting.value.type ?? 'none', | ||||
| 			enum: soundsTypes.map(f => ({ | ||||
| 				value: f ?? 'none', label: getSoundTypeName(f), | ||||
| 			})), | ||||
| 		}, | ||||
| 		soundFile: { | ||||
| 			type: 'drive-file', | ||||
| 			label: i18n.ts.file, | ||||
| 			defaultFileId: soundSetting.value.type === '_driveFile_' ? soundSetting.value.fileId : null, | ||||
| 			hidden: v => v.type !== '_driveFile_', | ||||
| 			validate: async (file: Misskey.entities.DriveFile) => { | ||||
| 				if (!file.type.startsWith('audio')) { | ||||
| 					os.alert({ | ||||
| 						type: 'warning', | ||||
| 						title: i18n.ts._soundSettings.driveFileTypeWarn, | ||||
| 						text: i18n.ts._soundSettings.driveFileTypeWarnDescription, | ||||
| 					}); | ||||
| 					return false; | ||||
| 				} | ||||
| 
 | ||||
| 				const duration = await getSoundDuration(file.url); | ||||
| 				if (duration >= 2000) { | ||||
| 					const { canceled } = await os.confirm({ | ||||
| 						type: 'warning', | ||||
| 						title: i18n.ts._soundSettings.driveFileDurationWarn, | ||||
| 						text: i18n.ts._soundSettings.driveFileDurationWarnDescription, | ||||
| 						okText: i18n.ts.continue, | ||||
| 						cancelText: i18n.ts.cancel, | ||||
| 					}); | ||||
| 					if (canceled) return false; | ||||
| 				} | ||||
| 
 | ||||
| 				return true; | ||||
| 			}, | ||||
| 		}, | ||||
| 		volume: { | ||||
| 			type: 'range', | ||||
| 			label: i18n.ts.volume, | ||||
| 			default: soundSetting.value.volume ?? 1, | ||||
| 			textConverter: (v) => `${Math.floor(v * 100)}%`, | ||||
| 			min: 0, | ||||
| 			max: 1, | ||||
| 			step: 0.05, | ||||
| 		}, | ||||
| 		listen: { | ||||
| 			type: 'button', | ||||
| 			content: i18n.ts.listen, | ||||
| 			action: (_, v) => { | ||||
| 				const sound = buildSoundStore(v); | ||||
| 				if (!sound) return; | ||||
| 				playMisskeySfxFile(sound); | ||||
| 			}, | ||||
| 		}, | ||||
| 	}); | ||||
| 	if (canceled) return; | ||||
| 
 | ||||
| 	const res = buildSoundStore(result); | ||||
| 	if (res) soundSetting.value = res; | ||||
| 
 | ||||
| 	function buildSoundStore(result: any): SoundStore | null { | ||||
| 		const type = (result.type === 'none' ? null : result.type) as SoundType; | ||||
| 		const volume = result.volume as number; | ||||
| 		const fileId = result.soundFile?.id ?? (soundSetting.value.type === '_driveFile_' ? soundSetting.value.fileId : undefined); | ||||
| 		const fileUrl = result.soundFile?.url ?? (soundSetting.value.type === '_driveFile_' ? soundSetting.value.fileUrl : undefined); | ||||
| 
 | ||||
| 		if (type === '_driveFile_') { | ||||
| 			if (!fileUrl || !fileId) { | ||||
| 				os.alert({ | ||||
| 					type: 'warning', | ||||
| 					text: i18n.ts._soundSettings.driveFileWarn, | ||||
| 				}); | ||||
| 				return null; | ||||
| 			} | ||||
| 			return { type, volume, fileId, fileUrl }; | ||||
| 		} else { | ||||
| 			return { type, volume }; | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|  | @ -4441,7 +4441,6 @@ export type components = { | |||
|       caseSensitive: boolean; | ||||
|       /** @default false */ | ||||
|       localOnly: boolean; | ||||
|       notify: boolean; | ||||
|       /** @default false */ | ||||
|       excludeBots: boolean; | ||||
|       /** @default false */ | ||||
|  | @ -9748,7 +9747,6 @@ export type operations = { | |||
|           excludeBots?: boolean; | ||||
|           withReplies: boolean; | ||||
|           withFile: boolean; | ||||
|           notify: boolean; | ||||
|         }; | ||||
|       }; | ||||
|     }; | ||||
|  | @ -10030,7 +10028,6 @@ export type operations = { | |||
|           excludeBots?: boolean; | ||||
|           withReplies?: boolean; | ||||
|           withFile?: boolean; | ||||
|           notify?: boolean; | ||||
|         }; | ||||
|       }; | ||||
|     }; | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue