diff options
| author | dakkar <dakkar@thenautilus.net> | 2024-03-02 17:28:34 +0000 |
|---|---|---|
| committer | dakkar <dakkar@thenautilus.net> | 2024-03-02 17:28:34 +0000 |
| commit | 23f476dbf32ef9a2fc7d2ed7aab9ce706a2409d0 (patch) | |
| tree | 0b9e79c2f18f4a206811561fa255f2510f60c175 /packages/frontend/src/components | |
| parent | merge: Add missing IMPORTANT_NOTES.md from Sharkey/OldJoinSharkey (!443) (diff) | |
| parent | merge: put back the readme (!447) (diff) | |
| download | sharkey-23f476dbf32ef9a2fc7d2ed7aab9ce706a2409d0.tar.gz sharkey-23f476dbf32ef9a2fc7d2ed7aab9ce706a2409d0.tar.bz2 sharkey-23f476dbf32ef9a2fc7d2ed7aab9ce706a2409d0.zip | |
Merge branch 'develop' into release/2024.3.1
Diffstat (limited to 'packages/frontend/src/components')
255 files changed, 4854 insertions, 2479 deletions
diff --git a/packages/frontend/src/components/MkAbuseReport.stories.impl.ts b/packages/frontend/src/components/MkAbuseReport.stories.impl.ts index 77e7c84d5c..cf09c96fd4 100644 --- a/packages/frontend/src/components/MkAbuseReport.stories.impl.ts +++ b/packages/frontend/src/components/MkAbuseReport.stories.impl.ts @@ -1,12 +1,12 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { action } from '@storybook/addon-actions'; import { StoryObj } from '@storybook/vue3'; -import { rest } from 'msw'; +import { HttpResponse, http } from 'msw'; import { abuseUserReport } from '../../.storybook/fakes.js'; import { commonHandlers } from '../../.storybook/mocks.js'; import MkAbuseReport from './MkAbuseReport.vue'; @@ -44,9 +44,9 @@ export const Default = { msw: { handlers: [ ...commonHandlers, - rest.post('/api/admin/resolve-abuse-user-report', async (req, res, ctx) => { - action('POST /api/admin/resolve-abuse-user-report')(await req.json()); - return res(ctx.json({})); + http.post('/api/admin/resolve-abuse-user-report', async ({ request }) => { + action('POST /api/admin/resolve-abuse-user-report')(await request.json()); + return HttpResponse.json({}); }), ], }, diff --git a/packages/frontend/src/components/MkAbuseReport.vue b/packages/frontend/src/components/MkAbuseReport.vue index 611c8a1782..0493e885b9 100644 --- a/packages/frontend/src/components/MkAbuseReport.vue +++ b/packages/frontend/src/components/MkAbuseReport.vue @@ -1,12 +1,12 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> <template> <div class="bcekxzvu _margin _panel"> <div class="target"> - <MkA v-user-preview="report.targetUserId" class="info" :to="`/admin/user/${report.targetUserId}`"> + <MkA v-user-preview="report.targetUserId" class="info" :to="`/admin/user/${report.targetUserId}`" :behavior="'window'"> <MkAvatar class="avatar" :user="report.targetUser" indicator/> <div class="names"> <MkUserName class="name" :user="report.targetUser"/> @@ -23,7 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only <Mfm :text="report.comment"/> </div> <hr/> - <div>{{ i18n.ts.reporter }}: <MkA :to="`/admin/user/${report.reporter.id}`" class="_link">@{{ report.reporter.username }}</MkA></div> + <div>{{ i18n.ts.reporter }}: <MkA :to="`/admin/user/${report.reporter.id}`" class="_link" :behavior="'window'">@{{ report.reporter.username }}</MkA></div> <div v-if="report.assignee"> {{ i18n.ts.moderator }}: <MkAcct :user="report.assignee"/> diff --git a/packages/frontend/src/components/MkAbuseReportWindow.stories.impl.ts b/packages/frontend/src/components/MkAbuseReportWindow.stories.impl.ts index dc842b3d1b..9df957f3ec 100644 --- a/packages/frontend/src/components/MkAbuseReportWindow.stories.impl.ts +++ b/packages/frontend/src/components/MkAbuseReportWindow.stories.impl.ts @@ -1,12 +1,12 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { action } from '@storybook/addon-actions'; import { StoryObj } from '@storybook/vue3'; -import { rest } from 'msw'; +import { HttpResponse, http } from 'msw'; import { userDetailed } from '../../.storybook/fakes.js'; import { commonHandlers } from '../../.storybook/mocks.js'; import MkAbuseReportWindow from './MkAbuseReportWindow.vue'; @@ -44,9 +44,9 @@ export const Default = { msw: { handlers: [ ...commonHandlers, - rest.post('/api/users/report-abuse', async (req, res, ctx) => { - action('POST /api/users/report-abuse')(await req.json()); - return res(ctx.json({})); + http.post('/api/users/report-abuse', async ({ request }) => { + action('POST /api/users/report-abuse')(await request.json()); + return HttpResponse.json({}); }), ], }, diff --git a/packages/frontend/src/components/MkAbuseReportWindow.vue b/packages/frontend/src/components/MkAbuseReportWindow.vue index 6819630b74..f228df85a6 100644 --- a/packages/frontend/src/components/MkAbuseReportWindow.vue +++ b/packages/frontend/src/components/MkAbuseReportWindow.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -39,7 +39,7 @@ import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; const props = defineProps<{ - user: Misskey.entities.User; + user: Misskey.entities.UserDetailed; initialComment?: string; }>(); diff --git a/packages/frontend/src/components/MkAccountMoved.stories.impl.ts b/packages/frontend/src/components/MkAccountMoved.stories.impl.ts index 33c6c24631..f1cfdc157a 100644 --- a/packages/frontend/src/components/MkAccountMoved.stories.impl.ts +++ b/packages/frontend/src/components/MkAccountMoved.stories.impl.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/components/MkAccountMoved.vue b/packages/frontend/src/components/MkAccountMoved.vue index b11cf1c8a0..83283a7073 100644 --- a/packages/frontend/src/components/MkAccountMoved.vue +++ b/packages/frontend/src/components/MkAccountMoved.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -17,7 +17,7 @@ import * as Misskey from 'misskey-js'; import MkMention from './MkMention.vue'; import { i18n } from '@/i18n.js'; import { host as localHost } from '@/config.js'; -import { api } from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; const user = ref<Misskey.entities.UserLite>(); @@ -25,7 +25,7 @@ const props = defineProps<{ movedTo: string; // user id }>(); -api('users/show', { userId: props.movedTo }).then(u => user.value = u); +misskeyApi('users/show', { userId: props.movedTo }).then(u => user.value = u); </script> <style lang="scss" module> diff --git a/packages/frontend/src/components/MkAchievements.stories.impl.ts b/packages/frontend/src/components/MkAchievements.stories.impl.ts index 6d972467b1..7614da51da 100644 --- a/packages/frontend/src/components/MkAchievements.stories.impl.ts +++ b/packages/frontend/src/components/MkAchievements.stories.impl.ts @@ -1,11 +1,11 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { StoryObj } from '@storybook/vue3'; -import { rest } from 'msw'; +import { HttpResponse, http } from 'msw'; import { userDetailed } from '../../.storybook/fakes.js'; import { commonHandlers } from '../../.storybook/mocks.js'; import MkAchievements from './MkAchievements.vue'; @@ -39,8 +39,8 @@ export const Empty = { msw: { handlers: [ ...commonHandlers, - rest.post('/api/users/achievements', (req, res, ctx) => { - return res(ctx.json([])); + http.post('/api/users/achievements', () => { + return HttpResponse.json([]); }), ], }, @@ -52,8 +52,8 @@ export const All = { msw: { handlers: [ ...commonHandlers, - rest.post('/api/users/achievements', (req, res, ctx) => { - return res(ctx.json(ACHIEVEMENT_TYPES.map((name) => ({ name, unlockedAt: 0 })))); + http.post('/api/users/achievements', () => { + return HttpResponse.json(ACHIEVEMENT_TYPES.map((name) => ({ name, unlockedAt: 0 }))); }), ], }, diff --git a/packages/frontend/src/components/MkAchievements.vue b/packages/frontend/src/components/MkAchievements.vue index cdd9cb87b1..8ec3ec0505 100644 --- a/packages/frontend/src/components/MkAchievements.vue +++ b/packages/frontend/src/components/MkAchievements.vue @@ -1,12 +1,12 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> <template> <div> <div v-if="achievements" :class="$style.root"> - <div v-for="achievement in achievements" :key="achievement" :class="$style.achievement" class="_panel"> + <div v-for="achievement in achievements" :key="achievement.name" :class="$style.achievement" class="_panel"> <div :class="$style.icon"> <div :class="[$style.iconFrame, { @@ -55,6 +55,7 @@ SPDX-License-Identifier: AGPL-3.0-only import * as Misskey from 'misskey-js'; import { onMounted, ref, computed } from 'vue'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; import { ACHIEVEMENT_TYPES, ACHIEVEMENT_BADGES, claimAchievement } from '@/scripts/achievements.js'; @@ -71,7 +72,7 @@ const achievements = ref<Misskey.entities.UsersAchievementsResponse | null>(null const lockedAchievements = computed(() => ACHIEVEMENT_TYPES.filter(x => !(achievements.value ?? []).some(a => a.name === x))); function fetch() { - os.api('users/achievements', { userId: props.user.id }).then(res => { + misskeyApi('users/achievements', { userId: props.user.id }).then(res => { achievements.value = []; for (const t of ACHIEVEMENT_TYPES) { const a = res.find(x => x.name === t); @@ -120,8 +121,8 @@ onMounted(() => { .iconFrame { position: relative; - width: 58px; - height: 58px; + width: var(--avatar); + height: var(--avatar); padding: 6px; border-radius: var(--radius-full); box-sizing: border-box; diff --git a/packages/frontend/src/components/MkAnalogClock.stories.impl.ts b/packages/frontend/src/components/MkAnalogClock.stories.impl.ts index f87ad30f9b..270ca40825 100644 --- a/packages/frontend/src/components/MkAnalogClock.stories.impl.ts +++ b/packages/frontend/src/components/MkAnalogClock.stories.impl.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/components/MkAnalogClock.vue b/packages/frontend/src/components/MkAnalogClock.vue index 0e252f7b1d..835efbd6cd 100644 --- a/packages/frontend/src/components/MkAnalogClock.vue +++ b/packages/frontend/src/components/MkAnalogClock.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/MkAnimBg.vue b/packages/frontend/src/components/MkAnimBg.vue index 284ee8f3f8..4bf6125af5 100644 --- a/packages/frontend/src/components/MkAnimBg.vue +++ b/packages/frontend/src/components/MkAnimBg.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/MkAnnouncementDialog.stories.impl.ts b/packages/frontend/src/components/MkAnnouncementDialog.stories.impl.ts index 42cfb90f7c..ffa4e56f5f 100644 --- a/packages/frontend/src/components/MkAnnouncementDialog.stories.impl.ts +++ b/packages/frontend/src/components/MkAnnouncementDialog.stories.impl.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/components/MkAnnouncementDialog.vue b/packages/frontend/src/components/MkAnnouncementDialog.vue index 4c6e3e693a..74d0e7214f 100644 --- a/packages/frontend/src/components/MkAnnouncementDialog.vue +++ b/packages/frontend/src/components/MkAnnouncementDialog.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -25,6 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { onMounted, shallowRef } from 'vue'; import * as Misskey from 'misskey-js'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import MkModal from '@/components/MkModal.vue'; import MkButton from '@/components/MkButton.vue'; import { i18n } from '@/i18n.js'; @@ -43,20 +44,20 @@ async function ok() { const confirm = await os.confirm({ type: 'question', title: i18n.ts._announcement.readConfirmTitle, - text: i18n.t('_announcement.readConfirmText', { title: props.announcement.title }), + text: i18n.tsx._announcement.readConfirmText({ title: props.announcement.title }), }); if (confirm.canceled) return; } - modal.value.close(); - os.api('i/read-announcement', { announcementId: props.announcement.id }); + modal.value?.close(); + misskeyApi('i/read-announcement', { announcementId: props.announcement.id }); updateAccount({ unreadAnnouncements: $i!.unreadAnnouncements.filter(a => a.id !== props.announcement.id), }); } function onBgClick() { - rootEl.value.animate([{ + rootEl.value?.animate([{ offset: 0, transform: 'scale(1)', }, { diff --git a/packages/frontend/src/components/MkAsUi.stories.impl.ts b/packages/frontend/src/components/MkAsUi.stories.impl.ts index 564fa902ba..cf8d5483b9 100644 --- a/packages/frontend/src/components/MkAsUi.stories.impl.ts +++ b/packages/frontend/src/components/MkAsUi.stories.impl.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/components/MkAsUi.vue b/packages/frontend/src/components/MkAsUi.vue index 8475233dfa..11f454daa2 100644 --- a/packages/frontend/src/components/MkAsUi.vue +++ b/packages/frontend/src/components/MkAsUi.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -10,8 +10,8 @@ SPDX-License-Identifier: AGPL-3.0-only <MkAsUi v-if="!g(child).hidden" :component="g(child)" :components="props.components" :size="size"/> </template> </div> - <span v-else-if="c.type === 'text'" :class="{ [$style.fontSerif]: c.font === 'serif', [$style.fontMonospace]: c.font === 'monospace' }" :style="{ fontSize: c.size ? `${c.size * 100}%` : null, fontWeight: c.bold ? 'bold' : null, color: c.color ?? null }">{{ c.text }}</span> - <Mfm v-else-if="c.type === 'mfm'" :class="{ [$style.fontSerif]: c.font === 'serif', [$style.fontMonospace]: c.font === 'monospace' }" :style="{ fontSize: c.size ? `${c.size * 100}%` : null, fontWeight: c.bold ? 'bold' : null, color: c.color ?? null }" :text="c.text" @clickEv="c.onClickEv"/> + <span v-else-if="c.type === 'text'" :class="{ [$style.fontSerif]: c.font === 'serif', [$style.fontMonospace]: c.font === 'monospace' }" :style="{ fontSize: c.size ? `${c.size * 100}%` : undefined, fontWeight: c.bold ? 'bold' : undefined, color: c.color }">{{ c.text }}</span> + <Mfm v-else-if="c.type === 'mfm'" :class="{ [$style.fontSerif]: c.font === 'serif', [$style.fontMonospace]: c.font === 'monospace' }" :style="{ fontSize: c.size ? `${c.size * 100}%` : null, fontWeight: c.bold ? 'bold' : null, color: c.color ?? null }" :text="c.text ?? ''" @clickEv="c.onClickEv"/> <MkButton v-else-if="c.type === 'button'" :primary="c.primary" :rounded="c.rounded" :disabled="c.disabled" :small="size === 'small'" inline @click="c.onClick">{{ c.text }}</MkButton> <div v-else-if="c.type === 'buttons'" class="_buttons" :style="{ justifyContent: align }"> <MkButton v-for="button in c.buttons" :primary="button.primary" :rounded="button.rounded" :disabled="button.disabled" inline :small="size === 'small'" @click="button.onClick">{{ button.text }}</MkButton> @@ -20,19 +20,19 @@ SPDX-License-Identifier: AGPL-3.0-only <template v-if="c.label" #label>{{ c.label }}</template> <template v-if="c.caption" #caption>{{ c.caption }}</template> </MkSwitch> - <MkTextarea v-else-if="c.type === 'textarea'" :modelValue="c.default" @update:modelValue="c.onInput"> + <MkTextarea v-else-if="c.type === 'textarea'" :modelValue="c.default ?? null" @update:modelValue="c.onInput"> <template v-if="c.label" #label>{{ c.label }}</template> <template v-if="c.caption" #caption>{{ c.caption }}</template> </MkTextarea> - <MkInput v-else-if="c.type === 'textInput'" :small="size === 'small'" :modelValue="c.default" @update:modelValue="c.onInput"> + <MkInput v-else-if="c.type === 'textInput'" :small="size === 'small'" :modelValue="c.default ?? null" @update:modelValue="c.onInput"> <template v-if="c.label" #label>{{ c.label }}</template> <template v-if="c.caption" #caption>{{ c.caption }}</template> </MkInput> - <MkInput v-else-if="c.type === 'numberInput'" :small="size === 'small'" :modelValue="c.default" type="number" @update:modelValue="c.onInput"> + <MkInput v-else-if="c.type === 'numberInput'" :small="size === 'small'" :modelValue="c.default ?? null" type="number" @update:modelValue="c.onInput"> <template v-if="c.label" #label>{{ c.label }}</template> <template v-if="c.caption" #caption>{{ c.caption }}</template> </MkInput> - <MkSelect v-else-if="c.type === 'select'" :small="size === 'small'" :modelValue="c.default" @update:modelValue="c.onChange"> + <MkSelect v-else-if="c.type === 'select'" :small="size === 'small'" :modelValue="c.default ?? null" @update:modelValue="c.onChange"> <template v-if="c.label" #label>{{ c.label }}</template> <template v-if="c.caption" #caption>{{ c.caption }}</template> <option v-for="item in c.items" :key="item.value" :value="item.value">{{ item.text }}</option> @@ -42,8 +42,8 @@ SPDX-License-Identifier: AGPL-3.0-only <MkPostForm fixed :instant="true" - :initialText="c.form.text" - :initialCw="c.form.cw" + :initialText="c.form?.text" + :initialCw="c.form?.cw" /> </div> <MkFolder v-else-if="c.type === 'folder'" :defaultOpen="c.opened"> @@ -52,7 +52,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkAsUi v-if="!g(child).hidden" :component="g(child)" :components="props.components" :size="size"/> </template> </MkFolder> - <div v-else-if="c.type === 'container'" :class="[$style.container, { [$style.fontSerif]: c.font === 'serif', [$style.fontMonospace]: c.font === 'monospace' }]" :style="{ textAlign: c.align ?? null, backgroundColor: c.bgColor ?? null, color: c.fgColor ?? null, borderWidth: c.borderWidth ? `${c.borderWidth}px` : 0, borderColor: c.borderColor ?? 'var(--divider)', padding: c.padding ? `${c.padding}px` : 0, borderRadius: c.rounded ? '8px' : 0 }"> + <div v-else-if="c.type === 'container'" :class="[$style.container, { [$style.fontSerif]: c.font === 'serif', [$style.fontMonospace]: c.font === 'monospace' }]" :style="{ textAlign: c.align, backgroundColor: c.bgColor, color: c.fgColor, borderWidth: c.borderWidth ? `${c.borderWidth}px` : 0, borderColor: c.borderColor ?? 'var(--divider)', padding: c.padding ? `${c.padding}px` : 0, borderRadius: c.rounded ? '8px' : 0 }"> <template v-for="child in c.children" :key="child"> <MkAsUi v-if="!g(child).hidden" :component="g(child)" :components="props.components" :size="size" :align="c.align"/> </template> @@ -68,7 +68,7 @@ import MkInput from '@/components/MkInput.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import MkTextarea from '@/components/MkTextarea.vue'; import MkSelect from '@/components/MkSelect.vue'; -import { AsUiComponent } from '@/scripts/aiscript/ui.js'; +import { AsUiComponent, AsUiRoot, AsUiPostFormButton } from '@/scripts/aiscript/ui.js'; import MkFolder from '@/components/MkFolder.vue'; import MkPostForm from '@/components/MkPostForm.vue'; @@ -85,20 +85,32 @@ const props = withDefaults(defineProps<{ const c = props.component; function g(id) { - return props.components.find(x => x.value.id === id).value; + const v = props.components.find(x => x.value.id === id)?.value; + if (v) return v; + + return { + id: 'dummy', + type: 'root', + children: [], + } as AsUiRoot; } -const valueForSwitch = ref(c.default ?? false); +const valueForSwitch = ref('default' in c && typeof c.default === 'boolean' ? c.default : false); function onSwitchUpdate(v) { valueForSwitch.value = v; - if (c.onChange) c.onChange(v); + if ('onChange' in c && c.onChange) { + c.onChange(v as never); + } } function openPostForm() { + const form = (c as AsUiPostFormButton).form; + if (!form) return; + os.post({ - initialText: c.form.text, - initialCw: c.form.cw, + initialText: form.text, + initialCw: form.cw, instant: true, }); } diff --git a/packages/frontend/src/components/MkAutocomplete.stories.impl.ts b/packages/frontend/src/components/MkAutocomplete.stories.impl.ts index 969519386f..ec24b8c240 100644 --- a/packages/frontend/src/components/MkAutocomplete.stories.impl.ts +++ b/packages/frontend/src/components/MkAutocomplete.stories.impl.ts @@ -1,14 +1,13 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { action } from '@storybook/addon-actions'; -import { expect } from '@storybook/jest'; -import { userEvent, waitFor, within } from '@storybook/testing-library'; +import { expect, userEvent, waitFor, within } from '@storybook/test'; import { StoryObj } from '@storybook/vue3'; -import { rest } from 'msw'; +import { HttpResponse, http } from 'msw'; import { userDetailed } from '../../.storybook/fakes.js'; import { commonHandlers } from '../../.storybook/mocks.js'; import MkAutocomplete from './MkAutocomplete.vue'; @@ -99,11 +98,11 @@ export const User = { msw: { handlers: [ ...commonHandlers, - rest.post('/api/users/search-by-username-and-host', (req, res, ctx) => { - return res(ctx.json([ + http.post('/api/users/search-by-username-and-host', () => { + return HttpResponse.json([ userDetailed('44', 'mizuki', 'misskey-hub.net', 'Mizuki'), userDetailed('49', 'momoko', 'misskey-hub.net', 'Momoko'), - ])); + ]); }), ], }, @@ -132,12 +131,12 @@ export const Hashtag = { msw: { handlers: [ ...commonHandlers, - rest.post('/api/hashtags/search', (req, res, ctx) => { - return res(ctx.json([ + http.post('/api/hashtags/search', () => { + return HttpResponse.json([ '気象警報注意報', '気象警報', '気象情報', - ])); + ]); }), ], }, diff --git a/packages/frontend/src/components/MkAutocomplete.vue b/packages/frontend/src/components/MkAutocomplete.vue index 1f819cf601..8b665bfacd 100644 --- a/packages/frontend/src/components/MkAutocomplete.vue +++ b/packages/frontend/src/components/MkAutocomplete.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only </ol> <ol v-else-if="emojis.length > 0" ref="suggests" :class="$style.list"> <li v-for="emoji in emojis" :key="emoji.emoji" :class="$style.item" tabindex="-1" @click="complete(type, emoji.emoji)" @keydown="onKeydown"> - <MkCustomEmoji v-if="'isCustomEmoji' in emoji && emoji.isCustomEmoji" :name="emoji.emoji" :class="$style.emoji"/> + <MkCustomEmoji v-if="'isCustomEmoji' in emoji && emoji.isCustomEmoji" :name="emoji.emoji" :class="$style.emoji" :fallbackToImage="true"/> <MkEmoji v-else :emoji="emoji.emoji" :class="$style.emoji"/> <!-- eslint-disable-next-line vue/no-v-html --> <span v-if="q" :class="$style.emojiName" v-html="sanitizeHtml(emoji.name.replace(q, `<b>${q}</b>`))"></span> @@ -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> @@ -42,33 +47,23 @@ SPDX-License-Identifier: AGPL-3.0-only import { markRaw, ref, shallowRef, computed, onUpdated, onMounted, onBeforeUnmount, nextTick, watch } from 'vue'; import sanitizeHtml from 'sanitize-html'; import contains from '@/scripts/contains.js'; -import { char2twemojiFilePath, char2fluentEmojiFilePath } from '@/scripts/emoji-base.js'; +import { char2twemojiFilePath, char2fluentEmojiFilePath, char2tossfaceFilePath } from '@/scripts/emoji-base.js'; import { acct } from '@/filters/user.js'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { defaultStore } from '@/store.js'; 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'; - -type EmojiDef = { - emoji: string; - name: string; - url: string; - aliasOf?: string; -} | { - emoji: string; - name: string; - aliasOf?: string; - isCustomEmoji?: true; -}; +import { MFM_TAGS, MFM_PARAMS } from '@/const.js'; +import { searchEmoji, EmojiDef } from '@/scripts/search-emoji.js'; const lib = emojilist.filter(x => x.category !== 'flags'); const emojiDb = computed(() => { //#region Unicode Emoji - const char2path = defaultStore.reactiveState.emojiStyle.value === 'twemoji' ? char2twemojiFilePath : char2fluentEmojiFilePath; + const char2path = defaultStore.reactiveState.emojiStyle.value === 'twemoji' ? char2twemojiFilePath : defaultStore.reactiveState.emojiStyle.value === 'tossface' ? char2tossfaceFilePath : char2fluentEmojiFilePath; const unicodeEmojiDB: EmojiDef[] = lib.map(x => ({ emoji: x.char, @@ -82,7 +77,7 @@ const emojiDb = computed(() => { unicodeEmojiDB.push({ emoji: emoji, name: k, - aliasOf: getEmojiName(emoji)!, + aliasOf: getEmojiName(emoji), url: char2path(emoji), }); } @@ -129,7 +124,7 @@ export default { <script lang="ts" setup> const props = defineProps<{ type: string; - q: string | null; + q: any; textarea: HTMLTextAreaElement; close: () => void; x: number; @@ -150,6 +145,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'); @@ -201,7 +197,7 @@ function exec() { users.value = JSON.parse(cache); fetching.value = false; } else { - os.api('users/search-by-username-and-host', { + misskeyApi('users/search-by-username-and-host', { username: props.q, limit: 10, detail: false, @@ -224,7 +220,7 @@ function exec() { hashtags.value = hashtags; fetching.value = false; } else { - os.api('hashtags/search', { + misskeyApi('hashtags/search', { query: props.q, limit: 30, }).then(searchedHashtags => { @@ -242,7 +238,7 @@ function exec() { return; } - emojis.value = emojiAutoComplete(props.q.toLowerCase(), emojiDb.value); + emojis.value = searchEmoji(props.q.toLowerCase(), emojiDb.value); } else if (props.type === 'mfmTag') { if (!props.q || props.q === '') { mfmTags.value = MFM_TAGS; @@ -250,79 +246,14 @@ function exec() { } mfmTags.value = MFM_TAGS.filter(tag => tag.startsWith(props.q ?? '')); - } -} - -type EmojiScore = { emoji: EmojiDef, score: number }; - -function emojiAutoComplete(query: string | null, emojiDb: EmojiDef[], max = 30): EmojiDef[] { - if (!query) { - return []; - } - - const matched = new Map<string, EmojiScore>(); - - // 前方一致(エイリアスなし) - emojiDb.some(x => { - if (x.name.toLowerCase().startsWith(query) && !x.aliasOf) { - matched.set(x.name, { emoji: x, score: query.length + 1 }); - } - return matched.size === max; - }); - - // 前方一致(エイリアス込み) - if (matched.size < max) { - emojiDb.some(x => { - if (x.name.toLowerCase().startsWith(query) && !matched.has(x.aliasOf ?? x.name)) { - matched.set(x.aliasOf ?? x.name, { emoji: x, score: query.length }); - } - return matched.size === max; - }); - } - - // 部分一致(エイリアス込み) - if (matched.size < max) { - emojiDb.some(x => { - if (x.name.toLowerCase().includes(query) && !matched.has(x.aliasOf ?? x.name)) { - matched.set(x.aliasOf ?? x.name, { emoji: x, score: query.length - 1 }); - } - return matched.size === max; - }); - } - - // 簡易あいまい検索(3文字以上) - if (matched.size < max && query.length > 3) { - const queryChars = [...query]; - const hitEmojis = new Map<string, EmojiScore>(); - - for (const x of emojiDb) { - // 文字列の位置を進めながら、クエリの文字を順番に探す - - let pos = 0; - let hit = 0; - for (const c of queryChars) { - pos = x.name.toLowerCase().indexOf(c, pos); - if (pos <= -1) break; - hit++; - } - - // 半分以上の文字が含まれていればヒットとする - if (hit > Math.ceil(queryChars.length / 2) && hit - 2 > (matched.get(x.aliasOf ?? x.name)?.score ?? 0)) { - hitEmojis.set(x.aliasOf ?? x.name, { emoji: x, score: hit - 2 }); - } + } else if (props.type === 'mfmParam') { + if (props.q.params.at(-1) === '') { + mfmParams.value = MFM_PARAMS[props.q.tag] ?? []; + return; } - // ヒットしたものを全部追加すると雑多になるので、先頭の6件程度だけにしておく(6件=オートコンプリートのポップアップのサイズ分) - [...hitEmojis.values()] - .sort((x, y) => y.score - x.score) - .slice(0, 6) - .forEach(it => matched.set(it.emoji.name, it)); + mfmParams.value = MFM_PARAMS[props.q.tag].filter(param => param.startsWith(props.q.params.at(-1) ?? '')); } - - return [...matched.values()] - .sort((x, y) => y.score - x.score) - .slice(0, max) - .map(it => it.emoji); } function onMousedown(event: Event) { @@ -408,7 +339,7 @@ function applySelect() { function chooseUser() { props.close(); - os.selectUser().then(user => { + os.selectUser({ includeSelf: true }).then(user => { complete('user', user); props.textarea.focus(); }); diff --git a/packages/frontend/src/components/MkAvatars.stories.impl.ts b/packages/frontend/src/components/MkAvatars.stories.impl.ts index d41b64695f..d2a4a9f03b 100644 --- a/packages/frontend/src/components/MkAvatars.stories.impl.ts +++ b/packages/frontend/src/components/MkAvatars.stories.impl.ts @@ -1,11 +1,11 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { StoryObj } from '@storybook/vue3'; -import { rest } from 'msw'; +import { HttpResponse, http } from 'msw'; import { userDetailed } from '../../.storybook/fakes.js'; import { commonHandlers } from '../../.storybook/mocks.js'; import MkAvatars from './MkAvatars.vue'; @@ -38,12 +38,12 @@ export const Default = { msw: { handlers: [ ...commonHandlers, - rest.post('/api/users/show', (req, res, ctx) => { - return res(ctx.json([ + http.post('/api/users/show', () => { + return HttpResponse.json([ userDetailed('17'), userDetailed('20'), userDetailed('18'), - ])); + ]); }), ], }, diff --git a/packages/frontend/src/components/MkAvatars.vue b/packages/frontend/src/components/MkAvatars.vue index 5644a324cf..8236d0ddb9 100644 --- a/packages/frontend/src/components/MkAvatars.vue +++ b/packages/frontend/src/components/MkAvatars.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { onMounted, ref } from 'vue'; import * as Misskey from 'misskey-js'; -import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; const props = withDefaults(defineProps<{ userIds: string[]; @@ -27,7 +27,7 @@ const props = withDefaults(defineProps<{ const users = ref<Misskey.entities.UserLite[]>([]); onMounted(async () => { - users.value = await os.api('users/show', { + users.value = await misskeyApi('users/show', { userIds: props.userIds, }) as unknown as Misskey.entities.UserLite[]; }); diff --git a/packages/frontend/src/components/MkButton.stories.impl.ts b/packages/frontend/src/components/MkButton.stories.impl.ts index e852557b12..e8802e4f8f 100644 --- a/packages/frontend/src/components/MkButton.stories.impl.ts +++ b/packages/frontend/src/components/MkButton.stories.impl.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/components/MkButton.vue b/packages/frontend/src/components/MkButton.vue index 9fcc49d3f0..c0f41b64d0 100644 --- a/packages/frontend/src/components/MkButton.vue +++ b/packages/frontend/src/components/MkButton.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkA v-else class="_button" :class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike }]" - :to="to" + :to="to ?? '#'" @mousedown="onMousedown" > <div ref="ripples" :class="$style.ripples" :data-children-class="$style.ripple"></div> @@ -131,6 +131,10 @@ function onMousedown(evt: MouseEvent): void { box-sizing: border-box; transition: background 0.1s ease; + &:hover { + text-decoration: none; + } + &:not(:disabled):hover { background: var(--buttonHoverBg); } diff --git a/packages/frontend/src/components/MkCaptcha.stories.impl.ts b/packages/frontend/src/components/MkCaptcha.stories.impl.ts index fb50e50b18..475257cc45 100644 --- a/packages/frontend/src/components/MkCaptcha.stories.impl.ts +++ b/packages/frontend/src/components/MkCaptcha.stories.impl.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/components/MkCaptcha.vue b/packages/frontend/src/components/MkCaptcha.vue index 40bca11e64..c64bb47e77 100644 --- a/packages/frontend/src/components/MkCaptcha.vue +++ b/packages/frontend/src/components/MkCaptcha.vue @@ -1,19 +1,22 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> <template> <div> - <span v-if="!available">{{ i18n.ts.waiting }}<MkEllipsis/></span> - <div ref="captchaEl"></div> + <span v-if="!available">Loading<MkEllipsis/></span> + <div v-if="props.provider == 'mcaptcha'"> + <div id="mcaptcha__widget-container" class="m-captcha-style"></div> + <div ref="captchaEl"></div> + </div> + <div v-else ref="captchaEl"></div> </div> </template> <script lang="ts" setup> -import { ref, shallowRef, computed, onMounted, onBeforeUnmount, watch } from 'vue'; +import { ref, shallowRef, computed, onMounted, onBeforeUnmount, watch, onUnmounted } from 'vue'; import { defaultStore } from '@/store.js'; -import { i18n } from '@/i18n.js'; // APIs provided by Captcha services export type Captcha = { @@ -26,7 +29,7 @@ export type Captcha = { getResponse(id: string): string; }; -export type CaptchaProvider = 'hcaptcha' | 'recaptcha' | 'turnstile'; +export type CaptchaProvider = 'hcaptcha' | 'recaptcha' | 'turnstile' | 'mcaptcha'; type CaptchaContainer = { readonly [_ in CaptchaProvider]?: Captcha; @@ -39,6 +42,7 @@ declare global { const props = defineProps<{ provider: CaptchaProvider; sitekey: string | null; // null will show error on request + instanceUrl?: string | null; modelValue?: string | null; }>(); @@ -55,6 +59,7 @@ const variable = computed(() => { case 'hcaptcha': return 'hcaptcha'; case 'recaptcha': return 'grecaptcha'; case 'turnstile': return 'turnstile'; + case 'mcaptcha': return 'mcaptcha'; } }); @@ -65,6 +70,7 @@ const src = computed(() => { case 'hcaptcha': return 'https://js.hcaptcha.com/1/api.js?render=explicit&recaptchacompat=off'; case 'recaptcha': return 'https://www.recaptcha.net/recaptcha/api.js?render=explicit'; case 'turnstile': return 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'; + case 'mcaptcha': return null; } }); @@ -72,9 +78,9 @@ const scriptId = computed(() => `script-${props.provider}`); const captcha = computed<Captcha>(() => window[variable.value] || {} as unknown as Captcha); -if (loaded) { +if (loaded || props.provider === 'mcaptcha') { available.value = true; -} else { +} else if (src.value !== null) { (document.getElementById(scriptId.value) ?? document.head.appendChild(Object.assign(document.createElement('script'), { async: true, id: scriptId.value, @@ -87,7 +93,7 @@ function reset() { if (captcha.value.reset) captcha.value.reset(); } -function requestRender() { +async function requestRender() { if (captcha.value.render && captchaEl.value instanceof Element) { captcha.value.render(captchaEl.value, { sitekey: props.sitekey, @@ -96,6 +102,15 @@ function requestRender() { 'expired-callback': callback, 'error-callback': callback, }); + } else if (props.provider === 'mcaptcha' && props.instanceUrl && props.sitekey) { + const { default: Widget } = await import('@mcaptcha/vanilla-glue'); + // @ts-expect-error avoid typecheck error + new Widget({ + siteKey: { + instanceUrl: new URL(props.instanceUrl), + key: props.sitekey, + }, + }); } else { window.setTimeout(requestRender, 1); } @@ -105,14 +120,27 @@ function callback(response?: string) { emit('update:modelValue', typeof response === 'string' ? response : null); } +function onReceivedMessage(message: MessageEvent) { + if (message.data.token) { + if (props.instanceUrl && new URL(message.origin).host === new URL(props.instanceUrl).host) { + callback(message.data.token); + } + } +} + onMounted(() => { if (available.value) { + window.addEventListener('message', onReceivedMessage); requestRender(); } else { watch(available, requestRender); } }); +onUnmounted(() => { + window.removeEventListener('message', onReceivedMessage); +}); + onBeforeUnmount(() => { reset(); }); diff --git a/packages/frontend/src/components/MkChannelFollowButton.vue b/packages/frontend/src/components/MkChannelFollowButton.vue index 4a58204b5b..07732d9205 100644 --- a/packages/frontend/src/components/MkChannelFollowButton.vue +++ b/packages/frontend/src/components/MkChannelFollowButton.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -26,7 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref } from 'vue'; -import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; const props = withDefaults(defineProps<{ @@ -44,12 +44,12 @@ async function onClick() { try { if (isFollowing.value) { - await os.api('channels/unfollow', { + await misskeyApi('channels/unfollow', { channelId: props.channel.id, }); isFollowing.value = false; } else { - await os.api('channels/follow', { + await misskeyApi('channels/follow', { channelId: props.channel.id, }); isFollowing.value = true; diff --git a/packages/frontend/src/components/MkChannelList.vue b/packages/frontend/src/components/MkChannelList.vue index 83d4401d2e..2850ecca16 100644 --- a/packages/frontend/src/components/MkChannelList.vue +++ b/packages/frontend/src/components/MkChannelList.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/MkChannelPreview.vue b/packages/frontend/src/components/MkChannelPreview.vue index f870b0eef1..1bac59d6df 100644 --- a/packages/frontend/src/components/MkChannelPreview.vue +++ b/packages/frontend/src/components/MkChannelPreview.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -20,7 +20,7 @@ SPDX-License-Identifier: AGPL-3.0-only </I18n> </div> <div> - <i class="ph-pencil ph-bold ph-lg"></i> + <i class="ph-pencil-simple ph-bold ph-lg"></i> <I18n :src="i18n.ts._channel.notesCount" tag="span" style="margin-left: 4px;"> <template #n> <b>{{ channel.notesCount }}</b> diff --git a/packages/frontend/src/components/MkChart.vue b/packages/frontend/src/components/MkChart.vue index adb3c134ae..04b6d2f29c 100644 --- a/packages/frontend/src/components/MkChart.vue +++ b/packages/frontend/src/components/MkChart.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -21,13 +21,13 @@ SPDX-License-Identifier: AGPL-3.0-only */ import { onMounted, ref, shallowRef, watch, PropType } from 'vue'; import { Chart } from 'chart.js'; -import gradient from 'chartjs-plugin-gradient'; -import * as os from '@/os.js'; +import { misskeyApiGet } from '@/scripts/misskey-api.js'; import { defaultStore } from '@/store.js'; import { useChartTooltip } from '@/scripts/use-chart-tooltip.js'; import { chartVLine } from '@/scripts/chart-vline.js'; import { alpha } from '@/scripts/color.js'; import date from '@/filters/date.js'; +import bytes from '@/filters/bytes.js'; import { initChart } from '@/scripts/init-chart.js'; import { chartLegend } from '@/scripts/chart-legend.js'; import MkChartLegend from '@/components/MkChartLegend.vue'; @@ -95,7 +95,7 @@ const getColor = (i) => { }; const now = new Date(); -let chartInstance: Chart = null; +let chartInstance: Chart | null = null; let chartData: { series: { name: string; @@ -108,9 +108,10 @@ let chartData: { y: number; }[]; }[]; -} = null; + bytes?: boolean; +} | null = null; -const chartEl = shallowRef<HTMLCanvasElement>(null); +const chartEl = shallowRef<HTMLCanvasElement | null>(null); const fetching = ref(true); const getDate = (ago: number) => { @@ -132,6 +133,7 @@ const format = (arr) => { const { handler: externalTooltipHandler } = useChartTooltip(); const render = () => { + if (chartData == null || chartEl.value == null) return; if (chartInstance) { chartInstance.destroy(); } @@ -188,7 +190,6 @@ const render = () => { stacked: props.stacked, offset: false, time: { - stepSize: 1, unit: props.span === 'day' ? 'month' : 'day', displayFormats: { day: 'M/d', @@ -198,6 +199,7 @@ const render = () => { grid: { }, ticks: { + stepSize: 1, display: props.detailed, maxRotation: 0, autoSkipPadding: 16, @@ -237,6 +239,9 @@ const render = () => { duration: 0, }, external: externalTooltipHandler, + callbacks: { + label: (item) => `${item.dataset.label}: ${chartData?.bytes ? bytes(item.parsed.y * 1000, 1) : item.parsed.y.toString()}`, + }, }, zoom: props.detailed ? { pan: { @@ -265,10 +270,9 @@ const render = () => { }, }, } : undefined, - gradient, }, }, - plugins: [chartVLine(vLineColor), ...(props.detailed ? [chartLegend(legendEl.value)] : [])], + plugins: [chartVLine(vLineColor), ...(props.detailed && legendEl.value ? [chartLegend(legendEl.value)] : [])], }); }; @@ -277,7 +281,7 @@ const exportData = () => { }; const fetchFederationChart = async (): Promise<typeof chartData> => { - const raw = await os.apiGet('charts/federation', { limit: props.limit, span: props.span }); + const raw = await misskeyApiGet('charts/federation', { limit: props.limit, span: props.span }); return { series: [{ name: 'Received', @@ -327,7 +331,7 @@ const fetchFederationChart = async (): Promise<typeof chartData> => { }; const fetchApRequestChart = async (): Promise<typeof chartData> => { - const raw = await os.apiGet('charts/ap-request', { limit: props.limit, span: props.span }); + const raw = await misskeyApiGet('charts/ap-request', { limit: props.limit, span: props.span }); return { series: [{ name: 'In', @@ -349,7 +353,7 @@ const fetchApRequestChart = async (): Promise<typeof chartData> => { }; const fetchNotesChart = async (type: string): Promise<typeof chartData> => { - const raw = await os.apiGet('charts/notes', { limit: props.limit, span: props.span }); + const raw = await misskeyApiGet('charts/notes', { limit: props.limit, span: props.span }); return { series: [{ name: 'All', @@ -396,7 +400,7 @@ const fetchNotesChart = async (type: string): Promise<typeof chartData> => { }; const fetchNotesTotalChart = async (): Promise<typeof chartData> => { - const raw = await os.apiGet('charts/notes', { limit: props.limit, span: props.span }); + const raw = await misskeyApiGet('charts/notes', { limit: props.limit, span: props.span }); return { series: [{ name: 'Combined', @@ -415,7 +419,7 @@ const fetchNotesTotalChart = async (): Promise<typeof chartData> => { }; const fetchUsersChart = async (total: boolean): Promise<typeof chartData> => { - const raw = await os.apiGet('charts/users', { limit: props.limit, span: props.span }); + const raw = await misskeyApiGet('charts/users', { limit: props.limit, span: props.span }); return { series: [{ name: 'Combined', @@ -443,7 +447,7 @@ const fetchUsersChart = async (total: boolean): Promise<typeof chartData> => { }; const fetchActiveUsersChart = async (): Promise<typeof chartData> => { - const raw = await os.apiGet('charts/active-users', { limit: props.limit, span: props.span }); + const raw = await misskeyApiGet('charts/active-users', { limit: props.limit, span: props.span }); return { series: [{ name: 'Read & Write', @@ -495,7 +499,7 @@ const fetchActiveUsersChart = async (): Promise<typeof chartData> => { }; const fetchDriveChart = async (): Promise<typeof chartData> => { - const raw = await os.apiGet('charts/drive', { limit: props.limit, span: props.span }); + const raw = await misskeyApiGet('charts/drive', { limit: props.limit, span: props.span }); return { bytes: true, series: [{ @@ -531,7 +535,7 @@ const fetchDriveChart = async (): Promise<typeof chartData> => { }; const fetchDriveFilesChart = async (): Promise<typeof chartData> => { - const raw = await os.apiGet('charts/drive', { limit: props.limit, span: props.span }); + const raw = await misskeyApiGet('charts/drive', { limit: props.limit, span: props.span }); return { series: [{ name: 'All', @@ -566,7 +570,7 @@ const fetchDriveFilesChart = async (): Promise<typeof chartData> => { }; const fetchInstanceRequestsChart = async (): Promise<typeof chartData> => { - const raw = await os.apiGet('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); + const raw = await misskeyApiGet('charts/instance', { host: props.args?.host, limit: props.limit, span: props.span }); return { series: [{ name: 'In', @@ -588,7 +592,7 @@ const fetchInstanceRequestsChart = async (): Promise<typeof chartData> => { }; const fetchInstanceUsersChart = async (total: boolean): Promise<typeof chartData> => { - const raw = await os.apiGet('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); + const raw = await misskeyApiGet('charts/instance', { host: props.args?.host, limit: props.limit, span: props.span }); return { series: [{ name: 'Users', @@ -603,7 +607,7 @@ const fetchInstanceUsersChart = async (total: boolean): Promise<typeof chartData }; const fetchInstanceNotesChart = async (total: boolean): Promise<typeof chartData> => { - const raw = await os.apiGet('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); + const raw = await misskeyApiGet('charts/instance', { host: props.args?.host, limit: props.limit, span: props.span }); return { series: [{ name: 'Notes', @@ -618,7 +622,7 @@ const fetchInstanceNotesChart = async (total: boolean): Promise<typeof chartData }; const fetchInstanceFfChart = async (total: boolean): Promise<typeof chartData> => { - const raw = await os.apiGet('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); + const raw = await misskeyApiGet('charts/instance', { host: props.args?.host, limit: props.limit, span: props.span }); return { series: [{ name: 'Following', @@ -641,7 +645,7 @@ const fetchInstanceFfChart = async (total: boolean): Promise<typeof chartData> = }; const fetchInstanceDriveUsageChart = async (total: boolean): Promise<typeof chartData> => { - const raw = await os.apiGet('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); + const raw = await misskeyApiGet('charts/instance', { host: props.args?.host, limit: props.limit, span: props.span }); return { bytes: true, series: [{ @@ -649,7 +653,7 @@ const fetchInstanceDriveUsageChart = async (total: boolean): Promise<typeof char type: 'area', color: '#008FFB', data: format(total - ? raw.drive.totalUsage + ? sum(raw.drive.incUsage) : sum(raw.drive.incUsage, negate(raw.drive.decUsage)), ), }], @@ -657,7 +661,7 @@ const fetchInstanceDriveUsageChart = async (total: boolean): Promise<typeof char }; const fetchInstanceDriveFilesChart = async (total: boolean): Promise<typeof chartData> => { - const raw = await os.apiGet('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); + const raw = await misskeyApiGet('charts/instance', { host: props.args?.host, limit: props.limit, span: props.span }); return { series: [{ name: 'Drive files', @@ -672,11 +676,11 @@ const fetchInstanceDriveFilesChart = async (total: boolean): Promise<typeof char }; const fetchPerUserNotesChart = async (): Promise<typeof chartData> => { - const raw = await os.apiGet('charts/user/notes', { userId: props.args.user.id, limit: props.limit, span: props.span }); + const raw = await misskeyApiGet('charts/user/notes', { userId: props.args?.user?.id, limit: props.limit, span: props.span }); return { - series: [...(props.args.withoutAll ? [] : [{ + series: [...(props.args?.withoutAll ? [] : [{ name: 'All', - type: 'line', + type: 'line' as const, data: format(sum(raw.inc, negate(raw.dec))), color: '#888888', }]), { @@ -704,7 +708,7 @@ const fetchPerUserNotesChart = async (): Promise<typeof chartData> => { }; const fetchPerUserPvChart = async (): Promise<typeof chartData> => { - const raw = await os.apiGet('charts/user/pv', { userId: props.args.user.id, limit: props.limit, span: props.span }); + const raw = await misskeyApiGet('charts/user/pv', { userId: props.args?.user?.id, limit: props.limit, span: props.span }); return { series: [{ name: 'Unique PV (user)', @@ -731,7 +735,7 @@ const fetchPerUserPvChart = async (): Promise<typeof chartData> => { }; const fetchPerUserFollowingChart = async (): Promise<typeof chartData> => { - const raw = await os.apiGet('charts/user/following', { userId: props.args.user.id, limit: props.limit, span: props.span }); + const raw = await misskeyApiGet('charts/user/following', { userId: props.args?.user?.id, limit: props.limit, span: props.span }); return { series: [{ name: 'Local', @@ -746,7 +750,7 @@ const fetchPerUserFollowingChart = async (): Promise<typeof chartData> => { }; const fetchPerUserFollowersChart = async (): Promise<typeof chartData> => { - const raw = await os.apiGet('charts/user/following', { userId: props.args.user.id, limit: props.limit, span: props.span }); + const raw = await misskeyApiGet('charts/user/following', { userId: props.args?.user?.id, limit: props.limit, span: props.span }); return { series: [{ name: 'Local', @@ -761,8 +765,9 @@ const fetchPerUserFollowersChart = async (): Promise<typeof chartData> => { }; const fetchPerUserDriveChart = async (): Promise<typeof chartData> => { - const raw = await os.apiGet('charts/user/drive', { userId: props.args.user.id, limit: props.limit, span: props.span }); + const raw = await misskeyApiGet('charts/user/drive', { userId: props.args?.user?.id, limit: props.limit, span: props.span }); return { + bytes: true, series: [{ name: 'Inc', type: 'area', @@ -806,6 +811,8 @@ const fetchAndRender = async () => { case 'per-user-following': return fetchPerUserFollowingChart(); case 'per-user-followers': return fetchPerUserFollowersChart(); case 'per-user-drive': return fetchPerUserDriveChart(); + + default: return null; } }; fetching.value = true; diff --git a/packages/frontend/src/components/MkChartLegend.vue b/packages/frontend/src/components/MkChartLegend.vue index c265fe6e97..240c9c919e 100644 --- a/packages/frontend/src/components/MkChartLegend.vue +++ b/packages/frontend/src/components/MkChartLegend.vue @@ -1,12 +1,12 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> <template> <div :class="$style.root"> <button v-for="item in items" class="_button item" :class="{ disabled: item.hidden }" @click="onClick(item)"> - <span class="box" :style="{ background: chart.config.type === 'line' ? item.strokeStyle?.toString() : item.fillStyle?.toString() }"></span> + <span class="box" :style="{ background: type === 'line' ? item.strokeStyle?.toString() : item.fillStyle?.toString() }"></span> {{ item.text }} </button> </div> @@ -16,25 +16,23 @@ SPDX-License-Identifier: AGPL-3.0-only import { shallowRef } from 'vue'; import { Chart, LegendItem } from 'chart.js'; -const props = defineProps({ -}); - const chart = shallowRef<Chart>(); +const type = shallowRef<string>(); const items = shallowRef<LegendItem[]>([]); function update(_chart: Chart, _items: LegendItem[]) { chart.value = _chart, items.value = _items; + if ('type' in _chart.config) type.value = _chart.config.type; } function onClick(item: LegendItem) { if (chart.value == null) return; - const { type } = chart.value.config; - if (type === 'pie' || type === 'doughnut') { + if (type.value === 'pie' || type.value === 'doughnut') { // Pie and doughnut charts only have a single dataset and visibility is per item - chart.value.toggleDataVisibility(item.index); + if (item.index != null) chart.value.toggleDataVisibility(item.index); } else { - chart.value.setDatasetVisibility(item.datasetIndex, !chart.value.isDatasetVisible(item.datasetIndex)); + if (item.datasetIndex != null) chart.value.setDatasetVisibility(item.datasetIndex, !chart.value.isDatasetVisible(item.datasetIndex)); } chart.value.update(); } diff --git a/packages/frontend/src/components/MkChartTooltip.vue b/packages/frontend/src/components/MkChartTooltip.vue index c11f516e37..51081ede23 100644 --- a/packages/frontend/src/components/MkChartTooltip.vue +++ b/packages/frontend/src/components/MkChartTooltip.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/MkClickerGame.vue b/packages/frontend/src/components/MkClickerGame.vue index 1e72319010..892ad31b09 100644 --- a/packages/frontend/src/components/MkClickerGame.vue +++ b/packages/frontend/src/components/MkClickerGame.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/MkClipPreview.vue b/packages/frontend/src/components/MkClipPreview.vue index 2f6790fa49..c51ad4356d 100644 --- a/packages/frontend/src/components/MkClipPreview.vue +++ b/packages/frontend/src/components/MkClipPreview.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/MkCode.core.vue b/packages/frontend/src/components/MkCode.core.vue index 579c72b186..f9aaf4eff3 100644 --- a/packages/frontend/src/components/MkCode.core.vue +++ b/packages/frontend/src/components/MkCode.core.vue @@ -1,18 +1,19 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> <!-- eslint-disable vue/no-v-html --> <template> -<div :class="['codeBlockRoot', { 'codeEditor': codeEditor }]" v-html="html"></div> +<div :class="[$style.codeBlockRoot, { [$style.codeEditor]: codeEditor }, (darkMode ? $style.dark : $style.light)]" v-html="html"></div> </template> <script lang="ts" setup> import { ref, computed, watch } from 'vue'; -import { BUNDLED_LANGUAGES } from 'shiki'; -import type { Lang as ShikiLang } from 'shiki'; -import { getHighlighter } from '@/scripts/code-highlighter.js'; +import { bundledLanguagesInfo } from 'shiki'; +import type { BuiltinLanguage } from 'shiki'; +import { getHighlighter, getTheme } from '@/scripts/code-highlighter.js'; +import { defaultStore } from '@/store.js'; const props = defineProps<{ code: string; @@ -21,25 +22,38 @@ const props = defineProps<{ }>(); const highlighter = await getHighlighter(); +const darkMode = defaultStore.reactiveState.darkMode; +const codeLang = ref<BuiltinLanguage | 'aiscript'>('js'); + +const [lightThemeName, darkThemeName] = await Promise.all([ + getTheme('light', true), + getTheme('dark', true), +]); -const codeLang = ref<ShikiLang | 'aiscript'>('js'); const html = computed(() => highlighter.codeToHtml(props.code, { lang: codeLang.value, - theme: 'dark-plus', + themes: { + fallback: 'dark-plus', + light: lightThemeName, + dark: darkThemeName, + }, + defaultColor: false, + cssVariablePrefix: '--shiki-', })); async function fetchLanguage(to: string): Promise<void> { - const language = to as ShikiLang; + const language = to as BuiltinLanguage; // Check for the loaded languages, and load the language if it's not loaded yet. if (!highlighter.getLoadedLanguages().includes(language)) { // Check if the language is supported by Shiki - const bundles = BUNDLED_LANGUAGES.filter((bundle) => { + const bundles = bundledLanguagesInfo.filter((bundle) => { // Languages are specified by their id, they can also have aliases (i. e. "js" and "javascript") return bundle.id === language || bundle.aliases?.includes(language); }); if (bundles.length > 0) { - await highlighter.loadLanguage(language); + if (_DEV_) console.log(`Loading language: ${language}`); + await highlighter.loadLanguage(bundles[0].import); codeLang.value = language; } else { codeLang.value = 'js'; @@ -57,12 +71,37 @@ watch(() => props.lang, (to) => { }, { immediate: true }); </script> -<style scoped lang="scss"> -.codeBlockRoot :deep(.shiki) { +<style module lang="scss"> +.codeBlockRoot :global(.shiki) > code { + counter-reset: step; + counter-increment: step 0; +} + +.codeBlockRoot :global(.shiki) > code > .line::before { + content: counter(step); + counter-increment: step; + width: 1rem; + margin-right: 1.5rem; + display: inline-block; + text-align: right; + color: rgba(115,138,148,.4) +} + +.codeBlockRoot :global(.shiki) { padding: 1em; margin: .5em 0; overflow: auto; border-radius: var(--radius-sm); + border: 1px solid var(--divider); + font-family: Consolas, Monaco, Andale Mono, Ubuntu Mono, monospace; + + color: var(--shiki-fallback); + background-color: var(--shiki-fallback-bg); + + & span { + color: var(--shiki-fallback); + background-color: var(--shiki-fallback-bg); + } & pre, & code { @@ -70,14 +109,35 @@ watch(() => props.lang, (to) => { } } +.light.codeBlockRoot :global(.shiki) { + color: var(--shiki-light); + background-color: var(--shiki-light-bg); + + & span { + color: var(--shiki-light); + background-color: var(--shiki-light-bg); + } +} + +.dark.codeBlockRoot :global(.shiki) { + color: var(--shiki-dark); + background-color: var(--shiki-dark-bg); + + & span { + color: var(--shiki-dark); + background-color: var(--shiki-dark-bg); + } +} + .codeBlockRoot.codeEditor { min-width: 100%; height: 100%; - & :deep(.shiki) { + & :global(.shiki) { padding: 12px; margin: 0; border-radius: var(--radius-sm); + border: none; min-height: 130px; pointer-events: none; min-width: calc(100% - 24px); @@ -89,6 +149,11 @@ watch(() => props.lang, (to) => { text-rendering: inherit; text-transform: inherit; white-space: pre; + + & span { + display: inline-block; + min-height: 1em; + } } } </style> diff --git a/packages/frontend/src/components/MkCode.vue b/packages/frontend/src/components/MkCode.vue index e0973b676a..acd2ea6f97 100644 --- a/packages/frontend/src/components/MkCode.vue +++ b/packages/frontend/src/components/MkCode.vue @@ -1,58 +1,72 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> <template> -<Suspense> - <template #fallback> - <MkLoading v-if="!inline ?? true"/> - </template> - <code v-if="inline" :class="$style.codeInlineRoot">{{ code }}</code> - <XCode v-else-if="show && lang" :code="code" :lang="lang"/> - <pre v-else-if="show" :class="$style.codeBlockFallbackRoot"><code :class="$style.codeBlockFallbackCode">{{ code }}</code></pre> - <button v-else :class="$style.codePlaceholderRoot" @click="show = true"> - <div :class="$style.codePlaceholderContainer"> - <div><i class="ph-code ph-bold ph-lg"></i> {{ i18n.ts.code }}</div> - <div>{{ i18n.ts.clickToShow }}</div> - </div> +<div :class="$style.codeBlockRoot"> + <button :class="$style.codeBlockCopyButton" class="_button" @click="copy"> + <i class="ph-copy ph-bold ph-lg"></i> </button> -</Suspense> + <Suspense> + <template #fallback> + <MkLoading /> + </template> + <XCode v-if="show && lang" :code="code" :lang="lang"/> + <pre v-else-if="show" :class="$style.codeBlockFallbackRoot"><code :class="$style.codeBlockFallbackCode">{{ code }}</code></pre> + <button v-else :class="$style.codePlaceholderRoot" @click="show = true"> + <div :class="$style.codePlaceholderContainer"> + <div><i class="ph-code ph-bold ph-lg"></i> {{ i18n.ts.code }}</div> + <div>{{ i18n.ts.clickToShow }}</div> + </div> + </button> + </Suspense> +</div> </template> <script lang="ts" setup> import { defineAsyncComponent, ref } from 'vue'; +import * as os from '@/os.js'; import MkLoading from '@/components/global/MkLoading.vue'; import { defaultStore } from '@/store.js'; import { i18n } from '@/i18n.js'; +import copyToClipboard from '@/scripts/copy-to-clipboard.js'; -defineProps<{ +const props = defineProps<{ code: string; lang?: string; - inline?: boolean; }>(); const show = ref(!defaultStore.state.dataSaver.code); const XCode = defineAsyncComponent(() => import('@/components/MkCode.core.vue')); + +function copy() { + copyToClipboard(props.code); + os.success(); +} </script> <style module lang="scss"> -.codeInlineRoot { - display: inline-block; - font-family: Consolas, Monaco, Andale Mono, Ubuntu Mono, monospace; - overflow-wrap: anywhere; - color: #D4D4D4; - background: #1E1E1E; - padding: .1em; - border-radius: .3em; +.codeBlockRoot { + position: relative; +} + +.codeBlockCopyButton { + position: absolute; + top: 8px; + right: 8px; + opacity: 0.5; + + &:hover { + opacity: 0.8; + } } .codeBlockFallbackRoot { display: block; overflow-wrap: anywhere; - color: #D4D4D4; - background: #1E1E1E; + background: var(--bg); padding: 1em; margin: .5em 0; overflow: auto; @@ -77,8 +91,8 @@ const XCode = defineAsyncComponent(() => import('@/components/MkCode.core.vue')) border-radius: var(--radius-sm); padding: 24px; margin-top: 4px; - color: #D4D4D4; - background: #1E1E1E; + color: var(--fg); + background: var(--bg); } .codePlaceholderContainer { diff --git a/packages/frontend/src/components/MkCodeEditor.vue b/packages/frontend/src/components/MkCodeEditor.vue index 0ec69a69af..30e518f8f0 100644 --- a/packages/frontend/src/components/MkCodeEditor.vue +++ b/packages/frontend/src/components/MkCodeEditor.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="$style.codeEditorScroller"> <textarea ref="inputEl" - v-model="vModel" + v-model="v" :class="[$style.textarea]" :disabled="disabled" :required="required" @@ -58,7 +58,6 @@ const emit = defineEmits<{ }>(); const { modelValue } = toRefs(props); -const vModel = ref<string>(modelValue.value ?? ''); const v = ref<string>(modelValue.value ?? ''); const focused = ref(false); const changed = ref(false); @@ -79,15 +78,14 @@ const onKeydown = (ev: KeyboardEvent) => { if (ev.code === 'Enter') { const pos = inputEl.value?.selectionStart ?? 0; - const posEnd = inputEl.value?.selectionEnd ?? vModel.value.length; + const posEnd = inputEl.value?.selectionEnd ?? v.value.length; if (pos === posEnd) { - const lines = vModel.value.slice(0, pos).split('\n'); + const lines = v.value.slice(0, pos).split('\n'); const currentLine = lines[lines.length - 1]; const currentLineSpaces = currentLine.match(/^\s+/); const posDelta = currentLineSpaces ? currentLineSpaces[0].length : 0; ev.preventDefault(); - vModel.value = vModel.value.slice(0, pos) + '\n' + (currentLineSpaces ? currentLineSpaces[0] : '') + vModel.value.slice(pos); - v.value = vModel.value; + v.value = v.value.slice(0, pos) + '\n' + (currentLineSpaces ? currentLineSpaces[0] : '') + v.value.slice(pos); nextTick(() => { inputEl.value?.setSelectionRange(pos + 1 + posDelta, pos + 1 + posDelta); }); @@ -97,9 +95,8 @@ const onKeydown = (ev: KeyboardEvent) => { if (ev.key === 'Tab') { const pos = inputEl.value?.selectionStart ?? 0; - const posEnd = inputEl.value?.selectionEnd ?? vModel.value.length; - vModel.value = vModel.value.slice(0, pos) + '\t' + vModel.value.slice(posEnd); - v.value = vModel.value; + const posEnd = inputEl.value?.selectionEnd ?? v.value.length; + v.value = v.value.slice(0, pos) + '\t' + v.value.slice(posEnd); nextTick(() => { inputEl.value?.setSelectionRange(pos + 1, pos + 1); }); @@ -199,20 +196,23 @@ watch(v, newValue => { resize: none; text-align: left; color: transparent; - caret-color: rgb(225, 228, 232); + caret-color: var(--fg); background-color: transparent; border: 0; border-radius: var(--radius-sm); + box-sizing: border-box; outline: 0; min-width: calc(100% - 24px); height: 100%; padding: 12px; + // the +2.5 rem is because of the line numbers + padding-left: calc(12px + 2.5rem); line-height: 1.5em; font-size: 1em; font-family: Consolas, Monaco, Andale Mono, Ubuntu Mono, monospace; } .textarea::selection { - color: #fff; + color: var(--bg); } </style> diff --git a/packages/frontend/src/components/MkCodeInline.vue b/packages/frontend/src/components/MkCodeInline.vue new file mode 100644 index 0000000000..6add80d1bc --- /dev/null +++ b/packages/frontend/src/components/MkCodeInline.vue @@ -0,0 +1,25 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<code :class="$style.root">{{ code }}</code> +</template> + +<script lang="ts" setup> +const props = defineProps<{ + code: string; +}>(); +</script> + +<style module lang="scss"> +.root { + display: inline-block; + font-family: Consolas, Monaco, Andale Mono, Ubuntu Mono, monospace; + overflow-wrap: anywhere; + background: var(--bg); + padding: .1em; + border-radius: .3em; +} +</style> diff --git a/packages/frontend/src/components/MkColorInput.vue b/packages/frontend/src/components/MkColorInput.vue index 4f15e88951..99aa46d561 100644 --- a/packages/frontend/src/components/MkColorInput.vue +++ b/packages/frontend/src/components/MkColorInput.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -41,8 +41,8 @@ const { modelValue } = toRefs(props); const v = ref(modelValue.value); const inputEl = shallowRef<HTMLElement>(); -const onInput = (ev: KeyboardEvent) => { - emit('update:modelValue', v.value); +const onInput = () => { + emit('update:modelValue', v.value ?? ''); }; </script> diff --git a/packages/frontend/src/components/MkContainer.vue b/packages/frontend/src/components/MkContainer.vue index 42c6cc1075..95188c335e 100644 --- a/packages/frontend/src/components/MkContainer.vue +++ b/packages/frontend/src/components/MkContainer.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/MkContextMenu.vue b/packages/frontend/src/components/MkContextMenu.vue index e29cf472f7..5ca3c77fb2 100644 --- a/packages/frontend/src/components/MkContextMenu.vue +++ b/packages/frontend/src/components/MkContextMenu.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -44,8 +44,8 @@ onMounted(() => { let left = props.ev.pageX + 1; // 間違って右ダブルクリックした場合に意図せずアイテムがクリックされるのを防ぐため + 1 let top = props.ev.pageY + 1; // 間違って右ダブルクリックした場合に意図せずアイテムがクリックされるのを防ぐため + 1 - const width = rootEl.value.offsetWidth; - const height = rootEl.value.offsetHeight; + const width = rootEl.value!.offsetWidth; + const height = rootEl.value!.offsetHeight; if (left + width - window.pageXOffset >= (window.innerWidth - SCROLLBAR_THICKNESS)) { left = (window.innerWidth - SCROLLBAR_THICKNESS) - width + window.pageXOffset; @@ -63,8 +63,10 @@ onMounted(() => { left = 0; } - rootEl.value.style.top = `${top}px`; - rootEl.value.style.left = `${left}px`; + if (rootEl.value) { + rootEl.value.style.top = `${top}px`; + rootEl.value.style.left = `${left}px`; + } document.body.addEventListener('mousedown', onMousedown); }); diff --git a/packages/frontend/src/components/MkCropperDialog.vue b/packages/frontend/src/components/MkCropperDialog.vue index 0a1ddd3171..54f6f39c9d 100644 --- a/packages/frontend/src/components/MkCropperDialog.vue +++ b/packages/frontend/src/components/MkCropperDialog.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -63,18 +63,25 @@ const loading = ref(true); const ok = async () => { const promise = new Promise<Misskey.entities.DriveFile>(async (res) => { - const croppedCanvas = await cropper?.getCropperSelection()?.$toCanvas(); + const croppedImage = await cropper?.getCropperImage(); + const croppedSection = await cropper?.getCropperSelection(); + + // 拡大率を計算し、(ほぼ)元の大きさに戻す + const zoomedRate = croppedImage.getBoundingClientRect().width / croppedImage.clientWidth; + const widthToRender = croppedSection.getBoundingClientRect().width / zoomedRate; + + const croppedCanvas = await croppedSection?.$toCanvas({ width: widthToRender }); croppedCanvas?.toBlob(blob => { if (!blob) return; const formData = new FormData(); formData.append('file', blob); formData.append('name', `cropped_${props.file.name}`); formData.append('isSensitive', props.file.isSensitive ? 'true' : 'false'); - formData.append('comment', props.file.comment ?? 'null'); + if (props.file.comment) { formData.append('comment', props.file.comment);} formData.append('i', $i!.token); - if (props.uploadFolder || props.uploadFolder === null) { - formData.append('folderId', props.uploadFolder ?? 'null'); - } else if (defaultStore.state.uploadFolder) { + if (props.uploadFolder) { + formData.append('folderId', props.uploadFolder); + } else if (props.uploadFolder !== null && defaultStore.state.uploadFolder) { formData.append('folderId', defaultStore.state.uploadFolder); } diff --git a/packages/frontend/src/components/MkCustomEmojiDetailedDialog.vue b/packages/frontend/src/components/MkCustomEmojiDetailedDialog.vue new file mode 100644 index 0000000000..84b5375a41 --- /dev/null +++ b/packages/frontend/src/components/MkCustomEmojiDetailedDialog.vue @@ -0,0 +1,104 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> + <MkModalWindow ref="dialogEl" @close="cancel()" @closed="$emit('closed')"> + <template #header>:{{ emoji.name }}:</template> + <template #default> + <MkSpacer> + <div style="display: flex; flex-direction: column; gap: 1em;"> + <div :class="$style.emojiImgWrapper"> + <MkCustomEmoji :name="emoji.name" :normal="true" :useOriginalSize="true" style="height: 100%;"></MkCustomEmoji> + </div> + <MkKeyValue :copy="`:${emoji.name}:`"> + <template #key>{{ i18n.ts.name }}</template> + <template #value>{{ emoji.name }}</template> + </MkKeyValue> + <MkKeyValue> + <template #key>{{ i18n.ts.tags }}</template> + <template #value> + <div v-if="emoji.aliases.length === 0">{{ i18n.ts.none }}</div> + <div v-else :class="$style.aliases"> + <span v-for="alias in emoji.aliases" :key="alias" :class="$style.alias"> + {{ alias }} + </span> + </div> + </template> + </MkKeyValue> + <MkKeyValue> + <template #key>{{ i18n.ts.category }}</template> + <template #value>{{ emoji.category ?? i18n.ts.none }}</template> + </MkKeyValue> + <MkKeyValue> + <template #key>{{ i18n.ts.sensitive }}</template> + <template #value>{{ emoji.isSensitive ? i18n.ts.yes : i18n.ts.no }}</template> + </MkKeyValue> + <MkKeyValue> + <template #key>{{ i18n.ts.localOnly }}</template> + <template #value>{{ emoji.localOnly ? i18n.ts.yes : i18n.ts.no }}</template> + </MkKeyValue> + <MkKeyValue> + <template #key>{{ i18n.ts.license }}</template> + <template #value><Mfm :text="emoji.license ?? i18n.ts.none" /></template> + </MkKeyValue> + <MkKeyValue :copy="emoji.url"> + <template #key>{{ i18n.ts.emojiUrl }}</template> + <template #value> + <MkLink :url="emoji.url" target="_blank">{{ emoji.url }}</MkLink> + </template> + </MkKeyValue> + </div> + </MkSpacer> + </template> + </MkModalWindow> +</template> + +<script lang="ts" setup> +import * as Misskey from 'misskey-js'; +import { defineProps, shallowRef } from 'vue'; +import { i18n } from '@/i18n.js'; +import MkModalWindow from '@/components/MkModalWindow.vue'; +import MkKeyValue from '@/components/MkKeyValue.vue'; +import MkLink from './MkLink.vue'; +const props = defineProps<{ + emoji: Misskey.entities.EmojiDetailed, +}>(); +const emit = defineEmits<{ + (ev: 'ok', cropped: Misskey.entities.DriveFile): void; + (ev: 'cancel'): void; + (ev: 'closed'): void; +}>(); +const dialogEl = shallowRef<InstanceType<typeof MkModalWindow>>(); +const cancel = () => { + emit('cancel'); + dialogEl.value!.close(); +}; +</script> + +<style lang="scss" module> +.emojiImgWrapper { + max-width: 100%; + height: 40cqh; + background-image: repeating-linear-gradient(45deg, transparent, transparent 8px, var(--X5) 8px, var(--X5) 14px); + border-radius: var(--radius); + margin: auto; + overflow-y: hidden; +} + +.aliases { + display: flex; + flex-wrap: wrap; + gap: 3px; +} + +.alias { + display: inline-block; + word-break: break-all; + padding: 3px 10px; + background-color: var(--X5); + border: solid 1px var(--divider); + border-radius: var(--radius); +} +</style> diff --git a/packages/frontend/src/components/MkCwButton.vue b/packages/frontend/src/components/MkCwButton.vue index 4a6d2dfba2..a2cb3185f4 100644 --- a/packages/frontend/src/components/MkCwButton.vue +++ b/packages/frontend/src/components/MkCwButton.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -10,6 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed } from 'vue'; import * as Misskey from 'misskey-js'; +import type { PollEditorModelValue } from '@/components/MkPollEditor.vue'; import { concat } from '@/scripts/array.js'; import { i18n } from '@/i18n.js'; import MkButton from '@/components/MkButton.vue'; @@ -17,22 +18,9 @@ import MkButton from '@/components/MkButton.vue'; const props = defineProps<{ modelValue: boolean; text: string | null; - renote: Misskey.entities.Note | null; - files: Misskey.entities.DriveFile[]; - poll?: { - expiresAt: string | null; - multiple: boolean; - choices: { - isVoted: boolean; - text: string; - votes: number; - }[]; - } | { - choices: string[]; - multiple: boolean; - expiresAt: string | null; - expiredAfter: string | null; - }; + renote?: Misskey.entities.Note | null; + files?: Misskey.entities.DriveFile[]; + poll?: Misskey.entities.Note['poll'] | PollEditorModelValue | null; }>(); const emit = defineEmits<{ @@ -41,9 +29,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 && props.files.length !== 0 ? [i18n.tsx._cw.files({ count: props.files.length })] : [], props.poll != null ? [i18n.ts.poll] : [], ] as string[][]).join(' / '); }); diff --git a/packages/frontend/src/components/MkDateSeparatedList.vue b/packages/frontend/src/components/MkDateSeparatedList.vue index b45aef45ff..475fbcb397 100644 --- a/packages/frontend/src/components/MkDateSeparatedList.vue +++ b/packages/frontend/src/components/MkDateSeparatedList.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -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(), }); @@ -118,34 +118,36 @@ export default defineComponent({ return children; }; - function onBeforeLeave(el: HTMLElement) { + function onBeforeLeave(element: Element) { + const el = element as HTMLElement; el.style.top = `${el.offsetTop}px`; el.style.left = `${el.offsetLeft}px`; } - function onLeaveCanceled(el: HTMLElement) { + function onLeaveCancelled(element: Element) { + const el = element as HTMLElement; el.style.top = ''; el.style.left = ''; } - return () => h( - defaultStore.state.animation ? TransitionGroup : 'div', - { - class: { - [$style['date-separated-list']]: true, - [$style['date-separated-list-nogap']]: props.noGap, - [$style['reversed']]: props.reversed, - [$style['direction-down']]: props.direction === 'down', - [$style['direction-up']]: props.direction === 'up', - }, - ...(defaultStore.state.animation ? { - name: 'list', - tag: 'div', - onBeforeLeave, - onLeaveCanceled, - } : {}), - }, - { default: renderChildren }); + // eslint-disable-next-line vue/no-setup-props-destructure + const classes = { + [$style['date-separated-list']]: true, + [$style['date-separated-list-nogap']]: props.noGap, + [$style['reversed']]: props.reversed, + [$style['direction-down']]: props.direction === 'down', + [$style['direction-up']]: props.direction === 'up', + }; + + return () => defaultStore.state.animation ? h(TransitionGroup, { + class: classes, + name: 'list', + tag: 'div', + onBeforeLeave, + onLeaveCancelled, + }, { default: renderChildren }) : h('div', { + class: classes, + }, { default: renderChildren }); }, }); </script> diff --git a/packages/frontend/src/components/MkDialog.vue b/packages/frontend/src/components/MkDialog.vue index 2c0f6a4d78..b81ebbbb11 100644 --- a/packages/frontend/src/components/MkDialog.vue +++ b/packages/frontend/src/components/MkDialog.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -30,19 +30,14 @@ SPDX-License-Identifier: AGPL-3.0-only <MkInput v-if="input" v-model="inputValue" autofocus :type="input.type || 'text'" :placeholder="input.placeholder || undefined" :autocomplete="input.autocomplete" @keydown="onInputKeydown"> <template v-if="input.type === 'password'" #prefix><i class="ph-lock ph-bold ph-lg"></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 ?? 0, max: input.maxLength ?? 'NaN' })"/> + <span v-else-if="okButtonDisabledReason === 'charactersBelow'" v-text="i18n.tsx._dialog.charactersBelow({ current: (inputValue as string)?.length ?? 0, min: input.minLength ?? 'NaN' })"/> </template> </MkInput> <MkSelect v-if="select" v-model="selectedValue" autofocus> <template v-if="select.items"> <option v-for="item in select.items" :value="item.value">{{ item.text }}</option> </template> - <template v-else> - <optgroup v-for="groupedItem in select.groupedItems" :label="groupedItem.label"> - <option v-for="item in groupedItem.items" :value="item.value">{{ item.text }}</option> - </optgroup> - </template> </MkSelect> <div v-if="(showOkButton || showCancelButton) && !actions" :class="$style.buttons"> <MkButton v-if="showOkButton" data-cy-modal-dialog-ok inline primary rounded :autofocus="!input && !select" :disabled="okButtonDisabledReason" @click="ok">{{ okText ?? ((showCancelButton || input || select) ? i18n.ts.ok : i18n.ts.gotIt) }}</MkButton> @@ -64,7 +59,7 @@ import MkSelect from '@/components/MkSelect.vue'; import { i18n } from '@/i18n.js'; type Input = { - type: 'text' | 'number' | 'password' | 'email' | 'url' | 'date' | 'time' | 'search' | 'datetime-local'; + type?: 'text' | 'number' | 'password' | 'email' | 'url' | 'date' | 'time' | 'search' | 'datetime-local'; placeholder?: string | null; autocomplete?: string; default: string | number | null; @@ -74,22 +69,17 @@ type Input = { type Select = { items: { - value: string; + value: any; text: string; }[]; - groupedItems: { - label: string; - items: { - value: string; - text: string; - }[]; - }[]; default: string | null; }; +type Result = string | number | true | null; + const props = withDefaults(defineProps<{ type?: 'success' | 'error' | 'warning' | 'info' | 'question' | 'waiting'; - title: string; + title?: string; text?: string; input?: Input; select?: Select; @@ -113,7 +103,7 @@ const props = withDefaults(defineProps<{ }); const emit = defineEmits<{ - (ev: 'done', v: { canceled: boolean; result: any }): void; + (ev: 'done', v: { canceled: true } | { canceled: false, result: Result }): void; (ev: 'closed'): void; }>(); @@ -125,7 +115,7 @@ const selectedValue = ref(props.select?.default ?? null); const okButtonDisabledReason = computed<null | 'charactersExceeded' | 'charactersBelow'>(() => { if (props.input) { if (props.input.minLength) { - if ((inputValue.value || inputValue.value === '') && (inputValue.value as string).length < props.input.minLength) { + if (inputValue.value == null || (inputValue.value as string).length < props.input.minLength) { return 'charactersBelow'; } } @@ -139,8 +129,11 @@ const okButtonDisabledReason = computed<null | 'charactersExceeded' | 'character return null; }); -function done(canceled: boolean, result?) { - emit('done', { canceled, result }); +// overload function を使いたいので lint エラーを無視する +function done(canceled: true): void; +function done(canceled: false, result: Result): void; // eslint-disable-line no-redeclare +function done(canceled: boolean, result?: Result): void { // eslint-disable-line no-redeclare + emit('done', { canceled, result } as { canceled: true } | { canceled: false, result: Result }); modal.value?.close(); } diff --git a/packages/frontend/src/components/MkDigitalClock.stories.impl.ts b/packages/frontend/src/components/MkDigitalClock.stories.impl.ts index 5d16c09bc5..e3391bcf7e 100644 --- a/packages/frontend/src/components/MkDigitalClock.stories.impl.ts +++ b/packages/frontend/src/components/MkDigitalClock.stories.impl.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/components/MkDigitalClock.vue b/packages/frontend/src/components/MkDigitalClock.vue index dff6e7d4dd..2e2321e6ac 100644 --- a/packages/frontend/src/components/MkDigitalClock.vue +++ b/packages/frontend/src/components/MkDigitalClock.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/MkDonation.vue b/packages/frontend/src/components/MkDonation.vue index a77ff42f94..a2780ddfe9 100644 --- a/packages/frontend/src/components/MkDonation.vue +++ b/packages/frontend/src/components/MkDonation.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -26,6 +26,16 @@ SPDX-License-Identifier: AGPL-3.0-only <MkLink target="_blank" url="https://ko-fi.com/transfem">{{ i18n.ts.learnMore }}</MkLink> </div> </div> + <div v-if="instance.donationUrl" :class="$style.text"> + <I18n :src="i18n.ts.pleaseDonateInstance" tag="span"> + <template #host> + {{ instance.name ?? host }} + </template> + </I18n> + <div style="margin-top: 0.2em;"> + <MkLink target="_blank" :url="instance.donationUrl">{{ i18n.ts.learnMore }}</MkLink> + </div> + </div> <div class="_buttons"> <MkButton @click="close">{{ i18n.ts.remindMeLater }}</MkButton> <MkButton @click="neverShow">{{ i18n.ts.neverShow }}</MkButton> diff --git a/packages/frontend/src/components/MkDrive.file.vue b/packages/frontend/src/components/MkDrive.file.vue index 9969c10258..13a2a2126c 100644 --- a/packages/frontend/src/components/MkDrive.file.vue +++ b/packages/frontend/src/components/MkDrive.file.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -49,9 +49,9 @@ import bytes from '@/filters/bytes.js'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; import { $i } from '@/account.js'; -import { useRouter } from '@/router.js'; import { getDriveFileMenu } from '@/scripts/get-drive-file-menu.js'; import { deviceKind } from '@/scripts/device-kind.js'; +import { useRouter } from '@/router/supplier.js'; const router = useRouter(); diff --git a/packages/frontend/src/components/MkDrive.folder.vue b/packages/frontend/src/components/MkDrive.folder.vue index dcaaa72cf4..945f45c012 100644 --- a/packages/frontend/src/components/MkDrive.folder.vue +++ b/packages/frontend/src/components/MkDrive.folder.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -35,6 +35,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { computed, defineAsyncComponent, ref } from 'vue'; import * as Misskey from 'misskey-js'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; import { defaultStore } from '@/store.js'; import { claimAchievement } from '@/scripts/achievements.js'; @@ -144,7 +145,7 @@ function onDrop(ev: DragEvent) { if (driveFile != null && driveFile !== '') { const file = JSON.parse(driveFile); emit('removeFile', file.id); - os.api('drive/files/update', { + misskeyApi('drive/files/update', { fileId: file.id, folderId: props.folder.id, }); @@ -160,7 +161,7 @@ function onDrop(ev: DragEvent) { if (folder.id === props.folder.id) return; emit('removeFolder', folder.id); - os.api('drive/folders/update', { + misskeyApi('drive/folders/update', { folderId: folder.id, parentId: props.folder.id, }).then(() => { @@ -204,7 +205,7 @@ function onDragend() { } function go() { - emit('move', props.folder.id); + emit('move', props.folder); } function rename() { @@ -214,7 +215,7 @@ function rename() { default: props.folder.name, }).then(({ canceled, result: name }) => { if (canceled) return; - os.api('drive/folders/update', { + misskeyApi('drive/folders/update', { folderId: props.folder.id, name: name, }); @@ -222,7 +223,7 @@ function rename() { } function deleteFolder() { - os.api('drive/folders/delete', { + misskeyApi('drive/folders/delete', { folderId: props.folder.id, }).then(() => { if (defaultStore.state.uploadFolder === props.folder.id) { diff --git a/packages/frontend/src/components/MkDrive.navFolder.vue b/packages/frontend/src/components/MkDrive.navFolder.vue index cac3c17c85..d78c215328 100644 --- a/packages/frontend/src/components/MkDrive.navFolder.vue +++ b/packages/frontend/src/components/MkDrive.navFolder.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -20,7 +20,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref } from 'vue'; import * as Misskey from 'misskey-js'; -import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; const props = defineProps<{ @@ -112,7 +112,7 @@ function onDrop(ev: DragEvent) { if (driveFile != null && driveFile !== '') { const file = JSON.parse(driveFile); emit('removeFile', file.id); - os.api('drive/files/update', { + misskeyApi('drive/files/update', { fileId: file.id, folderId: props.folder ? props.folder.id : null, }); @@ -126,7 +126,7 @@ function onDrop(ev: DragEvent) { // 移動先が自分自身ならreject if (props.folder && folder.id === props.folder.id) return; emit('removeFolder', folder.id); - os.api('drive/folders/update', { + misskeyApi('drive/folders/update', { folderId: folder.id, parentId: props.folder ? props.folder.id : null, }); diff --git a/packages/frontend/src/components/MkDrive.vue b/packages/frontend/src/components/MkDrive.vue index 00bb0e6e2b..2990ea6861 100644 --- a/packages/frontend/src/components/MkDrive.vue +++ b/packages/frontend/src/components/MkDrive.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -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> @@ -98,10 +98,12 @@ SPDX-License-Identifier: AGPL-3.0-only import { nextTick, onActivated, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'; import * as Misskey from 'misskey-js'; import MkButton from './MkButton.vue'; +import type { MenuItem } from '@/types/menu.js'; import XNavFolder from '@/components/MkDrive.navFolder.vue'; import XFolder from '@/components/MkDrive.folder.vue'; import XFile from '@/components/MkDrive.file.vue'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { useStream } from '@/stream.js'; import { defaultStore } from '@/store.js'; import { i18n } from '@/i18n.js'; @@ -254,7 +256,7 @@ function onDrop(ev: DragEvent): any { const file = JSON.parse(driveFile); if (files.value.some(f => f.id === file.id)) return; removeFile(file.id); - os.api('drive/files/update', { + misskeyApi('drive/files/update', { fileId: file.id, folderId: folder.value ? folder.value.id : null, }); @@ -270,7 +272,7 @@ function onDrop(ev: DragEvent): any { if (folder.value && droppedFolder.id === folder.value.id) return false; if (folders.value.some(f => f.id === droppedFolder.id)) return false; removeFolder(droppedFolder.id); - os.api('drive/folders/update', { + misskeyApi('drive/folders/update', { folderId: droppedFolder.id, parentId: folder.value ? folder.value.id : null, }).then(() => { @@ -307,7 +309,7 @@ function urlUpload() { placeholder: i18n.ts.uploadFromUrlDescription, }).then(({ canceled, result: url }) => { if (canceled || !url) return; - os.api('drive/files/upload-from-url', { + misskeyApi('drive/files/upload-from-url', { url: url, folderId: folder.value ? folder.value.id : undefined, }); @@ -325,7 +327,7 @@ function createFolder() { placeholder: i18n.ts.folderName, }).then(({ canceled, result: name }) => { if (canceled) return; - os.api('drive/folders/create', { + misskeyApi('drive/folders/create', { name: name, parentId: folder.value ? folder.value.id : undefined, }).then(createdFolder => { @@ -341,7 +343,7 @@ function renameFolder(folderToRename: Misskey.entities.DriveFolder) { default: folderToRename.name, }).then(({ canceled, result: name }) => { if (canceled) return; - os.api('drive/folders/update', { + misskeyApi('drive/folders/update', { folderId: folderToRename.id, name: name, }).then(updatedFolder => { @@ -352,7 +354,7 @@ function renameFolder(folderToRename: Misskey.entities.DriveFolder) { } function deleteFolder(folderToDelete: Misskey.entities.DriveFolder) { - os.api('drive/folders/delete', { + misskeyApi('drive/folders/delete', { folderId: folderToDelete.id, }).then(() => { // 削除時に親フォルダに移動 @@ -426,7 +428,7 @@ function chooseFolder(folderToChoose: Misskey.entities.DriveFolder) { } } -function move(target?: Misskey.entities.DriveFolder) { +function move(target?: Misskey.entities.DriveFolder | Misskey.entities.DriveFolder['id' | 'parentId']) { if (!target) { goRoot(); return; @@ -436,7 +438,7 @@ function move(target?: Misskey.entities.DriveFolder) { fetching.value = true; - os.api('drive/folders/show', { + misskeyApi('drive/folders/show', { folderId: target, }).then(folderToMove => { folder.value = folderToMove; @@ -535,7 +537,7 @@ async function fetch() { const foldersMax = 30; const filesMax = 30; - const foldersPromise = os.api('drive/folders', { + const foldersPromise = misskeyApi('drive/folders', { folderId: folder.value ? folder.value.id : null, limit: foldersMax + 1, }).then(fetchedFolders => { @@ -546,7 +548,7 @@ async function fetch() { return fetchedFolders; }); - const filesPromise = os.api('drive/files', { + const filesPromise = misskeyApi('drive/files', { folderId: folder.value ? folder.value.id : null, type: props.type, limit: filesMax + 1, @@ -571,7 +573,7 @@ function fetchMoreFolders() { const max = 30; - os.api('drive/folders', { + misskeyApi('drive/folders', { folderId: folder.value ? folder.value.id : null, type: props.type, untilId: folders.value.at(-1)?.id, @@ -594,7 +596,7 @@ function fetchMoreFiles() { const max = 30; // ファイル一覧取得 - os.api('drive/files', { + misskeyApi('drive/files', { folderId: folder.value ? folder.value.id : null, type: props.type, untilId: files.value.at(-1)?.id, @@ -612,7 +614,7 @@ function fetchMoreFiles() { } function getMenu() { - return [{ + const menu: MenuItem[] = [{ type: 'switch', text: i18n.ts.keepOriginalUploading, ref: keepOriginal, @@ -633,7 +635,7 @@ function getMenu() { }, folder.value ? { text: i18n.ts.renameFolder, icon: 'ph-textbox ph-bold ph-lg', - action: () => { renameFolder(folder.value); }, + action: () => { if (folder.value) renameFolder(folder.value); }, } : undefined, folder.value ? { text: i18n.ts.deleteFolder, icon: 'ph-trash ph-bold ph-lg', @@ -643,6 +645,8 @@ function getMenu() { icon: 'ph-folder ph-bold ph-lg-plus', action: () => { createFolder(); }, }]; + + return menu; } function showMenu(ev: MouseEvent) { diff --git a/packages/frontend/src/components/MkDriveFileThumbnail.vue b/packages/frontend/src/components/MkDriveFileThumbnail.vue index 3063523791..2f1fef4ea6 100644 --- a/packages/frontend/src/components/MkDriveFileThumbnail.vue +++ b/packages/frontend/src/components/MkDriveFileThumbnail.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/MkDriveSelectDialog.vue b/packages/frontend/src/components/MkDriveSelectDialog.vue index e65f4dd403..f1ecc27123 100644 --- a/packages/frontend/src/components/MkDriveSelectDialog.vue +++ b/packages/frontend/src/components/MkDriveSelectDialog.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -39,13 +39,13 @@ withDefaults(defineProps<{ }); const emit = defineEmits<{ - (ev: 'done', r?: Misskey.entities.DriveFile[]): void; + (ev: 'done', r?: Misskey.entities.DriveFile[] | Misskey.entities.DriveFolder[]): void; (ev: 'closed'): void; }>(); const dialog = shallowRef<InstanceType<typeof MkModalWindow>>(); -const selected = ref<Misskey.entities.DriveFile[]>([]); +const selected = ref<Misskey.entities.DriveFile[] | Misskey.entities.DriveFolder[]>([]); function ok() { emit('done', selected.value); @@ -57,7 +57,7 @@ function cancel() { dialog.value?.close(); } -function onChangeSelection(files: Misskey.entities.DriveFile[]) { - selected.value = files; +function onChangeSelection(v: Misskey.entities.DriveFile[] | Misskey.entities.DriveFolder[]) { + selected.value = v; } </script> diff --git a/packages/frontend/src/components/MkDriveWindow.vue b/packages/frontend/src/components/MkDriveWindow.vue index 72aa79b153..c0142ec76e 100644 --- a/packages/frontend/src/components/MkDriveWindow.vue +++ b/packages/frontend/src/components/MkDriveWindow.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/MkEmojiPicker.section.vue b/packages/frontend/src/components/MkEmojiPicker.section.vue index dabc12237a..a5839586b6 100644 --- a/packages/frontend/src/components/MkEmojiPicker.section.vue +++ b/packages/frontend/src/components/MkEmojiPicker.section.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -16,10 +16,11 @@ SPDX-License-Identifier: AGPL-3.0-only :key="emoji" :data-emoji="emoji" class="_button item" + :disabled="disabledEmojis?.value.includes(emoji)" @pointerenter="computeButtonTitle" @click="emit('chosen', emoji, $event)" > - <MkCustomEmoji v-if="emoji[0] === ':'" class="emoji" :name="emoji" :normal="true"/> + <MkCustomEmoji v-if="emoji[0] === ':'" class="emoji" :name="emoji" :normal="true" :fallbackToImage="true"/> <MkEmoji v-else class="emoji" :emoji="emoji" :normal="true"/> </button> </div> @@ -27,7 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only <!-- フォルダの中にはカスタム絵文字やフォルダがある --> <section v-else v-panel style="border-radius: var(--radius-sm); border-bottom: 0.5px solid var(--divider);"> <header class="_acrylic" @click="shown = !shown"> - <i class="toggle ti-fw" :class="shown ? 'ph-caret-down ph-bold ph-lg' : 'ph-caret-up ph-bold ph-lg'"></i> <slot></slot> (<i class="ph-folder ph-bold ph-lg"></i>:{{ customEmojiTree.length }} <i class="ph-smiley-sticker ph-bold ph-lg ti-fw"></i>:{{ emojis.length }}) + <i class="toggle ti-fw" :class="shown ? 'ph-caret-down ph-bold ph-lg' : 'ph-caret-up ph-bold ph-lg'"></i> <slot></slot> (<i class="ph-folder ph-bold ph-lg"></i>:{{ customEmojiTree?.length }} <i class="ph-smiley-sticker ph-bold ph-lg ti-fw"></i>:{{ emojis.length }}) </header> <div v-if="shown" style="padding-left: 9px;"> <MkEmojiPickerSection @@ -48,6 +49,7 @@ SPDX-License-Identifier: AGPL-3.0-only :key="emoji" :data-emoji="emoji" class="_button item" + :disabled="disabledEmojis?.value.includes(emoji)" @pointerenter="computeButtonTitle" @click="emit('chosen', emoji, $event)" > @@ -60,13 +62,14 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref, computed, Ref } from 'vue'; -import { i18n } from '../i18n.js'; import { CustomEmojiFolderTree, getEmojiName } from '@/scripts/emojilist.js'; +import { i18n } from '@/i18n.js'; import { customEmojis } from '@/custom-emojis.js'; import MkEmojiPickerSection from '@/components/MkEmojiPicker.section.vue'; const props = defineProps<{ emojis: string[] | Ref<string[]>; + disabledEmojis?: Ref<string[]>; initialShown?: boolean; hasChildSection?: boolean; customEmojiTree?: CustomEmojiFolderTree[]; @@ -84,10 +87,10 @@ const shown = ref(!!props.initialShown); function computeButtonTitle(ev: MouseEvent): void { const elm = ev.target as HTMLElement; const emoji = elm.dataset.emoji as string; - elm.title = getEmojiName(emoji) ?? emoji; + elm.title = getEmojiName(emoji); } -function nestedChosen(emoji: any, ev?: MouseEvent) { +function nestedChosen(emoji: any, ev: MouseEvent) { emit('chosen', emoji, ev); } </script> diff --git a/packages/frontend/src/components/MkEmojiPicker.vue b/packages/frontend/src/components/MkEmojiPicker.vue index b7e329d7c2..1219a29d85 100644 --- a/packages/frontend/src/components/MkEmojiPicker.vue +++ b/packages/frontend/src/components/MkEmojiPicker.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -14,11 +14,12 @@ SPDX-License-Identifier: AGPL-3.0-only v-for="emoji in searchResultCustom" :key="emoji.name" class="_button item" + :disabled="!canReact(emoji)" :title="emoji.name" tabindex="0" @click="chosen(emoji, $event)" > - <MkCustomEmoji class="emoji" :name="emoji.name"/> + <MkCustomEmoji class="emoji" :name="emoji.name" :fallbackToImage="true"/> </button> </div> <div v-if="searchResultUnicode.length > 0" class="body"> @@ -36,19 +37,20 @@ SPDX-License-Identifier: AGPL-3.0-only </section> <div v-if="tab === 'index'" class="group index"> - <section v-if="showPinned && pinned.length > 0"> + <section v-if="showPinned && (pinned && pinned.length > 0)"> <div class="body"> <button - v-for="emoji in pinned" - :key="emoji" - :data-emoji="emoji" + v-for="emoji in pinnedEmojisDef" + :key="getKey(emoji)" + :data-emoji="getKey(emoji)" class="_button item" + :disabled="!canReact(emoji)" tabindex="0" @pointerenter="computeButtonTitle" @click="chosen(emoji, $event)" > - <MkCustomEmoji v-if="emoji[0] === ':'" class="emoji" :name="emoji" :normal="true"/> - <MkEmoji v-else class="emoji" :emoji="emoji" :normal="true"/> + <MkCustomEmoji v-if="!emoji.hasOwnProperty('char')" class="emoji" :name="getKey(emoji)" :normal="true"/> + <MkEmoji v-else class="emoji" :emoji="getKey(emoji)" :normal="true"/> </button> </div> </section> @@ -57,15 +59,16 @@ SPDX-License-Identifier: AGPL-3.0-only <header class="_acrylic"><i class="ph-clock ph-bold ph-lg ti-fw"></i> {{ i18n.ts.recentUsed }}</header> <div class="body"> <button - v-for="emoji in recentlyUsedEmojis" - :key="emoji" + v-for="emoji in recentlyUsedEmojisDef" + :key="getKey(emoji)" class="_button item" - :data-emoji="emoji" + :disabled="!canReact(emoji)" + :data-emoji="getKey(emoji)" @pointerenter="computeButtonTitle" @click="chosen(emoji, $event)" > - <MkCustomEmoji v-if="emoji[0] === ':'" class="emoji" :name="emoji" :normal="true"/> - <MkEmoji v-else class="emoji" :emoji="emoji" :normal="true"/> + <MkCustomEmoji v-if="!emoji.hasOwnProperty('char')" class="emoji" :name="getKey(emoji)" :normal="true"/> + <MkEmoji v-else class="emoji" :emoji="getKey(emoji)" :normal="true"/> </button> </div> </section> @@ -76,7 +79,8 @@ SPDX-License-Identifier: AGPL-3.0-only v-for="child in customEmojiFolderRoot.children" :key="`custom:${child.value}`" :initialShown="false" - :emojis="computed(() => customEmojis.filter(e => child.value === '' ? (e.category === 'null' || !e.category) : e.category === child.value).filter(filterAvailable).map(e => `:${e.name}:`))" + :emojis="computed(() => customEmojis.filter(e => filterCategory(e, child.value)).map(e => `:${e.name}:`))" + :disabledEmojis="computed(() => customEmojis.filter(e => filterCategory(e, child.value)).filter(e => !canReact(e)).map(e => `:${e.name}:`))" :hasChildSection="child.children.length !== 0" :customEmojiTree="child.children" @chosen="chosen" @@ -109,6 +113,7 @@ import { unicodeEmojiCategories as categories, getEmojiName, CustomEmojiFolderTree, + getUnicodeEmoji, } from '@/scripts/emojilist.js'; import MkRippleEffect from '@/components/MkRippleEffect.vue'; import * as os from '@/os.js'; @@ -118,6 +123,7 @@ import { i18n } from '@/i18n.js'; import { defaultStore } from '@/store.js'; import { customEmojiCategories, customEmojis, customEmojisMap } from '@/custom-emojis.js'; import { $i } from '@/account.js'; +import { checkReactionPermissions } from '@/scripts/check-reaction-permissions.js'; const props = withDefaults(defineProps<{ showPinned?: boolean; @@ -126,6 +132,7 @@ const props = withDefaults(defineProps<{ asDrawer?: boolean; asWindow?: boolean; asReactionPicker?: boolean; // 今は使われてないが将来的に使いそう + targetNote?: Misskey.entities.Note; }>(), { showPinned: true, }); @@ -144,6 +151,13 @@ const { recentlyUsedEmojis, } = defaultStore.reactiveState; +const recentlyUsedEmojisDef = computed(() => { + return recentlyUsedEmojis.value.map(getDef).filter(x => x != null); +}); +const pinnedEmojisDef = computed(() => { + return pinned.value?.map(getDef).filter(x => x != null); +}); + const pinned = computed(() => props.pinnedEmojis); const size = computed(() => emojiPickerScale.value); const width = computed(() => emojiPickerWidth.value); @@ -221,6 +235,19 @@ watch(q, () => { } } } else { + if (customEmojisMap.has(newQ)) { + matches.add(customEmojisMap.get(newQ)!); + } + if (matches.size >= max) return matches; + + for (const emoji of emojis) { + if (emoji.aliases.some(alias => alias === newQ)) { + matches.add(emoji); + if (matches.size >= max) break; + } + } + if (matches.size >= max) return matches; + for (const emoji of emojis) { if (emoji.name.startsWith(newQ)) { matches.add(emoji); @@ -322,12 +349,16 @@ watch(q, () => { return matches; }; - searchResultCustom.value = Array.from(searchCustom()).filter(filterAvailable); + searchResultCustom.value = Array.from(searchCustom()); searchResultUnicode.value = Array.from(searchUnicode()); }); -function filterAvailable(emoji: Misskey.entities.EmojiSimple): boolean { - return (emoji.roleIdsThatCanBeUsedThisEmojiAsReaction == null || emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.length === 0) || ($i && $i.roles.some(r => emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.includes(r.id))); +function canReact(emoji: Misskey.entities.EmojiSimple | UnicodeEmojiDef | string): boolean { + return !props.targetNote || checkReactionPermissions($i!, props.targetNote, emoji); +} + +function filterCategory(emoji: Misskey.entities.EmojiSimple, category: string): boolean { + return category === '' ? (emoji.category === 'null' || !emoji.category) : emoji.category === category; } function focus() { @@ -347,11 +378,22 @@ function getKey(emoji: string | Misskey.entities.EmojiSimple | UnicodeEmojiDef): return typeof emoji === 'string' ? emoji : 'char' in emoji ? emoji.char : `:${emoji.name}:`; } +function getDef(emoji: string): string | Misskey.entities.EmojiSimple | UnicodeEmojiDef { + if (emoji.includes(':')) { + // カスタム絵文字が存在する場合はその情報を持つオブジェクトを返し、 + // サーバの管理画面から削除された等で情報が見つからない場合は名前の文字列をそのまま返しておく(undefinedを返すとエラーになるため) + const name = emoji.replaceAll(':', ''); + return customEmojisMap.get(name) ?? emoji; + } else { + return getUnicodeEmoji(emoji); + } +} + /** @see MkEmojiPicker.section.vue */ function computeButtonTitle(ev: MouseEvent): void { const elm = ev.target as HTMLElement; const emoji = elm.dataset.emoji as string; - elm.title = getEmojiName(emoji) ?? emoji; + elm.title = getEmojiName(emoji); } function chosen(emoji: any, ev?: MouseEvent) { @@ -511,6 +553,18 @@ defineExpose({ width: auto; height: auto; min-width: 0; + + &:disabled { + cursor: not-allowed; + background: linear-gradient(-45deg, transparent 0% 48%, var(--X6) 48% 52%, transparent 52% 100%); + opacity: 1; + + > .emoji { + filter: grayscale(1); + mix-blend-mode: exclusion; + opacity: 0.8; + } + } } } } @@ -533,6 +587,18 @@ defineExpose({ width: auto; height: auto; min-width: 0; + + &:disabled { + cursor: not-allowed; + background: linear-gradient(-45deg, transparent 0% 48%, var(--X6) 48% 52%, transparent 52% 100%); + opacity: 1; + + > .emoji { + filter: grayscale(1); + mix-blend-mode: exclusion; + opacity: 0.8; + } + } } } } @@ -648,6 +714,18 @@ defineExpose({ box-shadow: inset 0 0.15em 0.3em rgba(27, 31, 35, 0.15); } + &:disabled { + cursor: not-allowed; + background: linear-gradient(-45deg, transparent 0% 48%, var(--X6) 48% 52%, transparent 52% 100%); + opacity: 1; + + > .emoji { + filter: grayscale(1); + mix-blend-mode: exclusion; + opacity: 0.8; + } + } + > .emoji { height: 1.25em; vertical-align: -.25em; diff --git a/packages/frontend/src/components/MkEmojiPickerDialog.vue b/packages/frontend/src/components/MkEmojiPickerDialog.vue index 4068a79f08..c6b3896989 100644 --- a/packages/frontend/src/components/MkEmojiPickerDialog.vue +++ b/packages/frontend/src/components/MkEmojiPickerDialog.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -24,6 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only :showPinned="showPinned" :pinnedEmojis="pinnedEmojis" :asReactionPicker="asReactionPicker" + :targetNote="targetNote" :asDrawer="type === 'drawer'" :max-height="maxHeight" @chosen="chosen" @@ -32,6 +33,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> +import * as Misskey from 'misskey-js'; import { shallowRef } from 'vue'; import MkModal from '@/components/MkModal.vue'; import MkEmojiPicker from '@/components/MkEmojiPicker.vue'; @@ -43,6 +45,7 @@ const props = withDefaults(defineProps<{ showPinned?: boolean; pinnedEmojis?: string[], asReactionPicker?: boolean; + targetNote?: Misskey.entities.Note; choseAndClose?: boolean; }>(), { manualShowing: null, @@ -53,7 +56,7 @@ const props = withDefaults(defineProps<{ }); const emit = defineEmits<{ - (ev: 'done', v: any): void; + (ev: 'done', v: string): void; (ev: 'close'): void; (ev: 'closed'): void; }>(); @@ -61,7 +64,7 @@ const emit = defineEmits<{ const modal = shallowRef<InstanceType<typeof MkModal>>(); const picker = shallowRef<InstanceType<typeof MkEmojiPicker>>(); -function chosen(emoji: any) { +function chosen(emoji: string) { emit('done', emoji); if (props.choseAndClose) { modal.value?.close(); diff --git a/packages/frontend/src/components/MkEmojiPickerWindow.vue b/packages/frontend/src/components/MkEmojiPickerWindow.vue deleted file mode 100644 index 1a2c55e785..0000000000 --- a/packages/frontend/src/components/MkEmojiPickerWindow.vue +++ /dev/null @@ -1,47 +0,0 @@ -<!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors -SPDX-License-Identifier: AGPL-3.0-only ---> - -<template> -<MkWindow - ref="window" - :initialWidth="300" - :initialHeight="290" - :canResize="true" - :mini="true" - :front="true" - @closed="emit('closed')" -> - <MkEmojiPicker :showPinned="showPinned" :asReactionPicker="asReactionPicker" asWindow :class="$style.picker" @chosen="chosen"/> -</MkWindow> -</template> - -<script lang="ts" setup> -import { } from 'vue'; -import MkWindow from '@/components/MkWindow.vue'; -import MkEmojiPicker from '@/components/MkEmojiPicker.vue'; - -withDefaults(defineProps<{ - src?: HTMLElement; - showPinned?: boolean; - asReactionPicker?: boolean; -}>(), { - showPinned: true, -}); - -const emit = defineEmits<{ - (ev: 'chosen', v: any): void; - (ev: 'closed'): void; -}>(); - -function chosen(emoji: any) { - emit('chosen', emoji); -} -</script> - -<style lang="scss" module> -.picker { - height: 100%; -} -</style> diff --git a/packages/frontend/src/components/MkFeaturedPhotos.vue b/packages/frontend/src/components/MkFeaturedPhotos.vue index 6d1bad7433..8d875790bc 100644 --- a/packages/frontend/src/components/MkFeaturedPhotos.vue +++ b/packages/frontend/src/components/MkFeaturedPhotos.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -10,11 +10,11 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref } from 'vue'; import * as Misskey from 'misskey-js'; -import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; const meta = ref<Misskey.entities.MetaResponse>(); -os.api('meta', { detail: true }).then(gotMeta => { +misskeyApi('meta', { detail: true }).then(gotMeta => { meta.value = gotMeta; }); </script> diff --git a/packages/frontend/src/components/MkFileCaptionEditWindow.vue b/packages/frontend/src/components/MkFileCaptionEditWindow.vue index b799fb9447..39551e6b3c 100644 --- a/packages/frontend/src/components/MkFileCaptionEditWindow.vue +++ b/packages/frontend/src/components/MkFileCaptionEditWindow.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only :withOkButton="true" :okButtonDisabled="false" @ok="ok()" - @close="dialog.close()" + @close="dialog?.close()" @closed="emit('closed')" > <template #header>{{ i18n.ts.describeFile }}</template> @@ -48,6 +48,6 @@ const caption = ref(props.default); async function ok() { emit('done', caption.value); - dialog.value.close(); + dialog.value?.close(); } </script> diff --git a/packages/frontend/src/components/MkFileListForAdmin.vue b/packages/frontend/src/components/MkFileListForAdmin.vue index eb0d4d61ac..f3305e9f54 100644 --- a/packages/frontend/src/components/MkFileListForAdmin.vue +++ b/packages/frontend/src/components/MkFileListForAdmin.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div> <MkPagination v-slot="{items}" :pagination="pagination" class="urempief" :class="{ grid: viewMode === 'grid' }"> <MkA - v-for="file in items" + v-for="file in (items as Misskey.entities.DriveFile[])" :key="file.id" v-tooltip.mfm="`${file.type}\n${bytes(file.size)}\n${dateString(file.createdAt)}\nby ${file.user ? '@' + Misskey.acct.toString(file.user) : 'system'}`" :to="`/admin/file/${file.id}`" diff --git a/packages/frontend/src/components/MkFlashPreview.vue b/packages/frontend/src/components/MkFlashPreview.vue index ab435585d9..c5dd877971 100644 --- a/packages/frontend/src/components/MkFlashPreview.vue +++ b/packages/frontend/src/components/MkFlashPreview.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -9,7 +9,9 @@ SPDX-License-Identifier: AGPL-3.0-only <header> <h1 :title="flash.title">{{ flash.title }}</h1> </header> - <p v-if="flash.summary" :title="flash.summary">{{ flash.summary.length > 85 ? flash.summary.slice(0, 85) + '…' : flash.summary }}</p> + <p v-if="flash.summary" :title="flash.summary"> + <Mfm class="summaryMfm" :text="flash.summary" :plain="true" :nowrap="true"/> + </p> <footer> <img class="icon" :src="flash.user.avatarUrl"/> <p>{{ userName(flash.user) }}</p> @@ -54,6 +56,12 @@ const props = defineProps<{ margin: 0; color: var(--urlPreviewText); font-size: 0.8em; + overflow: clip; + + > .summaryMfm { + display: block; + width: 100%; + } } > footer { diff --git a/packages/frontend/src/components/MkFoldableSection.vue b/packages/frontend/src/components/MkFoldableSection.vue index 65afc48f06..51bcafd1c2 100644 --- a/packages/frontend/src/components/MkFoldableSection.vue +++ b/packages/frontend/src/components/MkFoldableSection.vue @@ -1,10 +1,10 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div ref="el" :class="$style.root"> +<div ref="rootEl" :class="$style.root"> <header :class="$style.header" class="_button" :style="{ background: bg }" @click="showBody = !showBody"> <div :class="$style.title"><div><slot name="header"></slot></div></div> <div :class="$style.divider"></div> @@ -14,7 +14,10 @@ SPDX-License-Identifier: AGPL-3.0-only </button> </header> <Transition - :name="defaultStore.state.animation ? 'folder-toggle' : ''" + :enterActiveClass="defaultStore.state.animation ? $style.folderToggleEnterActive : ''" + :leaveActiveClass="defaultStore.state.animation ? $style.folderToggleLeaveActive : ''" + :enterFromClass="defaultStore.state.animation ? $style.folderToggleEnterFrom : ''" + :leaveToClass="defaultStore.state.animation ? $style.folderToggleLeaveTo : ''" @enter="enter" @afterEnter="afterEnter" @leave="leave" @@ -42,8 +45,8 @@ const props = withDefaults(defineProps<{ expanded: true, }); -const el = shallowRef<HTMLDivElement>(); -const bg = ref<string | null>(null); +const rootEl = shallowRef<HTMLDivElement>(); +const bg = ref<string>(); const showBody = ref((props.persistKey && miLocalStorage.getItem(`${miLocalStoragePrefix}${props.persistKey}`)) ? (miLocalStorage.getItem(`${miLocalStoragePrefix}${props.persistKey}`) === 't') : props.expanded); watch(showBody, () => { @@ -52,40 +55,44 @@ watch(showBody, () => { } }); -function enter(el: Element) { +function enter(element: Element) { + const el = element as HTMLElement; const elementHeight = el.getBoundingClientRect().height; - el.style.height = 0; + el.style.height = '0'; el.offsetHeight; // reflow el.style.height = elementHeight + 'px'; } -function afterEnter(el: Element) { - el.style.height = null; +function afterEnter(element: Element) { + const el = element as HTMLElement; + el.style.height = 'unset'; } -function leave(el: Element) { +function leave(element: Element) { + const el = element as HTMLElement; const elementHeight = el.getBoundingClientRect().height; el.style.height = elementHeight + 'px'; el.offsetHeight; // reflow - el.style.height = 0; + el.style.height = '0'; } -function afterLeave(el: Element) { - el.style.height = null; +function afterLeave(element: Element) { + const el = element as HTMLElement; + el.style.height = 'unset'; } onMounted(() => { - function getParentBg(el: HTMLElement | null): string { + function getParentBg(el?: HTMLElement | null): string { if (el == null || el.tagName === 'BODY') return 'var(--bg)'; - const bg = el.style.background || el.style.backgroundColor; - if (bg) { - return bg; + const background = el.style.background || el.style.backgroundColor; + if (background) { + return background; } else { return getParentBg(el.parentElement); } } - const rawBg = getParentBg(el.value); + const rawBg = getParentBg(rootEl.value); const _bg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg); _bg.setAlpha(0.85); bg.value = _bg.toRgbString(); @@ -93,14 +100,12 @@ onMounted(() => { </script> <style lang="scss" module> -.folder-toggle-enter-active, .folder-toggle-leave-active { +.folderToggleEnterActive, .folderToggleLeaveActive { overflow-y: clip; transition: opacity 0.5s, height 0.5s !important; } -.folder-toggle-enter-from { - opacity: 0; -} -.folder-toggle-leave-to { + +.folderToggleEnterFrom, .folderToggleLeaveTo { opacity: 0; } diff --git a/packages/frontend/src/components/MkFolder.vue b/packages/frontend/src/components/MkFolder.vue index 03621a4255..64d390f52b 100644 --- a/packages/frontend/src/components/MkFolder.vue +++ b/packages/frontend/src/components/MkFolder.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -25,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </template> - <div v-if="openedAtLeastOnce" :class="[$style.body, { [$style.bgSame]: bgSame }]" :style="{ maxHeight: maxHeight ? `${maxHeight}px` : null, overflow: maxHeight ? `auto` : null }" :aria-hidden="!opened"> + <div v-if="openedAtLeastOnce" :class="[$style.body, { [$style.bgSame]: bgSame }]" :style="{ maxHeight: maxHeight ? `${maxHeight}px` : undefined, overflow: maxHeight ? `auto` : undefined }" :aria-hidden="!opened"> <Transition :enterActiveClass="defaultStore.state.animation ? $style.transition_toggle_enterActive : ''" :leaveActiveClass="defaultStore.state.animation ? $style.transition_toggle_leaveActive : ''" @@ -109,7 +109,7 @@ function toggle() { onMounted(() => { const computedStyle = getComputedStyle(document.documentElement); - const parentBg = getBgColor(rootEl.value.parentElement); + const parentBg = getBgColor(rootEl.value!.parentElement!); const myBg = computedStyle.getPropertyValue('--panel'); bgSame.value = parentBg === myBg; }); diff --git a/packages/frontend/src/components/MkFollowButton.vue b/packages/frontend/src/components/MkFollowButton.vue index d1b1956a03..d0e8750e6a 100644 --- a/packages/frontend/src/components/MkFollowButton.vue +++ b/packages/frontend/src/components/MkFollowButton.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -38,11 +38,12 @@ SPDX-License-Identifier: AGPL-3.0-only import { onBeforeUnmount, onMounted, ref } from 'vue'; import * as Misskey from 'misskey-js'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { useStream } from '@/stream.js'; import { i18n } from '@/i18n.js'; import { claimAchievement } from '@/scripts/achievements.js'; import { $i } from '@/account.js'; -import { defaultStore } from "@/store.js"; +import { defaultStore } from '@/store.js'; const props = withDefaults(defineProps<{ user: Misskey.entities.UserDetailed, @@ -63,7 +64,7 @@ const wait = ref(false); const connection = useStream().useChannel('main'); if (props.user.isFollowing == null) { - os.api('users/show', { + misskeyApi('users/show', { userId: props.user.id, }) .then(onFollowChange); @@ -83,22 +84,22 @@ 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; - await os.api('following/delete', { + await misskeyApi('following/delete', { userId: props.user.id, }); } else { if (hasPendingFollowRequestFromYou.value) { - await os.api('following/requests/cancel', { + await misskeyApi('following/requests/cancel', { userId: props.user.id, }); hasPendingFollowRequestFromYou.value = false; } else { - await os.api('following/create', { + await misskeyApi('following/create', { userId: props.user.id, withReplies: defaultStore.state.defaultWithReplies, }); diff --git a/packages/frontend/src/components/MkForgotPassword.vue b/packages/frontend/src/components/MkForgotPassword.vue index 9b57688a02..35112ad45d 100644 --- a/packages/frontend/src/components/MkForgotPassword.vue +++ b/packages/frontend/src/components/MkForgotPassword.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only ref="dialog" :width="370" :height="400" - @close="dialog.close()" + @close="dialog?.close()" @closed="emit('closed')" > <template #header>{{ i18n.ts.forgotPassword }}</template> @@ -66,6 +66,6 @@ async function onSubmit() { email: email.value, }); emit('done'); - dialog.value.close(); + dialog.value?.close(); } </script> diff --git a/packages/frontend/src/components/MkFormDialog.vue b/packages/frontend/src/components/MkFormDialog.vue index 6f882cfab7..deedc5badb 100644 --- a/packages/frontend/src/components/MkFormDialog.vue +++ b/packages/frontend/src/components/MkFormDialog.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -20,41 +20,45 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <MkSpacer :marginMin="20" :marginMax="32"> - <div class="_gaps_m"> - <template v-for="item in Object.keys(form).filter(item => !form[item].hidden)"> - <MkInput v-if="form[item].type === 'number'" v-model="values[item]" type="number" :step="form[item].step || 1"> - <template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template> - <template v-if="form[item].description" #caption>{{ form[item].description }}</template> + <div v-if="Object.keys(form).filter(item => !form[item].hidden).length > 0" class="_gaps_m"> + <template v-for="(v, k) in Object.fromEntries(Object.entries(form).filter(([_, v]) => !('hidden' in v) || 'hidden' in v && !v.hidden))"> + <MkInput v-if="v.type === 'number'" v-model="values[k]" type="number" :step="v.step || 1"> + <template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template> + <template v-if="v.description" #caption>{{ v.description }}</template> </MkInput> - <MkInput v-else-if="form[item].type === 'string' && !form[item].multiline" v-model="values[item]" type="text" :mfmAutocomplete="form[item].treatAsMfm"> - <template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template> - <template v-if="form[item].description" #caption>{{ form[item].description }}</template> + <MkInput v-else-if="v.type === 'string' && !v.multiline" v-model="values[k]" type="text" :mfmAutocomplete="v.treatAsMfm"> + <template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template> + <template v-if="v.description" #caption>{{ v.description }}</template> </MkInput> - <MkTextarea v-else-if="form[item].type === 'string' && form[item].multiline" v-model="values[item]" :mfmAutocomplete="form[item].treatAsMfm" :mfmPreview="form[item].treatAsMfm"> - <template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template> - <template v-if="form[item].description" #caption>{{ form[item].description }}</template> + <MkTextarea v-else-if="v.type === 'string' && v.multiline" v-model="values[k]" :mfmAutocomplete="v.treatAsMfm" :mfmPreview="v.treatAsMfm"> + <template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template> + <template v-if="v.description" #caption>{{ v.description }}</template> </MkTextarea> - <MkSwitch v-else-if="form[item].type === 'boolean'" v-model="values[item]"> - <span v-text="form[item].label || item"></span> - <template v-if="form[item].description" #caption>{{ form[item].description }}</template> + <MkSwitch v-else-if="v.type === 'boolean'" v-model="values[k]"> + <span v-text="v.label || k"></span> + <template v-if="v.description" #caption>{{ v.description }}</template> </MkSwitch> - <MkSelect v-else-if="form[item].type === 'enum'" v-model="values[item]"> - <template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template> - <option v-for="item in form[item].enum" :key="item.value" :value="item.value">{{ item.label }}</option> + <MkSelect v-else-if="v.type === 'enum'" v-model="values[k]"> + <template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template> + <option v-for="option in v.enum" :key="option.value" :value="option.value">{{ option.label }}</option> </MkSelect> - <MkRadios v-else-if="form[item].type === 'radio'" v-model="values[item]"> - <template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template> - <option v-for="item in form[item].options" :key="item.value" :value="item.value">{{ item.label }}</option> + <MkRadios v-else-if="v.type === 'radio'" v-model="values[k]"> + <template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template> + <option v-for="option in v.options" :key="option.value" :value="option.value">{{ option.label }}</option> </MkRadios> - <MkRange v-else-if="form[item].type === 'range'" v-model="values[item]" :min="form[item].min" :max="form[item].max" :step="form[item].step" :textConverter="form[item].textConverter"> - <template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template> - <template v-if="form[item].description" #caption>{{ form[item].description }}</template> + <MkRange v-else-if="v.type === 'range'" v-model="values[k]" :min="v.min" :max="v.max" :step="v.step" :textConverter="v.textConverter"> + <template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template> + <template v-if="v.description" #caption>{{ v.description }}</template> </MkRange> - <MkButton v-else-if="form[item].type === 'button'" @click="form[item].action($event, values)"> - <span v-text="form[item].content || item"></span> + <MkButton v-else-if="v.type === 'button'" @click="v.action($event, values)"> + <span v-text="v.content || k"></span> </MkButton> </template> </div> + <div v-else class="_fullinfo"> + <img :src="infoImageUrl" class="_ghost"/> + <div>{{ i18n.ts.nothing }}</div> + </div> </MkSpacer> </MkModalWindow> </template> @@ -68,19 +72,23 @@ import MkSelect from './MkSelect.vue'; import MkRange from './MkRange.vue'; import MkButton from './MkButton.vue'; import MkRadios from './MkRadios.vue'; +import type { Form } from '@/scripts/form.js'; import MkModalWindow from '@/components/MkModalWindow.vue'; import { i18n } from '@/i18n.js'; +import { infoImageUrl } from '@/instance.js'; const props = defineProps<{ title: string; - form: any; + form: Form; }>(); const emit = defineEmits<{ (ev: 'done', v: { - canceled?: boolean; - result?: any; + canceled: true; + } | { + result: Record<string, any>; }): void; + (ev: 'closed'): void; }>(); const dialog = shallowRef<InstanceType<typeof MkModalWindow>>(); @@ -94,13 +102,13 @@ function ok() { emit('done', { result: values, }); - dialog.value.close(); + dialog.value?.close(); } function cancel() { emit('done', { canceled: true, }); - dialog.value.close(); + dialog.value?.close(); } </script> diff --git a/packages/frontend/src/components/MkGalleryPostPreview.stories.impl.ts b/packages/frontend/src/components/MkGalleryPostPreview.stories.impl.ts index 035b727a35..a433ad680b 100644 --- a/packages/frontend/src/components/MkGalleryPostPreview.stories.impl.ts +++ b/packages/frontend/src/components/MkGalleryPostPreview.stories.impl.ts @@ -1,11 +1,10 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { expect } from '@storybook/jest'; -import { userEvent, waitFor, within } from '@storybook/testing-library'; +import { expect, userEvent, waitFor, within } from '@storybook/test'; import { StoryObj } from '@storybook/vue3'; import { galleryPost } from '../../.storybook/fakes.js'; import MkGalleryPostPreview from './MkGalleryPostPreview.vue'; diff --git a/packages/frontend/src/components/MkGalleryPostPreview.vue b/packages/frontend/src/components/MkGalleryPostPreview.vue index 316632b1a6..47cccd9b7c 100644 --- a/packages/frontend/src/components/MkGalleryPostPreview.vue +++ b/packages/frontend/src/components/MkGalleryPostPreview.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -14,8 +14,8 @@ SPDX-License-Identifier: AGPL-3.0-only leaveActiveClass: $style.transition_toggle_leaveActive, leaveToClass: $style.transition_toggle_leaveTo, }" - :src="post.files[0].thumbnailUrl" - :hash="post.files[0].blurhash" + :src="post.files?.[0]?.thumbnailUrl" + :hash="post.files?.[0]?.blurhash" :forceBlurhash="!show" /> </Transition> diff --git a/packages/frontend/src/components/MkGoogle.vue b/packages/frontend/src/components/MkGoogle.vue index c0b20507fc..c92a49d32a 100644 --- a/packages/frontend/src/components/MkGoogle.vue +++ b/packages/frontend/src/components/MkGoogle.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/MkHeatmap.vue b/packages/frontend/src/components/MkHeatmap.vue index a57e6c9292..0cc0df9911 100644 --- a/packages/frontend/src/components/MkHeatmap.vue +++ b/packages/frontend/src/components/MkHeatmap.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -15,7 +15,8 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { onMounted, nextTick, watch, shallowRef, ref } from 'vue'; import { Chart } from 'chart.js'; -import * as os from '@/os.js'; +import * as Misskey from 'misskey-js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { defaultStore } from '@/store.js'; import { useChartTooltip } from '@/scripts/use-chart-tooltip.js'; import { alpha } from '@/scripts/color.js'; @@ -23,14 +24,21 @@ import { initChart } from '@/scripts/init-chart.js'; initChart(); -const props = defineProps<{ - src: string; -}>(); +export type HeatmapSource = 'active-users' | 'notes' | 'ap-requests-inbox-received' | 'ap-requests-deliver-succeeded' | 'ap-requests-deliver-failed'; -const rootEl = shallowRef<HTMLDivElement>(null); -const chartEl = shallowRef<HTMLCanvasElement>(null); +const props = withDefaults(defineProps<{ + src: HeatmapSource; + user?: Misskey.entities.User; + label?: string; +}>(), { + user: undefined, + label: '', +}); + +const rootEl = shallowRef<HTMLDivElement | null>(null); +const chartEl = shallowRef<HTMLCanvasElement | null>(null); const now = new Date(); -let chartInstance: Chart = null; +let chartInstance: Chart | null = null; const fetching = ref(true); const { handler: externalTooltipHandler } = useChartTooltip({ @@ -38,6 +46,7 @@ const { handler: externalTooltipHandler } = useChartTooltip({ }); async function renderChart() { + if (rootEl.value == null) return; if (chartInstance) { chartInstance.destroy(); } @@ -56,7 +65,7 @@ async function renderChart() { return new Date(y, m, d - ago); }; - const format = (arr) => { + const format = (arr: number[]) => { return arr.map((v, i) => { const dt = getDate(i); const iso = `${dt.getFullYear()}-${(dt.getMonth() + 1).toString().padStart(2, '0')}-${dt.getDate().toString().padStart(2, '0')}`; @@ -69,22 +78,27 @@ async function renderChart() { }); }; - let values; + let values: number[] = []; if (props.src === 'active-users') { - const raw = await os.api('charts/active-users', { limit: chartLimit, span: 'day' }); + const raw = await misskeyApi('charts/active-users', { limit: chartLimit, span: 'day' }); values = raw.readWrite; } else if (props.src === 'notes') { - const raw = await os.api('charts/notes', { limit: chartLimit, span: 'day' }); - values = raw.local.inc; + if (props.user) { + const raw = await misskeyApi('charts/user/notes', { userId: props.user.id, limit: chartLimit, span: 'day' }); + values = raw.inc; + } else { + const raw = await misskeyApi('charts/notes', { limit: chartLimit, span: 'day' }); + values = raw.local.inc; + } } else if (props.src === 'ap-requests-inbox-received') { - const raw = await os.api('charts/ap-request', { limit: chartLimit, span: 'day' }); + const raw = await misskeyApi('charts/ap-request', { limit: chartLimit, span: 'day' }); values = raw.inboxReceived; } else if (props.src === 'ap-requests-deliver-succeeded') { - const raw = await os.api('charts/ap-request', { limit: chartLimit, span: 'day' }); + const raw = await misskeyApi('charts/ap-request', { limit: chartLimit, span: 'day' }); values = raw.deliverSucceeded; } else if (props.src === 'ap-requests-deliver-failed') { - const raw = await os.api('charts/ap-request', { limit: chartLimit, span: 'day' }); + const raw = await misskeyApi('charts/ap-request', { limit: chartLimit, span: 'day' }); values = raw.deliverFailed; } @@ -101,25 +115,25 @@ async function renderChart() { const marginEachCell = 4; + if (chartEl.value == null) return; + chartInstance = new Chart(chartEl.value, { type: 'matrix', data: { datasets: [{ - label: 'Read & Write', - data: format(values), - pointRadius: 0, + label: props.label, + data: format(values) as any, borderWidth: 0, - borderJoinStyle: 'round', borderRadius: 3, backgroundColor(c) { - const value = c.dataset.data[c.dataIndex].v; + // @ts-expect-error TS(2339) + const value = c.dataset.data[c.dataIndex].v as number; let a = (value - min) / max; if (value !== 0) { // 0でない限りは完全に不可視にはしない a = Math.max(a, 0.05); } return alpha(color, a); }, - fill: true, width(c) { const a = c.chart.chartArea ?? {}; return (a.right - a.left) / weeks - marginEachCell; @@ -128,6 +142,9 @@ async function renderChart() { const a = c.chart.chartArea ?? {}; return (a.bottom - a.top) / 7 - marginEachCell; }, + /* @see <https://github.com/misskey-dev/misskey/pull/10365#discussion_r1155511107> + }] satisfies ChartData[], + */ }], }, options: { @@ -190,12 +207,14 @@ async function renderChart() { enabled: false, callbacks: { title(context) { - const v = context[0].dataset.data[context[0].dataIndex]; - return v.d; + // @ts-expect-error TS(2339) + return context[0].dataset.data[context[0].dataIndex].d; }, label(context) { const v = context.dataset.data[context.dataIndex]; - return ['Active: ' + v.v]; + + // @ts-expect-error TS(2339) + return [v.v]; }, }, //mode: 'index', diff --git a/packages/frontend/src/components/MkHorizontalSwipe.vue b/packages/frontend/src/components/MkHorizontalSwipe.vue new file mode 100644 index 0000000000..196c962a06 --- /dev/null +++ b/packages/frontend/src/components/MkHorizontalSwipe.vue @@ -0,0 +1,239 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div + ref="rootEl" + :class="[$style.transitionRoot, { [$style.enableAnimation]: shouldAnimate }]" + @touchstart.passive="touchStart" + @touchmove.passive="touchMove" + @touchend.passive="touchEnd" +> + <Transition + :class="[$style.transitionChildren, { [$style.swiping]: isSwipingForClass }]" + :enterActiveClass="$style.swipeAnimation_enterActive" + :leaveActiveClass="$style.swipeAnimation_leaveActive" + :enterFromClass="transitionName === 'swipeAnimationLeft' ? $style.swipeAnimationLeft_enterFrom : $style.swipeAnimationRight_enterFrom" + :leaveToClass="transitionName === 'swipeAnimationLeft' ? $style.swipeAnimationLeft_leaveTo : $style.swipeAnimationRight_leaveTo" + :style="`--swipe: ${pullDistance}px;`" + > + <!-- 【注意】slot内の最上位要素に動的にkeyを設定すること --> + <!-- 各最上位要素にユニークなkeyの指定がないとTransitionがうまく動きません --> + <slot></slot> + </Transition> +</div> +</template> +<script lang="ts" setup> +import { ref, shallowRef, computed, nextTick, watch } from 'vue'; +import type { Tab } from '@/components/global/MkPageHeader.tabs.vue'; +import { defaultStore } from '@/store.js'; +import { isHorizontalSwipeSwiping as isSwiping } from '@/scripts/touch.js'; + +const rootEl = shallowRef<HTMLDivElement>(); + +// eslint-disable-next-line no-undef +const tabModel = defineModel<string>('tab'); + +const props = defineProps<{ + tabs: Tab[]; +}>(); + +const emit = defineEmits<{ + (ev: 'swiped', newKey: string, direction: 'left' | 'right'): void; +}>(); + +const shouldAnimate = computed(() => defaultStore.reactiveState.enableHorizontalSwipe.value || defaultStore.reactiveState.animation.value); + +// ▼ しきい値 ▼ // + +// スワイプと判定される最小の距離 +const MIN_SWIPE_DISTANCE = 20; + +// スワイプ時の動作を発火する最小の距離 +const SWIPE_DISTANCE_THRESHOLD = 70; + +// スワイプを中断するY方向の移動距離 +const SWIPE_ABORT_Y_THRESHOLD = 75; + +// スワイプできる最大の距離 +const MAX_SWIPE_DISTANCE = 120; + +// ▲ しきい値 ▲ // + +let startScreenX: number | null = null; +let startScreenY: number | null = null; + +const currentTabIndex = computed(() => props.tabs.findIndex(tab => tab.key === tabModel.value)); + +const pullDistance = ref(0); +const isSwipingForClass = ref(false); +let swipeAborted = false; + +function touchStart(event: TouchEvent) { + if (!defaultStore.reactiveState.enableHorizontalSwipe.value) return; + + if (event.touches.length !== 1) return; + + if (hasSomethingToDoWithXSwipe(event.target as HTMLElement)) return; + + startScreenX = event.touches[0].screenX; + startScreenY = event.touches[0].screenY; +} + +function touchMove(event: TouchEvent) { + if (!defaultStore.reactiveState.enableHorizontalSwipe.value) return; + + if (event.touches.length !== 1) return; + + if (startScreenX == null || startScreenY == null) return; + + if (swipeAborted) return; + + if (hasSomethingToDoWithXSwipe(event.target as HTMLElement)) return; + + let distanceX = event.touches[0].screenX - startScreenX; + let distanceY = event.touches[0].screenY - startScreenY; + + if (Math.abs(distanceY) > SWIPE_ABORT_Y_THRESHOLD) { + swipeAborted = true; + + pullDistance.value = 0; + isSwiping.value = false; + setTimeout(() => { + isSwipingForClass.value = false; + }, 400); + + return; + } + + if (Math.abs(distanceX) < MIN_SWIPE_DISTANCE) return; + if (Math.abs(distanceX) > MAX_SWIPE_DISTANCE) return; + + if (currentTabIndex.value === 0 || props.tabs[currentTabIndex.value - 1].onClick) { + distanceX = Math.min(distanceX, 0); + } + if (currentTabIndex.value === props.tabs.length - 1 || props.tabs[currentTabIndex.value + 1].onClick) { + distanceX = Math.max(distanceX, 0); + } + if (distanceX === 0) return; + + isSwiping.value = true; + isSwipingForClass.value = true; + nextTick(() => { + // グリッチを控えるため、1.5px以上の差がないと更新しない + if (Math.abs(distanceX - pullDistance.value) < 1.5) return; + pullDistance.value = distanceX; + }); +} + +function touchEnd(event: TouchEvent) { + if (swipeAborted) { + swipeAborted = false; + return; + } + + if (!defaultStore.reactiveState.enableHorizontalSwipe.value) return; + + if (event.touches.length !== 0) return; + + if (startScreenX == null) return; + + if (!isSwiping.value) return; + + if (hasSomethingToDoWithXSwipe(event.target as HTMLElement)) return; + + const distance = event.changedTouches[0].screenX - startScreenX; + + if (Math.abs(distance) > SWIPE_DISTANCE_THRESHOLD) { + if (distance > 0) { + if (props.tabs[currentTabIndex.value - 1] && !props.tabs[currentTabIndex.value - 1].onClick) { + tabModel.value = props.tabs[currentTabIndex.value - 1].key; + emit('swiped', props.tabs[currentTabIndex.value - 1].key, 'right'); + } + } else { + if (props.tabs[currentTabIndex.value + 1] && !props.tabs[currentTabIndex.value + 1].onClick) { + tabModel.value = props.tabs[currentTabIndex.value + 1].key; + emit('swiped', props.tabs[currentTabIndex.value + 1].key, 'left'); + } + } + } + + pullDistance.value = 0; + isSwiping.value = false; + window.setTimeout(() => { + isSwipingForClass.value = false; + }, 400); +} + +/** 横スワイプに関与する可能性のある要素を調べる */ +function hasSomethingToDoWithXSwipe(el: HTMLElement) { + if (['INPUT', 'TEXTAREA'].includes(el.tagName)) return true; + if (el.isContentEditable) return true; + if (el.scrollWidth > el.clientWidth) return true; + + const style = window.getComputedStyle(el); + if (['absolute', 'fixed', 'sticky'].includes(style.position)) return true; + if (['scroll', 'auto'].includes(style.overflowX)) return true; + if (style.touchAction === 'pan-x') return true; + + if (el.parentElement && el.parentElement !== rootEl.value) { + return hasSomethingToDoWithXSwipe(el.parentElement); + } else { + return false; + } +} + +const transitionName = ref<'swipeAnimationLeft' | 'swipeAnimationRight' | undefined>(undefined); + +watch(tabModel, (newTab, oldTab) => { + const newIndex = props.tabs.findIndex(tab => tab.key === newTab); + const oldIndex = props.tabs.findIndex(tab => tab.key === oldTab); + + if (oldIndex >= 0 && newIndex && oldIndex < newIndex) { + transitionName.value = 'swipeAnimationLeft'; + } else { + transitionName.value = 'swipeAnimationRight'; + } + + window.setTimeout(() => { + transitionName.value = undefined; + }, 400); +}); +</script> + +<style lang="scss" module> +.transitionRoot { + touch-action: pan-y pinch-zoom; + display: grid; + grid-template-columns: 100%; + overflow: clip; +} + +.transitionChildren { + grid-area: 1 / 1 / 2 / 2; + transform: translateX(var(--swipe)); +} + +.enableAnimation .transitionChildren { + &.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_enterFrom, + &.swipeAnimationLeft_leaveTo { + transform: translateX(calc(-100% - 24px)); + } +} + +.swiping { + transition: transform .2s ease-out; +} +</style> diff --git a/packages/frontend/src/components/MkImgWithBlurhash.vue b/packages/frontend/src/components/MkImgWithBlurhash.vue index 942861e1f4..4e3fafe845 100644 --- a/packages/frontend/src/components/MkImgWithBlurhash.vue +++ b/packages/frontend/src/components/MkImgWithBlurhash.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -73,7 +73,7 @@ const props = withDefaults(defineProps<{ leaveFromClass?: string; } | null; src?: string | null; - hash?: string; + hash?: string | null; alt?: string | null; title?: string | null; height?: number; diff --git a/packages/frontend/src/components/MkInfo.vue b/packages/frontend/src/components/MkInfo.vue index 6e643639f2..9a5874b5c0 100644 --- a/packages/frontend/src/components/MkInfo.vue +++ b/packages/frontend/src/components/MkInfo.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/MkInput.vue b/packages/frontend/src/components/MkInput.vue index b4b4e1b0b7..b026903b66 100644 --- a/packages/frontend/src/components/MkInput.vue +++ b/packages/frontend/src/components/MkInput.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -88,17 +88,18 @@ const focused = ref(false); const changed = ref(false); const invalid = ref(false); const filled = computed(() => v.value !== '' && v.value != null); -const inputEl = shallowRef<HTMLElement>(); +const inputEl = shallowRef<HTMLInputElement>(); const prefixEl = shallowRef<HTMLElement>(); const suffixEl = shallowRef<HTMLElement>(); const height = props.small ? 33 : props.large ? 39 : 36; -let autocomplete: Autocomplete; +let autocompleteWorker: Autocomplete | null = null; -const focus = () => inputEl.value.focus(); -const onInput = (ev: KeyboardEvent) => { +const focus = () => inputEl.value?.focus(); +const onInput = (event: Event) => { + const ev = event as KeyboardEvent; changed.value = true; emit('change', ev); }; @@ -115,9 +116,9 @@ const onKeydown = (ev: KeyboardEvent) => { const updated = () => { changed.value = false; if (type.value === 'number') { - emit('update:modelValue', parseFloat(v.value)); + emit('update:modelValue', typeof v.value === 'number' ? v.value : parseFloat(v.value ?? '0')); } else { - emit('update:modelValue', v.value); + emit('update:modelValue', v.value ?? ''); } }; @@ -127,7 +128,7 @@ watch(modelValue, newValue => { v.value = newValue; }); -watch(v, newValue => { +watch(v, () => { if (!props.manualSave) { if (props.debounce) { debouncedUpdated(); @@ -136,12 +137,14 @@ watch(v, newValue => { } } - invalid.value = inputEl.value.validity.badInput; + invalid.value = inputEl.value?.validity.badInput ?? true; }); // このコンポーネントが作成された時、非表示状態である場合がある // 非表示状態だと要素の幅などは0になってしまうので、定期的に計算する useInterval(() => { + if (inputEl.value == null) return; + if (prefixEl.value) { if (prefixEl.value.offsetWidth) { inputEl.value.style.paddingLeft = prefixEl.value.offsetWidth + 'px'; @@ -163,15 +166,15 @@ onMounted(() => { focus(); } }); - - if (props.mfmAutocomplete) { - autocomplete = new Autocomplete(inputEl.value, v, props.mfmAutocomplete === true ? null : props.mfmAutocomplete); + + if (props.mfmAutocomplete && inputEl.value) { + autocompleteWorker = new Autocomplete(inputEl.value, v, props.mfmAutocomplete === true ? undefined : props.mfmAutocomplete); } }); onUnmounted(() => { - if (autocomplete) { - autocomplete.detach(); + if (autocompleteWorker) { + autocompleteWorker.detach(); } }); diff --git a/packages/frontend/src/components/MkInstanceCardMini.vue b/packages/frontend/src/components/MkInstanceCardMini.vue index 9cde197e19..feb62415aa 100644 --- a/packages/frontend/src/components/MkInstanceCardMini.vue +++ b/packages/frontend/src/components/MkInstanceCardMini.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -18,7 +18,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { ref } from 'vue'; import * as Misskey from 'misskey-js'; import MkMiniChart from '@/components/MkMiniChart.vue'; -import * as os from '@/os.js'; +import { misskeyApiGet } from '@/scripts/misskey-api.js'; import { getProxiedImageUrlNullable } from '@/scripts/media-proxy.js'; const props = defineProps<{ @@ -27,7 +27,7 @@ const props = defineProps<{ const chartValues = ref<number[] | null>(null); -os.apiGet('charts/instance', { host: props.instance.host, limit: 16 + 1, span: 'day' }).then(res => { +misskeyApiGet('charts/instance', { host: props.instance.host, limit: 16 + 1, span: 'day' }).then(res => { // 今日のぶんの値はまだ途中の値であり、それも含めると大抵の場合前日よりも下降しているようなグラフになってしまうため今日は弾く res['requests.received'].splice(0, 1); chartValues.value = res['requests.received']; diff --git a/packages/frontend/src/components/MkInstanceStats.vue b/packages/frontend/src/components/MkInstanceStats.vue index 7b763ad385..d74c885041 100644 --- a/packages/frontend/src/components/MkInstanceStats.vue +++ b/packages/frontend/src/components/MkInstanceStats.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -51,7 +51,7 @@ SPDX-License-Identifier: AGPL-3.0-only <option value="ap-requests-deliver-failed">AP Requests: deliverFailed</option> </MkSelect> <div class="_panel" :class="$style.heatmap"> - <MkHeatmap :src="heatmapSrc"/> + <MkHeatmap :src="heatmapSrc" :label="'Read & Write'"/> </div> </MkFoldableSection> @@ -90,8 +90,9 @@ import MkSelect from '@/components/MkSelect.vue'; import MkChart from '@/components/MkChart.vue'; import { useChartTooltip } from '@/scripts/use-chart-tooltip.js'; import * as os from '@/os.js'; +import { misskeyApiGet } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; -import MkHeatmap from '@/components/MkHeatmap.vue'; +import MkHeatmap, { type HeatmapSource } from '@/components/MkHeatmap.vue'; import MkFoldableSection from '@/components/MkFoldableSection.vue'; import MkRetentionHeatmap from '@/components/MkRetentionHeatmap.vue'; import MkRetentionLineChart from '@/components/MkRetentionLineChart.vue'; @@ -102,7 +103,7 @@ initChart(); const chartLimit = 500; const chartSpan = ref<'hour' | 'day'>('hour'); const chartSrc = ref('active-users'); -const heatmapSrc = ref('active-users'); +const heatmapSrc = ref<HeatmapSource>('active-users'); const subDoughnutEl = shallowRef<HTMLCanvasElement>(); const pubDoughnutEl = shallowRef<HTMLCanvasElement>(); @@ -137,7 +138,8 @@ function createDoughnut(chartEl, tooltip, data) { }, }, onClick: (ev) => { - const hit = chartInstance.getElementsAtEventForMode(ev, 'nearest', { intersect: true }, false)[0]; + if (ev.native == null) return; + const hit = chartInstance.getElementsAtEventForMode(ev.native, 'nearest', { intersect: true }, false)[0]; if (hit && data[hit.index].onClick) { data[hit.index].onClick(); } @@ -162,24 +164,47 @@ function createDoughnut(chartEl, tooltip, data) { } onMounted(() => { - os.apiGet('federation/stats', { limit: 30 }).then(fedStats => { - createDoughnut(subDoughnutEl.value, externalTooltipHandler1, fedStats.topSubInstances.map(x => ({ + misskeyApiGet('federation/stats', { limit: 30 }).then(fedStats => { + type ChartData = { + name: string, + color: string | null, + value: number, + onClick?: () => void, + }[]; + + const subs: ChartData = fedStats.topSubInstances.map(x => ({ name: x.host, color: x.themeColor, value: x.followersCount, onClick: () => { os.pageWindow(`/instance-info/${x.host}`); }, - })).concat([{ name: '(other)', color: '#80808080', value: fedStats.otherFollowersCount }])); + })); + + subs.push({ + name: '(other)', + color: '#80808080', + value: fedStats.otherFollowersCount, + }); + + createDoughnut(subDoughnutEl.value, externalTooltipHandler1, subs); - createDoughnut(pubDoughnutEl.value, externalTooltipHandler2, fedStats.topPubInstances.map(x => ({ + const pubs: ChartData = fedStats.topPubInstances.map(x => ({ name: x.host, color: x.themeColor, value: x.followingCount, onClick: () => { os.pageWindow(`/instance-info/${x.host}`); }, - })).concat([{ name: '(other)', color: '#80808080', value: fedStats.otherFollowingCount }])); + })); + + pubs.push({ + name: '(other)', + color: '#80808080', + value: fedStats.otherFollowingCount, + }); + + createDoughnut(pubDoughnutEl.value, externalTooltipHandler2, pubs); }); }); </script> diff --git a/packages/frontend/src/components/MkInstanceTicker.vue b/packages/frontend/src/components/MkInstanceTicker.vue index e358a1c549..094d2f177f 100644 --- a/packages/frontend/src/components/MkInstanceTicker.vue +++ b/packages/frontend/src/components/MkInstanceTicker.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -18,9 +18,9 @@ import { getProxiedImageUrlNullable } from '@/scripts/media-proxy.js'; const props = defineProps<{ instance?: { - faviconUrl?: string - name: string - themeColor?: string + faviconUrl?: string | null + name?: string | null + themeColor?: string | null } }>(); @@ -30,7 +30,7 @@ const instance = props.instance ?? { themeColor: (document.querySelector('meta[name="theme-color-orig"]') as HTMLMetaElement).content, }; -const faviconUrl = computed(() => props.instance ? getProxiedImageUrlNullable(props.instance.faviconUrl, 'preview') : getProxiedImageUrlNullable(Instance.iconUrl, 'preview') ?? getProxiedImageUrlNullable(Instance.faviconUrl, 'preview') ?? '/favicon.ico'); +const faviconUrl = computed(() => props.instance ? getProxiedImageUrlNullable(props.instance.faviconUrl, 'preview') : getProxiedImageUrlNullable(Instance.iconUrl, 'preview') ?? '/favicon.ico'); const themeColor = instance.themeColor ?? '#777777'; diff --git a/packages/frontend/src/components/MkInviteCode.stories.impl.ts b/packages/frontend/src/components/MkInviteCode.stories.impl.ts index 2ea32dd3b6..456d215288 100644 --- a/packages/frontend/src/components/MkInviteCode.stories.impl.ts +++ b/packages/frontend/src/components/MkInviteCode.stories.impl.ts @@ -1,11 +1,11 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { StoryObj } from '@storybook/vue3'; -import { rest } from 'msw'; +import { HttpResponse, http } from 'msw'; import { userDetailed, inviteCode } from '../../.storybook/fakes.js'; import { commonHandlers } from '../../.storybook/mocks.js'; import MkInviteCode from './MkInviteCode.vue'; @@ -39,8 +39,8 @@ export const Default = { msw: { handlers: [ ...commonHandlers, - rest.post('/api/users/show', (req, res, ctx) => { - return res(ctx.json(userDetailed(req.params.userId as string))); + http.post('/api/users/show', ({ params }) => { + return HttpResponse.json(userDetailed(params.userId as string)); }), ], }, diff --git a/packages/frontend/src/components/MkInviteCode.vue b/packages/frontend/src/components/MkInviteCode.vue index 54d997d1c9..b095df20e5 100644 --- a/packages/frontend/src/components/MkInviteCode.vue +++ b/packages/frontend/src/components/MkInviteCode.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/MkKeyValue.vue b/packages/frontend/src/components/MkKeyValue.vue index 7a1a5eb016..2175c0e888 100644 --- a/packages/frontend/src/components/MkKeyValue.vue +++ b/packages/frontend/src/components/MkKeyValue.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/MkLaunchPad.vue b/packages/frontend/src/components/MkLaunchPad.vue index 099082f539..e232b4d66f 100644 --- a/packages/frontend/src/components/MkLaunchPad.vue +++ b/packages/frontend/src/components/MkLaunchPad.vue @@ -1,10 +1,10 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkModal ref="modal" v-slot="{ type, maxHeight }" :preferType="preferedModalType" :anchor="anchor" :transparentBg="true" :src="src" @click="modal.close()" @closed="emit('closed')"> +<MkModal ref="modal" v-slot="{ type, maxHeight }" :preferType="preferedModalType" :anchor="anchor" :transparentBg="true" :src="src" @click="modal?.close()" @closed="emit('closed')"> <div class="szkkfdyq _popup _shadow" :class="{ asDrawer: type === 'drawer' }" :style="{ maxHeight: maxHeight ? maxHeight + 'px' : '' }"> <div class="main"> <template v-for="item in items" :key="item.text"> @@ -63,7 +63,7 @@ const items = Object.keys(navbarItemDef).filter(k => !menu.includes(k)).map(k => })); function close() { - modal.value.close(); + modal.value?.close(); } </script> @@ -119,6 +119,7 @@ function close() { margin-top: 12px; font-size: 0.8em; line-height: 1.5em; + text-align: center; } > .indicatorWithValue { @@ -138,7 +139,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; diff --git a/packages/frontend/src/components/MkLink.vue b/packages/frontend/src/components/MkLink.vue index bda683002d..95de0d0247 100644 --- a/packages/frontend/src/components/MkLink.vue +++ b/packages/frontend/src/components/MkLink.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -7,6 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only <component :is="self ? 'MkA' : 'a'" ref="el" style="word-break: break-all;" class="_link" :[attr]="self ? url.substring(local.length) : url" :rel="rel ?? 'nofollow noopener'" :target="target" :title="url" + @click.stop > <slot></slot> <i v-if="target === '_blank'" class="ph-arrow-square-out ph-bold ph-lg" :class="$style.icon"></i> diff --git a/packages/frontend/src/components/MkMarquee.vue b/packages/frontend/src/components/MkMarquee.vue index 145b60c8e7..4a89d21b92 100644 --- a/packages/frontend/src/components/MkMarquee.vue +++ b/packages/frontend/src/components/MkMarquee.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -30,6 +30,7 @@ export default { const contentEl = ref<HTMLElement>(); function calc() { + if (contentEl.value == null) return; const eachLength = contentEl.value.offsetWidth / props.repeat; const factor = 3000; const duration = props.duration / ((1 / eachLength) * factor); diff --git a/packages/frontend/src/components/MkMediaAudio.vue b/packages/frontend/src/components/MkMediaAudio.vue new file mode 100644 index 0000000000..6351f5cfbe --- /dev/null +++ b/packages/frontend/src/components/MkMediaAudio.vue @@ -0,0 +1,364 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div + :class="[ + $style.audioContainer, + (audio.isSensitive && defaultStore.state.highlightSensitiveMedia) && $style.sensitive, + ]" + @contextmenu.stop +> + <button v-if="hide" :class="$style.hidden" @click="hide = false"> + <div :class="$style.hiddenTextWrapper"> + <b v-if="audio.isSensitive" style="display: block;"><i class="ph-eye-slash ph-bold ph-lg"></i> {{ i18n.ts.sensitive }}{{ defaultStore.state.dataSaver.media ? ` (${i18n.ts.audio}${audio.size ? ' ' + bytes(audio.size) : ''})` : '' }}</b> + <b v-else style="display: block;"><i class="ph-music-notes ph-bold ph-lg"></i> {{ defaultStore.state.dataSaver.media && audio.size ? bytes(audio.size) : i18n.ts.audio }}</b> + <span style="display: block;">{{ i18n.ts.clickToShow }}</span> + </div> + </button> + <div v-else :class="$style.audioControls"> + <audio + ref="audioEl" + preload="metadata" + > + <source :src="audio.url"> + </audio> + <div :class="[$style.controlsChild, $style.controlsLeft]"> + <button class="_button" :class="$style.controlButton" @click="togglePlayPause"> + <i v-if="isPlaying" class="ph-pause ph-bold ph-lg"></i> + <i v-else class="ph-play ph-bold ph-lg"></i> + </button> + </div> + <div :class="[$style.controlsChild, $style.controlsRight]"> + <a class="_button" :class="$style.controlButton" :href="audio.url" :download="audio.name" target="_blank"> + <i class="ph-download ph-bold ph-lg"></i> + </a> + <button class="_button" :class="$style.controlButton" @click="showMenu"> + <i class="ph-gear ph-bold ph-lg"></i> + </button> + </div> + <div :class="[$style.controlsChild, $style.controlsTime]">{{ hms(elapsedTimeMs) }}</div> + <div :class="[$style.controlsChild, $style.controlsVolume]"> + <button class="_button" :class="$style.controlButton" @click="toggleMute"> + <i v-if="volume === 0" class="ph-speaker-x ph-bold ph-lg"></i> + <i v-else class="ph-speaker-high ph-bold ph-lg"></i> + </button> + <MkMediaRange + v-model="volume" + :class="$style.volumeSeekbar" + /> + </div> + <MkMediaRange + v-model="rangePercent" + :class="$style.seekbarRoot" + :buffer="bufferedDataRatio" + /> + </div> +</div> +</template> + +<script lang="ts" setup> +import { shallowRef, watch, computed, ref, onDeactivated, onActivated, onMounted } from 'vue'; +import * as Misskey from 'misskey-js'; +import type { MenuItem } from '@/types/menu.js'; +import { defaultStore } from '@/store.js'; +import { i18n } from '@/i18n.js'; +import * as os from '@/os.js'; +import bytes from '@/filters/bytes.js'; +import { hms } from '@/filters/hms.js'; +import MkMediaRange from '@/components/MkMediaRange.vue'; +import { iAmModerator } from '@/account.js'; + +const props = defineProps<{ + audio: Misskey.entities.DriveFile; +}>(); + +const audioEl = shallowRef<HTMLAudioElement>(); + +// eslint-disable-next-line vue/no-setup-props-destructure +const hide = ref((defaultStore.state.nsfw === 'force' || defaultStore.state.dataSaver.media) ? true : (props.audio.isSensitive && defaultStore.state.nsfw !== 'ignore')); + +// Menu +const menuShowing = ref(false); + +function showMenu(ev: MouseEvent) { + let menu: MenuItem[] = []; + + menu = [ + // TODO: 再生キューに追加 + { + text: i18n.ts.hide, + icon: 'ph-eye-closed ph-bold ph-lg', + action: () => { + hide.value = true; + }, + }, + ]; + + if (iAmModerator) { + menu.push({ + type: 'divider', + }, { + text: props.audio.isSensitive ? i18n.ts.unmarkAsSensitive : i18n.ts.markAsSensitive, + icon: props.audio.isSensitive ? 'ph-eye ph-bold ph-lg' : 'ph-eye-slash ph-bold ph-lg', + danger: true, + action: () => toggleSensitive(props.audio), + }); + } + + menuShowing.value = true; + os.popupMenu(menu, ev.currentTarget ?? ev.target, { + align: 'right', + onClosing: () => { + menuShowing.value = false; + }, + }); +} + +function toggleSensitive(file: Misskey.entities.DriveFile) { + os.apiWithDialog('drive/files/update', { + fileId: file.id, + isSensitive: !file.isSensitive, + }); +} + +// MediaControl: Common State +const oncePlayed = ref(false); +const isReady = ref(false); +const isPlaying = ref(false); +const isActuallyPlaying = ref(false); +const elapsedTimeMs = ref(0); +const durationMs = ref(0); +const rangePercent = computed({ + get: () => { + return (elapsedTimeMs.value / durationMs.value) || 0; + }, + set: (to) => { + if (!audioEl.value) return; + audioEl.value.currentTime = to * durationMs.value / 1000; + }, +}); +const volume = ref(.25); +const bufferedEnd = ref(0); +const bufferedDataRatio = computed(() => { + if (!audioEl.value) return 0; + return bufferedEnd.value / audioEl.value.duration; +}); + +// MediaControl Events +function togglePlayPause() { + if (!isReady.value || !audioEl.value) return; + + if (isPlaying.value) { + audioEl.value.pause(); + isPlaying.value = false; + } else { + audioEl.value.play(); + isPlaying.value = true; + oncePlayed.value = true; + } +} + +function toggleMute() { + if (volume.value === 0) { + volume.value = .25; + } else { + volume.value = 0; + } +} + +let onceInit = false; +let stopAudioElWatch: () => void; + +function init() { + if (onceInit) return; + onceInit = true; + + stopAudioElWatch = watch(audioEl, () => { + if (audioEl.value) { + isReady.value = true; + + function updateMediaTick() { + if (audioEl.value) { + try { + bufferedEnd.value = audioEl.value.buffered.end(0); + } catch (err) { + bufferedEnd.value = 0; + } + + elapsedTimeMs.value = audioEl.value.currentTime * 1000; + } + window.requestAnimationFrame(updateMediaTick); + } + + updateMediaTick(); + + audioEl.value.addEventListener('play', () => { + isActuallyPlaying.value = true; + }); + + audioEl.value.addEventListener('pause', () => { + isActuallyPlaying.value = false; + isPlaying.value = false; + }); + + audioEl.value.addEventListener('ended', () => { + oncePlayed.value = false; + isActuallyPlaying.value = false; + isPlaying.value = false; + }); + + durationMs.value = audioEl.value.duration * 1000; + audioEl.value.addEventListener('durationchange', () => { + if (audioEl.value) { + durationMs.value = audioEl.value.duration * 1000; + } + }); + + audioEl.value.volume = volume.value; + } + }, { + immediate: true, + }); +} + +watch(volume, (to) => { + if (audioEl.value) audioEl.value.volume = to; +}); + +onMounted(() => { + init(); +}); + +onActivated(() => { + init(); +}); + +onDeactivated(() => { + isReady.value = false; + isPlaying.value = false; + isActuallyPlaying.value = false; + elapsedTimeMs.value = 0; + durationMs.value = 0; + bufferedEnd.value = 0; + hide.value = (defaultStore.state.nsfw === 'force' || defaultStore.state.dataSaver.media) ? true : (props.audio.isSensitive && defaultStore.state.nsfw !== 'ignore'); + stopAudioElWatch(); + onceInit = false; +}); +</script> + +<style lang="scss" module> +.audioContainer { + container-type: inline-size; + position: relative; + border: .5px solid var(--divider); + border-radius: var(--radius); + overflow: clip; +} + +.sensitive { + position: relative; + + &::after { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; + border-radius: inherit; + box-shadow: inset 0 0 0 4px var(--warn); + } +} + +.hidden { + width: 100%; + background: #000; + border: none; + outline: none; + font: inherit; + color: inherit; + cursor: pointer; + padding: 12px 0; + display: flex; + align-items: center; + justify-content: center; +} + +.hiddenTextWrapper { + text-align: center; + font-size: 0.8em; + color: #fff; +} + +.audioControls { + display: grid; + grid-template-areas: + "left time . volume right" + "seekbar seekbar seekbar seekbar seekbar"; + grid-template-columns: auto auto 1fr auto auto; + align-items: center; + gap: 4px 8px; + padding: 10px; +} + +.controlsChild { + display: flex; + align-items: center; + gap: 4px; + + .controlButton { + padding: 6px; + border-radius: calc(var(--radius) / 2); + font-size: 1.05rem; + + &:hover { + color: var(--accent); + background-color: var(--accentedBg); + } + } +} + +.controlsLeft { + grid-area: left; +} + +.controlsRight { + grid-area: right; +} + +.controlsTime { + grid-area: time; + font-size: .9rem; +} + +.controlsVolume { + grid-area: volume; + + .volumeSeekbar { + display: none; + } +} + +.seekbarRoot { + grid-area: seekbar; +} + +@container (min-width: 500px) { + .audioControls { + grid-template-areas: "left seekbar time volume right"; + grid-template-columns: auto 1fr auto auto auto; + } + + .controlsVolume { + .volumeSeekbar { + max-width: 90px; + display: block; + flex-grow: 1; + } + } +} +</style> diff --git a/packages/frontend/src/components/MkMediaBanner.vue b/packages/frontend/src/components/MkMediaBanner.vue index 7b0387cefe..605c1a4c80 100644 --- a/packages/frontend/src/components/MkMediaBanner.vue +++ b/packages/frontend/src/components/MkMediaBanner.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -10,15 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only <b>{{ i18n.ts.sensitive }}</b> <span>{{ i18n.ts.clickToShow }}</span> </div> - <div v-else-if="media.type.startsWith('audio') && media.type !== 'audio/midi'" :class="$style.audio"> - <audio - ref="audioEl" - :src="media.url" - :title="media.comment ?? undefined" - controls - preload="metadata" - /> - </div> + <MkMediaAudio v-else-if="media.type.startsWith('audio') && media.type !== 'audio/midi'" :audio="media"/> <a v-else :class="$style.download" :href="media.url" @@ -35,6 +27,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { shallowRef, watch, ref } from 'vue'; import * as Misskey from 'misskey-js'; import { i18n } from '@/i18n.js'; +import MkMediaAudio from '@/components/MkMediaAudio.vue'; const props = withDefaults(defineProps<{ media: Misskey.entities.DriveFile; diff --git a/packages/frontend/src/components/MkMediaImage.vue b/packages/frontend/src/components/MkMediaImage.vue index ef57cea32a..3f9cff8b71 100644 --- a/packages/frontend/src/components/MkMediaImage.vue +++ b/packages/frontend/src/components/MkMediaImage.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -43,7 +43,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-if="['image/gif', 'image/apng'].includes(image.type)" :class="$style.indicator">GIF</div> <div v-if="image.comment" :class="$style.indicator">ALT</div> <div v-if="image.isSensitive" :class="$style.indicator" style="color: var(--warn);" :title="i18n.ts.sensitive"><i class="ph-eye-closed ph-bold ph-lg"></i></div> - <div v-if="!image.comment" :class="$style.indicator" title="Image lacks descriptive text"><i class="ph-pencil ph-bold ph-lg-off"></i></div> + <div v-if="!image.comment" :class="$style.indicator" title="Image lacks descriptive text"><i class="ph-pencil-simple ph-bold ph-lg-off"></i></div> </div> <button :class="$style.menu" class="_button" @click.stop="showMenu"><i class="ph-dots-three ph-bold ph-lg" style="vertical-align: middle;"></i></button> <i class="ph-eye-slash ph-bold ph-lg" :class="$style.hide" @click.stop="hide = true"></i> diff --git a/packages/frontend/src/components/MkMediaList.vue b/packages/frontend/src/components/MkMediaList.vue index 8f73018734..3bf44aea8e 100644 --- a/packages/frontend/src/components/MkMediaList.vue +++ b/packages/frontend/src/components/MkMediaList.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -54,7 +54,7 @@ const count = computed(() => props.mediaList.filter(media => previewable(media)) let lightbox: PhotoSwipeLightbox | null; const popstateHandler = (): void => { - if (lightbox.pswp && lightbox.pswp.isOpen === true) { + if (lightbox?.pswp && lightbox.pswp.isOpen === true) { lightbox.pswp.close(); } }; @@ -69,7 +69,10 @@ async function calcAspectRatio() { return; } - const ratioMax = (ratio: number) => `${Math.max(ratio, img.properties.width / img.properties.height).toString()} / 1`; + const ratioMax = (ratio: number) => { + if (img.properties.width == null || img.properties.height == null) return ''; + return `${Math.max(ratio, img.properties.width / img.properties.height).toString()} / 1`; + }; switch (defaultStore.state.mediaListWithOneImageAppearance) { case '16_9': @@ -145,7 +148,7 @@ onMounted(() => { // element is children const { element } = itemData; - const id = element.dataset.id; + const id = element?.dataset.id; const file = props.mediaList.find(media => media.id === id); if (!file) return; @@ -155,14 +158,14 @@ onMounted(() => { if (file.properties.orientation != null && file.properties.orientation >= 5) { [itemData.w, itemData.h] = [itemData.h, itemData.w]; } - itemData.msrc = file.thumbnailUrl; + itemData.msrc = file.thumbnailUrl ?? undefined; itemData.alt = file.comment ?? undefined; itemData.comment = file.comment; itemData.thumbCropped = true; }); lightbox.on('uiRegister', () => { - lightbox.pswp.ui.registerElement({ + lightbox?.pswp?.ui?.registerElement({ name: 'altText', className: 'pwsp__alt-text-container', appendTo: 'wrapper', @@ -178,7 +181,7 @@ onMounted(() => { textBox.style.display = 'none'; } - textBox.textContent = pwsp.currSlide.data.comment; + textBox.textContent = pwsp.currSlide?.data.comment; }); }, }); diff --git a/packages/frontend/src/components/MkMediaRange.vue b/packages/frontend/src/components/MkMediaRange.vue new file mode 100644 index 0000000000..86ed8ba2cf --- /dev/null +++ b/packages/frontend/src/components/MkMediaRange.vue @@ -0,0 +1,152 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<!-- Media系専用のinput range --> +<template> +<div :style="sliderBgWhite ? '--sliderBg: rgba(255,255,255,.25);' : '--sliderBg: var(--scrollbarHandle);'"> + <div :class="$style.controlsSeekbar"> + <progress v-if="buffer !== undefined" :class="$style.buffer" :value="isNaN(buffer) ? 0 : buffer" min="0" max="1">{{ Math.round(buffer * 100) }}% buffered</progress> + <input v-model="model" :class="$style.seek" :style="`--value: ${modelValue * 100}%;`" type="range" min="0" max="1" step="any" @change="emit('dragEnded', modelValue)"/> + </div> +</div> +</template> + +<script setup lang="ts"> +import { computed, ModelRef } from 'vue'; + +withDefaults(defineProps<{ + buffer?: number; + sliderBgWhite?: boolean; +}>(), { + buffer: undefined, + sliderBgWhite: false, +}); + +const emit = defineEmits<{ + (ev: 'dragEnded', value: number): void; +}>(); + +// eslint-disable-next-line no-undef +const model = defineModel({ required: true }) as ModelRef<string | number>; +const modelValue = computed({ + get: () => typeof model.value === 'number' ? model.value : parseFloat(model.value), + set: v => { model.value = v; }, +}); +</script> + +<style lang="scss" module> +.controlsSeekbar { + position: relative; +} + +.seek { + position: relative; + -webkit-appearance: none; + appearance: none; + background: transparent; + border: 0; + border-radius: 26px; + color: var(--accent); + display: block; + height: 19px; + margin: 0; + min-width: 0; + padding: 0; + transition: box-shadow .3s ease; + width: 100%; + + &::-webkit-slider-runnable-track { + background-color: var(--sliderBg); + background-image: linear-gradient(to right,currentColor var(--value,0),transparent var(--value,0)); + border: 0; + border-radius: 99rem; + height: 5px; + transition: box-shadow .3s ease; + user-select: none; + } + + &::-moz-range-track { + background: transparent; + border: 0; + border-radius: 99rem; + height: 5px; + transition: box-shadow .3s ease; + user-select: none; + background-color: var(--sliderBg); + } + + &::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + background: #fff; + border: 0; + border-radius: 100%; + box-shadow: 0 1px 1px rgba(35, 40, 47, .15),0 0 0 1px rgba(35, 40, 47, .2); + height: 13px; + margin-top: -4px; + position: relative; + transition: all .2s ease; + width: 13px; + + &:active { + box-shadow: 0 1px 1px rgba(35, 40, 47, .15), 0 0 0 1px rgba(35, 40, 47, .15), 0 0 0 3px rgba(255, 255, 255, .5); + } + } + + &::-moz-range-thumb { + background: #fff; + border: 0; + border-radius: 100%; + box-shadow: 0 1px 1px rgba(35, 40, 47, .15),0 0 0 1px rgba(35, 40, 47, .2); + height: 13px; + position: relative; + transition: all .2s ease; + width: 13px; + + &:active { + box-shadow: 0 1px 1px rgba(35, 40, 47, .15), 0 0 0 1px rgba(35, 40, 47, .15), 0 0 0 3px rgba(255, 255, 255, .5); + } + } + + &::-moz-range-progress { + background: currentColor; + border-radius: 99rem; + height: 5px; + } +} + +.buffer { + appearance: none; + background: transparent; + color: var(--sliderBg); + border: 0; + border-radius: 99rem; + height: 5px; + left: 0; + margin-top: -2.5px; + padding: 0; + position: absolute; + top: 50%; + width: 100%; + + &::-webkit-progress-bar { + background: transparent; + } + + &::-webkit-progress-value { + background: currentColor; + border-radius: 100px; + min-width: 5px; + transition: width .2s ease; + } + + &::-moz-progress-bar { + background: currentColor; + border-radius: 100px; + min-width: 5px; + transition: width .2s ease; + } +} +</style> diff --git a/packages/frontend/src/components/MkMediaVideo.vue b/packages/frontend/src/components/MkMediaVideo.vue index a1950b110a..7c14ade130 100644 --- a/packages/frontend/src/components/MkMediaVideo.vue +++ b/packages/frontend/src/components/MkMediaVideo.vue @@ -1,71 +1,351 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div v-if="hide" :class="[$style.hidden, (video.isSensitive && defaultStore.state.highlightSensitiveMedia) && $style.sensitiveContainer]" @click="hide = false"> - <!-- 【注意】dataSaverMode が有効になっている際には、hide が false になるまでサムネイルや動画を読み込まないようにすること --> - <div :class="$style.sensitive"> - <b v-if="video.isSensitive" style="display: block;"><i class="ph-warning ph-bold ph-lg"></i> {{ i18n.ts.sensitive }}{{ defaultStore.state.dataSaver.media ? ` (${i18n.ts.video}${video.size ? ' ' + bytes(video.size) : ''})` : '' }}</b> - <b v-else style="display: block;"><i class="ph-film-strip ph-bold ph-lg"></i> {{ defaultStore.state.dataSaver.media && video.size ? bytes(video.size) : i18n.ts.video }}</b> - <span>{{ i18n.ts.clickToShow }}</span> - </div> -</div> -<div v-else :class="[$style.visible, (video.isSensitive && defaultStore.state.highlightSensitiveMedia) && $style.sensitiveContainer]"> - <video - ref="videoEl" - :class="$style.video" - :poster="video.thumbnailUrl" - :title="video.comment ?? undefined" - :alt="video.comment" - preload="none" - controls - @contextmenu.stop - > - <source - :src="video.url" +<div + ref="playerEl" + :class="[ + $style.videoContainer, + controlsShowing && $style.active, + (video.isSensitive && defaultStore.state.highlightSensitiveMedia) && $style.sensitive, + ]" + @mouseover="onMouseOver" + @mouseleave="onMouseLeave" + @contextmenu.stop +> + <button v-if="hide" :class="$style.hidden" @click="hide = false"> + <div :class="$style.hiddenTextWrapper"> + <b v-if="video.isSensitive" style="display: block;"><i class="ph-warning ph-bold ph-lg"></i> {{ i18n.ts.sensitive }}{{ defaultStore.state.dataSaver.media ? ` (${i18n.ts.video}${video.size ? ' ' + bytes(video.size) : ''})` : '' }}</b> + <b v-else style="display: block;"><i class="ph-film-strip ph-bold ph-lg"></i> {{ defaultStore.state.dataSaver.media && video.size ? bytes(video.size) : i18n.ts.video }}</b> + <span style="display: block;">{{ i18n.ts.clickToShow }}</span> + </div> + </button> + <div v-else :class="$style.videoRoot" @click.self="togglePlayPause"> + <video + ref="videoEl" + :class="$style.video" + :poster="video.thumbnailUrl ?? undefined" + :title="video.comment ?? undefined" + :alt="video.comment" + preload="metadata" + playsinline > - </video> - <i class="ph-eye-slash ph-bold ph-lg" :class="$style.hide" @click="hide = true"></i> + <source :src="video.url"> + </video> + <button v-if="isReady && !isPlaying" class="_button" :class="$style.videoOverlayPlayButton" @click="togglePlayPause"><i class="ph-play ph-bold ph-lg"></i></button> + <div v-else-if="!isActuallyPlaying" :class="$style.videoLoading"> + <MkLoading/> + </div> + <i class="ph-eye-closed ph-bold ph-lg" :class="$style.hide" @click="hide = true"></i> + <div :class="$style.indicators"> + <div v-if="video.comment" :class="$style.indicator">ALT</div> + <div v-if="video.isSensitive" :class="$style.indicator" style="color: var(--warn);" :title="i18n.ts.sensitive"><i class="ph-warning ph-bold ph-lg"></i></div> + </div> + <div :class="$style.videoControls" @click.self="togglePlayPause"> + <div :class="[$style.controlsChild, $style.controlsLeft]"> + <button class="_button" :class="$style.controlButton" @click="togglePlayPause"> + <i v-if="isPlaying" class="ph-pause ph-bold ph-lg"></i> + <i v-else class="ph-play ph-bold ph-lg"></i> + </button> + </div> + <div :class="[$style.controlsChild, $style.controlsRight]"> + <a class="_button" :class="$style.controlButton" :href="video.url" :download="video.name" target="_blank"> + <i class="ph-download ph-bold ph-lg"></i> + </a> + <button class="_button" :class="$style.controlButton" @click="showMenu"> + <i class="ph-gear ph-bold ph-lg"></i> + </button> + <button class="_button" :class="$style.controlButton" @click="toggleFullscreen"> + <i v-if="isFullscreen" class="ph-arrows-in ph-bold ph-lg"></i> + <i v-else class="ph-arrows-out ph-bold ph-lg"></i> + </button> + </div> + <div :class="[$style.controlsChild, $style.controlsTime]">{{ hms(elapsedTimeMs) }}</div> + <div :class="[$style.controlsChild, $style.controlsVolume]"> + <button class="_button" :class="$style.controlButton" @click="toggleMute"> + <i v-if="volume === 0" class="ph-speaker-x ph-bold ph-lg"></i> + <i v-else class="ph-speaker-high ph-bold ph-lg"></i> + </button> + <MkMediaRange + v-model="volume" + :sliderBgWhite="true" + :class="$style.volumeSeekbar" + /> + </div> + <MkMediaRange + v-model="rangePercent" + :sliderBgWhite="true" + :class="$style.seekbarRoot" + :buffer="bufferedDataRatio" + /> + </div> + </div> </div> </template> <script lang="ts" setup> -import { ref, shallowRef, watch } from 'vue'; +import { ref, shallowRef, computed, watch, onDeactivated, onActivated, onMounted } from 'vue'; import * as Misskey from 'misskey-js'; +import type { MenuItem } from '@/types/menu.js'; import bytes from '@/filters/bytes.js'; +import { hms } from '@/filters/hms.js'; import { defaultStore } from '@/store.js'; import { i18n } from '@/i18n.js'; +import * as os from '@/os.js'; +import { isFullscreenNotSupported } from '@/scripts/device-kind.js'; import hasAudio from '@/scripts/media-has-audio.js'; +import MkMediaRange from '@/components/MkMediaRange.vue'; +import { iAmModerator } from '@/account.js'; const props = defineProps<{ video: Misskey.entities.DriveFile; }>(); +// eslint-disable-next-line vue/no-setup-props-destructure const hide = ref((defaultStore.state.nsfw === 'force' || defaultStore.state.dataSaver.media) ? true : (props.video.isSensitive && defaultStore.state.nsfw !== 'ignore')); +// Menu +const menuShowing = ref(false); + +function showMenu(ev: MouseEvent) { + let menu: MenuItem[] = []; + + menu = [ + // TODO: 再生キューに追加 + { + text: i18n.ts.hide, + icon: 'ph-eye-closed ph-bold ph-lg', + action: () => { + hide.value = true; + }, + }, + ]; + + if (iAmModerator) { + menu.push({ + type: 'divider', + }, { + text: props.video.isSensitive ? i18n.ts.unmarkAsSensitive : i18n.ts.markAsSensitive, + icon: props.video.isSensitive ? 'ph-eye ph-bold ph-lg' : 'ph-eye-slash ph-bold ph-lg', + danger: true, + action: () => toggleSensitive(props.video), + }); + } + + menuShowing.value = true; + os.popupMenu(menu, ev.currentTarget ?? ev.target, { + align: 'right', + onClosing: () => { + menuShowing.value = false; + }, + }); +} + +function toggleSensitive(file: Misskey.entities.DriveFile) { + os.apiWithDialog('drive/files/update', { + fileId: file.id, + isSensitive: !file.isSensitive, + }); +} + +// MediaControl: Video State const videoEl = shallowRef<HTMLVideoElement>(); +const playerEl = shallowRef<HTMLDivElement>(); +const isHoverring = ref(false); +const controlsShowing = computed(() => { + if (!oncePlayed.value) return true; + if (isHoverring.value) return true; + if (menuShowing.value) return true; + return false; +}); +const isFullscreen = ref(false); +let controlStateTimer: string | number; -watch(videoEl, () => { - if (videoEl.value) { - videoEl.value.volume = 0.3; - hasAudio(videoEl.value).then(had => { - if (!had) { - videoEl.value.loop = videoEl.value.muted = true; - videoEl.value.play(); +// MediaControl: Common State +const oncePlayed = ref(false); +const isReady = ref(false); +const isPlaying = ref(false); +const isActuallyPlaying = ref(false); +const elapsedTimeMs = ref(0); +const durationMs = ref(0); +const rangePercent = computed({ + get: () => { + return (elapsedTimeMs.value / durationMs.value) || 0; + }, + set: (to) => { + if (!videoEl.value) return; + videoEl.value.currentTime = to * durationMs.value / 1000; + }, +}); +const volume = ref(.25); +const bufferedEnd = ref(0); +const bufferedDataRatio = computed(() => { + if (!videoEl.value) return 0; + return bufferedEnd.value / videoEl.value.duration; +}); + +// MediaControl Events +function onMouseOver() { + if (controlStateTimer) { + clearTimeout(controlStateTimer); + } + isHoverring.value = true; +} + +function onMouseLeave() { + controlStateTimer = window.setTimeout(() => { + isHoverring.value = false; + }, 100); +} + +function togglePlayPause() { + if (!isReady.value || !videoEl.value) return; + + if (isPlaying.value) { + videoEl.value.pause(); + isPlaying.value = false; + } else { + videoEl.value.play(); + isPlaying.value = true; + oncePlayed.value = true; + } +} + +function toggleFullscreen() { + if (isFullscreenNotSupported && videoEl.value) { + if (isFullscreen.value) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + videoEl.value.webkitExitFullscreen(); + isFullscreen.value = false; + } else { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + videoEl.value.webkitEnterFullscreen(); + isFullscreen.value = true; + } + } else if (playerEl.value) { + if (isFullscreen.value) { + document.exitFullscreen(); + isFullscreen.value = false; + } else { + playerEl.value.requestFullscreen({ navigationUI: 'hide' }); + isFullscreen.value = true; + } + } +} + +function toggleMute() { + if (volume.value === 0) { + volume.value = .25; + } else { + volume.value = 0; + } +} + +let onceInit = false; +let stopVideoElWatch: () => void; + +function init() { + if (onceInit) return; + onceInit = true; + + stopVideoElWatch = watch(videoEl, () => { + if (videoEl.value) { + isReady.value = true; + + function updateMediaTick() { + if (videoEl.value) { + try { + bufferedEnd.value = videoEl.value.buffered.end(0); + } catch (err) { + bufferedEnd.value = 0; + } + + elapsedTimeMs.value = videoEl.value.currentTime * 1000; + } + window.requestAnimationFrame(updateMediaTick); } - }); + + updateMediaTick(); + + videoEl.value.addEventListener('play', () => { + isActuallyPlaying.value = true; + }); + + videoEl.value.addEventListener('pause', () => { + isActuallyPlaying.value = false; + isPlaying.value = false; + }); + + videoEl.value.addEventListener('ended', () => { + oncePlayed.value = false; + isActuallyPlaying.value = false; + isPlaying.value = false; + }); + + durationMs.value = videoEl.value.duration * 1000; + videoEl.value.addEventListener('durationchange', () => { + if (videoEl.value) { + durationMs.value = videoEl.value.duration * 1000; + } + }); + + videoEl.value.volume = volume.value; + hasAudio(videoEl.value).then(had => { + if (!had && videoEl.value) { + videoEl.value.loop = videoEl.value.muted = true; + videoEl.value.play(); + } + }); + } + }, { + immediate: true, + }); +} + +watch(volume, (to) => { + if (videoEl.value) videoEl.value.volume = to; +}); + +watch(hide, (to) => { + if (to && isFullscreen.value) { + document.exitFullscreen(); + isFullscreen.value = false; } }); + +onMounted(() => { + init(); +}); + +onActivated(() => { + init(); +}); + +onDeactivated(() => { + isReady.value = false; + isPlaying.value = false; + isActuallyPlaying.value = false; + elapsedTimeMs.value = 0; + durationMs.value = 0; + bufferedEnd.value = 0; + hide.value = (defaultStore.state.nsfw === 'force' || defaultStore.state.dataSaver.media) ? true : (props.video.isSensitive && defaultStore.state.nsfw !== 'ignore'); + stopVideoElWatch(); + onceInit = false; +}); </script> <style lang="scss" module> -.visible { +.videoContainer { + container-type: inline-size; position: relative; + overflow: clip; } -.sensitiveContainer { +.sensitive { position: relative; &::after { @@ -81,45 +361,199 @@ watch(videoEl, () => { } } +.indicators { + display: inline-flex; + position: absolute; + top: 10px; + left: 10px; + pointer-events: none; + opacity: .5; + gap: 6px; +} + +.indicator { + /* Hardcode to black because either --bg or --fg makes it hard to read in dark/light mode */ + background-color: black; + border-radius: 6px; + color: var(--accentLighten); + display: inline-block; + font-weight: bold; + font-size: 0.8em; + padding: 2px 5px; +} + .hide { display: block; position: absolute; border-radius: var(--radius-sm); background-color: black; color: var(--accentLighten); - font-size: 14px; + font-size: 12px; opacity: .5; - padding: 3px 6px; + padding: 5px 8px; text-align: center; cursor: pointer; top: 12px; right: 12px; } -.video { +.hidden { + width: 100%; + height: 100%; + background: #000; + border: none; + outline: none; + font: inherit; + color: inherit; + cursor: pointer; + padding: 120px 0; display: flex; - justify-content: center; align-items: center; - font-size: 3.5em; - overflow: hidden; - background-position: center; - background-size: cover; + justify-content: center; +} + +.hiddenTextWrapper { + text-align: center; + font-size: 0.8em; + color: #fff; +} + +.videoRoot { + background: #000; + position: relative; width: 100%; height: 100%; + object-fit: contain; } -.hidden { +.video { + display: block; + height: 100%; + width: 100%; +} + +.videoOverlayPlayButton { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%,-50%); + + opacity: 0; + transition: opacity .4s ease-in-out; + + background: var(--accent); + color: #fff; + padding: 1rem; + border-radius: 99rem; + + font-size: 1.1rem; +} + +.videoLoading { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; display: flex; + align-items: center; justify-content: center; +} + +.videoControls { + display: grid; + grid-template-areas: + "left time . volume right" + "seekbar seekbar seekbar seekbar seekbar"; + grid-template-columns: auto auto 1fr auto auto; + align-items: center; + gap: 4px 8px; + + padding: 35px 10px 10px 10px; + background: linear-gradient(rgba(0, 0, 0, 0),rgba(0, 0, 0, .75)); + + position: absolute; + left: 0; + right: 0; + bottom: 0; + + transform: translateY(100%); + pointer-events: none; + opacity: 0; + transition: opacity .4s ease-in-out, transform .4s ease-in-out; +} + +.active { + .videoControls { + transform: translateY(0); + opacity: 1; + pointer-events: auto; + } + + .videoOverlayPlayButton { + opacity: 1; + } +} + +.controlsChild { + display: flex; align-items: center; - background: #111; + gap: 4px; color: #fff; + + .controlButton { + padding: 6px; + border-radius: calc(var(--radius) / 2); + transition: background-color .2s ease-in-out; + font-size: 1.05rem; + + &:hover { + background-color: var(--accent); + } + } } -.sensitive { - display: table-cell; - text-align: center; - font-size: 12px; +.controlsLeft { + grid-area: left; +} + +.controlsRight { + grid-area: right; +} + +.controlsTime { + grid-area: time; + font-size: .9rem; +} + +.controlsVolume { + grid-area: volume; + + .volumeSeekbar { + display: none; + } +} + +.seekbarRoot { + grid-area: seekbar; + /* ▼シークバー操作をやりやすくするためにクリックイベントが伝播されないエリアを拡張する */ + margin: -10px; + padding: 10px; +} + +@container (min-width: 500px) { + .videoControls { + grid-template-areas: "left seekbar time volume right"; + grid-template-columns: auto 1fr auto auto auto; + } + + .controlsVolume { + .volumeSeekbar { + max-width: 90px; + display: block; + flex-grow: 1; + } + } } .indicators { display: inline-flex; diff --git a/packages/frontend/src/components/MkMention.vue b/packages/frontend/src/components/MkMention.vue index 4d42053657..942c23a145 100644 --- a/packages/frontend/src/components/MkMention.vue +++ b/packages/frontend/src/components/MkMention.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -51,6 +51,7 @@ const avatarUrl = computed(() => defaultStore.state.disableShowingAnimatedImages padding: 4px 8px 4px 4px; border-radius: var(--radius-ellipse); color: var(--mention); + white-space: nowrap; &.isMe { color: var(--mentionMe); diff --git a/packages/frontend/src/components/MkMenu.child.vue b/packages/frontend/src/components/MkMenu.child.vue index 962dcd91eb..dfb6d34618 100644 --- a/packages/frontend/src/components/MkMenu.child.vue +++ b/packages/frontend/src/components/MkMenu.child.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -33,6 +33,7 @@ const align = 'left'; const SCROLLBAR_THICKNESS = 16; function setPosition() { + if (el.value == null) return; const rootRect = props.rootElement.getBoundingClientRect(); const parentRect = props.targetElement.getBoundingClientRect(); const myRect = el.value.getBoundingClientRect(); @@ -66,7 +67,7 @@ const ro = new ResizeObserver((entries, observer) => { }); onMounted(() => { - ro.observe(el.value); + if (el.value) ro.observe(el.value); setPosition(); nextTick(() => { setPosition(); @@ -79,7 +80,7 @@ onUnmounted(() => { defineExpose({ checkHit: (ev: MouseEvent) => { - return (ev.target === el.value || el.value.contains(ev.target)); + return (ev.target === el.value || el.value?.contains(ev.target as Node)); }, }); </script> diff --git a/packages/frontend/src/components/MkMenu.vue b/packages/frontend/src/components/MkMenu.vue index 5f48f43bfb..8395879d02 100644 --- a/packages/frontend/src/components/MkMenu.vue +++ b/packages/frontend/src/components/MkMenu.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only :style="{ width: (width && !asDrawer) ? width + 'px' : '', maxHeight: maxHeight ? maxHeight + 'px' : '' }" @contextmenu.self="e => e.preventDefault()" > - <template v-for="(item, i) in items2"> + <template v-for="(item, i) in (items2 ?? [])"> <div v-if="item.type === 'divider'" role="separator" :class="$style.divider"></div> <span v-else-if="item.type === 'label'" role="menuitem" :class="[$style.label, $style.item]"> <span style="opacity: 0.7;">{{ item.text }}</span> @@ -54,7 +54,7 @@ SPDX-License-Identifier: AGPL-3.0-only <span :class="$style.caret" style="pointer-events: none;"><i class="ph-caret-right ph-bold ph-lg ti-fw"></i></span> </div> </button> - <button v-else :tabindex="i" class="_button" role="menuitem" :class="[$style.item, { [$style.danger]: item.danger, [$style.active]: item.active }]" :disabled="item.active" @click="clicked(item.action, $event)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)"> + <button v-else :tabindex="i" class="_button" role="menuitem" :class="[$style.item, { [$style.danger]: item.danger, [$style.active]: getValue(item.active) }]" :disabled="getValue(item.active)" @click="clicked(item.action, $event)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)"> <i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i> <MkAvatar v-if="item.avatar" :user="item.avatar" :class="$style.avatar"/> <div :class="$style.item_content"> @@ -63,18 +63,18 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </button> </template> - <span v-if="items2.length === 0" :class="[$style.none, $style.item]"> + <span v-if="items2 == null || items2.length === 0" :class="[$style.none, $style.item]"> <span>{{ i18n.ts.none }}</span> </span> </div> <div v-if="childMenu"> - <XChild ref="child" :items="childMenu" :targetElement="childTarget" :rootElement="itemsEl" showing @actioned="childActioned" @close="close(false)"/> + <XChild ref="child" :items="childMenu" :targetElement="childTarget!" :rootElement="itemsEl!" showing @actioned="childActioned" @close="close(false)"/> </div> </div> </template> <script lang="ts"> -import { computed, defineAsyncComponent, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'; +import { ComputedRef, computed, defineAsyncComponent, isRef, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'; import { focusPrev, focusNext } from '@/scripts/focus.js'; import MkSwitchButton from '@/components/MkSwitch.button.vue'; import { MenuItem, InnerMenuItem, MenuPending, MenuAction, MenuSwitch, MenuParent } from '@/types/menu.js'; @@ -104,7 +104,7 @@ const emit = defineEmits<{ const itemsEl = shallowRef<HTMLDivElement>(); -const items2 = ref<InnerMenuItem[]>([]); +const items2 = ref<InnerMenuItem[]>(); const child = shallowRef<InstanceType<typeof XChild>>(); @@ -119,15 +119,15 @@ const childShowingItem = ref<MenuItem | null>(); let preferClick = isTouchUsing || props.asDrawer; watch(() => props.items, () => { - const items: (MenuItem | MenuPending)[] = [...props.items].filter(item => item !== undefined); + const items = [...props.items].filter(item => item !== undefined) as (NonNullable<MenuItem> | MenuPending)[]; for (let i = 0; i < items.length; i++) { const item = items[i]; - if (item && 'then' in item) { // if item is Promise + if ('then' in item) { // if item is Promise items[i] = { type: 'pending' }; item.then(actualItem => { - items2.value[i] = actualItem; + if (items2.value?.[i]) items2.value[i] = actualItem; }); } } @@ -151,7 +151,7 @@ function childActioned() { } const onGlobalMousedown = (event: MouseEvent) => { - if (childTarget.value && (event.target === childTarget.value || childTarget.value.contains(event.target))) return; + if (childTarget.value && (event.target === childTarget.value || childTarget.value.contains(event.target as Node))) return; if (child.value && child.value.checkHit(event)) return; closeChild(); }; @@ -169,7 +169,7 @@ function onItemMouseLeave(item) { } async function showChildren(item: MenuParent, ev: MouseEvent) { - const children = await (async () => { + const children: MenuItem[] = await (async () => { if (childrenCache.has(item)) { return childrenCache.get(item)!; } else { @@ -189,7 +189,7 @@ async function showChildren(item: MenuParent, ev: MouseEvent) { }); emit('hide'); } else { - childTarget.value = ev.currentTarget ?? ev.target; + childTarget.value = (ev.currentTarget ?? ev.target) as HTMLElement; // これでもリアクティビティは保たれる childMenu.value = children; childShowingItem.value = item; @@ -218,6 +218,10 @@ function switchItem(item: MenuSwitch & { ref: any }) { item.ref = !item.ref; } +function getValue<T>(item?: ComputedRef<T> | T) { + return isRef(item) ? item.value : item; +} + onMounted(() => { if (props.viaKeyboard) { nextTick(() => { @@ -450,7 +454,7 @@ onBeforeUnmount(() => { align-items: center; color: var(--indicator); font-size: 12px; - animation: blink 1s infinite; + animation: global-blink 1s infinite; } .divider { diff --git a/packages/frontend/src/components/MkMiniChart.vue b/packages/frontend/src/components/MkMiniChart.vue index f0a2c232bd..f2f2bf47a8 100644 --- a/packages/frontend/src/components/MkMiniChart.vue +++ b/packages/frontend/src/components/MkMiniChart.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -22,8 +22,8 @@ SPDX-License-Identifier: AGPL-3.0-only stroke-width="2" /> <circle - :cx="headX" - :cy="headY" + :cx="headX ?? undefined" + :cy="headY ?? undefined" r="3" :fill="color" /> diff --git a/packages/frontend/src/components/MkModPlayer.vue b/packages/frontend/src/components/MkModPlayer.vue index f61144cbca..75053cbc37 100644 --- a/packages/frontend/src/components/MkModPlayer.vue +++ b/packages/frontend/src/components/MkModPlayer.vue @@ -7,14 +7,17 @@ </div> <div v-else class="mod-player-enabled"> - <div class="pattern-display" @click="togglePattern()"> + <div class="pattern-display" @click="togglePattern()" @scroll="scrollHandler" @scrollend="scrollEndHandle"> <div v-if="patternHide" class="pattern-hide"> <b><i class="ph-eye ph-bold ph-lg"></i> Pattern Hidden</b> <span>{{ i18n.ts.clickToShow }}</span> </div> + <span class="patternShadowTop"></span> + <span class="patternShadowBottom"></span> <canvas ref="displayCanvas" class="pattern-canvas"></canvas> </div> <div class="controls"> + <input v-if="patternScrollSliderShow" ref="patternScrollSlider" v-model="patternScrollSliderPos" class="pattern-slider" type="range" min="0" max="100" step="0.01" style=""/> <button class="play" @click="playPause()"> <i v-if="playing" class="ph-pause ph-bold ph-lg"></i> <i v-else class="ph-play ph-bold ph-lg"></i> @@ -33,44 +36,31 @@ </template> <script lang="ts" setup> -import { ref, nextTick, computed } from 'vue'; +import { ref, nextTick, computed, watch, onDeactivated, onMounted } from 'vue'; import * as Misskey from 'misskey-js'; import { i18n } from '@/i18n.js'; import { defaultStore } from '@/store.js'; import { ChiptuneJsPlayer, ChiptuneJsConfig } from '@/scripts/chiptune2.js'; - -const CHAR_WIDTH = 6; -const CHAR_HEIGHT = 12; -const ROW_OFFSET_Y = 10; +import { isTouchUsing } from '@/scripts/touch.js'; const colours = { background: '#000000', - default: { - active: '#ffffff', - inactive: '#808080', - }, - quarter: { - active: '#ffff00', - inactive: '#ffe135', - }, - instr: { - active: '#80e0ff', - inactive: '#0099cc', - }, - volume: { - active: '#80ff80', - inactive: '#008000', - }, - fx: { - active: '#ff80e0', - inactive: '#800060', - }, - operant: { - active: '#ffe080', - inactive: '#806000', + foreground: { + default: '#ffffff', + quarter: '#ffff00', + instr: '#80e0ff', + volume: '#80ff80', + fx: '#ff80e0', + operant: '#ffe080', }, }; +const CHAR_WIDTH = 6; +const CHAR_HEIGHT = 12; +const ROW_OFFSET_Y = 10; +const MAX_TIME_SPENT = 50; +const MAX_TIME_PER_ROW = 15; + const props = defineProps<{ module: Misskey.entities.DriveFile }>(); @@ -79,29 +69,57 @@ const isSensitive = computed(() => { return props.module.isSensitive; }); const url = computed(() => { return props.module.url; }); let hide = ref((defaultStore.state.nsfw === 'force') ? true : isSensitive.value && (defaultStore.state.nsfw !== 'ignore')); let patternHide = ref(false); -let firstFrame = ref(true); let playing = ref(false); let displayCanvas = ref<HTMLCanvasElement>(); let progress = ref<HTMLProgressElement>(); let position = ref(0); +let patternScrollSlider = ref<HTMLProgressElement>(); +let patternScrollSliderShow = ref(false); +let patternScrollSliderPos = ref(0); const player = ref(new ChiptuneJsPlayer(new ChiptuneJsConfig())); -const rowBuffer = 24; +const maxRowNumbers = 0xFF; +const rowBuffer = 26; let buffer = null; let isSeeking = false; +let firstFrame = true; +let lastPattern = -1; +let lastDrawnRow = -1; +let numberRowCanvas = new OffscreenCanvas(2 * CHAR_WIDTH + 1, maxRowNumbers * CHAR_HEIGHT + 1); +let alreadyHiddenOnce = false; +let alreadyDrawn = [false]; +let patternTime = { 'current': 0, 'max': 0, 'initial': 0 }; -player.value.load(url.value).then((result) => { - buffer = result; - try { - player.value.play(buffer); - progress.value!.max = player.value.duration(); - display(); - } catch (err) { - console.warn(err); +function bakeNumberRow() { + let ctx = numberRowCanvas.getContext('2d', { alpha: false }) as OffscreenCanvasRenderingContext2D; + ctx.font = '10px monospace'; + + for (let i = 0; i < maxRowNumbers; i++) { + let rowText = i.toString(16); + if (rowText.length === 1) rowText = '0' + rowText; + + ctx.fillStyle = colours.foreground.default; + if (i % 4 === 0) ctx.fillStyle = colours.foreground.quarter; + + ctx.fillText(rowText, 0, 10 + i * 12); } - player.value.stop(); -}).catch((error) => { - console.error(error); +} + +onMounted(() => { + player.value.load(url.value).then((result) => { + buffer = result; + try { + player.value.play(buffer); + progress.value!.max = player.value.duration(); + bakeNumberRow(); + display(); + } catch (err) { + console.warn(err); + } + player.value.stop(); + }).catch((error) => { + console.error(error); + }); }); function playPause() { @@ -133,7 +151,7 @@ function stop(noDisplayUpdate = false) { if (!noDisplayUpdate) { try { player.value.play(buffer); - display(); + display(true); } catch (err) { console.warn(err); } @@ -162,104 +180,256 @@ function performSeek() { function toggleVisible() { hide.value = !hide.value; - if (!hide.value && patternHide.value) { - firstFrame.value = true; - patternHide.value = false; + if (!hide.value) { + lastPattern = -1; + lastDrawnRow = -1; } nextTick(() => { stop(hide.value); }); } function togglePattern() { patternHide.value = !patternHide.value; - if (!patternHide.value) { - if (player.value.getRow() === 0) { - try { - player.value.play(buffer); - display(); - } catch (err) { - console.warn(err); - } - player.value.stop(); + handleScrollBarEnable(); + + if (player.value.getRow() === 0 && player.value.getPattern() === 0) { + try { + player.value.play(buffer); + display(true); + } catch (err) { + console.warn(err); } + player.value.stop(); + } else { + display(true); } } -function display() { - if (!displayCanvas.value) { - stop(); - return; +function drawPattern() { + if (!displayCanvas.value) return; + const canvas = displayCanvas.value; + + const startTime = performance.now(); + const pattern = player.value.getPattern(); + const nbRows = player.value.getPatternNumRows(pattern); + const row = player.value.getRow(); + const halfbuf = rowBuffer / 2; + const minRow = row - halfbuf; + const maxRow = row + halfbuf; + + let rowDif = 0; + + let nbChannels = 0; + if (player.value.currentPlayingNode) { + nbChannels = player.value.currentPlayingNode.nbChannels; } + if (pattern === lastPattern) { + rowDif = row - lastDrawnRow; + } else { + if (patternTime.initial !== 0 && !alreadyHiddenOnce) { + const trackerTime = player.value.currentPlayingNode.getProcessTime(); - if (patternHide.value) return; + if (patternTime.initial + trackerTime.max > MAX_TIME_SPENT && trackerTime.max + patternTime.max > MAX_TIME_PER_ROW) { + alreadyHiddenOnce = true; + togglePattern(); + return; + } + } - if (firstFrame.value) { - firstFrame.value = false; - patternHide.value = true; + patternTime = { 'current': 0, 'max': 0, 'initial': 0 }; + alreadyDrawn = []; + if (canvas.width !== (12 + 84 * nbChannels + 2)) canvas.width = 12 + 84 * nbChannels + 2; + if (canvas.height !== (12 * nbRows)) canvas.height = 12 * nbRows; + } + + const ctx = canvas.getContext('2d', { alpha: false, desynchronized: true }) as CanvasRenderingContext2D; + if (ctx.font !== '10px monospace') ctx.font = '10px monospace'; + ctx.imageSmoothingEnabled = false; + if (pattern !== lastPattern) { + ctx.fillStyle = colours.background; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.drawImage( numberRowCanvas, 0, 0 ); } + ctx.fillStyle = colours.foreground.default; + for (let rowOffset = minRow + rowDif; rowOffset < maxRow + rowDif; rowOffset++) { + const rowToDraw = rowOffset - rowDif; + + if (alreadyDrawn[rowToDraw] === true) continue; + + if (rowToDraw >= 0 && rowToDraw < nbRows) { + const baseOffset = 2 * CHAR_WIDTH; + const baseRowOffset = ROW_OFFSET_Y + rowToDraw * CHAR_HEIGHT; + let done = drawRow(ctx, rowToDraw, nbChannels, pattern, baseOffset, baseRowOffset); + + alreadyDrawn[rowToDraw] = done; + } + } + + lastDrawnRow = row; + lastPattern = pattern; + + patternTime.current = performance.now() - startTime; + if (patternTime.initial !== 0 && patternTime.current > patternTime.max) patternTime.max = patternTime.current; + else if (patternTime.initial === 0) patternTime.initial = patternTime.current; +} + +function drawPetternPreview() { + if (!displayCanvas.value) return; const canvas = displayCanvas.value; const pattern = player.value.getPattern(); + const nbRows = player.value.getPatternNumRows(pattern); const row = player.value.getRow(); + const halfbuf = rowBuffer / 2; + alreadyDrawn = []; + let nbChannels = 0; if (player.value.currentPlayingNode) { nbChannels = player.value.currentPlayingNode.nbChannels; } - if (canvas.width !== 12 + 84 * nbChannels + 2) { - canvas.width = 12 + 84 * nbChannels + 2; - canvas.height = 12 * rowBuffer; - } - const nbRows = player.value.getPatternNumRows(pattern); - const ctx = canvas.getContext('2d') as CanvasRenderingContext2D; + if (canvas.width !== (12 + 84 * nbChannels + 2)) canvas.width = 12 + 84 * nbChannels + 2; + if (canvas.height !== (12 * rowBuffer)) canvas.height = 12 * rowBuffer; + + const ctx = canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D; ctx.font = '10px monospace'; + ctx.imageSmoothingEnabled = false; ctx.fillStyle = colours.background; ctx.fillRect(0, 0, canvas.width, canvas.height); - ctx.fillStyle = colours.default.inactive; + ctx.drawImage( numberRowCanvas, 0, (halfbuf - row) * CHAR_HEIGHT ); + for (let rowOffset = 0; rowOffset < rowBuffer; rowOffset++) { - const rowToDraw = row - rowBuffer / 2 + rowOffset; + const rowToDraw = rowOffset + row - halfbuf; + if (rowToDraw >= 0 && rowToDraw < nbRows) { - const active = (rowToDraw === row) ? 'active' : 'inactive'; - let rowText = parseInt(rowToDraw).toString(16); - if (rowText.length === 1) { - rowText = '0' + rowText; - } - ctx.fillStyle = colours.default[active]; - if (rowToDraw % 4 === 0) { - ctx.fillStyle = colours.quarter[active]; - } - ctx.fillText(rowText, 0, 10 + rowOffset * 12); - for (let channel = 0; channel < nbChannels; channel++) { - const part = player.value.getPatternRowChannel(pattern, rowToDraw, channel); - const baseOffset = (2 + (part.length + 1) * channel) * CHAR_WIDTH; - const baseRowOffset = ROW_OFFSET_Y + rowOffset * CHAR_HEIGHT; + const baseOffset = 2 * CHAR_WIDTH; + const baseRowOffset = ROW_OFFSET_Y + rowOffset * CHAR_HEIGHT; + drawRow(ctx, rowToDraw, nbChannels, pattern, baseOffset, baseRowOffset); + } else if (rowToDraw >= 0) { + const baseRowOffset = ROW_OFFSET_Y + rowOffset * CHAR_HEIGHT; + ctx.fillStyle = colours.background; + ctx.fillRect(0, baseRowOffset - CHAR_HEIGHT, CHAR_WIDTH * 2, baseRowOffset); + } + } - ctx.fillStyle = colours.default[active]; - ctx.fillText('|', baseOffset, baseRowOffset); + lastPattern = -1; + lastDrawnRow = -1; +} - const note = part.substring(0, 3); - ctx.fillStyle = colours.default[active]; - ctx.fillText(note, baseOffset + CHAR_WIDTH, baseRowOffset); +function drawRow(ctx: CanvasRenderingContext2D, row: number, channels: number, pattern: number, drawX = (2 * CHAR_WIDTH), drawY = ROW_OFFSET_Y) { + if (!player.value.currentPlayingNode) return false; + if (alreadyDrawn[row]) return true; + const spacer = 11; + const space = ' '; + let seperators = ''; + let note = ''; + let instr = ''; + let volume = ''; + let fx = ''; + let op = ''; + for (let channel = 0; channel < channels; channel++) { + const part = player.value.getPatternRowChannel(pattern, row, channel); - const instr = part.substring(4, 6); - ctx.fillStyle = colours.instr[active]; - ctx.fillText(instr, baseOffset + CHAR_WIDTH * 5, baseRowOffset); + seperators += '|' + space.repeat( spacer + 2 ); + note += part.substring(0, 3) + space.repeat( spacer ); + instr += part.substring(4, 6) + space.repeat( spacer + 1 ); + volume += part.substring(6, 9) + space.repeat( spacer ); + fx += part.substring(10, 11) + space.repeat( spacer + 2 ); + op += part.substring(11, 13) + space.repeat( spacer + 1 ); + } - const volume = part.substring(6, 9); - ctx.fillStyle = colours.volume[active]; - ctx.fillText(volume, baseOffset + CHAR_WIDTH * 7, baseRowOffset); + ctx.fillStyle = colours.foreground.default; + ctx.fillText(seperators, drawX, drawY); - const fx = part.substring(10, 11); - ctx.fillStyle = colours.fx[active]; - ctx.fillText(fx, baseOffset + CHAR_WIDTH * 11, baseRowOffset); + ctx.fillStyle = colours.foreground.default; + ctx.fillText(note, drawX + CHAR_WIDTH, drawY); - const op = part.substring(11, 13); - ctx.fillStyle = colours.operant[active]; - ctx.fillText(op, baseOffset + CHAR_WIDTH * 12, baseRowOffset); - } - } + ctx.fillStyle = colours.foreground.instr; + ctx.fillText(instr, drawX + CHAR_WIDTH * 5, drawY); + + ctx.fillStyle = colours.foreground.volume; + ctx.fillText(volume, drawX + CHAR_WIDTH * 7, drawY); + + ctx.fillStyle = colours.foreground.fx; + ctx.fillText(fx, drawX + CHAR_WIDTH * 11, drawY); + + ctx.fillStyle = colours.foreground.operant; + ctx.fillText(op, drawX + CHAR_WIDTH * 12, drawY); + + return true; +} + +function display(skipOptimizationChecks = false) { + if (!displayCanvas.value || !displayCanvas.value.parentElement) { + stop(); + return; } + + if (patternHide.value && !skipOptimizationChecks) return; + + if (firstFrame) { + // Changing it to false should enable pattern display by default. + patternHide.value = true; + handleScrollBarEnable(); + firstFrame = false; + } + + const row = player.value.getRow(); + const pattern = player.value.getPattern(); + + if ( row === lastDrawnRow && pattern === lastPattern && !skipOptimizationChecks) return; + + // Size vs speed + if (patternHide.value) drawPetternPreview(); + else drawPattern(); + + displayCanvas.value.style.top = !patternHide.value ? 'calc( 50% - ' + (row * CHAR_HEIGHT) + 'px )' : '0%'; } +let suppressScrollSliderWatcher = false; + +function scrollHandler() { + suppressScrollSliderWatcher = true; + + if (!patternScrollSlider.value) return; + if (!displayCanvas.value) return; + if (!displayCanvas.value.parentElement) return; + + patternScrollSliderPos.value = (displayCanvas.value.parentElement.scrollLeft) / (displayCanvas.value.width - displayCanvas.value.parentElement.offsetWidth) * 100; + patternScrollSlider.value.style.opacity = '1'; +} + +function scrollEndHandle() { + suppressScrollSliderWatcher = false; + + if (!patternScrollSlider.value) return; + patternScrollSlider.value.style.opacity = ''; +} + +function handleScrollBarEnable() { + patternScrollSliderShow.value = (!patternHide.value && !isTouchUsing); + if (patternScrollSliderShow.value !== true) return; + + if (!displayCanvas.value) return; + if (!displayCanvas.value.parentElement) return; + if (firstFrame) { + patternScrollSliderShow.value = (12 + 84 * player.value.getPatternNumRows(player.value.getPattern()) + 2 > displayCanvas.value.parentElement.offsetWidth); + } else { + patternScrollSliderShow.value = (displayCanvas.value.width > displayCanvas.value.parentElement.offsetWidth); + } +} + +watch(patternScrollSliderPos, () => { + if (suppressScrollSliderWatcher) return; + if (!displayCanvas.value) return; + if (!displayCanvas.value.parentElement) return; + + displayCanvas.value.parentElement.scrollLeft = (displayCanvas.value.width - displayCanvas.value.parentElement.offsetWidth) * patternScrollSliderPos.value / 100; +}); + +onDeactivated(() => { + stop(); +}); + </script> <style lang="scss" scoped> @@ -290,6 +460,7 @@ function display() { cursor: pointer; top: 12px; right: 12px; + z-index: 4; } > .pattern-display { @@ -299,22 +470,55 @@ function display() { overflow-y: hidden; background-color: black; text-align: center; + max-height: 312px; /* magic_number = CHAR_HEIGHT * rowBuffer, needs to be in px */ + + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } + .pattern-canvas { + position: relative; background-color: black; - height: 100%; + image-rendering: pixelated; + pointer-events: none; + z-index: 0; } + + .patternShadowTop { + background: #00000080; + width: 100%; + height: calc( 50% - 14px ); + translate: 0 -100%; + top: calc( 50% - 14px ); + position: absolute; + pointer-events: none; + z-index: 2; + } + + .patternShadowBottom { + background: #00000080; + width: 100%; + height: calc( 50% - 12px ); + top: calc( 50% - 1px ); + position: absolute; + pointer-events: none; + z-index: 2; + } + .pattern-hide { display: flex; flex-direction: column; justify-content: center; align-items: center; background: rgba(64, 64, 64, 0.3); - backdrop-filter: blur(2em); + backdrop-filter: var(--modalBgFilter); color: #fff; font-size: 12px; position: absolute; - z-index: 0; + z-index: 4; width: 100%; height: 100%; @@ -328,7 +532,7 @@ function display() { display: flex; width: 100%; background-color: var(--bg); - z-index: 1; + z-index: 5; > * { padding: 4px 8px; @@ -353,6 +557,18 @@ function display() { margin: 4px 8px; overflow-x: hidden; + &.pattern-slider { + position: absolute; + width: calc( 100% - 8px * 2 ); + top: calc( 100% - 21px * 3 ); + opacity: 0%; + transition: opacity 0.2s; + + &:hover { + opacity: 100%; + } + } + &:focus { outline: none; diff --git a/packages/frontend/src/components/MkModal.vue b/packages/frontend/src/components/MkModal.vue index 5cd31cdf7c..40e67fb4e0 100644 --- a/packages/frontend/src/components/MkModal.vue +++ b/packages/frontend/src/components/MkModal.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/MkModalWindow.vue b/packages/frontend/src/components/MkModalWindow.vue index b91988304d..fc634176c7 100644 --- a/packages/frontend/src/components/MkModalWindow.vue +++ b/packages/frontend/src/components/MkModalWindow.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -51,7 +51,7 @@ const bodyWidth = ref(0); const bodyHeight = ref(0); const close = () => { - modal.value.close(); + modal.value?.close(); }; const onBgClick = () => { @@ -67,11 +67,13 @@ const onKeydown = (evt) => { }; const ro = new ResizeObserver((entries, observer) => { + if (rootEl.value == null || headerEl.value == null) return; bodyWidth.value = rootEl.value.offsetWidth; bodyHeight.value = rootEl.value.offsetHeight - headerEl.value.offsetHeight; }); onMounted(() => { + if (rootEl.value == null || headerEl.value == null) return; bodyWidth.value = rootEl.value.offsetWidth; bodyHeight.value = rootEl.value.offsetHeight - headerEl.value.offsetHeight; ro.observe(rootEl.value); diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index 8a3b4cef48..9a667c3118 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -1,13 +1,13 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> <template> <div - v-if="!hardMuted && !muted" + v-if="!hardMuted && muted === false" v-show="!isDeleted" - ref="el" + ref="rootEl" v-hotkey="keymap" :class="[$style.root, { [$style.showActionsOnlyHover]: defaultStore.state.showNoteActionsOnlyHover }]" :tabindex="!isDeleted ? '-1' : undefined" @@ -39,7 +39,7 @@ SPDX-License-Identifier: AGPL-3.0-only </span> <span v-if="note.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ph-rocket ph-bold ph-lg"></i></span> <span v-if="note.channel" style="margin-left: 0.5em;" :title="note.channel.name"><i class="ph-television ph-bold ph-lg"></i></span> - <span v-if="note.updatedAt" ref="menuVersionsButton" style="margin-left: 0.5em;" title="Edited" @mousedown="menuVersions()"><i class="ph-pencil ph-bold ph-lg"></i></span> + <span v-if="note.updatedAt" ref="menuVersionsButton" style="margin-left: 0.5em;" title="Edited" @mousedown="menuVersions()"><i class="ph-pencil-simple ph-bold ph-lg"></i></span> </div> </div> <div v-if="renoteCollapsed" :class="$style.collapsedRenoteTarget"> @@ -49,7 +49,7 @@ SPDX-License-Identifier: AGPL-3.0-only <article v-else :class="$style.article" @contextmenu.stop="onContextmenu"> <div v-if="appearNote.channel" :class="$style.colorBar" :style="{ background: appearNote.channel.color }"></div> <MkAvatar :class="$style.avatar" :user="appearNote.user" :link="!mock" :preview="!mock"/> - <div :class="[$style.main, { [$style.clickToOpen]: defaultStore.state.clickToOpen }]" @click="defaultStore.state.clickToOpen ? noteclick(appearNote.id) : undefined"> + <div :class="[$style.main, { [$style.clickToOpen]: defaultStore.state.clickToOpen }]" @click.stop="defaultStore.state.clickToOpen ? noteclick(appearNote.id) : undefined"> <MkNoteHeader :note="appearNote" :mini="true" @click.stop/> <MkInstanceTicker v-if="showTicker" :instance="appearNote.user.instance"/> <div style="container-type: inline-size;"> @@ -74,18 +74,18 @@ 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> + <div v-else-if="translation"> + <b>{{ i18n.tsx.translatedFrom({ x: translation.sourceLang }) }}: </b> <Mfm :text="translation.text" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis"/> </div> </div> <MkButton v-if="!allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-play ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.play }}</MkButton> <MkButton v-else-if="!defaultStore.state.animatedMfm && allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-stop ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.stop }}</MkButton> </div> - <div v-if="appearNote.files.length > 0"> + <div v-if="appearNote.files && appearNote.files.length > 0"> <MkMediaList :mediaList="appearNote.files" @click.stop/> </div> - <MkPoll v-if="appearNote.poll" :note="appearNote" :class="$style.poll" @click.stop/> + <MkPoll v-if="appearNote.poll" :noteId="appearNote.id" :poll="appearNote.poll" :class="$style.poll" @click.stop/> <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :class="$style.urlPreview" @click.stop/> <div v-if="appearNote.renote" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></div> <button v-if="isLong && collapsed" :class="$style.collapsed" class="_button" @click.stop @click="collapsed = false"> @@ -145,7 +145,7 @@ SPDX-License-Identifier: AGPL-3.0-only <button v-if="defaultStore.state.showClipButtonInNoteFooter" ref="clipButton" :class="$style.footerButton" class="_button" @mousedown="clip()"> <i class="ph-paperclip ph-bold ph-lg"></i> </button> - <button ref="menuButton" :class="$style.footerButton" class="_button" @mousedown="menu()"> + <button ref="menuButton" :class="$style.footerButton" class="_button" @mousedown="showMenu()"> <i class="ph-dots-three ph-bold ph-lg"></i> </button> </footer> @@ -153,7 +153,14 @@ SPDX-License-Identifier: AGPL-3.0-only </article> </div> <div v-else-if="!hardMuted" :class="$style.muted" @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"/> @@ -171,7 +178,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, inject, onMounted, ref, shallowRef, Ref, watch, provide } from 'vue'; -import * as mfm from '@sharkey/sfm-js'; +import * as mfm from '@transfem-org/sfm-js'; import * as Misskey from 'misskey-js'; import MkNoteSub from '@/components/MkNoteSub.vue'; import MkNoteHeader from '@/components/MkNoteHeader.vue'; @@ -190,6 +197,7 @@ import { checkWordMute } from '@/scripts/check-word-mute.js'; import { userPage } from '@/filters/user.js'; import * as os from '@/os.js'; import * as sound from '@/scripts/sound.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { defaultStore, noteViewInterruptors } from '@/store.js'; import { reactionPicker } from '@/scripts/reaction-picker.js'; import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm.js'; @@ -207,7 +215,8 @@ import { MenuItem } from '@/types/menu.js'; import MkRippleEffect from '@/components/MkRippleEffect.vue'; import { showMovedDialog } from '@/scripts/show-moved-dialog.js'; import { shouldCollapsed } from '@/scripts/collapsed.js'; -import { useRouter } from '@/router.js'; +import { useRouter } from '@/router/supplier.js'; +import { boostMenuItems, type Visibility } from '@/scripts/boost-quote.js'; const props = withDefaults(defineProps<{ note: Misskey.entities.Note; @@ -227,6 +236,7 @@ const emit = defineEmits<{ const router = useRouter(); +const inTimeline = inject<boolean>('inTimeline', false); const inChannel = inject('inChannel', null); const currentClip = inject<Ref<Misskey.entities.Clip> | null>('currentClip', null); @@ -245,7 +255,7 @@ if (noteViewInterruptors.length > 0) { let result: Misskey.entities.Note | null = deepClone(note.value); for (const interruptor of noteViewInterruptors) { try { - result = await interruptor.handler(result); + result = await interruptor.handler(result!) as Misskey.entities.Note | null; if (result === null) { isDeleted.value = true; return; @@ -254,7 +264,7 @@ if (noteViewInterruptors.length > 0) { console.error(err); } } - note.value = result; + note.value = result as Misskey.entities.Note; }); } @@ -262,11 +272,11 @@ const isRenote = ( note.value.renote != null && note.value.text == null && note.value.cw == null && - note.value.fileIds.length === 0 && + note.value.fileIds && note.value.fileIds.length === 0 && note.value.poll == null ); -const el = shallowRef<HTMLElement>(); +const rootEl = shallowRef<HTMLElement>(); const menuButton = shallowRef<HTMLElement>(); const menuVersionsButton = shallowRef<HTMLElement>(); const renoteButton = shallowRef<HTMLElement>(); @@ -276,50 +286,61 @@ const quoteButton = shallowRef<HTMLElement>(); const clipButton = shallowRef<HTMLElement>(); const likeButton = shallowRef<HTMLElement>(); const appearNote = computed(() => isRenote ? note.value.renote as Misskey.entities.Note : note.value); -const renoteUrl = appearNote.value.renote ? appearNote.value.renote.url : null; -const renoteUri = appearNote.value.renote ? appearNote.value.renote.uri : null; const isMyRenote = $i && ($i.id === note.value.userId); const showContent = ref(defaultStore.state.uncollapseCW); -const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text).filter(u => u !== renoteUrl && u !== renoteUri) : null); -const urls = computed(() => parsed.value ? extractUrlFromMfm(parsed.value) : null); +const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : null); +const urls = computed(() => parsed.value ? extractUrlFromMfm(parsed.value).filter((url) => appearNote.value.renote?.url !== url && appearNote.value.renote?.uri !== url) : null); const isLong = shouldCollapsed(appearNote.value, urls.value ?? []); -const collapsed = defaultStore.state.expandLongNote && appearNote.value.cw == null ? false : ref(appearNote.value.cw == null && isLong); +const collapsed = ref(defaultStore.state.expandLongNote && appearNote.value.cw == null && isLong ? false : appearNote.value.cw == null && isLong); const isDeleted = ref(false); const renoted = ref(false); const muted = ref(checkMute(appearNote.value, $i?.mutedWords)); -const hardMuted = ref(props.withHardMute && checkMute(appearNote.value, $i?.hardMutedWords)); +const hardMuted = ref(props.withHardMute && checkMute(appearNote.value, $i?.hardMutedWords, true)); const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null); const translating = ref(false); const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.value.user.instance); -const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || (appearNote.value.visibility === 'followers' && appearNote.value.userId === $i.id)); -const renoteCollapsed = ref(defaultStore.state.collapseRenotes && isRenote && (($i && ($i.id === note.value.userId || $i.id === appearNote.value.userId)) || (appearNote.value.myReaction != null))); +const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || (appearNote.value.visibility === 'followers' && appearNote.value.userId === $i?.id)); +const renoteCollapsed = ref( + defaultStore.state.collapseRenotes && isRenote && ( + ($i && ($i.id === note.value.userId || $i.id === appearNote.value.userId)) || // `||` must be `||`! See https://github.com/misskey-dev/misskey/issues/13131 + (appearNote.value.myReaction != null) + ) +); const defaultLike = computed(() => defaultStore.state.like ? defaultStore.state.like : null); const animated = computed(() => parsed.value ? checkAnimationFromMfm(parsed.value) : null); const allowAnim = ref(defaultStore.state.advancedMfm && defaultStore.state.animatedMfm ? true : false); -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; } const keymap = { 'r': () => reply(true), 'e|a|plus': () => react(true), - 'q': () => renoteButton.value.renote(true), + 'q': () => renote(appearNote.value.visibility), 'up|k|shift+tab': focusBefore, 'down|j|tab': focusAfter, 'esc': blur, - 'm|o': () => menu(true), + 'm|o': () => showMenu(true), 's': () => showContent.value !== showContent.value, }; provide('react', (reaction: string) => { - os.api('notes/reactions/create', { + misskeyApi('notes/reactions/create', { noteId: appearNote.value.id, reaction: reaction, }); @@ -331,7 +352,7 @@ if (props.mock) { }, { deep: true }); } else { useNoteCapture({ - rootEl: el, + rootEl: rootEl, note: appearNote, pureNote: note, isDeletedRef: isDeleted, @@ -340,7 +361,7 @@ if (props.mock) { if (!props.mock) { useTooltip(renoteButton, async (showing) => { - const renotes = await os.api('notes/renotes', { + const renotes = await misskeyApi('notes/renotes', { noteId: appearNote.value.id, limit: 11, }); @@ -358,7 +379,7 @@ if (!props.mock) { }); useTooltip(quoteButton, async (showing) => { - const renotes = await os.api('notes/renotes', { + const renotes = await misskeyApi('notes/renotes', { noteId: appearNote.value.id, limit: 11, quote: true, @@ -377,7 +398,7 @@ if (!props.mock) { }); if ($i) { - os.api('notes/renotes', { + misskeyApi('notes/renotes', { noteId: appearNote.value.id, userId: $i.id, limit: 1, @@ -387,54 +408,15 @@ if (!props.mock) { } } -type Visibility = 'public' | 'home' | 'followers' | 'specified'; - -// defaultStore.state.visibilityがstringなためstringも受け付けている -function smallerVisibility(a: Visibility | string, b: Visibility | string): Visibility { - if (a === 'specified' || b === 'specified') return 'specified'; - if (a === 'followers' || b === 'followers') return 'followers'; - if (a === 'home' || b === 'home') return 'home'; - // if (a === 'public' || b === 'public') - return 'public'; -} - function boostVisibility() { - os.popupMenu([ - { - type: 'button', - icon: 'ph-globe-hemisphere-west ph-bold ph-lg', - text: i18n.ts._visibility['public'], - action: () => { - renote('public'); - }, - }, - { - type: 'button', - icon: 'ph-house ph-bold ph-lg', - text: i18n.ts._visibility['home'], - action: () => { - renote('home'); - }, - }, - { - type: 'button', - icon: 'ph-lock ph-bold ph-lg', - text: i18n.ts._visibility['followers'], - action: () => { - renote('followers'); - }, - }, - { - type: 'button', - icon: 'ph-planet ph-bold ph-lg', - text: i18n.ts._timelines.local, - action: () => { - renote('local'); - }, - }], renoteButton.value); + if (!defaultStore.state.showVisibilitySelectorOnBoost) { + renote(defaultStore.state.visibilityOnBoost); + } else { + os.popupMenu(boostMenuItems(appearNote, renote), renoteButton.value); + } } -function renote(visibility: Visibility | 'local') { +function renote(visibility: Visibility, localOnly: boolean = false) { pleaseLogin(); showMovedDialog(); @@ -448,7 +430,7 @@ function renote(visibility: Visibility | 'local') { } if (!props.mock) { - os.api('notes/create', { + misskeyApi('notes/create', { renoteId: appearNote.value.id, channelId: appearNote.value.channelId, }).then(() => { @@ -456,7 +438,7 @@ function renote(visibility: Visibility | 'local') { renoted.value = true; }); } - } else if (!appearNote.value.channel || appearNote.value.channel?.allowRenoteToExternal) { + } else if (!appearNote.value.channel || appearNote.value.channel.allowRenoteToExternal) { const el = renoteButton.value as HTMLElement | null | undefined; if (el) { const rect = el.getBoundingClientRect(); @@ -465,18 +447,10 @@ function renote(visibility: Visibility | 'local') { os.popup(MkRippleEffect, { x, y }, {}, 'end'); } - const configuredVisibility = defaultStore.state.rememberNoteVisibility ? defaultStore.state.visibility : defaultStore.state.defaultNoteVisibility; - const localOnlySetting = defaultStore.state.rememberNoteVisibility ? defaultStore.state.localOnly : defaultStore.state.defaultNoteLocalOnly; - - let noteVisibility = visibility === 'local' || visibility === 'specified' ? smallerVisibility(appearNote.value.visibility, configuredVisibility) : smallerVisibility(visibility, configuredVisibility); - if (appearNote.value.channel?.isSensitive) { - noteVisibility = smallerVisibility(visibility === 'local' || visibility === 'specified' ? appearNote.value.visibility : visibility, 'home'); - } - if (!props.mock) { - os.api('notes/create', { - localOnly: visibility === 'local' ? true : localOnlySetting, - visibility: noteVisibility, + misskeyApi('notes/create', { + localOnly: localOnly, + visibility: visibility, renoteId: appearNote.value.id, }).then(() => { os.toast(i18n.ts.renoted); @@ -498,9 +472,9 @@ function quote() { renote: appearNote.value, channel: appearNote.value.channel, }).then(() => { - os.api('notes/renotes', { + misskeyApi('notes/renotes', { noteId: appearNote.value.id, - userId: $i.id, + userId: $i?.id, limit: 1, quote: true, }).then((res) => { @@ -520,9 +494,9 @@ function quote() { os.post({ renote: appearNote.value, }).then(() => { - os.api('notes/renotes', { + misskeyApi('notes/renotes', { noteId: appearNote.value.id, - userId: $i.id, + userId: $i?.id, limit: 1, quote: true, }).then((res) => { @@ -550,7 +524,7 @@ function reply(viaKeyboard = false): void { reply: appearNote.value, channel: appearNote.value.channel, animation: !viaKeyboard, - }, () => { + }).then(() => { focus(); }); } @@ -558,10 +532,11 @@ function reply(viaKeyboard = false): void { function like(): void { pleaseLogin(); showMovedDialog(); + sound.playMisskeySfx('reaction'); if (props.mock) { return; } - os.api('notes/like', { + misskeyApi('notes/like', { noteId: appearNote.value.id, override: defaultLike.value, }); @@ -578,17 +553,17 @@ function react(viaKeyboard = false): void { pleaseLogin(); showMovedDialog(); if (appearNote.value.reactionAcceptance === 'likeOnly') { - sound.play('reaction'); + sound.playMisskeySfx('reaction'); if (props.mock) { return; } - os.api('notes/like', { + misskeyApi('notes/like', { noteId: appearNote.value.id, override: defaultLike.value, }); - const el = reactButton.value as HTMLElement | null | undefined; + const el = reactButton.value; if (el) { const rect = el.getBoundingClientRect(); const x = rect.left + (el.offsetWidth / 2); @@ -597,15 +572,15 @@ function react(viaKeyboard = false): void { } } else { blur(); - reactionPicker.show(reactButton.value, reaction => { - sound.play('reaction'); + reactionPicker.show(reactButton.value ?? null, note.value, reaction => { + sound.playMisskeySfx('reaction'); if (props.mock) { emit('reaction', reaction); return; } - os.api('notes/reactions/create', { + misskeyApi('notes/reactions/create', { noteId: appearNote.value.id, reaction: reaction, }); @@ -618,8 +593,8 @@ function react(viaKeyboard = false): void { } } -function undoReact(note): void { - const oldReaction = note.myReaction; +function undoReact(targetNote: Misskey.entities.Note): void { + const oldReaction = targetNote.myReaction; if (!oldReaction) return; if (props.mock) { @@ -627,8 +602,8 @@ function undoReact(note): void { return; } - os.api('notes/reactions/delete', { - noteId: note.id, + misskeyApi('notes/reactions/delete', { + noteId: targetNote.id, }); } @@ -636,7 +611,7 @@ function undoRenote(note) : void { if (props.mock) { return; } - os.api('notes/unrenote', { + misskeyApi('notes/unrenote', { noteId: note.id, }); os.toast(i18n.ts.rmboost); @@ -656,32 +631,34 @@ function onContextmenu(ev: MouseEvent): void { return; } - const isLink = (el: HTMLElement) => { + const isLink = (el: HTMLElement): boolean => { if (el.tagName === 'A') return true; // 再生速度の選択などのために、Audio要素のコンテキストメニューはブラウザデフォルトとする。 if (el.tagName === 'AUDIO') return true; if (el.parentElement) { return isLink(el.parentElement); } + return false; }; - if (isLink(ev.target)) return; - if (window.getSelection().toString() !== '') return; + + if (ev.target && isLink(ev.target as HTMLElement)) return; + if (window.getSelection()?.toString() !== '') return; if (defaultStore.state.useReactionPickerForContextMenu) { ev.preventDefault(); react(); } else { - const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, menuButton, isDeleted, currentClip: currentClip?.value }); + const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, isDeleted, currentClip: currentClip?.value }); os.contextMenu(menu, ev).then(focus).finally(cleanup); } } -function menu(viaKeyboard = false): void { +function showMenu(viaKeyboard = false): void { if (props.mock) { return; } - const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, menuButton, isDeleted, currentClip: currentClip?.value }); + const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, isDeleted, currentClip: currentClip?.value }); os.popupMenu(menu, menuButton.value, { viaKeyboard, }).then(focus).finally(cleanup); @@ -713,7 +690,7 @@ function showRenoteMenu(viaKeyboard = false): void { icon: 'ph-trash ph-bold ph-lg', danger: true, action: () => { - os.api('notes/delete', { + misskeyApi('notes/delete', { noteId: note.value.id, }); isDeleted.value = true; @@ -735,7 +712,7 @@ function showRenoteMenu(viaKeyboard = false): void { getCopyNoteLinkMenu(note.value, i18n.ts.copyLinkRenote), { type: 'divider' }, getAbuseNoteMenu(note.value, i18n.ts.reportAbuseRenote), - $i.isModerator || $i.isAdmin ? getUnrenote() : undefined, + ($i?.isModerator || $i?.isAdmin) ? getUnrenote() : undefined, ], renoteTime.value, { viaKeyboard: viaKeyboard, }); @@ -755,23 +732,23 @@ function animatedMFM() { } function focus() { - el.value.focus(); + rootEl.value?.focus(); } function blur() { - el.value.blur(); + rootEl.value?.blur(); } function focusBefore() { - focusPrev(el.value); + focusPrev(rootEl.value ?? null); } function focusAfter() { - focusNext(el.value); + focusNext(rootEl.value ?? null); } function readPromo() { - os.api('promo/read', { + misskeyApi('promo/read', { noteId: appearNote.value.id, }); isDeleted.value = true; @@ -825,12 +802,13 @@ function emitUpdReaction(emoji: string, delta: number) { } .footer { + display: flex; + align-items: center; + justify-content: space-between; position: relative; z-index: 1; margin-top: 0.4em; - width: max-content; - min-width: min-content; - max-width: fit-content; + max-width: 400px; } &:hover > .article > .main > .footer > .footerButton { @@ -986,8 +964,8 @@ function emitUpdReaction(emoji: string, delta: number) { flex-shrink: 0; display: block !important; margin: 0 14px 0 0; - width: 58px; - height: 58px; + width: var(--avatar); + height: var(--avatar); position: sticky !important; top: calc(22px + var(--stickyTop, 0px)); left: 0; @@ -1249,5 +1227,6 @@ function emitUpdReaction(emoji: string, delta: number) { .clickToOpen { cursor: pointer; + -webkit-tap-highlight-color: transparent; } </style> diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue index e287890e2c..3d15f69f73 100644 --- a/packages/frontend/src/components/MkNoteDetailed.vue +++ b/packages/frontend/src/components/MkNoteDetailed.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-if="!muted" v-show="!isDeleted" - ref="el" + ref="rootEl" v-hotkey="keymap" :class="$style.root" > @@ -58,7 +58,7 @@ SPDX-License-Identifier: AGPL-3.0-only <i v-else-if="appearNote.visibility === 'followers'" class="ph-lock ph-bold ph-lg"></i> <i v-else-if="appearNote.visibility === 'specified'" ref="specified" class="ph-envelope ph-bold ph-lg"></i> </span> - <span v-if="appearNote.updatedAt" ref="menuVersionsButton" style="margin-left: 0.5em;" title="Edited" @mousedown="menuVersions()"><i class="ph-pencil ph-bold ph-lg"></i></span> + <span v-if="appearNote.updatedAt" ref="menuVersionsButton" style="margin-left: 0.5em;" title="Edited" @mousedown="menuVersions()"><i class="ph-pencil-simple ph-bold ph-lg"></i></span> <span v-if="appearNote.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ph-rocket ph-bold ph-lg"></i></span> </div> </div> @@ -88,17 +88,17 @@ SPDX-License-Identifier: AGPL-3.0-only <a v-if="appearNote.renote != null" :class="$style.rn">RN:</a> <div v-if="translating || translation" :class="$style.translation"> <MkLoading v-if="translating" mini/> - <div v-else> - <b>{{ i18n.t('translatedFrom', { x: translation.sourceLang }) }}: </b> + <div v-else-if="translation"> + <b>{{ i18n.tsx.translatedFrom({ x: translation.sourceLang }) }}: </b> <Mfm :text="translation.text" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis"/> </div> </div> <MkButton v-if="!allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-play ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.play }}</MkButton> <MkButton v-else-if="!defaultStore.state.animatedMfm && allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-stop ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.stop }}</MkButton> - <div v-if="appearNote.files.length > 0"> + <div v-if="appearNote.files && appearNote.files.length > 0"> <MkMediaList :mediaList="appearNote.files"/> </div> - <MkPoll v-if="appearNote.poll" ref="pollViewer" :note="appearNote" :class="$style.poll"/> + <MkPoll v-if="appearNote.poll" ref="pollViewer" :noteId="appearNote.id" :poll="appearNote.poll" :class="$style.poll"/> <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" style="margin-top: 6px;"/> <div v-if="appearNote.renote" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote" :expandAllCws="props.expandAllCws"/></div> </div> @@ -154,7 +154,7 @@ SPDX-License-Identifier: AGPL-3.0-only <button v-if="defaultStore.state.showClipButtonInNoteFooter" ref="clipButton" class="_button" :class="$style.noteFooterButton" @mousedown="clip()"> <i class="ph-paperclip ph-bold ph-lg"></i> </button> - <button ref="menuButton" class="_button" :class="$style.noteFooterButton" @mousedown="menu()"> + <button ref="menuButton" class="_button" :class="$style.noteFooterButton" @mousedown="showMenu()"> <i class="ph-dots-three ph-bold ph-lg"></i> </button> </footer> @@ -166,11 +166,11 @@ SPDX-License-Identifier: AGPL-3.0-only <button class="_button" :class="[$style.tab, { [$style.tabActive]: tab === 'reactions' }]" @click="tab = 'reactions'"><i class="ph-smiley ph-bold ph-lg"></i> {{ i18n.ts.reactions }}</button> </div> <div> - <div v-if="tab === 'replies'" :class="$style.tab_replies"> + <div v-if="tab === 'replies'"> <div v-if="!repliesLoaded" style="padding: 16px"> <MkButton style="margin: 0 auto;" primary rounded @click="loadReplies">{{ i18n.ts.loadReplies }}</MkButton> </div> - <MkNoteSub v-for="note in replies" :key="note.id" :note="note" :class="$style.reply" :detail="true" :expandAllCws="props.expandAllCws" :onDeleteCallback="removeReply" /> + <MkNoteSub v-for="note in replies" :key="note.id" :note="note" :class="$style.reply" :detail="true" :expandAllCws="props.expandAllCws" :onDeleteCallback="removeReply"/> </div> <div v-else-if="tab === 'renotes'" :class="$style.tab_renotes"> <MkPagination :pagination="renotesPagination" :disableAutoLoad="true"> @@ -183,7 +183,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> </MkPagination> </div> - <div v-if="tab === 'quotes'" :class="$style.tab_replies"> + <div v-if="tab === 'quotes'"> <div v-if="!quotesLoaded" style="padding: 16px"> <MkButton style="margin: 0 auto;" primary rounded @click="loadQuotes">{{ i18n.ts.loadReplies }}</MkButton> </div> @@ -221,7 +221,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, inject, onMounted, provide, ref, shallowRef, watch } from 'vue'; -import * as mfm from '@sharkey/sfm-js'; +import * as mfm from '@transfem-org/sfm-js'; import * as Misskey from 'misskey-js'; import MkNoteSub from '@/components/MkNoteSub.vue'; import MkNoteSimple from '@/components/MkNoteSimple.vue'; @@ -237,6 +237,7 @@ import { checkWordMute } from '@/scripts/check-word-mute.js'; import { userPage } from '@/filters/user.js'; import { notePage } from '@/filters/note.js'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import * as sound from '@/scripts/sound.js'; import { defaultStore, noteViewInterruptors } from '@/store.js'; import { reactionPicker } from '@/scripts/reaction-picker.js'; @@ -253,9 +254,10 @@ import { checkAnimationFromMfm } from '@/scripts/check-animated-mfm.js'; import MkRippleEffect from '@/components/MkRippleEffect.vue'; import { showMovedDialog } from '@/scripts/show-moved-dialog.js'; import MkUserCardMini from '@/components/MkUserCardMini.vue'; -import MkPagination from '@/components/MkPagination.vue'; +import MkPagination, { type Paging } from '@/components/MkPagination.vue'; import MkReactionIcon from '@/components/MkReactionIcon.vue'; import MkButton from '@/components/MkButton.vue'; +import { boostMenuItems, type Visibility } from '@/scripts/boost-quote.js'; const props = defineProps<{ note: Misskey.entities.Note; @@ -272,7 +274,7 @@ if (noteViewInterruptors.length > 0) { let result: Misskey.entities.Note | null = deepClone(note.value); for (const interruptor of noteViewInterruptors) { try { - result = await interruptor.handler(result); + result = await interruptor.handler(result!) as Misskey.entities.Note | null; if (result === null) { isDeleted.value = true; return; @@ -281,18 +283,18 @@ if (noteViewInterruptors.length > 0) { console.error(err); } } - note.value = result; + note.value = result as Misskey.entities.Note; }); } const isRenote = ( note.value.renote != null && note.value.text == null && - note.value.fileIds.length === 0 && + note.value.fileIds && note.value.fileIds.length === 0 && note.value.poll == null ); -const el = shallowRef<HTMLElement>(); +const rootEl = shallowRef<HTMLElement>(); const menuButton = shallowRef<HTMLElement>(); const menuVersionsButton = shallowRef<HTMLElement>(); const renoteButton = shallowRef<HTMLElement>(); @@ -302,8 +304,6 @@ const quoteButton = shallowRef<HTMLElement>(); const clipButton = shallowRef<HTMLElement>(); const likeButton = shallowRef<HTMLElement>(); const appearNote = computed(() => isRenote ? note.value.renote as Misskey.entities.Note : note.value); -const renoteUrl = appearNote.value.renote ? appearNote.value.renote.url : null; -const renoteUri = appearNote.value.renote ? appearNote.value.renote.uri : null; const isMyRenote = $i && ($i.id === note.value.userId); const showContent = ref(defaultStore.state.uncollapseCW); const isDeleted = ref(false); @@ -312,14 +312,14 @@ const muted = ref($i ? checkWordMute(appearNote.value, $i, $i.mutedWords) : fals const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null); const translating = ref(false); const parsed = appearNote.value.text ? mfm.parse(appearNote.value.text) : null; -const urls = parsed ? extractUrlFromMfm(parsed).filter(u => u !== renoteUrl && u !== renoteUri) : null; +const urls = parsed ? extractUrlFromMfm(parsed).filter((url) => appearNote.value.renote?.url !== url && appearNote.value.renote?.uri !== url) : null; const animated = computed(() => parsed ? checkAnimationFromMfm(parsed) : null); const allowAnim = ref(defaultStore.state.advancedMfm && defaultStore.state.animatedMfm ? true : false); const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.value.user.instance); const conversation = ref<Misskey.entities.Note[]>([]); const replies = ref<Misskey.entities.Note[]>([]); const quotes = ref<Misskey.entities.Note[]>([]); -const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || appearNote.value.userId === $i.id); +const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || (appearNote.value.visibility === 'followers' && appearNote.value.userId === $i?.id)); const defaultLike = computed(() => defaultStore.state.like ? defaultStore.state.like : null); watch(() => props.expandAllCws, (expandAllCws) => { @@ -327,7 +327,7 @@ watch(() => props.expandAllCws, (expandAllCws) => { }); if ($i) { - os.api('notes/renotes', { + misskeyApi('notes/renotes', { noteId: appearNote.value.id, userId: $i.id, limit: 1, @@ -339,14 +339,14 @@ if ($i) { const keymap = { 'r': () => reply(true), 'e|a|plus': () => react(true), - 'q': () => renoteButton.value.renote(true), + 'q': () => renote(appearNote.value.visibility), 'esc': blur, - 'm|o': () => menu(true), + 'm|o': () => showMenu(true), 's': () => showContent.value !== showContent.value, }; provide('react', (reaction: string) => { - os.api('notes/reactions/create', { + misskeyApi('notes/reactions/create', { noteId: appearNote.value.id, reaction: reaction, }); @@ -355,7 +355,7 @@ provide('react', (reaction: string) => { const tab = ref('replies'); const reactionTabType = ref<string | null>(null); -const renotesPagination = computed(() => ({ +const renotesPagination = computed<Paging>(() => ({ endpoint: 'notes/renotes', limit: 10, params: { @@ -363,7 +363,7 @@ const renotesPagination = computed(() => ({ }, })); -const reactionsPagination = computed(() => ({ +const reactionsPagination = computed<Paging>(() => ({ endpoint: 'notes/reactions', limit: 10, params: { @@ -373,20 +373,20 @@ const reactionsPagination = computed(() => ({ })); async function addReplyTo(replyNote: Misskey.entities.Note) { - replies.value.unshift(replyNote); - appearNote.value.repliesCount += 1; + replies.value.unshift(replyNote); + appearNote.value.repliesCount += 1; } async function removeReply(id: Misskey.entities.Note['id']) { - const replyIdx = replies.value.findIndex(note => note.id === id); - if (replyIdx >= 0) { - replies.value.splice(replyIdx, 1); - appearNote.value.repliesCount -= 1; - } + const replyIdx = replies.value.findIndex(note => note.id === id); + if (replyIdx >= 0) { + replies.value.splice(replyIdx, 1); + appearNote.value.repliesCount -= 1; + } } useNoteCapture({ - rootEl: el, + rootEl: rootEl, note: appearNote, pureNote: note, isDeletedRef: isDeleted, @@ -394,7 +394,7 @@ useNoteCapture({ }); useTooltip(renoteButton, async (showing) => { - const renotes = await os.api('notes/renotes', { + const renotes = await misskeyApi('notes/renotes', { noteId: appearNote.value.id, limit: 11, }); @@ -412,7 +412,7 @@ useTooltip(renoteButton, async (showing) => { }); useTooltip(quoteButton, async (showing) => { - const renotes = await os.api('notes/renotes', { + const renotes = await misskeyApi('notes/renotes', { noteId: appearNote.value.id, limit: 11, quote: true, @@ -430,53 +430,15 @@ useTooltip(quoteButton, async (showing) => { }, {}, 'closed'); }); -type Visibility = 'public' | 'home' | 'followers' | 'specified'; - -function smallerVisibility(a: Visibility | string, b: Visibility | string): Visibility { - if (a === 'specified' || b === 'specified') return 'specified'; - if (a === 'followers' || b === 'followers') return 'followers'; - if (a === 'home' || b === 'home') return 'home'; - // if (a === 'public' || b === 'public') - return 'public'; -} - function boostVisibility() { - os.popupMenu([ - { - type: 'button', - icon: 'ph-globe-hemisphere-west ph-bold ph-lg', - text: i18n.ts._visibility['public'], - action: () => { - renote('public'); - }, - }, - { - type: 'button', - icon: 'ph-house ph-bold ph-lg', - text: i18n.ts._visibility['home'], - action: () => { - renote('home'); - }, - }, - { - type: 'button', - icon: 'ph-lock ph-bold ph-lg', - text: i18n.ts._visibility['followers'], - action: () => { - renote('followers'); - }, - }, - { - type: 'button', - icon: 'ph-planet ph-bold ph-lg', - text: i18n.ts._timelines.local, - action: () => { - renote('local'); - }, - }], renoteButton.value); + if (!defaultStore.state.showVisibilitySelectorOnBoost) { + renote(defaultStore.state.visibilityOnBoost); + } else { + os.popupMenu(boostMenuItems(appearNote, renote), renoteButton.value); + } } -function renote(visibility: Visibility | 'local') { +function renote(visibility: Visibility, localOnly: boolean = false) { pleaseLogin(); showMovedDialog(); @@ -489,14 +451,14 @@ function renote(visibility: Visibility | 'local') { os.popup(MkRippleEffect, { x, y }, {}, 'end'); } - os.api('notes/create', { + misskeyApi('notes/create', { renoteId: appearNote.value.id, channelId: appearNote.value.channelId, }).then(() => { os.toast(i18n.ts.renoted); renoted.value = true; }); - } else if (!appearNote.value.channel || appearNote.value.channel?.allowRenoteToExternal) { + } else if (!appearNote.value.channel || appearNote.value.channel.allowRenoteToExternal) { const el = renoteButton.value as HTMLElement | null | undefined; if (el) { const rect = el.getBoundingClientRect(); @@ -505,17 +467,9 @@ function renote(visibility: Visibility | 'local') { os.popup(MkRippleEffect, { x, y }, {}, 'end'); } - const configuredVisibility = defaultStore.state.rememberNoteVisibility ? defaultStore.state.visibility : defaultStore.state.defaultNoteVisibility; - const localOnlySetting = defaultStore.state.rememberNoteVisibility ? defaultStore.state.localOnly : defaultStore.state.defaultNoteLocalOnly; - - let noteVisibility = visibility === 'local' || visibility === 'specified' ? smallerVisibility(appearNote.value.visibility, configuredVisibility) : smallerVisibility(visibility, configuredVisibility); - if (appearNote.value.channel?.isSensitive) { - noteVisibility = smallerVisibility(visibility === 'local' || visibility === 'specified' ? appearNote.value.visibility : visibility, 'home'); - } - - os.api('notes/create', { - localOnly: visibility === 'local' ? true : localOnlySetting, - visibility: noteVisibility, + misskeyApi('notes/create', { + localOnly: localOnly, + visibility: visibility, renoteId: appearNote.value.id, }).then(() => { os.toast(i18n.ts.renoted); @@ -533,9 +487,9 @@ function quote() { renote: appearNote.value, channel: appearNote.value.channel, }).then(() => { - os.api('notes/renotes', { + misskeyApi('notes/renotes', { noteId: appearNote.value.id, - userId: $i.id, + userId: $i?.id, limit: 1, quote: true, }).then((res) => { @@ -555,9 +509,9 @@ function quote() { os.post({ renote: appearNote.value, }).then(() => { - os.api('notes/renotes', { + misskeyApi('notes/renotes', { noteId: appearNote.value.id, - userId: $i.id, + userId: $i?.id, limit: 1, quote: true, }).then((res) => { @@ -583,7 +537,7 @@ function reply(viaKeyboard = false): void { reply: appearNote.value, channel: appearNote.value.channel, animation: !viaKeyboard, - }, () => { + }).then(() => { focus(); }); } @@ -592,7 +546,9 @@ function react(viaKeyboard = false): void { pleaseLogin(); showMovedDialog(); if (appearNote.value.reactionAcceptance === 'likeOnly') { - os.api('notes/like', { + sound.playMisskeySfx('reaction'); + + misskeyApi('notes/like', { noteId: appearNote.value.id, override: defaultLike.value, }); @@ -605,10 +561,10 @@ function react(viaKeyboard = false): void { } } else { blur(); - reactionPicker.show(reactButton.value, reaction => { - sound.play('reaction'); + reactionPicker.show(reactButton.value ?? null, note.value, reaction => { + sound.playMisskeySfx('reaction'); - os.api('notes/reactions/create', { + misskeyApi('notes/reactions/create', { noteId: appearNote.value.id, reaction: reaction, }); @@ -624,7 +580,8 @@ function react(viaKeyboard = false): void { function like(): void { pleaseLogin(); showMovedDialog(); - os.api('notes/like', { + sound.playMisskeySfx('reaction'); + misskeyApi('notes/like', { noteId: appearNote.value.id, override: defaultLike.value, }); @@ -640,14 +597,14 @@ function like(): void { function undoReact(note): void { const oldReaction = note.myReaction; if (!oldReaction) return; - os.api('notes/reactions/delete', { + misskeyApi('notes/reactions/delete', { noteId: note.id, }); } function undoRenote() : void { if (!renoted.value) return; - os.api('notes/unrenote', { + misskeyApi('notes/unrenote', { noteId: appearNote.value.id, }); os.toast(i18n.ts.rmboost); @@ -663,26 +620,28 @@ function undoRenote() : void { } function onContextmenu(ev: MouseEvent): void { - const isLink = (el: HTMLElement) => { + const isLink = (el: HTMLElement): boolean => { if (el.tagName === 'A') return true; if (el.parentElement) { return isLink(el.parentElement); } + return false; }; - if (isLink(ev.target)) return; - if (window.getSelection().toString() !== '') return; + + if (ev.target && isLink(ev.target as HTMLElement)) return; + if (window.getSelection()?.toString() !== '') return; if (defaultStore.state.useReactionPickerForContextMenu) { ev.preventDefault(); react(); } else { - const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, menuButton, isDeleted }); + const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, isDeleted }); os.contextMenu(menu, ev).then(focus).finally(cleanup); } } -function menu(viaKeyboard = false): void { - const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, menuButton, isDeleted }); +function showMenu(viaKeyboard = false): void { + const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, isDeleted }); os.popupMenu(menu, menuButton.value, { viaKeyboard, }).then(focus).finally(cleanup); @@ -707,7 +666,7 @@ function showRenoteMenu(viaKeyboard = false): void { icon: 'ph-trash ph-bold ph-lg', danger: true, action: () => { - os.api('notes/delete', { + misskeyApi('notes/delete', { noteId: note.value.id, }); isDeleted.value = true; @@ -718,18 +677,18 @@ function showRenoteMenu(viaKeyboard = false): void { } function focus() { - el.value.focus(); + rootEl.value?.focus(); } function blur() { - el.value.blur(); + rootEl.value?.blur(); } const repliesLoaded = ref(false); function loadReplies() { repliesLoaded.value = true; - os.api('notes/children', { + misskeyApi('notes/children', { noteId: appearNote.value.id, limit: 30, showQuotes: false, @@ -744,7 +703,7 @@ const quotesLoaded = ref(false); function loadQuotes() { quotesLoaded.value = true; - os.api('notes/renotes', { + misskeyApi('notes/renotes', { noteId: appearNote.value.id, limit: 30, quote: true, @@ -759,7 +718,8 @@ const conversationLoaded = ref(false); function loadConversation() { conversationLoaded.value = true; - os.api('notes/conversation', { + if (appearNote.value.replyId == null) return; + misskeyApi('notes/conversation', { noteId: appearNote.value.replyId, }).then(res => { conversation.value = res.reverse(); @@ -871,8 +831,8 @@ function animatedMFM() { .noteHeaderAvatar { display: block; flex-shrink: 0; - width: 58px; - height: 58px; + width: var(--avatar); + height: var(--avatar); } .noteHeaderBody { diff --git a/packages/frontend/src/components/MkNoteHeader.vue b/packages/frontend/src/components/MkNoteHeader.vue index 6121db3f8f..e643590e86 100644 --- a/packages/frontend/src/components/MkNoteHeader.vue +++ b/packages/frontend/src/components/MkNoteHeader.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-if="note.user.isBot" :class="$style.isBot">bot</div> <div :class="$style.username"><MkAcct :user="note.user"/></div> <div v-if="note.user.badgeRoles" :class="$style.badgeRoles"> - <img v-for="role in note.user.badgeRoles" :key="role.id" v-tooltip="role.name" :class="$style.badgeRole" :src="role.iconUrl"/> + <img v-for="(role, i) in note.user.badgeRoles" :key="i" v-tooltip="role.name" :class="$style.badgeRole" :src="role.iconUrl!"/> </div> <div :class="$style.info"> <div v-if="mock"> @@ -28,7 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only <i v-else-if="note.visibility === 'followers'" class="ph-lock ph-bold ph-lg"></i> <i v-else-if="note.visibility === 'specified'" ref="specified" class="ph-envelope ph-bold ph-lg"></i> </span> - <span v-if="note.updatedAt" ref="menuVersionsButton" style="margin-left: 0.5em; cursor: pointer;" title="Edited" @mousedown="menuVersions()"><i class="ph-pencil ph-bold ph-lg"></i></span> + <span v-if="note.updatedAt" ref="menuVersionsButton" style="margin-left: 0.5em; cursor: pointer;" title="Edited" @mousedown="menuVersions()"><i class="ph-pencil-simple ph-bold ph-lg"></i></span> <span v-if="note.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ph-rocket ph-bold ph-lg"></i></span> <span v-if="note.channel" style="margin-left: 0.5em;" :title="note.channel.name"><i class="ph-television ph-bold ph-lg"></i></span> </div> diff --git a/packages/frontend/src/components/MkNotePreview.vue b/packages/frontend/src/components/MkNotePreview.vue index c517bc6800..3fcd7593ba 100644 --- a/packages/frontend/src/components/MkNotePreview.vue +++ b/packages/frontend/src/components/MkNotePreview.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <div> <p v-if="useCw" :class="$style.cw"> - <Mfm v-if="cw != ''" :text="cw" :author="user" :nyaize="'respect'" :i="user" style="margin-right: 8px;"/> + <Mfm v-if="cw != null && cw != ''" :text="cw" :author="user" :nyaize="'respect'" :i="user" style="margin-right: 8px;"/> <MkCwButton v-model="showContent" :text="text.trim()" :files="files" :poll="poll" style="margin: 4px 0;"/> </p> <div v-show="!useCw || showContent"> @@ -26,6 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref } from 'vue'; import * as Misskey from 'misskey-js'; +import type { PollEditorModelValue } from '@/components/MkPollEditor.vue'; import MkCwButton from '@/components/MkCwButton.vue'; const showContent = ref(false); @@ -33,12 +34,7 @@ const showContent = ref(false); const props = defineProps<{ text: string; files: Misskey.entities.DriveFile[]; - poll?: { - choices: string[]; - multiple: boolean; - expiresAt: string | null; - expiredAfter: string | null; - }; + poll?: PollEditorModelValue; useCw: boolean; cw: string | null; user: Misskey.entities.User; diff --git a/packages/frontend/src/components/MkNoteSimple.vue b/packages/frontend/src/components/MkNoteSimple.vue index 7a6109ee0b..477cf4521a 100644 --- a/packages/frontend/src/components/MkNoteSimple.vue +++ b/packages/frontend/src/components/MkNoteSimple.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkCwButton v-model="showContent" :text="note.text" :files="note.files" :poll="note.poll" @click.stop/> </p> <div v-show="note.cw == null || showContent"> - <MkSubNoteContent :hideFiles="hideFiles" :class="$style.text" :note="note"/> + <MkSubNoteContent :hideFiles="hideFiles" :class="$style.text" :note="note" :expandAllCws="props.expandAllCws"/> </div> </div> </div> diff --git a/packages/frontend/src/components/MkNoteSub.vue b/packages/frontend/src/components/MkNoteSub.vue index d96785a2d9..37811dd52e 100644 --- a/packages/frontend/src/components/MkNoteSub.vue +++ b/packages/frontend/src/components/MkNoteSub.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -16,7 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkCwButton v-model="showContent" :text="note.text" :files="note.files" :poll="note.poll"/> </p> <div v-show="note.cw == null || showContent"> - <MkSubNoteContent :class="$style.text" :note="note" :translating="translating" :translation="translation"/> + <MkSubNoteContent :class="$style.text" :note="note" :translating="translating" :translation="translation" :expandAllCws="props.expandAllCws"/> </div> </div> <footer :class="$style.footer"> @@ -91,6 +91,8 @@ import MkSubNoteContent from '@/components/MkSubNoteContent.vue'; import MkCwButton from '@/components/MkCwButton.vue'; import { notePage } from '@/filters/note.js'; import * as os from '@/os.js'; +import * as sound from '@/scripts/sound.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; import { $i } from '@/account.js'; import { userPage } from '@/filters/user.js'; @@ -103,6 +105,7 @@ import { reactionPicker } from '@/scripts/reaction-picker.js'; import { claimAchievement } from '@/scripts/achievements.js'; import { getNoteMenu } from '@/scripts/get-note-menu.js'; import { useNoteCapture } from '@/scripts/use-note-capture.js'; +import { boostMenuItems, type Visibility } from '@/scripts/boost-quote.js'; const canRenote = computed(() => ['public', 'home'].includes(props.note.visibility) || props.note.userId === $i.id); @@ -138,21 +141,21 @@ const replies = ref<Misskey.entities.Note[]>([]); const isRenote = ( props.note.renote != null && props.note.text == null && - props.note.fileIds.length === 0 && + props.note.fileIds && props.note.fileIds.length === 0 && props.note.poll == null ); async function addReplyTo(replyNote: Misskey.entities.Note) { - replies.value.unshift(replyNote); - appearNote.value.repliesCount += 1; + replies.value.unshift(replyNote); + appearNote.value.repliesCount += 1; } async function removeReply(id: Misskey.entities.Note['id']) { - const replyIdx = replies.value.findIndex(note => note.id === id); - if (replyIdx >= 0) { - replies.value.splice(replyIdx, 1); - appearNote.value.repliesCount -= 1; - } + const replyIdx = replies.value.findIndex(note => note.id === id); + if (replyIdx >= 0) { + replies.value.splice(replyIdx, 1); + appearNote.value.repliesCount -= 1; + } } useNoteCapture({ @@ -165,7 +168,7 @@ useNoteCapture({ }); if ($i) { - os.api('notes/renotes', { + misskeyApi('notes/renotes', { noteId: appearNote.value.id, userId: $i.id, limit: 1, @@ -193,8 +196,9 @@ function reply(viaKeyboard = false): void { function react(viaKeyboard = false): void { pleaseLogin(); showMovedDialog(); + sound.playMisskeySfx('reaction'); if (props.note.reactionAcceptance === 'likeOnly') { - os.api('notes/like', { + misskeyApi('notes/like', { noteId: props.note.id, override: defaultLike.value, }); @@ -207,8 +211,8 @@ function react(viaKeyboard = false): void { } } else { blur(); - reactionPicker.show(reactButton.value, reaction => { - os.api('notes/reactions/create', { + reactionPicker.show(reactButton.value ?? null, props.note, reaction => { + misskeyApi('notes/reactions/create', { noteId: props.note.id, reaction: reaction, }); @@ -224,7 +228,8 @@ function react(viaKeyboard = false): void { function like(): void { pleaseLogin(); showMovedDialog(); - os.api('notes/like', { + sound.playMisskeySfx('reaction'); + misskeyApi('notes/like', { noteId: props.note.id, override: defaultLike.value, }); @@ -240,14 +245,14 @@ function like(): void { function undoReact(note): void { const oldReaction = note.myReaction; if (!oldReaction) return; - os.api('notes/reactions/delete', { + misskeyApi('notes/reactions/delete', { noteId: note.id, }); } function undoRenote() : void { if (!renoted.value) return; - os.api('notes/unrenote', { + misskeyApi('notes/unrenote', { noteId: appearNote.value.id, }); os.toast(i18n.ts.rmboost); @@ -269,42 +274,14 @@ watch(() => props.expandAllCws, (expandAllCws) => { }); function boostVisibility() { - os.popupMenu([ - { - type: 'button', - icon: 'ph-globe-hemisphere-west ph-bold ph-lg', - text: i18n.ts._visibility['public'], - action: () => { - renote('public'); - }, - }, - { - type: 'button', - icon: 'ph-house ph-bold ph-lg', - text: i18n.ts._visibility['home'], - action: () => { - renote('home'); - }, - }, - { - type: 'button', - icon: 'ph-lock ph-bold ph-lg', - text: i18n.ts._visibility['followers'], - action: () => { - renote('followers'); - }, - }, - { - type: 'button', - icon: 'ph-planet ph-bold ph-lg', - text: i18n.ts._timelines.local, - action: () => { - renote('local'); - }, - }], renoteButton.value); + if (!defaultStore.state.showVisibilitySelectorOnBoost) { + renote(defaultStore.state.visibilityOnBoost); + } else { + os.popupMenu(boostMenuItems(appearNote, renote), renoteButton.value); + } } -function renote(visibility: 'public' | 'home' | 'followers' | 'specified' | 'local') { +function renote(visibility: Visibility, localOnly: boolean = false) { pleaseLogin(); showMovedDialog(); @@ -317,9 +294,9 @@ function renote(visibility: 'public' | 'home' | 'followers' | 'specified' | 'loc os.popup(MkRippleEffect, { x, y }, {}, 'end'); } - os.api('notes/create', { - renoteId: props.note.id, - channelId: props.note.channelId, + misskeyApi('notes/create', { + renoteId: appearNote.value.id, + channelId: appearNote.value.channelId, }).then(() => { os.toast(i18n.ts.renoted); renoted.value = true; @@ -333,10 +310,10 @@ function renote(visibility: 'public' | 'home' | 'followers' | 'specified' | 'loc os.popup(MkRippleEffect, { x, y }, {}, 'end'); } - os.api('notes/create', { - renoteId: props.note.id, - localOnly: visibility === 'local' ? true : false, - visibility: visibility === 'local' || visibility === 'specified' ? props.note.visibility : visibility, + misskeyApi('notes/create', { + renoteId: appearNote.value.id, + localOnly: localOnly, + visibility: visibility, }).then(() => { os.toast(i18n.ts.renoted); renoted.value = true; @@ -353,7 +330,7 @@ function quote() { renote: appearNote.value, channel: appearNote.value.channel, }).then(() => { - os.api('notes/renotes', { + misskeyApi('notes/renotes', { noteId: props.note.id, userId: $i.id, limit: 1, @@ -375,7 +352,7 @@ function quote() { os.post({ renote: appearNote.value, }).then(() => { - os.api('notes/renotes', { + misskeyApi('notes/renotes', { noteId: props.note.id, userId: $i.id, limit: 1, @@ -404,7 +381,7 @@ function menu(viaKeyboard = false): void { } if (props.detail) { - os.api('notes/children', { + misskeyApi('notes/children', { noteId: props.note.id, limit: numberOfReplies.value, showQuotes: false, diff --git a/packages/frontend/src/components/MkNotes.vue b/packages/frontend/src/components/MkNotes.vue index fc1c8a0f09..afe43d965c 100644 --- a/packages/frontend/src/components/MkNotes.vue +++ b/packages/frontend/src/components/MkNotes.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -38,7 +38,7 @@ SPDX-License-Identifier: AGPL-3.0-only :ad="true" :class="$style.notes" > - <SkNote :key="note._featuredId_ || note._prId_ || note.id" :class="$style.note" :note="note"/> + <SkNote :key="note._featuredId_ || note._prId_ || note.id" :class="$style.note" :note="note" :withHardMute="true"/> </MkDateSeparatedList> </div> </template> diff --git a/packages/frontend/src/components/MkNotification.vue b/packages/frontend/src/components/MkNotification.vue index ed79ca0d86..562cc38bf3 100644 --- a/packages/frontend/src/components/MkNotification.vue +++ b/packages/frontend/src/components/MkNotification.vue @@ -1,15 +1,13 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> <template> <div :class="$style.root"> <div :class="$style.head"> - <MkAvatar v-if="notification.type === 'pollEnded'" :class="$style.icon" :user="notification.note.user" link preview/> - <MkAvatar v-else-if="notification.type === 'note'" :class="$style.icon" :user="notification.note.user" link preview/> - <MkAvatar v-else-if="notification.type === 'roleAssigned'" :class="$style.icon" :user="$i" link preview/> - <MkAvatar v-else-if="notification.type === 'achievementEarned'" :class="$style.icon" :user="$i" link preview/> + <MkAvatar v-if="['pollEnded', 'note', 'edited'].includes(notification.type) && notification.note" :class="$style.icon" :user="notification.note.user" link preview/> + <MkAvatar v-else-if="['roleAssigned', 'achievementEarned'].includes(notification.type)" :class="$style.icon" :user="$i" link preview/> <div v-else-if="notification.type === 'reaction:grouped'" :class="[$style.icon, $style.icon_reactionGroup]"><i class="ph-smiley ph-bold ph-lg" style="line-height: 1;"></i></div> <div v-else-if="notification.type === 'renote:grouped'" :class="[$style.icon, $style.icon_renoteGroup]"><i class="ph-rocket-launch ph-bold ph-lg" style="line-height: 1;"></i></div> <img v-else-if="notification.type === 'test'" :class="$style.icon" :src="infoImageUrl"/> @@ -26,8 +24,10 @@ SPDX-License-Identifier: AGPL-3.0-only [$style.t_quote]: notification.type === 'quote', [$style.t_pollEnded]: notification.type === 'pollEnded', [$style.t_achievementEarned]: notification.type === 'achievementEarned', + [$style.t_roleAssigned]: notification.type === 'roleAssigned' && notification.role.iconUrl == null, + [$style.t_pollEnded]: notification.type === 'edited', }]" - > + > <!-- we re-use t_pollEnded for "edited" instead of making an identical style --> <i v-if="notification.type === 'follow'" class="ph-plus ph-bold ph-lg"></i> <i v-else-if="notification.type === 'receiveFollowRequest'" class="ph-clock ph-bold ph-lg"></i> <i v-else-if="notification.type === 'followRequestAccepted'" class="ph-check ph-bold ph-lg"></i> @@ -37,12 +37,16 @@ SPDX-License-Identifier: AGPL-3.0-only <i v-else-if="notification.type === 'quote'" class="ph-quotes ph-bold ph-lg"></i> <i v-else-if="notification.type === 'pollEnded'" class="ph-chart-bar-horizontal ph-bold ph-lg"></i> <i v-else-if="notification.type === 'achievementEarned'" class="ph-trophy ph-bold ph-lg"></i> - <img v-else-if="notification.type === 'roleAssigned'" style="height: 1.3em; vertical-align: -22%;" :src="notification.role.iconUrl" alt=""/> + <template v-else-if="notification.type === 'roleAssigned'"> + <img v-if="notification.role.iconUrl" style="height: 1.3em; vertical-align: -22%;" :src="notification.role.iconUrl" alt=""/> + <i v-else class="ph-seal-check ph-bold ph-lg"></i> + </template> + <i v-else-if="notification.type === 'edited'" class="ph-pencil ph-bold ph-lg"></i> <!-- notification.reaction が null になることはまずないが、ここでoptional chaining使うと一部ブラウザで刺さるので念の為 --> <MkReactionIcon v-else-if="notification.type === 'reaction'" :withTooltip="true" - :reaction="notification.reaction ? notification.reaction.replace(/^:(\w+):$/, ':$1@.:') : notification.reaction" + :reaction="notification.reaction.replace(/^:(\w+):$/, ':$1@.:')" :noStyle="true" style="width: 100%; height: 100%;" /> @@ -55,10 +59,11 @@ SPDX-License-Identifier: AGPL-3.0-only <span v-else-if="notification.type === 'roleAssigned'">{{ i18n.ts._notification.roleAssigned }}</span> <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>{{ notification.header }}</span> + <MkA v-else-if="notification.type === 'follow' || notification.type === 'mention' || notification.type === 'reply' || notification.type === 'renote' || notification.type === 'quote' || notification.type === 'reaction' || notification.type === 'receiveFollowRequest' || notification.type === 'followRequestAccepted'" 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.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-if="notification.type === 'app'">{{ notification.header }}</span> + <span v-else-if="notification.type === 'edited'">{{ i18n.ts._notification.edited }}</span> <MkTime v-if="withTime" :time="notification.createdAt" :class="$style.headerTime"/> </header> <div> @@ -97,7 +102,6 @@ SPDX-License-Identifier: AGPL-3.0-only </MkA> <template v-else-if="notification.type === 'follow'"> <span :class="$style.text" style="opacity: 0.6;">{{ i18n.ts.youGotNewFollower }}</span> - <div v-if="full"><MkFollowButton :user="notification.user" :full="true"/></div> </template> <span v-else-if="notification.type === 'followRequestAccepted'" :class="$style.text" style="opacity: 0.6;">{{ i18n.ts.followRequestAccepted }}</span> <template v-else-if="notification.type === 'receiveFollowRequest'"> @@ -113,12 +117,12 @@ SPDX-License-Identifier: AGPL-3.0-only </span> <div v-if="notification.type === 'reaction:grouped'"> - <div v-for="reaction of notification.reactions" :class="$style.reactionsItem"> + <div v-for="reaction of notification.reactions" :key="reaction.user.id + reaction.reaction" :class="$style.reactionsItem"> <MkAvatar :class="$style.reactionsItemAvatar" :user="reaction.user" link preview/> <div :class="$style.reactionsItemReaction"> <MkReactionIcon :withTooltip="true" - :reaction="reaction.reaction ? reaction.reaction.replace(/^:(\w+):$/, ':$1@.:') : reaction.reaction" + :reaction="reaction.reaction.replace(/^:(\w+):$/, ':$1@.:')" :noStyle="true" style="width: 100%; height: 100%;" /> @@ -126,10 +130,16 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> <div v-else-if="notification.type === 'renote:grouped'"> - <div v-for="user of notification.users" :class="$style.reactionsItem"> + <div v-for="user of notification.users" :key="user.id" :class="$style.reactionsItem"> <MkAvatar :class="$style.reactionsItemAvatar" :user="user" link preview/> </div> </div> + + <MkA v-else-if="notification.type === 'edited'" :class="$style.text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)"> + <i class="ph-quotes ph-bold ph-lg" :class="$style.quote"></i> + <Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="true" :author="notification.note.user"/> + <i class="ph-quotes ph-bold ph-lg" :class="$style.quote"></i> + </MkA> </div> </div> </div> @@ -139,16 +149,17 @@ SPDX-License-Identifier: AGPL-3.0-only import { ref } from 'vue'; import * as Misskey from 'misskey-js'; import MkReactionIcon from '@/components/MkReactionIcon.vue'; -import MkFollowButton from '@/components/MkFollowButton.vue'; import MkButton from '@/components/MkButton.vue'; import { getNoteSummary } from '@/scripts/get-note-summary.js'; import { notePage } from '@/filters/note.js'; import { userPage } from '@/filters/user.js'; import { i18n } from '@/i18n.js'; -import * as os from '@/os.js'; -import { $i } from '@/account.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; +import { signinRequired } from '@/account.js'; import { infoImageUrl } from '@/instance.js'; +const $i = signinRequired(); + const props = withDefaults(defineProps<{ notification: Misskey.entities.Notification; withTime?: boolean; @@ -161,13 +172,15 @@ const props = withDefaults(defineProps<{ const followRequestDone = ref(false); const acceptFollowRequest = () => { + if (props.notification.user == null) return; followRequestDone.value = true; - os.api('following/requests/accept', { userId: props.notification.user.id }); + misskeyApi('following/requests/accept', { userId: props.notification.user.id }); }; const rejectFollowRequest = () => { + if (props.notification.user == null) return; followRequestDone.value = true; - os.api('following/requests/reject', { userId: props.notification.user.id }); + misskeyApi('following/requests/reject', { userId: props.notification.user.id }); }; </script> @@ -283,6 +296,12 @@ const rejectFollowRequest = () => { pointer-events: none; } +.t_roleAssigned { + padding: 3px; + background: #88a6b7; + pointer-events: none; +} + .tail { flex: 1; min-width: 0; diff --git a/packages/frontend/src/components/MkNotificationSelectWindow.vue b/packages/frontend/src/components/MkNotificationSelectWindow.vue index 6725776f43..71b38d99ed 100644 --- a/packages/frontend/src/components/MkNotificationSelectWindow.vue +++ b/packages/frontend/src/components/MkNotificationSelectWindow.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -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> diff --git a/packages/frontend/src/components/MkNotifications.vue b/packages/frontend/src/components/MkNotifications.vue index a157820d56..68bf1bf3d8 100644 --- a/packages/frontend/src/components/MkNotifications.vue +++ b/packages/frontend/src/components/MkNotifications.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -15,11 +15,11 @@ SPDX-License-Identifier: AGPL-3.0-only <template #default="{ items: notifications }"> <MkDateSeparatedList v-if="defaultStore.state.noteDesign === 'misskey'" v-slot="{ item: notification }" :class="$style.list" :items="notifications" :noGap="true"> - <MkNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :key="notification.id" :note="notification.note" :withHardMute="true"/> + <MkNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :key="notification.id + ':note'" :note="notification.note" :withHardMute="true"/> <XNotification v-else :key="notification.id" :notification="notification" :withTime="true" :full="true" class="_panel"/> </MkDateSeparatedList> <MkDateSeparatedList v-else-if="defaultStore.state.noteDesign === 'sharkey'" v-slot="{ item: notification }" :class="$style.list" :items="notifications" :noGap="true"> - <SkNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :key="notification.id" :note="notification.note" :withHardMute="true"/> + <SkNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :key="notification.id + ':note'" :note="notification.note" :withHardMute="true"/> <XNotification v-else :key="notification.id" :notification="notification" :withTime="true" :full="true" class="_panel"/> </MkDateSeparatedList> </template> @@ -40,6 +40,7 @@ import { notificationTypes } from '@/const.js'; import { infoImageUrl } from '@/instance.js'; import { defaultStore } from '@/store.js'; import MkPullToRefresh from '@/components/MkPullToRefresh.vue'; +import * as Misskey from 'misskey-js'; const props = defineProps<{ excludeTypes?: typeof notificationTypes[number][]; @@ -68,7 +69,7 @@ function onNotification(notification) { } if (!isMuted) { - pagingComponent.value.prepend(notification); + pagingComponent.value?.prepend(notification); } } @@ -80,17 +81,19 @@ function reload() { }); } -let connection; +let connection: Misskey.ChannelConnection<Misskey.Channels['main']>; onMounted(() => { connection = useStream().useChannel('main'); connection.on('notification', onNotification); + connection.on('notificationFlushed', reload); }); onActivated(() => { pagingComponent.value?.reload(); connection = useStream().useChannel('main'); connection.on('notification', onNotification); + connection.on('notificationFlushed', reload); }); onUnmounted(() => { diff --git a/packages/frontend/src/components/MkNumber.vue b/packages/frontend/src/components/MkNumber.vue index aa04ab253b..a278205b61 100644 --- a/packages/frontend/src/components/MkNumber.vue +++ b/packages/frontend/src/components/MkNumber.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -9,7 +9,6 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { reactive, watch } from 'vue'; -import gsap from 'gsap'; import number from '@/filters/number.js'; const props = defineProps<{ @@ -20,8 +19,24 @@ const tweened = reactive({ number: 0, }); -watch(() => props.value, (n) => { - gsap.to(tweened, { duration: 1, number: Number(n) || 0 }); +watch(() => props.value, (to, from) => { + // requestAnimationFrameを利用して、500msでfromからtoまでを1次関数的に変化させる + let start: number | null = null; + + function step(timestamp: number) { + if (start === null) { + start = timestamp; + } + const elapsed = timestamp - start; + tweened.number = (from ?? 0) + (to - (from ?? 0)) * elapsed / 500; + if (elapsed < 500) { + window.requestAnimationFrame(step); + } else { + tweened.number = to; + } + } + + window.requestAnimationFrame(step); }, { immediate: true, }); diff --git a/packages/frontend/src/components/MkNumberDiff.vue b/packages/frontend/src/components/MkNumberDiff.vue index a98b6c4713..1825cc5405 100644 --- a/packages/frontend/src/components/MkNumberDiff.vue +++ b/packages/frontend/src/components/MkNumberDiff.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/MkObjectView.value.vue b/packages/frontend/src/components/MkObjectView.value.vue index aa05c43c0b..870599aa94 100644 --- a/packages/frontend/src/components/MkObjectView.value.vue +++ b/packages/frontend/src/components/MkObjectView.value.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/MkObjectView.vue b/packages/frontend/src/components/MkObjectView.vue index 30ec896ce4..bb9122c976 100644 --- a/packages/frontend/src/components/MkObjectView.vue +++ b/packages/frontend/src/components/MkObjectView.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/MkOmit.vue b/packages/frontend/src/components/MkOmit.vue index 702bb95dc7..a0bc0c628e 100644 --- a/packages/frontend/src/components/MkOmit.vue +++ b/packages/frontend/src/components/MkOmit.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -27,7 +27,7 @@ const omitted = ref(false); const ignoreOmit = ref(false); const calcOmit = () => { - if (omitted.value || ignoreOmit.value) return; + if (omitted.value || ignoreOmit.value || content.value == null) return; omitted.value = content.value.offsetHeight > props.maxHeight; }; @@ -37,7 +37,7 @@ const omitObserver = new ResizeObserver((entries, observer) => { onMounted(() => { calcOmit(); - omitObserver.observe(content.value); + omitObserver.observe(content.value as HTMLElement); }); onUnmounted(() => { diff --git a/packages/frontend/src/components/MkPagePreview.vue b/packages/frontend/src/components/MkPagePreview.vue index 6c8a0e56a6..f6dc00698c 100644 --- a/packages/frontend/src/components/MkPagePreview.vue +++ b/packages/frontend/src/components/MkPagePreview.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -20,7 +20,7 @@ SPDX-License-Identifier: AGPL-3.0-only </header> <p v-if="page.summary" :title="page.summary">{{ page.summary.length > 85 ? page.summary.slice(0, 85) + '…' : page.summary }}</p> <footer> - <img class="icon" :src="page.user.avatarUrl"/> + <img v-if="page.user.avatarUrl" class="icon" :src="page.user.avatarUrl"/> <p>{{ userName(page.user) }}</p> </footer> </article> diff --git a/packages/frontend/src/components/MkPageWindow.vue b/packages/frontend/src/components/MkPageWindow.vue index 13a703e9f6..c3fa724a7a 100644 --- a/packages/frontend/src/components/MkPageWindow.vue +++ b/packages/frontend/src/components/MkPageWindow.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -16,33 +16,33 @@ SPDX-License-Identifier: AGPL-3.0-only @closed="$emit('closed')" > <template #header> - <template v-if="pageMetadata?.value"> - <i v-if="pageMetadata.value.icon" :class="pageMetadata.value.icon" style="margin-right: 0.5em;"></i> - <span>{{ pageMetadata.value.title }}</span> + <template v-if="pageMetadata"> + <i v-if="pageMetadata.icon" :class="pageMetadata.icon" style="margin-right: 0.5em;"></i> + <span>{{ pageMetadata.title }}</span> </template> </template> <div ref="contents" :class="$style.root" style="container-type: inline-size;"> - <RouterView :key="reloadCount" :router="router"/> + <RouterView :key="reloadCount" :router="windowRouter"/> </div> </MkWindow> </template> <script lang="ts" setup> -import { ComputedRef, onMounted, onUnmounted, provide, shallowRef, ref, computed } from 'vue'; +import { computed, onMounted, onUnmounted, provide, ref, shallowRef } from 'vue'; import RouterView from '@/components/global/RouterView.vue'; import MkWindow from '@/components/MkWindow.vue'; import { popout as _popout } from '@/scripts/popout.js'; import copyToClipboard from '@/scripts/copy-to-clipboard.js'; import { url } from '@/config.js'; -import { mainRouter, routes, page } from '@/router.js'; -import { $i } from '@/account.js'; -import { Router, useScrollPositionManager } from '@/nirax.js'; +import { useScrollPositionManager } from '@/nirax.js'; import { i18n } from '@/i18n.js'; -import { PageMetadata, provideMetadataReceiver } from '@/scripts/page-metadata.js'; +import { PageMetadata, provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js'; import { openingWindowsCount } from '@/os.js'; import { claimAchievement } from '@/scripts/achievements.js'; import { getScrollContainer } from '@/scripts/scroll.js'; +import { useRouterFactory } from '@/router/supplier.js'; +import { mainRouter } from '@/router/main.js'; const props = defineProps<{ initialPath: string; @@ -52,17 +52,18 @@ defineEmits<{ (ev: 'closed'): void; }>(); -const router = new Router(routes, props.initialPath, !!$i, page(() => import('@/pages/not-found.vue'))); +const routerFactory = useRouterFactory(); +const windowRouter = routerFactory(props.initialPath); -const contents = shallowRef<HTMLElement>(); -const pageMetadata = ref<null | ComputedRef<PageMetadata>>(); +const contents = shallowRef<HTMLElement | null>(null); +const pageMetadata = ref<null | PageMetadata>(null); const windowEl = shallowRef<InstanceType<typeof MkWindow>>(); const history = ref<{ path: string; key: any; }[]>([{ - path: router.getCurrentPath(), - key: router.getCurrentKey(), + path: windowRouter.getCurrentPath(), + key: windowRouter.getCurrentKey(), }]); const buttonsLeft = computed(() => { - const buttons = []; + const buttons: Record<string, unknown>[] = []; if (history.value.length > 1) { buttons.push({ @@ -75,7 +76,7 @@ const buttonsLeft = computed(() => { }); const buttonsRight = computed(() => { const buttons = [{ - icon: 'ph-arrow-clockwise ph-bold ph-lg', + icon: 'ph-arrows-clockwise ph-bold ph-lg', title: i18n.ts.reload, onClick: reload, }, { @@ -88,14 +89,23 @@ const buttonsRight = computed(() => { }); const reloadCount = ref(0); -router.addListener('push', ctx => { +windowRouter.addListener('push', ctx => { history.value.push({ path: ctx.path, key: ctx.key }); }); -provide('router', router); -provideMetadataReceiver((info) => { +windowRouter.addListener('replace', ctx => { + history.value.pop(); + history.value.push({ path: ctx.path, key: ctx.key }); +}); + +windowRouter.init(); + +provide('router', windowRouter); +provideMetadataReceiver((metadataGetter) => { + const info = metadataGetter(); pageMetadata.value = info; }); +provideReactiveMetadata(pageMetadata); provide('shouldOmitHeaderTitle', true); provide('shouldHeaderThin', true); provide('forceSpacerMin', true); @@ -113,20 +123,20 @@ const contextmenu = computed(() => ([{ icon: 'ph-arrow-square-out ph-bold ph-lg', text: i18n.ts.openInNewTab, action: () => { - window.open(url + router.getCurrentPath(), '_blank', 'noopener'); - windowEl.value.close(); + window.open(url + windowRouter.getCurrentPath(), '_blank', 'noopener'); + windowEl.value?.close(); }, }, { icon: 'ph-link ph-bold ph-lg', text: i18n.ts.copyLink, action: () => { - copyToClipboard(url + router.getCurrentPath()); + copyToClipboard(url + windowRouter.getCurrentPath()); }, }])); function back() { history.value.pop(); - router.replace(history.value.at(-1)!.path, history.value.at(-1)!.key); + windowRouter.replace(history.value.at(-1)!.path, history.value.at(-1)!.key); } function reload() { @@ -134,20 +144,20 @@ function reload() { } function close() { - windowEl.value.close(); + windowEl.value?.close(); } function expand() { - mainRouter.push(router.getCurrentPath(), 'forcePage'); - windowEl.value.close(); + mainRouter.push(windowRouter.getCurrentPath(), 'forcePage'); + windowEl.value?.close(); } function popout() { - _popout(router.getCurrentPath(), windowEl.value.$el); - windowEl.value.close(); + _popout(windowRouter.getCurrentPath(), windowEl.value?.$el); + windowEl.value?.close(); } -useScrollPositionManager(() => getScrollContainer(contents.value), router); +useScrollPositionManager(() => getScrollContainer(contents.value), windowRouter); onMounted(() => { openingWindowsCount.value++; diff --git a/packages/frontend/src/components/MkPagination.vue b/packages/frontend/src/components/MkPagination.vue index bdd96238d3..62a85389ad 100644 --- a/packages/frontend/src/components/MkPagination.vue +++ b/packages/frontend/src/components/MkPagination.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -46,6 +46,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { computed, ComputedRef, isRef, nextTick, onActivated, onBeforeMount, onBeforeUnmount, onDeactivated, ref, shallowRef, watch } from 'vue'; import * as Misskey from 'misskey-js'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { onScrollTop, isTopVisible, getBodyScrollHeight, getScrollContainer, onScrollBottom, scrollToBottom, scroll, isBottomVisible } from '@/scripts/scroll.js'; import { useDocumentVisibility } from '@/scripts/use-document-visibility.js'; import { defaultStore } from '@/store.js'; @@ -203,7 +204,7 @@ async function init(): Promise<void> { queue.value = new Map(); fetching.value = true; const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {}; - await os.api(props.pagination.endpoint, { + await misskeyApi<MisskeyEntity[]>(props.pagination.endpoint, { ...params, limit: props.pagination.limit ?? 10, allowPartial: true, @@ -239,7 +240,7 @@ const fetchMore = async (): Promise<void> => { if (!more.value || fetching.value || moreFetching.value || items.value.size === 0) return; moreFetching.value = true; const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {}; - await os.api(props.pagination.endpoint, { + await misskeyApi<MisskeyEntity[]>(props.pagination.endpoint, { ...params, limit: SECOND_FETCH_LIMIT, ...(props.pagination.offsetMode ? { @@ -303,7 +304,7 @@ const fetchMoreAhead = async (): Promise<void> => { if (!more.value || fetching.value || moreFetching.value || items.value.size === 0) return; moreFetching.value = true; const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {}; - await os.api(props.pagination.endpoint, { + await misskeyApi<MisskeyEntity[]>(props.pagination.endpoint, { ...params, limit: SECOND_FETCH_LIMIT, ...(props.pagination.offsetMode ? { diff --git a/packages/frontend/src/components/MkPasswordDialog.vue b/packages/frontend/src/components/MkPasswordDialog.vue index 85dd402730..3c0cdaa786 100644 --- a/packages/frontend/src/components/MkPasswordDialog.vue +++ b/packages/frontend/src/components/MkPasswordDialog.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -41,7 +41,9 @@ import MkInput from '@/components/MkInput.vue'; import MkButton from '@/components/MkButton.vue'; import MkModalWindow from '@/components/MkModalWindow.vue'; import { i18n } from '@/i18n.js'; -import { $i } from '@/account.js'; +import { signinRequired } from '@/account.js'; + +const $i = signinRequired(); const emit = defineEmits<{ (ev: 'done', v: { password: string; token: string | null; }): void; diff --git a/packages/frontend/src/components/MkPlusOneEffect.vue b/packages/frontend/src/components/MkPlusOneEffect.vue index a741a3f7a8..6c22edb943 100644 --- a/packages/frontend/src/components/MkPlusOneEffect.vue +++ b/packages/frontend/src/components/MkPlusOneEffect.vue @@ -1,11 +1,11 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> <template> <div :class="$style.root" :style="{ zIndex, top: `${y - 64}px`, left: `${x - 64}px` }"> - <span class="text" :class="{ up }">+1</span> + <span class="text" :class="{ up }">+{{ value }}</span> </div> </template> @@ -16,7 +16,9 @@ import * as os from '@/os.js'; const props = withDefaults(defineProps<{ x: number; y: number; + value?: number | string; }>(), { + value: 1, }); const emit = defineEmits<{ @@ -40,6 +42,7 @@ onMounted(() => { <style lang="scss" module> .root { + user-select: none; pointer-events: none; position: fixed; width: 128px; diff --git a/packages/frontend/src/components/MkPoll.vue b/packages/frontend/src/components/MkPoll.vue index 6ee0c44658..8c0804de04 100644 --- a/packages/frontend/src/components/MkPoll.vue +++ b/packages/frontend/src/components/MkPoll.vue @@ -1,29 +1,28 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> <template> <div :class="{ [$style.done]: closed || isVoted }"> <ul :class="$style.choices"> - <li v-for="(choice, i) in note.poll.choices" :key="i" :class="$style.choice" @click="vote(i)"> + <li v-for="(choice, i) in poll.choices" :key="i" :class="$style.choice" @click="vote(i)"> <div :class="$style.bg" :style="{ 'width': `${showResult ? (choice.votes / total * 100) : 0}%` }"></div> <span :class="$style.fg"> <template v-if="choice.isVoted"><i class="ph-check ph-bold ph-lg" 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 v-if="note.poll.multiple"> · </span> - <span v-if="note.poll.multiple" style="color: var(--accent); font-weight: bolder;">{{ i18n.ts._poll.multiple }}</span> + <span>{{ i18n.tsx._poll.totalVotes({ n: total }) }}</span> + <span v-if="poll.multiple"> · </span> + <span v-if="poll.multiple" style="color: var(--accent); font-weight: bolder;">{{ i18n.ts._poll.multiple }}</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> <span v-else-if="closed">{{ i18n.ts._poll.closed }}</span> - <span v-if="!isLocal"><span> · </span><a @click.stop="refresh">{{ i18n.ts.reload }}</a></span> <span v-if="remaining > 0"> · {{ timer }}</span> </p> </div> @@ -35,36 +34,38 @@ import * as Misskey from 'misskey-js'; import { sum } from '@/scripts/array.js'; import { pleaseLogin } from '@/scripts/please-login.js'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; import { useInterval } from '@/scripts/use-interval.js'; const props = defineProps<{ - note: Misskey.entities.Note; + noteId: string; + poll: NonNullable<Misskey.entities.Note['poll']>; readOnly?: boolean; }>(); const remaining = ref(-1); -const total = computed(() => sum(props.note.poll.choices.map(x => x.votes))); +const total = computed(() => sum(props.poll.choices.map(x => x.votes))); const closed = computed(() => remaining.value === 0); -const isLocal = computed(() => !props.note.uri); -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', { - s: Math.floor(remaining.value % 60), - m: Math.floor(remaining.value / 60) % 60, - h: Math.floor(remaining.value / 3600) % 24, - d: Math.floor(remaining.value / 86400), - })); +const isVoted = computed(() => !props.poll.multiple && props.poll.choices.some(c => c.isVoted)); +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, + d: Math.floor(remaining.value / 86400), +})); const showResult = ref(props.readOnly || isVoted.value); // 期限付きアンケート -if (props.note.poll.expiresAt) { +if (props.poll.expiresAt) { const tick = () => { - remaining.value = Math.floor(Math.max(new Date(props.note.poll.expiresAt).getTime() - Date.now(), 0) / 1000); + remaining.value = Math.floor(Math.max(new Date(props.poll.expiresAt!).getTime() - Date.now(), 0) / 1000); if (remaining.value === 0) { showResult.value = true; } @@ -80,34 +81,26 @@ const vote = async (id) => { pleaseLogin(); if (props.readOnly || closed.value || isVoted.value) return; - if (!props.note.poll.multiple) { + if (!props.poll.multiple) { const { canceled } = await os.confirm({ type: 'question', - text: i18n.t('voteConfirm', { choice: props.note.poll.choices[id].text }), + text: i18n.tsx.voteConfirm({ choice: props.poll.choices[id].text }), }); if (canceled) return; } else { const { canceled } = await os.confirm({ type: 'question', - text: i18n.t('voteConfirmMulti', { choice: props.note.poll.choices[id].text }), + text: i18n.tsx.voteConfirmMulti({ choice: props.poll.choices[id].text }), }); if (canceled) return; } - await os.api('notes/polls/vote', { - noteId: props.note.id, + await misskeyApi('notes/polls/vote', { + noteId: props.noteId, choice: id, }); - if (!showResult.value) showResult.value = !props.note.poll.multiple; + if (!showResult.value) showResult.value = !props.poll.multiple; }; - -async function refresh() { - if (!props.note.uri) return; - const obj = await os.apiWithDialog('ap/show', { uri: props.note.uri }); - if (obj.type === 'Note' && obj.object.poll) { - props.note.poll = obj.object.poll; // eslint-disable-line vue/no-mutating-props - } -} </script> <style lang="scss" module> diff --git a/packages/frontend/src/components/MkPollEditor.vue b/packages/frontend/src/components/MkPollEditor.vue index f46779a632..98fbf25370 100644 --- a/packages/frontend/src/components/MkPollEditor.vue +++ b/packages/frontend/src/components/MkPollEditor.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -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="ph-x ph-bold ph-lg"></i> @@ -62,21 +62,18 @@ import { formatDateTimeString } from '@/scripts/format-time-string.js'; import { addTime } from '@/scripts/time.js'; import { i18n } from '@/i18n.js'; +export type PollEditorModelValue = { + expiresAt: number | null; + expiredAfter: number | null; + choices: string[]; + multiple: boolean; +}; + const props = defineProps<{ - modelValue: { - expiresAt: string; - expiredAfter: number; - choices: string[]; - multiple: boolean; - }; + modelValue: PollEditorModelValue; }>(); const emit = defineEmits<{ - (ev: 'update:modelValue', v: { - expiresAt: string; - expiredAfter: number; - choices: string[]; - multiple: boolean; - }): void; + (ev: 'update:modelValue', v: PollEditorModelValue): void; }>(); const choices = ref(props.modelValue.choices); @@ -89,7 +86,9 @@ const unit = ref('second'); if (props.modelValue.expiresAt) { expiration.value = 'at'; - atDate.value = atTime.value = props.modelValue.expiresAt; + const expiresAt = new Date(props.modelValue.expiresAt); + atDate.value = formatDateTimeString(expiresAt, 'yyyy-MM-dd'); + atTime.value = formatDateTimeString(expiresAt, 'HH:mm'); } else if (typeof props.modelValue.expiredAfter === 'number') { expiration.value = 'after'; after.value = props.modelValue.expiredAfter / 1000; @@ -113,20 +112,21 @@ function remove(i) { choices.value = choices.value.filter((_, _i) => _i !== i); } -function get() { +function get(): PollEditorModelValue { const calcAt = () => { return new Date(`${atDate.value} ${atTime.value}`).getTime(); }; const calcAfter = () => { - let base = parseInt(after.value); + let base = parseInt(after.value.toString()); switch (unit.value) { + // @ts-expect-error fallthrough case 'day': base *= 24; - // fallthrough + // @ts-expect-error fallthrough case 'hour': base *= 60; - // fallthrough + // @ts-expect-error fallthrough case 'minute': base *= 60; - // fallthrough + // eslint-disable-next-line no-fallthrough case 'second': return base *= 1000; default: return null; } @@ -135,10 +135,8 @@ function get() { return { choices: choices.value, multiple: multiple.value, - ...( - expiration.value === 'at' ? { expiresAt: calcAt() } : - expiration.value === 'after' ? { expiredAfter: calcAfter() } : {} - ), + expiresAt: expiration.value === 'at' ? calcAt() : null, + expiredAfter: expiration.value === 'after' ? calcAfter() : null, }; } diff --git a/packages/frontend/src/components/MkPopupMenu.vue b/packages/frontend/src/components/MkPopupMenu.vue index 1d92374f4f..3748f0cc64 100644 --- a/packages/frontend/src/components/MkPopupMenu.vue +++ b/packages/frontend/src/components/MkPopupMenu.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index aa37cef6c2..d9e50fbb79 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -84,7 +84,7 @@ SPDX-License-Identifier: AGPL-3.0-only <button v-tooltip="i18n.ts.useCw" class="_button" :class="[$style.footerButton, { [$style.footerButtonActive]: useCw }]" @click="useCw = !useCw"><i class="ph-eye-slash ph-bold ph-lg"></i></button> <button v-tooltip="i18n.ts.mention" class="_button" :class="$style.footerButton" @click="insertMention"><i class="ph-at ph-bold ph-lg"></i></button> <button v-tooltip="i18n.ts.hashtags" class="_button" :class="[$style.footerButton, { [$style.footerButtonActive]: withHashtags }]" @click="withHashtags = !withHashtags"><i class="ph-hash ph-bold ph-lg"></i></button> - <button v-if="postFormActions.length > 0" v-tooltip="i18n.ts.plugin" class="_button" :class="$style.footerButton" @click="showActions"><i class="ph-plug ph-bold ph-lg"></i></button> + <button v-if="postFormActions.length > 0" v-tooltip="i18n.ts.plugins" class="_button" :class="$style.footerButton" @click="showActions"><i class="ph-plug ph-bold ph-lg"></i></button> <button v-tooltip="i18n.ts.emoji" :class="['_button', $style.footerButton]" @click="insertEmoji"><i class="ph-smiley ph-bold ph-lg"></i></button> <button v-if="showAddMfmFunction" v-tooltip="i18n.ts.addMfmFunction" :class="['_button', $style.footerButton]" @click="insertMfmFunction"><i class="ph-palette ph-bold ph-lg"></i></button> </div> @@ -101,27 +101,28 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { inject, watch, nextTick, onMounted, defineAsyncComponent, provide, shallowRef, ref, computed } from 'vue'; -import * as mfm from '@sharkey/sfm-js'; +import { inject, watch, nextTick, onMounted, defineAsyncComponent, provide, shallowRef, ref, computed, toRaw } from 'vue'; +import * as mfm from '@transfem-org/sfm-js'; import * as Misskey from 'misskey-js'; import insertTextAtCursor from 'insert-text-at-cursor'; import { toASCII } from 'punycode/'; import MkNoteSimple from '@/components/MkNoteSimple.vue'; import MkNotePreview from '@/components/MkNotePreview.vue'; import XPostFormAttaches from '@/components/MkPostFormAttaches.vue'; -import MkPollEditor from '@/components/MkPollEditor.vue'; +import MkPollEditor, { type PollEditorModelValue } from '@/components/MkPollEditor.vue'; import { host, url } from '@/config.js'; import { erase, unique } from '@/scripts/array.js'; import { extractMentions } from '@/scripts/extract-mentions.js'; import { formatTimeString } from '@/scripts/format-time-string.js'; import { Autocomplete } from '@/scripts/autocomplete.js'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { selectFiles } from '@/scripts/select-file.js'; import { defaultStore, notePostInterruptors, postFormActions } from '@/store.js'; import MkInfo from '@/components/MkInfo.vue'; import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; -import { $i, notesCount, incNotesCount, getAccounts, openAccountMenu as openAccountMenu_ } from '@/account.js'; +import { signinRequired, notesCount, incNotesCount, getAccounts, openAccountMenu as openAccountMenu_ } from '@/account.js'; import { uploadFile } from '@/scripts/upload.js'; import { deepClone } from '@/scripts/clone.js'; import MkRippleEffect from '@/components/MkRippleEffect.vue'; @@ -130,6 +131,8 @@ import { claimAchievement } from '@/scripts/achievements.js'; import { emojiPicker } from '@/scripts/emoji-picker.js'; import { mfmFunctionPicker } from '@/scripts/mfm-function-picker.js'; +const $i = signinRequired(); + const modal = inject('modal'); const props = withDefaults(defineProps<{ @@ -137,13 +140,13 @@ const props = withDefaults(defineProps<{ renote?: Misskey.entities.Note; channel?: Misskey.entities.Channel; // TODO mention?: Misskey.entities.User; - specified?: Misskey.entities.User; + specified?: Misskey.entities.UserDetailed; initialText?: string; initialCw?: string; initialVisibility?: (typeof Misskey.noteVisibilities)[number]; initialFiles?: Misskey.entities.DriveFile[]; initialLocalOnly?: boolean; - initialVisibleUsers?: Misskey.entities.User[]; + initialVisibleUsers?: Misskey.entities.UserDetailed[]; initialNote?: Misskey.entities.Note; instant?: boolean; fixed?: boolean; @@ -171,18 +174,13 @@ const emit = defineEmits<{ const textareaEl = shallowRef<HTMLTextAreaElement | null>(null); const cwInputEl = shallowRef<HTMLInputElement | null>(null); const hashtagsInputEl = shallowRef<HTMLInputElement | null>(null); -const visibilityButton = shallowRef<HTMLElement | null>(null); +const visibilityButton = shallowRef<HTMLElement>(); const posting = ref(false); const posted = ref(false); const text = ref(props.initialText ?? ''); const files = ref(props.initialFiles ?? []); -const poll = ref<{ - choices: string[]; - multiple: boolean; - expiresAt: string | null; - expiredAfter: string | null; -} | null>(null); +const poll = ref<PollEditorModelValue | null>(null); const useCw = ref<boolean>(!!props.initialCw); const showPreview = ref(defaultStore.state.showPreview); watch(showPreview, () => defaultStore.set('showPreview', showPreview.value)); @@ -309,7 +307,7 @@ if (props.reply && props.reply.text != null) { } } -if ($i?.isSilenced && visibility.value === 'public') { +if ($i.isSilenced && visibility.value === 'public') { visibility.value = 'home'; } @@ -330,15 +328,15 @@ if (props.reply && ['home', 'followers', 'specified'].includes(props.reply.visib if (visibility.value === 'specified') { if (props.reply.visibleUserIds) { - os.api('users/show', { - userIds: props.reply.visibleUserIds.filter(uid => uid !== $i.id && uid !== props.reply.userId), + misskeyApi('users/show', { + userIds: props.reply.visibleUserIds.filter(uid => uid !== $i.id && uid !== props.reply?.userId), }).then(users => { users.forEach(pushVisibleUser); }); } if (props.reply.userId !== $i.id) { - os.api('users/show', { userId: props.reply.userId }).then(user => { + misskeyApi('users/show', { userId: props.reply.userId }).then(user => { pushVisibleUser(user); }); } @@ -389,7 +387,7 @@ function addMissingMention() { for (const x of extractMentions(ast)) { if (!visibleUsers.value.some(u => (u.username === x.username) && (u.host === x.host))) { - os.api('users/show', { username: x.username, host: x.host }).then(user => { + misskeyApi('users/show', { username: x.username, host: x.host }).then(user => { visibleUsers.value.push(user); }); } @@ -466,9 +464,10 @@ function setVisibility() { os.popup(defineAsyncComponent(() => import('@/components/MkVisibilityPicker.vue')), { currentVisibility: visibility.value, - isSilenced: $i?.isSilenced, + isSilenced: $i.isSilenced, localOnly: localOnly.value, src: visibilityButton.value, + ...(props.reply ? { isReplyVisibilitySpecified: props.reply.visibility === 'specified' } : {}), }, { changeVisibility: v => { visibility.value = v; @@ -537,7 +536,7 @@ async function toggleReactionAcceptance() { reactionAcceptance.value = select.result; } -function pushVisibleUser(user) { +function pushVisibleUser(user: Misskey.entities.UserDetailed) { if (!visibleUsers.value.some(u => u.username === user.username && u.host === user.host)) { visibleUsers.value.push(user); } @@ -579,10 +578,12 @@ function onCompositionEnd(ev: CompositionEvent) { async function onPaste(ev: ClipboardEvent) { if (props.mock) return; + if (!ev.clipboardData) return; - for (const { item, i } of Array.from(ev.clipboardData.items, (item, i) => ({ item, i }))) { + for (const { item, i } of Array.from(ev.clipboardData.items, (data, x) => ({ item: data, i: x }))) { if (item.kind === 'file') { const file = item.getAsFile(); + if (!file) continue; const lio = file.name.lastIndexOf('.'); const ext = lio >= 0 ? file.name.slice(lio) : ''; const formatted = `${formatTimeString(new Date(file.lastModified), defaultStore.state.pastedFileName).replace(/{{number}}/g, `${i + 1}`)}${ext}`; @@ -604,7 +605,7 @@ async function onPaste(ev: ClipboardEvent) { return; } - quoteId.value = paste.substring(url.length).match(/^\/notes\/(.+?)\/?$/)[1]; + quoteId.value = paste.substring(url.length).match(/^\/notes\/(.+?)\/?$/)?.[1] ?? null; }); } } @@ -635,26 +636,26 @@ function onDragover(ev) { } } -function onDragenter(ev) { +function onDragenter() { draghover.value = true; } -function onDragleave(ev) { +function onDragleave() { draghover.value = false; } -function onDrop(ev): void { +function onDrop(ev: DragEvent): void { draghover.value = false; // ファイルだったら - if (ev.dataTransfer.files.length > 0) { + if (ev.dataTransfer && ev.dataTransfer.files.length > 0) { ev.preventDefault(); for (const x of Array.from(ev.dataTransfer.files)) upload(x); return; } //#region ドライブのファイル - const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); + const driveFile = ev.dataTransfer?.getData(_DATA_TRANSFER_DRIVE_FILE_); if (driveFile != null && driveFile !== '') { const file = JSON.parse(driveFile); files.value.push(file); @@ -702,11 +703,14 @@ async function post(ev?: MouseEvent) { } if (ev) { - const el = ev.currentTarget ?? ev.target; - const rect = el.getBoundingClientRect(); - const x = rect.left + (el.offsetWidth / 2); - const y = rect.top + (el.offsetHeight / 2); - os.popup(MkRippleEffect, { x, y }, {}, 'end'); + const el = (ev.currentTarget ?? ev.target) as HTMLElement | null; + + if (el) { + const rect = el.getBoundingClientRect(); + const x = rect.left + (el.offsetWidth / 2); + const y = rect.top + (el.offsetHeight / 2); + os.popup(MkRippleEffect, { x, y }, {}, 'end'); + } } if (props.mock) return; @@ -741,6 +745,29 @@ async function post(ev?: MouseEvent) { visibility.value = 'home'; } } + + if (defaultStore.state.warnMissingAltText) { + const filesData = toRaw(files.value); + + const isMissingAltText = filesData.some(file => !file.comment); + + if (isMissingAltText) { + const { canceled, result } = await os.actions({ + type: 'warning', + text: i18n.ts.thisPostIsMissingAltText, + actions: [{ + value: 'cancel', + text: i18n.ts.thisPostIsMissingAltTextCancel, + }, { + value: 'ignore', + text: i18n.ts.thisPostIsMissingAltTextIgnore, + }], + }); + + if (canceled) return; + if (result === 'cancel') return; + } + } let postData = { text: text.value === '' ? null : text.value, @@ -759,29 +786,39 @@ async function post(ev?: MouseEvent) { if (withHashtags.value && hashtags.value && hashtags.value.trim() !== '') { const hashtags_ = hashtags.value.trim().split(' ').map(x => x.startsWith('#') ? x : '#' + x).join(' '); - postData.text = postData.text ? `${postData.text} ${hashtags_}` : hashtags_; + if (!postData.text) { + postData.text = hashtags_; + } else { + const postTextLines = postData.text.split('\n'); + if (postTextLines[postTextLines.length - 1].trim() === '') { + postTextLines[postTextLines.length - 1] += hashtags_; + } else { + postTextLines[postTextLines.length - 1] += ' ' + hashtags_; + } + postData.text = postTextLines.join('\n'); + } } // plugin if (notePostInterruptors.length > 0) { for (const interruptor of notePostInterruptors) { try { - postData = await interruptor.handler(deepClone(postData)); + postData = await interruptor.handler(deepClone(postData)) as typeof postData; } catch (err) { console.error(err); } } } - let token = undefined; + let token: string | undefined = undefined; if (postAccount.value) { const storedAccounts = await getAccounts(); - token = storedAccounts.find(x => x.id === postAccount.value.id)?.token; + token = storedAccounts.find(x => x.id === postAccount.value?.id)?.token; } posting.value = true; - os.api(postData.editId ? 'notes/edit' : 'notes/create', postData, token).then(() => { + misskeyApi(postData.editId ? 'notes/edit' : 'notes/create', postData, token).then(() => { if (props.freezeAfterPosted) { posted.value = true; } else { @@ -791,7 +828,7 @@ async function post(ev?: MouseEvent) { deleteDraft(); emit('posted'); if (postData.text && postData.text !== '') { - const hashtags_ = mfm.parse(postData.text).filter(x => x.type === 'hashtag').map(x => x.props.hashtag); + const hashtags_ = mfm.parse(postData.text).map(x => x.type === 'hashtag' && x.props.hashtag).filter(x => x) as string[]; const history = JSON.parse(miLocalStorage.getItem('hashtags') ?? '[]') as string[]; miLocalStorage.setItem('hashtags', JSON.stringify(unique(hashtags_.concat(history)))); } @@ -854,16 +891,17 @@ function cancel() { } function insertMention() { - os.selectUser().then(user => { + os.selectUser({ localOnly: localOnly.value, includeSelf: true }).then(user => { insertTextAtCursor(textareaEl.value, '@' + Misskey.acct.toString(user) + ' '); }); } async function insertEmoji(ev: MouseEvent) { textAreaReadOnly.value = true; - + const target = ev.currentTarget ?? ev.target; + if (target == null) return; emojiPicker.show( - ev.currentTarget ?? ev.target, + target as HTMLElement, emoji => { insertTextAtCursor(textareaEl.value, emoji); }, @@ -875,6 +913,7 @@ async function insertEmoji(ev: MouseEvent) { } async function insertMfmFunction(ev: MouseEvent) { + if (textareaEl.value == null) return; mfmFunctionPicker( ev.currentTarget ?? ev.target, textareaEl.value, @@ -882,14 +921,15 @@ async function insertMfmFunction(ev: MouseEvent) { ); } -function showActions(ev) { +function showActions(ev: MouseEvent) { os.popupMenu(postFormActions.map(action => ({ text: action.title, action: () => { action.handler({ text: text.value, cw: cw.value, - }, (key, value) => { + }, (key, value: any) => { + if (typeof key !== 'string') return; if (key === 'text') { text.value = value; } if (key === 'cw') { useCw.value = value !== null; cw.value = value; } }); @@ -926,9 +966,9 @@ onMounted(() => { } // TODO: detach when unmount - new Autocomplete(textareaEl.value, text); - new Autocomplete(cwInputEl.value, cw); - new Autocomplete(hashtagsInputEl.value, hashtags); + if (textareaEl.value) new Autocomplete(textareaEl.value, text); + if (cwInputEl.value) new Autocomplete(cwInputEl.value, cw); + if (hashtagsInputEl.value) new Autocomplete(hashtagsInputEl.value, hashtags); nextTick(() => { // 書きかけの投稿を復元 @@ -958,19 +998,19 @@ onMounted(() => { if (props.initialNote) { const init = props.initialNote; text.value = init.text ? init.text : ''; - files.value = init.files; - cw.value = init.cw; + files.value = init.files ?? []; + cw.value = init.cw ?? null; useCw.value = init.cw != null; if (init.poll) { poll.value = { choices: init.poll.choices.map(x => x.text), multiple: init.poll.multiple, - expiresAt: init.poll.expiresAt ? new Date(init.poll.expiresAt).getTime().toString() : null, - expiredAfter: init.poll.expiredAfter ? new Date(init.poll.expiredAfter).getTime().toString() : null, + expiresAt: init.poll.expiresAt ? (new Date(init.poll.expiresAt)).getTime() : null, + expiredAfter: null, }; } visibility.value = init.visibility; - localOnly.value = init.localOnly; + localOnly.value = init.localOnly ?? false; quoteId.value = init.renote ? init.renote.id : null; } diff --git a/packages/frontend/src/components/MkPostFormAttaches.vue b/packages/frontend/src/components/MkPostFormAttaches.vue index b2597d090b..956dad8021 100644 --- a/packages/frontend/src/components/MkPostFormAttaches.vue +++ b/packages/frontend/src/components/MkPostFormAttaches.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -24,6 +24,7 @@ import { defineAsyncComponent, inject } from 'vue'; import * as Misskey from 'misskey-js'; import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default)); @@ -55,13 +56,30 @@ 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.t('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); return; } - os.api('drive/files/update', { + misskeyApi('drive/files/update', { fileId: file.id, isSensitive: !file.isSensitive, }).then(() => { @@ -75,10 +93,10 @@ async function rename(file) { const { canceled, result } = await os.inputText({ title: i18n.ts.enterFileName, default: file.name, - allowEmpty: false, + minLength: 1, }); if (canceled) return; - os.api('drive/files/update', { + misskeyApi('drive/files/update', { fileId: file.id, name: result, }).then(() => { @@ -96,7 +114,7 @@ async function describe(file) { }, { done: caption => { let comment = caption.length === 0 ? null : caption; - os.api('drive/files/update', { + misskeyApi('drive/files/update', { fileId: file.id, comment: comment, }).then(() => { @@ -134,9 +152,16 @@ function showFileMenu(file: Misskey.entities.DriveFile, ev: MouseEvent): void { icon: 'ph-crop ph-bold ph-lg', action: () : void => { crop(file); }, }] : [], { + type: 'divider', + }, { text: i18n.ts.attachCancel, icon: 'ph-x-circle ph-bold ph-lg', action: () => { detachMedia(file.id); }, + }, { + text: i18n.ts.deleteFile, + icon: 'ph-trash ph-bold ph-lg', + danger: true, + action: () => { detachAndDeleteMedia(file); }, }], ev.currentTarget ?? ev.target).then(() => menuShowing = false); menuShowing = true; } diff --git a/packages/frontend/src/components/MkPostFormDialog.vue b/packages/frontend/src/components/MkPostFormDialog.vue index cd25077bfb..5260ac2a08 100644 --- a/packages/frontend/src/components/MkPostFormDialog.vue +++ b/packages/frontend/src/components/MkPostFormDialog.vue @@ -1,11 +1,11 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkModal ref="modal" :preferType="'dialog'" @click="modal.close()" @closed="onModalClosed()"> - <MkPostForm ref="form" :class="$style.form" v-bind="props" autofocus freezeAfterPosted @posted="onPosted" @cancel="modal.close()" @esc="modal.close()"/> +<MkModal ref="modal" :preferType="'dialog'" @click="modal?.close()" @closed="onModalClosed()"> + <MkPostForm ref="form" :class="$style.form" v-bind="props" autofocus freezeAfterPosted @posted="onPosted" @cancel="modal?.close()" @esc="modal?.close()"/> </MkModal> </template> @@ -20,13 +20,13 @@ const props = defineProps<{ renote?: Misskey.entities.Note; channel?: any; // TODO mention?: Misskey.entities.User; - specified?: Misskey.entities.User; + specified?: Misskey.entities.UserDetailed; initialText?: string; initialCw?: string; - initialVisibility?: typeof Misskey.noteVisibilities; + initialVisibility?: (typeof Misskey.noteVisibilities)[number]; initialFiles?: Misskey.entities.DriveFile[]; initialLocalOnly?: boolean; - initialVisibleUsers?: Misskey.entities.User[]; + initialVisibleUsers?: Misskey.entities.UserDetailed[]; initialNote?: Misskey.entities.Note; instant?: boolean; fixed?: boolean; @@ -42,7 +42,7 @@ const modal = shallowRef<InstanceType<typeof MkModal>>(); const form = shallowRef<InstanceType<typeof MkPostForm>>(); function onPosted() { - modal.value.close({ + modal.value?.close({ useSendAnimation: true, }); } diff --git a/packages/frontend/src/components/MkPullToRefresh.vue b/packages/frontend/src/components/MkPullToRefresh.vue index e963697997..b1ec440e42 100644 --- a/packages/frontend/src/components/MkPullToRefresh.vue +++ b/packages/frontend/src/components/MkPullToRefresh.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -26,6 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { onMounted, onUnmounted, ref, shallowRef } from 'vue'; import { i18n } from '@/i18n.js'; import { getScrollContainer } from '@/scripts/scroll.js'; +import { isHorizontalSwipeSwiping } from '@/scripts/touch.js'; const SCROLL_STOP = 10; const MAX_PULL_DISTANCE = Infinity; @@ -129,7 +130,7 @@ function moveEnd() { function moving(event: TouchEvent | PointerEvent) { if (!isPullStart.value || isRefreshing.value || disabled) return; - if ((scrollEl?.scrollTop ?? 0) > (supportPointerDesktop ? SCROLL_STOP : SCROLL_STOP + pullDistance.value)) { + if ((scrollEl?.scrollTop ?? 0) > (supportPointerDesktop ? SCROLL_STOP : SCROLL_STOP + pullDistance.value) || isHorizontalSwipeSwiping.value) { pullDistance.value = 0; isPullEnd.value = false; moveEnd(); @@ -148,6 +149,10 @@ function moving(event: TouchEvent | PointerEvent) { if (event.cancelable) event.preventDefault(); } + if (pullDistance.value > SCROLL_STOP) { + event.stopPropagation(); + } + isPullEnd.value = pullDistance.value >= FIRE_THRESHOLD; } diff --git a/packages/frontend/src/components/MkPushNotificationAllowButton.vue b/packages/frontend/src/components/MkPushNotificationAllowButton.vue index ebbd5e6cdc..5e42df4795 100644 --- a/packages/frontend/src/components/MkPushNotificationAllowButton.vue +++ b/packages/frontend/src/components/MkPushNotificationAllowButton.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -45,7 +45,8 @@ import { ref } from 'vue'; import { $i, getAccounts } from '@/account.js'; import MkButton from '@/components/MkButton.vue'; import { instance } from '@/instance.js'; -import { api, apiWithDialog, promiseDialog } from '@/os.js'; +import { apiWithDialog, promiseDialog } from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; defineProps<{ @@ -82,7 +83,7 @@ function subscribe() { pushSubscription.value = subscription; // Register - pushRegistrationInServer.value = await api('sw/register', { + pushRegistrationInServer.value = await misskeyApi('sw/register', { endpoint: subscription.endpoint, auth: encode(subscription.getKey('auth')), publickey: encode(subscription.getKey('p256dh')), @@ -125,7 +126,7 @@ async function unsubscribe() { } function encode(buffer: ArrayBuffer | null) { - return btoa(String.fromCharCode.apply(null, new Uint8Array(buffer))); + return btoa(String.fromCharCode.apply(null, buffer ? new Uint8Array(buffer) as any : [])); } /** @@ -159,7 +160,7 @@ if (navigator.serviceWorker == null) { supported.value = true; if (pushSubscription.value) { - const res = await api('sw/show-registration', { + const res = await misskeyApi('sw/show-registration', { endpoint: pushSubscription.value.endpoint, }); diff --git a/packages/frontend/src/components/MkRadio.vue b/packages/frontend/src/components/MkRadio.vue index edb3abe5f7..0b4023f254 100644 --- a/packages/frontend/src/components/MkRadio.vue +++ b/packages/frontend/src/components/MkRadio.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/MkRadios.vue b/packages/frontend/src/components/MkRadios.vue index d9178f3362..549438f61b 100644 --- a/packages/frontend/src/components/MkRadios.vue +++ b/packages/frontend/src/components/MkRadios.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -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(); @@ -35,7 +38,7 @@ export default defineComponent({ h('div', { class: 'body', }, options.map(option => h(MkRadio, { - key: option.key, + key: option.key as string, value: option.props?.value, modelValue: value.value, 'onUpdate:modelValue': _v => value.value = _v, diff --git a/packages/frontend/src/components/MkRange.vue b/packages/frontend/src/components/MkRange.vue index c1f5b6a790..46d76e2551 100644 --- a/packages/frontend/src/components/MkRange.vue +++ b/packages/frontend/src/components/MkRange.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -43,6 +43,7 @@ const props = withDefaults(defineProps<{ const emit = defineEmits<{ (ev: 'update:modelValue', value: number): void; + (ev: 'dragEnded', value: number): void; }>(); const containerEl = shallowRef<HTMLElement>(); @@ -85,7 +86,7 @@ onMounted(() => { ro = new ResizeObserver((entries, observer) => { calcThumbPosition(); }); - ro.observe(containerEl.value); + if (containerEl.value) ro.observe(containerEl.value); }); onUnmounted(() => { @@ -121,7 +122,7 @@ const onMousedown = (ev: MouseEvent | TouchEvent) => { const onDrag = (ev: MouseEvent | TouchEvent) => { ev.preventDefault(); const containerRect = containerEl.value!.getBoundingClientRect(); - const pointerX = ev.touches && ev.touches.length > 0 ? ev.touches[0].clientX : ev.clientX; + const pointerX = 'touches' in ev && ev.touches.length > 0 ? ev.touches[0].clientX : 'clientX' in ev ? ev.clientX : 0; const pointerPositionOnContainer = pointerX - (containerRect.left + (thumbWidth / 2)); rawValue.value = Math.min(1, Math.max(0, pointerPositionOnContainer / (containerEl.value!.offsetWidth - thumbWidth))); @@ -143,6 +144,7 @@ const onMousedown = (ev: MouseEvent | TouchEvent) => { // 値が変わってたら通知 if (beforeValue !== finalValue.value) { emit('update:modelValue', finalValue.value); + emit('dragEnded', finalValue.value); } }; diff --git a/packages/frontend/src/components/MkReactionEffect.vue b/packages/frontend/src/components/MkReactionEffect.vue index 75eb91e7ad..361e246e9f 100644 --- a/packages/frontend/src/components/MkReactionEffect.vue +++ b/packages/frontend/src/components/MkReactionEffect.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/MkReactionIcon.vue b/packages/frontend/src/components/MkReactionIcon.vue index fdc3bfd23c..068a2968db 100644 --- a/packages/frontend/src/components/MkReactionIcon.vue +++ b/packages/frontend/src/components/MkReactionIcon.vue @@ -1,10 +1,10 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkCustomEmoji v-if="reaction[0] === ':'" ref="elRef" :name="reaction" :normal="true" :noStyle="noStyle" :url="emojiUrl"/> +<MkCustomEmoji v-if="reaction[0] === ':'" ref="elRef" :name="reaction" :normal="true" :noStyle="noStyle" :url="emojiUrl" :fallbackToImage="true"/> <MkEmoji v-else ref="elRef" :emoji="reaction" :normal="true" :noStyle="noStyle"/> </template> diff --git a/packages/frontend/src/components/MkReactionTooltip.vue b/packages/frontend/src/components/MkReactionTooltip.vue index 8527b45347..15409a216a 100644 --- a/packages/frontend/src/components/MkReactionTooltip.vue +++ b/packages/frontend/src/components/MkReactionTooltip.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/MkReactionsViewer.details.vue b/packages/frontend/src/components/MkReactionsViewer.details.vue index 1b0d8f74a3..8b5e6efdf3 100644 --- a/packages/frontend/src/components/MkReactionsViewer.details.vue +++ b/packages/frontend/src/components/MkReactionsViewer.details.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -44,7 +44,7 @@ function getReactionName(reaction: string): string { if (trimLocal.startsWith(':')) { return trimLocal; } - return getEmojiName(reaction) ?? reaction; + return getEmojiName(reaction); } </script> diff --git a/packages/frontend/src/components/MkReactionsViewer.reaction.vue b/packages/frontend/src/components/MkReactionsViewer.reaction.vue index 09e864e497..2464d21b6a 100644 --- a/packages/frontend/src/components/MkReactionsViewer.reaction.vue +++ b/packages/frontend/src/components/MkReactionsViewer.reaction.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -10,8 +10,9 @@ SPDX-License-Identifier: AGPL-3.0-only class="_button" :class="[$style.root, { [$style.reacted]: note.myReaction == reaction, [$style.canToggle]: canToggle, [$style.small]: defaultStore.state.reactionsDisplaySize === 'small', [$style.large]: defaultStore.state.reactionsDisplaySize === 'large' }]" @click="toggleReaction()" + @contextmenu.prevent.stop="menu" > - <MkReactionIcon :class="defaultStore.state.limitWidthOfReaction ? $style.limitWidth : ''" :reaction="reaction" :emojiUrl="note.reactionEmojis[reaction.substring(1, reaction.length - 1)]" @click="toggleReaction()"/> + <MkReactionIcon :class="defaultStore.state.limitWidthOfReaction ? $style.limitWidth : ''" :reaction="reaction" :emojiUrl="note.reactionEmojis[reaction.substring(1, reaction.length - 1)]" @click="toggleReaction()" @click.stop/> <span :class="$style.count">{{ count }}</span> </button> </template> @@ -19,9 +20,11 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, inject, onMounted, shallowRef, watch } from 'vue'; import * as Misskey from 'misskey-js'; +import MkCustomEmojiDetailedDialog from './MkCustomEmojiDetailedDialog.vue'; import XDetails from '@/components/MkReactionsViewer.details.vue'; import MkReactionIcon from '@/components/MkReactionIcon.vue'; import * as os from '@/os.js'; +import { misskeyApi, misskeyApiGet } from '@/scripts/misskey-api.js'; import { useTooltip } from '@/scripts/use-tooltip.js'; import { $i } from '@/account.js'; import MkReactionEffect from '@/components/MkReactionEffect.vue'; @@ -29,6 +32,9 @@ import { claimAchievement } from '@/scripts/achievements.js'; import { defaultStore } from '@/store.js'; import { i18n } from '@/i18n.js'; import * as sound from '@/scripts/sound.js'; +import { checkReactionPermissions } from '@/scripts/check-reaction-permissions.js'; +import { customEmojisMap } from '@/custom-emojis.js'; +import { getUnicodeEmoji } from '@/scripts/emojilist.js'; const props = defineProps<{ reaction: string; @@ -45,13 +51,17 @@ const emit = defineEmits<{ const buttonEl = shallowRef<HTMLElement>(); -const canToggle = computed(() => !props.reaction.match(/@\w/) && $i); +const emojiName = computed(() => props.reaction.replace(/:/g, '').replace(/@\./, '')); +const emoji = computed(() => customEmojisMap.get(emojiName.value) ?? getUnicodeEmoji(props.reaction)); + +const canToggle = computed(() => { + return !props.reaction.match(/@\w/) && $i && emoji.value && checkReactionPermissions($i, props.note, emoji.value); +}); +const canGetInfo = computed(() => !props.reaction.match(/@\w/) && props.reaction.includes(':')); async function toggleReaction() { if (!canToggle.value) return; - // TODO: その絵文字を使う権限があるかどうか確認 - const oldReaction = props.note.myReaction; if (oldReaction) { const confirm = await os.confirm({ @@ -61,7 +71,7 @@ async function toggleReaction() { if (confirm.canceled) return; if (oldReaction !== props.reaction) { - sound.play('reaction'); + sound.playMisskeySfx('reaction'); } if (mock) { @@ -69,25 +79,25 @@ async function toggleReaction() { return; } - os.api('notes/reactions/delete', { + misskeyApi('notes/reactions/delete', { noteId: props.note.id, }).then(() => { if (oldReaction !== props.reaction) { - os.api('notes/reactions/create', { + misskeyApi('notes/reactions/create', { noteId: props.note.id, reaction: props.reaction, }); } }); } else { - sound.play('reaction'); + sound.playMisskeySfx('reaction'); if (mock) { emit('reactionToggled', props.reaction, (props.count + 1)); return; } - os.api('notes/reactions/create', { + misskeyApi('notes/reactions/create', { noteId: props.note.id, reaction: props.reaction, }); @@ -97,9 +107,24 @@ async function toggleReaction() { } } +async function menu(ev) { + if (!canGetInfo.value) return; + + os.popupMenu([{ + text: i18n.ts.info, + icon: 'ph-info ph-bold ph-lg', + action: async () => { + os.popup(MkCustomEmojiDetailedDialog, { + emoji: await misskeyApiGet('emoji', { + name: props.reaction.replace(/:/g, '').replace(/@\./, ''), + }), + }); + }, + }], ev.currentTarget ?? ev.target); +} + function anime() { - if (document.hidden) return; - if (!defaultStore.state.animation) return; + if (document.hidden || !defaultStore.state.animation || buttonEl.value == null) return; const rect = buttonEl.value.getBoundingClientRect(); const x = rect.left + 16; @@ -117,7 +142,7 @@ onMounted(() => { if (!mock) { useTooltip(buttonEl, async (showing) => { - const reactions = await os.apiGet('notes/reactions', { + const reactions = await misskeyApiGet('notes/reactions', { noteId: props.note.id, type: props.reaction, limit: 10, diff --git a/packages/frontend/src/components/MkReactionsViewer.vue b/packages/frontend/src/components/MkReactionsViewer.vue index d2a5c431fe..3d3130cd51 100644 --- a/packages/frontend/src/components/MkReactionsViewer.vue +++ b/packages/frontend/src/components/MkReactionsViewer.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/MkRemoteCaution.vue b/packages/frontend/src/components/MkRemoteCaution.vue index e8ca9260bb..5106cdfd6a 100644 --- a/packages/frontend/src/components/MkRemoteCaution.vue +++ b/packages/frontend/src/components/MkRemoteCaution.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/MkRetentionHeatmap.vue b/packages/frontend/src/components/MkRetentionHeatmap.vue index e69aa1be80..64b573c4d3 100644 --- a/packages/frontend/src/components/MkRetentionHeatmap.vue +++ b/packages/frontend/src/components/MkRetentionHeatmap.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { onMounted, nextTick, shallowRef, ref } from 'vue'; import { Chart } from 'chart.js'; -import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { defaultStore } from '@/store.js'; import { useChartTooltip } from '@/scripts/use-chart-tooltip.js'; import { alpha } from '@/scripts/color.js'; @@ -23,10 +23,9 @@ import { initChart } from '@/scripts/init-chart.js'; initChart(); -const rootEl = shallowRef<HTMLDivElement>(null); -const chartEl = shallowRef<HTMLCanvasElement>(null); -const now = new Date(); -let chartInstance: Chart = null; +const rootEl = shallowRef<HTMLDivElement | null>(null); +const chartEl = shallowRef<HTMLCanvasElement | null>(null); +let chartInstance: Chart | null = null; const fetching = ref(true); const { handler: externalTooltipHandler } = useChartTooltip({ @@ -34,6 +33,7 @@ const { handler: externalTooltipHandler } = useChartTooltip({ }); async function renderChart() { + if (rootEl.value == null) return; if (chartInstance) { chartInstance.destroy(); } @@ -43,11 +43,16 @@ async function renderChart() { const maxDays = wide ? 10 : narrow ? 5 : 7; - let raw = await os.api('retention', { }); + let raw = await misskeyApi('retention', { }); raw = raw.slice(0, maxDays + 1); - const data = []; + const data: { + x: number; + y: string; + v: number; + }[] = []; + for (const record of raw) { data.push({ x: 0, @@ -83,19 +88,20 @@ async function renderChart() { const marginEachCell = 12; + if (chartEl.value == null) return; + chartInstance = new Chart(chartEl.value, { type: 'matrix', data: { datasets: [{ label: 'Active', - data: data, - pointRadius: 0, + data: data as any, borderWidth: 0, - borderJoinStyle: 'round', borderRadius: 3, backgroundColor(c) { - const value = c.dataset.data[c.dataIndex].v; - const m = max(c.dataset.data[c.dataIndex].y); + const v = c.dataset.data[c.dataIndex] as unknown as typeof data[0]; + const value = v.v; + const m = max(v.y); if (m === 0) { return alpha(color, 0); } else { @@ -103,7 +109,6 @@ async function renderChart() { return alpha(color, a); } }, - fill: true, width(c) { const a = c.chart.chartArea ?? {}; return (a.right - a.left) / maxDays - marginEachCell; @@ -146,7 +151,6 @@ async function renderChart() { }, y: { type: 'time', - min: new Date(new Date().getFullYear(), new Date().getMonth(), new Date().getDate() - maxDays), offset: true, reverse: true, position: 'left', @@ -179,7 +183,7 @@ async function renderChart() { return getYYYYMMDD(new Date(new Date(v.y).getTime() + (v.x * 86400000))); }, label(context) { - const v = context.dataset.data[context.dataIndex]; + const v = context.dataset.data[context.dataIndex] as unknown as typeof data[0]; const m = max(v.y); if (m === 0) { return [`Active: ${v.v} (-%)`]; diff --git a/packages/frontend/src/components/MkRetentionLineChart.vue b/packages/frontend/src/components/MkRetentionLineChart.vue index e2682ec06b..c3daa9c9a4 100644 --- a/packages/frontend/src/components/MkRetentionLineChart.vue +++ b/packages/frontend/src/components/MkRetentionLineChart.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -16,15 +16,15 @@ import { useChartTooltip } from '@/scripts/use-chart-tooltip.js'; import { chartVLine } from '@/scripts/chart-vline.js'; import { alpha } from '@/scripts/color.js'; import { initChart } from '@/scripts/init-chart.js'; -import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; initChart(); -const chartEl = shallowRef<HTMLCanvasElement>(null); +const chartEl = shallowRef<HTMLCanvasElement | null>(null); const { handler: externalTooltipHandler } = useChartTooltip(); -let chartInstance: Chart; +let chartInstance: Chart | null = null; const getYYYYMMDD = (date: Date) => { const y = date.getFullYear().toString().padStart(2, '0'); @@ -40,13 +40,15 @@ const getDate = (ymd: string) => { }; onMounted(async () => { - let raw = await os.api('retention', { }); + let raw = await misskeyApi('retention', { }); const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; const accent = tinycolor(getComputedStyle(document.documentElement).getPropertyValue('--accent')); const color = accent.toHex(); + if (chartEl.value == null) return; + chartInstance = new Chart(chartEl.value, { type: 'line', data: { @@ -67,7 +69,7 @@ onMounted(async () => { x: (i + 1).toString(), y: (v / record.users) * 100, d: getYYYYMMDD(new Date(record.createdAt)), - }))], + }))] as any, })), }, options: { @@ -109,11 +111,11 @@ onMounted(async () => { enabled: false, callbacks: { title(context) { - const v = context[0].dataset.data[context[0].dataIndex]; + const v = context[0].dataset.data[context[0].dataIndex] as unknown as { x: string, y: number, d: string }; return `${v.x} days later`; }, label(context) { - const v = context.dataset.data[context.dataIndex]; + const v = context.dataset.data[context.dataIndex] as unknown as { x: string, y: number, d: string }; const p = Math.round(v.y) + '%'; return `${v.d} ${p}`; }, diff --git a/packages/frontend/src/components/MkRippleEffect.vue b/packages/frontend/src/components/MkRippleEffect.vue index 860b083327..ee5bb73ebf 100644 --- a/packages/frontend/src/components/MkRippleEffect.vue +++ b/packages/frontend/src/components/MkRippleEffect.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -77,7 +77,14 @@ const emit = defineEmits<{ (ev: 'end'): void; }>(); -const particles = []; +const particles: { + size: number; + xA: number; + yA: number; + xB: number; + yB: number; + color: string; +}[] = []; const origin = 64; const colors = ['#FF1493', '#00FFFF', '#FFE202']; const zIndex = os.claimZIndex('high'); diff --git a/packages/frontend/src/components/MkRolePreview.vue b/packages/frontend/src/components/MkRolePreview.vue index bd1767155b..f0343d499b 100644 --- a/packages/frontend/src/components/MkRolePreview.vue +++ b/packages/frontend/src/components/MkRolePreview.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/MkSelect.vue b/packages/frontend/src/components/MkSelect.vue index 665ae2b813..ecac99ae45 100644 --- a/packages/frontend/src/components/MkSelect.vue +++ b/packages/frontend/src/components/MkSelect.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -27,16 +27,17 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <div :class="$style.caption"><slot name="caption"></slot></div> - <MkButton v-if="manualSave && changed" primary @click="updated"><i class="ph-floppy-disk ph-bold ph-lg"></i> {{ i18n.ts.save }}</MkButton> + <MkButton v-if="manualSave && changed" primary :class="$style.save" @click="updated"><i class="ph-floppy-disk ph-bold ph-lg"></i> {{ i18n.ts.save }}</MkButton> </div> </template> <script lang="ts" setup> -import { onMounted, nextTick, ref, watch, computed, toRefs, VNode, useSlots } from 'vue'; +import { onMounted, nextTick, ref, watch, computed, toRefs, VNode, useSlots, VNodeChild } from 'vue'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; import { useInterval } from '@/scripts/use-interval.js'; import { i18n } from '@/i18n.js'; +import { MenuItem } from '@/types/menu.js'; const props = defineProps<{ modelValue: string | null; @@ -52,7 +53,7 @@ const props = defineProps<{ }>(); const emit = defineEmits<{ - (ev: 'change', _ev: KeyboardEvent): void; + (ev: 'changeByUser', value: string | null): void; (ev: 'update:modelValue', value: string | null): void; }>(); @@ -74,10 +75,9 @@ const height = props.large ? 39 : 36; -const focus = () => inputEl.value.focus(); +const focus = () => inputEl.value?.focus(); const onInput = (ev) => { changed.value = true; - emit('change', ev); }; const updated = () => { @@ -89,17 +89,19 @@ watch(modelValue, newValue => { v.value = newValue; }); -watch(v, newValue => { +watch(v, () => { if (!props.manualSave) { updated(); } - invalid.value = inputEl.value.validity.badInput; + invalid.value = inputEl.value?.validity.badInput ?? true; }); // このコンポーネントが作成された時、非表示状態である場合がある // 非表示状態だと要素の幅などは0になってしまうので、定期的に計算する useInterval(() => { + if (inputEl.value == null) return; + if (prefixEl.value) { if (prefixEl.value.offsetWidth) { inputEl.value.style.paddingLeft = prefixEl.value.offsetWidth + 'px'; @@ -123,35 +125,38 @@ onMounted(() => { }); }); -function show(ev: MouseEvent) { +function show() { focused.value = true; opening.value = true; - const menu = []; + const menu: MenuItem[] = []; let options = slots.default!(); const pushOption = (option: VNode) => { menu.push({ - text: option.children, - active: computed(() => v.value === option.props.value), + text: option.children as string, + active: computed(() => v.value === option.props?.value), action: () => { - v.value = option.props.value; + v.value = option.props?.value; + changed.value = true; + emit('changeByUser', v.value); }, }); }; - const scanOptions = (options: VNode[]) => { + const scanOptions = (options: VNodeChild[]) => { for (const vnode of options) { + if (typeof vnode !== 'object' || vnode === null || Array.isArray(vnode)) continue; if (vnode.type === 'optgroup') { const optgroup = vnode; menu.push({ type: 'label', - text: optgroup.props.label, + text: optgroup.props?.label, }); - scanOptions(optgroup.children); + if (Array.isArray(optgroup.children)) scanOptions(optgroup.children); } else if (Array.isArray(vnode.children)) { // 何故かフラグメントになってくることがある const fragment = vnode; - scanOptions(fragment.children); + if (Array.isArray(fragment.children)) scanOptions(fragment.children); } else if (vnode.props == null) { // v-if で条件が false のときにこうなる // nop? } else { @@ -164,7 +169,7 @@ function show(ev: MouseEvent) { scanOptions(options); os.popupMenu(menu, container.value, { - width: container.value.offsetWidth, + width: container.value?.offsetWidth, onClosing: () => { opening.value = false; }, @@ -284,6 +289,10 @@ function show(ev: MouseEvent) { padding-left: 6px; } +.save { + margin: 8px 0 0 0; +} + .chevron { transition: transform 0.1s ease-out; } diff --git a/packages/frontend/src/components/MkSignin.vue b/packages/frontend/src/components/MkSignin.vue index c884ce53ea..dc68a99593 100644 --- a/packages/frontend/src/components/MkSignin.vue +++ b/packages/frontend/src/components/MkSignin.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -59,6 +59,7 @@ import MkInput from '@/components/MkInput.vue'; import MkInfo from '@/components/MkInfo.vue'; import { host as configHost } from '@/config.js'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { login } from '@/account.js'; import { i18n } from '@/i18n.js'; @@ -95,7 +96,7 @@ const props = defineProps({ }); function onUsernameChange(): void { - os.api('users/show', { + misskeyApi('users/show', { username: username.value, }).then(userResponse => { user.value = userResponse; @@ -111,6 +112,7 @@ function onLogin(res: any): Promise<void> | void { } async function queryKey(): Promise<void> { + if (credentialRequest.value == null) return; queryingKey.value = true; await webAuthnRequest(credentialRequest.value) .catch(() => { @@ -120,7 +122,7 @@ async function queryKey(): Promise<void> { credentialRequest.value = null; queryingKey.value = false; signing.value = true; - return os.api('signin', { + return misskeyApi('signin', { username: username.value, password: password.value, credential: credential.toJSON(), @@ -142,7 +144,7 @@ function onSubmit(): void { signing.value = true; if (!totpLogin.value && user.value && user.value.twoFactorEnabled) { if (webAuthnSupported() && user.value.securityKeys) { - os.api('signin', { + misskeyApi('signin', { username: username.value, password: password.value, }).then(res => { @@ -159,7 +161,7 @@ function onSubmit(): void { signing.value = false; } } else { - os.api('signin', { + misskeyApi('signin', { username: username.value, password: password.value, token: user.value?.twoFactorEnabled ? token.value : undefined, diff --git a/packages/frontend/src/components/MkSigninDialog.vue b/packages/frontend/src/components/MkSigninDialog.vue index 6f961cff05..33355bb99e 100644 --- a/packages/frontend/src/components/MkSigninDialog.vue +++ b/packages/frontend/src/components/MkSigninDialog.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/MkSignupDialog.form.vue b/packages/frontend/src/components/MkSignupDialog.form.vue index 9984b09c1a..7d03381a49 100644 --- a/packages/frontend/src/components/MkSignupDialog.form.vue +++ b/packages/frontend/src/components/MkSignupDialog.form.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -67,6 +67,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template #prefix><i class="ph-chalkboard-teacher ph-bold ph-lg"></i></template> </MkInput> <MkCaptcha v-if="instance.enableHcaptcha" ref="hcaptcha" v-model="hCaptchaResponse" :class="$style.captcha" provider="hcaptcha" :sitekey="instance.hcaptchaSiteKey"/> + <MkCaptcha v-if="instance.enableMcaptcha" ref="mcaptcha" v-model="mCaptchaResponse" :class="$style.captcha" provider="mcaptcha" :sitekey="instance.mcaptchaSiteKey" :instanceUrl="instance.mcaptchaInstanceUrl"/> <MkCaptcha v-if="instance.enableRecaptcha" ref="recaptcha" v-model="reCaptchaResponse" :class="$style.captcha" provider="recaptcha" :sitekey="instance.recaptchaSiteKey"/> <MkCaptcha v-if="instance.enableTurnstile" ref="turnstile" v-model="turnstileResponse" :class="$style.captcha" provider="turnstile" :sitekey="instance.turnstileSiteKey"/> <MkButton type="submit" :disabled="shouldDisableSubmitting" large gradate rounded data-cy-signup-submit style="margin: 0 auto;"> @@ -83,11 +84,13 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref, computed } from 'vue'; import { toUnicode } from 'punycode/'; +import * as Misskey from 'misskey-js'; import MkButton from './MkButton.vue'; import MkInput from './MkInput.vue'; import MkCaptcha, { type Captcha } from '@/components/MkCaptcha.vue'; import * as config from '@/config.js'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { login } from '@/account.js'; import { instance } from '@/instance.js'; import { i18n } from '@/i18n.js'; @@ -99,7 +102,7 @@ const props = withDefaults(defineProps<{ }); const emit = defineEmits<{ - (ev: 'signup', user: Record<string, any>): void; + (ev: 'signup', user: Misskey.entities.SigninResponse): void; (ev: 'signupEmailPending'): void; (ev: 'approvalPending'): void; }>(); @@ -122,6 +125,7 @@ const passwordStrength = ref<'' | 'low' | 'medium' | 'high'>(''); const passwordRetypeState = ref<null | 'match' | 'not-match'>(null); const submitting = ref<boolean>(false); const hCaptchaResponse = ref<string | null>(null); +const mCaptchaResponse = ref<string | null>(null); const reCaptchaResponse = ref<string | null>(null); const turnstileResponse = ref<string | null>(null); const usernameAbortController = ref<null | AbortController>(null); @@ -130,6 +134,7 @@ const emailAbortController = ref<null | AbortController>(null); const shouldDisableSubmitting = computed((): boolean => { return submitting.value || instance.enableHcaptcha && !hCaptchaResponse.value || + instance.enableMcaptcha && !mCaptchaResponse.value || instance.enableRecaptcha && !reCaptchaResponse.value || instance.enableTurnstile && !turnstileResponse.value || instance.emailRequiredForSignup && emailState.value !== 'ok' || @@ -186,7 +191,7 @@ function onChangeUsername(): void { usernameState.value = 'wait'; usernameAbortController.value = new AbortController(); - os.api('username/available', { + misskeyApi('username/available', { username: username.value, }, undefined, usernameAbortController.value.signal).then(result => { usernameState.value = result.available ? 'ok' : 'unavailable'; @@ -209,7 +214,7 @@ function onChangeEmail(): void { emailState.value = 'wait'; emailAbortController.value = new AbortController(); - os.api('email-address/available', { + misskeyApi('email-address/available', { emailAddress: email.value, }, undefined, emailAbortController.value.signal).then(result => { emailState.value = result.available ? 'ok' : @@ -251,20 +256,22 @@ async function onSubmit(): Promise<void> { submitting.value = true; try { - await os.api('signup', { + await misskeyApi('signup', { username: username.value, password: password.value, emailAddress: email.value, invitationCode: invitationCode.value, reason: reason.value, 'hcaptcha-response': hCaptchaResponse.value, + 'm-captcha-response': mCaptchaResponse.value, 'g-recaptcha-response': reCaptchaResponse.value, + 'turnstile-response': turnstileResponse.value, }); if (instance.emailRequiredForSignup) { 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 if (instance.approvalRequiredForSignup) { @@ -275,7 +282,7 @@ async function onSubmit(): Promise<void> { }); emit('approvalPending'); } else { - const res = await os.api('signin', { + const res = await misskeyApi('signin', { username: username.value, password: password.value, }); diff --git a/packages/frontend/src/components/MkSignupDialog.rules.stories.impl.ts b/packages/frontend/src/components/MkSignupDialog.rules.stories.impl.ts index ab26df6342..fcd1ffde3e 100644 --- a/packages/frontend/src/components/MkSignupDialog.rules.stories.impl.ts +++ b/packages/frontend/src/components/MkSignupDialog.rules.stories.impl.ts @@ -1,11 +1,10 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { expect } from '@storybook/jest'; -import { userEvent, waitFor, within } from '@storybook/testing-library'; +import { expect, userEvent, waitFor, within } from '@storybook/test'; import { StoryObj } from '@storybook/vue3'; import { onBeforeUnmount } from 'vue'; import MkSignupServerRules from './MkSignupDialog.rules.vue'; diff --git a/packages/frontend/src/components/MkSignupDialog.rules.vue b/packages/frontend/src/components/MkSignupDialog.rules.vue index bc4fec305b..18a9eeda23 100644 --- a/packages/frontend/src/components/MkSignupDialog.rules.vue +++ b/packages/frontend/src/components/MkSignupDialog.rules.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -24,7 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template #suffix><i v-if="agreeServerRules" class="ph-check ph-bold ph-lg" style="color: var(--success)"></i></template> <ol class="_gaps_s" :class="$style.rules"> - <li v-for="item in instance.serverRules" :class="$style.rule"><div :class="$style.ruleText" v-html="item"></div></li> + <li v-for="item in instance.serverRules" :class="$style.rule"><div :class="$style.ruleText" v-html="sanitizeHtml(item)"></div></li> </ol> <MkSwitch :modelValue="agreeServerRules" style="margin-top: 16px;" @update:modelValue="updateAgreeServerRules">{{ i18n.ts.agree }}</MkSwitch> @@ -34,8 +34,8 @@ SPDX-License-Identifier: AGPL-3.0-only <template #label>{{ tosPrivacyPolicyLabel }}</template> <template #suffix><i v-if="agreeTosAndPrivacyPolicy" class="ph-check ph-bold ph-lg" style="color: var(--success)"></i></template> <div class="_gaps_s"> - <div v-if="availableTos"><a :href="instance.tosUrl" class="_link" target="_blank">{{ i18n.ts.termsOfService }} <i class="ph-arrow-square-out ph-bold ph-lg"></i></a></div> - <div v-if="availablePrivacyPolicy"><a :href="instance.privacyPolicyUrl" class="_link" target="_blank">{{ i18n.ts.privacyPolicy }} <i class="ph-arrow-square-out ph-bold ph-lg"></i></a></div> + <div v-if="availableTos"><a :href="instance.tosUrl ?? undefined" class="_link" target="_blank">{{ i18n.ts.termsOfService }} <i class="ph-arrow-square-out ph-bold ph-lg"></i></a></div> + <div v-if="availablePrivacyPolicy"><a :href="instance.privacyPolicyUrl ?? undefined" class="_link" target="_blank">{{ i18n.ts.privacyPolicy }} <i class="ph-arrow-square-out ph-bold ph-lg"></i></a></div> </div> <MkSwitch :modelValue="agreeTosAndPrivacyPolicy" style="margin-top: 16px;" @update:modelValue="updateAgreeTosAndPrivacyPolicy">{{ i18n.ts.agree }}</MkSwitch> @@ -45,7 +45,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template #label>{{ i18n.ts.basicNotesBeforeCreateAccount }}</template> <template #suffix><i v-if="agreeNote" class="ph-check ph-bold ph-lg" style="color: var(--success)"></i></template> - <a href="https://git.joinsharkey.org/Sharkey/JoinSharkey/src/branch/main/IMPORTANT_NOTES.md" class="_link" target="_blank">{{ i18n.ts.basicNotesBeforeCreateAccount }} <i class="ph-arrow-square-out ph-bold ph-lg"></i></a> + <a href="https://activitypub.software/TransFem-org/Sharkey/-/blob/stable/IMPORTANT_NOTES.md" class="_link" target="_blank">{{ i18n.ts.basicNotesBeforeCreateAccount }} <i class="ph-arrow-square-out ph-bold ph-lg"></i></a> <MkSwitch :modelValue="agreeNote" style="margin-top: 16px;" data-cy-signup-rules-notes-agree @update:modelValue="updateAgreeNote">{{ i18n.ts.agree }}</MkSwitch> </MkFolder> @@ -65,6 +65,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { computed, ref } from 'vue'; import { instance } from '@/instance.js'; import { i18n } from '@/i18n.js'; +import sanitizeHtml from 'sanitize-html'; import MkButton from '@/components/MkButton.vue'; import MkFolder from '@/components/MkFolder.vue'; import MkSwitch from '@/components/MkSwitch.vue'; @@ -105,7 +106,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 +120,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 +136,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; diff --git a/packages/frontend/src/components/MkSignupDialog.vue b/packages/frontend/src/components/MkSignupDialog.vue index 6efdced69f..91e7d5dd53 100644 --- a/packages/frontend/src/components/MkSignupDialog.vue +++ b/packages/frontend/src/components/MkSignupDialog.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only ref="dialog" :width="500" :height="600" - @close="dialog.close()" + @close="dialog?.close()" @closed="$emit('closed')" > <template #header>{{ i18n.ts.signup }}</template> @@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only :leaveToClass="$style.transition_x_leaveTo" > <template v-if="!isAcceptedServerRule"> - <XServerRules @done="isAcceptedServerRule = true" @cancel="dialog.close()"/> + <XServerRules @done="isAcceptedServerRule = true" @cancel="dialog?.close()"/> </template> <template v-else> <XSignup :autoSet="autoSet" @signup="onSignup" @signupEmailPending="onSignupEmailPending" @approvalPending="onApprovalPending"/> @@ -34,7 +34,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { shallowRef, ref } from 'vue'; - +import * as Misskey from 'misskey-js'; import XSignup from '@/components/MkSignupDialog.form.vue'; import XServerRules from '@/components/MkSignupDialog.rules.vue'; import MkModalWindow from '@/components/MkModalWindow.vue'; @@ -47,7 +47,7 @@ const props = withDefaults(defineProps<{ }); const emit = defineEmits<{ - (ev: 'done'): void; + (ev: 'done', res: Misskey.entities.SigninResponse): void; (ev: 'closed'): void; }>(); @@ -55,13 +55,13 @@ const dialog = shallowRef<InstanceType<typeof MkModalWindow>>(); const isAcceptedServerRule = ref(false); -function onSignup(res) { +function onSignup(res: Misskey.entities.SigninResponse) { emit('done', res); - dialog.value.close(); + dialog.value?.close(); } function onSignupEmailPending() { - dialog.value.close(); + dialog.value?.close(); } function onApprovalPending() { diff --git a/packages/frontend/src/components/MkSourceCodeAvailablePopup.vue b/packages/frontend/src/components/MkSourceCodeAvailablePopup.vue new file mode 100644 index 0000000000..7b936b656c --- /dev/null +++ b/packages/frontend/src/components/MkSourceCodeAvailablePopup.vue @@ -0,0 +1,113 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div class="_panel _shadow" :class="$style.root"> + <div :class="$style.icon"> + <svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-brand-open-source" width="40" height="40" viewBox="0 0 24 24" stroke-width="1" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"> + <path stroke="none" d="M0 0h24v24H0z" fill="none"/> + <path d="M12 3a9 9 0 0 1 3.618 17.243l-2.193 -5.602a3 3 0 1 0 -2.849 0l-2.193 5.603a9 9 0 0 1 3.617 -17.244z"/> + </svg> + </div> + <div :class="$style.main"> + <div :class="$style.title"> + <I18n :src="i18n.ts.aboutX" tag="span"> + <template #x> + {{ instance.name ?? host }} + </template> + </I18n> + </div> + <div :class="$style.text"> + <I18n :src="i18n.ts._aboutMisskey.thisIsModifiedVersion" tag="span"> + <template #name> + {{ instance.name ?? host }} + </template> + </I18n> + <I18n :src="i18n.ts.correspondingSourceIsAvailable" tag="span"> + <template #anchor> + <MkA to="/about-sharkey" class="_link">{{ i18n.ts.aboutMisskey }}</MkA> + </template> + </I18n> + </div> + <div class="_buttons"> + <MkButton @click="close">{{ i18n.ts.gotIt }}</MkButton> + </div> + </div> + <button class="_button" :class="$style.close" @click="close"><i class="ph-x ph-bold ph-lg"></i></button> +</div> +</template> + +<script lang="ts" setup> +import MkButton from '@/components/MkButton.vue'; +import { host } from '@/config.js'; +import { i18n } from '@/i18n.js'; +import { instance } from '@/instance.js'; +import { miLocalStorage } from '@/local-storage.js'; +import * as os from '@/os.js'; + +const emit = defineEmits<{ + (ev: 'closed'): void; +}>(); + +const zIndex = os.claimZIndex('low'); + +function close() { + miLocalStorage.setItem('modifiedVersionMustProminentlyOfferInAgplV3Section13Read', 'true'); + emit('closed'); +} +</script> + +<style lang="scss" module> +.root { + position: fixed; + z-index: v-bind(zIndex); + bottom: var(--margin); + left: 0; + right: 0; + margin: auto; + box-sizing: border-box; + width: calc(100% - (var(--margin) * 2)); + max-width: 500px; + display: flex; + backdrop-filter: var(--blur, blur(15px)); +} + +.icon { + text-align: center; + padding-top: 25px; + width: 100px; + color: var(--accent); +} +@media (max-width: 500px) { + .icon { + width: 80px; + } +} +@media (max-width: 450px) { + .icon { + width: 70px; + } +} + +.main { + padding: 25px 25px 25px 0; + flex: 1; +} + +.close { + position: absolute; + top: 8px; + right: 8px; + padding: 8px; +} + +.title { + font-weight: bold; +} + +.text { + margin: 0.7em 0 1em 0; +} +</style> diff --git a/packages/frontend/src/components/MkSparkle.vue b/packages/frontend/src/components/MkSparkle.vue index 269825e25e..8491ce2f84 100644 --- a/packages/frontend/src/components/MkSparkle.vue +++ b/packages/frontend/src/components/MkSparkle.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -89,10 +89,11 @@ let ro: ResizeObserver | undefined; onMounted(() => { ro = new ResizeObserver((entries, observer) => { - width.value = el.value?.offsetWidth + 64; - height.value = el.value?.offsetHeight + 64; + if (el.value == null) return; + width.value = el.value.offsetWidth + 64; + height.value = el.value.offsetHeight + 64; }); - ro.observe(el.value); + if (el.value) ro.observe(el.value); const add = () => { if (stop) return; const x = (Math.random() * (width.value - 64)); diff --git a/packages/frontend/src/components/MkSubNoteContent.vue b/packages/frontend/src/components/MkSubNoteContent.vue index c071fb938a..7e63bbe82d 100644 --- a/packages/frontend/src/components/MkSubNoteContent.vue +++ b/packages/frontend/src/components/MkSubNoteContent.vue @@ -1,13 +1,13 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> <template> <div :class="[$style.root, { [$style.collapsed]: collapsed }]"> - <div :class="{ [$style.clickToOpen]: defaultStore.state.clickToOpen }" @click="defaultStore.state.clickToOpen ? noteclick(note.id) : undefined"> + <div :class="{ [$style.clickToOpen]: defaultStore.state.clickToOpen }" @click.stop="defaultStore.state.clickToOpen ? noteclick(note.id) : undefined"> <span v-if="note.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span> - <span v-if="note.deletedAt" style="opacity: 0.5">({{ i18n.ts.deleted }})</span> + <span v-if="note.deletedAt" style="opacity: 0.5">({{ i18n.ts.deletedNote }})</span> <MkA v-if="note.replyId" :class="$style.reply" :to="`/notes/${note.replyId}`" @click.stop><i class="ph-arrow-bend-left-up ph-bold ph-lg"></i></MkA> <Mfm v-if="note.text" :text="note.text" :author="note.user" :nyaize="'respect'" :isAnim="allowAnim" :emojiUrls="note.emojis"/> <MkButton v-if="!allowAnim && animated && !hideFiles" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-play ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.play }}</MkButton> @@ -15,40 +15,40 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-if="note.text && translating || note.text && 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="note.user" :nyaize="'respect'" :emojiUrls="note.emojis"/> </div> </div> <MkA v-if="note.renoteId" :class="$style.rp" :to="`/notes/${note.renoteId}`" @click.stop>RN: ...</MkA> </div> - <details v-if="note.files.length > 0" :open="!defaultStore.state.collapseFiles && !hideFiles"> - <summary>({{ i18n.t('withNFiles', { n: note.files.length }) }})</summary> + <details v-if="note.files && note.files.length > 0" :open="!defaultStore.state.collapseFiles && !hideFiles"> + <summary>({{ i18n.tsx.withNFiles({ n: note.files.length }) }})</summary> <MkMediaList :mediaList="note.files"/> </details> <details v-if="note.poll"> <summary>{{ i18n.ts.poll }}</summary> - <MkPoll :note="note"/> + <MkPoll :noteId="note.id" :poll="note.poll"/> </details> - <button v-if="isLong && collapsed" :class="$style.fade" class="_button" @click="collapsed = false"> + <button v-if="isLong && collapsed" :class="$style.fade" class="_button" @click.stop="collapsed = false"> <span :class="$style.fadeLabel">{{ i18n.ts.showMore }}</span> </button> - <button v-else-if="isLong && !collapsed" :class="$style.showLess" class="_button" @click="collapsed = true"> + <button v-else-if="isLong && !collapsed" :class="$style.showLess" class="_button" @click.stop="collapsed = true"> <span :class="$style.showLessLabel">{{ i18n.ts.showLess }}</span> </button> </div> </template> <script lang="ts" setup> -import { ref, computed } from 'vue'; +import { ref, computed, watch } from 'vue'; import * as Misskey from 'misskey-js'; -import * as mfm from '@sharkey/sfm-js'; +import * as mfm from '@transfem-org/sfm-js'; import MkMediaList from '@/components/MkMediaList.vue'; import MkPoll from '@/components/MkPoll.vue'; import MkButton from '@/components/MkButton.vue'; import { i18n } from '@/i18n.js'; import { shouldCollapsed } from '@/scripts/collapsed.js'; import { defaultStore } from '@/store.js'; -import { useRouter } from '@/router.js'; +import { useRouter } from '@/router/supplier.js'; import * as os from '@/os.js'; import { checkAnimationFromMfm } from '@/scripts/check-animated-mfm.js'; @@ -57,6 +57,7 @@ const props = defineProps<{ translating?: boolean; translation?: any; hideFiles?: boolean; + expandAllCws?: boolean; }>(); const router = useRouter(); @@ -87,6 +88,10 @@ function animatedMFM() { } const collapsed = ref(isLong); + +watch(() => props.expandAllCws, (expandAllCws) => { + if (expandAllCws) collapsed.value = false; +}); </script> <style lang="scss" module> @@ -165,5 +170,6 @@ const collapsed = ref(isLong); .clickToOpen { cursor: pointer; + -webkit-tap-highlight-color: transparent; } </style> diff --git a/packages/frontend/src/components/MkSuperMenu.vue b/packages/frontend/src/components/MkSuperMenu.vue index 93296dd9d5..2a7c72ccd9 100644 --- a/packages/frontend/src/components/MkSuperMenu.vue +++ b/packages/frontend/src/components/MkSuperMenu.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/MkSwitch.button.vue b/packages/frontend/src/components/MkSwitch.button.vue index b82f36cdd3..21339d1b4e 100644 --- a/packages/frontend/src/components/MkSwitch.button.vue +++ b/packages/frontend/src/components/MkSwitch.button.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -24,7 +24,7 @@ import { i18n } from '@/i18n.js'; const props = withDefaults(defineProps<{ checked: boolean | Ref<boolean>; - disabled?: boolean; + disabled?: boolean | Ref<boolean>; }>(), { disabled: false, }); diff --git a/packages/frontend/src/components/MkSwitch.vue b/packages/frontend/src/components/MkSwitch.vue index 35e5aebbdd..5672c8e9f7 100644 --- a/packages/frontend/src/components/MkSwitch.vue +++ b/packages/frontend/src/components/MkSwitch.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/MkTab.vue b/packages/frontend/src/components/MkTab.vue index 2b56b946d2..54ab8fc663 100644 --- a/packages/frontend/src/components/MkTab.vue +++ b/packages/frontend/src/components/MkTab.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -13,18 +13,18 @@ export default defineComponent({ }, }, setup(props, { emit, slots }) { - const options = slots.default(); + const options = slots.default?.() ?? []; return () => h('div', { class: 'pxhvhrfw', }, options.map(option => withDirectives(h('button', { - class: ['_button', { active: props.modelValue === option.props.value }], - key: option.key, - disabled: props.modelValue === option.props.value, + class: ['_button', { active: props.modelValue === option.props?.value }], + key: option.key as string, + disabled: props.modelValue === option.props?.value, onClick: () => { - emit('update:modelValue', option.props.value); + emit('update:modelValue', option.props?.value); }, - }, option.children), [ + }, option.children ?? []), [ [resolveDirective('click-anime')], ]))); }, diff --git a/packages/frontend/src/components/MkTagCloud.vue b/packages/frontend/src/components/MkTagCloud.vue index 083c34906f..6b9c181597 100644 --- a/packages/frontend/src/components/MkTagCloud.vue +++ b/packages/frontend/src/components/MkTagCloud.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -52,7 +52,7 @@ watch(available, () => { }); onMounted(() => { - width.value = rootEl.value.offsetWidth; + if (rootEl.value) width.value = rootEl.value.offsetWidth; if (loaded) { available.value = true; diff --git a/packages/frontend/src/components/MkTextarea.vue b/packages/frontend/src/components/MkTextarea.vue index 5c70adde11..3082842699 100644 --- a/packages/frontend/src/components/MkTextarea.vue +++ b/packages/frontend/src/components/MkTextarea.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only :readonly="readonly" :placeholder="placeholder" :pattern="pattern" - :autocomplete="props.autocomplete" + :autocomplete="autocomplete" :spellcheck="spellcheck" @focus="focused = true" @blur="focused = false" @@ -76,9 +76,9 @@ const invalid = ref(false); const filled = computed(() => v.value !== '' && v.value != null); const inputEl = shallowRef<HTMLTextAreaElement>(); const preview = ref(false); -let autocomplete: Autocomplete; +let autocompleteWorker: Autocomplete | null = null; -const focus = () => inputEl.value.focus(); +const focus = () => inputEl.value?.focus(); const onInput = (ev) => { changed.value = true; emit('change', ev); @@ -111,10 +111,10 @@ const updated = () => { const debouncedUpdated = debounce(1000, updated); watch(modelValue, newValue => { - v.value = newValue; + v.value = newValue ?? ''; }); -watch(v, newValue => { +watch(v, () => { if (!props.manualSave) { if (props.debounce) { debouncedUpdated(); @@ -123,7 +123,7 @@ watch(v, newValue => { } } - invalid.value = inputEl.value.validity.badInput; + invalid.value = inputEl.value?.validity.badInput ?? true; }); onMounted(() => { @@ -133,14 +133,14 @@ onMounted(() => { } }); - if (props.mfmAutocomplete) { - autocomplete = new Autocomplete(inputEl.value, v, props.mfmAutocomplete === true ? null : props.mfmAutocomplete); + if (props.mfmAutocomplete && inputEl.value) { + autocompleteWorker = new Autocomplete(inputEl.value, v, props.mfmAutocomplete === true ? undefined : props.mfmAutocomplete); } }); onUnmounted(() => { - if (autocomplete) { - autocomplete.detach(); + if (autocompleteWorker) { + autocompleteWorker.detach(); } }); </script> diff --git a/packages/frontend/src/components/MkTimeline.vue b/packages/frontend/src/components/MkTimeline.vue index 8bd68c0fd2..1c14174a37 100644 --- a/packages/frontend/src/components/MkTimeline.vue +++ b/packages/frontend/src/components/MkTimeline.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -11,14 +11,14 @@ SPDX-License-Identifier: AGPL-3.0-only :pagination="paginationQuery" :noGap="!defaultStore.state.showGapBetweenNotesInTimeline" @queue="emit('queue', $event)" - @status="prComponent.setDisabled($event)" + @status="prComponent?.setDisabled($event)" /> </MkPullToRefresh> </template> <script lang="ts" setup> -import { computed, watch, onUnmounted, provide, ref } from 'vue'; -import { Connection } from 'misskey-js/built/streaming.js'; +import { computed, watch, onUnmounted, provide, ref, shallowRef } from 'vue'; +import * as Misskey from 'misskey-js'; import MkNotes from '@/components/MkNotes.vue'; import MkPullToRefresh from '@/components/MkPullToRefresh.vue'; import { useStream } from '@/stream.js'; @@ -29,7 +29,7 @@ import { defaultStore } from '@/store.js'; import { Paging } from '@/components/MkPagination.vue'; const props = withDefaults(defineProps<{ - src: string; + src: 'home' | 'local' | 'social' | 'bubble' | 'global' | 'mentions' | 'directs' | 'list' | 'antenna' | 'channel' | 'role'; list?: string; antenna?: string; channel?: string; @@ -51,6 +51,7 @@ const emit = defineEmits<{ (ev: 'queue', count: number): void; }>(); +provide('inTimeline', true); provide('inChannel', computed(() => props.src === 'channel')); type TimelineQueryType = { @@ -65,12 +66,14 @@ type TimelineQueryType = { roleId?: string } -const prComponent = ref<InstanceType<typeof MkPullToRefresh>>(); -const tlComponent = ref<InstanceType<typeof MkNotes>>(); +const prComponent = shallowRef<InstanceType<typeof MkPullToRefresh>>(); +const tlComponent = shallowRef<InstanceType<typeof MkNotes>>(); let tlNotesCount = 0; -const prepend = note => { +function prepend(note) { + if (tlComponent.value == null) return; + tlNotesCount++; if (instance.notesPerOneAd > 0 && tlNotesCount % instance.notesPerOneAd === 0) { @@ -82,18 +85,19 @@ const prepend = note => { emit('note'); if (props.sound) { - sound.play($i && (note.userId === $i.id) ? 'noteMy' : 'note'); + sound.playMisskeySfx($i && (note.userId === $i.id) ? 'noteMy' : 'note'); } -}; +} -let connection: Connection; -let connection2: Connection; +let connection: Misskey.ChannelConnection | null = null; +let connection2: Misskey.ChannelConnection | null = null; let paginationQuery: Paging | null = null; const stream = useStream(); function connectChannel() { if (props.src === 'antenna') { + if (props.antenna == null) return; connection = stream.useChannel('antenna', { antennaId: props.antenna, }); @@ -141,20 +145,24 @@ function connectChannel() { connection = stream.useChannel('main'); connection.on('mention', onNote); } else if (props.src === 'list') { + if (props.list == null) return; connection = stream.useChannel('userList', { + withRenotes: props.withRenotes, withFiles: props.onlyFiles ? true : undefined, listId: props.list, }); } else if (props.src === 'channel') { + if (props.channel == null) return; connection = stream.useChannel('channel', { channelId: props.channel, }); } else if (props.src === 'role') { + if (props.role == null) return; connection = stream.useChannel('roleTimeline', { roleId: props.role, }); } - if (props.src !== 'directs' || props.src !== 'mentions') connection.on('note', prepend); + if (props.src !== 'directs' && props.src !== 'mentions') connection?.on('note', prepend); } function disconnectChannel() { @@ -163,7 +171,7 @@ function disconnectChannel() { } function updatePaginationQuery() { - let endpoint: string | null; + let endpoint: keyof Misskey.Endpoints | null; let query: TimelineQueryType | null; if (props.src === 'antenna') { @@ -219,6 +227,7 @@ function updatePaginationQuery() { } else if (props.src === 'list') { endpoint = 'notes/user-list-timeline'; query = { + withRenotes: props.withRenotes, withFiles: props.onlyFiles ? true : undefined, listId: props.list, }; @@ -257,8 +266,9 @@ function refreshEndpointAndChannel() { updatePaginationQuery(); } +// デッキのリストカラムでwithRenotesを変更した場合に自動的に更新されるようにさせる // IDが切り替わったら切り替え先のTLを表示させたい -watch(() => [props.list, props.antenna, props.channel, props.role], refreshEndpointAndChannel); +watch(() => [props.list, props.antenna, props.channel, props.role, props.withRenotes], refreshEndpointAndChannel); // 初回表示用 refreshEndpointAndChannel(); @@ -269,6 +279,8 @@ onUnmounted(() => { function reloadTimeline() { return new Promise<void>((res) => { + if (tlComponent.value == null) return; + tlNotesCount = 0; tlComponent.value.pagingComponent?.reload().then(() => { diff --git a/packages/frontend/src/components/MkToast.vue b/packages/frontend/src/components/MkToast.vue index 82cd236193..a117e49350 100644 --- a/packages/frontend/src/components/MkToast.vue +++ b/packages/frontend/src/components/MkToast.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/MkTokenGenerateWindow.vue b/packages/frontend/src/components/MkTokenGenerateWindow.vue index 8e8e26ed5f..b32066c950 100644 --- a/packages/frontend/src/components/MkTokenGenerateWindow.vue +++ b/packages/frontend/src/components/MkTokenGenerateWindow.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only :withOkButton="true" :okButtonDisabled="false" :canClose="false" - @close="dialog.close()" + @close="dialog?.close()" @closed="$emit('closed')" @ok="ok()" > @@ -33,7 +33,13 @@ 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(permissions)" :key="kind" v-model="permissions[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.ts._permissions[kind] }}</MkSwitch> + </div> </div> </div> </MkSpacer> @@ -49,6 +55,7 @@ import MkButton from './MkButton.vue'; import MkInfo from './MkInfo.vue'; import MkModalWindow from '@/components/MkModalWindow.vue'; import { i18n } from '@/i18n.js'; +import { iAmAdmin } from '@/account.js'; const props = withDefaults(defineProps<{ title?: string | null; @@ -68,37 +75,76 @@ const emit = defineEmits<{ }>(); const defaultPermissions = Misskey.permissions.filter(p => !p.startsWith('read:admin') && !p.startsWith('write:admin')); +const adminPermissions = Misskey.permissions.filter(p => p.startsWith('read:admin') || p.startsWith('write:admin')); + const dialog = shallowRef<InstanceType<typeof MkModalWindow>>(); const name = ref(props.initialName); -const permissions = ref(<Record<(typeof Misskey.permissions)[number], boolean>>{}); +const permissionSwitches = ref(<Record<(typeof Misskey.permissions)[number], boolean>>{}); +const permissionSwitchesForAdmin = ref(<Record<(typeof Misskey.permissions)[number], boolean>>{}); if (props.initialPermissions) { for (const kind of props.initialPermissions) { - permissions.value[kind] = true; + permissionSwitches.value[kind] = true; } } else { for (const kind of defaultPermissions) { - permissions.value[kind] = false; + permissionSwitches.value[kind] = false; + } + + if (iAmAdmin) { + for (const kind of adminPermissions) { + permissionSwitchesForAdmin.value[kind] = false; + } } } function ok(): void { emit('done', { name: name.value, - permissions: Object.keys(permissions.value).filter(p => permissions.value[p]), + permissions: [ + ...Object.keys(permissionSwitches.value).filter(p => permissionSwitches.value[p]), + ...(iAmAdmin ? Object.keys(permissionSwitchesForAdmin.value).filter(p => permissionSwitchesForAdmin.value[p]) : []), + ], }); - dialog.value.close(); + dialog.value?.close(); } function disableAll(): void { - for (const p in permissions.value) { - permissions.value[p] = false; + for (const p in permissionSwitches.value) { + permissionSwitches.value[p] = false; + } + if (iAmAdmin) { + for (const p in permissionSwitchesForAdmin.value) { + permissionSwitchesForAdmin.value[p] = false; + } } } function enableAll(): void { - for (const p in permissions.value) { - permissions.value[p] = true; + for (const p in permissionSwitches.value) { + permissionSwitches.value[p] = true; + } + if (iAmAdmin) { + for (const p in permissionSwitchesForAdmin.value) { + permissionSwitchesForAdmin.value[p] = true; + } } } </script> + +<style module lang="scss"> +.adminPermissions { + margin: 8px -6px 0; + padding: 24px 6px 6px; + border: 2px solid var(--error); + border-radius: calc(var(--radius) / 2); +} + +.adminPermissionsHeader { + margin: -34px 0 6px 12px; + padding: 0 4px; + width: fit-content; + color: var(--error); + background: var(--panel); +} +</style> diff --git a/packages/frontend/src/components/MkTooltip.vue b/packages/frontend/src/components/MkTooltip.vue index eeb9325a29..aac07008a4 100644 --- a/packages/frontend/src/components/MkTooltip.vue +++ b/packages/frontend/src/components/MkTooltip.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -13,8 +13,10 @@ SPDX-License-Identifier: AGPL-3.0-only > <div v-show="showing" ref="el" :class="$style.root" class="_acrylic _shadow" :style="{ zIndex, maxWidth: maxWidth + 'px' }"> <slot> - <Mfm v-if="asMfm" :text="text"/> - <span v-else>{{ text }}</span> + <template v-if="text"> + <Mfm v-if="asMfm" :text="text"/> + <span v-else>{{ text }}</span> + </template> </slot> </div> </Transition> @@ -53,6 +55,7 @@ const el = shallowRef<HTMLElement>(); const zIndex = os.claimZIndex('high'); function setPosition() { + if (el.value == null) return; const data = calcPopupPosition(el.value, { anchorElement: props.targetElement, direction: props.direction, diff --git a/packages/frontend/src/components/MkTutorialDialog.Note.vue b/packages/frontend/src/components/MkTutorialDialog.Note.vue index 3fca958055..5544434b5f 100644 --- a/packages/frontend/src/components/MkTutorialDialog.Note.vue +++ b/packages/frontend/src/components/MkTutorialDialog.Note.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-else-if="phase === 'howToReact'" class="_gaps"> <div style="text-align: center; padding: 0 16px;">{{ i18n.ts._initialTutorial._reaction.description }}</div> <div>{{ i18n.ts._initialTutorial._reaction.letsTryReacting }}</div> - <MkNote :class="$style.exampleNoteRoot" :note="exampleNote" :mock="true" @reaction="addReaction" @removeReaction="removeReaction" @updateReaction="updateReaction"/> + <MkNote :class="$style.exampleNoteRoot" :note="exampleNote" :mock="true" @reaction="addReaction" @removeReaction="removeReaction"/> <div v-if="onceReacted"><b style="color: var(--accent);"><i class="ph-check ph-bold ph-lg"></i> {{ i18n.ts._initialTutorial.wellDone }}</b> {{ i18n.ts._initialTutorial._reaction.reactNotification }}<br>{{ i18n.ts._initialTutorial._reaction.reactDone }}</div> </div> </template> @@ -53,7 +53,7 @@ const exampleNote = reactive<Misskey.entities.Note>({ isBot: false, isCat: true, emojis: {}, - onlineStatus: null, + onlineStatus: 'unknown', badgeRoles: [], }, text: 'just setting up my shonk', @@ -86,7 +86,6 @@ function doNotification(emoji: string): void { const notification: Misskey.entities.Notification = { id: Math.random().toString(), createdAt: new Date().toUTCString(), - isRead: false, type: 'reaction', reaction: emoji, user: $i, diff --git a/packages/frontend/src/components/MkTutorialDialog.PostNote.vue b/packages/frontend/src/components/MkTutorialDialog.PostNote.vue index f093d6d9ef..1771559a9b 100644 --- a/packages/frontend/src/components/MkTutorialDialog.PostNote.vue +++ b/packages/frontend/src/components/MkTutorialDialog.PostNote.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -58,7 +58,7 @@ const exampleCWNote = reactive<Misskey.entities.Note>({ isBot: false, isCat: true, emojis: {}, - onlineStatus: null, + onlineStatus: 'unknown', badgeRoles: [], }, text: i18n.ts._initialTutorial._postNote._cw._exampleNote.note, diff --git a/packages/frontend/src/components/MkTutorialDialog.Sensitive.vue b/packages/frontend/src/components/MkTutorialDialog.Sensitive.vue index dd255a2214..4b4e8ea8f8 100644 --- a/packages/frontend/src/components/MkTutorialDialog.Sensitive.vue +++ b/packages/frontend/src/components/MkTutorialDialog.Sensitive.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -40,7 +40,7 @@ const emit = defineEmits<{ const onceSucceeded = ref<boolean>(false); function doSucceeded(fileId: string, to: boolean) { - if (fileId === exampleNote.fileIds[0] && to) { + if (fileId === exampleNote.fileIds?.[0] && to) { onceSucceeded.value = true; emit('succeeded'); } diff --git a/packages/frontend/src/components/MkTutorialDialog.Timeline.vue b/packages/frontend/src/components/MkTutorialDialog.Timeline.vue index c2384423fd..f5670c7ebd 100644 --- a/packages/frontend/src/components/MkTutorialDialog.Timeline.vue +++ b/packages/frontend/src/components/MkTutorialDialog.Timeline.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/MkTutorialDialog.vue b/packages/frontend/src/components/MkTutorialDialog.vue index a734f93ec9..6cd7019fed 100644 --- a/packages/frontend/src/components/MkTutorialDialog.vue +++ b/packages/frontend/src/components/MkTutorialDialog.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only @close="close(true)" @closed="emit('closed')" > - <template v-if="page === 1" #header><i class="ph-pencil ph-bold pg-lg"></i> {{ i18n.ts._initialTutorial._note.title }}</template> + <template v-if="page === 1" #header><i class="ph-pencil-simple ph-bold pg-lg"></i> {{ i18n.ts._initialTutorial._note.title }}</template> <template v-else-if="page === 2" #header><i class="ph-smiley ph-bold pg-lg"></i> {{ i18n.ts._initialTutorial._reaction.title }}</template> <template v-else-if="page === 3" #header><i class="ph-house ph-bold pg-lg"></i> {{ i18n.ts._initialTutorial._timeline.title }}</template> <template v-else-if="page === 4" #header><i class="ph-plus ph-bold pg-lg"></i> {{ i18n.ts._initialTutorial._postNote.title }}</template> @@ -133,7 +133,7 @@ SPDX-License-Identifier: AGPL-3.0-only <a href="https://misskey-hub.net/docs/for-users/" target="_blank" class="_link">{{ i18n.ts.help }}</a> </template> </I18n> - <div>{{ i18n.t('_initialAccountSetting.haveFun', { name: instance.name ?? host }) }}</div> + <div>{{ i18n.tsx._initialAccountSetting.haveFun({ name: instance.name ?? host }) }}</div> <div class="_buttonsCenter" style="margin-top: 16px;"> <MkButton v-if="initialPage !== 4" rounded @click="page--"><i class="ph-arrow-left ph-bold pg-lg"></i> {{ i18n.ts.goBack }}</MkButton> <MkButton rounded primary gradate @click="close(false)">{{ i18n.ts.close }}</MkButton> diff --git a/packages/frontend/src/components/MkUpdated.vue b/packages/frontend/src/components/MkUpdated.vue index 07efaf8982..4fb0749931 100644 --- a/packages/frontend/src/components/MkUpdated.vue +++ b/packages/frontend/src/components/MkUpdated.vue @@ -1,15 +1,15 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkModal ref="modal" :zPriority="'middle'" @click="$refs.modal.close()" @closed="$emit('closed')"> +<MkModal ref="modal" :zPriority="'middle'" @click="modal?.close()" @closed="$emit('closed')"> <div :class="$style.root"> <div :class="$style.title"><MkSparkle>{{ i18n.ts.misskeyUpdated }}</MkSparkle></div> <div :class="$style.version">✨{{ version }}🚀</div> <MkButton full @click="whatIsNew">{{ i18n.ts.whatIsNew }}</MkButton> - <MkButton :class="$style.gotIt" primary full @click="$refs.modal.close()">{{ i18n.ts.gotIt }}</MkButton> + <MkButton :class="$style.gotIt" primary full @click="modal?.close()">{{ i18n.ts.gotIt }}</MkButton> </div> </MkModal> </template> @@ -26,8 +26,8 @@ import { confetti } from '@/scripts/confetti.js'; const modal = shallowRef<InstanceType<typeof MkModal>>(); const whatIsNew = () => { - modal.value.close(); - window.open(`https://git.joinsharkey.org/Sharkey/Sharkey/releases/tag/${version}`, '_blank'); + modal.value?.close(); + window.open(`https://activitypub.software/TransFem-org/Sharkey/-/releases/${version}`, '_blank'); }; onMounted(() => { diff --git a/packages/frontend/src/components/MkUrlPreview.vue b/packages/frontend/src/components/MkUrlPreview.vue index 486aaa0bbd..10ba137b94 100644 --- a/packages/frontend/src/components/MkUrlPreview.vue +++ b/packages/frontend/src/components/MkUrlPreview.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only v-if="player.url.startsWith('http://') || player.url.startsWith('https://')" sandbox="allow-popups allow-scripts allow-storage-access-by-user-activation allow-same-origin" scrolling="no" - :allow="player.allow.join(';')" + :allow="player.allow == null ? 'autoplay;encrypted-media;fullscreen' : player.allow.filter(x => ['autoplay', 'clipboard-write', 'fullscreen', 'encrypted-media', 'picture-in-picture', 'web-share'].includes(x)).join(';')" :class="$style.playerIframe" :src="player.url + (player.url.match(/\?/) ? '&autoplay=1&auto_play=1' : '?autoplay=1&auto_play=1')" :style="{ border: 0 }" @@ -83,8 +83,8 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { defineAsyncComponent, onUnmounted, ref } from 'vue'; -import type { summaly } from 'summaly'; +import { defineAsyncComponent, onDeactivated, onUnmounted, ref } from 'vue'; +import type { summaly } from '@misskey-dev/summaly'; import { url as local } from '@/config.js'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; @@ -131,6 +131,10 @@ const embedId = `embed${Math.random().toString().replace(/\D/, '')}`; const tweetHeight = ref(150); const unknownUrl = ref(false); +onDeactivated(() => { + playerEnabled.value = false; +}); + const requestUrl = new URL(props.url); if (!['http:', 'https:'].includes(requestUrl.protocol)) throw new Error('invalid url'); diff --git a/packages/frontend/src/components/MkUrlPreviewPopup.vue b/packages/frontend/src/components/MkUrlPreviewPopup.vue index 81c383540c..cf75064be7 100644 --- a/packages/frontend/src/components/MkUrlPreviewPopup.vue +++ b/packages/frontend/src/components/MkUrlPreviewPopup.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue b/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue index 3fbadbe34f..13ab6fd763 100644 --- a/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue +++ b/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkModalWindow ref="dialog" :width="400" - @close="dialog.close()" + @close="dialog?.close()" @closed="$emit('closed')" > <template v-if="announcement" #header>:{{ announcement.title }}:</template> @@ -56,6 +56,7 @@ import MkModalWindow from '@/components/MkModalWindow.vue'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; import MkTextarea from '@/components/MkTextarea.vue'; import MkSwitch from '@/components/MkSwitch.vue'; @@ -63,14 +64,14 @@ import MkRadios from '@/components/MkRadios.vue'; const props = defineProps<{ user: Misskey.entities.User, - announcement?: any, + announcement?: Misskey.entities.Announcement, }>(); const dialog = ref<InstanceType<typeof MkModalWindow> | null>(null); -const title = ref<string>(props.announcement ? props.announcement.title : ''); -const text = ref<string>(props.announcement ? props.announcement.text : ''); -const icon = ref<string>(props.announcement ? props.announcement.icon : 'info'); -const display = ref<string>(props.announcement ? props.announcement.display : 'dialog'); +const title = ref(props.announcement ? props.announcement.title : ''); +const text = ref(props.announcement ? props.announcement.text : ''); +const icon = ref(props.announcement ? props.announcement.icon : 'info'); +const display = ref(props.announcement ? props.announcement.display : 'dialog'); const needConfirmationToRead = ref(props.announcement ? props.announcement.needConfirmationToRead : false); const emit = defineEmits<{ @@ -91,18 +92,18 @@ async function done() { if (props.announcement) { await os.apiWithDialog('admin/announcements/update', { - id: props.announcement.id, ...params, + id: props.announcement.id, }); emit('done', { updated: { - id: props.announcement.id, ...params, + id: props.announcement.id, }, }); - dialog.value.close(); + dialog.value?.close(); } else { const created = await os.apiWithDialog('admin/announcements/create', params); @@ -110,25 +111,27 @@ async function done() { created: created, }); - dialog.value.close(); + dialog.value?.close(); } } async function del() { const { canceled } = await os.confirm({ type: 'warning', - text: i18n.t('removeAreYouSure', { x: title.value }), + text: i18n.tsx.removeAreYouSure({ x: title.value }), }); if (canceled) return; - os.api('admin/announcements/delete', { - id: props.announcement.id, - }).then(() => { - emit('done', { - deleted: true, + if (props.announcement) { + await misskeyApi('admin/announcements/delete', { + id: props.announcement.id, }); - dialog.value.close(); + } + + emit('done', { + deleted: true, }); + dialog.value?.close(); } </script> diff --git a/packages/frontend/src/components/MkUserCardMini.vue b/packages/frontend/src/components/MkUserCardMini.vue index b9c7377972..603f9f2435 100644 --- a/packages/frontend/src/components/MkUserCardMini.vue +++ b/packages/frontend/src/components/MkUserCardMini.vue @@ -1,16 +1,16 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div v-adaptive-bg :class="[$style.root, { yellow: user.isSilenced, blue: !user.approved, red: user.isSuspended, gray: false }]"> - <MkAvatar class="avatar" :user="user" indicator/> - <div class="body"> - <span class="name"><MkUserName class="name" :user="user"/></span> - <span class="sub"><span class="acct _monospace">@{{ acct(user) }}</span></span> +<div v-adaptive-bg :class="[$style.root]"> + <MkAvatar :class="$style.avatar" :user="user" indicator/> + <div :class="$style.body"> + <span :class="$style.name"><MkUserName :user="user"/></span> + <span :class="$style.sub"><span class="_monospace">@{{ acct(user) }}</span></span> </div> - <MkMiniChart v-if="chartValues" class="chart" :src="chartValues"/> + <MkMiniChart v-if="chartValues" :class="$style.chart" :src="chartValues"/> </div> </template> @@ -18,7 +18,7 @@ SPDX-License-Identifier: AGPL-3.0-only import * as Misskey from 'misskey-js'; import { onMounted, ref } from 'vue'; import MkMiniChart from '@/components/MkMiniChart.vue'; -import * as os from '@/os.js'; +import { misskeyApiGet } from '@/scripts/misskey-api.js'; import { acct } from '@/filters/user.js'; const props = withDefaults(defineProps<{ @@ -32,7 +32,7 @@ const chartValues = ref<number[] | null>(null); onMounted(() => { if (props.withChart) { - os.apiGet('charts/user/notes', { userId: props.user.id, limit: 16 + 1, span: 'day' }).then(res => { + misskeyApiGet('charts/user/notes', { userId: props.user.id, limit: 16 + 1, span: 'day' }).then(res => { // 今日のぶんの値はまだ途中の値であり、それも含めると大抵の場合前日よりも下降しているようなグラフになってしまうため今日は弾く res.inc.splice(0, 1); chartValues.value = res.inc; @@ -42,77 +42,53 @@ onMounted(() => { </script> <style lang="scss" module> -.root { - $bodyTitleHieght: 18px; - $bodyInfoHieght: 16px; +$bodyTitleHieght: 18px; +$bodyInfoHieght: 16px; +.root { display: flex; align-items: center; padding: 16px; background: var(--panel); border-radius: var(--radius-sm); +} - > :global(.avatar) { - display: block; - width: ($bodyTitleHieght + $bodyInfoHieght); - height: ($bodyTitleHieght + $bodyInfoHieght); - margin-right: 12px; - } - - > :global(.body) { - flex: 1; - overflow: hidden; - font-size: 0.9em; - color: var(--fg); - padding-right: 8px; - - > :global(.name) { - display: block; - width: 100%; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - line-height: $bodyTitleHieght; - } - - > :global(.sub) { - display: block; - width: 100%; - font-size: 95%; - opacity: 0.7; - line-height: $bodyInfoHieght; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - } - - > :global(.chart) { - height: 30px; - } +.avatar { + display: block; + width: ($bodyTitleHieght + $bodyInfoHieght); + height: ($bodyTitleHieght + $bodyInfoHieght); + margin-right: 12px; +} - &:global(.yellow) { - --c: rgb(255 196 0 / 15%); - background-image: linear-gradient(45deg, var(--c) 16.67%, transparent 16.67%, transparent 50%, var(--c) 50%, var(--c) 66.67%, transparent 66.67%, transparent 100%); - background-size: 16px 16px; - } +.body { + flex: 1; + overflow: hidden; + font-size: 0.9em; + color: var(--fg); + padding-right: 8px; +} - &:global(.blue) { - --c: rgba(0 153 255 / 15%); - background-image: linear-gradient(45deg, var(--c) 16.67%, transparent 16.67%, transparent 50%, var(--c) 50%, var(--c) 66.67%, transparent 66.67%, transparent 100%); - background-size: 16px 16px; - } +.name { + display: block; + width: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: $bodyTitleHieght; +} - &:global(.red) { - --c: rgb(255 0 0 / 15%); - background-image: linear-gradient(45deg, var(--c) 16.67%, transparent 16.67%, transparent 50%, var(--c) 50%, var(--c) 66.67%, transparent 66.67%, transparent 100%); - background-size: 16px 16px; - } +.sub { + display: block; + width: 100%; + font-size: 95%; + opacity: 0.7; + line-height: $bodyInfoHieght; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} - &:global(.gray) { - --c: var(--bg); - background-image: linear-gradient(45deg, var(--c) 16.67%, transparent 16.67%, transparent 50%, var(--c) 50%, var(--c) 66.67%, transparent 66.67%, transparent 100%); - background-size: 16px 16px; - } +.chart { + height: 30px; } </style> diff --git a/packages/frontend/src/components/MkUserInfo.vue b/packages/frontend/src/components/MkUserInfo.vue index 4e326911d8..63c4af41a0 100644 --- a/packages/frontend/src/components/MkUserInfo.vue +++ b/packages/frontend/src/components/MkUserInfo.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -65,8 +65,8 @@ defineProps<{ top: 62px; left: 13px; z-index: 2; - width: 58px; - height: 58px; + width: var(--avatar); + height: var(--avatar); border: solid 4px var(--panel); } diff --git a/packages/frontend/src/components/MkUserList.vue b/packages/frontend/src/components/MkUserList.vue index 56a61dce23..17a9254d01 100644 --- a/packages/frontend/src/components/MkUserList.vue +++ b/packages/frontend/src/components/MkUserList.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/MkUserOnlineIndicator.vue b/packages/frontend/src/components/MkUserOnlineIndicator.vue index 76470cba88..9f04353f62 100644 --- a/packages/frontend/src/components/MkUserOnlineIndicator.vue +++ b/packages/frontend/src/components/MkUserOnlineIndicator.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/MkUserPopup.vue b/packages/frontend/src/components/MkUserPopup.vue index ec2c48b1cf..6550fc4ec1 100644 --- a/packages/frontend/src/components/MkUserPopup.vue +++ b/packages/frontend/src/components/MkUserPopup.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -38,7 +38,7 @@ SPDX-License-Identifier: AGPL-3.0-only </dt> <dd :class="$style.fieldvalue"> <Mfm :text="field.value" :nyaize="false" :author="user" :colored="false"/> - <i v-if="user.verifiedLinks.includes(field.value)" v-tooltip:dialog="i18n.ts.verifiedLink" class="ph-seal-check ph-bold ph-lg" :class="$style.verifiedLink"></i> + <i v-if="user.verifiedLinks.includes(field.value)" v-tooltip:dialog="i18n.ts.verifiedLink" class="ph-seal-check ph-bold ph-lg"></i> </dd> </dl> </div> @@ -72,6 +72,7 @@ import * as Misskey from 'misskey-js'; import MkFollowButton from '@/components/MkFollowButton.vue'; import { userPage } from '@/filters/user.js'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { getUserMenu } from '@/scripts/get-user-menu.js'; import number from '@/filters/number.js'; import { i18n } from '@/i18n.js'; @@ -97,6 +98,7 @@ const top = ref(0); const left = ref(0); function showMenu(ev: MouseEvent) { + if (user.value == null) return; const { menu, cleanup } = getUserMenu(user.value); os.popupMenu(menu, ev.currentTarget ?? ev.target).finally(cleanup); } @@ -109,7 +111,7 @@ onMounted(() => { Misskey.acct.parse(props.q.substring(1)) : { userId: props.q }; - os.api('users/show', query).then(res => { + misskeyApi('users/show', query).then(res => { if (!props.showing) return; user.value = res; }); @@ -199,8 +201,8 @@ onMounted(() => { right: 0; margin: 0 auto; z-index: 2; - width: 58px; - height: 58px; + width: var(--avatar); + height: var(--avatar); } .title { diff --git a/packages/frontend/src/components/MkUserSelectDialog.vue b/packages/frontend/src/components/MkUserSelectDialog.vue index 9d41147bd2..b76be051d8 100644 --- a/packages/frontend/src/components/MkUserSelectDialog.vue +++ b/packages/frontend/src/components/MkUserSelectDialog.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -16,7 +16,11 @@ SPDX-License-Identifier: AGPL-3.0-only <template #header>{{ i18n.ts.selectUser }}</template> <div> <div :class="$style.form"> - <FormSplit :minWidth="170"> + <MkInput v-if="localOnly" v-model="username" :autofocus="true" @update:modelValue="search"> + <template #label>{{ i18n.ts.username }}</template> + <template #prefix>@</template> + </MkInput> + <FormSplit v-else :minWidth="170"> <MkInput v-model="username" :autofocus="true" @update:modelValue="search"> <template #label>{{ i18n.ts.username }}</template> <template #prefix>@</template> @@ -62,11 +66,11 @@ import * as Misskey from 'misskey-js'; import MkInput from '@/components/MkInput.vue'; import FormSplit from '@/components/form/split.vue'; import MkModalWindow from '@/components/MkModalWindow.vue'; -import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { defaultStore } from '@/store.js'; import { i18n } from '@/i18n.js'; import { $i } from '@/account.js'; -import { hostname } from '@/config.js'; +import { host as currentHost, hostname } from '@/config.js'; const emit = defineEmits<{ (ev: 'ok', selected: Misskey.entities.UserDetailed): void; @@ -74,58 +78,85 @@ const emit = defineEmits<{ (ev: 'closed'): void; }>(); -const props = defineProps<{ +const props = withDefaults(defineProps<{ includeSelf?: boolean; -}>(); + localOnly?: boolean; +}>(), { + includeSelf: false, + localOnly: false, +}); const username = ref(''); const host = ref(''); -const users = ref<Misskey.entities.UserDetailed[]>([]); +const users = ref<Misskey.entities.UserLite[]>([]); const recentUsers = ref<Misskey.entities.UserDetailed[]>([]); -const selected = ref<Misskey.entities.UserDetailed | null>(null); +const selected = ref<Misskey.entities.UserLite | null>(null); const dialogEl = ref(); -const search = () => { +function search() { if (username.value === '' && host.value === '') { users.value = []; return; } - os.api('users/search-by-username-and-host', { + + misskeyApi('users/search-by-username-and-host', { username: username.value, - host: host.value, + host: props.localOnly ? '.' : host.value, limit: 10, detail: false, }).then(_users => { - users.value = _users; + users.value = _users.filter((u) => { + if (props.includeSelf) { + return true; + } else { + return u.id !== $i?.id; + } + }); }); -}; +} -const ok = () => { +async function ok() { if (selected.value == null) return; - emit('ok', selected.value); + + const user = await misskeyApi('users/show', { + userId: selected.value.id, + }); + emit('ok', user); + dialogEl.value.close(); // 最近使ったユーザー更新 let recents = defaultStore.state.recentlyUsedUsers; - recents = recents.filter(x => x !== selected.value.id); + 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(() => { - os.api('users/show', { + misskeyApi('users/show', { userIds: defaultStore.state.recentlyUsedUsers, - }).then(users => { - if (props.includeSelf && users.find(x => $i ? x.id === $i.id : true) == null) { - recentUsers.value = [$i, ...users]; - } else { - recentUsers.value = users; - } + }).then(foundUsers => { + let _users = foundUsers; + _users = _users.filter((u) => { + if (props.localOnly) { + return u.host == null; + } else { + return true; + } + }); + _users = _users.filter((u) => { + if (props.includeSelf) { + return true; + } else { + return u.id !== $i?.id; + } + }); + recentUsers.value = _users; }); }); </script> @@ -133,7 +164,7 @@ onMounted(() => { <style lang="scss" module> .form { - padding: 0 var(--root-margin); + padding: calc(var(--root-margin) / 2) var(--root-margin); } .result, diff --git a/packages/frontend/src/components/MkUserSetupDialog.Follow.stories.impl.ts b/packages/frontend/src/components/MkUserSetupDialog.Follow.stories.impl.ts index 45c7da40ce..638bfb4372 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.Follow.stories.impl.ts +++ b/packages/frontend/src/components/MkUserSetupDialog.Follow.stories.impl.ts @@ -1,11 +1,11 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { StoryObj } from '@storybook/vue3'; -import { rest } from 'msw'; +import { HttpResponse, http } from 'msw'; import { commonHandlers } from '../../.storybook/mocks.js'; import { userDetailed } from '../../.storybook/fakes.js'; import MkUserSetupDialog_Follow from './MkUserSetupDialog.Follow.vue'; @@ -38,17 +38,17 @@ export const Default = { msw: { handlers: [ ...commonHandlers, - rest.post('/api/users', (req, res, ctx) => { - return res(ctx.json([ + http.post('/api/users', () => { + return HttpResponse.json([ userDetailed('44'), userDetailed('49'), - ])); + ]); }), - rest.post('/api/pinned-users', (req, res, ctx) => { - return res(ctx.json([ + http.post('/api/pinned-users', () => { + return HttpResponse.json([ userDetailed('44'), userDetailed('49'), - ])); + ]); }), ], }, diff --git a/packages/frontend/src/components/MkUserSetupDialog.Follow.vue b/packages/frontend/src/components/MkUserSetupDialog.Follow.vue index 5f3f5b81dd..1524ea0ec9 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.Follow.vue +++ b/packages/frontend/src/components/MkUserSetupDialog.Follow.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkPagination :pagination="pinnedUsers"> <template #default="{ items }"> <div :class="$style.users"> - <XUser v-for="item in items" :key="item.id" :user="item"/> + <XUser v-for="item in (items as Misskey.entities.UserDetailed[])" :key="item.id" :user="item"/> </div> </template> </MkPagination> @@ -25,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkPagination :pagination="popularUsers"> <template #default="{ items }"> <div :class="$style.users"> - <XUser v-for="item in items" :key="item.id" :user="item"/> + <XUser v-for="item in (items as Misskey.entities.UserDetailed[])" :key="item.id" :user="item"/> </div> </template> </MkPagination> @@ -34,18 +34,28 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> +import * as Misskey from 'misskey-js'; import { i18n } from '@/i18n.js'; import MkFolder from '@/components/MkFolder.vue'; import XUser from '@/components/MkUserSetupDialog.User.vue'; -import MkPagination from '@/components/MkPagination.vue'; +import MkPagination, { type Paging } from '@/components/MkPagination.vue'; -const pinnedUsers = { endpoint: 'pinned-users', noPaging: true }; +const pinnedUsers: Paging = { + endpoint: 'pinned-users', + noPaging: true, + limit: 10, +}; -const popularUsers = { endpoint: 'users', limit: 10, noPaging: true, params: { - state: 'alive', - origin: 'local', - sort: '+follower', -} }; +const popularUsers: Paging = { + endpoint: 'users', + limit: 10, + noPaging: true, + params: { + state: 'alive', + origin: 'local', + sort: '+follower', + }, +}; </script> <style lang="scss" module> diff --git a/packages/frontend/src/components/MkUserSetupDialog.Privacy.stories.impl.ts b/packages/frontend/src/components/MkUserSetupDialog.Privacy.stories.impl.ts index 0f81c0817d..2a7947c6f8 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.Privacy.stories.impl.ts +++ b/packages/frontend/src/components/MkUserSetupDialog.Privacy.stories.impl.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/components/MkUserSetupDialog.Privacy.vue b/packages/frontend/src/components/MkUserSetupDialog.Privacy.vue index 664c4da203..6d2f0bbb99 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.Privacy.vue +++ b/packages/frontend/src/components/MkUserSetupDialog.Privacy.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -41,14 +41,14 @@ import { i18n } from '@/i18n.js'; import MkSwitch from '@/components/MkSwitch.vue'; import MkInfo from '@/components/MkInfo.vue'; import MkFolder from '@/components/MkFolder.vue'; -import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; const isLocked = ref(false); const hideOnlineStatus = ref(false); const noCrawle = ref(false); watch([isLocked, hideOnlineStatus, noCrawle], () => { - os.api('i/update', { + misskeyApi('i/update', { isLocked: !!isLocked.value, hideOnlineStatus: !!hideOnlineStatus.value, noCrawle: !!noCrawle.value, diff --git a/packages/frontend/src/components/MkUserSetupDialog.Profile.stories.impl.ts b/packages/frontend/src/components/MkUserSetupDialog.Profile.stories.impl.ts index d2c6f7d479..c6088a5ae3 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.Profile.stories.impl.ts +++ b/packages/frontend/src/components/MkUserSetupDialog.Profile.stories.impl.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/components/MkUserSetupDialog.Profile.vue b/packages/frontend/src/components/MkUserSetupDialog.Profile.vue index 37aa677b44..3194641cdb 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.Profile.vue +++ b/packages/frontend/src/components/MkUserSetupDialog.Profile.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -39,7 +39,9 @@ import FormSlot from '@/components/form/slot.vue'; import MkInfo from '@/components/MkInfo.vue'; import { chooseFileFromPc } from '@/scripts/select-file.js'; import * as os from '@/os.js'; -import { $i } from '@/account.js'; +import { signinRequired } from '@/account.js'; + +const $i = signinRequired(); const name = ref($i.name ?? ''); const description = ref($i.description ?? ''); @@ -68,7 +70,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, }); diff --git a/packages/frontend/src/components/MkUserSetupDialog.User.stories.impl.ts b/packages/frontend/src/components/MkUserSetupDialog.User.stories.impl.ts index 31176c0832..f0206e0cb4 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.User.stories.impl.ts +++ b/packages/frontend/src/components/MkUserSetupDialog.User.stories.impl.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/components/MkUserSetupDialog.User.vue b/packages/frontend/src/components/MkUserSetupDialog.User.vue index 621995cc5b..a4b9746f4b 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.User.vue +++ b/packages/frontend/src/components/MkUserSetupDialog.User.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -29,7 +29,7 @@ import * as Misskey from 'misskey-js'; import { ref } from 'vue'; import MkButton from '@/components/MkButton.vue'; import { i18n } from '@/i18n.js'; -import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; const props = defineProps<{ user: Misskey.entities.UserDetailed; @@ -39,7 +39,7 @@ const isFollowing = ref(false); async function follow() { isFollowing.value = true; - os.api('following/create', { + misskeyApi('following/create', { userId: props.user.id, }); } @@ -59,8 +59,8 @@ async function follow() { top: 30px; left: 13px; z-index: 2; - width: 58px; - height: 58px; + width: var(--avatar); + height: var(--avatar); border: solid 4px var(--panel); } diff --git a/packages/frontend/src/components/MkUserSetupDialog.stories.impl.ts b/packages/frontend/src/components/MkUserSetupDialog.stories.impl.ts index 5182db12b2..3f5ae734bd 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.stories.impl.ts +++ b/packages/frontend/src/components/MkUserSetupDialog.stories.impl.ts @@ -1,11 +1,11 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { StoryObj } from '@storybook/vue3'; -import { rest } from 'msw'; +import { HttpResponse, http } from 'msw'; import { commonHandlers } from '../../.storybook/mocks.js'; import { userDetailed } from '../../.storybook/fakes.js'; import MkUserSetupDialog from './MkUserSetupDialog.vue'; @@ -38,17 +38,17 @@ export const Default = { msw: { handlers: [ ...commonHandlers, - rest.post('/api/users', (req, res, ctx) => { - return res(ctx.json([ + http.post('/api/users', () => { + return HttpResponse.json([ userDetailed('44'), userDetailed('49'), - ])); + ]); }), - rest.post('/api/pinned-users', (req, res, ctx) => { - return res(ctx.json([ + http.post('/api/pinned-users', () => { + return HttpResponse.json([ userDetailed('44'), userDetailed('49'), - ])); + ]); }), ], }, diff --git a/packages/frontend/src/components/MkUserSetupDialog.vue b/packages/frontend/src/components/MkUserSetupDialog.vue index be945c1066..bd8949890c 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.vue +++ b/packages/frontend/src/components/MkUserSetupDialog.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -93,7 +93,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="_gaps" style="text-align: center;"> <i class="ph-bell-ringing ph-bold ph-lg" 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="ph-arrow-left ph-bold ph-lg"></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="ph-check ph-bold ph-lg" 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="ph-arrow-right ph-bold ph-lg"></i></MkButton> </div> diff --git a/packages/frontend/src/components/MkUsersTooltip.vue b/packages/frontend/src/components/MkUsersTooltip.vue index 37548952b6..054a503257 100644 --- a/packages/frontend/src/components/MkUsersTooltip.vue +++ b/packages/frontend/src/components/MkUsersTooltip.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/MkVisibilityPicker.vue b/packages/frontend/src/components/MkVisibilityPicker.vue index 61edc345a9..bd6edad663 100644 --- a/packages/frontend/src/components/MkVisibilityPicker.vue +++ b/packages/frontend/src/components/MkVisibilityPicker.vue @@ -1,29 +1,29 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkModal ref="modal" v-slot="{ type }" :zPriority="'high'" :src="src" @click="modal.close()" @closed="emit('closed')"> +<MkModal ref="modal" v-slot="{ type }" :zPriority="'high'" :src="src" @click="modal?.close()" @closed="emit('closed')"> <div class="_popup" :class="{ [$style.root]: true, [$style.asDrawer]: type === 'drawer' }"> <div :class="[$style.label, $style.item]"> {{ i18n.ts.visibility }} </div> - <button key="public" :disabled="isSilenced" class="_button" :class="[$style.item, { [$style.active]: v === 'public' }]" data-index="1" @click="choose('public')"> + <button key="public" :disabled="isSilenced || isReplyVisibilitySpecified" class="_button" :class="[$style.item, { [$style.active]: v === 'public' }]" data-index="1" @click="choose('public')"> <div :class="$style.icon"><i class="ph-globe-hemisphere-west ph-bold ph-lg"></i></div> <div :class="$style.body"> <span :class="$style.itemTitle">{{ i18n.ts._visibility.public }}</span> <span :class="$style.itemDescription">{{ i18n.ts._visibility.publicDescription }}</span> </div> </button> - <button key="home" class="_button" :class="[$style.item, { [$style.active]: v === 'home' }]" data-index="2" @click="choose('home')"> + <button key="home" :disabled="isReplyVisibilitySpecified" class="_button" :class="[$style.item, { [$style.active]: v === 'home' }]" data-index="2" @click="choose('home')"> <div :class="$style.icon"><i class="ph-house ph-bold ph-lg"></i></div> <div :class="$style.body"> <span :class="$style.itemTitle">{{ i18n.ts._visibility.home }}</span> <span :class="$style.itemDescription">{{ i18n.ts._visibility.homeDescription }}</span> </div> </button> - <button key="followers" class="_button" :class="[$style.item, { [$style.active]: v === 'followers' }]" data-index="3" @click="choose('followers')"> + <button key="followers" :disabled="isReplyVisibilitySpecified" class="_button" :class="[$style.item, { [$style.active]: v === 'followers' }]" data-index="3" @click="choose('followers')"> <div :class="$style.icon"><i class="ph-lock ph-bold ph-lg"></i></div> <div :class="$style.body"> <span :class="$style.itemTitle">{{ i18n.ts._visibility.followers }}</span> @@ -54,6 +54,7 @@ const props = withDefaults(defineProps<{ isSilenced: boolean; localOnly: boolean; src?: HTMLElement; + isReplyVisibilitySpecified?: boolean; }>(), { }); diff --git a/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue b/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue index 746ed3e0de..cab42cd59d 100644 --- a/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue +++ b/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -13,11 +13,11 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, shallowRef, ref } from 'vue'; +import { onMounted, shallowRef, ref, nextTick } from 'vue'; import { Chart } from 'chart.js'; import gradient from 'chartjs-plugin-gradient'; import tinycolor from 'tinycolor2'; -import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { defaultStore } from '@/store.js'; import { useChartTooltip } from '@/scripts/use-chart-tooltip.js'; import { chartVLine } from '@/scripts/chart-vline.js'; @@ -25,9 +25,9 @@ import { initChart } from '@/scripts/init-chart.js'; initChart(); -const chartEl = shallowRef<HTMLCanvasElement>(null); +const chartEl = shallowRef<HTMLCanvasElement | null>(null); const now = new Date(); -let chartInstance: Chart = null; +let chartInstance: Chart | null = null; const chartLimit = 30; const fetching = ref(true); @@ -53,7 +53,11 @@ async function renderChart() { })); }; - const raw = await os.api('charts/active-users', { limit: chartLimit, span: 'day' }); + const raw = await misskeyApi('charts/active-users', { limit: chartLimit, span: 'day' }); + + fetching.value = false; + + await nextTick(); const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; @@ -65,6 +69,8 @@ async function renderChart() { const max = Math.max(...raw.read); + if (chartEl.value == null) return; + chartInstance = new Chart(chartEl.value, { type: 'bar', data: { @@ -97,7 +103,6 @@ async function renderChart() { type: 'time', offset: true, time: { - stepSize: 1, unit: 'day', displayFormats: { day: 'M/d', @@ -108,6 +113,7 @@ async function renderChart() { display: false, }, ticks: { + stepSize: 1, display: true, maxRotation: 0, autoSkipPadding: 8, @@ -141,13 +147,10 @@ async function renderChart() { }, external: externalTooltipHandler, }, - gradient, }, }, plugins: [chartVLine(vLineColor)], }); - - fetching.value = false; } onMounted(async () => { diff --git a/packages/frontend/src/components/MkVisitorDashboard.vue b/packages/frontend/src/components/MkVisitorDashboard.vue index 862a38bd54..d8e6ba9a09 100644 --- a/packages/frontend/src/components/MkVisitorDashboard.vue +++ b/packages/frontend/src/components/MkVisitorDashboard.vue @@ -1,12 +1,12 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> <template> <div v-if="meta" :class="$style.root"> <div :class="[$style.main, $style.panel]"> - <img :src="instance.iconUrl || instance.faviconUrl || '/apple-touch-icon.png'" alt="" :class="$style.mainIcon"/> + <img :src="instance.iconUrl || '/apple-touch-icon.png'" alt="" :class="$style.mainIcon"/> <button class="_button _acrylic" :class="$style.mainMenu" @click="showMenu"><i class="ph-dots-three ph-bold ph-lg"></i></button> <div :class="$style.mainFg"> <h1 :class="$style.mainTitle"> @@ -16,7 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-only </h1> <div :class="$style.mainAbout"> <!-- eslint-disable-next-line vue/no-v-html --> - <div v-html="meta.description || i18n.ts.headlineMisskey"></div> + <div v-html="sanitizeHtml(meta.description) || i18n.ts.headlineMisskey"></div> </div> <div v-if="instance.disableRegistration" :class="$style.mainWarn"> <MkInfo warn>{{ i18n.ts.invitationRequiredToRegister }}</MkInfo> @@ -56,6 +56,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref } from 'vue'; import * as Misskey from 'misskey-js'; +import sanitizeHtml from 'sanitize-html'; import XSigninDialog from '@/components/MkSigninDialog.vue'; import XSignupDialog from '@/components/MkSignupDialog.vue'; import MkButton from '@/components/MkButton.vue'; @@ -63,6 +64,7 @@ import MkTimeline from '@/components/MkTimeline.vue'; import MkInfo from '@/components/MkInfo.vue'; import { instanceName } from '@/config.js'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; import MkNumber from '@/components/MkNumber.vue'; @@ -71,11 +73,11 @@ import XActiveUsersChart from '@/components/MkVisitorDashboard.ActiveUsersChart. const meta = ref<Misskey.entities.MetaResponse | null>(null); const stats = ref<Misskey.entities.StatsResponse | null>(null); -os.api('meta', { detail: true }).then(_meta => { +misskeyApi('meta', { detail: true }).then(_meta => { meta.value = _meta; }); -os.api('stats', {}).then((res) => { +misskeyApi('stats', {}).then((res) => { stats.value = res; }); @@ -108,21 +110,27 @@ function showMenu(ev) { text: i18n.ts.impressum, icon: 'ph-newspaper-clipping ph-bold ph-lg', action: () => { - window.open(instance.impressumUrl, '_blank', 'noopener'); + window.open(instance.impressumUrl!, '_blank', 'noopener'); }, } : undefined, (instance.tosUrl) ? { text: i18n.ts.termsOfService, icon: 'ph-notebook ph-bold ph-lg', action: () => { - window.open(instance.tosUrl, '_blank', 'noopener'); + window.open(instance.tosUrl!, '_blank', 'noopener'); }, } : undefined, (instance.privacyPolicyUrl) ? { text: i18n.ts.privacyPolicy, icon: 'ph-shield ph-bold ph-lg', action: () => { - window.open(instance.privacyPolicyUrl, '_blank', 'noopener'); + window.open(instance.privacyPolicyUrl!, '_blank', 'noopener'); }, - } : undefined, (!instance.impressumUrl && !instance.tosUrl && !instance.privacyPolicyUrl) ? undefined : { type: 'divider' }, { + } : undefined, (instance.donationUrl) ? { + text: i18n.ts.donation, + icon: 'ph-hand-coins ph-bold ph-lg', + action: () => { + window.open(instance.donationUrl, '_blank', 'noopener'); + }, + } : undefined, (!instance.impressumUrl && !instance.tosUrl && !instance.privacyPolicyUrl && !instance.donationUrl) ? undefined : { type: 'divider' }, { text: i18n.ts.help, icon: 'ph-question ph-bold ph-lg', action: () => { diff --git a/packages/frontend/src/components/MkWaitingDialog.vue b/packages/frontend/src/components/MkWaitingDialog.vue index 28943efd1a..ad2105cc0b 100644 --- a/packages/frontend/src/components/MkWaitingDialog.vue +++ b/packages/frontend/src/components/MkWaitingDialog.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -32,7 +32,7 @@ const emit = defineEmits<{ function done() { emit('done'); - modal.value.close(); + modal.value?.close(); } watch(() => props.showing, () => { diff --git a/packages/frontend/src/components/MkWidgets.vue b/packages/frontend/src/components/MkWidgets.vue index bc1f33c43e..05a0f6e04e 100644 --- a/packages/frontend/src/components/MkWidgets.vue +++ b/packages/frontend/src/components/MkWidgets.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -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="ph-plus ph-bold ph-lg"></i> {{ i18n.ts.add }}</MkButton> <MkButton inline @click="$emit('exit')">{{ i18n.ts.close }}</MkButton> @@ -104,19 +104,21 @@ const updateWidget = (id, data) => { }; function onContextmenu(widget: Widget, ev: MouseEvent) { - const isLink = (el: HTMLElement) => { + const element = ev.target as HTMLElement | null; + const isLink = (el: HTMLElement): boolean => { if (el.tagName === 'A') return true; if (el.parentElement) { return isLink(el.parentElement); } + return false; }; - if (isLink(ev.target)) return; - if (['INPUT', 'TEXTAREA', 'IMG', 'VIDEO', 'CANVAS'].includes(ev.target.tagName) || ev.target.attributes['contenteditable']) return; + if (element && isLink(element)) return; + if (element && (['INPUT', 'TEXTAREA', 'IMG', 'VIDEO', 'CANVAS'].includes(element.tagName) || element.attributes['contenteditable'])) return; if (window.getSelection()?.toString() !== '') return; os.contextMenu([{ type: 'label', - text: i18n.t(`_widgets.${widget.name}`), + text: i18n.ts._widgets[widget.name], }, { icon: 'ph-gear ph-bold ph-lg', text: i18n.ts.settings, diff --git a/packages/frontend/src/components/MkWindow.vue b/packages/frontend/src/components/MkWindow.vue index e5b8bd9b15..f13b53b005 100644 --- a/packages/frontend/src/components/MkWindow.vue +++ b/packages/frontend/src/components/MkWindow.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -63,7 +63,7 @@ import { defaultStore } from '@/store.js'; const minHeight = 50; const minWidth = 250; -function dragListen(fn: (ev: MouseEvent) => void) { +function dragListen(fn: (ev: MouseEvent | TouchEvent) => void) { window.addEventListener('mousemove', fn); window.addEventListener('touchmove', fn); window.addEventListener('mouseleave', dragClear.bind(null, fn)); @@ -138,11 +138,12 @@ function onContextmenu(ev: MouseEvent) { // 最前面へ移動 function top() { if (rootEl.value) { - rootEl.value.style.zIndex = os.claimZIndex(props.front ? 'middle' : 'low'); + rootEl.value.style.zIndex = os.claimZIndex(props.front ? 'middle' : 'low').toString(); } } function maximize() { + if (rootEl.value == null) return; maximized.value = true; unResizedTop = rootEl.value.style.top; unResizedLeft = rootEl.value.style.left; @@ -155,6 +156,7 @@ function maximize() { } function unMaximize() { + if (rootEl.value == null) return; maximized.value = false; rootEl.value.style.top = unResizedTop; rootEl.value.style.left = unResizedLeft; @@ -163,6 +165,7 @@ function unMaximize() { } function minimize() { + if (rootEl.value == null) return; minimized.value = true; unResizedWidth = rootEl.value.style.width; unResizedHeight = rootEl.value.style.height; @@ -171,8 +174,8 @@ function minimize() { } function unMinimize() { + if (rootEl.value == null) return; const main = rootEl.value; - if (main == null) return; minimized.value = false; rootEl.value.style.width = unResizedWidth; @@ -199,9 +202,17 @@ function onDblClick() { } } -function onHeaderMousedown(evt: MouseEvent) { +function getPositionX(event: MouseEvent | TouchEvent) { + return 'touches' in event && event.touches.length > 0 ? event.touches[0].clientX : 'clientX' in event ? event.clientX : 0; +} + +function getPositionY(event: MouseEvent | TouchEvent) { + return 'touches' in event && event.touches.length > 0 ? event.touches[0].clientY : 'clientY' in event ? event.clientY : 0; +} + +function onHeaderMousedown(evt: MouseEvent | TouchEvent) { // 右クリックはコンテキストメニューを開こうとした可能性が高いため無視 - if (evt.button === 2) return; + if ('button' in evt && evt.button === 2) return; let beforeMaximized = false; @@ -226,8 +237,8 @@ function onHeaderMousedown(evt: MouseEvent) { const position = main.getBoundingClientRect(); - const clickX = evt.touches && evt.touches.length > 0 ? evt.touches[0].clientX : evt.clientX; - const clickY = evt.touches && evt.touches.length > 0 ? evt.touches[0].clientY : evt.clientY; + const clickX = getPositionX(evt); + const clickY = getPositionY(evt); const moveBaseX = beforeMaximized ? parseInt(unResizedWidth, 10) / 2 : clickX - position.left; // TODO: parseIntやめる const moveBaseY = beforeMaximized ? 20 : clickY - position.top; const browserWidth = window.innerWidth; @@ -251,8 +262,10 @@ function onHeaderMousedown(evt: MouseEvent) { // 右はみ出し if (moveLeft + windowWidth > browserWidth) moveLeft = browserWidth - windowWidth; - rootEl.value.style.left = moveLeft + 'px'; - rootEl.value.style.top = moveTop + 'px'; + if (rootEl.value) { + rootEl.value.style.left = moveLeft + 'px'; + rootEl.value.style.top = moveTop + 'px'; + } } if (beforeMaximized) { @@ -261,26 +274,26 @@ function onHeaderMousedown(evt: MouseEvent) { // 動かした時 dragListen(me => { - const x = me.touches && me.touches.length > 0 ? me.touches[0].clientX : me.clientX; - const y = me.touches && me.touches.length > 0 ? me.touches[0].clientY : me.clientY; + const x = getPositionX(me); + const y = getPositionY(me); move(x, y); }); } // 上ハンドル掴み時 -function onTopHandleMousedown(evt) { +function onTopHandleMousedown(evt: MouseEvent | TouchEvent) { const main = rootEl.value; // どういうわけかnullになることがある if (main == null) return; - const base = evt.clientY; + const base = getPositionY(evt); const height = parseInt(getComputedStyle(main, '').height, 10); const top = parseInt(getComputedStyle(main, '').top, 10); // 動かした時 dragListen(me => { - const move = me.clientY - base; + const move = getPositionY(me) - base; if (top + move > 0) { if (height + -move > minHeight) { applyTransformHeight(height + -move); @@ -297,18 +310,18 @@ function onTopHandleMousedown(evt) { } // 右ハンドル掴み時 -function onRightHandleMousedown(evt) { +function onRightHandleMousedown(evt: MouseEvent | TouchEvent) { const main = rootEl.value; if (main == null) return; - const base = evt.clientX; + const base = getPositionX(evt); const width = parseInt(getComputedStyle(main, '').width, 10); const left = parseInt(getComputedStyle(main, '').left, 10); const browserWidth = window.innerWidth; // 動かした時 dragListen(me => { - const move = me.clientX - base; + const move = getPositionX(me) - base; if (left + width + move < browserWidth) { if (width + move > minWidth) { applyTransformWidth(width + move); @@ -322,18 +335,18 @@ function onRightHandleMousedown(evt) { } // 下ハンドル掴み時 -function onBottomHandleMousedown(evt) { +function onBottomHandleMousedown(evt: MouseEvent | TouchEvent) { const main = rootEl.value; if (main == null) return; - const base = evt.clientY; + const base = getPositionY(evt); const height = parseInt(getComputedStyle(main, '').height, 10); const top = parseInt(getComputedStyle(main, '').top, 10); const browserHeight = window.innerHeight; // 動かした時 dragListen(me => { - const move = me.clientY - base; + const move = getPositionY(me) - base; if (top + height + move < browserHeight) { if (height + move > minHeight) { applyTransformHeight(height + move); @@ -347,17 +360,17 @@ function onBottomHandleMousedown(evt) { } // 左ハンドル掴み時 -function onLeftHandleMousedown(evt) { +function onLeftHandleMousedown(evt: MouseEvent | TouchEvent) { const main = rootEl.value; if (main == null) return; - const base = evt.clientX; + const base = getPositionX(evt); const width = parseInt(getComputedStyle(main, '').width, 10); const left = parseInt(getComputedStyle(main, '').left, 10); // 動かした時 dragListen(me => { - const move = me.clientX - base; + const move = getPositionX(me) - base; if (left + move > 0) { if (width + -move > minWidth) { applyTransformWidth(width + -move); @@ -374,25 +387,25 @@ function onLeftHandleMousedown(evt) { } // 左上ハンドル掴み時 -function onTopLeftHandleMousedown(evt) { +function onTopLeftHandleMousedown(evt: MouseEvent | TouchEvent) { onTopHandleMousedown(evt); onLeftHandleMousedown(evt); } // 右上ハンドル掴み時 -function onTopRightHandleMousedown(evt) { +function onTopRightHandleMousedown(evt: MouseEvent | TouchEvent) { onTopHandleMousedown(evt); onRightHandleMousedown(evt); } // 右下ハンドル掴み時 -function onBottomRightHandleMousedown(evt) { +function onBottomRightHandleMousedown(evt: MouseEvent | TouchEvent) { onBottomHandleMousedown(evt); onRightHandleMousedown(evt); } // 左下ハンドル掴み時 -function onBottomLeftHandleMousedown(evt) { +function onBottomLeftHandleMousedown(evt: MouseEvent | TouchEvent) { onBottomHandleMousedown(evt); onLeftHandleMousedown(evt); } @@ -400,23 +413,23 @@ function onBottomLeftHandleMousedown(evt) { // 高さを適用 function applyTransformHeight(height) { if (height > window.innerHeight) height = window.innerHeight; - rootEl.value.style.height = height + 'px'; + if (rootEl.value) rootEl.value.style.height = height + 'px'; } // 幅を適用 function applyTransformWidth(width) { if (width > window.innerWidth) width = window.innerWidth; - rootEl.value.style.width = width + 'px'; + if (rootEl.value) rootEl.value.style.width = width + 'px'; } // Y座標を適用 function applyTransformTop(top) { - rootEl.value.style.top = top + 'px'; + if (rootEl.value) rootEl.value.style.top = top + 'px'; } // X座標を適用 function applyTransformLeft(left) { - rootEl.value.style.left = left + 'px'; + if (rootEl.value) rootEl.value.style.left = left + 'px'; } function onBrowserResize() { @@ -438,8 +451,10 @@ onMounted(() => { applyTransformWidth(props.initialWidth); if (props.initialHeight) applyTransformHeight(props.initialHeight); - applyTransformTop((window.innerHeight / 2) - (rootEl.value.offsetHeight / 2)); - applyTransformLeft((window.innerWidth / 2) - (rootEl.value.offsetWidth / 2)); + if (rootEl.value) { + applyTransformTop((window.innerHeight / 2) - (rootEl.value.offsetHeight / 2)); + applyTransformLeft((window.innerWidth / 2) - (rootEl.value.offsetWidth / 2)); + } // 他のウィンドウ内のボタンなどを押してこのウィンドウが開かれた場合、親が最前面になろうとするのでそれに隠されないようにする top(); diff --git a/packages/frontend/src/components/MkYouTubePlayer.vue b/packages/frontend/src/components/MkYouTubePlayer.vue index a9b2e8a00d..3ad2a95bc3 100644 --- a/packages/frontend/src/components/MkYouTubePlayer.vue +++ b/packages/frontend/src/components/MkYouTubePlayer.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -39,7 +39,7 @@ if (!['http:', 'https:'].includes(requestUrl.protocol)) throw new Error('invalid const fetching = ref(true); const title = ref<string | null>(null); const player = ref({ - url: null, + url: null as string | null, width: null, height: null, }); diff --git a/packages/frontend/src/components/SkApprovalUser.vue b/packages/frontend/src/components/SkApprovalUser.vue index 2bf6361ac8..f85944cd04 100644 --- a/packages/frontend/src/components/SkApprovalUser.vue +++ b/packages/frontend/src/components/SkApprovalUser.vue @@ -33,6 +33,7 @@ import MkFolder from '@/components/MkFolder.vue'; import MkButton from '@/components/MkButton.vue'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; const props = defineProps<{ user: Misskey.entities.User; @@ -42,7 +43,7 @@ let reason = ref(''); let email = ref(''); function getReason() { - return os.api('admin/show-user', { + return misskeyApi('admin/show-user', { userId: props.user.id, }).then(info => { reason.value = info?.signupReason; @@ -87,7 +88,7 @@ async function approveAccount() { text: i18n.ts.approveConfirm, }); if (confirm.canceled) return; - await os.api('admin/approve-user', { userId: props.user.id }); + await misskeyApi('admin/approve-user', { userId: props.user.id }); emits('deleted', props.user.id); } </script> diff --git a/packages/frontend/src/components/SkInstanceTicker.vue b/packages/frontend/src/components/SkInstanceTicker.vue index d69e5fecec..9cfc332698 100644 --- a/packages/frontend/src/components/SkInstanceTicker.vue +++ b/packages/frontend/src/components/SkInstanceTicker.vue @@ -46,11 +46,22 @@ const bg = { align-items: center; height: 1.5ex; border-radius: var(--radius-xl); - margin-top: 5px; padding: 4px; overflow: clip; color: #fff; - text-shadow: -1px -1px 0 #000,1px -1px 0 #000,-1px 1px 0 #000,1px 1px 0 #000; + text-shadow: /* .866 ≈ sin(60deg) */ + 1px 0 1px #000, + .866px .5px 1px #000, + .5px .866px 1px #000, + 0 1px 1px #000, + -.5px .866px 1px #000, + -.866px .5px 1px #000, + -1px 0 1px #000, + -.866px -.5px 1px #000, + -.5px -.866px 1px #000, + 0 -1px 1px #000, + .5px -.866px 1px #000, + .866px -.5px 1px #000; } .icon { @@ -59,7 +70,9 @@ const bg = { } .name { - margin-left: 4px; + padding: 0.5ex; + margin: -0.5ex; + margin-left: calc(4px - 0.5ex); line-height: 1; font-size: 0.8em; font-weight: bold; diff --git a/packages/frontend/src/components/SkNote.vue b/packages/frontend/src/components/SkNote.vue index 83909654c7..09decad1a2 100644 --- a/packages/frontend/src/components/SkNote.vue +++ b/packages/frontend/src/components/SkNote.vue @@ -5,9 +5,9 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div - v-if="!hardMuted && !muted" + v-if="!hardMuted && muted === false" v-show="!isDeleted" - ref="el" + ref="rootEl" v-hotkey="keymap" :class="[$style.root, { [$style.showActionsOnlyHover]: defaultStore.state.showNoteActionsOnlyHover }]" :tabindex="!isDeleted ? '-1' : undefined" @@ -39,7 +39,7 @@ SPDX-License-Identifier: AGPL-3.0-only </span> <span v-if="note.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ph-rocket ph-bold ph-lg"></i></span> <span v-if="note.channel" style="margin-left: 0.5em;" :title="note.channel.name"><i class="ph-television ph-bold ph-lg"></i></span> - <span v-if="note.updatedAt" ref="menuVersionsButton" style="margin-left: 0.5em;" title="Edited" @mousedown="menuVersions()"><i class="ph-pencil ph-bold ph-lg"></i></span> + <span v-if="note.updatedAt" ref="menuVersionsButton" style="margin-left: 0.5em;" title="Edited" @mousedown="menuVersions()"><i class="ph-pencil-simple ph-bold ph-lg"></i></span> </div> </div> <div v-if="renoteCollapsed" :class="$style.collapsedRenoteTarget"> @@ -54,7 +54,7 @@ SPDX-License-Identifier: AGPL-3.0-only <SkNoteHeader :note="appearNote" :mini="true"/> </div> </div> - <div :class="[{ [$style.clickToOpen]: defaultStore.state.clickToOpen }]" @click="defaultStore.state.clickToOpen ? noteclick(appearNote.id) : undefined"> + <div :class="[{ [$style.clickToOpen]: defaultStore.state.clickToOpen }]" @click.stop="defaultStore.state.clickToOpen ? noteclick(appearNote.id) : undefined"> <div style="container-type: inline-size;"> <p v-if="appearNote.cw != null" :class="$style.cw"> <Mfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :author="appearNote.user" :nyaize="'respect'"/> @@ -76,18 +76,18 @@ 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> + <div v-else-if="translation"> + <b>{{ i18n.tsx.translatedFrom({ x: translation.sourceLang }) }}: </b> <Mfm :text="translation.text" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis"/> </div> </div> <MkButton v-if="!allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-play ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.play }}</MkButton> <MkButton v-else-if="!defaultStore.state.animatedMfm && allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-stop ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.stop }}</MkButton> </div> - <div v-if="appearNote.files.length > 0"> + <div v-if="appearNote.files && appearNote.files.length > 0"> <MkMediaList :mediaList="appearNote.files" @click.stop/> </div> - <MkPoll v-if="appearNote.poll" :note="appearNote" :class="$style.poll" @click.stop/> + <MkPoll v-if="appearNote.poll" :noteId="appearNote.id" :poll="appearNote.poll" :class="$style.poll" @click.stop/> <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :class="$style.urlPreview" @click.stop/> <div v-if="appearNote.renote" :class="$style.quote"><SkNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></div> <button v-if="isLong && collapsed" :class="$style.collapsed" class="_button" @click.stop @click="collapsed = false"> @@ -147,7 +147,7 @@ SPDX-License-Identifier: AGPL-3.0-only <button v-if="defaultStore.state.showClipButtonInNoteFooter" ref="clipButton" :class="$style.footerButton" class="_button" @mousedown="clip()"> <i class="ph-paperclip ph-bold ph-lg"></i> </button> - <button ref="menuButton" :class="$style.footerButton" class="_button" @mousedown="menu()"> + <button ref="menuButton" :class="$style.footerButton" class="_button" @mousedown="showMenu()"> <i class="ph-dots-three ph-bold ph-lg"></i> </button> </footer> @@ -155,7 +155,14 @@ SPDX-License-Identifier: AGPL-3.0-only </article> </div> <div v-else-if="!hardMuted" :class="$style.muted" @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"/> @@ -173,7 +180,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, inject, onMounted, ref, shallowRef, Ref, watch, provide } from 'vue'; -import * as mfm from '@sharkey/sfm-js'; +import * as mfm from '@transfem-org/sfm-js'; import * as Misskey from 'misskey-js'; import SkNoteSub from '@/components/SkNoteSub.vue'; import SkNoteHeader from '@/components/SkNoteHeader.vue'; @@ -190,6 +197,7 @@ import { focusPrev, focusNext } from '@/scripts/focus.js'; import { checkWordMute } from '@/scripts/check-word-mute.js'; import { userPage } from '@/filters/user.js'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import * as sound from '@/scripts/sound.js'; import { defaultStore, noteViewInterruptors } from '@/store.js'; import { reactionPicker } from '@/scripts/reaction-picker.js'; @@ -208,7 +216,8 @@ import { MenuItem } from '@/types/menu.js'; import MkRippleEffect from '@/components/MkRippleEffect.vue'; import { showMovedDialog } from '@/scripts/show-moved-dialog.js'; import { shouldCollapsed } from '@/scripts/collapsed.js'; -import { useRouter } from '@/router.js'; +import { useRouter } from '@/router/supplier.js'; +import { boostMenuItems, type Visibility } from '@/scripts/boost-quote.js'; const props = withDefaults(defineProps<{ note: Misskey.entities.Note; @@ -228,6 +237,7 @@ const emit = defineEmits<{ const router = useRouter(); +const inTimeline = inject<boolean>('inTimeline', false); const inChannel = inject('inChannel', null); const currentClip = inject<Ref<Misskey.entities.Clip> | null>('currentClip', null); @@ -246,7 +256,7 @@ if (noteViewInterruptors.length > 0) { let result: Misskey.entities.Note | null = deepClone(note.value); for (const interruptor of noteViewInterruptors) { try { - result = await interruptor.handler(result); + result = await interruptor.handler(result!) as Misskey.entities.Note | null; if (result === null) { isDeleted.value = true; return; @@ -255,7 +265,7 @@ if (noteViewInterruptors.length > 0) { console.error(err); } } - note.value = result; + note.value = result as Misskey.entities.Note; }); } @@ -263,11 +273,11 @@ const isRenote = ( note.value.renote != null && note.value.text == null && note.value.cw == null && - note.value.fileIds.length === 0 && + note.value.fileIds && note.value.fileIds.length === 0 && note.value.poll == null ); -const el = shallowRef<HTMLElement>(); +const rootEl = shallowRef<HTMLElement>(); const menuButton = shallowRef<HTMLElement>(); const menuVersionsButton = shallowRef<HTMLElement>(); const renoteButton = shallowRef<HTMLElement>(); @@ -277,50 +287,61 @@ const quoteButton = shallowRef<HTMLElement>(); const clipButton = shallowRef<HTMLElement>(); const likeButton = shallowRef<HTMLElement>(); const appearNote = computed(() => isRenote ? note.value.renote as Misskey.entities.Note : note.value); -const renoteUrl = appearNote.value.renote ? appearNote.value.renote.url : null; -const renoteUri = appearNote.value.renote ? appearNote.value.renote.uri : null; const isMyRenote = $i && ($i.id === note.value.userId); const showContent = ref(defaultStore.state.uncollapseCW); -const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text).filter(u => u !== renoteUrl && u !== renoteUri) : null); -const urls = computed(() => parsed.value ? extractUrlFromMfm(parsed.value) : null); +const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : null); +const urls = computed(() => parsed.value ? extractUrlFromMfm(parsed.value).filter((url) => appearNote.value.renote?.url !== url && appearNote.value.renote?.uri !== url) : null); const isLong = shouldCollapsed(appearNote.value, urls.value ?? []); -const collapsed = defaultStore.state.expandLongNote && appearNote.value.cw == null ? false : ref(appearNote.value.cw == null && isLong); +const collapsed = ref(defaultStore.state.expandLongNote && appearNote.value.cw == null && isLong ? false : appearNote.value.cw == null && isLong); const isDeleted = ref(false); const renoted = ref(false); const muted = ref(checkMute(appearNote.value, $i?.mutedWords)); -const hardMuted = ref(props.withHardMute && checkMute(appearNote.value, $i?.hardMutedWords)); -const translation = ref<any>(null); +const hardMuted = ref(props.withHardMute && checkMute(appearNote.value, $i?.hardMutedWords, true)); +const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null); const translating = ref(false); const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.value.user.instance); -const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || (appearNote.value.visibility === 'followers' && appearNote.value.userId === $i.id)); -const renoteCollapsed = ref(defaultStore.state.collapseRenotes && isRenote && (($i && ($i.id === note.value.userId || $i.id === appearNote.value.userId)) || (appearNote.value.myReaction != null))); +const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || (appearNote.value.visibility === 'followers' && appearNote.value.userId === $i?.id)); +const renoteCollapsed = ref( + defaultStore.state.collapseRenotes && isRenote && ( + ($i && ($i.id === note.value.userId || $i.id === appearNote.value.userId)) || // `||` must be `||`! See https://github.com/misskey-dev/misskey/issues/13131 + (appearNote.value.myReaction != null) + ) +); const defaultLike = computed(() => defaultStore.state.like ? defaultStore.state.like : null); const animated = computed(() => parsed.value ? checkAnimationFromMfm(parsed.value) : null); const allowAnim = ref(defaultStore.state.advancedMfm && defaultStore.state.animatedMfm ? true : false); -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; } const keymap = { 'r': () => reply(true), 'e|a|plus': () => react(true), - 'q': () => renoteButton.value.renote(true), + 'q': () => renote(appearNote.value.visibility), 'up|k|shift+tab': focusBefore, 'down|j|tab': focusAfter, 'esc': blur, - 'm|o': () => menu(true), + 'm|o': () => showMenu(true), 's': () => showContent.value !== showContent.value, }; provide('react', (reaction: string) => { - os.api('notes/reactions/create', { + misskeyApi('notes/reactions/create', { noteId: appearNote.value.id, reaction: reaction, }); @@ -332,7 +353,7 @@ if (props.mock) { }, { deep: true }); } else { useNoteCapture({ - rootEl: el, + rootEl: rootEl, note: appearNote, pureNote: note, isDeletedRef: isDeleted, @@ -341,7 +362,7 @@ if (props.mock) { if (!props.mock) { useTooltip(renoteButton, async (showing) => { - const renotes = await os.api('notes/renotes', { + const renotes = await misskeyApi('notes/renotes', { noteId: appearNote.value.id, limit: 11, }); @@ -359,7 +380,7 @@ if (!props.mock) { }); useTooltip(quoteButton, async (showing) => { - const renotes = await os.api('notes/renotes', { + const renotes = await misskeyApi('notes/renotes', { noteId: appearNote.value.id, limit: 11, quote: true, @@ -378,7 +399,7 @@ if (!props.mock) { }); if ($i) { - os.api('notes/renotes', { + misskeyApi('notes/renotes', { noteId: appearNote.value.id, userId: $i.id, limit: 1, @@ -388,54 +409,15 @@ if (!props.mock) { } } -type Visibility = 'public' | 'home' | 'followers' | 'specified'; - -// defaultStore.state.visibilityがstringなためstringも受け付けている -function smallerVisibility(a: Visibility | string, b: Visibility | string): Visibility { - if (a === 'specified' || b === 'specified') return 'specified'; - if (a === 'followers' || b === 'followers') return 'followers'; - if (a === 'home' || b === 'home') return 'home'; - // if (a === 'public' || b === 'public') - return 'public'; -} - function boostVisibility() { - os.popupMenu([ - { - type: 'button', - icon: 'ph-globe-hemisphere-west ph-bold ph-lg', - text: i18n.ts._visibility['public'], - action: () => { - renote('public'); - }, - }, - { - type: 'button', - icon: 'ph-house ph-bold ph-lg', - text: i18n.ts._visibility['home'], - action: () => { - renote('home'); - }, - }, - { - type: 'button', - icon: 'ph-lock ph-bold ph-lg', - text: i18n.ts._visibility['followers'], - action: () => { - renote('followers'); - }, - }, - { - type: 'button', - icon: 'ph-planet ph-bold ph-lg', - text: i18n.ts._timelines.local, - action: () => { - renote('local'); - }, - }], renoteButton.value); + if (!defaultStore.state.showVisibilitySelectorOnBoost) { + renote(defaultStore.state.visibilityOnBoost); + } else { + os.popupMenu(boostMenuItems(appearNote, renote), renoteButton.value); + } } -function renote(visibility: Visibility | 'local') { +function renote(visibility: Visibility, localOnly: boolean = false) { pleaseLogin(); showMovedDialog(); @@ -449,7 +431,7 @@ function renote(visibility: Visibility | 'local') { } if (!props.mock) { - os.api('notes/create', { + misskeyApi('notes/create', { renoteId: appearNote.value.id, channelId: appearNote.value.channelId, }).then(() => { @@ -457,7 +439,7 @@ function renote(visibility: Visibility | 'local') { renoted.value = true; }); } - } else if (!appearNote.value.channel || appearNote.value.channel?.allowRenoteToExternal) { + } else if (!appearNote.value.channel || appearNote.value.channel.allowRenoteToExternal) { const el = renoteButton.value as HTMLElement | null | undefined; if (el) { const rect = el.getBoundingClientRect(); @@ -466,18 +448,10 @@ function renote(visibility: Visibility | 'local') { os.popup(MkRippleEffect, { x, y }, {}, 'end'); } - const configuredVisibility = defaultStore.state.rememberNoteVisibility ? defaultStore.state.visibility : defaultStore.state.defaultNoteVisibility; - const localOnlySetting = defaultStore.state.rememberNoteVisibility ? defaultStore.state.localOnly : defaultStore.state.defaultNoteLocalOnly; - - let noteVisibility = visibility === 'local' || visibility === 'specified' ? smallerVisibility(appearNote.value.visibility, configuredVisibility) : smallerVisibility(visibility, configuredVisibility); - if (appearNote.value.channel?.isSensitive) { - noteVisibility = smallerVisibility(visibility === 'local' || visibility === 'specified' ? appearNote.value.visibility : visibility, 'home'); - } - if (!props.mock) { - os.api('notes/create', { - localOnly: visibility === 'local' ? true : localOnlySetting, - visibility: noteVisibility, + misskeyApi('notes/create', { + localOnly: localOnly, + visibility: visibility, renoteId: appearNote.value.id, }).then(() => { os.toast(i18n.ts.renoted); @@ -499,9 +473,9 @@ function quote() { renote: appearNote.value, channel: appearNote.value.channel, }).then(() => { - os.api('notes/renotes', { + misskeyApi('notes/renotes', { noteId: appearNote.value.id, - userId: $i.id, + userId: $i?.id, limit: 1, quote: true, }).then((res) => { @@ -521,9 +495,9 @@ function quote() { os.post({ renote: appearNote.value, }).then(() => { - os.api('notes/renotes', { + misskeyApi('notes/renotes', { noteId: appearNote.value.id, - userId: $i.id, + userId: $i?.id, limit: 1, quote: true, }).then((res) => { @@ -551,7 +525,7 @@ function reply(viaKeyboard = false): void { reply: appearNote.value, channel: appearNote.value.channel, animation: !viaKeyboard, - }, () => { + }).then(() => { focus(); }); } @@ -559,10 +533,11 @@ function reply(viaKeyboard = false): void { function like(): void { pleaseLogin(); showMovedDialog(); + sound.playMisskeySfx('reaction'); if (props.mock) { return; } - os.api('notes/like', { + misskeyApi('notes/like', { noteId: appearNote.value.id, override: defaultLike.value, }); @@ -579,17 +554,17 @@ function react(viaKeyboard = false): void { pleaseLogin(); showMovedDialog(); if (appearNote.value.reactionAcceptance === 'likeOnly') { - sound.play('reaction'); + sound.playMisskeySfx('reaction'); if (props.mock) { return; } - os.api('notes/like', { + misskeyApi('notes/like', { noteId: appearNote.value.id, override: defaultLike.value, }); - const el = reactButton.value as HTMLElement | null | undefined; + const el = reactButton.value; if (el) { const rect = el.getBoundingClientRect(); const x = rect.left + (el.offsetWidth / 2); @@ -598,15 +573,15 @@ function react(viaKeyboard = false): void { } } else { blur(); - reactionPicker.show(reactButton.value, reaction => { - sound.play('reaction'); + reactionPicker.show(reactButton.value ?? null, note.value, reaction => { + sound.playMisskeySfx('reaction'); if (props.mock) { emit('reaction', reaction); return; } - os.api('notes/reactions/create', { + misskeyApi('notes/reactions/create', { noteId: appearNote.value.id, reaction: reaction, }); @@ -619,8 +594,8 @@ function react(viaKeyboard = false): void { } } -function undoReact(note): void { - const oldReaction = note.myReaction; +function undoReact(targetNote: Misskey.entities.Note): void { + const oldReaction = targetNote.myReaction; if (!oldReaction) return; if (props.mock) { @@ -628,8 +603,8 @@ function undoReact(note): void { return; } - os.api('notes/reactions/delete', { - noteId: note.id, + misskeyApi('notes/reactions/delete', { + noteId: targetNote.id, }); } @@ -637,7 +612,7 @@ function undoRenote(note) : void { if (props.mock) { return; } - os.api('notes/unrenote', { + misskeyApi('notes/unrenote', { noteId: note.id, }); os.toast(i18n.ts.rmboost); @@ -657,32 +632,34 @@ function onContextmenu(ev: MouseEvent): void { return; } - const isLink = (el: HTMLElement) => { + const isLink = (el: HTMLElement): boolean => { if (el.tagName === 'A') return true; // 再生速度の選択などのために、Audio要素のコンテキストメニューはブラウザデフォルトとする。 if (el.tagName === 'AUDIO') return true; if (el.parentElement) { return isLink(el.parentElement); } + return false; }; - if (isLink(ev.target)) return; - if (window.getSelection().toString() !== '') return; + + if (ev.target && isLink(ev.target as HTMLElement)) return; + if (window.getSelection()?.toString() !== '') return; if (defaultStore.state.useReactionPickerForContextMenu) { ev.preventDefault(); react(); } else { - const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, menuButton, isDeleted, currentClip: currentClip?.value }); + const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, isDeleted, currentClip: currentClip?.value }); os.contextMenu(menu, ev).then(focus).finally(cleanup); } } -function menu(viaKeyboard = false): void { +function showMenu(viaKeyboard = false): void { if (props.mock) { return; } - const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, menuButton, isDeleted, currentClip: currentClip?.value }); + const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, isDeleted, currentClip: currentClip?.value }); os.popupMenu(menu, menuButton.value, { viaKeyboard, }).then(focus).finally(cleanup); @@ -714,7 +691,7 @@ function showRenoteMenu(viaKeyboard = false): void { icon: 'ph-trash ph-bold ph-lg', danger: true, action: () => { - os.api('notes/delete', { + misskeyApi('notes/delete', { noteId: note.value.id, }); isDeleted.value = true; @@ -736,7 +713,7 @@ function showRenoteMenu(viaKeyboard = false): void { getCopyNoteLinkMenu(note.value, i18n.ts.copyLinkRenote), { type: 'divider' }, getAbuseNoteMenu(note.value, i18n.ts.reportAbuseRenote), - $i.isModerator || $i.isAdmin ? getUnrenote() : undefined, + ($i?.isModerator || $i?.isAdmin) ? getUnrenote() : undefined, ], renoteTime.value, { viaKeyboard: viaKeyboard, }); @@ -756,23 +733,23 @@ function animatedMFM() { } function focus() { - el.value.focus(); + rootEl.value?.focus(); } function blur() { - el.value.blur(); + rootEl.value?.blur(); } function focusBefore() { - focusPrev(el.value); + focusPrev(rootEl.value ?? null); } function focusAfter() { - focusNext(el.value); + focusNext(rootEl.value ?? null); } function readPromo() { - os.api('promo/read', { + misskeyApi('promo/read', { noteId: appearNote.value.id, }); isDeleted.value = true; @@ -819,19 +796,20 @@ function emitUpdReaction(emoji: string, delta: number) { margin: auto; width: calc(100% - 8px); height: calc(100% - 8px); - border: dashed 1px var(--focus); + border: solid 1px var(--focus); border-radius: var(--radius); box-sizing: border-box; } } .footer { + display: flex; + align-items: center; + justify-content: space-between; position: relative; z-index: 1; margin-top: 0.4em; - width: max-content; - min-width: min-content; - max-width: fit-content; + max-width: 400px; } &:hover > .article > .main > .footer > .footerButton { @@ -882,7 +860,6 @@ function emitUpdReaction(emoji: string, delta: number) { } .replyTo { - opacity: 0.7; padding-bottom: 0; } @@ -890,11 +867,28 @@ function emitUpdReaction(emoji: string, delta: number) { position: relative; display: flex; align-items: center; - padding: 24px 38px 16px; + padding: 24px 32px 0 calc(32px + var(--avatar) + 14px); line-height: 28px; white-space: pre; color: var(--renote); + &::before { + content: ''; + position: absolute; + top: 0; + left: calc(32px + .5 * var(--avatar)); + bottom: -8px; + border-left: var(--thread-width) solid var(--thread); + } + + &:first-child { + padding-left: 32px; + + &::before { + display: none; + } + } + & + .article { padding-top: 8px; } @@ -906,7 +900,7 @@ function emitUpdReaction(emoji: string, delta: number) { .renoteAvatar { flex-shrink: 0; - display: inline-block; + display: none; /* same as Firefish, but keeping the element around in case someone wants to add it back via CSS override */ width: 28px; height: 28px; margin: 0 8px 0 0; @@ -987,8 +981,8 @@ function emitUpdReaction(emoji: string, delta: number) { display: block !important; position: sticky !important; margin: 0 14px 0 0; - width: 58px; - height: 58px; + width: var(--avatar); + height: var(--avatar); position: sticky !important; top: calc(22px + var(--stickyTop, 0px)); left: 0; @@ -1130,24 +1124,24 @@ function emitUpdReaction(emoji: string, delta: number) { @container (max-width: 580px) { .root { font-size: 0.95em; + --avatar: 46px; } .renote { - padding: 24px 28px 16px; + padding: 24px 26px 0 calc(26px + var(--avatar) + 14px); + + &::before { + left: calc(26px + .5 * var(--avatar)); + } } .collapsedRenoteTarget { - padding: 8px 28px 24px; + padding: 8px 26px 24px; } .article { padding: 24px 26px; } - - .avatar { - width: 50px; - height: 50px; - } } @container (max-width: 500px) { @@ -1164,9 +1158,23 @@ function emitUpdReaction(emoji: string, delta: number) { } } +@container (max-width: 500px) { + .renote { + padding: 23px 25px 0 calc(25px + var(--avatar) + 14px); + + &::before { + left: calc(25px + .5 * var(--avatar)); + } + } +} + @container (max-width: 480px) { .renote { - padding: 20px 24px 8px; + padding: 22px 24px 0 calc(24px + var(--avatar) + 14px); + + &::before { + left: calc(24px + .5 * var(--avatar)); + } } .tip { @@ -1184,10 +1192,12 @@ function emitUpdReaction(emoji: string, delta: number) { } @container (max-width: 450px) { + .root { + --avatar: 44px; + } + .avatar { margin: 0 10px 0 0; - width: 46px; - height: 46px; top: calc(14px + var(--stickyTop, 0px)); } } @@ -1220,11 +1230,6 @@ function emitUpdReaction(emoji: string, delta: number) { } @container (max-width: 300px) { - .avatar { - width: 44px; - height: 44px; - } - .root:not(.showActionsOnlyHover) { .footerButton { &:not(:last-child) { @@ -1256,5 +1261,6 @@ function emitUpdReaction(emoji: string, delta: number) { .clickToOpen { cursor: pointer; + -webkit-tap-highlight-color: transparent; } </style> diff --git a/packages/frontend/src/components/SkNoteDetailed.vue b/packages/frontend/src/components/SkNoteDetailed.vue index f850adba1b..ced7e7a176 100644 --- a/packages/frontend/src/components/SkNoteDetailed.vue +++ b/packages/frontend/src/components/SkNoteDetailed.vue @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-if="!muted" v-show="!isDeleted" - ref="el" + ref="rootEl" v-hotkey="keymap" :class="$style.root" > @@ -40,10 +40,10 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> <template v-if="appearNote.reply && appearNote.reply.replyId"> - <SkNoteSub v-for="note in conversation" :key="note.id" :class="$style.replyToMore" :note="note" :expandAllCws="props.expandAllCws"/> + <SkNoteSub v-for="note in conversation" :key="note.id" :note="note" :expandAllCws="props.expandAllCws" detailed/> </template> - <SkNoteSub v-if="appearNote.reply" :note="appearNote.reply" :class="$style.replyTo" :expandAllCws="props.expandAllCws"/> - <article :class="$style.note" @contextmenu.stop="onContextmenu"> + <SkNoteSub v-if="appearNote.reply" :note="appearNote.reply" :class="$style.replyTo" :expandAllCws="props.expandAllCws" detailed/> + <article :id="appearNote.id" ref="noteEl" :class="$style.note" tabindex="-1" @contextmenu.stop="onContextmenu"> <header :class="$style.noteHeader"> <MkAvatar :class="$style.noteHeaderAvatar" :user="appearNote.user" indicator link preview/> <div style="display: flex; align-items: center; white-space: nowrap; overflow: hidden;"> @@ -68,7 +68,7 @@ SPDX-License-Identifier: AGPL-3.0-only <i v-else-if="appearNote.visibility === 'followers'" class="ph-lock ph-bold ph-lg"></i> <i v-else-if="appearNote.visibility === 'specified'" ref="specified" class="ph-envelope ph-bold ph-lg"></i> </span> - <span v-if="appearNote.updatedAt" ref="menuVersionsButton" style="margin-left: 0.5em;" title="Edited" @mousedown="menuVersions()"><i class="ph-pencil ph-bold ph-lg"></i></span> + <span v-if="appearNote.updatedAt" ref="menuVersionsButton" style="margin-left: 0.5em;" title="Edited" @mousedown="menuVersions()"><i class="ph-pencil-simple ph-bold ph-lg"></i></span> <span v-if="appearNote.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ph-rocket ph-bold ph-lg"></i></span> </div> <SkInstanceTicker v-if="showTicker" :instance="appearNote.user.instance"/> @@ -96,32 +96,32 @@ SPDX-License-Identifier: AGPL-3.0-only <a v-if="appearNote.renote != null" :class="$style.rn">RN:</a> <div v-if="translating || translation" :class="$style.translation"> <MkLoading v-if="translating" mini/> - <div v-else> - <b>{{ i18n.t('translatedFrom', { x: translation.sourceLang }) }}: </b> + <div v-else-if="translation"> + <b>{{ i18n.tsx.translatedFrom({ x: translation.sourceLang }) }}: </b> <Mfm :text="translation.text" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis"/> </div> </div> <MkButton v-if="!allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-play ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.play }}</MkButton> <MkButton v-else-if="!defaultStore.state.animatedMfm && allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-stop ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.stop }}</MkButton> - <div v-if="appearNote.files.length > 0"> + <div v-if="appearNote.files && appearNote.files.length > 0"> <MkMediaList :mediaList="appearNote.files"/> </div> - <MkPoll v-if="appearNote.poll" ref="pollViewer" :note="appearNote" :class="$style.poll"/> + <MkPoll v-if="appearNote.poll" ref="pollViewer" :noteId="appearNote.id" :poll="appearNote.poll" :class="$style.poll"/> <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" style="margin-top: 6px;"/> <div v-if="appearNote.renote" :class="$style.quote"><SkNoteSimple :note="appearNote.renote" :class="$style.quoteNote" :expandAllCws="props.expandAllCws"/></div> </div> <MkA v-if="appearNote.channel && !inChannel" :class="$style.channel" :to="`/channels/${appearNote.channel.id}`"><i class="ph-television ph-bold ph-lg"></i> {{ appearNote.channel.name }}</MkA> </div> - <footer :class="$style.footer"> - <div :class="$style.noteFooterInfo"> - <div v-if="appearNote.updatedAt"> - {{ i18n.ts.edited }}: <MkTime :time="appearNote.updatedAt" mode="detail"/> - </div> - <MkA :to="notePage(appearNote)"> - <MkTime :time="appearNote.createdAt" mode="detail" colored/> - </MkA> + <div :class="$style.noteFooterInfo"> + <div v-if="appearNote.updatedAt"> + {{ i18n.ts.edited }}: <MkTime :time="appearNote.updatedAt" mode="detail"/> </div> - <MkReactionsViewer ref="reactionsViewer" :note="appearNote"/> + <MkA :to="notePage(appearNote)"> + <MkTime :time="appearNote.createdAt" mode="detail" colored/> + </MkA> + </div> + <MkReactionsViewer ref="reactionsViewer" :note="appearNote"/> + <footer :class="$style.footer"> <button class="_button" :class="$style.noteFooterButton" @click="reply()"> <i class="ph-arrow-u-up-left ph-bold ph-lg"></i> <p v-if="appearNote.repliesCount > 0" :class="$style.noteFooterButtonCount">{{ appearNote.repliesCount }}</p> @@ -162,7 +162,7 @@ SPDX-License-Identifier: AGPL-3.0-only <button v-if="defaultStore.state.showClipButtonInNoteFooter" ref="clipButton" class="_button" :class="$style.noteFooterButton" @mousedown="clip()"> <i class="ph-paperclip ph-bold ph-lg"></i> </button> - <button ref="menuButton" class="_button" :class="$style.noteFooterButton" @mousedown="menu()"> + <button ref="menuButton" class="_button" :class="$style.noteFooterButton" @mousedown="showMenu()"> <i class="ph-dots-three ph-bold ph-lg"></i> </button> </footer> @@ -174,11 +174,11 @@ SPDX-License-Identifier: AGPL-3.0-only <button class="_button" :class="[$style.tab, { [$style.tabActive]: tab === 'reactions' }]" @click="tab = 'reactions'"><i class="ph-smiley ph-bold ph-lg"></i> {{ i18n.ts.reactions }}</button> </div> <div> - <div v-if="tab === 'replies'" :class="$style.tab_replies"> + <div v-if="tab === 'replies'"> <div v-if="!repliesLoaded" style="padding: 16px"> <MkButton style="margin: 0 auto;" primary rounded @click="loadReplies">{{ i18n.ts.loadReplies }}</MkButton> </div> - <SkNoteSub v-for="note in replies" :key="note.id" :note="note" :class="$style.reply" :detail="true" :expandAllCws="props.expandAllCws" :onDeleteCallback="removeReply" /> + <SkNoteSub v-for="note in replies" :key="note.id" :note="note" :class="$style.reply" :detail="true" :expandAllCws="props.expandAllCws" :onDeleteCallback="removeReply" :isReply="true"/> </div> <div v-else-if="tab === 'renotes'" :class="$style.tab_renotes"> <MkPagination :pagination="renotesPagination" :disableAutoLoad="true"> @@ -191,11 +191,11 @@ SPDX-License-Identifier: AGPL-3.0-only </template> </MkPagination> </div> - <div v-if="tab === 'quotes'" :class="$style.tab_replies"> + <div v-if="tab === 'quotes'"> <div v-if="!quotesLoaded" style="padding: 16px"> <MkButton style="margin: 0 auto;" primary rounded @click="loadQuotes">{{ i18n.ts.loadReplies }}</MkButton> </div> - <SkNoteSub v-for="note in quotes" :key="note.id" :note="note" :class="$style.reply" :detail="true" :expandAllCws="props.expandAllCws"/> + <SkNoteSub v-for="note in quotes" :key="note.id" :note="note" :class="$style.reply" :detail="true" :expandAllCws="props.expandAllCws" :reply="true"/> </div> <div v-else-if="tab === 'reactions'" :class="$style.tab_reactions"> <div :class="$style.reactionTabs"> @@ -228,8 +228,8 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, inject, onMounted, provide, ref, shallowRef, watch } from 'vue'; -import * as mfm from '@sharkey/sfm-js'; +import { computed, inject, onMounted, onUnmounted, onUpdated, provide, ref, shallowRef, watch } from 'vue'; +import * as mfm from '@transfem-org/sfm-js'; import * as Misskey from 'misskey-js'; import SkNoteSub from '@/components/SkNoteSub.vue'; import SkNoteSimple from '@/components/SkNoteSimple.vue'; @@ -245,6 +245,7 @@ import { checkWordMute } from '@/scripts/check-word-mute.js'; import { userPage } from '@/filters/user.js'; import { notePage } from '@/filters/note.js'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import * as sound from '@/scripts/sound.js'; import { defaultStore, noteViewInterruptors } from '@/store.js'; import { reactionPicker } from '@/scripts/reaction-picker.js'; @@ -261,9 +262,10 @@ import { checkAnimationFromMfm } from '@/scripts/check-animated-mfm.js'; import MkRippleEffect from '@/components/MkRippleEffect.vue'; import { showMovedDialog } from '@/scripts/show-moved-dialog.js'; import MkUserCardMini from '@/components/MkUserCardMini.vue'; -import MkPagination from '@/components/MkPagination.vue'; +import MkPagination, { type Paging } from '@/components/MkPagination.vue'; import MkReactionIcon from '@/components/MkReactionIcon.vue'; import MkButton from '@/components/MkButton.vue'; +import { boostMenuItems, type Visibility } from '@/scripts/boost-quote.js'; const props = defineProps<{ note: Misskey.entities.Note; @@ -280,7 +282,7 @@ if (noteViewInterruptors.length > 0) { let result: Misskey.entities.Note | null = deepClone(note.value); for (const interruptor of noteViewInterruptors) { try { - result = await interruptor.handler(result); + result = await interruptor.handler(result!) as Misskey.entities.Note | null; if (result === null) { isDeleted.value = true; return; @@ -289,18 +291,19 @@ if (noteViewInterruptors.length > 0) { console.error(err); } } - note.value = result; + note.value = result as Misskey.entities.Note; }); } const isRenote = ( note.value.renote != null && note.value.text == null && - note.value.fileIds.length === 0 && + note.value.fileIds && note.value.fileIds.length === 0 && note.value.poll == null ); -const el = shallowRef<HTMLElement>(); +const rootEl = shallowRef<HTMLElement>(); +const noteEl = shallowRef<HTMLElement>(); const menuButton = shallowRef<HTMLElement>(); const menuVersionsButton = shallowRef<HTMLElement>(); const renoteButton = shallowRef<HTMLElement>(); @@ -310,24 +313,22 @@ const quoteButton = shallowRef<HTMLElement>(); const clipButton = shallowRef<HTMLElement>(); const likeButton = shallowRef<HTMLElement>(); const appearNote = computed(() => isRenote ? note.value.renote as Misskey.entities.Note : note.value); -const renoteUrl = appearNote.value.renote ? appearNote.value.renote.url : null; -const renoteUri = appearNote.value.renote ? appearNote.value.renote.uri : null; const isMyRenote = $i && ($i.id === note.value.userId); const showContent = ref(defaultStore.state.uncollapseCW); const isDeleted = ref(false); const renoted = ref(false); const muted = ref($i ? checkWordMute(appearNote.value, $i, $i.mutedWords) : false); -const translation = ref(null); +const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null); const translating = ref(false); const parsed = appearNote.value.text ? mfm.parse(appearNote.value.text) : null; -const urls = parsed ? extractUrlFromMfm(parsed).filter(u => u !== renoteUrl && u !== renoteUri) : null; +const urls = parsed ? extractUrlFromMfm(parsed).filter((url) => appearNote.value.renote?.url !== url && appearNote.value.renote?.uri !== url) : null; const animated = computed(() => parsed ? checkAnimationFromMfm(parsed) : null); const allowAnim = ref(defaultStore.state.advancedMfm && defaultStore.state.animatedMfm ? true : false); const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.value.user.instance); const conversation = ref<Misskey.entities.Note[]>([]); const replies = ref<Misskey.entities.Note[]>([]); const quotes = ref<Misskey.entities.Note[]>([]); -const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || appearNote.value.userId === $i.id); +const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || (appearNote.value.visibility === 'followers' && appearNote.value.userId === $i?.id)); const defaultLike = computed(() => defaultStore.state.like ? defaultStore.state.like : null); watch(() => props.expandAllCws, (expandAllCws) => { @@ -335,7 +336,7 @@ watch(() => props.expandAllCws, (expandAllCws) => { }); if ($i) { - os.api('notes/renotes', { + misskeyApi('notes/renotes', { noteId: appearNote.value.id, userId: $i.id, limit: 1, @@ -347,23 +348,23 @@ if ($i) { const keymap = { 'r': () => reply(true), 'e|a|plus': () => react(true), - 'q': () => renoteButton.value.renote(true), + 'q': () => renote(appearNote.value.visibility), 'esc': blur, - 'm|o': () => menu(true), + 'm|o': () => showMenu(true), 's': () => showContent.value !== showContent.value, }; provide('react', (reaction: string) => { - os.api('notes/reactions/create', { + misskeyApi('notes/reactions/create', { noteId: appearNote.value.id, reaction: reaction, }); }); const tab = ref('replies'); -const reactionTabType = ref(null); +const reactionTabType = ref<string | null>(null); -const renotesPagination = computed(() => ({ +const renotesPagination = computed<Paging>(() => ({ endpoint: 'notes/renotes', limit: 10, params: { @@ -371,7 +372,7 @@ const renotesPagination = computed(() => ({ }, })); -const reactionsPagination = computed(() => ({ +const reactionsPagination = computed<Paging>(() => ({ endpoint: 'notes/reactions', limit: 10, params: { @@ -381,20 +382,20 @@ const reactionsPagination = computed(() => ({ })); async function addReplyTo(replyNote: Misskey.entities.Note) { - replies.value.unshift(replyNote); - appearNote.value.repliesCount += 1; + replies.value.unshift(replyNote); + appearNote.value.repliesCount += 1; } async function removeReply(id: Misskey.entities.Note['id']) { - const replyIdx = replies.value.findIndex(note => note.id === id); - if (replyIdx >= 0) { - replies.value.splice(replyIdx, 1); - appearNote.value.repliesCount -= 1; - } + const replyIdx = replies.value.findIndex(note => note.id === id); + if (replyIdx >= 0) { + replies.value.splice(replyIdx, 1); + appearNote.value.repliesCount -= 1; + } } useNoteCapture({ - rootEl: el, + rootEl: rootEl, note: appearNote, pureNote: note, isDeletedRef: isDeleted, @@ -402,7 +403,7 @@ useNoteCapture({ }); useTooltip(renoteButton, async (showing) => { - const renotes = await os.api('notes/renotes', { + const renotes = await misskeyApi('notes/renotes', { noteId: appearNote.value.id, limit: 11, }); @@ -420,7 +421,7 @@ useTooltip(renoteButton, async (showing) => { }); useTooltip(quoteButton, async (showing) => { - const renotes = await os.api('notes/renotes', { + const renotes = await misskeyApi('notes/renotes', { noteId: appearNote.value.id, limit: 11, quote: true, @@ -438,53 +439,15 @@ useTooltip(quoteButton, async (showing) => { }, {}, 'closed'); }); -type Visibility = 'public' | 'home' | 'followers' | 'specified'; - -function smallerVisibility(a: Visibility | string, b: Visibility | string): Visibility { - if (a === 'specified' || b === 'specified') return 'specified'; - if (a === 'followers' || b === 'followers') return 'followers'; - if (a === 'home' || b === 'home') return 'home'; - // if (a === 'public' || b === 'public') - return 'public'; -} - function boostVisibility() { - os.popupMenu([ - { - type: 'button', - icon: 'ph-globe-hemisphere-west ph-bold ph-lg', - text: i18n.ts._visibility['public'], - action: () => { - renote('public'); - }, - }, - { - type: 'button', - icon: 'ph-house ph-bold ph-lg', - text: i18n.ts._visibility['home'], - action: () => { - renote('home'); - }, - }, - { - type: 'button', - icon: 'ph-lock ph-bold ph-lg', - text: i18n.ts._visibility['followers'], - action: () => { - renote('followers'); - }, - }, - { - type: 'button', - icon: 'ph-planet ph-bold ph-lg', - text: i18n.ts._timelines.local, - action: () => { - renote('local'); - }, - }], renoteButton.value); + if (!defaultStore.state.showVisibilitySelectorOnBoost) { + renote(defaultStore.state.visibilityOnBoost); + } else { + os.popupMenu(boostMenuItems(appearNote, renote), renoteButton.value); + } } -function renote(visibility: Visibility | 'local') { +function renote(visibility: Visibility, localOnly: boolean = false) { pleaseLogin(); showMovedDialog(); @@ -497,14 +460,14 @@ function renote(visibility: Visibility | 'local') { os.popup(MkRippleEffect, { x, y }, {}, 'end'); } - os.api('notes/create', { + misskeyApi('notes/create', { renoteId: appearNote.value.id, channelId: appearNote.value.channelId, }).then(() => { os.toast(i18n.ts.renoted); renoted.value = true; }); - } else if (!appearNote.value.channel || appearNote.value.channel?.allowRenoteToExternal) { + } else if (!appearNote.value.channel || appearNote.value.channel.allowRenoteToExternal) { const el = renoteButton.value as HTMLElement | null | undefined; if (el) { const rect = el.getBoundingClientRect(); @@ -513,17 +476,9 @@ function renote(visibility: Visibility | 'local') { os.popup(MkRippleEffect, { x, y }, {}, 'end'); } - const configuredVisibility = defaultStore.state.rememberNoteVisibility ? defaultStore.state.visibility : defaultStore.state.defaultNoteVisibility; - const localOnlySetting = defaultStore.state.rememberNoteVisibility ? defaultStore.state.localOnly : defaultStore.state.defaultNoteLocalOnly; - - let noteVisibility = visibility === 'local' || visibility === 'specified' ? smallerVisibility(appearNote.value.visibility, configuredVisibility) : smallerVisibility(visibility, configuredVisibility); - if (appearNote.value.channel?.isSensitive) { - noteVisibility = smallerVisibility(visibility === 'local' || visibility === 'specified' ? appearNote.value.visibility : visibility, 'home'); - } - - os.api('notes/create', { - localOnly: visibility === 'local' ? true : localOnlySetting, - visibility: noteVisibility, + misskeyApi('notes/create', { + localOnly: localOnly, + visibility: visibility, renoteId: appearNote.value.id, }).then(() => { os.toast(i18n.ts.renoted); @@ -541,9 +496,9 @@ function quote() { renote: appearNote.value, channel: appearNote.value.channel, }).then(() => { - os.api('notes/renotes', { + misskeyApi('notes/renotes', { noteId: appearNote.value.id, - userId: $i.id, + userId: $i?.id, limit: 1, quote: true, }).then((res) => { @@ -563,9 +518,9 @@ function quote() { os.post({ renote: appearNote.value, }).then(() => { - os.api('notes/renotes', { + misskeyApi('notes/renotes', { noteId: appearNote.value.id, - userId: $i.id, + userId: $i?.id, limit: 1, quote: true, }).then((res) => { @@ -591,7 +546,7 @@ function reply(viaKeyboard = false): void { reply: appearNote.value, channel: appearNote.value.channel, animation: !viaKeyboard, - }, () => { + }).then(() => { focus(); }); } @@ -600,7 +555,9 @@ function react(viaKeyboard = false): void { pleaseLogin(); showMovedDialog(); if (appearNote.value.reactionAcceptance === 'likeOnly') { - os.api('notes/like', { + sound.playMisskeySfx('reaction'); + + misskeyApi('notes/like', { noteId: appearNote.value.id, override: defaultLike.value, }); @@ -613,10 +570,10 @@ function react(viaKeyboard = false): void { } } else { blur(); - reactionPicker.show(reactButton.value, reaction => { - sound.play('reaction'); + reactionPicker.show(reactButton.value ?? null, note.value, reaction => { + sound.playMisskeySfx('reaction'); - os.api('notes/reactions/create', { + misskeyApi('notes/reactions/create', { noteId: appearNote.value.id, reaction: reaction, }); @@ -632,7 +589,8 @@ function react(viaKeyboard = false): void { function like(): void { pleaseLogin(); showMovedDialog(); - os.api('notes/like', { + sound.playMisskeySfx('reaction'); + misskeyApi('notes/like', { noteId: appearNote.value.id, override: defaultLike.value, }); @@ -648,14 +606,14 @@ function like(): void { function undoReact(note): void { const oldReaction = note.myReaction; if (!oldReaction) return; - os.api('notes/reactions/delete', { + misskeyApi('notes/reactions/delete', { noteId: note.id, }); } function undoRenote() : void { if (!renoted.value) return; - os.api('notes/unrenote', { + misskeyApi('notes/unrenote', { noteId: appearNote.value.id, }); os.toast(i18n.ts.rmboost); @@ -671,26 +629,28 @@ function undoRenote() : void { } function onContextmenu(ev: MouseEvent): void { - const isLink = (el: HTMLElement) => { + const isLink = (el: HTMLElement): boolean => { if (el.tagName === 'A') return true; if (el.parentElement) { return isLink(el.parentElement); } + return false; }; - if (isLink(ev.target)) return; - if (window.getSelection().toString() !== '') return; + + if (ev.target && isLink(ev.target as HTMLElement)) return; + if (window.getSelection()?.toString() !== '') return; if (defaultStore.state.useReactionPickerForContextMenu) { ev.preventDefault(); react(); } else { - const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, menuButton, isDeleted }); + const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, isDeleted }); os.contextMenu(menu, ev).then(focus).finally(cleanup); } } -function menu(viaKeyboard = false): void { - const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, menuButton, isDeleted }); +function showMenu(viaKeyboard = false): void { + const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, isDeleted }); os.popupMenu(menu, menuButton.value, { viaKeyboard, }).then(focus).finally(cleanup); @@ -715,7 +675,7 @@ function showRenoteMenu(viaKeyboard = false): void { icon: 'ph-trash ph-bold ph-lg', danger: true, action: () => { - os.api('notes/delete', { + misskeyApi('notes/delete', { noteId: note.value.id, }); isDeleted.value = true; @@ -726,18 +686,18 @@ function showRenoteMenu(viaKeyboard = false): void { } function focus() { - el.value.focus(); + noteEl.value?.focus(); } function blur() { - el.value.blur(); + noteEl.value?.blur(); } const repliesLoaded = ref(false); function loadReplies() { repliesLoaded.value = true; - os.api('notes/children', { + misskeyApi('notes/children', { noteId: appearNote.value.id, limit: 30, showQuotes: false, @@ -752,7 +712,7 @@ const quotesLoaded = ref(false); function loadQuotes() { quotesLoaded.value = true; - os.api('notes/renotes', { + misskeyApi('notes/renotes', { noteId: appearNote.value.id, limit: 30, quote: true, @@ -767,10 +727,12 @@ const conversationLoaded = ref(false); function loadConversation() { conversationLoaded.value = true; - os.api('notes/conversation', { + if (appearNote.value.replyId == null) return; + misskeyApi('notes/conversation', { noteId: appearNote.value.replyId, }).then(res => { conversation.value = res.reverse(); + focus(); }); } @@ -787,6 +749,31 @@ function animatedMFM() { }).then((res) => { if (!res.canceled) allowAnim.value = true; }); } } + +let isScrolling = false; + +function setScrolling() { + isScrolling = true; +} + +onMounted(() => { + document.addEventListener('wheel', setScrolling); + isScrolling = false; + noteEl.value?.scrollIntoView({ block: 'center' }); +}); + +onUpdated(() => { + if (!isScrolling) { + noteEl.value?.scrollIntoView({ block: 'center' }); + if (location.hash) { + location.replace(location.hash); // Jump to highlighted reply + } + } +}); + +onUnmounted(() => { + document.removeEventListener('wheel', setScrolling); +}); </script> <style lang="scss" module> @@ -798,23 +785,19 @@ function animatedMFM() { } .footer { + display: flex; + align-items: center; + justify-content: space-between; position: relative; z-index: 1; margin-top: 0.4em; - width: max-content; - min-width: min-content; - max-width: fit-content; + max-width: 400px; } .replyTo { - opacity: 0.7; padding-bottom: 0; } -.replyToMore { - opacity: 0.7; -} - .renote { display: flex; align-items: center; @@ -859,6 +842,7 @@ function animatedMFM() { } .note { + position: relative; padding: 32px; font-size: 1.2em; overflow: hidden; @@ -866,6 +850,28 @@ function animatedMFM() { &:hover > .main > .footer > .button { opacity: 1; } + + &:focus-visible { + outline: none; + + &:after { + content: ""; + pointer-events: none; + display: block; + position: absolute; + z-index: 10; + top: 0; + left: 0; + right: 0; + bottom: 0; + margin: auto; + width: calc(100% - 8px); + height: calc(100% - 8px); + border: solid 1px var(--focus); + border-radius: var(--radius); + box-sizing: border-box; + } + } } .noteHeader { @@ -879,8 +885,8 @@ function animatedMFM() { .noteHeaderAvatar { display: block; flex-shrink: 0; - width: 58px; - height: 58px; + width: var(--avatar); + height: var(--avatar); } .noteHeaderBody { @@ -1021,10 +1027,17 @@ function animatedMFM() { } .tab { + display: flex; + align-items: center; + justify-content: center; flex: 1; padding: 12px 8px; border-top: solid 2px transparent; border-bottom: solid 2px transparent; + + > i { + margin-right: 8px; + } } .tabActive { diff --git a/packages/frontend/src/components/SkNoteHeader.vue b/packages/frontend/src/components/SkNoteHeader.vue index d3ecdf17bb..7dc4c8f019 100644 --- a/packages/frontend/src/components/SkNoteHeader.vue +++ b/packages/frontend/src/components/SkNoteHeader.vue @@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only </MkA> <div v-if="note.user.isBot" :class="$style.isBot">bot</div> <div v-if="note.user.badgeRoles" :class="$style.badgeRoles"> - <img v-for="role in note.user.badgeRoles" :key="role.id" v-tooltip="role.name" :class="$style.badgeRole" :src="role.iconUrl"/> + <img v-for="(role, i) in note.user.badgeRoles" :key="i" v-tooltip="role.name" :class="$style.badgeRole" :src="role.iconUrl!"/> </div> </div> <div :class="$style.username"><MkAcct :user="note.user"/></div> @@ -33,7 +33,7 @@ SPDX-License-Identifier: AGPL-3.0-only <i v-else-if="note.visibility === 'followers'" class="ph-lock ph-bold ph-lg"></i> <i v-else-if="note.visibility === 'specified'" ref="specified" class="ph-envelope ph-bold ph-lg"></i> </span> - <span v-if="note.updatedAt" ref="menuVersionsButton" style="margin-left: 0.5em; cursor: pointer;" title="Edited" @mousedown="menuVersions()"><i class="ph-pencil ph-bold ph-lg"></i></span> + <span v-if="note.updatedAt" ref="menuVersionsButton" style="margin-left: 0.5em; cursor: pointer;" title="Edited" @mousedown="menuVersions()"><i class="ph-pencil-simple ph-bold ph-lg"></i></span> <span v-if="note.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ph-rocket ph-bold ph-lg"></i></span> <span v-if="note.channel" style="margin-left: 0.5em;" :title="note.channel.name"><i class="ph-television ph-bold ph-lg"></i></span> </div> @@ -65,7 +65,7 @@ SPDX-License-Identifier: AGPL-3.0-only <i v-else-if="note.visibility === 'followers'" class="ph-lock ph-bold ph-lg"></i> <i v-else-if="note.visibility === 'specified'" ref="specified" class="ph-envelope ph-bold ph-lg"></i> </span> - <span v-if="note.updatedAt" ref="menuVersionsButton" style="margin-left: 0.5em; cursor: pointer;" title="Edited" @mousedown="menuVersions()"><i class="ph-pencil ph-bold ph-lg"></i></span> + <span v-if="note.updatedAt" ref="menuVersionsButton" style="margin-left: 0.5em; cursor: pointer;" title="Edited" @mousedown="menuVersions()"><i class="ph-pencil-simple ph-bold ph-lg"></i></span> <span v-if="note.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ph-rocket ph-bold ph-lg"></i></span> <span v-if="note.channel" style="margin-left: 0.5em;" :title="note.channel.name"><i class="ph-television ph-bold ph-lg"></i></span> </div> @@ -82,7 +82,7 @@ import { getNoteVersionsMenu } from '@/scripts/get-note-versions-menu.js'; import SkInstanceTicker from '@/components/SkInstanceTicker.vue'; import { popupMenu } from '@/os.js'; import { defaultStore } from '@/store.js'; -import { useRouter } from '@/router.js'; +import { useRouter } from '@/router/supplier.js'; import { deviceKind } from '@/scripts/device-kind.js'; const props = defineProps<{ @@ -116,6 +116,8 @@ const mock = inject<boolean>('mock', false); .root { display: flex; cursor: auto; /* not clickToOpen-able */ + min-height: 100%; + align-items: center; } .classicRoot { @@ -135,6 +137,7 @@ const mock = inject<boolean>('mock', false); display: flex; align-items: flex-end; margin-left: auto; + margin-bottom: auto; padding-left: 10px; overflow: clip; } @@ -143,10 +146,9 @@ const mock = inject<boolean>('mock', false); .name { flex-shrink: 1; display: block; - // note, these margin top values were done by hand may need futher checking if it actualy aligns pixel perfect - margin: 3px .5em 0 0; + margin: 0 .5em 0 0; padding: 0; - overflow: scroll; + overflow: hidden; overflow-wrap: anywhere; font-size: 1em; font-weight: bold; @@ -192,8 +194,7 @@ const mock = inject<boolean>('mock', false); .username { flex-shrink: 9999999; - // note these top margins were made to align with the instance ticker - margin: 4px .5em 0 0; + margin: 0 .5em 0 0; overflow: hidden; text-overflow: ellipsis; font-size: .95em; diff --git a/packages/frontend/src/components/SkNoteSimple.vue b/packages/frontend/src/components/SkNoteSimple.vue index fe12baedeb..533aa60961 100644 --- a/packages/frontend/src/components/SkNoteSimple.vue +++ b/packages/frontend/src/components/SkNoteSimple.vue @@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkCwButton v-model="showContent" :text="note.text" :files="note.files" :poll="note.poll" @click.stop/> </p> <div v-show="note.cw == null || showContent"> - <MkSubNoteContent :hideFiles="hideFiles" :class="$style.text" :note="note"/> + <MkSubNoteContent :hideFiles="hideFiles" :class="$style.text" :note="note" :expandAllCws="props.expandAllCws"/> </div> </div> </div> @@ -48,6 +48,11 @@ watch(() => props.expandAllCws, (expandAllCws) => { margin: 0; padding: 0; font-size: 0.95em; + + &:hover, &:focus-within { + background: var(--panelHighlight); + transition: background .2s; + } } .avatar { diff --git a/packages/frontend/src/components/SkNoteSub.vue b/packages/frontend/src/components/SkNoteSub.vue index bc482294b4..1cffd8dd66 100644 --- a/packages/frontend/src/components/SkNoteSub.vue +++ b/packages/frontend/src/components/SkNoteSub.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div v-show="!isDeleted" v-if="!muted" ref="el" :class="[$style.root, { [$style.children]: depth > 1 }]"> +<div v-show="!isDeleted" v-if="!muted" ref="el" :class="[$style.root, { [$style.children]: depth > 1, [$style.isReply]: props.isReply, [$style.detailed]: props.detailed }]"> <div v-if="!hideLine" :class="$style.line"></div> <div :class="$style.main"> <div v-if="note.channel" :class="$style.colorBar" :style="{ background: note.channel.color }"></div> @@ -24,11 +24,11 @@ SPDX-License-Identifier: AGPL-3.0-only <MkCwButton v-model="showContent" :text="note.text" :files="note.files" :poll="note.poll"/> </p> <div v-show="note.cw == null || showContent"> - <MkSubNoteContent :class="$style.text" :note="note" :translating="translating" :translation="translation"/> + <MkSubNoteContent :class="$style.text" :note="note" :translating="translating" :translation="translation" :expandAllCws="props.expandAllCws"/> </div> </div> + <MkReactionsViewer ref="reactionsViewer" :note="note"/> <footer :class="$style.footer"> - <MkReactionsViewer ref="reactionsViewer" :note="note"/> <button class="_button" :class="$style.noteFooterButton" @click="reply()"> <i class="ph-arrow-u-up-left ph-bold ph-lg"></i> <p v-if="note.repliesCount > 0" :class="$style.noteFooterButtonCount">{{ note.repliesCount }}</p> @@ -73,7 +73,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> <template v-if="depth < numberOfReplies"> - <SkNoteSub v-for="reply in replies" :key="reply.id" :note="reply" :class="[$style.reply, { [$style.single]: replies.length === 1 }]" :detail="true" :depth="depth + 1" :expandAllCws="props.expandAllCws" :onDeleteCallback="removeReply"/> + <SkNoteSub v-for="reply in replies" :key="reply.id" :note="reply" :class="[$style.reply, { [$style.single]: replies.length === 1 }]" :detail="true" :depth="depth + 1" :expandAllCws="props.expandAllCws" :onDeleteCallback="removeReply" :isReply="props.isReply"/> </template> <div v-else :class="$style.more"> <MkA class="_link" :to="notePage(note)">{{ i18n.ts.continueThread }} <i class="ph-caret-double-right ph-bold ph-lg"></i></MkA> @@ -99,6 +99,8 @@ import MkSubNoteContent from '@/components/MkSubNoteContent.vue'; import MkCwButton from '@/components/MkCwButton.vue'; import { notePage } from '@/filters/note.js'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; +import * as sound from '@/scripts/sound.js'; import { i18n } from '@/i18n.js'; import { $i } from '@/account.js'; import { userPage } from '@/filters/user.js'; @@ -111,6 +113,7 @@ import { reactionPicker } from '@/scripts/reaction-picker.js'; import { claimAchievement } from '@/scripts/achievements.js'; import { getNoteMenu } from '@/scripts/get-note-menu.js'; import { useNoteCapture } from '@/scripts/use-note-capture.js'; +import { boostMenuItems, type Visibility } from '@/scripts/boost-quote.js'; const canRenote = computed(() => ['public', 'home'].includes(props.note.visibility) || props.note.userId === $i.id); const hideLine = computed(() => { return props.detail ? true : false; }); @@ -123,8 +126,13 @@ const props = withDefaults(defineProps<{ // how many notes are in between this one and the note being viewed in detail depth?: number; + + isReply?: boolean; + detailed?: boolean; }>(), { depth: 1, + isReply: false, + detailed: false, }); const el = shallowRef<HTMLElement>(); @@ -147,7 +155,7 @@ const replies = ref<Misskey.entities.Note[]>([]); const isRenote = ( props.note.renote != null && props.note.text == null && - props.note.fileIds.length === 0 && + props.note.fileIds && props.note.fileIds.length === 0 && props.note.poll == null ); @@ -174,7 +182,7 @@ useNoteCapture({ }); if ($i) { - os.api('notes/renotes', { + misskeyApi('notes/renotes', { noteId: appearNote.value.id, userId: $i.id, limit: 1, @@ -202,8 +210,9 @@ function reply(viaKeyboard = false): void { function react(viaKeyboard = false): void { pleaseLogin(); showMovedDialog(); + sound.playMisskeySfx('reaction'); if (props.note.reactionAcceptance === 'likeOnly') { - os.api('notes/like', { + misskeyApi('notes/like', { noteId: props.note.id, override: defaultLike.value, }); @@ -216,8 +225,8 @@ function react(viaKeyboard = false): void { } } else { blur(); - reactionPicker.show(reactButton.value, reaction => { - os.api('notes/reactions/create', { + reactionPicker.show(reactButton.value ?? null, props.note, reaction => { + misskeyApi('notes/reactions/create', { noteId: props.note.id, reaction: reaction, }); @@ -233,7 +242,8 @@ function react(viaKeyboard = false): void { function like(): void { pleaseLogin(); showMovedDialog(); - os.api('notes/like', { + sound.playMisskeySfx('reaction'); + misskeyApi('notes/like', { noteId: props.note.id, override: defaultLike.value, }); @@ -249,14 +259,14 @@ function like(): void { function undoReact(note): void { const oldReaction = note.myReaction; if (!oldReaction) return; - os.api('notes/reactions/delete', { + misskeyApi('notes/reactions/delete', { noteId: note.id, }); } function undoRenote() : void { if (!renoted.value) return; - os.api('notes/unrenote', { + misskeyApi('notes/unrenote', { noteId: appearNote.value.id, }); os.toast(i18n.ts.rmboost); @@ -278,42 +288,14 @@ watch(() => props.expandAllCws, (expandAllCws) => { }); function boostVisibility() { - os.popupMenu([ - { - type: 'button', - icon: 'ph-globe-hemisphere-west ph-bold ph-lg', - text: i18n.ts._visibility['public'], - action: () => { - renote('public'); - }, - }, - { - type: 'button', - icon: 'ph-house ph-bold ph-lg', - text: i18n.ts._visibility['home'], - action: () => { - renote('home'); - }, - }, - { - type: 'button', - icon: 'ph-lock ph-bold ph-lg', - text: i18n.ts._visibility['followers'], - action: () => { - renote('followers'); - }, - }, - { - type: 'button', - icon: 'ph-planet ph-bold ph-lg', - text: i18n.ts._timelines.local, - action: () => { - renote('local'); - }, - }], renoteButton.value); + if (!defaultStore.state.showVisibilitySelectorOnBoost) { + renote(defaultStore.state.visibilityOnBoost); + } else { + os.popupMenu(boostMenuItems(appearNote, renote), renoteButton.value); + } } -function renote(visibility: 'public' | 'home' | 'followers' | 'specified' | 'local') { +function renote(visibility: Visibility, localOnly: boolean = false) { pleaseLogin(); showMovedDialog(); @@ -326,9 +308,9 @@ function renote(visibility: 'public' | 'home' | 'followers' | 'specified' | 'loc os.popup(MkRippleEffect, { x, y }, {}, 'end'); } - os.api('notes/create', { - renoteId: props.note.id, - channelId: props.note.channelId, + misskeyApi('notes/create', { + renoteId: appearNote.value.id, + channelId: appearNote.value.channelId, }).then(() => { os.toast(i18n.ts.renoted); renoted.value = true; @@ -342,10 +324,10 @@ function renote(visibility: 'public' | 'home' | 'followers' | 'specified' | 'loc os.popup(MkRippleEffect, { x, y }, {}, 'end'); } - os.api('notes/create', { - renoteId: props.note.id, - localOnly: visibility === 'local' ? true : false, - visibility: visibility === 'local' || visibility === 'specified' ? props.note.visibility : visibility, + misskeyApi('notes/create', { + renoteId: appearNote.value.id, + localOnly: localOnly, + visibility: visibility, }).then(() => { os.toast(i18n.ts.renoted); renoted.value = true; @@ -362,7 +344,7 @@ function quote() { renote: appearNote.value, channel: appearNote.value.channel, }).then(() => { - os.api('notes/renotes', { + misskeyApi('notes/renotes', { noteId: props.note.id, userId: $i.id, limit: 1, @@ -384,7 +366,7 @@ function quote() { os.post({ renote: appearNote.value, }).then(() => { - os.api('notes/renotes', { + misskeyApi('notes/renotes', { noteId: props.note.id, userId: $i.id, limit: 1, @@ -413,7 +395,7 @@ function menu(viaKeyboard = false): void { } if (props.detail) { - os.api('notes/children', { + misskeyApi('notes/children', { noteId: props.note.id, limit: numberOfReplies.value, showQuotes: false, @@ -426,35 +408,61 @@ if (props.detail) { <style lang="scss" module> .root { padding: 28px 32px; - font-size: 0.9em; position: relative; + --reply-indent: calc(.5 * var(--avatar)); + &.children { - padding: 10px 0 0 16px; - font-size: 1em; + padding: 10px 0 0 8px; + } + + &.isReply { + /* @link https://utopia.fyi/clamp/calculator?a=450,580,26—36 */ + --avatar: clamp(26px, -8.6154px + 7.6923cqi, 36px); } } .line { position: absolute; - height: calc(100% - 58px); // 58px of avatar height (see SkNote) - left: 60px; + left: calc(32px + .5 * var(--avatar)); // using solid instead of dotted, stylelistic choice - border-left: 2.5px solid rgb(174, 174, 174); - top: 86px; // 28px of .root padding, plus 58px of avatar height (see SkNote) + border-left: var(--thread-width) solid var(--thread); + top: calc(28px + var(--avatar)); // 28px of .root padding, plus 58px of avatar height (see SkNote) + bottom: -28px; } .footer { + display: flex; + align-items: center; + justify-content: space-between; position: relative; z-index: 1; margin-top: 0.4em; - width: max-content; - min-width: min-content; - max-width: fit-content; + max-width: 400px; } .main { - display: flex; + position: relative; + display: flex; + + :is(.detailed, .isReply) &::after { + content: ""; + position: absolute; + top: -12px; + right: -12px; + left: -12px; + bottom: -12px; + background: var(--panelHighlight); + border-radius: var(--radius); + opacity: 0; + transition: opacity .2s, background .2s; + z-index: -1; + } + + :is(.detailed, .isReply) &:hover::after, + :is(.detailed, .isReply) &:focus-within::after { + opacity: 1; + } } .colorBar { @@ -471,8 +479,8 @@ if (props.detail) { flex-shrink: 0; display: block; margin: 0 14px 0 0; - width: 58px; - height: 58px; + width: var(--avatar); + height: var(--avatar); border-radius: var(--radius-sm); } @@ -500,10 +508,6 @@ if (props.detail) { padding-top: 10px; opacity: 0.7; - &:not(:last-child) { - margin-right: 1.5em; - } - &:hover { color: var(--fgHighlighted); } @@ -521,15 +525,11 @@ if (props.detail) { @container (max-width: 580px) { .root { padding: 28px 26px 0; + --avatar: 46px; } .line { - left: 50.5px; - } - - .avatar { - width: 50px; - height: 50px; + left: calc(26px + .5 * var(--avatar)); } } @@ -537,6 +537,11 @@ if (props.detail) { .root { padding: 23px 25px; } + + .line { + top: calc(23px + var(--avatar)); + left: calc(25px + .5 * var(--avatar)); + } } @container (max-width: 400px) { @@ -581,21 +586,17 @@ if (props.detail) { @container (max-width: 480px) { .root { padding: 22px 24px; - - &.children { - padding: 10px 0 0 8px; - } } -} -@container (max-width: 450px) { .line { - left: 46px; + top: calc(22px + var(--avatar)); + left: calc(24px + .5 * var(--avatar)); } +} - .avatar { - width: 46px; - height: 46px; +@container (max-width: 450px) { + .root { + --avatar: 44px; } } @@ -616,19 +617,19 @@ if (props.detail) { .threadLine { width: 0; flex-grow: 1; - border-left: 2.5px solid rgb(174, 174, 174); - margin-left: 29px; + border-left: var(--thread-width) solid var(--thread); + margin-left: var(--reply-indent); } .reply { - margin-left: 29px; + margin-left: var(--reply-indent); } .reply:not(:last-child) { - border-left: 2.5px solid rgb(174, 174, 174); + border-left: var(--thread-width) solid var(--thread); &::before { - left: -2px; + left: calc(-1 * var(--thread-width)); } } @@ -637,10 +638,10 @@ if (props.detail) { content: ''; left: 0px; top: -10px; - height: 49px; + height: calc(10px + 10px + .5 * var(--avatar)); width: 15px; - border-left: 2.5px solid rgb(174, 174, 174); - border-bottom: 2.5px solid rgb(174, 174, 174); + border-left: var(--thread-width) solid var(--thread); + border-bottom: var(--thread-width) solid var(--thread); border-bottom-left-radius: 15px; } @@ -649,40 +650,9 @@ if (props.detail) { padding-left: 0 !important; &::before { - left: 29px; + left: var(--reply-indent); width: 0; border-bottom: unset; } } - -@container (max-width: 580px) { - .threadLine, .reply { - margin-left: 25px; - } - .reply::before { - height: 45px; - } - .single::before { - left: 25px; - } - .single { - margin-left: 0; - } -} - -@container (max-width: 450px) { - .threadLine, .reply { - margin-left: 23px; - } - .reply::before { - height: 43px; - } - .single::before { - left: 23px; - width: 9px; - } - .single { - margin-left: 0; - } -} </style> diff --git a/packages/frontend/src/components/SkOldNoteWindow.vue b/packages/frontend/src/components/SkOldNoteWindow.vue index f8de28e346..bed44bbb08 100644 --- a/packages/frontend/src/components/SkOldNoteWindow.vue +++ b/packages/frontend/src/components/SkOldNoteWindow.vue @@ -77,7 +77,7 @@ <script lang="ts" setup> import { inject, onMounted, ref, shallowRef, computed } from 'vue'; -import * as mfm from '@sharkey/sfm-js'; +import * as mfm from '@transfem-org/sfm-js'; import * as Misskey from 'misskey-js'; import MkNoteSimple from '@/components/MkNoteSimple.vue'; import MkMediaList from '@/components/MkMediaList.vue'; @@ -177,8 +177,8 @@ const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultS .noteHeaderAvatar { display: block; flex-shrink: 0; - width: 58px; - height: 58px; + width: var(--avatar); + height: var(--avatar); } .noteHeaderBody { diff --git a/packages/frontend/src/components/SkOneko.vue b/packages/frontend/src/components/SkOneko.vue new file mode 100644 index 0000000000..fbf50067a9 --- /dev/null +++ b/packages/frontend/src/components/SkOneko.vue @@ -0,0 +1,240 @@ +<template> +<div ref="nekoEl" :class="$style.oneko" aria-hidden="true"></div> +</template> + +<script lang="ts" setup> +// oneko.js: https://github.com/adryd325/oneko.js +// modified to be a vue component by ShittyKopper :3 + +import { shallowRef, onMounted } from 'vue'; + +const nekoEl = shallowRef<HTMLDivElement>(); + +let nekoPosX = 32; +let nekoPosY = 32; + +let mousePosX = 0; +let mousePosY = 0; + +let frameCount = 0; +let idleTime = 0; +let idleAnimation: string|null = null; +let idleAnimationFrame = 0; +let lastFrameTimestamp; + +const nekoSpeed = 10; +const spriteSets = { + idle: [[-3, -3]], + alert: [[-7, -3]], + scratchSelf: [ + [-5, 0], + [-6, 0], + [-7, 0], + ], + scratchWallN: [ + [0, 0], + [0, -1], + ], + scratchWallS: [ + [-7, -1], + [-6, -2], + ], + scratchWallE: [ + [-2, -2], + [-2, -3], + ], + scratchWallW: [ + [-4, 0], + [-4, -1], + ], + tired: [[-3, -2]], + sleeping: [ + [-2, 0], + [-2, -1], + ], + N: [ + [-1, -2], + [-1, -3], + ], + NE: [ + [0, -2], + [0, -3], + ], + E: [ + [-3, 0], + [-3, -1], + ], + SE: [ + [-5, -1], + [-5, -2], + ], + S: [ + [-6, -3], + [-7, -2], + ], + SW: [ + [-5, -3], + [-6, -1], + ], + W: [ + [-4, -2], + [-4, -3], + ], + NW: [ + [-1, 0], + [-1, -1], + ], +}; + +function init() { + if (!nekoEl.value) return; + + nekoEl.value.style.left = `${nekoPosX - 16}px`; + nekoEl.value.style.top = `${nekoPosY - 16}px`; + + document.addEventListener('mousemove', (event) => { + mousePosX = event.clientX; + mousePosY = event.clientY; + }); + + window.requestAnimationFrame(onAnimationFrame); +} + +function onAnimationFrame(timestamp) { + // Stops execution if the neko element is removed from DOM + if (!nekoEl.value?.isConnected) { + return; + } + if (!lastFrameTimestamp) { + lastFrameTimestamp = timestamp; + } + if (timestamp - lastFrameTimestamp > 100) { + lastFrameTimestamp = timestamp; + frame(); + } + window.requestAnimationFrame(onAnimationFrame); +} + +// eslint-disable-next-line no-shadow +function setSprite(name, frame) { + if (!nekoEl.value) return; + + const sprite = spriteSets[name][frame % spriteSets[name].length]; + nekoEl.value.style.backgroundPosition = `${sprite[0] * 32}px ${sprite[1] * 32}px`; +} + +function resetIdleAnimation() { + idleAnimation = null; + idleAnimationFrame = 0; +} + +function idle() { + idleTime += 1; + + // every ~ 20 seconds + if ( + idleTime > 10 && + Math.floor(Math.random() * 200) === 0 && + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + idleAnimation == null + ) { + let avalibleIdleAnimations = ['sleeping', 'scratchSelf']; + if (nekoPosX < 32) { + avalibleIdleAnimations.push('scratchWallW'); + } + if (nekoPosY < 32) { + avalibleIdleAnimations.push('scratchWallN'); + } + if (nekoPosX > window.innerWidth - 32) { + avalibleIdleAnimations.push('scratchWallE'); + } + if (nekoPosY > window.innerHeight - 32) { + avalibleIdleAnimations.push('scratchWallS'); + } + idleAnimation = + avalibleIdleAnimations[Math.floor(Math.random() * avalibleIdleAnimations.length)]; + } + + switch (idleAnimation) { + case 'sleeping': + if (idleAnimationFrame < 8) { + setSprite('tired', 0); + break; + } + setSprite('sleeping', Math.floor(idleAnimationFrame / 4)); + if (idleAnimationFrame > 192) { + resetIdleAnimation(); + } + break; + case 'scratchWallN': + case 'scratchWallS': + case 'scratchWallE': + case 'scratchWallW': + case 'scratchSelf': + setSprite(idleAnimation, idleAnimationFrame); + if (idleAnimationFrame > 9) { + resetIdleAnimation(); + } + break; + default: + setSprite('idle', 0); + return; + } + idleAnimationFrame += 1; +} + +function frame() { + if (!nekoEl.value) return; + + frameCount += 1; + const diffX = nekoPosX - mousePosX; + const diffY = nekoPosY - mousePosY; + const distance = Math.sqrt(diffX ** 2 + diffY ** 2); + + if (distance < nekoSpeed || distance < 48) { + idle(); + return; + } + + idleAnimation = null; + idleAnimationFrame = 0; + + if (idleTime > 1) { + setSprite('alert', 0); + // count down after being alerted before moving + idleTime = Math.min(idleTime, 7); + idleTime -= 1; + return; + } + + let direction; + direction = diffY / distance > 0.5 ? 'N' : ''; + direction += diffY / distance < -0.5 ? 'S' : ''; + direction += diffX / distance > 0.5 ? 'W' : ''; + direction += diffX / distance < -0.5 ? 'E' : ''; + setSprite(direction, frameCount); + + nekoPosX -= (diffX / distance) * nekoSpeed; + nekoPosY -= (diffY / distance) * nekoSpeed; + + nekoPosX = Math.min(Math.max(16, nekoPosX), window.innerWidth - 16); + nekoPosY = Math.min(Math.max(16, nekoPosY), window.innerHeight - 16); + + nekoEl.value.style.left = `${nekoPosX - 16}px`; + nekoEl.value.style.top = `${nekoPosY - 16}px`; +} + +onMounted(init); +</script> + +<style module> +.oneko { + width: 32px; + height: 32px; + position: fixed; + pointer-events: none; + image-rendering: pixelated; + z-index: 2147483647; + background-image: url(/client-assets/oneko.gif); +} +</style> diff --git a/packages/frontend/src/components/form/link.vue b/packages/frontend/src/components/form/link.vue index 88602a007c..8c8a343010 100644 --- a/packages/frontend/src/components/form/link.vue +++ b/packages/frontend/src/components/form/link.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/form/section.vue b/packages/frontend/src/components/form/section.vue index 6af63d1ec6..ad37daa265 100644 --- a/packages/frontend/src/components/form/section.vue +++ b/packages/frontend/src/components/form/section.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/form/slot.vue b/packages/frontend/src/components/form/slot.vue index dc4d197507..f54db0ca82 100644 --- a/packages/frontend/src/components/form/slot.vue +++ b/packages/frontend/src/components/form/slot.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/form/split.vue b/packages/frontend/src/components/form/split.vue index 8cb24b479e..2a015c9520 100644 --- a/packages/frontend/src/components/form/split.vue +++ b/packages/frontend/src/components/form/split.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/form/suspense.vue b/packages/frontend/src/components/form/suspense.vue index 933f00b081..54566dc135 100644 --- a/packages/frontend/src/components/form/suspense.vue +++ b/packages/frontend/src/components/form/suspense.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/global/I18n.vue b/packages/frontend/src/components/global/I18n.vue new file mode 100644 index 0000000000..162aa2bcf8 --- /dev/null +++ b/packages/frontend/src/components/global/I18n.vue @@ -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> diff --git a/packages/frontend/src/components/global/MkA.stories.impl.ts b/packages/frontend/src/components/global/MkA.stories.impl.ts index 62f4805a11..c1d8cf0ca6 100644 --- a/packages/frontend/src/components/global/MkA.stories.impl.ts +++ b/packages/frontend/src/components/global/MkA.stories.impl.ts @@ -1,11 +1,10 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { expect } from '@storybook/jest'; -import { userEvent, within } from '@storybook/testing-library'; +import { expect, userEvent, within } from '@storybook/test'; import { StoryObj } from '@storybook/vue3'; import MkA from './MkA.vue'; import { tick } from '@/scripts/test-utils.js'; @@ -33,7 +32,8 @@ export const Default = { async play({ canvasElement }) { const canvas = within(canvasElement); const a = canvas.getByRole<HTMLAnchorElement>('link'); - await expect(a.href).toMatch(/^https?:\/\/.*#test$/); + // FIXME: 通るけどその後落ちるのでコメントアウト + // await expect(a.href).toMatch(/^https?:\/\/.*#test$/); await userEvent.pointer({ keys: '[MouseRight]', target: a }); await tick(); const menu = canvas.getByRole('menu'); @@ -45,6 +45,7 @@ export const Default = { }, args: { to: '#test', + behavior: 'browser', }, parameters: { layout: 'centered', diff --git a/packages/frontend/src/components/global/MkA.vue b/packages/frontend/src/components/global/MkA.vue index e2b59869a4..b3c58cf235 100644 --- a/packages/frontend/src/components/global/MkA.vue +++ b/packages/frontend/src/components/global/MkA.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -15,7 +15,7 @@ import * as os from '@/os.js'; import copyToClipboard from '@/scripts/copy-to-clipboard.js'; import { url } from '@/config.js'; import { i18n } from '@/i18n.js'; -import { useRouter } from '@/router.js'; +import { useRouter } from '@/router/supplier.js'; const props = withDefaults(defineProps<{ to: string; diff --git a/packages/frontend/src/components/global/MkAcct.stories.impl.ts b/packages/frontend/src/components/global/MkAcct.stories.impl.ts index 49ec61211c..04960ec60c 100644 --- a/packages/frontend/src/components/global/MkAcct.stories.impl.ts +++ b/packages/frontend/src/components/global/MkAcct.stories.impl.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/components/global/MkAcct.vue b/packages/frontend/src/components/global/MkAcct.vue index 594494f3c8..8cb082585b 100644 --- a/packages/frontend/src/components/global/MkAcct.vue +++ b/packages/frontend/src/components/global/MkAcct.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -21,7 +21,7 @@ import { host as hostRaw } from '@/config.js'; import { defaultStore } from '@/store.js'; defineProps<{ - user: Misskey.entities.UserDetailed; + user: Misskey.entities.User; detail?: boolean; }>(); diff --git a/packages/frontend/src/components/global/MkAd.stories.impl.ts b/packages/frontend/src/components/global/MkAd.stories.impl.ts index 5ae45ec58f..f6cdc2bf23 100644 --- a/packages/frontend/src/components/global/MkAd.stories.impl.ts +++ b/packages/frontend/src/components/global/MkAd.stories.impl.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/components/global/MkAd.vue b/packages/frontend/src/components/global/MkAd.vue index b3eb6d681f..f13a161ae8 100644 --- a/packages/frontend/src/components/global/MkAd.vue +++ b/packages/frontend/src/components/global/MkAd.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/global/MkAvatar.stories.impl.ts b/packages/frontend/src/components/global/MkAvatar.stories.impl.ts index 515d7eab18..933754ec4c 100644 --- a/packages/frontend/src/components/global/MkAvatar.stories.impl.ts +++ b/packages/frontend/src/components/global/MkAvatar.stories.impl.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/components/global/MkAvatar.vue b/packages/frontend/src/components/global/MkAvatar.vue index 4a876931c3..de62fe12a9 100644 --- a/packages/frontend/src/components/global/MkAvatar.vue +++ b/packages/frontend/src/components/global/MkAvatar.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -27,7 +27,7 @@ SPDX-License-Identifier: AGPL-3.0-only <img v-for="decoration in decorations ?? user.avatarDecorations" :class="[$style.decoration]" - :src="decoration.url" + :src="getDecorationUrl(decoration)" :style="{ rotate: getDecorationAngle(decoration), scale: getDecorationScale(decoration), @@ -81,15 +81,22 @@ const bound = computed(() => props.link ? { to: userPage(props.user), target: props.target } : {}); -const url = computed(() => (defaultStore.state.disableShowingAnimatedImages || defaultStore.state.dataSaver.avatar) - ? getStaticImageUrl(props.user.avatarUrl) - : props.user.avatarUrl); +const url = computed(() => { + if (props.user.avatarUrl == null) return null; + if (defaultStore.state.disableShowingAnimatedImages || defaultStore.state.dataSaver.avatar) return getStaticImageUrl(props.user.avatarUrl); + return props.user.avatarUrl; +}); function onClick(ev: MouseEvent): void { if (props.link) return; emit('click', ev); } +function getDecorationUrl(decoration: Omit<Misskey.entities.UserDetailed['avatarDecorations'][number], 'id'>) { + if (defaultStore.state.disableShowingAnimatedImages || defaultStore.state.dataSaver.avatar) return getStaticImageUrl(decoration.url); + return decoration.url; +} + function getDecorationAngle(decoration: Omit<Misskey.entities.UserDetailed['avatarDecorations'][number], 'id'>) { const angle = decoration.angle ?? 0; return angle === 0 ? undefined : `${angle * 360}deg`; @@ -109,6 +116,7 @@ function getDecorationOffset(decoration: Omit<Misskey.entities.UserDetailed['ava const color = ref<string | undefined>(); watch(() => props.user.avatarBlurhash, () => { + if (props.user.avatarBlurhash == null) return; color.value = extractAvgColorFromBlurhash(props.user.avatarBlurhash); }, { immediate: true, diff --git a/packages/frontend/src/components/global/MkCondensedLine.stories.impl.ts b/packages/frontend/src/components/global/MkCondensedLine.stories.impl.ts index 7df49a2066..e4e90cddd5 100644 --- a/packages/frontend/src/components/global/MkCondensedLine.stories.impl.ts +++ b/packages/frontend/src/components/global/MkCondensedLine.stories.impl.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/components/global/MkCondensedLine.vue b/packages/frontend/src/components/global/MkCondensedLine.vue index 2ed615f5ff..7c4957d77f 100644 --- a/packages/frontend/src/components/global/MkCondensedLine.vue +++ b/packages/frontend/src/components/global/MkCondensedLine.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/global/MkCustomEmoji.stories.impl.ts b/packages/frontend/src/components/global/MkCustomEmoji.stories.impl.ts index f50217b70d..9e6177045d 100644 --- a/packages/frontend/src/components/global/MkCustomEmoji.stories.impl.ts +++ b/packages/frontend/src/components/global/MkCustomEmoji.stories.impl.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ @@ -48,3 +48,18 @@ export const Missing = { name: Default.args.name, }, } satisfies StoryObj<typeof MkCustomEmoji>; +export const ErrorToText = { + ...Default, + args: { + url: 'https://example.com/404', + name: Default.args.name, + }, +} satisfies StoryObj<typeof MkCustomEmoji>; +export const ErrorToImage = { + ...Default, + args: { + url: 'https://example.com/404', + name: Default.args.name, + fallbackToImage: true, + }, +} satisfies StoryObj<typeof MkCustomEmoji>; diff --git a/packages/frontend/src/components/global/MkCustomEmoji.vue b/packages/frontend/src/components/global/MkCustomEmoji.vue index e8732d1b16..b57a311c0c 100644 --- a/packages/frontend/src/components/global/MkCustomEmoji.vue +++ b/packages/frontend/src/components/global/MkCustomEmoji.vue @@ -1,10 +1,16 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> <template> -<span v-if="errored">:{{ customEmojiName }}:</span> +<img + v-if="errored && fallbackToImage" + :class="[$style.root, { [$style.normal]: normal, [$style.noStyle]: noStyle }]" + src="/client-assets/dummy.png" + :title="alt" +/> +<span v-else-if="errored">:{{ customEmojiName }}:</span> <img v-else :class="[$style.root, { [$style.normal]: normal, [$style.noStyle]: noStyle }]" @@ -24,9 +30,11 @@ import { getProxiedImageUrl, getStaticImageUrl } from '@/scripts/media-proxy.js' import { defaultStore } from '@/store.js'; import { customEmojisMap } from '@/custom-emojis.js'; import * as os from '@/os.js'; +import { misskeyApiGet } from '@/scripts/misskey-api.js'; import copyToClipboard from '@/scripts/copy-to-clipboard.js'; import * as sound from '@/scripts/sound.js'; import { i18n } from '@/i18n.js'; +import MkCustomEmojiDetailedDialog from '@/components/MkCustomEmojiDetailedDialog.vue'; const props = defineProps<{ name: string; @@ -37,6 +45,7 @@ const props = defineProps<{ useOriginalSize?: boolean; menu?: boolean; menuReaction?: boolean; + fallbackToImage?: boolean; }>(); const react = inject<((name: string) => void) | null>('react', null); @@ -55,7 +64,7 @@ const rawUrl = computed(() => { }); const url = computed(() => { - if (rawUrl.value == null) return null; + if (rawUrl.value == null) return undefined; const proxied = (rawUrl.value.startsWith('/emoji/') || (props.useOriginalSize && isLocal.value)) @@ -91,9 +100,21 @@ function onClick(ev: MouseEvent) { icon: 'ph-smiley ph-bold ph-lg', action: () => { react(`:${props.name}:`); - sound.play('reaction'); + sound.playMisskeySfx('reaction'); + }, + }] : []), { + text: i18n.ts.info, + icon: 'ph-info ph-bold ph-lg', + action: async () => { + os.popup(MkCustomEmojiDetailedDialog, { + emoji: await misskeyApiGet('emoji', { + name: customEmojiName.value, + }), + }, { + anchor: ev.target, + }); }, - }] : [])], ev.currentTarget ?? ev.target); + }], ev.currentTarget ?? ev.target); } } </script> diff --git a/packages/frontend/src/components/global/MkEllipsis.stories.impl.ts b/packages/frontend/src/components/global/MkEllipsis.stories.impl.ts index 32deaae8e2..6a8fcf4fe3 100644 --- a/packages/frontend/src/components/global/MkEllipsis.stories.impl.ts +++ b/packages/frontend/src/components/global/MkEllipsis.stories.impl.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/components/global/MkEllipsis.vue b/packages/frontend/src/components/global/MkEllipsis.vue index 5cc07f7040..4ba6be10fe 100644 --- a/packages/frontend/src/components/global/MkEllipsis.vue +++ b/packages/frontend/src/components/global/MkEllipsis.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/global/MkEmoji.stories.impl.ts b/packages/frontend/src/components/global/MkEmoji.stories.impl.ts index c8beec7e8f..309c015757 100644 --- a/packages/frontend/src/components/global/MkEmoji.stories.impl.ts +++ b/packages/frontend/src/components/global/MkEmoji.stories.impl.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/components/global/MkEmoji.vue b/packages/frontend/src/components/global/MkEmoji.vue index b1d62db33c..2e7a0c5bb7 100644 --- a/packages/frontend/src/components/global/MkEmoji.vue +++ b/packages/frontend/src/components/global/MkEmoji.vue @@ -1,19 +1,18 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> <template> <img v-if="!useOsNativeEmojis" :class="$style.root" :src="url" :alt="props.emoji" decoding="async" @pointerenter="computeTitle" @click="onClick" v-on:click.stop/> -<span v-else-if="useOsNativeEmojis" :alt="props.emoji" @pointerenter="computeTitle" @click="onClick" v-on:click.stop>{{ props.emoji }}</span> -<span v-else>{{ emoji }}</span> +<span v-else :alt="props.emoji" @pointerenter="computeTitle" @click="onClick" v-on:click.stop>{{ colorizedNativeEmoji }}</span> </template> <script lang="ts" setup> import { computed, inject } from 'vue'; -import { char2twemojiFilePath, char2fluentEmojiFilePath } from '@/scripts/emoji-base.js'; +import { char2fluentEmojiFilePath, char2twemojiFilePath, char2tossfaceFilePath } from '@/scripts/emoji-base.js'; import { defaultStore } from '@/store.js'; -import { getEmojiName } from '@/scripts/emojilist.js'; +import { colorizeEmoji, getEmojiName } from '@/scripts/emojilist.js'; import * as os from '@/os.js'; import copyToClipboard from '@/scripts/copy-to-clipboard.js'; import * as sound from '@/scripts/sound.js'; @@ -27,17 +26,15 @@ const props = defineProps<{ const react = inject<((name: string) => void) | null>('react', null); -const char2path = defaultStore.state.emojiStyle === 'twemoji' ? char2twemojiFilePath : char2fluentEmojiFilePath; +const char2path = defaultStore.state.emojiStyle === 'twemoji' ? char2twemojiFilePath : defaultStore.state.emojiStyle === 'tossface' ? char2tossfaceFilePath : char2fluentEmojiFilePath; const useOsNativeEmojis = computed(() => defaultStore.state.emojiStyle === 'native'); -const url = computed(() => { - return char2path(props.emoji); -}); +const url = computed(() => char2path(props.emoji)); +const colorizedNativeEmoji = computed(() => colorizeEmoji(props.emoji)); // Searching from an array with 2000 items for every emoji felt like too energy-consuming, so I decided to do it lazily on pointerenter function computeTitle(event: PointerEvent): void { - const title = getEmojiName(props.emoji as string) ?? props.emoji as string; - (event.target as HTMLElement).title = title; + (event.target as HTMLElement).title = getEmojiName(props.emoji); } function onClick(ev: MouseEvent) { @@ -57,7 +54,7 @@ function onClick(ev: MouseEvent) { icon: 'ph-smiley ph-bold ph-lg', action: () => { react(props.emoji); - sound.play('reaction'); + sound.playMisskeySfx('reaction'); }, }] : [])], ev.currentTarget ?? ev.target); } diff --git a/packages/frontend/src/components/global/MkError.stories.impl.ts b/packages/frontend/src/components/global/MkError.stories.impl.ts index cf0a1dbb5f..daef04cd87 100644 --- a/packages/frontend/src/components/global/MkError.stories.impl.ts +++ b/packages/frontend/src/components/global/MkError.stories.impl.ts @@ -1,12 +1,11 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { action } from '@storybook/addon-actions'; -import { expect } from '@storybook/jest'; -import { waitFor } from '@storybook/testing-library'; +import { expect, waitFor } from '@storybook/test'; import { StoryObj } from '@storybook/vue3'; import MkError from './MkError.vue'; export const Default = { diff --git a/packages/frontend/src/components/global/MkError.stories.meta.ts b/packages/frontend/src/components/global/MkError.stories.meta.ts index a3955c5786..1abbc56f50 100644 --- a/packages/frontend/src/components/global/MkError.stories.meta.ts +++ b/packages/frontend/src/components/global/MkError.stories.meta.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/components/global/MkError.vue b/packages/frontend/src/components/global/MkError.vue index 47b42467d6..2976cd7be8 100644 --- a/packages/frontend/src/components/global/MkError.vue +++ b/packages/frontend/src/components/global/MkError.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/global/MkFooterSpacer.vue b/packages/frontend/src/components/global/MkFooterSpacer.vue index e78df6b8d9..1a75855fa1 100644 --- a/packages/frontend/src/components/global/MkFooterSpacer.vue +++ b/packages/frontend/src/components/global/MkFooterSpacer.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/global/MkLazy.vue b/packages/frontend/src/components/global/MkLazy.vue index 6d7ff4ca49..f35932ae77 100644 --- a/packages/frontend/src/components/global/MkLazy.vue +++ b/packages/frontend/src/components/global/MkLazy.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/global/MkLoading.stories.impl.ts b/packages/frontend/src/components/global/MkLoading.stories.impl.ts index 9cedd68fd8..c781ad0479 100644 --- a/packages/frontend/src/components/global/MkLoading.stories.impl.ts +++ b/packages/frontend/src/components/global/MkLoading.stories.impl.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/components/global/MkLoading.vue b/packages/frontend/src/components/global/MkLoading.vue index 3f34e83f58..49d8ace37b 100644 --- a/packages/frontend/src/components/global/MkLoading.vue +++ b/packages/frontend/src/components/global/MkLoading.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.stories.impl.ts b/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.stories.impl.ts index 9cdb490e4b..730351f795 100644 --- a/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.stories.impl.ts +++ b/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.stories.impl.ts @@ -1,12 +1,11 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { StoryObj } from '@storybook/vue3'; -import { within } from '@storybook/testing-library'; -import { expect } from '@storybook/jest'; +import { expect, within } from '@storybook/test'; import MkMisskeyFlavoredMarkdown from './MkMisskeyFlavoredMarkdown.js'; export const Default = { render(args) { diff --git a/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts b/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts index a3bfdf0bb4..f8b5fcfedc 100644 --- a/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts +++ b/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts @@ -1,10 +1,10 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ import { VNode, h, defineAsyncComponent, SetupContext } from 'vue'; -import * as mfm from '@sharkey/sfm-js'; +import * as mfm from '@transfem-org/sfm-js'; import * as Misskey from 'misskey-js'; import MkUrl from '@/components/global/MkUrl.vue'; import MkTime from '@/components/global/MkTime.vue'; @@ -13,12 +13,14 @@ import MkMention from '@/components/MkMention.vue'; import MkEmoji from '@/components/global/MkEmoji.vue'; import MkCustomEmoji from '@/components/global/MkCustomEmoji.vue'; import MkCode from '@/components/MkCode.vue'; +import MkCodeInline from '@/components/MkCodeInline.vue'; import MkGoogle from '@/components/MkGoogle.vue'; import MkSparkle from '@/components/MkSparkle.vue'; import MkA from '@/components/global/MkA.vue'; import { host } from '@/config.js'; import { defaultStore } from '@/store.js'; import { nyaize as doNyaize } from '@/scripts/nyaize.js'; +import { safeParseFloat } from '@/scripts/safe-parse.js'; const QUOTE_STYLE = ` display: block; @@ -35,7 +37,7 @@ type MfmProps = { nowrap?: boolean; author?: Misskey.entities.UserLite; isNote?: boolean; - emojiUrls?: string[]; + emojiUrls?: Record<string, string>; rootScale?: number; nyaize?: boolean | 'respect'; parsedNodes?: mfm.MfmNode[] | null; @@ -49,22 +51,28 @@ type MfmEvents = { }; // eslint-disable-next-line import/no-default-export -export default function(props: MfmProps, context: SetupContext<MfmEvents>) { +export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEvents>['emit'] }) { const isNote = props.isNote ?? true; - const shouldNyaize = props.nyaize ? props.nyaize === 'respect' ? props.author?.isCat ? props.author?.speakAsCat : false : false : false; + const shouldNyaize = props.nyaize ? props.nyaize === 'respect' ? props.author?.isCat ? props.author.speakAsCat : false : false : false; // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (props.text == null || props.text === '') return; const rootAst = props.parsedNodes ?? (props.plain ? mfm.parseSimple : mfm.parse)(props.text); - const validTime = (t: string | null | undefined) => { + const validTime = (t: string | boolean | null | undefined) => { if (t == null) return null; + if (typeof t === 'boolean') return null; return t.match(/^[0-9.]+s$/) ? t : null; }; const useAnim = defaultStore.state.advancedMfm && defaultStore.state.animatedMfm ? true : props.isAnim ? true : false; + const validColor = (c: unknown): string | null => { + if (typeof c !== 'string') return null; + return c.match(/^[0-9a-f]{3,6}$/i) ? c : null; + }; + const MkFormula = defineAsyncComponent(() => import('@/components/MkFormula.vue')); /** @@ -115,7 +123,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': { @@ -220,14 +228,14 @@ export default function(props: MfmProps, context: SetupContext<MfmEvents>) { return h(MkSparkle, {}, genEl(token.children, scale)); } case 'rotate': { - const degrees = parseFloat(token.props.args.deg ?? '90'); + const degrees = safeParseFloat(token.props.args.deg) ?? 90; style = `transform: rotate(${degrees}deg); transform-origin: center center;`; break; } case 'position': { if (!defaultStore.state.advancedMfm) break; - const x = parseFloat(token.props.args.x ?? '0'); - const y = parseFloat(token.props.args.y ?? '0'); + const x = safeParseFloat(token.props.args.x) ?? 0; + const y = safeParseFloat(token.props.args.y) ?? 0; style = `transform: translateX(${x}em) translateY(${y}em);`; break; } @@ -236,24 +244,38 @@ export default function(props: MfmProps, context: SetupContext<MfmEvents>) { style = ''; break; } - const x = Math.min(parseFloat(token.props.args.x ?? '1'), 5); - const y = Math.min(parseFloat(token.props.args.y ?? '1'), 5); + const x = Math.min(safeParseFloat(token.props.args.x) ?? 1, 5); + const y = Math.min(safeParseFloat(token.props.args.y) ?? 1, 5); style = `transform: scale(${x}, ${y});`; scale = scale * Math.max(x, y); break; } case 'fg': { - let color = token.props.args.color; - if (!/^[0-9a-f]{3,6}$/i.test(color)) color = 'f00'; + let color = validColor(token.props.args.color); + color = color ?? 'f00'; style = `color: #${color}; overflow-wrap: anywhere;`; break; } case 'bg': { - let color = token.props.args.color; - if (!/^[0-9a-f]{3,6}$/i.test(color)) color = 'f00'; + let color = validColor(token.props.args.color); + color = color ?? 'f00'; style = `background-color: #${color}; overflow-wrap: anywhere;`; break; } + case 'border': { + let color = validColor(token.props.args.color); + color = color ? `#${color}` : 'var(--accent)'; + let b_style = token.props.args.style; + if ( + typeof b_style !== 'string' || + !['hidden', 'dotted', 'dashed', 'solid', 'double', 'groove', 'ridge', 'inset', 'outset'] + .includes(b_style) + ) b_style = 'solid'; + const width = safeParseFloat(token.props.args.width) ?? 1; + const radius = safeParseFloat(token.props.args.radius) ?? 0; + style = `border: ${width}px ${b_style} ${color}; border-radius: ${radius}px;${token.props.args.noclip ? '' : ' overflow: clip;'}`; + break; + } case 'ruby': { if (token.children.length === 1) { const child = token.children[0]; @@ -292,7 +314,8 @@ export default function(props: MfmProps, context: SetupContext<MfmEvents>) { return h('span', { onClick(ev: MouseEvent): void { ev.stopPropagation(); ev.preventDefault(); - context.emit('clickEv', token.props.args.ev ?? ''); + const clickEv = typeof token.props.args.ev === 'string' ? token.props.args.ev : ''; + emit('clickEv', clickEv); } }, genEl(token.children, scale)); } } @@ -353,15 +376,14 @@ export default function(props: MfmProps, context: SetupContext<MfmEvents>) { return [h(MkCode, { key: Math.random(), code: token.props.code, - lang: token.props.lang, + lang: token.props.lang ?? undefined, })]; } case 'inlineCode': { - return [h(MkCode, { + return [h(MkCodeInline, { key: Math.random(), code: token.props.code, - inline: true, })]; } @@ -388,6 +410,7 @@ export default function(props: MfmProps, context: SetupContext<MfmEvents>) { useOriginalSize: scale >= 2.5, menu: props.enableEmojiMenu, menuReaction: props.enableEmojiMenuReaction, + fallbackToImage: false, })]; } else { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition @@ -397,8 +420,7 @@ export default function(props: MfmProps, context: SetupContext<MfmEvents>) { return [h(MkCustomEmoji, { key: Math.random(), name: token.props.name, - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - url: props.emojiUrls ? props.emojiUrls[token.props.name] : null, + url: props.emojiUrls && props.emojiUrls[token.props.name], normal: props.plain, host: props.author.host, useOriginalSize: scale >= 2.5, diff --git a/packages/frontend/src/components/global/MkPageHeader.stories.impl.ts b/packages/frontend/src/components/global/MkPageHeader.stories.impl.ts index 05d2872e91..d4327e1463 100644 --- a/packages/frontend/src/components/global/MkPageHeader.stories.impl.ts +++ b/packages/frontend/src/components/global/MkPageHeader.stories.impl.ts @@ -1,10 +1,10 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { waitFor } from '@storybook/testing-library'; +import { waitFor } from '@storybook/test'; import { StoryObj } from '@storybook/vue3'; import MkPageHeader from './MkPageHeader.vue'; export const Empty = { diff --git a/packages/frontend/src/components/global/MkPageHeader.tabs.stories.impl.ts b/packages/frontend/src/components/global/MkPageHeader.tabs.stories.impl.ts index 130dde63af..5d2126435e 100644 --- a/packages/frontend/src/components/global/MkPageHeader.tabs.stories.impl.ts +++ b/packages/frontend/src/components/global/MkPageHeader.tabs.stories.impl.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/components/global/MkPageHeader.tabs.vue b/packages/frontend/src/components/global/MkPageHeader.tabs.vue index 75c8e73582..53bb5472dc 100644 --- a/packages/frontend/src/components/global/MkPageHeader.tabs.vue +++ b/packages/frontend/src/components/global/MkPageHeader.tabs.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -38,6 +38,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts"> export type Tab = { key: string; + title: string; onClick?: (ev: MouseEvent) => void; } & ( | { @@ -120,8 +121,9 @@ function onTabWheel(ev: WheelEvent) { let entering = false; -async function enter(el: HTMLElement) { +async function enter(element: Element) { entering = true; + const el = element as HTMLElement; const elementWidth = el.getBoundingClientRect().width; el.style.width = '0'; el.style.paddingLeft = '0'; @@ -135,11 +137,12 @@ async function enter(el: HTMLElement) { setTimeout(renderTab, 170); } -function afterEnter(el: HTMLElement) { +function afterEnter(element: Element) { //el.style.width = ''; } -async function leave(el: HTMLElement) { +async function leave(element: Element) { + const el = element as HTMLElement; const elementWidth = el.getBoundingClientRect().width; el.style.width = elementWidth + 'px'; el.style.paddingLeft = ''; @@ -148,7 +151,8 @@ async function leave(el: HTMLElement) { el.style.paddingLeft = '0'; } -function afterLeave(el: HTMLElement) { +function afterLeave(element: Element) { + const el = element as HTMLElement; el.style.width = ''; } diff --git a/packages/frontend/src/components/global/MkPageHeader.vue b/packages/frontend/src/components/global/MkPageHeader.vue index a36d9517cd..95ac102013 100644 --- a/packages/frontend/src/components/global/MkPageHeader.vue +++ b/packages/frontend/src/components/global/MkPageHeader.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -15,23 +15,23 @@ SPDX-License-Identifier: AGPL-3.0-only </button> </div> - <template v-if="metadata"> + <template v-if="pageMetadata"> <div v-if="displayBackButton && !narrow" style="margin: 0 -45px 0 0;" :class="$style.buttonsLeft"> <button class="_button" :class="$style.button" style="left: 5px;" @click.stop="goBack()" @touchstart="preventDrag"> <i class="ph-caret-left ph-bold ph-lg"></i> </button> </div> <div v-if="!hideTitle" :class="$style.titleContainer" @click="top"> - <div v-if="metadata.avatar" :class="$style.titleAvatarContainer"> - <MkAvatar :class="$style.titleAvatar" :user="metadata.avatar" indicator/> + <div v-if="pageMetadata.avatar" :class="$style.titleAvatarContainer"> + <MkAvatar :class="$style.titleAvatar" :user="pageMetadata.avatar" indicator/> </div> - <i v-else-if="metadata.icon" :class="[$style.titleIcon, metadata.icon]"></i> + <i v-else-if="pageMetadata.icon" :class="[$style.titleIcon, pageMetadata.icon]"></i> <div :class="$style.title"> - <MkUserName v-if="metadata.userName" :user="metadata.userName" :nowrap="false"/> - <div v-else-if="metadata.title">{{ metadata.title }}</div> - <div v-if="metadata.subtitle" :class="$style.subtitle"> - {{ metadata.subtitle }} + <MkUserName v-if="pageMetadata.userName" :user="pageMetadata.userName" :nowrap="true"/> + <div v-else-if="pageMetadata.title">{{ pageMetadata.title }}</div> + <div v-if="pageMetadata.subtitle" :class="$style.subtitle"> + {{ pageMetadata.subtitle }} </div> </div> </div> @@ -55,7 +55,7 @@ import tinycolor from 'tinycolor2'; import XTabs, { Tab } from './MkPageHeader.tabs.vue'; import { scrollToTop } from '@/scripts/scroll.js'; import { globalEvents } from '@/events.js'; -import { injectPageMetadata } from '@/scripts/page-metadata.js'; +import { injectReactiveMetadata } from '@/scripts/page-metadata.js'; import { $i, openAccountMenu as openAccountMenu_ } from '@/account.js'; import { PageHeaderItem } from '@/types/page-header.js'; @@ -76,7 +76,7 @@ const emit = defineEmits<{ const displayBackButton = props.displayBackButton && history.state.key !== 'index' && history.length > 1 && inject('shouldBackButton', true); -const metadata = injectPageMetadata(); +const pageMetadata = injectReactiveMetadata(); const hideTitle = inject('shouldOmitHeaderTitle', false); const thin_ = props.thin || inject('shouldHeaderThin', false); diff --git a/packages/frontend/src/components/global/MkSpacer.vue b/packages/frontend/src/components/global/MkSpacer.vue index a384e06f77..db01c10eb0 100644 --- a/packages/frontend/src/components/global/MkSpacer.vue +++ b/packages/frontend/src/components/global/MkSpacer.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/global/MkStickyContainer.stories.impl.ts b/packages/frontend/src/components/global/MkStickyContainer.stories.impl.ts index 16c62ce03d..186048991e 100644 --- a/packages/frontend/src/components/global/MkStickyContainer.stories.impl.ts +++ b/packages/frontend/src/components/global/MkStickyContainer.stories.impl.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/components/global/MkStickyContainer.vue b/packages/frontend/src/components/global/MkStickyContainer.vue index 70cc68b14c..89993e1b8e 100644 --- a/packages/frontend/src/components/global/MkStickyContainer.vue +++ b/packages/frontend/src/components/global/MkStickyContainer.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -63,27 +63,32 @@ onMounted(() => { watch([parentStickyTop, parentStickyBottom], calc); watch(childStickyTop, () => { + if (bodyEl.value == null) return; bodyEl.value.style.setProperty('--stickyTop', `${childStickyTop.value}px`); }, { immediate: true, }); watch(childStickyBottom, () => { + if (bodyEl.value == null) return; bodyEl.value.style.setProperty('--stickyBottom', `${childStickyBottom.value}px`); }, { immediate: true, }); - headerEl.value.style.position = 'sticky'; - headerEl.value.style.top = 'var(--stickyTop, 0)'; - headerEl.value.style.zIndex = '1000'; - - footerEl.value.style.position = 'sticky'; - footerEl.value.style.bottom = 'var(--stickyBottom, 0)'; - footerEl.value.style.zIndex = '1000'; + if (headerEl.value != null) { + headerEl.value.style.position = 'sticky'; + headerEl.value.style.top = 'var(--stickyTop, 0)'; + headerEl.value.style.zIndex = '1000'; + observer.observe(headerEl.value); + } - observer.observe(headerEl.value); - observer.observe(footerEl.value); + if (footerEl.value != null) { + footerEl.value.style.position = 'sticky'; + footerEl.value.style.bottom = 'var(--stickyBottom, 0)'; + footerEl.value.style.zIndex = '1000'; + observer.observe(footerEl.value); + } }); onUnmounted(() => { diff --git a/packages/frontend/src/components/global/MkTime.stories.impl.ts b/packages/frontend/src/components/global/MkTime.stories.impl.ts index 0eeefa4859..355c839113 100644 --- a/packages/frontend/src/components/global/MkTime.stories.impl.ts +++ b/packages/frontend/src/components/global/MkTime.stories.impl.ts @@ -1,16 +1,16 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { expect } from '@storybook/jest'; +import { expect } from '@storybook/test'; import { StoryObj } from '@storybook/vue3'; import MkTime from './MkTime.vue'; import { i18n } from '@/i18n.js'; import { dateTimeFormat } from '@/scripts/intl-const.js'; const now = new Date('2023-04-01T00:00:00.000Z'); -const future = new Date(8640000000000000); +const future = new Date('2024-04-01T00:00:00.000Z'); const oneHourAgo = new Date(now.getTime() - 3600000); const oneDayAgo = new Date(now.getTime() - 86400000); const oneWeekAgo = new Date(now.getTime() - 604800000); @@ -49,11 +49,12 @@ export const Empty = { export const RelativeFuture = { ...Empty, async play({ canvasElement }) { - await expect(canvasElement).toHaveTextContent(i18n.ts._ago.future); + await expect(canvasElement).toHaveTextContent(i18n.tsx._timeIn.years({ n: 1 })); // n (1) = future (2024) - now (2023) }, args: { ...Empty.args, time: future, + origin: now, }, } satisfies StoryObj<typeof MkTime>; export const AbsoluteFuture = { @@ -123,7 +124,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 +163,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 +202,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 +241,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 +280,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, diff --git a/packages/frontend/src/components/global/MkTime.vue b/packages/frontend/src/components/global/MkTime.vue index e11db9dc31..67532268d3 100644 --- a/packages/frontend/src/components/global/MkTime.vue +++ b/packages/frontend/src/components/global/MkTime.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -24,7 +24,7 @@ const props = withDefaults(defineProps<{ mode?: 'relative' | 'absolute' | 'detail'; colored?: boolean; }>(), { - origin: isChromatic() ? new Date('2023-04-01T00:00:00Z') : null, + origin: isChromatic() ? () => new Date('2023-04-01T00:00:00Z') : null, mode: 'relative', }); @@ -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() }) ); }); diff --git a/packages/frontend/src/components/global/MkUrl.stories.impl.ts b/packages/frontend/src/components/global/MkUrl.stories.impl.ts index b35b6114fd..34a4adfe49 100644 --- a/packages/frontend/src/components/global/MkUrl.stories.impl.ts +++ b/packages/frontend/src/components/global/MkUrl.stories.impl.ts @@ -1,13 +1,12 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { expect } from '@storybook/jest'; -import { userEvent, waitFor, within } from '@storybook/testing-library'; +import { expect, userEvent, waitFor, within } from '@storybook/test'; import { StoryObj } from '@storybook/vue3'; -import { rest } from 'msw'; +import { HttpResponse, http } from 'msw'; import { commonHandlers } from '../../../.storybook/mocks.js'; import MkUrl from './MkUrl.vue'; export const Default = { @@ -59,8 +58,8 @@ export const Default = { msw: { handlers: [ ...commonHandlers, - rest.get('/url', (req, res, ctx) => { - return res(ctx.json({ + http.get('/url', () => { + return HttpResponse.json({ title: 'Misskey Hub', icon: 'https://misskey-hub.net/favicon.ico', description: 'Misskeyはオープンソースの分散型ソーシャルネットワーキングプラットフォームです。', @@ -74,7 +73,7 @@ export const Default = { sitename: 'misskey-hub.net', sensitive: false, url: 'https://misskey-hub.net/', - })); + }); }), ], }, diff --git a/packages/frontend/src/components/global/MkUrl.vue b/packages/frontend/src/components/global/MkUrl.vue index 667a113432..b810840b69 100644 --- a/packages/frontend/src/components/global/MkUrl.vue +++ b/packages/frontend/src/components/global/MkUrl.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -7,6 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only <component :is="self ? 'MkA' : 'a'" ref="el" :class="$style.root" class="_link" :[attr]="self ? props.url.substring(local.length) : props.url" :rel="rel ?? 'nofollow noopener'" :target="target" @contextmenu.stop="() => {}" + @click.stop > <template v-if="!self"> <span :class="$style.schema">{{ schema }}//</span> diff --git a/packages/frontend/src/components/global/MkUserName.stories.impl.ts b/packages/frontend/src/components/global/MkUserName.stories.impl.ts index 8f47a6c1ab..88bf4f4e6c 100644 --- a/packages/frontend/src/components/global/MkUserName.stories.impl.ts +++ b/packages/frontend/src/components/global/MkUserName.stories.impl.ts @@ -1,10 +1,10 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { expect } from '@storybook/jest'; +import { expect } from '@storybook/test'; import { StoryObj } from '@storybook/vue3'; import { userDetailed } from '../../../.storybook/fakes.js'; import MkUserName from './MkUserName.vue'; diff --git a/packages/frontend/src/components/global/MkUserName.vue b/packages/frontend/src/components/global/MkUserName.vue index be283ea922..c5bcf53102 100644 --- a/packages/frontend/src/components/global/MkUserName.vue +++ b/packages/frontend/src/components/global/MkUserName.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/global/RouterView.stories.impl.ts b/packages/frontend/src/components/global/RouterView.stories.impl.ts index 2fe4c53e78..5dfe12b0c9 100644 --- a/packages/frontend/src/components/global/RouterView.stories.impl.ts +++ b/packages/frontend/src/components/global/RouterView.stories.impl.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/components/global/RouterView.vue b/packages/frontend/src/components/global/RouterView.vue index 99ed8adbef..06cb30eff1 100644 --- a/packages/frontend/src/components/global/RouterView.vue +++ b/packages/frontend/src/components/global/RouterView.vue @@ -1,10 +1,13 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> <template> -<KeepAlive :max="defaultStore.state.numberOfPageCache"> +<KeepAlive + :max="defaultStore.state.numberOfPageCache" + :exclude="pageCacheController" +> <Suspense :timeout="0"> <component :is="currentPageComponent" :key="key" v-bind="Object.fromEntries(currentPageProps)"/> @@ -16,12 +19,14 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { inject, onBeforeUnmount, provide, shallowRef, ref } from 'vue'; -import { Resolved, Router } from '@/nirax.js'; +import { inject, onBeforeUnmount, provide, ref, shallowRef, computed, nextTick } from 'vue'; +import { IRouter, Resolved, RouteDef } from '@/nirax.js'; import { defaultStore } from '@/store.js'; +import { globalEvents } from '@/events.js'; +import MkLoadingPage from '@/pages/_loading_.vue'; const props = defineProps<{ - router?: Router; + router?: IRouter; }>(); const router = props.router ?? inject('router'); @@ -46,20 +51,47 @@ function resolveNested(current: Resolved, d = 0): Resolved | null { } const current = resolveNested(router.current)!; -const currentPageComponent = shallowRef(current.route.component); +const currentPageComponent = shallowRef('component' in current.route ? current.route.component : MkLoadingPage); const currentPageProps = ref(current.props); const key = ref(current.route.path + JSON.stringify(Object.fromEntries(current.props))); function onChange({ resolved, key: newKey }) { const current = resolveNested(resolved); - if (current == null) return; + if (current == null || 'redirect' in current.route) return; currentPageComponent.value = current.route.component; currentPageProps.value = current.props; key.value = current.route.path + JSON.stringify(Object.fromEntries(current.props)); + + nextTick(() => { + // ページ遷移完了後に再びキャッシュを有効化 + if (clearCacheRequested.value) { + clearCacheRequested.value = false; + } + }); } router.addListener('change', onChange); +// #region キャッシュ制御 + +/** + * キャッシュクリアが有効になったら、全キャッシュをクリアする + * + * keepAlive側にwatcherがあるのですぐ消えるとはおもうけど、念のためページ遷移完了まではキャッシュを無効化しておく。 + * キャッシュ有効時向けにexcludeを使いたい場合は、pageCacheControllerに並列に突っ込むのではなく、下に追記すること + */ +const pageCacheController = computed(() => clearCacheRequested.value ? /.*/ : undefined); +const clearCacheRequested = ref(false); + +globalEvents.on('requestClearPageCache', () => { + if (_DEV_) console.log('clear page cache requested'); + if (!clearCacheRequested.value) { + clearCacheRequested.value = true; + } +}); + +// #endregion + onBeforeUnmount(() => { router.removeListener('change', onChange); }); diff --git a/packages/frontend/src/components/global/i18n.ts b/packages/frontend/src/components/global/i18n.ts deleted file mode 100644 index 2f4d7edabd..0000000000 --- a/packages/frontend/src/components/global/i18n.ts +++ /dev/null @@ -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]())); -} diff --git a/packages/frontend/src/components/index.ts b/packages/frontend/src/components/index.ts index a3e13c3a50..44d8d59941 100644 --- a/packages/frontend/src/components/index.ts +++ b/packages/frontend/src/components/index.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ @@ -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'; diff --git a/packages/frontend/src/components/page/block.type.ts b/packages/frontend/src/components/page/block.type.ts deleted file mode 100644 index cdd39339e6..0000000000 --- a/packages/frontend/src/components/page/block.type.ts +++ /dev/null @@ -1,34 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -export type BlockBase = { - id: string; - type: string; -}; - -export type TextBlock = BlockBase & { - type: 'text'; - text: string; -}; - -export type SectionBlock = BlockBase & { - type: 'section'; - title: string; - children: Block[]; -}; - -export type ImageBlock = BlockBase & { - type: 'image'; - fileId: string | null; -}; - -export type NoteBlock = BlockBase & { - type: 'note'; - detailed: boolean; - note: string | null; -}; - -export type Block = - TextBlock | SectionBlock | ImageBlock | NoteBlock; diff --git a/packages/frontend/src/components/page/page.block.vue b/packages/frontend/src/components/page/page.block.vue index 7dbbaa03b4..164720ac6b 100644 --- a/packages/frontend/src/components/page/page.block.vue +++ b/packages/frontend/src/components/page/page.block.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -14,7 +14,6 @@ import XText from './page.text.vue'; import XSection from './page.section.vue'; import XImage from './page.image.vue'; import XNote from './page.note.vue'; -import { Block } from './block.type.js'; function getComponent(type: string) { switch (type) { @@ -27,7 +26,7 @@ function getComponent(type: string) { } defineProps<{ - block: Block, + block: Misskey.entities.PageBlock, h: number, page: Misskey.entities.Page, }>(); diff --git a/packages/frontend/src/components/page/page.image.vue b/packages/frontend/src/components/page/page.image.vue index 29aebf63e5..ced02943db 100644 --- a/packages/frontend/src/components/page/page.image.vue +++ b/packages/frontend/src/components/page/page.image.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -14,15 +14,19 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ref } from 'vue'; +import { onMounted, ref } from 'vue'; import * as Misskey from 'misskey-js'; -import { ImageBlock } from './block.type.js'; import MediaImage from '@/components/MkMediaImage.vue'; const props = defineProps<{ - block: ImageBlock, + block: Misskey.entities.PageBlock, page: Misskey.entities.Page, }>(); -const image = ref<Misskey.entities.DriveFile>(props.page.attachedFiles.find(x => x.id === props.block.fileId)); +const image = ref<Misskey.entities.DriveFile | null>(null); + +onMounted(() => { + image.value = props.page.attachedFiles.find(x => x.id === props.block.fileId) ?? null; +}); + </script> diff --git a/packages/frontend/src/components/page/page.note.vue b/packages/frontend/src/components/page/page.note.vue index d885ebb1d6..7b56494a6e 100644 --- a/packages/frontend/src/components/page/page.note.vue +++ b/packages/frontend/src/components/page/page.note.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -13,20 +13,20 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { onMounted, ref } from 'vue'; import * as Misskey from 'misskey-js'; -import { NoteBlock } from './block.type.js'; import MkNote from '@/components/MkNote.vue'; import MkNoteDetailed from '@/components/MkNoteDetailed.vue'; -import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; const props = defineProps<{ - block: NoteBlock, + block: Misskey.entities.PageBlock, page: Misskey.entities.Page, }>(); const note = ref<Misskey.entities.Note | null>(null); onMounted(() => { - os.api('notes/show', { noteId: props.block.note }) + if (props.block.note == null) return; + misskeyApi('notes/show', { noteId: props.block.note }) .then(result => { note.value = result; }); diff --git a/packages/frontend/src/components/page/page.section.vue b/packages/frontend/src/components/page/page.section.vue index e4e5a43b59..e3d26d924f 100644 --- a/packages/frontend/src/components/page/page.section.vue +++ b/packages/frontend/src/components/page/page.section.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -25,12 +25,11 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { defineAsyncComponent } from 'vue'; import * as Misskey from 'misskey-js'; -import { SectionBlock } from './block.type.js'; const XBlock = defineAsyncComponent(() => import('./page.block.vue')); defineProps<{ - block: SectionBlock, + block: Misskey.entities.PageBlock, h: number, page: Misskey.entities.Page, }>(); diff --git a/packages/frontend/src/components/page/page.text.vue b/packages/frontend/src/components/page/page.text.vue index c0849a6d42..6a9415e137 100644 --- a/packages/frontend/src/components/page/page.text.vue +++ b/packages/frontend/src/components/page/page.text.vue @@ -1,26 +1,25 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> <template> <div class="_gaps"> - <Mfm :text="block.text" :isNote="false"/> + <Mfm :text="block.text ?? ''" :isNote="false"/> <MkUrlPreview v-for="url in urls" :key="url" :url="url"/> </div> </template> <script lang="ts" setup> import { defineAsyncComponent } from 'vue'; -import * as mfm from '@sharkey/sfm-js'; +import * as mfm from '@transfem-org/sfm-js'; import * as Misskey from 'misskey-js'; -import { TextBlock } from './block.type.js'; import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm.js'; const MkUrlPreview = defineAsyncComponent(() => import('@/components/MkUrlPreview.vue')); const props = defineProps<{ - block: TextBlock, + block: Misskey.entities.PageBlock, page: Misskey.entities.Page, }>(); diff --git a/packages/frontend/src/components/page/page.vue b/packages/frontend/src/components/page/page.vue index 94ca7bdf04..53c70b01f4 100644 --- a/packages/frontend/src/components/page/page.vue +++ b/packages/frontend/src/components/page/page.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> |