Merge remote-tracking branch 'misskey-dev/develop' into io

This commit is contained in:
まっちゃとーにゅ
2024-01-23 04:10:51 +09:00
266 changed files with 20375 additions and 6619 deletions

View File

@@ -77,9 +77,18 @@ export async function mainBoot() {
if (defaultStore.state.enableSeasonalScreenEffect) {
const month = new Date().getMonth() + 1;
if (month === 12 || month === 1) {
const SnowfallEffect = (await import('@/scripts/snowfall-effect.js')).SnowfallEffect;
new SnowfallEffect().render();
if (defaultStore.state.hemisphere === 'S') {
// ▼南半球
if (month === 7 || month === 8) {
const SnowfallEffect = (await import('@/scripts/snowfall-effect.js')).SnowfallEffect;
new SnowfallEffect().render();
}
} else {
// ▼北半球
if (month === 12 || month === 1) {
const SnowfallEffect = (await import('@/scripts/snowfall-effect.js')).SnowfallEffect;
new SnowfallEffect().render();
}
}
}
@@ -196,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

@@ -28,7 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only
{{ i18n.ts.moderator }}:
<MkAcct :user="report.assignee"/>
</div>
<div v-if="report.category">カテゴリ: {{ i18n.t(`_abuseReportCategory.${report.category}`) }}</div>
<div v-if="report.category">カテゴリ: {{ i18n.ts[`_abuseReportCategory.${report.category}`] }}</div>
<div><MkTime :time="report.createdAt"/></div>
<div class="action">
<MkSwitch v-model="forward" :disabled="report.targetUser.host == null || report.resolved">

View File

@@ -50,7 +50,7 @@ async function gotIt(): Promise<void> {
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;
@@ -130,7 +135,7 @@ export default {
<script lang="ts" setup>
const props = defineProps<{
type: string;
q: string | null;
q: any;
textarea: HTMLTextAreaElement;
close: () => void;
x: number;
@@ -151,6 +156,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');
@@ -251,6 +257,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,15 +31,15 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkInput v-if="input.type !== 'textarea'" 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>
<MkTextarea v-if="input.type === 'textarea'" v-model="inputValue" :placeholder="input.placeholder || undefined" :autocomplete="input.autocomplete">
<template #label>{{ input.placeholder }}</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>
</MkTextarea>
</template>

View File

@@ -82,8 +82,8 @@ 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 && folder == null"><strong>{{ i18n.ts.emptyDrive }}</strong><br/>{{ i18n.t('empty-drive-description') }}</div>
<div v-if="draghover">{{ i18n.ts['empty-draghover'] }}</div>
<div v-if="!draghover && folder == null"><strong>{{ i18n.ts.emptyDrive }}</strong><br/>{{ i18n.ts['empty-drive-description'] }}</div>
<div v-if="!draghover && folder != null">{{ i18n.ts.emptyFolder }}</div>
</div>
</div>

View File

@@ -84,7 +84,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

@@ -6,10 +6,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div
ref="rootEl"
:class="[$style.transitionRoot, (defaultStore.state.animation && $style.enableAnimation)]"
@touchstart="touchStart"
@touchmove="touchMove"
@touchend="touchEnd"
:class="[$style.transitionRoot]"
@touchstart.passive="touchStart"
@touchmove.passive="touchMove"
@touchend.passive="touchEnd"
>
<Transition
:class="[$style.transitionChildren, { [$style.swiping]: isSwipingForClass }]"
@@ -25,6 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</Transition>
</div>
</template>
<script lang="ts" setup>
import { ref, shallowRef, computed, nextTick, watch } from 'vue';
import type { Tab } from '@/components/global/MkPageHeader.tabs.vue';
@@ -154,7 +155,7 @@ function touchEnd(event: TouchEvent) {
pullDistance.value = 0;
isSwiping.value = false;
setTimeout(() => {
window.setTimeout(() => {
isSwipingForClass.value = false;
}, 400);
}
@@ -178,28 +179,29 @@ watch(tabModel, (newTab, oldTab) => {
</script>
<style lang="scss" module>
.transitionRoot.enableAnimation {
.transitionRoot {
display: grid;
grid-template-columns: 100%;
overflow: clip;
}
.transitionChildren {
grid-area: 1 / 1 / 2 / 2;
transform: translateX(var(--swipe));
.transitionChildren {
grid-area: 1 / 1 / 2 / 2;
transform: translateX(var(--swipe));
&.swipeAnimation_enterActive,
&.swipeAnimation_leaveActive {
transition: transform .3s cubic-bezier(0.65, 0.05, 0.36, 1);
}
&.swipeAnimation_enterActive,
&.swipeAnimation_leaveActive {
transition: transform .3s cubic-bezier(0.65, 0.05, 0.36, 1);
}
&.swipeAnimationRight_leaveTo,
&.swipeAnimationLeft_enterFrom {
transform: translateX(calc(100% + 24px));
}
&.swipeAnimationRight_leaveTo,
&.swipeAnimationLeft_enterFrom {
transform: translateX(calc(100% + 24px));
}
&.swipeAnimationRight_enterFrom,
&.swipeAnimationLeft_leaveTo {
transform: translateX(calc(-100% - 24px));
}
&.swipeAnimationRight_enterFrom,
&.swipeAnimationLeft_leaveTo {
transform: translateX(calc(-100% - 24px));
}
}

View File

@@ -138,7 +138,7 @@ function close() {
left: 32px;
color: var(--indicator);
font-size: 8px;
animation: blink 1s infinite;
animation: global-blink 1s infinite;
@media (max-width: 500px) {
top: 16px;

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

@@ -450,7 +450,7 @@ onBeforeUnmount(() => {
align-items: center;
color: var(--indicator);
font-size: 12px;
animation: blink 1s infinite;
animation: global-blink 1s infinite;
}
.divider {

View File

@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div
v-if="!muted"
v-if="muted === false"
v-show="!isDeleted"
ref="el"
v-hotkey="keymap"
@@ -73,7 +73,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>
@@ -134,7 +134,14 @@ SPDX-License-Identifier: AGPL-3.0-only
</article>
</div>
<div v-else :class="$style.muted" :style="hideMutedNotes ? 'display: none' : undefined" @click="muted = false">
<I18n :src="i18n.ts.userSaysSomething" tag="small">
<I18n v-if="muted === 'sensitiveMute'" :src="i18n.ts.userSaysSomethingSensitive" tag="small">
<template #name>
<MkA v-user-preview="appearNote.userId" :to="userPage(appearNote.user)">
<MkUserName :user="appearNote.user"/>
</MkA>
</template>
</I18n>
<I18n v-else :src="i18n.ts.userSaysSomething" tag="small">
<template #name>
<MkA v-user-preview="appearNote.userId" :to="userPage(appearNote.user)">
<MkUserName :user="appearNote.user"/>
@@ -196,6 +203,7 @@ const emit = defineEmits<{
(ev: 'removeReaction', emoji: string): void;
}>();
const inTimeline = inject<boolean>('inTimeline', false);
const inChannel = inject('inChannel', null);
const currentClip = inject<Ref<Misskey.entities.Clip> | null>('currentClip', null);
@@ -250,12 +258,20 @@ const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.vi
const renoteCollapsed = ref(defaultStore.state.collapseRenotes && isRenote && (($i && ($i.id === note.value.userId || $i.id === appearNote.value.userId)) || (appearNote.value.myReaction != null)));
const hideMutedNotes = defaultStore.state.hideMutedNotes;
function checkMute(note: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null): boolean {
/* Overload FunctionにLintが対応していないのでコメントアウト
function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly: true): boolean;
function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly: false): boolean | 'sensitiveMute';
*/
function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly = false): boolean | 'sensitiveMute' {
if (mutedWords == null) return false;
if (checkWordMute(note, $i, mutedWords)) return true;
if (note.reply && checkWordMute(note.reply, $i, mutedWords)) return true;
if (note.renote && checkWordMute(note.renote, $i, mutedWords)) return true;
if (checkWordMute(noteToCheck, $i, mutedWords)) return true;
if (noteToCheck.reply && checkWordMute(noteToCheck.reply, $i, mutedWords)) return true;
if (noteToCheck.renote && checkWordMute(noteToCheck.renote, $i, mutedWords)) return true;
if (checkOnly) return false;
if (inTimeline && !defaultStore.state.tl.filter.withSensitive && noteToCheck.files?.some((v) => v.isSensitive)) return 'sensitiveMute';
return false;
}

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

@@ -72,7 +72,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-if="maxTextLength - textLength < 100" :class="['_acrylic', $style.textCount, { [$style.textOver]: textLength > maxTextLength }]">{{ maxTextLength - textLength }}</div>
</div>
<input v-show="withHashtags" ref="hashtagsInputEl" v-model="hashtags" :class="$style.hashtags" :placeholder="i18n.ts.hashtags" list="hashtags">
<MkInfo v-if="files.length > 0" warn :class="$style.guidelineInfo" :rounded="false"><Mfm :text="i18n.t('_postForm.guidelineInfo', { tosUrl: instance.tosUrl, nsfwGuideUrl })"/></MkInfo>
<MkInfo v-if="files.length > 0" warn :class="$style.guidelineInfo" :rounded="false"><Mfm :text="i18n.tsx._postForm.guidelineInfo({ tosUrl: instance.tosUrl, nsfwGuideUrl })"/></MkInfo>
<XPostFormAttaches v-model="files" @detach="detachFile" @changeSensitive="updateFileSensitive" @changeName="updateFileName" @replaceFile="replaceFile"/>
<MkPollEditor v-if="poll" v-model="poll" @destroyed="poll = null"/>
<MkNotePreview v-if="showPreview" :class="$style.preview" :text="text" :files="files" :poll="poll ?? undefined" :useCw="useCw" :cw="cw" :user="postAccount ?? $i"/>

View File

@@ -56,6 +56,23 @@ function detachMedia(id: string) {
}
}
async function detachAndDeleteMedia(file: Misskey.entities.DriveFile) {
if (mock) return;
detachMedia(file.id);
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.tsx.driveFileDeleteConfirm({ name: file.name }),
});
if (canceled) return;
os.apiWithDialog('drive/files/delete', {
fileId: file.id,
});
}
function toggleSensitive(file) {
if (mock) {
emit('changeSensitive', file, !file.isSensitive);
@@ -138,6 +155,13 @@ function showFileMenu(file: Misskey.entities.DriveFile, ev: MouseEvent): void {
text: i18n.ts.attachCancel,
icon: 'ti ti-circle-x',
action: () => { detachMedia(file.id); },
}, {
type: 'divider',
}, {
text: i18n.ts.deleteFile,
icon: 'ti ti-trash',
danger: true,
action: () => { detachAndDeleteMedia(file); },
}], ev.currentTarget ?? ev.target).then(() => menuShowing = false);
menuShowing = true;
}

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

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

View File

@@ -263,7 +263,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

@@ -105,7 +105,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;
@@ -119,7 +119,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,
}),
});
@@ -135,7 +135,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

@@ -49,6 +49,7 @@ const emit = defineEmits<{
(ev: 'queue', count: number): void;
}>();
provide('inTimeline', true);
provide('inChannel', computed(() => props.src === 'channel'));
type TimelineQueryType = {

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/" rel="nofollow noopener" 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

@@ -53,7 +53,7 @@ SPDX-License-Identifier: AGPL-3.0-only
{{ i18n.ts._announcement.silence }}
<template #caption>{{ i18n.ts._announcement.silenceDescription }}</template>
</MkSwitch>
<p v-if="reads">{{ i18n.t('nUsersRead', { n: reads }) }}</p>
<p v-if="reads">{{ i18n.tsx.nUsersRead({ n: reads }) }}</p>
<MkUserCardMini v-if="props.user.id" :user="props.user"></MkUserCardMini>
<MkButton v-if="announcement" danger @click="del()"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton>
</div>
@@ -149,7 +149,7 @@ async function done(): Promise<void> {
async function del(): Promise<void> {
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

@@ -85,7 +85,7 @@ const recentUsers = ref<Misskey.entities.UserDetailed[]>([]);
const selected = ref<Misskey.entities.UserDetailed | null>(null);
const dialogEl = ref();
const search = () => {
function search() {
if (username.value === '' && host.value === '') {
users.value = [];
return;
@@ -98,9 +98,9 @@ const search = () => {
}).then(_users => {
users.value = _users;
});
};
}
const ok = () => {
function ok() {
if (selected.value == null) return;
emit('ok', selected.value);
dialogEl.value.close();
@@ -110,12 +110,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

@@ -118,7 +118,7 @@ export default function(props: MfmProps, context: SetupContext<MfmEvents>) {
case 'tada': {
const speed = validTime(token.props.args.speed) ?? '1s';
const delay = validTime(token.props.args.delay) ?? '0s';
style = 'font-size: 150%;' + (useAnim ? `animation: tada ${speed} linear infinite both; animation-delay: ${delay};` : '');
style = 'font-size: 150%;' + (useAnim ? `animation: global-tada ${speed} linear infinite both; animation-delay: ${delay};` : '');
break;
}
case 'jelly': {

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.js';
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')),
@@ -336,7 +337,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',
@@ -527,18 +533,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

@@ -420,7 +420,7 @@ export function form(title, form) {
});
}
export async function selectUser(opts: { includeSelf?: boolean } = {}) {
export async function selectUser(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

@@ -183,9 +183,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>
@@ -312,7 +312,7 @@ async function resetPassword() {
});
os.alert({
type: 'success',
text: i18n.t('newPasswordIs', { password }),
text: i18n.tsx.newPasswordIs({ password }),
});
}
}
@@ -395,7 +395,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

@@ -161,7 +161,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

@@ -76,7 +76,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSwitch v-model="announcement.silence" :helpText="i18n.ts._announcement.silenceDescription">
{{ i18n.ts._announcement.silence }}
</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>
<MkUserCardMini v-if="announcement.userId" :user="announcement.user" @click="editUser(announcement)"></MkUserCardMini>
<MkButton v-else class="button" inline primary @click="editUser(announcement)">{{ i18n.ts.specifyUser }}</MkButton>
<div class="buttons _buttons">
@@ -159,7 +159,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

@@ -80,7 +80,7 @@ async function read(announcement): Promise<void> {
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

@@ -60,7 +60,7 @@ function add() {
function del(avatarDecoration) {
os.confirm({
type: 'warning',
text: i18n.t('deleteAreYouSure', { x: avatarDecoration.name }),
text: i18n.tsx.deleteAreYouSure({ x: avatarDecoration.name }),
}).then(({ canceled }) => {
if (canceled) return;
avatarDecorations.value = avatarDecorations.value.filter(x => x !== avatarDecoration);

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

@@ -45,7 +45,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"/>
@@ -123,7 +123,7 @@ function onGameEnd() {
definePageMetadata({
title: i18n.ts.bubbleGame,
icon: 'ti ti-apple',
icon: 'ti ti-device-gamepad',
});
</script>

View File

@@ -4,10 +4,12 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkModalWindow
ref="dialog"
:width="400"
@close="dialog.close()"
<MkWindow
ref="windowEl"
:initialWidth="400"
:initialHeight="500"
:canResize="false"
@close="windowEl.close()"
@closed="$emit('closed')"
>
<template v-if="emoji" #header>:{{ emoji.name }}:</template>
@@ -90,13 +92,13 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkButton primary rounded style="margin: 0 auto;" @click="done"><i class="ti ti-check"></i> {{ props.emoji ? i18n.ts.update : i18n.ts.create }}</MkButton>
</div>
</div>
</MkModalWindow>
</MkWindow>
</template>
<script lang="ts" setup>
import { computed, watch, ref } from 'vue';
import * as Misskey from 'misskey-js';
import MkModalWindow from '@/components/MkModalWindow.vue';
import MkWindow from '@/components/MkWindow.vue';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
import MkTextarea from '@/components/MkTextarea.vue';
@@ -114,7 +116,7 @@ const props = defineProps<{
emoji?: any,
}>();
const dialog = ref<InstanceType<typeof MkModalWindow> | null>(null);
const windowEl = ref<InstanceType<typeof MkWindow> | null>(null);
const name = ref<string>(props.emoji ? props.emoji.name : '');
const category = ref<string>(props.emoji ? props.emoji.category : '');
const aliases = ref<string>(props.emoji ? props.emoji.aliases.join(' ') : '');
@@ -197,7 +199,7 @@ async function done() {
},
});
dialog.value.close();
windowEl.value.close();
} else {
const created = await os.apiWithDialog('admin/emoji/add', params);
@@ -205,14 +207,14 @@ async function done() {
created: created,
});
dialog.value.close();
windowEl.value.close();
}
}
async function del() {
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.t('removeAreYouSure', { x: name.value }),
text: i18n.tsx.removeAreYouSure({ x: name.value }),
});
if (canceled) return;
@@ -222,7 +224,7 @@ async function del() {
emit('done', {
deleted: true,
});
dialog.value.close();
windowEl.value.close();
});
}
</script>

View File

@@ -439,7 +439,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

@@ -96,9 +96,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>
@@ -155,7 +155,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,637 @@
<!--
SPDX-FileCopyrightText: syuilo and other misskey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkSpacer :contentMax="500">
<div :class="$style.root" class="_gaps">
<div style="display: flex; align-items: center; justify-content: center; gap: 10px;">
<span>({{ i18n.ts._reversi.black }})</span>
<MkAvatar style="width: 32px; height: 32px;" :user="blackUser" :showIndicator="true"/>
<span> vs </span>
<MkAvatar style="width: 32px; height: 32px;" :user="whiteUser" :showIndicator="true"/>
<span>({{ i18n.ts._reversi.white }})</span>
</div>
<div style="overflow: clip; line-height: 28px;">
<div v-if="!iAmPlayer && !game.isEnded && turnUser">
<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">
<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">{{ i18n.ts._reversi.opponentTurn }}<MkEllipsis/><span style="margin-left: 1em; opacity: 0.7;">({{ i18n.tsx.remainingN({ n: opTurnTimerRmain }) }})</span></div>
<div v-if="iAmPlayer && !game.isEnded && isMyTurn"><span style="display: inline-block; font-weight: bold; animation: global-tada 1s linear infinite both;">{{ i18n.ts._reversi.myTurn }}</span><span style="margin-left: 1em; opacity: 0.7;">({{ i18n.tsx.remainingN({ n: myTurnTimerRmain }) }})</span></div>
<div v-if="game.isEnded && logPos == game.logs.length">
<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.surrenderedUserId != null"> ({{ i18n.ts._reversi.surrendered }})</span>
<span v-if="game.timeoutUserId != null"> ({{ i18n.ts._reversi.timeout }})</span>
</template>
<template v-else>{{ i18n.ts._reversi.drawn }}</template>
</div>
</div>
<div :class="$style.board">
<div :class="$style.boardInner">
<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"
:key="i"
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)"
>
<Transition
:enterActiveClass="$style.transition_flip_enterActive"
:leaveActiveClass="$style.transition_flip_leaveActive"
:enterFromClass="$style.transition_flip_enterFrom"
:leaveToClass="$style.transition_flip_leaveTo"
mode="default"
>
<template v-if="useAvatarAsStone">
<img v-if="stone === true" :class="$style.boardCellStone" :src="blackUser.avatarUrl"/>
<img v-else-if="stone === false" :class="$style.boardCellStone" :src="whiteUser.avatarUrl"/>
</template>
<template v-else>
<img v-if="stone === true" :class="$style.boardCellStone" src="/client-assets/reversi/stone_b.png"/>
<img v-else-if="stone === false" :class="$style.boardCellStone" src="/client-assets/reversi/stone_w.png"/>
</template>
</Transition>
</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>
<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 class="_panel" style="padding: 16px;">
<div>
<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>
<div style="display: flex; align-items: center;">
<span style="margin-right: 8px;">({{ i18n.ts._reversi.black }})</span>
<MkAvatar style="width: 32px; height: 32px; margin-right: 8px;" :user="blackUser" :showIndicator="true"/>
<MkA :to="userPage(blackUser)"><MkUserName :user="blackUser"/></MkA>
</div>
<div> vs </div>
<div style="display: flex; align-items: center;">
<span style="margin-right: 8px;">({{ i18n.ts._reversi.white }})</span>
<MkAvatar style="width: 32px; height: 32px; margin-right: 8px;" :user="whiteUser" :showIndicator="true"/>
<MkA :to="userPage(whiteUser)"><MkUserName :user="whiteUser"/></MkA>
</div>
</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>
</div>
<MkFolder>
<template #label>{{ i18n.ts.options }}</template>
<div class="_gaps_s" style="text-align: left;">
<MkSwitch v-model="showBoardLabels">Show labels</MkSwitch>
<MkSwitch v-model="useAvatarAsStone">useAvatarAsStone</MkSwitch>
</div>
</MkFolder>
<div class="_buttonsCenter">
<MkButton v-if="!game.isEnded && iAmPlayer" danger @click="surrender">{{ i18n.ts._reversi.surrender }}</MkButton>
<MkButton @click="share">{{ i18n.ts.share }}</MkButton>
</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, onActivated, onDeactivated, 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 MkFolder from '@/components/MkFolder.vue';
import MkSwitch from '@/components/MkSwitch.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';
import * as sound from '@/scripts/sound.js';
import * as os from '@/os.js';
import { confetti } from '@/scripts/confetti.js';
const $i = signinRequired();
const props = defineProps<{
game: Misskey.entities.ReversiGameDetailed;
connection?: Misskey.ChannelConnection | null;
}>();
const showBoardLabels = ref<boolean>(false);
const useAvatarAsStone = ref<boolean>(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 || props.connection == null) return;
const crc32 = CRC32.str(JSON.stringify(game.value.logs)).toString();
if (_DEV_) console.log('crc32', crc32);
props.connection.send('resync', {
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.playUrl('/client-assets/reversi/put.mp3', {
volume: 1,
playbackRate: 1,
});
const id = Math.random().toString(36).slice(2);
props.connection!.send('putStone', {
pos: pos,
id,
});
appliedOps.push(id);
myTurnTimerRmain.value = game.value.timeLimitForEachTurn;
opTurnTimerRmain.value = game.value.timeLimitForEachTurn;
checkEnd();
}
const myTurnTimerRmain = ref<number>(game.value.timeLimitForEachTurn);
const opTurnTimerRmain = ref<number>(game.value.timeLimitForEachTurn);
const TIMER_INTERVAL_SEC = 3;
if (!props.game.isEnded) {
useInterval(() => {
if (myTurnTimerRmain.value > 0) {
myTurnTimerRmain.value = Math.max(0, myTurnTimerRmain.value - TIMER_INTERVAL_SEC);
}
if (opTurnTimerRmain.value > 0) {
opTurnTimerRmain.value = Math.max(0, opTurnTimerRmain.value - TIMER_INTERVAL_SEC);
}
if (iAmPlayer.value) {
if ((isMyTurn.value && myTurnTimerRmain.value === 0) || (!isMyTurn.value && opTurnTimerRmain.value === 0)) {
props.connection!.send('claimTimeIsUp', {});
}
}
}, TIMER_INTERVAL_SEC * 1000, { immediate: false, afterMounted: true });
}
async 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': {
sound.playUrl('/client-assets/reversi/put.mp3', {
volume: 1,
playbackRate: 1,
});
if (log.player !== engine.value.turn) { // = desyncが発生している
const _game = await misskeyApi('reversi/show-game', {
gameId: props.game.id,
});
restoreGame(_game);
return;
}
engine.value.putStone(log.pos);
triggerRef(engine);
myTurnTimerRmain.value = game.value.timeLimitForEachTurn;
opTurnTimerRmain.value = game.value.timeLimitForEachTurn;
checkEnd();
break;
}
default:
break;
}
}
}
function onStreamEnded(x) {
game.value = deepClone(x.game);
if (game.value.winnerId === $i.id) {
confetti({
duration: 1000 * 3,
});
sound.playUrl('/client-assets/reversi/win.mp3', {
volume: 1,
playbackRate: 1,
});
} else {
sound.playUrl('/client-assets/reversi/lose.mp3', {
volume: 1,
playbackRate: 1,
});
}
}
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 restoreGame(_game) {
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 onStreamResynced(_game) {
console.log('resynced');
restoreGame(_game);
}
async function surrender() {
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.ts.areYouSure,
});
if (canceled) return;
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);
}
function share() {
os.post({
initialText: `#MisskeyReversi ${location.href}`,
instant: true,
});
}
onMounted(() => {
if (props.connection != null) {
props.connection.on('log', onStreamLog);
props.connection.on('resynced', onStreamResynced);
props.connection.on('ended', onStreamEnded);
}
});
onActivated(() => {
if (props.connection != null) {
props.connection.on('log', onStreamLog);
props.connection.on('resynced', onStreamResynced);
props.connection.on('ended', onStreamEnded);
}
});
onDeactivated(() => {
if (props.connection != null) {
props.connection.off('log', onStreamLog);
props.connection.off('resynced', onStreamResynced);
props.connection.off('ended', onStreamEnded);
}
});
onUnmounted(() => {
if (props.connection != null) {
props.connection.off('log', onStreamLog);
props.connection.off('resynced', onStreamResynced);
props.connection.off('ended', onStreamEnded);
}
});
</script>
<style lang="scss" module>
@use "sass:math";
.transition_flip_enterActive,
.transition_flip_leaveActive {
backface-visibility: hidden;
transition: opacity 0.5s ease, transform 0.5s ease;
}
.transition_flip_enterFrom {
transform: rotateY(-180deg);
opacity: 0;
}
.transition_flip_leaveTo {
transform: rotateY(180deg);
opacity: 0;
}
$label-size: 16px;
$gap: 4px;
.root {
text-align: center;
}
.board {
width: 100%;
box-sizing: border-box;
margin: 0 auto;
padding: 7px;
background: #8C4F26;
box-shadow: 0 6px 16px #0007, 0 0 1px 1px #693410, inset 0 0 2px 1px #ce8a5c;
border-radius: 12px;
}
.boardInner {
padding: 32px;
background: var(--panel);
box-shadow: 0 0 2px 1px #ce8a5c, inset 0 0 1px 1px #693410;
border-radius: 8px;
}
@container (max-width: 400px) {
.boardInner {
padding: 16px;
}
}
.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: 100%;
aspect-ratio: 1;
transform-style: preserve-3d;
perspective: 150px;
transition: border 0.25s ease, opacity 0.25s ease;
&.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;
}
}
.boardCellStone {
position: absolute;
top: 0;
left: 0;
pointer-events: none;
user-select: none;
display: block;
width: 100%;
height: 100%;
border-radius: 100%;
}
</style>

View File

@@ -0,0 +1,278 @@
<!--
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="{ [$style.disallow]: isReady }">
<div class="_gaps" :class="{ [$style.disallowInner]: isReady }">
<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.timeLimitForEachTurn }}</template>
<template #suffix>{{ game.timeLimitForEachTurn }}{{ i18n.ts._time.second }}</template>
<MkRadios v-model="game.timeLimitForEachTurn">
<option :value="5">5{{ i18n.ts._time.second }}</option>
<option :value="10">10{{ i18n.ts._time.second }}</option>
<option :value="30">30{{ i18n.ts._time.second }}</option>
<option :value="60">60{{ i18n.ts._time.second }}</option>
<option :value="90">90{{ i18n.ts._time.second }}</option>
<option :value="120">120{{ i18n.ts._time.second }}</option>
<option :value="180">180{{ i18n.ts._time.second }}</option>
<option :value="3600">3600{{ i18n.ts._time.second }}</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>
</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="cancel">{{ 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';
import { useRouter } from '@/global/router/supplier.js';
const $i = signinRequired();
const router = useRouter();
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 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');
});
watch(() => game.value.timeLimitForEachTurn, () => {
updateSettings('timeLimitForEachTurn');
});
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);
}
async function cancel() {
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.ts.areYouSure,
});
if (canceled) return;
props.connection.send('cancel', {});
router.push('/reversi');
}
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>
.disallow {
cursor: not-allowed;
}
.disallowInner {
pointer-events: none;
user-select: none;
opacity: 0.7;
}
.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,89 @@
<!--
SPDX-FileCopyrightText: syuilo and other misskey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div v-if="game == null || (!game.isEnded && 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';
import { signinRequired } from '@/account.js';
import { useRouter } from '@/global/router/supplier.js';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
const $i = signinRequired();
const router = useRouter();
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();
}
if (!game.value.isEnded) {
connection.value = useStream().useChannel('reversiGame', {
gameId: game.value.id,
});
connection.value.on('started', x => {
game.value = x.game;
});
connection.value.on('canceled', x => {
connection.value?.dispose();
if (x.userId !== $i.id) {
os.alert({
type: 'warning',
text: i18n.ts._reversi.gameCanceled,
});
router.push('/reversi');
}
});
}
}
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,318 @@
<!--
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="_panel _gaps" style="padding: 16px;">
<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>
<div style="font-size: 90%; opacity: 0.7; text-align: center;"><i class="ti ti-music"></i> {{ i18n.ts.soundWillBePlayed }}</div>
</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" :disableAutoLoad="true">
<template #default="{ items }">
<div :class="$style.gamePreviews">
<MkA v-for="g in items" :key="g.id" v-panel :class="[$style.gamePreview, !g.isEnded && $style.gamePreviewActive]" tabindex="-1" :to="`/reversi/g/${g.id}`">
<div :class="$style.gamePreviewPlayers">
<span v-if="g.winnerId === g.user1Id" style="margin-right: 0.75em; color: var(--accent); font-weight: bold;"><i class="ti ti-trophy"></i></span>
<span v-if="g.winnerId === g.user2Id" style="margin-right: 0.75em; visibility: hidden;"><i class="ti ti-x"></i></span>
<MkAvatar :class="$style.gamePreviewPlayersAvatar" :user="g.user1"/>
<span style="margin: 0 1em;">vs</span>
<MkAvatar :class="$style.gamePreviewPlayersAvatar" :user="g.user2"/>
<span v-if="g.winnerId === g.user1Id" style="margin-left: 0.75em; visibility: hidden;"><i class="ti ti-x"></i></span>
<span v-if="g.winnerId === g.user2Id" style="margin-left: 0.75em; color: var(--accent); font-weight: bold;"><i class="ti ti-trophy"></i></span>
</div>
<div :class="$style.gamePreviewFooter">
<span v-if="!g.isEnded" :class="$style.gamePreviewStatusActive">{{ i18n.ts._reversi.playing }}</span>
<span v-else>{{ i18n.ts._reversi.ended }}</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" :disableAutoLoad="true">
<template #default="{ items }">
<div :class="$style.gamePreviews">
<MkA v-for="g in items" :key="g.id" v-panel :class="[$style.gamePreview, !g.isEnded && $style.gamePreviewActive]" tabindex="-1" :to="`/reversi/g/${g.id}`">
<div :class="$style.gamePreviewPlayers">
<span v-if="g.winnerId === g.user1Id" style="margin-right: 0.75em; color: var(--accent); font-weight: bold;"><i class="ti ti-trophy"></i></span>
<span v-if="g.winnerId === g.user2Id" style="margin-right: 0.75em; visibility: hidden;"><i class="ti ti-x"></i></span>
<MkAvatar :class="$style.gamePreviewPlayersAvatar" :user="g.user1"/>
<span style="margin: 0 1em;">vs</span>
<MkAvatar :class="$style.gamePreviewPlayersAvatar" :user="g.user2"/>
<span v-if="g.winnerId === g.user1Id" style="margin-left: 0.75em; visibility: hidden;"><i class="ti ti-x"></i></span>
<span v-if="g.winnerId === g.user2Id" style="margin-left: 0.75em; color: var(--accent); font-weight: bold;"><i class="ti ti-trophy"></i></span>
</div>
<div :class="$style.gamePreviewFooter">
<span v-if="!g.isEnded" :class="$style.gamePreviewStatusActive">{{ i18n.ts._reversi.playing }}</span>
<span v-else>{{ i18n.ts._reversi.ended }}</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, onDeactivated, 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';
import * as sound from '@/scripts/sound.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;
sound.playUrl('/client-assets/reversi/matched.mp3', {
volume: 1,
playbackRate: 1,
});
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;
});
});
onDeactivated(() => {
cancelMatching();
});
onUnmounted(() => {
cancelMatching();
});
definePageMetadata(computed(() => ({
title: 'Reversi',
icon: 'ti ti-device-gamepad',
})));
</script>
<style lang="scss" module>
@keyframes blink {
0% { opacity: 1; }
50% { opacity: 0.2; }
}
.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;
}
.gamePreviewActive {
box-shadow: inset 0 0 8px 0px var(--accent);
}
.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;
}
.gamePreviewStatusActive {
color: var(--accent);
font-weight: bold;
animation: blink 2s infinite;
}
.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): Promise<void> {
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

@@ -17,6 +17,13 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
</MkSelect>
<MkRadios v-model="hemisphere">
<template #label>{{ i18n.ts.hemisphere }}</template>
<option value="N">{{ i18n.ts._hemisphere.N }}</option>
<option value="S">{{ i18n.ts._hemisphere.S }}</option>
<template #caption>{{ i18n.ts._hemisphere.caption }}</template>
</MkRadios>
<MkRadios v-model="overridedDeviceKind">
<template #label>{{ i18n.ts.overridedDeviceKind }}</template>
<option :value="null">{{ i18n.ts.auto }}</option>
@@ -78,9 +85,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>
</FormSection>
@@ -261,6 +268,7 @@ async function reloadAsk() {
unisonReload();
}
const hemisphere = computed(defaultStore.makeGetterSetter('hemisphere'));
const overridedDeviceKind = computed(defaultStore.makeGetterSetter('overridedDeviceKind'));
const serverDisconnectedBehavior = computed(defaultStore.makeGetterSetter('serverDisconnectedBehavior'));
const showNoteActionsOnlyHover = computed(defaultStore.makeGetterSetter('showNoteActionsOnlyHover'));
@@ -324,6 +332,7 @@ watch(useSystemFont, () => {
});
watch([
hemisphere,
lang,
fontSize,
useSystemFont,

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

@@ -58,7 +58,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

@@ -206,7 +206,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,
});
@@ -232,7 +232,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

@@ -66,19 +66,49 @@ const rootEl = shallowRef<HTMLElement>();
const queue = ref(0);
const srcWhenNotSignin = ref(isLocalTimelineAvailable ? 'local' : 'global');
const src = computed({ get: () => ($i ? defaultStore.reactiveState.tl.value.src : srcWhenNotSignin.value), set: (x) => saveSrc(x) });
const withRenotes = ref(true);
const withReplies = ref($i ? defaultStore.state.tlWithReplies : false);
const onlyFiles = ref(false);
const src = computed({
get: () => ($i ? defaultStore.reactiveState.tl.value.src : srcWhenNotSignin.value),
set: (x) => saveSrc(x),
});
const withRenotes = computed({
get: () => defaultStore.reactiveState.tl.value.filter.withRenotes,
set: (x: boolean) => saveTlFilter('withRenotes', x),
});
const withReplies = computed({
get: () => {
if (!$i) return false;
if (['local', 'social'].includes(src.value) && onlyFiles.value) {
return false;
} else {
return defaultStore.reactiveState.tl.value.filter.withReplies;
}
},
set: (x: boolean) => saveTlFilter('withReplies', x),
});
const onlyFiles = computed({
get: () => {
if (['local', 'social'].includes(src.value) && withReplies.value) {
return false;
} else {
return defaultStore.reactiveState.tl.value.filter.onlyFiles;
}
},
set: (x: boolean) => saveTlFilter('onlyFiles', x),
});
const withSensitive = computed({
get: () => defaultStore.reactiveState.tl.value.filter.withSensitive,
set: (x: boolean) => {
saveTlFilter('withSensitive', x);
// これだけはクライアント側で完結する処理なので手動でリロード
tlComponent.value?.reloadTimeline();
},
});
watch(src, () => {
queue.value = 0;
});
watch(withReplies, (x) => {
if ($i) defaultStore.set('tlWithReplies', x);
});
function queueUpdated(q: number): void {
queue.value = q;
}
@@ -154,18 +184,38 @@ async function chooseChannel(ev: MouseEvent): Promise<void> {
}
function saveSrc(newSrc: 'home' | 'local' | 'social' | 'global' | `list:${string}`): void {
let userList = null;
const out = {
...defaultStore.state.tl,
src: newSrc,
};
if (newSrc.startsWith('userList:')) {
const id = newSrc.substring('userList:'.length);
userList = defaultStore.reactiveState.pinnedUserLists.value.find(l => l.id === id);
out.userList = defaultStore.reactiveState.pinnedUserLists.value.find(l => l.id === id) ?? null;
}
defaultStore.set('tl', {
src: newSrc,
userList,
});
defaultStore.set('tl', out);
srcWhenNotSignin.value = newSrc;
}
function saveTlFilter(key: keyof typeof defaultStore.state.tl.filter, newValue: boolean) {
if (key !== 'withReplies' || $i) {
const out = { ...defaultStore.state.tl };
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!out.filter) {
out.filter = {
withRenotes: true,
withReplies: true,
withSensitive: true,
onlyFiles: false,
};
}
out.filter[key] = newValue;
defaultStore.set('tl', out);
}
return newValue;
}
async function timetravel(): Promise<void> {
const { canceled, result: date } = await os.inputDate({
title: i18n.ts.date,
@@ -202,6 +252,10 @@ const headerActions = computed(() => {
ref: withReplies,
disabled: onlyFiles,
} : undefined, {
type: 'switch',
text: i18n.ts.withSensitive,
ref: withSensitive,
}, {
type: 'switch',
text: i18n.ts.fileAttachedOnly,
ref: onlyFiles,
@@ -215,8 +269,7 @@ const headerActions = computed(() => {
icon: 'ti ti-refresh',
text: i18n.ts.reload,
handler: (ev: Event) => {
console.log('called');
tlComponent.value.reloadTimeline();
tlComponent.value?.reloadTimeline();
},
});
}

View File

@@ -87,7 +87,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</dl>
<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 }) }})</dd>
<dd class="value">{{ user.birthday.replace('-', '/').replace('-', '/') }} ({{ i18n.tsx.yearsOld({ age }) }})</dd>
</dl>
<dl class="field">
<dt class="name"><i class="ti ti-calendar ti-fw"></i> {{ i18n.ts.registeredDate }}</dt>

View File

@@ -7,6 +7,7 @@
import { onUnmounted, Ref, ref, watch } from 'vue';
import { BroadcastChannel } from 'broadcast-channel';
import { defu } from 'defu';
import { $i } from '@/account.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { get, set } from '@/scripts/idb-proxy.js';
@@ -80,6 +81,18 @@ export class Storage<T extends StateDef> {
this.loaded = this.ready.then(() => this.load());
}
private isPureObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
private mergeState<T>(value: T, def: T): T {
if (this.isPureObject(value) && this.isPureObject(def)) {
if (_DEV_) console.log('Merging state. Incoming: ', value, ' Default: ', def);
return defu(value, def) as T;
}
return value;
}
private async init(): Promise<void> {
await this.migrate();
@@ -89,11 +102,11 @@ export class Storage<T extends StateDef> {
for (const [k, v] of Object.entries(this.def) as [keyof T, T[keyof T]['default']][]) {
if (v.where === 'device' && Object.prototype.hasOwnProperty.call(deviceState, k)) {
this.reactiveState[k].value = this.state[k] = deviceState[k];
this.reactiveState[k].value = this.state[k] = this.mergeState<T[keyof T]['default']>(deviceState[k], v.default);
} else if (v.where === 'account' && $i && Object.prototype.hasOwnProperty.call(registryCache, k)) {
this.reactiveState[k].value = this.state[k] = registryCache[k];
this.reactiveState[k].value = this.state[k] = this.mergeState<T[keyof T]['default']>(registryCache[k], v.default);
} else if (v.where === 'deviceAccount' && Object.prototype.hasOwnProperty.call(deviceAccountState, k)) {
this.reactiveState[k].value = this.state[k] = deviceAccountState[k];
this.reactiveState[k].value = this.state[k] = this.mergeState<T[keyof T]['default']>(deviceAccountState[k], v.default);
} else {
this.reactiveState[k].value = this.state[k] = v.default;
if (_DEV_) console.log('Use default value', k, v.default);

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;

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