Merge remote-tracking branch 'upstream/develop' into refactor-frontend-types

This commit is contained in:
yukineko 2024-01-08 15:12:37 +09:00
commit afa82e2553
No known key found for this signature in database
GPG key ID: E5BACB72109B7B90
146 changed files with 3366 additions and 846 deletions

View file

@ -22,6 +22,7 @@ import { getAccountFromId } from '@/scripts/get-account-from-id.js';
import { deckStore } from '@/ui/deck/deck-store.js';
import { miLocalStorage } from '@/local-storage.js';
import { fetchCustomEmojis } from '@/custom-emojis.js';
import { setupRouter } from '@/global/router/definition.js';
export async function common(createVue: () => App<Element>) {
console.info(`Misskey v${version}`);
@ -241,6 +242,8 @@ export async function common(createVue: () => App<Element>) {
const app = createVue();
setupRouter(app);
if (_DEV_) {
app.config.performance = true;
}

View file

@ -3,23 +3,23 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { createApp, markRaw, defineAsyncComponent } from 'vue';
import { createApp, defineAsyncComponent, markRaw } from 'vue';
import { common } from './common.js';
import { ui } from '@/config.js';
import { i18n } from '@/i18n.js';
import { confirm, alert, post, popup, toast } from '@/os.js';
import { alert, confirm, popup, post, toast } from '@/os.js';
import { useStream } from '@/stream.js';
import * as sound from '@/scripts/sound.js';
import { $i, updateAccount, signout } from '@/account.js';
import { defaultStore, ColdDeviceStorage } from '@/store.js';
import { $i, signout, updateAccount } from '@/account.js';
import { ColdDeviceStorage, defaultStore } from '@/store.js';
import { makeHotkey } from '@/scripts/hotkey.js';
import { reactionPicker } from '@/scripts/reaction-picker.js';
import { miLocalStorage } from '@/local-storage.js';
import { claimAchievement, claimedAchievements } from '@/scripts/achievements.js';
import { mainRouter } from '@/router.js';
import { initializeSw } from '@/scripts/initialize-sw.js';
import { deckStore } from '@/ui/deck/deck-store.js';
import { emojiPicker } from '@/scripts/emoji-picker.js';
import { mainRouter } from '@/global/router/main.js';
export async function mainBoot() {
const { isClientUpdated } = await common(() => createApp(

View file

@ -6,12 +6,16 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div>
<span v-if="!available">Loading<MkEllipsis/></span>
<div ref="captchaEl"></div>
<div v-if="props.provider == 'mcaptcha'">
<div id="mcaptcha__widget-container" class="m-captcha-style"></div>
<div ref="captchaEl"></div>
</div>
<div v-else ref="captchaEl"></div>
</div>
</template>
<script lang="ts" setup>
import { ref, shallowRef, computed, onMounted, onBeforeUnmount, watch } from 'vue';
import { ref, shallowRef, computed, onMounted, onBeforeUnmount, watch, onUnmounted } from 'vue';
import { defaultStore } from '@/store.js';
// APIs provided by Captcha services
@ -25,7 +29,7 @@ export type Captcha = {
getResponse(id: string): string;
};
export type CaptchaProvider = 'hcaptcha' | 'recaptcha' | 'turnstile';
export type CaptchaProvider = 'hcaptcha' | 'recaptcha' | 'turnstile' | 'mcaptcha';
type CaptchaContainer = {
readonly [_ in CaptchaProvider]?: Captcha;
@ -38,6 +42,7 @@ declare global {
const props = defineProps<{
provider: CaptchaProvider;
sitekey: string | null; // null will show error on request
instanceUrl?: string | null;
modelValue?: string | null;
}>();
@ -54,6 +59,7 @@ const variable = computed(() => {
case 'hcaptcha': return 'hcaptcha';
case 'recaptcha': return 'grecaptcha';
case 'turnstile': return 'turnstile';
case 'mcaptcha': return 'mcaptcha';
}
});
@ -64,6 +70,7 @@ const src = computed(() => {
case 'hcaptcha': return 'https://js.hcaptcha.com/1/api.js?render=explicit&recaptchacompat=off';
case 'recaptcha': return 'https://www.recaptcha.net/recaptcha/api.js?render=explicit';
case 'turnstile': return 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
case 'mcaptcha': return null;
}
});
@ -71,9 +78,9 @@ const scriptId = computed(() => `script-${props.provider}`);
const captcha = computed<Captcha>(() => window[variable.value] || {} as unknown as Captcha);
if (loaded) {
if (loaded || props.provider === 'mcaptcha') {
available.value = true;
} else {
} else if (src.value !== null) {
(document.getElementById(scriptId.value) ?? document.head.appendChild(Object.assign(document.createElement('script'), {
async: true,
id: scriptId.value,
@ -86,7 +93,7 @@ function reset() {
if (captcha.value.reset) captcha.value.reset();
}
function requestRender() {
async function requestRender() {
if (captcha.value.render && captchaEl.value instanceof Element) {
captcha.value.render(captchaEl.value, {
sitekey: props.sitekey,
@ -95,6 +102,15 @@ function requestRender() {
'expired-callback': callback,
'error-callback': callback,
});
} else if (props.provider === 'mcaptcha' && props.instanceUrl && props.sitekey) {
const { default: Widget } = await import('@mcaptcha/vanilla-glue');
// @ts-expect-error avoid typecheck error
new Widget({
siteKey: {
instanceUrl: new URL(props.instanceUrl),
key: props.sitekey,
},
});
} else {
window.setTimeout(requestRender, 1);
}
@ -104,14 +120,27 @@ function callback(response?: string) {
emit('update:modelValue', typeof response === 'string' ? response : null);
}
function onReceivedMessage(message: MessageEvent) {
if (message.data.token) {
if (props.instanceUrl && new URL(message.origin).host === new URL(props.instanceUrl).host) {
callback(<string>message.data.token);
}
}
}
onMounted(() => {
if (available.value) {
window.addEventListener('message', onReceivedMessage);
requestRender();
} else {
watch(available, requestRender);
}
});
onUnmounted(() => {
window.removeEventListener('message', onReceivedMessage);
});
onBeforeUnmount(() => {
reset();
});

View file

@ -45,9 +45,9 @@ import bytes from '@/filters/bytes.js';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
import { $i } from '@/account.js';
import { useRouter } from '@/router.js';
import { getDriveFileMenu } from '@/scripts/get-drive-file-menu.js';
import { deviceKind } from '@/scripts/device-kind.js';
import { useRouter } from '@/global/router/supplier.js';
const router = useRouter();

View file

@ -23,26 +23,26 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<div ref="contents" :class="$style.root" style="container-type: inline-size;">
<RouterView :key="reloadCount" :router="router"/>
<RouterView :key="reloadCount" :router="windowRouter"/>
</div>
</MkWindow>
</template>
<script lang="ts" setup>
import { ComputedRef, onMounted, onUnmounted, provide, shallowRef, ref, computed } from 'vue';
import { computed, ComputedRef, onMounted, onUnmounted, provide, ref, shallowRef } from 'vue';
import RouterView from '@/components/global/RouterView.vue';
import MkWindow from '@/components/MkWindow.vue';
import { popout as _popout } from '@/scripts/popout.js';
import copyToClipboard from '@/scripts/copy-to-clipboard.js';
import { url } from '@/config.js';
import { mainRouter, routes, page } from '@/router.js';
import { $i } from '@/account.js';
import { Router, useScrollPositionManager } from '@/nirax.js';
import { useScrollPositionManager } from '@/nirax.js';
import { i18n } from '@/i18n.js';
import { PageMetadata, provideMetadataReceiver } from '@/scripts/page-metadata.js';
import { openingWindowsCount } from '@/os.js';
import { claimAchievement } from '@/scripts/achievements.js';
import { getScrollContainer } from '@/scripts/scroll.js';
import { useRouterFactory } from '@/global/router/supplier.js';
import { mainRouter } from '@/global/router/main.js';
const props = defineProps<{
initialPath: string;
@ -52,14 +52,15 @@ defineEmits<{
(ev: 'closed'): void;
}>();
const router = new Router(routes, props.initialPath, !!$i, page(() => import('@/pages/not-found.vue')));
const routerFactory = useRouterFactory();
const windowRouter = routerFactory(props.initialPath);
const contents = shallowRef<HTMLElement>();
const pageMetadata = ref<null | ComputedRef<PageMetadata>>();
const windowEl = shallowRef<InstanceType<typeof MkWindow>>();
const history = ref<{ path: string; key: any; }[]>([{
path: router.getCurrentPath(),
key: router.getCurrentKey(),
path: windowRouter.getCurrentPath(),
key: windowRouter.getCurrentKey(),
}]);
const buttonsLeft = computed(() => {
const buttons = [];
@ -88,11 +89,11 @@ const buttonsRight = computed(() => {
});
const reloadCount = ref(0);
router.addListener('push', ctx => {
windowRouter.addListener('push', ctx => {
history.value.push({ path: ctx.path, key: ctx.key });
});
provide('router', router);
provide('router', windowRouter);
provideMetadataReceiver((info) => {
pageMetadata.value = info;
});
@ -112,20 +113,20 @@ const contextmenu = computed(() => ([{
icon: 'ti ti-external-link',
text: i18n.ts.openInNewTab,
action: () => {
window.open(url + router.getCurrentPath(), '_blank', 'noopener');
window.open(url + windowRouter.getCurrentPath(), '_blank', 'noopener');
windowEl.value.close();
},
}, {
icon: 'ti ti-link',
text: i18n.ts.copyLink,
action: () => {
copyToClipboard(url + router.getCurrentPath());
copyToClipboard(url + windowRouter.getCurrentPath());
},
}]));
function back() {
history.value.pop();
router.replace(history.value.at(-1)!.path, history.value.at(-1)!.key);
windowRouter.replace(history.value.at(-1)!.path, history.value.at(-1)!.key);
}
function reload() {
@ -137,16 +138,16 @@ function close() {
}
function expand() {
mainRouter.push(router.getCurrentPath(), 'forcePage');
mainRouter.push(windowRouter.getCurrentPath(), 'forcePage');
windowEl.value.close();
}
function popout() {
_popout(router.getCurrentPath(), windowEl.value.$el);
_popout(windowRouter.getCurrentPath(), windowEl.value.$el);
windowEl.value.close();
}
useScrollPositionManager(() => getScrollContainer(contents.value), router);
useScrollPositionManager(() => getScrollContainer(contents.value), windowRouter);
onMounted(() => {
openingWindowsCount.value++;

View file

@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div :class="$style.root" :style="{ zIndex, top: `${y - 64}px`, left: `${x - 64}px` }">
<span class="text" :class="{ up }">+1</span>
<span class="text" :class="{ up }">+{{ value }}</span>
</div>
</template>
@ -16,7 +16,9 @@ import * as os from '@/os.js';
const props = withDefaults(defineProps<{
x: number;
y: number;
value?: number;
}>(), {
value: 1,
});
const emit = defineEmits<{
@ -40,6 +42,7 @@ onMounted(() => {
<style lang="scss" module>
.root {
user-select: none;
pointer-events: none;
position: fixed;
width: 128px;

View file

@ -63,6 +63,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
</MkInput>
<MkCaptcha v-if="instance.enableHcaptcha" ref="hcaptcha" v-model="hCaptchaResponse" :class="$style.captcha" provider="hcaptcha" :sitekey="instance.hcaptchaSiteKey"/>
<MkCaptcha v-if="instance.enableMcaptcha" ref="mcaptcha" v-model="mCaptchaResponse" :class="$style.captcha" provider="mcaptcha" :sitekey="instance.mcaptchaSiteKey" :instanceUrl="instance.mcaptchaInstanceUrl"/>
<MkCaptcha v-if="instance.enableRecaptcha" ref="recaptcha" v-model="reCaptchaResponse" :class="$style.captcha" provider="recaptcha" :sitekey="instance.recaptchaSiteKey"/>
<MkCaptcha v-if="instance.enableTurnstile" ref="turnstile" v-model="turnstileResponse" :class="$style.captcha" provider="turnstile" :sitekey="instance.turnstileSiteKey"/>
<MkButton type="submit" :disabled="shouldDisableSubmitting" large gradate rounded data-cy-signup-submit style="margin: 0 auto;">
@ -117,6 +118,7 @@ const passwordStrength = ref<'' | 'low' | 'medium' | 'high'>('');
const passwordRetypeState = ref<null | 'match' | 'not-match'>(null);
const submitting = ref<boolean>(false);
const hCaptchaResponse = ref<string | null>(null);
const mCaptchaResponse = ref<string | null>(null);
const reCaptchaResponse = ref<string | null>(null);
const turnstileResponse = ref<string | null>(null);
const usernameAbortController = ref<null | AbortController>(null);
@ -125,6 +127,7 @@ const emailAbortController = ref<null | AbortController>(null);
const shouldDisableSubmitting = computed((): boolean => {
return submitting.value ||
instance.enableHcaptcha && !hCaptchaResponse.value ||
instance.enableMcaptcha && !mCaptchaResponse.value ||
instance.enableRecaptcha && !reCaptchaResponse.value ||
instance.enableTurnstile && !turnstileResponse.value ||
instance.emailRequiredForSignup && emailState.value !== 'ok' ||
@ -252,6 +255,7 @@ async function onSubmit(): Promise<void> {
emailAddress: email.value,
invitationCode: invitationCode.value,
'hcaptcha-response': hCaptchaResponse.value,
'm-captcha-response': mCaptchaResponse.value,
'g-recaptcha-response': reCaptchaResponse.value,
'turnstile-response': turnstileResponse.value,
});

View file

@ -132,6 +132,7 @@ function connectChannel() {
connection.on('mention', onNote);
} else if (props.src === 'list') {
connection = stream.useChannel('userList', {
withRenotes: props.withRenotes,
withFiles: props.onlyFiles ? true : undefined,
listId: props.list,
});
@ -198,6 +199,7 @@ function updatePaginationQuery() {
} else if (props.src === 'list') {
endpoint = 'notes/user-list-timeline';
query = {
withRenotes: props.withRenotes,
withFiles: props.onlyFiles ? true : undefined,
listId: props.list,
};
@ -236,8 +238,9 @@ function refreshEndpointAndChannel() {
updatePaginationQuery();
}
// withRenotes
// IDTL
watch(() => [props.list, props.antenna, props.channel, props.role], refreshEndpointAndChannel);
watch(() => [props.list, props.antenna, props.channel, props.role, props.withRenotes], refreshEndpointAndChannel);
//
refreshEndpointAndChannel();

View file

@ -15,7 +15,7 @@ import * as os from '@/os.js';
import copyToClipboard from '@/scripts/copy-to-clipboard.js';
import { url } from '@/config.js';
import { i18n } from '@/i18n.js';
import { useRouter } from '@/router.js';
import { useRouter } from '@/global/router/supplier.js';
const props = withDefaults(defineProps<{
to: string;

View file

@ -5,15 +5,14 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<img v-if="!useOsNativeEmojis" :class="$style.root" :src="url" :alt="props.emoji" decoding="async" @pointerenter="computeTitle" @click="onClick"/>
<span v-else-if="useOsNativeEmojis" :alt="props.emoji" @pointerenter="computeTitle" @click="onClick">{{ props.emoji }}</span>
<span v-else>{{ emoji }}</span>
<span v-else :alt="props.emoji" @pointerenter="computeTitle" @click="onClick">{{ colorizedNativeEmoji }}</span>
</template>
<script lang="ts" setup>
import { computed, inject } from 'vue';
import { char2twemojiFilePath, char2fluentEmojiFilePath } from '@/scripts/emoji-base.js';
import { defaultStore } from '@/store.js';
import { getEmojiName } from '@/scripts/emojilist.js';
import { colorizeEmoji, getEmojiName } from '@/scripts/emojilist.js';
import * as os from '@/os.js';
import copyToClipboard from '@/scripts/copy-to-clipboard.js';
import * as sound from '@/scripts/sound.js';
@ -30,9 +29,8 @@ const react = inject<((name: string) => void) | null>('react', null);
const char2path = defaultStore.state.emojiStyle === 'twemoji' ? char2twemojiFilePath : char2fluentEmojiFilePath;
const useOsNativeEmojis = computed(() => defaultStore.state.emojiStyle === 'native');
const url = computed(() => {
return char2path(props.emoji);
});
const url = computed(() => char2path(props.emoji));
const colorizedNativeEmoji = computed(() => colorizeEmoji(props.emoji));
// Searching from an array with 2000 items for every emoji felt like too energy-consuming, so I decided to do it lazily on pointerenter
function computeTitle(event: PointerEvent): void {

View file

@ -16,12 +16,12 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { inject, onBeforeUnmount, provide, shallowRef, ref } from 'vue';
import { Resolved, Router } from '@/nirax.js';
import { inject, onBeforeUnmount, provide, ref, shallowRef } from 'vue';
import { IRouter, Resolved } from '@/nirax.js';
import { defaultStore } from '@/store.js';
const props = defineProps<{
router?: Router;
router?: IRouter;
}>();
const router = props.router ?? inject('router');

View file

@ -0,0 +1,571 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { App, AsyncComponentLoader, defineAsyncComponent, provide } from 'vue';
import { IRouter, Router } from '@/nirax.js';
import { $i, iAmModerator } from '@/account.js';
import MkLoading from '@/pages/_loading_.vue';
import MkError from '@/pages/_error_.vue';
import { setMainRouter } from '@/global/router/main.js';
const page = (loader: AsyncComponentLoader<any>) => defineAsyncComponent({
loader: loader,
loadingComponent: MkLoading,
errorComponent: MkError,
});
const routes = [{
path: '/@:initUser/pages/:initPageName/view-source',
component: page(() => import('@/pages/page-editor/page-editor.vue')),
}, {
path: '/@:username/pages/:pageName',
component: page(() => import('@/pages/page.vue')),
}, {
path: '/@:acct/following',
component: page(() => import('@/pages/user/following.vue')),
}, {
path: '/@:acct/followers',
component: page(() => import('@/pages/user/followers.vue')),
}, {
name: 'user',
path: '/@:acct/:page?',
component: page(() => import('@/pages/user/index.vue')),
}, {
name: 'note',
path: '/notes/:noteId',
component: page(() => import('@/pages/note.vue')),
}, {
name: 'list',
path: '/list/:listId',
component: page(() => import('@/pages/list.vue')),
}, {
path: '/clips/:clipId',
component: page(() => import('@/pages/clip.vue')),
}, {
path: '/instance-info/:host',
component: page(() => import('@/pages/instance-info.vue')),
}, {
name: 'settings',
path: '/settings',
component: page(() => import('@/pages/settings/index.vue')),
loginRequired: true,
children: [{
path: '/profile',
name: 'profile',
component: page(() => import('@/pages/settings/profile.vue')),
}, {
path: '/avatar-decoration',
name: 'avatarDecoration',
component: page(() => import('@/pages/settings/avatar-decoration.vue')),
}, {
path: '/roles',
name: 'roles',
component: page(() => import('@/pages/settings/roles.vue')),
}, {
path: '/privacy',
name: 'privacy',
component: page(() => import('@/pages/settings/privacy.vue')),
}, {
path: '/emoji-picker',
name: 'emojiPicker',
component: page(() => import('@/pages/settings/emoji-picker.vue')),
}, {
path: '/drive',
name: 'drive',
component: page(() => import('@/pages/settings/drive.vue')),
}, {
path: '/drive/cleaner',
name: 'drive',
component: page(() => import('@/pages/settings/drive-cleaner.vue')),
}, {
path: '/notifications',
name: 'notifications',
component: page(() => import('@/pages/settings/notifications.vue')),
}, {
path: '/email',
name: 'email',
component: page(() => import('@/pages/settings/email.vue')),
}, {
path: '/security',
name: 'security',
component: page(() => import('@/pages/settings/security.vue')),
}, {
path: '/general',
name: 'general',
component: page(() => import('@/pages/settings/general.vue')),
}, {
path: '/theme/install',
name: 'theme',
component: page(() => import('@/pages/settings/theme.install.vue')),
}, {
path: '/theme/manage',
name: 'theme',
component: page(() => import('@/pages/settings/theme.manage.vue')),
}, {
path: '/theme',
name: 'theme',
component: page(() => import('@/pages/settings/theme.vue')),
}, {
path: '/navbar',
name: 'navbar',
component: page(() => import('@/pages/settings/navbar.vue')),
}, {
path: '/statusbar',
name: 'statusbar',
component: page(() => import('@/pages/settings/statusbar.vue')),
}, {
path: '/sounds',
name: 'sounds',
component: page(() => import('@/pages/settings/sounds.vue')),
}, {
path: '/plugin/install',
name: 'plugin',
component: page(() => import('@/pages/settings/plugin.install.vue')),
}, {
path: '/plugin',
name: 'plugin',
component: page(() => import('@/pages/settings/plugin.vue')),
}, {
path: '/import-export',
name: 'import-export',
component: page(() => import('@/pages/settings/import-export.vue')),
}, {
path: '/mute-block',
name: 'mute-block',
component: page(() => import('@/pages/settings/mute-block.vue')),
}, {
path: '/api',
name: 'api',
component: page(() => import('@/pages/settings/api.vue')),
}, {
path: '/apps',
name: 'api',
component: page(() => import('@/pages/settings/apps.vue')),
}, {
path: '/webhook/edit/:webhookId',
name: 'webhook',
component: page(() => import('@/pages/settings/webhook.edit.vue')),
}, {
path: '/webhook/new',
name: 'webhook',
component: page(() => import('@/pages/settings/webhook.new.vue')),
}, {
path: '/webhook',
name: 'webhook',
component: page(() => import('@/pages/settings/webhook.vue')),
}, {
path: '/deck',
name: 'deck',
component: page(() => import('@/pages/settings/deck.vue')),
}, {
path: '/preferences-backups',
name: 'preferences-backups',
component: page(() => import('@/pages/settings/preferences-backups.vue')),
}, {
path: '/migration',
name: 'migration',
component: page(() => import('@/pages/settings/migration.vue')),
}, {
path: '/custom-css',
name: 'general',
component: page(() => import('@/pages/settings/custom-css.vue')),
}, {
path: '/accounts',
name: 'profile',
component: page(() => import('@/pages/settings/accounts.vue')),
}, {
path: '/other',
name: 'other',
component: page(() => import('@/pages/settings/other.vue')),
}, {
path: '/',
component: page(() => import('@/pages/_empty_.vue')),
}],
}, {
path: '/reset-password/:token?',
component: page(() => import('@/pages/reset-password.vue')),
}, {
path: '/signup-complete/:code',
component: page(() => import('@/pages/signup-complete.vue')),
}, {
path: '/announcements',
component: page(() => import('@/pages/announcements.vue')),
}, {
path: '/about',
component: page(() => import('@/pages/about.vue')),
hash: 'initialTab',
}, {
path: '/about-misskey',
component: page(() => import('@/pages/about-misskey.vue')),
}, {
path: '/invite',
name: 'invite',
component: page(() => import('@/pages/invite.vue')),
}, {
path: '/ads',
component: page(() => import('@/pages/ads.vue')),
}, {
path: '/theme-editor',
component: page(() => import('@/pages/theme-editor.vue')),
loginRequired: true,
}, {
path: '/roles/:role',
component: page(() => import('@/pages/role.vue')),
}, {
path: '/user-tags/:tag',
component: page(() => import('@/pages/user-tag.vue')),
}, {
path: '/explore',
component: page(() => import('@/pages/explore.vue')),
hash: 'initialTab',
}, {
path: '/search',
component: page(() => import('@/pages/search.vue')),
query: {
q: 'query',
channel: 'channel',
type: 'type',
origin: 'origin',
},
}, {
path: '/authorize-follow',
component: page(() => import('@/pages/follow.vue')),
loginRequired: true,
}, {
path: '/share',
component: page(() => import('@/pages/share.vue')),
loginRequired: true,
}, {
path: '/api-console',
component: page(() => import('@/pages/api-console.vue')),
loginRequired: true,
}, {
path: '/scratchpad',
component: page(() => import('@/pages/scratchpad.vue')),
}, {
path: '/auth/:token',
component: page(() => import('@/pages/auth.vue')),
}, {
path: '/miauth/:session',
component: page(() => import('@/pages/miauth.vue')),
query: {
callback: 'callback',
name: 'name',
icon: 'icon',
permission: 'permission',
},
}, {
path: '/oauth/authorize',
component: page(() => import('@/pages/oauth.vue')),
}, {
path: '/tags/:tag',
component: page(() => import('@/pages/tag.vue')),
}, {
path: '/pages/new',
component: page(() => import('@/pages/page-editor/page-editor.vue')),
loginRequired: true,
}, {
path: '/pages/edit/:initPageId',
component: page(() => import('@/pages/page-editor/page-editor.vue')),
loginRequired: true,
}, {
path: '/pages',
component: page(() => import('@/pages/pages.vue')),
}, {
path: '/play/:id/edit',
component: page(() => import('@/pages/flash/flash-edit.vue')),
loginRequired: true,
}, {
path: '/play/new',
component: page(() => import('@/pages/flash/flash-edit.vue')),
loginRequired: true,
}, {
path: '/play/:id',
component: page(() => import('@/pages/flash/flash.vue')),
}, {
path: '/play',
component: page(() => import('@/pages/flash/flash-index.vue')),
}, {
path: '/gallery/:postId/edit',
component: page(() => import('@/pages/gallery/edit.vue')),
loginRequired: true,
}, {
path: '/gallery/new',
component: page(() => import('@/pages/gallery/edit.vue')),
loginRequired: true,
}, {
path: '/gallery/:postId',
component: page(() => import('@/pages/gallery/post.vue')),
}, {
path: '/gallery',
component: page(() => import('@/pages/gallery/index.vue')),
}, {
path: '/channels/:channelId/edit',
component: page(() => import('@/pages/channel-editor.vue')),
loginRequired: true,
}, {
path: '/channels/new',
component: page(() => import('@/pages/channel-editor.vue')),
loginRequired: true,
}, {
path: '/channels/:channelId',
component: page(() => import('@/pages/channel.vue')),
}, {
path: '/channels',
component: page(() => import('@/pages/channels.vue')),
}, {
path: '/custom-emojis-manager',
component: page(() => import('@/pages/custom-emojis-manager.vue')),
}, {
path: '/avatar-decorations',
name: 'avatarDecorations',
component: page(() => import('@/pages/avatar-decorations.vue')),
}, {
path: '/registry/keys/:domain/:path(*)?',
component: page(() => import('@/pages/registry.keys.vue')),
}, {
path: '/registry/value/:domain/:path(*)?',
component: page(() => import('@/pages/registry.value.vue')),
}, {
path: '/registry',
component: page(() => import('@/pages/registry.vue')),
}, {
path: '/install-extentions',
component: page(() => import('@/pages/install-extentions.vue')),
loginRequired: true,
}, {
path: '/admin/user/:userId',
component: iAmModerator ? page(() => import('@/pages/admin-user.vue')) : page(() => import('@/pages/not-found.vue')),
}, {
path: '/admin/file/:fileId',
component: iAmModerator ? page(() => import('@/pages/admin-file.vue')) : page(() => import('@/pages/not-found.vue')),
}, {
path: '/admin',
component: iAmModerator ? page(() => import('@/pages/admin/index.vue')) : page(() => import('@/pages/not-found.vue')),
children: [{
path: '/overview',
name: 'overview',
component: page(() => import('@/pages/admin/overview.vue')),
}, {
path: '/users',
name: 'users',
component: page(() => import('@/pages/admin/users.vue')),
}, {
path: '/emojis',
name: 'emojis',
component: page(() => import('@/pages/custom-emojis-manager.vue')),
}, {
path: '/avatar-decorations',
name: 'avatarDecorations',
component: page(() => import('@/pages/avatar-decorations.vue')),
}, {
path: '/queue',
name: 'queue',
component: page(() => import('@/pages/admin/queue.vue')),
}, {
path: '/files',
name: 'files',
component: page(() => import('@/pages/admin/files.vue')),
}, {
path: '/federation',
name: 'federation',
component: page(() => import('@/pages/admin/federation.vue')),
}, {
path: '/announcements',
name: 'announcements',
component: page(() => import('@/pages/admin/announcements.vue')),
}, {
path: '/ads',
name: 'ads',
component: page(() => import('@/pages/admin/ads.vue')),
}, {
path: '/roles/:id/edit',
name: 'roles',
component: page(() => import('@/pages/admin/roles.edit.vue')),
}, {
path: '/roles/new',
name: 'roles',
component: page(() => import('@/pages/admin/roles.edit.vue')),
}, {
path: '/roles/:id',
name: 'roles',
component: page(() => import('@/pages/admin/roles.role.vue')),
}, {
path: '/roles',
name: 'roles',
component: page(() => import('@/pages/admin/roles.vue')),
}, {
path: '/database',
name: 'database',
component: page(() => import('@/pages/admin/database.vue')),
}, {
path: '/abuses',
name: 'abuses',
component: page(() => import('@/pages/admin/abuses.vue')),
}, {
path: '/modlog',
name: 'modlog',
component: page(() => import('@/pages/admin/modlog.vue')),
}, {
path: '/settings',
name: 'settings',
component: page(() => import('@/pages/admin/settings.vue')),
}, {
path: '/branding',
name: 'branding',
component: page(() => import('@/pages/admin/branding.vue')),
}, {
path: '/moderation',
name: 'moderation',
component: page(() => import('@/pages/admin/moderation.vue')),
}, {
path: '/email-settings',
name: 'email-settings',
component: page(() => import('@/pages/admin/email-settings.vue')),
}, {
path: '/object-storage',
name: 'object-storage',
component: page(() => import('@/pages/admin/object-storage.vue')),
}, {
path: '/security',
name: 'security',
component: page(() => import('@/pages/admin/security.vue')),
}, {
path: '/relays',
name: 'relays',
component: page(() => import('@/pages/admin/relays.vue')),
}, {
path: '/instance-block',
name: 'instance-block',
component: page(() => import('@/pages/admin/instance-block.vue')),
}, {
path: '/proxy-account',
name: 'proxy-account',
component: page(() => import('@/pages/admin/proxy-account.vue')),
}, {
path: '/external-services',
name: 'external-services',
component: page(() => import('@/pages/admin/external-services.vue')),
}, {
path: '/other-settings',
name: 'other-settings',
component: page(() => import('@/pages/admin/other-settings.vue')),
}, {
path: '/server-rules',
name: 'server-rules',
component: page(() => import('@/pages/admin/server-rules.vue')),
}, {
path: '/invites',
name: 'invites',
component: page(() => import('@/pages/admin/invites.vue')),
}, {
path: '/',
component: page(() => import('@/pages/_empty_.vue')),
}],
}, {
path: '/my/notifications',
component: page(() => import('@/pages/notifications.vue')),
loginRequired: true,
}, {
path: '/my/favorites',
component: page(() => import('@/pages/favorites.vue')),
loginRequired: true,
}, {
path: '/my/achievements',
component: page(() => import('@/pages/achievements.vue')),
loginRequired: true,
}, {
path: '/my/drive/folder/:folder',
component: page(() => import('@/pages/drive.vue')),
loginRequired: true,
}, {
path: '/my/drive',
component: page(() => import('@/pages/drive.vue')),
loginRequired: true,
}, {
path: '/my/drive/file/:fileId',
component: page(() => import('@/pages/drive.file.vue')),
loginRequired: true,
}, {
path: '/my/follow-requests',
component: page(() => import('@/pages/follow-requests.vue')),
loginRequired: true,
}, {
path: '/my/lists/:listId',
component: page(() => import('@/pages/my-lists/list.vue')),
loginRequired: true,
}, {
path: '/my/lists',
component: page(() => import('@/pages/my-lists/index.vue')),
loginRequired: true,
}, {
path: '/my/clips',
component: page(() => import('@/pages/my-clips/index.vue')),
loginRequired: true,
}, {
path: '/my/antennas/create',
component: page(() => import('@/pages/my-antennas/create.vue')),
loginRequired: true,
}, {
path: '/my/antennas/:antennaId',
component: page(() => import('@/pages/my-antennas/edit.vue')),
loginRequired: true,
}, {
path: '/my/antennas',
component: page(() => import('@/pages/my-antennas/index.vue')),
loginRequired: true,
}, {
path: '/timeline/list/:listId',
component: page(() => import('@/pages/user-list-timeline.vue')),
loginRequired: true,
}, {
path: '/timeline/antenna/:antennaId',
component: page(() => import('@/pages/antenna-timeline.vue')),
loginRequired: true,
}, {
path: '/clicker',
component: page(() => import('@/pages/clicker.vue')),
loginRequired: true,
}, {
path: '/bubble-game',
component: page(() => import('@/pages/drop-and-fusion.vue')),
loginRequired: true,
}, {
path: '/timeline',
component: page(() => import('@/pages/timeline.vue')),
}, {
name: 'index',
path: '/',
component: $i ? page(() => import('@/pages/timeline.vue')) : page(() => import('@/pages/welcome.vue')),
globalCacheKey: 'index',
}, {
path: '/:(*)',
component: page(() => import('@/pages/not-found.vue')),
}];
function createRouterImpl(path: string): IRouter {
return new Router(routes, path, !!$i, page(() => import('@/pages/not-found.vue')));
}
/**
* {@link Router}{@link mainRouter}
* {@link Router}{@link provide}`routerFactory`
*/
export function setupRouter(app: App) {
app.provide('routerFactory', createRouterImpl);
const mainRouter = createRouterImpl(location.pathname + location.search + location.hash);
window.history.replaceState({ key: mainRouter.getCurrentKey() }, '', location.href);
window.addEventListener('popstate', (event) => {
mainRouter.replace(location.pathname + location.search + location.hash, event.state?.key);
});
mainRouter.addListener('push', ctx => {
window.history.pushState({ key: ctx.key }, '', ctx.path);
});
setMainRouter(mainRouter);
}

View file

@ -0,0 +1,163 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { ShallowRef } from 'vue';
import { EventEmitter } from 'eventemitter3';
import { IRouter, Resolved, RouteDef, RouterEvent } from '@/nirax.js';
function getMainRouter(): IRouter {
const router = mainRouterHolder;
if (!router) {
throw new Error('mainRouter is not found.');
}
return router;
}
/**
*
* {@link setupRouter}
*/
export function setMainRouter(router: IRouter) {
if (mainRouterHolder) {
throw new Error('mainRouter is already exists.');
}
mainRouterHolder = router;
}
/**
* {@link mainRouter}
* {@link mainRouter}undefinedになる期間がある
* undefined込みにしたくないのでこのクラスを緩衝材として使用する
*/
class MainRouterProxy implements IRouter {
private supplier: () => IRouter;
constructor(supplier: () => IRouter) {
this.supplier = supplier;
}
get current(): Resolved {
return this.supplier().current;
}
get currentRef(): ShallowRef<Resolved> {
return this.supplier().currentRef;
}
get currentRoute(): ShallowRef<RouteDef> {
return this.supplier().currentRoute;
}
get navHook(): ((path: string, flag?: any) => boolean) | null {
return this.supplier().navHook;
}
set navHook(value) {
this.supplier().navHook = value;
}
getCurrentKey(): string {
return this.supplier().getCurrentKey();
}
getCurrentPath(): any {
return this.supplier().getCurrentPath();
}
push(path: string, flag?: any): void {
this.supplier().push(path, flag);
}
replace(path: string, key?: string | null): void {
this.supplier().replace(path, key);
}
resolve(path: string): Resolved | null {
return this.supplier().resolve(path);
}
eventNames(): Array<EventEmitter.EventNames<RouterEvent>> {
return this.supplier().eventNames();
}
listeners<T extends EventEmitter.EventNames<RouterEvent>>(
event: T,
): Array<EventEmitter.EventListener<RouterEvent, T>> {
return this.supplier().listeners(event);
}
listenerCount(
event: EventEmitter.EventNames<RouterEvent>,
): number {
return this.supplier().listenerCount(event);
}
emit<T extends EventEmitter.EventNames<RouterEvent>>(
event: T,
...args: EventEmitter.EventArgs<RouterEvent, T>
): boolean {
return this.supplier().emit(event, ...args);
}
on<T extends EventEmitter.EventNames<RouterEvent>>(
event: T,
fn: EventEmitter.EventListener<RouterEvent, T>,
context?: any,
): this {
this.supplier().on(event, fn, context);
return this;
}
addListener<T extends EventEmitter.EventNames<RouterEvent>>(
event: T,
fn: EventEmitter.EventListener<RouterEvent, T>,
context?: any,
): this {
this.supplier().addListener(event, fn, context);
return this;
}
once<T extends EventEmitter.EventNames<RouterEvent>>(
event: T,
fn: EventEmitter.EventListener<RouterEvent, T>,
context?: any,
): this {
this.supplier().once(event, fn, context);
return this;
}
removeListener<T extends EventEmitter.EventNames<RouterEvent>>(
event: T,
fn?: EventEmitter.EventListener<RouterEvent, T>,
context?: any,
once?: boolean,
): this {
this.supplier().removeListener(event, fn, context, once);
return this;
}
off<T extends EventEmitter.EventNames<RouterEvent>>(
event: T,
fn?: EventEmitter.EventListener<RouterEvent, T>,
context?: any,
once?: boolean,
): this {
this.supplier().off(event, fn, context, once);
return this;
}
removeAllListeners(
event?: EventEmitter.EventNames<RouterEvent>,
): this {
this.supplier().removeAllListeners(event);
return this;
}
}
let mainRouterHolder: IRouter | null = null;
export const mainRouter: IRouter = new MainRouterProxy(getMainRouter);

View file

@ -0,0 +1,30 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { inject } from 'vue';
import { IRouter, Router } from '@/nirax.js';
import { mainRouter } from '@/global/router/main.js';
/**
* {@link Router}
* {@link setupRouter}{@link provide}{@link IRouter}
*/
export function useRouter(): IRouter {
return inject<Router | null>('router', null) ?? mainRouter;
}
/**
* {@link Router}
* {@link setupRouter}
*/
export function useRouterFactory(): (path: string) => IRouter {
const factory = inject<(path: string) => IRouter>('routerFactory');
if (!factory) {
console.error('routerFactory is not defined.');
throw new Error('routerFactory is not defined.');
}
return factory;
}

View file

@ -16,13 +16,13 @@
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self';
content="default-src 'self' https://newassets.hcaptcha.com/ https://challenges.cloudflare.com/ http://localhost:7493/;
worker-src 'self';
script-src 'self' 'unsafe-eval';
script-src 'self' 'unsafe-eval' https://*.hcaptcha.com https://challenges.cloudflare.com;
style-src 'self' 'unsafe-inline';
img-src 'self' data: www.google.com xn--931a.moe localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000;
img-src 'self' data: blob: www.google.com xn--931a.moe localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000;
media-src 'self' localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000;
connect-src 'self' localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000;"
connect-src 'self' localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000 https://newassets.hcaptcha.com;"
/>
<meta property="og:site_name" content="[DEV BUILD] Misskey" />
<meta name="viewport" content="width=device-width, initial-scale=1">

View file

@ -5,11 +5,11 @@
// NIRAX --- A lightweight router
import { EventEmitter } from 'eventemitter3';
import { Component, onMounted, shallowRef, ShallowRef } from 'vue';
import { EventEmitter } from 'eventemitter3';
import { safeURIDecode } from '@/scripts/safe-uri-decode.js';
type RouteDef = {
export type RouteDef = {
path: string;
component: Component;
query?: Record<string, string>;
@ -27,6 +27,27 @@ type ParsedPath = (string | {
optional?: boolean;
})[];
export type RouterEvent = {
change: (ctx: {
beforePath: string;
path: string;
resolved: Resolved;
key: string;
}) => void;
replace: (ctx: {
path: string;
key: string;
}) => void;
push: (ctx: {
beforePath: string;
path: string;
route: RouteDef | null;
props: Map<string, string> | null;
key: string;
}) => void;
same: () => void;
}
export type Resolved = { route: RouteDef; props: Map<string, string | boolean>; child?: Resolved; };
function parsePath(path: string): ParsedPath {
@ -54,26 +75,85 @@ function parsePath(path: string): ParsedPath {
return res;
}
export class Router extends EventEmitter<{
change: (ctx: {
beforePath: string;
path: string;
resolved: Resolved;
key: string;
}) => void;
replace: (ctx: {
path: string;
key: string;
}) => void;
push: (ctx: {
beforePath: string;
path: string;
route: RouteDef | null;
props: Map<string, string> | null;
key: string;
}) => void;
same: () => void;
}> {
export interface IRouter extends EventEmitter<RouterEvent> {
current: Resolved;
currentRef: ShallowRef<Resolved>;
currentRoute: ShallowRef<RouteDef>;
navHook: ((path: string, flag?: any) => boolean) | null;
resolve(path: string): Resolved | null;
getCurrentPath(): any;
getCurrentKey(): string;
push(path: string, flag?: any): void;
replace(path: string, key?: string | null): void;
/** @see EventEmitter */
eventNames(): Array<EventEmitter.EventNames<RouterEvent>>;
/** @see EventEmitter */
listeners<T extends EventEmitter.EventNames<RouterEvent>>(
event: T
): Array<EventEmitter.EventListener<RouterEvent, T>>;
/** @see EventEmitter */
listenerCount(
event: EventEmitter.EventNames<RouterEvent>
): number;
/** @see EventEmitter */
emit<T extends EventEmitter.EventNames<RouterEvent>>(
event: T,
...args: EventEmitter.EventArgs<RouterEvent, T>
): boolean;
/** @see EventEmitter */
on<T extends EventEmitter.EventNames<RouterEvent>>(
event: T,
fn: EventEmitter.EventListener<RouterEvent, T>,
context?: any
): this;
/** @see EventEmitter */
addListener<T extends EventEmitter.EventNames<RouterEvent>>(
event: T,
fn: EventEmitter.EventListener<RouterEvent, T>,
context?: any
): this;
/** @see EventEmitter */
once<T extends EventEmitter.EventNames<RouterEvent>>(
event: T,
fn: EventEmitter.EventListener<RouterEvent, T>,
context?: any
): this;
/** @see EventEmitter */
removeListener<T extends EventEmitter.EventNames<RouterEvent>>(
event: T,
fn?: EventEmitter.EventListener<RouterEvent, T>,
context?: any,
once?: boolean | undefined
): this;
/** @see EventEmitter */
off<T extends EventEmitter.EventNames<RouterEvent>>(
event: T,
fn?: EventEmitter.EventListener<RouterEvent, T>,
context?: any,
once?: boolean | undefined
): this;
/** @see EventEmitter */
removeAllListeners(
event?: EventEmitter.EventNames<RouterEvent>
): this;
}
export class Router extends EventEmitter<RouterEvent> implements IRouter {
private routes: RouteDef[];
public current: Resolved;
public currentRef: ShallowRef<Resolved> = shallowRef();
@ -277,7 +357,7 @@ export class Router extends EventEmitter<{
}
}
export function useScrollPositionManager(getScrollContainer: () => HTMLElement, router: Router) {
export function useScrollPositionManager(getScrollContainer: () => HTMLElement, router: IRouter) {
const scrollPosStore = new Map<string, number>();
onMounted(() => {

View file

@ -10,6 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkRadios v-model="provider">
<option :value="null">{{ i18n.ts.none }} ({{ i18n.ts.notRecommended }})</option>
<option value="hcaptcha">hCaptcha</option>
<option value="mcaptcha">mCaptcha</option>
<option value="recaptcha">reCAPTCHA</option>
<option value="turnstile">Turnstile</option>
</MkRadios>
@ -28,6 +29,24 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkCaptcha provider="hcaptcha" :sitekey="hcaptchaSiteKey || '10000000-ffff-ffff-ffff-000000000001'"/>
</FormSlot>
</template>
<template v-else-if="provider === 'mcaptcha'">
<MkInput v-model="mcaptchaSiteKey">
<template #prefix><i class="ti ti-key"></i></template>
<template #label>{{ i18n.ts.mcaptchaSiteKey }}</template>
</MkInput>
<MkInput v-model="mcaptchaSecretKey">
<template #prefix><i class="ti ti-key"></i></template>
<template #label>{{ i18n.ts.mcaptchaSecretKey }}</template>
</MkInput>
<MkInput v-model="mcaptchaInstanceUrl">
<template #prefix><i class="ti ti-link"></i></template>
<template #label>{{ i18n.ts.mcaptchaInstanceUrl }}</template>
</MkInput>
<FormSlot v-if="mcaptchaSiteKey && mcaptchaInstanceUrl">
<template #label>{{ i18n.ts.preview }}</template>
<MkCaptcha provider="mcaptcha" :sitekey="mcaptchaSiteKey" :instanceUrl="mcaptchaInstanceUrl"/>
</FormSlot>
</template>
<template v-else-if="provider === 'recaptcha'">
<MkInput v-model="recaptchaSiteKey">
<template #prefix><i class="ti ti-key"></i></template>
@ -81,6 +100,9 @@ const MkCaptcha = defineAsyncComponent(() => import('@/components/MkCaptcha.vue'
const provider = ref<CaptchaProvider | null>(null);
const hcaptchaSiteKey = ref<string | null>(null);
const hcaptchaSecretKey = ref<string | null>(null);
const mcaptchaSiteKey = ref<string | null>(null);
const mcaptchaSecretKey = ref<string | null>(null);
const mcaptchaInstanceUrl = ref<string | null>(null);
const recaptchaSiteKey = ref<string | null>(null);
const recaptchaSecretKey = ref<string | null>(null);
const turnstileSiteKey = ref<string | null>(null);
@ -90,12 +112,18 @@ async function init() {
const meta = await misskeyApi('admin/meta');
hcaptchaSiteKey.value = meta.hcaptchaSiteKey;
hcaptchaSecretKey.value = meta.hcaptchaSecretKey;
mcaptchaSiteKey.value = meta.mcaptchaSiteKey;
mcaptchaSecretKey.value = meta.mcaptchaSecretKey;
mcaptchaInstanceUrl.value = meta.mcaptchaInstanceUrl;
recaptchaSiteKey.value = meta.recaptchaSiteKey;
recaptchaSecretKey.value = meta.recaptchaSecretKey;
turnstileSiteKey.value = meta.turnstileSiteKey;
turnstileSecretKey.value = meta.turnstileSecretKey;
provider.value = meta.enableHcaptcha ? 'hcaptcha' : meta.enableRecaptcha ? 'recaptcha' : meta.enableTurnstile ? 'turnstile' : null;
provider.value = meta.enableHcaptcha ? 'hcaptcha' :
meta.enableRecaptcha ? 'recaptcha' :
meta.enableTurnstile ? 'turnstile' :
meta.enableMcaptcha ? 'mcaptcha' : null;
}
function save() {
@ -103,6 +131,10 @@ function save() {
enableHcaptcha: provider.value === 'hcaptcha',
hcaptchaSiteKey: hcaptchaSiteKey.value,
hcaptchaSecretKey: hcaptchaSecretKey.value,
enableMcaptcha: provider.value === 'mcaptcha',
mcaptchaSiteKey: mcaptchaSiteKey.value,
mcaptchaSecretKey: mcaptchaSecretKey.value,
mcaptchaInstanceUrl: mcaptchaInstanceUrl.value,
enableRecaptcha: provider.value === 'recaptcha',
recaptchaSiteKey: recaptchaSiteKey.value,
recaptchaSecretKey: recaptchaSecretKey.value,

View file

@ -36,8 +36,8 @@ import { instance } from '@/instance.js';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { lookupUser, lookupUserByEmail } from '@/scripts/lookup-user.js';
import { useRouter } from '@/router.js';
import { PageMetadata, definePageMetadata, provideMetadataReceiver } from '@/scripts/page-metadata.js';
import { useRouter } from '@/global/router/supplier.js';
const isEmpty = (x: string | null) => x == null || x === '';

View file

@ -31,9 +31,9 @@ import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { useRouter } from '@/router.js';
import MkButton from '@/components/MkButton.vue';
import { rolesCache } from '@/cache.js';
import { useRouter } from '@/global/router/supplier.js';
const router = useRouter();

View file

@ -70,12 +70,12 @@ import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { useRouter } from '@/router.js';
import MkButton from '@/components/MkButton.vue';
import MkUserCardMini from '@/components/MkUserCardMini.vue';
import MkInfo from '@/components/MkInfo.vue';
import MkPagination from '@/components/MkPagination.vue';
import { infoImageUrl } from '@/instance.js';
import { useRouter } from '@/global/router/supplier.js';
const router = useRouter();

View file

@ -237,9 +237,9 @@ import { misskeyApi } from '@/scripts/misskey-api.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { instance } from '@/instance.js';
import { useRouter } from '@/router.js';
import MkFoldableSection from '@/components/MkFoldableSection.vue';
import { ROLE_POLICIES } from '@/const.js';
import { useRouter } from '@/global/router/supplier.js';
const router = useRouter();
const baseRoleQ = ref('');

View file

@ -13,6 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #icon><i class="ti ti-shield"></i></template>
<template #label>{{ i18n.ts.botProtection }}</template>
<template v-if="enableHcaptcha" #suffix>hCaptcha</template>
<template v-else-if="enableMcaptcha" #suffix>mCaptcha</template>
<template v-else-if="enableRecaptcha" #suffix>reCAPTCHA</template>
<template v-else-if="enableTurnstile" #suffix>Turnstile</template>
<template v-else #suffix>{{ i18n.ts.none }} ({{ i18n.ts.notRecommended }})</template>
@ -155,6 +156,7 @@ import { definePageMetadata } from '@/scripts/page-metadata.js';
const summalyProxy = ref<string>('');
const enableHcaptcha = ref<boolean>(false);
const enableMcaptcha = ref<boolean>(false);
const enableRecaptcha = ref<boolean>(false);
const enableTurnstile = ref<boolean>(false);
const sensitiveMediaDetection = ref<string>('none');
@ -174,6 +176,7 @@ async function init() {
const meta = await misskeyApi('admin/meta');
summalyProxy.value = meta.summalyProxy;
enableHcaptcha.value = meta.enableHcaptcha;
enableMcaptcha.value = meta.enableMcaptcha;
enableRecaptcha.value = meta.enableRecaptcha;
enableTurnstile.value = meta.enableTurnstile;
sensitiveMediaDetection.value = meta.sensitiveMediaDetection;

View file

@ -30,9 +30,9 @@ import MkTimeline from '@/components/MkTimeline.vue';
import { scroll } from '@/scripts/scroll.js';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { useRouter } from '@/router.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { i18n } from '@/i18n.js';
import { useRouter } from '@/global/router/supplier.js';
const router = useRouter();

View file

@ -77,12 +77,12 @@ import MkColorInput from '@/components/MkColorInput.vue';
import { selectFile } from '@/scripts/select-file.js';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { useRouter } from '@/router.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { i18n } from '@/i18n.js';
import MkFolder from '@/components/MkFolder.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import MkTextarea from '@/components/MkTextarea.vue';
import { useRouter } from '@/global/router/supplier.js';
const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default));

View file

@ -75,7 +75,6 @@ import MkTimeline from '@/components/MkTimeline.vue';
import XChannelFollowButton from '@/components/MkChannelFollowButton.vue';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { useRouter } from '@/router.js';
import { $i, iAmModerator } from '@/account.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
@ -92,6 +91,7 @@ import { PageHeaderItem } from '@/types/page-header.js';
import { isSupportShare } from '@/scripts/navigator.js';
import copyToClipboard from '@/scripts/copy-to-clipboard.js';
import { miLocalStorage } from '@/local-storage.js';
import { useRouter } from '@/global/router/supplier.js';
const router = useRouter();

View file

@ -58,9 +58,9 @@ import MkInput from '@/components/MkInput.vue';
import MkRadios from '@/components/MkRadios.vue';
import MkButton from '@/components/MkButton.vue';
import MkFoldableSection from '@/components/MkFoldableSection.vue';
import { useRouter } from '@/router.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { i18n } from '@/i18n.js';
import { useRouter } from '@/global/router/supplier.js';
const router = useRouter();

View file

@ -80,7 +80,7 @@ import { infoImageUrl } from '@/instance.js';
import { i18n } from '@/i18n.js';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { useRouter } from '@/router.js';
import { useRouter } from '@/global/router/supplier.js';
const router = useRouter();

View file

@ -0,0 +1,825 @@
<!--
SPDX-FileCopyrightText: syuilo and other misskey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkStickyContainer>
<template #header><MkPageHeader/></template>
<MkSpacer :contentMax="800">
<div v-show="!gameStarted" :class="$style.root">
<div style="text-align: center;" class="_gaps">
<div :class="$style.frame">
<div :class="$style.frameInner">
<img src="/client-assets/drop-and-fusion/logo.png" style="display: block; max-width: 100%; max-height: 200px; margin: auto;"/>
</div>
</div>
<div :class="$style.frame">
<div :class="$style.frameInner">
<div class="_gaps" style="padding: 16px;">
<MkSelect v-model="gameMode">
<option value="normal">NORMAL</option>
<option value="square">SQUARE</option>
</MkSelect>
<MkButton primary gradate large rounded inline @click="start">{{ i18n.ts.start }}</MkButton>
</div>
</div>
</div>
</div>
</div>
<div v-show="gameStarted" class="_gaps_s" :class="$style.root">
<div style="display: flex;">
<div :class="$style.frame" style="flex: 1; margin-right: 10px;">
<div :class="$style.frameInner">
<b>BUBBLE GAME</b>
<div>- {{ gameMode }} -</div>
</div>
</div>
<div :class="[$style.frame, $style.stock]" style="margin-left: auto;">
<div :class="$style.frameInner" style="text-align: center;">
NEXT >>>
<TransitionGroup
:enterActiveClass="$style.transition_stock_enterActive"
:leaveActiveClass="$style.transition_stock_leaveActive"
:enterFromClass="$style.transition_stock_enterFrom"
:leaveToClass="$style.transition_stock_leaveTo"
:moveClass="$style.transition_stock_move"
>
<div v-for="x in stock" :key="x.id" style="display: inline-block;">
<img :src="game.getTextureImageUrl(x.mono)" style="width: 32px;"/>
</div>
</TransitionGroup>
</div>
</div>
</div>
<div :class="$style.main" @contextmenu.stop.prevent>
<div ref="containerEl" :class="[$style.container, { [$style.gameOver]: gameOver }]" @click.stop.prevent="onClick" @touchmove.stop.prevent="onTouchmove" @touchend="onTouchend" @mousemove="onMousemove">
<img v-if="defaultStore.state.darkMode" src="/client-assets/drop-and-fusion/frame-dark.svg" :class="$style.mainFrameImg"/>
<img v-else src="/client-assets/drop-and-fusion/frame-light.svg" :class="$style.mainFrameImg"/>
<canvas ref="canvasEl" :class="$style.canvas"/>
<Transition
:enterActiveClass="$style.transition_combo_enterActive"
:leaveActiveClass="$style.transition_combo_leaveActive"
:enterFromClass="$style.transition_combo_enterFrom"
:leaveToClass="$style.transition_combo_leaveTo"
:moveClass="$style.transition_combo_move"
>
<div v-show="combo > 1" :class="$style.combo" :style="{ fontSize: `${100 + ((comboPrev - 2) * 15)}%` }">{{ comboPrev }} Chain!</div>
</Transition>
<img v-if="currentPick" src="/client-assets/drop-and-fusion/dropper.png" :class="$style.dropper" :style="{ left: dropperX + 'px' }"/>
<Transition
:enterActiveClass="$style.transition_picked_enterActive"
:leaveActiveClass="$style.transition_picked_leaveActive"
:enterFromClass="$style.transition_picked_enterFrom"
:leaveToClass="$style.transition_picked_leaveTo"
:moveClass="$style.transition_picked_move"
mode="out-in"
>
<img v-if="currentPick" :key="currentPick.id" :src="game.getTextureImageUrl(currentPick.mono)" :class="$style.currentMono" :style="{ top: -(currentPick?.mono.size / 2) + 'px', left: (dropperX - (currentPick?.mono.size / 2)) + 'px', width: `${currentPick?.mono.size}px` }"/>
</Transition>
<template v-if="dropReady && currentPick">
<img src="/client-assets/drop-and-fusion/drop-arrow.svg" :class="$style.currentMonoArrow" :style="{ top: (currentPick.mono.size / 2) + 10 + 'px', left: (dropperX - 10) + 'px', width: `20px` }"/>
<div :class="$style.dropGuide" :style="{ left: (dropperX - 2) + 'px' }"/>
</template>
<div v-if="gameOver" :class="$style.gameOverLabel">
<div class="_gaps_s">
<img src="/client-assets/drop-and-fusion/gameover.png" style="width: 200px; max-width: 100%; display: block; margin: auto; margin-bottom: -5px;"/>
<div>SCORE: <MkNumber :value="score"/></div>
<div>MAX CHAIN: <MkNumber :value="maxCombo"/></div>
<div class="_buttonsCenter">
<MkButton primary rounded @click="restart">Restart</MkButton>
<MkButton primary rounded @click="share">Share</MkButton>
</div>
</div>
</div>
</div>
</div>
<div style="display: flex;">
<div :class="$style.frame" style="flex: 1; margin-right: 10px;">
<div :class="$style.frameInner">
<div>SCORE: <b><MkNumber :value="score"/></b> (MAX CHAIN: <b><MkNumber :value="maxCombo"/></b>)</div>
<div>HIGH SCORE: <b v-if="highScore"><MkNumber :value="highScore"/></b><b v-else>-</b></div>
</div>
</div>
<div :class="[$style.frame]" style="margin-left: auto;">
<div :class="$style.frameInner" style="text-align: center;">
<div @click="showConfig = !showConfig"><i class="ti ti-settings"></i></div>
</div>
</div>
</div>
<div v-if="showConfig" :class="$style.frame">
<div :class="$style.frameInner">
<MkRange v-model="bgmVolume" :min="0" :max="1" :step="0.0025" :textConverter="(v) => `${Math.floor(v * 100)}%`" :continuousUpdate="true">
<template #label>BGM {{ i18n.ts.volume }}</template>
</MkRange>
</div>
</div>
<div v-if="showConfig" :class="$style.frame">
<div :class="$style.frameInner">
<div>Credit</div>
<div>BGM: @ys@misskey.design</div>
</div>
</div>
<div :class="$style.frame">
<div :class="$style.frameInner">
<MkButton @click="restart">Restart</MkButton>
</div>
</div>
</div>
</MkSpacer>
</MkStickyContainer>
</template>
<script lang="ts" setup>
import { onDeactivated, ref, shallowRef, watch } from 'vue';
import * as Misskey from 'misskey-js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import MkRippleEffect from '@/components/MkRippleEffect.vue';
import * as os from '@/os.js';
import MkNumber from '@/components/MkNumber.vue';
import MkPlusOneEffect from '@/components/MkPlusOneEffect.vue';
import MkButton from '@/components/MkButton.vue';
import { claimAchievement } from '@/scripts/achievements.js';
import { defaultStore } from '@/store.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { i18n } from '@/i18n.js';
import { useInterval } from '@/scripts/use-interval.js';
import MkSelect from '@/components/MkSelect.vue';
import { apiUrl } from '@/config.js';
import { $i } from '@/account.js';
import { DropAndFusionGame, Mono } from '@/scripts/drop-and-fusion-engine.js';
import * as sound from '@/scripts/sound.js';
import MkRange from '@/components/MkRange.vue';
const containerEl = shallowRef<HTMLElement>();
const canvasEl = shallowRef<HTMLCanvasElement>();
const dropperX = ref(0);
const NORMAL_BASE_SIZE = 30;
const NORAML_MONOS: Mono[] = [{
id: '9377076d-c980-4d83-bdaf-175bc58275b7',
level: 10,
size: NORMAL_BASE_SIZE * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25,
shape: 'circle',
score: 512,
dropCandidate: false,
sfxPitch: 0.25,
img: '/client-assets/drop-and-fusion/exploding_head.png',
imgSize: 256,
spriteScale: 1.12,
}, {
id: 'be9f38d2-b267-4b1a-b420-904e22e80568',
level: 9,
size: NORMAL_BASE_SIZE * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25,
shape: 'circle',
score: 256,
dropCandidate: false,
sfxPitch: 0.5,
img: '/client-assets/drop-and-fusion/face_with_symbols_on_mouth.png',
imgSize: 256,
spriteScale: 1.12,
}, {
id: 'beb30459-b064-4888-926b-f572e4e72e0c',
level: 8,
size: NORMAL_BASE_SIZE * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25,
shape: 'circle',
score: 128,
dropCandidate: false,
sfxPitch: 0.75,
img: '/client-assets/drop-and-fusion/cold_face.png',
imgSize: 256,
spriteScale: 1.12,
}, {
id: 'feab6426-d9d8-49ae-849c-048cdbb6cdf0',
level: 7,
size: NORMAL_BASE_SIZE * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25,
shape: 'circle',
score: 64,
dropCandidate: false,
sfxPitch: 1,
img: '/client-assets/drop-and-fusion/zany_face.png',
imgSize: 256,
spriteScale: 1.12,
}, {
id: 'd6d8fed6-6d18-4726-81a1-6cf2c974df8a',
level: 6,
size: NORMAL_BASE_SIZE * 1.25 * 1.25 * 1.25 * 1.25 * 1.25,
shape: 'circle',
score: 32,
dropCandidate: false,
sfxPitch: 1.5,
img: '/client-assets/drop-and-fusion/pleading_face.png',
imgSize: 256,
spriteScale: 1.12,
}, {
id: '249c728e-230f-4332-bbbf-281c271c75b2',
level: 5,
size: NORMAL_BASE_SIZE * 1.25 * 1.25 * 1.25 * 1.25,
shape: 'circle',
score: 16,
dropCandidate: true,
sfxPitch: 2,
img: '/client-assets/drop-and-fusion/face_with_open_mouth.png',
imgSize: 256,
spriteScale: 1.12,
}, {
id: '23d67613-d484-4a93-b71e-3e81b19d6186',
level: 4,
size: NORMAL_BASE_SIZE * 1.25 * 1.25 * 1.25,
shape: 'circle',
score: 8,
dropCandidate: true,
sfxPitch: 2.5,
img: '/client-assets/drop-and-fusion/smiling_face_with_sunglasses.png',
imgSize: 256,
spriteScale: 1.12,
}, {
id: '3cbd0add-ad7d-4685-bad0-29f6dddc0b99',
level: 3,
size: NORMAL_BASE_SIZE * 1.25 * 1.25,
shape: 'circle',
score: 4,
dropCandidate: true,
sfxPitch: 3,
img: '/client-assets/drop-and-fusion/grinning_squinting_face.png',
imgSize: 256,
spriteScale: 1.12,
}, {
id: '8f86d4f4-ee02-41bf-ad38-1ce0ae457fb5',
level: 2,
size: NORMAL_BASE_SIZE * 1.25,
shape: 'circle',
score: 2,
dropCandidate: true,
sfxPitch: 3.5,
img: '/client-assets/drop-and-fusion/smiling_face_with_hearts.png',
imgSize: 256,
spriteScale: 1.12,
}, {
id: '64ec4add-ce39-42b4-96cb-33908f3f118d',
level: 1,
size: NORMAL_BASE_SIZE,
shape: 'circle',
score: 1,
dropCandidate: true,
sfxPitch: 4,
img: '/client-assets/drop-and-fusion/heart_suit.png',
imgSize: 256,
spriteScale: 1.12,
}];
const SQUARE_BASE_SIZE = 28;
const SQUARE_MONOS: Mono[] = [{
id: 'f75fd0ba-d3d4-40a4-9712-b470e45b0525',
level: 10,
size: SQUARE_BASE_SIZE * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25,
shape: 'rectangle',
score: 512,
dropCandidate: false,
sfxPitch: 0.25,
img: '/client-assets/drop-and-fusion/keycap_10.png',
imgSize: 256,
spriteScale: 1.12,
}, {
id: '7b70f4af-1c01-45fd-af72-61b1f01e03d1',
level: 9,
size: SQUARE_BASE_SIZE * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25,
shape: 'rectangle',
score: 256,
dropCandidate: false,
sfxPitch: 0.5,
img: '/client-assets/drop-and-fusion/keycap_9.png',
imgSize: 256,
spriteScale: 1.12,
}, {
id: '41607ef3-b6d6-4829-95b6-3737bf8bb956',
level: 8,
size: SQUARE_BASE_SIZE * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25,
shape: 'rectangle',
score: 128,
dropCandidate: false,
sfxPitch: 0.75,
img: '/client-assets/drop-and-fusion/keycap_8.png',
imgSize: 256,
spriteScale: 1.12,
}, {
id: '8a8310d2-0374-460f-bb50-ca9cd3ee3416',
level: 7,
size: SQUARE_BASE_SIZE * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25,
shape: 'rectangle',
score: 64,
dropCandidate: false,
sfxPitch: 1,
img: '/client-assets/drop-and-fusion/keycap_7.png',
imgSize: 256,
spriteScale: 1.12,
}, {
id: '1092e069-fe1a-450b-be97-b5d477ec398c',
level: 6,
size: SQUARE_BASE_SIZE * 1.25 * 1.25 * 1.25 * 1.25 * 1.25,
shape: 'rectangle',
score: 32,
dropCandidate: false,
sfxPitch: 1.5,
img: '/client-assets/drop-and-fusion/keycap_6.png',
imgSize: 256,
spriteScale: 1.12,
}, {
id: '2294734d-7bb8-4781-bb7b-ef3820abf3d0',
level: 5,
size: SQUARE_BASE_SIZE * 1.25 * 1.25 * 1.25 * 1.25,
shape: 'rectangle',
score: 16,
dropCandidate: true,
sfxPitch: 2,
img: '/client-assets/drop-and-fusion/keycap_5.png',
imgSize: 256,
spriteScale: 1.12,
}, {
id: 'ea8a61af-e350-45f7-ba6a-366fcd65692a',
level: 4,
size: SQUARE_BASE_SIZE * 1.25 * 1.25 * 1.25,
shape: 'rectangle',
score: 8,
dropCandidate: true,
sfxPitch: 2.5,
img: '/client-assets/drop-and-fusion/keycap_4.png',
imgSize: 256,
spriteScale: 1.12,
}, {
id: 'd0c74815-fc1c-4fbe-9953-c92e4b20f919',
level: 3,
size: SQUARE_BASE_SIZE * 1.25 * 1.25,
shape: 'rectangle',
score: 4,
dropCandidate: true,
sfxPitch: 3,
img: '/client-assets/drop-and-fusion/keycap_3.png',
imgSize: 256,
spriteScale: 1.12,
}, {
id: 'd8fbd70e-611d-402d-87da-1a7fd8cd2c8d',
level: 2,
size: SQUARE_BASE_SIZE * 1.25,
shape: 'rectangle',
score: 2,
dropCandidate: true,
sfxPitch: 3.5,
img: '/client-assets/drop-and-fusion/keycap_2.png',
imgSize: 256,
spriteScale: 1.12,
}, {
id: '35e476ee-44bd-4711-ad42-87be245d3efd',
level: 1,
size: SQUARE_BASE_SIZE,
shape: 'rectangle',
score: 1,
dropCandidate: true,
sfxPitch: 4,
img: '/client-assets/drop-and-fusion/keycap_1.png',
imgSize: 256,
spriteScale: 1.12,
}];
const GAME_WIDTH = 450;
const GAME_HEIGHT = 600;
let viewScaleX = 1;
let viewScaleY = 1;
const currentPick = shallowRef<{ id: string; mono: Mono } | null>(null);
const stock = shallowRef<{ id: string; mono: Mono }[]>([]);
const score = ref(0);
const combo = ref(0);
const comboPrev = ref(0);
const maxCombo = ref(0);
const dropReady = ref(true);
const gameMode = ref<'normal' | 'square'>('normal');
const gameOver = ref(false);
const gameStarted = ref(false);
const highScore = ref<number | null>(null);
const showConfig = ref(false);
const bgmVolume = ref(0.1);
let game: DropAndFusionGame;
let containerElRect: DOMRect | null = null;
function onClick(ev: MouseEvent) {
if (!containerElRect) return;
const x = (ev.clientX - containerElRect.left) / viewScaleX;
game.drop(x);
}
function onTouchend(ev: TouchEvent) {
if (!containerElRect) return;
const x = (ev.changedTouches[0].clientX - containerElRect.left) / viewScaleX;
game.drop(x);
}
function onMousemove(ev: MouseEvent) {
if (!containerElRect) return;
const x = (ev.clientX - containerElRect.left);
moveDropper(containerElRect, x);
}
function onTouchmove(ev: TouchEvent) {
if (!containerElRect) return;
const x = (ev.touches[0].clientX - containerElRect.left);
moveDropper(containerElRect, x);
}
function moveDropper(rect: DOMRect, x: number) {
dropperX.value = Math.min(rect.width * ((GAME_WIDTH - game.PLAYAREA_MARGIN) / GAME_WIDTH), Math.max(rect.width * (game.PLAYAREA_MARGIN / GAME_WIDTH), x));
}
function restart() {
game.dispose();
gameOver.value = false;
currentPick.value = null;
dropReady.value = true;
stock.value = [];
score.value = 0;
combo.value = 0;
comboPrev.value = 0;
gameStarted.value = false;
}
function attachGameEvents() {
game.addListener('changeScore', value => {
score.value = value;
});
game.addListener('changeCombo', value => {
if (value === 0) {
comboPrev.value = combo.value;
} else {
comboPrev.value = value;
}
maxCombo.value = Math.max(maxCombo.value, value);
combo.value = value;
});
game.addListener('changeStock', value => {
currentPick.value = JSON.parse(JSON.stringify(value[0]));
stock.value = JSON.parse(JSON.stringify(value.slice(1)));
});
game.addListener('dropped', () => {
dropReady.value = false;
window.setTimeout(() => {
if (!gameOver.value) {
dropReady.value = true;
}
}, game.DROP_INTERVAL);
});
game.addListener('fusioned', (x, y, scoreDelta) => {
if (!canvasEl.value) return;
const rect = canvasEl.value.getBoundingClientRect();
const domX = rect.left + (x * viewScaleX);
const domY = rect.top + (y * viewScaleY);
os.popup(MkRippleEffect, { x: domX, y: domY }, {}, 'end');
os.popup(MkPlusOneEffect, { x: domX, y: domY, value: scoreDelta }, {}, 'end');
});
game.addListener('monoAdded', (mono) => {
//
if (mono.level === 10) {
claimAchievement('bubbleGameExplodingHead');
const monos = game.getActiveMonos();
if (monos.filter(x => x.level === 10).length >= 2) {
claimAchievement('bubbleGameDoubleExplodingHead');
}
}
});
game.addListener('gameOver', () => {
currentPick.value = null;
dropReady.value = false;
gameOver.value = true;
if (score.value > (highScore.value ?? 0)) {
highScore.value = score.value;
misskeyApi('i/registry/set', {
scope: ['dropAndFusionGame'],
key: 'highScore:' + gameMode.value,
value: highScore.value,
});
}
});
}
let bgmNodes: ReturnType<typeof sound.createSourceNode> = null;
async function start() {
try {
highScore.value = await misskeyApi('i/registry/get', {
scope: ['dropAndFusionGame'],
key: 'highScore:' + gameMode.value,
});
} catch (err) {
highScore.value = null;
}
game = new DropAndFusionGame({
width: GAME_WIDTH,
height: GAME_HEIGHT,
canvas: canvasEl.value!,
...(
gameMode.value === 'normal' ? {
monoDefinitions: NORAML_MONOS,
} : {
monoDefinitions: SQUARE_MONOS,
}
),
});
attachGameEvents();
os.promiseDialog(game.load(), async () => {
game.start();
gameStarted.value = true;
if (bgmNodes) {
bgmNodes.soundSource.stop();
bgmNodes = null;
}
const bgmBuffer = await sound.loadAudio('/client-assets/drop-and-fusion/bgm_1.mp3');
if (!bgmBuffer) return;
bgmNodes = sound.createSourceNode(bgmBuffer, bgmVolume.value);
if (!bgmNodes) return;
bgmNodes.soundSource.loop = true;
bgmNodes.soundSource.start();
});
}
watch(bgmVolume, (value) => {
if (bgmNodes) {
bgmNodes.gainNode.gain.value = value;
}
});
function getGameImageDriveFile() {
return new Promise<Misskey.entities.DriveFile | null>(res => {
const dcanvas = document.createElement('canvas');
dcanvas.width = GAME_WIDTH;
dcanvas.height = GAME_HEIGHT;
const ctx = dcanvas.getContext('2d');
if (!ctx || !canvasEl.value) return res(null);
const dimage = new Image();
dimage.src = '/client-assets/drop-and-fusion/frame-light.svg';
dimage.addEventListener('load', () => {
ctx.fillStyle = '#fff';
ctx.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT);
ctx.drawImage(dimage, 0, 0, GAME_WIDTH, GAME_HEIGHT);
ctx.drawImage(canvasEl.value!, 0, 0, GAME_WIDTH, GAME_HEIGHT);
dcanvas.toBlob(blob => {
if (!blob) return res(null);
if ($i == null) return res(null);
const formData = new FormData();
formData.append('file', blob);
formData.append('name', `bubble-game-${Date.now()}.png`);
formData.append('isSensitive', 'false');
formData.append('comment', 'null');
formData.append('i', $i.token);
if (defaultStore.state.uploadFolder) {
formData.append('folderId', defaultStore.state.uploadFolder);
}
window.fetch(apiUrl + '/drive/files/create', {
method: 'POST',
body: formData,
})
.then(response => response.json())
.then(f => {
res(f);
});
}, 'image/png');
dcanvas.remove();
});
});
}
async function share() {
const uploading = getGameImageDriveFile();
os.promiseDialog(uploading);
const file = await uploading;
if (!file) return;
os.post({
initialText: `#BubbleGame
MODE: ${gameMode.value}
SCORE: ${score.value} (MAX CHAIN: ${maxCombo.value})})`,
initialFiles: [file],
});
}
useInterval(() => {
if (!canvasEl.value) return;
const actualCanvasWidth = canvasEl.value.getBoundingClientRect().width;
const actualCanvasHeight = canvasEl.value.getBoundingClientRect().height;
viewScaleX = actualCanvasWidth / GAME_WIDTH;
viewScaleY = actualCanvasHeight / GAME_HEIGHT;
containerElRect = containerEl.value?.getBoundingClientRect() ?? null;
}, 1000, { immediate: false, afterMounted: true });
onDeactivated(() => {
game.dispose();
});
definePageMetadata({
title: i18n.ts.bubbleGame,
icon: 'ti ti-apple',
});
</script>
<style lang="scss" module>
.transition_stock_move,
.transition_stock_enterActive,
.transition_stock_leaveActive {
transition: opacity 0.4s cubic-bezier(0,.5,.5,1), transform 0.4s cubic-bezier(0,.5,.5,1) !important;
}
.transition_stock_enterFrom,
.transition_stock_leaveTo {
opacity: 0;
transform: scale(0.7);
}
.transition_stock_leaveActive {
position: absolute;
}
.transition_picked_move,
.transition_picked_enterActive {
transition: opacity 0.5s cubic-bezier(0,.5,.5,1), transform 0.5s cubic-bezier(0,.5,.5,1) !important;
}
.transition_picked_leaveActive {
transition: all 0s !important;
}
.transition_picked_enterFrom,
.transition_picked_leaveTo {
opacity: 0;
transform: translateY(-50px);
}
.transition_picked_leaveActive {
position: absolute;
}
.transition_combo_move,
.transition_combo_enterActive {
transition: all 0s !important;
}
.transition_combo_leaveActive {
transition: opacity 0.4s cubic-bezier(0,.5,.5,1), transform 0.4s cubic-bezier(0,.5,.5,1) !important;
}
.transition_combo_enterFrom,
.transition_combo_leaveTo {
opacity: 0;
transform: scale(0.7);
}
.transition_combo_leaveActive {
position: absolute;
}
.root {
margin: 0 auto;
max-width: 600px;
user-select: none;
* {
user-select: none;
}
}
.frame {
padding: 7px;
background: #8C4F26;
box-shadow: 0 6px 16px #0007, 0 0 1px 1px #693410, inset 0 0 2px 1px #ce8a5c;
border-radius: 10px;
}
.frameInner {
padding: 4px 8px;
background: #F1E8DC;
box-shadow: 0 0 2px 1px #ce8a5c, inset 0 0 1px 1px #693410;
border-radius: 6px;
color: #693410;
}
.main {
position: relative;
}
.mainFrameImg {
position: absolute;
top: 0;
left: 0;
width: 100%;
// iOS
//filter: drop-shadow(0 6px 16px #0007);
pointer-events: none;
user-select: none;
}
.canvas {
position: relative;
display: block;
z-index: 1;
margin-top: -50px;
width: 100% !important;
height: auto !important;
pointer-events: none;
user-select: none;
}
.container {
position: relative;
}
.stock {
pointer-events: none;
user-select: none;
}
.combo {
position: absolute;
z-index: 3;
top: 50%;
width: 100%;
text-align: center;
font-weight: bold;
font-style: oblique;
color: #fff;
-webkit-text-stroke: 1px rgb(255, 145, 0);
text-shadow: 0 0 6px #0005;
pointer-events: none;
user-select: none;
}
.currentMono {
position: absolute;
margin-top: 80px;
z-index: 2;
filter: drop-shadow(0 6px 16px #0007);
pointer-events: none;
user-select: none;
}
.dropper {
position: absolute;
top: 0;
width: 70px;
margin-top: -10px;
margin-left: -30px;
z-index: 2;
filter: drop-shadow(0 6px 16px #0007);
pointer-events: none;
user-select: none;
}
.currentMonoArrow {
position: absolute;
margin-top: 100px;
z-index: 3;
animation: currentMonoArrow 2s ease infinite;
pointer-events: none;
user-select: none;
}
.dropGuide {
position: absolute;
top: 120px;
z-index: 3;
width: 3px;
height: calc(100% - 120px);
background: #f002;
pointer-events: none;
user-select: none;
}
.gameOverLabel {
position: absolute;
z-index: 10;
top: 50%;
width: 100%;
padding: 16px;
box-sizing: border-box;
background: #0007;
color: #fff;
text-align: center;
font-weight: bold;
}
.gameOver {
.canvas {
filter: grayscale(1);
}
}
@keyframes currentMonoArrow {
0% { transform: translateY(0); }
25% { transform: translateY(-8px); }
50% { transform: translateY(0); }
75% { transform: translateY(-8px); }
100% { transform: translateY(0); }
}
</style>

View file

@ -45,7 +45,7 @@ import MkTextarea from '@/components/MkTextarea.vue';
import MkCodeEditor from '@/components/MkCodeEditor.vue';
import MkInput from '@/components/MkInput.vue';
import MkSelect from '@/components/MkSelect.vue';
import { useRouter } from '@/router.js';
import { useRouter } from '@/global/router/supplier.js';
const PRESET_DEFAULT = `/// @ 0.16.0

View file

@ -42,9 +42,9 @@ import { computed, ref } from 'vue';
import MkFlashPreview from '@/components/MkFlashPreview.vue';
import MkPagination from '@/components/MkPagination.vue';
import MkButton from '@/components/MkButton.vue';
import { useRouter } from '@/router.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { useRouter } from '@/global/router/supplier.js';
const router = useRouter();

View file

@ -13,9 +13,9 @@ import { } from 'vue';
import * as Misskey from 'misskey-js';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { mainRouter } from '@/router.js';
import { i18n } from '@/i18n.js';
import { defaultStore } from '@/store.js';
import { mainRouter } from '@/global/router/main.js';
async function follow(user): Promise<void> {
const { canceled } = await os.confirm({

View file

@ -48,9 +48,9 @@ import FormSuspense from '@/components/form/suspense.vue';
import { selectFiles } from '@/scripts/select-file.js';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { useRouter } from '@/router.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { i18n } from '@/i18n.js';
import { useRouter } from '@/global/router/supplier.js';
const router = useRouter();

View file

@ -53,7 +53,7 @@ import MkPagination from '@/components/MkPagination.vue';
import MkGalleryPostPreview from '@/components/MkGalleryPostPreview.vue';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { i18n } from '@/i18n.js';
import { useRouter } from '@/router.js';
import { useRouter } from '@/global/router/supplier.js';
const router = useRouter();

View file

@ -72,13 +72,13 @@ import MkPagination from '@/components/MkPagination.vue';
import MkGalleryPostPreview from '@/components/MkGalleryPostPreview.vue';
import MkFollowButton from '@/components/MkFollowButton.vue';
import { url } from '@/config.js';
import { useRouter } from '@/router.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { defaultStore } from '@/store.js';
import { $i } from '@/account.js';
import { isSupportShare } from '@/scripts/navigator.js';
import copyToClipboard from '@/scripts/copy-to-clipboard.js';
import { useRouter } from '@/global/router/supplier.js';
const router = useRouter();

View file

@ -14,8 +14,8 @@ import { ref } from 'vue';
import XAntenna from './editor.vue';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { useRouter } from '@/router.js';
import { antennasCache } from '@/cache.js';
import { useRouter } from '@/global/router/supplier.js';
const router = useRouter();

View file

@ -15,9 +15,9 @@ import * as Misskey from 'misskey-js';
import XAntenna from './editor.vue';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { i18n } from '@/i18n.js';
import { useRouter } from '@/router.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { antennasCache } from '@/cache.js';
import { useRouter } from '@/global/router/supplier.js';
const router = useRouter();

View file

@ -58,7 +58,6 @@ import * as Misskey from 'misskey-js';
import MkButton from '@/components/MkButton.vue';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { mainRouter } from '@/router.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { i18n } from '@/i18n.js';
import { userPage } from '@/filters/user.js';
@ -70,6 +69,7 @@ import { userListsCache } from '@/cache.js';
import { signinRequired } from '@/account.js';
import { defaultStore } from '@/store.js';
import MkPagination from '@/components/MkPagination.vue';
import { mainRouter } from '@/global/router/main.js';
const $i = signinRequired();

View file

@ -73,10 +73,10 @@ import { url } from '@/config.js';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { selectFile } from '@/scripts/select-file.js';
import { mainRouter } from '@/router.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { $i } from '@/account.js';
import { mainRouter } from '@/global/router/main.js';
const props = defineProps<{
initPageId?: string;

View file

@ -40,9 +40,9 @@ import { computed, ref } from 'vue';
import MkPagePreview from '@/components/MkPagePreview.vue';
import MkPagination from '@/components/MkPagination.vue';
import MkButton from '@/components/MkButton.vue';
import { useRouter } from '@/router.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { useRouter } from '@/global/router/supplier.js';
const router = useRouter();

View file

@ -25,8 +25,8 @@ import MkInput from '@/components/MkInput.vue';
import MkButton from '@/components/MkButton.vue';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
import { mainRouter } from '@/router.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { mainRouter } from '@/global/router/main.js';
const props = defineProps<{
token?: string;

View file

@ -51,8 +51,8 @@ import { i18n } from '@/i18n.js';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import MkFoldableSection from '@/components/MkFoldableSection.vue';
import { useRouter } from '@/router.js';
import MkFolder from '@/components/MkFolder.vue';
import { useRouter } from '@/global/router/supplier.js';
const router = useRouter();

View file

@ -34,7 +34,7 @@ import { i18n } from '@/i18n.js';
import * as os from '@/os.js';
import MkFoldableSection from '@/components/MkFoldableSection.vue';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { useRouter } from '@/router.js';
import { useRouter } from '@/global/router/supplier.js';
const router = useRouter();

View file

@ -21,6 +21,14 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkButton primary :class="$style.button" inline @click="exportFavorites()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton>
</MkFolder>
</FormSection>
<FormSection>
<template #label><i class="ti ti-star"></i> {{ i18n.ts._exportOrImport.clips }}</template>
<MkFolder>
<template #label>{{ i18n.ts.export }}</template>
<template #icon><i class="ti ti-download"></i></template>
<MkButton primary :class="$style.button" inline @click="exportClips()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton>
</MkFolder>
</FormSection>
<FormSection>
<template #label><i class="ti ti-users"></i> {{ i18n.ts._exportOrImport.followingList }}</template>
<div class="_gaps_s">
@ -157,6 +165,10 @@ const exportFavorites = () => {
misskeyApi('i/export-favorites', {}).then(onExportSuccess).catch(onError);
};
const exportClips = () => {
misskeyApi('i/export-clips', {}).then(onExportSuccess).catch(onError);
};
const exportFollowing = () => {
misskeyApi('i/export-following', {
excludeMuting: excludeMutingUsers.value,

View file

@ -35,9 +35,9 @@ import MkSuperMenu from '@/components/MkSuperMenu.vue';
import { signout, $i } from '@/account.js';
import { clearCache } from '@/scripts/clear-cache.js';
import { instance } from '@/instance.js';
import { useRouter } from '@/router.js';
import { PageMetadata, definePageMetadata, provideMetadataReceiver } from '@/scripts/page-metadata.js';
import * as os from '@/os.js';
import { useRouter } from '@/global/router/supplier.js';
const indexInfo = {
title: i18n.ts.settings,

View file

@ -51,7 +51,7 @@ import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { useRouter } from '@/router.js';
import { useRouter } from '@/global/router/supplier.js';
const router = useRouter();

View file

@ -29,9 +29,9 @@ import * as Misskey from 'misskey-js';
import MkTimeline from '@/components/MkTimeline.vue';
import { scroll } from '@/scripts/scroll.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { useRouter } from '@/router.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { i18n } from '@/i18n.js';
import { useRouter } from '@/global/router/supplier.js';
const router = useRouter();

View file

@ -166,13 +166,13 @@ import { getUserMenu } from '@/scripts/get-user-menu.js';
import number from '@/filters/number.js';
import { userPage } from '@/filters/user.js';
import * as os from '@/os.js';
import { useRouter } from '@/router.js';
import { i18n } from '@/i18n.js';
import { $i, iAmModerator } from '@/account.js';
import { dateString } from '@/filters/date.js';
import { confetti } from '@/scripts/confetti.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { isFollowingVisibleForMe, isFollowersVisibleForMe } from '@/scripts/isFfVisibleForMe.js';
import { useRouter } from '@/global/router/supplier.js';
function calcAge(birthdate: string): number {
const date = new Date(birthdate);

View file

@ -1,557 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { AsyncComponentLoader, defineAsyncComponent, inject } from 'vue';
import { Router } from '@/nirax.js';
import { $i, iAmModerator } from '@/account.js';
import MkLoading from '@/pages/_loading_.vue';
import MkError from '@/pages/_error_.vue';
export const page = (loader: AsyncComponentLoader<any>) => defineAsyncComponent({
loader: loader,
loadingComponent: MkLoading,
errorComponent: MkError,
});
export const routes = [{
path: '/@:initUser/pages/:initPageName/view-source',
component: page(() => import('./pages/page-editor/page-editor.vue')),
}, {
path: '/@:username/pages/:pageName',
component: page(() => import('./pages/page.vue')),
}, {
path: '/@:acct/following',
component: page(() => import('./pages/user/following.vue')),
}, {
path: '/@:acct/followers',
component: page(() => import('./pages/user/followers.vue')),
}, {
name: 'user',
path: '/@:acct/:page?',
component: page(() => import('./pages/user/index.vue')),
}, {
name: 'note',
path: '/notes/:noteId',
component: page(() => import('./pages/note.vue')),
}, {
name: 'list',
path: '/list/:listId',
component: page(() => import('./pages/list.vue')),
}, {
path: '/clips/:clipId',
component: page(() => import('./pages/clip.vue')),
}, {
path: '/instance-info/:host',
component: page(() => import('./pages/instance-info.vue')),
}, {
name: 'settings',
path: '/settings',
component: page(() => import('./pages/settings/index.vue')),
loginRequired: true,
children: [{
path: '/profile',
name: 'profile',
component: page(() => import('./pages/settings/profile.vue')),
}, {
path: '/avatar-decoration',
name: 'avatarDecoration',
component: page(() => import('./pages/settings/avatar-decoration.vue')),
}, {
path: '/roles',
name: 'roles',
component: page(() => import('./pages/settings/roles.vue')),
}, {
path: '/privacy',
name: 'privacy',
component: page(() => import('./pages/settings/privacy.vue')),
}, {
path: '/emoji-picker',
name: 'emojiPicker',
component: page(() => import('./pages/settings/emoji-picker.vue')),
}, {
path: '/drive',
name: 'drive',
component: page(() => import('./pages/settings/drive.vue')),
}, {
path: '/drive/cleaner',
name: 'drive',
component: page(() => import('./pages/settings/drive-cleaner.vue')),
}, {
path: '/notifications',
name: 'notifications',
component: page(() => import('./pages/settings/notifications.vue')),
}, {
path: '/email',
name: 'email',
component: page(() => import('./pages/settings/email.vue')),
}, {
path: '/security',
name: 'security',
component: page(() => import('./pages/settings/security.vue')),
}, {
path: '/general',
name: 'general',
component: page(() => import('./pages/settings/general.vue')),
}, {
path: '/theme/install',
name: 'theme',
component: page(() => import('./pages/settings/theme.install.vue')),
}, {
path: '/theme/manage',
name: 'theme',
component: page(() => import('./pages/settings/theme.manage.vue')),
}, {
path: '/theme',
name: 'theme',
component: page(() => import('./pages/settings/theme.vue')),
}, {
path: '/navbar',
name: 'navbar',
component: page(() => import('./pages/settings/navbar.vue')),
}, {
path: '/statusbar',
name: 'statusbar',
component: page(() => import('./pages/settings/statusbar.vue')),
}, {
path: '/sounds',
name: 'sounds',
component: page(() => import('./pages/settings/sounds.vue')),
}, {
path: '/plugin/install',
name: 'plugin',
component: page(() => import('./pages/settings/plugin.install.vue')),
}, {
path: '/plugin',
name: 'plugin',
component: page(() => import('./pages/settings/plugin.vue')),
}, {
path: '/import-export',
name: 'import-export',
component: page(() => import('./pages/settings/import-export.vue')),
}, {
path: '/mute-block',
name: 'mute-block',
component: page(() => import('./pages/settings/mute-block.vue')),
}, {
path: '/api',
name: 'api',
component: page(() => import('./pages/settings/api.vue')),
}, {
path: '/apps',
name: 'api',
component: page(() => import('./pages/settings/apps.vue')),
}, {
path: '/webhook/edit/:webhookId',
name: 'webhook',
component: page(() => import('./pages/settings/webhook.edit.vue')),
}, {
path: '/webhook/new',
name: 'webhook',
component: page(() => import('./pages/settings/webhook.new.vue')),
}, {
path: '/webhook',
name: 'webhook',
component: page(() => import('./pages/settings/webhook.vue')),
}, {
path: '/deck',
name: 'deck',
component: page(() => import('./pages/settings/deck.vue')),
}, {
path: '/preferences-backups',
name: 'preferences-backups',
component: page(() => import('./pages/settings/preferences-backups.vue')),
}, {
path: '/migration',
name: 'migration',
component: page(() => import('./pages/settings/migration.vue')),
}, {
path: '/custom-css',
name: 'general',
component: page(() => import('./pages/settings/custom-css.vue')),
}, {
path: '/accounts',
name: 'profile',
component: page(() => import('./pages/settings/accounts.vue')),
}, {
path: '/other',
name: 'other',
component: page(() => import('./pages/settings/other.vue')),
}, {
path: '/',
component: page(() => import('./pages/_empty_.vue')),
}],
}, {
path: '/reset-password/:token?',
component: page(() => import('./pages/reset-password.vue')),
}, {
path: '/signup-complete/:code',
component: page(() => import('./pages/signup-complete.vue')),
}, {
path: '/announcements',
component: page(() => import('./pages/announcements.vue')),
}, {
path: '/about',
component: page(() => import('./pages/about.vue')),
hash: 'initialTab',
}, {
path: '/about-misskey',
component: page(() => import('./pages/about-misskey.vue')),
}, {
path: '/invite',
name: 'invite',
component: page(() => import('./pages/invite.vue')),
}, {
path: '/ads',
component: page(() => import('./pages/ads.vue')),
}, {
path: '/theme-editor',
component: page(() => import('./pages/theme-editor.vue')),
loginRequired: true,
}, {
path: '/roles/:role',
component: page(() => import('./pages/role.vue')),
}, {
path: '/user-tags/:tag',
component: page(() => import('./pages/user-tag.vue')),
}, {
path: '/explore',
component: page(() => import('./pages/explore.vue')),
hash: 'initialTab',
}, {
path: '/search',
component: page(() => import('./pages/search.vue')),
query: {
q: 'query',
channel: 'channel',
type: 'type',
origin: 'origin',
},
}, {
path: '/authorize-follow',
component: page(() => import('./pages/follow.vue')),
loginRequired: true,
}, {
path: '/share',
component: page(() => import('./pages/share.vue')),
loginRequired: true,
}, {
path: '/api-console',
component: page(() => import('./pages/api-console.vue')),
loginRequired: true,
}, {
path: '/scratchpad',
component: page(() => import('./pages/scratchpad.vue')),
}, {
path: '/auth/:token',
component: page(() => import('./pages/auth.vue')),
}, {
path: '/miauth/:session',
component: page(() => import('./pages/miauth.vue')),
query: {
callback: 'callback',
name: 'name',
icon: 'icon',
permission: 'permission',
},
}, {
path: '/oauth/authorize',
component: page(() => import('./pages/oauth.vue')),
}, {
path: '/tags/:tag',
component: page(() => import('./pages/tag.vue')),
}, {
path: '/pages/new',
component: page(() => import('./pages/page-editor/page-editor.vue')),
loginRequired: true,
}, {
path: '/pages/edit/:initPageId',
component: page(() => import('./pages/page-editor/page-editor.vue')),
loginRequired: true,
}, {
path: '/pages',
component: page(() => import('./pages/pages.vue')),
}, {
path: '/play/:id/edit',
component: page(() => import('./pages/flash/flash-edit.vue')),
loginRequired: true,
}, {
path: '/play/new',
component: page(() => import('./pages/flash/flash-edit.vue')),
loginRequired: true,
}, {
path: '/play/:id',
component: page(() => import('./pages/flash/flash.vue')),
}, {
path: '/play',
component: page(() => import('./pages/flash/flash-index.vue')),
}, {
path: '/gallery/:postId/edit',
component: page(() => import('./pages/gallery/edit.vue')),
loginRequired: true,
}, {
path: '/gallery/new',
component: page(() => import('./pages/gallery/edit.vue')),
loginRequired: true,
}, {
path: '/gallery/:postId',
component: page(() => import('./pages/gallery/post.vue')),
}, {
path: '/gallery',
component: page(() => import('./pages/gallery/index.vue')),
}, {
path: '/channels/:channelId/edit',
component: page(() => import('./pages/channel-editor.vue')),
loginRequired: true,
}, {
path: '/channels/new',
component: page(() => import('./pages/channel-editor.vue')),
loginRequired: true,
}, {
path: '/channels/:channelId',
component: page(() => import('./pages/channel.vue')),
}, {
path: '/channels',
component: page(() => import('./pages/channels.vue')),
}, {
path: '/custom-emojis-manager',
component: page(() => import('./pages/custom-emojis-manager.vue')),
}, {
path: '/avatar-decorations',
name: 'avatarDecorations',
component: page(() => import('./pages/avatar-decorations.vue')),
}, {
path: '/registry/keys/:domain/:path(*)?',
component: page(() => import('./pages/registry.keys.vue')),
}, {
path: '/registry/value/:domain/:path(*)?',
component: page(() => import('./pages/registry.value.vue')),
}, {
path: '/registry',
component: page(() => import('./pages/registry.vue')),
}, {
path: '/install-extentions',
component: page(() => import('./pages/install-extentions.vue')),
loginRequired: true,
}, {
path: '/admin/user/:userId',
component: iAmModerator ? page(() => import('./pages/admin-user.vue')) : page(() => import('./pages/not-found.vue')),
}, {
path: '/admin/file/:fileId',
component: iAmModerator ? page(() => import('./pages/admin-file.vue')) : page(() => import('./pages/not-found.vue')),
}, {
path: '/admin',
component: iAmModerator ? page(() => import('./pages/admin/index.vue')) : page(() => import('./pages/not-found.vue')),
children: [{
path: '/overview',
name: 'overview',
component: page(() => import('./pages/admin/overview.vue')),
}, {
path: '/users',
name: 'users',
component: page(() => import('./pages/admin/users.vue')),
}, {
path: '/emojis',
name: 'emojis',
component: page(() => import('./pages/custom-emojis-manager.vue')),
}, {
path: '/avatar-decorations',
name: 'avatarDecorations',
component: page(() => import('./pages/avatar-decorations.vue')),
}, {
path: '/queue',
name: 'queue',
component: page(() => import('./pages/admin/queue.vue')),
}, {
path: '/files',
name: 'files',
component: page(() => import('./pages/admin/files.vue')),
}, {
path: '/federation',
name: 'federation',
component: page(() => import('./pages/admin/federation.vue')),
}, {
path: '/announcements',
name: 'announcements',
component: page(() => import('./pages/admin/announcements.vue')),
}, {
path: '/ads',
name: 'ads',
component: page(() => import('./pages/admin/ads.vue')),
}, {
path: '/roles/:id/edit',
name: 'roles',
component: page(() => import('./pages/admin/roles.edit.vue')),
}, {
path: '/roles/new',
name: 'roles',
component: page(() => import('./pages/admin/roles.edit.vue')),
}, {
path: '/roles/:id',
name: 'roles',
component: page(() => import('./pages/admin/roles.role.vue')),
}, {
path: '/roles',
name: 'roles',
component: page(() => import('./pages/admin/roles.vue')),
}, {
path: '/database',
name: 'database',
component: page(() => import('./pages/admin/database.vue')),
}, {
path: '/abuses',
name: 'abuses',
component: page(() => import('./pages/admin/abuses.vue')),
}, {
path: '/modlog',
name: 'modlog',
component: page(() => import('./pages/admin/modlog.vue')),
}, {
path: '/settings',
name: 'settings',
component: page(() => import('./pages/admin/settings.vue')),
}, {
path: '/branding',
name: 'branding',
component: page(() => import('./pages/admin/branding.vue')),
}, {
path: '/moderation',
name: 'moderation',
component: page(() => import('./pages/admin/moderation.vue')),
}, {
path: '/email-settings',
name: 'email-settings',
component: page(() => import('./pages/admin/email-settings.vue')),
}, {
path: '/object-storage',
name: 'object-storage',
component: page(() => import('./pages/admin/object-storage.vue')),
}, {
path: '/security',
name: 'security',
component: page(() => import('./pages/admin/security.vue')),
}, {
path: '/relays',
name: 'relays',
component: page(() => import('./pages/admin/relays.vue')),
}, {
path: '/instance-block',
name: 'instance-block',
component: page(() => import('./pages/admin/instance-block.vue')),
}, {
path: '/proxy-account',
name: 'proxy-account',
component: page(() => import('./pages/admin/proxy-account.vue')),
}, {
path: '/external-services',
name: 'external-services',
component: page(() => import('./pages/admin/external-services.vue')),
}, {
path: '/other-settings',
name: 'other-settings',
component: page(() => import('./pages/admin/other-settings.vue')),
}, {
path: '/server-rules',
name: 'server-rules',
component: page(() => import('./pages/admin/server-rules.vue')),
}, {
path: '/invites',
name: 'invites',
component: page(() => import('./pages/admin/invites.vue')),
}, {
path: '/',
component: page(() => import('./pages/_empty_.vue')),
}],
}, {
path: '/my/notifications',
component: page(() => import('./pages/notifications.vue')),
loginRequired: true,
}, {
path: '/my/favorites',
component: page(() => import('./pages/favorites.vue')),
loginRequired: true,
}, {
path: '/my/achievements',
component: page(() => import('./pages/achievements.vue')),
loginRequired: true,
}, {
path: '/my/drive/folder/:folder',
component: page(() => import('./pages/drive.vue')),
loginRequired: true,
}, {
path: '/my/drive',
component: page(() => import('./pages/drive.vue')),
loginRequired: true,
}, {
path: '/my/drive/file/:fileId',
component: page(() => import('./pages/drive.file.vue')),
loginRequired: true,
}, {
path: '/my/follow-requests',
component: page(() => import('./pages/follow-requests.vue')),
loginRequired: true,
}, {
path: '/my/lists/:listId',
component: page(() => import('./pages/my-lists/list.vue')),
loginRequired: true,
}, {
path: '/my/lists',
component: page(() => import('./pages/my-lists/index.vue')),
loginRequired: true,
}, {
path: '/my/clips',
component: page(() => import('./pages/my-clips/index.vue')),
loginRequired: true,
}, {
path: '/my/antennas/create',
component: page(() => import('./pages/my-antennas/create.vue')),
loginRequired: true,
}, {
path: '/my/antennas/:antennaId',
component: page(() => import('./pages/my-antennas/edit.vue')),
loginRequired: true,
}, {
path: '/my/antennas',
component: page(() => import('./pages/my-antennas/index.vue')),
loginRequired: true,
}, {
path: '/timeline/list/:listId',
component: page(() => import('./pages/user-list-timeline.vue')),
loginRequired: true,
}, {
path: '/timeline/antenna/:antennaId',
component: page(() => import('./pages/antenna-timeline.vue')),
loginRequired: true,
}, {
path: '/clicker',
component: page(() => import('./pages/clicker.vue')),
loginRequired: true,
}, {
path: '/timeline',
component: page(() => import('./pages/timeline.vue')),
}, {
name: 'index',
path: '/',
component: $i ? page(() => import('./pages/timeline.vue')) : page(() => import('./pages/welcome.vue')),
globalCacheKey: 'index',
}, {
path: '/:(*)',
component: page(() => import('./pages/not-found.vue')),
}];
export const mainRouter = new Router(routes, location.pathname + location.search + location.hash, !!$i, page(() => import('@/pages/not-found.vue')));
window.history.replaceState({ key: mainRouter.getCurrentKey() }, '', location.href);
mainRouter.addListener('push', ctx => {
window.history.pushState({ key: ctx.key }, '', ctx.path);
});
window.addEventListener('popstate', (event) => {
mainRouter.replace(location.pathname + location.search + location.hash, event.state?.key);
});
export function useRouter(): Router {
return inject<Router | null>('router', null) ?? mainRouter;
}

View file

@ -83,6 +83,8 @@ export const ACHIEVEMENT_TYPES = [
'brainDiver',
'smashTestNotificationButton',
'tutorialCompleted',
'bubbleGameExplodingHead',
'bubbleGameDoubleExplodingHead',
] as const;
export const ACHIEVEMENT_BADGES = {
@ -466,6 +468,16 @@ export const ACHIEVEMENT_BADGES = {
bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))',
frame: 'bronze',
},
'bubbleGameExplodingHead': {
img: '/fluent-emoji/1f92f.png',
bg: 'linear-gradient(0deg, rgb(255 77 77), rgb(247 155 214))',
frame: 'bronze',
},
'bubbleGameDoubleExplodingHead': {
img: '/fluent-emoji/1f92f.png',
bg: 'linear-gradient(0deg, rgb(255 77 77), rgb(247 155 214))',
frame: 'silver',
},
/* @see <https://github.com/misskey-dev/misskey/pull/10365#discussion_r1155511107>
} as const satisfies Record<typeof ACHIEVEMENT_TYPES[number], {
img: string;

View file

@ -218,7 +218,7 @@ function getTextOptions(def: values.Value | undefined): Omit<AsUiText, 'id' | 't
};
}
function getMfmOptions(def: values.Value | undefined): Omit<AsUiMfm, 'id' | 'type'> {
function getMfmOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Omit<AsUiMfm, 'id' | 'type'> {
utils.assertObject(def);
const text = def.value.get('text');
@ -241,7 +241,7 @@ function getMfmOptions(def: values.Value | undefined): Omit<AsUiMfm, 'id' | 'typ
color: color?.value,
font: font?.value,
onClickEv: (evId: string) => {
if (onClickEv) call(onClickEv, values.STR(evId));
if (onClickEv) call(onClickEv, [values.STR(evId)]);
},
};
}

View file

@ -0,0 +1,400 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { EventEmitter } from 'eventemitter3';
import * as Matter from 'matter-js';
import * as sound from '@/scripts/sound.js';
export type Mono = {
id: string;
level: number;
size: number;
shape: 'circle' | 'rectangle';
score: number;
dropCandidate: boolean;
sfxPitch: number;
img: string;
imgSize: number;
spriteScale: number;
};
const PHYSICS_QUALITY_FACTOR = 16; // 低いほどパフォーマンスが高いがガタガタして安定しなくなる、逆に高すぎても何故か不安定になる
export class DropAndFusionGame extends EventEmitter<{
changeScore: (newScore: number) => void;
changeCombo: (newCombo: number) => void;
changeStock: (newStock: { id: string; mono: Mono }[]) => void;
dropped: () => void;
fusioned: (x: number, y: number, scoreDelta: number) => void;
monoAdded: (mono: Mono) => void;
gameOver: () => void;
}> {
private COMBO_INTERVAL = 1000;
public readonly DROP_INTERVAL = 500;
public readonly PLAYAREA_MARGIN = 25;
private STOCK_MAX = 4;
private loaded = false;
private engine: Matter.Engine;
private render: Matter.Render;
private runner: Matter.Runner;
private overflowCollider: Matter.Body;
private isGameOver = false;
private gameWidth: number;
private gameHeight: number;
private monoDefinitions: Mono[] = [];
private monoTextures: Record<string, Blob> = {};
private monoTextureUrls: Record<string, string> = {};
/**
*
*/
private activeBodyIds: Matter.Body['id'][] = [];
private latestDroppedBodyId: Matter.Body['id'] | null = null;
private latestDroppedAt = 0;
private latestFusionedAt = 0;
private stock: { id: string; mono: Mono }[] = [];
private _combo = 0;
private get combo() {
return this._combo;
}
private set combo(value: number) {
this._combo = value;
this.emit('changeCombo', value);
}
private _score = 0;
private get score() {
return this._score;
}
private set score(value: number) {
this._score = value;
this.emit('changeScore', value);
}
private comboIntervalId: number | null = null;
constructor(opts: {
canvas: HTMLCanvasElement;
width: number;
height: number;
monoDefinitions: Mono[];
}) {
super();
this.gameWidth = opts.width;
this.gameHeight = opts.height;
this.monoDefinitions = opts.monoDefinitions;
this.engine = Matter.Engine.create({
constraintIterations: 2 * PHYSICS_QUALITY_FACTOR,
positionIterations: 6 * PHYSICS_QUALITY_FACTOR,
velocityIterations: 4 * PHYSICS_QUALITY_FACTOR,
gravity: {
x: 0,
y: 1,
},
timing: {
timeScale: 2,
},
enableSleeping: false,
});
this.render = Matter.Render.create({
engine: this.engine,
canvas: opts.canvas,
options: {
width: this.gameWidth,
height: this.gameHeight,
background: 'transparent', // transparent to hide
wireframeBackground: 'transparent', // transparent to hide
wireframes: false,
showSleeping: false,
pixelRatio: Math.max(2, window.devicePixelRatio),
},
});
Matter.Render.run(this.render);
this.runner = Matter.Runner.create();
Matter.Runner.run(this.runner, this.engine);
this.engine.world.bodies = [];
//#region walls
const WALL_OPTIONS: Matter.IChamferableBodyDefinition = {
isStatic: true,
friction: 0.7,
slop: 1.0,
render: {
strokeStyle: 'transparent',
fillStyle: 'transparent',
},
};
const thickness = 100;
Matter.Composite.add(this.engine.world, [
Matter.Bodies.rectangle(this.gameWidth / 2, this.gameHeight + (thickness / 2) - this.PLAYAREA_MARGIN, this.gameWidth, thickness, WALL_OPTIONS),
Matter.Bodies.rectangle(this.gameWidth + (thickness / 2) - this.PLAYAREA_MARGIN, this.gameHeight / 2, thickness, this.gameHeight, WALL_OPTIONS),
Matter.Bodies.rectangle(-((thickness / 2) - this.PLAYAREA_MARGIN), this.gameHeight / 2, thickness, this.gameHeight, WALL_OPTIONS),
]);
//#endregion
this.overflowCollider = Matter.Bodies.rectangle(this.gameWidth / 2, 0, this.gameWidth, 200, {
isStatic: true,
isSensor: true,
render: {
strokeStyle: 'transparent',
fillStyle: 'transparent',
},
});
Matter.Composite.add(this.engine.world, this.overflowCollider);
// fit the render viewport to the scene
Matter.Render.lookAt(this.render, {
min: { x: 0, y: 0 },
max: { x: this.gameWidth, y: this.gameHeight },
});
}
private createBody(mono: Mono, x: number, y: number) {
const options: Matter.IBodyDefinition = {
label: mono.id,
//density: 0.0005,
density: mono.size / 1000,
restitution: 0.2,
frictionAir: 0.01,
friction: 0.7,
frictionStatic: 5,
slop: 1.0,
//mass: 0,
render: {
sprite: {
texture: mono.img,
xScale: (mono.size / mono.imgSize) * mono.spriteScale,
yScale: (mono.size / mono.imgSize) * mono.spriteScale,
},
},
};
if (mono.shape === 'circle') {
return Matter.Bodies.circle(x, y, mono.size / 2, options);
} else if (mono.shape === 'rectangle') {
return Matter.Bodies.rectangle(x, y, mono.size, mono.size, options);
} else {
throw new Error('unrecognized shape');
}
}
private fusion(bodyA: Matter.Body, bodyB: Matter.Body) {
const now = Date.now();
if (this.latestFusionedAt > now - this.COMBO_INTERVAL) {
this.combo++;
} else {
this.combo = 1;
}
this.latestFusionedAt = now;
// TODO: 単に位置だけでなくそれぞれの動きベクトルも融合する?
const newX = (bodyA.position.x + bodyB.position.x) / 2;
const newY = (bodyA.position.y + bodyB.position.y) / 2;
Matter.Composite.remove(this.engine.world, [bodyA, bodyB]);
this.activeBodyIds = this.activeBodyIds.filter(x => x !== bodyA.id && x !== bodyB.id);
const currentMono = this.monoDefinitions.find(y => y.id === bodyA.label)!;
const nextMono = this.monoDefinitions.find(x => x.level === currentMono.level + 1);
if (nextMono) {
const body = this.createBody(nextMono, newX, newY);
Matter.Composite.add(this.engine.world, body);
// 連鎖してfusionした場合の分かりやすさのため少し間を置いてからfusion対象になるようにする
window.setTimeout(() => {
this.activeBodyIds.push(body.id);
}, 100);
const comboBonus = 1 + ((this.combo - 1) / 5);
const additionalScore = Math.round(currentMono.score * comboBonus);
this.score += additionalScore;
// TODO: 効果音再生はコンポーネント側の責務なので移動する
const pan = ((newX / this.gameWidth) - 0.5) * 2;
sound.playUrl('/client-assets/drop-and-fusion/bubble2.mp3', 1, pan, nextMono.sfxPitch);
this.emit('monoAdded', nextMono);
this.emit('fusioned', newX, newY, additionalScore);
} else {
//const VELOCITY = 30;
//for (let i = 0; i < 10; i++) {
// const body = createBody(FRUITS.find(x => x.level === (1 + Math.floor(Math.random() * 3)))!, x + ((Math.random() * VELOCITY) - (VELOCITY / 2)), y + ((Math.random() * VELOCITY) - (VELOCITY / 2)));
// Matter.Composite.add(world, body);
// bodies.push(body);
//}
//sound.playUrl({
// type: 'syuilo/bubble2',
// volume: 1,
//});
}
}
private gameOver() {
this.isGameOver = true;
Matter.Runner.stop(this.runner);
this.emit('gameOver');
}
/** テクスチャをすべてキャッシュする */
private async loadMonoTextures() {
async function loadSingleMonoTexture(mono: Mono, game: DropAndFusionGame) {
// Matter-js内にキャッシュがある場合はスキップ
if (game.render.textures[mono.img]) return;
console.log('loading', mono.img);
let src = mono.img;
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (game.monoTextureUrls[mono.img]) {
src = game.monoTextureUrls[mono.img];
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
} else if (game.monoTextures[mono.img]) {
src = URL.createObjectURL(game.monoTextures[mono.img]);
game.monoTextureUrls[mono.img] = src;
} else {
const res = await fetch(mono.img);
const blob = await res.blob();
game.monoTextures[mono.img] = blob;
src = URL.createObjectURL(blob);
game.monoTextureUrls[mono.img] = src;
}
const image = new Image();
image.src = src;
game.render.textures[mono.img] = image;
}
return Promise.all(this.monoDefinitions.map(x => loadSingleMonoTexture(x, this)));
}
public start() {
if (!this.loaded) throw new Error('game is not loaded yet');
for (let i = 0; i < this.STOCK_MAX; i++) {
this.stock.push({
id: Math.random().toString(),
mono: this.monoDefinitions.filter(x => x.dropCandidate)[Math.floor(Math.random() * this.monoDefinitions.filter(x => x.dropCandidate).length)],
});
}
this.emit('changeStock', this.stock);
// TODO: fusion予約状態のアイテムは光らせるなどの演出をすると楽しそう
let fusionReservedPairs: { bodyA: Matter.Body; bodyB: Matter.Body }[] = [];
const minCollisionEnergyForSound = 2.5;
const maxCollisionEnergyForSound = 9;
const soundPitchMax = 4;
const soundPitchMin = 0.5;
Matter.Events.on(this.engine, 'collisionStart', (event) => {
for (const pairs of event.pairs) {
const { bodyA, bodyB } = pairs;
if (bodyA.id === this.overflowCollider.id || bodyB.id === this.overflowCollider.id) {
if (bodyA.id === this.latestDroppedBodyId || bodyB.id === this.latestDroppedBodyId) {
continue;
}
this.gameOver();
break;
}
const shouldFusion = (bodyA.label === bodyB.label) && !fusionReservedPairs.some(x => x.bodyA.id === bodyA.id || x.bodyA.id === bodyB.id || x.bodyB.id === bodyA.id || x.bodyB.id === bodyB.id);
if (shouldFusion) {
if (this.activeBodyIds.includes(bodyA.id) && this.activeBodyIds.includes(bodyB.id)) {
this.fusion(bodyA, bodyB);
} else {
fusionReservedPairs.push({ bodyA, bodyB });
window.setTimeout(() => {
fusionReservedPairs = fusionReservedPairs.filter(x => x.bodyA.id !== bodyA.id && x.bodyB.id !== bodyB.id);
this.fusion(bodyA, bodyB);
}, 100);
}
} else {
const energy = pairs.collision.depth;
if (energy > minCollisionEnergyForSound) {
// TODO: 効果音再生はコンポーネント側の責務なので移動する
const vol = (Math.min(maxCollisionEnergyForSound, energy - minCollisionEnergyForSound) / maxCollisionEnergyForSound) / 4;
const pan = ((((bodyA.position.x + bodyB.position.x) / 2) / this.gameWidth) - 0.5) * 2;
const pitch = soundPitchMin + ((soundPitchMax - soundPitchMin) * (1 - (Math.min(10, energy) / 10)));
sound.playUrl('/client-assets/drop-and-fusion/poi1.mp3', vol, pan, pitch);
}
}
}
});
this.comboIntervalId = window.setInterval(() => {
if (this.latestFusionedAt < Date.now() - this.COMBO_INTERVAL) {
this.combo = 0;
}
}, 500);
}
public async load() {
await this.loadMonoTextures();
this.loaded = true;
}
public getTextureImageUrl(mono: Mono) {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (this.monoTextureUrls[mono.img]) {
return this.monoTextureUrls[mono.img];
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
} else if (this.monoTextures[mono.img]) {
// Gameクラス内にキャッシュがある場合はそれを使う
const out = URL.createObjectURL(this.monoTextures[mono.img]);
this.monoTextureUrls[mono.img] = out;
return out;
} else {
return mono.img;
}
}
public getActiveMonos() {
return this.engine.world.bodies.map(x => this.monoDefinitions.find((mono) => mono.id === x.label)!).filter(x => x !== undefined);
}
public drop(_x: number) {
if (this.isGameOver) return;
if (Date.now() - this.latestDroppedAt < this.DROP_INTERVAL) {
return;
}
const st = this.stock.shift()!;
this.stock.push({
id: Math.random().toString(),
mono: this.monoDefinitions.filter(x => x.dropCandidate)[Math.floor(Math.random() * this.monoDefinitions.filter(x => x.dropCandidate).length)],
});
this.emit('changeStock', this.stock);
const x = Math.min(this.gameWidth - this.PLAYAREA_MARGIN - (st.mono.size / 2), Math.max(this.PLAYAREA_MARGIN + (st.mono.size / 2), _x));
const body = this.createBody(st.mono, x, 50 + st.mono.size / 2);
Matter.Composite.add(this.engine.world, body);
this.activeBodyIds.push(body.id);
this.latestDroppedBodyId = body.id;
this.latestDroppedAt = Date.now();
this.emit('dropped');
this.emit('monoAdded', st.mono);
// TODO: 効果音再生はコンポーネント側の責務なので移動する
const pan = ((x / this.gameWidth) - 0.5) * 2;
sound.playUrl('/client-assets/drop-and-fusion/poi2.mp3', 1, pan);
}
public dispose() {
if (this.comboIntervalId) window.clearInterval(this.comboIntervalId);
Matter.Render.stop(this.render);
Matter.Runner.stop(this.runner);
Matter.World.clear(this.engine.world, false);
Matter.Engine.clear(this.engine);
}
}

View file

@ -36,7 +36,8 @@ for (let i = 0; i < emojilist.length; i++) {
export const emojiCharByCategory = _charGroupByCategory;
export function getEmojiName(char: string): string | null {
const idx = _indexByChar.get(char);
// Colorize it because emojilist.json assumes that
const idx = _indexByChar.get(colorizeEmoji(char));
if (idx == null) {
return null;
} else {
@ -44,6 +45,10 @@ export function getEmojiName(char: string): string | null {
}
}
export function colorizeEmoji(char: string) {
return char.length === 1 ? `${char}\uFE0F` : char;
}
export interface CustomEmojiFolderTree {
value: string;
category: string;

View file

@ -13,11 +13,11 @@ import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { defaultStore, userActions } from '@/store.js';
import { $i, iAmModerator } from '@/account.js';
import { mainRouter } from '@/router.js';
import { Router } from '@/nirax.js';
import { IRouter } from '@/nirax.js';
import { antennasCache, rolesCache, userListsCache } from '@/cache.js';
import { mainRouter } from '@/global/router/main.js';
export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router = mainRouter) {
export function getUserMenu(user: Misskey.entities.UserDetailed, router: IRouter = mainRouter) {
const meId = $i ? $i.id : null;
const cleanups = [] as (() => void)[];

View file

@ -6,8 +6,8 @@
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { i18n } from '@/i18n.js';
import { mainRouter } from '@/router.js';
import { Router } from '@/nirax.js';
import { mainRouter } from '@/global/router/main.js';
export async function lookup(router?: Router) {
const _router = router ?? mainRouter;

View file

@ -10,12 +10,17 @@ import { $i } from '@/account.js';
export const pendingApiRequestsCount = ref(0);
// Implements Misskey.api.ApiClient.request
export function misskeyApi<E extends keyof Misskey.Endpoints, P extends Misskey.Endpoints[E]['req']>(
export function misskeyApi<
ResT = void,
E extends keyof Misskey.Endpoints = keyof Misskey.Endpoints,
P extends Misskey.Endpoints[E]['req'] = Misskey.Endpoints[E]['req'],
_ResT = ResT extends void ? Misskey.api.SwitchCaseResponseType<E, P> : ResT,
>(
endpoint: E,
data: P = {} as any,
token?: string | null | undefined,
signal?: AbortSignal,
): Promise<Misskey.api.SwitchCaseResponseType<E, P>> {
): Promise<_ResT> {
if (endpoint.includes('://')) throw new Error('invalid endpoint');
pendingApiRequestsCount.value++;
@ -23,7 +28,7 @@ export function misskeyApi<E extends keyof Misskey.Endpoints, P extends Misskey.
pendingApiRequestsCount.value--;
};
const promise = new Promise<Misskey.Endpoints[E]['res'] | void>((resolve, reject) => {
const promise = new Promise<_ResT>((resolve, reject) => {
// Append a credential
if ($i) (data as any).i = $i.token;
if (token !== undefined) (data as any).i = token;
@ -44,7 +49,7 @@ export function misskeyApi<E extends keyof Misskey.Endpoints, P extends Misskey.
if (res.status === 200) {
resolve(body);
} else if (res.status === 204) {
resolve();
resolve(undefined as _ResT); // void -> undefined
} else {
reject(body.error);
}
@ -57,10 +62,15 @@ export function misskeyApi<E extends keyof Misskey.Endpoints, P extends Misskey.
}
// Implements Misskey.api.ApiClient.request
export function misskeyApiGet<E extends keyof Misskey.Endpoints, P extends Misskey.Endpoints[E]['req']>(
export function misskeyApiGet<
ResT = void,
E extends keyof Misskey.Endpoints = keyof Misskey.Endpoints,
P extends Misskey.Endpoints[E]['req'] = Misskey.Endpoints[E]['req'],
_ResT = ResT extends void ? Misskey.api.SwitchCaseResponseType<E, P> : ResT,
>(
endpoint: E,
data: P = {} as any,
): Promise<Misskey.api.SwitchCaseResponseType<E, P>> {
): Promise<_ResT> {
pendingApiRequestsCount.value++;
const onFinally = () => {
@ -69,7 +79,7 @@ export function misskeyApiGet<E extends keyof Misskey.Endpoints, P extends Missk
const query = new URLSearchParams(data as any);
const promise = new Promise<Misskey.Endpoints[E]['res'] | void>((resolve, reject) => {
const promise = new Promise<_ResT>((resolve, reject) => {
// Send request
window.fetch(`${apiUrl}/${endpoint}?${query}`, {
method: 'GET',
@ -81,7 +91,7 @@ export function misskeyApiGet<E extends keyof Misskey.Endpoints, P extends Missk
if (res.status === 200) {
resolve(body);
} else if (res.status === 204) {
resolve();
resolve(undefined as _ResT); // void -> undefined
} else {
reject(body.error);
}

View file

@ -5,7 +5,6 @@
import type { SoundStore } from '@/store.js';
import { defaultStore } from '@/store.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
let ctx: AudioContext;
const cache = new Map<string, AudioBuffer>();
@ -89,63 +88,35 @@ export type OperationType = typeof operationTypes[number];
/**
*
* @param soundStore
* @param url url
* @param options `useCache`: `true`
*/
export async function loadAudio(soundStore: SoundStore, options?: { useCache?: boolean; }) {
export async function loadAudio(url: string, 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 (cache.has(url)) {
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;
return cache.get(url) as AudioBuffer;
}
}
let response: Response;
if (soundStore.type === '_driveFile_') {
try {
response = await fetch(soundStore.fileUrl);
} catch (err) {
try {
// URLが変わっている可能性があるのでドライブ側からURLを取得するフォールバック
const apiRes = await misskeyApi('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;
}
try {
response = await fetch(url);
} catch (err) {
return;
}
const arrayBuffer = await response.arrayBuffer();
const audioBuffer = await ctx.decodeAudioData(arrayBuffer);
if (options?.useCache ?? true) {
if (soundStore.type === '_driveFile_') {
cache.set(soundStore.fileId, audioBuffer);
} else {
cache.set(soundStore.type, audioBuffer);
}
cache.set(url, audioBuffer);
}
return audioBuffer;
@ -174,25 +145,46 @@ export function play(operationType: OperationType) {
* @param soundStore
*/
export async function playFile(soundStore: SoundStore) {
const buffer = await loadAudio(soundStore);
if (soundStore.type === null || (soundStore.type === '_driveFile_' && !soundStore.fileUrl)) {
return;
}
const url = soundStore.type === '_driveFile_' ? soundStore.fileUrl : `/client-assets/sounds/${soundStore.type}.mp3`;
const buffer = await loadAudio(url);
if (!buffer) return;
createSourceNode(buffer, soundStore.volume)?.start();
createSourceNode(buffer, soundStore.volume)?.soundSource.start();
}
export function createSourceNode(buffer: AudioBuffer, volume: number) : AudioBufferSourceNode | null {
export async function playUrl(url: string, volume = 1, pan = 0, playbackRate = 1) {
const buffer = await loadAudio(url);
if (!buffer) return;
createSourceNode(buffer, volume, pan, playbackRate)?.soundSource.start();
}
export function createSourceNode(buffer: AudioBuffer, volume: number, pan = 0, playbackRate = 1): {
soundSource: AudioBufferSourceNode;
panNode: StereoPannerNode;
gainNode: GainNode;
} | null {
const masterVolume = defaultStore.state.sound_masterVolume;
if (isMute() || masterVolume === 0 || volume === 0) {
return null;
}
const panNode = ctx.createStereoPanner();
panNode.pan.value = pan;
const gainNode = ctx.createGain();
gainNode.gain.value = masterVolume * volume;
const soundSource = ctx.createBufferSource();
soundSource.buffer = buffer;
soundSource.connect(gainNode).connect(ctx.destination);
soundSource.playbackRate.value = playbackRate;
soundSource
.connect(panNode)
.connect(gainNode)
.connect(ctx.destination);
return soundSource;
return { soundSource, panNode, gainNode };
}
/**

View file

@ -27,6 +27,11 @@ function toolsMenuItems(): MenuItem[] {
to: '/clicker',
text: '🍪👈',
icon: 'ti ti-cookie',
}, {
type: 'link',
to: '/bubble-game',
text: i18n.ts.bubbleGame,
icon: 'ti ti-apple',
}, ($i && ($i.isAdmin || $i.policies.canManageCustomEmojis)) ? {
type: 'link',
to: '/custom-emojis-manager',

View file

@ -7,8 +7,8 @@ import { post } from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { $i, login } from '@/account.js';
import { getAccountFromId } from '@/scripts/get-account-from-id.js';
import { mainRouter } from '@/router.js';
import { deepClone } from '@/scripts/clone.js';
import { mainRouter } from '@/global/router/main.js';
export function swInject() {
navigator.serviceWorker.addEventListener('message', async ev => {

View file

@ -52,11 +52,11 @@ import XCommon from './_common_/common.vue';
import { instanceName } from '@/config.js';
import { StickySidebar } from '@/scripts/sticky-sidebar.js';
import * as os from '@/os.js';
import { mainRouter } from '@/router.js';
import { PageMetadata, provideMetadataReceiver } from '@/scripts/page-metadata.js';
import { defaultStore } from '@/store.js';
import { i18n } from '@/i18n.js';
import { miLocalStorage } from '@/local-storage.js';
import { mainRouter } from '@/global/router/main.js';
const XHeaderMenu = defineAsyncComponent(() => import('./classic.header.vue'));
const XWidgets = defineAsyncComponent(() => import('./universal.widgets.vue'));

View file

@ -103,7 +103,6 @@ import * as os from '@/os.js';
import { navbarItemDef } from '@/navbar.js';
import { $i } from '@/account.js';
import { i18n } from '@/i18n.js';
import { mainRouter } from '@/router.js';
import { unisonReload } from '@/scripts/unison-reload.js';
import { deviceKind } from '@/scripts/device-kind.js';
import { defaultStore } from '@/store.js';
@ -117,6 +116,7 @@ import XWidgetsColumn from '@/ui/deck/widgets-column.vue';
import XMentionsColumn from '@/ui/deck/mentions-column.vue';
import XDirectColumn from '@/ui/deck/direct-column.vue';
import XRoleTimelineColumn from '@/ui/deck/role-timeline-column.vue';
import { mainRouter } from '@/global/router/main.js';
const XStatusBars = defineAsyncComponent(() => import('@/ui/_common_/statusbars.vue'));
const XAnnouncements = defineAsyncComponent(() => import('@/ui/_common_/announcements.vue'));

View file

@ -24,10 +24,10 @@ import XColumn from './column.vue';
import { deckStore, Column } from '@/ui/deck/deck-store.js';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
import { mainRouter } from '@/router.js';
import { PageMetadata, provideMetadataReceiver } from '@/scripts/page-metadata.js';
import { useScrollPositionManager } from '@/nirax.js';
import { getScrollContainer } from '@/scripts/scroll.js';
import { mainRouter } from '@/global/router/main.js';
defineProps<{
column: Column;

View file

@ -16,9 +16,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { provide, ComputedRef, ref } from 'vue';
import XCommon from './_common_/common.vue';
import { mainRouter } from '@/router.js';
import { PageMetadata, provideMetadataReceiver } from '@/scripts/page-metadata.js';
import { instanceName } from '@/config.js';
import { mainRouter } from '@/global/router/main.js';
const pageMetadata = ref<null | ComputedRef<PageMetadata>>();

View file

@ -105,12 +105,12 @@ import { defaultStore } from '@/store.js';
import { navbarItemDef } from '@/navbar.js';
import { i18n } from '@/i18n.js';
import { $i } from '@/account.js';
import { mainRouter } from '@/router.js';
import { PageMetadata, provideMetadataReceiver } from '@/scripts/page-metadata.js';
import { deviceKind } from '@/scripts/device-kind.js';
import { miLocalStorage } from '@/local-storage.js';
import { CURRENT_STICKY_BOTTOM } from '@/const.js';
import { useScrollPositionManager } from '@/nirax.js';
import { mainRouter } from '@/global/router/main.js';
const XWidgets = defineAsyncComponent(() => import('./universal.widgets.vue'));
const XSidebar = defineAsyncComponent(() => import('@/ui/_common_/navbar.vue'));

View file

@ -79,10 +79,10 @@ import { instance } from '@/instance.js';
import XSigninDialog from '@/components/MkSigninDialog.vue';
import XSignupDialog from '@/components/MkSignupDialog.vue';
import { ColdDeviceStorage, defaultStore } from '@/store.js';
import { mainRouter } from '@/router.js';
import { PageMetadata, provideMetadataReceiver } from '@/scripts/page-metadata.js';
import { i18n } from '@/i18n.js';
import MkVisitorDashboard from '@/components/MkVisitorDashboard.vue';
import { mainRouter } from '@/global/router/main.js';
const DESKTOP_THRESHOLD = 1100;

View file

@ -24,10 +24,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { provide, ComputedRef, ref } from 'vue';
import XCommon from './_common_/common.vue';
import { mainRouter } from '@/router.js';
import { PageMetadata, provideMetadataReceiver } from '@/scripts/page-metadata.js';
import { instanceName, ui } from '@/config.js';
import { i18n } from '@/i18n.js';
import { mainRouter } from '@/global/router/main.js';
const pageMetadata = ref<null | ComputedRef<PageMetadata>>();

View file

@ -104,10 +104,7 @@ const jammedAudioBuffer = ref<AudioBuffer | null>(null);
const jammedSoundNodePlaying = ref<boolean>(false);
if (defaultStore.state.sound_masterVolume) {
sound.loadAudio({
type: 'syuilo/queue-jammed',
volume: 1,
}).then(buf => {
sound.loadAudio('/client-assets/sounds/syuilo/queue-jammed.mp3').then(buf => {
if (!buf) throw new Error('[WidgetJobQueue] Failed to initialize AudioBuffer');
jammedAudioBuffer.value = buf;
});
@ -126,7 +123,7 @@ const onStats = (stats) => {
current[domain].delayed = stats[domain].delayed;
if (current[domain].waiting > 0 && widgetProps.sound && jammedAudioBuffer.value && !jammedSoundNodePlaying.value) {
const soundNode = sound.createSourceNode(jammedAudioBuffer.value, 1);
const soundNode = sound.createSourceNode(jammedAudioBuffer.value, 1)?.soundSource;
if (soundNode) {
jammedSoundNodePlaying.value = true;
soundNode.onended = () => jammedSoundNodePlaying.value = false;

View file

@ -80,13 +80,13 @@ import * as Misskey from 'misskey-js';
import { v4 as uuid } from 'uuid';
const props = defineProps<{
connection: any,
connection: Misskey.ChannelConnection<Misskey.Channels['serverStats']>,
meta: Misskey.entities.ServerInfoResponse
}>();
const viewBoxX = ref<number>(50);
const viewBoxY = ref<number>(30);
const stats = ref<any[]>([]);
const stats = ref<Misskey.entities.ServerStats[]>([]);
const cpuGradientId = uuid();
const cpuMaskId = uuid();
const memGradientId = uuid();
@ -107,6 +107,7 @@ onMounted(() => {
props.connection.on('statsLog', onStatsLog);
props.connection.send('requestLog', {
id: Math.random().toString().substring(2, 10),
length: 50,
});
});
@ -115,7 +116,7 @@ onBeforeUnmount(() => {
props.connection.off('statsLog', onStatsLog);
});
function onStats(connStats) {
function onStats(connStats: Misskey.entities.ServerStats) {
stats.value.push(connStats);
if (stats.value.length > 50) stats.value.shift();
@ -136,8 +137,8 @@ function onStats(connStats) {
memP.value = (connStats.mem.active / props.meta.mem.total * 100).toFixed(0);
}
function onStatsLog(statsLog) {
for (const revStats of [...statsLog].reverse()) {
function onStatsLog(statsLog: Misskey.entities.ServerStatsLog) {
for (const revStats of statsLog.reverse()) {
onStats(revStats);
}
}

View file

@ -20,13 +20,13 @@ import * as Misskey from 'misskey-js';
import XPie from './pie.vue';
const props = defineProps<{
connection: any,
connection: Misskey.ChannelConnection<Misskey.Channels['serverStats']>,
meta: Misskey.entities.ServerInfoResponse
}>();
const usage = ref<number>(0);
function onStats(stats) {
function onStats(stats: Misskey.entities.ServerStats) {
usage.value = stats.cpu;
}

View file

@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { onUnmounted, ref } from 'vue';
import * as Misskey from 'misskey-js';
import { useWidgetPropsManager, Widget, WidgetComponentExpose } from '../widget.js';
import { useWidgetPropsManager, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from '../widget.js';
import XCpuMemory from './cpu-mem.vue';
import XNet from './net.vue';
import XCpu from './cpu.vue';
@ -54,11 +54,8 @@ const widgetPropsDef = {
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
// vueimporttype
//const props = defineProps<WidgetComponentProps<WidgetProps>>();
//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
const props = defineProps<WidgetComponentProps<WidgetProps>>();
const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
const { widgetProps, configure, save } = useWidgetPropsManager(name,
widgetPropsDef,

View file

@ -22,7 +22,7 @@ import XPie from './pie.vue';
import bytes from '@/filters/bytes.js';
const props = defineProps<{
connection: any,
connection: Misskey.ChannelConnection<Misskey.Channels['serverStats']>,
meta: Misskey.entities.ServerInfoResponse
}>();
@ -31,7 +31,7 @@ const total = ref<number>(0);
const used = ref<number>(0);
const free = ref<number>(0);
function onStats(stats) {
function onStats(stats: Misskey.entities.ServerStats) {
usage.value = stats.mem.active / props.meta.mem.total;
total.value = props.meta.mem.total;
used.value = stats.mem.active;

View file

@ -54,13 +54,13 @@ import * as Misskey from 'misskey-js';
import bytes from '@/filters/bytes.js';
const props = defineProps<{
connection: any,
connection: Misskey.ChannelConnection<Misskey.Channels['serverStats']>,
meta: Misskey.entities.ServerInfoResponse
}>();
const viewBoxX = ref<number>(50);
const viewBoxY = ref<number>(30);
const stats = ref<any[]>([]);
const stats = ref<Misskey.entities.ServerStats[]>([]);
const inPolylinePoints = ref<string>('');
const outPolylinePoints = ref<string>('');
const inPolygonPoints = ref<string>('');
@ -77,6 +77,7 @@ onMounted(() => {
props.connection.on('statsLog', onStatsLog);
props.connection.send('requestLog', {
id: Math.random().toString().substring(2, 10),
length: 50,
});
});
@ -85,7 +86,7 @@ onBeforeUnmount(() => {
props.connection.off('statsLog', onStatsLog);
});
function onStats(connStats) {
function onStats(connStats: Misskey.entities.ServerStats) {
stats.value.push(connStats);
if (stats.value.length > 50) stats.value.shift();
@ -109,8 +110,8 @@ function onStats(connStats) {
outRecent.value = connStats.net.tx;
}
function onStatsLog(statsLog) {
for (const revStats of [...statsLog].reverse()) {
function onStatsLog(statsLog: Misskey.entities.ServerStatsLog) {
for (const revStats of statsLog.reverse()) {
onStats(revStats);
}
}