Merge tag '2023.12.0-beta.3' into merge-upstream
This commit is contained in:
commit
e77ddfce91
423 changed files with 42868 additions and 10814 deletions
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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++;
|
||||
|
||||
|
|
@ -52,7 +57,10 @@ export function api<E extends keyof Misskey.Endpoints, P extends Misskey.Endpoin
|
|||
}
|
||||
|
||||
// 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 = () => {
|
||||
|
|
|
|||
14
packages/frontend/src/scripts/clear-cache.ts
Normal file
14
packages/frontend/src/scripts/clear-cache.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
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('theme');
|
||||
miLocalStorage.removeItem('emojis');
|
||||
miLocalStorage.removeItem('lastEmojisFetchedAt');
|
||||
await fetchCustomEmojis(true);
|
||||
unisonReload();
|
||||
}
|
||||
57
packages/frontend/src/scripts/emoji-picker.ts
Normal file
57
packages/frontend/src/scripts/emoji-picker.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
/*
|
||||
* 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';
|
||||
|
||||
/**
|
||||
* 絵文字ピッカーを表示する。
|
||||
* 類似の機能として{@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() {
|
||||
await popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerDialog.vue')), {
|
||||
src: this.src,
|
||||
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();
|
||||
|
|
@ -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;
|
||||
|
|
@ -280,11 +281,11 @@ export function getNoteMenu(props: {
|
|||
window.open(appearNote.url ?? appearNote.uri, '_blank', 'noopener');
|
||||
},
|
||||
} : undefined,
|
||||
{
|
||||
...(isSupportShare() ? [{
|
||||
icon: 'ti ti-share',
|
||||
text: i18n.ts.share,
|
||||
action: share,
|
||||
},
|
||||
}] : []),
|
||||
$i && $i.policies.canUseTranslator && instance.translatorAvailable ? {
|
||||
icon: 'ti ti-language-hiragana',
|
||||
text: i18n.ts.translate,
|
||||
|
|
@ -484,7 +485,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',
|
||||
|
|
|
|||
9
packages/frontend/src/scripts/media-has-audio.ts
Normal file
9
packages/frontend/src/scripts/media-has-audio.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
8
packages/frontend/src/scripts/navigator.ts
Normal file
8
packages/frontend/src/scripts/navigator.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
25
packages/frontend/src/scripts/post-message.ts
Normal file
25
packages/frontend/src/scripts/post-message.ts
Normal file
|
|
@ -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,
|
||||
}, '*');
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
@ -122,37 +133,116 @@ export const soundsTypes = [
|
|||
'Copyright_Misskey.io/ThinaticSystem/yonderuzo3',
|
||||
] as const;
|
||||
|
||||
export async function loadAudio(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 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 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 async function playFile(file: string, volume: number) {
|
||||
const buffer = await loadAudio(file);
|
||||
createSourceNode(buffer, volume)?.start();
|
||||
/**
|
||||
* サウンド設定形式で指定された音声を再生する
|
||||
* @param soundStore サウンド設定
|
||||
*/
|
||||
export async function playFile(soundStore: SoundStore) {
|
||||
const buffer = await loadAudio(soundStore);
|
||||
if (!buffer) return;
|
||||
createSourceNode(buffer, soundStore.volume)?.start();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -165,3 +255,39 @@ export function createSourceNode(buffer: AudioBuffer, volume: number) : AudioBuf
|
|||
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue