Merge remote-tracking branch 'misskey-original/develop' into develop

# Conflicts:
#	locales/index.d.ts
#	packages/backend/src/models/RepositoryModule.ts
#	packages/backend/src/models/_.ts
#	packages/frontend/src/components/MkDrive.vue
#	packages/frontend/src/components/MkEmojiEditDialog.vue
#	packages/frontend/src/components/MkFollowButton.vue
#	packages/frontend/src/components/MkSignupDialog.form.vue
#	packages/frontend/src/components/MkUserSelectDialog.vue
#	packages/frontend/src/components/index.ts
#	packages/frontend/src/os.ts
#	packages/frontend/src/pages/avatar-decorations.vue
#	packages/frontend/src/pages/settings/mute-block.word-mute.vue
#	packages/frontend/src/pages/user/home.vue
#	packages/misskey-bubble-game/src/monos.ts
This commit is contained in:
mattyatea 2024-01-20 14:54:06 +09:00
commit f6d3fde92d
172 changed files with 13294 additions and 497 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

View file

@ -41,6 +41,7 @@
"chartjs-plugin-zoom": "2.0.1",
"chromatic": "10.1.0",
"compare-versions": "6.1.0",
"crc-32": "^1.2.2",
"cropperjs": "2.0.0-beta.4",
"date-fns": "2.30.0",
"escape-regexp": "0.0.1",
@ -53,12 +54,13 @@
"matter-js": "0.19.0",
"mfm-js": "0.24.0",
"misskey-js": "workspace:*",
"misskey-reversi": "workspace:*",
"misskey-bubble-game": "workspace:*",
"photoswipe": "5.4.3",
"punycode": "2.3.1",
"rollup": "4.9.1",
"sanitize-html": "2.11.0",
"sass": "1.69.5",
"seedrandom": "^3.0.5",
"shiki": "0.14.7",
"strict-event-emitter-types": "2.0.0",
"textarea-caret": "3.1.0",

View file

@ -205,7 +205,7 @@ export async function mainBoot() {
const lastUsedDate = parseInt(lastUsed, 10);
// 二時間以上前なら
if (Date.now() - lastUsedDate > 1000 * 60 * 60 * 2) {
toast(i18n.t('welcomeBackWithName', {
toast(i18n.tsx.welcomeBackWithName({
name: $i.name || $i.username,
}));
}

View file

@ -44,7 +44,7 @@ async function ok() {
const confirm = await os.confirm({
type: 'question',
title: i18n.ts._announcement.readConfirmTitle,
text: i18n.t('_announcement.readConfirmText', { title: props.announcement.title }),
text: i18n.tsx._announcement.readConfirmText({ title: props.announcement.title }),
});
if (confirm.canceled) return;
}

View file

@ -35,6 +35,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<span>{{ tag }}</span>
</li>
</ol>
<ol v-else-if="mfmParams.length > 0" ref="suggests" :class="$style.list">
<li v-for="param in mfmParams" tabindex="-1" :class="$style.item" @click="complete(type, q.params.toSpliced(-1, 1, param).join(','))" @keydown="onKeydown">
<span>{{ param }}</span>
</li>
</ol>
</div>
</template>
@ -51,7 +56,7 @@ import { emojilist, getEmojiName } from '@/scripts/emojilist.js';
import { i18n } from '@/i18n.js';
import { miLocalStorage } from '@/local-storage.js';
import { customEmojis } from '@/custom-emojis.js';
import { MFM_TAGS } from '@/const.js';
import { MFM_TAGS, MFM_PARAMS } from '@/const.js';
type EmojiDef = {
emoji: string;
@ -134,7 +139,7 @@ export default {
<script lang="ts" setup>
const props = defineProps<{
type: string;
q: string | null;
q: any;
textarea: HTMLTextAreaElement;
close: () => void;
x: number;
@ -155,6 +160,7 @@ const hashtags = ref<any[]>([]);
const emojis = ref<(EmojiDef)[]>([]);
const items = ref<Element[] | HTMLCollection>([]);
const mfmTags = ref<string[]>([]);
const mfmParams = ref<string[]>([]);
const select = ref(-1);
const zIndex = os.claimZIndex('high');
@ -255,6 +261,13 @@ function exec() {
}
mfmTags.value = MFM_TAGS.filter(tag => tag.startsWith(props.q ?? ''));
} else if (props.type === 'mfmParam') {
if (props.q.params.at(-1) === '') {
mfmParams.value = MFM_PARAMS[props.q.tag] ?? [];
return;
}
mfmParams.value = MFM_PARAMS[props.q.tag].filter(param => param.startsWith(props.q.params.at(-1) ?? ''));
}
}

View file

@ -41,9 +41,9 @@ const emit = defineEmits<{
const label = computed(() => {
return concat([
props.text ? [i18n.t('_cw.chars', { count: props.text.length })] : [],
props.text ? [i18n.tsx._cw.chars({ count: props.text.length })] : [],
props.renote ? [i18n.ts.quote] : [],
props.files.length !== 0 ? [i18n.t('_cw.files', { count: props.files.length })] : [],
props.files.length !== 0 ? [i18n.tsx._cw.files({ count: props.files.length })] : [],
props.poll != null ? [i18n.ts.poll] : [],
] as string[][]).join(' / ');
});

View file

@ -46,7 +46,7 @@ export default defineComponent({
function getDateText(time: string) {
const date = new Date(time).getDate();
const month = new Date(time).getMonth() + 1;
return i18n.t('monthAndDay', {
return i18n.tsx.monthAndDay({
month: month.toString(),
day: date.toString(),
});

View file

@ -31,8 +31,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkInput v-if="input" v-model="inputValue" autofocus :type="input.type || 'text'" :placeholder="input.placeholder || undefined" :autocomplete="input.autocomplete" @keydown="onInputKeydown">
<template v-if="input.type === 'password'" #prefix><i class="ti ti-lock"></i></template>
<template #caption>
<span v-if="okButtonDisabledReason === 'charactersExceeded'" v-text="i18n.t('_dialog.charactersExceeded', { current: (inputValue as string).length, max: input.maxLength ?? 'NaN' })"/>
<span v-else-if="okButtonDisabledReason === 'charactersBelow'" v-text="i18n.t('_dialog.charactersBelow', { current: (inputValue as string).length, min: input.minLength ?? 'NaN' })"/>
<span v-if="okButtonDisabledReason === 'charactersExceeded'" v-text="i18n.tsx._dialog.charactersExceeded({ current: (inputValue as string).length, max: input.maxLength ?? 'NaN' })"/>
<span v-else-if="okButtonDisabledReason === 'charactersBelow'" v-text="i18n.tsx._dialog.charactersBelow({ current: (inputValue as string).length, min: input.minLength ?? 'NaN' })"/>
</template>
</MkInput>
<MkSelect v-if="select" v-model="selectedValue" autofocus>

View file

@ -100,11 +100,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkButton v-show="moreFiles" ref="loadMoreFiles" @click="fetchMoreFiles">{{ i18n.ts.loadMore }}</MkButton>
</div>
<div v-if="files.length == 0 && folders.length == 0 && !fetching" :class="$style.empty">
<div v-if="draghover">{{ i18n.t('empty-draghover') }}</div>
<div v-if="draghover">{{ i18n.ts['empty-draghover'] }}</div>
<div v-if="!draghover && folder == null">
<strong>{{
i18n.ts.emptyDrive
}}</strong><br/>{{ i18n.t('empty-drive-description') }}
}}</strong><br/>{{ i18n.ts['empty-drive-description'] }}
</div>
<div v-if="!draghover && folder != null">{{ i18n.ts.emptyFolder }}</div>
</div>

View file

@ -207,7 +207,7 @@ async function done() {
async function del() {
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.t('removeAreYouSure', { x: name }),
text: i18n.tsx.removeAreYouSure({ x: name }),
});
if (canceled) return;

View file

@ -106,7 +106,7 @@ async function onClick() {
if (isFollowing.value) {
const {canceled} = await os.confirm({
type: 'warning',
text: i18n.t('unfollowConfirm', {name: props.user.name || props.user.username}),
text: i18n.tsx.unfollowConfirm({name: props.user.name || props.user.username}),
});
if (canceled) return;

View file

@ -180,6 +180,7 @@ watch(tabModel, (newTab, oldTab) => {
<style lang="scss" module>
.transitionRoot.enableAnimation {
display: grid;
grid-template-columns: 100%;
overflow: clip;
.transitionChildren {

View file

@ -22,7 +22,6 @@ SPDX-License-Identifier: AGPL-3.0-only
<audio
ref="audioEl"
preload="metadata"
:class="$style.audio"
>
<source :src="audio.url">
</audio>

View file

@ -81,7 +81,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-if="translating || translation" :class="$style.translation">
<MkLoading v-if="translating" mini/>
<div v-else>
<b>{{ i18n.t('translatedFrom', { x: translation.sourceLang }) }}: </b>
<b>{{ i18n.tsx.translatedFrom({ x: translation.sourceLang }) }}: </b>
<Mfm :text="translation.text" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis"/>
</div>
</div>

View file

@ -87,7 +87,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-if="translating || translation" :class="$style.translation">
<MkLoading v-if="translating" mini/>
<div v-else>
<b>{{ i18n.t('translatedFrom', { x: translation.sourceLang }) }}: </b>
<b>{{ i18n.tsx.translatedFrom({ x: translation.sourceLang }) }}: </b>
<Mfm :text="translation.text" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis"/>
</div>
</div>

View file

@ -56,8 +56,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<span v-else-if="notification.type === 'achievementEarned'">{{ i18n.ts._notification.achievementEarned }}</span>
<span v-else-if="notification.type === 'test'">{{ i18n.ts._notification.testNotification }}</span>
<MkA v-else-if="notification.user" v-user-preview="notification.user.id" :class="$style.headerName" :to="userPage(notification.user)"><MkUserName :user="notification.user"/></MkA>
<span v-else-if="notification.type === 'reaction:grouped'">{{ i18n.t('_notification.reactedBySomeUsers', { n: notification.reactions.length }) }}</span>
<span v-else-if="notification.type === 'renote:grouped'">{{ i18n.t('_notification.renotedBySomeUsers', { n: notification.users.length }) }}</span>
<span v-else-if="notification.type === 'reaction:grouped'">{{ i18n.tsx._notification.reactedBySomeUsers({ n: notification.reactions.length }) }}</span>
<span v-else-if="notification.type === 'renote:grouped'">{{ i18n.tsx._notification.renotedBySomeUsers({ n: notification.users.length }) }}</span>
<span v-else>{{ notification.header }}</span>
<MkTime v-if="withTime" :time="notification.createdAt" :class="$style.headerTime"/>
</header>

View file

@ -23,7 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkButton inline @click="disableAll">{{ i18n.ts.disableAll }}</MkButton>
<MkButton inline @click="enableAll">{{ i18n.ts.enableAll }}</MkButton>
</div>
<MkSwitch v-for="ntype in notificationTypes" :key="ntype" v-model="typesMap[ntype].value">{{ i18n.t(`_notification._types.${ntype}`) }}</MkSwitch>
<MkSwitch v-for="ntype in notificationTypes" :key="ntype" v-model="typesMap[ntype].value">{{ i18n.ts._notification._types[ntype] }}</MkSwitch>
</div>
</MkSpacer>
</MkModalWindow>

View file

@ -11,12 +11,12 @@ SPDX-License-Identifier: AGPL-3.0-only
<span :class="$style.fg">
<template v-if="choice.isVoted"><i class="ti ti-check" style="margin-right: 4px; color: var(--accent);"></i></template>
<Mfm :text="choice.text" :plain="true"/>
<span v-if="showResult" style="margin-left: 4px; opacity: 0.7;">({{ i18n.t('_poll.votesCount', { n: choice.votes }) }})</span>
<span v-if="showResult" style="margin-left: 4px; opacity: 0.7;">({{ i18n.tsx._poll.votesCount({ n: choice.votes }) }})</span>
</span>
</li>
</ul>
<p v-if="!readOnly" :class="$style.info">
<span>{{ i18n.t('_poll.totalVotes', { n: total }) }}</span>
<span>{{ i18n.tsx._poll.totalVotes({ n: total }) }}</span>
<span> · </span>
<a v-if="!closed && !isVoted" style="color: inherit;" @click="showResult = !showResult">{{ showResult ? i18n.ts._poll.vote : i18n.ts._poll.showResult }}</a>
<span v-if="isVoted">{{ i18n.ts._poll.voted }}</span>
@ -47,10 +47,11 @@ const remaining = ref(-1);
const total = computed(() => sum(props.note.poll.choices.map(x => x.votes)));
const closed = computed(() => remaining.value === 0);
const isVoted = computed(() => !props.note.poll.multiple && props.note.poll.choices.some(c => c.isVoted));
const timer = computed(() => i18n.t(
remaining.value >= 86400 ? '_poll.remainingDays' :
remaining.value >= 3600 ? '_poll.remainingHours' :
remaining.value >= 60 ? '_poll.remainingMinutes' : '_poll.remainingSeconds', {
const timer = computed(() => i18n.tsx._poll[
remaining.value >= 86400 ? 'remainingDays' :
remaining.value >= 3600 ? 'remainingHours' :
remaining.value >= 60 ? 'remainingMinutes' : 'remainingSeconds'
]({
s: Math.floor(remaining.value % 60),
m: Math.floor(remaining.value / 60) % 60,
h: Math.floor(remaining.value / 3600) % 24,
@ -81,7 +82,7 @@ const vote = async (id) => {
const { canceled } = await os.confirm({
type: 'question',
text: i18n.t('voteConfirm', { choice: props.note.poll.choices[id].text }),
text: i18n.tsx.voteConfirm({ choice: props.note.poll.choices[id].text }),
});
if (canceled) return;

View file

@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</p>
<ul>
<li v-for="(choice, i) in choices" :key="i">
<MkInput class="input" small :modelValue="choice" :placeholder="i18n.t('_poll.choiceN', { n: i + 1 })" @update:modelValue="onInput(i, $event)">
<MkInput class="input" small :modelValue="choice" :placeholder="i18n.tsx._poll.choiceN({ n: i + 1 })" @update:modelValue="onInput(i, $event)">
</MkInput>
<button class="_button" @click="remove(i)">
<i class="ti ti-x"></i>

View file

@ -18,6 +18,9 @@ export default defineComponent({
watch(value, () => {
context.emit('update:modelValue', value.value);
});
watch(() => props.modelValue, v => {
value.value = v;
});
if (!context.slots.default) return null;
let options = context.slots.default();
const label = context.slots.label && context.slots.label();

View file

@ -52,7 +52,7 @@ const props = defineProps<{
}>();
const emit = defineEmits<{
(ev: 'change', _ev: KeyboardEvent): void;
(ev: 'changeByUser'): void;
(ev: 'update:modelValue', value: string | null): void;
}>();
@ -77,7 +77,6 @@ const height =
const focus = () => inputEl.value.focus();
const onInput = (ev) => {
changed.value = true;
emit('change', ev);
};
const updated = () => {
@ -136,6 +135,7 @@ function show(ev: MouseEvent) {
active: computed(() => v.value === option.props.value),
action: () => {
v.value = option.props.value;
emit('changeByUser', v.value);
},
});
};

View file

@ -301,7 +301,7 @@ async function onSubmit(): Promise<void> {
os.alert({
type: 'success',
title: i18n.ts._signup.almostThere,
text: i18n.t('_signup.emailSent', {email: email.value}),
text: i18n.tsx._signup.emailSent({email: email.value}),
});
emit('signupEmailPending');
} else {

View file

@ -107,7 +107,7 @@ async function updateAgreeServerRules(v: boolean) {
const confirm = await os.confirm({
type: 'question',
title: i18n.ts.doYouAgree,
text: i18n.t('iHaveReadXCarefullyAndAgree', { x: i18n.ts.serverRules }),
text: i18n.tsx.iHaveReadXCarefullyAndAgree({ x: i18n.ts.serverRules }),
});
if (confirm.canceled) return;
agreeServerRules.value = true;
@ -121,7 +121,7 @@ async function updateAgreeTosAndPrivacyPolicy(v: boolean) {
const confirm = await os.confirm({
type: 'question',
title: i18n.ts.doYouAgree,
text: i18n.t('iHaveReadXCarefullyAndAgree', {
text: i18n.tsx.iHaveReadXCarefullyAndAgree({
x: tosPrivacyPolicyLabel.value,
}),
});
@ -137,7 +137,7 @@ async function updateAgreeNote(v: boolean) {
const confirm = await os.confirm({
type: 'question',
title: i18n.ts.doYouAgree,
text: i18n.t('iHaveReadXCarefullyAndAgree', { x: i18n.ts.basicNotesBeforeCreateAccount }),
text: i18n.tsx.iHaveReadXCarefullyAndAgree({ x: i18n.ts.basicNotesBeforeCreateAccount }),
});
if (confirm.canceled) return;
agreeNote.value = true;

View file

@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkA v-if="note.renoteId" :class="$style.rp" :to="`/notes/${note.renoteId}`">RN: ...</MkA>
</div>
<details v-if="note.files.length > 0">
<summary>({{ i18n.t('withNFiles', { n: note.files.length }) }})</summary>
<summary>({{ i18n.tsx.withNFiles({ n: note.files.length }) }})</summary>
<MkMediaList :mediaList="note.files"/>
</details>
<details v-if="note.poll">

View file

@ -33,12 +33,12 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkButton inline @click="enableAll">{{ i18n.ts.enableAll }}</MkButton>
</div>
<div class="_gaps_s">
<MkSwitch v-for="kind in Object.keys(permissionSwitches)" :key="kind" v-model="permissionSwitches[kind]">{{ i18n.t(`_permissions.${kind}`) }}</MkSwitch>
<MkSwitch v-for="kind in Object.keys(permissionSwitches)" :key="kind" v-model="permissionSwitches[kind]">{{ i18n.ts._permissions[kind] }}</MkSwitch>
</div>
<div v-if="iAmAdmin" :class="$style.adminPermissions">
<div :class="$style.adminPermissionsHeader"><b>{{ i18n.ts.adminPermission }}</b></div>
<div class="_gaps_s">
<MkSwitch v-for="kind in Object.keys(permissionSwitchesForAdmin)" :key="kind" v-model="permissionSwitchesForAdmin[kind]">{{ i18n.t(`_permissions.${kind}`) }}</MkSwitch>
<MkSwitch v-for="kind in Object.keys(permissionSwitchesForAdmin)" :key="kind" v-model="permissionSwitchesForAdmin[kind]">{{ i18n.ts._permissions[kind] }}</MkSwitch>
</div>
</div>
</div>

View file

@ -133,7 +133,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<a href="https://misskey-hub.net/docs/for-users/" target="_blank" class="_link">{{ i18n.ts.help }}</a>
</template>
</I18n>
<div>{{ i18n.t('_initialAccountSetting.haveFun', { name: instance.name ?? host }) }}</div>
<div>{{ i18n.tsx._initialAccountSetting.haveFun({ name: instance.name ?? host }) }}</div>
<div class="_buttonsCenter" style="margin-top: 16px;">
<MkButton v-if="initialPage !== 4" rounded @click="page--"><i class="ti ti-arrow-left"></i> {{ i18n.ts.goBack }}</MkButton>
<MkButton rounded primary gradate @click="close(false)">{{ i18n.ts.close }}</MkButton>

View file

@ -118,7 +118,7 @@ async function done() {
async function del() {
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.t('removeAreYouSure', { x: title.value }),
text: i18n.tsx.removeAreYouSure({ x: title.value }),
});
if (canceled) return;

View file

@ -87,7 +87,7 @@ const selected = ref<Misskey.entities.UserDetailed | null>(null);
const multipleSelected = ref<Misskey.entities.UserDetailed[]>([]);
const dialogEl = ref();
const search = () => {
function search() {
if (username.value === '' && host.value === '') {
users.value = [];
return;
@ -100,9 +100,9 @@ const search = () => {
}).then(_users => {
users.value = _users;
});
};
}
const ok = () => {
function ok() {
if ((!selected.value && multipleSelected.value.length < 1)) return;
emit('ok', selected.value ?? multipleSelected.value);
dialogEl.value.close();
@ -113,12 +113,12 @@ const ok = () => {
recents = recents.filter(x => x !== selected.value.id);
recents.unshift(selected.value.id);
defaultStore.set('recentlyUsedUsers', recents.splice(0, 16));
};
}
const cancel = () => {
function cancel() {
emit('cancel');
dialogEl.value.close();
};
}
onMounted(() => {
misskeyApi('users/show', {

View file

@ -68,7 +68,7 @@ function setAvatar(ev) {
const { canceled } = await os.confirm({
type: 'question',
text: i18n.t('cropImageAsk'),
text: i18n.ts.cropImageAsk,
okText: i18n.ts.cropYes,
cancelText: i18n.ts.cropNo,
});

View file

@ -93,7 +93,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="_gaps" style="text-align: center;">
<i class="ti ti-bell-ringing-2" style="display: block; margin: auto; font-size: 3em; color: var(--accent);"></i>
<div style="font-size: 120%;">{{ i18n.ts.pushNotification }}</div>
<div style="padding: 0 16px;">{{ i18n.t('_initialAccountSetting.pushNotificationDescription', { name: instance.name ?? host }) }}</div>
<div style="padding: 0 16px;">{{ i18n.tsx._initialAccountSetting.pushNotificationDescription({ name: instance.name ?? host }) }}</div>
<MkPushNotificationAllowButton primary showOnlyToRegister style="margin: 0 auto;"/>
<div class="_buttonsCenter" style="margin-top: 16px;">
<MkButton rounded data-cy-user-setup-back @click="page--"><i class="ti ti-arrow-left"></i> {{ i18n.ts.goBack }}</MkButton>
@ -110,7 +110,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="_gaps" style="text-align: center;">
<i class="ti ti-check" style="display: block; margin: auto; font-size: 3em; color: var(--accent);"></i>
<div style="font-size: 120%;">{{ i18n.ts._initialAccountSetting.initialAccountSettingCompleted }}</div>
<div>{{ i18n.t('_initialAccountSetting.youCanContinueTutorial', { name: instance.name ?? host }) }}</div>
<div>{{ i18n.tsx._initialAccountSetting.youCanContinueTutorial({ name: instance.name ?? host }) }}</div>
<div class="_buttonsCenter" style="margin-top: 16px;">
<MkButton rounded primary gradate data-cy-user-setup-continue @click="launchTutorial()">{{ i18n.ts._initialAccountSetting.startTutorial }} <i class="ti ti-arrow-right"></i></MkButton>
</div>

View file

@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<header :class="$style.editHeader">
<MkSelect v-model="widgetAdderSelected" style="margin-bottom: var(--margin)" data-cy-widget-select>
<template #label>{{ i18n.ts.selectWidget }}</template>
<option v-for="widget in widgetDefs" :key="widget" :value="widget">{{ i18n.t(`_widgets.${widget}`) }}</option>
<option v-for="widget in widgetDefs" :key="widget" :value="widget">{{ i18n.ts._widgets[widget] }}</option>
</MkSelect>
<MkButton inline primary data-cy-widget-add @click="addWidget"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton>
<MkButton inline @click="$emit('exit')">{{ i18n.ts.close }}</MkButton>
@ -109,7 +109,7 @@ function onContextmenu(widget: Widget, ev: MouseEvent) {
os.contextMenu([{
type: 'label',
text: i18n.t(`_widgets.${widget.name}`),
text: i18n.ts._widgets[widget.name],
}, {
icon: 'ti ti-settings',
text: i18n.ts.settings,

View file

@ -0,0 +1,46 @@
<template>
<render/>
</template>
<script setup lang="ts" generic="T extends string | ParameterizedString">
import { computed, h } from 'vue';
import type { ParameterizedString } from '../../../../../locales/index.js';
const props = withDefaults(defineProps<{
src: T;
tag?: string;
// eslint-disable-next-line vue/require-default-prop
textTag?: string;
}>(), {
tag: 'span',
});
const slots = defineSlots<T extends ParameterizedString<infer R> ? { [K in R]: () => unknown } : NonNullable<unknown>>();
const parsed = computed(() => {
let str = props.src as string;
const value: (string | { arg: string; })[] = [];
for (;;) {
const nextBracketOpen = str.indexOf('{');
const nextBracketClose = str.indexOf('}');
if (nextBracketOpen === -1) {
value.push(str);
break;
} else {
if (nextBracketOpen > 0) value.push(str.substring(0, nextBracketOpen));
value.push({
arg: str.substring(nextBracketOpen + 1, nextBracketClose),
});
}
str = str.substring(nextBracketClose + 1);
}
return value;
});
const render = () => {
return h(props.tag, parsed.value.map(x => typeof x === 'string' ? (props.textTag ? h(props.textTag, x) : x) : slots[x.arg]()));
};
</script>

View file

@ -123,7 +123,7 @@ export const DetailNow = {
export const RelativeOneHourAgo = {
...Empty,
async play({ canvasElement }) {
await expect(canvasElement).toHaveTextContent(i18n.t('_ago.hoursAgo', { n: 1 }));
await expect(canvasElement).toHaveTextContent(i18n.tsx._ago.hoursAgo({ n: 1 }));
},
args: {
...Empty.args,
@ -162,7 +162,7 @@ export const DetailOneHourAgo = {
export const RelativeOneDayAgo = {
...Empty,
async play({ canvasElement }) {
await expect(canvasElement).toHaveTextContent(i18n.t('_ago.daysAgo', { n: 1 }));
await expect(canvasElement).toHaveTextContent(i18n.tsx._ago.daysAgo({ n: 1 }));
},
args: {
...Empty.args,
@ -201,7 +201,7 @@ export const DetailOneDayAgo = {
export const RelativeOneWeekAgo = {
...Empty,
async play({ canvasElement }) {
await expect(canvasElement).toHaveTextContent(i18n.t('_ago.weeksAgo', { n: 1 }));
await expect(canvasElement).toHaveTextContent(i18n.tsx._ago.weeksAgo({ n: 1 }));
},
args: {
...Empty.args,
@ -240,7 +240,7 @@ export const DetailOneWeekAgo = {
export const RelativeOneMonthAgo = {
...Empty,
async play({ canvasElement }) {
await expect(canvasElement).toHaveTextContent(i18n.t('_ago.monthsAgo', { n: 1 }));
await expect(canvasElement).toHaveTextContent(i18n.tsx._ago.monthsAgo({ n: 1 }));
},
args: {
...Empty.args,
@ -279,7 +279,7 @@ export const DetailOneMonthAgo = {
export const RelativeOneYearAgo = {
...Empty,
async play({ canvasElement }) {
await expect(canvasElement).toHaveTextContent(i18n.t('_ago.yearsAgo', { n: 1 }));
await expect(canvasElement).toHaveTextContent(i18n.tsx._ago.yearsAgo({ n: 1 }));
},
args: {
...Empty.args,

View file

@ -55,21 +55,21 @@ const relative = computed<string>(() => {
if (invalid) return i18n.ts._ago.invalid;
return (
ago.value >= 31536000 ? i18n.t('_ago.yearsAgo', { n: Math.round(ago.value / 31536000).toString() }) :
ago.value >= 2592000 ? i18n.t('_ago.monthsAgo', { n: Math.round(ago.value / 2592000).toString() }) :
ago.value >= 604800 ? i18n.t('_ago.weeksAgo', { n: Math.round(ago.value / 604800).toString() }) :
ago.value >= 86400 ? i18n.t('_ago.daysAgo', { n: Math.round(ago.value / 86400).toString() }) :
ago.value >= 3600 ? i18n.t('_ago.hoursAgo', { n: Math.round(ago.value / 3600).toString() }) :
ago.value >= 60 ? i18n.t('_ago.minutesAgo', { n: (~~(ago.value / 60)).toString() }) :
ago.value >= 10 ? i18n.t('_ago.secondsAgo', { n: (~~(ago.value % 60)).toString() }) :
ago.value >= 31536000 ? i18n.tsx._ago.yearsAgo({ n: Math.round(ago.value / 31536000).toString() }) :
ago.value >= 2592000 ? i18n.tsx._ago.monthsAgo({ n: Math.round(ago.value / 2592000).toString() }) :
ago.value >= 604800 ? i18n.tsx._ago.weeksAgo({ n: Math.round(ago.value / 604800).toString() }) :
ago.value >= 86400 ? i18n.tsx._ago.daysAgo({ n: Math.round(ago.value / 86400).toString() }) :
ago.value >= 3600 ? i18n.tsx._ago.hoursAgo({ n: Math.round(ago.value / 3600).toString() }) :
ago.value >= 60 ? i18n.tsx._ago.minutesAgo({ n: (~~(ago.value / 60)).toString() }) :
ago.value >= 10 ? i18n.tsx._ago.secondsAgo({ n: (~~(ago.value % 60)).toString() }) :
ago.value >= -3 ? i18n.ts._ago.justNow :
ago.value < -31536000 ? i18n.t('_timeIn.years', { n: Math.round(-ago.value / 31536000).toString() }) :
ago.value < -2592000 ? i18n.t('_timeIn.months', { n: Math.round(-ago.value / 2592000).toString() }) :
ago.value < -604800 ? i18n.t('_timeIn.weeks', { n: Math.round(-ago.value / 604800).toString() }) :
ago.value < -86400 ? i18n.t('_timeIn.days', { n: Math.round(-ago.value / 86400).toString() }) :
ago.value < -3600 ? i18n.t('_timeIn.hours', { n: Math.round(-ago.value / 3600).toString() }) :
ago.value < -60 ? i18n.t('_timeIn.minutes', { n: (~~(-ago.value / 60)).toString() }) :
i18n.t('_timeIn.seconds', { n: (~~(-ago.value % 60)).toString() })
ago.value < -31536000 ? i18n.tsx._timeIn.years({ n: Math.round(-ago.value / 31536000).toString() }) :
ago.value < -2592000 ? i18n.tsx._timeIn.months({ n: Math.round(-ago.value / 2592000).toString() }) :
ago.value < -604800 ? i18n.tsx._timeIn.weeks({ n: Math.round(-ago.value / 604800).toString() }) :
ago.value < -86400 ? i18n.tsx._timeIn.days({ n: Math.round(-ago.value / 86400).toString() }) :
ago.value < -3600 ? i18n.tsx._timeIn.hours({ n: Math.round(-ago.value / 3600).toString() }) :
ago.value < -60 ? i18n.tsx._timeIn.minutes({ n: (~~(-ago.value / 60)).toString() }) :
i18n.tsx._timeIn.seconds({ n: (~~(-ago.value % 60)).toString() })
);
});

View file

@ -1,29 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { h } from 'vue';
export default function(props: { src: string; tag?: string; textTag?: string; }, { slots }) {
let str = props.src;
const parsed = [] as (string | { arg: string; })[];
while (true) {
const nextBracketOpen = str.indexOf('{');
const nextBracketClose = str.indexOf('}');
if (nextBracketOpen === -1) {
parsed.push(str);
break;
} else {
if (nextBracketOpen > 0) parsed.push(str.substring(0, nextBracketOpen));
parsed.push({
arg: str.substring(nextBracketOpen + 1, nextBracketClose),
});
}
str = str.substring(nextBracketClose + 1);
}
return h(props.tag ?? 'span', parsed.map(x => typeof x === 'string' ? (props.textTag ? h(props.textTag, x) : x) : slots[x.arg]()));
}

View file

@ -16,7 +16,7 @@ import MkUserName from './global/MkUserName.vue';
import MkEllipsis from './global/MkEllipsis.vue';
import MkTime from './global/MkTime.vue';
import MkUrl from './global/MkUrl.vue';
import I18n from './global/i18n';
import I18n from './global/I18n.vue';
import RouterView from './global/RouterView.vue';
import MkLoading from './global/MkLoading.vue';
import MkError from './global/MkError.vue';

View file

@ -113,3 +113,27 @@ export const DEFAULT_NOT_FOUND_IMAGE_URL = 'https://xn--931a.moe/assets/not-foun
export const DEFAULT_INFO_IMAGE_URL = 'https://xn--931a.moe/assets/info.jpg';
export const MFM_TAGS = ['tada', 'jelly', 'twitch', 'shake', 'spin', 'jump', 'bounce', 'flip', 'x2', 'x3', 'x4', 'scale', 'position', 'fg', 'bg', 'border', 'font', 'blur', 'rainbow', 'sparkle', 'rotate', 'ruby', 'unixtime'];
export const MFM_PARAMS: Record<typeof MFM_TAGS[number], string[]> = {
tada: ['speed=', 'delay='],
jelly: ['speed=', 'delay='],
twitch: ['speed=', 'delay='],
shake: ['speed=', 'delay='],
spin: ['speed=', 'delay=', 'left', 'alternate', 'x', 'y'],
jump: ['speed=', 'delay='],
bounce: ['speed=', 'delay='],
flip: ['h', 'v'],
x2: [],
x3: [],
x4: [],
scale: ['x=', 'y='],
position: ['x=', 'y='],
fg: ['color='],
bg: ['color='],
border: ['width=', 'style=', 'color=', 'radius=', 'noclip'],
font: ['serif', 'monospace', 'cursive', 'fantasy', 'emoji', 'math'],
blur: [],
rainbow: ['speed=', 'delay='],
rotate: ['deg='],
ruby: [],
unixtime: [],
};

View file

@ -15,6 +15,7 @@ const page = (loader: AsyncComponentLoader<any>) => defineAsyncComponent({
loadingComponent: MkLoading,
errorComponent: MkError,
});
const routes = [{
path: '/@:initUser/pages/:initPageName/view-source',
component: page(() => import('@/pages/page-editor/page-editor.vue')),
@ -332,7 +333,12 @@ const routes = [{
component: page(() => import('@/pages/registry.vue')),
}, {
path: '/install-extentions',
component: page(() => import('@/pages/install-extentions.vue')),
// Note: This path is kept for compatibility. It may be deleted.
component: page(() => import('@/pages/install-extensions.vue')),
loginRequired: true,
}, {
path: '/install-extensions',
component: page(() => import('@/pages/install-extensions.vue')),
loginRequired: true,
}, {
path: '/admin/user/:userId',
@ -523,18 +529,26 @@ const routes = [{
path: '/timeline/antenna/:antennaId',
component: page(() => import('@/pages/antenna-timeline.vue')),
loginRequired: true,
}, {
path: '/games',
component: page(() => import('@/pages/games.vue')),
loginRequired: true,
}, {
path: '/clicker',
component: page(() => import('@/pages/clicker.vue')),
loginRequired: true,
}, {
path: '/games',
component: page(() => import('@/pages/games.vue')),
loginRequired: false,
}, {
path: '/bubble-game',
component: page(() => import('@/pages/drop-and-fusion.vue')),
loginRequired: true,
}, {
path: '/reversi',
component: page(() => import('@/pages/reversi/index.vue')),
loginRequired: false,
}, {
path: '/reversi/g/:gameId',
component: page(() => import('@/pages/reversi/game.vue')),
loginRequired: false,
}, {
path: '/timeline',
component: page(() => import('@/pages/timeline.vue')),

View file

@ -10,6 +10,7 @@ import { I18n } from '@/scripts/i18n.js';
export const i18n = markRaw(new I18n<Locale>(locale));
export function updateI18n(newLocale) {
i18n.ts = newLocale;
export function updateI18n(newLocale: Locale) {
// @ts-expect-error -- private field
i18n.locale = newLocale;
}

View file

@ -450,7 +450,7 @@ export async function selectUser(opts: { includeSelf?: boolean, multiple?: boole
});
}
export async function multipleSelectUser(opts: { includeSelf?: boolean } = {}) {
export async function multipleSelectUser(opts: { includeSelf?: boolean } = {}): Promise<Misskey.entities.UserLite> {
return new Promise((resolve, reject) => {
popup(defineAsyncComponent(() => import('@/components/MkUserSelectDialog.vue')), {
includeSelf: opts.includeSelf,

View file

@ -29,7 +29,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #key>Misskey</template>
<template #value>{{ version }}</template>
</MkKeyValue>
<div v-html="i18n.t('poweredByMisskeyDescription', { name: instance.name ?? host })">
<div v-html="i18n.tsx.poweredByMisskeyDescription({ name: instance.name ?? host })">
</div>
<FormLink to="/about-misskey">{{ i18n.ts.aboutMisskey }}</FormLink>
</div>

View file

@ -104,7 +104,7 @@ fetch();
async function del() {
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.t('removeAreYouSure', { x: file.value.name }),
text: i18n.tsx.removeAreYouSure({ x: file.value.name }),
});
if (canceled) return;

View file

@ -182,9 +182,9 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkSelect>
</div>
<div class="charts">
<div class="label">{{ i18n.t('recentNHours', { n: 90 }) }}</div>
<div class="label">{{ i18n.tsx.recentNHours({ n: 90 }) }}</div>
<MkChart class="chart" :src="chartSrc" span="hour" :limit="90" :args="{ user, withoutAll: true }" :detailed="true"></MkChart>
<div class="label">{{ i18n.t('recentNDays', { n: 90 }) }}</div>
<div class="label">{{ i18n.tsx.recentNDays({ n: 90 }) }}</div>
<MkChart class="chart" :src="chartSrc" span="day" :limit="90" :args="{ user, withoutAll: true }" :detailed="true"></MkChart>
</div>
</div>
@ -307,7 +307,7 @@ async function resetPassword() {
});
os.alert({
type: 'success',
text: i18n.t('newPasswordIs', { password }),
text: i18n.tsx.newPasswordIs({ password }),
});
}
}
@ -390,7 +390,7 @@ async function deleteAccount() {
if (confirm.canceled) return;
const typed = await os.inputText({
text: i18n.t('typeToConfirm', { x: user.value?.username }),
text: i18n.tsx.typeToConfirm({ x: user.value?.username }),
});
if (typed.canceled) return;

View file

@ -160,7 +160,7 @@ function add() {
function remove(ad) {
os.confirm({
type: 'warning',
text: i18n.t('removeAreYouSure', { x: ad.url }),
text: i18n.tsx.removeAreYouSure({ x: ad.url }),
}).then(({ canceled }) => {
if (canceled) return;
ads.value = ads.value.filter(x => x !== ad);

View file

@ -54,7 +54,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSwitch v-model="announcement.needConfirmationToRead" :helpText="i18n.ts._announcement.needConfirmationToReadDescription">
{{ i18n.ts._announcement.needConfirmationToRead }}
</MkSwitch>
<p v-if="announcement.reads">{{ i18n.t('nUsersRead', { n: announcement.reads }) }}</p>
<p v-if="announcement.reads">{{ i18n.tsx.nUsersRead({ n: announcement.reads }) }}</p>
<div class="buttons _buttons">
<MkButton class="button" inline primary @click="save(announcement)"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
<MkButton v-if="announcement.id != null" class="button" inline @click="archive(announcement)"><i class="ti ti-check"></i> {{ i18n.ts._announcement.end }} ({{ i18n.ts.archive }})</MkButton>
@ -109,7 +109,7 @@ function add() {
function del(announcement) {
os.confirm({
type: 'warning',
text: i18n.t('deleteAreYouSure', { x: announcement.title }),
text: i18n.tsx.deleteAreYouSure({ x: announcement.title }),
}).then(({ canceled }) => {
if (canceled) return;
announcements.value = announcements.value.filter(x => x !== announcement);

View file

@ -19,10 +19,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #prefix><i class="ti ti-link"></i></template>
<template #label>{{ i18n.ts._serverSettings.iconUrl }} (App/192px)</template>
<template #caption>
<div>{{ i18n.t('_serverSettings.appIconDescription', { host: instance.name ?? host }) }}</div>
<div>{{ i18n.tsx._serverSettings.appIconDescription({ host: instance.name ?? host }) }}</div>
<div>({{ i18n.ts._serverSettings.appIconUsageExample }})</div>
<div>{{ i18n.ts._serverSettings.appIconStyleRecommendation }}</div>
<div><strong>{{ i18n.t('_serverSettings.appIconResolutionMustBe', { resolution: '192x192px' }) }}</strong></div>
<div><strong>{{ i18n.tsx._serverSettings.appIconResolutionMustBe({ resolution: '192x192px' }) }}</strong></div>
</template>
</MkInput>
@ -30,10 +30,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #prefix><i class="ti ti-link"></i></template>
<template #label>{{ i18n.ts._serverSettings.iconUrl }} (App/512px)</template>
<template #caption>
<div>{{ i18n.t('_serverSettings.appIconDescription', { host: instance.name ?? host }) }}</div>
<div>{{ i18n.tsx._serverSettings.appIconDescription({ host: instance.name ?? host }) }}</div>
<div>({{ i18n.ts._serverSettings.appIconUsageExample }})</div>
<div>{{ i18n.ts._serverSettings.appIconStyleRecommendation }}</div>
<div><strong>{{ i18n.t('_serverSettings.appIconResolutionMustBe', { resolution: '512x512px' }) }}</strong></div>
<div><strong>{{ i18n.tsx._serverSettings.appIconResolutionMustBe({ resolution: '512x512px' }) }}</strong></div>
</template>
</MkInput>

View file

@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<i v-if="relay.status === 'accepted'" class="ti ti-check" :class="$style.icon" style="color: var(--success);"></i>
<i v-else-if="relay.status === 'rejected'" class="ti ti-ban" :class="$style.icon" style="color: var(--error);"></i>
<i v-else class="ti ti-clock" :class="$style.icon"></i>
<span>{{ i18n.t(`_relayStatus.${relay.status}`) }}</span>
<span>{{ i18n.ts._relayStatus[relay.status] }}</span>
</div>
<MkButton class="button" inline danger @click="remove(relay.inbox)"><i class="ti ti-trash"></i> {{ i18n.ts.remove }}</MkButton>
</div>

View file

@ -104,7 +104,7 @@ function edit() {
async function del() {
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.t('deleteAreYouSure', { x: role.name }),
text: i18n.tsx.deleteAreYouSure({ x: role.name }),
});
if (canceled) return;

View file

@ -71,27 +71,28 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="_gaps_m">
<span>{{ i18n.ts.activeEmailValidationDescription }}</span>
<MkSwitch v-model="enableActiveEmailValidation" @update:modelValue="save">
<MkSwitch v-model="enableActiveEmailValidation">
<template #label>Enable</template>
</MkSwitch>
<MkSwitch v-model="enableVerifymailApi" @update:modelValue="save">
<MkSwitch v-model="enableVerifymailApi">
<template #label>Use Verifymail.io API</template>
</MkSwitch>
<MkInput v-model="verifymailAuthKey" @update:modelValue="save">
<MkInput v-model="verifymailAuthKey">
<template #prefix><i class="ti ti-key"></i></template>
<template #label>Verifymail.io API Auth Key</template>
</MkInput>
<MkSwitch v-model="enableTruemailApi" @update:modelValue="save">
<MkSwitch v-model="enableTruemailApi">
<template #label>Use TrueMail API</template>
</MkSwitch>
<MkInput v-model="truemailInstance" @update:modelValue="save">
<MkInput v-model="truemailInstance">
<template #prefix><i class="ti ti-key"></i></template>
<template #label>TrueMail API Instance</template>
</MkInput>
<MkInput v-model="truemailAuthKey" @update:modelValue="save">
<MkInput v-model="truemailAuthKey">
<template #prefix><i class="ti ti-key"></i></template>
<template #label>TrueMail API Auth Key</template>
</MkInput>
<MkButton primary @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
</div>
</MkFolder>
@ -192,7 +193,10 @@ async function init() {
enableActiveEmailValidation.value = meta.enableActiveEmailValidation;
enableVerifymailApi.value = meta.enableVerifymailApi;
verifymailAuthKey.value = meta.verifymailAuthKey;
bannedEmailDomains.value = meta.bannedEmailDomains.join('\n');
enableTruemailApi.value = meta.enableTruemailApi;
truemailInstance.value = meta.truemailInstance;
truemailAuthKey.value = meta.truemailAuthKey;
bannedEmailDomains.value = meta.bannedEmailDomains?.join('\n') || "";
}
function save() {

View file

@ -78,7 +78,7 @@ async function read(announcement) {
const confirm = await os.confirm({
type: 'question',
title: i18n.ts._announcement.readConfirmTitle,
text: i18n.t('_announcement.readConfirmText', { title: announcement.title }),
text: i18n.tsx._announcement.readConfirmText({ title: announcement.title }),
});
if (confirm.canceled) return;
}

View file

@ -6,12 +6,12 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<section>
<div v-if="app.permission.length > 0">
<p>{{ i18n.t('_auth.permission', { name }) }}</p>
<p>{{ i18n.tsx._auth.permission({ name }) }}</p>
<ul>
<li v-for="p in app.permission" :key="p">{{ i18n.t(`_permissions.${p}`) }}</li>
<li v-for="p in app.permission" :key="p">{{ i18n.ts._permissions[p] }}</li>
</ul>
</div>
<div>{{ i18n.t('_auth.shareAccess', { name: `${name} (${app.id})` }) }}</div>
<div>{{ i18n.tsx._auth.shareAccess({ name: `${name} (${app.id})` }) }}</div>
<div :class="$style.buttons">
<MkButton inline @click="cancel">{{ i18n.ts.cancel }}</MkButton>
<MkButton inline primary @click="accept">{{ i18n.ts.accept }}</MkButton>

View file

@ -25,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<h1>{{ i18n.ts._auth.denied }}</h1>
</div>
<div v-if="state == 'accepted' && session">
<h1>{{ session.app.isAuthorized ? i18n.t('already-authorized') : i18n.ts.allowed }}</h1>
<h1>{{ session.app.isAuthorized ? i18n.ts['already-authorized'] : i18n.ts.allowed }}</h1>
<p v-if="session.app.callbackUrl">
{{ i18n.ts._auth.callback }}
<MkEllipsis/>

View file

@ -49,7 +49,6 @@ function add() {
category: '',
});
}
function selectItems(decorationId) {
if (selectItemsId.value.includes(decorationId)) {
const index = selectItemsId.value.indexOf(decorationId);
@ -58,6 +57,25 @@ function selectItems(decorationId) {
selectItemsId.value.push(decorationId);
}
}
function del(avatarDecoration) {
os.confirm({
type: 'warning',
text: i18n.tsx.deleteAreYouSure({ x: avatarDecoration.name }),
}).then(({ canceled }) => {
if (canceled) return;
avatarDecorations.value = avatarDecorations.value.filter(x => x !== avatarDecoration);
misskeyApi('admin/avatar-decorations/delete', avatarDecoration);
});
}
async function save(avatarDecoration) {
if (avatarDecoration.id == null) {
await os.apiWithDialog('admin/avatar-decorations/create', avatarDecoration);
load();
} else {
selectItemsId.value.push(decorationId);
}
}
function openDecorationEdit(avatarDecoration) {
os.popup(defineAsyncComponent(() => import('@/components/MkAvatarDecoEditDialog.vue')), {

View file

@ -174,7 +174,7 @@ function save() {
async function archive() {
const { canceled } = await os.confirm({
type: 'warning',
title: i18n.t('channelArchiveConfirmTitle', { name: name.value }),
title: i18n.tsx.channelArchiveConfirmTitle({ name: name.value }),
text: i18n.ts.channelArchiveConfirmDescription,
});

View file

@ -145,7 +145,7 @@ const headerActions = computed(() => clip.value && isOwned.value ? [{
handler: async (): Promise<void> => {
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.t('deleteAreYouSure', { x: clip.value.name }),
text: i18n.tsx.deleteAreYouSure({ x: clip.value.name }),
});
if (canceled) return;

View file

@ -180,7 +180,7 @@ async function deleteFile() {
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.t('driveFileDeleteConfirm', { name: file.value.name }),
text: i18n.tsx.driveFileDeleteConfirm({ name: file.value.name }),
});
if (canceled) return;

View file

@ -180,6 +180,7 @@ SPDX-License-Identifier: AGPL-3.0-only
import { computed, onDeactivated, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue';
import * as Matter from 'matter-js';
import * as Misskey from 'misskey-js';
import { DropAndFusionGame, Mono } from 'misskey-bubble-game';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import MkRippleEffect from '@/components/MkRippleEffect.vue';
import * as os from '@/os.js';
@ -193,7 +194,6 @@ import { i18n } from '@/i18n.js';
import { useInterval } from '@/scripts/use-interval.js';
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';
import copyToClipboard from '@/scripts/copy-to-clipboard.js';

View file

@ -46,7 +46,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.frame">
<div :class="$style.frameInner">
<div class="_gaps_s" style="padding: 16px;">
<div><b>{{ i18n.t('lastNDays', { n: 7 }) }} {{ i18n.ts.ranking }}</b> ({{ gameMode }})</div>
<div><b>{{ i18n.tsx.lastNDays({ n: 7 }) }} {{ i18n.ts.ranking }}</b> ({{ gameMode }})</div>
<div v-if="ranking" class="_gaps_s">
<div v-for="r in ranking" :key="r.id" :class="$style.rankingRecord">
<MkAvatar :link="true" style="width: 24px; height: 24px; margin-right: 4px;" :user="r.user"/>
@ -128,7 +128,7 @@ function onGameEnd() {
definePageMetadata({
title: i18n.ts.bubbleGame,
icon: 'ti ti-apple',
icon: 'ti ti-device-gamepad',
});
</script>

View file

@ -438,7 +438,7 @@ function show() {
async function del() {
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.t('deleteAreYouSure', { x: flash.value.title }),
text: i18n.tsx.deleteAreYouSure({ x: flash.value.title }),
});
if (canceled) return;

View file

@ -20,7 +20,7 @@ import { mainRouter } from '@/global/router/main.js';
async function follow(user): Promise<void> {
const { canceled } = await os.confirm({
type: 'question',
text: i18n.t('followConfirm', { name: user.name || user.username }),
text: i18n.tsx.followConfirm({ name: user.name || user.username }),
});
if (canceled) {

View file

@ -7,10 +7,17 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkStickyContainer>
<template #header><MkPageHeader/></template>
<MkSpacer :contentMax="800">
<div class="_panel">
<MkA to="/bubble-game">
<img src="/client-assets/drop-and-fusion/logo.png" style="display: block; max-width: 100%; max-height: 200px; margin: auto;"/>
</MkA>
<div class="_gaps">
<div class="_panel">
<MkA to="/bubble-game">
<img src="/client-assets/drop-and-fusion/logo.png" style="display: block; max-width: 100%; max-height: 200px; margin: auto;"/>
</MkA>
</div>
<div class="_panel">
<MkA to="/reversi">
<img src="/client-assets/reversi/logo.png" style="display: block; max-width: 100%; max-height: 200px; margin: auto;"/>
</MkA>
</div>
</div>
</MkSpacer>
</MkStickyContainer>

View file

@ -95,9 +95,9 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkSelect>
</div>
<div class="charts">
<div class="label">{{ i18n.t('recentNHours', { n: 90 }) }}</div>
<div class="label">{{ i18n.tsx.recentNHours({ n: 90 }) }}</div>
<MkChart class="chart" :src="chartSrc" span="hour" :limit="90" :args="{ host: host }" :detailed="true"></MkChart>
<div class="label">{{ i18n.t('recentNDays', { n: 90 }) }}</div>
<div class="label">{{ i18n.tsx.recentNDays({ n: 90 }) }}</div>
<MkChart class="chart" :src="chartSrc" span="day" :limit="90" :args="{ host: host }" :detailed="true"></MkChart>
</div>
</div>

View file

@ -19,9 +19,9 @@ SPDX-License-Identifier: AGPL-3.0-only
</MKSpacer>
<MkSpacer v-else :contentMax="800">
<div class="_gaps_m" style="text-align: center;">
<div v-if="resetCycle && inviteLimit">{{ i18n.t('inviteLimitResetCycle', { time: resetCycle, limit: inviteLimit }) }}</div>
<div v-if="resetCycle && inviteLimit">{{ i18n.tsx.inviteLimitResetCycle({ time: resetCycle, limit: inviteLimit }) }}</div>
<MkButton inline primary rounded :disabled="currentInviteLimit !== null && currentInviteLimit <= 0" @click="create"><i class="ti ti-user-plus"></i> {{ i18n.ts.createInviteCode }}</MkButton>
<div v-if="currentInviteLimit !== null">{{ i18n.t('createLimitRemaining', { limit: currentInviteLimit }) }}</div>
<div v-if="currentInviteLimit !== null">{{ i18n.tsx.createLimitRemaining({ limit: currentInviteLimit }) }}</div>
<MkPagination ref="pagingComponent" :pagination="pagination">
<template #default="{ items }">

View file

@ -20,13 +20,13 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<div v-else>
<div v-if="_permissions.length > 0">
<p v-if="name">{{ i18n.t('_auth.permission', { name }) }}</p>
<p v-if="name">{{ i18n.tsx._auth.permission({ name }) }}</p>
<p v-else>{{ i18n.ts._auth.permissionAsk }}</p>
<ul>
<li v-for="p in _permissions" :key="p">{{ i18n.t(`_permissions.${p}`) }}</li>
<li v-for="p in _permissions" :key="p">{{ i18n.ts._permissions[p] }}</li>
</ul>
</div>
<div v-if="name">{{ i18n.t('_auth.shareAccess', { name }) }}</div>
<div v-if="name">{{ i18n.tsx._auth.shareAccess({ name }) }}</div>
<div v-else>{{ i18n.ts._auth.shareAccessAsk }}</div>
<div :class="$style.buttons">
<MkButton inline @click="deny">{{ i18n.ts.cancel }}</MkButton>

View file

@ -116,7 +116,7 @@ async function saveAntenna() {
async function deleteAntenna() {
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.t('removeAreYouSure', { x: props.antenna.name }),
text: i18n.tsx.removeAreYouSure({ x: props.antenna.name }),
});
if (canceled) return;

View file

@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-if="items.length > 0" class="_gaps">
<MkA v-for="list in items" :key="list.id" class="_panel" :class="$style.list" :to="`/my/lists/${ list.id }`">
<div style="margin-bottom: 4px;">{{ list.name }} <span :class="$style.nUsers">({{ i18n.t('nUsers', { n: `${list.userIds.length}/${$i.policies['userEachUserListsLimit']}` }) }})</span></div>
<div style="margin-bottom: 4px;">{{ list.name }} <span :class="$style.nUsers">({{ i18n.tsx.nUsers({ n: `${list.userIds.length}/${$i.policies['userEachUserListsLimit']}` }) }})</span></div>
<MkAvatars :userIds="list.userIds" :limit="10"/>
</MkA>
</div>

View file

@ -25,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkFolder defaultOpen>
<template #label>{{ i18n.ts.members }}</template>
<template #caption>{{ i18n.t('nUsers', { n: `${list.userIds.length}/${$i.policies['userEachUserListsLimit']}` }) }}</template>
<template #caption>{{ i18n.tsx.nUsers({ n: `${list.userIds.length}/${$i.policies['userEachUserListsLimit']}` }) }}</template>
<div class="_gaps_s">
<MkButton rounded primary style="margin: 0 auto;" @click="addUser()">{{ i18n.ts.addUser }}</MkButton>
@ -163,7 +163,7 @@ async function deleteList() {
if (!list.value) return;
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.t('removeAreYouSure', { x: list.value.name }),
text: i18n.tsx.removeAreYouSure({ x: list.value.name }),
});
if (canceled) return;

View file

@ -147,7 +147,7 @@ definePageMetadata(computed(() => note.value ? {
avatar: note.value.user,
path: `/notes/${note.value.id}`,
share: {
title: i18n.t('noteOf', { user: note.value.user.name }),
title: i18n.tsx.noteOf({ user: note.value.user.name }),
text: note.value.text,
},
} : null));

View file

@ -51,7 +51,7 @@ const directNotesPagination = {
function setFilter(ev) {
const typeItems = notificationTypes.map(t => ({
text: i18n.t(`_notification._types.${t}`),
text: i18n.ts._notification._types[t],
active: includeTypes.value && includeTypes.value.includes(t),
action: () => {
includeTypes.value = [t];

View file

@ -9,13 +9,13 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSpacer :contentMax="800">
<div v-if="$i">
<div v-if="permissions.length > 0">
<p v-if="name">{{ i18n.t('_auth.permission', { name }) }}</p>
<p v-if="name">{{ i18n.tsx._auth.permission({ name }) }}</p>
<p v-else>{{ i18n.ts._auth.permissionAsk }}</p>
<ul>
<li v-for="p in permissions" :key="p">{{ i18n.t(`_permissions.${p}`) }}</li>
<li v-for="p in permissions" :key="p">{{ i18n.ts._permissions[p] }}</li>
</ul>
</div>
<div v-if="name">{{ i18n.t('_auth.shareAccess', { name }) }}</div>
<div v-if="name">{{ i18n.tsx._auth.shareAccess({ name }) }}</div>
<div v-else>{{ i18n.ts._auth.shareAccessAsk }}</div>
<form :class="$style.buttons" action="/oauth/decision" accept-charset="utf-8" method="post">
<input name="login_token" type="hidden" :value="$i.token"/>

View file

@ -175,7 +175,7 @@ function save() {
function del() {
os.confirm({
type: 'warning',
text: i18n.t('removeAreYouSure', { x: title.value.trim() }),
text: i18n.tsx.removeAreYouSure({ x: title.value.trim() }),
}).then(({ canceled }) => {
if (canceled) return;
misskeyApi('pages/delete', {

View file

@ -0,0 +1,440 @@
<!--
SPDX-FileCopyrightText: syuilo and other misskey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkSpacer :contentMax="600">
<div :class="$style.root" class="_gaps">
<header><b><MkA :to="userPage(blackUser)"><MkUserName :user="blackUser"/></MkA></b>({{ i18n.ts._reversi.black }}) vs <b><MkA :to="userPage(whiteUser)"><MkUserName :user="whiteUser"/></MkA></b>({{ i18n.ts._reversi.white }})</header>
<div style="overflow: clip; line-height: 28px;">
<div v-if="!iAmPlayer && !game.isEnded && turnUser" class="turn">
<Mfm :key="'turn:' + turnUser.id" :text="i18n.tsx._reversi.turnOf({ name: turnUser.name ?? turnUser.username })" :plain="true" :customEmojis="turnUser.emojis"/>
<MkEllipsis/>
</div>
<div v-if="(logPos !== game.logs.length) && turnUser" class="turn">
<Mfm :key="'past-turn-of:' + turnUser.id" :text="i18n.tsx._reversi.pastTurnOf({ name: turnUser.name ?? turnUser.username })" :plain="true" :customEmojis="turnUser.emojis"/>
</div>
<div v-if="iAmPlayer && !game.isEnded && !isMyTurn" class="turn1">{{ i18n.ts._reversi.opponentTurn }}<MkEllipsis/></div>
<div v-if="iAmPlayer && !game.isEnded && isMyTurn" class="turn2" style="animation: tada 1s linear infinite both;">{{ i18n.ts._reversi.myTurn }}</div>
<div v-if="game.isEnded && logPos == game.logs.length" class="result">
<template v-if="game.winner">
<Mfm :key="'won'" :text="i18n.tsx._reversi.won({ name: game.winner.name ?? game.winner.username })" :plain="true" :customEmojis="game.winner.emojis"/>
<span v-if="game.surrendered != null"> ({{ i18n.ts._reversi.surrendered }})</span>
</template>
<template v-else>{{ i18n.ts._reversi.drawn }}</template>
</div>
</div>
<div :class="$style.board">
<div v-if="showBoardLabels" :class="$style.labelsX">
<span v-for="i in game.map[0].length" :class="$style.labelsXLabel">{{ String.fromCharCode(64 + i) }}</span>
</div>
<div style="display: flex;">
<div v-if="showBoardLabels" :class="$style.labelsY">
<div v-for="i in game.map.length" :class="$style.labelsYLabel">{{ i }}</div>
</div>
<div :class="$style.boardCells" :style="cellsStyle">
<div
v-for="(stone, i) in engine.board"
v-tooltip="`${String.fromCharCode(65 + engine.posToXy(i)[0])}${engine.posToXy(i)[1] + 1}`"
:class="[$style.boardCell, {
[$style.boardCell_empty]: stone == null,
[$style.boardCell_none]: engine.map[i] === 'null',
[$style.boardCell_isEnded]: game.isEnded,
[$style.boardCell_myTurn]: !game.isEnded && isMyTurn,
[$style.boardCell_can]: turnUser ? engine.canPut(turnUser.id === blackUser.id, i) : null,
[$style.boardCell_prev]: engine.prevPos === i
}]"
@click="putStone(i)"
>
<img v-if="stone === true" style="pointer-events: none; user-select: none; display: block; width: 100%; height: 100%;" :src="blackUser.avatarUrl">
<img v-if="stone === false" style="pointer-events: none; user-select: none; display: block; width: 100%; height: 100%;" :src="whiteUser.avatarUrl">
</div>
</div>
<div v-if="showBoardLabels" :class="$style.labelsY">
<div v-for="i in game.map.length" :class="$style.labelsYLabel">{{ i }}</div>
</div>
</div>
<div v-if="showBoardLabels" :class="$style.labelsX">
<span v-for="i in game.map[0].length" :class="$style.labelsXLabel">{{ String.fromCharCode(64 + i) }}</span>
</div>
</div>
<div class="status"><b>{{ i18n.tsx._reversi.turnCount({ count: logPos }) }}</b> {{ i18n.ts._reversi.black }}:{{ engine.blackCount }} {{ i18n.ts._reversi.white }}:{{ engine.whiteCount }} {{ i18n.ts._reversi.total }}:{{ engine.blackCount + engine.whiteCount }}</div>
<div v-if="!game.isEnded && iAmPlayer" class="_buttonsCenter">
<MkButton danger @click="surrender">{{ i18n.ts._reversi.surrender }}</MkButton>
</div>
<div v-if="game.isEnded" class="_panel _gaps_s" style="padding: 16px;">
<div>{{ logPos }} / {{ game.logs.length }}</div>
<div v-if="!autoplaying" class="_buttonsCenter">
<MkButton :disabled="logPos === 0" @click="logPos = 0"><i class="ti ti-chevrons-left"></i></MkButton>
<MkButton :disabled="logPos === 0" @click="logPos--"><i class="ti ti-chevron-left"></i></MkButton>
<MkButton :disabled="logPos === game.logs.length" @click="logPos++"><i class="ti ti-chevron-right"></i></MkButton>
<MkButton :disabled="logPos === game.logs.length" @click="logPos = game.logs.length"><i class="ti ti-chevrons-right"></i></MkButton>
</div>
<MkButton style="margin: auto;" :disabled="autoplaying" @click="autoplay()"><i class="ti ti-player-play"></i></MkButton>
</div>
<div>
<p v-if="game.isLlotheo">{{ i18n.ts._reversi.isLlotheo }}</p>
<p v-if="game.loopedBoard">{{ i18n.ts._reversi.loopedMap }}</p>
<p v-if="game.canPutEverywhere">{{ i18n.ts._reversi.canPutEverywhere }}</p>
</div>
<MkA v-if="game.isEnded" :to="`/reversi`">
<img src="/client-assets/reversi/logo.png" style="display: block; max-width: 100%; width: 200px; margin: auto;"/>
</MkA>
</div>
</MkSpacer>
</template>
<script lang="ts" setup>
import { computed, onMounted, onUnmounted, ref, shallowRef, triggerRef, watch } from 'vue';
import * as CRC32 from 'crc-32';
import * as Misskey from 'misskey-js';
import * as Reversi from 'misskey-reversi';
import MkButton from '@/components/MkButton.vue';
import { deepClone } from '@/scripts/clone.js';
import { useInterval } from '@/scripts/use-interval.js';
import { signinRequired } from '@/account.js';
import { i18n } from '@/i18n.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { userPage } from '@/filters/user.js';
const $i = signinRequired();
const props = defineProps<{
game: Misskey.entities.ReversiGameDetailed;
connection: Misskey.ChannelConnection;
}>();
const showBoardLabels = true;
const autoplaying = ref<boolean>(false);
const game = ref<Misskey.entities.ReversiGameDetailed>(deepClone(props.game));
const logPos = ref<number>(game.value.logs.length);
const engine = shallowRef<Reversi.Game>(Reversi.Serializer.restoreGame({
map: game.value.map,
isLlotheo: game.value.isLlotheo,
canPutEverywhere: game.value.canPutEverywhere,
loopedBoard: game.value.loopedBoard,
logs: game.value.logs,
}));
const iAmPlayer = computed(() => {
return game.value.user1Id === $i.id || game.value.user2Id === $i.id;
});
const myColor = computed(() => {
if (!iAmPlayer.value) return null;
if (game.value.user1Id === $i.id && game.value.black === 1) return true;
if (game.value.user2Id === $i.id && game.value.black === 2) return true;
return false;
});
const opColor = computed(() => {
if (!iAmPlayer.value) return null;
return !myColor.value;
});
const blackUser = computed(() => {
return game.value.black === 1 ? game.value.user1 : game.value.user2;
});
const whiteUser = computed(() => {
return game.value.black === 1 ? game.value.user2 : game.value.user1;
});
const turnUser = computed(() => {
if (engine.value.turn === true) {
return game.value.black === 1 ? game.value.user1 : game.value.user2;
} else if (engine.value.turn === false) {
return game.value.black === 1 ? game.value.user2 : game.value.user1;
} else {
return null;
}
});
const isMyTurn = computed(() => {
if (!iAmPlayer.value) return false;
const u = turnUser.value;
if (u == null) return false;
return u.id === $i.id;
});
const cellsStyle = computed(() => {
return {
'grid-template-rows': `repeat(${game.value.map.length}, 1fr)`,
'grid-template-columns': `repeat(${game.value.map[0].length}, 1fr)`,
};
});
watch(logPos, (v) => {
if (!game.value.isEnded) return;
engine.value = Reversi.Serializer.restoreGame({
map: game.value.map,
isLlotheo: game.value.isLlotheo,
canPutEverywhere: game.value.canPutEverywhere,
loopedBoard: game.value.loopedBoard,
logs: game.value.logs.slice(0, v),
});
});
if (game.value.isStarted && !game.value.isEnded) {
useInterval(() => {
if (game.value.isEnded) return;
const crc32 = CRC32.str(JSON.stringify(game.value.logs)).toString();
if (_DEV_) console.log('crc32', crc32);
props.connection.send('syncState', {
crc32: crc32,
});
}, 10000, { immediate: false, afterMounted: true });
}
const appliedOps: string[] = [];
function putStone(pos) {
if (game.value.isEnded) return;
if (!iAmPlayer.value) return;
if (!isMyTurn.value) return;
if (!engine.value.canPut(myColor.value!, pos)) return;
engine.value.putStone(pos);
triggerRef(engine);
//
//sound.play(myColor.value ? 'reversiPutBlack' : 'reversiPutWhite');
const id = Math.random().toString(36).slice(2);
props.connection.send('putStone', {
pos: pos,
id,
});
appliedOps.push(id);
checkEnd();
}
function onStreamLog(log: Reversi.Serializer.Log & { id: string | null }) {
game.value.logs = Reversi.Serializer.serializeLogs([
...Reversi.Serializer.deserializeLogs(game.value.logs),
log,
]);
logPos.value++;
if (log.id == null || !appliedOps.includes(log.id)) {
switch (log.operation) {
case 'put': {
engine.value.putStone(log.pos);
triggerRef(engine);
checkEnd();
//sound.play(x.color ? 'reversiPutBlack' : 'reversiPutWhite');
break;
}
default:
break;
}
}
}
function onStreamEnded(x) {
game.value = deepClone(x.game);
}
function checkEnd() {
game.value.isEnded = engine.value.isEnded;
if (game.value.isEnded) {
if (engine.value.winner === true) {
game.value.winnerId = game.value.black === 1 ? game.value.user1Id : game.value.user2Id;
game.value.winner = game.value.black === 1 ? game.value.user1 : game.value.user2;
} else if (engine.value.winner === false) {
game.value.winnerId = game.value.black === 1 ? game.value.user2Id : game.value.user1Id;
game.value.winner = game.value.black === 1 ? game.value.user2 : game.value.user1;
} else {
game.value.winnerId = null;
game.value.winner = null;
}
}
}
function onStreamRescue(_game) {
console.log('rescue');
game.value = deepClone(_game);
engine.value = Reversi.Serializer.restoreGame({
map: game.value.map,
isLlotheo: game.value.isLlotheo,
canPutEverywhere: game.value.canPutEverywhere,
loopedBoard: game.value.loopedBoard,
logs: game.value.logs,
});
logPos.value = game.value.logs.length;
checkEnd();
}
function surrender() {
misskeyApi('reversi/surrender', {
gameId: game.value.id,
});
}
function autoplay() {
autoplaying.value = true;
logPos.value = 0;
const logs = Reversi.Serializer.deserializeLogs(game.value.logs);
window.setTimeout(() => {
logPos.value = 1;
let i = 1;
let previousLog = logs[0];
const tick = () => {
const log = logs[i];
const time = log.time - previousLog.time;
setTimeout(() => {
i++;
logPos.value++;
previousLog = log;
if (i < logs.length) {
tick();
} else {
autoplaying.value = false;
}
}, time);
};
tick();
}, 1000);
}
onMounted(() => {
props.connection.on('log', onStreamLog);
props.connection.on('rescue', onStreamRescue);
props.connection.on('ended', onStreamEnded);
});
onUnmounted(() => {
props.connection.off('log', onStreamLog);
props.connection.off('rescue', onStreamRescue);
props.connection.off('ended', onStreamEnded);
});
</script>
<style lang="scss" module>
@use "sass:math";
$label-size: 16px;
$gap: 4px;
.root {
text-align: center;
}
.board {
width: calc(100% - 16px);
max-width: 500px;
margin: 0 auto;
}
.labelsX {
height: $label-size;
padding: 0 $label-size;
display: flex;
}
.labelsXLabel {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.8em;
&:first-child {
margin-left: -(math.div($gap, 2));
}
&:last-child {
margin-right: -(math.div($gap, 2));
}
}
.labelsY {
width: $label-size;
display: flex;
flex-direction: column;
}
.labelsYLabel {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
&:first-child {
margin-top: -(math.div($gap, 2));
}
&:last-child {
margin-bottom: -(math.div($gap, 2));
}
}
.boardCells {
flex: 1;
display: grid;
grid-gap: $gap;
}
.boardCell {
background: transparent;
border-radius: 6px;
overflow: clip;
aspect-ratio: 1;
&.boardCell_empty {
border: solid 2px var(--divider);
}
&.boardCell_empty.boardCell_can {
border-color: var(--accent);
opacity: 0.5;
}
&.boardCell_empty.boardCell_myTurn {
border-color: var(--divider);
opacity: 1;
&.boardCell_can {
border-color: var(--accent);
cursor: pointer;
&:hover {
background: var(--accent);
}
}
}
&.boardCell_prev {
box-shadow: 0 0 0 4px var(--accent);
}
&.boardCell_isEnded {
border-color: var(--divider);
}
&.boardCell_none {
border-color: transparent !important;
}
}
</style>

View file

@ -0,0 +1,236 @@
<!--
SPDX-FileCopyrightText: syuilo and other misskey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkStickyContainer>
<MkSpacer :contentMax="600">
<div style="text-align: center;"><b><MkUserName :user="game.user1"/></b> vs <b><MkUserName :user="game.user2"/></b></div>
<div class="_gaps">
<div style="font-size: 1.5em; text-align: center;">{{ i18n.ts._reversi.gameSettings }}</div>
<div class="_panel">
<div style="display: flex; align-items: center; padding: 16px; border-bottom: solid 1px var(--divider);">
<div>{{ mapName }}</div>
<MkButton style="margin-left: auto;" @click="chooseMap">{{ i18n.ts._reversi.chooseBoard }}</MkButton>
</div>
<div style="padding: 16px;">
<div v-if="game.map == null"><i class="ti ti-dice"></i></div>
<div v-else :class="$style.board" :style="{ 'grid-template-rows': `repeat(${ game.map.length }, 1fr)`, 'grid-template-columns': `repeat(${ game.map[0].length }, 1fr)` }">
<div v-for="(x, i) in game.map.join('')" :class="[$style.boardCell, { [$style.boardCellNone]: x == ' ' }]" @click="onMapCellClick(i, x)">
<i v-if="x === 'b' || x === 'w'" style="pointer-events: none; user-select: none;" :class="x === 'b' ? 'ti ti-circle-filled' : 'ti ti-circle'"></i>
</div>
</div>
</div>
</div>
<MkFolder :defaultOpen="true">
<template #label>{{ i18n.ts._reversi.blackOrWhite }}</template>
<MkRadios v-model="game.bw">
<option value="random">{{ i18n.ts.random }}</option>
<option :value="'1'">
<I18n :src="i18n.ts._reversi.blackIs" tag="span">
<template #name>
<b><MkUserName :user="game.user1"/></b>
</template>
</I18n>
</option>
<option :value="'2'">
<I18n :src="i18n.ts._reversi.blackIs" tag="span">
<template #name>
<b><MkUserName :user="game.user2"/></b>
</template>
</I18n>
</option>
</MkRadios>
</MkFolder>
<MkFolder :defaultOpen="true">
<template #label>{{ i18n.ts._reversi.rules }}</template>
<div class="_gaps_s">
<MkSwitch v-model="game.isLlotheo" @update:modelValue="updateSettings('isLlotheo')">{{ i18n.ts._reversi.isLlotheo }}</MkSwitch>
<MkSwitch v-model="game.loopedBoard" @update:modelValue="updateSettings('loopedBoard')">{{ i18n.ts._reversi.loopedMap }}</MkSwitch>
<MkSwitch v-model="game.canPutEverywhere" @update:modelValue="updateSettings('canPutEverywhere')">{{ i18n.ts._reversi.canPutEverywhere }}</MkSwitch>
</div>
</MkFolder>
</div>
</MkSpacer>
<template #footer>
<div :class="$style.footer">
<MkSpacer :contentMax="700" :marginMin="16" :marginMax="16">
<div style="text-align: center; margin-bottom: 10px;">
<template v-if="isReady && isOpReady">{{ i18n.ts._reversi.thisGameIsStartedSoon }}<MkEllipsis/></template>
<template v-if="isReady && !isOpReady">{{ i18n.ts._reversi.waitingForOther }}<MkEllipsis/></template>
<template v-if="!isReady && isOpReady">{{ i18n.ts._reversi.waitingForMe }}</template>
<template v-if="!isReady && !isOpReady">{{ i18n.ts._reversi.waitingBoth }}<MkEllipsis/></template>
</div>
<div class="_buttonsCenter">
<MkButton rounded danger @click="exit">{{ i18n.ts.cancel }}</MkButton>
<MkButton v-if="!isReady" rounded primary @click="ready">{{ i18n.ts._reversi.ready }}</MkButton>
<MkButton v-if="isReady" rounded @click="unready">{{ i18n.ts._reversi.cancelReady }}</MkButton>
</div>
</MkSpacer>
</div>
</template>
</MkStickyContainer>
</template>
<script lang="ts" setup>
import { computed, watch, ref, onMounted, shallowRef, onUnmounted } from 'vue';
import * as Misskey from 'misskey-js';
import * as Reversi from 'misskey-reversi';
import { i18n } from '@/i18n.js';
import { signinRequired } from '@/account.js';
import { deepClone } from '@/scripts/clone.js';
import MkButton from '@/components/MkButton.vue';
import MkRadios from '@/components/MkRadios.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import MkFolder from '@/components/MkFolder.vue';
import * as os from '@/os.js';
import { MenuItem } from '@/types/menu.js';
const $i = signinRequired();
const mapCategories = Array.from(new Set(Object.values(Reversi.maps).map(x => x.category)));
const props = defineProps<{
game: Misskey.entities.ReversiGameDetailed;
connection: Misskey.ChannelConnection;
}>();
const game = ref<Misskey.entities.ReversiGameDetailed>(deepClone(props.game));
const isLlotheo = ref<boolean>(false);
const mapName = computed(() => {
if (game.value.map == null) return 'Random';
const found = Object.values(Reversi.maps).find(x => x.data.join('') === game.value.map.join(''));
return found ? found.name! : '-Custom-';
});
const isReady = computed(() => {
if (game.value.user1Id === $i.id && game.value.user1Ready) return true;
if (game.value.user2Id === $i.id && game.value.user2Ready) return true;
return false;
});
const isOpReady = computed(() => {
if (game.value.user1Id !== $i.id && game.value.user1Ready) return true;
if (game.value.user2Id !== $i.id && game.value.user2Ready) return true;
return false;
});
watch(() => game.value.bw, () => {
updateSettings('bw');
});
function chooseMap(ev: MouseEvent) {
const menu: MenuItem[] = [];
for (const c of mapCategories) {
const maps = Object.values(Reversi.maps).filter(x => x.category === c);
if (maps.length === 0) continue;
if (c != null) {
menu.push({
type: 'label',
text: c,
});
}
for (const m of maps) {
menu.push({
text: m.name!,
action: () => {
game.value.map = m.data;
updateSettings('map');
},
});
}
}
os.popupMenu(menu, ev.currentTarget ?? ev.target);
}
function exit() {
props.connection.send('exit', {});
}
function ready() {
props.connection.send('ready', true);
}
function unready() {
props.connection.send('ready', false);
}
function onChangeReadyStates(states) {
game.value.user1Ready = states.user1;
game.value.user2Ready = states.user2;
}
function updateSettings(key: keyof Misskey.entities.ReversiGameDetailed) {
props.connection.send('updateSettings', {
key: key,
value: game.value[key],
});
}
function onUpdateSettings({ userId, key, value }: { userId: string; key: keyof Misskey.entities.ReversiGameDetailed; value: any; }) {
if (userId === $i.id) return;
if (game.value[key] === value) return;
game.value[key] = value;
}
function onMapCellClick(pos: number, pixel: string) {
const x = pos % game.value.map[0].length;
const y = Math.floor(pos / game.value.map[0].length);
const newPixel =
pixel === ' ' ? '-' :
pixel === '-' ? 'b' :
pixel === 'b' ? 'w' :
' ';
const line = game.value.map[y].split('');
line[x] = newPixel;
game.value.map[y] = line.join('');
updateSettings('map');
}
props.connection.on('changeReadyStates', onChangeReadyStates);
props.connection.on('updateSettings', onUpdateSettings);
onUnmounted(() => {
props.connection.off('changeReadyStates', onChangeReadyStates);
props.connection.off('updateSettings', onUpdateSettings);
});
</script>
<style lang="scss" module>
.board {
display: grid;
grid-gap: 4px;
width: 300px;
height: 300px;
margin: 0 auto;
color: var(--fg);
}
.boardCell {
display: grid;
place-items: center;
background: transparent;
border: solid 2px var(--divider);
border-radius: 6px;
overflow: clip;
cursor: pointer;
}
.boardCellNone {
border-color: transparent;
}
.footer {
-webkit-backdrop-filter: var(--blur, blur(15px));
backdrop-filter: var(--blur, blur(15px));
background: var(--acrylicBg);
border-top: solid 0.5px var(--divider);
}
</style>

View file

@ -0,0 +1,68 @@
<!--
SPDX-FileCopyrightText: syuilo and other misskey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div v-if="game == null || connection == null"><MkLoading/></div>
<GameSetting v-else-if="!game.isStarted" :game="game" :connection="connection"/>
<GameBoard v-else :game="game" :connection="connection"/>
</template>
<script lang="ts" setup>
import { computed, watch, ref, onMounted, shallowRef, onUnmounted } from 'vue';
import * as Misskey from 'misskey-js';
import GameSetting from './game.setting.vue';
import GameBoard from './game.board.vue';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { useStream } from '@/stream.js';
const props = defineProps<{
gameId: string;
}>();
const game = shallowRef<Misskey.entities.ReversiGameDetailed | null>(null);
const connection = shallowRef<Misskey.ChannelConnection | null>(null);
watch(() => props.gameId, () => {
fetchGame();
});
async function fetchGame() {
const _game = await misskeyApi('reversi/show-game', {
gameId: props.gameId,
});
game.value = _game;
if (connection.value) {
connection.value.dispose();
}
connection.value = useStream().useChannel('reversiGame', {
gameId: game.value.id,
});
connection.value.on('started', x => {
game.value = x.game;
});
}
onMounted(() => {
fetchGame();
});
onUnmounted(() => {
if (connection.value) {
connection.value.dispose();
}
});
const headerActions = computed(() => []);
const headerTabs = computed(() => []);
definePageMetadata(computed(() => ({
title: 'Reversi',
icon: 'ti ti-device-gamepad',
})));
</script>

View file

@ -0,0 +1,271 @@
<!--
SPDX-FileCopyrightText: syuilo and other misskey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkSpacer v-if="!matchingAny && !matchingUser" :contentMax="600">
<div class="_gaps">
<div>
<img src="/client-assets/reversi/logo.png" style="display: block; max-width: 100%; max-height: 200px; margin: auto;"/>
</div>
<div class="_buttonsCenter">
<MkButton primary gradate rounded @click="matchAny">{{ i18n.ts._reversi.freeMatch }}</MkButton>
<MkButton primary gradate rounded @click="matchUser">{{ i18n.ts.invite }}</MkButton>
</div>
<MkFolder v-if="invitations.length > 0" :defaultOpen="true">
<template #label>{{ i18n.ts.invitations }}</template>
<div class="_gaps_s">
<button v-for="user in invitations" :key="user.id" v-panel :class="$style.invitation" class="_button" tabindex="-1" @click="accept(user)">
<MkAvatar style="width: 32px; height: 32px; margin-right: 8px;" :user="user" :showIndicator="true"/>
<span style="margin-right: 8px;"><b><MkUserName :user="user"/></b></span>
<span>@{{ user.username }}</span>
</button>
</div>
</MkFolder>
<MkFolder v-if="$i" :defaultOpen="true">
<template #label>{{ i18n.ts._reversi.myGames }}</template>
<MkPagination :pagination="myGamesPagination">
<template #default="{ items }">
<div :class="$style.gamePreviews">
<MkA v-for="g in items" :key="g.id" v-panel :class="$style.gamePreview" tabindex="-1" :to="`/reversi/g/${g.id}`">
<div :class="$style.gamePreviewPlayers">
<MkAvatar :class="$style.gamePreviewPlayersAvatar" :user="g.user1"/><b><MkUserName :user="g.user1"/></b> vs <b><MkUserName :user="g.user2"/></b><MkAvatar :class="$style.gamePreviewPlayersAvatar" :user="g.user2"/>
</div>
<div :class="$style.gamePreviewFooter">
<span :style="!g.isEnded ? 'color: var(--accent);' : ''">{{ g.isEnded ? i18n.ts._reversi.ended : i18n.ts._reversi.playing }}</span>
<MkTime style="margin-left: auto; opacity: 0.7;" :time="g.createdAt"/>
</div>
</MkA>
</div>
</template>
</MkPagination>
</MkFolder>
<MkFolder :defaultOpen="true">
<template #label>{{ i18n.ts._reversi.allGames }}</template>
<MkPagination :pagination="gamesPagination">
<template #default="{ items }">
<div :class="$style.gamePreviews">
<MkA v-for="g in items" :key="g.id" v-panel :class="$style.gamePreview" tabindex="-1" :to="`/reversi/g/${g.id}`">
<div :class="$style.gamePreviewPlayers">
<MkAvatar :class="$style.gamePreviewPlayersAvatar" :user="g.user1"/><b><MkUserName :user="g.user1"/></b> vs <b><MkUserName :user="g.user2"/></b><MkAvatar :class="$style.gamePreviewPlayersAvatar" :user="g.user2"/>
</div>
<div :class="$style.gamePreviewFooter">
<span :style="!g.isEnded ? 'color: var(--accent);' : ''">{{ g.isEnded ? i18n.ts._reversi.ended : i18n.ts._reversi.playing }}</span>
<MkTime style="margin-left: auto; opacity: 0.7;" :time="g.createdAt"/>
</div>
</MkA>
</div>
</template>
</MkPagination>
</MkFolder>
</div>
</MkSpacer>
<MkSpacer v-else :contentMax="600">
<div :class="$style.waitingScreen">
<div v-if="matchingUser" :class="$style.waitingScreenTitle">
<I18n :src="i18n.ts.waitingFor" tag="span">
<template #x>
<b><MkUserName :user="matchingUser"/></b>
</template>
</I18n>
<MkEllipsis/>
</div>
<div v-else :class="$style.waitingScreenTitle">
{{ i18n.ts._reversi.lookingForPlayer }}<MkEllipsis/>
</div>
<div class="cancel">
<MkButton inline rounded @click="cancelMatching">{{ i18n.ts.cancel }}</MkButton>
</div>
</div>
</MkSpacer>
</template>
<script lang="ts" setup>
import { computed, onMounted, onUnmounted, ref } from 'vue';
import * as Misskey from 'misskey-js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { useStream } from '@/stream.js';
import MkButton from '@/components/MkButton.vue';
import MkFolder from '@/components/MkFolder.vue';
import { i18n } from '@/i18n.js';
import { $i } from '@/account.js';
import MkPagination from '@/components/MkPagination.vue';
import { useRouter } from '@/global/router/supplier.js';
import * as os from '@/os.js';
import { useInterval } from '@/scripts/use-interval.js';
const myGamesPagination = {
endpoint: 'reversi/games' as const,
limit: 10,
params: {
my: true,
},
};
const gamesPagination = {
endpoint: 'reversi/games' as const,
limit: 10,
};
const router = useRouter();
if ($i) {
const connection = useStream().useChannel('reversi');
connection.on('matched', x => {
startGame(x.game);
});
connection.on('invited', invitation => {
if (invitations.value.some(x => x.id === invitation.user.id)) return;
invitations.value.unshift(invitation.user);
});
onUnmounted(() => {
connection.dispose();
});
}
const invitations = ref<Misskey.entities.UserLite[]>([]);
const matchingUser = ref<Misskey.entities.UserLite | null>(null);
const matchingAny = ref<boolean>(false);
function startGame(game: Misskey.entities.ReversiGameDetailed) {
matchingUser.value = null;
matchingAny.value = false;
router.push(`/reversi/g/${game.id}`);
}
async function matchHeatbeat() {
if (matchingUser.value) {
const res = await misskeyApi('reversi/match', {
userId: matchingUser.value.id,
});
if (res != null) {
startGame(res);
}
} else if (matchingAny.value) {
const res = await misskeyApi('reversi/match', {
userId: null,
});
if (res != null) {
startGame(res);
}
}
}
async function matchUser() {
const user = await os.selectUser({ local: true });
if (user == null) return;
matchingUser.value = user;
matchHeatbeat();
}
async function matchAny() {
matchingAny.value = true;
matchHeatbeat();
}
function cancelMatching() {
if (matchingUser.value) {
misskeyApi('reversi/cancel-match', { userId: matchingUser.value.id });
matchingUser.value = null;
} else if (matchingAny.value) {
misskeyApi('reversi/cancel-match', { userId: null });
matchingAny.value = false;
}
}
async function accept(user) {
const game = await misskeyApi('reversi/match', {
userId: user.id,
});
if (game) {
startGame(game);
}
}
useInterval(matchHeatbeat, 1000 * 10, { immediate: false, afterMounted: true });
onMounted(() => {
misskeyApi('reversi/invitations').then(_invitations => {
invitations.value = _invitations;
});
});
definePageMetadata(computed(() => ({
title: 'Reversi',
icon: 'ti ti-device-gamepad',
})));
</script>
<style lang="scss" module>
.invitation {
display: flex;
box-sizing: border-box;
width: 100%;
padding: 16px;
line-height: 32px;
text-align: left;
}
.gamePreviews {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
grid-gap: var(--margin);
}
.gamePreview {
font-size: 90%;
border-radius: 8px;
overflow: clip;
}
.gamePreviewPlayers {
text-align: center;
padding: 16px;
line-height: 32px;
}
.gamePreviewPlayersAvatar {
width: 32px;
height: 32px;
&:first-child {
margin-right: 8px;
}
&:last-child {
margin-left: 8px;
}
}
.gamePreviewFooter {
display: flex;
align-items: baseline;
border-top: solid 0.5px var(--divider);
padding: 6px 10px;
font-size: 0.9em;
}
.waitingScreen {
text-align: center;
}
.waitingScreenTitle {
font-size: 1.5em;
margin-bottom: 16px;
margin-top: 32px;
}
</style>

View file

@ -141,7 +141,7 @@ async function unregisterKey(key) {
const confirm = await os.confirm({
type: 'question',
title: i18n.ts._2fa.removeKey,
text: i18n.t('_2fa.removeKeyConfirm', { name: key.name }),
text: i18n.tsx._2fa.removeKeyConfirm({ name: key.name }),
});
if (confirm.canceled) return;

View file

@ -30,7 +30,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<details>
<summary>{{ i18n.ts.details }}</summary>
<ul>
<li v-for="p in token.permission" :key="p">{{ i18n.t(`_permissions.${p}`) }}</li>
<li v-for="p in token.permission" :key="p">{{ i18n.ts._permissions[p] }}</li>
</ul>
</details>
<div>

View file

@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div>
<div v-if="!loading" class="_gaps">
<MkInfo>{{ i18n.t('_profile.avatarDecorationMax', { max: $i.policies.avatarDecorationLimit }) }} ({{ i18n.t('remainingN', { n: $i.policies.avatarDecorationLimit - $i.avatarDecorations.length }) }})</MkInfo>
<MkInfo>{{ i18n.tsx._profile.avatarDecorationMax({ max: $i.policies.avatarDecorationLimit }) }} ({{ i18n.tsx.remainingN({ n: $i.policies.avatarDecorationLimit - $i.avatarDecorations.length }) }})</MkInfo>
<MkAvatar :class="$style.avatar" :user="$i" forceShowDecoration/>

View file

@ -91,9 +91,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkRadios v-model="mediaListWithOneImageAppearance">
<template #label>{{ i18n.ts.mediaListWithOneImageAppearance }}</template>
<option value="expand">{{ i18n.ts.default }}</option>
<option value="16_9">{{ i18n.t('limitTo', { x: '16:9' }) }}</option>
<option value="1_1">{{ i18n.t('limitTo', { x: '1:1' }) }}</option>
<option value="2_3">{{ i18n.t('limitTo', { x: '2:3' }) }}</option>
<option value="16_9">{{ i18n.tsx.limitTo({ x: '16:9' }) }}</option>
<option value="1_1">{{ i18n.tsx.limitTo({ x: '1:1' }) }}</option>
<option value="2_3">{{ i18n.tsx.limitTo({ x: '2:3' }) }}</option>
</MkRadios>
</div>
</MkFoldableSection>

View file

@ -21,7 +21,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="_gaps">
<MkInput v-for="(_, i) in accountAliases" v-model="accountAliases[i]">
<template #prefix><i class="ti ti-plane-arrival"></i></template>
<template #label>{{ i18n.t('_accountMigration.moveFromLabel', { n: i + 1 }) }}</template>
<template #label>{{ i18n.tsx._accountMigration.moveFromLabel({ n: i + 1 }) }}</template>
</MkInput>
</div>
</div>
@ -97,7 +97,7 @@ async function move(): Promise<void> {
const account = moveToAccount.value;
const confirm = await os.confirm({
type: 'warning',
text: i18n.t('_accountMigration.migrationConfirm', { account }),
text: i18n.tsx._accountMigration.migrationConfirm({ account }),
});
if (confirm.canceled) return;
await os.apiWithDialog('i/move', {

View file

@ -64,7 +64,7 @@ async function save() {
os.alert({
type: 'error',
title: i18n.ts.regexpError,
text: i18n.t('regexpErrorDescription', { tab: 'word mute', line: i + 1 }) + '\n' + err.toString(),
text: i18n.tsx.regexpErrorDescription({ tab: 'word mute', line: i + 1 }) + '\n' + err.toString(),
});
// re-throw error so these invalid settings are not saved
throw err;

View file

@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #label>{{ i18n.ts.notificationRecieveConfig }}</template>
<div class="_gaps_s">
<MkFolder v-for="type in notificationTypes.filter(x => !nonConfigurableNotificationTypes.includes(x))" :key="type">
<template #label>{{ i18n.t('_notification._types.' + type) }}</template>
<template #label>{{ i18n.ts._notification._types[type] }}</template>
<template #suffix>
{{
$i.notificationRecieveConfig[type]?.type === 'never' ? i18n.ts.none :

View file

@ -212,7 +212,7 @@ function changeAvatar(ev) {
const { canceled } = await os.confirm({
type: 'question',
text: i18n.t('cropImageAsk'),
text: i18n.ts.cropImageAsk,
okText: i18n.ts.cropYes,
cancelText: i18n.ts.cropNo,
});
@ -238,7 +238,7 @@ function changeBanner(ev) {
const { canceled } = await os.confirm({
type: 'question',
text: i18n.t('cropImageAsk'),
text: i18n.ts.cropImageAsk,
okText: i18n.ts.cropYes,
cancelText: i18n.ts.cropNo,
});

View file

@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #label>{{ i18n.ts.sounds }}</template>
<div class="_gaps_s">
<MkFolder v-for="type in operationTypes" :key="type">
<template #label>{{ i18n.t('_sfx.' + type) }}</template>
<template #label>{{ i18n.ts._sfx[type] }}</template>
<template #suffix>{{ getSoundTypeName(sounds[type].type) }}</template>
<XSound :type="sounds[type].type" :volume="sounds[type].volume" :fileId="sounds[type].fileId" :fileUrl="sounds[type].fileUrl" @update="(res) => updated(type, res)"/>
@ -33,9 +33,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { Ref, computed, ref } from 'vue';
import XSound from './sounds.sound.vue';
import type { SoundType, OperationType } from '@/scripts/sound.js';
import type { SoundStore } from '@/store.js';
import XSound from './sounds.sound.vue';
import MkRange from '@/components/MkRange.vue';
import MkButton from '@/components/MkButton.vue';
import FormSection from '@/components/form/section.vue';

View file

@ -33,7 +33,7 @@ async function install(code: string): Promise<void> {
await installTheme(code);
os.alert({
type: 'success',
text: i18n.t('_theme.installed', { name: theme.name }),
text: i18n.tsx._theme.installed({ name: theme.name }),
});
} catch (err) {
switch (err.message.toLowerCase()) {

View file

@ -99,7 +99,7 @@ async function save(): Promise<void> {
async function del(): Promise<void> {
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.t('deleteAreYouSure', { x: webhook.name }),
text: i18n.tsx.deleteAreYouSure({ x: webhook.name }),
});
if (canceled) return;

View file

@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<i class="ti ti-user-check"></i>
</div>
<div class="_gaps_m" style="padding: 32px;">
<div>{{ i18n.t('clickToFinishEmailVerification', { ok: i18n.ts.gotIt }) }}</div>
<div>{{ i18n.tsx.clickToFinishEmailVerification({ ok: i18n.ts.gotIt }) }}</div>
<div>
<MkButton gradate large rounded type="submit" :disabled="submitting" data-cy-admin-ok style="margin: 0 auto;">
{{ submitting ? i18n.ts.processing : i18n.ts.gotIt }}<MkEllipsis v-if="submitting"/>

View file

@ -208,7 +208,7 @@ async function saveAs() {
changed.value = false;
os.alert({
type: 'success',
text: i18n.t('_theme.installed', { name: theme.value.name }),
text: i18n.tsx._theme.installed({ name: theme.value.name }),
});
}

View file

@ -94,7 +94,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<dl v-if="user.birthday" class="field">
<dt class="name"><i class="ti ti-cake ti-fw"></i> {{ i18n.ts.birthday }}</dt>
<dd class="value">{{ user.birthday.replace('-', '/').replace('-', '/') }} ({{
i18n.t('yearsOld', {age})
i18n.tsx.yearsOld({age})
}})
</dd>
</dl>

View file

@ -8,13 +8,13 @@ import getCaretCoordinates from 'textarea-caret';
import { toASCII } from 'punycode/';
import { popup } from '@/os.js';
export type SuggestionType = 'user' | 'hashtag' | 'emoji' | 'mfmTag';
export type SuggestionType = 'user' | 'hashtag' | 'emoji' | 'mfmTag' | 'mfmParam';
export class Autocomplete {
private suggestion: {
x: Ref<number>;
y: Ref<number>;
q: Ref<string | null>;
q: Ref<any>;
close: () => void;
} | null;
private textarea: HTMLInputElement | HTMLTextAreaElement;
@ -49,7 +49,7 @@ export class Autocomplete {
this.textarea = textarea;
this.textRef = textRef;
this.opening = false;
this.onlyType = onlyType ?? ['user', 'hashtag', 'emoji', 'mfmTag'];
this.onlyType = onlyType ?? ['user', 'hashtag', 'emoji', 'mfmTag', 'mfmParam'];
this.attach();
}
@ -80,6 +80,7 @@ export class Autocomplete {
const hashtagIndex = text.lastIndexOf('#');
const emojiIndex = text.lastIndexOf(':');
const mfmTagIndex = text.lastIndexOf('$');
const mfmParamIndex = text.lastIndexOf('.');
const max = Math.max(
mentionIndex,
@ -94,7 +95,8 @@ export class Autocomplete {
const isMention = mentionIndex !== -1;
const isHashtag = hashtagIndex !== -1;
const isMfmTag = mfmTagIndex !== -1;
const isMfmParam = mfmParamIndex !== -1 && text.split(/\$\[[a-zA-Z]+/).pop()?.includes('.');
const isMfmTag = mfmTagIndex !== -1 && !isMfmParam;
const isEmoji = emojiIndex !== -1 && text.split(/:[a-z0-9_+\-]+:/).pop()!.includes(':');
let opened = false;
@ -134,6 +136,17 @@ export class Autocomplete {
}
}
if (isMfmParam && !opened && this.onlyType.includes('mfmParam')) {
const mfmParam = text.substring(mfmParamIndex + 1);
if (!mfmParam.includes(' ')) {
this.open('mfmParam', {
tag: text.substring(mfmTagIndex + 2, mfmParamIndex),
params: mfmParam.split(','),
});
opened = true;
}
}
if (!opened) {
this.close();
}
@ -142,7 +155,7 @@ export class Autocomplete {
/**
*
*/
private async open(type: string, q: string | null) {
private async open(type: string, q: any) {
if (type !== this.currentType) {
this.close();
}
@ -280,6 +293,22 @@ export class Autocomplete {
const pos = trimmedBefore.length + (value.length + 3);
this.textarea.setSelectionRange(pos, pos);
});
} else if (type === 'mfmParam') {
const source = this.text;
const before = source.substring(0, caret);
const trimmedBefore = before.substring(0, before.lastIndexOf('.'));
const after = source.substring(caret);
// 挿入
this.text = `${trimmedBefore}.${value}${after}`;
// キャレットを戻す
nextTick(() => {
this.textarea.focus();
const pos = trimmedBefore.length + (value.length + 1);
this.textarea.setSelectionRange(pos, pos);
});
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -66,7 +66,7 @@ function addApp() {
async function deleteFile(file: Misskey.entities.DriveFile) {
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.t('driveFileDeleteConfirm', { name: file.name }),
text: i18n.tsx.driveFileDeleteConfirm({ name: file.name }),
});
if (canceled) return;

View file

@ -47,7 +47,7 @@ export async function getNoteClipMenu(props: {
if (err.id === '734806c4-542c-463a-9311-15c512803965') {
const confirm = await os.confirm({
type: 'warning',
text: i18n.t('confirmToUnclipAlreadyClippedNote', { name: clip.name }),
text: i18n.tsx.confirmToUnclipAlreadyClippedNote({ name: clip.name }),
});
if (!confirm.canceled) {
os.apiWithDialog('clips/remove-note', { clipId: clip.id, noteId: appearNote.id });
@ -235,7 +235,7 @@ export function getNoteMenu(props: {
function share(): void {
navigator.share({
title: i18n.t('noteOf', { user: appearNote.user.name }),
title: i18n.tsx.noteOf({ user: appearNote.user.name }),
text: appearNote.text,
url: `${url}/notes/${appearNote.id}`,
});

View file

@ -30,7 +30,7 @@ export const getNoteSummary = (note: Misskey.entities.Note): string => {
// ファイルが添付されているとき
if ((note.files || []).length !== 0) {
summary += ` (${i18n.t('withNFiles', { n: note.files.length })})`;
summary += ` (${i18n.tsx.withNFiles({ n: note.files.length })})`;
}
// 投票が添付されているとき

View file

@ -2,33 +2,293 @@
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { ILocale, ParameterizedString } from '../../../../locales/index.js';
export class I18n<T extends Record<string, any>> {
public ts: T;
type FlattenKeys<T extends ILocale, TPrediction> = keyof {
[K in keyof T as T[K] extends ILocale
? FlattenKeys<T[K], TPrediction> extends infer C extends string
? `${K & string}.${C}`
: never
: T[K] extends TPrediction
? K
: never]: T[K];
};
constructor(locale: T) {
this.ts = locale;
type ParametersOf<T extends ILocale, TKey extends FlattenKeys<T, ParameterizedString>> = TKey extends `${infer K}.${infer C}`
// @ts-expect-error -- C は明らかに FlattenKeys<T[K], ParameterizedString> になるが、型システムはここでは TKey がドット区切りであることのコンテキストを持たないので、型システムに合法にて示すことはできない。
? ParametersOf<T[K], C>
: TKey extends keyof T
? T[TKey] extends ParameterizedString<infer P>
? P
: never
: never;
type Tsx<T extends ILocale> = {
readonly [K in keyof T as T[K] extends string ? never : K]: T[K] extends ParameterizedString<infer P>
? (arg: { readonly [_ in P]: string | number }) => string
// @ts-expect-error -- 証明省略
: Tsx<T[K]>;
};
export class I18n<T extends ILocale> {
private tsxCache?: Tsx<T>;
constructor(public locale: T) {
//#region BIND
this.t = this.t.bind(this);
//#endregion
}
// string にしているのは、ドット区切りでのパス指定を許可するため
// なるべくこのメソッド使うよりもlocale直接参照の方がvueのキャッシュ効いてパフォーマンスが良いかも
public t(key: string, args?: Record<string, string | number>): string {
try {
let str = key.split('.').reduce((o, i) => o[i], this.ts) as unknown as string;
public get ts(): T {
if (_DEV_) {
class Handler<TTarget extends ILocale> implements ProxyHandler<TTarget> {
get(target: TTarget, p: string | symbol): unknown {
const value = target[p as keyof TTarget];
if (args) {
for (const [k, v] of Object.entries(args)) {
str = str.replace(`{${k}}`, v.toString());
if (typeof value === 'object') {
return new Proxy(value, new Handler<TTarget[keyof TTarget] & ILocale>());
}
if (typeof value === 'string') {
const parameters = Array.from(value.matchAll(/\{(\w+)\}/g), ([, parameter]) => parameter);
if (parameters.length) {
console.error(`Missing locale parameters: ${parameters.join(', ')} at ${String(p)}`);
}
return value;
}
console.error(`Unexpected locale key: ${String(p)}`);
return p;
}
}
return str;
} catch (err) {
console.warn(`missing localization '${key}'`);
return key;
return new Proxy(this.locale, new Handler());
}
return this.locale;
}
public get tsx(): Tsx<T> {
if (_DEV_) {
if (this.tsxCache) {
return this.tsxCache;
}
class Handler<TTarget extends ILocale> implements ProxyHandler<TTarget> {
get(target: TTarget, p: string | symbol): unknown {
const value = target[p as keyof TTarget];
if (typeof value === 'object') {
return new Proxy(value, new Handler<TTarget[keyof TTarget] & ILocale>());
}
if (typeof value === 'string') {
const quasis: string[] = [];
const expressions: string[] = [];
let cursor = 0;
while (~cursor) {
const start = value.indexOf('{', cursor);
if (!~start) {
quasis.push(value.slice(cursor));
break;
}
quasis.push(value.slice(cursor, start));
const end = value.indexOf('}', start);
expressions.push(value.slice(start + 1, end));
cursor = end + 1;
}
if (!expressions.length) {
console.error(`Unexpected locale key: ${String(p)}`);
return () => value;
}
return (arg) => {
let str = quasis[0];
for (let i = 0; i < expressions.length; i++) {
if (!Object.hasOwn(arg, expressions[i])) {
console.error(`Missing locale parameters: ${expressions[i]} at ${String(p)}`);
}
str += arg[expressions[i]] + quasis[i + 1];
}
return str;
};
}
console.error(`Unexpected locale key: ${String(p)}`);
return p;
}
}
return this.tsxCache = new Proxy(this.locale, new Handler()) as unknown as Tsx<T>;
}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (this.tsxCache) {
return this.tsxCache;
}
function build(target: ILocale): Tsx<T> {
const result = {} as Tsx<T>;
for (const k in target) {
if (!Object.hasOwn(target, k)) {
continue;
}
const value = target[k as keyof typeof target];
if (typeof value === 'object') {
result[k] = build(value as ILocale);
} else if (typeof value === 'string') {
const quasis: string[] = [];
const expressions: string[] = [];
let cursor = 0;
while (~cursor) {
const start = value.indexOf('{', cursor);
if (!~start) {
quasis.push(value.slice(cursor));
break;
}
quasis.push(value.slice(cursor, start));
const end = value.indexOf('}', start);
expressions.push(value.slice(start + 1, end));
cursor = end + 1;
}
if (!expressions.length) {
continue;
}
result[k] = (arg) => {
let str = quasis[0];
for (let i = 0; i < expressions.length; i++) {
str += arg[expressions[i]] + quasis[i + 1];
}
return str;
};
}
}
return result;
}
return this.tsxCache = build(this.locale);
}
/**
* @deprecated 使 ts vue
*/
public t<TKey extends FlattenKeys<T, string>>(key: TKey): string;
/**
* @deprecated 使 tsx vue
*/
public t<TKey extends FlattenKeys<T, ParameterizedString>>(key: TKey, args: { readonly [_ in ParametersOf<T, TKey>]: string | number }): string;
public t(key: string, args?: { readonly [_: string]: string | number }) {
let str: string | ParameterizedString | ILocale = this.locale;
for (const k of key.split('.')) {
str = str[k];
if (_DEV_) {
if (typeof str === 'undefined') {
console.error(`Unexpected locale key: ${key}`);
return key;
}
}
}
if (args) {
if (_DEV_) {
const missing = Array.from((str as string).matchAll(/\{(\w+)\}/g), ([, parameter]) => parameter).filter(parameter => !Object.hasOwn(args, parameter));
if (missing.length) {
console.error(`Missing locale parameters: ${missing.join(', ')} at ${key}`);
}
}
for (const [k, v] of Object.entries(args)) {
const search = `{${k}}`;
if (_DEV_) {
if (!(str as string).includes(search)) {
console.error(`Unexpected locale parameter: ${k} at ${key}`);
}
}
str = (str as string).replace(search, v.toString());
}
}
return str;
}
}
if (import.meta.vitest) {
const { describe, expect, it } = import.meta.vitest;
describe('i18n', () => {
it('t', () => {
const i18n = new I18n({
foo: 'foo',
bar: {
baz: 'baz',
qux: 'qux {0}' as unknown as ParameterizedString<'0'>,
quux: 'quux {0} {1}' as unknown as ParameterizedString<'0' | '1'>,
},
});
expect(i18n.t('foo')).toBe('foo');
expect(i18n.t('bar.baz')).toBe('baz');
expect(i18n.tsx.bar.qux({ 0: 'hoge' })).toBe('qux hoge');
expect(i18n.tsx.bar.quux({ 0: 'hoge', 1: 'fuga' })).toBe('quux hoge fuga');
});
it('ts', () => {
const i18n = new I18n({
foo: 'foo',
bar: {
baz: 'baz',
qux: 'qux {0}' as unknown as ParameterizedString<'0'>,
quux: 'quux {0} {1}' as unknown as ParameterizedString<'0' | '1'>,
},
});
expect(i18n.ts.foo).toBe('foo');
expect(i18n.ts.bar.baz).toBe('baz');
});
it('tsx', () => {
const i18n = new I18n({
foo: 'foo',
bar: {
baz: 'baz',
qux: 'qux {0}' as unknown as ParameterizedString<'0'>,
quux: 'quux {0} {1}' as unknown as ParameterizedString<'0' | '1'>,
},
});
expect(i18n.tsx.bar.qux({ 0: 'hoge' })).toBe('qux hoge');
expect(i18n.tsx.bar.quux({ 0: 'hoge', 1: 'fuga' })).toBe('quux hoge fuga');
});
});
}

View file

@ -190,7 +190,7 @@ const addColumn = async (ev) => {
const { canceled, result: column } = await os.select({
title: i18n.ts._deck.addColumn,
items: columns.map(column => ({
value: column, text: i18n.t('_deck._columns.' + column),
value: column, text: i18n.ts._deck._columns[column],
})),
});
if (canceled) return;
@ -198,7 +198,7 @@ const addColumn = async (ev) => {
addColumnToStore({
type: column,
id: uuid(),
name: i18n.t('_deck._columns.' + column),
name: i18n.ts._deck._columns[column],
width: 330,
});
};
@ -257,7 +257,7 @@ function changeProfile(ev: MouseEvent) {
async function deleteProfile() {
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.t('deleteAreYouSure', { x: deckStore.state.profile }),
text: i18n.tsx.deleteAreYouSure({ x: deckStore.state.profile }),
});
if (canceled) return;

View file

@ -7,11 +7,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="[$style.root, { _panel: !widgetProps.transparent }]" data-cy-mkw-calendar>
<div :class="[$style.calendar, { [$style.isHoliday]: isHoliday }]">
<p :class="$style.monthAndYear">
<span :class="$style.year">{{ i18n.t('yearX', { year }) }}</span>
<span :class="$style.month">{{ i18n.t('monthX', { month }) }}</span>
<span :class="$style.year">{{ i18n.tsx.yearX({ year }) }}</span>
<span :class="$style.month">{{ i18n.tsx.monthX({ month }) }}</span>
</p>
<p v-if="month === 1 && day === 1" class="day">🎉{{ i18n.t('dayX', { day }) }}<span style="display: inline-block; transform: scaleX(-1);">🎉</span></p>
<p v-else :class="$style.day">{{ i18n.t('dayX', { day }) }}</p>
<p v-if="month === 1 && day === 1" class="day">🎉{{ i18n.tsx.dayX({ day }) }}<span style="display: inline-block; transform: scaleX(-1);">🎉</span></p>
<p v-else :class="$style.day">{{ i18n.tsx.dayX({ day }) }}</p>
<p :class="$style.weekDay">{{ weekDay }}</p>
</div>
<div :class="$style.info">

View file

@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<p v-if="widgetProps.folderId == null">
{{ i18n.ts.folder }}
</p>
<p v-if="widgetProps.folderId != null && images.length === 0 && !fetching">{{ i18n.t('no-image') }}</p>
<p v-if="widgetProps.folderId != null && images.length === 0 && !fetching">{{ i18n.ts['no-image'] }}</p>
<div ref="slideA" class="slide a"></div>
<div ref="slideB" class="slide b"></div>
</div>

View file

@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<template #header>
<button class="_button" @click="choose">
<span>{{ widgetProps.src === 'list' ? widgetProps.list.name : widgetProps.src === 'antenna' ? widgetProps.antenna.name : i18n.t('_timelines.' + widgetProps.src) }}</span>
<span>{{ widgetProps.src === 'list' ? widgetProps.list.name : widgetProps.src === 'antenna' ? widgetProps.antenna.name : i18n.ts._timelines[widgetProps.src] }}</span>
<i :class="menuOpened ? 'ti ti-chevron-up' : 'ti ti-chevron-down'" style="margin-left: 8px;"></i>
</button>
</template>

Some files were not shown because too many files have changed in this diff Show more