新着ノートをサウンドで通知する機能を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:
anatawa12 2024-05-27 20:54:53 +09:00 committed by GitHub
parent d7982e471c
commit 4579be0f54
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 341 additions and 53 deletions

View file

@ -49,6 +49,7 @@
- Enhance: AiScriptを0.18.0にバージョンアップ - Enhance: AiScriptを0.18.0にバージョンアップ
- Enhance: 通常のノートでも、お気に入りに登録したチャンネルにリノートできるように - Enhance: 通常のノートでも、お気に入りに登録したチャンネルにリノートできるように
- Enhance: 長いテキストをペーストした際にテキストファイルとして添付するかどうかを選択できるように - Enhance: 長いテキストをペーストした際にテキストファイルとして添付するかどうかを選択できるように
- Enhance: 新着ートをサウンドで通知する機能をdeck UIに追加しました
- Enhance: コントロールパネルのクイックアクションからファイルを照会できるように - Enhance: コントロールパネルのクイックアクションからファイルを照会できるように
- Enhance: コントロールパネルのクイックアクションから通常の照会を行えるように - Enhance: コントロールパネルのクイックアクションから通常の照会を行えるように
- Fix: 一部のページ内リンクが正しく動作しない問題を修正 - Fix: 一部のページ内リンクが正しく動作しない問題を修正

8
locales/index.d.ts vendored
View file

@ -1280,6 +1280,10 @@ export interface Locale extends ILocale {
* *
*/ */
"selectFolders": string; "selectFolders": string;
/**
*
*/
"fileNotSelected": string;
/** /**
* *
*/ */
@ -9143,6 +9147,10 @@ export interface Locale extends ILocale {
* *
*/ */
"addColumn": string; "addColumn": string;
/**
*
*/
"newNoteNotificationSettings": string;
/** /**
* *
*/ */

View file

@ -316,6 +316,7 @@ selectFile: "ファイルを選択"
selectFiles: "ファイルを選択" selectFiles: "ファイルを選択"
selectFolder: "フォルダーを選択" selectFolder: "フォルダーを選択"
selectFolders: "フォルダーを選択" selectFolders: "フォルダーを選択"
fileNotSelected: "ファイルが選択されていません"
renameFile: "ファイル名を変更" renameFile: "ファイル名を変更"
folderName: "フォルダー名" folderName: "フォルダー名"
createFolder: "フォルダーを作成" createFolder: "フォルダーを作成"
@ -2420,6 +2421,7 @@ _deck:
alwaysShowMainColumn: "常にメインカラムを表示" alwaysShowMainColumn: "常にメインカラムを表示"
columnAlign: "カラムの寄せ" columnAlign: "カラムの寄せ"
addColumn: "カラムを追加" addColumn: "カラムを追加"
newNoteNotificationSettings: "新着ノート通知の設定"
configureColumn: "カラムの設定" configureColumn: "カラムの設定"
swapLeft: "左に移動" swapLeft: "左に移動"
swapRight: "右に移動" swapRight: "右に移動"

View file

@ -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`);
}
}

View file

@ -38,7 +38,6 @@ export class AntennaEntityService {
users: antenna.users, users: antenna.users,
caseSensitive: antenna.caseSensitive, caseSensitive: antenna.caseSensitive,
localOnly: antenna.localOnly, localOnly: antenna.localOnly,
notify: antenna.notify,
excludeBots: antenna.excludeBots, excludeBots: antenna.excludeBots,
withReplies: antenna.withReplies, withReplies: antenna.withReplies,
withFile: antenna.withFile, withFile: antenna.withFile,

View file

@ -90,9 +90,6 @@ export class MiAntenna {
}) })
public expression: string | null; public expression: string | null;
@Column('boolean')
public notify: boolean;
@Index() @Index()
@Column('boolean', { @Column('boolean', {
default: true, default: true,

View file

@ -72,10 +72,6 @@ export const packedAntennaSchema = {
optional: false, nullable: false, optional: false, nullable: false,
default: false, default: false,
}, },
notify: {
type: 'boolean',
optional: false, nullable: false,
},
excludeBots: { excludeBots: {
type: 'boolean', type: 'boolean',
optional: false, nullable: false, optional: false, nullable: false,

View file

@ -84,7 +84,6 @@ export class ExportAntennasProcessorService {
excludeBots: antenna.excludeBots, excludeBots: antenna.excludeBots,
withReplies: antenna.withReplies, withReplies: antenna.withReplies,
withFile: antenna.withFile, withFile: antenna.withFile,
notify: antenna.notify,
})); }));
if (antennas.length - 1 !== index) { if (antennas.length - 1 !== index) {
write(', '); write(', ');

View file

@ -47,9 +47,8 @@ const validate = new Ajv().compile({
excludeBots: { type: 'boolean' }, excludeBots: { type: 'boolean' },
withReplies: { type: 'boolean' }, withReplies: { type: 'boolean' },
withFile: { 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() @Injectable()
@ -92,7 +91,6 @@ export class ImportAntennasProcessorService {
excludeBots: antenna.excludeBots, excludeBots: antenna.excludeBots,
withReplies: antenna.withReplies, withReplies: antenna.withReplies,
withFile: antenna.withFile, withFile: antenna.withFile,
notify: antenna.notify,
}).then(x => this.antennasRepository.findOneByOrFail(x.identifiers[0])); }).then(x => this.antennasRepository.findOneByOrFail(x.identifiers[0]));
this.logger.succ('Antenna created: ' + result.id); this.logger.succ('Antenna created: ' + result.id);
this.globalEventService.publishInternalEvent('antennaCreated', result); this.globalEventService.publishInternalEvent('antennaCreated', result);

View file

@ -67,9 +67,8 @@ export const paramDef = {
excludeBots: { type: 'boolean' }, excludeBots: { type: 'boolean' },
withReplies: { type: 'boolean' }, withReplies: { type: 'boolean' },
withFile: { 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; } as const;
@Injectable() @Injectable()
@ -128,7 +127,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
excludeBots: ps.excludeBots, excludeBots: ps.excludeBots,
withReplies: ps.withReplies, withReplies: ps.withReplies,
withFile: ps.withFile, withFile: ps.withFile,
notify: ps.notify,
}).then(x => this.antennasRepository.findOneByOrFail(x.identifiers[0])); }).then(x => this.antennasRepository.findOneByOrFail(x.identifiers[0]));
this.globalEventService.publishInternalEvent('antennaCreated', antenna); this.globalEventService.publishInternalEvent('antennaCreated', antenna);

View file

@ -66,7 +66,6 @@ export const paramDef = {
excludeBots: { type: 'boolean' }, excludeBots: { type: 'boolean' },
withReplies: { type: 'boolean' }, withReplies: { type: 'boolean' },
withFile: { type: 'boolean' }, withFile: { type: 'boolean' },
notify: { type: 'boolean' },
}, },
required: ['antennaId'], required: ['antennaId'],
} as const; } as const;
@ -124,7 +123,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
excludeBots: ps.excludeBots, excludeBots: ps.excludeBots,
withReplies: ps.withReplies, withReplies: ps.withReplies,
withFile: ps.withFile, withFile: ps.withFile,
notify: ps.notify,
isActive: true, isActive: true,
lastUsedAt: new Date(), lastUsedAt: new Date(),
}); });

View file

@ -38,7 +38,6 @@ describe('アンテナ', () => {
excludeKeywords: [['']], excludeKeywords: [['']],
keywords: [['keyword']], keywords: [['keyword']],
name: 'test', name: 'test',
notify: false,
src: 'all' as const, src: 'all' as const,
userListId: null, userListId: null,
users: [''], users: [''],
@ -151,7 +150,6 @@ describe('アンテナ', () => {
isActive: true, isActive: true,
keywords: [['keyword']], keywords: [['keyword']],
name: 'test', name: 'test',
notify: false,
src: 'all', src: 'all',
userListId: null, userListId: null,
users: [''], users: [''],
@ -219,8 +217,6 @@ describe('アンテナ', () => {
{ parameters: () => ({ withReplies: true }) }, { parameters: () => ({ withReplies: true }) },
{ parameters: () => ({ withFile: false }) }, { parameters: () => ({ withFile: false }) },
{ parameters: () => ({ withFile: true }) }, { parameters: () => ({ withFile: true }) },
{ parameters: () => ({ notify: false }) },
{ parameters: () => ({ notify: true }) },
]; ];
test.each(antennaParamPattern)('を作成できること($#)', async ({ parameters }) => { test.each(antennaParamPattern)('を作成できること($#)', async ({ parameters }) => {
const response = await successfulApiCall({ const response = await successfulApiCall({

View file

@ -191,7 +191,6 @@ describe('Account Move', () => {
localOnly: false, localOnly: false,
withReplies: false, withReplies: false,
withFile: false, withFile: false,
notify: false,
}, alice); }, alice);
antennaId = antenna.body.id; antennaId = antenna.body.id;
@ -435,7 +434,6 @@ describe('Account Move', () => {
localOnly: false, localOnly: false,
withReplies: false, withReplies: false,
withFile: false, withFile: false,
notify: false,
}, alice); }, alice);
assert.strictEqual(res.status, 403); assert.strictEqual(res.status, 403);

View file

@ -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>

View file

@ -21,8 +21,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSpacer :marginMin="20" :marginMax="32"> <MkSpacer :marginMin="20" :marginMax="32">
<div v-if="Object.keys(form).filter(item => !form[item].hidden).length > 0" class="_gaps_m"> <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))"> <template v-for="(v, k) in Object.fromEntries(Object.entries(form))">
<MkInput v-if="v.type === 'number'" v-model="values[k]" type="number" :step="v.step || 1"> <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 #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> <template v-if="v.description" #caption>{{ v.description }}</template>
</MkInput> </MkInput>
@ -53,6 +54,12 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkButton v-else-if="v.type === 'button'" @click="v.action($event, values)"> <MkButton v-else-if="v.type === 'button'" @click="v.action($event, values)">
<span v-text="v.content || k"></span> <span v-text="v.content || k"></span>
</MkButton> </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> </template>
</div> </div>
<div v-else class="_fullinfo"> <div v-else class="_fullinfo">
@ -72,6 +79,7 @@ import MkSelect from './MkSelect.vue';
import MkRange from './MkRange.vue'; import MkRange from './MkRange.vue';
import MkButton from './MkButton.vue'; import MkButton from './MkButton.vue';
import MkRadios from './MkRadios.vue'; import MkRadios from './MkRadios.vue';
import XFile from './MkFormDialog.file.vue';
import type { Form } from '@/scripts/form.js'; import type { Form } from '@/scripts/form.js';
import MkModalWindow from '@/components/MkModalWindow.vue'; import MkModalWindow from '@/components/MkModalWindow.vue';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';

View file

@ -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 => { return new Promise(resolve => {
popup(defineAsyncComponent(() => import('@/components/MkFormDialog.vue')), { title, form: f }, { popup(defineAsyncComponent(() => import('@/components/MkFormDialog.vue')), { title, form: f }, {
done: result => { done: result => {

View file

@ -39,7 +39,6 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSwitch v-model="localOnly">{{ i18n.ts.localOnly }}</MkSwitch> <MkSwitch v-model="localOnly">{{ i18n.ts.localOnly }}</MkSwitch>
<MkSwitch v-model="caseSensitive">{{ i18n.ts.caseSensitive }}</MkSwitch> <MkSwitch v-model="caseSensitive">{{ i18n.ts.caseSensitive }}</MkSwitch>
<MkSwitch v-model="withFile">{{ i18n.ts.withFileAntenna }}</MkSwitch> <MkSwitch v-model="withFile">{{ i18n.ts.withFileAntenna }}</MkSwitch>
<MkSwitch v-model="notify">{{ i18n.ts.notifyAntenna }}</MkSwitch>
</div> </div>
<div :class="$style.actions"> <div :class="$style.actions">
<MkButton inline primary @click="saveAntenna()"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton> <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 excludeBots = ref<boolean>(props.antenna.excludeBots);
const withReplies = ref<boolean>(props.antenna.withReplies); const withReplies = ref<boolean>(props.antenna.withReplies);
const withFile = ref<boolean>(props.antenna.withFile); const withFile = ref<boolean>(props.antenna.withFile);
const notify = ref<boolean>(props.antenna.notify);
const userLists = ref<Misskey.entities.UserList[] | null>(null); const userLists = ref<Misskey.entities.UserList[] | null>(null);
watch(() => src.value, async () => { watch(() => src.value, async () => {
@ -99,7 +97,6 @@ async function saveAntenna() {
excludeBots: excludeBots.value, excludeBots: excludeBots.value,
withReplies: withReplies.value, withReplies: withReplies.value,
withFile: withFile.value, withFile: withFile.value,
notify: notify.value,
caseSensitive: caseSensitive.value, caseSensitive: caseSensitive.value,
localOnly: localOnly.value, localOnly: localOnly.value,
users: users.value.trim().split('\n').map(x => x.trim()), users: users.value.trim().split('\n').map(x => x.trim()),

View file

@ -3,18 +3,22 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import * as Misskey from 'misskey-js';
type EnumItem = string | { type EnumItem = string | {
label: string; label: string;
value: string; value: string;
}; };
type Hidden = boolean | ((v: any) => boolean);
export type FormItem = { export type FormItem = {
label?: string; label?: string;
type: 'string'; type: 'string';
default: string | null; default: string | null;
description?: string; description?: string;
required?: boolean; required?: boolean;
hidden?: boolean; hidden?: Hidden;
multiline?: boolean; multiline?: boolean;
treatAsMfm?: boolean; treatAsMfm?: boolean;
} | { } | {
@ -23,27 +27,27 @@ export type FormItem = {
default: number | null; default: number | null;
description?: string; description?: string;
required?: boolean; required?: boolean;
hidden?: boolean; hidden?: Hidden;
step?: number; step?: number;
} | { } | {
label?: string; label?: string;
type: 'boolean'; type: 'boolean';
default: boolean | null; default: boolean | null;
description?: string; description?: string;
hidden?: boolean; hidden?: Hidden;
} | { } | {
label?: string; label?: string;
type: 'enum'; type: 'enum';
default: string | null; default: string | null;
required?: boolean; required?: boolean;
hidden?: boolean; hidden?: Hidden;
enum: EnumItem[]; enum: EnumItem[];
} | { } | {
label?: string; label?: string;
type: 'radio'; type: 'radio';
default: unknown | null; default: unknown | null;
required?: boolean; required?: boolean;
hidden?: boolean; hidden?: Hidden;
options: { options: {
label: string; label: string;
value: unknown; value: unknown;
@ -58,20 +62,27 @@ export type FormItem = {
min: number; min: number;
max: number; max: number;
textConverter?: (value: number) => string; textConverter?: (value: number) => string;
hidden?: Hidden;
} | { } | {
label?: string; label?: string;
type: 'object'; type: 'object';
default: Record<string, unknown> | null; default: Record<string, unknown> | null;
hidden: boolean; hidden: Hidden;
} | { } | {
label?: string; label?: string;
type: 'array'; type: 'array';
default: unknown[] | null; default: unknown[] | null;
hidden: boolean; hidden: Hidden;
} | { } | {
type: 'button'; type: 'button';
content?: string; content?: string;
hidden?: Hidden;
action: (ev: MouseEvent, v: any) => void; 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>; export type Form = Record<string, FormItem>;
@ -84,8 +95,9 @@ type GetItemType<Item extends FormItem> =
Item['type'] extends 'range' ? number : Item['type'] extends 'range' ? number :
Item['type'] extends 'enum' ? string : Item['type'] extends 'enum' ? string :
Item['type'] extends 'array' ? unknown[] : Item['type'] extends 'array' ? unknown[] :
Item['type'] extends 'object' ? Record<string, unknown> Item['type'] extends 'object' ? Record<string, unknown> :
: never; Item['type'] extends 'drive-file' ? Misskey.entities.DriveFile | undefined :
never;
export type GetFormResultType<F extends Form> = { export type GetFormResultType<F extends Form> = {
[P in keyof F]: GetItemType<F[P]>; [P in keyof F]: GetItemType<F[P]>;

View file

@ -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> <i class="ti ti-antenna"></i><span style="margin-left: 8px;">{{ column.name }}</span>
</template> </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> </XColumn>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { onMounted, shallowRef } from 'vue'; import { onMounted, ref, shallowRef, watch } from 'vue';
import XColumn from './column.vue'; import XColumn from './column.vue';
import { updateColumn, Column } from './deck-store.js'; import { updateColumn, Column } from './deck-store.js';
import MkTimeline from '@/components/MkTimeline.vue'; import MkTimeline from '@/components/MkTimeline.vue';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js'; import { misskeyApi } from '@/scripts/misskey-api.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
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<{ const props = defineProps<{
column: Column; column: Column;
@ -28,6 +32,7 @@ const props = defineProps<{
}>(); }>();
const timeline = shallowRef<InstanceType<typeof MkTimeline>>(); const timeline = shallowRef<InstanceType<typeof MkTimeline>>();
const soundSetting = ref<SoundStore>(props.column.soundSetting ?? { type: null, volume: 1 });
onMounted(() => { onMounted(() => {
if (props.column.antennaId == null) { if (props.column.antennaId == null) {
@ -35,6 +40,10 @@ onMounted(() => {
} }
}); });
watch(soundSetting, v => {
updateColumn(props.column.id, { soundSetting: v });
});
async function setAntenna() { async function setAntenna() {
const antennas = await misskeyApi('antennas/list'); const antennas = await misskeyApi('antennas/list');
const { canceled, result: antenna } = await os.select({ const { canceled, result: antenna } = await os.select({
@ -54,7 +63,11 @@ function editAntenna() {
os.pageWindow('my/antennas/' + props.column.antennaId); os.pageWindow('my/antennas/' + props.column.antennaId);
} }
const menu = [ function onNote() {
sound.playMisskeySfxFile(soundSetting.value);
}
const menu: MenuItem[] = [
{ {
icon: 'ti ti-pencil', icon: 'ti ti-pencil',
text: i18n.ts.selectAntenna, text: i18n.ts.selectAntenna,
@ -65,6 +78,11 @@ const menu = [
text: i18n.ts.editAntenna, text: i18n.ts.editAntenna,
action: editAntenna, action: editAntenna,
}, },
{
icon: 'ti ti-bell',
text: i18n.ts._deck.newNoteNotificationSettings,
action: () => soundSettingsButton(soundSetting),
},
]; ];
/* /*

View file

@ -13,13 +13,13 @@ SPDX-License-Identifier: AGPL-3.0-only
<div style="padding: 8px; text-align: center;"> <div style="padding: 8px; text-align: center;">
<MkButton primary gradate rounded inline small @click="post"><i class="ti ti-pencil"></i></MkButton> <MkButton primary gradate rounded inline small @click="post"><i class="ti ti-pencil"></i></MkButton>
</div> </div>
<MkTimeline ref="timeline" src="channel" :channel="column.channelId"/> <MkTimeline ref="timeline" src="channel" :channel="column.channelId" @note="onNote"/>
</template> </template>
</XColumn> </XColumn>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { shallowRef } from 'vue'; import { ref, shallowRef, watch } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import XColumn from './column.vue'; import XColumn from './column.vue';
import { updateColumn, Column } from './deck-store.js'; import { updateColumn, Column } from './deck-store.js';
@ -29,6 +29,10 @@ import * as os from '@/os.js';
import { favoritedChannelsCache } from '@/cache.js'; import { favoritedChannelsCache } from '@/cache.js';
import { misskeyApi } from '@/scripts/misskey-api.js'; import { misskeyApi } from '@/scripts/misskey-api.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
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<{ const props = defineProps<{
column: Column; column: Column;
@ -37,11 +41,16 @@ const props = defineProps<{
const timeline = shallowRef<InstanceType<typeof MkTimeline>>(); const timeline = shallowRef<InstanceType<typeof MkTimeline>>();
const channel = shallowRef<Misskey.entities.Channel>(); const channel = shallowRef<Misskey.entities.Channel>();
const soundSetting = ref<SoundStore>(props.column.soundSetting ?? { type: null, volume: 1 });
if (props.column.channelId == null) { if (props.column.channelId == null) {
setChannel(); setChannel();
} }
watch(soundSetting, v => {
updateColumn(props.column.id, { soundSetting: v });
});
async function setChannel() { async function setChannel() {
const channels = await favoritedChannelsCache.fetch(); const channels = await favoritedChannelsCache.fetch();
const { canceled, result: chosenChannel } = await os.select({ 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', icon: 'ti ti-pencil',
text: i18n.ts.selectChannel, text: i18n.ts.selectChannel,
action: setChannel, action: setChannel,
}, {
icon: 'ti ti-bell',
text: i18n.ts._deck.newNoteNotificationSettings,
action: () => soundSettingsButton(soundSetting),
}]; }];
</script> </script>

View file

@ -9,6 +9,7 @@ import { notificationTypes } from 'misskey-js';
import { Storage } from '@/pizzax.js'; import { Storage } from '@/pizzax.js';
import { misskeyApi } from '@/scripts/misskey-api.js'; import { misskeyApi } from '@/scripts/misskey-api.js';
import { deepClone } from '@/scripts/clone.js'; import { deepClone } from '@/scripts/clone.js';
import { SoundStore } from '@/store.js';
type ColumnWidget = { type ColumnWidget = {
name: string; name: string;
@ -33,6 +34,7 @@ export type Column = {
withRenotes?: boolean; withRenotes?: boolean;
withReplies?: boolean; withReplies?: boolean;
onlyFiles?: boolean; onlyFiles?: boolean;
soundSetting: SoundStore;
}; };
export const deckStore = markRaw(new Storage('deck', { export const deckStore = markRaw(new Storage('deck', {

View file

@ -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> <i class="ti ti-list"></i><span style="margin-left: 8px;">{{ column.name }}</span>
</template> </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> </XColumn>
</template> </template>
@ -21,6 +21,10 @@ import MkTimeline from '@/components/MkTimeline.vue';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js'; import { misskeyApi } from '@/scripts/misskey-api.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
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<{ const props = defineProps<{
column: Column; column: Column;
@ -29,6 +33,7 @@ const props = defineProps<{
const timeline = shallowRef<InstanceType<typeof MkTimeline>>(); const timeline = shallowRef<InstanceType<typeof MkTimeline>>();
const withRenotes = ref(props.column.withRenotes ?? true); const withRenotes = ref(props.column.withRenotes ?? true);
const soundSetting = ref<SoundStore>(props.column.soundSetting ?? { type: null, volume: 1 });
if (props.column.listId == null) { if (props.column.listId == null) {
setList(); setList();
@ -40,6 +45,10 @@ watch(withRenotes, v => {
}); });
}); });
watch(soundSetting, v => {
updateColumn(props.column.id, { soundSetting: v });
});
async function setList() { async function setList() {
const lists = await misskeyApi('users/lists/list'); const lists = await misskeyApi('users/lists/list');
const { canceled, result: list } = await os.select({ const { canceled, result: list } = await os.select({
@ -59,7 +68,11 @@ function editList() {
os.pageWindow('my/lists/' + props.column.listId); os.pageWindow('my/lists/' + props.column.listId);
} }
const menu = [ function onNote() {
sound.playMisskeySfxFile(soundSetting.value);
}
const menu: MenuItem[] = [
{ {
icon: 'ti ti-pencil', icon: 'ti ti-pencil',
text: i18n.ts.selectList, text: i18n.ts.selectList,
@ -75,5 +88,10 @@ const menu = [
text: i18n.ts.showRenotes, text: i18n.ts.showRenotes,
ref: withRenotes, ref: withRenotes,
}, },
{
icon: 'ti ti-bell',
text: i18n.ts._deck.newNoteNotificationSettings,
action: () => soundSettingsButton(soundSetting),
},
]; ];
</script> </script>

View file

@ -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> <i class="ti ti-badge"></i><span style="margin-left: 8px;">{{ column.name }}</span>
</template> </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> </XColumn>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { onMounted, shallowRef } from 'vue'; import { onMounted, ref, shallowRef, watch } from 'vue';
import XColumn from './column.vue'; import XColumn from './column.vue';
import { updateColumn, Column } from './deck-store.js'; import { updateColumn, Column } from './deck-store.js';
import MkTimeline from '@/components/MkTimeline.vue'; import MkTimeline from '@/components/MkTimeline.vue';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js'; import { misskeyApi } from '@/scripts/misskey-api.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
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<{ const props = defineProps<{
column: Column; column: Column;
@ -28,6 +32,7 @@ const props = defineProps<{
}>(); }>();
const timeline = shallowRef<InstanceType<typeof MkTimeline>>(); const timeline = shallowRef<InstanceType<typeof MkTimeline>>();
const soundSetting = ref<SoundStore>(props.column.soundSetting ?? { type: null, volume: 1 });
onMounted(() => { onMounted(() => {
if (props.column.roleId == null) { if (props.column.roleId == null) {
@ -35,6 +40,10 @@ onMounted(() => {
} }
}); });
watch(soundSetting, v => {
updateColumn(props.column.id, { soundSetting: v });
});
async function setRole() { async function setRole() {
const roles = (await misskeyApi('roles/list')).filter(x => x.isExplorable); const roles = (await misskeyApi('roles/list')).filter(x => x.isExplorable);
const { canceled, result: role } = await os.select({ 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', icon: 'ti ti-pencil',
text: i18n.ts.role, text: i18n.ts.role,
action: setRole, action: setRole,
}, {
icon: 'ti ti-bell',
text: i18n.ts._deck.newNoteNotificationSettings,
action: () => soundSettingsButton(soundSetting),
}]; }];
/* /*

View file

@ -28,6 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:withRenotes="withRenotes" :withRenotes="withRenotes"
:withReplies="withReplies" :withReplies="withReplies"
:onlyFiles="onlyFiles" :onlyFiles="onlyFiles"
@note="onNote"
/> />
</XColumn> </XColumn>
</template> </template>
@ -41,6 +42,10 @@ import * as os from '@/os.js';
import { $i } from '@/account.js'; import { $i } from '@/account.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { instance } from '@/instance.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<{ const props = defineProps<{
column: Column; 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 isLocalTimelineAvailable = (($i == null && instance.policies.ltlAvailable) || ($i != null && $i.policies.ltlAvailable));
const isGlobalTimelineAvailable = (($i == null && instance.policies.gtlAvailable) || ($i != null && $i.policies.gtlAvailable)); 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 withRenotes = ref(props.column.withRenotes ?? true);
const withReplies = ref(props.column.withReplies ?? false); const withReplies = ref(props.column.withReplies ?? false);
const onlyFiles = ref(props.column.onlyFiles ?? 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(() => { onMounted(() => {
if (props.column.tl == null) { if (props.column.tl == null) {
setType(); setType();
@ -108,10 +118,18 @@ async function setType() {
}); });
} }
const menu = [{ function onNote() {
sound.playMisskeySfxFile(soundSetting.value);
}
const menu: MenuItem[] = [{
icon: 'ti ti-pencil', icon: 'ti ti-pencil',
text: i18n.ts.timeline, text: i18n.ts.timeline,
action: setType, action: setType,
}, {
icon: 'ti ti-bell',
text: i18n.ts._deck.newNoteNotificationSettings,
action: () => soundSettingsButton(soundSetting),
}, { }, {
type: 'switch', type: 'switch',
text: i18n.ts.showRenotes, text: i18n.ts.showRenotes,

View file

@ -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 };
}
}
}

View file

@ -4441,7 +4441,6 @@ export type components = {
caseSensitive: boolean; caseSensitive: boolean;
/** @default false */ /** @default false */
localOnly: boolean; localOnly: boolean;
notify: boolean;
/** @default false */ /** @default false */
excludeBots: boolean; excludeBots: boolean;
/** @default false */ /** @default false */
@ -9748,7 +9747,6 @@ export type operations = {
excludeBots?: boolean; excludeBots?: boolean;
withReplies: boolean; withReplies: boolean;
withFile: boolean; withFile: boolean;
notify: boolean;
}; };
}; };
}; };
@ -10030,7 +10028,6 @@ export type operations = {
excludeBots?: boolean; excludeBots?: boolean;
withReplies?: boolean; withReplies?: boolean;
withFile?: boolean; withFile?: boolean;
notify?: boolean;
}; };
}; };
}; };