{{ i18n.ts.moderator }}:
diff --git a/packages/frontend/src/components/MkAutocomplete.vue b/packages/frontend/src/components/MkAutocomplete.vue
index a0f4961116..c1fcbd7ac1 100644
--- a/packages/frontend/src/components/MkAutocomplete.vue
+++ b/packages/frontend/src/components/MkAutocomplete.vue
@@ -265,7 +265,7 @@ function emojiAutoComplete(query: string | null, emojiDb: EmojiDef[], max = 30):
// 前方一致(エイリアスなし)
emojiDb.some(x => {
if (x.name.startsWith(query) && !x.aliasOf) {
- matched.set(x.name, { emoji: x, score: query.length });
+ matched.set(x.name, { emoji: x, score: query.length + 1 });
}
return matched.size === max;
});
@@ -273,8 +273,8 @@ function emojiAutoComplete(query: string | null, emojiDb: EmojiDef[], max = 30):
// 前方一致(エイリアス込み)
if (matched.size < max) {
emojiDb.some(x => {
- if (x.name.startsWith(query)) {
- matched.set(x.name, { emoji: x, score: query.length });
+ if (x.name.startsWith(query) && !matched.has(x.aliasOf ?? x.name)) {
+ matched.set(x.aliasOf ?? x.name, { emoji: x, score: query.length });
}
return matched.size === max;
});
@@ -283,36 +283,32 @@ function emojiAutoComplete(query: string | null, emojiDb: EmojiDef[], max = 30):
// 部分一致(エイリアス込み)
if (matched.size < max) {
emojiDb.some(x => {
- if (x.name.includes(query)) {
- matched.set(x.name, { emoji: x, score: query.length });
+ if (x.name.includes(query) && !matched.has(x.aliasOf ?? x.name)) {
+ matched.set(x.aliasOf ?? x.name, { emoji: x, score: query.length - 1 });
}
return matched.size === max;
});
}
- // 簡易あいまい検索
- if (matched.size < max) {
+ // 簡易あいまい検索(3文字以上)
+ if (matched.size < max && query.length > 3) {
const queryChars = [...query];
const hitEmojis = new Map
();
for (const x of emojiDb) {
- // クエリ文字列の1文字単位で絵文字名にヒットするかを見る
- // ただし、過剰に検出されるのを防ぐためクエリ文字列に登場する順番で絵文字名を走査する
+ // 文字列の位置を進めながら、クエリの文字を順番に探す
- let queryCharHitPos = 0;
- let queryCharHitCount = 0;
- for (let idx = 0; idx < queryChars.length; idx++) {
- queryCharHitPos = x.name.indexOf(queryChars[idx], queryCharHitPos);
- if (queryCharHitPos <= -1) {
- break;
- }
-
- queryCharHitCount++;
+ let pos = 0;
+ let hit = 0;
+ for (const c of queryChars) {
+ pos = x.name.indexOf(c, pos);
+ if (pos <= -1) break;
+ hit++;
}
- // ヒット数が少なすぎると検索結果が汚れるので調節する
- if (queryCharHitCount > 2) {
- hitEmojis.set(x.name, { emoji: x, score: queryCharHitCount });
+ // 半分以上の文字が含まれていればヒットとする
+ if (hit > Math.ceil(queryChars.length / 2) && hit - 2 > (matched.get(x.aliasOf ?? x.name)?.score ?? 0)) {
+ hitEmojis.set(x.aliasOf ?? x.name, { emoji: x, score: hit - 2 });
}
}
diff --git a/packages/frontend/src/components/MkMediaVideo.vue b/packages/frontend/src/components/MkMediaVideo.vue
index 43c64b4c85..b6e8f1ff22 100644
--- a/packages/frontend/src/components/MkMediaVideo.vue
+++ b/packages/frontend/src/components/MkMediaVideo.vue
@@ -37,6 +37,7 @@ import * as Misskey from 'misskey-js';
import bytes from '@/filters/bytes.js';
import { defaultStore } from '@/store.js';
import { i18n } from '@/i18n.js';
+import hasAudio from '@/scripts/media-has-audio.js';
const props = defineProps<{
video: Misskey.entities.DriveFile;
@@ -49,6 +50,12 @@ const videoEl = shallowRef();
watch(videoEl, () => {
if (videoEl.value) {
videoEl.value.volume = 0.3;
+ hasAudio(videoEl.value).then(had => {
+ if (!had) {
+ videoEl.value.loop = videoEl.value.muted = true;
+ videoEl.value.play();
+ }
+ });
}
});
diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue
index 6349df2e30..d047495dc9 100644
--- a/packages/frontend/src/components/MkNote.vue
+++ b/packages/frontend/src/components/MkNote.vue
@@ -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';
@@ -336,6 +337,8 @@ function react(viaKeyboard = false): void {
pleaseLogin();
showMovedDialog();
if (appearNote.reactionAcceptance === 'likeOnly') {
+ sound.play('reaction');
+
if (props.mock) {
return;
}
@@ -354,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/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/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/sounds.vue b/packages/frontend/src/pages/settings/sounds.vue
index cd1707a594..05e4b0d14c 100644
--- a/packages/frontend/src/pages/settings/sounds.vue
+++ b/packages/frontend/src/pages/settings/sounds.vue
@@ -5,6 +5,12 @@ SPDX-License-Identifier: AGPL-3.0-only
+
+ {{ i18n.ts.notUseSound }}
+
+
+ {{ i18n.ts.useSoundOnlyWhenActive }}
+
{{ i18n.ts.masterVolume }}
@@ -35,10 +41,13 @@ import MkFolder from '@/components/MkFolder.vue';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { defaultStore } from '@/store.js';
+import MkSwitch from '@/components/MkSwitch.vue';
+const notUseSound = computed(defaultStore.makeGetterSetter('sound_notUseSound'));
+const useSoundOnlyWhenActive = computed(defaultStore.makeGetterSetter('sound_useSoundOnlyWhenActive'));
const masterVolume = computed(defaultStore.makeGetterSetter('sound_masterVolume'));
-const soundsKeys = ['note', 'noteMy', 'notification', 'antenna', 'channel'] as const;
+const soundsKeys = ['note', 'noteMy', 'notification', 'antenna', 'channel', 'reaction'] as const;
const sounds = ref>>({
note: defaultStore.reactiveState.sound_note,
@@ -46,6 +55,7 @@ const sounds = ref>>({
notification: defaultStore.reactiveState.sound_notification,
antenna: defaultStore.reactiveState.sound_antenna,
channel: defaultStore.reactiveState.sound_channel,
+ reaction: defaultStore.reactiveState.sound_reaction,
});
async function updated(type: keyof typeof sounds.value, sound) {
diff --git a/packages/frontend/src/pages/user/index.vue b/packages/frontend/src/pages/user/index.vue
index 7550d5bcb2..50cc9a3311 100644
--- a/packages/frontend/src/pages/user/index.vue
+++ b/packages/frontend/src/pages/user/index.vue
@@ -18,6 +18,7 @@ SPDX-License-Identifier: AGPL-3.0-only
+
@@ -44,6 +45,7 @@ const XLists = defineAsyncComponent(() => import('./lists.vue'));
const XPages = defineAsyncComponent(() => import('./pages.vue'));
const XFlashs = defineAsyncComponent(() => import('./flashs.vue'));
const XGallery = defineAsyncComponent(() => import('./gallery.vue'));
+const XRaw = defineAsyncComponent(() => import('./raw.vue'));
const props = withDefaults(defineProps<{
acct: string;
@@ -112,6 +114,10 @@ const headerTabs = $computed(() => user ? [{
key: 'gallery',
title: i18n.ts.gallery,
icon: 'ti ti-icons',
+}, {
+ key: 'raw',
+ title: 'Raw',
+ icon: 'ti ti-code',
}] : []);
definePageMetadata(computed(() => user ? {
diff --git a/packages/frontend/src/pages/user/raw.vue b/packages/frontend/src/pages/user/raw.vue
new file mode 100644
index 0000000000..0c0bfc29ca
--- /dev/null
+++ b/packages/frontend/src/pages/user/raw.vue
@@ -0,0 +1,130 @@
+
+
+
+
+
+
+
+
+
+ @{{ acct(user) }}
+
+ Suspended
+ Silenced
+ Moderator
+
+
+
+
+
+
+ ID
+ {{ user.id }}
+
+
+ {{ i18n.ts.createdAt }}
+
+
+
+
+
+ Raw
+
+
+
+
+
+
+
+
+
diff --git a/packages/frontend/src/scripts/media-has-audio.ts b/packages/frontend/src/scripts/media-has-audio.ts
new file mode 100644
index 0000000000..3421a38a76
--- /dev/null
+++ b/packages/frontend/src/scripts/media-has-audio.ts
@@ -0,0 +1,9 @@
+export default async function hasAudio(media: HTMLMediaElement) {
+ const cloned = media.cloneNode() as HTMLMediaElement;
+ cloned.muted = (cloned as typeof cloned & Partial).playsInline = true;
+ cloned.play();
+ await new Promise((resolve) => cloned.addEventListener('playing', resolve));
+ const result = !!(cloned as any).audioTracks?.length || (cloned as any).mozHasAudio || !!(cloned as any).webkitAudioDecodedByteCount;
+ cloned.remove();
+ return result;
+}
diff --git a/packages/frontend/src/scripts/sound.ts b/packages/frontend/src/scripts/sound.ts
index 2b604bd98a..a3cddba1f4 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) {
@@ -91,7 +104,7 @@ export async function playFile(file: string, volume: number) {
export function createSourceNode(buffer: AudioBuffer, volume: number) : AudioBufferSourceNode | null {
const masterVolume = defaultStore.state.sound_masterVolume;
- if (masterVolume === 0 || volume === 0) {
+ if (isMute() || masterVolume === 0 || volume === 0) {
return null;
}
@@ -104,3 +117,18 @@ export function createSourceNode(buffer: AudioBuffer, volume: number) : AudioBuf
return soundSource;
}
+
+export function isMute(): boolean {
+ if (defaultStore.state.sound_notUseSound) {
+ // サウンドを出力しない
+ return true;
+ }
+
+ // noinspection RedundantIfStatementJS
+ if (defaultStore.state.sound_useSoundOnlyWhenActive && document.visibilityState === 'hidden') {
+ // ブラウザがアクティブな時のみサウンドを出力する
+ return true;
+ }
+
+ return false;
+}
diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts
index 6d95ddba35..40fb1dde76 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,
@@ -387,6 +391,14 @@ export const defaultStore = markRaw(new Storage('base', {
where: 'device',
default: 0.3,
},
+ sound_notUseSound: {
+ where: 'device',
+ default: false,
+ },
+ sound_useSoundOnlyWhenActive: {
+ where: 'device',
+ default: false,
+ },
sound_note: {
where: 'device',
default: { type: 'syuilo/n-aec', volume: 1 },
@@ -407,6 +419,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);