summaryrefslogtreecommitdiff
path: root/packages/frontend/src/scripts
diff options
context:
space:
mode:
authorMarie <Marie@kaifa.ch>2023-12-23 02:09:23 +0100
committerMarie <Marie@kaifa.ch>2023-12-23 02:09:23 +0100
commit5db583a3eb61d50de14d875ebf7ecef20490e313 (patch)
tree783dd43d2ac660c32e745a4485d499e9ddc43324 /packages/frontend/src/scripts
parentadd: Custom MOTDs (diff)
parentUpdate CHANGELOG.md (diff)
downloadsharkey-5db583a3eb61d50de14d875ebf7ecef20490e313.tar.gz
sharkey-5db583a3eb61d50de14d875ebf7ecef20490e313.tar.bz2
sharkey-5db583a3eb61d50de14d875ebf7ecef20490e313.zip
merge: upstream
Diffstat (limited to 'packages/frontend/src/scripts')
-rw-r--r--packages/frontend/src/scripts/aiscript/api.ts2
-rw-r--r--packages/frontend/src/scripts/aiscript/ui.ts8
-rw-r--r--packages/frontend/src/scripts/api.ts55
-rw-r--r--packages/frontend/src/scripts/autocomplete.ts14
-rw-r--r--packages/frontend/src/scripts/clear-cache.ts15
-rw-r--r--packages/frontend/src/scripts/emoji-picker.ts60
-rw-r--r--packages/frontend/src/scripts/emojilist.ts6
-rw-r--r--packages/frontend/src/scripts/get-drive-file-menu.ts8
-rw-r--r--packages/frontend/src/scripts/get-note-menu.ts34
-rw-r--r--packages/frontend/src/scripts/get-user-menu.ts18
-rw-r--r--packages/frontend/src/scripts/isFfVisibleForMe.ts14
-rw-r--r--packages/frontend/src/scripts/media-has-audio.ts9
-rw-r--r--packages/frontend/src/scripts/navigator.ts8
-rw-r--r--packages/frontend/src/scripts/page-metadata.ts1
-rw-r--r--packages/frontend/src/scripts/post-message.ts25
-rw-r--r--packages/frontend/src/scripts/reaction-picker.ts9
-rw-r--r--packages/frontend/src/scripts/snowfall-effect.ts476
-rw-r--r--packages/frontend/src/scripts/sound.ts168
-rw-r--r--packages/frontend/src/scripts/theme.ts2
19 files changed, 824 insertions, 108 deletions
diff --git a/packages/frontend/src/scripts/aiscript/api.ts b/packages/frontend/src/scripts/aiscript/api.ts
index fb7ab924b7..038ae23109 100644
--- a/packages/frontend/src/scripts/aiscript/api.ts
+++ b/packages/frontend/src/scripts/aiscript/api.ts
@@ -50,6 +50,7 @@ export function createAiScriptEnv(opts) {
return values.ERROR('request_failed', utils.jsToVal(err));
});
}),
+ /* セキュリティ上の問題があるため無効化
'Mk:apiExternal': values.FN_NATIVE(async ([host, ep, param, token]) => {
utils.assertString(host);
utils.assertString(ep);
@@ -60,6 +61,7 @@ export function createAiScriptEnv(opts) {
return values.ERROR('request_failed', utils.jsToVal(err));
});
}),
+ */
'Mk:save': values.FN_NATIVE(([key, value]) => {
utils.assertString(key);
miLocalStorage.setItem(`aiscript:${opts.storageKey}:${key.value}`, JSON.stringify(utils.valToJs(value)));
diff --git a/packages/frontend/src/scripts/aiscript/ui.ts b/packages/frontend/src/scripts/aiscript/ui.ts
index d326b956e8..75b9248432 100644
--- a/packages/frontend/src/scripts/aiscript/ui.ts
+++ b/packages/frontend/src/scripts/aiscript/ui.ts
@@ -121,6 +121,7 @@ export type AsUiPostFormButton = AsUiComponentBase & {
rounded?: boolean;
form?: {
text: string;
+ cw?: string;
};
};
@@ -128,6 +129,7 @@ export type AsUiPostForm = AsUiComponentBase & {
type: 'postForm';
form?: {
text: string;
+ cw?: string;
};
};
@@ -454,8 +456,11 @@ function getPostFormButtonOptions(def: values.Value | undefined, call: (fn: valu
const getForm = () => {
const text = form!.value.get('text');
utils.assertString(text);
+ const cw = form!.value.get('cw');
+ if (cw) utils.assertString(cw);
return {
text: text.value,
+ cw: cw?.value,
};
};
@@ -478,8 +483,11 @@ function getPostFormOptions(def: values.Value | undefined, call: (fn: values.VFn
const getForm = () => {
const text = form!.value.get('text');
utils.assertString(text);
+ const cw = form!.value.get('cw');
+ if (cw) utils.assertString(cw);
return {
text: text.value,
+ cw: cw?.value,
};
};
diff --git a/packages/frontend/src/scripts/api.ts b/packages/frontend/src/scripts/api.ts
index 080977e5e4..8f3a163938 100644
--- a/packages/frontend/src/scripts/api.ts
+++ b/packages/frontend/src/scripts/api.ts
@@ -10,7 +10,12 @@ import { $i } from '@/account.js';
export const pendingApiRequestsCount = ref(0);
// Implements Misskey.api.ApiClient.request
-export function api<E extends keyof Misskey.Endpoints, P extends Misskey.Endpoints[E]['req']>(endpoint: E, data: P = {} as any, token?: string | null | undefined, signal?: AbortSignal): Promise<Misskey.Endpoints[E]['res']> {
+export function api<E extends keyof Misskey.Endpoints, P extends Misskey.Endpoints[E]['req']>(
+ endpoint: E,
+ data: P = {} as any,
+ token?: string | null | undefined,
+ signal?: AbortSignal,
+): Promise<Misskey.api.SwitchCaseResponseType<E, P>> {
if (endpoint.includes('://')) throw new Error('invalid endpoint');
pendingApiRequestsCount.value++;
@@ -51,51 +56,11 @@ export function api<E extends keyof Misskey.Endpoints, P extends Misskey.Endpoin
return promise;
}
-export function apiExternal<E extends keyof Misskey.Endpoints, P extends Misskey.Endpoints[E]['req']>(hostUrl: string, endpoint: E, data: P = {} as any, token?: string | null | undefined, signal?: AbortSignal): Promise<Misskey.Endpoints[E]['res']> {
- if (!/^https?:\/\//.test(hostUrl)) throw new Error('invalid host name');
- if (endpoint.includes('://')) throw new Error('invalid endpoint');
- pendingApiRequestsCount.value++;
-
- const onFinally = () => {
- pendingApiRequestsCount.value--;
- };
-
- const promise = new Promise<Misskey.Endpoints[E]['res'] | void>((resolve, reject) => {
- // Append a credential
- (data as any).i = token;
-
- const fullUrl = (hostUrl.slice(-1) === '/' ? hostUrl.slice(0, -1) : hostUrl)
- + '/api/' + (endpoint.slice(0, 1) === '/' ? endpoint.slice(1) : endpoint);
- // Send request
- window.fetch(fullUrl, {
- method: 'POST',
- body: JSON.stringify(data),
- credentials: 'omit',
- cache: 'no-cache',
- headers: {
- 'Content-Type': 'application/json',
- },
- signal,
- }).then(async (res) => {
- const body = res.status === 204 ? null : await res.json();
-
- if (res.status === 200) {
- resolve(body);
- } else if (res.status === 204) {
- resolve();
- } else {
- reject(body.error);
- }
- }).catch(reject);
- });
-
- promise.then(onFinally, onFinally);
-
- return promise;
-}
-
// Implements Misskey.api.ApiClient.request
-export function apiGet <E extends keyof Misskey.Endpoints, P extends Misskey.Endpoints[E]['req']>(endpoint: E, data: P = {} as any): Promise<Misskey.Endpoints[E]['res']> {
+export function apiGet<E extends keyof Misskey.Endpoints, P extends Misskey.Endpoints[E]['req']>(
+ endpoint: E,
+ data: P = {} as any,
+): Promise<Misskey.api.SwitchCaseResponseType<E, P>> {
pendingApiRequestsCount.value++;
const onFinally = () => {
diff --git a/packages/frontend/src/scripts/autocomplete.ts b/packages/frontend/src/scripts/autocomplete.ts
index 0d6756d498..6f3d3ba8e1 100644
--- a/packages/frontend/src/scripts/autocomplete.ts
+++ b/packages/frontend/src/scripts/autocomplete.ts
@@ -8,6 +8,8 @@ import getCaretCoordinates from 'textarea-caret';
import { toASCII } from 'punycode/';
import { popup } from '@/os.js';
+export type SuggestionType = 'user' | 'hashtag' | 'emoji' | 'mfmTag';
+
export class Autocomplete {
private suggestion: {
x: Ref<number>;
@@ -19,6 +21,7 @@ export class Autocomplete {
private currentType: string;
private textRef: Ref<string>;
private opening: boolean;
+ private onlyType: SuggestionType[];
private get text(): string {
// Use raw .value to get the latest value
@@ -35,7 +38,7 @@ export class Autocomplete {
/**
* 対象のテキストエリアを与えてインスタンスを初期化します。
*/
- constructor(textarea: HTMLInputElement | HTMLTextAreaElement, textRef: Ref<string>) {
+ constructor(textarea: HTMLInputElement | HTMLTextAreaElement, textRef: Ref<string>, onlyType?: SuggestionType[]) {
//#region BIND
this.onInput = this.onInput.bind(this);
this.complete = this.complete.bind(this);
@@ -46,6 +49,7 @@ export class Autocomplete {
this.textarea = textarea;
this.textRef = textRef;
this.opening = false;
+ this.onlyType = onlyType ?? ['user', 'hashtag', 'emoji', 'mfmTag'];
this.attach();
}
@@ -95,7 +99,7 @@ export class Autocomplete {
let opened = false;
- if (isMention) {
+ if (isMention && this.onlyType.includes('user')) {
const username = text.substring(mentionIndex + 1);
if (username !== '' && username.match(/^[a-zA-Z0-9_.]+$/)) {
this.open('user', username);
@@ -106,7 +110,7 @@ export class Autocomplete {
}
}
- if (isHashtag && !opened) {
+ if (isHashtag && !opened && this.onlyType.includes('hashtag')) {
const hashtag = text.substring(hashtagIndex + 1);
if (!hashtag.includes(' ')) {
this.open('hashtag', hashtag);
@@ -114,7 +118,7 @@ export class Autocomplete {
}
}
- if (isEmoji && !opened) {
+ if (isEmoji && !opened && this.onlyType.includes('emoji')) {
const emoji = text.substring(emojiIndex + 1);
if (!emoji.includes(' ')) {
this.open('emoji', emoji);
@@ -122,7 +126,7 @@ export class Autocomplete {
}
}
- if (isMfmTag && !opened) {
+ if (isMfmTag && !opened && this.onlyType.includes('mfmTag')) {
const mfmTag = text.substring(mfmTagIndex + 1);
if (!mfmTag.includes(' ')) {
this.open('mfmTag', mfmTag.replace('[', ''));
diff --git a/packages/frontend/src/scripts/clear-cache.ts b/packages/frontend/src/scripts/clear-cache.ts
new file mode 100644
index 0000000000..f2db87c4fb
--- /dev/null
+++ b/packages/frontend/src/scripts/clear-cache.ts
@@ -0,0 +1,15 @@
+import { unisonReload } from '@/scripts/unison-reload.js';
+import * as os from '@/os.js';
+import { miLocalStorage } from '@/local-storage.js';
+import { fetchCustomEmojis } from '@/custom-emojis.js';
+
+export async function clearCache() {
+ os.waiting();
+ miLocalStorage.removeItem('locale');
+ miLocalStorage.removeItem('localeVersion');
+ miLocalStorage.removeItem('theme');
+ miLocalStorage.removeItem('emojis');
+ miLocalStorage.removeItem('lastEmojisFetchedAt');
+ await fetchCustomEmojis(true);
+ unisonReload();
+}
diff --git a/packages/frontend/src/scripts/emoji-picker.ts b/packages/frontend/src/scripts/emoji-picker.ts
new file mode 100644
index 0000000000..f87c3f6fb2
--- /dev/null
+++ b/packages/frontend/src/scripts/emoji-picker.ts
@@ -0,0 +1,60 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and other misskey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { defineAsyncComponent, Ref, ref } from 'vue';
+import { popup } from '@/os.js';
+import { defaultStore } from '@/store.js';
+
+/**
+ * 絵文字ピッカーを表示する。
+ * 類似の機能として{@link ReactionPicker}が存在しているが、この機能とは動きが異なる。
+ * 投稿フォームなどで絵文字を選択する時など、絵文字ピックアップ後でもダイアログが消えずに残り、
+ * 一度表示したダイアログを連続で使用できることが望ましいシーンでの利用が想定される。
+ */
+class EmojiPicker {
+ private src: Ref<HTMLElement | null> = ref(null);
+ private manualShowing = ref(false);
+ private onChosen?: (emoji: string) => void;
+ private onClosed?: () => void;
+
+ constructor() {
+ // nop
+ }
+
+ public async init() {
+ const emojisRef = defaultStore.reactiveState.pinnedEmojis;
+ await popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerDialog.vue')), {
+ src: this.src,
+ pinnedEmojis: emojisRef,
+ asReactionPicker: false,
+ manualShowing: this.manualShowing,
+ choseAndClose: false,
+ }, {
+ done: emoji => {
+ if (this.onChosen) this.onChosen(emoji);
+ },
+ close: () => {
+ this.manualShowing.value = false;
+ },
+ closed: () => {
+ this.src.value = null;
+ if (this.onClosed) this.onClosed();
+ },
+ });
+ }
+
+ public show(
+ src: HTMLElement,
+ onChosen?: EmojiPicker['onChosen'],
+ onClosed?: EmojiPicker['onClosed'],
+ ) {
+ this.src.value = src;
+ this.manualShowing.value = true;
+ this.onChosen = onChosen;
+ this.onClosed = onClosed;
+ }
+}
+
+export const emojiPicker = new EmojiPicker();
diff --git a/packages/frontend/src/scripts/emojilist.ts b/packages/frontend/src/scripts/emojilist.ts
index 4159da84c8..8885bf4b7f 100644
--- a/packages/frontend/src/scripts/emojilist.ts
+++ b/packages/frontend/src/scripts/emojilist.ts
@@ -43,3 +43,9 @@ export function getEmojiName(char: string): string | null {
return emojilist[idx].name;
}
}
+
+export interface CustomEmojiFolderTree {
+ value: string;
+ category: string;
+ children: CustomEmojiFolderTree[];
+}
diff --git a/packages/frontend/src/scripts/get-drive-file-menu.ts b/packages/frontend/src/scripts/get-drive-file-menu.ts
index 87f3886847..d6a5b00c0b 100644
--- a/packages/frontend/src/scripts/get-drive-file-menu.ts
+++ b/packages/frontend/src/scripts/get-drive-file-menu.ts
@@ -82,7 +82,7 @@ export function getDriveFileMenu(file: Misskey.entities.DriveFile, folder?: Miss
to: `/my/drive/file/${file.id}`,
text: i18n.ts._fileViewer.title,
icon: 'ph-file-text ph-bold ph-lg',
- }, null, {
+ }, { type: 'divider' }, {
text: i18n.ts.rename,
icon: 'ph-textbox ph-bold ph-lg',
action: () => rename(file),
@@ -101,7 +101,7 @@ export function getDriveFileMenu(file: Misskey.entities.DriveFile, folder?: Miss
aspectRatio: NaN,
uploadFolder: folder ? folder.id : folder,
}),
- }] : [], null, {
+ }] : [], { type: 'divider' }, {
text: i18n.ts.createNoteFromTheFile,
icon: 'ph-pencil ph-bold ph-lg',
action: () => os.post({
@@ -118,7 +118,7 @@ export function getDriveFileMenu(file: Misskey.entities.DriveFile, folder?: Miss
text: i18n.ts.download,
icon: 'ph-download ph-bold ph-lg',
download: file.name,
- }, null, {
+ }, { type: 'divider' }, {
text: i18n.ts.delete,
icon: 'ph-trash ph-bold ph-lg',
danger: true,
@@ -126,7 +126,7 @@ export function getDriveFileMenu(file: Misskey.entities.DriveFile, folder?: Miss
}];
if (defaultStore.state.devMode) {
- menu = menu.concat([null, {
+ menu = menu.concat([{ type: 'divider' }, {
icon: 'ph-identification-card ph-bold ph-lg',
text: i18n.ts.copyFileId,
action: () => {
diff --git a/packages/frontend/src/scripts/get-note-menu.ts b/packages/frontend/src/scripts/get-note-menu.ts
index e64c08c0ab..e23986ea4a 100644
--- a/packages/frontend/src/scripts/get-note-menu.ts
+++ b/packages/frontend/src/scripts/get-note-menu.ts
@@ -18,6 +18,7 @@ import { getUserMenu } from '@/scripts/get-user-menu.js';
import { clipsCache } from '@/cache.js';
import { MenuItem } from '@/types/menu.js';
import MkRippleEffect from '@/components/MkRippleEffect.vue';
+import { isSupportShare } from '@/scripts/navigator.js';
export async function getNoteClipMenu(props: {
note: Misskey.entities.Note;
@@ -60,7 +61,7 @@ export async function getNoteClipMenu(props: {
},
);
},
- })), null, {
+ })), { type: 'divider' }, {
icon: 'ph-plus ph-bold ph-lg',
text: i18n.ts.createNew,
action: async () => {
@@ -93,7 +94,7 @@ export async function getNoteClipMenu(props: {
}];
}
-export function getAbuseNoteMenu(note: misskey.entities.Note, text: string): MenuItem {
+export function getAbuseNoteMenu(note: Misskey.entities.Note, text: string): MenuItem {
return {
icon: 'ph-warning-circle ph-bold ph-lg',
text,
@@ -107,7 +108,7 @@ export function getAbuseNoteMenu(note: misskey.entities.Note, text: string): Men
};
}
-export function getCopyNoteLinkMenu(note: misskey.entities.Note, text: string): MenuItem {
+export function getCopyNoteLinkMenu(note: Misskey.entities.Note, text: string): MenuItem {
return {
icon: 'ph-link ph-bold ph-lg',
text,
@@ -285,7 +286,7 @@ export function getNoteMenu(props: {
text: i18n.ts.unclip,
danger: true,
action: unclip,
- }, null] : []
+ }, { type: 'divider' }] : []
), {
icon: 'ph-info ph-bold ph-lg',
text: i18n.ts.details,
@@ -302,20 +303,20 @@ export function getNoteMenu(props: {
icon: 'ph-arrow-square-out ph-bold ph-lg',
text: i18n.ts.showOnRemote,
action: () => {
- window.open(appearNote.url ?? appearNote.uri, '_blank');
+ window.open(appearNote.url ?? appearNote.uri, '_blank', 'noopener');
},
} : undefined,
- {
+ ...(isSupportShare() ? [{
icon: 'ph-share-network ph-bold ph-lg',
text: i18n.ts.share,
action: share,
- },
+ }] : []),
$i && $i.policies.canUseTranslator && instance.translatorAvailable ? {
icon: 'ph-translate ph-bold ph-lg',
text: i18n.ts.translate,
action: translate,
} : undefined,
- null,
+ { type: 'divider' },
statePromise.then(state => state.isFavorited ? {
icon: 'ph-star-half ph-bold ph-lg',
text: i18n.ts.unfavorite,
@@ -362,7 +363,7 @@ export function getNoteMenu(props: {
},
/*
...($i.isModerator || $i.isAdmin ? [
- null,
+ { type: 'divider' },
{
icon: 'ph-megaphone ph-bold ph-lg',
text: i18n.ts.promote,
@@ -371,13 +372,13 @@ export function getNoteMenu(props: {
: []
),*/
...(appearNote.userId !== $i.id ? [
- null,
+ { type: 'divider' },
appearNote.userId !== $i.id ? getAbuseNoteMenu(appearNote, i18n.ts.reportAbuse) : undefined,
]
: []
),
...(appearNote.userId === $i.id || $i.isModerator || $i.isAdmin ? [
- null,
+ { type: 'divider' },
appearNote.userId === $i.id ? {
icon: 'ph-pencil ph-bold ph-lg',
text: i18n.ts.edit,
@@ -415,14 +416,14 @@ export function getNoteMenu(props: {
icon: 'ph-arrow-square-out ph-bold ph-lg',
text: i18n.ts.showOnRemote,
action: () => {
- window.open(appearNote.url ?? appearNote.uri, '_blank');
+ window.open(appearNote.url ?? appearNote.uri, '_blank', 'noopener');
},
} : undefined]
.filter(x => x !== undefined);
}
if (noteActions.length > 0) {
- menu = menu.concat([null, ...noteActions.map(action => ({
+ menu = menu.concat([{ type: "divider" }, ...noteActions.map(action => ({
icon: 'ph-plug ph-bold ph-lg',
text: action.title,
action: () => {
@@ -432,7 +433,7 @@ export function getNoteMenu(props: {
}
if (defaultStore.state.devMode) {
- menu = menu.concat([null, {
+ menu = menu.concat([{ type: "divider" }, {
icon: 'ph-identification-card ph-bold ph-lg',
text: i18n.ts.copyNoteId,
action: () => {
@@ -518,7 +519,7 @@ export function getRenoteMenu(props: {
}]);
}
- if (!appearNote.channel || appearNote.channel?.allowRenoteToExternal) {
+ if (!appearNote.channel || appearNote.channel.allowRenoteToExternal) {
normalRenoteItems.push(...[{
text: i18n.ts.renote,
icon: 'ti ti-repeat',
@@ -561,10 +562,9 @@ export function getRenoteMenu(props: {
}]);
}
- // nullを挟むことで区切り線を出せる
const renoteItems = [
...normalRenoteItems,
- ...(channelRenoteItems.length > 0 && normalRenoteItems.length > 0) ? [null] : [],
+ ...(channelRenoteItems.length > 0 && normalRenoteItems.length > 0) ? [{ type: 'divider' }] : [],
...channelRenoteItems,
];
diff --git a/packages/frontend/src/scripts/get-user-menu.ts b/packages/frontend/src/scripts/get-user-menu.ts
index 41d0df1b72..67bc781aef 100644
--- a/packages/frontend/src/scripts/get-user-menu.ts
+++ b/packages/frontend/src/scripts/get-user-menu.ts
@@ -119,7 +119,7 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router
userId: user.id,
});
}
-
+
async function invalidateFollow() {
if (!await getConfirmed(i18n.ts.breakFollowConfirm)) return;
@@ -189,7 +189,7 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router
const canonical = user.host === null ? `@${user.username}` : `@${user.username}@${user.host}`;
os.post({ specified: user, initialText: `${canonical} ` });
},
- }, null, {
+ }, { type: 'divider' }, {
icon: 'ph-pencil ph-bold ph-lg',
text: i18n.ts.editMemo,
action: () => {
@@ -313,7 +313,7 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router
}]);
//}
- menu = menu.concat([null, {
+ menu = menu.concat([{ type: 'divider' }, {
icon: user.isMuted ? 'ph-eye ph-bold ph-lg' : 'ph-eye-slash ph-bold ph-lg',
text: user.isMuted ? i18n.ts.unmute : i18n.ts.mute,
action: toggleMute,
@@ -335,7 +335,7 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router
}]);
}
- menu = menu.concat([null, {
+ menu = menu.concat([{ type: 'divider' }, {
icon: 'ph-warning-circle ph-bold ph-lg',
text: i18n.ts.reportAbuse,
action: reportAbuse,
@@ -343,15 +343,15 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router
}
if (user.host !== null) {
- menu = menu.concat([null, {
+ menu = menu.concat([{ type: 'divider' }, {
icon: 'ph-arrows-counter-clockwise ph-bold ph-lg',
text: i18n.ts.updateRemoteUser,
action: userInfoUpdate,
}]);
}
-
+
if (defaultStore.state.devMode) {
- menu = menu.concat([null, {
+ menu = menu.concat([{ type: 'divider' }, {
icon: 'ph-identification-card ph-bold ph-lg',
text: i18n.ts.copyUserId,
action: () => {
@@ -361,7 +361,7 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router
}
if ($i && meId === user.id) {
- menu = menu.concat([null, {
+ menu = menu.concat([{ type: 'divider' }, {
icon: 'ph-pencil ph-bold ph-lg',
text: i18n.ts.editProfile,
action: () => {
@@ -371,7 +371,7 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router
}
if (userActions.length > 0) {
- menu = menu.concat([null, ...userActions.map(action => ({
+ menu = menu.concat([{ type: 'divider' }, ...userActions.map(action => ({
icon: 'ph-plug ph-bold ph-lg',
text: action.title,
action: () => {
diff --git a/packages/frontend/src/scripts/isFfVisibleForMe.ts b/packages/frontend/src/scripts/isFfVisibleForMe.ts
index 0567f3b34a..dc0e90d20a 100644
--- a/packages/frontend/src/scripts/isFfVisibleForMe.ts
+++ b/packages/frontend/src/scripts/isFfVisibleForMe.ts
@@ -6,11 +6,19 @@
import * as Misskey from 'misskey-js';
import { $i } from '@/account.js';
-export function isFfVisibleForMe(user: Misskey.entities.UserDetailed): boolean {
+export function isFollowingVisibleForMe(user: Misskey.entities.UserDetailed): boolean {
if ($i && $i.id === user.id) return true;
- if (user.ffVisibility === 'private') return false;
- if (user.ffVisibility === 'followers' && !user.isFollowing) return false;
+ if (user.followingVisibility === 'private') return false;
+ if (user.followingVisibility === 'followers' && !user.isFollowing) return false;
+
+ return true;
+}
+export function isFollowersVisibleForMe(user: Misskey.entities.UserDetailed): boolean {
+ if ($i && $i.id === user.id) return true;
+
+ if (user.followersVisibility === 'private') return false;
+ if (user.followersVisibility === 'followers' && !user.isFollowing) return false;
return true;
}
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<HTMLVideoElement>).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/navigator.ts b/packages/frontend/src/scripts/navigator.ts
new file mode 100644
index 0000000000..b13186a10e
--- /dev/null
+++ b/packages/frontend/src/scripts/navigator.ts
@@ -0,0 +1,8 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and other misskey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export function isSupportShare(): boolean {
+ return 'share' in navigator;
+}
diff --git a/packages/frontend/src/scripts/page-metadata.ts b/packages/frontend/src/scripts/page-metadata.ts
index 330ba8da83..369e46aae1 100644
--- a/packages/frontend/src/scripts/page-metadata.ts
+++ b/packages/frontend/src/scripts/page-metadata.ts
@@ -15,6 +15,7 @@ export type PageMetadata = {
icon?: string | null;
avatar?: Misskey.entities.User | null;
userName?: Misskey.entities.User | null;
+ needWideArea?: boolean;
};
export function definePageMetadata(metadata: PageMetadata | null | Ref<PageMetadata | null> | ComputedRef<PageMetadata | null>): void {
diff --git a/packages/frontend/src/scripts/post-message.ts b/packages/frontend/src/scripts/post-message.ts
new file mode 100644
index 0000000000..80441caf15
--- /dev/null
+++ b/packages/frontend/src/scripts/post-message.ts
@@ -0,0 +1,25 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and other misskey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export const postMessageEventTypes = [
+ 'misskey:shareForm:shareCompleted',
+] as const;
+
+export type PostMessageEventType = typeof postMessageEventTypes[number];
+
+export type MiPostMessageEvent = {
+ type: PostMessageEventType;
+ payload?: any;
+};
+
+/**
+ * 親フレームにイベントを送信
+ */
+export function postMessageToParentWindow(type: PostMessageEventType, payload?: any): void {
+ window.postMessage({
+ type,
+ payload,
+ }, '*');
+}
diff --git a/packages/frontend/src/scripts/reaction-picker.ts b/packages/frontend/src/scripts/reaction-picker.ts
index 19e1bfba2c..9b13e794f5 100644
--- a/packages/frontend/src/scripts/reaction-picker.ts
+++ b/packages/frontend/src/scripts/reaction-picker.ts
@@ -5,6 +5,7 @@
import { defineAsyncComponent, Ref, ref } from 'vue';
import { popup } from '@/os.js';
+import { defaultStore } from '@/store.js';
class ReactionPicker {
private src: Ref<HTMLElement | null> = ref(null);
@@ -17,25 +18,27 @@ class ReactionPicker {
}
public async init() {
+ const reactionsRef = defaultStore.reactiveState.reactions;
await popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerDialog.vue')), {
src: this.src,
+ pinnedEmojis: reactionsRef,
asReactionPicker: true,
manualShowing: this.manualShowing,
}, {
done: reaction => {
- this.onChosen!(reaction);
+ if (this.onChosen) this.onChosen(reaction);
},
close: () => {
this.manualShowing.value = false;
},
closed: () => {
this.src.value = null;
- this.onClosed!();
+ if (this.onClosed) this.onClosed();
},
});
}
- public show(src: HTMLElement, onChosen: ReactionPicker['onChosen'], onClosed: ReactionPicker['onClosed']) {
+ public show(src: HTMLElement, onChosen?: ReactionPicker['onChosen'], onClosed?: ReactionPicker['onClosed']) {
this.src.value = src;
this.manualShowing.value = true;
this.onChosen = onChosen;
diff --git a/packages/frontend/src/scripts/snowfall-effect.ts b/packages/frontend/src/scripts/snowfall-effect.ts
new file mode 100644
index 0000000000..a09f02cec0
--- /dev/null
+++ b/packages/frontend/src/scripts/snowfall-effect.ts
@@ -0,0 +1,476 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and other misskey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export class SnowfallEffect {
+ private VERTEX_SOURCE = `#version 300 es
+ in vec4 a_position;
+ in vec4 a_color;
+ in vec3 a_rotation;
+ in vec3 a_speed;
+ in float a_size;
+ out vec4 v_color;
+ out float v_rotation;
+ uniform float u_time;
+ uniform mat4 u_projection;
+ uniform vec3 u_worldSize;
+ uniform float u_gravity;
+ uniform float u_wind;
+
+ void main() {
+ v_color = a_color;
+ v_rotation = a_rotation.x + u_time * a_rotation.y;
+
+ vec3 pos = a_position.xyz;
+
+ float turbulence = 1.0;
+
+ pos.x = mod(pos.x + u_time + u_wind * a_speed.x, u_worldSize.x * 2.0) - u_worldSize.x;
+ pos.y = mod(pos.y - u_time * a_speed.y * u_gravity, u_worldSize.y * 2.0) - u_worldSize.y;
+
+ pos.x += sin(u_time * a_speed.z * turbulence) * a_rotation.z;
+ pos.z += cos(u_time * a_speed.z * turbulence) * a_rotation.z;
+
+ gl_Position = u_projection * vec4(pos.xyz, a_position.w);
+ gl_PointSize = (a_size / gl_Position.w) * 100.0;
+ }
+ `;
+
+ private FRAGMENT_SOURCE = `#version 300 es
+ precision highp float;
+
+ in vec4 v_color;
+ in float v_rotation;
+ uniform sampler2D u_texture;
+ out vec4 out_color;
+
+ void main() {
+ vec2 rotated = vec2(
+ cos(v_rotation) * (gl_PointCoord.x - 0.5) + sin(v_rotation) * (gl_PointCoord.y - 0.5) + 0.5,
+ cos(v_rotation) * (gl_PointCoord.y - 0.5) - sin(v_rotation) * (gl_PointCoord.x - 0.5) + 0.5
+ );
+
+ vec4 snowflake = texture(u_texture, rotated);
+
+ out_color = vec4(snowflake.rgb * v_color.xyz, snowflake.a * v_color.a);
+ }
+ `;
+
+ private gl: WebGLRenderingContext;
+ private program: WebGLProgram;
+ private canvas: HTMLCanvasElement;
+ private buffers: Record<string, {
+ size: number;
+ value: number[] | Float32Array;
+ location: number;
+ ref: WebGLBuffer;
+ }>;
+ private uniforms: Record<string, {
+ type: string;
+ value: number[] | Float32Array;
+ location: WebGLUniformLocation;
+ }>;
+ private texture: WebGLTexture;
+ private camera: {
+ fov: number;
+ near: number;
+ far: number;
+ aspect: number;
+ z: number;
+ };
+ private wind: {
+ current: number;
+ force: number;
+ target: number;
+ min: number;
+ max: number;
+ easing: number;
+ };
+ private time: {
+ start: number;
+ previous: number;
+ } = {
+ start: 0,
+ previous: 0,
+ };
+ private raf = 0;
+
+ private density: number = 1 / 90;
+ private depth = 100;
+ private count = 1000;
+ private gravity = 100;
+ private speed: number = 1 / 10000;
+ private color: number[] = [1, 1, 1];
+ private opacity = 1;
+ private size = 4;
+ private snowflake = 'data:image/png;base64,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';
+
+ private INITIAL_BUFFERS = () => ({
+ position: { size: 3, value: [] },
+ color: { size: 4, value: [] },
+ size: { size: 1, value: [] },
+ rotation: { size: 3, value: [] },
+ speed: { size: 3, value: [] },
+ });
+
+ private INITIAL_UNIFORMS = () => ({
+ time: { type: 'float', value: 0 },
+ worldSize: { type: 'vec3', value: [0, 0, 0] },
+ gravity: { type: 'float', value: this.gravity },
+ wind: { type: 'float', value: 0 },
+ projection: {
+ type: 'mat4',
+ value: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
+ },
+ });
+
+ private UNIFORM_SETTERS = {
+ int: 'uniform1i',
+ float: 'uniform1f',
+ vec2: 'uniform2fv',
+ vec3: 'uniform3fv',
+ vec4: 'uniform4fv',
+ mat2: 'uniformMatrix2fv',
+ mat3: 'uniformMatrix3fv',
+ mat4: 'uniformMatrix4fv',
+ };
+
+ private CAMERA = {
+ fov: 60,
+ near: 5,
+ far: 10000,
+ aspect: 1,
+ z: 100,
+ };
+
+ private WIND = {
+ current: 0,
+ force: 0.01,
+ target: 0.01,
+ min: 0,
+ max: 0.125,
+ easing: 0.0005,
+ };
+
+ constructor() {
+ const canvas = this.initCanvas();
+ const gl = canvas.getContext('webgl2', { antialias: true });
+ if (gl == null) throw new Error('Failed to get WebGL context');
+
+ document.body.append(canvas);
+
+ this.canvas = canvas;
+ this.gl = gl;
+ this.program = this.initProgram();
+ this.buffers = this.initBuffers();
+ this.uniforms = this.initUniforms();
+ this.texture = this.initTexture();
+ this.camera = this.initCamera();
+ this.wind = this.initWind();
+
+ this.resize = this.resize.bind(this);
+ this.update = this.update.bind(this);
+
+ window.addEventListener('resize', () => this.resize());
+ }
+
+ private initCanvas(): HTMLCanvasElement {
+ const canvas = document.createElement('canvas');
+
+ Object.assign(canvas.style, {
+ position: 'fixed',
+ top: 0,
+ left: 0,
+ width: '100vw',
+ height: '100vh',
+ background: 'transparent',
+ 'pointer-events': 'none',
+ 'z-index': 2147483647,
+ });
+
+ return canvas;
+ }
+
+ private initCamera() {
+ return { ...this.CAMERA };
+ }
+
+ private initWind() {
+ return { ...this.WIND };
+ }
+
+ private initShader(type, source): WebGLShader {
+ const { gl } = this;
+ const shader = gl.createShader(type);
+ if (shader == null) throw new Error('Failed to create shader');
+
+ gl.shaderSource(shader, source);
+ gl.compileShader(shader);
+
+ return shader;
+ }
+
+ private initProgram(): WebGLProgram {
+ const { gl } = this;
+ const vertex = this.initShader(gl.VERTEX_SHADER, this.VERTEX_SOURCE);
+ const fragment = this.initShader(gl.FRAGMENT_SHADER, this.FRAGMENT_SOURCE);
+ const program = gl.createProgram();
+ if (program == null) throw new Error('Failed to create program');
+
+ gl.attachShader(program, vertex);
+ gl.attachShader(program, fragment);
+ gl.linkProgram(program);
+ gl.useProgram(program);
+
+ return program;
+ }
+
+ private initBuffers(): SnowfallEffect['buffers'] {
+ const { gl, program } = this;
+ const buffers = this.INITIAL_BUFFERS() as unknown as SnowfallEffect['buffers'];
+
+ for (const [name, buffer] of Object.entries(buffers)) {
+ buffer.location = gl.getAttribLocation(program, `a_${name}`);
+ buffer.ref = gl.createBuffer()!;
+
+ gl.bindBuffer(gl.ARRAY_BUFFER, buffer.ref);
+ gl.enableVertexAttribArray(buffer.location);
+ gl.vertexAttribPointer(
+ buffer.location,
+ buffer.size,
+ gl.FLOAT,
+ false,
+ 0,
+ 0,
+ );
+ }
+
+ return buffers;
+ }
+
+ private updateBuffers() {
+ const { buffers } = this;
+
+ for (const name of Object.keys(buffers)) {
+ this.setBuffer(name);
+ }
+ }
+
+ private setBuffer(name: string, value?) {
+ const { gl, buffers } = this;
+ const buffer = buffers[name];
+
+ buffer.value = new Float32Array(value ?? buffer.value);
+
+ gl.bindBuffer(gl.ARRAY_BUFFER, buffer.ref);
+ gl.bufferData(gl.ARRAY_BUFFER, buffer.value, gl.STATIC_DRAW);
+ }
+
+ private initUniforms(): SnowfallEffect['uniforms'] {
+ const { gl, program } = this;
+ const uniforms = this.INITIAL_UNIFORMS() as unknown as SnowfallEffect['uniforms'];
+
+ for (const [name, uniform] of Object.entries(uniforms)) {
+ uniform.location = gl.getUniformLocation(program, `u_${name}`)!;
+ }
+
+ return uniforms;
+ }
+
+ private updateUniforms() {
+ const { uniforms } = this;
+
+ for (const name of Object.keys(uniforms)) {
+ this.setUniform(name);
+ }
+ }
+
+ private setUniform(name: string, value?) {
+ const { gl, uniforms } = this;
+ const uniform = uniforms[name];
+ const setter = this.UNIFORM_SETTERS[uniform.type];
+ const isMatrix = /^mat[2-4]$/i.test(uniform.type);
+
+ uniform.value = value ?? uniform.value;
+
+ if (isMatrix) {
+ gl[setter](uniform.location, false, uniform.value);
+ } else {
+ gl[setter](uniform.location, uniform.value);
+ }
+ }
+
+ private initTexture() {
+ const { gl } = this;
+ const texture = gl.createTexture();
+ if (texture == null) throw new Error('Failed to create texture');
+ const image = new Image();
+
+ gl.bindTexture(gl.TEXTURE_2D, texture);
+ gl.texImage2D(
+ gl.TEXTURE_2D,
+ 0,
+ gl.RGBA,
+ 1,
+ 1,
+ 0,
+ gl.RGBA,
+ gl.UNSIGNED_BYTE,
+ new Uint8Array([0, 0, 0, 0]),
+ );
+
+ image.onload = () => {
+ gl.bindTexture(gl.TEXTURE_2D, texture);
+ gl.texImage2D(
+ gl.TEXTURE_2D,
+ 0,
+ gl.RGBA,
+ gl.RGBA,
+ gl.UNSIGNED_BYTE,
+ image,
+ );
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
+ };
+
+ image.src = this.snowflake;
+
+ return texture;
+ }
+
+ private initSnowflakes(vw: number, vh: number, dpi: number) {
+ const position: number[] = [];
+ const color: number[] = [];
+ const size: number[] = [];
+ const rotation: number[] = [];
+ const speed: number[] = [];
+
+ const height = 1 / this.density;
+ const width = (vw / vh) * height;
+ const depth = this.depth;
+ const count = this.count;
+ const length = (vw / vh) * count;
+
+ for (let i = 0; i < length; ++i) {
+ position.push(
+ -width + Math.random() * width * 2,
+ -height + Math.random() * height * 2,
+ Math.random() * depth * 2,
+ );
+
+ speed.push(1 + Math.random(), 1 + Math.random(), Math.random() * 10);
+
+ rotation.push(
+ Math.random() * 2 * Math.PI,
+ Math.random() * 20,
+ Math.random() * 10,
+ );
+
+ color.push(...this.color, 0.1 + Math.random() * this.opacity);
+ //size.push((this.size * Math.random() * this.size * vh * dpi) / 1000);
+ size.push((this.size * vh * dpi) / 1000);
+ }
+
+ this.setUniform('worldSize', [width, height, depth]);
+
+ this.setBuffer('position', position);
+ this.setBuffer('color', color);
+ this.setBuffer('rotation', rotation);
+ this.setBuffer('size', size);
+ this.setBuffer('speed', speed);
+ }
+
+ private setProjection(aspect: number) {
+ const { camera } = this;
+
+ camera.aspect = aspect;
+
+ const fovRad = (camera.fov * Math.PI) / 180;
+ const f = Math.tan(Math.PI * 0.5 - 0.5 * fovRad);
+ const rangeInv = 1.0 / (camera.near - camera.far);
+
+ const m0 = f / camera.aspect;
+ const m5 = f;
+ const m10 = (camera.near + camera.far) * rangeInv;
+ const m11 = -1;
+ const m14 = camera.near * camera.far * rangeInv * 2 + camera.z;
+ const m15 = camera.z;
+
+ return [m0, 0, 0, 0, 0, m5, 0, 0, 0, 0, m10, m11, 0, 0, m14, m15];
+ }
+
+ public render() {
+ const { gl } = this;
+
+ gl.enable(gl.BLEND);
+ gl.enable(gl.CULL_FACE);
+ gl.blendFunc(gl.SRC_ALPHA, gl.ONE);
+ gl.disable(gl.DEPTH_TEST);
+
+ this.updateBuffers();
+ this.updateUniforms();
+ this.resize(true);
+
+ this.time = {
+ start: window.performance.now(),
+ previous: window.performance.now(),
+ };
+
+ if (this.raf) window.cancelAnimationFrame(this.raf);
+ this.raf = window.requestAnimationFrame(this.update);
+
+ return this;
+ }
+
+ private resize(updateSnowflakes = false) {
+ const { canvas, gl } = this;
+ const vw = canvas.offsetWidth;
+ const vh = canvas.offsetHeight;
+ const aspect = vw / vh;
+ const dpi = window.devicePixelRatio;
+
+ canvas.width = vw * dpi;
+ canvas.height = vh * dpi;
+
+ gl.viewport(0, 0, vw * dpi, vh * dpi);
+ gl.clearColor(0, 0, 0, 0);
+
+ if (updateSnowflakes === true) {
+ this.initSnowflakes(vw, vh, dpi);
+ }
+
+ this.setUniform('projection', this.setProjection(aspect));
+ }
+
+ private update(timestamp: number) {
+ const { gl, buffers, wind } = this;
+ const elapsed = (timestamp - this.time.start) * this.speed;
+ const delta = timestamp - this.time.previous;
+
+ gl.clear(gl.COLOR_BUFFER_BIT);
+ gl.drawArrays(
+ gl.POINTS,
+ 0,
+ buffers.position.value.length / buffers.position.size,
+ );
+
+ if (Math.random() > 0.995) {
+ wind.target =
+ (wind.min + Math.random() * (wind.max - wind.min)) *
+ (Math.random() > 0.5 ? -1 : 1);
+ }
+
+ wind.force += (wind.target - wind.force) * wind.easing;
+ wind.current += wind.force * (delta * 0.2);
+
+ this.setUniform('wind', wind.current);
+ this.setUniform('time', elapsed);
+
+ this.time.previous = timestamp;
+
+ this.raf = window.requestAnimationFrame(this.update);
+ }
+}
diff --git a/packages/frontend/src/scripts/sound.ts b/packages/frontend/src/scripts/sound.ts
index 4b0cd0bb39..2f7545ef0d 100644
--- a/packages/frontend/src/scripts/sound.ts
+++ b/packages/frontend/src/scripts/sound.ts
@@ -3,13 +3,22 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
+import type { SoundStore } from '@/store.js';
import { defaultStore } from '@/store.js';
+import * as os from '@/os.js';
-const ctx = new AudioContext();
+let ctx: AudioContext;
const cache = new Map<string, AudioBuffer>();
+let canPlay = true;
export const soundsTypes = [
+ // 音声なし
null,
+
+ // ドライブの音声
+ '_driveFile_',
+
+ // プリインストール
'syuilo/n-aec',
'syuilo/n-aec-4va',
'syuilo/n-aec-4vb',
@@ -38,6 +47,8 @@ export const soundsTypes = [
'syuilo/waon',
'syuilo/popo',
'syuilo/triple',
+ 'syuilo/bubble1',
+ 'syuilo/bubble2',
'syuilo/poi1',
'syuilo/poi2',
'syuilo/pirori',
@@ -61,46 +72,161 @@ export const soundsTypes = [
'noizenecio/kick_gaba7',
] as const;
-export async function getAudio(file: string, useCache = true) {
- if (useCache && cache.has(file)) {
- return cache.get(file)!;
+export const operationTypes = [
+ 'noteMy',
+ 'note',
+ 'antenna',
+ 'channel',
+ 'notification',
+ 'reaction',
+] as const;
+
+/** サウンドの種類 */
+export type SoundType = typeof soundsTypes[number];
+
+/** スプライトの種類 */
+export type OperationType = typeof operationTypes[number];
+
+/**
+ * 音声を読み込む
+ * @param soundStore サウンド設定
+ * @param options `useCache`: デフォルトは`true` 一度再生した音声はキャッシュする
+ */
+export async function loadAudio(soundStore: SoundStore, options?: { useCache?: boolean; }) {
+ if (_DEV_) console.log('loading audio. opts:', options);
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+ if (soundStore.type === null || (soundStore.type === '_driveFile_' && !soundStore.fileUrl)) {
+ return;
+ }
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+ if (ctx == null) {
+ ctx = new AudioContext();
+ }
+ if (options?.useCache ?? true) {
+ if (soundStore.type === '_driveFile_' && cache.has(soundStore.fileId)) {
+ if (_DEV_) console.log('use cache');
+ return cache.get(soundStore.fileId) as AudioBuffer;
+ } else if (cache.has(soundStore.type)) {
+ if (_DEV_) console.log('use cache');
+ return cache.get(soundStore.type) as AudioBuffer;
+ }
+ }
+
+ let response: Response;
+
+ if (soundStore.type === '_driveFile_') {
+ try {
+ response = await fetch(soundStore.fileUrl);
+ } catch (err) {
+ try {
+ // URLが変わっている可能性があるのでドライブ側からURLを取得するフォールバック
+ const apiRes = await os.api('drive/files/show', {
+ fileId: soundStore.fileId,
+ });
+ response = await fetch(apiRes.url);
+ } catch (fbErr) {
+ // それでも無理なら諦める
+ return;
+ }
+ }
+ } else {
+ try {
+ response = await fetch(`/client-assets/sounds/${soundStore.type}.mp3`);
+ } catch (err) {
+ return;
+ }
}
- const response = await fetch(`/client-assets/sounds/${file}.mp3`);
const arrayBuffer = await response.arrayBuffer();
const audioBuffer = await ctx.decodeAudioData(arrayBuffer);
- if (useCache) {
- cache.set(file, audioBuffer);
+ if (options?.useCache ?? true) {
+ if (soundStore.type === '_driveFile_') {
+ cache.set(soundStore.fileId, audioBuffer);
+ } else {
+ cache.set(soundStore.type, audioBuffer);
+ }
}
return audioBuffer;
}
-export function setVolume(audio: HTMLAudioElement, volume: number): HTMLAudioElement {
- const masterVolume = defaultStore.state.sound_masterVolume;
- audio.volume = masterVolume - ((1 - volume) * masterVolume);
- return audio;
+/**
+ * 既定のスプライトを再生する
+ * @param type スプライトの種類を指定
+ */
+export function play(operationType: OperationType) {
+ const sound = defaultStore.state[`sound_${operationType}`];
+ if (_DEV_) console.log('play', operationType, sound);
+ if (sound.type == null || !canPlay) return;
+
+ canPlay = false;
+ playFile(sound).finally(() => {
+ // ごく短時間に音が重複しないように
+ setTimeout(() => {
+ canPlay = true;
+ }, 25);
+ });
}
-export function play(type: 'noteMy' | 'note' | 'antenna' | 'channel' | 'notification') {
- const sound = defaultStore.state[`sound_${type}`];
- if (_DEV_) console.log('play', type, sound);
- if (sound.type == null) return;
- playFile(sound.type, sound.volume);
+/**
+ * サウンド設定形式で指定された音声を再生する
+ * @param soundStore サウンド設定
+ */
+export async function playFile(soundStore: SoundStore) {
+ const buffer = await loadAudio(soundStore);
+ if (!buffer) return;
+ createSourceNode(buffer, soundStore.volume)?.start();
}
-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) {
- return;
+ if (isMute() || masterVolume === 0 || volume === 0) {
+ return null;
}
const gainNode = ctx.createGain();
gainNode.gain.value = masterVolume * volume;
const soundSource = ctx.createBufferSource();
- soundSource.buffer = await getAudio(file);
+ soundSource.buffer = buffer;
soundSource.connect(gainNode).connect(ctx.destination);
- soundSource.start();
+
+ return soundSource;
+}
+
+/**
+ * 音声の長さをミリ秒で取得する
+ * @param file ファイルのURL(ドライブIDではない)
+ */
+export async function getSoundDuration(file: string): Promise<number> {
+ const audioEl = document.createElement('audio');
+ audioEl.src = file;
+ return new Promise((resolve) => {
+ const si = setInterval(() => {
+ if (audioEl.readyState > 0) {
+ resolve(audioEl.duration * 1000);
+ clearInterval(si);
+ audioEl.remove();
+ }
+ }, 100);
+ });
+}
+
+/**
+ * ミュートすべきかどうかを判断する
+ */
+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/scripts/theme.ts b/packages/frontend/src/scripts/theme.ts
index 22b8a5df37..3bf6d5798c 100644
--- a/packages/frontend/src/scripts/theme.ts
+++ b/packages/frontend/src/scripts/theme.ts
@@ -44,7 +44,7 @@ export const getBuiltinThemes = () => Promise.all(
'd-cherry',
'd-ice',
'd-u0',
- ].map(name => import(`../themes/${name}.json5`).then(({ default: _default }): Theme => _default)),
+ ].map(name => import(`@/themes/${name}.json5`).then(({ default: _default }): Theme => _default)),
);
export const getBuiltinThemesRef = () => {