+
@@ -163,6 +163,7 @@ import { focusPrev, focusNext } from '@/scripts/focus.js';
import { checkWordMute } from '@/scripts/check-word-mute.js';
import { userPage } from '@/filters/user.js';
import * as os from '@/os.js';
+import * as sound from '@/scripts/sound.js';
import { defaultStore, noteViewInterruptors } from '@/store.js';
import { reactionPicker } from '@/scripts/reaction-picker.js';
import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm.js';
@@ -183,6 +184,7 @@ const props = withDefaults(defineProps<{
note: Misskey.entities.Note;
pinned?: boolean;
mock?: boolean;
+ withHardMute?: boolean;
}>(), {
mock: false,
});
@@ -239,13 +241,23 @@ const urls = $computed(() => parsed ? extractUrlFromMfm(parsed) : null);
const isLong = shouldCollapsed(appearNote, urls ?? []);
const collapsed = ref(appearNote.cw == null && isLong);
const isDeleted = ref(false);
-const muted = ref($i ? checkWordMute(appearNote, $i, $i.mutedWords) : false);
+const muted = ref(checkMute(appearNote, $i?.mutedWords));
+const hardMuted = ref(props.withHardMute && checkMute(appearNote, $i?.hardMutedWords));
const translation = ref(null);
const translating = ref(false);
const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.user.instance);
const canRenote = computed(() => ['public', 'home'].includes(appearNote.visibility) || (appearNote.visibility === 'followers' && appearNote.userId === $i.id));
let renoteCollapsed = $ref(defaultStore.state.collapseRenotes && isRenote && (($i && ($i.id === note.userId || $i.id === appearNote.userId)) || (appearNote.myReaction != null)));
+function checkMute(note: Misskey.entities.Note, mutedWords: Array | undefined | null): boolean {
+ if (mutedWords == null) return false;
+
+ if (checkWordMute(note, $i, mutedWords)) return true;
+ if (note.reply && checkWordMute(note.reply, $i, mutedWords)) return true;
+ if (note.renote && checkWordMute(note.renote, $i, mutedWords)) return true;
+ return false;
+}
+
const keymap = {
'r': () => reply(true),
'e|a|plus': () => react(true),
@@ -325,6 +337,8 @@ function react(viaKeyboard = false): void {
pleaseLogin();
showMovedDialog();
if (appearNote.reactionAcceptance === 'likeOnly') {
+ sound.play('reaction');
+
if (props.mock) {
return;
}
@@ -343,6 +357,8 @@ function react(viaKeyboard = false): void {
} else {
blur();
reactionPicker.show(reactButton.value, reaction => {
+ sound.play('reaction');
+
if (props.mock) {
emit('reaction', reaction);
return;
diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue
index d1bc3f676f..d8089ac36f 100644
--- a/packages/frontend/src/components/MkNoteDetailed.vue
+++ b/packages/frontend/src/components/MkNoteDetailed.vue
@@ -210,6 +210,7 @@ import { checkWordMute } from '@/scripts/check-word-mute.js';
import { userPage } from '@/filters/user.js';
import { notePage } from '@/filters/note.js';
import * as os from '@/os.js';
+import * as sound from '@/scripts/sound.js';
import { defaultStore, noteViewInterruptors } from '@/store.js';
import { reactionPicker } from '@/scripts/reaction-picker.js';
import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm.js';
@@ -369,6 +370,8 @@ function react(viaKeyboard = false): void {
pleaseLogin();
showMovedDialog();
if (appearNote.reactionAcceptance === 'likeOnly') {
+ sound.play('reaction');
+
os.api('notes/reactions/create', {
noteId: appearNote.id,
reaction: '❤️',
@@ -383,6 +386,8 @@ function react(viaKeyboard = false): void {
} else {
blur();
reactionPicker.show(reactButton.value, reaction => {
+ sound.play('reaction');
+
os.api('notes/reactions/create', {
noteId: appearNote.id,
reaction: reaction,
diff --git a/packages/frontend/src/components/MkNotes.vue b/packages/frontend/src/components/MkNotes.vue
index 89fd504dcc..7af31074db 100644
--- a/packages/frontend/src/components/MkNotes.vue
+++ b/packages/frontend/src/components/MkNotes.vue
@@ -24,7 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:ad="true"
:class="$style.notes"
>
-
+
diff --git a/packages/frontend/src/components/MkNotifications.vue b/packages/frontend/src/components/MkNotifications.vue
index 0c817bd64c..7b072fa492 100644
--- a/packages/frontend/src/components/MkNotifications.vue
+++ b/packages/frontend/src/components/MkNotifications.vue
@@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-
+
diff --git a/packages/frontend/src/components/MkReactionsViewer.reaction.vue b/packages/frontend/src/components/MkReactionsViewer.reaction.vue
index 2b850016c5..65a5c2374e 100644
--- a/packages/frontend/src/components/MkReactionsViewer.reaction.vue
+++ b/packages/frontend/src/components/MkReactionsViewer.reaction.vue
@@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:class="[$style.root, { [$style.reacted]: note.myReaction == reaction, [$style.canToggle]: canToggle, [$style.small]: defaultStore.state.reactionsDisplaySize === 'small', [$style.large]: defaultStore.state.reactionsDisplaySize === 'large' }]"
@click="toggleReaction()"
>
-
+
{{ count }}
@@ -28,6 +28,7 @@ import MkReactionEffect from '@/components/MkReactionEffect.vue';
import { claimAchievement } from '@/scripts/achievements.js';
import { defaultStore } from '@/store.js';
import { i18n } from '@/i18n.js';
+import * as sound from '@/scripts/sound.js';
const props = defineProps<{
reaction: string;
@@ -59,6 +60,10 @@ async function toggleReaction() {
});
if (confirm.canceled) return;
+ if (oldReaction !== props.reaction) {
+ sound.play('reaction');
+ }
+
if (mock) {
emit('reactionToggled', props.reaction, (props.count - 1));
return;
@@ -75,6 +80,8 @@ async function toggleReaction() {
}
});
} else {
+ sound.play('reaction');
+
if (mock) {
emit('reactionToggled', props.reaction, (props.count + 1));
return;
@@ -188,7 +195,7 @@ if (!mock) {
}
}
-.icon {
+.limitWidth {
max-width: 150px;
}
diff --git a/packages/frontend/src/components/global/MkTime.vue b/packages/frontend/src/components/global/MkTime.vue
index f08d538fc0..2eeab4d284 100644
--- a/packages/frontend/src/components/global/MkTime.vue
+++ b/packages/frontend/src/components/global/MkTime.vue
@@ -28,12 +28,25 @@ const props = withDefaults(defineProps<{
mode: 'relative',
});
-const _time = props.time == null ? NaN :
- typeof props.time === 'number' ? props.time :
- (props.time instanceof Date ? props.time : new Date(props.time)).getTime();
+function getDateSafe(n: Date | string | number) {
+ try {
+ if (n instanceof Date) {
+ return n;
+ }
+ return new Date(n);
+ } catch (err) {
+ return {
+ getTime: () => NaN,
+ };
+ }
+}
+
+// eslint-disable-next-line vue/no-setup-props-destructure
+const _time = props.time == null ? NaN : getDateSafe(props.time).getTime();
const invalid = Number.isNaN(_time);
const absolute = !invalid ? dateTimeFormat.format(_time) : i18n.ts._ago.invalid;
+// eslint-disable-next-line vue/no-setup-props-destructure
let now = $ref((props.origin ?? new Date()).getTime());
const ago = $computed(() => (now - _time) / 1000/*ms*/);
diff --git a/packages/frontend/src/pages/admin/roles.vue b/packages/frontend/src/pages/admin/roles.vue
index d3f3773564..91cd86485f 100644
--- a/packages/frontend/src/pages/admin/roles.vue
+++ b/packages/frontend/src/pages/admin/roles.vue
@@ -198,13 +198,13 @@ SPDX-License-Identifier: AGPL-3.0-only
{{ i18n.ts._role.new }}
- Manual roles
+ {{ i18n.ts._role.manualRoles }}
- Conditional roles
+ {{ i18n.ts._role.conditionalRoles }}
diff --git a/packages/frontend/src/pages/settings/general.vue b/packages/frontend/src/pages/settings/general.vue
index 06d3789829..313b5efc46 100644
--- a/packages/frontend/src/pages/settings/general.vue
+++ b/packages/frontend/src/pages/settings/general.vue
@@ -56,6 +56,7 @@ SPDX-License-Identifier: AGPL-3.0-only
+ {{ i18n.ts.limitWidthOfReaction }}
@@ -226,6 +227,7 @@ const serverDisconnectedBehavior = computed(defaultStore.makeGetterSetter('serve
const showNoteActionsOnlyHover = computed(defaultStore.makeGetterSetter('showNoteActionsOnlyHover'));
const showClipButtonInNoteFooter = computed(defaultStore.makeGetterSetter('showClipButtonInNoteFooter'));
const reactionsDisplaySize = computed(defaultStore.makeGetterSetter('reactionsDisplaySize'));
+const limitWidthOfReaction = computed(defaultStore.makeGetterSetter('limitWidthOfReaction'));
const collapseRenotes = computed(defaultStore.makeGetterSetter('collapseRenotes'));
const reduceAnimation = computed(defaultStore.makeGetterSetter('animation', v => !v, v => !v));
const useBlurEffectForModal = computed(defaultStore.makeGetterSetter('useBlurEffectForModal'));
@@ -290,6 +292,7 @@ watch([
overridedDeviceKind,
mediaListWithOneImageAppearance,
reactionsDisplaySize,
+ limitWidthOfReaction,
highlightSensitiveMedia,
keepScreenOn,
disableStreamingTimeline,
diff --git a/packages/frontend/src/pages/settings/mute-block.vue b/packages/frontend/src/pages/settings/mute-block.vue
index c6cbd424ec..4883ca0df4 100644
--- a/packages/frontend/src/pages/settings/mute-block.vue
+++ b/packages/frontend/src/pages/settings/mute-block.vue
@@ -9,7 +9,14 @@ SPDX-License-Identifier: AGPL-3.0-only
{{ i18n.ts.wordMute }}
-
+
+
+
+
+
+ {{ i18n.ts.hardWordMute }}
+
+
@@ -129,6 +136,7 @@ import { definePageMetadata } from '@/scripts/page-metadata.js';
import MkUserCardMini from '@/components/MkUserCardMini.vue';
import * as os from '@/os.js';
import { infoImageUrl } from '@/instance.js';
+import { $i } from '@/account.js';
import MkFolder from '@/components/MkFolder.vue';
const renoteMutingPagination = {
@@ -207,6 +215,14 @@ async function toggleBlockItem(item) {
}
}
+async function saveMutedWords(mutedWords: (string | string[])[]) {
+ await os.api('i/update', { mutedWords });
+}
+
+async function saveHardMutedWords(hardMutedWords: (string | string[])[]) {
+ await os.api('i/update', { hardMutedWords });
+}
+
const headerActions = $computed(() => []);
const headerTabs = $computed(() => []);
diff --git a/packages/frontend/src/pages/settings/mute-block.word-mute.vue b/packages/frontend/src/pages/settings/mute-block.word-mute.vue
index 25a836ea55..7328967c51 100644
--- a/packages/frontend/src/pages/settings/mute-block.word-mute.vue
+++ b/packages/frontend/src/pages/settings/mute-block.word-mute.vue
@@ -18,16 +18,17 @@ SPDX-License-Identifier: AGPL-3.0-only
+
+
diff --git a/packages/frontend/src/scripts/sound.ts b/packages/frontend/src/scripts/sound.ts
index 2b604bd98a..d28d629227 100644
--- a/packages/frontend/src/scripts/sound.ts
+++ b/packages/frontend/src/scripts/sound.ts
@@ -5,8 +5,9 @@
import { defaultStore } from '@/store.js';
-const ctx = new AudioContext();
+let ctx: AudioContext;
const cache = new Map();
+let canPlay = true;
export const soundsTypes = [
null,
@@ -38,6 +39,8 @@ export const soundsTypes = [
'syuilo/waon',
'syuilo/popo',
'syuilo/triple',
+ 'syuilo/bubble1',
+ 'syuilo/bubble2',
'syuilo/poi1',
'syuilo/poi2',
'syuilo/pirori',
@@ -62,6 +65,9 @@ export const soundsTypes = [
] as const;
export async function loadAudio(file: string, useCache = true) {
+ if (ctx == null) {
+ ctx = new AudioContext();
+ }
if (useCache && cache.has(file)) {
return cache.get(file)!;
}
@@ -77,11 +83,18 @@ export async function loadAudio(file: string, useCache = true) {
return audioBuffer;
}
-export function play(type: 'noteMy' | 'note' | 'antenna' | 'channel' | 'notification') {
+export function play(type: 'noteMy' | 'note' | 'antenna' | 'channel' | 'notification' | 'reaction') {
const sound = defaultStore.state[`sound_${type}`];
if (_DEV_) console.log('play', type, sound);
- if (sound.type == null) return;
- playFile(sound.type, sound.volume);
+ if (sound.type == null || !canPlay) return;
+
+ canPlay = false;
+ playFile(sound.type, sound.volume).then(() => {
+ // ごく短時間に音が重複しないように
+ setTimeout(() => {
+ canPlay = true;
+ }, 25);
+ });
}
export async function playFile(file: string, volume: number) {
diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts
index 6d95ddba35..f2ed4e7c0b 100644
--- a/packages/frontend/src/store.ts
+++ b/packages/frontend/src/store.ts
@@ -330,6 +330,10 @@ export const defaultStore = markRaw(new Storage('base', {
where: 'device',
default: 'medium' as 'small' | 'medium' | 'large',
},
+ limitWidthOfReaction: {
+ where: 'device',
+ default: true,
+ },
forceShowAds: {
where: 'device',
default: false,
@@ -407,6 +411,10 @@ export const defaultStore = markRaw(new Storage('base', {
where: 'device',
default: { type: 'syuilo/square-pico', volume: 1 },
},
+ sound_reaction: {
+ where: 'device',
+ default: { type: 'syuilo/bubble2', volume: 1 },
+ },
}));
// TODO: 他のタブと永続化されたstateを同期
diff --git a/packages/frontend/src/widgets/WidgetJobQueue.vue b/packages/frontend/src/widgets/WidgetJobQueue.vue
index fa82997570..8c990e8e49 100644
--- a/packages/frontend/src/widgets/WidgetJobQueue.vue
+++ b/packages/frontend/src/widgets/WidgetJobQueue.vue
@@ -58,6 +58,7 @@ import { useStream } from '@/stream.js';
import number from '@/filters/number.js';
import * as sound from '@/scripts/sound.js';
import { deepClone } from '@/scripts/clone.js';
+import { defaultStore } from '@/store.js';
const name = 'jobQueue';
@@ -102,7 +103,9 @@ const prev = reactive({} as typeof current);
let jammedAudioBuffer: AudioBuffer | null = $ref(null);
let jammedSoundNodePlaying: boolean = $ref(false);
-sound.loadAudio('syuilo/queue-jammed').then(buf => jammedAudioBuffer = buf);
+if (defaultStore.state.sound_masterVolume) {
+ sound.loadAudio('syuilo/queue-jammed').then(buf => jammedAudioBuffer = buf);
+}
for (const domain of ['inbox', 'deliver']) {
prev[domain] = deepClone(current[domain]);
diff --git a/packages/frontend/test/init.ts b/packages/frontend/test/init.ts
index 986fa99c17..ab5e84b53c 100644
--- a/packages/frontend/test/init.ts
+++ b/packages/frontend/test/init.ts
@@ -25,3 +25,21 @@ vi.mock('@/store.js', () => {
},
};
});
+
+// Add mocks for Web Audio API
+const AudioNodeMock = vi.fn(() => ({
+ connect: vi.fn(() => ({ connect: vi.fn() })),
+ start: vi.fn(),
+}));
+
+const GainNodeMock = vi.fn(() => ({
+ gain: vi.fn(),
+}));
+
+const AudioContextMock = vi.fn(() => ({
+ createBufferSource: vi.fn(() => new AudioNodeMock()),
+ createGain: vi.fn(() => new GainNodeMock()),
+ decodeAudioData: vi.fn(),
+}));
+
+vi.stubGlobal('AudioContext', AudioContextMock);
diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md
index 63c3cb71a5..dc93c4be3b 100644
--- a/packages/misskey-js/etc/misskey-js.api.md
+++ b/packages/misskey-js/etc/misskey-js.api.md
@@ -1565,7 +1565,8 @@ export type Endpoints = {
injectFeaturedNote?: boolean;
receiveAnnouncementEmail?: boolean;
alwaysMarkNsfw?: boolean;
- mutedWords?: string[][];
+ mutedWords?: (string[] | string)[];
+ hardMutedWords?: (string[] | string)[];
notificationRecieveConfig?: any;
emailNotificationTypes?: string[];
alsoKnownAs?: string[];
@@ -2516,7 +2517,8 @@ type MeDetailed = UserDetailed & {
integrations: Record;
isDeleted: boolean;
isExplorable: boolean;
- mutedWords: string[][];
+ mutedWords: (string[] | string)[];
+ hardMutedWords: (string[] | string)[];
notificationRecieveConfig: {
[notificationType in typeof notificationTypes_2[number]]?: {
type: 'all';
@@ -3053,9 +3055,9 @@ type UserSorting = '+follower' | '-follower' | '+createdAt' | '-createdAt' | '+u
//
// src/api.types.ts:16:32 - (ae-forgotten-export) The symbol "TODO" needs to be exported by the entry point index.d.ts
// src/api.types.ts:20:25 - (ae-forgotten-export) The symbol "NoParams" needs to be exported by the entry point index.d.ts
-// src/api.types.ts:634:18 - (ae-forgotten-export) The symbol "ShowUserReq" needs to be exported by the entry point index.d.ts
-// src/entities.ts:116:2 - (ae-forgotten-export) The symbol "notificationTypes_2" needs to be exported by the entry point index.d.ts
-// src/entities.ts:627:2 - (ae-forgotten-export) The symbol "ModerationLogPayloads" needs to be exported by the entry point index.d.ts
+// src/api.types.ts:635:18 - (ae-forgotten-export) The symbol "ShowUserReq" needs to be exported by the entry point index.d.ts
+// src/entities.ts:117:2 - (ae-forgotten-export) The symbol "notificationTypes_2" needs to be exported by the entry point index.d.ts
+// src/entities.ts:628:2 - (ae-forgotten-export) The symbol "ModerationLogPayloads" needs to be exported by the entry point index.d.ts
// src/streaming.types.ts:33:4 - (ae-forgotten-export) The symbol "FIXME" needs to be exported by the entry point index.d.ts
// (No @packageDocumentation comment for this package)