Merge tag '13.11.0' into io

# Conflicts:
#	packages/backend/src/server/ServerService.ts
#	packages/backend/src/server/api/endpoints/notes/timeline.ts
This commit is contained in:
和風ドレッシング
2023-04-08 22:01:55 +09:00
650 changed files with 32472 additions and 9221 deletions

View File

@@ -443,11 +443,14 @@ export const ACHIEVEMENT_BADGES = {
bg: 'linear-gradient(0deg, rgb(144, 224, 255), rgb(255, 168, 252))',
frame: 'bronze',
},
/* @see <https://github.com/misskey-dev/misskey/pull/10365#discussion_r1155511107>
} as const satisfies Record<typeof ACHIEVEMENT_TYPES[number], {
img: string;
bg: string | null;
frame: 'bronze' | 'silver' | 'gold' | 'platinum';
}>;
*/
} as const;
export const claimedAchievements: typeof ACHIEVEMENT_TYPES[number][] = ($i && $i.achievements) ? $i.achievements.map(x => x.name) : [];

View File

@@ -471,7 +471,7 @@ export function registerAsUiLib(components: Ref<AsUiComponent>[], done: (root: R
components.push(component);
const instance = values.OBJ(new Map([
['id', values.STR(_id)],
['update', values.FN_NATIVE(async ([def], opts) => {
['update', values.FN_NATIVE(([def], opts) => {
utils.assertObject(def);
const updates = getOptions(def, call);
for (const update of def.value.keys()) {
@@ -491,13 +491,13 @@ export function registerAsUiLib(components: Ref<AsUiComponent>[], done: (root: R
return {
'Ui:root': rootInstance,
'Ui:patch': values.FN_NATIVE(async ([id, val], opts) => {
'Ui:patch': values.FN_NATIVE(([id, val], opts) => {
utils.assertString(id);
utils.assertArray(val);
patch(id.value, val.value, opts.call);
}),
'Ui:get': values.FN_NATIVE(async ([id], opts) => {
'Ui:get': values.FN_NATIVE(([id], opts) => {
utils.assertString(id);
const instance = instances[id.value];
if (instance) {
@@ -508,7 +508,7 @@ export function registerAsUiLib(components: Ref<AsUiComponent>[], done: (root: R
}),
// Ui:root.update({ children: [...] }) の糖衣構文
'Ui:render': values.FN_NATIVE(async ([children], opts) => {
'Ui:render': values.FN_NATIVE(([children], opts) => {
utils.assertArray(children);
rootComponent.value.children = children.value.map(v => {
@@ -517,51 +517,51 @@ export function registerAsUiLib(components: Ref<AsUiComponent>[], done: (root: R
});
}),
'Ui:C:container': values.FN_NATIVE(async ([def, id], opts) => {
'Ui:C:container': values.FN_NATIVE(([def, id], opts) => {
return createComponentInstance('container', def, id, getContainerOptions, opts.call);
}),
'Ui:C:text': values.FN_NATIVE(async ([def, id], opts) => {
'Ui:C:text': values.FN_NATIVE(([def, id], opts) => {
return createComponentInstance('text', def, id, getTextOptions, opts.call);
}),
'Ui:C:mfm': values.FN_NATIVE(async ([def, id], opts) => {
'Ui:C:mfm': values.FN_NATIVE(([def, id], opts) => {
return createComponentInstance('mfm', def, id, getMfmOptions, opts.call);
}),
'Ui:C:textarea': values.FN_NATIVE(async ([def, id], opts) => {
'Ui:C:textarea': values.FN_NATIVE(([def, id], opts) => {
return createComponentInstance('textarea', def, id, getTextareaOptions, opts.call);
}),
'Ui:C:textInput': values.FN_NATIVE(async ([def, id], opts) => {
'Ui:C:textInput': values.FN_NATIVE(([def, id], opts) => {
return createComponentInstance('textInput', def, id, getTextInputOptions, opts.call);
}),
'Ui:C:numberInput': values.FN_NATIVE(async ([def, id], opts) => {
'Ui:C:numberInput': values.FN_NATIVE(([def, id], opts) => {
return createComponentInstance('numberInput', def, id, getNumberInputOptions, opts.call);
}),
'Ui:C:button': values.FN_NATIVE(async ([def, id], opts) => {
'Ui:C:button': values.FN_NATIVE(([def, id], opts) => {
return createComponentInstance('button', def, id, getButtonOptions, opts.call);
}),
'Ui:C:buttons': values.FN_NATIVE(async ([def, id], opts) => {
'Ui:C:buttons': values.FN_NATIVE(([def, id], opts) => {
return createComponentInstance('buttons', def, id, getButtonsOptions, opts.call);
}),
'Ui:C:switch': values.FN_NATIVE(async ([def, id], opts) => {
'Ui:C:switch': values.FN_NATIVE(([def, id], opts) => {
return createComponentInstance('switch', def, id, getSwitchOptions, opts.call);
}),
'Ui:C:select': values.FN_NATIVE(async ([def, id], opts) => {
'Ui:C:select': values.FN_NATIVE(([def, id], opts) => {
return createComponentInstance('select', def, id, getSelectOptions, opts.call);
}),
'Ui:C:folder': values.FN_NATIVE(async ([def, id], opts) => {
'Ui:C:folder': values.FN_NATIVE(([def, id], opts) => {
return createComponentInstance('folder', def, id, getFolderOptions, opts.call);
}),
'Ui:C:postFormButton': values.FN_NATIVE(async ([def, id], opts) => {
'Ui:C:postFormButton': values.FN_NATIVE(([def, id], opts) => {
return createComponentInstance('postFormButton', def, id, getPostFormButtonOptions, opts.call);
}),
};

View File

@@ -5,7 +5,7 @@ import { $i } from '@/account';
export const pendingApiRequestsCount = ref(0);
// Implements Misskey.api.ApiClient.request
export function api<E extends keyof Endpoints, P extends Endpoints[E]['req']>(endpoint: E, data: P = {} as any, token?: string | null | undefined): Promise<Endpoints[E]['res']> {
export function api<E extends keyof Endpoints, P extends Endpoints[E]['req']>(endpoint: E, data: P = {} as any, token?: string | null | undefined, signal?: AbortSignal): Promise<Endpoints[E]['res']> {
pendingApiRequestsCount.value++;
const onFinally = () => {
@@ -26,6 +26,7 @@ export function api<E extends keyof Endpoints, P extends Endpoints[E]['req']>(en
headers: {
'Content-Type': 'application/json',
},
signal,
}).then(async (res) => {
const body = res.status === 204 ? null : await res.json();

View File

@@ -0,0 +1,80 @@
export class Cache<T> {
private cachedAt: number | null = null;
private value: T | undefined;
private lifetime: number;
constructor(lifetime: Cache<never>['lifetime']) {
this.lifetime = lifetime;
}
public set(value: T): void {
this.cachedAt = Date.now();
this.value = value;
}
public get(): T | undefined {
if (this.cachedAt == null) return undefined;
if ((Date.now() - this.cachedAt) > this.lifetime) {
this.value = undefined;
this.cachedAt = null;
return undefined;
}
return this.value;
}
public delete() {
this.value = undefined;
this.cachedAt = null;
}
/**
* キャッシュがあればそれを返し、無ければfetcherを呼び出して結果をキャッシュ&返します
* optional: キャッシュが存在してもvalidatorでfalseを返すとキャッシュ無効扱いにします
*/
public async fetch(fetcher: () => Promise<T>, validator?: (cachedValue: T) => boolean): Promise<T> {
const cachedValue = this.get();
if (cachedValue !== undefined) {
if (validator) {
if (validator(cachedValue)) {
// Cache HIT
return cachedValue;
}
} else {
// Cache HIT
return cachedValue;
}
}
// Cache MISS
const value = await fetcher();
this.set(value);
return value;
}
/**
* キャッシュがあればそれを返し、無ければfetcherを呼び出して結果をキャッシュ&返します
* optional: キャッシュが存在してもvalidatorでfalseを返すとキャッシュ無効扱いにします
*/
public async fetchMaybe(fetcher: () => Promise<T | undefined>, validator?: (cachedValue: T) => boolean): Promise<T | undefined> {
const cachedValue = this.get();
if (cachedValue !== undefined) {
if (validator) {
if (validator(cachedValue)) {
// Cache HIT
return cachedValue;
}
} else {
// Cache HIT
return cachedValue;
}
}
// Cache MISS
const value = await fetcher();
if (value !== undefined) {
this.set(value);
}
return value;
}
}

View File

@@ -0,0 +1,93 @@
import * as Misskey from 'misskey-js';
import { defineAsyncComponent } from 'vue';
import { i18n } from '@/i18n';
import copyToClipboard from '@/scripts/copy-to-clipboard';
import * as os from '@/os';
function rename(file: Misskey.entities.DriveFile) {
os.inputText({
title: i18n.ts.renameFile,
placeholder: i18n.ts.inputNewFileName,
default: file.name,
}).then(({ canceled, result: name }) => {
if (canceled) return;
os.api('drive/files/update', {
fileId: file.id,
name: name,
});
});
}
function describe(file: Misskey.entities.DriveFile) {
os.popup(defineAsyncComponent(() => import('@/components/MkFileCaptionEditWindow.vue')), {
default: file.comment != null ? file.comment : '',
file: file,
}, {
done: caption => {
os.api('drive/files/update', {
fileId: file.id,
comment: caption.length === 0 ? null : caption,
});
},
}, 'closed');
}
function toggleSensitive(file: Misskey.entities.DriveFile) {
os.api('drive/files/update', {
fileId: file.id,
isSensitive: !file.isSensitive,
});
}
function copyUrl(file: Misskey.entities.DriveFile) {
copyToClipboard(file.url);
os.success();
}
/*
function addApp() {
alert('not implemented yet');
}
*/
async function deleteFile(file: Misskey.entities.DriveFile) {
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.t('driveFileDeleteConfirm', { name: file.name }),
});
if (canceled) return;
os.api('drive/files/delete', {
fileId: file.id,
});
}
export function getDriveFileMenu(file: Misskey.entities.DriveFile) {
return [{
text: i18n.ts.rename,
icon: 'ti ti-forms',
action: () => rename(file),
}, {
text: file.isSensitive ? i18n.ts.unmarkAsSensitive : i18n.ts.markAsSensitive,
icon: file.isSensitive ? 'ti ti-eye' : 'ti ti-eye-off',
action: () => toggleSensitive(file),
}, {
text: i18n.ts.describeFile,
icon: 'ti ti-text-caption',
action: () => describe(file),
}, null, {
text: i18n.ts.copyUrl,
icon: 'ti ti-link',
action: () => copyUrl(file),
}, {
type: 'a',
href: file.url,
target: '_blank',
text: i18n.ts.download,
icon: 'ti ti-download',
download: file.name,
}, null, {
text: i18n.ts.delete,
icon: 'ti ti-trash',
danger: true,
action: () => deleteFile(file),
}];
}

View File

@@ -10,6 +10,81 @@ import { url } from '@/config';
import { noteActions } from '@/store';
import { miLocalStorage } from '@/local-storage';
import { getUserMenu } from '@/scripts/get-user-menu';
import { clipsCache } from '@/cache';
export async function getNoteClipMenu(props: {
note: misskey.entities.Note;
isDeleted: Ref<boolean>;
currentClip?: misskey.entities.Clip;
}) {
const isRenote = (
props.note.renote != null &&
props.note.text == null &&
props.note.fileIds.length === 0 &&
props.note.poll == null
);
const appearNote = isRenote ? props.note.renote as misskey.entities.Note : props.note;
const clips = await clipsCache.fetch(() => os.api('clips/list'));
return [...clips.map(clip => ({
text: clip.name,
action: () => {
claimAchievement('noteClipped1');
os.promiseDialog(
os.api('clips/add-note', { clipId: clip.id, noteId: appearNote.id }),
null,
async (err) => {
if (err.id === '734806c4-542c-463a-9311-15c512803965') {
const confirm = await os.confirm({
type: 'warning',
text: i18n.t('confirmToUnclipAlreadyClippedNote', { name: clip.name }),
});
if (!confirm.canceled) {
os.apiWithDialog('clips/remove-note', { clipId: clip.id, noteId: appearNote.id });
if (props.currentClip?.id === clip.id) props.isDeleted.value = true;
}
} else {
os.alert({
type: 'error',
text: err.message + '\n' + err.id,
});
}
},
);
},
})), null, {
icon: 'ti ti-plus',
text: i18n.ts.createNew,
action: async () => {
const { canceled, result } = await os.form(i18n.ts.createNewClip, {
name: {
type: 'string',
label: i18n.ts.name,
},
description: {
type: 'string',
required: false,
multiline: true,
label: i18n.ts.description,
},
isPublic: {
type: 'boolean',
label: i18n.ts.public,
default: false,
},
});
if (canceled) return;
const clip = await os.apiWithDialog('clips/create', result);
clipsCache.delete();
claimAchievement('noteClipped1');
os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: appearNote.id });
},
}];
}
export function getNoteMenu(props: {
note: misskey.entities.Note;
@@ -17,7 +92,7 @@ export function getNoteMenu(props: {
translation: Ref<any>;
translating: Ref<boolean>;
isDeleted: Ref<boolean>;
currentClipPage?: Ref<misskey.entities.Clip>;
currentClip?: misskey.entities.Clip;
}) {
const isRenote = (
props.note.renote != null &&
@@ -101,7 +176,7 @@ export function getNoteMenu(props: {
}
async function unclip(): Promise<void> {
os.apiWithDialog('clips/remove-note', { clipId: props.currentClipPage.value.id, noteId: appearNote.id });
os.apiWithDialog('clips/remove-note', { clipId: props.currentClip.id, noteId: appearNote.id });
props.isDeleted.value = true;
}
@@ -155,7 +230,7 @@ export function getNoteMenu(props: {
menu = [
...(
props.currentClipPage?.value.userId === $i.id ? [{
props.currentClip?.userId === $i.id ? [{
icon: 'ti ti-backspace',
text: i18n.ts.unclip,
danger: true,
@@ -208,64 +283,7 @@ export function getNoteMenu(props: {
type: 'parent',
icon: 'ti ti-paperclip',
text: i18n.ts.clip,
children: async () => {
const clips = await os.api('clips/list');
return [{
icon: 'ti ti-plus',
text: i18n.ts.createNew,
action: async () => {
const { canceled, result } = await os.form(i18n.ts.createNewClip, {
name: {
type: 'string',
label: i18n.ts.name,
},
description: {
type: 'string',
required: false,
multiline: true,
label: i18n.ts.description,
},
isPublic: {
type: 'boolean',
label: i18n.ts.public,
default: false,
},
});
if (canceled) return;
const clip = await os.apiWithDialog('clips/create', result);
claimAchievement('noteClipped1');
os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: appearNote.id });
},
}, null, ...clips.map(clip => ({
text: clip.name,
action: () => {
claimAchievement('noteClipped1');
os.promiseDialog(
os.api('clips/add-note', { clipId: clip.id, noteId: appearNote.id }),
null,
async (err) => {
if (err.id === '734806c4-542c-463a-9311-15c512803965') {
const confirm = await os.confirm({
type: 'warning',
text: i18n.t('confirmToUnclipAlreadyClippedNote', { name: clip.name }),
});
if (!confirm.canceled) {
os.apiWithDialog('clips/remove-note', { clipId: clip.id, noteId: appearNote.id });
if (props.currentClipPage?.value.id === clip.id) props.isDeleted.value = true;
}
} else {
os.alert({
type: 'error',
text: err.message + '\n' + err.id,
});
}
},
);
},
}))];
},
children: () => getNoteClipMenu(props),
},
statePromise.then(state => state.isMutedThread ? {
icon: 'ti ti-message-off',
@@ -276,7 +294,7 @@ export function getNoteMenu(props: {
text: i18n.ts.muteThread,
action: () => toggleThreadMute(true),
}),
appearNote.userId === $i.id ? ($i.pinnedNoteIds || []).includes(appearNote.id) ? {
appearNote.userId === $i.id ? ($i.pinnedNoteIds ?? []).includes(appearNote.id) ? {
icon: 'ti ti-pinned-off',
text: i18n.ts.unpin,
action: () => togglePin(false),

View File

@@ -8,6 +8,7 @@ import { userActions } from '@/store';
import { $i, iAmModerator } from '@/account';
import { mainRouter } from '@/router';
import { Router } from '@/nirax';
import { rolesCache, userListsCache } from '@/cache';
export function getUserMenu(user: misskey.entities.UserDetailed, router: Router = mainRouter) {
const meId = $i ? $i.id : null;
@@ -53,6 +54,14 @@ export function getUserMenu(user: misskey.entities.UserDetailed, router: Router
}
}
async function toggleRenoteMute() {
os.apiWithDialog(user.isRenoteMuted ? 'renote-mute/delete' : 'renote-mute/create', {
userId: user.id,
}).then(() => {
user.isRenoteMuted = !user.isRenoteMuted;
});
}
async function toggleBlock() {
if (!await getConfirmed(user.isBlocking ? i18n.ts.unblockConfirm : i18n.ts.blockConfirm)) return;
@@ -111,14 +120,14 @@ export function getUserMenu(user: misskey.entities.UserDetailed, router: Router
icon: 'ti ti-mail',
text: i18n.ts.sendMessage,
action: () => {
os.post({ specified: user });
os.post({ specified: user, initialText: `@${user.username} ` });
},
}, null, {
type: 'parent',
icon: 'ti ti-list',
text: i18n.ts.addToList,
children: async () => {
const lists = await os.api('users/lists/list');
const lists = await userListsCache.fetch(() => os.api('users/lists/list'));
return lists.map(list => ({
text: list.name,
@@ -139,7 +148,7 @@ export function getUserMenu(user: misskey.entities.UserDetailed, router: Router
icon: 'ti ti-badges',
text: i18n.ts.roles,
children: async () => {
const roles = await os.api('admin/roles/list');
const roles = await rolesCache.fetch(() => os.api('admin/roles/list'));
return roles.filter(r => r.target === 'manual').map(r => ({
text: r.name,
@@ -179,6 +188,10 @@ export function getUserMenu(user: misskey.entities.UserDetailed, router: Router
icon: user.isMuted ? 'ti ti-eye' : 'ti ti-eye-off',
text: user.isMuted ? i18n.ts.unmute : i18n.ts.mute,
action: toggleMute,
}, {
icon: user.isRenoteMuted ? 'ti ti-repeat' : 'ti ti-repeat-off',
text: user.isRenoteMuted ? i18n.ts.renoteUnmute : i18n.ts.renoteMute,
action: toggleRenoteMute,
}, {
icon: 'ti ti-ban',
text: user.isBlocking ? i18n.ts.unblock : i18n.ts.block,

View File

@@ -1,4 +1,3 @@
import autobind from 'autobind-decorator';
import { ref, Ref, unref } from 'vue';
import { collectPageVars } from '../collect-page-vars';
import { initHpmlLib } from './lib';
@@ -51,7 +50,6 @@ export class Hpml {
this.eval();
}
@autobind
public eval() {
try {
this.vars.value = this.evaluateVars();
@@ -60,7 +58,6 @@ export class Hpml {
}
}
@autobind
public interpolate(str: string) {
if (str == null) return null;
return str.replace(/{(.+?)}/g, match => {
@@ -69,12 +66,10 @@ export class Hpml {
});
}
@autobind
public registerCanvas(id: string, canvas: any) {
this.canvases[id] = canvas;
}
@autobind
public updatePageVar(name: string, value: any) {
const pageVar = this.pageVars.find(v => v.name === name);
if (pageVar !== undefined) {
@@ -84,13 +79,11 @@ export class Hpml {
}
}
@autobind
public updateRandomSeed(seed: string) {
this.opts.randomSeed = seed;
this.envVars.SEED = seed;
}
@autobind
private _interpolateScope(str: string, scope: HpmlScope) {
return str.replace(/{(.+?)}/g, match => {
const v = scope.getState(match.slice(1, -1).trim());
@@ -98,7 +91,6 @@ export class Hpml {
});
}
@autobind
public evaluateVars(): Record<string, any> {
const values: Record<string, any> = {};
@@ -117,7 +109,6 @@ export class Hpml {
return values;
}
@autobind
private evaluate(expr: Expr, scope: HpmlScope): any {
if (isLiteralValue(expr)) {
if (expr.type === null) {

View File

@@ -2,7 +2,6 @@
* Hpml
*/
import autobind from 'autobind-decorator';
import { Hpml } from './evaluator';
import { funcDefs } from './lib';
@@ -61,7 +60,6 @@ export class HpmlScope {
this.name = name ?? 'anonymous';
}
@autobind
public createChildScope(states: Record<string, any>, name?: HpmlScope['name']): HpmlScope {
const layer = [states, ...this.layerdStates];
return new HpmlScope(layer, name);
@@ -71,7 +69,6 @@ export class HpmlScope {
* 指定した名前の変数の値を取得します
* @param name 変数名
*/
@autobind
public getState(name: string): any {
for (const later of this.layerdStates) {
const state = later[name];

View File

@@ -1,4 +1,3 @@
import autobind from 'autobind-decorator';
import { isLiteralValue } from './expr';
import { funcDefs } from './lib';
import { envVarsDef } from '.';
@@ -23,7 +22,6 @@ export class HpmlTypeChecker {
this.pageVars = pageVars;
}
@autobind
public typeCheck(v: Expr): TypeError | null {
if (isLiteralValue(v)) return null;
@@ -61,7 +59,6 @@ export class HpmlTypeChecker {
return null;
}
@autobind
public getExpectedType(v: Expr, slot: number): Type {
const def = funcDefs[v.type ?? ''];
if (def == null) {
@@ -89,7 +86,6 @@ export class HpmlTypeChecker {
}
}
@autobind
public infer(v: Expr): Type {
if (v.type === null) return null;
if (v.type === 'text') return 'string';
@@ -144,7 +140,6 @@ export class HpmlTypeChecker {
}
}
@autobind
public getVarByName(name: string): Variable {
const v = this.variables.find(x => x.name === name);
if (v !== undefined) {
@@ -154,25 +149,21 @@ export class HpmlTypeChecker {
}
}
@autobind
public getVarsByType(type: Type): Variable[] {
if (type == null) return this.variables;
return this.variables.filter(x => (this.infer(x) === null) || (this.infer(x) === type));
}
@autobind
public getEnvVarsByType(type: Type): string[] {
if (type == null) return Object.keys(envVarsDef);
return Object.entries(envVarsDef).filter(([k, v]) => v === null || type === v).map(([k, v]) => k);
}
@autobind
public getPageVarsByType(type: Type): string[] {
if (type == null) return this.pageVars.map(v => v.name);
return this.pageVars.filter(v => type === v.type).map(v => v.name);
}
@autobind
public isUsedName(name: string) {
if (this.variables.some(v => v.name === name)) {
return true;

View File

@@ -0,0 +1,41 @@
import * as os from '@/os';
import { i18n } from '@/i18n';
import { mainRouter } from '@/router';
import { Router } from '@/nirax';
export async function lookup(router?: Router) {
const _router = router ?? mainRouter;
const { canceled, result: query } = await os.inputText({
title: i18n.ts.lookup,
});
if (canceled) return;
if (query.startsWith('@') && !query.includes(' ')) {
_router.push(`/${query}`);
return;
}
if (query.startsWith('#')) {
_router.push(`/tags/${encodeURIComponent(query.substr(1))}`);
return;
}
if (query.startsWith('https://')) {
const promise = os.api('ap/show', {
uri: query,
});
os.promiseDialog(promise, null, null, i18n.ts.fetchingAsApObject);
const res = await promise;
if (res.type === 'User') {
_router.push(`/@${res.object.username}@${res.object.host}`);
} else if (res.type === 'Note') {
_router.push(`/notes/${res.object.id}`);
}
return;
}
}

View File

@@ -10,7 +10,10 @@ export function getProxiedImageUrl(imageUrl: string, type?: 'preview', mustOrigi
imageUrl = (new URL(imageUrl)).searchParams.get('url') ?? imageUrl;
}
return `${mustOrigin ? localProxy : instance.mediaProxy}/image.webp?${query({
return `${mustOrigin ? localProxy : instance.mediaProxy}/${
type === 'preview' ? 'preview.webp'
: 'image.webp'
}?${query({
url: imageUrl,
fallback: '1',
...(type ? { [type]: '1' } : {}),

View File

@@ -133,8 +133,8 @@ export function setVolume(audio: HTMLAudioElement, volume: number): HTMLAudioEle
return audio;
}
export function play(type: string) {
const sound = ColdDeviceStorage.get('sound_' + type as any);
export function play(type: 'noteMy' | 'note' | 'antenna' | 'channel' | 'notification') {
const sound = ColdDeviceStorage.get(`sound_${type}`);
if (sound.type == null) return;
playFile(sound.type, sound.volume);
}

View File

@@ -0,0 +1,6 @@
/// <reference types="@testing-library/jest-dom"/>
export async function tick(): Promise<void> {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
await new Promise((globalThis.requestIdleCallback ?? setTimeout) as never);
}