summaryrefslogtreecommitdiff
path: root/packages
diff options
context:
space:
mode:
authoranatawa12 <anatawa12@icloud.com>2024-05-27 20:54:53 +0900
committerGitHub <noreply@github.com>2024-05-27 20:54:53 +0900
commit4579be0f5401001bcfc27c4d56133cc910f3f581 (patch)
treee6ca129d8d22e5684250a380943bd431ff8c2f4d /packages
parentNew Crowdin updates (#13860) (diff)
downloadmisskey-4579be0f5401001bcfc27c4d56133cc910f3f581.tar.gz
misskey-4579be0f5401001bcfc27c4d56133cc910f3f581.tar.bz2
misskey-4579be0f5401001bcfc27c4d56133cc910f3f581.zip
新着ノートをサウンドで通知する機能を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>
Diffstat (limited to 'packages')
-rw-r--r--packages/backend/migration/1716450883149-RemoveAntennaNotify.js16
-rw-r--r--packages/backend/src/core/entities/AntennaEntityService.ts1
-rw-r--r--packages/backend/src/models/Antenna.ts3
-rw-r--r--packages/backend/src/models/json-schema/antenna.ts4
-rw-r--r--packages/backend/src/queue/processors/ExportAntennasProcessorService.ts1
-rw-r--r--packages/backend/src/queue/processors/ImportAntennasProcessorService.ts4
-rw-r--r--packages/backend/src/server/api/endpoints/antennas/create.ts4
-rw-r--r--packages/backend/src/server/api/endpoints/antennas/update.ts2
-rw-r--r--packages/backend/test/e2e/antennas.ts4
-rw-r--r--packages/backend/test/e2e/move.ts2
-rw-r--r--packages/frontend/src/components/MkFormDialog.file.vue71
-rw-r--r--packages/frontend/src/components/MkFormDialog.vue12
-rw-r--r--packages/frontend/src/os.ts2
-rw-r--r--packages/frontend/src/pages/my-antennas/editor.vue3
-rw-r--r--packages/frontend/src/scripts/form.ts30
-rw-r--r--packages/frontend/src/ui/deck/antenna-column.vue24
-rw-r--r--packages/frontend/src/ui/deck/channel-column.vue23
-rw-r--r--packages/frontend/src/ui/deck/deck-store.ts2
-rw-r--r--packages/frontend/src/ui/deck/list-column.vue22
-rw-r--r--packages/frontend/src/ui/deck/role-timeline-column.vue23
-rw-r--r--packages/frontend/src/ui/deck/tl-column.vue20
-rw-r--r--packages/frontend/src/ui/deck/tl-note-notification.ts107
-rw-r--r--packages/misskey-js/src/autogen/types.ts3
23 files changed, 330 insertions, 53 deletions
diff --git a/packages/backend/migration/1716450883149-RemoveAntennaNotify.js b/packages/backend/migration/1716450883149-RemoveAntennaNotify.js
new file mode 100644
index 0000000000..b5a2441855
--- /dev/null
+++ b/packages/backend/migration/1716450883149-RemoveAntennaNotify.js
@@ -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`);
+ }
+}
diff --git a/packages/backend/src/core/entities/AntennaEntityService.ts b/packages/backend/src/core/entities/AntennaEntityService.ts
index 3ec8efa6bf..4a17a3d80f 100644
--- a/packages/backend/src/core/entities/AntennaEntityService.ts
+++ b/packages/backend/src/core/entities/AntennaEntityService.ts
@@ -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,
diff --git a/packages/backend/src/models/Antenna.ts b/packages/backend/src/models/Antenna.ts
index f5e819059e..33e6f48189 100644
--- a/packages/backend/src/models/Antenna.ts
+++ b/packages/backend/src/models/Antenna.ts
@@ -90,9 +90,6 @@ export class MiAntenna {
})
public expression: string | null;
- @Column('boolean')
- public notify: boolean;
-
@Index()
@Column('boolean', {
default: true,
diff --git a/packages/backend/src/models/json-schema/antenna.ts b/packages/backend/src/models/json-schema/antenna.ts
index 78cf6d3ba2..c4ac358fa6 100644
--- a/packages/backend/src/models/json-schema/antenna.ts
+++ b/packages/backend/src/models/json-schema/antenna.ts
@@ -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,
diff --git a/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts b/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts
index 1d8e90f367..88c4ea29c0 100644
--- a/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts
+++ b/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts
@@ -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(', ');
diff --git a/packages/backend/src/queue/processors/ImportAntennasProcessorService.ts b/packages/backend/src/queue/processors/ImportAntennasProcessorService.ts
index ff1c04de06..e5b7c5ac52 100644
--- a/packages/backend/src/queue/processors/ImportAntennasProcessorService.ts
+++ b/packages/backend/src/queue/processors/ImportAntennasProcessorService.ts
@@ -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);
diff --git a/packages/backend/src/server/api/endpoints/antennas/create.ts b/packages/backend/src/server/api/endpoints/antennas/create.ts
index 57c8eb4958..6b7bacb054 100644
--- a/packages/backend/src/server/api/endpoints/antennas/create.ts
+++ b/packages/backend/src/server/api/endpoints/antennas/create.ts
@@ -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);
diff --git a/packages/backend/src/server/api/endpoints/antennas/update.ts b/packages/backend/src/server/api/endpoints/antennas/update.ts
index e6720aacf8..0c30bca9e0 100644
--- a/packages/backend/src/server/api/endpoints/antennas/update.ts
+++ b/packages/backend/src/server/api/endpoints/antennas/update.ts
@@ -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(),
});
diff --git a/packages/backend/test/e2e/antennas.ts b/packages/backend/test/e2e/antennas.ts
index cf5c7dd130..4f78cc999d 100644
--- a/packages/backend/test/e2e/antennas.ts
+++ b/packages/backend/test/e2e/antennas.ts
@@ -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({
diff --git a/packages/backend/test/e2e/move.ts b/packages/backend/test/e2e/move.ts
index 4e5306da97..35050130dc 100644
--- a/packages/backend/test/e2e/move.ts
+++ b/packages/backend/test/e2e/move.ts
@@ -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);
diff --git a/packages/frontend/src/components/MkFormDialog.file.vue b/packages/frontend/src/components/MkFormDialog.file.vue
new file mode 100644
index 0000000000..9360594236
--- /dev/null
+++ b/packages/frontend/src/components/MkFormDialog.file.vue
@@ -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>
diff --git a/packages/frontend/src/components/MkFormDialog.vue b/packages/frontend/src/components/MkFormDialog.vue
index deedc5badb..124f114111 100644
--- a/packages/frontend/src/components/MkFormDialog.vue
+++ b/packages/frontend/src/components/MkFormDialog.vue
@@ -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';
diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts
index c561e84a23..f656a52371 100644
--- a/packages/frontend/src/os.ts
+++ b/packages/frontend/src/os.ts
@@ -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 => {
diff --git a/packages/frontend/src/pages/my-antennas/editor.vue b/packages/frontend/src/pages/my-antennas/editor.vue
index 97edbc44ce..2949bfc02c 100644
--- a/packages/frontend/src/pages/my-antennas/editor.vue
+++ b/packages/frontend/src/pages/my-antennas/editor.vue
@@ -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()),
diff --git a/packages/frontend/src/scripts/form.ts b/packages/frontend/src/scripts/form.ts
index b0db404f28..242a504c3b 100644
--- a/packages/frontend/src/scripts/form.ts
+++ b/packages/frontend/src/scripts/form.ts
@@ -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]>;
diff --git a/packages/frontend/src/ui/deck/antenna-column.vue b/packages/frontend/src/ui/deck/antenna-column.vue
index b42a21bf6f..c3dc1e4fce 100644
--- a/packages/frontend/src/ui/deck/antenna-column.vue
+++ b/packages/frontend/src/ui/deck/antenna-column.vue
@@ -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),
+ },
];
/*
diff --git a/packages/frontend/src/ui/deck/channel-column.vue b/packages/frontend/src/ui/deck/channel-column.vue
index 28c741bba2..7c5b13eaf1 100644
--- a/packages/frontend/src/ui/deck/channel-column.vue
+++ b/packages/frontend/src/ui/deck/channel-column.vue
@@ -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>
diff --git a/packages/frontend/src/ui/deck/deck-store.ts b/packages/frontend/src/ui/deck/deck-store.ts
index 70b55e8172..bb3c04cd5c 100644
--- a/packages/frontend/src/ui/deck/deck-store.ts
+++ b/packages/frontend/src/ui/deck/deck-store.ts
@@ -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', {
diff --git a/packages/frontend/src/ui/deck/list-column.vue b/packages/frontend/src/ui/deck/list-column.vue
index 70ea54326f..5369112494 100644
--- a/packages/frontend/src/ui/deck/list-column.vue
+++ b/packages/frontend/src/ui/deck/list-column.vue
@@ -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>
diff --git a/packages/frontend/src/ui/deck/role-timeline-column.vue b/packages/frontend/src/ui/deck/role-timeline-column.vue
index eae2ee13f3..32ab7527b4 100644
--- a/packages/frontend/src/ui/deck/role-timeline-column.vue
+++ b/packages/frontend/src/ui/deck/role-timeline-column.vue
@@ -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),
}];
/*
diff --git a/packages/frontend/src/ui/deck/tl-column.vue b/packages/frontend/src/ui/deck/tl-column.vue
index f9066d9db7..a967335edf 100644
--- a/packages/frontend/src/ui/deck/tl-column.vue
+++ b/packages/frontend/src/ui/deck/tl-column.vue
@@ -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,11 +118,19 @@ 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,
ref: withRenotes,
diff --git a/packages/frontend/src/ui/deck/tl-note-notification.ts b/packages/frontend/src/ui/deck/tl-note-notification.ts
new file mode 100644
index 0000000000..275ea56ba0
--- /dev/null
+++ b/packages/frontend/src/ui/deck/tl-note-notification.ts
@@ -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 };
+ }
+ }
+}
diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts
index 208f03dc3e..11567677c9 100644
--- a/packages/misskey-js/src/autogen/types.ts
+++ b/packages/misskey-js/src/autogen/types.ts
@@ -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;
};
};
};