diff options
| author | Julia <julia@insertdomain.name> | 2025-05-29 00:07:38 +0000 |
|---|---|---|
| committer | Julia <julia@insertdomain.name> | 2025-05-29 00:07:38 +0000 |
| commit | 6b554c178b81f13f83a69b19d44b72b282a0c119 (patch) | |
| tree | f5537f1a56323a4dd57ba150b3cb84a2d8b5dc63 /packages/frontend/src/components | |
| parent | merge: Security fixes (!970) (diff) | |
| parent | bump version for release (diff) | |
| download | sharkey-6b554c178b81f13f83a69b19d44b72b282a0c119.tar.gz sharkey-6b554c178b81f13f83a69b19d44b72b282a0c119.tar.bz2 sharkey-6b554c178b81f13f83a69b19d44b72b282a0c119.zip | |
merge: release 2025.4.2 (!1051)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/1051
Approved-by: Hazelnoot <acomputerdog@gmail.com>
Approved-by: Marie <github@yuugi.dev>
Approved-by: Julia <julia@insertdomain.name>
Diffstat (limited to 'packages/frontend/src/components')
302 files changed, 5636 insertions, 2972 deletions
diff --git a/packages/frontend/src/components/DynamicNote.vue b/packages/frontend/src/components/DynamicNote.vue index 6703099591..67707bfda9 100644 --- a/packages/frontend/src/components/DynamicNote.vue +++ b/packages/frontend/src/components/DynamicNote.vue @@ -17,21 +17,19 @@ SPDX-License-Identifier: AGPL-3.0-only <script setup lang="ts"> import * as Misskey from 'misskey-js'; -import { computed, defineAsyncComponent, shallowRef } from 'vue'; +import { computed, defineAsyncComponent, useTemplateRef } from 'vue'; import type { ComponentExposed } from 'vue-component-type-helpers'; import type MkNote from '@/components/MkNote.vue'; import type SkNote from '@/components/SkNote.vue'; -import { defaultStore } from '@/store'; +import { prefer } from '@/preferences'; -const XNote = computed(() => - defineAsyncComponent(() => - defaultStore.reactiveState.noteDesign.value === 'misskey' - ? import('@/components/MkNote.vue') - : import('@/components/SkNote.vue'), - ), +const XNote = defineAsyncComponent(() => + prefer.s.noteDesign === 'misskey' + ? import('@/components/MkNote.vue') + : import('@/components/SkNote.vue') ); -const rootEl = shallowRef<ComponentExposed<typeof MkNote | typeof SkNote>>(); +const rootEl = useTemplateRef<ComponentExposed<typeof MkNote | typeof SkNote>>('rootEl'); defineExpose({ rootEl }); diff --git a/packages/frontend/src/components/DynamicNoteDetailed.vue b/packages/frontend/src/components/DynamicNoteDetailed.vue new file mode 100644 index 0000000000..8594db2328 --- /dev/null +++ b/packages/frontend/src/components/DynamicNoteDetailed.vue @@ -0,0 +1,38 @@ +<!-- +SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<XNoteDetailed + ref="rootEl" + :note="note" + :initialTab="initialTab" + :expandAllCws="expandAllCws" +/> +</template> + +<script setup lang="ts"> +import * as Misskey from 'misskey-js'; +import { computed, defineAsyncComponent, useTemplateRef } from 'vue'; +import type { ComponentExposed } from 'vue-component-type-helpers'; +import type MkNoteDetailed from '@/components/MkNoteDetailed.vue'; +import type SkNoteDetailed from '@/components/SkNoteDetailed.vue'; +import { prefer } from '@/preferences'; + +const XNoteDetailed = defineAsyncComponent(() => + prefer.s.noteDesign === 'misskey' + ? import('@/components/MkNoteDetailed.vue') + : import('@/components/SkNoteDetailed.vue'), +); + +const rootEl = useTemplateRef<ComponentExposed<typeof MkNoteDetailed | typeof SkNoteDetailed>>('rootEl'); + +defineExpose({ rootEl }); + +defineProps<{ + note: Misskey.entities.Note; + initialTab?: string; + expandAllCws?: boolean; +}>(); +</script> diff --git a/packages/frontend/src/components/DynamicNoteSimple.vue b/packages/frontend/src/components/DynamicNoteSimple.vue new file mode 100644 index 0000000000..5eaeaf6c23 --- /dev/null +++ b/packages/frontend/src/components/DynamicNoteSimple.vue @@ -0,0 +1,46 @@ +<!-- +SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<XNoteSimple + ref="rootEl" + :note="note" + :expandAllCws="expandAllCws" + :hideFiles="hideFiles" + @editScheduledNote="() => emit('editScheduleNote')" +/> +</template> + +<script setup lang="ts"> +import * as Misskey from 'misskey-js'; +import { computed, defineAsyncComponent, useTemplateRef } from 'vue'; +import type { ComponentExposed } from 'vue-component-type-helpers'; +import type MkNoteSimple from '@/components/MkNoteSimple.vue'; +import type SkNoteSimple from '@/components/SkNoteSimple.vue'; +import { prefer } from '@/preferences'; + +const XNoteSimple = defineAsyncComponent(() => + prefer.s.noteDesign === 'misskey' + ? import('@/components/MkNoteSimple.vue') + : import('@/components/SkNoteSimple.vue'), +); + +const rootEl = useTemplateRef<ComponentExposed<typeof MkNoteSimple | typeof SkNoteSimple>>('rootEl'); + +defineExpose({ rootEl }); + +defineProps<{ + note: Misskey.entities.Note & { + isSchedule?: boolean, + scheduledNoteId?: string + }; + expandAllCws?: boolean; + hideFiles?: boolean; +}>(); + +const emit = defineEmits<{ + (ev: 'editScheduleNote'): void; +}>(); +</script> diff --git a/packages/frontend/src/components/MkAbuseReport.vue b/packages/frontend/src/components/MkAbuseReport.vue index d59c5b2c57..c52fdb898e 100644 --- a/packages/frontend/src/components/MkAbuseReport.vue +++ b/packages/frontend/src/components/MkAbuseReport.vue @@ -35,7 +35,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template #label>{{ i18n.ts.target }}: <MkAcct :user="report.targetUser"/></template> <template #suffix>#{{ report.targetUserId.toUpperCase() }}</template> - <div style="container-type: inline-size;"> + <div style="height: 300px; --MI-stickyTop: 0; --MI-stickyBottom: 0;"> <RouterView :router="targetRouter"/> </div> </MkFolder> @@ -53,7 +53,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template #label>{{ i18n.ts.reporter }}: <MkAcct :user="report.reporter"/></template> <template #suffix>#{{ report.reporterId.toUpperCase() }}</template> - <div style="container-type: inline-size;"> + <div style="height: 300px; --MI-stickyTop: 0; --MI-stickyBottom: 0;"> <RouterView :router="reporterRouter"/> </div> </MkFolder> @@ -88,9 +88,9 @@ import { i18n } from '@/i18n.js'; import { dateString } from '@/filters/date.js'; import MkFolder from '@/components/MkFolder.vue'; import RouterView from '@/components/global/RouterView.vue'; -import { useRouterFactory } from '@/router/supplier'; import MkTextarea from '@/components/MkTextarea.vue'; -import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; +import { createRouter } from '@/router.js'; const props = defineProps<{ report: Misskey.entities.AdminAbuseUserReportsResponse[number]; @@ -100,10 +100,9 @@ const emit = defineEmits<{ (ev: 'resolved', reportId: string): void; }>(); -const routerFactory = useRouterFactory(); -const targetRouter = routerFactory(`/admin/user/${props.report.targetUserId}`); +const targetRouter = createRouter(`/admin/user/${props.report.targetUserId}`); targetRouter.init(); -const reporterRouter = routerFactory(`/admin/user/${props.report.reporterId}`); +const reporterRouter = createRouter(`/admin/user/${props.report.reporterId}`); reporterRouter.init(); const moderationNote = ref(props.report.moderationNote ?? ''); @@ -135,7 +134,7 @@ function forward() { function showMenu(ev: MouseEvent) { os.popupMenu([{ - icon: 'ti ti-id', + icon: 'ti ti-hash', text: 'Copy ID', action: () => { copyToClipboard(props.report.id); diff --git a/packages/frontend/src/components/MkAbuseReportWindow.stories.impl.ts b/packages/frontend/src/components/MkAbuseReportWindow.stories.impl.ts index 9df957f3ec..b62096bbe9 100644 --- a/packages/frontend/src/components/MkAbuseReportWindow.stories.impl.ts +++ b/packages/frontend/src/components/MkAbuseReportWindow.stories.impl.ts @@ -5,7 +5,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { action } from '@storybook/addon-actions'; -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { HttpResponse, http } from 'msw'; import { userDetailed } from '../../.storybook/fakes.js'; import { commonHandlers } from '../../.storybook/mocks.js'; diff --git a/packages/frontend/src/components/MkAbuseReportWindow.vue b/packages/frontend/src/components/MkAbuseReportWindow.vue index a634a748e9..61297fdc76 100644 --- a/packages/frontend/src/components/MkAbuseReportWindow.vue +++ b/packages/frontend/src/components/MkAbuseReportWindow.vue @@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> </I18n> </template> - <MkSpacer :marginMin="20" :marginMax="28"> + <div class="_spacer" style="--MI_SPACER-min: 20px; --MI_SPACER-max: 28px;"> <div class="_gaps_m" :class="$style.root"> <div class=""> <MkTextarea v-model="comment"> @@ -25,12 +25,12 @@ SPDX-License-Identifier: AGPL-3.0-only <MkButton primary full :disabled="comment.length === 0" @click="send">{{ i18n.ts.send }}</MkButton> </div> </div> - </MkSpacer> + </div> </MkWindow> </template> <script setup lang="ts"> -import { ref, shallowRef } from 'vue'; +import { ref, useTemplateRef } from 'vue'; import * as Misskey from 'misskey-js'; import MkWindow from '@/components/MkWindow.vue'; import MkTextarea from '@/components/MkTextarea.vue'; @@ -47,7 +47,7 @@ const emit = defineEmits<{ (ev: 'closed'): void; }>(); -const uiWindow = shallowRef<InstanceType<typeof MkWindow>>(); +const uiWindow = useTemplateRef('uiWindow'); const comment = ref(props.initialComment ?? ''); function send() { diff --git a/packages/frontend/src/components/MkAccountMoved.stories.impl.ts b/packages/frontend/src/components/MkAccountMoved.stories.impl.ts index cad26de6e2..b907b5b25a 100644 --- a/packages/frontend/src/components/MkAccountMoved.stories.impl.ts +++ b/packages/frontend/src/components/MkAccountMoved.stories.impl.ts @@ -5,7 +5,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { action } from '@storybook/addon-actions'; -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { HttpResponse, http } from 'msw'; import { commonHandlers } from '../../.storybook/mocks.js'; import { userDetailed } from '../../.storybook/fakes.js'; diff --git a/packages/frontend/src/components/MkAccountMoved.vue b/packages/frontend/src/components/MkAccountMoved.vue index 0839955d9d..cb8032c019 100644 --- a/packages/frontend/src/components/MkAccountMoved.vue +++ b/packages/frontend/src/components/MkAccountMoved.vue @@ -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 '@@/js/config.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; const user = ref<Misskey.entities.UserLite>(); diff --git a/packages/frontend/src/components/MkAchievements.stories.impl.ts b/packages/frontend/src/components/MkAchievements.stories.impl.ts index 7614da51da..d838997616 100644 --- a/packages/frontend/src/components/MkAchievements.stories.impl.ts +++ b/packages/frontend/src/components/MkAchievements.stories.impl.ts @@ -4,12 +4,12 @@ */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { HttpResponse, http } from 'msw'; import { userDetailed } from '../../.storybook/fakes.js'; import { commonHandlers } from '../../.storybook/mocks.js'; import MkAchievements from './MkAchievements.vue'; -import { ACHIEVEMENT_TYPES } from '@/scripts/achievements.js'; +import { ACHIEVEMENT_TYPES } from '@/utility/achievements.js'; export const Empty = { render(args) { return { diff --git a/packages/frontend/src/components/MkAchievements.vue b/packages/frontend/src/components/MkAchievements.vue index 087ad51fe3..c4ea0a2142 100644 --- a/packages/frontend/src/components/MkAchievements.vue +++ b/packages/frontend/src/components/MkAchievements.vue @@ -55,9 +55,9 @@ 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 { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { ACHIEVEMENT_TYPES, ACHIEVEMENT_BADGES, claimAchievement } from '@/scripts/achievements.js'; +import { ACHIEVEMENT_TYPES, ACHIEVEMENT_BADGES, claimAchievement } from '@/utility/achievements.js'; const props = withDefaults(defineProps<{ user: Misskey.entities.User; diff --git a/packages/frontend/src/components/MkAnalogClock.stories.impl.ts b/packages/frontend/src/components/MkAnalogClock.stories.impl.ts index 270ca40825..a01d91ad20 100644 --- a/packages/frontend/src/components/MkAnalogClock.stories.impl.ts +++ b/packages/frontend/src/components/MkAnalogClock.stories.impl.ts @@ -4,7 +4,7 @@ */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import isChromatic from 'chromatic/isChromatic'; import MkAnalogClock from './MkAnalogClock.vue'; export const Default = { diff --git a/packages/frontend/src/components/MkAnalogClock.vue b/packages/frontend/src/components/MkAnalogClock.vue index c8fa6246e0..eac1ea9534 100644 --- a/packages/frontend/src/components/MkAnalogClock.vue +++ b/packages/frontend/src/components/MkAnalogClock.vue @@ -82,7 +82,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { computed, onMounted, onBeforeUnmount, ref } from 'vue'; import tinycolor from 'tinycolor2'; import { globalEvents } from '@/events.js'; -import { defaultIdlingRenderScheduler } from '@/scripts/idle-render.js'; +import { defaultIdlingRenderScheduler } from '@/utility/idle-render.js'; // https://stackoverflow.com/questions/1878907/how-can-i-find-the-difference-between-two-angles const angleDiff = (a: number, b: number) => { @@ -192,7 +192,7 @@ function tick() { tick(); function calcColors() { - const computedStyle = getComputedStyle(document.documentElement); + const computedStyle = getComputedStyle(window.document.documentElement); const dark = tinycolor(computedStyle.getPropertyValue('--MI_THEME-bg')).isDark(); const accent = tinycolor(computedStyle.getPropertyValue('--MI_THEME-accent')).toHexString(); majorGraduationColor.value = dark ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.3)'; diff --git a/packages/frontend/src/components/MkAnimBg.vue b/packages/frontend/src/components/MkAnimBg.vue index 4bf6125af5..e57fbcdee3 100644 --- a/packages/frontend/src/components/MkAnimBg.vue +++ b/packages/frontend/src/components/MkAnimBg.vue @@ -4,14 +4,14 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<canvas ref="canvasEl" style="width: 100%; height: 100%; pointer-events: none;"></canvas> +<canvas ref="canvasEl" style="display: block; width: 100%; height: 100%; pointer-events: none;"></canvas> </template> <script lang="ts" setup> -import { onMounted, onUnmounted, shallowRef } from 'vue'; +import { onMounted, onUnmounted, useTemplateRef } from 'vue'; import isChromatic from 'chromatic/isChromatic'; -const canvasEl = shallowRef<HTMLCanvasElement>(); +const canvasEl = useTemplateRef('canvasEl'); const props = withDefaults(defineProps<{ scale?: number; diff --git a/packages/frontend/src/components/MkAnnouncementDialog.stories.impl.ts b/packages/frontend/src/components/MkAnnouncementDialog.stories.impl.ts index bf3ddb935b..627cb0c4ff 100644 --- a/packages/frontend/src/components/MkAnnouncementDialog.stories.impl.ts +++ b/packages/frontend/src/components/MkAnnouncementDialog.stories.impl.ts @@ -5,7 +5,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { action } from '@storybook/addon-actions'; -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { HttpResponse, http } from 'msw'; import { commonHandlers } from '../../.storybook/mocks.js'; import MkAnnouncementDialog from './MkAnnouncementDialog.vue'; diff --git a/packages/frontend/src/components/MkAnnouncementDialog.vue b/packages/frontend/src/components/MkAnnouncementDialog.vue index 6c335d71d9..56fd422c56 100644 --- a/packages/frontend/src/components/MkAnnouncementDialog.vue +++ b/packages/frontend/src/components/MkAnnouncementDialog.vue @@ -22,22 +22,23 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, shallowRef } from 'vue'; +import { onMounted, useTemplateRef } from 'vue'; import * as Misskey from 'misskey-js'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import MkModal from '@/components/MkModal.vue'; import MkButton from '@/components/MkButton.vue'; import { i18n } from '@/i18n.js'; -import { $i, updateAccountPartial } from '@/account.js'; +import { $i } from '@/i.js'; +import { updateCurrentAccountPartial } from '@/accounts.js'; const props = withDefaults(defineProps<{ announcement: Misskey.entities.Announcement; }>(), { }); -const rootEl = shallowRef<HTMLDivElement>(); -const modal = shallowRef<InstanceType<typeof MkModal>>(); +const rootEl = useTemplateRef('rootEl'); +const modal = useTemplateRef('modal'); async function ok() { if (props.announcement.needConfirmationToRead) { @@ -51,7 +52,7 @@ async function ok() { modal.value?.close(); misskeyApi('i/read-announcement', { announcementId: props.announcement.id }); - updateAccountPartial({ + updateCurrentAccountPartial({ unreadAnnouncements: $i!.unreadAnnouncements.filter(a => a.id !== props.announcement.id), }); } diff --git a/packages/frontend/src/components/MkAntennaEditor.stories.impl.ts b/packages/frontend/src/components/MkAntennaEditor.stories.impl.ts index 1749e07a4e..4d921a4c48 100644 --- a/packages/frontend/src/components/MkAntennaEditor.stories.impl.ts +++ b/packages/frontend/src/components/MkAntennaEditor.stories.impl.ts @@ -5,7 +5,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { action } from '@storybook/addon-actions'; -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { HttpResponse, http } from 'msw'; import { commonHandlers } from '../../.storybook/mocks.js'; import MkAntennaEditor from './MkAntennaEditor.vue'; diff --git a/packages/frontend/src/components/MkAntennaEditor.vue b/packages/frontend/src/components/MkAntennaEditor.vue index e622d57f1e..e2febf7225 100644 --- a/packages/frontend/src/components/MkAntennaEditor.vue +++ b/packages/frontend/src/components/MkAntennaEditor.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkSpacer :contentMax="700"> +<div class="_spacer" style="--MI_SPACER-w: 700px;"> <div> <div class="_gaps_m"> <MkInput v-model="name"> @@ -39,6 +39,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkSwitch v-model="localOnly">{{ i18n.ts.localOnly }}</MkSwitch> <MkSwitch v-model="caseSensitive">{{ i18n.ts.caseSensitive }}</MkSwitch> <MkSwitch v-model="withFile">{{ i18n.ts.withFileAntenna }}</MkSwitch> + <MkSwitch v-model="excludeNotesInSensitiveChannel">{{ i18n.ts.excludeNotesInSensitiveChannel }}</MkSwitch> </div> <div :class="$style.actions"> <div class="_buttons"> @@ -47,22 +48,22 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> </div> -</MkSpacer> +</div> </template> <script lang="ts" setup> import { watch, ref } from 'vue'; import * as Misskey from 'misskey-js'; +import type { DeepPartial } from '@/utility/merge.js'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; import MkTextarea from '@/components/MkTextarea.vue'; import MkSelect from '@/components/MkSelect.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { deepMerge } from '@/scripts/merge.js'; -import type { DeepPartial } from '@/scripts/merge.js'; +import { deepMerge } from '@/utility/merge.js'; type PartialAllowedAntenna = Omit<Misskey.entities.Antenna, 'id' | 'createdAt' | 'updatedAt'> & { id?: string; @@ -86,6 +87,7 @@ const initialAntenna = deepMerge<PartialAllowedAntenna>(props.antenna ?? {}, { caseSensitive: false, localOnly: false, withFile: false, + excludeNotesInSensitiveChannel: false, isActive: true, hasUnreadNote: false, notify: false, @@ -108,6 +110,7 @@ const localOnly = ref<boolean>(initialAntenna.localOnly); const excludeBots = ref<boolean>(initialAntenna.excludeBots); const withReplies = ref<boolean>(initialAntenna.withReplies); const withFile = ref<boolean>(initialAntenna.withFile); +const excludeNotesInSensitiveChannel = ref<boolean>(initialAntenna.excludeNotesInSensitiveChannel); const userLists = ref<Misskey.entities.UserList[] | null>(null); watch(() => src.value, async () => { @@ -124,6 +127,7 @@ async function saveAntenna() { excludeBots: excludeBots.value, withReplies: withReplies.value, withFile: withFile.value, + excludeNotesInSensitiveChannel: excludeNotesInSensitiveChannel.value, caseSensitive: caseSensitive.value, localOnly: localOnly.value, users: users.value.trim().split('\n').map(x => x.trim()), diff --git a/packages/frontend/src/components/MkAntennaEditorDialog.stories.impl.ts b/packages/frontend/src/components/MkAntennaEditorDialog.stories.impl.ts index 1c6ca83b47..5878b52fb9 100644 --- a/packages/frontend/src/components/MkAntennaEditorDialog.stories.impl.ts +++ b/packages/frontend/src/components/MkAntennaEditorDialog.stories.impl.ts @@ -5,7 +5,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { action } from '@storybook/addon-actions'; -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { HttpResponse, http } from 'msw'; import { commonHandlers } from '../../.storybook/mocks.js'; import MkAntennaEditorDialog from './MkAntennaEditorDialog.vue'; diff --git a/packages/frontend/src/components/MkAntennaEditorDialog.vue b/packages/frontend/src/components/MkAntennaEditorDialog.vue index 6d815d29f3..0ebf5abf4c 100644 --- a/packages/frontend/src/components/MkAntennaEditorDialog.vue +++ b/packages/frontend/src/components/MkAntennaEditorDialog.vue @@ -23,7 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { shallowRef } from 'vue'; +import { useTemplateRef } from 'vue'; import * as Misskey from 'misskey-js'; import MkModalWindow from '@/components/MkModalWindow.vue'; import XAntennaEditor from '@/components/MkAntennaEditor.vue'; @@ -40,7 +40,7 @@ const emit = defineEmits<{ (ev: 'closed'): void, }>(); -const dialog = shallowRef<InstanceType<typeof MkModalWindow>>(); +const dialog = useTemplateRef('dialog'); function onAntennaCreated(newAntenna: Misskey.entities.Antenna) { emit('created', newAntenna); diff --git a/packages/frontend/src/components/MkAsUi.vue b/packages/frontend/src/components/MkAsUi.vue index 564d1fe7e3..86eddbca51 100644 --- a/packages/frontend/src/components/MkAsUi.vue +++ b/packages/frontend/src/components/MkAsUi.vue @@ -63,14 +63,15 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { Ref, ref, computed } from 'vue'; +import { ref, computed } from 'vue'; +import type { Ref } from 'vue'; import * as os from '@/os.js'; import MkButton from '@/components/MkButton.vue'; 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, AsUiRoot, AsUiPostFormButton } from '@/scripts/aiscript/ui.js'; +import type { AsUiComponent, AsUiRoot, AsUiPostFormButton } from '@/aiscript/ui.js'; import MkFolder from '@/components/MkFolder.vue'; import MkPostForm from '@/components/MkPostForm.vue'; diff --git a/packages/frontend/src/components/MkAuthConfirm.vue b/packages/frontend/src/components/MkAuthConfirm.vue index f78d2d38f0..b3331d742b 100644 --- a/packages/frontend/src/components/MkAuthConfirm.vue +++ b/packages/frontend/src/components/MkAuthConfirm.vue @@ -117,14 +117,13 @@ SPDX-License-Identifier: AGPL-3.0-only <script setup lang="ts"> import { ref, computed } from 'vue'; import * as Misskey from 'misskey-js'; - import MkButton from '@/components/MkButton.vue'; - -import { $i, getAccounts, getAccountWithSigninDialog, getAccountWithSignupDialog } from '@/account.js'; +import { $i } from '@/i.js'; +import { getAccounts, getAccountWithSigninDialog, getAccountWithSignupDialog } from '@/accounts.js'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; -import { getProxiedImageUrl } from '@/scripts/media-proxy.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { getProxiedImageUrl } from '@/utility/media-proxy.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; const props = defineProps<{ name?: string; diff --git a/packages/frontend/src/components/MkAutocomplete.stories.impl.ts b/packages/frontend/src/components/MkAutocomplete.stories.impl.ts index ec24b8c240..64ccb708aa 100644 --- a/packages/frontend/src/components/MkAutocomplete.stories.impl.ts +++ b/packages/frontend/src/components/MkAutocomplete.stories.impl.ts @@ -6,13 +6,13 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { action } from '@storybook/addon-actions'; import { expect, userEvent, waitFor, within } from '@storybook/test'; -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { HttpResponse, http } from 'msw'; import { userDetailed } from '../../.storybook/fakes.js'; import { commonHandlers } from '../../.storybook/mocks.js'; import MkAutocomplete from './MkAutocomplete.vue'; import MkInput from './MkInput.vue'; -import { tick } from '@/scripts/test-utils.js'; +import { tick } from '@/utility/test-utils.js'; const common = { render(args) { return { diff --git a/packages/frontend/src/components/MkAutocomplete.vue b/packages/frontend/src/components/MkAutocomplete.vue index a0cd066c06..6608eeaa47 100644 --- a/packages/frontend/src/components/MkAutocomplete.vue +++ b/packages/frontend/src/components/MkAutocomplete.vue @@ -15,12 +15,12 @@ SPDX-License-Identifier: AGPL-3.0-only </li> <li tabindex="-1" :class="$style.item" @click="chooseUser()" @keydown="onKeydown">{{ i18n.ts.selectUser }}</li> </ol> - <ol v-else-if="hashtags.length > 0" ref="suggests" :class="$style.list"> + <ol v-else-if="type === 'hashtag' && hashtags.length > 0" ref="suggests" :class="$style.list"> <li v-for="hashtag in hashtags" tabindex="-1" :class="$style.item" @click="complete(type, hashtag)" @keydown="onKeydown"> <span class="name">{{ hashtag }}</span> </li> </ol> - <ol v-else-if="emojis.length > 0" ref="suggests" :class="$style.list"> + <ol v-else-if="type === 'emoji' || type === 'emojiComplete' && 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" :fallbackToImage="true"/> <MkEmoji v-else :emoji="emoji.emoji" :class="$style.emoji"/> @@ -30,12 +30,12 @@ SPDX-License-Identifier: AGPL-3.0-only <span v-if="emoji.aliasOf" :class="$style.emojiAlias">({{ emoji.aliasOf }})</span> </li> </ol> - <ol v-else-if="mfmTags.length > 0" ref="suggests" :class="$style.list"> + <ol v-else-if="type === 'mfmTag' && mfmTags.length > 0" ref="suggests" :class="$style.list"> <li v-for="tag in mfmTags" tabindex="-1" :class="$style.item" @click="complete(type, tag)" @keydown="onKeydown"> <span>{{ tag }}</span> </li> </ol> - <ol v-else-if="mfmParams.length > 0" ref="suggests" :class="$style.list"> + <ol v-else-if="type === 'mfmParam' && 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> @@ -44,26 +44,60 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts"> -import { markRaw, ref, shallowRef, computed, onUpdated, onMounted, onBeforeUnmount, nextTick, watch } from 'vue'; +import { markRaw, ref, useTemplateRef, computed, onUpdated, onMounted, onBeforeUnmount, nextTick, watch } from 'vue'; import sanitizeHtml from 'sanitize-html'; import { emojilist, getEmojiName } from '@@/js/emojilist.js'; -import contains from '@/scripts/contains.js'; import { char2twemojiFilePath, char2fluentEmojiFilePath, char2tossfaceFilePath } from '@@/js/emoji-base.js'; +import { MFM_TAGS, MFM_PARAMS } from '@@/js/const.js'; +import type { EmojiDef } from '@/utility/search-emoji.js'; +import contains from '@/utility/contains.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 { misskeyApi } from '@/utility/misskey-api.js'; +import { store } from '@/store.js'; import { i18n } from '@/i18n.js'; import { miLocalStorage } from '@/local-storage.js'; import { customEmojis } from '@/custom-emojis.js'; -import { MFM_TAGS, MFM_PARAMS } from '@@/js/const.js'; -import { searchEmoji, EmojiDef } from '@/scripts/search-emoji.js'; +import { searchEmoji, searchEmojiExact } from '@/utility/search-emoji.js'; +import { prefer } from '@/preferences.js'; + +export type CompleteInfo = { + user: { + payload: any; + query: string | null; + }, + hashtag: { + payload: string; + query: string; + }, + // `:emo` -> `:emoji:` or some unicode emoji + emoji: { + payload: string; + query: string; + }, + // like emoji but for `:emoji:` -> unicode emoji + emojiComplete: { + payload: string; + query: string; + }, + mfmTag: { + payload: string; + query: string; + }, + mfmParam: { + payload: string; + query: { + tag: string; + params: string[]; + }; + }, +}; const lib = emojilist.filter(x => x.category !== 'flags'); -const emojiDb = computed(() => { +const unicodeEmojiDB = computed(() => { //#region Unicode Emoji - const char2path = defaultStore.reactiveState.emojiStyle.value === 'twemoji' ? char2twemojiFilePath : defaultStore.reactiveState.emojiStyle.value === 'tossface' ? char2tossfaceFilePath : char2fluentEmojiFilePath; + const char2path = prefer.r.emojiStyle.value === 'twemoji' ? char2twemojiFilePath : prefer.r.emojiStyle.value === 'tossface' ? char2tossfaceFilePath : char2fluentEmojiFilePath; const unicodeEmojiDB: EmojiDef[] = lib.map(x => ({ emoji: x.char, @@ -71,7 +105,7 @@ const emojiDb = computed(() => { url: char2path(x.char), })); - for (const index of Object.values(defaultStore.state.additionalUnicodeEmojiIndexes)) { + for (const index of Object.values(store.s.additionalUnicodeEmojiIndexes)) { for (const [emoji, keywords] of Object.entries(index)) { for (const k of keywords) { unicodeEmojiDB.push({ @@ -85,6 +119,12 @@ const emojiDb = computed(() => { } unicodeEmojiDB.sort((a, b) => a.name.length - b.name.length); + + return unicodeEmojiDB; +}); + +const emojiDb = computed(() => { + //#region Unicode Emoji //#endregion //#region Custom Emoji @@ -112,7 +152,7 @@ const emojiDb = computed(() => { customEmojiDB.sort((a, b) => a.name.length - b.name.length); //#endregion - return markRaw([...customEmojiDB, ...unicodeEmojiDB]); + return markRaw([...customEmojiDB, ...unicodeEmojiDB.value]); }); export default { @@ -121,23 +161,28 @@ export default { }; </script> -<script lang="ts" setup> -const props = defineProps<{ - type: string; - q: any; - textarea: HTMLTextAreaElement; +<script lang="ts" setup generic="T extends keyof CompleteInfo"> +type PropsType<T extends keyof CompleteInfo> = { + type: T; + q: CompleteInfo[T]['query']; + // なぜかわからないけど HTMLTextAreaElement | HTMLInputElement だと addEventListener/removeEventListenerがエラー + textarea: (HTMLTextAreaElement | HTMLInputElement) & HTMLElement; close: () => void; x: number; y: number; -}>(); +}; +//const props = defineProps<PropsType<keyof CompleteInfo>>(); +// ↑と同じだけど↓にしないとdiscriminated unionにならない。 +// https://www.typescriptlang.org/docs/handbook/typescript-in-5-minutes-func.html#discriminated-unions +const props = defineProps<PropsType<'user'> | PropsType<'hashtag'> | PropsType<'emoji'> | PropsType<'emojiComplete'> | PropsType<'mfmTag'> | PropsType<'mfmParam'>>(); const emit = defineEmits<{ - (event: 'done', value: { type: string; value: any }): void; + <T extends keyof CompleteInfo>(event: 'done', value: { type: T; value: CompleteInfo[T]['payload'] }): void; (event: 'closed'): void; }>(); const suggests = ref<Element>(); -const rootEl = shallowRef<HTMLDivElement>(); +const rootEl = useTemplateRef('rootEl'); const fetching = ref(true); const users = ref<any[]>([]); @@ -149,14 +194,14 @@ const mfmParams = ref<string[]>([]); const select = ref(-1); const zIndex = os.claimZIndex('high'); -function complete(type: string, value: any) { +function complete<T extends keyof CompleteInfo>(type: T, value: CompleteInfo[T]['payload']) { emit('done', { type, value }); emit('closed'); - if (type === 'emoji') { - let recents = defaultStore.state.recentlyUsedEmojis; + if (type === 'emoji' || type === 'emojiComplete') { + let recents = store.s.recentlyUsedEmojis; recents = recents.filter((emoji: any) => emoji !== value); recents.unshift(value); - defaultStore.set('recentlyUsedEmojis', recents.splice(0, 32)); + store.set('recentlyUsedEmojis', recents.splice(0, 32)); } } @@ -197,8 +242,10 @@ function exec() { users.value = JSON.parse(cache); fetching.value = false; } else { + const [username, host] = props.q.toString().split('@'); misskeyApi('users/search-by-username-and-host', { - username: props.q, + username: username, + host: host, limit: 10, detail: false, }).then(searchedUsers => { @@ -234,11 +281,13 @@ function exec() { } else if (props.type === 'emoji') { if (!props.q || props.q === '') { // 最近使った絵文字をサジェスト - emojis.value = defaultStore.state.recentlyUsedEmojis.map(emoji => emojiDb.value.find(dbEmoji => dbEmoji.emoji === emoji)).filter(x => x) as EmojiDef[]; + emojis.value = store.s.recentlyUsedEmojis.map(emoji => emojiDb.value.find(dbEmoji => dbEmoji.emoji === emoji)).filter(x => x) as EmojiDef[]; return; } emojis.value = searchEmoji(props.q.normalize('NFC').toLowerCase(), emojiDb.value); + } else if (props.type === 'emojiComplete') { + emojis.value = searchEmojiExact(props.q.normalize('NFC').toLowerCase(), unicodeEmojiDB.value); } else if (props.type === 'mfmTag') { if (!props.q || props.q === '') { mfmTags.value = MFM_TAGS; @@ -355,7 +404,7 @@ onMounted(() => { props.textarea.addEventListener('keydown', onKeydown); - document.body.addEventListener('mousedown', onMousedown); + window.document.body.addEventListener('mousedown', onMousedown); nextTick(() => { exec(); @@ -371,7 +420,7 @@ onMounted(() => { onBeforeUnmount(() => { props.textarea.removeEventListener('keydown', onKeydown); - document.body.removeEventListener('mousedown', onMousedown); + window.document.body.removeEventListener('mousedown', onMousedown); }); </script> @@ -407,7 +456,7 @@ onBeforeUnmount(() => { text-overflow: ellipsis; &:hover { - background: var(--MI_THEME-X3); + background: light-dark(rgba(0, 0, 0, 0.05), rgba(255, 255, 255, 0.05)); } &[data-selected='true'] { @@ -416,7 +465,7 @@ onBeforeUnmount(() => { } &:active { - background: var(--MI_THEME-accentDarken); + background: hsl(from var(--MI_THEME-accent) h s calc(l - 10)); color: #fff !important; } } diff --git a/packages/frontend/src/components/MkAvatars.stories.impl.ts b/packages/frontend/src/components/MkAvatars.stories.impl.ts index d2a4a9f03b..6e20294438 100644 --- a/packages/frontend/src/components/MkAvatars.stories.impl.ts +++ b/packages/frontend/src/components/MkAvatars.stories.impl.ts @@ -4,7 +4,7 @@ */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { HttpResponse, http } from 'msw'; import { userDetailed } from '../../.storybook/fakes.js'; import { commonHandlers } from '../../.storybook/mocks.js'; diff --git a/packages/frontend/src/components/MkAvatars.vue b/packages/frontend/src/components/MkAvatars.vue index 8236d0ddb9..1c44ed60d8 100644 --- a/packages/frontend/src/components/MkAvatars.vue +++ b/packages/frontend/src/components/MkAvatars.vue @@ -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 { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; const props = withDefaults(defineProps<{ userIds: string[]; diff --git a/packages/frontend/src/components/MkButton.stories.impl.ts b/packages/frontend/src/components/MkButton.stories.impl.ts index e8802e4f8f..0a569b3beb 100644 --- a/packages/frontend/src/components/MkButton.stories.impl.ts +++ b/packages/frontend/src/components/MkButton.stories.impl.ts @@ -6,7 +6,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ /* eslint-disable import/no-default-export */ import { action } from '@storybook/addon-actions'; -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import MkButton from './MkButton.vue'; export const Default = { render(args) { diff --git a/packages/frontend/src/components/MkButton.vue b/packages/frontend/src/components/MkButton.vue index a6e5651d63..53453be2c1 100644 --- a/packages/frontend/src/components/MkButton.vue +++ b/packages/frontend/src/components/MkButton.vue @@ -7,11 +7,11 @@ SPDX-License-Identifier: AGPL-3.0-only <button v-if="!link" ref="el" 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 }]" + :class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.accent]: accent, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike, [$style.iconOnly]: iconOnly, [$style.wait]: wait }]" :type="type" :name="name" :value="value" - :disabled="disabled" + :disabled="disabled || wait" @click="emit('click', $event)" @mousedown="onMousedown" > @@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only </button> <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 }]" + :class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.accent]: accent, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike, [$style.iconOnly]: iconOnly, [$style.wait]: wait }]" :to="to ?? '#'" :behavior="linkBehavior" @mousedown="onMousedown" @@ -35,7 +35,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { nextTick, onMounted, shallowRef } from 'vue'; +import { nextTick, onMounted, useTemplateRef } from 'vue'; const props = defineProps<{ type?: 'button' | 'submit' | 'reset'; @@ -48,6 +48,7 @@ const props = defineProps<{ linkBehavior?: null | 'window' | 'browser'; autofocus?: boolean; wait?: boolean; + accent?: boolean; danger?: boolean; full?: boolean; small?: boolean; @@ -57,14 +58,15 @@ const props = defineProps<{ name?: string; value?: string; disabled?: boolean; + iconOnly?: boolean; }>(); const emit = defineEmits<{ (ev: 'click', payload: MouseEvent): void; }>(); -const el = shallowRef<HTMLElement | null>(null); -const ripples = shallowRef<HTMLElement | null>(null); +const el = useTemplateRef('el'); +const ripples = useTemplateRef('ripples'); onMounted(() => { if (props.autofocus) { @@ -91,7 +93,7 @@ function onMousedown(evt: MouseEvent): void { const target = evt.target! as HTMLElement; const rect = target.getBoundingClientRect(); - const ripple = document.createElement('div'); + const ripple = window.document.createElement('div'); ripple.classList.add(ripples.value!.dataset.childrenClass!); ripple.style.top = (evt.clientY - rect.top - 1).toString() + 'px'; ripple.style.left = (evt.clientX - rect.left - 1).toString() + 'px'; @@ -147,6 +149,11 @@ function onMousedown(evt: MouseEvent): void { background: var(--MI_THEME-buttonHoverBg); } + &.iconOnly { + padding: 7px; + min-width: auto; + } + &.small { font-size: 90%; padding: 6px 12px; @@ -220,28 +227,46 @@ function onMousedown(evt: MouseEvent): void { background: linear-gradient(90deg, var(--MI_THEME-buttonGradateA), var(--MI_THEME-buttonGradateB)); &:not(:disabled):hover { - background: linear-gradient(90deg, hsl(from var(--MI_THEME-accent) h s calc(l + 5)), hsl(from var(--MI_THEME-accent) h s calc(l + 5))); + background: linear-gradient(90deg, hsl(from var(--MI_THEME-buttonGradateA) h s calc(l + 5)), hsl(from var(--MI_THEME-buttonGradateB) h s calc(l + 5))); } &:not(:disabled):active { - background: linear-gradient(90deg, hsl(from var(--MI_THEME-accent) h s calc(l + 5)), hsl(from var(--MI_THEME-accent) h s calc(l + 5))); + background: linear-gradient(90deg, hsl(from var(--MI_THEME-buttonGradateA) h s calc(l + 5)), hsl(from var(--MI_THEME-buttonGradateB) h s calc(l + 5))); + } + } + + &.accent { + font-weight: bold; + color: var(--MI_THEME-accent); + + &.primary { + color: #fff; + background: var(--MI_THEME-accent); + + &:not(:disabled):hover { + background: hsl(from var(--MI_THEME-accent) h s calc(l + 10)); + } + + &:not(:disabled):active { + background: hsl(from var(--MI_THEME-accent) h s calc(l - 10)); + } } } &.danger { font-weight: bold; - color: #ff2a2a; + color: var(--MI_THEME-error); &.primary { color: #fff; - background: #ff2a2a; + background: var(--MI_THEME-error); &:not(:disabled):hover { - background: #ff4242; + background: hsl(from var(--MI_THEME-error) h s calc(l + 10)); } &:not(:disabled):active { - background: #d42e2e; + background: hsl(from var(--MI_THEME-error) h s calc(l - 10)); } } } @@ -250,6 +275,10 @@ function onMousedown(evt: MouseEvent): void { opacity: 0.5; } + &.wait { + cursor: wait !important; + } + &:focus-visible { outline-offset: 2px; } diff --git a/packages/frontend/src/components/MkCaptcha.vue b/packages/frontend/src/components/MkCaptcha.vue index aeed90722f..21f604aa43 100644 --- a/packages/frontend/src/components/MkCaptcha.vue +++ b/packages/frontend/src/components/MkCaptcha.vue @@ -26,8 +26,8 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ref, shallowRef, computed, onMounted, onBeforeUnmount, watch, onUnmounted } from 'vue'; -import { defaultStore } from '@/store.js'; +import { ref, useTemplateRef, computed, onMounted, onBeforeUnmount, watch, onUnmounted } from 'vue'; +import { store } from '@/store.js'; // APIs provided by Captcha services // see: https://docs.hcaptcha.com/configuration/#javascript-api @@ -53,6 +53,8 @@ type CaptchaContainer = { }; declare global { + // Window を拡張してるため、空ではない + // eslint-disable-next-line @typescript-eslint/no-empty-object-type interface Window extends CaptchaContainer { } } @@ -70,7 +72,7 @@ const emit = defineEmits<{ const available = ref(false); -const captchaEl = shallowRef<HTMLDivElement | undefined>(); +const captchaEl = useTemplateRef('captchaEl'); const captchaWidgetId = ref<string | undefined>(undefined); const testcaptchaInput = ref(''); const testcaptchaPassed = ref(false); @@ -96,6 +98,7 @@ const src = computed(() => { case 'fc': return 'https://cdn.jsdelivr.net/npm/friendly-challenge@0.9.18/widget.min.js'; case 'mcaptcha': return null; case 'testcaptcha': return null; + default: return null; } }); @@ -115,7 +118,7 @@ watch(() => [props.instanceUrl, props.sitekey, props.secretKey], async () => { if (loaded || props.provider === 'mcaptcha' || props.provider === 'testcaptcha') { available.value = true; } else if (src.value !== null) { - (document.getElementById(scriptId.value) ?? document.head.appendChild(Object.assign(document.createElement('script'), { + (window.document.getElementById(scriptId.value) ?? window.document.head.appendChild(Object.assign(window.document.createElement('script'), { async: true, id: scriptId.value, src: src.value, @@ -152,12 +155,12 @@ async function requestRender() { if (captcha.value.render && captchaEl.value instanceof Element && props.sitekey) { // reCAPTCHAのレンダリング重複判定を回避するため、captchaEl配下に仮のdivを用意する. // (同じdivに対して複数回renderを呼び出すとreCAPTCHAはエラーを返すので) - const elem = document.createElement('div'); + const elem = window.document.createElement('div'); captchaEl.value.appendChild(elem); captchaWidgetId.value = captcha.value.render(elem, { sitekey: props.sitekey, - theme: defaultStore.state.darkMode ? 'dark' : 'light', + theme: store.s.darkMode ? 'dark' : 'light', callback: callback, 'expired-callback': () => callback(undefined), 'error-callback': () => callback(undefined), @@ -185,7 +188,7 @@ async function requestRender() { function clearWidget() { if (props.provider === 'mcaptcha') { - const container = document.getElementById('mcaptcha__widget-container'); + const container = window.document.getElementById('mcaptcha__widget-container'); if (container) { container.innerHTML = ''; } diff --git a/packages/frontend/src/components/MkChannelFollowButton.stories.impl.ts b/packages/frontend/src/components/MkChannelFollowButton.stories.impl.ts index b9770670dc..4304c2e2b7 100644 --- a/packages/frontend/src/components/MkChannelFollowButton.stories.impl.ts +++ b/packages/frontend/src/components/MkChannelFollowButton.stories.impl.ts @@ -2,20 +2,18 @@ * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ - -/* eslint-disable @typescript-eslint/explicit-function-return-type */ -/* eslint-disable import/no-default-export */ -import { StoryObj } from '@storybook/vue3'; + import { HttpResponse, http } from 'msw'; import { action } from '@storybook/addon-actions'; import { expect, userEvent, within } from '@storybook/test'; import { channel } from '../../.storybook/fakes.js'; import { commonHandlers } from '../../.storybook/mocks.js'; import MkChannelFollowButton from './MkChannelFollowButton.vue'; +import type { StoryObj } from '@storybook/vue3'; import { i18n } from '@/i18n.js'; function sleep(ms: number) { - return new Promise(resolve => setTimeout(resolve, ms)); + return new Promise(resolve => window.setTimeout(resolve, ms)); } export const Default = { diff --git a/packages/frontend/src/components/MkChannelFollowButton.vue b/packages/frontend/src/components/MkChannelFollowButton.vue index 7aa916134f..48f2f328f6 100644 --- a/packages/frontend/src/components/MkChannelFollowButton.vue +++ b/packages/frontend/src/components/MkChannelFollowButton.vue @@ -27,7 +27,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref } from 'vue'; import * as Misskey from 'misskey-js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; const props = withDefaults(defineProps<{ @@ -103,13 +103,13 @@ async function onClick() { background: var(--MI_THEME-accent); &:hover { - background: var(--MI_THEME-accentLighten); - border-color: var(--MI_THEME-accentLighten); + background: hsl(from var(--MI_THEME-accent) h s calc(l + 10)); + border-color: hsl(from var(--MI_THEME-accent) h s calc(l + 10)); } &:active { - background: var(--MI_THEME-accentDarken); - border-color: var(--MI_THEME-accentDarken); + background: hsl(from var(--MI_THEME-accent) h s calc(l - 10)); + border-color: hsl(from var(--MI_THEME-accent) h s calc(l - 10)); } } diff --git a/packages/frontend/src/components/MkChannelList.stories.impl.ts b/packages/frontend/src/components/MkChannelList.stories.impl.ts index f69b20c049..47ca864dc0 100644 --- a/packages/frontend/src/components/MkChannelList.stories.impl.ts +++ b/packages/frontend/src/components/MkChannelList.stories.impl.ts @@ -5,7 +5,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ /* eslint-disable import/no-default-export */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { HttpResponse, http } from 'msw'; import { action } from '@storybook/addon-actions'; import { channel } from '../../.storybook/fakes.js'; diff --git a/packages/frontend/src/components/MkChannelList.vue b/packages/frontend/src/components/MkChannelList.vue index 2850ecca16..fdb7d2a1c4 100644 --- a/packages/frontend/src/components/MkChannelList.vue +++ b/packages/frontend/src/components/MkChannelList.vue @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkPagination :pagination="pagination"> <template #empty> <div class="_fullinfo"> - <img :src="infoImageUrl" class="_ghost"/> + <img :src="infoImageUrl" draggable="false"/> <div>{{ i18n.ts.notFound }}</div> </div> </template> @@ -19,8 +19,9 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> +import type { Paging } from '@/components/MkPagination.vue'; import MkChannelPreview from '@/components/MkChannelPreview.vue'; -import MkPagination, { Paging } from '@/components/MkPagination.vue'; +import MkPagination from '@/components/MkPagination.vue'; import { i18n } from '@/i18n.js'; import { infoImageUrl } from '@/instance.js'; diff --git a/packages/frontend/src/components/MkChannelPreview.stories.impl.ts b/packages/frontend/src/components/MkChannelPreview.stories.impl.ts index de0193c78f..dbee069771 100644 --- a/packages/frontend/src/components/MkChannelPreview.stories.impl.ts +++ b/packages/frontend/src/components/MkChannelPreview.stories.impl.ts @@ -5,7 +5,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ /* eslint-disable import/no-default-export */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { channel } from '../../.storybook/fakes.js'; import MkChannelPreview from './MkChannelPreview.vue'; export const Default = { diff --git a/packages/frontend/src/components/MkChart.stories.impl.ts b/packages/frontend/src/components/MkChart.stories.impl.ts index 1bcb9c30d8..3caf01d34e 100644 --- a/packages/frontend/src/components/MkChart.stories.impl.ts +++ b/packages/frontend/src/components/MkChart.stories.impl.ts @@ -5,7 +5,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ /* eslint-disable import/no-default-export */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { http } from 'msw'; import { commonHandlers } from '../../.storybook/mocks.js'; import { getChartResolver } from '../../.storybook/charts.js'; diff --git a/packages/frontend/src/components/MkChart.vue b/packages/frontend/src/components/MkChart.vue index d05f4921f6..7e164362c1 100644 --- a/packages/frontend/src/components/MkChart.vue +++ b/packages/frontend/src/components/MkChart.vue @@ -45,23 +45,19 @@ export type ChartSrc = </script> <script lang="ts" setup> -/* eslint-disable id-denylist -- - Chart.js has a `data` attribute in most chart definitions, which triggers the - id-denylist violation when setting it. This is causing about 60+ lint issues. - As this is part of Chart.js's API it makes sense to disable the check here. -*/ -import { onMounted, ref, shallowRef, watch } from 'vue'; + +import { onMounted, ref, useTemplateRef, watch } from 'vue'; import { Chart } from 'chart.js'; import * as Misskey from 'misskey-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 { misskeyApiGet } from '@/utility/misskey-api.js'; +import { store } from '@/store.js'; +import { useChartTooltip } from '@/use/use-chart-tooltip.js'; +import { chartVLine } from '@/utility/chart-vline.js'; +import { alpha } from '@/utility/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 { initChart } from '@/utility/init-chart.js'; +import { chartLegend } from '@/utility/chart-legend.js'; import MkChartLegend from '@/components/MkChartLegend.vue'; initChart(); @@ -96,7 +92,7 @@ const props = withDefaults(defineProps<{ nowForChromatic: undefined, }); -const legendEl = shallowRef<InstanceType<typeof MkChartLegend>>(); +const legendEl = useTemplateRef('legendEl'); const sum = (...arr) => arr.reduce((r, a) => r.map((b, i) => a[i] + b)); const negate = arr => arr.map(x => -x); @@ -134,7 +130,7 @@ let chartData: { bytes?: boolean; } | null = null; -const chartEl = shallowRef<HTMLCanvasElement | null>(null); +const chartEl = useTemplateRef('chartEl'); const fetching = ref(true); const getDate = (ago: number) => { @@ -161,7 +157,7 @@ const render = () => { chartInstance.destroy(); } - const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; + const vLineColor = store.s.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; const maxes = chartData.series.map((x, i) => Math.max(...x.data.map(d => d.y))); @@ -849,7 +845,7 @@ watch(() => [props.src, props.span], fetchAndRender); onMounted(() => { fetchAndRender(); }); -/* eslint-enable id-denylist */ + </script> <style lang="scss" module> diff --git a/packages/frontend/src/components/MkChartLegend.vue b/packages/frontend/src/components/MkChartLegend.vue index e28d6ad6ba..ed0c3412d2 100644 --- a/packages/frontend/src/components/MkChartLegend.vue +++ b/packages/frontend/src/components/MkChartLegend.vue @@ -14,7 +14,8 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { shallowRef } from 'vue'; -import { Chart, LegendItem } from 'chart.js'; +import { Chart } from 'chart.js'; +import type { LegendItem } from 'chart.js'; const chart = shallowRef<Chart>(); const type = shallowRef<string>(); diff --git a/packages/frontend/src/components/MkChatHistories.stories.impl.ts b/packages/frontend/src/components/MkChatHistories.stories.impl.ts new file mode 100644 index 0000000000..8268adc36f --- /dev/null +++ b/packages/frontend/src/components/MkChatHistories.stories.impl.ts @@ -0,0 +1,45 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { http, HttpResponse } from 'msw'; +import { action } from '@storybook/addon-actions'; +import { chatMessage } from '../../.storybook/fakes'; +import MkChatHistories from './MkChatHistories.vue'; +import type { StoryObj } from '@storybook/vue3'; +import type * as Misskey from 'misskey-js'; +export const Default = { + render(args) { + return { + components: { + MkChatHistories, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '<MkChatHistories v-bind="props" />', + }; + }, + parameters: { + layout: 'centered', + msw: { + handlers: [ + http.post('/api/chat/history', async ({ request }) => { + const body = await request.json() as Misskey.entities.ChatHistoryRequest; + action('POST /api/chat/history')(body); + return HttpResponse.json([chatMessage(body.room)]); + }), + ], + }, + }, +} satisfies StoryObj<typeof MkChatHistories>; diff --git a/packages/frontend/src/components/MkChatHistories.vue b/packages/frontend/src/components/MkChatHistories.vue new file mode 100644 index 0000000000..c508ea8451 --- /dev/null +++ b/packages/frontend/src/components/MkChatHistories.vue @@ -0,0 +1,208 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div v-if="history.length > 0" class="_gaps_s"> + <MkA + v-for="item in history" + :key="item.id" + :class="[$style.message, { [$style.isMe]: item.isMe, [$style.isRead]: item.message.isRead }]" + class="_panel" + :to="item.message.toRoomId ? `/chat/room/${item.message.toRoomId}` : `/chat/user/${item.other!.id}`" + > + <MkAvatar v-if="item.message.toRoomId" :class="$style.messageAvatar" :user="item.message.fromUser" indicator :preview="false"/> + <MkAvatar v-else-if="item.other" :class="$style.messageAvatar" :user="item.other" indicator :preview="false"/> + <div :class="$style.messageBody"> + <header v-if="item.message.toRoom" :class="$style.messageHeader"> + <span :class="$style.messageHeaderName"><i class="ti ti-users"></i> {{ item.message.toRoom.name }}</span> + <MkTime :time="item.message.createdAt" :class="$style.messageHeaderTime"/> + </header> + <header v-else :class="$style.messageHeader"> + <MkUserName :class="$style.messageHeaderName" :user="item.other!"/> + <MkAcct :class="$style.messageHeaderUsername" :user="item.other!"/> + <MkTime :time="item.message.createdAt" :class="$style.messageHeaderTime"/> + </header> + <div :class="$style.messageBodyText"><span v-if="item.isMe" :class="$style.youSaid">{{ i18n.ts.you }}:</span>{{ item.message.text }}</div> + </div> + </MkA> +</div> +<div v-if="!initializing && history.length == 0" class="_fullinfo"> + <div>{{ i18n.ts._chat.noHistory }}</div> +</div> +<MkLoading v-if="initializing"/> +</template> + +<script lang="ts" setup> +import { onActivated, onDeactivated, onMounted, ref } from 'vue'; +import * as Misskey from 'misskey-js'; +import { useInterval } from '@@/js/use-interval.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { i18n } from '@/i18n.js'; +import { ensureSignin } from '@/i.js'; + +const $i = ensureSignin(); + +const history = ref<{ + id: string; + message: Misskey.entities.ChatMessage; + other: Misskey.entities.ChatMessage['fromUser'] | Misskey.entities.ChatMessage['toUser'] | null; + isMe: boolean; +}[]>([]); + +const initializing = ref(true); +const fetching = ref(false); + +async function fetchHistory() { + if (fetching.value) return; + + fetching.value = true; + + const [userMessages, roomMessages] = await Promise.all([ + misskeyApi('chat/history', { room: false }), + misskeyApi('chat/history', { room: true }), + ]); + + history.value = [...userMessages, ...roomMessages] + .toSorted((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) + .map(m => ({ + id: m.id, + message: m, + other: (!('room' in m) || m.room == null) ? (m.fromUserId === $i.id ? m.toUser : m.fromUser) : null, + isMe: m.fromUserId === $i.id, + })); + + fetching.value = false; + initializing.value = false; +} + +let isActivated = true; + +onActivated(() => { + isActivated = true; +}); + +onDeactivated(() => { + isActivated = false; +}); + +useInterval(() => { + // TODO: DOM的にバックグラウンドになっていないかどうかも考慮する + if (!window.document.hidden && isActivated) { + fetchHistory(); + } +}, 1000 * 10, { + immediate: false, + afterMounted: true, +}); + +onActivated(() => { + fetchHistory(); +}); + +onMounted(() => { + fetchHistory(); +}); +</script> + +<style lang="scss" module> +.message { + position: relative; + display: flex; + padding: 16px 24px; + + &.isRead, + &.isMe { + opacity: 0.8; + } + + &:not(.isMe):not(.isRead) { + &::before { + content: ''; + position: absolute; + top: 8px; + right: 8px; + width: 8px; + height: 8px; + border-radius: 100%; + background-color: var(--MI_THEME-accent); + } + } +} + +@container (max-width: 500px) { + .message { + font-size: 90%; + padding: 14px 20px; + } +} + +@container (max-width: 450px) { + .message { + font-size: 80%; + padding: 12px 16px; + } +} + +.messageAvatar { + width: 50px; + height: 50px; + margin: 0 16px 0 0; +} + +@container (max-width: 500px) { + .messageAvatar { + width: 45px; + height: 45px; + } +} + +@container (max-width: 450px) { + .messageAvatar { + width: 40px; + height: 40px; + } +} + +.messageBody { + flex: 1; + min-width: 0; +} + +.messageHeader { + display: flex; + align-items: center; + margin-bottom: 2px; + white-space: nowrap; + overflow: clip; +} + +.messageHeaderName { + margin: 0; + padding: 0; + overflow: hidden; + text-overflow: ellipsis; + font-size: 1em; + font-weight: bold; +} + +.messageHeaderUsername { + margin: 0 8px; +} + +.messageHeaderTime { + margin-left: auto; +} + +.messageBodyText { + overflow: hidden; + overflow-wrap: break-word; + font-size: 1.1em; +} + +.youSaid { + font-weight: bold; + margin-right: 0.5em; +} +</style> diff --git a/packages/frontend/src/components/MkClickerGame.stories.impl.ts b/packages/frontend/src/components/MkClickerGame.stories.impl.ts index 36313f965d..6e1eb13d61 100644 --- a/packages/frontend/src/components/MkClickerGame.stories.impl.ts +++ b/packages/frontend/src/components/MkClickerGame.stories.impl.ts @@ -2,18 +2,16 @@ * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ - -/* eslint-disable @typescript-eslint/explicit-function-return-type */ -/* eslint-disable import/no-default-export */ -import { StoryObj } from '@storybook/vue3'; + import { HttpResponse, http } from 'msw'; import { action } from '@storybook/addon-actions'; import { expect, userEvent, within } from '@storybook/test'; import { commonHandlers } from '../../.storybook/mocks.js'; import MkClickerGame from './MkClickerGame.vue'; +import type { StoryObj } from '@storybook/vue3'; function sleep(ms: number) { - return new Promise(resolve => setTimeout(resolve, ms)); + return new Promise(resolve => window.setTimeout(resolve, ms)); } export const Default = { diff --git a/packages/frontend/src/components/MkClickerGame.vue b/packages/frontend/src/components/MkClickerGame.vue index 9a0a9fba05..775964af50 100644 --- a/packages/frontend/src/components/MkClickerGame.vue +++ b/packages/frontend/src/components/MkClickerGame.vue @@ -23,9 +23,9 @@ import { computed, onMounted, onUnmounted, ref } from 'vue'; import MkPlusOneEffect from '@/components/MkPlusOneEffect.vue'; import * as os from '@/os.js'; import { useInterval } from '@@/js/use-interval.js'; -import * as game from '@/scripts/clicker-game.js'; +import * as game from '@/utility/clicker-game.js'; import number from '@/filters/number.js'; -import { claimAchievement } from '@/scripts/achievements.js'; +import { claimAchievement } from '@/utility/achievements.js'; const saveData = game.saveData; const cookies = computed(() => saveData.value?.cookies); diff --git a/packages/frontend/src/components/MkClipPreview.stories.impl.ts b/packages/frontend/src/components/MkClipPreview.stories.impl.ts index 62503fb98a..496dc09eed 100644 --- a/packages/frontend/src/components/MkClipPreview.stories.impl.ts +++ b/packages/frontend/src/components/MkClipPreview.stories.impl.ts @@ -5,7 +5,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ /* eslint-disable import/no-default-export */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { clip } from '../../.storybook/fakes.js'; import MkClipPreview from './MkClipPreview.vue'; export const Default = { diff --git a/packages/frontend/src/components/MkClipPreview.vue b/packages/frontend/src/components/MkClipPreview.vue index 5b09ec90dd..2154c08ab3 100644 --- a/packages/frontend/src/components/MkClipPreview.vue +++ b/packages/frontend/src/components/MkClipPreview.vue @@ -26,7 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only import * as Misskey from 'misskey-js'; import { computed } from 'vue'; import { i18n } from '@/i18n.js'; -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; import number from '@/filters/number.js'; const props = withDefaults(defineProps<{ diff --git a/packages/frontend/src/components/MkCode.core.vue b/packages/frontend/src/components/MkCode.core.vue index e253b1b55f..40f41f5d0f 100644 --- a/packages/frontend/src/components/MkCode.core.vue +++ b/packages/frontend/src/components/MkCode.core.vue @@ -12,8 +12,8 @@ SPDX-License-Identifier: AGPL-3.0-only import { computed, ref, watch } from 'vue'; import { bundledLanguagesInfo } from 'shiki/langs'; import type { BundledLanguage } from 'shiki/langs'; -import { getHighlighter, getTheme } from '@/scripts/code-highlighter.js'; -import { defaultStore } from '@/store.js'; +import { getHighlighter, getTheme } from '@/utility/code-highlighter.js'; +import { store } from '@/store.js'; const props = defineProps<{ code: string; @@ -22,7 +22,7 @@ const props = defineProps<{ }>(); const highlighter = await getHighlighter(); -const darkMode = defaultStore.reactiveState.darkMode; +const darkMode = store.r.darkMode; const codeLang = ref<BundledLanguage | 'aiscript'>('js'); const [lightThemeName, darkThemeName] = await Promise.all([ @@ -93,7 +93,7 @@ watch(() => props.lang, (to) => { .codeBlockRoot :global(.shiki) { padding: 1em; - margin: .5em 0; + margin: 0; overflow: auto; border-radius: var(--MI-radius-sm); border: 1px solid var(--MI_THEME-divider); diff --git a/packages/frontend/src/components/MkCode.stories.impl.ts b/packages/frontend/src/components/MkCode.stories.impl.ts index b7e53e8e35..fae9d459fb 100644 --- a/packages/frontend/src/components/MkCode.stories.impl.ts +++ b/packages/frontend/src/components/MkCode.stories.impl.ts @@ -5,7 +5,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ /* eslint-disable import/no-default-export */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import MkCode from './MkCode.vue'; const code = `for (let i, 100) { <: if (i % 15 == 0) "FizzBuzz" diff --git a/packages/frontend/src/components/MkCode.vue b/packages/frontend/src/components/MkCode.vue index a46b220101..d79ecc7302 100644 --- a/packages/frontend/src/components/MkCode.vue +++ b/packages/frontend/src/components/MkCode.vue @@ -10,10 +10,10 @@ SPDX-License-Identifier: AGPL-3.0-only </button> <Suspense> <template #fallback> - <MkLoading /> + <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> + <XCode v-if="show && lang" class="_selectable" :code="code" :lang="lang"/> + <pre v-else-if="show" class="_selectable" :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="ti ti-code"></i> {{ i18n.ts.code }}</div> @@ -28,9 +28,9 @@ SPDX-License-Identifier: AGPL-3.0-only 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'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; +import { prefer } from '@/preferences.js'; const props = withDefaults(defineProps<{ code: string; @@ -42,13 +42,12 @@ const props = withDefaults(defineProps<{ forceShow: false, }); -const show = ref(props.forceShow === true ? true : !defaultStore.state.dataSaver.code); +const show = ref(props.forceShow === true ? true : !prefer.s.dataSaver.code); const XCode = defineAsyncComponent(() => import('@/components/MkCode.core.vue')); function copy() { copyToClipboard(props.code); - os.success(); } </script> @@ -71,11 +70,9 @@ function copy() { .codeBlockFallbackRoot { display: block; overflow-wrap: anywhere; - background: var(--MI_THEME-bg); padding: 1em; - margin: .5em 0; + margin: 0; overflow: auto; - border-radius: var(--MI-radius-sm); } .codeBlockFallbackCode { diff --git a/packages/frontend/src/components/MkCodeEditor.stories.impl.ts b/packages/frontend/src/components/MkCodeEditor.stories.impl.ts index 5c410c4886..c76b6fd08e 100644 --- a/packages/frontend/src/components/MkCodeEditor.stories.impl.ts +++ b/packages/frontend/src/components/MkCodeEditor.stories.impl.ts @@ -5,7 +5,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ /* eslint-disable import/no-default-export */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { action } from '@storybook/addon-actions'; import MkCodeEditor from './MkCodeEditor.vue'; const code = `for (let i, 100) { diff --git a/packages/frontend/src/components/MkCodeEditor.vue b/packages/frontend/src/components/MkCodeEditor.vue index 49b083815a..766d1535ce 100644 --- a/packages/frontend/src/components/MkCodeEditor.vue +++ b/packages/frontend/src/components/MkCodeEditor.vue @@ -32,7 +32,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ref, watch, toRefs, shallowRef, nextTick } from 'vue'; +import { ref, watch, toRefs, useTemplateRef, nextTick } from 'vue'; import { debounce } from 'throttle-debounce'; import MkButton from '@/components/MkButton.vue'; import { i18n } from '@/i18n.js'; @@ -61,7 +61,7 @@ const { modelValue } = toRefs(props); const v = ref<string>(modelValue.value ?? ''); const focused = ref(false); const changed = ref(false); -const inputEl = shallowRef<HTMLTextAreaElement>(); +const inputEl = useTemplateRef('inputEl'); const focus = () => inputEl.value?.focus(); @@ -140,7 +140,7 @@ watch(v, newValue => { .caption { font-size: 0.85em; padding: 8px 0 0 0; - color: var(--MI_THEME-fgTransparentWeak); + color: color(from var(--MI_THEME-fg) srgb r g b / 0.75); &:empty { display: none; diff --git a/packages/frontend/src/components/MkCodeInline.stories.impl.ts b/packages/frontend/src/components/MkCodeInline.stories.impl.ts index 51d4d106ff..c17be177cb 100644 --- a/packages/frontend/src/components/MkCodeInline.stories.impl.ts +++ b/packages/frontend/src/components/MkCodeInline.stories.impl.ts @@ -5,7 +5,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ /* eslint-disable import/no-default-export */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import MkCodeInline from './MkCodeInline.vue'; export const Default = { render(args) { diff --git a/packages/frontend/src/components/MkColorInput.stories.impl.ts b/packages/frontend/src/components/MkColorInput.stories.impl.ts index 61383e2cae..3df92ca858 100644 --- a/packages/frontend/src/components/MkColorInput.stories.impl.ts +++ b/packages/frontend/src/components/MkColorInput.stories.impl.ts @@ -5,7 +5,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ /* eslint-disable import/no-default-export */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { action } from '@storybook/addon-actions'; import MkColorInput from './MkColorInput.vue'; export const Default = { diff --git a/packages/frontend/src/components/MkColorInput.vue b/packages/frontend/src/components/MkColorInput.vue index babf356942..c2085df4f2 100644 --- a/packages/frontend/src/components/MkColorInput.vue +++ b/packages/frontend/src/components/MkColorInput.vue @@ -19,12 +19,15 @@ SPDX-License-Identifier: AGPL-3.0-only @input="onInput" > </div> + <MkButton @click="removeColor">{{ i18n.ts.reset }}</MkButton> <div :class="$style.caption"><slot name="caption"></slot></div> </div> </template> <script lang="ts" setup> -import { ref, shallowRef, toRefs } from 'vue'; +import { ref, useTemplateRef, toRefs } from 'vue'; +import MkButton from '@/components/MkButton.vue'; +import { i18n } from '@/i18n.js'; const props = defineProps<{ modelValue: string | null; @@ -39,11 +42,15 @@ const emit = defineEmits<{ const { modelValue } = toRefs(props); const v = ref(modelValue.value); -const inputEl = shallowRef<HTMLElement>(); +const inputEl = useTemplateRef('inputEl'); const onInput = () => { emit('update:modelValue', v.value ?? ''); }; +const removeColor = () => { + v.value = null; + onInput(); +}; </script> <style lang="scss" module> @@ -60,7 +67,7 @@ const onInput = () => { .caption { font-size: 0.85em; padding: 8px 0 0 0; - color: var(--MI_THEME-fgTransparentWeak); + color: color(from var(--MI_THEME-fg) srgb r g b / 0.75); &:empty { display: none; diff --git a/packages/frontend/src/components/MkContainer.vue b/packages/frontend/src/components/MkContainer.vue index 30a9b26bef..3a65dc42d3 100644 --- a/packages/frontend/src/components/MkContainer.vue +++ b/packages/frontend/src/components/MkContainer.vue @@ -19,16 +19,16 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </header> <Transition - :enterActiveClass="defaultStore.state.animation ? $style.transition_toggle_enterActive : ''" - :leaveActiveClass="defaultStore.state.animation ? $style.transition_toggle_leaveActive : ''" - :enterFromClass="defaultStore.state.animation ? $style.transition_toggle_enterFrom : ''" - :leaveToClass="defaultStore.state.animation ? $style.transition_toggle_leaveTo : ''" + :enterActiveClass="prefer.s.animation ? $style.transition_toggle_enterActive : ''" + :leaveActiveClass="prefer.s.animation ? $style.transition_toggle_leaveActive : ''" + :enterFromClass="prefer.s.animation ? $style.transition_toggle_enterFrom : ''" + :leaveToClass="prefer.s.animation ? $style.transition_toggle_leaveTo : ''" @enter="enter" @afterEnter="afterEnter" @leave="leave" @afterLeave="afterLeave" > - <div v-show="showBody" ref="contentEl" :class="[$style.content, { [$style.omitted]: omitted }]"> + <div v-show="showBody" ref="contentEl" :class="[$style.content, { [$style.omitted]: omitted, [$style.naked]: naked }]"> <slot></slot> <button v-if="omitted" :class="$style.fade" class="_button" @click="showMore"> <span :class="$style.fadeLabel">{{ i18n.ts.showMore }}</span> @@ -39,8 +39,8 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, onUnmounted, ref, shallowRef, watch } from 'vue'; -import { defaultStore } from '@/store.js'; +import { onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue'; +import { prefer } from '@/preferences.js'; import { i18n } from '@/i18n.js'; const props = withDefaults(defineProps<{ @@ -58,9 +58,9 @@ const props = withDefaults(defineProps<{ maxHeight: null, }); -const rootEl = shallowRef<HTMLElement>(); -const contentEl = shallowRef<HTMLElement>(); -const headerEl = shallowRef<HTMLElement>(); +const rootEl = useTemplateRef('rootEl'); +const contentEl = useTemplateRef('contentEl'); +const headerEl = useTemplateRef('headerEl'); const showBody = ref(props.expanded); const ignoreOmit = ref(false); const omitted = ref(false); @@ -180,11 +180,15 @@ onUnmounted(() => { top: var(--MI-stickyTop, 0px); left: 0; color: var(--MI_THEME-panelHeaderFg); - background: var(--MI_THEME-panelHeaderBg); - border-bottom: solid 0.5px var(--MI_THEME-panelHeaderDivider); + background: color-mix(in srgb, var(--MI_THEME-panelHeaderBg) 35%, transparent); z-index: 2; line-height: 1.4em; - background: color-mix(in srgb, var(--MI_THEME-panelHeaderBg) 35%, transparent); +} + +@container style(--MI_THEME-panelHeaderBg: var(--MI_THEME-panel)) { + .header { + box-shadow: 0 0.5px 0 0 light-dark(#0002, #fff2); + } } .title { @@ -216,6 +220,19 @@ onUnmounted(() => { .content { --MI-stickyTop: 0px; + /* + 理屈は知らないけど、ここでbackgroundを設定しておかないと + スクロールコンテナーが少なくともChromeにおいて + main thread scrolling になってしまい、パフォーマンスが(多分)落ちる。 + backgroundが透明だと裏側を描画しないといけなくなるとかそういう理由かもしれない + */ + background: var(--MI_THEME-panel); + + &.naked { + background: transparent !important; + box-shadow: none !important; + } + &.omitted { position: relative; max-height: var(--maxHeight); diff --git a/packages/frontend/src/components/MkContextMenu.stories.impl.ts b/packages/frontend/src/components/MkContextMenu.stories.impl.ts index 1ff0f51bd4..7a5e36131b 100644 --- a/packages/frontend/src/components/MkContextMenu.stories.impl.ts +++ b/packages/frontend/src/components/MkContextMenu.stories.impl.ts @@ -5,7 +5,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ /* eslint-disable import/no-default-export */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { userEvent, within } from '@storybook/test'; import MkContextMenu from './MkContextMenu.vue'; import * as os from '@/os.js'; diff --git a/packages/frontend/src/components/MkContextMenu.vue b/packages/frontend/src/components/MkContextMenu.vue index f51fefa0c0..9c6397a72c 100644 --- a/packages/frontend/src/components/MkContextMenu.vue +++ b/packages/frontend/src/components/MkContextMenu.vue @@ -6,10 +6,10 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <Transition appear - :enterActiveClass="defaultStore.state.animation ? $style.transition_fade_enterActive : ''" - :leaveActiveClass="defaultStore.state.animation ? $style.transition_fade_leaveActive : ''" - :enterFromClass="defaultStore.state.animation ? $style.transition_fade_enterFrom : ''" - :leaveToClass="defaultStore.state.animation ? $style.transition_fade_leaveTo : ''" + :enterActiveClass="prefer.s.animation ? $style.transition_fade_enterActive : ''" + :leaveActiveClass="prefer.s.animation ? $style.transition_fade_leaveActive : ''" + :enterFromClass="prefer.s.animation ? $style.transition_fade_enterFrom : ''" + :leaveToClass="prefer.s.animation ? $style.transition_fade_leaveTo : ''" > <div ref="rootEl" :class="$style.root" :style="{ zIndex }" @contextmenu.prevent.stop="() => {}"> <MkMenu :items="items" :align="'left'" @close="emit('closed')"/> @@ -18,11 +18,11 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, onBeforeUnmount, shallowRef, ref } from 'vue'; +import { onMounted, onBeforeUnmount, useTemplateRef, ref } from 'vue'; import MkMenu from './MkMenu.vue'; import type { MenuItem } from '@/types/menu.js'; -import contains from '@/scripts/contains.js'; -import { defaultStore } from '@/store.js'; +import contains from '@/utility/contains.js'; +import { prefer } from '@/preferences.js'; import * as os from '@/os.js'; const props = defineProps<{ @@ -34,7 +34,7 @@ const emit = defineEmits<{ (ev: 'closed'): void; }>(); -const rootEl = shallowRef<HTMLDivElement>(); +const rootEl = useTemplateRef('rootEl'); const zIndex = ref<number>(os.claimZIndex('high')); @@ -68,11 +68,11 @@ onMounted(() => { rootEl.value.style.left = `${left}px`; } - document.body.addEventListener('mousedown', onMousedown); + window.document.body.addEventListener('mousedown', onMousedown); }); onBeforeUnmount(() => { - document.body.removeEventListener('mousedown', onMousedown); + window.document.body.removeEventListener('mousedown', onMousedown); }); function onMousedown(evt: Event) { diff --git a/packages/frontend/src/components/MkCropperDialog.stories.impl.ts b/packages/frontend/src/components/MkCropperDialog.stories.impl.ts index ce13093975..78cb4120de 100644 --- a/packages/frontend/src/components/MkCropperDialog.stories.impl.ts +++ b/packages/frontend/src/components/MkCropperDialog.stories.impl.ts @@ -3,14 +3,12 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -/* eslint-disable @typescript-eslint/explicit-function-return-type */ -/* eslint-disable import/no-default-export */ -import { StoryObj } from '@storybook/vue3'; import { HttpResponse, http } from 'msw'; import { action } from '@storybook/addon-actions'; import { file } from '../../.storybook/fakes.js'; import { commonHandlers } from '../../.storybook/mocks.js'; import MkCropperDialog from './MkCropperDialog.vue'; +import type { StoryObj } from '@storybook/vue3'; export const Default = { render(args) { return { @@ -55,7 +53,7 @@ export const Default = { http.get('/proxy/image.webp', async ({ request }) => { const url = new URL(request.url).searchParams.get('url'); if (url === 'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/fedi.jpg?raw=true') { - const image = await (await fetch('client-assets/fedi.jpg')).blob(); + const image = await (await window.fetch('client-assets/fedi.jpg')).blob(); return new HttpResponse(image, { headers: { 'Content-Type': 'image/jpeg', diff --git a/packages/frontend/src/components/MkCropperDialog.vue b/packages/frontend/src/components/MkCropperDialog.vue index 0186cfc2c0..5012980992 100644 --- a/packages/frontend/src/components/MkCropperDialog.vue +++ b/packages/frontend/src/components/MkCropperDialog.vue @@ -31,17 +31,17 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, shallowRef, ref } from 'vue'; +import { onMounted, useTemplateRef, ref } from 'vue'; import * as Misskey from 'misskey-js'; import Cropper from 'cropperjs'; import tinycolor from 'tinycolor2'; +import { apiUrl } from '@@/js/config.js'; import MkModalWindow from '@/components/MkModalWindow.vue'; import * as os from '@/os.js'; -import { $i } from '@/account.js'; -import { defaultStore } from '@/store.js'; -import { apiUrl } from '@@/js/config.js'; +import { $i } from '@/i.js'; import { i18n } from '@/i18n.js'; -import { getProxiedImageUrl } from '@/scripts/media-proxy.js'; +import { getProxiedImageUrl } from '@/utility/media-proxy.js'; +import { prefer } from '@/preferences.js'; const emit = defineEmits<{ (ev: 'ok', cropped: Misskey.entities.DriveFile): void; @@ -56,8 +56,8 @@ const props = defineProps<{ }>(); const imgUrl = getProxiedImageUrl(props.file.url, undefined, true); -const dialogEl = shallowRef<InstanceType<typeof MkModalWindow>>(); -const imgEl = shallowRef<HTMLImageElement>(); +const dialogEl = useTemplateRef('dialogEl'); +const imgEl = useTemplateRef('imgEl'); let cropper: Cropper | null = null; const loading = ref(true); @@ -73,21 +73,24 @@ const ok = async () => { const croppedCanvas = await croppedSection?.$toCanvas({ width: widthToRender }); croppedCanvas?.toBlob(blob => { if (!blob) return; + if (!$i) return; const formData = new FormData(); formData.append('file', blob); formData.append('name', `cropped_${props.file.name}`); formData.append('isSensitive', props.file.isSensitive ? 'true' : 'false'); if (props.file.comment) { formData.append('comment', props.file.comment);} - formData.append('i', $i!.token); if (props.uploadFolder) { formData.append('folderId', props.uploadFolder); - } else if (props.uploadFolder !== null && defaultStore.state.uploadFolder) { - formData.append('folderId', defaultStore.state.uploadFolder); + } else if (props.uploadFolder !== null && prefer.s.uploadFolder) { + formData.append('folderId', prefer.s.uploadFolder); } window.fetch(apiUrl + '/drive/files/create', { method: 'POST', body: formData, + headers: { + 'Authorization': `Bearer ${$i.token}`, + }, }) .then(response => response.json()) .then(f => { @@ -122,7 +125,7 @@ onMounted(() => { cropper = new Cropper(imgEl.value!, { }); - const computedStyle = getComputedStyle(document.documentElement); + const computedStyle = getComputedStyle(window.document.documentElement); const selection = cropper.getCropperSelection()!; selection.themeColor = tinycolor(computedStyle.getPropertyValue('--MI_THEME-accent')).toHexString(); diff --git a/packages/frontend/src/components/MkCustomEmojiDetailedDialog.stories.impl.ts b/packages/frontend/src/components/MkCustomEmojiDetailedDialog.stories.impl.ts index 8a05e06311..3da27dcedb 100644 --- a/packages/frontend/src/components/MkCustomEmojiDetailedDialog.stories.impl.ts +++ b/packages/frontend/src/components/MkCustomEmojiDetailedDialog.stories.impl.ts @@ -5,7 +5,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ /* eslint-disable import/no-default-export */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { emojiDetailed } from '../../.storybook/fakes.js'; import MkCustomEmojiDetailedDialog from './MkCustomEmojiDetailedDialog.vue'; export const Default = { diff --git a/packages/frontend/src/components/MkCustomEmojiDetailedDialog.vue b/packages/frontend/src/components/MkCustomEmojiDetailedDialog.vue index e6ab17417d..ed5a20b4eb 100644 --- a/packages/frontend/src/components/MkCustomEmojiDetailedDialog.vue +++ b/packages/frontend/src/components/MkCustomEmojiDetailedDialog.vue @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkModalWindow ref="dialogEl" @close="cancel()" @closed="emit('closed')"> <template #header>:{{ emoji.name }}:</template> <template #default> - <MkSpacer> + <div class="_spacer"> <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> @@ -50,21 +50,21 @@ SPDX-License-Identifier: AGPL-3.0-only </template> </MkKeyValue> </div> - </MkSpacer> + </div> </template> </MkModalWindow> </template> <script lang="ts" setup> import * as Misskey from 'misskey-js'; -import { shallowRef } from 'vue'; +import { useTemplateRef } from 'vue'; import MkLink from '@/components/MkLink.vue'; import { i18n } from '@/i18n.js'; import MkModalWindow from '@/components/MkModalWindow.vue'; import MkKeyValue from '@/components/MkKeyValue.vue'; const props = defineProps<{ - emoji: Misskey.entities.EmojiDetailed, + emoji: Misskey.entities.EmojiDetailed, }>(); const emit = defineEmits<{ @@ -73,7 +73,7 @@ const emit = defineEmits<{ (ev: 'closed'): void; }>(); -const dialogEl = shallowRef<InstanceType<typeof MkModalWindow>>(); +const dialogEl = useTemplateRef('dialogEl'); function cancel() { emit('cancel'); @@ -85,7 +85,7 @@ function cancel() { .emojiImgWrapper { max-width: 100%; height: 40cqh; - background-image: repeating-linear-gradient(45deg, transparent, transparent 8px, var(--MI_THEME-X5) 8px, var(--MI_THEME-X5) 14px); + background-image: repeating-linear-gradient(45deg, transparent, transparent 8px, light-dark(rgba(0, 0, 0, 0.05), rgba(255, 255, 255, 0.05)) 8px, light-dark(rgba(0, 0, 0, 0.05), rgba(255, 255, 255, 0.05)) 14px); border-radius: var(--MI-radius); margin: auto; overflow-y: hidden; @@ -101,7 +101,7 @@ function cancel() { display: inline-block; word-break: break-all; padding: 3px 10px; - background-color: var(--MI_THEME-X5); + background-color: light-dark(rgba(0, 0, 0, 0.05), rgba(255, 255, 255, 0.05)); border: solid 1px var(--MI_THEME-divider); border-radius: var(--MI-radius); } diff --git a/packages/frontend/src/components/MkCwButton.stories.impl.ts b/packages/frontend/src/components/MkCwButton.stories.impl.ts index 5d6ea56da9..bbe5f4eddb 100644 --- a/packages/frontend/src/components/MkCwButton.stories.impl.ts +++ b/packages/frontend/src/components/MkCwButton.stories.impl.ts @@ -5,7 +5,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ /* eslint-disable import/no-default-export */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { action } from '@storybook/addon-actions'; import { expect, userEvent, within } from '@storybook/test'; import { file } from '../../.storybook/fakes.js'; diff --git a/packages/frontend/src/components/MkCwButton.vue b/packages/frontend/src/components/MkCwButton.vue index b5f6e78b6c..cc8bbf1104 100644 --- a/packages/frontend/src/components/MkCwButton.vue +++ b/packages/frontend/src/components/MkCwButton.vue @@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { computed } from 'vue'; import * as Misskey from 'misskey-js'; import type { PollEditorModelValue } from '@/components/MkPollEditor.vue'; -import { concat } from '@/scripts/array.js'; +import { concat } from '@/utility/array.js'; import { i18n } from '@/i18n.js'; import MkButton from '@/components/MkButton.vue'; diff --git a/packages/frontend/src/components/MkDateSeparatedList.vue b/packages/frontend/src/components/MkDateSeparatedList.vue index eeeabb476e..63e6b74154 100644 --- a/packages/frontend/src/components/MkDateSeparatedList.vue +++ b/packages/frontend/src/components/MkDateSeparatedList.vue @@ -3,16 +3,19 @@ SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> +<!-- TODO: 親からスタイルを当てにくいことや実装がトリッキーなことを鑑み廃止または使用の縮小(timeline-date-separate.tsを使う) --> + <script lang="ts"> -import { defineComponent, h, PropType, TransitionGroup, useCssModule } from 'vue'; +import { defineComponent, h, TransitionGroup, useCssModule } from 'vue'; +import type { PropType } from 'vue'; +import type { MisskeyEntity } from '@/types/date-separated-list.js'; import MkAd from '@/components/global/MkAd.vue'; import { isDebuggerEnabled, stackTraceInstances } from '@/debug.js'; -import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; import { instance } from '@/instance.js'; -import { defaultStore } from '@/store.js'; -import { MisskeyEntity } from '@/types/date-separated-list.js'; -import { $i } from '@/account.js'; +import { prefer } from '@/preferences.js'; +import { getDateText } from '@/utility/timeline-date-separate.js'; +import { $i } from '@/i.js'; export default defineComponent({ props: { @@ -45,15 +48,6 @@ export default defineComponent({ setup(props, { slots, expose }) { const $style = useCssModule(); // カスタムレンダラなので使っても大丈夫 - function getDateText(dateInstance: Date) { - const date = dateInstance.getDate(); - const month = dateInstance.getMonth() + 1; - return i18n.tsx.monthAndDay({ - month: month.toString(), - day: date.toString(), - }); - } - if (props.items.length === 0) return; const renderChildrenImpl = (shouldHideAds: boolean) => props.items.map((item, i) => { @@ -115,7 +109,7 @@ export default defineComponent({ }); const renderChildren = () => { - const shouldHideAds = !defaultStore.state.forceShowAds && $i && $i.policies.canHideAds; + const shouldHideAds = (!prefer.s.forceShowAds && $i && $i.policies.canHideAds) ?? false; const children = renderChildrenImpl(shouldHideAds); if (isDebuggerEnabled(6864)) { @@ -152,7 +146,7 @@ export default defineComponent({ [$style['direction-up']]: props.direction === 'up', }; - return () => defaultStore.state.animation ? h(TransitionGroup, { + return () => prefer.s.animation ? h(TransitionGroup, { class: classes, name: 'list', tag: 'div', @@ -170,21 +164,17 @@ export default defineComponent({ container-type: inline-size; &:global { - > .list-move { - transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1); - } - - &.deny-move-transition > .list-move { - transition: none !important; - } + > .list-move { + transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1); + } - > .list-enter-active { - transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1); - } + > .list-enter-active { + transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1); + } - > *:empty { - display: none; - } + > *:empty { + display: none; + } } &:not(.date-separated-list-nogap) > *:not(:last-child) { diff --git a/packages/frontend/src/components/MkDialog.stories.impl.ts b/packages/frontend/src/components/MkDialog.stories.impl.ts index 2d8d3661f2..57c7916049 100644 --- a/packages/frontend/src/components/MkDialog.stories.impl.ts +++ b/packages/frontend/src/components/MkDialog.stories.impl.ts @@ -5,7 +5,7 @@ import { action } from '@storybook/addon-actions'; import { expect, userEvent, waitFor, within } from '@storybook/test'; -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { i18n } from '@/i18n.js'; import MkDialog from './MkDialog.vue'; const Base = { diff --git a/packages/frontend/src/components/MkDialog.vue b/packages/frontend/src/components/MkDialog.vue index 9a59a9aac7..34a54a57bc 100644 --- a/packages/frontend/src/components/MkDialog.vue +++ b/packages/frontend/src/components/MkDialog.vue @@ -25,8 +25,8 @@ SPDX-License-Identifier: AGPL-3.0-only <i v-else-if="type === 'question'" :class="$style.iconInner" class="ti ti-help-circle"></i> <MkLoading v-else-if="type === 'waiting'" :class="$style.iconInner" :em="true"/> </div> - <header v-if="title" :class="$style.title"><Mfm :text="title"/></header> - <div v-if="text" :class="$style.text"><Mfm :text="text" :isBlock="true" :plain="plain" /></div> + <header v-if="title" :class="$style.title" class="_selectable"><Mfm :text="title"/></header> + <div v-if="text" :class="$style.text" class="_selectable"><Mfm :text="text" :isBlock="true" :plain="plain"/></div> <MkInput v-if="input" v-model="inputValue" autofocus :type="input.type || 'text'" :placeholder="input.placeholder || undefined" :autocomplete="input.autocomplete" @keydown="onInputKeydown"> <template v-if="input.type === 'password'" #prefix><i class="ti ti-lock"></i></template> <template #caption> @@ -56,7 +56,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ref, shallowRef, computed } from 'vue'; +import { ref, useTemplateRef, computed } from 'vue'; import MkModal from '@/components/MkModal.vue'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; @@ -118,7 +118,7 @@ const emit = defineEmits<{ (ev: 'closed'): void; }>(); -const modal = shallowRef<InstanceType<typeof MkModal>>(); +const modal = useTemplateRef('modal'); const inputValue = ref<string | number | null>(props.input?.default ?? null); const selectedValue = ref(props.select?.default ?? null); @@ -143,6 +143,7 @@ const okButtonDisabledReason = computed<null | 'charactersExceeded' | 'character // 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 e3391bcf7e..af58f5c375 100644 --- a/packages/frontend/src/components/MkDigitalClock.stories.impl.ts +++ b/packages/frontend/src/components/MkDigitalClock.stories.impl.ts @@ -4,7 +4,7 @@ */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import isChromatic from 'chromatic/isChromatic'; import MkDigitalClock from './MkDigitalClock.vue'; export const Default = { diff --git a/packages/frontend/src/components/MkDigitalClock.vue b/packages/frontend/src/components/MkDigitalClock.vue index 2e2321e6ac..8198356a76 100644 --- a/packages/frontend/src/components/MkDigitalClock.vue +++ b/packages/frontend/src/components/MkDigitalClock.vue @@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { onMounted, onUnmounted, ref, watch } from 'vue'; -import { defaultIdlingRenderScheduler } from '@/scripts/idle-render.js'; +import { defaultIdlingRenderScheduler } from '@/utility/idle-render.js'; const props = withDefaults(defineProps<{ showS?: boolean; diff --git a/packages/frontend/src/components/MkDisableSection.stories.impl.ts b/packages/frontend/src/components/MkDisableSection.stories.impl.ts new file mode 100644 index 0000000000..78e556c63e --- /dev/null +++ b/packages/frontend/src/components/MkDisableSection.stories.impl.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import MkDisableSection from './MkDisableSection.vue'; +void MkDisableSection; diff --git a/packages/frontend/src/components/MkDisableSection.vue b/packages/frontend/src/components/MkDisableSection.vue new file mode 100644 index 0000000000..bd7ecf225d --- /dev/null +++ b/packages/frontend/src/components/MkDisableSection.vue @@ -0,0 +1,42 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div :class="[$style.root]"> + <div :inert="disabled" :class="[{ [$style.disabled]: disabled }]"> + <slot></slot> + </div> + <div v-if="disabled" :class="[$style.cover]"></div> +</div> +</template> + +<script lang="ts" setup> +defineProps<{ + disabled?: boolean; +}>(); +</script> + +<style lang="scss" module> +.root { + position: relative; +} + +.disabled { + opacity: 0.3; + filter: saturate(0.5); +} + +.cover { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + cursor: not-allowed; + --color: light-dark(rgba(0, 0, 0, 0.05), rgba(255, 255, 255, 0.05)); + background-size: auto auto; + background-image: repeating-linear-gradient(135deg, transparent, transparent 10px, var(--color) 4px, var(--color) 14px); +} +</style> diff --git a/packages/frontend/src/components/MkDonation.stories.impl.ts b/packages/frontend/src/components/MkDonation.stories.impl.ts index 27d6b7df6c..71d0c20c63 100644 --- a/packages/frontend/src/components/MkDonation.stories.impl.ts +++ b/packages/frontend/src/components/MkDonation.stories.impl.ts @@ -4,7 +4,7 @@ */ import { action } from '@storybook/addon-actions'; -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { onBeforeUnmount } from 'vue'; import MkDonation from './MkDonation.vue'; import { instance } from '@/instance.js'; diff --git a/packages/frontend/src/components/MkDrive.file.stories.impl.ts b/packages/frontend/src/components/MkDrive.file.stories.impl.ts index 5f6e6a0667..933383775c 100644 --- a/packages/frontend/src/components/MkDrive.file.stories.impl.ts +++ b/packages/frontend/src/components/MkDrive.file.stories.impl.ts @@ -4,7 +4,7 @@ */ import { action } from '@storybook/addon-actions'; -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import MkDrive_file from './MkDrive.file.vue'; import { file } from '../../.storybook/fakes.js'; export const Default = { diff --git a/packages/frontend/src/components/MkDrive.file.vue b/packages/frontend/src/components/MkDrive.file.vue index 5dee448329..61c02c49b5 100644 --- a/packages/frontend/src/components/MkDrive.file.vue +++ b/packages/frontend/src/components/MkDrive.file.vue @@ -48,10 +48,10 @@ import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue'; import bytes from '@/filters/bytes.js'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; -import { $i } from '@/account.js'; -import { getDriveFileMenu } from '@/scripts/get-drive-file-menu.js'; -import { deviceKind } from '@/scripts/device-kind.js'; -import { useRouter } from '@/router/supplier.js'; +import { $i } from '@/i.js'; +import { getDriveFileMenu } from '@/utility/get-drive-file-menu.js'; +import { deviceKind } from '@/utility/device-kind.js'; +import { useRouter } from '@/router.js'; const router = useRouter(); @@ -155,11 +155,11 @@ function onDragend() { background: var(--MI_THEME-accent); &:hover { - background: var(--MI_THEME-accentLighten); + background: hsl(from var(--MI_THEME-accent) h s calc(l + 10)); } &:active { - background: var(--MI_THEME-accentDarken); + background: hsl(from var(--MI_THEME-accent) h s calc(l - 10)); } > .label { diff --git a/packages/frontend/src/components/MkDrive.folder.stories.impl.ts b/packages/frontend/src/components/MkDrive.folder.stories.impl.ts index 5f8ef48520..e6c7c2f645 100644 --- a/packages/frontend/src/components/MkDrive.folder.stories.impl.ts +++ b/packages/frontend/src/components/MkDrive.folder.stories.impl.ts @@ -4,7 +4,7 @@ */ import { action } from '@storybook/addon-actions'; -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { http, HttpResponse } from 'msw'; import * as Misskey from 'misskey-js'; import MkDrive_folder from './MkDrive.folder.vue'; diff --git a/packages/frontend/src/components/MkDrive.folder.vue b/packages/frontend/src/components/MkDrive.folder.vue index 8496890f60..d8ae3e9562 100644 --- a/packages/frontend/src/components/MkDrive.folder.vue +++ b/packages/frontend/src/components/MkDrive.folder.vue @@ -24,7 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template v-if="!hover"><i :class="$style.icon" class="ti ti-folder ti-fw"></i></template> {{ folder.name }} </p> - <p v-if="defaultStore.state.uploadFolder == folder.id" :class="$style.upload"> + <p v-if="prefer.s.uploadFolder == folder.id" :class="$style.upload"> {{ i18n.ts.uploadFolder }} </p> <button v-if="selectMode" class="_button" :class="$style.checkboxWrapper" @click.prevent.stop="checkboxClicked"> @@ -38,11 +38,11 @@ import { computed, defineAsyncComponent, ref } from 'vue'; import * as Misskey from 'misskey-js'; import type { MenuItem } from '@/types/menu.js'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { defaultStore } from '@/store.js'; -import { claimAchievement } from '@/scripts/achievements.js'; -import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; +import { claimAchievement } from '@/utility/achievements.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; +import { prefer } from '@/preferences.js'; const props = withDefaults(defineProps<{ folder: Misskey.entities.DriveFolder; @@ -244,8 +244,8 @@ function deleteFolder() { misskeyApi('drive/folders/delete', { folderId: props.folder.id, }).then(() => { - if (defaultStore.state.uploadFolder === props.folder.id) { - defaultStore.set('uploadFolder', null); + if (prefer.s.uploadFolder === props.folder.id) { + prefer.commit('uploadFolder', null); } }).catch(err => { switch (err.id) { @@ -266,7 +266,7 @@ function deleteFolder() { } function setAsUploadFolder() { - defaultStore.set('uploadFolder', props.folder.id); + prefer.commit('uploadFolder', props.folder.id); } function onContextmenu(ev: MouseEvent) { @@ -295,9 +295,9 @@ function onContextmenu(ev: MouseEvent) { danger: true, action: deleteFolder, }]; - if (defaultStore.state.devMode) { + if (prefer.s.devMode) { menu = menu.concat([{ type: 'divider' }, { - icon: 'ti ti-id', + icon: 'ti ti-hash', text: i18n.ts.copyFolderId, action: () => { copyToClipboard(props.folder.id); diff --git a/packages/frontend/src/components/MkDrive.navFolder.vue b/packages/frontend/src/components/MkDrive.navFolder.vue index 8df3c86ebf..7433aea061 100644 --- a/packages/frontend/src/components/MkDrive.navFolder.vue +++ b/packages/frontend/src/components/MkDrive.navFolder.vue @@ -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 { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; const props = defineProps<{ diff --git a/packages/frontend/src/components/MkDrive.stories.impl.ts b/packages/frontend/src/components/MkDrive.stories.impl.ts index fe20e61415..4394eebfda 100644 --- a/packages/frontend/src/components/MkDrive.stories.impl.ts +++ b/packages/frontend/src/components/MkDrive.stories.impl.ts @@ -4,7 +4,7 @@ */ import { action } from '@storybook/addon-actions'; -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { http, HttpResponse } from 'msw'; import * as Misskey from 'misskey-js'; import MkDrive from './MkDrive.vue'; diff --git a/packages/frontend/src/components/MkDrive.vue b/packages/frontend/src/components/MkDrive.vue index 5a0803f1e3..3627704c1b 100644 --- a/packages/frontend/src/components/MkDrive.vue +++ b/packages/frontend/src/components/MkDrive.vue @@ -4,41 +4,44 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div :class="$style.root"> - <nav :class="$style.nav"> - <div :class="$style.navPath" @contextmenu.prevent.stop="() => {}"> - <XNavFolder - :class="[$style.navPathItem, { [$style.navCurrent]: folder == null }]" - :parentFolder="folder" - @move="move" - @upload="upload" - @removeFile="removeFile" - @removeFolder="removeFolder" - /> - <template v-for="f in hierarchyFolders"> - <span :class="[$style.navPathItem, $style.navSeparator]"><i class="ti ti-chevron-right"></i></span> +<MkStickyContainer> + <template #header> + <nav :class="$style.nav"> + <div :class="$style.navPath" @contextmenu.prevent.stop="() => {}"> <XNavFolder - :folder="f" + :class="[$style.navPathItem, { [$style.navCurrent]: folder == null }]" :parentFolder="folder" - :class="[$style.navPathItem]" @move="move" @upload="upload" @removeFile="removeFile" @removeFolder="removeFolder" /> - </template> - <span v-if="folder != null" :class="[$style.navPathItem, $style.navSeparator]"><i class="ti ti-chevron-right"></i></span> - <span v-if="folder != null" :class="[$style.navPathItem, $style.navCurrent]">{{ folder.name }}</span> - </div> - <div :class="$style.navMenu"> - <!-- "Search drive via alt text or file names" --> - <MkInput v-model="searchQuery" :large="true" :autofocus="true" type="search" :placeholder="i18n.ts.driveSearchbarPlaceholder" @enter="fetch"> - <template #prefix><i class="ph-magnifying-glass ph-bold ph-lg"></i></template> - </MkInput> + <template v-for="f in hierarchyFolders"> + <span :class="[$style.navPathItem, $style.navSeparator]"><i class="ti ti-chevron-right"></i></span> + <XNavFolder + :folder="f" + :parentFolder="folder" + :class="[$style.navPathItem]" + @move="move" + @upload="upload" + @removeFile="removeFile" + @removeFolder="removeFolder" + /> + </template> + <span v-if="folder != null" :class="[$style.navPathItem, $style.navSeparator]"><i class="ti ti-chevron-right"></i></span> + <span v-if="folder != null" :class="[$style.navPathItem, $style.navCurrent]">{{ folder.name }}</span> + </div> + <div :class="$style.navMenu"> + <!-- "Search drive via alt text or file names" --> + <MkInput v-model="searchQuery" :large="true" :autofocus="true" type="search" :placeholder="i18n.ts.driveSearchbarPlaceholder" @enter="fetch"> + <template #prefix><i class="ph-magnifying-glass ph-bold ph-lg"></i></template> + </MkInput> + + <button class="_button" :class="$style.navMenu" @click="showMenu"><i class="ti ti-dots"></i></button> + </div> + </nav> + </template> - <button class="_button" :class="$style.navMenu" @click="showMenu"><i class="ti ti-dots"></i></button> - </div> - </nav> <div ref="main" :class="[$style.main, { [$style.uploading]: uploadings.length > 0, [$style.fetching]: fetching }]" @@ -49,6 +52,7 @@ SPDX-License-Identifier: AGPL-3.0-only @contextmenu.stop="onContextmenu" > <div ref="contents"> + <MkInfo v-if="!store.r.readDriveTip.value" closable @close="closeTip()"><div v-html="i18n.ts.driveAboutTip"></div></MkInfo> <div v-show="folders.length > 0" ref="foldersContainer" :class="$style.folders"> <XFolder v-for="(f, i) in folders" @@ -98,26 +102,28 @@ SPDX-License-Identifier: AGPL-3.0-only <MkLoading v-if="fetching"/> </div> <div v-if="draghover" :class="$style.dropzone"></div> - <input ref="fileInput" style="display: none;" type="file" accept="*/*" multiple tabindex="-1" @change="onChangeFileInput"/> -</div> +</MkStickyContainer> </template> <script lang="ts" setup> -import { nextTick, onActivated, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'; +import { nextTick, onActivated, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue'; import * as Misskey from 'misskey-js'; import MkButton from './MkButton.vue'; +import MkInfo from './MkInfo.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 MkInput from '@/components/MkInput.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { useStream } from '@/stream.js'; -import { defaultStore } from '@/store.js'; import { i18n } from '@/i18n.js'; -import { uploadFile, uploads } from '@/scripts/upload.js'; -import { claimAchievement } from '@/scripts/achievements.js'; +import { uploadFile, uploads } from '@/utility/upload.js'; +import { claimAchievement } from '@/utility/achievements.js'; +import { prefer } from '@/preferences.js'; +import { chooseFileFromPc } from '@/utility/select-file.js'; +import { store } from '@/store.js'; const searchQuery = ref(''); @@ -139,8 +145,7 @@ const emit = defineEmits<{ (ev: 'open-folder', v: Misskey.entities.DriveFolder): void; }>(); -const loadMoreFiles = shallowRef<InstanceType<typeof MkButton>>(); -const fileInput = shallowRef<HTMLInputElement>(); +const loadMoreFiles = useTemplateRef('loadMoreFiles'); const folder = ref<Misskey.entities.DriveFolder | null>(null); const files = ref<Misskey.entities.DriveFile[]>([]); @@ -152,7 +157,6 @@ const selectedFiles = ref<Misskey.entities.DriveFile[]>([]); const selectedFolders = ref<Misskey.entities.DriveFolder[]>([]); const uploadings = uploads; const connection = useStream().useChannel('drive'); -const keepOriginal = ref<boolean>(defaultStore.state.keepOriginalUploading); // 外部渡しが多いので$refは使わないほうがよい // ドロップされようとしているか const draghover = ref(false); @@ -314,10 +318,6 @@ function onDrop(ev: DragEvent) { //#endregion } -function selectLocalFile() { - fileInput.value?.click(); -} - function urlUpload() { os.inputText({ title: i18n.ts.uploadFromUrl, @@ -393,15 +393,8 @@ function deleteFolder(folderToDelete: Misskey.entities.DriveFolder) { }); } -function onChangeFileInput() { - if (!fileInput.value?.files) return; - for (const file of Array.from(fileInput.value.files)) { - upload(file, folder.value); - } -} - -function upload(file: File, folderToUpload?: Misskey.entities.DriveFolder | null) { - uploadFile(file, (folderToUpload && typeof folderToUpload === 'object') ? folderToUpload.id : null, undefined, keepOriginal.value).then(res => { +function upload(file: File, folderToUpload?: Misskey.entities.DriveFolder | null, keepOriginal?: boolean) { + uploadFile(file, (folderToUpload && typeof folderToUpload === 'object') ? folderToUpload.id : null, undefined, keepOriginal).then(res => { addFile(res, true); }); } @@ -644,16 +637,20 @@ function getMenu() { const menu: MenuItem[] = []; menu.push({ - type: 'switch', - text: i18n.ts.keepOriginalUploading, - ref: keepOriginal, - }, { type: 'divider' }, { text: i18n.ts.addFile, type: 'label', }, { + text: i18n.ts.upload + ' (' + i18n.ts.compress + ')', + icon: 'ti ti-upload', + action: () => { + chooseFileFromPc(true, { uploadFolder: folder.value?.id, keepOriginal: false }); + }, + }, { text: i18n.ts.upload, icon: 'ti ti-upload', - action: () => { selectLocalFile(); }, + action: () => { + chooseFileFromPc(true, { uploadFolder: folder.value?.id, keepOriginal: true }); + }, }, { text: i18n.ts.fromUrl, icon: 'ti ti-link', @@ -729,8 +726,12 @@ function onContextmenu(ev: MouseEvent) { os.contextMenu(getMenu(), ev); } +function closeTip() { + store.set('readDriveTip', true); +} + onMounted(() => { - if (defaultStore.state.enableInfiniteScroll && loadMoreFiles.value) { + if (prefer.s.enableInfiniteScroll && loadMoreFiles.value) { nextTick(() => { ilFilesObserver.observe(loadMoreFiles.value?.$el); }); @@ -751,7 +752,7 @@ onMounted(() => { }); onActivated(() => { - if (defaultStore.state.enableInfiniteScroll) { + if (prefer.s.enableInfiniteScroll) { nextTick(() => { ilFilesObserver.observe(loadMoreFiles.value?.$el); }); @@ -765,22 +766,17 @@ onBeforeUnmount(() => { </script> <style lang="scss" module> -.root { - display: flex; - flex-direction: column; - height: 100%; -} - .nav { display: flex; - z-index: 2; width: 100%; padding: 0 8px; box-sizing: border-box; overflow: auto; font-size: 0.9em; - box-shadow: 0 1px 0 var(--MI_THEME-divider); - user-select: none; + background: color(from var(--MI_THEME-bg) srgb r g b / 0.75); + -webkit-backdrop-filter: var(--MI-blur, blur(15px)); + backdrop-filter: var(--MI-blur, blur(15px)); + border-bottom: solid 0.5px var(--MI_THEME-divider); } .navPath { diff --git a/packages/frontend/src/components/MkDriveFileThumbnail.stories.impl.ts b/packages/frontend/src/components/MkDriveFileThumbnail.stories.impl.ts index 3fa24d7edb..d259444e94 100644 --- a/packages/frontend/src/components/MkDriveFileThumbnail.stories.impl.ts +++ b/packages/frontend/src/components/MkDriveFileThumbnail.stories.impl.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import MkDriveFileThumbnail from './MkDriveFileThumbnail.vue'; import { file } from '../../.storybook/fakes.js'; export const Default = { diff --git a/packages/frontend/src/components/MkDriveSelectDialog.vue b/packages/frontend/src/components/MkDriveSelectDialog.vue index f1ecc27123..1b9455e3f3 100644 --- a/packages/frontend/src/components/MkDriveSelectDialog.vue +++ b/packages/frontend/src/components/MkDriveSelectDialog.vue @@ -24,7 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ref, shallowRef } from 'vue'; +import { ref, useTemplateRef } from 'vue'; import * as Misskey from 'misskey-js'; import XDrive from '@/components/MkDrive.vue'; import MkModalWindow from '@/components/MkModalWindow.vue'; @@ -43,7 +43,7 @@ const emit = defineEmits<{ (ev: 'closed'): void; }>(); -const dialog = shallowRef<InstanceType<typeof MkModalWindow>>(); +const dialog = useTemplateRef('dialog'); const selected = ref<Misskey.entities.DriveFile[] | Misskey.entities.DriveFolder[]>([]); diff --git a/packages/frontend/src/components/MkEmbedCodeGenDialog.vue b/packages/frontend/src/components/MkEmbedCodeGenDialog.vue index 6e9eb75920..d18fe0ed0c 100644 --- a/packages/frontend/src/components/MkEmbedCodeGenDialog.vue +++ b/packages/frontend/src/components/MkEmbedCodeGenDialog.vue @@ -89,7 +89,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script setup lang="ts"> -import { shallowRef, ref, computed, nextTick, onMounted, onDeactivated, onUnmounted } from 'vue'; +import { useTemplateRef, ref, computed, nextTick, onMounted, onDeactivated, onUnmounted } from 'vue'; import { url } from '@@/js/config.js'; import { embedRouteWithScrollbar } from '@@/js/embed-page.js'; import type { EmbeddableEntity, EmbedParams } from '@@/js/embed-page.js'; @@ -105,8 +105,8 @@ import MkInfo from '@/components/MkInfo.vue'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; -import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; -import { normalizeEmbedParams, getEmbedCode } from '@/scripts/get-embed-code.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; +import { normalizeEmbedParams, getEmbedCode } from '@/utility/get-embed-code.js'; const emit = defineEmits<{ (ev: 'ok'): void; @@ -121,7 +121,7 @@ const props = defineProps<{ }>(); //#region Modalの制御 -const dialogEl = shallowRef<InstanceType<typeof MkModalWindow>>(); +const dialogEl = useTemplateRef('dialogEl'); function cancel() { emit('cancel'); @@ -180,7 +180,7 @@ function applyToPreview() { nextTick(() => { if (currentPreviewUrl === embedPreviewUrl.value) { // URLが変わらなくてもリロード - iframeEl.value?.contentWindow?.location.reload(); + iframeEl.value?.contentWindow?.window.location.reload(); } }); } @@ -194,14 +194,13 @@ function generate() { function doCopy() { copyToClipboard(result.value); - os.success(); } //#endregion //#region プレビューのリサイズ -const resizerRootEl = shallowRef<HTMLDivElement>(); +const resizerRootEl = useTemplateRef('resizerRootEl'); const iframeLoading = ref(true); -const iframeEl = shallowRef<HTMLIFrameElement>(); +const iframeEl = useTemplateRef('iframeEl'); const iframeHeight = ref(0); const iframeScale = ref(1); const iframeStyle = computed(() => { diff --git a/packages/frontend/src/components/MkEmojiPicker.section.vue b/packages/frontend/src/components/MkEmojiPicker.section.vue index a4f763e895..8142fdeb36 100644 --- a/packages/frontend/src/components/MkEmojiPicker.section.vue +++ b/packages/frontend/src/components/MkEmojiPicker.section.vue @@ -61,8 +61,10 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ref, computed, Ref } from 'vue'; -import { CustomEmojiFolderTree, getEmojiName } from '@@/js/emojilist.js'; +import { ref, computed } from 'vue'; +import type { Ref } from 'vue'; +import { getEmojiName } from '@@/js/emojilist.js'; +import type { CustomEmojiFolderTree } from '@@/js/emojilist.js'; import { i18n } from '@/i18n.js'; import { customEmojis } from '@/custom-emojis.js'; import MkEmojiPickerSection from '@/components/MkEmojiPicker.section.vue'; diff --git a/packages/frontend/src/components/MkEmojiPicker.stories.impl.ts b/packages/frontend/src/components/MkEmojiPicker.stories.impl.ts index d38d8de808..bf4158a2c8 100644 --- a/packages/frontend/src/components/MkEmojiPicker.stories.impl.ts +++ b/packages/frontend/src/components/MkEmojiPicker.stories.impl.ts @@ -5,7 +5,7 @@ import { action } from '@storybook/addon-actions'; import { expect, userEvent, waitFor, within } from '@storybook/test'; -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { i18n } from '@/i18n.js'; import MkEmojiPicker from './MkEmojiPicker.vue'; export const Default = { diff --git a/packages/frontend/src/components/MkEmojiPicker.vue b/packages/frontend/src/components/MkEmojiPicker.vue index a782ae9d3b..6b1add81bc 100644 --- a/packages/frontend/src/components/MkEmojiPicker.vue +++ b/packages/frontend/src/components/MkEmojiPicker.vue @@ -115,31 +115,34 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ref, shallowRef, computed, watch, onMounted } from 'vue'; +import { ref, useTemplateRef, computed, watch, onMounted } from 'vue'; import * as Misskey from 'misskey-js'; import { emojilist, emojiCharByCategory, - UnicodeEmojiDef, unicodeEmojiCategories as categories, getEmojiName, - CustomEmojiFolderTree, getUnicodeEmoji, } from '@@/js/emojilist.js'; +import type { + UnicodeEmojiDef, + CustomEmojiFolderTree, +} from '@@/js/emojilist.js'; import XSection from '@/components/MkEmojiPicker.section.vue'; import MkRippleEffect from '@/components/MkRippleEffect.vue'; import * as os from '@/os.js'; -import { isTouchUsing } from '@/scripts/touch.js'; -import { deviceKind } from '@/scripts/device-kind.js'; +import { isTouchUsing } from '@/utility/touch.js'; +import { deviceKind } from '@/utility/device-kind.js'; import { i18n } from '@/i18n.js'; -import { defaultStore } from '@/store.js'; +import { store } from '@/store.js'; import { customEmojiCategories, customEmojis, customEmojisMap } from '@/custom-emojis.js'; -import { $i } from '@/account.js'; -import { checkReactionPermissions } from '@/scripts/check-reaction-permissions.js'; +import { $i } from '@/i.js'; +import { checkReactionPermissions } from '@/utility/check-reaction-permissions.js'; +import { prefer } from '@/preferences.js'; const props = withDefaults(defineProps<{ showPinned?: boolean; - pinnedEmojis?: string[]; + pinnedEmojis?: string[]; maxHeight?: number; asDrawer?: boolean; asWindow?: boolean; @@ -154,15 +157,16 @@ const emit = defineEmits<{ (ev: 'esc'): void; }>(); -const searchEl = shallowRef<HTMLInputElement>(); -const emojisEl = shallowRef<HTMLDivElement>(); +const searchEl = useTemplateRef('searchEl'); +const emojisEl = useTemplateRef('emojisEl'); const { emojiPickerScale, emojiPickerWidth, emojiPickerHeight, - recentlyUsedEmojis, -} = defaultStore.reactiveState; +} = prefer.r; + +const recentlyUsedEmojis = store.r.recentlyUsedEmojis; const recentlyUsedEmojisDef = computed(() => { return recentlyUsedEmojis.value.map(getDef).filter(x => x != null); @@ -186,7 +190,7 @@ function parseAndMergeCategories(input: string, root: CustomEmojiFolderTree): Cu const parts = input.split('/').map(p => p.trim()); let currentNode: CustomEmojiFolderTree = root; - const currentPath = []; + const currentPath = [] as string[]; for (const part of parts) { currentPath.push(part); let existingNode = currentNode.children.find((node) => node.value === part); @@ -317,7 +321,7 @@ watch(q, () => { } if (matches.size >= max) return matches; - for (const index of Object.values(defaultStore.state.additionalUnicodeEmojiIndexes)) { + for (const index of Object.values(store.s.additionalUnicodeEmojiIndexes)) { for (const emoji of emojis) { if (keywords.every(keyword => index[emoji.char].some(k => k.includes(keyword)))) { matches.add(emoji); @@ -334,7 +338,7 @@ watch(q, () => { } if (matches.size >= max) return matches; - for (const index of Object.values(defaultStore.state.additionalUnicodeEmojiIndexes)) { + for (const index of Object.values(store.s.additionalUnicodeEmojiIndexes)) { for (const emoji of emojis) { if (index[emoji.char].some(k => k.startsWith(newQ))) { matches.add(emoji); @@ -351,7 +355,7 @@ watch(q, () => { } if (matches.size >= max) return matches; - for (const index of Object.values(defaultStore.state.additionalUnicodeEmojiIndexes)) { + for (const index of Object.values(store.s.additionalUnicodeEmojiIndexes)) { for (const emoji of emojis) { if (index[emoji.char].some(k => k.includes(newQ))) { matches.add(emoji); @@ -413,7 +417,7 @@ function computeButtonTitle(ev: MouseEvent): void { function chosen(emoji: string | Misskey.entities.EmojiSimple | UnicodeEmojiDef, ev?: MouseEvent) { const el = ev && (ev.currentTarget ?? ev.target) as HTMLElement | null | undefined; - if (el) { + if (el && prefer.s.animation) { const rect = el.getBoundingClientRect(); const x = rect.left + (el.offsetWidth / 2); const y = rect.top + (el.offsetHeight / 2); @@ -427,10 +431,10 @@ function chosen(emoji: string | Misskey.entities.EmojiSimple | UnicodeEmojiDef, // 最近使った絵文字更新 if (!pinned.value?.includes(key)) { - let recents = defaultStore.state.recentlyUsedEmojis; + let recents = store.s.recentlyUsedEmojis; recents = recents.filter((emoji) => emoji !== key); recents.unshift(key); - defaultStore.set('recentlyUsedEmojis', recents.splice(0, 32)); + store.set('recentlyUsedEmojis', recents.splice(0, 32)); } } @@ -582,7 +586,7 @@ defineExpose({ &:disabled { cursor: not-allowed; - background: linear-gradient(-45deg, transparent 0% 48%, var(--MI_THEME-X6) 48% 52%, transparent 52% 100%); + background: linear-gradient(-45deg, transparent 0% 48%, light-dark(rgba(0, 0, 0, 0.25), rgba(255, 255, 255, 0.15)) 48% 52%, transparent 52% 100%); opacity: 1; > .emoji { @@ -617,7 +621,7 @@ defineExpose({ &:disabled { cursor: not-allowed; - background: linear-gradient(-45deg, transparent 0% 48%, var(--MI_THEME-X6) 48% 52%, transparent 52% 100%); + background: linear-gradient(-45deg, transparent 0% 48%, light-dark(rgba(0, 0, 0, 0.25), rgba(255, 255, 255, 0.15)) 48% 52%, transparent 52% 100%); opacity: 1; > .emoji { @@ -738,7 +742,7 @@ defineExpose({ &:disabled { cursor: not-allowed; - background: linear-gradient(-45deg, transparent 0% 48%, var(--MI_THEME-X6) 48% 52%, transparent 52% 100%); + background: linear-gradient(-45deg, transparent 0% 48%, light-dark(rgba(0, 0, 0, 0.25), rgba(255, 255, 255, 0.15)) 48% 52%, transparent 52% 100%); opacity: 1; > .emoji { diff --git a/packages/frontend/src/components/MkEmojiPickerDialog.vue b/packages/frontend/src/components/MkEmojiPickerDialog.vue index 3178f72498..118eb8ecc0 100644 --- a/packages/frontend/src/components/MkEmojiPickerDialog.vue +++ b/packages/frontend/src/components/MkEmojiPickerDialog.vue @@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only ref="modal" v-slot="{ type, maxHeight }" :zPriority="'middle'" - :preferType="defaultStore.state.emojiPickerStyle" + :preferType="prefer.s.emojiPickerStyle" :hasInteractionWithOtherFocusTrappedEls="true" :transparentBg="true" :manualShowing="manualShowing" @@ -37,19 +37,19 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import * as Misskey from 'misskey-js'; -import { shallowRef } from 'vue'; +import { useTemplateRef } from 'vue'; import MkModal from '@/components/MkModal.vue'; import MkEmojiPicker from '@/components/MkEmojiPicker.vue'; -import { defaultStore } from '@/store.js'; +import { prefer } from '@/preferences.js'; const props = withDefaults(defineProps<{ manualShowing?: boolean | null; src?: HTMLElement; showPinned?: boolean; - pinnedEmojis?: string[], + pinnedEmojis?: string[], asReactionPicker?: boolean; targetNote?: Misskey.entities.Note; - choseAndClose?: boolean; + choseAndClose?: boolean; }>(), { manualShowing: null, showPinned: true, @@ -64,8 +64,8 @@ const emit = defineEmits<{ (ev: 'closed'): void; }>(); -const modal = shallowRef<InstanceType<typeof MkModal>>(); -const picker = shallowRef<InstanceType<typeof MkEmojiPicker>>(); +const modal = useTemplateRef('modal'); +const picker = useTemplateRef('picker'); function chosen(emoji: string) { emit('done', emoji); @@ -79,7 +79,7 @@ function opening() { picker.value?.focus(); // 何故かちょっと待たないとフォーカスされない - setTimeout(() => { + window.setTimeout(() => { picker.value?.focus(); }, 10); } diff --git a/packages/frontend/src/components/MkExtensionInstaller.stories.impl.ts b/packages/frontend/src/components/MkExtensionInstaller.stories.impl.ts index 6763f7c546..f531762710 100644 --- a/packages/frontend/src/components/MkExtensionInstaller.stories.impl.ts +++ b/packages/frontend/src/components/MkExtensionInstaller.stories.impl.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import MkExtensionInstaller from './MkExtensionInstaller.vue'; import lightTheme from '@@/themes/_light.json5'; diff --git a/packages/frontend/src/components/MkExtensionInstaller.vue b/packages/frontend/src/components/MkExtensionInstaller.vue index d59b20435e..a2247d844b 100644 --- a/packages/frontend/src/components/MkExtensionInstaller.vue +++ b/packages/frontend/src/components/MkExtensionInstaller.vue @@ -11,54 +11,91 @@ SPDX-License-Identifier: AGPL-3.0-only <!-- 拡張用? --> <i v-else class="ti ti-download"></i> </div> - <h2 :class="$style.extInstallerTitle">{{ i18n.ts._externalResourceInstaller[`_${extension.type}`].title }}</h2> - <div :class="$style.extInstallerNormDesc">{{ i18n.ts._externalResourceInstaller.checkVendorBeforeInstall }}</div> - <MkInfo v-if="isPlugin" :warn="true">{{ i18n.ts._plugin.installWarn }}</MkInfo> - <FormSection> - <template #label>{{ i18n.ts._externalResourceInstaller[`_${extension.type}`].metaTitle }}</template> - <div class="_gaps_s"> - <FormSplit> + + <h2 v-if="isPlugin" :class="$style.extInstallerTitle">{{ i18n.ts._externalResourceInstaller._plugin.title }}</h2> + <h2 v-else-if="isTheme" :class="$style.extInstallerTitle">{{ i18n.ts._externalResourceInstaller._theme.title }}</h2> + + <MkInfo :warn="true">{{ i18n.ts._externalResourceInstaller.checkVendorBeforeInstall }}</MkInfo> + + <div v-if="isPlugin" class="_gaps_s"> + <MkFolder :defaultOpen="true"> + <template #icon><i class="ti ti-info-circle"></i></template> + <template #label>{{ i18n.ts.metadata }}</template> + + <div class="_gaps_s"> + <FormSplit> + <MkKeyValue> + <template #key>{{ i18n.ts.name }}</template> + <template #value>{{ extension.meta.name }}</template> + </MkKeyValue> + <MkKeyValue> + <template #key>{{ i18n.ts.author }}</template> + <template #value>{{ extension.meta.author }}</template> + </MkKeyValue> + </FormSplit> + <MkKeyValue> + <template #key>{{ i18n.ts.description }}</template> + <template #value>{{ extension.meta.description ?? i18n.ts.none }}</template> + </MkKeyValue> <MkKeyValue> - <template #key>{{ i18n.ts.name }}</template> - <template #value>{{ extension.meta.name }}</template> + <template #key>{{ i18n.ts.version }}</template> + <template #value>{{ extension.meta.version }}</template> </MkKeyValue> <MkKeyValue> - <template #key>{{ i18n.ts.author }}</template> - <template #value>{{ extension.meta.author }}</template> + <template #key>{{ i18n.ts.permission }}</template> + <template #value> + <ul v-if="extension.meta.permissions && extension.meta.permissions.length > 0" :class="$style.extInstallerKVList"> + <li v-for="permission in extension.meta.permissions" :key="permission">{{ i18n.ts._permissions[permission] }}</li> + </ul> + <template v-else>{{ i18n.ts.none }}</template> + </template> </MkKeyValue> - </FormSplit> - <MkKeyValue v-if="isPlugin"> - <template #key>{{ i18n.ts.description }}</template> - <template #value>{{ extension.meta.description ?? i18n.ts.none }}</template> - </MkKeyValue> - <MkKeyValue v-if="isPlugin"> - <template #key>{{ i18n.ts.version }}</template> - <template #value>{{ extension.meta.version }}</template> - </MkKeyValue> - <MkKeyValue v-if="isPlugin"> - <template #key>{{ i18n.ts.permission }}</template> - <template #value> - <ul v-if="extension.meta.permissions && extension.meta.permissions.length > 0" :class="$style.extInstallerKVList"> - <li v-for="permission in extension.meta.permissions" :key="permission">{{ i18n.ts._permissions[permission] }}</li> - </ul> - <template v-else>{{ i18n.ts.none }}</template> - </template> - </MkKeyValue> - <MkKeyValue v-if="isTheme"> - <template #key>{{ i18n.ts._externalResourceInstaller._meta.base }}</template> - <template #value>{{ i18n.ts[extension.meta.base ?? 'none'] }}</template> - </MkKeyValue> - <MkFolder> - <template #icon><i class="ti ti-code"></i></template> - <template #label>{{ i18n.ts._plugin.viewSource }}</template> + </div> + </MkFolder> + + <MkFolder :withSpacer="false"> + <template #icon><i class="ti ti-code"></i></template> + <template #label>{{ i18n.ts._plugin.viewSource }}</template> + + <MkCode :code="extension.raw"/> + </MkFolder> + </div> + <div v-else-if="isTheme" class="_gaps_s"> + <MkFolder :defaultOpen="true"> + <template #icon><i class="ti ti-info-circle"></i></template> + <template #label>{{ i18n.ts.metadata }}</template> + + <div class="_gaps_s"> + <FormSplit> + <MkKeyValue> + <template #key>{{ i18n.ts.name }}</template> + <template #value>{{ extension.meta.name }}</template> + </MkKeyValue> + <MkKeyValue> + <template #key>{{ i18n.ts.author }}</template> + <template #value>{{ extension.meta.author }}</template> + </MkKeyValue> + </FormSplit> + <MkKeyValue> + <template #key>{{ i18n.ts._externalResourceInstaller._meta.base }}</template> + <template #value>{{ i18n.ts[extension.meta.base ?? 'none'] }}</template> + </MkKeyValue> + </div> + </MkFolder> + + <MkFolder :withSpacer="false"> + <template #icon><i class="ti ti-code"></i></template> + <template #label>{{ i18n.ts._theme.code }}</template> + + <MkCode :code="extension.raw"/> + </MkFolder> + </div> - <MkCode :code="extension.raw"/> - </MkFolder> - </div> - </FormSection> <slot name="additionalInfo"/> + <div class="_buttonsCenter"> - <MkButton primary @click="emits('confirm')"><i class="ti ti-check"></i> {{ i18n.ts.install }}</MkButton> + <MkButton danger rounded large @click="emits('cancel')"><i class="ti ti-x"></i> {{ i18n.ts.cancel }}</MkButton> + <MkButton gradate rounded large @click="emits('confirm')"><i class="ti ti-download"></i> {{ i18n.ts.install }}</MkButton> </div> </div> </template> @@ -105,6 +142,7 @@ const props = defineProps<{ const emits = defineEmits<{ (ev: 'confirm'): void; + (ev: 'cancel'): void; }>(); </script> @@ -112,13 +150,13 @@ const emits = defineEmits<{ .extInstallerRoot { border-radius: var(--MI-radius); background: var(--MI_THEME-panel); - padding: 1.5rem; + padding: 20px; } .extInstallerIconWrapper { width: 48px; height: 48px; - font-size: 24px; + font-size: 20px; line-height: 48px; text-align: center; border-radius: 50%; @@ -135,10 +173,6 @@ const emits = defineEmits<{ margin: 0; } -.extInstallerNormDesc { - text-align: center; -} - .extInstallerKVList { margin-top: 0; margin-bottom: 0; diff --git a/packages/frontend/src/components/MkFeatureBanner.vue b/packages/frontend/src/components/MkFeatureBanner.vue new file mode 100644 index 0000000000..e990ffc8f0 --- /dev/null +++ b/packages/frontend/src/components/MkFeatureBanner.vue @@ -0,0 +1,43 @@ +<!-- +SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div v-panel :class="$style.root"> + <img :class="$style.img" :src="icon"/> + <div :class="$style.text"> + <slot></slot> + </div> +</div> +</template> + +<script setup lang="ts"> +withDefaults(defineProps<{ + icon: string; + color: string; +}>(), { +}); +</script> + +<style module lang="scss"> +.root { + padding: 20px 24px; + text-align: center; + border-radius: var(--MI-radius); + background: linear-gradient(180deg, color(from v-bind(color) srgb r g b / 0.1), color(from v-bind(color) srgb r g b / 0)); +} + +.img { + display: block; + margin: 0 auto; + width: 40px; + aspect-ratio: 1; +} + +.text { + margin-top: 12px; + font-size: 85%; + mix-blend-mode: luminosity; +} +</style> diff --git a/packages/frontend/src/components/MkFileCaptionEditWindow.vue b/packages/frontend/src/components/MkFileCaptionEditWindow.vue index 8754c72b7b..bdfbe13fb4 100644 --- a/packages/frontend/src/components/MkFileCaptionEditWindow.vue +++ b/packages/frontend/src/components/MkFileCaptionEditWindow.vue @@ -15,17 +15,17 @@ SPDX-License-Identifier: AGPL-3.0-only @closed="emit('closed')" > <template #header>{{ i18n.ts.describeFile }}</template> - <MkSpacer :marginMin="20" :marginMax="28"> + <div class="_spacer" style="--MI_SPACER-min: 20px; --MI_SPACER-max: 28px;"> <MkDriveFileThumbnail :file="file" fit="contain" style="height: 193px; margin-bottom: 16px;"/> <MkTextarea v-model="caption" autofocus :placeholder="i18n.ts.inputNewDescription" @keydown="onKeydown($event)"> <template #label>{{ i18n.ts.caption }}</template> </MkTextarea> - </MkSpacer> + </div> </MkModalWindow> </template> <script lang="ts" setup> -import { shallowRef, ref } from 'vue'; +import { useTemplateRef, ref } from 'vue'; import * as Misskey from 'misskey-js'; import MkModalWindow from '@/components/MkModalWindow.vue'; import MkTextarea from '@/components/MkTextarea.vue'; @@ -42,7 +42,7 @@ const emit = defineEmits<{ (ev: 'closed'): void; }>(); -const dialog = shallowRef<InstanceType<typeof MkModalWindow>>(); +const dialog = useTemplateRef('dialog'); const caption = ref(props.default); diff --git a/packages/frontend/src/components/MkFlashPreview.stories.impl.ts b/packages/frontend/src/components/MkFlashPreview.stories.impl.ts index fa5288b73d..4a751062c9 100644 --- a/packages/frontend/src/components/MkFlashPreview.stories.impl.ts +++ b/packages/frontend/src/components/MkFlashPreview.stories.impl.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import MkFlashPreview from './MkFlashPreview.vue'; import { flash } from './../../.storybook/fakes.js'; export const Public = { diff --git a/packages/frontend/src/components/MkFoldableSection.vue b/packages/frontend/src/components/MkFoldableSection.vue index 5bf3fdfe76..aa5c82fc16 100644 --- a/packages/frontend/src/components/MkFoldableSection.vue +++ b/packages/frontend/src/components/MkFoldableSection.vue @@ -14,10 +14,10 @@ SPDX-License-Identifier: AGPL-3.0-only </button> </header> <Transition - :enterActiveClass="defaultStore.state.animation ? $style.folderToggleEnterActive : ''" - :leaveActiveClass="defaultStore.state.animation ? $style.folderToggleLeaveActive : ''" - :enterFromClass="defaultStore.state.animation ? $style.folderToggleEnterFrom : ''" - :leaveToClass="defaultStore.state.animation ? $style.folderToggleLeaveTo : ''" + :enterActiveClass="prefer.s.animation ? $style.folderToggleEnterActive : ''" + :leaveActiveClass="prefer.s.animation ? $style.folderToggleLeaveActive : ''" + :enterFromClass="prefer.s.animation ? $style.folderToggleEnterFrom : ''" + :leaveToClass="prefer.s.animation ? $style.folderToggleLeaveTo : ''" @enter="enter" @afterEnter="afterEnter" @leave="leave" @@ -31,10 +31,10 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, ref, shallowRef, watch } from 'vue'; +import { onMounted, ref, useTemplateRef, watch } from 'vue'; import { miLocalStorage } from '@/local-storage.js'; -import { defaultStore } from '@/store.js'; -import { getBgColor } from '@/scripts/get-bg-color.js'; +import { prefer } from '@/preferences.js'; +import { getBgColor } from '@/utility/get-bg-color.js'; const miLocalStoragePrefix = 'ui:folder:' as const; @@ -46,7 +46,7 @@ const props = withDefaults(defineProps<{ persistKey: null, }); -const rootEl = shallowRef<HTMLElement>(); +const rootEl = useTemplateRef('rootEl'); const parentBg = ref<string | null>(null); // eslint-disable-next-line vue/no-setup-props-reactivity-loss const showBody = ref((props.persistKey && miLocalStorage.getItem(`${miLocalStoragePrefix}${props.persistKey}`)) ? (miLocalStorage.getItem(`${miLocalStoragePrefix}${props.persistKey}`) === 't') : props.expanded); diff --git a/packages/frontend/src/components/MkFolder.vue b/packages/frontend/src/components/MkFolder.vue index 084c81bb52..2e5d0a3dea 100644 --- a/packages/frontend/src/components/MkFolder.vue +++ b/packages/frontend/src/components/MkFolder.vue @@ -27,26 +27,33 @@ SPDX-License-Identifier: AGPL-3.0-only <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 : ''" - :enterFromClass="defaultStore.state.animation ? $style.transition_toggle_enterFrom : ''" - :leaveToClass="defaultStore.state.animation ? $style.transition_toggle_leaveTo : ''" - @enter="enter" - @afterEnter="afterEnter" - @leave="leave" - @afterLeave="afterLeave" + :enterActiveClass="prefer.s.animation ? $style.transition_toggle_enterActive : ''" + :leaveActiveClass="prefer.s.animation ? $style.transition_toggle_leaveActive : ''" + :enterFromClass="prefer.s.animation ? $style.transition_toggle_enterFrom : ''" + :leaveToClass="prefer.s.animation ? $style.transition_toggle_leaveTo : ''" > <KeepAlive> <div v-show="opened"> - <MkSpacer v-if="withSpacer" :marginMin="spacerMin" :marginMax="spacerMax"> - <slot></slot> - </MkSpacer> - <div v-else> - <slot></slot> - </div> - <div v-if="$slots.footer" :class="$style.footer"> - <slot name="footer"></slot> - </div> + <MkStickyContainer> + <template #header> + <div v-if="$slots.header" :class="$style.inBodyHeader"> + <slot name="header"></slot> + </div> + </template> + + <div v-if="withSpacer" class="_spacer" :style="{ '--MI_SPACER-min': props.spacerMin + 'px', '--MI_SPACER-max': props.spacerMax + 'px' }"> + <slot></slot> + </div> + <div v-else> + <slot></slot> + </div> + + <template #footer> + <div v-if="$slots.footer" :class="$style.inBodyFooter"> + <slot name="footer"></slot> + </div> + </template> + </MkStickyContainer> </div> </KeepAlive> </Transition> @@ -56,9 +63,9 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { nextTick, onMounted, ref, shallowRef } from 'vue'; -import { defaultStore } from '@/store.js'; -import { getBgColor } from '@/scripts/get-bg-color.js'; +import { nextTick, onMounted, ref, useTemplateRef } from 'vue'; +import { prefer } from '@/preferences.js'; +import { getBgColor } from '@/utility/get-bg-color.js'; const props = withDefaults(defineProps<{ defaultOpen?: boolean; @@ -74,37 +81,11 @@ const props = withDefaults(defineProps<{ spacerMax: 22, }); -const rootEl = shallowRef<HTMLElement>(); +const rootEl = useTemplateRef('rootEl'); const bgSame = ref(false); const opened = ref(props.defaultOpen); const openedAtLeastOnce = ref(props.defaultOpen); -function enter(el: Element) { - if (!(el instanceof HTMLElement)) return; - const elementHeight = el.getBoundingClientRect().height; - el.style.height = '0'; - el.offsetHeight; // reflow - el.style.height = `${Math.min(elementHeight, props.maxHeight ?? Infinity)}px`; -} - -function afterEnter(el: Element) { - if (!(el instanceof HTMLElement)) return; - el.style.height = ''; -} - -function leave(el: Element) { - if (!(el instanceof HTMLElement)) return; - const elementHeight = el.getBoundingClientRect().height; - el.style.height = `${elementHeight}px`; - el.offsetHeight; // reflow - el.style.height = '0'; -} - -function afterLeave(el: Element) { - if (!(el instanceof HTMLElement)) return; - el.style.height = ''; -} - function toggle() { if (!opened.value) { openedAtLeastOnce.value = true; @@ -116,7 +97,7 @@ function toggle() { } onMounted(() => { - const computedStyle = getComputedStyle(document.documentElement); + const computedStyle = getComputedStyle(window.document.documentElement); const parentBg = getBgColor(rootEl.value?.parentElement) ?? 'transparent'; const myBg = computedStyle.getPropertyValue('--MI_THEME-panel'); bgSame.value = parentBg === myBg; @@ -126,16 +107,18 @@ onMounted(() => { <style lang="scss" module> .transition_toggle_enterActive, .transition_toggle_leaveActive { - overflow-y: clip; - transition: opacity 0.3s, height 0.3s, transform 0.3s !important; + overflow-y: hidden; // 子要素のmarginが突き出るため clip を使ってはいけない + transition: opacity 0.3s, height 0.3s !important; } .transition_toggle_enterFrom, .transition_toggle_leaveTo { opacity: 0; + height: 0; } .root { display: block; + interpolate-size: allow-keywords; // heightのtransitionを動作させるために必要 } .header { @@ -175,7 +158,7 @@ onMounted(() => { } .headerLower { - color: var(--MI_THEME-fgTransparentWeak); + color: color(from var(--MI_THEME-fg) srgb r g b / 0.75); font-size: .85em; padding-left: 4px; } @@ -209,13 +192,13 @@ onMounted(() => { } .headerTextSub { - color: var(--MI_THEME-fgTransparentWeak); + color: color(from var(--MI_THEME-fg) srgb r g b / 0.75); font-size: .85em; } .headerRight { margin-left: auto; - color: var(--MI_THEME-fgTransparentWeak); + color: color(from var(--MI_THEME-fg) srgb r g b / 0.75); white-space: nowrap; } @@ -230,16 +213,23 @@ onMounted(() => { &.bgSame { background: var(--MI_THEME-bg); + + .inBodyHeader { + background: color(from var(--MI_THEME-bg) srgb r g b / 0.75); + } } } -.footer { - position: sticky !important; - z-index: 1; - bottom: var(--MI-stickyBottom, 0px); - left: 0; +.inBodyHeader { + background: color(from var(--MI_THEME-panel) srgb r g b / 0.75); + -webkit-backdrop-filter: var(--MI-blur, blur(15px)); + backdrop-filter: var(--MI-blur, blur(15px)); + border-bottom: solid 0.5px var(--MI_THEME-divider); +} + +.inBodyFooter { padding: 12px; - background: var(--MI_THEME-acrylicBg); + background: color(from var(--MI_THEME-bg) srgb r g b / 0.5); -webkit-backdrop-filter: var(--MI-blur, blur(15px)); backdrop-filter: var(--MI-blur, blur(15px)); background-size: auto auto; diff --git a/packages/frontend/src/components/MkFollowButton.vue b/packages/frontend/src/components/MkFollowButton.vue index 42e8485f46..1d86af39e5 100644 --- a/packages/frontend/src/components/MkFollowButton.vue +++ b/packages/frontend/src/components/MkFollowButton.vue @@ -39,13 +39,13 @@ import { onBeforeUnmount, onMounted, ref, watch } from 'vue'; import * as Misskey from 'misskey-js'; import { host } from '@@/js/config.js'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { useStream } from '@/stream.js'; import { i18n } from '@/i18n.js'; -import { claimAchievement } from '@/scripts/achievements.js'; -import { pleaseLogin } from '@/scripts/please-login.js'; -import { $i } from '@/account.js'; -import { defaultStore } from '@/store.js'; +import { claimAchievement } from '@/utility/achievements.js'; +import { pleaseLogin } from '@/utility/please-login.js'; +import { $i } from '@/i.js'; +import { prefer } from '@/preferences.js'; const props = withDefaults(defineProps<{ user: Misskey.entities.UserDetailed, @@ -106,7 +106,7 @@ async function onClick() { userId: props.user.id, }); } else { - if (defaultStore.state.alwaysConfirmFollow) { + if (prefer.s.alwaysConfirmFollow && !hasPendingFollowRequestFromYou.value) { const { canceled } = await os.confirm({ type: 'question', text: i18n.tsx.followConfirm({ name: props.user.name || props.user.username }), @@ -119,6 +119,16 @@ async function onClick() { } if (hasPendingFollowRequestFromYou.value) { + const { canceled } = await os.confirm({ + type: 'question', + text: i18n.ts.undoFollowRequestConfirm, + }); + + if (canceled) { + wait.value = false; + return; + } + await misskeyApi('following/requests/cancel', { userId: props.user.id, }); @@ -126,11 +136,11 @@ async function onClick() { } else { await misskeyApi('following/create', { userId: props.user.id, - withReplies: defaultStore.state.defaultWithReplies, + withReplies: prefer.s.defaultFollowWithReplies, }); emit('update:user', { ...props.user, - withReplies: defaultStore.state.defaultWithReplies, + withReplies: prefer.s.defaultFollowWithReplies, }); hasPendingFollowRequestFromYou.value = true; @@ -217,13 +227,13 @@ onBeforeUnmount(() => { background: var(--MI_THEME-accent); &:hover { - background: var(--MI_THEME-accentLighten); - border-color: var(--MI_THEME-accentLighten); + background: hsl(from var(--MI_THEME-accent) h s calc(l + 10)); + border-color: hsl(from var(--MI_THEME-accent) h s calc(l + 10)); } &:active { - background: var(--MI_THEME-accentDarken); - border-color: var(--MI_THEME-accentDarken); + background: hsl(from var(--MI_THEME-accent) h s calc(l - 10)); + border-color: hsl(from var(--MI_THEME-accent) h s calc(l - 10)); } } diff --git a/packages/frontend/src/components/MkForgotPassword.vue b/packages/frontend/src/components/MkForgotPassword.vue index 35112ad45d..57946aaf2b 100644 --- a/packages/frontend/src/components/MkForgotPassword.vue +++ b/packages/frontend/src/components/MkForgotPassword.vue @@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only > <template #header>{{ i18n.ts.forgotPassword }}</template> - <MkSpacer :marginMin="20" :marginMax="28"> + <div class="_spacer" style="--MI_SPACER-min: 20px; --MI_SPACER-max: 28px;"> <form v-if="instance.enableEmail" @submit.prevent="onSubmit"> <div class="_gaps_m"> <MkInput v-model="username" type="text" pattern="^[a-zA-Z0-9_]+$" :spellcheck="false" autofocus required> @@ -34,7 +34,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-else> {{ i18n.ts._forgotPassword.contactAdmin }} </div> - </MkSpacer> + </div> </MkModalWindow> </template> diff --git a/packages/frontend/src/components/MkFormDialog.file.vue b/packages/frontend/src/components/MkFormDialog.file.vue index ecb6cf882b..0a902f3400 100644 --- a/packages/frontend/src/components/MkFormDialog.file.vue +++ b/packages/frontend/src/components/MkFormDialog.file.vue @@ -15,8 +15,8 @@ import * as Misskey from 'misskey-js'; import { computed, ref } from 'vue'; import { i18n } from '@/i18n.js'; import MkButton from '@/components/MkButton.vue'; -import { selectFile } from '@/scripts/select-file.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { selectFile } from '@/utility/select-file.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; const props = defineProps<{ fileId?: string | null; diff --git a/packages/frontend/src/components/MkFormDialog.vue b/packages/frontend/src/components/MkFormDialog.vue index a639eae208..0884cdc016 100644 --- a/packages/frontend/src/components/MkFormDialog.vue +++ b/packages/frontend/src/components/MkFormDialog.vue @@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only {{ title }} </template> - <MkSpacer :marginMin="20" :marginMax="32"> + <div class="_spacer" style="--MI_SPACER-min: 20px; --MI_SPACER-max: 32px;"> <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))"> <template v-if="typeof v.hidden == 'function' ? v.hidden(values) : v.hidden"></template> @@ -63,15 +63,15 @@ SPDX-License-Identifier: AGPL-3.0-only </template> </div> <div v-else class="_fullinfo"> - <img :src="infoImageUrl" class="_ghost"/> + <img :src="infoImageUrl" draggable="false"/> <div>{{ i18n.ts.nothing }}</div> </div> - </MkSpacer> + </div> </MkModalWindow> </template> <script lang="ts" setup> -import { reactive, shallowRef } from 'vue'; +import { reactive, useTemplateRef } from 'vue'; import MkInput from './MkInput.vue'; import MkTextarea from './MkTextarea.vue'; import MkSwitch from './MkSwitch.vue'; @@ -80,7 +80,7 @@ import MkRange from './MkRange.vue'; import MkButton from './MkButton.vue'; import MkRadios from './MkRadios.vue'; import XFile from './MkFormDialog.file.vue'; -import type { Form } from '@/scripts/form.js'; +import type { Form } from '@/utility/form.js'; import MkModalWindow from '@/components/MkModalWindow.vue'; import { i18n } from '@/i18n.js'; import { infoImageUrl } from '@/instance.js'; @@ -99,7 +99,7 @@ const emit = defineEmits<{ (ev: 'closed'): void; }>(); -const dialog = shallowRef<InstanceType<typeof MkModalWindow>>(); +const dialog = useTemplateRef('dialog'); const values = reactive({}); for (const item in props.form) { diff --git a/packages/frontend/src/components/MkFukidashi.vue b/packages/frontend/src/components/MkFukidashi.vue index 8b1c56fca4..fba5dc854c 100644 --- a/packages/frontend/src/components/MkFukidashi.vue +++ b/packages/frontend/src/components/MkFukidashi.vue @@ -10,6 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only tail === 'left' ? $style.left : $style.right, negativeMargin === true && $style.negativeMargin, shadow === true && $style.shadow, + accented === true && $style.accented ]" > <div :class="$style.bg"> @@ -30,10 +31,12 @@ withDefaults(defineProps<{ tail?: 'left' | 'right' | 'none'; negativeMargin?: boolean; shadow?: boolean; + accented?: boolean; }>(), { tail: 'right', negativeMargin: false, shadow: false, + accented: false, }); </script> @@ -47,6 +50,10 @@ withDefaults(defineProps<{ min-height: calc(var(--fukidashi-radius) * 2); padding-top: calc(var(--fukidashi-radius) * .13); + &.accented { + --fukidashi-bg: color-mix(in srgb, var(--MI_THEME-accent), var(--MI_THEME-panel) 85%); + } + &.shadow { filter: drop-shadow(0 4px 32px var(--MI_THEME-shadow)); } @@ -77,7 +84,13 @@ withDefaults(defineProps<{ .content { position: relative; - padding: 8px 12px; + padding: 10px 14px; +} + +@container (max-width: 450px) { + .content { + padding: 8px 12px; + } } .tail { diff --git a/packages/frontend/src/components/MkGalleryPostPreview.stories.impl.ts b/packages/frontend/src/components/MkGalleryPostPreview.stories.impl.ts index a433ad680b..616e04aabb 100644 --- a/packages/frontend/src/components/MkGalleryPostPreview.stories.impl.ts +++ b/packages/frontend/src/components/MkGalleryPostPreview.stories.impl.ts @@ -5,7 +5,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { expect, userEvent, waitFor, within } from '@storybook/test'; -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { galleryPost } from '../../.storybook/fakes.js'; import MkGalleryPostPreview from './MkGalleryPostPreview.vue'; export const Default = { diff --git a/packages/frontend/src/components/MkGalleryPostPreview.vue b/packages/frontend/src/components/MkGalleryPostPreview.vue index 22f8355acf..49a6c65170 100644 --- a/packages/frontend/src/components/MkGalleryPostPreview.vue +++ b/packages/frontend/src/components/MkGalleryPostPreview.vue @@ -35,14 +35,14 @@ SPDX-License-Identifier: AGPL-3.0-only import * as Misskey from 'misskey-js'; import { computed, ref } from 'vue'; import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue'; -import { defaultStore } from '@/store.js'; +import { prefer } from '@/preferences.js'; const props = defineProps<{ post: Misskey.entities.GalleryPost; }>(); const hover = ref(false); -const safe = computed(() => defaultStore.state.nsfw === 'ignore' || defaultStore.state.nsfw === 'respect' && !props.post.isSensitive); +const safe = computed(() => prefer.s.nsfw === 'ignore' || prefer.s.nsfw === 'respect' && !props.post.isSensitive); const show = computed(() => safe.value || hover.value); function enterHover(): void { diff --git a/packages/frontend/src/components/MkGoogle.vue b/packages/frontend/src/components/MkGoogle.vue index 6cdab2479e..e7ee7ffab5 100644 --- a/packages/frontend/src/components/MkGoogle.vue +++ b/packages/frontend/src/components/MkGoogle.vue @@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref } from 'vue'; import { i18n } from '@/i18n.js'; -import { defaultStore } from '@/store'; +import { prefer } from '@/preferences.js'; const props = defineProps<{ q: string; @@ -23,7 +23,7 @@ const query = ref(props.q); const search = () => { const searchQuery = encodeURIComponent(query.value); - const searchUrl = defaultStore.state.searchEngine.replace(/{query}|%s\b/g, searchQuery); + const searchUrl = prefer.s.searchEngine.replace(/{query}|%s\b/g, searchQuery); window.open(searchUrl, '_blank', 'noopener'); }; diff --git a/packages/frontend/src/components/MkHeatmap.vue b/packages/frontend/src/components/MkHeatmap.vue index 0cc0df9911..28bb936755 100644 --- a/packages/frontend/src/components/MkHeatmap.vue +++ b/packages/frontend/src/components/MkHeatmap.vue @@ -13,14 +13,14 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, nextTick, watch, shallowRef, ref } from 'vue'; +import { onMounted, nextTick, watch, useTemplateRef, ref } from 'vue'; import { Chart } from 'chart.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'; -import { initChart } from '@/scripts/init-chart.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { store } from '@/store.js'; +import { useChartTooltip } from '@/use/use-chart-tooltip.js'; +import { alpha } from '@/utility/color.js'; +import { initChart } from '@/utility/init-chart.js'; initChart(); @@ -35,8 +35,8 @@ const props = withDefaults(defineProps<{ label: '', }); -const rootEl = shallowRef<HTMLDivElement | null>(null); -const chartEl = shallowRef<HTMLCanvasElement | null>(null); +const rootEl = useTemplateRef('rootEl'); +const chartEl = useTemplateRef('chartEl'); const now = new Date(); let chartInstance: Chart | null = null; const fetching = ref(true); @@ -106,7 +106,7 @@ async function renderChart() { await nextTick(); - const color = defaultStore.state.darkMode ? '#b4e900' : '#86b300'; + const color = store.s.darkMode ? '#b4e900' : '#86b300'; // 視覚上の分かりやすさのため上から最も大きい3つの値の平均を最大値とする const max = values.slice().sort((a, b) => b - a).slice(0, 3).reduce((a, b) => a + b, 0) / 3; diff --git a/packages/frontend/src/components/MkImgPreviewDialog.stories.impl.ts b/packages/frontend/src/components/MkImgPreviewDialog.stories.impl.ts new file mode 100644 index 0000000000..339e6d10f3 --- /dev/null +++ b/packages/frontend/src/components/MkImgPreviewDialog.stories.impl.ts @@ -0,0 +1,40 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { StoryObj } from '@storybook/vue3'; +import { file } from '../../.storybook/fakes.js'; +import MkImgPreviewDialog from './MkImgPreviewDialog.vue'; +export const Default = { + render(args) { + return { + components: { + MkImgPreviewDialog, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '<MkImgPreviewDialog v-bind="props" />', + }; + }, + args: { + file: file(), + }, + parameters: { + chromatic: { + // NOTE: ロードが終わるまで待つ + delay: 3000, + }, + layout: 'centered', + }, +} satisfies StoryObj<typeof MkImgPreviewDialog>; diff --git a/packages/frontend/src/components/MkImgPreviewDialog.vue b/packages/frontend/src/components/MkImgPreviewDialog.vue new file mode 100644 index 0000000000..3e6e4e0ec9 --- /dev/null +++ b/packages/frontend/src/components/MkImgPreviewDialog.vue @@ -0,0 +1,58 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<MkModalWindow + ref="modal" + :width="1800" + :height="900" + @close="close" + @esc="close" + @click="close" +> + <template #header>{{ file.name }}</template> + <div :class="$style.container"> + <img :src="file.url" :alt="file.comment ?? file.name" :class="$style.img"/> + </div> +</MkModalWindow> +</template> +<script lang="ts" setup> +import { defineProps, ref } from 'vue'; +import MkModalWindow from './MkModalWindow.vue'; +import type * as Misskey from 'misskey-js'; + +defineProps<{ + file: Misskey.entities.DriveFile; +}>(); + +const modal = ref<typeof MkModalWindow | null>(null); + +function close() { + modal.value?.close(); +} + +</script> +<style lang="scss" module> + .container { + box-sizing: border-box; + width: 100%; + height: 100%; + min-height: 0; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + + background-color: var(--MI_THEME-bg); + background-size: auto auto; + background-image: repeating-linear-gradient(135deg, transparent, transparent 6px, var(--MI_THEME-panel) 6px, var(--MI_THEME-panel) 12px); + } + + .img { + width: 100%; + max-height: 100%; + object-fit: contain; + } +</style> diff --git a/packages/frontend/src/components/MkImgWithBlurhash.vue b/packages/frontend/src/components/MkImgWithBlurhash.vue index b0741aaf5e..1282a8fedb 100644 --- a/packages/frontend/src/components/MkImgWithBlurhash.vue +++ b/packages/frontend/src/components/MkImgWithBlurhash.vue @@ -6,16 +6,42 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div ref="root" :class="['chromatic-ignore', $style.root, { [$style.cover]: cover }]" :title="title ?? ''"> <TransitionGroup - :duration="defaultStore.state.animation && props.transition?.duration || undefined" - :enterActiveClass="defaultStore.state.animation && props.transition?.enterActiveClass || undefined" - :leaveActiveClass="defaultStore.state.animation && (props.transition?.leaveActiveClass ?? $style.transition_leaveActive) || undefined" - :enterFromClass="defaultStore.state.animation && props.transition?.enterFromClass || undefined" - :leaveToClass="defaultStore.state.animation && props.transition?.leaveToClass || undefined" - :enterToClass="defaultStore.state.animation && props.transition?.enterToClass || undefined" - :leaveFromClass="defaultStore.state.animation && props.transition?.leaveFromClass || undefined" + :duration="prefer.s.animation && props.transition?.duration || undefined" + :enterActiveClass="prefer.s.animation && props.transition?.enterActiveClass || undefined" + :leaveActiveClass="prefer.s.animation && (props.transition?.leaveActiveClass ?? $style.transition_leaveActive) || undefined" + :enterFromClass="prefer.s.animation && props.transition?.enterFromClass || undefined" + :leaveToClass="prefer.s.animation && props.transition?.leaveToClass || undefined" + :enterToClass="prefer.s.animation && props.transition?.enterToClass || undefined" + :leaveFromClass="prefer.s.animation && props.transition?.leaveFromClass || undefined" > - <canvas v-show="hide" key="canvas" ref="canvas" :class="$style.canvas" :width="canvasWidth" :height="canvasHeight" :title="title ?? undefined" tabindex="-1"/> - <img v-show="!hide" key="img" ref="img" :height="imgHeight ?? undefined" :width="imgWidth ?? undefined" :class="$style.img" :src="src ?? undefined" :title="title ?? undefined" :alt="alt ?? undefined" loading="eager" decoding="async" tabindex="-1"/> + <canvas + v-show="hide" + key="canvas" + ref="canvas" + :class="$style.canvas" + :width="canvasWidth" + :height="canvasHeight" + :title="title ?? undefined" + draggable="false" + tabindex="-1" + style="-webkit-user-drag: none;" + /> + <img + v-show="!hide" + key="img" + ref="img" + :height="imgHeight ?? undefined" + :width="imgWidth ?? undefined" + :class="$style.img" + :src="src ?? undefined" + :title="title ?? undefined" + :alt="alt ?? undefined" + loading="eager" + decoding="async" + draggable="false" + tabindex="-1" + style="-webkit-user-drag: none;" + /> </TransitionGroup> </div> </template> @@ -29,7 +55,7 @@ import { extractAvgColorFromBlurhash } from '@@/js/extract-avg-color-from-blurha const canvasPromise = new Promise<WorkerMultiDispatch | HTMLCanvasElement>(resolve => { // テスト環境で Web Worker インスタンスは作成できない if (import.meta.env.MODE === 'test') { - const canvas = document.createElement('canvas'); + const canvas = window.document.createElement('canvas'); canvas.width = 64; canvas.height = 64; resolve(canvas); @@ -43,13 +69,11 @@ const canvasPromise = new Promise<WorkerMultiDispatch | HTMLCanvasElement>(resol Math.min(navigator.hardwareConcurrency - 1, 4), ); resolve(workers); - if (_DEV_) console.log('WebGL2 in worker is supported!'); } else { - const canvas = document.createElement('canvas'); + const canvas = window.document.createElement('canvas'); canvas.width = 64; canvas.height = 64; resolve(canvas); - if (_DEV_) console.log('WebGL2 in worker is not supported...'); } testWorker.terminate(); }); @@ -57,10 +81,10 @@ const canvasPromise = new Promise<WorkerMultiDispatch | HTMLCanvasElement>(resol </script> <script lang="ts" setup> -import { computed, nextTick, onMounted, onUnmounted, shallowRef, watch, ref } from 'vue'; +import { computed, nextTick, onMounted, onUnmounted, useTemplateRef, watch, ref } from 'vue'; import { v4 as uuid } from 'uuid'; import { render } from 'buraha'; -import { defaultStore } from '@/store.js'; +import { prefer } from '@/preferences.js'; const props = withDefaults(defineProps<{ transition?: { @@ -94,9 +118,9 @@ const props = withDefaults(defineProps<{ }); const viewId = uuid(); -const canvas = shallowRef<HTMLCanvasElement>(); -const root = shallowRef<HTMLDivElement>(); -const img = shallowRef<HTMLImageElement>(); +const canvas = useTemplateRef('canvas'); +const root = useTemplateRef('root'); +const img = useTemplateRef('img'); const loaded = ref(false); const canvasWidth = ref(64); const canvasHeight = ref(64); @@ -111,7 +135,9 @@ function waitForDecode() { .then(() => img.value?.decode()) .then(() => { loaded.value = true; - }); + }) + // Ignore decoding errors + .catch(() => {}); } else { loaded.value = false; } diff --git a/packages/frontend/src/components/MkInfo.vue b/packages/frontend/src/components/MkInfo.vue index bdc38f5142..fb66098a54 100644 --- a/packages/frontend/src/components/MkInfo.vue +++ b/packages/frontend/src/components/MkInfo.vue @@ -4,11 +4,11 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div :class="[$style.root, { [$style.warn]: warn }]"> +<div :class="[$style.root, { [$style.warn]: warn }]" class="_selectable"> <i v-if="warn" class="ti ti-alert-triangle" :class="$style.i"></i> <i v-else class="ti ti-info-circle" :class="$style.i"></i> <div><slot></slot></div> - <button v-if="closable" :class="$style.button" class="_button" @click="close()"><i class="ti ti-x"></i></button> + <button v-if="closable" :class="$style.button" class="_button" @click="closeInfo()"><i class="ti ti-x"></i></button> </div> </template> @@ -24,7 +24,7 @@ const emit = defineEmits<{ (ev: 'close'): void; }>(); -function close() { +function closeInfo() { // こいつの中では非表示動作は行わない emit('close'); } @@ -39,7 +39,6 @@ function close() { background: color-mix(in srgb, var(--MI_THEME-infoBg) 65%, transparent); color: var(--MI_THEME-infoFg); border-radius: var(--MI-radius); - white-space: pre-wrap; z-index: 1; &.warn { diff --git a/packages/frontend/src/components/MkInput.vue b/packages/frontend/src/components/MkInput.vue index ec299dce36..f07a1ac33e 100644 --- a/packages/frontend/src/components/MkInput.vue +++ b/packages/frontend/src/components/MkInput.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div> +<div class="_selectable"> <div :class="$style.label" @click="focus"><slot name="label"></slot></div> <div :class="[$style.input, { [$style.inline]: inline, [$style.disabled]: disabled, [$style.focused]: focused }]"> <div ref="prefixEl" :class="$style.prefix"><slot name="prefix"></slot></div> @@ -44,12 +44,14 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, onUnmounted, nextTick, ref, shallowRef, watch, computed, toRefs, InputHTMLAttributes } from 'vue'; +import { onMounted, onUnmounted, nextTick, ref, useTemplateRef, watch, computed, toRefs } from 'vue'; import { debounce } from 'throttle-debounce'; -import MkButton from '@/components/MkButton.vue'; import { useInterval } from '@@/js/use-interval.js'; +import type { InputHTMLAttributes } from 'vue'; +import type { SuggestionType } from '@/utility/autocomplete.js'; +import MkButton from '@/components/MkButton.vue'; import { i18n } from '@/i18n.js'; -import { Autocomplete, SuggestionType } from '@/scripts/autocomplete.js'; +import { Autocomplete } from '@/utility/autocomplete.js'; const props = defineProps<{ modelValue: string | number | null; @@ -90,9 +92,9 @@ const focused = ref(false); const changed = ref(false); const invalid = ref(false); const filled = computed(() => v.value !== '' && v.value != null); -const inputEl = shallowRef<HTMLInputElement>(); -const prefixEl = shallowRef<HTMLElement>(); -const suffixEl = shallowRef<HTMLElement>(); +const inputEl = useTemplateRef('inputEl'); +const prefixEl = useTemplateRef('prefixEl'); +const suffixEl = useTemplateRef('suffixEl'); const height = props.small ? 33 : props.large ? 39 : @@ -199,7 +201,7 @@ defineExpose({ .caption { font-size: 0.85em; padding: 8px 0 0 0; - color: var(--MI_THEME-fgTransparentWeak); + color: color(from var(--MI_THEME-fg) srgb r g b / 0.75); &:empty { display: none; diff --git a/packages/frontend/src/components/MkInstanceCardMini.stories.impl.ts b/packages/frontend/src/components/MkInstanceCardMini.stories.impl.ts index 9e8de9d878..bd69fb2f82 100644 --- a/packages/frontend/src/components/MkInstanceCardMini.stories.impl.ts +++ b/packages/frontend/src/components/MkInstanceCardMini.stories.impl.ts @@ -3,13 +3,12 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -/* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { StoryObj } from '@storybook/vue3'; import { HttpResponse, http } from 'msw'; import { federationInstance } from '../../.storybook/fakes.js'; import { commonHandlers } from '../../.storybook/mocks.js'; import { getChartResolver } from '../../.storybook/charts.js'; import MkInstanceCardMini from './MkInstanceCardMini.vue'; +import type { StoryObj } from '@storybook/vue3'; export const Default = { render(args) { @@ -48,7 +47,7 @@ export const Default = { const url = new URL(urlStr); if (url.href.startsWith('https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/')) { - const image = await (await fetch(`client-assets/${url.pathname.split('/').pop()}`)).blob(); + const image = await (await window.fetch(`client-assets/${url.pathname.split('/').pop()}`)).blob(); return new HttpResponse(image, { headers: { 'Content-Type': 'image/jpeg', diff --git a/packages/frontend/src/components/MkInstanceCardMini.vue b/packages/frontend/src/components/MkInstanceCardMini.vue index b063b82b17..d20b24a439 100644 --- a/packages/frontend/src/components/MkInstanceCardMini.vue +++ b/packages/frontend/src/components/MkInstanceCardMini.vue @@ -18,8 +18,8 @@ SPDX-License-Identifier: AGPL-3.0-only import { ref } from 'vue'; import * as Misskey from 'misskey-js'; import MkMiniChart from '@/components/MkMiniChart.vue'; -import { misskeyApiGet } from '@/scripts/misskey-api.js'; -import { getProxiedImageUrlNullable } from '@/scripts/media-proxy.js'; +import { misskeyApiGet } from '@/utility/misskey-api.js'; +import { getProxiedImageUrlNullable } from '@/utility/media-proxy.js'; const props = defineProps<{ instance: Misskey.entities.FederationInstance; diff --git a/packages/frontend/src/components/MkInstanceStats.vue b/packages/frontend/src/components/MkInstanceStats.vue index d8066857fe..90391005bc 100644 --- a/packages/frontend/src/components/MkInstanceStats.vue +++ b/packages/frontend/src/components/MkInstanceStats.vue @@ -84,21 +84,22 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, ref, computed, shallowRef } from 'vue'; +import { onMounted, ref, computed, useTemplateRef } from 'vue'; import { Chart } from 'chart.js'; +import type { HeatmapSource } from '@/components/MkHeatmap.vue'; import MkSelect from '@/components/MkSelect.vue'; import MkChart from '@/components/MkChart.vue'; -import { useChartTooltip } from '@/scripts/use-chart-tooltip.js'; -import { $i } from '@/account.js'; +import { useChartTooltip } from '@/use/use-chart-tooltip.js'; +import { $i } from '@/i.js'; import * as os from '@/os.js'; -import { misskeyApiGet } from '@/scripts/misskey-api.js'; +import { misskeyApiGet } from '@/utility/misskey-api.js'; import { instance } from '@/instance.js'; import { i18n } from '@/i18n.js'; -import MkHeatmap, { type HeatmapSource } from '@/components/MkHeatmap.vue'; +import MkHeatmap from '@/components/MkHeatmap.vue'; import MkFoldableSection from '@/components/MkFoldableSection.vue'; import MkRetentionHeatmap from '@/components/MkRetentionHeatmap.vue'; import MkRetentionLineChart from '@/components/MkRetentionLineChart.vue'; -import { initChart } from '@/scripts/init-chart.js'; +import { initChart } from '@/utility/init-chart.js'; initChart(); @@ -108,8 +109,8 @@ const chartLimit = 500; const chartSpan = ref<'hour' | 'day'>('hour'); const chartSrc = ref('active-users'); const heatmapSrc = ref<HeatmapSource>('active-users'); -const subDoughnutEl = shallowRef<HTMLCanvasElement>(); -const pubDoughnutEl = shallowRef<HTMLCanvasElement>(); +const subDoughnutEl = useTemplateRef('subDoughnutEl'); +const pubDoughnutEl = useTemplateRef('pubDoughnutEl'); const { handler: externalTooltipHandler1 } = useChartTooltip({ position: 'middle', @@ -125,7 +126,7 @@ function createDoughnut(chartEl, tooltip, data) { labels: data.map(x => x.name), datasets: [{ backgroundColor: data.map(x => x.color), - borderColor: getComputedStyle(document.documentElement).getPropertyValue('--MI_THEME-panel'), + borderColor: getComputedStyle(window.document.documentElement).getPropertyValue('--MI_THEME-panel'), borderWidth: 2, hoverOffset: 0, data: data.map(x => x.value), diff --git a/packages/frontend/src/components/MkInstanceTicker.vue b/packages/frontend/src/components/MkInstanceTicker.vue index 9d9cc76822..e0c07f1f9f 100644 --- a/packages/frontend/src/components/MkInstanceTicker.vue +++ b/packages/frontend/src/components/MkInstanceTicker.vue @@ -11,10 +11,11 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, type CSSProperties } from 'vue'; +import { computed } from 'vue'; +import type { CSSProperties } from 'vue'; import { instanceName as localInstanceName } from '@@/js/config.js'; import { instance as localInstance } from '@/instance.js'; -import { getProxiedImageUrlNullable } from '@/scripts/media-proxy.js'; +import { getProxiedImageUrlNullable } from '@/utility/media-proxy.js'; const props = defineProps<{ host: string | null; diff --git a/packages/frontend/src/components/MkInviteCode.stories.impl.ts b/packages/frontend/src/components/MkInviteCode.stories.impl.ts index 456d215288..ccdebf0a4d 100644 --- a/packages/frontend/src/components/MkInviteCode.stories.impl.ts +++ b/packages/frontend/src/components/MkInviteCode.stories.impl.ts @@ -4,7 +4,7 @@ */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { HttpResponse, http } from 'msw'; import { userDetailed, inviteCode } from '../../.storybook/fakes.js'; import { commonHandlers } from '../../.storybook/mocks.js'; diff --git a/packages/frontend/src/components/MkInviteCode.vue b/packages/frontend/src/components/MkInviteCode.vue index 1a71f6574f..ab797459cc 100644 --- a/packages/frontend/src/components/MkInviteCode.vue +++ b/packages/frontend/src/components/MkInviteCode.vue @@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="$style.items"> <div> <div :class="$style.label">{{ i18n.ts.invitationCode }}</div> - <div>{{ invite.code }}</div> + <div class="_selectableAtomic">{{ invite.code }}</div> </div> <div v-if="moderator"> <div :class="$style.label">{{ i18n.ts.inviteCodeCreator }}</div> @@ -64,7 +64,7 @@ import { computed } from 'vue'; import * as Misskey from 'misskey-js'; import MkFolder from '@/components/MkFolder.vue'; import MkButton from '@/components/MkButton.vue'; -import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; @@ -90,7 +90,6 @@ function deleteCode() { function copyInviteCode() { copyToClipboard(props.invite.code); - os.success(); } </script> diff --git a/packages/frontend/src/components/MkKeyValue.vue b/packages/frontend/src/components/MkKeyValue.vue index 50c9e16e5e..b4185d2d0a 100644 --- a/packages/frontend/src/components/MkKeyValue.vue +++ b/packages/frontend/src/components/MkKeyValue.vue @@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="$style.key"> <slot name="key"></slot> </div> - <div :class="$style.value"> + <div :class="$style.value" class="_selectable"> <slot name="value"></slot> <button v-if="copy" v-tooltip="i18n.ts.copy" class="_textButton" style="margin-left: 0.5em;" @click="copy_"><i class="ti ti-copy"></i></button> </div> @@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { } from 'vue'; -import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; @@ -31,7 +31,6 @@ const props = withDefaults(defineProps<{ const copy_ = () => { copyToClipboard(props.copy); - os.success(); }; </script> diff --git a/packages/frontend/src/components/MkLaunchPad.vue b/packages/frontend/src/components/MkLaunchPad.vue index c7af75e2e7..f33896b7da 100644 --- a/packages/frontend/src/components/MkLaunchPad.vue +++ b/packages/frontend/src/components/MkLaunchPad.vue @@ -27,11 +27,11 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { shallowRef } from 'vue'; +import { useTemplateRef } from 'vue'; import MkModal from '@/components/MkModal.vue'; import { navbarItemDef } from '@/navbar.js'; -import { defaultStore } from '@/store.js'; -import { deviceKind } from '@/scripts/device-kind.js'; +import { deviceKind } from '@/utility/device-kind.js'; +import { prefer } from '@/preferences.js'; const props = withDefaults(defineProps<{ src?: HTMLElement; @@ -48,9 +48,9 @@ const preferedModalType = (deviceKind === 'desktop' && props.src != null) ? 'pop deviceKind === 'smartphone' ? 'drawer' : 'dialog'; -const modal = shallowRef<InstanceType<typeof MkModal>>(); +const modal = useTemplateRef('modal'); -const menu = defaultStore.state.menu; +const menu = prefer.s.menu; const items = Object.keys(navbarItemDef).filter(k => !menu.includes(k)).map(k => navbarItemDef[k]).filter(def => def.show == null ? true : def.show).map(def => ({ type: def.to ? 'link' : 'button', diff --git a/packages/frontend/src/components/MkLink.vue b/packages/frontend/src/components/MkLink.vue index ad54e1b00e..3a942b03dc 100644 --- a/packages/frontend/src/components/MkLink.vue +++ b/packages/frontend/src/components/MkLink.vue @@ -19,11 +19,11 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { defineAsyncComponent, ref } from 'vue'; import { url as local } from '@@/js/config.js'; -import { useTooltip } from '@/scripts/use-tooltip.js'; +import { useTooltip } from '@/use/use-tooltip.js'; import * as os from '@/os.js'; import { isEnabledUrlPreview } from '@/instance.js'; -import { MkABehavior } from '@/components/global/MkA.vue'; -import { warningExternalWebsite } from '@/scripts/warning-external-website.js'; +import type { MkABehavior } from '@/components/global/MkA.vue'; +import { warningExternalWebsite } from '@/utility/warning-external-website.js'; import { maybeMakeRelative } from '@@/js/url.js'; const props = withDefaults(defineProps<{ diff --git a/packages/frontend/src/components/MkMediaAudio.vue b/packages/frontend/src/components/MkMediaAudio.vue index 10450fb621..4c78854fad 100644 --- a/packages/frontend/src/components/MkMediaAudio.vue +++ b/packages/frontend/src/components/MkMediaAudio.vue @@ -10,20 +10,20 @@ SPDX-License-Identifier: AGPL-3.0-only tabindex="0" :class="[ $style.audioContainer, - (audio.isSensitive && defaultStore.state.highlightSensitiveMedia) && $style.sensitive, + (audio.isSensitive && prefer.s.highlightSensitiveMedia) && $style.sensitive, ]" @contextmenu.stop @keydown.stop > <button v-if="hide" :class="$style.hidden" @click="show"> <div :class="$style.hiddenTextWrapper"> - <b v-if="audio.isSensitive" style="display: block;"><i class="ti ti-eye-exclamation"></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="ti ti-music"></i> {{ defaultStore.state.dataSaver.media && audio.size ? bytes(audio.size) : i18n.ts.audio }}</b> + <b v-if="audio.isSensitive" style="display: block;"><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}{{ prefer.s.dataSaver.media ? ` (${i18n.ts.audio}${audio.size ? ' ' + bytes(audio.size) : ''})` : '' }}</b> + <b v-else style="display: block;"><i class="ti ti-music"></i> {{ prefer.s.dataSaver.media && audio.size ? bytes(audio.size) : i18n.ts.audio }}</b> <span style="display: block;">{{ i18n.ts.clickToShow }}</span> </div> </button> - <div v-else-if="defaultStore.reactiveState.useNativeUIForVideoAudioPlayer.value" :class="$style.nativeAudioContainer"> + <div v-else-if="prefer.s.useNativeUiForVideoAudioPlayer" :class="$style.nativeAudioContainer"> <audio ref="audioEl" preload="metadata" @@ -91,17 +91,18 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { shallowRef, watch, computed, ref, onDeactivated, onActivated, onMounted } from 'vue'; +import { useTemplateRef, 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 type { Keymap } from '@/utility/hotkey.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; -import { type Keymap } from '@/scripts/hotkey.js'; import bytes from '@/filters/bytes.js'; import { hms } from '@/filters/hms.js'; import MkMediaRange from '@/components/MkMediaRange.vue'; -import { $i, iAmModerator } from '@/account.js'; +import { $i, iAmModerator } from '@/i.js'; +import { prefer } from '@/preferences.js'; const props = defineProps<{ audio: Misskey.entities.DriveFile; @@ -150,17 +151,17 @@ const keymap = { // PlayerElもしくはその子要素にフォーカスがあるかどうか function hasFocus() { if (!playerEl.value) return false; - return playerEl.value === document.activeElement || playerEl.value.contains(document.activeElement); + return playerEl.value === window.document.activeElement || playerEl.value.contains(window.document.activeElement); } -const playerEl = shallowRef<HTMLDivElement>(); -const audioEl = shallowRef<HTMLAudioElement>(); +const playerEl = useTemplateRef('playerEl'); +const audioEl = useTemplateRef('audioEl'); // eslint-disable-next-line vue/no-setup-props-reactivity-loss -const hide = ref((defaultStore.state.nsfw === 'force' || defaultStore.state.dataSaver.media) ? true : (props.audio.isSensitive && defaultStore.state.nsfw !== 'ignore')); +const hide = ref((prefer.s.nsfw === 'force' || prefer.s.dataSaver.media) ? true : (props.audio.isSensitive && prefer.s.nsfw !== 'ignore')); async function show() { - if (props.audio.isSensitive && defaultStore.state.confirmWhenRevealingSensitiveMedia) { + if (props.audio.isSensitive && prefer.s.confirmWhenRevealingSensitiveMedia) { const { canceled } = await os.confirm({ type: 'question', text: i18n.ts.sensitiveMediaRevealConfirm, @@ -219,10 +220,9 @@ function showMenu(ev: MouseEvent) { }); } + const details: MenuItem[] = []; if ($i?.id === props.audio.userId) { - menu.push({ - type: 'divider', - }, { + details.push({ type: 'link', text: i18n.ts._fileViewer.title, icon: 'ti ti-info-circle', @@ -230,6 +230,29 @@ function showMenu(ev: MouseEvent) { }); } + if (iAmModerator) { + details.push({ + type: 'link', + text: i18n.ts.moderation, + icon: 'ti ti-photo-exclamation', + to: `/admin/file/${props.audio.id}`, + }); + } + + if (details.length > 0) { + menu.push({ type: 'divider' }, ...details); + } + + if (prefer.s.devMode) { + menu.push({ type: 'divider' }, { + icon: 'ti ti-hash', + text: i18n.ts.copyFileId, + action: () => { + copyToClipboard(props.audio.id); + }, + }); + } + menuShowing.value = true; os.popupMenu(menu, ev.currentTarget ?? ev.target, { align: 'right', @@ -239,7 +262,14 @@ function showMenu(ev: MouseEvent) { }); } -function toggleSensitive(file: Misskey.entities.DriveFile) { +async function toggleSensitive(file: Misskey.entities.DriveFile) { + const { canceled } = await os.confirm({ + type: 'warning', + text: file.isSensitive ? i18n.ts.unmarkAsSensitiveConfirm : i18n.ts.markAsSensitiveConfirm, + }); + + if (canceled) return; + os.apiWithDialog('drive/files/update', { fileId: file.id, isSensitive: !file.isSensitive, @@ -380,7 +410,7 @@ onDeactivated(() => { 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'); + hide.value = (prefer.s.nsfw === 'force' || prefer.s.dataSaver.media) ? true : (props.audio.isSensitive && prefer.s.nsfw !== 'ignore'); stopAudioElWatch(); onceInit = false; if (mediaTickFrameId) { diff --git a/packages/frontend/src/components/MkMediaBanner.vue b/packages/frontend/src/components/MkMediaBanner.vue index 56048a33d8..2aa971d43c 100644 --- a/packages/frontend/src/components/MkMediaBanner.vue +++ b/packages/frontend/src/components/MkMediaBanner.vue @@ -27,9 +27,9 @@ SPDX-License-Identifier: AGPL-3.0-only import { ref } from 'vue'; import * as Misskey from 'misskey-js'; import { i18n } from '@/i18n.js'; -import { defaultStore } from '@/store.js'; import * as os from '@/os.js'; import MkMediaAudio from '@/components/MkMediaAudio.vue'; +import { prefer } from '@/preferences.js'; const props = defineProps<{ media: Misskey.entities.DriveFile; @@ -38,7 +38,7 @@ const props = defineProps<{ const hide = ref(true); async function show() { - if (props.media.isSensitive && defaultStore.state.confirmWhenRevealingSensitiveMedia) { + if (props.media.isSensitive && prefer.s.confirmWhenRevealingSensitiveMedia) { const { canceled } = await os.confirm({ type: 'question', text: i18n.ts.sensitiveMediaRevealConfirm, diff --git a/packages/frontend/src/components/MkMediaImage.vue b/packages/frontend/src/components/MkMediaImage.vue index 4aca7256a5..54a1d8f324 100644 --- a/packages/frontend/src/components/MkMediaImage.vue +++ b/packages/frontend/src/components/MkMediaImage.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div :class="[hide ? $style.hidden : $style.visible, (image.isSensitive && defaultStore.state.highlightSensitiveMedia) && $style.sensitive]" @click="onclick"> +<div :class="[hide ? $style.hidden : $style.visible, (image.isSensitive && prefer.s.highlightSensitiveMedia) && $style.sensitive]" @click="onclick"> <component :is="disableImageLink ? 'div' : 'a'" v-bind="disableImageLink ? { @@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only > <ImgWithBlurhash :hash="image.blurhash" - :src="(defaultStore.state.dataSaver.media && hide) ? null : url" + :src="(prefer.s.dataSaver.media && hide) ? null : url" :forceBlurhash="hide" :cover="hide || cover" :alt="image.comment" @@ -32,8 +32,8 @@ SPDX-License-Identifier: AGPL-3.0-only <template v-if="hide"> <div :class="$style.hiddenText"> <div :class="$style.hiddenTextWrapper"> - <b v-if="image.isSensitive" style="display: block;"><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}{{ defaultStore.state.dataSaver.media ? ` (${i18n.ts.image}${image.size ? ' ' + bytes(image.size) : ''})` : '' }}</b> - <b v-else style="display: block;"><i class="ti ti-photo"></i> {{ defaultStore.state.dataSaver.media && image.size ? bytes(image.size) : i18n.ts.image }}</b> + <b v-if="image.isSensitive" style="display: block;"><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}{{ prefer.s.dataSaver.media ? ` (${i18n.ts.image}${image.size ? ' ' + bytes(image.size) : ''})` : '' }}</b> + <b v-else style="display: block;"><i class="ti ti-photo"></i> {{ prefer.s.dataSaver.media && image.size ? bytes(image.size) : i18n.ts.image }}</b> <span v-if="controls" style="display: block;">{{ i18n.ts.clickToShow }}</span> </div> </div> @@ -55,13 +55,14 @@ SPDX-License-Identifier: AGPL-3.0-only import { watch, ref, computed } from 'vue'; import * as Misskey from 'misskey-js'; import type { MenuItem } from '@/types/menu.js'; -import { getStaticImageUrl } from '@/scripts/media-proxy.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; +import { getStaticImageUrl } from '@/utility/media-proxy.js'; import bytes from '@/filters/bytes.js'; import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue'; -import { defaultStore } from '@/store.js'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; -import { $i, iAmModerator } from '@/account.js'; +import { $i, iAmModerator } from '@/i.js'; +import { prefer } from '@/preferences.js'; const props = withDefaults(defineProps<{ image: Misskey.entities.DriveFile; @@ -77,9 +78,9 @@ const props = withDefaults(defineProps<{ const hide = ref(true); -const url = computed(() => (props.raw || defaultStore.state.loadRawImages) +const url = computed(() => (props.raw || prefer.s.loadRawImages) ? props.image.url - : defaultStore.state.disableShowingAnimatedImages + : prefer.s.disableShowingAnimatedImages ? getStaticImageUrl(props.image.url) : props.image.thumbnailUrl, ); @@ -91,7 +92,7 @@ async function onclick(ev: MouseEvent) { if (hide.value) { ev.stopPropagation(); - if (props.image.isSensitive && defaultStore.state.confirmWhenRevealingSensitiveMedia) { + if (props.image.isSensitive && prefer.s.confirmWhenRevealingSensitiveMedia) { const { canceled } = await os.confirm({ type: 'question', text: i18n.ts.sensitiveMediaRevealConfirm, @@ -105,7 +106,7 @@ async function onclick(ev: MouseEvent) { // Plugin:register_note_view_interruptor を使って書き換えられる可能性があるためwatchする watch(() => props.image, () => { - hide.value = (defaultStore.state.nsfw === 'force' || defaultStore.state.dataSaver.media) ? true : (props.image.isSensitive && defaultStore.state.nsfw !== 'ignore'); + hide.value = (prefer.s.nsfw === 'force' || prefer.s.dataSaver.media) ? true : (props.image.isSensitive && prefer.s.nsfw !== 'ignore'); }, { deep: true, immediate: true, @@ -124,19 +125,28 @@ function showMenu(ev: MouseEvent) { if (iAmModerator) { menuItems.push({ - text: i18n.ts.markAsSensitive, + text: props.image.isSensitive ? i18n.ts.unmarkAsSensitive : i18n.ts.markAsSensitive, icon: 'ti ti-eye-exclamation', danger: true, - action: () => { - os.apiWithDialog('drive/files/update', { fileId: props.image.id, isSensitive: true }); + action: async () => { + const { canceled } = await os.confirm({ + type: 'warning', + text: props.image.isSensitive ? i18n.ts.unmarkAsSensitiveConfirm : i18n.ts.markAsSensitiveConfirm, + }); + + if (canceled) return; + + os.apiWithDialog('drive/files/update', { + fileId: props.image.id, + isSensitive: !props.image.isSensitive, + }); }, }); } + const details: MenuItem[] = []; if ($i?.id === props.image.userId) { - menuItems.push({ - type: 'divider', - }, { + details.push({ type: 'link', text: i18n.ts._fileViewer.title, icon: 'ti ti-info-circle', @@ -144,6 +154,29 @@ function showMenu(ev: MouseEvent) { }); } + if (iAmModerator) { + details.push({ + type: 'link', + text: i18n.ts.moderation, + icon: 'ti ti-photo-exclamation', + to: `/admin/file/${props.image.id}`, + }); + } + + if (details.length > 0) { + menuItems.push({ type: 'divider' }, ...details); + } + + if (prefer.s.devMode) { + menuItems.push({ type: 'divider' }, { + icon: 'ti ti-hash', + text: i18n.ts.copyFileId, + action: () => { + copyToClipboard(props.image.id); + }, + }); + } + os.popupMenu(menuItems, ev.currentTarget ?? ev.target); } @@ -188,7 +221,7 @@ function showMenu(ev: MouseEvent) { position: absolute; border-radius: var(--MI-radius-sm); background-color: black; - color: var(--MI_THEME-accentLighten); + color: hsl(from var(--MI_THEME-accent) h s calc(l + 10)); font-size: 12px; opacity: .5; padding: 5px 8px; @@ -262,7 +295,7 @@ html[data-color-scheme=light] .visible { /* Hardcode to black because either --MI_THEME-bg or --MI_THEME-fg makes it hard to read in dark/light mode */ background-color: black; border-radius: var(--MI-radius-sm); - color: var(--MI_THEME-accentLighten); + color: hsl(from var(--MI_THEME-accent) h s calc(l + 10)); display: inline-block; font-weight: bold; font-size: 0.8em; diff --git a/packages/frontend/src/components/MkMediaList.vue b/packages/frontend/src/components/MkMediaList.vue index 487cf509d6..fcc01d9197 100644 --- a/packages/frontend/src/components/MkMediaList.vue +++ b/packages/frontend/src/components/MkMediaList.vue @@ -12,9 +12,9 @@ SPDX-License-Identifier: AGPL-3.0-only :class="[ $style.medias, count === 1 ? [$style.n1, { - [$style.n116_9]: defaultStore.reactiveState.mediaListWithOneImageAppearance.value === '16_9', - [$style.n11_1]: defaultStore.reactiveState.mediaListWithOneImageAppearance.value === '1_1', - [$style.n12_3]: defaultStore.reactiveState.mediaListWithOneImageAppearance.value === '2_3', + [$style.n116_9]: prefer.s.mediaListWithOneImageAppearance === '16_9', + [$style.n11_1]: prefer.s.mediaListWithOneImageAppearance === '1_1', + [$style.n12_3]: prefer.s.mediaListWithOneImageAppearance === '2_3', }] : count === 2 ? $style.n2 : count === 3 ? $style.n3 : count === 4 ? $style.n4 : $style.nMany, ]" > @@ -30,29 +30,29 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, onMounted, onUnmounted, shallowRef } from 'vue'; +import { computed, onMounted, onUnmounted, useTemplateRef } from 'vue'; import * as Misskey from 'misskey-js'; import PhotoSwipeLightbox from 'photoswipe/lightbox'; import PhotoSwipe from 'photoswipe'; import 'photoswipe/style.css'; +import { FILE_TYPE_BROWSERSAFE, FILE_EXT_TRACKER_MODULES, FILE_TYPE_TRACKER_MODULES, FILE_TYPE_FLASH_CONTENT, FILE_EXT_FLASH_CONTENT } from '@@/js/const.js'; import XBanner from '@/components/MkMediaBanner.vue'; import XImage from '@/components/MkMediaImage.vue'; import XVideo from '@/components/MkMediaVideo.vue'; import XModPlayer from '@/components/SkModPlayer.vue'; import XFlashPlayer from '@/components/SkFlashPlayer.vue'; import * as os from '@/os.js'; -import { FILE_TYPE_BROWSERSAFE, FILE_EXT_TRACKER_MODULES, FILE_TYPE_TRACKER_MODULES, FILE_TYPE_FLASH_CONTENT, FILE_EXT_FLASH_CONTENT } from '@@/js/const.js'; -import { defaultStore } from '@/store.js'; -import { focusParent } from '@/scripts/focus.js'; +import { focusParent } from '@/utility/focus.js'; +import { prefer } from '@/preferences.js'; const props = defineProps<{ mediaList: Misskey.entities.DriveFile[]; raw?: boolean; }>(); -const gallery = shallowRef<HTMLDivElement>(); +const gallery = useTemplateRef('gallery'); const pswpZIndex = os.claimZIndex('middle'); -document.documentElement.style.setProperty('--mk-pswp-root-z-index', pswpZIndex.toString()); +window.document.documentElement.style.setProperty('--mk-pswp-root-z-index', pswpZIndex.toString()); const count = computed(() => props.mediaList.filter(media => previewable(media)).length); let lightbox: PhotoSwipeLightbox | null = null; @@ -79,7 +79,7 @@ async function calcAspectRatio() { return `${Math.max(ratio, img.properties.width / img.properties.height).toString()} / 1`; }; - switch (defaultStore.state.mediaListWithOneImageAppearance) { + switch (prefer.s.mediaListWithOneImageAppearance) { case '16_9': gallery.value.style.aspectRatio = ratioMax(16 / 9); break; @@ -182,7 +182,7 @@ onMounted(() => { className: 'pswp__alt-text-container', appendTo: 'wrapper', onInit: (el, pswp) => { - const textBox = document.createElement('p'); + const textBox = window.document.createElement('p'); textBox.className = 'pswp__alt-text _acrylic'; el.appendChild(textBox); @@ -206,19 +206,19 @@ onMounted(() => { }); lightbox.on('afterInit', () => { - activeEl = document.activeElement instanceof HTMLElement ? document.activeElement : null; + activeEl = window.document.activeElement instanceof HTMLElement ? window.document.activeElement : null; focusParent(activeEl, true, true); lightbox?.pswp?.element?.focus({ preventScroll: true, }); - history.pushState(null, '', '#pswp'); + window.history.pushState(null, '', '#pswp'); }); lightbox.on('destroy', () => { focusParent(activeEl, true, false); activeEl = null; if (window.location.hash === '#pswp') { - history.back(); + window.history.back(); } }); @@ -261,7 +261,6 @@ defineExpose({ .container { position: relative; width: 100%; - margin-top: 4px; } .medias { diff --git a/packages/frontend/src/components/MkMediaRange.vue b/packages/frontend/src/components/MkMediaRange.vue index df7505b0c3..9689dc5cfa 100644 --- a/packages/frontend/src/components/MkMediaRange.vue +++ b/packages/frontend/src/components/MkMediaRange.vue @@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script setup lang="ts"> -import { computed, ModelRef } from 'vue'; +import { computed } from 'vue'; withDefaults(defineProps<{ buffer?: number; @@ -28,8 +28,7 @@ const emit = defineEmits<{ (ev: 'dragEnded', value: number): void; }>(); -// eslint-disable-next-line no-undef -const model = defineModel({ required: true }) as ModelRef<string | number>; +const model = defineModel<string | number>({ required: true }); const modelValue = computed({ get: () => typeof model.value === 'number' ? model.value : parseFloat(model.value), set: v => { model.value = v; }, diff --git a/packages/frontend/src/components/MkMediaVideo.vue b/packages/frontend/src/components/MkMediaVideo.vue index 04f8e9f8d8..a335ff4575 100644 --- a/packages/frontend/src/components/MkMediaVideo.vue +++ b/packages/frontend/src/components/MkMediaVideo.vue @@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only :class="[ $style.videoContainer, controlsShowing && $style.active, - (video.isSensitive && defaultStore.state.highlightSensitiveMedia) && $style.sensitive, + (video.isSensitive && prefer.s.highlightSensitiveMedia) && $style.sensitive, ]" @mouseover="onMouseOver" @mouseleave="onMouseLeave" @@ -20,13 +20,13 @@ SPDX-License-Identifier: AGPL-3.0-only > <button v-if="hide" :class="$style.hidden" @click="show"> <div :class="$style.hiddenTextWrapper"> - <b v-if="video.isSensitive" style="display: block;"><i class="ti ti-eye-exclamation"></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="ti ti-movie"></i> {{ defaultStore.state.dataSaver.media && video.size ? bytes(video.size) : i18n.ts.video }}</b> + <b v-if="video.isSensitive" style="display: block;"><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}{{ prefer.s.dataSaver.media ? ` (${i18n.ts.video}${video.size ? ' ' + bytes(video.size) : ''})` : '' }}</b> + <b v-else style="display: block;"><i class="ti ti-movie"></i> {{ prefer.s.dataSaver.media && video.size ? bytes(video.size) : i18n.ts.video }}</b> <span style="display: block;">{{ i18n.ts.clickToShow }}</span> </div> </button> - <div v-else-if="defaultStore.reactiveState.useNativeUIForVideoAudioPlayer.value" :class="$style.videoRoot"> + <div v-else-if="prefer.s.useNativeUiForVideoAudioPlayer" :class="$style.videoRoot"> <video ref="videoEl" :class="$style.video" @@ -112,19 +112,20 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ref, shallowRef, computed, watch, onDeactivated, onActivated, onMounted } from 'vue'; +import { ref, useTemplateRef, computed, watch, onDeactivated, onActivated, onMounted } from 'vue'; import * as Misskey from 'misskey-js'; import type { MenuItem } from '@/types/menu.js'; -import { type Keymap } from '@/scripts/hotkey.js'; +import type { Keymap } from '@/utility/hotkey.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.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 { exitFullscreen, requestFullscreen } from '@/scripts/fullscreen.js'; -import hasAudio from '@/scripts/media-has-audio.js'; +import { exitFullscreen, requestFullscreen } from '@/utility/fullscreen.js'; +import hasAudio from '@/utility/media-has-audio.js'; import MkMediaRange from '@/components/MkMediaRange.vue'; -import { $i, iAmModerator } from '@/account.js'; +import { $i, iAmModerator } from '@/i.js'; +import { prefer } from '@/preferences.js'; const props = defineProps<{ video: Misskey.entities.DriveFile; @@ -173,14 +174,14 @@ const keymap = { // PlayerElもしくはその子要素にフォーカスがあるかどうか function hasFocus() { if (!playerEl.value) return false; - return playerEl.value === document.activeElement || playerEl.value.contains(document.activeElement); + return playerEl.value === window.document.activeElement || playerEl.value.contains(window.document.activeElement); } // eslint-disable-next-line vue/no-setup-props-reactivity-loss -const hide = ref((defaultStore.state.nsfw === 'force' || defaultStore.state.dataSaver.media) ? true : (props.video.isSensitive && defaultStore.state.nsfw !== 'ignore')); +const hide = ref((prefer.s.nsfw === 'force' || prefer.s.dataSaver.media) ? true : (props.video.isSensitive && prefer.s.nsfw !== 'ignore')); async function show() { - if (props.video.isSensitive && defaultStore.state.confirmWhenRevealingSensitiveMedia) { + if (props.video.isSensitive && prefer.s.confirmWhenRevealingSensitiveMedia) { const { canceled } = await os.confirm({ type: 'question', text: i18n.ts.sensitiveMediaRevealConfirm, @@ -218,7 +219,7 @@ function showMenu(ev: MouseEvent) { '2.0x': 2, }, }, - ...(document.pictureInPictureEnabled ? [{ + ...(window.document.pictureInPictureEnabled ? [{ text: i18n.ts._mediaControls.pip, icon: 'ti ti-picture-in-picture', action: togglePictureInPicture, @@ -244,10 +245,9 @@ function showMenu(ev: MouseEvent) { }); } + const details: MenuItem[] = []; if ($i?.id === props.video.userId) { - menu.push({ - type: 'divider', - }, { + details.push({ type: 'link', text: i18n.ts._fileViewer.title, icon: 'ti ti-info-circle', @@ -255,6 +255,29 @@ function showMenu(ev: MouseEvent) { }); } + if (iAmModerator) { + details.push({ + type: 'link', + text: i18n.ts.moderation, + icon: 'ti ti-photo-exclamation', + to: `/admin/file/${props.video.id}`, + }); + } + + if (details.length > 0) { + menu.push({ type: 'divider' }, ...details); + } + + if (prefer.s.devMode) { + menu.push({ type: 'divider' }, { + icon: 'ti ti-hash', + text: i18n.ts.copyFileId, + action: () => { + copyToClipboard(props.video.id); + }, + }); + } + menuShowing.value = true; os.popupMenu(menu, ev.currentTarget ?? ev.target, { align: 'right', @@ -264,7 +287,14 @@ function showMenu(ev: MouseEvent) { }); } -function toggleSensitive(file: Misskey.entities.DriveFile) { +async function toggleSensitive(file: Misskey.entities.DriveFile) { + const { canceled } = await os.confirm({ + type: 'warning', + text: file.isSensitive ? i18n.ts.unmarkAsSensitiveConfirm : i18n.ts.markAsSensitiveConfirm, + }); + + if (canceled) return; + os.apiWithDialog('drive/files/update', { fileId: file.id, isSensitive: !file.isSensitive, @@ -272,8 +302,8 @@ function toggleSensitive(file: Misskey.entities.DriveFile) { } // MediaControl: Video State -const videoEl = shallowRef<HTMLVideoElement>(); -const playerEl = shallowRef<HTMLDivElement>(); +const videoEl = useTemplateRef('videoEl'); +const playerEl = useTemplateRef('playerEl'); const isHoverring = ref(false); const controlsShowing = computed(() => { if (!oncePlayed.value) return true; @@ -312,7 +342,7 @@ const bufferedDataRatio = computed(() => { // MediaControl Events function onMouseOver() { if (controlStateTimer) { - clearTimeout(controlStateTimer); + window.clearTimeout(controlStateTimer); } isHoverring.value = true; } @@ -357,8 +387,8 @@ function toggleFullscreen() { function togglePictureInPicture() { if (videoEl.value) { - if (document.pictureInPictureElement) { - document.exitPictureInPicture(); + if (window.document.pictureInPictureElement) { + window.document.exitPictureInPicture(); } else { videoEl.value.requestPictureInPicture(); } @@ -475,7 +505,7 @@ onDeactivated(() => { 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'); + hide.value = (prefer.s.nsfw === 'force' || prefer.s.dataSaver.media) ? true : (props.video.isSensitive && prefer.s.nsfw !== 'ignore'); stopVideoElWatch(); onceInit = false; if (mediaTickFrameId) { @@ -526,7 +556,7 @@ onDeactivated(() => { /* Hardcode to black because either --MI_THEME-bg or --MI_THEME-fg makes it hard to read in dark/light mode */ background-color: black; border-radius: 6px; - color: var(--MI_THEME-accentLighten); + color: hsl(from var(--MI_THEME-accent) h s calc(l + 10)); display: inline-block; font-weight: bold; font-size: 0.8em; @@ -538,7 +568,7 @@ onDeactivated(() => { position: absolute; border-radius: var(--MI-radius-sm); background-color: black; - color: var(--MI_THEME-accentLighten); + color: hsl(from var(--MI_THEME-accent) h s calc(l + 10)); font-size: 12px; opacity: .5; padding: 5px 8px; diff --git a/packages/frontend/src/components/MkMention.vue b/packages/frontend/src/components/MkMention.vue index ac50d82a63..de8f39e7a8 100644 --- a/packages/frontend/src/components/MkMention.vue +++ b/packages/frontend/src/components/MkMention.vue @@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only <img :class="$style.icon" :src="avatarUrl" alt=""> <span> <span>@{{ username }}</span> - <span v-if="(host != localHost) || defaultStore.state.showFullAcct" :class="$style.host">@{{ toUnicode(host) }}</span> + <span v-if="(host != localHost)" :class="$style.host">@{{ toUnicode(host) }}</span> </span> </MkA> </template> @@ -17,10 +17,10 @@ SPDX-License-Identifier: AGPL-3.0-only import { toUnicode } from 'punycode.js'; import { computed } from 'vue'; import { host as localHost } from '@@/js/config.js'; -import { $i } from '@/account.js'; -import { defaultStore } from '@/store.js'; -import { getStaticImageUrl } from '@/scripts/media-proxy.js'; -import { MkABehavior } from '@/components/global/MkA.vue'; +import type { MkABehavior } from '@/components/global/MkA.vue'; +import { $i } from '@/i.js'; +import { getStaticImageUrl } from '@/utility/media-proxy.js'; +import { prefer } from '@/preferences.js'; const props = defineProps<{ username: string; @@ -36,7 +36,7 @@ const isMe = $i && ( `@${props.username}@${toUnicode(props.host)}` === `@${$i.username}@${toUnicode(localHost)}`.toLowerCase() ); -const avatarUrl = computed(() => defaultStore.state.disableShowingAnimatedImages || defaultStore.state.dataSaver.avatar +const avatarUrl = computed(() => prefer.s.disableShowingAnimatedImages || prefer.s.dataSaver.avatar ? getStaticImageUrl(`/avatar/@${props.username}@${props.host}`) : `/avatar/@${props.username}@${props.host}`, ); diff --git a/packages/frontend/src/components/MkMenu.child.vue b/packages/frontend/src/components/MkMenu.child.vue index 086573ba6d..f7cd72b6c6 100644 --- a/packages/frontend/src/components/MkMenu.child.vue +++ b/packages/frontend/src/components/MkMenu.child.vue @@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { nextTick, onMounted, onUnmounted, provide, shallowRef, watch } from 'vue'; +import { nextTick, onMounted, onUnmounted, provide, useTemplateRef, watch } from 'vue'; import MkMenu from './MkMenu.vue'; import type { MenuItem } from '@/types/menu.js'; @@ -28,7 +28,7 @@ const emit = defineEmits<{ provide('isNestingMenu', true); -const el = shallowRef<HTMLElement>(); +const el = useTemplateRef('el'); const align = 'left'; const SCROLLBAR_THICKNESS = 16; diff --git a/packages/frontend/src/components/MkMenu.vue b/packages/frontend/src/components/MkMenu.vue index b0a1b80210..c2fc967e1d 100644 --- a/packages/frontend/src/components/MkMenu.vue +++ b/packages/frontend/src/components/MkMenu.vue @@ -11,6 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only [$style.center]: align === 'center', [$style.big]: big, [$style.asDrawer]: asDrawer, + [$style.widthSpecified]: width != null, }" @focusin.passive.stop="() => {}" > @@ -29,12 +30,19 @@ SPDX-License-Identifier: AGPL-3.0-only > <template v-for="item in (items2 ?? [])"> <div v-if="item.type === 'divider'" role="separator" tabindex="-1" :class="$style.divider"></div> - <span v-else-if="item.type === 'label'" role="menuitem" tabindex="-1" :class="[$style.label, $style.item]"> - <span style="opacity: 0.7;">{{ item.text }}</span> - </span> + + <div v-else-if="item.type === 'label'" role="menuitem" tabindex="-1" :class="[$style.label]"> + <span>{{ item.text }}</span> + </div> + <span v-else-if="item.type === 'pending'" role="menuitem" tabindex="0" :class="[$style.pending, $style.item]"> <span><MkEllipsis/></span> </span> + + <div v-else-if="item.type === 'component'" role="menuitem" tabindex="-1" :class="[$style.componentItem]"> + <component :is="item.component" v-bind="item.props"/> + </div> + <MkA v-else-if="item.type === 'link'" role="menuitem" @@ -48,10 +56,14 @@ SPDX-License-Identifier: AGPL-3.0-only <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"> - <span :class="$style.item_content_text">{{ item.text }}</span> + <div :class="$style.item_content_text"> + <div :class="$style.item_content_text_title">{{ item.text }}</div> + <div v-if="item.caption" :class="$style.item_content_text_caption">{{ item.caption }}</div> + </div> <span v-if="item.indicate" :class="$style.indicator" class="_blink"><i class="_indicatorCircle"></i></span> </div> </MkA> + <a v-else-if="item.type === 'a'" role="menuitem" @@ -67,10 +79,14 @@ SPDX-License-Identifier: AGPL-3.0-only > <i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i> <div :class="$style.item_content"> - <span :class="$style.item_content_text">{{ item.text }}</span> + <div :class="$style.item_content_text"> + <div :class="$style.item_content_text_title">{{ item.text }}</div> + <div v-if="item.caption" :class="$style.item_content_text_caption">{{ item.caption }}</div> + </div> <span v-if="item.indicate" :class="$style.indicator" class="_blink"><i class="_indicatorCircle"></i></span> </div> </a> + <button v-else-if="item.type === 'user'" role="menuitem" @@ -85,6 +101,7 @@ SPDX-License-Identifier: AGPL-3.0-only <span :class="$style.indicator" class="_blink"><i class="_indicatorCircle"></i></span> </div> </button> + <button v-else-if="item.type === 'switch'" role="menuitemcheckbox" @@ -98,10 +115,14 @@ SPDX-License-Identifier: AGPL-3.0-only <i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i> <MkSwitchButton v-else :class="$style.switchButton" :checked="item.ref" :disabled="item.disabled" @toggle="switchItem(item)"/> <div :class="$style.item_content"> - <span :class="[$style.item_content_text, { [$style.switchText]: !item.icon }]">{{ item.text }}</span> + <div :class="[$style.item_content_text, { [$style.switchText]: !item.icon }]"> + <div :class="$style.item_content_text_title">{{ item.text }}</div> + <div v-if="item.caption" :class="$style.item_content_text_caption">{{ item.caption }}</div> + </div> <MkSwitchButton v-if="item.icon" :class="[$style.switchButton, $style.caret]" :checked="item.ref" :disabled="item.disabled" @toggle="switchItem(item)"/> </div> </button> + <button v-else-if="item.type === 'radio'" role="menuitem" @@ -114,10 +135,14 @@ SPDX-License-Identifier: AGPL-3.0-only > <i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]" style="pointer-events: none;"></i> <div :class="$style.item_content"> - <span :class="$style.item_content_text" style="pointer-events: none;">{{ item.text }}</span> + <div :class="$style.item_content_text" style="pointer-events: none;"> + <div :class="$style.item_content_text_title">{{ item.text }}</div> + <div v-if="item.caption" :class="$style.item_content_text_caption">{{ item.caption }}</div> + </div> <span :class="$style.caret" style="pointer-events: none;"><i class="ti ti-chevron-right ti-fw"></i></span> </div> </button> + <button v-else-if="item.type === 'radioOption'" role="menuitemradio" @@ -131,9 +156,13 @@ SPDX-License-Identifier: AGPL-3.0-only <span :class="[$style.radioIcon, { [$style.radioChecked]: unref(item.active) }]"></span> </div> <div :class="$style.item_content"> - <span :class="$style.item_content_text">{{ item.text }}</span> + <div :class="$style.item_content_text"> + <div :class="$style.item_content_text_title">{{ item.text }}</div> + <div v-if="item.caption" :class="$style.item_content_text_caption">{{ item.caption }}</div> + </div> </div> </button> + <button v-else-if="item.type === 'parent'" role="menuitem" @@ -145,12 +174,17 @@ SPDX-License-Identifier: AGPL-3.0-only > <i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]" style="pointer-events: none;"></i> <div :class="$style.item_content"> - <span :class="$style.item_content_text" style="pointer-events: none;">{{ item.text }}</span> + <div :class="$style.item_content_text" style="pointer-events: none;"> + <div :class="$style.item_content_text_title">{{ item.text }}</div> + <div v-if="item.caption" :class="$style.item_content_text_caption">{{ item.caption }}</div> + </div> <span :class="$style.caret" style="pointer-events: none;"><i class="ti ti-chevron-right ti-fw"></i></span> </div> </button> + <button - v-else role="menuitem" + v-else + role="menuitem" tabindex="0" :class="['_button', $style.item, { [$style.danger]: item.danger, [$style.active]: unref(item.active) }]" @click.prevent="unref(item.active) ? close(false) : clicked(item.action, $event)" @@ -160,11 +194,15 @@ SPDX-License-Identifier: AGPL-3.0-only <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"> - <span :class="$style.item_content_text">{{ item.text }}</span> + <div :class="$style.item_content_text"> + <div :class="$style.item_content_text_title">{{ item.text }}</div> + <div v-if="item.caption" :class="$style.item_content_text_caption">{{ item.caption }}</div> + </div> <span v-if="item.indicate" :class="$style.indicator" class="_blink"><i class="_indicatorCircle"></i></span> </div> </button> </template> + <span v-if="items2 == null || items2.length === 0" tabindex="-1" :class="[$style.none, $style.item]"> <span>{{ i18n.ts.none }}</span> </span> @@ -176,15 +214,15 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts"> -import { computed, defineAsyncComponent, inject, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, unref, watch } from 'vue'; +import { computed, defineAsyncComponent, inject, nextTick, onBeforeUnmount, onMounted, ref, useTemplateRef, unref, watch, shallowRef } from 'vue'; +import type { MenuItem, InnerMenuItem, MenuPending, MenuAction, MenuSwitch, MenuRadio, MenuRadioOption, MenuParent } from '@/types/menu.js'; +import type { Keymap } from '@/utility/hotkey.js'; import MkSwitchButton from '@/components/MkSwitch.button.vue'; -import { MenuItem, InnerMenuItem, MenuPending, MenuAction, MenuSwitch, MenuRadio, MenuRadioOption, MenuParent } from '@/types/menu.js'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; -import { isTouchUsing } from '@/scripts/touch.js'; -import { type Keymap } from '@/scripts/hotkey.js'; -import { isFocusable } from '@/scripts/focus.js'; -import { getNodeOrNull } from '@/scripts/get-dom-node-or-null.js'; +import { isTouchUsing } from '@/utility/touch.js'; +import { isFocusable } from '@/utility/focus.js'; +import { getNodeOrNull } from '@/utility/get-dom-node-or-null.js'; const childrenCache = new WeakMap<MenuParent, MenuItem[]>(); </script> @@ -209,11 +247,11 @@ const big = isTouchUsing; const isNestingMenu = inject<boolean>('isNestingMenu', false); -const itemsEl = shallowRef<HTMLElement>(); +const itemsEl = useTemplateRef('itemsEl'); const items2 = ref<InnerMenuItem[]>(); -const child = shallowRef<InstanceType<typeof XChild>>(); +const child = useTemplateRef('child'); const keymap = { 'up|k|shift+tab': { @@ -254,7 +292,7 @@ watch(() => props.items, () => { }); const childMenu = ref<MenuItem[] | null>(); -const childTarget = shallowRef<HTMLElement | null>(); +const childTarget = shallowRef<HTMLElement>(); function closeChild() { childMenu.value = null; @@ -355,10 +393,10 @@ function switchItem(item: MenuSwitch & { ref: any }) { function focusUp() { if (disposed) return; - if (!itemsEl.value?.contains(document.activeElement)) return; + if (!itemsEl.value?.contains(window.document.activeElement)) return; const focusableElements = Array.from(itemsEl.value.children).filter(isFocusable); - const activeIndex = focusableElements.findIndex(el => el === document.activeElement); + const activeIndex = focusableElements.findIndex(el => el === window.document.activeElement); const targetIndex = (activeIndex !== -1 && activeIndex !== 0) ? (activeIndex - 1) : (focusableElements.length - 1); const targetElement = focusableElements.at(targetIndex) ?? itemsEl.value; @@ -367,10 +405,10 @@ function focusUp() { function focusDown() { if (disposed) return; - if (!itemsEl.value?.contains(document.activeElement)) return; + if (!itemsEl.value?.contains(window.document.activeElement)) return; const focusableElements = Array.from(itemsEl.value.children).filter(isFocusable); - const activeIndex = focusableElements.findIndex(el => el === document.activeElement); + const activeIndex = focusableElements.findIndex(el => el === window.document.activeElement); const targetIndex = (activeIndex !== -1 && activeIndex !== (focusableElements.length - 1)) ? (activeIndex + 1) : 0; const targetElement = focusableElements.at(targetIndex) ?? itemsEl.value; @@ -397,9 +435,9 @@ const onGlobalMousedown = (ev: MouseEvent) => { const setupHandlers = () => { if (!isNestingMenu) { - document.addEventListener('focusin', onGlobalFocusin, { passive: true }); + window.document.addEventListener('focusin', onGlobalFocusin, { passive: true }); } - document.addEventListener('mousedown', onGlobalMousedown, { passive: true }); + window.document.addEventListener('mousedown', onGlobalMousedown, { passive: true }); }; let disposed = false; @@ -407,9 +445,9 @@ let disposed = false; const disposeHandlers = () => { disposed = true; if (!isNestingMenu) { - document.removeEventListener('focusin', onGlobalFocusin); + window.document.removeEventListener('focusin', onGlobalFocusin); } - document.removeEventListener('mousedown', onGlobalMousedown); + window.document.removeEventListener('mousedown', onGlobalMousedown); }; onMounted(() => { @@ -435,6 +473,12 @@ onBeforeUnmount(() => { } } + &:not(.asDrawer):not(.widthSpecified) { + > .menu { + max-width: 400px; + } + } + &.big:not(.asDrawer) { > .menu { min-width: 230px; @@ -558,11 +602,11 @@ onBeforeUnmount(() => { } &.danger { - --menuFg: #ff2a2a; + --menuFg: var(--MI_THEME-error); --menuHoverFg: #fff; - --menuHoverBg: #ff4242; + --menuHoverBg: var(--MI_THEME-error); --menuActiveFg: #fff; - --menuActiveBg: #d42e2e; + --menuActiveBg: hsl(from var(--MI_THEME-error) h s calc(l - 10)); } &.radio { @@ -575,12 +619,6 @@ onBeforeUnmount(() => { --menuActiveBg: var(--MI_THEME-accentedBg); } - &.label { - pointer-events: none; - font-size: 0.7em; - padding-bottom: 4px; - } - &.pending { pointer-events: none; opacity: 0.7; @@ -604,10 +642,19 @@ onBeforeUnmount(() => { .item_content_text { max-width: calc(100vw - 4rem); +} + +.item_content_text_title { text-overflow: ellipsis; overflow: hidden; } +.item_content_text_caption { + text-wrap: auto; + font-size: 85%; + opacity: 0.7; +} + .switchButton { margin-left: -2px; --height: 1.35em; @@ -641,6 +688,19 @@ onBeforeUnmount(() => { font-size: 12px; } +.label { + position: relative; + padding: 6px 16px; + box-sizing: border-box; + white-space: nowrap; + font-size: 0.7em; + text-align: left; + overflow: hidden; + text-overflow: ellipsis; + opacity: 0.7; + pointer-events: none; +} + .divider { margin: 8px 0; border-top: solid 0.5px var(--MI_THEME-divider); diff --git a/packages/frontend/src/components/MkMiniChart.vue b/packages/frontend/src/components/MkMiniChart.vue index 7ea585ecc2..98bd471438 100644 --- a/packages/frontend/src/components/MkMiniChart.vue +++ b/packages/frontend/src/components/MkMiniChart.vue @@ -48,7 +48,7 @@ const polygonPoints = ref(''); const headX = ref<number | null>(null); const headY = ref<number | null>(null); const clock = ref<number | null>(null); -const accent = tinycolor(getComputedStyle(document.documentElement).getPropertyValue('--MI_THEME-accent')); +const accent = tinycolor(getComputedStyle(window.document.documentElement).getPropertyValue('--MI_THEME-accent')); const color = accent.toRgbString(); function draw(): void { diff --git a/packages/frontend/src/components/MkModal.vue b/packages/frontend/src/components/MkModal.vue index a446dad0ab..3bcf835ec9 100644 --- a/packages/frontend/src/components/MkModal.vue +++ b/packages/frontend/src/components/MkModal.vue @@ -42,14 +42,15 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { nextTick, normalizeClass, onMounted, onUnmounted, provide, watch, ref, shallowRef, computed } from 'vue'; +import { nextTick, normalizeClass, onMounted, onUnmounted, provide, watch, ref, useTemplateRef, computed } from 'vue'; +import type { Keymap } from '@/utility/hotkey.js'; import * as os from '@/os.js'; -import { isTouchUsing } from '@/scripts/touch.js'; -import { defaultStore } from '@/store.js'; -import { deviceKind } from '@/scripts/device-kind.js'; -import { type Keymap } from '@/scripts/hotkey.js'; -import { focusTrap } from '@/scripts/focus-trap.js'; -import { focusParent } from '@/scripts/focus.js'; +import { isTouchUsing } from '@/utility/touch.js'; +import { deviceKind } from '@/utility/device-kind.js'; +import { focusTrap } from '@/utility/focus-trap.js'; +import { focusParent } from '@/utility/focus.js'; +import { prefer } from '@/preferences.js'; +import { DI } from '@/di.js'; function getFixedContainer(el: Element | null): Element | null { if (el == null || el.tagName === 'BODY') return null; @@ -94,19 +95,19 @@ const emit = defineEmits<{ (ev: 'closed'): void; }>(); -provide('modal', true); +provide(DI.inModal, true); const maxHeight = ref<number>(); const fixed = ref(false); const transformOrigin = ref('center'); const showing = ref(true); -const modalRootEl = shallowRef<HTMLElement>(); -const content = shallowRef<HTMLElement>(); +const modalRootEl = useTemplateRef('modalRootEl'); +const content = useTemplateRef('content'); const zIndex = os.claimZIndex(props.zPriority); const useSendAnime = ref(false); const type = computed<ModalTypes>(() => { if (props.preferType === 'auto') { - if ((defaultStore.state.menuStyle === 'drawer') || (defaultStore.state.menuStyle === 'auto' && isTouchUsing && deviceKind === 'smartphone')) { + if ((prefer.s.menuStyle === 'drawer') || (prefer.s.menuStyle === 'auto' && isTouchUsing && deviceKind === 'smartphone')) { return 'drawer'; } else { return props.src != null ? 'popup' : 'dialog'; @@ -117,7 +118,7 @@ const type = computed<ModalTypes>(() => { }); const isEnableBgTransparent = computed(() => props.transparentBg && (type.value === 'popup')); const transitionName = computed((() => - defaultStore.state.animation + prefer.s.animation ? useSendAnime.value ? 'send' : type.value === 'drawer' diff --git a/packages/frontend/src/components/MkModalWindow.vue b/packages/frontend/src/components/MkModalWindow.vue index f06cfffee4..19989e375b 100644 --- a/packages/frontend/src/components/MkModalWindow.vue +++ b/packages/frontend/src/components/MkModalWindow.vue @@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, onUnmounted, shallowRef, ref } from 'vue'; +import { onMounted, onUnmounted, useTemplateRef, ref } from 'vue'; import MkModal from './MkModal.vue'; const props = withDefaults(defineProps<{ @@ -47,9 +47,9 @@ const emit = defineEmits<{ (event: 'esc'): void; }>(); -const modal = shallowRef<InstanceType<typeof MkModal>>(); -const rootEl = shallowRef<HTMLElement>(); -const headerEl = shallowRef<HTMLElement>(); +const modal = useTemplateRef('modal'); +const rootEl = useTemplateRef('rootEl'); +const headerEl = useTemplateRef('headerEl'); const bodyWidth = ref(0); const bodyHeight = ref(0); diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index 3f52244bdc..366321565d 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only v-show="!isDeleted" ref="rootEl" v-hotkey="keymap" - :class="[$style.root, { [$style.showActionsOnlyHover]: defaultStore.state.showNoteActionsOnlyHover, [$style.skipRender]: defaultStore.state.skipNoteRender }]" + :class="[$style.root, { [$style.showActionsOnlyHover]: prefer.s.showNoteActionsOnlyHover, [$style.skipRender]: prefer.s.skipNoteRender }]" :tabindex="isDeleted ? '-1' : '0'" > <div v-if="appearNote.reply && inReplyToCollapsed" :class="$style.collapsedInReplyTo"> @@ -19,8 +19,6 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <MkNoteSub v-if="appearNote.reply" v-show="!renoteCollapsed && !inReplyToCollapsed" :note="appearNote.reply" :class="$style.replyTo"/> <div v-if="pinned" :class="$style.tip"><i class="ti ti-pin"></i> {{ i18n.ts.pinnedNote }}</div> - <!--<div v-if="appearNote._prId_" class="tip"><i class="ti ti-speakerphone"></i> {{ i18n.ts.promotion }}<button class="_textButton hide" @click="readPromo()">{{ i18n.ts.hideThisNote }} <i class="ti ti-x"></i></button></div>--> - <!--<div v-if="appearNote._featuredId_" class="tip"><i class="ti ti-bolt"></i> {{ i18n.ts.featured }}</div>--> <div v-if="isRenote" :class="$style.renote"> <div v-if="note.channel" :class="$style.colorBar" :style="{ background: note.channel.color }"></div> <MkAvatar :class="$style.renoteAvatar" :user="note.user" link preview/> @@ -53,8 +51,8 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <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.stop="defaultStore.state.clickToOpen ? noteclick(appearNote.id) : undefined"> + <MkAvatar :class="[$style.avatar, prefer.s.useStickyIcons ? $style.useSticky : null]" :user="appearNote.user" :link="!mock" :preview="!mock"/> + <div :class="[$style.main, { [$style.clickToOpen]: prefer.s.clickToOpen }]" @click.stop="prefer.s.clickToOpen ? noteclick(appearNote.id) : undefined"> <MkNoteHeader :note="appearNote" :mini="true" @click.stop/> <MkInstanceTicker v-if="showTicker" :host="appearNote.user.host" :instance="appearNote.user.instance"/> <div style="container-type: inline-size;"> @@ -86,23 +84,18 @@ SPDX-License-Identifier: AGPL-3.0-only :enableEmojiMenuReaction="true" :isAnim="allowAnim" :isBlock="true" + class="_selectable" /> - <div v-if="translating || translation" :class="$style.translation"> - <MkLoading v-if="translating" mini/> - <div v-else-if="translation"> - <b>{{ i18n.tsx.translatedFrom({ x: translation.sourceLang }) }}: </b> - <Mfm :text="translation.text" :isBlock="true" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis"/> - </div> - </div> + <SkNoteTranslation :note="note" :translation="translation" :translating="translating"></SkNoteTranslation> <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> + <MkButton v-else-if="!prefer.s.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 && appearNote.files.length > 0"> <MkMediaList ref="galleryEl" :mediaList="appearNote.files" @click.stop/> </div> <MkPoll v-if="appearNote.poll" :noteId="appearNote.id" :poll="appearNote.poll" :local="!appearNote.user.host" :author="appearNote.user" :emojiUrls="appearNote.emojis" :class="$style.poll" @click.stop/> <div v-if="isEnabledUrlPreview"> - <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :showAsQuote="!appearNote.user.rejectQuotes" :skipNoteIds="[appearNote.renote?.id]" :class="$style.urlPreview" @click.stop/> + <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :showAsQuote="!appearNote.user.rejectQuotes" :skipNoteIds="selfNoteIds" :class="$style.urlPreview" @click.stop/> </div> <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"> @@ -115,7 +108,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkA v-if="appearNote.channel && !inChannel" :class="$style.channel" :to="`/channels/${appearNote.channel.id}`"><i class="ti ti-device-tv"></i> {{ appearNote.channel.name }}</MkA> </bdi> </div> - <MkReactionsViewer v-if="appearNote.reactionAcceptance !== 'likeOnly'" :note="appearNote" :maxNumber="16" @click.stop @mockUpdateMyReaction="emitUpdReaction"> + <MkReactionsViewer v-if="appearNote.reactionAcceptance !== 'likeOnly'" style="margin-top: 6px;" :note="appearNote" :maxNumber="16" @click.stop @mockUpdateMyReaction="emitUpdReaction"> <template #more> <MkA :to="`/notes/${appearNote.id}/reactions`" :class="[$style.reactionOmitted]">{{ i18n.ts.more }}</MkA> </template> @@ -159,12 +152,15 @@ SPDX-License-Identifier: AGPL-3.0-only <i v-else-if="appearNote.myReaction != null" class="ti ti-minus" style="color: var(--MI_THEME-accent);"></i> <i v-else-if="appearNote.reactionAcceptance === 'likeOnly'" class="ti ti-heart"></i> <i v-else class="ph-smiley ph-bold ph-lg"></i> - <p v-if="(appearNote.reactionAcceptance === 'likeOnly' || defaultStore.state.showReactionsCount) && appearNote.reactionCount > 0" :class="$style.footerButtonCount">{{ number(appearNote.reactionCount) }}</p> + <p v-if="(appearNote.reactionAcceptance === 'likeOnly' || prefer.s.showReactionsCount) && appearNote.reactionCount > 0" :class="$style.footerButtonCount">{{ number(appearNote.reactionCount) }}</p> </button> - <button v-if="defaultStore.state.showClipButtonInNoteFooter" ref="clipButton" :class="$style.footerButton" class="_button" @mousedown.prevent="clip()"> + <button v-if="prefer.s.showClipButtonInNoteFooter" ref="clipButton" :class="$style.footerButton" class="_button" @click.stop="clip()"> <i class="ti ti-paperclip"></i> </button> - <button ref="menuButton" :class="$style.footerButton" class="_button" @mousedown.prevent="showMenu()"> + <button v-if="prefer.s.showTranslationButtonInNoteFooter && $i?.policies.canUseTranslator && instance.translatorAvailable" ref="translationButton" class="_button" :class="$style.footerButton" :disabled="translating || !!translation" @click.stop="translate()"> + <i class="ti ti-language-hiragana"></i> + </button> + <button ref="menuButton" :class="$style.footerButton" class="_button" @click.stop="showMenu()"> <i class="ti ti-dots"></i> </button> </footer> @@ -172,30 +168,7 @@ SPDX-License-Identifier: AGPL-3.0-only </article> </div> <div v-else-if="!hardMuted" :class="$style.muted" @click="muted = false"> - <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-if="showSoftWordMutedWord !== true" :src="i18n.ts.userSaysSomething" 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.userSaysSomethingAbout" tag="small"> - <template #name> - <MkA v-user-preview="appearNote.userId" :to="userPage(appearNote.user)"> - <MkUserName :user="appearNote.user"/> - </MkA> - </template> - <template #word> - {{ Array.isArray(muted) ? muted.map(words => Array.isArray(words) ? words.join() : words).slice(0, 3).join(' ') : muted }} - </template> - </I18n> + <SkMutedNote :muted="muted" :note="appearNote"></SkMutedNote> </div> <div v-else> <!-- @@ -206,14 +179,18 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, inject, onMounted, ref, shallowRef, Ref, watch, provide } from 'vue'; +import { computed, inject, onMounted, ref, useTemplateRef, watch, provide } from 'vue'; import * as mfm from '@transfem-org/sfm-js'; import * as Misskey from 'misskey-js'; import { isLink } from '@@/js/is-link.js'; import { shouldCollapsed } from '@@/js/collapsed.js'; -import { host } from '@@/js/config.js'; +import * as config from '@@/js/config.js'; import { computeMergedCw } from '@@/js/compute-merged-cw.js'; +import type { Ref } from 'vue'; import type { MenuItem } from '@/types/menu.js'; +import type { OpenOnRemoteOptions } from '@/utility/please-login.js'; +import type { Keymap } from '@/utility/hotkey.js'; +import type { Visibility } from '@/utility/boost-quote.js'; import MkNoteSub from '@/components/MkNoteSub.vue'; import MkNoteHeader from '@/components/MkNoteHeader.vue'; import MkNoteSimple from '@/components/MkNoteSimple.vue'; @@ -226,35 +203,40 @@ import MkUsersTooltip from '@/components/MkUsersTooltip.vue'; import MkUrlPreview from '@/components/MkUrlPreview.vue'; import MkInstanceTicker from '@/components/MkInstanceTicker.vue'; import MkButton from '@/components/MkButton.vue'; -import { pleaseLogin, type OpenOnRemoteOptions } from '@/scripts/please-login.js'; -import { checkWordMute } from '@/scripts/check-word-mute.js'; +import { pleaseLogin } from '@/utility/please-login.js'; +import { checkMutes } from '@/utility/check-word-mute.js'; import { notePage } from '@/filters/note.js'; import { userPage } from '@/filters/user.js'; import number from '@/filters/number.js'; import * as os from '@/os.js'; -import * as sound from '@/scripts/sound.js'; -import { misskeyApi, misskeyApiGet } 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'; -import { checkAnimationFromMfm } from '@/scripts/check-animated-mfm.js'; -import { $i } from '@/account.js'; +import * as sound from '@/utility/sound.js'; +import { misskeyApi, misskeyApiGet } from '@/utility/misskey-api.js'; +import { reactionPicker } from '@/utility/reaction-picker.js'; +import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js'; +import { checkAnimationFromMfm } from '@/utility/check-animated-mfm.js'; +import { $i } from '@/i.js'; import { i18n } from '@/i18n.js'; -import { getAbuseNoteMenu, getCopyNoteLinkMenu, getNoteClipMenu, getNoteMenu } from '@/scripts/get-note-menu.js'; -import { getNoteVersionsMenu } from '@/scripts/get-note-versions-menu.js'; -import { useNoteCapture } from '@/scripts/use-note-capture.js'; -import { deepClone } from '@/scripts/clone.js'; -import { useTooltip } from '@/scripts/use-tooltip.js'; -import { claimAchievement } from '@/scripts/achievements.js'; -import { getNoteSummary } from '@/scripts/get-note-summary.js'; +import { getAbuseNoteMenu, getCopyNoteLinkMenu, getNoteClipMenu, getNoteMenu, getRenoteMenu, translateNote } from '@/utility/get-note-menu.js'; +import { getNoteVersionsMenu } from '@/utility/get-note-versions-menu.js'; +import { useNoteCapture } from '@/use/use-note-capture.js'; +import { deepClone } from '@/utility/clone.js'; +import { useTooltip } from '@/use/use-tooltip.js'; +import { claimAchievement } from '@/utility/achievements.js'; +import { getNoteSummary } from '@/utility/get-note-summary.js'; import MkRippleEffect from '@/components/MkRippleEffect.vue'; -import { showMovedDialog } from '@/scripts/show-moved-dialog.js'; -import { useRouter } from '@/router/supplier.js'; -import { boostMenuItems, type Visibility, computeRenoteTooltip } from '@/scripts/boost-quote.js'; -import { isEnabledUrlPreview } from '@/instance.js'; -import { type Keymap } from '@/scripts/hotkey.js'; -import { focusPrev, focusNext } from '@/scripts/focus.js'; -import { getAppearNote } from '@/scripts/get-appear-note.js'; +import { showMovedDialog } from '@/utility/show-moved-dialog.js'; +import { boostMenuItems, computeRenoteTooltip } from '@/utility/boost-quote.js'; +import { instance, isEnabledUrlPreview } from '@/instance.js'; +import { focusPrev, focusNext } from '@/utility/focus.js'; +import { getAppearNote } from '@/utility/get-appear-note.js'; +import { prefer } from '@/preferences.js'; +import { getPluginHandlers } from '@/plugin.js'; +import { DI } from '@/di.js'; +import { useRouter } from '@/router.js'; +import SkMutedNote from '@/components/SkMutedNote.vue'; +import SkNoteTranslation from '@/components/SkNoteTranslation.vue'; +import { getSelfNoteIds } from '@/utility/get-self-note-ids.js'; +import { extractPreviewUrls } from '@/utility/extract-preview-urls.js'; const props = withDefaults(defineProps<{ note: Misskey.entities.Note; @@ -265,7 +247,7 @@ const props = withDefaults(defineProps<{ mock: false, }); -provide('mock', props.mock); +provide(DI.mock, props.mock); const emit = defineEmits<{ (ev: 'reaction', emoji: string): void; @@ -274,21 +256,20 @@ const emit = defineEmits<{ const router = useRouter(); -const inTimeline = inject<boolean>('inTimeline', false); -const tl_withSensitive = inject<Ref<boolean>>('tl_withSensitive', ref(true)); const inChannel = inject('inChannel', null); const currentClip = inject<Ref<Misskey.entities.Clip> | null>('currentClip', null); const note = ref(deepClone(props.note)); function noteclick(id: string) { - const selection = document.getSelection(); + const selection = window.document.getSelection(); if (selection?.toString().length === 0) { router.push(`/notes/${id}`); } } // plugin +const noteViewInterruptors = getPluginHandlers('note_view_interruptor'); if (noteViewInterruptors.length > 0) { onMounted(async () => { let result: Misskey.entities.Note | null = deepClone(note.value); @@ -309,77 +290,51 @@ if (noteViewInterruptors.length > 0) { const isRenote = Misskey.note.isPureRenote(note.value); -const rootEl = shallowRef<HTMLElement>(); -const menuButton = shallowRef<HTMLElement>(); -const menuVersionsButton = shallowRef<HTMLElement>(); -const renoteButton = shallowRef<HTMLElement>(); -const renoteTime = shallowRef<HTMLElement>(); -const reactButton = shallowRef<HTMLElement>(); -const quoteButton = shallowRef<HTMLElement>(); -const clipButton = shallowRef<HTMLElement>(); -const likeButton = shallowRef<HTMLElement>(); +const rootEl = useTemplateRef('rootEl'); +const menuButton = useTemplateRef('menuButton'); +const renoteButton = useTemplateRef('renoteButton'); +const renoteTime = useTemplateRef('renoteTime'); +const reactButton = useTemplateRef('reactButton'); +const clipButton = useTemplateRef('clipButton'); +const menuVersionsButton = useTemplateRef('menuVersionsButton'); +const quoteButton = useTemplateRef('quoteButton'); +const likeButton = useTemplateRef('likeButton'); const appearNote = computed(() => getAppearNote(note.value)); -const galleryEl = shallowRef<InstanceType<typeof MkMediaList>>(); +const galleryEl = useTemplateRef('galleryEl'); const isMyRenote = $i && ($i.id === note.value.userId); -const showContent = ref(defaultStore.state.uncollapseCW); +const showContent = ref(prefer.s.uncollapseCW); 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 urls = computed(() => parsed.value ? extractPreviewUrls(props.note, parsed.value) : null); +const selfNoteIds = computed(() => getSelfNoteIds(props.note)); const isLong = shouldCollapsed(appearNote.value, urls.value ?? []); -const collapsed = ref(defaultStore.state.expandLongNote && appearNote.value.cw == null && isLong ? false : appearNote.value.cw == null && isLong); +const collapsed = ref(prefer.s.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, true)); -const showSoftWordMutedWord = computed(() => defaultStore.state.showSoftWordMutedWord); -const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null); +const { muted, hardMuted } = checkMutes(appearNote.value, props.withHardMute); +const translation = ref<Misskey.entities.NotesTranslateResponse | false | null>(null); const translating = ref(false); -const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.value.user.instance); +const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.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 && ( + prefer.s.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 inReplyToCollapsed = ref(defaultStore.state.collapseNotesRepliedTo); -const defaultLike = computed(() => defaultStore.state.like ? defaultStore.state.like : null); +const inReplyToCollapsed = ref(prefer.s.collapseNotesRepliedTo); +const defaultLike = computed(() => prefer.s.like ? prefer.s.like : null); const animated = computed(() => parsed.value ? checkAnimationFromMfm(parsed.value) : null); -const allowAnim = ref(defaultStore.state.advancedMfm && defaultStore.state.animatedMfm ? true : false); +const allowAnim = ref(prefer.s.advancedMfm && prefer.s.animatedMfm); const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({ type: 'lookup', - url: `https://${host}/notes/${appearNote.value.id}`, + url: appearNote.value.url ?? appearNote.value.uri ?? `${config.url}/notes/${appearNote.value.id}`, })); const mergedCW = computed(() => computeMergedCw(appearNote.value)); const renoteTooltip = computeRenoteTooltip(renoted); -/* 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): Array<string | string[]> | false | 'sensitiveMute'; -*/ -function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly = false): Array<string | string[]> | false | 'sensitiveMute' { - if (mutedWords != null) { - const result = checkWordMute(noteToCheck, $i, mutedWords); - if (Array.isArray(result)) return result; - - const replyResult = noteToCheck.reply && checkWordMute(noteToCheck.reply, $i, mutedWords); - if (Array.isArray(replyResult)) return replyResult; - - const renoteResult = noteToCheck.renote && checkWordMute(noteToCheck.renote, $i, mutedWords); - if (Array.isArray(renoteResult)) return renoteResult; - } - - if (checkOnly) return false; - - if (inTimeline && tl_withSensitive.value === false && noteToCheck.files?.some((v) => v.isSensitive)) { - return 'sensitiveMute'; - } - - return false; -} - let renoting = false; const keymap = { @@ -393,7 +348,7 @@ const keymap = { }, 'q': () => { if (renoteCollapsed.value) return; - if (canRenote.value && !renoted.value && !renoting) renote(defaultStore.state.visibilityOnBoost); + if (canRenote.value && !renoted.value && !renoting) renote(prefer.s.visibilityOnBoost); }, 'm': () => { if (renoteCollapsed.value) return; @@ -401,9 +356,14 @@ const keymap = { }, 'c': () => { if (renoteCollapsed.value) return; - if (!defaultStore.state.showClipButtonInNoteFooter) return; + if (!prefer.s.showClipButtonInNoteFooter) return; clip(); }, + 't': () => { + if (prefer.s.showTranslationButtonInNoteFooter && $i?.policies.canUseTranslator && instance.translatorAvailable) { + translate(); + } + }, 'o': () => { if (renoteCollapsed.value) return; galleryEl.value?.openGallery(); @@ -431,7 +391,8 @@ const keymap = { }, } as const satisfies Keymap; -provide('react', (reaction: string) => { +provide(DI.mfmEmojiReactCallback, (reaction) => { + sound.playMisskeySfx('reaction'); misskeyApi('notes/reactions/create', { noteId: appearNote.value.id, reaction: reaction, @@ -453,6 +414,8 @@ if (props.mock) { if (!props.mock) { useTooltip(renoteButton, async (showing) => { + if (!renoteButton.value) return; + const renotes = await misskeyApi('notes/renotes', { noteId: appearNote.value.id, limit: 11, @@ -473,6 +436,8 @@ if (!props.mock) { }); useTooltip(quoteButton, async (showing) => { + if (!quoteButton.value) return; + const renotes = await misskeyApi('notes/renotes', { noteId: appearNote.value.id, limit: 11, @@ -531,8 +496,8 @@ if (!props.mock) { function boostVisibility(forceMenu: boolean = false) { if (renoting) return; - if (!defaultStore.state.showVisibilitySelectorOnBoost && !forceMenu) { - renote(defaultStore.state.visibilityOnBoost); + if (!prefer.s.showVisibilitySelectorOnBoost && !forceMenu) { + renote(prefer.s.visibilityOnBoost); } else { os.popupMenu(boostMenuItems(appearNote, renote), renoteButton.value); } @@ -699,7 +664,7 @@ function react(viaKeyboard = false): void { override: defaultLike.value, }); const el = reactButton.value; - if (el) { + if (el && prefer.s.animation) { const rect = el.getBoundingClientRect(); const x = rect.left + (el.offsetWidth / 2); const y = rect.top + (el.offsetHeight / 2); @@ -709,7 +674,16 @@ function react(viaKeyboard = false): void { } } else { blur(); - reactionPicker.show(reactButton.value ?? null, note.value, reaction => { + reactionPicker.show(reactButton.value ?? null, note.value, async (reaction) => { + if (prefer.s.confirmOnReact) { + const confirm = await os.confirm({ + type: 'question', + text: i18n.tsx.reactAreYouSure({ emoji: reaction.replace('@.', '') }), + }); + + if (confirm.canceled) return; + } + sound.playMisskeySfx('reaction'); if (props.mock) { @@ -781,7 +755,7 @@ function onContextmenu(ev: MouseEvent): void { if (ev.target && isLink(ev.target as HTMLElement)) return; if (window.getSelection()?.toString() !== '') return; - if (defaultStore.state.useReactionPickerForContextMenu) { + if (prefer.s.useReactionPickerForContextMenu) { ev.preventDefault(); react(); } else { @@ -799,11 +773,9 @@ function showMenu(): void { os.popupMenu(menu, menuButton.value).then(focus).finally(cleanup); } -async function menuVersions(viaKeyboard = false): Promise<void> { - const { menu, cleanup } = await getNoteVersionsMenu({ note: note.value, menuVersionsButton }); - os.popupMenu(menu, menuVersionsButton.value, { - viaKeyboard, - }).then(focus).finally(cleanup); +async function menuVersions(): Promise<void> { + const { menu, cleanup } = await getNoteVersionsMenu({ note: note.value }); + os.popupMenu(menu, menuVersionsButton.value).then(focus).finally(cleanup); } async function clip(): Promise<void> { @@ -814,6 +786,12 @@ async function clip(): Promise<void> { os.popupMenu(await getNoteClipMenu({ note: note.value, isDeleted, currentClip: currentClip?.value }), clipButton.value).then(focus); } +async function translate() { + if (props.mock) return; + + await translateNote(appearNote.value.id, translation, translating); +} + function showRenoteMenu(): void { if (props.mock) { return; @@ -906,7 +884,6 @@ function emitUpdReaction(emoji: string, delta: number) { <style lang="scss" module> .root { position: relative; - transition: box-shadow 0.1s ease; font-size: 1.05em; overflow: clip; contain: content; @@ -977,6 +954,8 @@ function emitUpdReaction(emoji: string, delta: number) { } .skipRender { + // TODO: これが有効だとTransitionGroupでnoteを追加するときに一瞬がくっとなってしまうのをどうにかしたい + // Transitionが完了するのを待ってからskipRenderを付与すれば解決しそうだけどパフォーマンス的な影響が不明 content-visibility: auto; contain-intrinsic-size: 0 150px; } @@ -1111,9 +1090,12 @@ function emitUpdReaction(emoji: string, delta: number) { margin: 0 14px 0 0; width: var(--MI-avatar); height: var(--MI-avatar); - position: sticky !important; - top: calc(22px + var(--MI-stickyTop, 0px)); - left: 0; + + &.useSticky { + position: sticky !important; + top: calc(22px + var(--MI-stickyTop, 0px)); + left: 0; + } } .main { @@ -1184,13 +1166,6 @@ function emitUpdReaction(emoji: string, delta: number) { margin-right: 0.5em; } -.translation { - border: solid 0.5px var(--MI_THEME-divider); - border-radius: var(--MI-radius); - padding: 12px; - margin-top: 8px; -} - .urlPreview { margin-top: 8px; } @@ -1308,7 +1283,10 @@ function emitUpdReaction(emoji: string, delta: number) { margin: 0 10px 0 0; width: 46px; height: 46px; - top: calc(14px + var(--MI-stickyTop, 0px)); + + &.useSticky { + top: calc(14px + var(--MI-stickyTop, 0px)); + } } } @@ -1364,6 +1342,11 @@ function emitUpdReaction(emoji: string, delta: number) { padding: 8px; text-align: center; opacity: 0.7; + cursor: pointer; +} + +.muted:hover { + background: var(--MI_THEME-buttonBg); } .reactionOmitted { diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue index 1daca114c0..52cc836926 100644 --- a/packages/frontend/src/components/MkNoteDetailed.vue +++ b/packages/frontend/src/components/MkNoteDetailed.vue @@ -101,23 +101,18 @@ SPDX-License-Identifier: AGPL-3.0-only :enableEmojiMenuReaction="true" :isAnim="allowAnim" :isBlock="true" + class="_selectable" /> <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-if="translation"> - <b>{{ i18n.tsx.translatedFrom({ x: translation.sourceLang }) }}: </b> - <Mfm :text="translation.text" :isBlock="true" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis"/> - </div> - </div> + <SkNoteTranslation :note="note" :translation="translation" :translating="translating"></SkNoteTranslation> <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> + <MkButton v-else-if="!prefer.s.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 && appearNote.files.length > 0"> <MkMediaList ref="galleryEl" :mediaList="appearNote.files"/> </div> <MkPoll v-if="appearNote.poll" ref="pollViewer" :noteId="appearNote.id" :poll="appearNote.poll" :local="!appearNote.user.host" :class="$style.poll" :author="appearNote.user" :emojiUrls="appearNote.emojis"/> <div v-if="isEnabledUrlPreview"> - <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" :showAsQuote="!appearNote.user.rejectQuotes" :skipNoteIds="[appearNote.renote?.id]" style="margin-top: 6px;"/> + <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" :showAsQuote="!appearNote.user.rejectQuotes" :skipNoteIds="selfNoteIds" style="margin-top: 6px;"/> </div> <div v-if="appearNote.renote" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote" :expandAllCws="props.expandAllCws"/></div> </div> @@ -132,7 +127,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkTime :time="appearNote.createdAt" mode="detail" colored/> </MkA> </div> - <MkReactionsViewer v-if="appearNote.reactionAcceptance !== 'likeOnly'" ref="reactionsViewer" :note="appearNote"/> + <MkReactionsViewer v-if="appearNote.reactionAcceptance !== 'likeOnly'" ref="reactionsViewer" style="margin-top: 6px;" :note="appearNote"/> <button class="_button" :class="$style.noteFooterButton" @click="reply()"> <i class="ti ti-arrow-back-up"></i> <p v-if="appearNote.repliesCount > 0" :class="$style.noteFooterButtonCount">{{ number(appearNote.repliesCount) }}</p> @@ -169,12 +164,15 @@ SPDX-License-Identifier: AGPL-3.0-only <i v-else-if="appearNote.myReaction != null" class="ti ti-minus" style="color: var(--MI_THEME-accent);"></i> <i v-else-if="appearNote.reactionAcceptance === 'likeOnly'" class="ti ti-heart"></i> <i v-else class="ph-smiley ph-bold ph-lg"></i> - <p v-if="(appearNote.reactionAcceptance === 'likeOnly' || defaultStore.state.showReactionsCount) && appearNote.reactionCount > 0" :class="$style.noteFooterButtonCount">{{ number(appearNote.reactionCount) }}</p> + <p v-if="(appearNote.reactionAcceptance === 'likeOnly' || prefer.s.showReactionsCount) && appearNote.reactionCount > 0" :class="$style.noteFooterButtonCount">{{ number(appearNote.reactionCount) }}</p> </button> - <button v-if="defaultStore.state.showClipButtonInNoteFooter" ref="clipButton" class="_button" :class="$style.noteFooterButton" @mousedown.prevent="clip()"> + <button v-if="prefer.s.showClipButtonInNoteFooter" ref="clipButton" class="_button" :class="$style.noteFooterButton" @click.stop="clip()"> <i class="ti ti-paperclip"></i> </button> - <button ref="menuButton" class="_button" :class="$style.noteFooterButton" @mousedown.prevent="showMenu()"> + <button v-if="prefer.s.showTranslationButtonInNoteFooter && $i?.policies.canUseTranslator && instance.translatorAvailable" ref="translationButton" class="_button" :class="$style.noteFooterButton" :disabled="translating || !!translation" @click.stop="translate()"> + <i class="ti ti-language-hiragana"></i> + </button> + <button ref="menuButton" class="_button" :class="$style.noteFooterButton" @click.stop="showMenu()"> <i class="ti ti-dots"></i> </button> </footer> @@ -229,23 +227,21 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> <div v-else class="_panel" :class="$style.muted" @click="muted = false"> - <I18n :src="i18n.ts.userSaysSomething" tag="small"> - <template #name> - <MkA v-user-preview="appearNote.userId" :to="userPage(appearNote.user)"> - <MkUserName :user="appearNote.user"/> - </MkA> - </template> - </I18n> + <SkMutedNote :muted="muted" :note="appearNote"></SkMutedNote> </div> </template> <script lang="ts" setup> -import { computed, inject, onMounted, provide, ref, shallowRef, watch } from 'vue'; +import { computed, inject, onMounted, provide, ref, useTemplateRef, watch } from 'vue'; import * as mfm from '@transfem-org/sfm-js'; import * as Misskey from 'misskey-js'; import { isLink } from '@@/js/is-link.js'; -import { host } from '@@/js/config.js'; +import * as config from '@@/js/config.js'; import { computeMergedCw } from '@@/js/compute-merged-cw.js'; +import type { OpenOnRemoteOptions } from '@/utility/please-login.js'; +import type { Paging } from '@/components/MkPagination.vue'; +import type { Keymap } from '@/utility/hotkey.js'; +import type { Visibility } from '@/utility/boost-quote.js'; import MkNoteSub from '@/components/MkNoteSub.vue'; import MkNoteSimple from '@/components/MkNoteSimple.vue'; import MkReactionsViewer from '@/components/MkReactionsViewer.vue'; @@ -256,41 +252,46 @@ import MkPoll from '@/components/MkPoll.vue'; import MkUsersTooltip from '@/components/MkUsersTooltip.vue'; import MkUrlPreview from '@/components/MkUrlPreview.vue'; import MkInstanceTicker from '@/components/MkInstanceTicker.vue'; -import { pleaseLogin, type OpenOnRemoteOptions } from '@/scripts/please-login.js'; -import { checkWordMute } from '@/scripts/check-word-mute.js'; +import { pleaseLogin } from '@/utility/please-login.js'; +import { checkMutes } from '@/utility/check-word-mute.js'; import { userPage } from '@/filters/user.js'; import { notePage } from '@/filters/note.js'; import number from '@/filters/number.js'; import * as os from '@/os.js'; -import { misskeyApi, misskeyApiGet } 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'; -import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm.js'; -import { $i } from '@/account.js'; +import { misskeyApi, misskeyApiGet } from '@/utility/misskey-api.js'; +import * as sound from '@/utility/sound.js'; +import { reactionPicker } from '@/utility/reaction-picker.js'; +import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js'; +import { $i } from '@/i.js'; import { i18n } from '@/i18n.js'; -import { getNoteClipMenu, getNoteMenu } from '@/scripts/get-note-menu.js'; -import { getNoteVersionsMenu } from '@/scripts/get-note-versions-menu.js'; -import { useNoteCapture } from '@/scripts/use-note-capture.js'; -import { deepClone } from '@/scripts/clone.js'; -import { useTooltip } from '@/scripts/use-tooltip.js'; -import { claimAchievement } from '@/scripts/achievements.js'; -import { checkAnimationFromMfm } from '@/scripts/check-animated-mfm.js'; +import { getNoteClipMenu, getNoteMenu, getRenoteMenu, translateNote } from '@/utility/get-note-menu.js'; +import { getNoteVersionsMenu } from '@/utility/get-note-versions-menu.js'; +import { checkAnimationFromMfm } from '@/utility/check-animated-mfm.js'; +import { useNoteCapture } from '@/use/use-note-capture.js'; +import { deepClone } from '@/utility/clone.js'; +import { useTooltip } from '@/use/use-tooltip.js'; +import { claimAchievement } from '@/utility/achievements.js'; import MkRippleEffect from '@/components/MkRippleEffect.vue'; -import { showMovedDialog } from '@/scripts/show-moved-dialog.js'; +import { showMovedDialog } from '@/utility/show-moved-dialog.js'; import MkUserCardMini from '@/components/MkUserCardMini.vue'; -import MkPagination, { type Paging } from '@/components/MkPagination.vue'; +import MkPagination from '@/components/MkPagination.vue'; import MkReactionIcon from '@/components/MkReactionIcon.vue'; import MkButton from '@/components/MkButton.vue'; -import { boostMenuItems, type Visibility, computeRenoteTooltip } from '@/scripts/boost-quote.js'; -import { isEnabledUrlPreview } from '@/instance.js'; -import { getAppearNote } from '@/scripts/get-appear-note.js'; -import { type Keymap } from '@/scripts/hotkey.js'; +import { boostMenuItems, computeRenoteTooltip } from '@/utility/boost-quote.js'; +import { instance, isEnabledUrlPreview } from '@/instance.js'; +import { getAppearNote } from '@/utility/get-appear-note.js'; +import { prefer } from '@/preferences.js'; +import { getPluginHandlers } from '@/plugin.js'; +import { DI } from '@/di.js'; +import SkMutedNote from '@/components/SkMutedNote.vue'; +import SkNoteTranslation from '@/components/SkNoteTranslation.vue'; +import { getSelfNoteIds } from '@/utility/get-self-note-ids.js'; +import { extractPreviewUrls } from '@/utility/extract-preview-urls.js'; const props = withDefaults(defineProps<{ note: Misskey.entities.Note; + initialTab?: string; expandAllCws?: boolean; - initialTab: string; }>(), { initialTab: 'replies', }); @@ -300,6 +301,7 @@ const inChannel = inject('inChannel', null); const note = ref(deepClone(props.note)); // plugin +const noteViewInterruptors = getPluginHandlers('note_view_interruptor'); if (noteViewInterruptors.length > 0) { onMounted(async () => { let result: Misskey.entities.Note | null = deepClone(note.value); @@ -320,39 +322,41 @@ if (noteViewInterruptors.length > 0) { const isRenote = Misskey.note.isPureRenote(note.value); -const rootEl = shallowRef<HTMLElement>(); -const menuButton = shallowRef<HTMLElement>(); -const menuVersionsButton = shallowRef<HTMLElement>(); -const renoteButton = shallowRef<HTMLElement>(); -const renoteTime = shallowRef<HTMLElement>(); -const reactButton = shallowRef<HTMLElement>(); -const quoteButton = shallowRef<HTMLElement>(); -const clipButton = shallowRef<HTMLElement>(); -const likeButton = shallowRef<HTMLElement>(); +const rootEl = useTemplateRef('rootEl'); +const menuButton = useTemplateRef('menuButton'); +const renoteButton = useTemplateRef('renoteButton'); +const renoteTime = useTemplateRef('renoteTime'); +const reactButton = useTemplateRef('reactButton'); +const clipButton = useTemplateRef('clipButton'); +const menuVersionsButton = useTemplateRef('menuVersionsButton'); +const quoteButton = useTemplateRef('quoteButton'); +const likeButton = useTemplateRef('likeButton'); const appearNote = computed(() => getAppearNote(note.value)); -const galleryEl = shallowRef<InstanceType<typeof MkMediaList>>(); +const galleryEl = useTemplateRef('galleryEl'); const isMyRenote = $i && ($i.id === note.value.userId); -const showContent = ref(defaultStore.state.uncollapseCW); +const showContent = ref(prefer.s.uncollapseCW); const isDeleted = ref(false); const renoted = ref(false); -const muted = ref($i ? checkWordMute(appearNote.value, $i, $i.mutedWords) : false); -const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null); +const translation = ref<Misskey.entities.NotesTranslateResponse | false | null>(null); const translating = ref(false); -const parsed = appearNote.value.text ? mfm.parse(appearNote.value.text) : 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 parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : null); +const urls = computed(() => parsed.value ? extractPreviewUrls(props.note, parsed.value) : null); +const selfNoteIds = computed(() => getSelfNoteIds(props.note)); +const animated = computed(() => parsed.value ? checkAnimationFromMfm(parsed.value) : null); +const allowAnim = ref(prefer.s.advancedMfm && prefer.s.animatedMfm); +const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.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.visibility === 'followers' && appearNote.value.userId === $i?.id)); -const defaultLike = computed(() => defaultStore.state.like ? defaultStore.state.like : null); +const defaultLike = computed(() => prefer.s.like ? prefer.s.like : null); const mergedCW = computed(() => computeMergedCw(appearNote.value)); const renoteTooltip = computeRenoteTooltip(renoted); +const { muted } = checkMutes(appearNote.value); + watch(() => props.expandAllCws, (expandAllCws) => { if (expandAllCws !== showContent.value) showContent.value = expandAllCws; }); @@ -371,18 +375,23 @@ let renoting = false; const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({ type: 'lookup', - url: `https://${host}/notes/${appearNote.value.id}`, + url: appearNote.value.url ?? appearNote.value.uri ?? `${config.url}/notes/${appearNote.value.id}`, })); const keymap = { 'r': () => reply(), 'e|a|plus': () => react(), - 'q': () => { if (canRenote.value && !renoted.value && !renoting) renote(defaultStore.state.visibilityOnBoost); }, + 'q': () => { if (canRenote.value && !renoted.value && !renoting) renote(prefer.s.visibilityOnBoost); }, 'm': () => showMenu(), 'c': () => { - if (!defaultStore.state.showClipButtonInNoteFooter) return; + if (!prefer.s.showClipButtonInNoteFooter) return; clip(); }, + 't': () => { + if (prefer.s.showTranslationButtonInNoteFooter && $i?.policies.canUseTranslator && instance.translatorAvailable) { + translate(); + } + }, 'o': () => galleryEl.value?.openGallery(), 'v|enter': () => { if (appearNote.value.cw != null) { @@ -395,7 +404,8 @@ const keymap = { }, } as const satisfies Keymap; -provide('react', (reaction: string) => { +provide(DI.mfmEmojiReactCallback, (reaction) => { + sound.playMisskeySfx('reaction'); misskeyApi('notes/reactions/create', { noteId: appearNote.value.id, reaction: reaction, @@ -487,8 +497,8 @@ useTooltip(quoteButton, async (showing) => { function boostVisibility(forceMenu: boolean = false) { if (renoting) return; - if (!defaultStore.state.showVisibilitySelectorOnBoost && !forceMenu) { - renote(defaultStore.state.visibilityOnBoost); + if (!prefer.s.showVisibilitySelectorOnBoost && !forceMenu) { + renote(prefer.s.visibilityOnBoost); } else { os.popupMenu(boostMenuItems(appearNote, renote), renoteButton.value); } @@ -524,7 +534,7 @@ function renote(visibility: Visibility, localOnly: boolean = false) { renoting = true; - if (appearNote.value.channel) { + if (appearNote.value.channel && !appearNote.value.channel.allowRenoteToExternal) { const el = renoteButton.value as HTMLElement | null | undefined; if (el) { const rect = el.getBoundingClientRect(); @@ -542,7 +552,7 @@ function renote(visibility: Visibility, localOnly: boolean = false) { os.toast(i18n.ts.renoted); renoted.value = true; }).finally(() => { renoting = false; }); - } else if (!appearNote.value.channel || appearNote.value.channel.allowRenoteToExternal) { + } else { const el = renoteButton.value as HTMLElement | null | undefined; if (el) { const rect = el.getBoundingClientRect(); @@ -644,7 +654,7 @@ function react(): void { override: defaultLike.value, }); const el = reactButton.value; - if (el) { + if (el && prefer.s.animation) { const rect = el.getBoundingClientRect(); const x = rect.left + (el.offsetWidth / 2); const y = rect.top + (el.offsetHeight / 2); @@ -654,7 +664,16 @@ function react(): void { } } else { blur(); - reactionPicker.show(reactButton.value ?? null, note.value, reaction => { + reactionPicker.show(reactButton.value ?? null, note.value, async (reaction) => { + if (prefer.s.confirmOnReact) { + const confirm = await os.confirm({ + type: 'question', + text: i18n.tsx.reactAreYouSure({ emoji: reaction.replace('@.', '') }), + }); + + if (confirm.canceled) return; + } + sound.playMisskeySfx('reaction'); misskeyApi('notes/reactions/create', { @@ -728,7 +747,7 @@ function onContextmenu(ev: MouseEvent): void { if (ev.target && isLink(ev.target as HTMLElement)) return; if (window.getSelection()?.toString() !== '') return; - if (defaultStore.state.useReactionPickerForContextMenu) { + if (prefer.s.useReactionPickerForContextMenu) { ev.preventDefault(); react(); } else { @@ -743,7 +762,7 @@ function showMenu(): void { } async function menuVersions(): Promise<void> { - const { menu, cleanup } = await getNoteVersionsMenu({ note: note.value, menuVersionsButton }); + const { menu, cleanup } = await getNoteVersionsMenu({ note: note.value }); os.popupMenu(menu, menuVersionsButton.value).then(focus).finally(cleanup); } @@ -751,6 +770,10 @@ async function clip(): Promise<void> { os.popupMenu(await getNoteClipMenu({ note: note.value, isDeleted }), clipButton.value).then(focus); } +async function translate() { + await translateNote(appearNote.value.id, translation, translating); +} + function showRenoteMenu(): void { if (!isMyRenote) return; pleaseLogin({ openOnRemote: pleaseLoginContext.value }); @@ -817,7 +840,7 @@ function loadConversation() { }); } -if (appearNote.value.reply && appearNote.value.reply.replyId && defaultStore.state.autoloadConversation) loadConversation(); +if (appearNote.value.reply && appearNote.value.reply.replyId && prefer.s.autoloadConversation) loadConversation(); function animatedMFM() { if (allowAnim.value) { @@ -1029,13 +1052,6 @@ function animatedMFM() { color: var(--MI_THEME-renote); } -.translation { - border: solid 0.5px var(--MI_THEME-divider); - border-radius: var(--MI-radius); - padding: 12px; - margin-top: 8px; -} - .poll { font-size: 80%; } @@ -1182,5 +1198,10 @@ function animatedMFM() { padding: 8px; text-align: center; opacity: 0.7; + cursor: pointer; +} + +.muted:hover { + background: var(--MI_THEME-buttonBg); } </style> diff --git a/packages/frontend/src/components/MkNoteHeader.vue b/packages/frontend/src/components/MkNoteHeader.vue index 3b15242685..84f9f26359 100644 --- a/packages/frontend/src/components/MkNoteHeader.vue +++ b/packages/frontend/src/components/MkNoteHeader.vue @@ -41,9 +41,9 @@ import * as Misskey from 'misskey-js'; import { i18n } from '@/i18n.js'; import { notePage } from '@/filters/note.js'; import { userPage } from '@/filters/user.js'; -import { getNoteVersionsMenu } from '@/scripts/get-note-versions-menu.js'; +import { getNoteVersionsMenu } from '@/utility/get-note-versions-menu.js'; import { popupMenu } from '@/os.js'; -import { defaultStore } from '@/store.js'; +import { DI } from '@/di.js'; const props = defineProps<{ note: Misskey.entities.Note & { @@ -54,14 +54,12 @@ const props = defineProps<{ const menuVersionsButton = shallowRef<HTMLElement>(); -async function menuVersions(viaKeyboard = false): Promise<void> { - const { menu, cleanup } = await getNoteVersionsMenu({ note: props.note, menuVersionsButton }); - popupMenu(menu, menuVersionsButton.value, { - viaKeyboard, - }).then(focus).finally(cleanup); +async function menuVersions(): Promise<void> { + const { menu, cleanup } = await getNoteVersionsMenu({ note: props.note }); + popupMenu(menu, menuVersionsButton.value).then(focus).finally(cleanup); } -const mock = inject<boolean>('mock', false); +const mock = inject(DI.mock, false); </script> <style lang="scss" module> diff --git a/packages/frontend/src/components/MkNoteMediaGrid.vue b/packages/frontend/src/components/MkNoteMediaGrid.vue index bf105c3c27..764d9f6a32 100644 --- a/packages/frontend/src/components/MkNoteMediaGrid.vue +++ b/packages/frontend/src/components/MkNoteMediaGrid.vue @@ -4,51 +4,51 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> - <template v-for="file in note.files"> - <div - v-if="((( - (defaultStore.state.nsfw === 'force' || file.isSensitive) && - defaultStore.state.nsfw !== 'ignore' - ) || (defaultStore.state.dataSaver.media && file.type.startsWith('image/'))) && - !showingFiles.has(file.id) - )" - :class="[$style.filePreview, { [$style.square]: square }]" - @click="showingFiles.add(file.id)" - > - <MkDriveFileThumbnail - :file="file" - fit="cover" - :highlightWhenSensitive="defaultStore.state.highlightSensitiveMedia" - :forceBlurhash="true" - :large="true" - :class="$style.file" - /> - <div :class="$style.sensitive"> - <div> - <div v-if="file.isSensitive"><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}{{ defaultStore.state.dataSaver.media && file.size ? ` (${bytes(file.size)})` : '' }}</div> - <div v-else><i class="ti ti-photo"></i> {{ defaultStore.state.dataSaver.media && file.size ? bytes(file.size) : i18n.ts.image }}</div> - <div>{{ i18n.ts.clickToShow }}</div> - </div> +<template v-for="file in note.files"> + <div + v-if="((( + (prefer.s.nsfw === 'force' || file.isSensitive) && + prefer.s.nsfw !== 'ignore' + ) || (prefer.s.dataSaver.media && file.type.startsWith('image/'))) && + !showingFiles.has(file.id) + )" + :class="[$style.filePreview, { [$style.square]: square }]" + @click="showingFiles.add(file.id)" + > + <MkDriveFileThumbnail + :file="file" + fit="cover" + :highlightWhenSensitive="prefer.s.highlightSensitiveMedia" + :forceBlurhash="true" + :large="true" + :class="$style.file" + /> + <div :class="$style.sensitive"> + <div> + <div v-if="file.isSensitive"><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}{{ prefer.s.dataSaver.media && file.size ? ` (${bytes(file.size)})` : '' }}</div> + <div v-else><i class="ti ti-photo"></i> {{ prefer.s.dataSaver.media && file.size ? bytes(file.size) : i18n.ts.image }}</div> + <div>{{ i18n.ts.clickToShow }}</div> </div> </div> - <MkA v-else :class="[$style.filePreview, { [$style.square]: square }]" :to="notePage(note)"> - <MkDriveFileThumbnail - :file="file" - fit="cover" - :highlightWhenSensitive="defaultStore.state.highlightSensitiveMedia" - :large="true" - :class="$style.file" - /> - </MkA> - </template> + </div> + <MkA v-else :class="[$style.filePreview, { [$style.square]: square }]" :to="notePage(note)"> + <MkDriveFileThumbnail + :file="file" + fit="cover" + :highlightWhenSensitive="prefer.s.highlightSensitiveMedia" + :large="true" + :class="$style.file" + /> + </MkA> +</template> </template> <script lang="ts" setup> import { ref } from 'vue'; +import * as Misskey from 'misskey-js'; import { notePage } from '@/filters/note.js'; import { i18n } from '@/i18n.js'; -import * as Misskey from 'misskey-js'; -import { defaultStore } from '@/store.js'; +import { prefer } from '@/preferences.js'; import bytes from '@/filters/bytes.js'; import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue'; diff --git a/packages/frontend/src/components/MkNoteSimple.vue b/packages/frontend/src/components/MkNoteSimple.vue index 3720aa7493..f946e6768d 100644 --- a/packages/frontend/src/components/MkNoteSimple.vue +++ b/packages/frontend/src/components/MkNoteSimple.vue @@ -34,19 +34,19 @@ import MkNoteHeader from '@/components/MkNoteHeader.vue'; import MkSubNoteContent from '@/components/MkSubNoteContent.vue'; import MkCwButton from '@/components/MkCwButton.vue'; import MkButton from '@/components/MkButton.vue'; -import { defaultStore } from '@/store.js'; import { i18n } from '@/i18n.js'; +import { prefer } from '@/preferences.js'; const props = defineProps<{ note: Misskey.entities.Note & { - isSchedule? : boolean, + isSchedule?: boolean, scheduledNoteId?: string }; expandAllCws?: boolean; hideFiles?: boolean; }>(); -let showContent = ref(defaultStore.state.uncollapseCW); +const showContent = ref(prefer.s.uncollapseCW); const isDeleted = ref(false); const mergedCW = computed(() => computeMergedCw(props.note)); diff --git a/packages/frontend/src/components/MkNoteSub.vue b/packages/frontend/src/components/MkNoteSub.vue index b2967a7cf3..282854c6a8 100644 --- a/packages/frontend/src/components/MkNoteSub.vue +++ b/packages/frontend/src/components/MkNoteSub.vue @@ -32,7 +32,7 @@ SPDX-License-Identifier: AGPL-3.0-only class="_button" :class="$style.noteFooterButton" :style="renoted ? 'color: var(--MI_THEME-accent) !important;' : ''" - @mousedown="renoted ? undoRenote() : boostVisibility($event.shiftKey)" + @click.stop="renoted ? undoRenote() : boostVisibility($event.shiftKey)" > <i class="ph-rocket-launch ph-bold ph-lg"></i> <p v-if="note.renoteCount > 0" :class="$style.noteFooterButtonCount">{{ note.renoteCount }}</p> @@ -42,30 +42,36 @@ SPDX-License-Identifier: AGPL-3.0-only ref="quoteButton" class="_button" :class="$style.noteFooterButton" - @mousedown="quote()" + @click.stop="quote()" > <i class="ph-quotes ph-bold ph-lg"></i> </button> <button v-else class="_button" :class="$style.noteFooterButton" disabled> <i class="ph-prohibit ph-bold ph-lg"></i> </button> - <button v-if="note.myReaction == null && note.reactionAcceptance !== 'likeOnly'" ref="likeButton" :class="$style.noteFooterButton" class="_button" @mousedown="like()"> + <button v-if="note.myReaction == null && note.reactionAcceptance !== 'likeOnly'" ref="likeButton" :class="$style.noteFooterButton" class="_button" @click.stop="like()"> <i class="ph-heart ph-bold ph-lg"></i> </button> - <button v-if="note.myReaction == null" ref="reactButton" :class="$style.noteFooterButton" class="_button" @mousedown="react()"> + <button v-if="note.myReaction == null" ref="reactButton" :class="$style.noteFooterButton" class="_button" @click.stop="react()"> <i v-if="note.reactionAcceptance === 'likeOnly'" class="ph-heart ph-bold ph-lg"></i> <i v-else class="ph-smiley ph-bold ph-lg"></i> </button> <button v-if="note.myReaction != null" ref="reactButton" class="_button" :class="[$style.noteFooterButton, $style.reacted]" @click="undoReact(note)"> <i class="ph-minus ph-bold ph-lg"></i> </button> - <button ref="menuButton" class="_button" :class="$style.noteFooterButton" @mousedown="menu()"> + <button v-if="prefer.s.showClipButtonInNoteFooter" ref="clipButton" :class="$style.noteFooterButton" class="_button" @click.stop="clip()"> + <i class="ti ti-paperclip"></i> + </button> + <button v-if="prefer.s.showTranslationButtonInNoteFooter && $i?.policies.canUseTranslator && instance.translatorAvailable" ref="translationButton" class="_button" :class="$style.noteFooterButton" :disabled="translating || !!translation" @click.stop="translate()"> + <i class="ti ti-language-hiragana"></i> + </button> + <button ref="menuButton" class="_button" :class="$style.noteFooterButton" @click.stop="menu()"> <i class="ph-dots-three ph-bold ph-lg"></i> </button> </footer> </div> </div> - <template v-if="depth < numberOfReplies"> + <template v-if="depth < prefer.s.numberOfReplies"> <MkNoteSub v-for="reply in replies" :key="reply.id" :note="reply" :class="$style.reply" :detail="true" :depth="depth + 1" :expandAllCws="props.expandAllCws" :onDeleteCallback="removeReply"/> </template> <div v-else :class="$style.more"> @@ -73,44 +79,41 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> <div v-else :class="$style.muted" @click="muted = false"> - <I18n :src="i18n.ts.userSaysSomething" tag="small"> - <template #name> - <MkA v-user-preview="note.userId" :to="userPage(note.user)"> - <MkUserName :user="note.user"/> - </MkA> - </template> - </I18n> + <SkMutedNote :muted="muted" :note="appearNote"></SkMutedNote> </div> </template> <script lang="ts" setup> -import { computed, ref, shallowRef, watch } from 'vue'; +import { computed, inject, ref, shallowRef, useTemplateRef, watch } from 'vue'; import * as Misskey from 'misskey-js'; import { computeMergedCw } from '@@/js/compute-merged-cw.js'; +import * as config from '@@/js/config.js'; +import type { Ref } from 'vue'; +import type { Visibility } from '@/utility/boost-quote.js'; +import type { OpenOnRemoteOptions } from '@/utility/please-login.js'; import MkNoteHeader from '@/components/MkNoteHeader.vue'; import MkReactionsViewer from '@/components/MkReactionsViewer.vue'; 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 * as sound from '@/utility/sound.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; import { userPage } from '@/filters/user.js'; -import { checkWordMute } from '@/scripts/check-word-mute.js'; -import { defaultStore } from '@/store.js'; -import { host } from '@@/js/config.js'; -import { pleaseLogin, type OpenOnRemoteOptions } from '@/scripts/please-login.js'; -import { showMovedDialog } from '@/scripts/show-moved-dialog.js'; +import { checkMutes } from '@/utility/check-word-mute.js'; +import { pleaseLogin } from '@/utility/please-login.js'; +import { showMovedDialog } from '@/utility/show-moved-dialog.js'; import MkRippleEffect from '@/components/MkRippleEffect.vue'; -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, computeRenoteTooltip } from '@/scripts/boost-quote.js'; - -const canRenote = computed(() => ['public', 'home'].includes(props.note.visibility) || props.note.userId === $i.id); +import { reactionPicker } from '@/utility/reaction-picker.js'; +import { claimAchievement } from '@/utility/achievements.js'; +import { getNoteClipMenu, getNoteMenu, translateNote } from '@/utility/get-note-menu.js'; +import { boostMenuItems, computeRenoteTooltip } from '@/utility/boost-quote.js'; +import { prefer } from '@/preferences.js'; +import { useNoteCapture } from '@/use/use-note-capture.js'; +import SkMutedNote from '@/components/SkMutedNote.vue'; +import { instance } from '@/instance'; const props = withDefaults(defineProps<{ note: Misskey.entities.Note; @@ -122,25 +125,27 @@ const props = withDefaults(defineProps<{ depth?: number; }>(), { depth: 1, + onDeleteCallback: undefined, }); +const canRenote = computed(() => ['public', 'home'].includes(props.note.visibility) || props.note.userId === $i?.id); + const el = shallowRef<HTMLElement>(); -const muted = ref($i ? checkWordMute(props.note, $i, $i.mutedWords) : false); -const translation = ref<any>(null); +const translation = ref<Misskey.entities.NotesTranslateResponse | false | null>(null); const translating = ref(false); const isDeleted = ref(false); const renoted = ref(false); -const numberOfReplies = ref(defaultStore.state.numberOfReplies); const reactButton = shallowRef<HTMLElement>(); +const clipButton = useTemplateRef('clipButton'); const renoteButton = shallowRef<HTMLElement>(); const quoteButton = shallowRef<HTMLElement>(); const menuButton = shallowRef<HTMLElement>(); const likeButton = shallowRef<HTMLElement>(); -const renoteTooltip = computeRenoteTooltip(computed); +const renoteTooltip = computeRenoteTooltip(renoted); -let appearNote = computed(() => isRenote ? props.note.renote as Misskey.entities.Note : props.note); -const defaultLike = computed(() => defaultStore.state.like ? defaultStore.state.like : null); +const appearNote = computed(() => isRenote ? props.note.renote as Misskey.entities.Note : props.note); +const defaultLike = computed(() => prefer.s.like ? prefer.s.like : null); const replies = ref<Misskey.entities.Note[]>([]); const mergedCW = computed(() => computeMergedCw(appearNote.value)); @@ -154,9 +159,11 @@ const isRenote = ( const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({ type: 'lookup', - url: `https://${host}/notes/${appearNote.value.id}`, + url: appearNote.value.url ?? appearNote.value.uri ?? `${config.url}/notes/${appearNote.value.id}`, })); +const currentClip = inject<Ref<Misskey.entities.Clip> | null>('currentClip', null); + async function addReplyTo(replyNote: Misskey.entities.Note) { replies.value.unshift(replyNote); appearNote.value.repliesCount += 1; @@ -170,13 +177,15 @@ async function removeReply(id: Misskey.entities.Note['id']) { } } +const { muted } = checkMutes(appearNote.value); + useNoteCapture({ rootEl: el, note: appearNote, isDeletedRef: isDeleted, // only update replies if we are, in fact, showing replies - onReplyCallback: props.detail && props.depth < numberOfReplies.value ? addReplyTo : undefined, - onDeleteCallback: props.detail && props.depth < numberOfReplies.value ? props.onDeleteCallback : undefined, + onReplyCallback: props.detail && props.depth < prefer.s.numberOfReplies ? addReplyTo : undefined, + onDeleteCallback: props.detail && props.depth < prefer.s.numberOfReplies ? props.onDeleteCallback : undefined, }); if ($i) { @@ -190,22 +199,21 @@ if ($i) { } function focus() { - el.value.focus(); + el.value?.focus(); } -function reply(viaKeyboard = false): void { +async function reply(viaKeyboard = false): Promise<void> { pleaseLogin({ openOnRemote: pleaseLoginContext.value }); showMovedDialog(); - os.post({ + await os.post({ reply: props.note, - channel: props.note.channel, + channel: props.note.channel ?? undefined, animation: !viaKeyboard, - }, () => { - focus(); }); + focus(); } -function react(viaKeyboard = false): void { +function react(): void { pleaseLogin({ openOnRemote: pleaseLoginContext.value }); showMovedDialog(); sound.playMisskeySfx('reaction'); @@ -285,15 +293,15 @@ function undoRenote() : void { } } -let showContent = ref(defaultStore.state.uncollapseCW); +let showContent = ref(prefer.s.uncollapseCW); watch(() => props.expandAllCws, (expandAllCws) => { if (expandAllCws !== showContent.value) showContent.value = expandAllCws; }); function boostVisibility(forceMenu: boolean = false) { - if (!defaultStore.state.showVisibilitySelectorOnBoost && !forceMenu) { - renote(defaultStore.state.visibilityOnBoost); + if (!prefer.s.showVisibilitySelectorOnBoost && !forceMenu) { + renote(prefer.s.visibilityOnBoost); } else { os.popupMenu(boostMenuItems(appearNote, renote), renoteButton.value); } @@ -347,71 +355,50 @@ function quote() { pleaseLogin({ openOnRemote: pleaseLoginContext.value }); showMovedDialog(); - if (appearNote.value.channel) { - os.post({ - renote: appearNote.value, - channel: appearNote.value.channel, - }).then((cancelled) => { - if (cancelled) return; - misskeyApi('notes/renotes', { - noteId: props.note.id, - userId: $i.id, - limit: 1, - quote: true, - }).then((res) => { - if (!(res.length > 0)) return; - const el = quoteButton.value as HTMLElement | null | undefined; - if (el && res.length > 0) { - const rect = el.getBoundingClientRect(); - const x = rect.left + (el.offsetWidth / 2); - const y = rect.top + (el.offsetHeight / 2); - const { dispose } = os.popup(MkRippleEffect, { x, y }, { - end: () => dispose(), - }); - } + os.post({ + renote: appearNote.value, + channel: appearNote.value.channel ?? undefined, + }).then((cancelled) => { + if (cancelled) return; + misskeyApi('notes/renotes', { + noteId: props.note.id, + userId: $i?.id, + limit: 1, + quote: true, + }).then((res) => { + if (!(res.length > 0)) return; + const popupEl = quoteButton.value as HTMLElement | null | undefined; + if (popupEl && res.length > 0) { + const rect = popupEl.getBoundingClientRect(); + const x = rect.left + (popupEl.offsetWidth / 2); + const y = rect.top + (popupEl.offsetHeight / 2); + const { dispose } = os.popup(MkRippleEffect, { x, y }, { + end: () => dispose(), + }); + } - os.toast(i18n.ts.quoted); - }); + os.toast(i18n.ts.quoted); }); - } else { - os.post({ - renote: appearNote.value, - }).then((cancelled) => { - if (cancelled) return; - misskeyApi('notes/renotes', { - noteId: props.note.id, - userId: $i.id, - limit: 1, - quote: true, - }).then((res) => { - if (!(res.length > 0)) return; - const el = quoteButton.value as HTMLElement | null | undefined; - if (el && res.length > 0) { - const rect = el.getBoundingClientRect(); - const x = rect.left + (el.offsetWidth / 2); - const y = rect.top + (el.offsetHeight / 2); - const { dispose } = os.popup(MkRippleEffect, { x, y }, { - end: () => dispose(), - }); - } + }); +} - os.toast(i18n.ts.quoted); - }); - }); - } +function menu(): void { + const { menu, cleanup } = getNoteMenu({ note: props.note, translating, translation, isDeleted }); + os.popupMenu(menu, menuButton.value).then(focus).finally(cleanup); +} + +async function clip(): Promise<void> { + os.popupMenu(await getNoteClipMenu({ note: props.note, isDeleted, currentClip: currentClip?.value }), clipButton.value).then(focus); } -function menu(viaKeyboard = false): void { - const { menu, cleanup } = getNoteMenu({ note: props.note, translating, translation, menuButton, isDeleted }); - os.popupMenu(menu, menuButton.value, { - viaKeyboard, - }).then(focus).finally(cleanup); +async function translate() { + await translateNote(appearNote.value.id, translation, translating); } if (props.detail) { misskeyApi('notes/children', { noteId: props.note.id, - limit: numberOfReplies.value, + limit: prefer.s.numberOfReplies, showQuotes: false, }).then(res => { replies.value = res; @@ -546,5 +533,10 @@ if (props.detail) { border: 1px solid var(--MI_THEME-divider); margin: 8px 8px 0 8px; border-radius: var(--MI-radius-sm); + cursor: pointer; +} + +.muted:hover { + background: var(--MI_THEME-buttonBg); } </style> diff --git a/packages/frontend/src/components/MkNotes.vue b/packages/frontend/src/components/MkNotes.vue index bd157d0b14..efb481d01d 100644 --- a/packages/frontend/src/components/MkNotes.vue +++ b/packages/frontend/src/components/MkNotes.vue @@ -7,43 +7,35 @@ SPDX-License-Identifier: AGPL-3.0-only <MkPagination ref="pagingComponent" :pagination="pagination" :disableAutoLoad="disableAutoLoad"> <template #empty> <div class="_fullinfo"> - <img :src="infoImageUrl" class="_ghost"/> + <img :src="infoImageUrl" draggable="false"/> <div>{{ i18n.ts.noNotes }}</div> </div> </template> <template #default="{ items: notes }"> - <div :class="[$style.root, { [$style.noGap]: noGap }]"> - <MkDateSeparatedList - ref="notes" - v-slot="{ item: note }" - :items="notes" - :direction="pagination.reversed ? 'up' : 'down'" - :reversed="pagination.reversed" - :noGap="noGap" - :ad="true" - :class="$style.notes" - > - <MkNote :key="note._featuredId_ || note._prId_ || note.id" :class="$style.note" :note="note" :withHardMute="true"/> - </MkDateSeparatedList> + <div :class="[$style.root, { [$style.noGap]: noGap, '_gaps': !noGap, [$style.reverse]: pagination.reversed }]"> + <template v-for="(note, i) in notes" :key="note.id"> + <div v-if="note._shouldInsertAd_" :class="[$style.noteWithAd, { '_gaps': !noGap }]" :data-scroll-anchor="note.id"> + <DynamicNote :class="$style.note" :note="note as Misskey.entities.Note" :withHardMute="true"/> + <div :class="$style.ad"> + <MkAd :preferForms="['horizontal', 'horizontal-big']"/> + </div> + </div> + <DynamicNote v-else :class="$style.note" :note="note as Misskey.entities.Note" :withHardMute="true" :data-scroll-anchor="note.id"/> + </template> </div> </template> </MkPagination> </template> <script lang="ts" setup> -import { defineAsyncComponent, shallowRef } from 'vue'; -import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue'; -import MkPagination, { Paging } from '@/components/MkPagination.vue'; +import * as Misskey from 'misskey-js'; +import { useTemplateRef } from 'vue'; +import type { Paging } from '@/components/MkPagination.vue'; +import DynamicNote from '@/components/DynamicNote.vue'; +import MkPagination from '@/components/MkPagination.vue'; import { i18n } from '@/i18n.js'; import { infoImageUrl } from '@/instance.js'; -import { defaultStore } from '@/store.js'; - -const MkNote = defineAsyncComponent(() => - (defaultStore.state.noteDesign === 'misskey') ? import('@/components/MkNote.vue') : - (defaultStore.state.noteDesign === 'sharkey') ? import('@/components/SkNote.vue') : - null -); const props = defineProps<{ pagination: Paging; @@ -51,7 +43,7 @@ const props = defineProps<{ disableAutoLoad?: boolean; }>(); -const pagingComponent = shallowRef<InstanceType<typeof MkPagination>>(); +const pagingComponent = useTemplateRef('pagingComponent'); defineExpose({ pagingComponent, @@ -59,24 +51,40 @@ defineExpose({ </script> <style lang="scss" module> +.reverse { + display: flex; + flex-direction: column-reverse; +} + .root { + container-type: inline-size; + &.noGap { - border-radius: var(--MI-radius); + background: color-mix(in srgb, var(--MI_THEME-panel) 65%, transparent); - > .notes { - background: color-mix(in srgb, var(--MI_THEME-panel) 65%, transparent); + .note { + border-bottom: solid 0.5px var(--MI_THEME-divider); + } + + .ad { + padding: 8px; + background-size: auto auto; + background-image: repeating-linear-gradient(45deg, transparent, transparent 8px, var(--MI_THEME-bg) 8px, var(--MI_THEME-bg) 14px); + border-bottom: solid 0.5px var(--MI_THEME-divider); } } &:not(.noGap) { - > .notes { - background: var(--MI_THEME-bg); + background: var(--MI_THEME-bg); - .note { - background: color-mix(in srgb, var(--MI_THEME-panel) 65%, transparent); - border-radius: var(--MI-radius); - } + .note { + background: color-mix(in srgb, var(--MI_THEME-panel) 65%, transparent); + border-radius: var(--MI-radius); } } } + +.ad:empty { + display: none; +} </style> diff --git a/packages/frontend/src/components/MkNotification.vue b/packages/frontend/src/components/MkNotification.vue index a910151e42..220ca04d47 100644 --- a/packages/frontend/src/components/MkNotification.vue +++ b/packages/frontend/src/components/MkNotification.vue @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="$style.root"> <div :class="$style.head"> <MkAvatar v-if="['pollEnded', 'note', 'edited', 'scheduledNotePosted'].includes(notification.type) && 'note' in notification" :class="$style.icon" :user="notification.note.user" link preview/> - <MkAvatar v-else-if="['roleAssigned', 'achievementEarned', 'exportCompleted', 'login', 'scheduledNoteFailed'].includes(notification.type)" :class="$style.icon" :user="$i" link preview/> + <MkAvatar v-else-if="['roleAssigned', 'achievementEarned', 'exportCompleted', 'login', 'createToken', 'scheduledNoteFailed'].includes(notification.type)" :class="$style.icon" :user="$i" link preview/> <div v-else-if="notification.type === 'reaction:grouped' && notification.note.reactionAcceptance === 'likeOnly'" :class="[$style.icon, $style.icon_reactionGroupHeart]"><i class="ph-smiley ph-bold ph-lg" style="line-height: 1;"></i></div> <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="ti ti-repeat" style="line-height: 1;"></i></div> @@ -27,6 +27,8 @@ SPDX-License-Identifier: AGPL-3.0-only [$style.t_achievementEarned]: notification.type === 'achievementEarned', [$style.t_exportCompleted]: notification.type === 'exportCompleted', [$style.t_login]: notification.type === 'login', + [$style.t_createToken]: notification.type === 'createToken', + [$style.t_chatRoomInvitationReceived]: notification.type === 'chatRoomInvitationReceived', [$style.t_roleAssigned]: notification.type === 'roleAssigned' && notification.role.iconUrl == null, [$style.t_pollEnded]: notification.type === 'edited', [$style.t_roleAssigned]: notification.type === 'scheduledNoteFailed', @@ -44,6 +46,8 @@ SPDX-License-Identifier: AGPL-3.0-only <i v-else-if="notification.type === 'achievementEarned'" class="ti ti-medal"></i> <i v-else-if="notification.type === 'exportCompleted'" class="ti ti-archive"></i> <i v-else-if="notification.type === 'login'" class="ti ti-login-2"></i> + <i v-else-if="notification.type === 'createToken'" class="ti ti-key"></i> + <i v-else-if="notification.type === 'chatRoomInvitationReceived'" class="ti ti-messages"></i> <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="ti ti-badges"></i> @@ -66,8 +70,10 @@ SPDX-License-Identifier: AGPL-3.0-only <span v-if="notification.type === 'pollEnded'">{{ i18n.ts._notification.pollEnded }}</span> <span v-else-if="notification.type === 'note'">{{ i18n.ts._notification.newNote }}: <MkUserName :user="notification.note.user"/></span> <span v-else-if="notification.type === 'roleAssigned'">{{ i18n.ts._notification.roleAssigned }}</span> + <span v-else-if="notification.type === 'chatRoomInvitationReceived'">{{ i18n.ts._notification.chatRoomInvitationReceived }}</span> <span v-else-if="notification.type === 'achievementEarned'">{{ i18n.ts._notification.achievementEarned }}</span> <span v-else-if="notification.type === 'login'">{{ i18n.ts._notification.login }}</span> + <span v-else-if="notification.type === 'createToken'">{{ i18n.ts._notification.createToken }}</span> <span v-else-if="notification.type === 'test'">{{ i18n.ts._notification.testNotification }}</span> <span v-else-if="notification.type === 'exportCompleted'">{{ i18n.tsx._notification.exportOfXCompleted({ x: exportEntityName[notification.exportedEntity] }) }}</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> @@ -111,12 +117,18 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-else-if="notification.type === 'roleAssigned'" :class="$style.text"> {{ notification.role.name }} </div> + <div v-else-if="notification.type === 'chatRoomInvitationReceived'" :class="$style.text"> + {{ notification.invitation.room.name }} + </div> <MkA v-else-if="notification.type === 'achievementEarned'" :class="$style.text" to="/my/achievements"> {{ i18n.ts._achievements._types['_' + notification.achievement].title }} </MkA> <MkA v-else-if="notification.type === 'exportCompleted'" :class="$style.text" :to="`/my/drive/file/${notification.fileId}`"> {{ i18n.ts.showFile }} </MkA> + <MkA v-else-if="notification.type === 'createToken'" :class="$style.text" to="/settings/apps"> + <Mfm :text="i18n.tsx._notification.createTokenDescription({ text: i18n.ts.manageAccessTokens })"/> + </MkA> <div v-else-if="notification.type === 'scheduledNoteFailed'" :class="$style.text"> {{ notification.reason }} </div> @@ -183,21 +195,21 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { Ref, ref, watch } from 'vue'; +import { ref, watch } from 'vue'; import * as Misskey from 'misskey-js'; -import { UserDetailed } from 'misskey-js/autogen/models.js'; +import type { Ref } from 'vue'; import MkReactionIcon from '@/components/MkReactionIcon.vue'; import MkButton from '@/components/MkButton.vue'; -import { getNoteSummary } from '@/scripts/get-note-summary.js'; +import { getNoteSummary } from '@/utility/get-note-summary.js'; import { notePage } from '@/filters/note.js'; import { userPage } from '@/filters/user.js'; import { i18n } from '@/i18n.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { signinRequired } from '@/account.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { ensureSignin } from '@/i.js'; import { infoImageUrl } from '@/instance.js'; import MkFollowButton from '@/components/MkFollowButton.vue'; -const $i = signinRequired(); +const $i = ensureSignin(); const props = withDefaults(defineProps<{ notification: Misskey.entities.Notification; @@ -208,42 +220,46 @@ const props = withDefaults(defineProps<{ full: false, }); -const userDetailed: Ref<UserDetailed | null> = ref(null); +type ExportCompletedNotification = Misskey.entities.Notification & { type: 'exportCompleted' }; + +const exportEntityName = { + antenna: i18n.ts.antennas, + blocking: i18n.ts.blockedUsers, + clip: i18n.ts.clips, + customEmoji: i18n.ts.customEmojis, + favorite: i18n.ts.favorites, + following: i18n.ts.following, + muting: i18n.ts.mutedUsers, + note: i18n.ts.notes, + userList: i18n.ts.lists, +} as const satisfies Record<ExportCompletedNotification['exportedEntity'], string>; const followRequestDone = ref(true); +const userDetailed: Ref<Misskey.entities.UserDetailed | null> = ref(null); // watch() is required because computed() doesn't support async. watch(props, async () => { const type = props.notification.type; // To avoid extra lookups, only do the query when it actually matters. - if (type === 'follow' || type === 'receiveFollowRequest') { - const user = await misskeyApi('users/show', { - userId: props.notification.userId, - }); + if ((type === 'follow' || type === 'receiveFollowRequest') && props.notification.userId) { + try { + const user = await misskeyApi('users/show', { + userId: props.notification.userId, + }); - userDetailed.value = user; - followRequestDone.value = !user.hasPendingFollowRequestToYou; + userDetailed.value = user; + followRequestDone.value = !user.hasPendingFollowRequestToYou; + } catch { + userDetailed.value = null; + followRequestDone.value = false; + } } else { userDetailed.value = null; followRequestDone.value = false; } }, { immediate: true }); -type ExportCompletedNotification = Misskey.entities.Notification & { type: 'exportCompleted' }; - -const exportEntityName = { - antenna: i18n.ts.antennas, - blocking: i18n.ts.blockedUsers, - clip: i18n.ts.clips, - customEmoji: i18n.ts.customEmojis, - favorite: i18n.ts.favorites, - following: i18n.ts.following, - muting: i18n.ts.mutedUsers, - note: i18n.ts.notes, - userList: i18n.ts.lists, -} as const satisfies Record<ExportCompletedNotification['exportedEntity'], string>; - const acceptFollowRequest = () => { if (!('user' in props.notification)) return; followRequestDone.value = true; @@ -335,6 +351,7 @@ function getActualReactedUsersCount(notification: Misskey.entities.Notification) right: -2px; width: 20px; height: 20px; + line-height: 20px; box-sizing: border-box; border-radius: var(--MI-radius-full); background: var(--MI_THEME-panel); @@ -349,65 +366,65 @@ function getActualReactedUsersCount(notification: Misskey.entities.Notification) } .t_follow, .t_followRequestAccepted, .t_receiveFollowRequest { - padding: 3px; background: var(--eventFollow); pointer-events: none; } .t_renote { - padding: 3px; background: var(--eventRenote); pointer-events: none; } .t_quote { - padding: 3px; background: var(--eventRenote); pointer-events: none; } .t_reply { - padding: 3px; background: var(--eventReply); pointer-events: none; } .t_mention { - padding: 3px; background: var(--eventOther); pointer-events: none; } .t_pollEnded { - padding: 3px; background: var(--eventOther); pointer-events: none; } .t_achievementEarned { - padding: 3px; background: var(--eventAchievement); pointer-events: none; } .t_exportCompleted { - padding: 3px; background: var(--eventOther); pointer-events: none; } .t_roleAssigned { - padding: 3px; background: var(--eventOther); pointer-events: none; } .t_login { - padding: 3px; background: var(--eventLogin); pointer-events: none; } +.t_createToken { + background: var(--eventOther); + pointer-events: none; +} + +.t_chatRoomInvitationReceived { + background: var(--eventOther); + 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 d07827d11a..bb01a008bd 100644 --- a/packages/frontend/src/components/MkNotificationSelectWindow.vue +++ b/packages/frontend/src/components/MkNotificationSelectWindow.vue @@ -16,7 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-only > <template #header>{{ i18n.ts.notificationSetting }}</template> - <MkSpacer :marginMin="20" :marginMax="28"> + <div class="_spacer" style="--MI_SPACER-min: 20px; --MI_SPACER-max: 28px;"> <div class="_gaps_m"> <MkInfo>{{ i18n.ts.notificationSettingDesc }}</MkInfo> <div class="_buttons"> @@ -25,20 +25,21 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <MkSwitch v-for="ntype in notificationTypes" :key="ntype" v-model="typesMap[ntype].value">{{ i18n.ts._notification._types[ntype] }}</MkSwitch> </div> - </MkSpacer> + </div> </MkModalWindow> </template> <script lang="ts" setup> -import { ref, Ref, shallowRef } from 'vue'; +import { ref, useTemplateRef } from 'vue'; +import { notificationTypes } from '@@/js/const.js'; import MkSwitch from './MkSwitch.vue'; import MkInfo from './MkInfo.vue'; import MkButton from './MkButton.vue'; +import type { Ref } from 'vue'; import MkModalWindow from '@/components/MkModalWindow.vue'; -import { notificationTypes } from '@@/js/const.js'; import { i18n } from '@/i18n.js'; -type TypesMap = Record<typeof notificationTypes[number], Ref<boolean>> +type TypesMap = Record<typeof notificationTypes[number], Ref<boolean>>; const emit = defineEmits<{ (ev: 'done', v: { excludeTypes: string[] }): void, @@ -51,7 +52,7 @@ const props = withDefaults(defineProps<{ excludeTypes: () => [], }); -const dialog = shallowRef<InstanceType<typeof MkModalWindow>>(); +const dialog = useTemplateRef('dialog'); const typesMap = notificationTypes.reduce((p, t) => ({ ...p, [t]: ref<boolean>(!props.excludeTypes.includes(t)) }), {} as TypesMap); diff --git a/packages/frontend/src/components/MkNotifications.vue b/packages/frontend/src/components/MkNotifications.vue index 51c4ea7ce4..54edf771ed 100644 --- a/packages/frontend/src/components/MkNotifications.vue +++ b/packages/frontend/src/components/MkNotifications.vue @@ -8,47 +8,51 @@ SPDX-License-Identifier: AGPL-3.0-only <MkPagination ref="pagingComponent" :pagination="pagination"> <template #empty> <div class="_fullinfo"> - <img :src="infoImageUrl" class="_ghost"/> + <img :src="infoImageUrl" draggable="false"/> <div>{{ i18n.ts.noNotifications }}</div> </div> </template> <template #default="{ items: notifications }"> - <MkDateSeparatedList v-slot="{ item: notification }" :class="$style.list" :items="notifications" :noGap="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> + <component + :is="prefer.s.animation ? TransitionGroup : 'div'" :class="[$style.notifications]" + :enterActiveClass="$style.transition_x_enterActive" + :leaveActiveClass="$style.transition_x_leaveActive" + :enterFromClass="$style.transition_x_enterFrom" + :leaveToClass="$style.transition_x_leaveTo" + :moveClass=" $style.transition_x_move" + tag="div" + > + <div v-for="(notification, i) in notifications" :key="notification.id"> + <DynamicNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :class="$style.item" :note="notification.note" :withHardMute="true" :data-scroll-anchor="notification.id"/> + <XNotification v-else :class="$style.item" :notification="notification" :withTime="true" :full="true" :data-scroll-anchor="notification.id"/> + </div> + </component> </template> </MkPagination> </MkPullToRefresh> </template> <script lang="ts" setup> -import { defineAsyncComponent, onUnmounted, onDeactivated, onMounted, computed, shallowRef, onActivated } from 'vue'; +import { onUnmounted, onMounted, computed, useTemplateRef, TransitionGroup } from 'vue'; +import * as Misskey from 'misskey-js'; +import type { notificationTypes } from '@@/js/const.js'; import MkPagination from '@/components/MkPagination.vue'; import XNotification from '@/components/MkNotification.vue'; -import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue'; +import DynamicNote from '@/components/DynamicNote.vue'; import { useStream } from '@/stream.js'; import { i18n } from '@/i18n.js'; -import { notificationTypes } from '@@/js/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 MkNote = defineAsyncComponent(() => - (defaultStore.state.noteDesign === 'misskey') ? import('@/components/MkNote.vue') : - (defaultStore.state.noteDesign === 'sharkey') ? import('@/components/SkNote.vue') : - null -); +import { prefer } from '@/preferences.js'; const props = defineProps<{ excludeTypes?: typeof notificationTypes[number][]; }>(); -const pagingComponent = shallowRef<InstanceType<typeof MkPagination>>(); +const pagingComponent = useTemplateRef('pagingComponent'); -const pagination = computed(() => defaultStore.reactiveState.useGroupedNotifications.value ? { +const pagination = computed(() => prefer.r.useGroupedNotifications.value ? { endpoint: 'i/notifications-grouped' as const, limit: 20, params: computed(() => ({ @@ -64,7 +68,7 @@ const pagination = computed(() => defaultStore.reactiveState.useGroupedNotificat function onNotification(notification) { const isMuted = props.excludeTypes ? props.excludeTypes.includes(notification.type) : false; - if (isMuted || document.visibilityState === 'visible') { + if (isMuted || window.document.visibilityState === 'visible') { useStream().send('readNotification'); } @@ -89,28 +93,36 @@ onMounted(() => { connection.on('notificationFlushed', reload); }); -onActivated(() => { - pagingComponent.value?.reload(); - connection = useStream().useChannel('main'); - connection.on('notification', onNotification); - connection.on('notificationFlushed', reload); -}); - onUnmounted(() => { if (connection) connection.dispose(); }); -onDeactivated(() => { - if (connection) connection.dispose(); -}); - defineExpose({ reload, }); </script> <style lang="scss" module> -.list { +.transition_x_move, +.transition_x_enterActive, +.transition_x_leaveActive { + transition: opacity 0.3s cubic-bezier(0,.5,.5,1), transform 0.3s cubic-bezier(0,.5,.5,1) !important; +} +.transition_x_enterFrom, +.transition_x_leaveTo { + opacity: 0; + transform: translateY(-50%); +} +.transition_x_leaveActive { + position: absolute; +} + +.notifications { + container-type: inline-size; background: var(--MI_THEME-panel); } + +.item { + border-bottom: solid 0.5px var(--MI_THEME-divider); +} </style> diff --git a/packages/frontend/src/components/MkNumber.vue b/packages/frontend/src/components/MkNumber.vue index a278205b61..7c2393bf5c 100644 --- a/packages/frontend/src/components/MkNumber.vue +++ b/packages/frontend/src/components/MkNumber.vue @@ -10,6 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { reactive, watch } from 'vue'; import number from '@/filters/number.js'; +import { prefer } from '@/preferences'; const props = defineProps<{ value: number; @@ -36,7 +37,11 @@ watch(() => props.value, (to, from) => { } } - window.requestAnimationFrame(step); + if (prefer.s.animation) { + window.requestAnimationFrame(step); + } else { + tweened.number = to; + } }, { immediate: true, }); diff --git a/packages/frontend/src/components/MkOmit.vue b/packages/frontend/src/components/MkOmit.vue index b978a71c15..a6f2e1b4e9 100644 --- a/packages/frontend/src/components/MkOmit.vue +++ b/packages/frontend/src/components/MkOmit.vue @@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, onUnmounted, shallowRef, ref } from 'vue'; +import { onMounted, onUnmounted, useTemplateRef, ref } from 'vue'; import { i18n } from '@/i18n.js'; const props = withDefaults(defineProps<{ @@ -22,7 +22,7 @@ const props = withDefaults(defineProps<{ maxHeight: 200, }); -const content = shallowRef<HTMLElement>(); +const content = useTemplateRef('content'); const omitted = ref(false); const ignoreOmit = ref(false); @@ -62,7 +62,6 @@ onUnmounted(() => { left: 0; width: 100%; height: 64px; - //background: linear-gradient(0deg, var(--MI_THEME-panel), color(from var(--MI_THEME-panel) srgb r g b / 0)); > .fadeLabel { display: inline-block; diff --git a/packages/frontend/src/components/MkPageWindow.vue b/packages/frontend/src/components/MkPageWindow.vue index 3ff4cc215c..a9e4704b24 100644 --- a/packages/frontend/src/components/MkPageWindow.vue +++ b/packages/frontend/src/components/MkPageWindow.vue @@ -22,28 +22,29 @@ SPDX-License-Identifier: AGPL-3.0-only </template> </template> - <div ref="contents" :class="$style.root" style="container-type: inline-size;"> - <RouterView :key="reloadCount" :router="windowRouter"/> + <div :class="$style.root" class="_forceShrinkSpacer"> + <StackingRouterView v-if="prefer.s['experimental.stackingRouterView']" :key="reloadCount" :router="windowRouter"/> + <RouterView v-else :key="reloadCount" :router="windowRouter"/> </div> </MkWindow> </template> <script lang="ts" setup> -import { computed, onMounted, onUnmounted, provide, ref, shallowRef } from 'vue'; +import { computed, onMounted, onUnmounted, provide, ref, useTemplateRef } from 'vue'; import { url } from '@@/js/config.js'; -import { getScrollContainer } from '@@/js/scroll.js'; +import type { PageMetadata } from '@/page.js'; import MkUserName from './global/MkUserName.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 { useScrollPositionManager } from '@/nirax.js'; +import { popout as _popout } from '@/utility/popout.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; import { i18n } from '@/i18n.js'; -import { PageMetadata, provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js'; +import { provideMetadataReceiver, provideReactiveMetadata } from '@/page.js'; import { openingWindowsCount } from '@/os.js'; -import { claimAchievement } from '@/scripts/achievements.js'; -import { useRouterFactory } from '@/router/supplier.js'; -import { mainRouter } from '@/router/main.js'; +import { claimAchievement } from '@/utility/achievements.js'; +import { createRouter, mainRouter } from '@/router.js'; +import { DI } from '@/di.js'; +import { prefer } from '@/preferences.js'; const props = defineProps<{ initialPath: string; @@ -53,15 +54,12 @@ const emit = defineEmits<{ (ev: 'closed'): void; }>(); -const routerFactory = useRouterFactory(); -const windowRouter = routerFactory(props.initialPath); +const windowRouter = createRouter(props.initialPath); -const contents = shallowRef<HTMLElement | null>(null); const pageMetadata = ref<null | PageMetadata>(null); -const windowEl = shallowRef<InstanceType<typeof MkWindow>>(); -const history = ref<{ path: string; key: string; }[]>([{ - path: windowRouter.getCurrentPath(), - key: windowRouter.getCurrentKey(), +const windowEl = useTemplateRef('windowEl'); +const history = ref<{ path: string; }[]>([{ + path: windowRouter.getCurrentFullPath(), }]); const buttonsLeft = computed(() => { const buttons: Record<string, unknown>[] = []; @@ -90,18 +88,32 @@ const buttonsRight = computed(() => { }); const reloadCount = ref(0); +function getSearchMarker(path: string) { + const hash = path.split('#')[1]; + if (hash == null) return null; + return hash; +} + +const searchMarkerId = ref<string | null>(getSearchMarker(props.initialPath)); + windowRouter.addListener('push', ctx => { - history.value.push({ path: ctx.path, key: ctx.key }); + history.value.push({ path: ctx.fullPath }); }); windowRouter.addListener('replace', ctx => { history.value.pop(); - history.value.push({ path: ctx.path, key: ctx.key }); + history.value.push({ path: ctx.fullPath }); +}); + +windowRouter.addListener('change', ctx => { + if (_DEV_) console.log('windowRouter: change', ctx.fullPath); + searchMarkerId.value = getSearchMarker(ctx.fullPath); }); windowRouter.init(); -provide('router', windowRouter); +provide(DI.router, windowRouter); +provide(DI.inAppSearchMarkerId, searchMarkerId); provideMetadataReceiver((metadataGetter) => { const info = metadataGetter(); pageMetadata.value = info; @@ -109,7 +121,6 @@ provideMetadataReceiver((metadataGetter) => { provideReactiveMetadata(pageMetadata); provide('shouldOmitHeaderTitle', true); provide('shouldHeaderThin', true); -provide('forceSpacerMin', true); provide('shouldBackButton', false); const contextmenu = computed(() => ([{ @@ -124,20 +135,20 @@ const contextmenu = computed(() => ([{ icon: 'ti ti-external-link', text: i18n.ts.openInNewTab, action: () => { - window.open(url + windowRouter.getCurrentPath(), '_blank', 'noopener'); + window.open(url + windowRouter.getCurrentFullPath(), '_blank', 'noopener'); windowEl.value?.close(); }, }, { icon: 'ti ti-link', text: i18n.ts.copyLink, action: () => { - copyToClipboard(url + windowRouter.getCurrentPath()); + copyToClipboard(url + windowRouter.getCurrentFullPath()); }, }])); function back() { history.value.pop(); - windowRouter.replace(history.value.at(-1)!.path, history.value.at(-1)!.key); + windowRouter.replace(history.value.at(-1)!.path); } function reload() { @@ -149,17 +160,15 @@ function close() { } function expand() { - mainRouter.push(windowRouter.getCurrentPath(), 'forcePage'); + mainRouter.push(windowRouter.getCurrentFullPath(), 'forcePage'); windowEl.value?.close(); } function popout() { - _popout(windowRouter.getCurrentPath(), windowEl.value?.$el); + _popout(windowRouter.getCurrentFullPath(), windowEl.value?.$el); windowEl.value?.close(); } -useScrollPositionManager(() => getScrollContainer(contents.value), windowRouter); - onMounted(() => { openingWindowsCount.value++; if (openingWindowsCount.value >= 3) { @@ -178,9 +187,7 @@ defineExpose({ <style lang="scss" module> .root { - overscroll-behavior: contain; - - min-height: 100%; + height: 100%; background: var(--MI_THEME-bg); --MI-margin: var(--MI-marginHalf); diff --git a/packages/frontend/src/components/MkPagination.vue b/packages/frontend/src/components/MkPagination.vue index f37cb10f6d..79a268e8f6 100644 --- a/packages/frontend/src/components/MkPagination.vue +++ b/packages/frontend/src/components/MkPagination.vue @@ -5,53 +5,53 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <Transition - :enterActiveClass="defaultStore.state.animation ? $style.transition_fade_enterActive : ''" - :leaveActiveClass="defaultStore.state.animation ? $style.transition_fade_leaveActive : ''" - :enterFromClass="defaultStore.state.animation ? $style.transition_fade_enterFrom : ''" - :leaveToClass="defaultStore.state.animation ? $style.transition_fade_leaveTo : ''" + :enterActiveClass="prefer.s.animation ? $style.transition_fade_enterActive : ''" + :leaveActiveClass="prefer.s.animation ? $style.transition_fade_leaveActive : ''" + :enterFromClass="prefer.s.animation ? $style.transition_fade_enterFrom : ''" + :leaveToClass="prefer.s.animation ? $style.transition_fade_leaveTo : ''" mode="out-in" > <MkLoading v-if="fetching"/> <MkError v-else-if="error" @retry="init()"/> - <div v-else-if="empty" key="_empty_" class="empty"> + <div v-else-if="empty" key="_empty_"> <slot name="empty"> <div class="_fullinfo"> - <img :src="infoImageUrl" class="_ghost"/> + <img :src="infoImageUrl" draggable="false"/> <div>{{ i18n.ts.nothing }}</div> </div> </slot> </div> - <div v-else ref="rootEl"> - <div v-show="pagination.reversed && more" key="_more_" class="_margin"> - <MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? appearFetchMoreAhead : null" :class="$style.more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary rounded @click="fetchMoreAhead"> + <div v-else ref="rootEl" class="_gaps"> + <div v-show="pagination.reversed && more" key="_more_"> + <MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? appearFetchMoreAhead : null" :class="$style.more" :wait="moreFetching" primary rounded @click="fetchMoreAhead"> {{ i18n.ts.loadMore }} </MkButton> - <MkLoading v-else class="loading"/> + <MkLoading v-else/> </div> <slot :items="Array.from(items.values())" :fetching="fetching || moreFetching"></slot> - <div v-show="!pagination.reversed && more" key="_more_" class="_margin"> - <MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? appearFetchMore : null" :class="$style.more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary rounded @click="fetchMore"> + <div v-show="!pagination.reversed && more" key="_more_"> + <MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? appearFetchMore : null" :class="$style.more" :wait="moreFetching" primary rounded @click="fetchMore"> {{ i18n.ts.loadMore }} </MkButton> - <MkLoading v-else class="loading"/> + <MkLoading v-else/> </div> </div> </Transition> </template> <script lang="ts"> -import { computed, ComputedRef, isRef, nextTick, onActivated, onBeforeMount, onBeforeUnmount, onDeactivated, ref, shallowRef, watch, type Ref } from 'vue'; +import { computed, isRef, nextTick, onActivated, onBeforeMount, onBeforeUnmount, onDeactivated, ref, useTemplateRef, watch } from 'vue'; import * as Misskey from 'misskey-js'; import { useDocumentVisibility } from '@@/js/use-document-visibility.js'; -import { onScrollTop, isTopVisible, getBodyScrollHeight, getScrollContainer, onScrollBottom, scrollToBottom, scroll, isBottomVisible } from '@@/js/scroll.js'; -import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { defaultStore } from '@/store.js'; -import { MisskeyEntity } from '@/types/date-separated-list.js'; +import { onScrollTop, isHeadVisible, getBodyScrollHeight, getScrollContainer, onScrollBottom, scrollToBottom, scrollInContainer, isTailVisible } from '@@/js/scroll.js'; +import type { ComputedRef, Ref } from 'vue'; +import type { MisskeyEntity } from '@/types/date-separated-list.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; +import { prefer } from '@/preferences.js'; const SECOND_FETCH_LIMIT = 30; const TOLERANCE = 16; @@ -74,8 +74,6 @@ export type Paging<E extends keyof Misskey.Endpoints = keyof Misskey.Endpoints> reversed?: boolean; offsetMode?: boolean | ComputedRef<boolean>; - - pageEl?: HTMLElement; }; type MisskeyEntityMap = Map<string, MisskeyEntity>; @@ -107,7 +105,7 @@ const emit = defineEmits<{ (ev: 'init'): void; }>(); -const rootEl = shallowRef<HTMLElement>(); +const rootEl = useTemplateRef('rootEl'); // 遡り中かどうか const backed = ref(false); @@ -140,10 +138,9 @@ const empty = computed(() => items.value.size === 0); const error = ref(false); const { enableInfiniteScroll, -} = defaultStore.reactiveState; +} = prefer.r; -const contentEl = computed(() => props.pagination.pageEl ?? rootEl.value); -const scrollableElement = computed(() => contentEl.value ? getScrollContainer(contentEl.value) : document.body); +const scrollableElement = computed(() => rootEl.value ? getScrollContainer(rootEl.value) : window.document.body); const visibility = useDocumentVisibility(); @@ -174,13 +171,13 @@ watch(rootEl, () => { }); }); -watch([backed, contentEl], () => { +watch([backed, rootEl], () => { if (!backed.value) { - if (!contentEl.value) return; + if (!rootEl.value) return; scrollRemove.value = props.pagination.reversed - ? onScrollBottom(contentEl.value, executeQueue, TOLERANCE) - : onScrollTop(contentEl.value, (topVisible) => { if (topVisible) executeQueue(); }, TOLERANCE); + ? onScrollBottom(rootEl.value, executeQueue, TOLERANCE) + : onScrollTop(rootEl.value, (topVisible) => { if (topVisible) executeQueue(); }, TOLERANCE); } else { if (scrollRemove.value) scrollRemove.value(); scrollRemove.value = null; @@ -200,10 +197,10 @@ watch(error, (n, o) => { emit('status', n); }); -function getActualValue<T>(input: T|Ref<T>|undefined, defaultValue: T) : T { - if (!input) return defaultValue; - if (isRef(input)) return input.value; - return input; +function getActualValue<T>(input: T | Ref<T> | undefined, defaultValue: T) : T { + if (!input) return defaultValue; + if (isRef(input)) return input.value; + return input; } async function init(): Promise<void> { @@ -247,23 +244,24 @@ const reload = (): Promise<void> => { const fetchMore = async (): Promise<void> => { if (!more.value || fetching.value || moreFetching.value || items.value.size === 0) return; moreFetching.value = true; - const params = getActualValue<Paging['params']>(props.pagination.params, {}); - const offsetMode = getActualValue(props.pagination.offsetMode, false); - await misskeyApi<MisskeyEntity[]>(props.pagination.endpoint, { - ...params, - limit: SECOND_FETCH_LIMIT, - ...(offsetMode ? { - offset: items.value.size, - } : { - untilId: Array.from(items.value.keys()).at(-1), - }), - }).then(res => { + try { + const params = getActualValue<Paging['params']>(props.pagination.params, {}); + const offsetMode = getActualValue(props.pagination.offsetMode, false); + const res = await misskeyApi<MisskeyEntity[]>(props.pagination.endpoint, { + ...params, + limit: SECOND_FETCH_LIMIT, + ...(offsetMode ? { + offset: items.value.size, + } : { + untilId: Array.from(items.value.keys()).at(-1), + }), + }); for (let i = 0; i < res.length; i++) { const item = res[i]; if (i === 10) item._shouldInsertAd_ = true; } - const reverseConcat = _res => { + const reverseConcat = (_res: typeof res) => { const oldHeight = scrollableElement.value ? scrollableElement.value.scrollHeight : getBodyScrollHeight(); const oldScroll = scrollableElement.value ? scrollableElement.value.scrollTop : window.scrollY; @@ -271,7 +269,7 @@ const fetchMore = async (): Promise<void> => { return nextTick(() => { if (scrollableElement.value) { - scroll(scrollableElement.value, { top: oldScroll + (scrollableElement.value.scrollHeight - oldHeight), behavior: 'instant' }); + scrollInContainer(scrollableElement.value, { top: oldScroll + (scrollableElement.value.scrollHeight - oldHeight), behavior: 'instant' }); } else { window.scroll({ top: oldScroll + (getBodyScrollHeight() - oldHeight), behavior: 'instant' }); } @@ -282,30 +280,24 @@ const fetchMore = async (): Promise<void> => { if (res.length === 0) { if (props.pagination.reversed) { - reverseConcat(res).then(() => { - more.value = false; - moreFetching.value = false; - }); + await reverseConcat(res); + more.value = false; } else { items.value = concatMapWithArray(items.value, res); more.value = false; - moreFetching.value = false; } } else { if (props.pagination.reversed) { - reverseConcat(res).then(() => { - more.value = true; - moreFetching.value = false; - }); + await reverseConcat(res); + more.value = true; } else { items.value = concatMapWithArray(items.value, res); more.value = true; - moreFetching.value = false; } } - }, err => { + } finally { moreFetching.value = false; - }); + } }; const fetchMoreAhead = async (): Promise<void> => { @@ -360,7 +352,7 @@ const appearFetchMoreAhead = async (): Promise<void> => { fetchMoreAppearTimeout(); }; -const isTop = (): boolean => isBackTop.value || (props.pagination.reversed ? isBottomVisible : isTopVisible)(contentEl.value!, TOLERANCE); +const isHead = (): boolean => isBackTop.value || (props.pagination.reversed ? isTailVisible : isHeadVisible)(rootEl.value!, TOLERANCE); watch(visibility, () => { if (visibility.value === 'hidden') { @@ -371,11 +363,11 @@ watch(visibility, () => { BACKGROUND_PAUSE_WAIT_SEC * 1000); } else { // 'visible' if (timerForSetPause) { - clearTimeout(timerForSetPause); + window.clearTimeout(timerForSetPause); timerForSetPause = null; } else { isPausingUpdate = false; - if (isTop()) { + if (isHead()) { executeQueue(); } } @@ -387,16 +379,18 @@ watch(visibility, () => { * ストリーミングから降ってきたアイテムはこれで追加する * @param item アイテム */ -const prepend = (item: MisskeyEntity): void => { +function prepend(item: MisskeyEntity): void { if (items.value.size === 0) { items.value.set(item.id, item); fetching.value = false; return; } - if (isTop() && !isPausingUpdate) unshiftItems([item]); + if (_DEV_) console.log(isHead(), isPausingUpdate); + + if (isHead() && !isPausingUpdate) unshiftItems([item]); else prependQueue(item); -}; +} /** * 新着アイテムをitemsの先頭に追加し、displayLimitを適用する @@ -458,18 +452,18 @@ onDeactivated(() => { }); function toBottom() { - scrollToBottom(contentEl.value!); + scrollToBottom(rootEl.value!); } onBeforeMount(() => { init().then(() => { if (props.pagination.reversed) { nextTick(() => { - setTimeout(toBottom, 800); + window.setTimeout(toBottom, 800); // scrollToBottomでmoreFetchingボタンが画面外まで出るまで // more = trueを遅らせる - setTimeout(() => { + window.setTimeout(() => { moreFetching.value = false; }, 2000); }); @@ -479,11 +473,11 @@ onBeforeMount(() => { onBeforeUnmount(() => { if (timerForSetPause) { - clearTimeout(timerForSetPause); + window.clearTimeout(timerForSetPause); timerForSetPause = null; } if (preventAppearFetchMoreTimer.value) { - clearTimeout(preventAppearFetchMoreTimer.value); + window.clearTimeout(preventAppearFetchMoreTimer.value); preventAppearFetchMoreTimer.value = null; } scrollObserver.value?.disconnect(); diff --git a/packages/frontend/src/components/MkPasswordDialog.vue b/packages/frontend/src/components/MkPasswordDialog.vue index e749725fea..826081ffe5 100644 --- a/packages/frontend/src/components/MkPasswordDialog.vue +++ b/packages/frontend/src/components/MkPasswordDialog.vue @@ -13,9 +13,9 @@ SPDX-License-Identifier: AGPL-3.0-only > <template #header>{{ i18n.ts.authentication }}</template> - <MkSpacer :marginMin="20" :marginMax="28"> + <div class="_spacer" style="--MI_SPACER-min: 20px; --MI_SPACER-max: 28px;"> <div style="padding: 0 0 16px 0; text-align: center;"> - <img src="/fluent-emoji/1f510.png" alt="🔐" style="display: block; margin: 0 auto; width: 48px;"> + <img src="/client-assets/locked_with_key_3d.png" alt="🔐" style="display: block; margin: 0 auto; width: 48px;"> <div style="margin-top: 16px;">{{ i18n.ts.authenticationRequiredToContinue }}</div> </div> @@ -34,19 +34,19 @@ SPDX-License-Identifier: AGPL-3.0-only <MkButton :disabled="(password ?? '') == '' || ($i.twoFactorEnabled && (token ?? '') == '')" type="submit" primary rounded style="margin: 0 auto;"><i class="ti ti-lock-open"></i> {{ i18n.ts.continue }}</MkButton> </div> </form> - </MkSpacer> + </div> </MkModalWindow> </template> <script lang="ts" setup> -import { onMounted, shallowRef, ref } from 'vue'; +import { onMounted, useTemplateRef, ref } from 'vue'; import MkInput from '@/components/MkInput.vue'; import MkButton from '@/components/MkButton.vue'; import MkModalWindow from '@/components/MkModalWindow.vue'; import { i18n } from '@/i18n.js'; -import { signinRequired } from '@/account.js'; +import { ensureSignin } from '@/i.js'; -const $i = signinRequired(); +const $i = ensureSignin(); const emit = defineEmits<{ (ev: 'done', v: { password: string; token: string | null; }): void; @@ -54,8 +54,8 @@ const emit = defineEmits<{ (ev: 'cancelled'): void; }>(); -const dialog = shallowRef<InstanceType<typeof MkModalWindow>>(); -const passwordInput = shallowRef<InstanceType<typeof MkInput>>(); +const dialog = useTemplateRef('dialog'); +const passwordInput = useTemplateRef('passwordInput'); const password = ref(''); const isBackupCode = ref(false); const token = ref<string | null>(null); diff --git a/packages/frontend/src/components/MkPolkadots.vue b/packages/frontend/src/components/MkPolkadots.vue new file mode 100644 index 0000000000..285c4d0b79 --- /dev/null +++ b/packages/frontend/src/components/MkPolkadots.vue @@ -0,0 +1,40 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div :class="[$style.root, accented ? $style.accented : null]"></div> +</template> + +<script lang="ts" setup> +const props = withDefaults(defineProps<{ + accented?: boolean; +}>(), { + accented: false, +}); +</script> + +<style lang="scss" module> +.root { + --c: var(--MI_THEME-divider); + + &.accented { + --c: var(--MI_THEME-accent); + opacity: 0.5; + } + + --dot-size: 2px; + --gap-size: 40px; + --offset: calc(var(--gap-size) / 2); + + height: 200px; + margin-bottom: -200px; + + background-image: linear-gradient(transparent 60%, transparent 100%), radial-gradient(var(--c) var(--dot-size), transparent var(--dot-size)), radial-gradient(var(--c) var(--dot-size), transparent var(--dot-size)); + background-position: 0 0, 0 0, var(--offset) var(--offset); + background-size: 100% 100%, var(--gap-size) var(--gap-size), var(--gap-size) var(--gap-size); + mask-image: linear-gradient(to bottom, black 0%, transparent 100%); + pointer-events: none; +} +</style> diff --git a/packages/frontend/src/components/MkPoll.vue b/packages/frontend/src/components/MkPoll.vue index f6218de4c8..72f3ced088 100644 --- a/packages/frontend/src/components/MkPoll.vue +++ b/packages/frontend/src/components/MkPoll.vue @@ -33,15 +33,15 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, ref } from 'vue'; import * as Misskey from 'misskey-js'; -import { host } from '@@/js/config.js'; +import * as config from '@@/js/config.js'; import { useInterval } from '@@/js/use-interval.js'; -import type { OpenOnRemoteOptions } from '@/scripts/please-login.js'; -import { sum } from '@/scripts/array.js'; -import { pleaseLogin } from '@/scripts/please-login.js'; +import type { OpenOnRemoteOptions } from '@/utility/please-login.js'; +import { sum } from '@/utility/array.js'; +import { pleaseLogin } from '@/utility/please-login.js'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; const props = defineProps<{ noteId: string; @@ -72,7 +72,7 @@ const showResult = ref(props.readOnly || isVoted.value); const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({ type: 'lookup', - url: `https://${host}/notes/${props.noteId}`, + url: `${config.url}/notes/${props.noteId}`, })); // 期限付きアンケート diff --git a/packages/frontend/src/components/MkPollEditor.vue b/packages/frontend/src/components/MkPollEditor.vue index 3726ddf822..22fe189a63 100644 --- a/packages/frontend/src/components/MkPollEditor.vue +++ b/packages/frontend/src/components/MkPollEditor.vue @@ -58,8 +58,8 @@ import MkInput from './MkInput.vue'; import MkSelect from './MkSelect.vue'; import MkSwitch from './MkSwitch.vue'; import MkButton from './MkButton.vue'; -import { formatDateTimeString } from '@/scripts/format-time-string.js'; -import { addTime } from '@/scripts/time.js'; +import { formatDateTimeString } from '@/utility/format-time-string.js'; +import { addTime } from '@/utility/time.js'; import { i18n } from '@/i18n.js'; export type PollEditorModelValue = { diff --git a/packages/frontend/src/components/MkPopupMenu.vue b/packages/frontend/src/components/MkPopupMenu.vue index f1b5ff4de0..be5927c536 100644 --- a/packages/frontend/src/components/MkPopupMenu.vue +++ b/packages/frontend/src/components/MkPopupMenu.vue @@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ref, shallowRef } from 'vue'; +import { ref, useTemplateRef } from 'vue'; import MkModal from './MkModal.vue'; import MkMenu from './MkMenu.vue'; import type { MenuItem } from '@/types/menu.js'; @@ -28,7 +28,7 @@ const emit = defineEmits<{ (ev: 'closing'): void; }>(); -const modal = shallowRef<InstanceType<typeof MkModal>>(); +const modal = useTemplateRef('modal'); const manualShowing = ref(true); const hiding = ref(false); diff --git a/packages/frontend/src/components/MkPostForm.TextCounter.vue b/packages/frontend/src/components/MkPostForm.TextCounter.vue new file mode 100644 index 0000000000..b1d39df5d3 --- /dev/null +++ b/packages/frontend/src/components/MkPostForm.TextCounter.vue @@ -0,0 +1,95 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div :class="[$style.textCountRoot]"> + <div :class="$style.textCountLabel">{{ i18n.ts.textCount }}</div> + <div + :class="[$style.textCount, + { [$style.danger]: textCountPercentage > 100 }, + { [$style.warning]: textCountPercentage > 90 && textCountPercentage <= 100 }, + ]" + > + <div :class="$style.textCountGraph"></div> + <div><span :class="$style.textCountCurrent">{{ number(textLength) }}</span> / {{ number(maxTextLength) }}</div> + </div> +</div> +</template> + +<script lang="ts" setup> +import { computed, useTemplateRef } from 'vue'; +import { instance } from '@/instance.js'; +import { i18n } from '@/i18n.js'; +import number from '@/filters/number.js'; + +const props = defineProps<{ + textLength: number; +}>(); + +const maxTextLength = computed(() => { + return instance ? instance.maxNoteTextLength : 1000; +}); + +const textCountPercentage = computed(() => { + return props.textLength / maxTextLength.value * 100; +}); +</script> + +<style lang="scss" module> +.textCountRoot { + padding: 4px 14px; +} + +.textCountLabel { + font-size: 11px; + opacity: 0.8; + margin-bottom: 4px; +} + +.textCount { + display: flex; + gap: var(--MI-marginHalf); + align-items: center; + font-size: 12px; + --countColor: var(--MI_THEME-accent); + + &.danger { + --countColor: var(--MI_THEME-error); + } + + &.warning { + --countColor: var(--MI_THEME-warn); + } + + .textCountGraph { + position: relative; + width: 24px; + height: 24px; + border-radius: 50%; + background-image: conic-gradient( + var(--countColor) 0% v-bind("Math.min(100, textCountPercentage) + '%'"), + rgba(0, 0, 0, .2) v-bind("Math.min(100, textCountPercentage) + '%'") 100% + ); + + &::after { + content: ''; + position: absolute; + width: 16px; + height: 16px; + border-radius: 50%; + background-color: var(--MI_THEME-popup); + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + } + } + + .textCountCurrent { + color: var(--countColor); + font-weight: 700; + font-size: 18px; + } +} +</style> diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index ca227d649a..000ccf50bf 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -20,7 +20,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <div :class="$style.headerRight"> <template v-if="!(channel != null && fixed)"> - <button v-if="channel == null" ref="visibilityButton" v-click-anime v-tooltip="i18n.ts.visibility" :class="['_button', $style.headerRightItem, $style.visibility]" :disabled="editId != null" @click="setVisibility"> + <button v-if="channel == null" ref="visibilityButton" v-tooltip="i18n.ts.visibility" :class="['_button', $style.headerRightItem, $style.visibility]" :disabled="editId != null" @click="setVisibility"> <span v-if="visibility === 'public'"><i class="ti ti-world"></i></span> <span v-if="visibility === 'home'"><i class="ti ti-home"></i></span> <span v-if="visibility === 'followers'"><i class="ti ti-lock"></i></span> @@ -32,11 +32,12 @@ SPDX-License-Identifier: AGPL-3.0-only <span :class="$style.headerRightButtonText">{{ channel.name }}</span> </button> </template> - <button v-click-anime v-tooltip="i18n.ts._visibility.disableFederation" class="_button" :class="[$style.headerRightItem, { [$style.danger]: localOnly }]" :disabled="channel != null || visibility === 'specified' || editId != null" @click="toggleLocalOnly"> + <button v-tooltip="i18n.ts._visibility.disableFederation" class="_button" :class="[$style.headerRightItem, { [$style.danger]: localOnly }]" :disabled="channel != null || visibility === 'specified' || editId != null" @click="toggleLocalOnly"> <span v-if="!localOnly"><i class="ti ti-rocket"></i></span> <span v-else><i class="ti ti-rocket-off"></i></span> </button> - <button v-click-anime v-tooltip="i18n.ts.reactionAcceptance" class="_button" :class="[$style.headerRightItem, { [$style.danger]: reactionAcceptance === 'likeOnly' }]" @click="toggleReactionAcceptance"> + <button ref="otherSettingsButton" v-tooltip="i18n.ts.other" class="_button" :class="$style.headerRightItem" @click="showOtherSettings"><i class="ti ti-dots"></i></button> + <button v-tooltip="i18n.ts.reactionAcceptance" class="_button" :class="[$style.headerRightItem, { [$style.danger]: reactionAcceptance === 'likeOnly' }]" @click="toggleReactionAcceptance"> <span v-if="reactionAcceptance === 'likeOnly'"><i class="ti ti-heart"></i></span> <span v-else-if="reactionAcceptance === 'likeOnlyForRemote'"><i class="ti ti-heart-plus"></i></span> <span v-else><i class="ph-smiley ph-bold ph-lg"></i></span> @@ -65,9 +66,9 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> <MkInfo v-if="hasNotSpecifiedMentions" warn :class="$style.hasNotSpecifiedMentions">{{ i18n.ts.notSpecifiedMentionWarning }} - <button class="_textButton" @click="addMissingMention()">{{ i18n.ts.add }}</button></MkInfo> - <div v-show="useCw" :class="$style.cwFrame"> + <div v-show="useCw" :class="$style.cwOuter"> <input ref="cwInputEl" v-model="cw" :class="$style.cw" :placeholder="i18n.ts.annotation" @keydown="onKeydown" @keyup="onKeyup" @compositionend="onCompositionEnd"> - <div v-if="maxCwLength - cwLength < 100" :class="['_acrylic', $style.textCount, { [$style.textOver]: cwLength > maxCwLength }]">{{ maxCwLength - cwLength }}</div> + <div v-if="maxCwTextLength - cwTextLength < 20" :class="['_acrylic', $style.cwTextCount, { [$style.cwTextOver]: cwTextLength > maxCwTextLength }]">{{ maxCwTextLength - cwTextLength }}</div> </div> <div :class="[$style.textOuter, { [$style.withCw]: useCw }]"> <div v-if="channel" :class="$style.colorBar" :style="{ background: channel.color }"></div> @@ -91,7 +92,6 @@ SPDX-License-Identifier: AGPL-3.0-only <button v-if="postFormActions.length > 0" v-tooltip="i18n.ts.plugins" class="_button" :class="$style.footerButton" @click="showActions"><i class="ti ti-plug"></i></button> <button v-tooltip="i18n.ts.emoji" :class="['_button', $style.footerButton]" @click="insertEmoji"><i class="ti ti-mood-happy"></i></button> <button v-if="showAddMfmFunction" v-tooltip="i18n.ts.addMfmFunction" :class="['_button', $style.footerButton]" @click="insertMfmFunction"><i class="ti ti-palette"></i></button> - <button v-tooltip="i18n.ts.otherSettings" :class="['_button', $style.footerButton]" @click="showOtherMenu"><i class="ti ti-dots"></i></button> </div> <div :class="$style.footerRight"> <button v-tooltip="i18n.ts.previewNoteText" class="_button" :class="[$style.footerButton, { [$style.previewButtonActive]: showPreview }]" @click="showPreview = !showPreview"><i class="ti ti-eye"></i></button> @@ -106,43 +106,50 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { inject, watch, nextTick, onMounted, defineAsyncComponent, provide, shallowRef, ref, computed, toRaw, type ShallowRef } from 'vue'; +import { inject, watch, nextTick, onMounted, defineAsyncComponent, provide, shallowRef, ref, computed, useTemplateRef, 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.js'; import { host, url } from '@@/js/config.js'; import { appendContentWarning } from '@@/js/append-content-warning.js'; +import type { ShallowRef } from 'vue'; import type { MenuItem } from '@/types/menu.js'; import type { PostFormProps } from '@/types/post-form.js'; -import MkNoteSimple from '@/components/MkNoteSimple.vue'; +import type { PollEditorModelValue } from '@/components/MkPollEditor.vue'; import MkNotePreview from '@/components/MkNotePreview.vue'; import XPostFormAttaches from '@/components/MkPostFormAttaches.vue'; -import MkPollEditor, { type PollEditorModelValue } from '@/components/MkPollEditor.vue'; -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 XTextCounter from '@/components/MkPostForm.TextCounter.vue'; +import MkPollEditor from '@/components/MkPollEditor.vue'; +import MkNoteSimple from '@/components/MkNoteSimple.vue'; +import { erase, unique } from '@/utility/array.js'; +import { extractMentions } from '@/utility/extract-mentions.js'; +import { formatTimeString } from '@/utility/format-time-string.js'; +import { Autocomplete } from '@/utility/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 { misskeyApi } from '@/utility/misskey-api.js'; +import { selectFiles } from '@/utility/select-file.js'; +import { store } from '@/store.js'; import MkInfo from '@/components/MkInfo.vue'; import { i18n } from '@/i18n.js'; import { instance } from '@/instance.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 { ensureSignin, notesCount, incNotesCount } from '@/i.js'; +import { getAccounts, openAccountMenu as openAccountMenu_ } from '@/accounts.js'; +import { uploadFile } from '@/utility/upload.js'; +import { deepClone } from '@/utility/clone.js'; import MkRippleEffect from '@/components/MkRippleEffect.vue'; -import { miLocalStorage } from '@/local-storage.js'; -import { claimAchievement } from '@/scripts/achievements.js'; -import { emojiPicker } from '@/scripts/emoji-picker.js'; -import { mfmFunctionPicker } from '@/scripts/mfm-function-picker.js'; import MkScheduleEditor from '@/components/MkScheduleEditor.vue'; +import { miLocalStorage } from '@/local-storage.js'; +import { claimAchievement } from '@/utility/achievements.js'; +import { emojiPicker } from '@/utility/emoji-picker.js'; +import { mfmFunctionPicker } from '@/utility/mfm-function-picker.js'; +import { prefer } from '@/preferences.js'; +import { getPluginHandlers } from '@/plugin.js'; +import { DI } from '@/di.js'; -const $i = signinRequired(); +const $i = ensureSignin(); -const modal = inject('modal'); +const modal = inject(DI.inModal, false); const props = withDefaults(defineProps<PostFormProps & { fixed?: boolean; @@ -157,7 +164,7 @@ const props = withDefaults(defineProps<PostFormProps & { initialLocalOnly: undefined, }); -provide('mock', props.mock); +provide(DI.mock, props.mock); const emit = defineEmits<{ (ev: 'posted'): void; @@ -168,30 +175,31 @@ const emit = defineEmits<{ (ev: 'fileChangeSensitive', fileId: string, to: boolean): void; }>(); -const textareaEl = shallowRef<HTMLTextAreaElement | null>(null); -const cwInputEl = shallowRef<HTMLInputElement | null>(null); -const hashtagsInputEl = shallowRef<HTMLInputElement | null>(null); -const visibilityButton = shallowRef<HTMLElement>(); +const textareaEl = useTemplateRef('textareaEl'); +const cwInputEl = useTemplateRef('cwInputEl'); +const hashtagsInputEl = useTemplateRef('hashtagsInputEl'); +const visibilityButton = useTemplateRef('visibilityButton'); +const otherSettingsButton = useTemplateRef('otherSettingsButton'); const posting = ref(false); const posted = ref(false); const text = ref(props.initialText ?? ''); const files = ref(props.initialFiles ?? []); const poll = ref<PollEditorModelValue | null>(null); +const initialPoll = ref<PollEditorModelValue | null>(null); const useCw = ref<boolean>(!!props.initialCw); -const showPreview = ref(defaultStore.state.showPreview); -watch(showPreview, () => defaultStore.set('showPreview', showPreview.value)); -const showAddMfmFunction = ref(defaultStore.state.enableQuickAddMfmFunction); -watch(showAddMfmFunction, () => defaultStore.set('enableQuickAddMfmFunction', showAddMfmFunction.value)); +const showPreview = ref(store.s.showPreview); +watch(showPreview, () => store.set('showPreview', showPreview.value)); +const showAddMfmFunction = ref(prefer.s.enableQuickAddMfmFunction); +watch(showAddMfmFunction, () => prefer.commit('enableQuickAddMfmFunction', showAddMfmFunction.value)); const cw = ref<string | null>(props.initialCw ?? null); -const localOnly = ref(props.initialLocalOnly ?? (defaultStore.state.rememberNoteVisibility ? defaultStore.state.localOnly : defaultStore.state.defaultNoteLocalOnly)); -const visibility = ref(props.initialVisibility ?? (defaultStore.state.rememberNoteVisibility ? defaultStore.state.visibility : defaultStore.state.defaultNoteVisibility)); +const localOnly = ref(props.initialLocalOnly ?? (prefer.s.rememberNoteVisibility ? store.s.localOnly : prefer.s.defaultNoteLocalOnly)); +const visibility = ref(props.initialVisibility ?? (prefer.s.rememberNoteVisibility ? store.s.visibility : prefer.s.defaultNoteVisibility)); const visibleUsers = ref<Misskey.entities.UserDetailed[]>([]); if (props.initialVisibleUsers) { props.initialVisibleUsers.forEach(u => pushVisibleUser(u)); } -const reactionAcceptance = ref(defaultStore.state.reactionAcceptance); -const autocomplete = ref(null); +const reactionAcceptance = ref(store.s.reactionAcceptance); const draghover = ref(false); const quoteId = ref<string | null>(null); const hasNotSpecifiedMentions = ref(false); @@ -204,6 +212,7 @@ const scheduleNote = ref<{ scheduledAt: number | null; } | null>(null); const renoteTargetNote: ShallowRef<PostFormProps['renote'] | null> = shallowRef(props.renote); +const postFormActions = getPluginHandlers('post_form_action'); const draftKey = computed((): string => { let key = props.channel ? `channel:${props.channel.id}` : ''; @@ -255,8 +264,11 @@ const maxTextLength = computed((): number => { return instance ? instance.maxNoteTextLength : 1000; }); -const cwLength = computed(() => cw.value?.length ?? 0); -const maxCwLength = computed(() => instance.maxCwLength); +const cwTextLength = computed((): number => { + return cw.value?.length ?? 0; +}); + +const maxCwTextLength = computed(() => instance.maxCwLength); const canPost = computed((): boolean => { return !props.mock && !posting.value && !posted.value && @@ -268,13 +280,19 @@ const canPost = computed((): boolean => { quoteId.value != null ) && (textLength.value <= maxTextLength.value) && - (cwLength.value <= maxCwLength.value) && + ( + useCw.value ? + ( + cw.value != null && cw.value.trim() !== '' && + cwTextLength.value <= maxCwTextLength.value + ) : true + ) && (files.value.length <= 16) && (!poll.value || poll.value.choices.length >= 2); }); -const withHashtags = computed(defaultStore.makeGetterSetter('postFormWithHashtags')); -const hashtags = computed(defaultStore.makeGetterSetter('postFormHashtags')); +const withHashtags = computed(store.makeGetterSetter('postFormWithHashtags')); +const hashtags = computed(store.makeGetterSetter('postFormHashtags')); watch(text, () => { checkMissingMention(); @@ -299,19 +317,10 @@ if (props.reply && (props.reply.user.username !== $i.username || (props.reply.us text.value = `@${props.reply.user.username}${props.reply.user.host != null ? '@' + toASCII(props.reply.user.host) : ''} `; } -if (props.reply && props.reply.text != null) { - const ast = mfm.parse(props.reply.text); - const otherHost = props.reply.user.host; - - for (const x of extractMentions(ast)) { - const mention = x.host ? - `@${x.username}@${toASCII(x.host)}` : - (otherHost == null || otherHost === host) ? - `@${x.username}` : - `@${x.username}@${toASCII(otherHost)}`; - - // 自分は除外 - if ($i.username === x.username && (x.host == null || x.host === host)) continue; +if (props.reply && props.reply.mentionHandles) { + for (const [user, mention] of Object.entries(props.reply.mentionHandles)) { + // Don't mention ourself + if (user === $i.id) continue; // 重複は除外 if (text.value.includes(`${mention} `)) continue; @@ -362,7 +371,7 @@ if (props.specified) { } // keep cw when reply -if (defaultStore.state.keepCw && props.reply && props.reply.cw) { +if (prefer.s.keepCw && props.reply && props.reply.cw) { useCw.value = true; cw.value = props.reply.cw; } @@ -407,7 +416,7 @@ function checkMissingMention() { const ast = mfm.parse(text.value); for (const x of extractMentions(ast)) { - if (!visibleUsers.value.some(u => (u.username === x.username) && (u.host === x.host))) { + if (!visibleUsers.value.some(u => (u.username.toLowerCase() === x.username.toLowerCase()) && (u.host === x.host))) { hasNotSpecifiedMentions.value = true; return; } @@ -420,7 +429,7 @@ function addMissingMention() { const ast = mfm.parse(text.value); for (const x of extractMentions(ast)) { - if (!visibleUsers.value.some(u => (u.username === x.username) && (u.host === x.host))) { + if (!visibleUsers.value.some(u => (u.username.toLowerCase() === x.username.toLowerCase()) && (u.host === x.host))) { misskeyApi('users/show', { username: x.username, host: x.host }).then(user => { pushVisibleUser(user); }); @@ -484,7 +493,7 @@ function replaceFile(file: Misskey.entities.DriveFile, newFile: Misskey.entities function upload(file: File, name?: string): void { if (props.mock) return; - uploadFile(file, defaultStore.state.uploadFolder, name).then(res => { + uploadFile(file, prefer.s.uploadFolder, name).then(res => { files.value.push(res); }); } @@ -505,8 +514,8 @@ function setVisibility() { }, { changeVisibility: v => { visibility.value = v; - if (defaultStore.state.rememberNoteVisibility) { - defaultStore.set('visibility', visibility.value); + if (prefer.s.rememberNoteVisibility) { + store.set('visibility', visibility.value); } }, closed: () => dispose(), @@ -553,8 +562,8 @@ async function toggleLocalOnly() { } localOnly.value = !localOnly.value; - if (defaultStore.state.rememberNoteVisibility) { - defaultStore.set('localOnly', localOnly.value); + if (prefer.s.rememberNoteVisibility) { + store.set('localOnly', localOnly.value); } } @@ -574,8 +583,69 @@ async function toggleReactionAcceptance() { reactionAcceptance.value = select.result; } +//#region その他の設定メニューpopup +function showOtherSettings() { + let reactionAcceptanceIcon = 'ti ti-icons'; + + if (reactionAcceptance.value === 'likeOnly') { + reactionAcceptanceIcon = 'ti ti-heart _love'; + } else if (reactionAcceptance.value === 'likeOnlyForRemote') { + reactionAcceptanceIcon = 'ti ti-heart-plus'; + } + + const menuItems = [{ + type: 'component', + component: XTextCounter, + props: { + textLength: textLength, + }, + }, { type: 'divider' }, { + icon: reactionAcceptanceIcon, + text: i18n.ts.reactionAcceptance, + action: () => { + toggleReactionAcceptance(); + }, + }, { type: 'divider' }, { + icon: 'ti ti-trash', + text: i18n.ts.reset, + danger: true, + action: async () => { + if (props.mock) return; + const { canceled } = await os.confirm({ + type: 'question', + text: i18n.ts.resetAreYouSure, + }); + if (canceled) return; + clear(); + }, + }] satisfies MenuItem[]; + + if ($i.policies.scheduleNoteMax > 0) { + menuItems.push({ type: 'divider' }, { + type: 'button', + text: i18n.ts.schedulePost, + icon: 'ti ti-calendar-time', + action: toggleScheduleNote, + }, { + type: 'button', + text: i18n.ts.schedulePostList, + icon: 'ti ti-calendar-event', + action: () => { + const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkSchedulePostListDialog.vue')), {}, { + closed: () => { + dispose(); + }, + }); + }, + }); + } + + os.popupMenu(menuItems, otherSettingsButton.value); +} +//#endregion + function pushVisibleUser(user: Misskey.entities.UserDetailed) { - if (!visibleUsers.value.some(u => u.username === user.username && u.host === user.host)) { + if (!visibleUsers.value.some(u => u.username.toLowerCase() === user.username.toLowerCase() && u.host === user.host)) { visibleUsers.value.push(user); } } @@ -623,6 +693,8 @@ function onCompositionEnd(ev: CompositionEvent) { justEndedComposition.value = true; } +const pastedFileName = 'yyyy-MM-dd HH-mm-ss [{{number}}]'; + async function onPaste(ev: ClipboardEvent) { if (props.mock) return; if (!ev.clipboardData) return; @@ -633,7 +705,7 @@ async function onPaste(ev: ClipboardEvent) { 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}`; + const formatted = `${formatTimeString(new Date(file.lastModified), pastedFileName).replace(/{{number}}/g, `${i + 1}`)}${ext}`; upload(file, formatted); } } @@ -678,7 +750,7 @@ async function onPaste(ev: ClipboardEvent) { return; } - const fileName = formatTimeString(new Date(), defaultStore.state.pastedFileName).replace(/{{number}}/g, '0'); + const fileName = formatTimeString(new Date(), pastedFileName).replace(/{{number}}/g, '0'); const file = new File([paste], `${fileName}.txt`, { type: 'text/plain' }); upload(file, `${fileName}.txt`); }); @@ -772,19 +844,19 @@ function deleteDraft() { miLocalStorage.setItem('drafts', JSON.stringify(draftData)); } -async function post(ev?: MouseEvent) { - if (useCw.value && (cw.value == null || cw.value.trim() === '')) { - os.alert({ - type: 'error', - text: i18n.ts.cwNotationRequired, - }); - return; - } +function isAnnoying(text: string): boolean { + return text.includes('$[x2') || + text.includes('$[x3') || + text.includes('$[x4') || + text.includes('$[scale') || + text.includes('$[position'); +} +async function post(ev?: MouseEvent) { if (ev) { const el = (ev.currentTarget ?? ev.target) as HTMLElement | null; - if (el) { + if (el && prefer.s.animation) { const rect = el.getBoundingClientRect(); const x = rect.left + (el.offsetWidth / 2); const y = rect.top + (el.offsetHeight / 2); @@ -796,14 +868,10 @@ async function post(ev?: MouseEvent) { if (props.mock) return; - const annoying = - text.value.includes('$[x2') || - text.value.includes('$[x3') || - text.value.includes('$[x4') || - text.value.includes('$[scale') || - text.value.includes('$[position'); - - if (annoying && visibility.value === 'public') { + if (visibility.value === 'public' && ( + (useCw.value && cw.value != null && cw.value.trim() !== '' && isAnnoying(cw.value)) || // CWが迷惑になる場合 + ((!useCw.value || cw.value == null || cw.value.trim() === '') && text.value != null && text.value.trim() !== '' && isAnnoying(text.value)) // CWが無い かつ 本文が迷惑になる場合 + )) { const { canceled, result } = await os.actions({ type: 'warning', text: i18n.ts.thisPostMayBeAnnoying, @@ -827,7 +895,7 @@ async function post(ev?: MouseEvent) { } } - if (defaultStore.state.warnMissingAltText) { + if (prefer.s.warnMissingAltText) { const filesData = toRaw(files.value); const isMissingAltText = filesData.filter( @@ -884,6 +952,7 @@ async function post(ev?: MouseEvent) { } // plugin + const notePostInterruptors = getPluginHandlers('note_post_interruptor'); if (notePostInterruptors.length > 0) { for (const interruptor of notePostInterruptors) { try { @@ -894,13 +963,22 @@ async function post(ev?: MouseEvent) { } } - let token: string | undefined = undefined; + let token: string | null | undefined = undefined; if (postAccount.value) { const storedAccounts = await getAccounts(); token = storedAccounts.find(x => x.id === postAccount.value?.id)?.token; } + if (postData.editId && postData.poll !== initialPoll.value) { + const { canceled } = await os.confirm({ + type: 'warning', + title: i18n.ts._confirmPollEdit.title, + text: i18n.ts._confirmPollEdit.text, + }); + if (canceled) return; + } + posting.value = true; misskeyApi(postData.editId ? 'notes/edit' : (postData.scheduleNote ? 'notes/schedule/create' : 'notes/create'), postData, token).then(() => { if (props.freezeAfterPosted) { @@ -1063,32 +1141,6 @@ function toggleScheduleNote() { } } -function showOtherMenu(ev: MouseEvent) { - const menuItems: MenuItem[] = []; - - if ($i.policies.scheduleNoteMax > 0) { - menuItems.push({ - type: 'button', - text: i18n.ts.schedulePost, - icon: 'ti ti-calendar-time', - action: toggleScheduleNote, - }, { - type: 'button', - text: i18n.ts.schedulePostList, - icon: 'ti ti-calendar-event', - action: () => { - const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkSchedulePostListDialog.vue')), {}, { - closed: () => { - dispose(); - }, - }); - }, - }); - } - - os.popupMenu(menuItems, ev.currentTarget ?? ev.target); -} - onMounted(() => { if (props.autofocus) { focus(); @@ -1150,6 +1202,7 @@ onMounted(() => { expiresAt: init.poll.expiresAt ? (new Date(init.poll.expiresAt)).getTime() : null, expiredAfter: null, }; + if (props.editId) initialPoll.value = poll.value; } if (init.visibleUserIds) { misskeyApi('users/show', { userIds: init.visibleUserIds }).then(users => { @@ -1163,6 +1216,7 @@ onMounted(() => { scheduledAt: new Date(init.createdAt).getTime(), }; } + saveDraft(); } nextTick(() => watchForDraft()); @@ -1182,6 +1236,8 @@ defineExpose({ &.modal { width: 100%; max-width: 520px; + overflow-x: clip; + overflow-y: auto; } } @@ -1292,7 +1348,7 @@ defineExpose({ border-radius: var(--MI-radius-sm); &:hover { - background: var(--MI_THEME-X5); + background: light-dark(rgba(0, 0, 0, 0.05), rgba(255, 255, 255, 0.05)); } &:disabled { @@ -1356,7 +1412,7 @@ defineExpose({ margin-right: 14px; padding: 8px 0 8px 8px; border-radius: var(--MI-radius-sm); - background: var(--MI_THEME-X4); + background: light-dark(rgba(0, 0, 0, 0.1), rgba(255, 255, 255, 0.1)); } .hasNotSpecifiedMentions { @@ -1371,7 +1427,7 @@ defineExpose({ padding: 0 24px; margin: 0; width: 100%; - font-size: 16px; + font-size: 110%; border: none; border-radius: 0; background: transparent; @@ -1387,7 +1443,12 @@ defineExpose({ } } -.cwFrame { +.cwOuter { + width: 100%; + position: relative; +} + +.cw { z-index: 1; padding-bottom: 8px; border-bottom: solid 0.5px var(--MI_THEME-divider); @@ -1396,6 +1457,23 @@ defineExpose({ position: relative; } +.cwTextCount { + position: absolute; + top: 0; + right: 2px; + padding: 2px 6px; + font-size: .9em; + color: var(--MI_THEME-warn); + border-radius: 6px; + max-width: 100%; + min-width: 1.6em; + text-align: center; + + &.cwTextOver { + color: #ff2a2a; + } +} + .hashtags { z-index: 1; padding-top: 8px; @@ -1470,7 +1548,7 @@ defineExpose({ border-radius: var(--MI-radius-sm); &:hover { - background: var(--MI_THEME-X5); + background: light-dark(rgba(0, 0, 0, 0.05), rgba(255, 255, 255, 0.05)); } &.footerButtonActive { diff --git a/packages/frontend/src/components/MkPostFormAttaches.vue b/packages/frontend/src/components/MkPostFormAttaches.vue index bab7d22112..d7747413f6 100644 --- a/packages/frontend/src/components/MkPostFormAttaches.vue +++ b/packages/frontend/src/components/MkPostFormAttaches.vue @@ -22,20 +22,27 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </template> </Sortable> - <p :class="[$style.remain, { - [$style.exceeded]: props.modelValue.length > 16, - }]">{{ 16 - props.modelValue.length }}/16</p> + <p + :class="[$style.remain, { + [$style.exceeded]: props.modelValue.length > 16, + }]" + > + {{ props.modelValue.length }}/16 + </p> </div> </template> <script lang="ts" setup> import { defineAsyncComponent, inject } from 'vue'; import * as Misskey from 'misskey-js'; +import type { MenuItem } from '@/types/menu'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import type { MenuItem } from '@/types/menu.js'; +import { prefer } from '@/preferences.js'; +import { DI } from '@/di.js'; const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default)); @@ -44,7 +51,7 @@ const props = defineProps<{ detachMediaFn?: (id: string) => void; }>(); -const mock = inject<boolean>('mock', false); +const mock = inject(DI.mock, false); const emit = defineEmits<{ (ev: 'update:modelValue', value: Misskey.entities.DriveFile[]): void; @@ -168,6 +175,14 @@ function showFileMenu(file: Misskey.entities.DriveFile, ev: MouseEvent | Keyboar text: i18n.ts.cropImage, icon: 'ti ti-crop', action: () : void => { crop(file); }, + }, { + text: i18n.ts.preview, + icon: 'ti ti-photo-search', + action: () => { + os.popup(defineAsyncComponent(() => import('@/components/MkImgPreviewDialog.vue')), { + file: file, + }); + }, }); } @@ -184,6 +199,16 @@ function showFileMenu(file: Misskey.entities.DriveFile, ev: MouseEvent | Keyboar action: () => { detachAndDeleteMedia(file); }, }); + if (prefer.s.devMode) { + menuItems.push({ type: 'divider' }, { + icon: 'ti ti-hash', + text: i18n.ts.copyFileId, + action: () => { + copyToClipboard(file.id); + }, + }); + } + os.popupMenu(menuItems, 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 0fd17e12c7..aa3eebb257 100644 --- a/packages/frontend/src/components/MkPostFormDialog.vue +++ b/packages/frontend/src/components/MkPostFormDialog.vue @@ -4,17 +4,32 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkModal ref="modal" :preferType="'dialog'" @click="modal?.close()" @closed="onModalClosed()" @esc="modal?.close()"> - <MkPostForm ref="form" :class="$style.form" v-bind="props" autofocus freezeAfterPosted @posted="onPosted" @cancel="onCancel" @esc="onCancel"/> +<MkModal + ref="modal" + :preferType="'dialog'" + @click="modal?.close()" + @closed="onModalClosed()" + @esc="modal?.close()" +> + <MkPostForm + ref="form" + :class="$style.form" + v-bind="props" + autofocus + freezeAfterPosted + @posted="onPosted" + @cancel="onCancel" + @esc="onCancel" + /> </MkModal> </template> <script lang="ts" setup> -import { shallowRef } from 'vue'; +import { useTemplateRef } from 'vue'; +import type { PostFormProps } from '@/types/post-form.js'; import MkModal from '@/components/MkModal.vue'; import MkPostForm from '@/components/MkPostForm.vue'; import * as Misskey from 'misskey-js'; -import type { PostFormProps } from '@/types/post-form.js'; const props = withDefaults(defineProps<PostFormProps & { instant?: boolean; @@ -29,8 +44,7 @@ const emit = defineEmits<{ (ev: 'closed', cancelled: boolean): void; }>(); -const modal = shallowRef<InstanceType<typeof MkModal>>(); -const form = shallowRef<InstanceType<typeof MkPostForm>>(); +const modal = useTemplateRef('modal'); function onPosted() { modal.value?.close({ diff --git a/packages/frontend/src/components/MkPreferenceContainer.vue b/packages/frontend/src/components/MkPreferenceContainer.vue new file mode 100644 index 0000000000..70b111513c --- /dev/null +++ b/packages/frontend/src/components/MkPreferenceContainer.vue @@ -0,0 +1,103 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div :class="$style.root" @contextmenu.prevent.stop="showMenu($event, true)"> + <div :class="$style.body"> + <slot></slot> + </div> + <div :class="$style.menu"> + <i v-if="isSyncEnabled" class="ti ti-cloud-cog" style="color: var(--MI_THEME-accent); opacity: 0.7;"></i> + <i v-if="isAccountOverrided" class="ti ti-user-cog" style="color: var(--MI_THEME-accent); opacity: 0.7;"></i> + <div :class="$style.buttons"> + <button class="_button" style="color: var(--MI_THEME-fg)" @click="showMenu($event)"><i class="ti ti-dots"></i></button> + </div> + </div> +</div> +</template> + +<script lang="ts" setup> +import { ref } from 'vue'; +import type { PREF_DEF } from '@/preferences/def.js'; +import * as os from '@/os.js'; +import { prefer } from '@/preferences.js'; + +const props = withDefaults(defineProps<{ + k: keyof typeof PREF_DEF; +}>(), { +}); + +const isAccountOverrided = ref(prefer.isAccountOverrided(props.k)); +const isSyncEnabled = ref(prefer.isSyncEnabled(props.k)); + +function showMenu(ev: MouseEvent, contextmenu?: boolean) { + const i = window.setInterval(() => { + isAccountOverrided.value = prefer.isAccountOverrided(props.k); + isSyncEnabled.value = prefer.isSyncEnabled(props.k); + }, 100); + if (contextmenu) { + os.contextMenu(prefer.getPerPrefMenu(props.k), ev).then(() => { + window.clearInterval(i); + }); + } else { + os.popupMenu(prefer.getPerPrefMenu(props.k), ev.currentTarget ?? ev.target, { + onClosing: () => { + window.clearInterval(i); + }, + }); + } +} +</script> + +<style lang="scss" module> +.root { + position: relative; + display: flex; + + &:hover { + &::before { + content: ''; + position: absolute; + top: -8px; + left: -8px; + width: calc(100% + 16px); + height: calc(100% + 16px); + border-radius: 8px; + background: light-dark(rgba(0, 0, 0, 0.02), rgba(255, 255, 255, 0.02)); + pointer-events: none; + } + + .menu { + .buttons { + opacity: 0.7; + } + } + } + + .body { + flex: 1; + } + + .menu { + display: flex; + gap: 8px; + align-items: center; + margin-left: 12px; + font-size: 12px; + padding-left: 8px; + border-left: solid 1px var(--MI_THEME-divider); + + &:hover { + .buttons { + opacity: 1; + } + } + + .buttons { + opacity: 0.3; + } + } +} +</style> diff --git a/packages/frontend/src/components/MkPreview.vue b/packages/frontend/src/components/MkPreview.vue index 6efd99d14b..d8dfbd1655 100644 --- a/packages/frontend/src/components/MkPreview.vue +++ b/packages/frontend/src/components/MkPreview.vue @@ -43,7 +43,7 @@ import MkTextarea from '@/components/MkTextarea.vue'; import MkRadio from '@/components/MkRadio.vue'; import * as os from '@/os.js'; import * as config from '@@/js/config.js'; -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; const text = ref(''); const flag = ref(true); diff --git a/packages/frontend/src/components/MkPullToRefresh.vue b/packages/frontend/src/components/MkPullToRefresh.vue index 4fb4c6fe56..22ae563d13 100644 --- a/packages/frontend/src/components/MkPullToRefresh.vue +++ b/packages/frontend/src/components/MkPullToRefresh.vue @@ -16,17 +16,16 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> </div> - <div :class="{ [$style.slotClip]: isPullStart }"> - <slot/> - </div> + + <slot/> </div> </template> <script lang="ts" setup> -import { onMounted, onUnmounted, ref, shallowRef } from 'vue'; -import { i18n } from '@/i18n.js'; +import { onMounted, onUnmounted, ref, useTemplateRef } from 'vue'; import { getScrollContainer } from '@@/js/scroll.js'; -import { isHorizontalSwipeSwiping } from '@/scripts/touch.js'; +import { i18n } from '@/i18n.js'; +import { isHorizontalSwipeSwiping } from '@/utility/touch.js'; const SCROLL_STOP = 10; const MAX_PULL_DISTANCE = Infinity; @@ -43,7 +42,7 @@ const pullDistance = ref(0); let supportPointerDesktop = false; let startScreenY: number | null = null; -const rootEl = shallowRef<HTMLDivElement>(); +const rootEl = useTemplateRef('rootEl'); let scrollEl: HTMLElement | null = null; let disabled = false; @@ -82,11 +81,11 @@ function moveBySystem(to: number): Promise<void> { return; } const startTime = Date.now(); - let intervalId = setInterval(() => { + let intervalId = window.setInterval(() => { const time = Date.now() - startTime; if (time > RELEASE_TRANSITION_DURATION) { pullDistance.value = to; - clearInterval(intervalId); + window.clearInterval(intervalId); r(); return; } @@ -261,8 +260,4 @@ defineExpose({ margin: 5px 0; } } - -.slotClip { - overflow-y: clip; -} </style> diff --git a/packages/frontend/src/components/MkPushNotificationAllowButton.vue b/packages/frontend/src/components/MkPushNotificationAllowButton.vue index 5e42df4795..9c37eb5e72 100644 --- a/packages/frontend/src/components/MkPushNotificationAllowButton.vue +++ b/packages/frontend/src/components/MkPushNotificationAllowButton.vue @@ -42,12 +42,13 @@ SPDX-License-Identifier: AGPL-3.0-only <script setup lang="ts"> import { ref } from 'vue'; -import { $i, getAccounts } from '@/account.js'; +import { $i } from '@/i.js'; import MkButton from '@/components/MkButton.vue'; import { instance } from '@/instance.js'; import { apiWithDialog, promiseDialog } from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; +import { getAccounts } from '@/accounts.js'; defineProps<{ primary?: boolean; diff --git a/packages/frontend/src/components/MkRadios.vue b/packages/frontend/src/components/MkRadios.vue index af81eb814d..884890bf70 100644 --- a/packages/frontend/src/components/MkRadios.vue +++ b/packages/frontend/src/components/MkRadios.vue @@ -4,7 +4,8 @@ SPDX-License-Identifier: AGPL-3.0-only --> <script lang="ts"> -import { VNode, defineComponent, h, ref, watch } from 'vue'; +import { defineComponent, h, ref, watch } from 'vue'; +import type { VNode } from 'vue'; import MkRadio from './MkRadio.vue'; export default defineComponent({ @@ -77,7 +78,7 @@ export default defineComponent({ > .caption { font-size: 0.85em; padding: 8px 0 0 0; - color: var(--MI_THEME-fgTransparentWeak); + color: color(from var(--MI_THEME-fg) srgb r g b / 0.75); &:empty { display: none; diff --git a/packages/frontend/src/components/MkRange.vue b/packages/frontend/src/components/MkRange.vue index d009f3858c..d668fa342e 100644 --- a/packages/frontend/src/components/MkRange.vue +++ b/packages/frontend/src/components/MkRange.vue @@ -33,8 +33,8 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, defineAsyncComponent, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue'; -import { isTouchUsing } from '@/scripts/touch.js'; +import { computed, defineAsyncComponent, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue'; +import { isTouchUsing } from '@/utility/touch.js'; import * as os from '@/os.js'; const props = withDefaults(defineProps<{ @@ -58,8 +58,8 @@ const emit = defineEmits<{ (ev: 'dragEnded', value: number): void; }>(); -const containerEl = shallowRef<HTMLElement>(); -const thumbEl = shallowRef<HTMLElement>(); +const containerEl = useTemplateRef('containerEl'); +const thumbEl = useTemplateRef('thumbEl'); const rawValue = ref((props.modelValue - props.min) / (props.max - props.min)); const steppedRawValue = computed(() => { @@ -151,20 +151,21 @@ function onMousedown(ev: MouseEvent | TouchEvent) { closed: () => dispose(), }); - const style = document.createElement('style'); - style.appendChild(document.createTextNode('* { cursor: grabbing !important; } body * { pointer-events: none !important; }')); - document.head.appendChild(style); + const style = window.document.createElement('style'); + style.appendChild(window.document.createTextNode('* { cursor: grabbing !important; } body * { pointer-events: none !important; }')); + window.document.head.appendChild(style); const thumbWidth = getThumbWidth(); const onDrag = (ev: MouseEvent | TouchEvent) => { ev.preventDefault(); + let beforeValue = finalValue.value; const containerRect = containerEl.value!.getBoundingClientRect(); 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))); - if (props.continuousUpdate) { + if (props.continuousUpdate && beforeValue !== finalValue.value) { emit('update:modelValue', finalValue.value); } }; @@ -172,7 +173,7 @@ function onMousedown(ev: MouseEvent | TouchEvent) { let beforeValue = finalValue.value; const onMouseup = () => { - document.head.removeChild(style); + window.document.head.removeChild(style); tooltipForDragShowing.value = false; window.removeEventListener('mousemove', onDrag); window.removeEventListener('touchmove', onDrag); @@ -212,7 +213,7 @@ function onMousedown(ev: MouseEvent | TouchEvent) { > .caption { font-size: 0.85em; padding: 8px 0 0 0; - color: var(--MI_THEME-fgTransparentWeak); + color: color(from var(--MI_THEME-fg) srgb r g b / 0.75); &:empty { display: none; @@ -286,7 +287,7 @@ function onMousedown(ev: MouseEvent | TouchEvent) { border-radius: var(--MI-radius-ellipse); &:hover { - background: var(--MI_THEME-accentLighten); + background: hsl(from var(--MI_THEME-accent) h s calc(l + 10)); } } } diff --git a/packages/frontend/src/components/MkReactionIcon.vue b/packages/frontend/src/components/MkReactionIcon.vue index c0cbd8a65d..453253f0fc 100644 --- a/packages/frontend/src/components/MkReactionIcon.vue +++ b/packages/frontend/src/components/MkReactionIcon.vue @@ -9,8 +9,8 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { defineAsyncComponent, shallowRef } from 'vue'; -import { useTooltip } from '@/scripts/use-tooltip.js'; +import { defineAsyncComponent, useTemplateRef } from 'vue'; +import { useTooltip } from '@/use/use-tooltip.js'; import * as os from '@/os.js'; const props = defineProps<{ @@ -20,7 +20,7 @@ const props = defineProps<{ withTooltip?: boolean; }>(); -const elRef = shallowRef(); +const elRef = useTemplateRef('elRef'); if (props.withTooltip) { useTooltip(elRef, (showing) => { diff --git a/packages/frontend/src/components/MkReactionsViewer.reaction.vue b/packages/frontend/src/components/MkReactionsViewer.reaction.vue index 9cd972639f..494b61ca9d 100644 --- a/packages/frontend/src/components/MkReactionsViewer.reaction.vue +++ b/packages/frontend/src/components/MkReactionsViewer.reaction.vue @@ -8,33 +8,34 @@ SPDX-License-Identifier: AGPL-3.0-only ref="buttonEl" v-ripple="canToggle" 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' }]" + :class="[$style.root, { [$style.reacted]: note.myReaction == reaction, [$style.canToggle]: canToggle, [$style.small]: prefer.s.reactionsDisplaySize === 'small', [$style.large]: prefer.s.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()" @click.stop/> + <MkReactionIcon :class="prefer.s.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> <script lang="ts" setup> -import { computed, inject, onMounted, shallowRef, watch } from 'vue'; +import { computed, inject, onMounted, useTemplateRef, watch } from 'vue'; import * as Misskey from 'misskey-js'; import { getUnicodeEmoji } from '@@/js/emojilist.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 { misskeyApi, misskeyApiGet } from '@/utility/misskey-api.js'; +import { useTooltip } from '@/use/use-tooltip.js'; +import { $i } from '@/i.js'; import MkReactionEffect from '@/components/MkReactionEffect.vue'; -import { claimAchievement } from '@/scripts/achievements.js'; -import { defaultStore } from '@/store.js'; +import { claimAchievement } from '@/utility/achievements.js'; import { i18n } from '@/i18n.js'; -import * as sound from '@/scripts/sound.js'; -import { checkReactionPermissions } from '@/scripts/check-reaction-permissions.js'; +import * as sound from '@/utility/sound.js'; +import { checkReactionPermissions } from '@/utility/check-reaction-permissions.js'; import { customEmojisMap } from '@/custom-emojis.js'; +import { prefer } from '@/preferences.js'; +import { DI } from '@/di.js'; const props = defineProps<{ reaction: string; @@ -43,13 +44,13 @@ const props = defineProps<{ note: Misskey.entities.Note; }>(); -const mock = inject<boolean>('mock', false); +const mock = inject(DI.mock, false); const emit = defineEmits<{ (ev: 'reactionToggled', emoji: string, newCount: number): void; }>(); -const buttonEl = shallowRef<HTMLElement>(); +const buttonEl = useTemplateRef('buttonEl'); const emojiName = computed(() => props.reaction.replace(/:/g, '').replace(/@\./, '')); const emoji = computed(() => customEmojisMap.get(emojiName.value) ?? getUnicodeEmoji(props.reaction)); @@ -90,6 +91,15 @@ async function toggleReaction() { } }); } else { + if (prefer.s.confirmOnReact) { + const confirm = await os.confirm({ + type: 'question', + text: i18n.tsx.reactAreYouSure({ emoji: props.reaction.replace('@.', '') }), + }); + + if (confirm.canceled) return; + } + sound.playMisskeySfx('reaction'); if (mock) { @@ -126,7 +136,7 @@ async function menu(ev) { } function anime() { - if (document.hidden || !defaultStore.state.animation || buttonEl.value == null) return; + if (window.document.hidden || !prefer.s.animation || buttonEl.value == null) return; const rect = buttonEl.value.getBoundingClientRect(); const x = rect.left + 16; @@ -172,7 +182,6 @@ if (!mock) { .root { display: inline-flex; height: 42px; - margin: 2px; padding: 0 6px; font-size: 1.5em; border-radius: var(--MI-radius-sm); diff --git a/packages/frontend/src/components/MkReactionsViewer.vue b/packages/frontend/src/components/MkReactionsViewer.vue index a70ed18d18..945640ab41 100644 --- a/packages/frontend/src/components/MkReactionsViewer.vue +++ b/packages/frontend/src/components/MkReactionsViewer.vue @@ -4,24 +4,27 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<TransitionGroup - :enterActiveClass="defaultStore.state.animation ? $style.transition_x_enterActive : ''" - :leaveActiveClass="defaultStore.state.animation ? $style.transition_x_leaveActive : ''" - :enterFromClass="defaultStore.state.animation ? $style.transition_x_enterFrom : ''" - :leaveToClass="defaultStore.state.animation ? $style.transition_x_leaveTo : ''" - :moveClass="defaultStore.state.animation ? $style.transition_x_move : ''" +<component + :is="prefer.s.animation ? TransitionGroup : 'div'" + :enterActiveClass="$style.transition_x_enterActive" + :leaveActiveClass="$style.transition_x_leaveActive" + :enterFromClass="$style.transition_x_enterFrom" + :leaveToClass="$style.transition_x_leaveTo" + :moveClass="$style.transition_x_move" tag="div" :class="$style.root" > <XReaction v-for="[reaction, count] in reactions" :key="reaction" :reaction="reaction" :count="count" :isInitial="initialReactions.has(reaction)" :note="note" @reactionToggled="onMockToggleReaction"/> - <slot v-if="hasMoreReactions" name="more"/> -</TransitionGroup> + <slot v-if="hasMoreReactions" :key="'$more'" name="more"/> +</component> </template> <script lang="ts" setup> import * as Misskey from 'misskey-js'; import { inject, watch, ref } from 'vue'; +import { TransitionGroup } from 'vue'; import XReaction from '@/components/MkReactionsViewer.reaction.vue'; -import { defaultStore } from '@/store.js'; +import { prefer } from '@/preferences.js'; +import { DI } from '@/di.js'; const props = withDefaults(defineProps<{ note: Misskey.entities.Note; @@ -30,7 +33,7 @@ const props = withDefaults(defineProps<{ maxNumber: Infinity, }); -const mock = inject<boolean>('mock', false); +const mock = inject(DI.mock, false); const emit = defineEmits<{ (ev: 'mockUpdateMyReaction', emoji: string, delta: number): void; @@ -103,7 +106,7 @@ watch([() => props.note.reactions, () => props.maxNumber], ([newSource, maxNumbe display: flex; flex-wrap: wrap; align-items: center; - margin: 4px -2px 0 -2px; + gap: 4px; cursor: auto; /* not clickToOpen-able */ &:empty { diff --git a/packages/frontend/src/components/MkRemoteCaution.vue b/packages/frontend/src/components/MkRemoteCaution.vue index 6391468204..0d1a2f0b76 100644 --- a/packages/frontend/src/components/MkRemoteCaution.vue +++ b/packages/frontend/src/components/MkRemoteCaution.vue @@ -4,14 +4,14 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div :class="$style.root"><i class="ti ti-alert-triangle" style="margin-right: 8px;"></i>{{ i18n.ts.remoteUserCaution }}<a :class="$style.link" :href="href" rel="nofollow noopener" target="_blank">{{ i18n.ts.showOnRemote }}</a></div> +<div :class="$style.root"><i class="ti ti-alert-triangle" style="margin-right: 8px;"></i>{{ i18n.ts.remoteUserCaution }}<a v-if="href" :class="$style.link" :href="href" rel="nofollow noopener" target="_blank">{{ i18n.ts.showOnRemote }}</a></div> </template> <script lang="ts" setup> import { i18n } from '@/i18n.js'; defineProps<{ - href: string; + href?: string; }>(); </script> diff --git a/packages/frontend/src/components/MkRemoteEmojiEditDialog.vue b/packages/frontend/src/components/MkRemoteEmojiEditDialog.vue index 873b276b3d..cb50df1743 100644 --- a/packages/frontend/src/components/MkRemoteEmojiEditDialog.vue +++ b/packages/frontend/src/components/MkRemoteEmojiEditDialog.vue @@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template #header>:{{ name }}:</template> <div style="display: flex; flex-direction: column; min-height: 100%;"> - <MkSpacer :marginMin="20" :marginMax="28" style="flex-grow: 1;"> + <div class="_spacer" style="--MI_SPACER-min: 20px; --MI_SPACER-max: 28px; flex-grow: 1;"> <div class="_gaps_m"> <div v-if="imgUrl != null" :class="$style.imgs"> <div style="background: #000;" :class="$style.imgContainer"> @@ -45,7 +45,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template #value>{{ license }}</template> </MkKeyValue> </div> - </MkSpacer> + </div> <div :class="$style.footer"> <MkButton primary rounded style="margin: 0 auto;" @click="done"> <i class="ti ti-plus"></i> {{ i18n.ts.import }} @@ -125,7 +125,7 @@ async function done() { left: 0; padding: 12px; border-top: solid 0.5px var(--MI_THEME-divider); - background: var(--MI_THEME-acrylicBg); + background: color(from var(--MI_THEME-bg) srgb r g b / 0.5); -webkit-backdrop-filter: var(--MI-blur, blur(15px)); backdrop-filter: var(--MI-blur, blur(15px)); } diff --git a/packages/frontend/src/components/MkRetentionHeatmap.vue b/packages/frontend/src/components/MkRetentionHeatmap.vue index 64b573c4d3..1ab2397337 100644 --- a/packages/frontend/src/components/MkRetentionHeatmap.vue +++ b/packages/frontend/src/components/MkRetentionHeatmap.vue @@ -13,18 +13,18 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, nextTick, shallowRef, ref } from 'vue'; +import { onMounted, nextTick, useTemplateRef, ref } from 'vue'; import { Chart } from 'chart.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'; -import { initChart } from '@/scripts/init-chart.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { store } from '@/store.js'; +import { useChartTooltip } from '@/use/use-chart-tooltip.js'; +import { alpha } from '@/utility/color.js'; +import { initChart } from '@/utility/init-chart.js'; initChart(); -const rootEl = shallowRef<HTMLDivElement | null>(null); -const chartEl = shallowRef<HTMLCanvasElement | null>(null); +const rootEl = useTemplateRef('rootEl'); +const chartEl = useTemplateRef('chartEl'); let chartInstance: Chart | null = null; const fetching = ref(true); @@ -75,7 +75,7 @@ async function renderChart() { await nextTick(); - const color = defaultStore.state.darkMode ? '#b4e900' : '#86b300'; + const color = store.s.darkMode ? '#b4e900' : '#86b300'; const getYYYYMMDD = (date: Date) => { const y = date.getFullYear().toString().padStart(2, '0'); diff --git a/packages/frontend/src/components/MkRetentionLineChart.vue b/packages/frontend/src/components/MkRetentionLineChart.vue index d41793b0fa..ba66ffecc0 100644 --- a/packages/frontend/src/components/MkRetentionLineChart.vue +++ b/packages/frontend/src/components/MkRetentionLineChart.vue @@ -8,19 +8,19 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, shallowRef } from 'vue'; +import { onMounted, useTemplateRef } from 'vue'; import { Chart } from 'chart.js'; import tinycolor from 'tinycolor2'; -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 { initChart } from '@/scripts/init-chart.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { store } from '@/store.js'; +import { useChartTooltip } from '@/use/use-chart-tooltip.js'; +import { chartVLine } from '@/utility/chart-vline.js'; +import { alpha } from '@/utility/color.js'; +import { initChart } from '@/utility/init-chart.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; initChart(); -const chartEl = shallowRef<HTMLCanvasElement | null>(null); +const chartEl = useTemplateRef('chartEl'); const { handler: externalTooltipHandler } = useChartTooltip(); @@ -42,9 +42,9 @@ const getDate = (ymd: string) => { onMounted(async () => { let raw = await misskeyApi('retention', { }); - const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; + const vLineColor = store.s.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; - const accent = tinycolor(getComputedStyle(document.documentElement).getPropertyValue('--MI_THEME-accent')); + const accent = tinycolor(getComputedStyle(window.document.documentElement).getPropertyValue('--MI_THEME-accent')); const color = accent.toHex(); if (chartEl.value == null) return; diff --git a/packages/frontend/src/components/MkRolePreview.vue b/packages/frontend/src/components/MkRolePreview.vue index 3f14c5b5e0..15149b3f0c 100644 --- a/packages/frontend/src/components/MkRolePreview.vue +++ b/packages/frontend/src/components/MkRolePreview.vue @@ -41,7 +41,7 @@ import { i18n } from '@/i18n.js'; const props = withDefaults(defineProps<{ role: Misskey.entities.Role; forModeration: boolean; - detailed: boolean; + detailed?: boolean; }>(), { detailed: true, }); diff --git a/packages/frontend/src/components/MkRoleSelectDialog.stories.impl.ts b/packages/frontend/src/components/MkRoleSelectDialog.stories.impl.ts index 411d62edf9..b090f0a0fa 100644 --- a/packages/frontend/src/components/MkRoleSelectDialog.stories.impl.ts +++ b/packages/frontend/src/components/MkRoleSelectDialog.stories.impl.ts @@ -4,7 +4,7 @@ */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { http, HttpResponse } from 'msw'; import { role } from '../../.storybook/fakes.js'; import { commonHandlers } from '../../.storybook/mocks.js'; diff --git a/packages/frontend/src/components/MkRoleSelectDialog.vue b/packages/frontend/src/components/MkRoleSelectDialog.vue index 8d11bd855f..6888824437 100644 --- a/packages/frontend/src/components/MkRoleSelectDialog.vue +++ b/packages/frontend/src/components/MkRoleSelectDialog.vue @@ -11,10 +11,10 @@ SPDX-License-Identifier: AGPL-3.0-only :width="400" :height="500" @close="onCloseModalWindow" - @closed="$emit('dispose')" + @closed="emit('closed')" > <template #header>{{ title }}</template> - <MkSpacer :marginMin="20" :marginMax="28"> + <div class="_spacer" style="--MI_SPACER-min: 20px; --MI_SPACER-max: 28px;"> <MkLoading v-if="fetching"/> <div v-else class="_gaps" :class="$style.root"> <div :class="$style.header"> @@ -38,7 +38,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkButton @click="onCancelClicked">{{ i18n.ts.cancel }}</MkButton> </div> </div> - </MkSpacer> + </div> </MkModalWindow> </template> @@ -49,16 +49,15 @@ import { i18n } from '@/i18n.js'; import MkButton from '@/components/MkButton.vue'; import MkInfo from '@/components/MkInfo.vue'; import MkRolePreview from '@/components/MkRolePreview.vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import * as os from '@/os.js'; -import MkSpacer from '@/components/global/MkSpacer.vue'; import MkModalWindow from '@/components/MkModalWindow.vue'; import MkLoading from '@/components/global/MkLoading.vue'; const emit = defineEmits<{ (ev: 'done', value: Misskey.entities.Role[]), (ev: 'close'), - (ev: 'dispose'), + (ev: 'closed'), }>(); const props = withDefaults(defineProps<{ @@ -144,7 +143,7 @@ fetchRoles(); } .roleItemArea { - background-color: var(--MI_THEME-acrylicBg); + background-color: color(from var(--MI_THEME-bg) srgb r g b / 0.5); border-radius: var(--MI-radius); padding: 12px; overflow-y: auto; diff --git a/packages/frontend/src/components/MkScheduleEditor.vue b/packages/frontend/src/components/MkScheduleEditor.vue index 695a474998..1a6d5fd82d 100644 --- a/packages/frontend/src/components/MkScheduleEditor.vue +++ b/packages/frontend/src/components/MkScheduleEditor.vue @@ -20,8 +20,8 @@ SPDX-License-Identifier: AGPL-3.0-only import { onMounted, ref, watch } from 'vue'; import moment from 'moment'; import MkInput from '@/components/MkInput.vue'; -import { formatDateTimeString } from '@/scripts/format-time-string.js'; -import { addTime } from '@/scripts/time.js'; +import { formatDateTimeString } from '@/utility/format-time-string.js'; +import { addTime } from '@/utility/time.js'; import { i18n } from '@/i18n.js'; const props = defineProps<{ diff --git a/packages/frontend/src/components/MkSchedulePostListDialog.vue b/packages/frontend/src/components/MkSchedulePostListDialog.vue index d0716ead79..41f366b082 100644 --- a/packages/frontend/src/components/MkSchedulePostListDialog.vue +++ b/packages/frontend/src/components/MkSchedulePostListDialog.vue @@ -11,11 +11,11 @@ SPDX-License-Identifier: AGPL-3.0-only @close="cancel()" > <template #header>{{ i18n.ts.schedulePostList }}</template> - <MkSpacer :marginMin="14" :marginMax="16"> + <div class="_spacer" style="--MI_SPACER-min: 14px; --MI_SPACER-max: 16px;"> <MkPagination ref="paginationEl" :pagination="pagination"> <template #empty> <div class="_fullinfo"> - <img :src="infoImageUrl" class="_ghost"/> + <img :src="infoImageUrl" draggable="false"/> <div>{{ i18n.ts.nothing }}</div> </div> </template> @@ -26,7 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </template> </MkPagination> - </MkSpacer> + </div> </MkModalWindow> </template> diff --git a/packages/frontend/src/components/MkSelect.vue b/packages/frontend/src/components/MkSelect.vue index 79a56b68a8..cf4e4eda74 100644 --- a/packages/frontend/src/components/MkSelect.vue +++ b/packages/frontend/src/components/MkSelect.vue @@ -40,11 +40,29 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, nextTick, ref, watch, computed, toRefs, VNode, useSlots, VNodeChild } from 'vue'; +import { onMounted, nextTick, ref, watch, computed, toRefs, useSlots } from 'vue'; import { useInterval } from '@@/js/use-interval.js'; +import type { VNode, VNodeChild } from 'vue'; import type { MenuItem } from '@/types/menu.js'; import * as os from '@/os.js'; +type ItemOption = { + type?: 'option'; + value: string | number | null; + label: string; +}; + +type ItemGroup = { + type: 'group'; + label: string; + items: ItemOption[]; +}; + +export type MkSelectItem = ItemOption | ItemGroup; + +// TODO: itemsをslot内のoptionで指定する用法は廃止する(props.itemsを必須化する) +// see: https://github.com/misskey-dev/misskey/issues/15558 + const props = defineProps<{ modelValue: string | number | null; required?: boolean; @@ -55,6 +73,7 @@ const props = defineProps<{ inline?: boolean; small?: boolean; large?: boolean; + items?: MkSelectItem[]; }>(); const emit = defineEmits<{ @@ -106,7 +125,30 @@ onMounted(() => { }); }); -watch(modelValue, () => { +watch([modelValue, () => props.items], () => { + if (props.items) { + let found: ItemOption | null = null; + for (const item of props.items) { + if (item.type === 'group') { + for (const option of item.items) { + if (option.value === modelValue.value) { + found = option; + break; + } + } + } else { + if (item.value === modelValue.value) { + found = item; + break; + } + } + } + if (found) { + currentValueText.value = found.label; + } + return; + } + const scanOptions = (options: VNodeChild[]) => { for (const vnode of options) { if (typeof vnode !== 'object' || vnode === null || Array.isArray(vnode)) continue; @@ -129,7 +171,7 @@ watch(modelValue, () => { }; scanOptions(slots.default!()); -}, { immediate: true }); +}, { immediate: true, deep: true }); function show() { if (opening.value) return; @@ -138,41 +180,70 @@ function show() { opening.value = true; const menu: MenuItem[] = []; - let options = slots.default!(); - const pushOption = (option: VNode) => { - menu.push({ - text: option.children as string, - active: computed(() => modelValue.value === option.props?.value), - action: () => { - emit('update:modelValue', option.props?.value); - }, - }); - }; - - 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; + if (props.items) { + for (const item of props.items) { + if (item.type === 'group') { menu.push({ type: 'label', - text: optgroup.props?.label, + text: item.label, }); - if (Array.isArray(optgroup.children)) scanOptions(optgroup.children); - } else if (Array.isArray(vnode.children)) { // 何故かフラグメントになってくることがある - const fragment = vnode; - if (Array.isArray(fragment.children)) scanOptions(fragment.children); - } else if (vnode.props == null) { // v-if で条件が false のときにこうなる - // nop? + for (const option of item.items) { + menu.push({ + text: option.label, + active: computed(() => modelValue.value === option.value), + action: () => { + emit('update:modelValue', option.value); + }, + }); + } } else { - const option = vnode; - pushOption(option); + menu.push({ + text: item.label, + active: computed(() => modelValue.value === item.value), + action: () => { + emit('update:modelValue', item.value); + }, + }); } } - }; + } else { + let options = slots.default!(); + + const pushOption = (option: VNode) => { + menu.push({ + text: option.children as string, + active: computed(() => modelValue.value === option.props?.value), + action: () => { + emit('update:modelValue', option.props?.value); + }, + }); + }; - scanOptions(options); + 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, + }); + if (Array.isArray(optgroup.children)) scanOptions(optgroup.children); + } else if (Array.isArray(vnode.children)) { // 何故かフラグメントになってくることがある + const fragment = vnode; + if (Array.isArray(fragment.children)) scanOptions(fragment.children); + } else if (vnode.props == null) { // v-if で条件が false のときにこうなる + // nop? + } else { + const option = vnode; + pushOption(option); + } + } + }; + + scanOptions(options); + } os.popupMenu(menu, container.value, { width: container.value?.offsetWidth, @@ -197,7 +268,7 @@ function show() { .caption { font-size: 0.85em; padding: 8px 0 0 0; - color: var(--MI_THEME-fgTransparentWeak); + color: color(from var(--MI_THEME-fg) srgb r g b / 0.75); &:empty { display: none; diff --git a/packages/frontend/src/components/MkSignin.input.vue b/packages/frontend/src/components/MkSignin.input.vue index e98ac9cfd2..aacd1eae2a 100644 --- a/packages/frontend/src/components/MkSignin.input.vue +++ b/packages/frontend/src/components/MkSignin.input.vue @@ -58,7 +58,7 @@ import { toUnicode } from 'punycode.js'; import { query, extractDomain } from '@@/js/url.js'; import { host as configHost } from '@@/js/config.js'; -import type { OpenOnRemoteOptions } from '@/scripts/please-login.js'; +import type { OpenOnRemoteOptions } from '@/utility/please-login.js'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; diff --git a/packages/frontend/src/components/MkSignin.vue b/packages/frontend/src/components/MkSignin.vue index d6177762d2..68151fdc78 100644 --- a/packages/frontend/src/components/MkSignin.vue +++ b/packages/frontend/src/components/MkSignin.vue @@ -67,20 +67,20 @@ SPDX-License-Identifier: AGPL-3.0-only import { nextTick, onBeforeUnmount, ref, shallowRef, useTemplateRef } from 'vue'; import * as Misskey from 'misskey-js'; import { supported as webAuthnSupported, parseRequestOptionsFromJSON } from '@github/webauthn-json/browser-ponyfill'; - import type { AuthenticationPublicKeyCredential } from '@github/webauthn-json/browser-ponyfill'; -import type { OpenOnRemoteOptions } from '@/scripts/please-login.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { showSuspendedDialog } from '@/scripts/show-suspended-dialog.js'; -import { login } from '@/account.js'; +import type { OpenOnRemoteOptions } from '@/utility/please-login.js'; +import type { PwResponse } from '@/components/MkSignin.password.vue'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { showSuspendedDialog } from '@/utility/show-suspended-dialog.js'; import { i18n } from '@/i18n.js'; -import { showSystemAccountDialog } from '@/scripts/show-system-account-dialog.js'; +import { showSystemAccountDialog } from '@/utility/show-system-account-dialog.js'; import * as os from '@/os.js'; import XInput from '@/components/MkSignin.input.vue'; -import XPassword, { type PwResponse } from '@/components/MkSignin.password.vue'; +import XPassword from '@/components/MkSignin.password.vue'; import XTotp from '@/components/MkSignin.totp.vue'; import XPasskey from '@/components/MkSignin.passkey.vue'; +import { login } from '@/accounts.js'; const emit = defineEmits<{ (ev: 'login', v: Misskey.entities.SigninFlowResponse & { finished: true }): void; diff --git a/packages/frontend/src/components/MkSigninDialog.vue b/packages/frontend/src/components/MkSigninDialog.vue index 676a336ec7..60c99880cd 100644 --- a/packages/frontend/src/components/MkSigninDialog.vue +++ b/packages/frontend/src/components/MkSigninDialog.vue @@ -24,8 +24,8 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import * as Misskey from 'misskey-js'; -import { shallowRef } from 'vue'; -import type { OpenOnRemoteOptions } from '@/scripts/please-login.js'; +import { useTemplateRef } from 'vue'; +import type { OpenOnRemoteOptions } from '@/utility/please-login.js'; import MkSignin from '@/components/MkSignin.vue'; import MkModal from '@/components/MkModal.vue'; import { i18n } from '@/i18n.js'; @@ -46,7 +46,7 @@ const emit = defineEmits<{ (ev: 'cancelled'): void; }>(); -const modal = shallowRef<InstanceType<typeof MkModal>>(); +const modal = useTemplateRef('modal'); function onClose() { emit('cancelled'); @@ -84,7 +84,7 @@ function onLogin(res: Misskey.entities.SigninFlowResponse & { finished: true }) align-items: center; font-weight: bold; backdrop-filter: var(--MI-blur, blur(15px)); - background: var(--MI_THEME-acrylicBg); + background: color(from var(--MI_THEME-bg) srgb r g b / 0.5); z-index: 1; } diff --git a/packages/frontend/src/components/MkSignupDialog.form.vue b/packages/frontend/src/components/MkSignupDialog.form.vue index dd263ce642..365b23f4ce 100644 --- a/packages/frontend/src/components/MkSignupDialog.form.vue +++ b/packages/frontend/src/components/MkSignupDialog.form.vue @@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="$style.banner"> <i class="ti ti-user-edit"></i> </div> - <MkSpacer :marginMin="20" :marginMax="32"> + <div class="_spacer" style="--MI_SPACER-min: 20px; --MI_SPACER-max: 32px;"> <form class="_gaps_m" autocomplete="new-password" @submit.prevent="onSubmit"> <MkInput v-if="instance.disableRegistration" v-model="invitationCode" type="text" :spellcheck="false" required> <template #label>{{ i18n.ts.invitationCode }}</template> @@ -79,7 +79,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template v-else>{{ i18n.ts.start }}</template> </MkButton> </form> - </MkSpacer> + </div> </div> </template> @@ -90,12 +90,13 @@ import * as Misskey from 'misskey-js'; import * as config from '@@/js/config.js'; import MkButton from './MkButton.vue'; import MkInput from './MkInput.vue'; -import MkCaptcha, { type Captcha } from '@/components/MkCaptcha.vue'; +import type { Captcha } from '@/components/MkCaptcha.vue'; +import MkCaptcha from '@/components/MkCaptcha.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { login } from '@/account.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { instance } from '@/instance.js'; import { i18n } from '@/i18n.js'; +import { login } from '@/accounts.js'; const props = withDefaults(defineProps<{ autoSet?: boolean; @@ -278,7 +279,7 @@ async function onSubmit(): Promise<void> { 'testcaptcha-response': testcaptchaResponse.value, }; - const res = await fetch(`${config.apiUrl}/signup`, { + const res = await window.fetch(`${config.apiUrl}/signup`, { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/packages/frontend/src/components/MkSignupDialog.rules.stories.impl.ts b/packages/frontend/src/components/MkSignupDialog.rules.stories.impl.ts index 9df3ec0c30..8d99bc44b7 100644 --- a/packages/frontend/src/components/MkSignupDialog.rules.stories.impl.ts +++ b/packages/frontend/src/components/MkSignupDialog.rules.stories.impl.ts @@ -5,7 +5,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { expect, userEvent, waitFor, within } from '@storybook/test'; -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { onBeforeUnmount } from 'vue'; import MkSignupServerRules from './MkSignupDialog.rules.vue'; import { i18n } from '@/i18n.js'; diff --git a/packages/frontend/src/components/MkSignupDialog.rules.vue b/packages/frontend/src/components/MkSignupDialog.rules.vue index d1685c6990..3034f2269b 100644 --- a/packages/frontend/src/components/MkSignupDialog.rules.vue +++ b/packages/frontend/src/components/MkSignupDialog.rules.vue @@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="$style.banner"> <i class="ti ti-checklist"></i> </div> - <MkSpacer :marginMin="20" :marginMax="28"> + <div class="_spacer" style="--MI_SPACER-min: 20px; --MI_SPACER-max: 28px;"> <div class="_gaps_m"> <div v-if="instance.disableRegistration || instance.federation !== 'all'" class="_gaps_s"> <MkInfo v-if="instance.disableRegistration" warn>{{ i18n.ts.invitationRequiredToRegister }}</MkInfo> @@ -59,7 +59,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkButton inline primary rounded gradate :disabled="!agreed" data-cy-signup-rules-continue @click="emit('done')">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton> </div> </div> - </MkSpacer> + </div> </div> </template> @@ -67,7 +67,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 '@/scripts/sanitize-html.js'; +import sanitizeHtml from '@/utility/sanitize-html.js'; import MkButton from '@/components/MkButton.vue'; import MkFolder from '@/components/MkFolder.vue'; import MkSwitch from '@/components/MkSwitch.vue'; diff --git a/packages/frontend/src/components/MkSignupDialog.vue b/packages/frontend/src/components/MkSignupDialog.vue index 291c3ecc2f..bf1b5fcf3e 100644 --- a/packages/frontend/src/components/MkSignupDialog.vue +++ b/packages/frontend/src/components/MkSignupDialog.vue @@ -33,7 +33,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { shallowRef, ref } from 'vue'; +import { useTemplateRef, ref } from 'vue'; import * as Misskey from 'misskey-js'; import XSignup from '@/components/MkSignupDialog.form.vue'; import XServerRules from '@/components/MkSignupDialog.rules.vue'; @@ -52,7 +52,7 @@ const emit = defineEmits<{ (ev: 'closed'): void; }>(); -const dialog = shallowRef<InstanceType<typeof MkModalWindow>>(); +const dialog = useTemplateRef('dialog'); const isAcceptedServerRule = ref(false); diff --git a/packages/frontend/src/components/MkSortOrderEditor.define.ts b/packages/frontend/src/components/MkSortOrderEditor.define.ts index f023b5d72b..e56b93f98a 100644 --- a/packages/frontend/src/components/MkSortOrderEditor.define.ts +++ b/packages/frontend/src/components/MkSortOrderEditor.define.ts @@ -3,9 +3,9 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -export type SortOrderDirection = '+' | '-' +export type SortOrderDirection = '+' | '-'; export type SortOrder<T extends string> = { key: T; direction: SortOrderDirection; -} +}; diff --git a/packages/frontend/src/components/MkSortOrderEditor.vue b/packages/frontend/src/components/MkSortOrderEditor.vue index 9decacc5f5..27ffc724ae 100644 --- a/packages/frontend/src/components/MkSortOrderEditor.vue +++ b/packages/frontend/src/components/MkSortOrderEditor.vue @@ -27,9 +27,9 @@ SPDX-License-Identifier: AGPL-3.0-only import { toRefs } from 'vue'; import MkTagItem from '@/components/MkTagItem.vue'; import MkButton from '@/components/MkButton.vue'; -import { MenuItem } from '@/types/menu.js'; +import type { MenuItem } from '@/types/menu.js'; import * as os from '@/os.js'; -import { SortOrder } from '@/components/MkSortOrderEditor.define.js'; +import type { SortOrder } from '@/components/MkSortOrderEditor.define.js'; const emit = defineEmits<{ (ev: 'update', sortOrders: SortOrder<T>[]): void; diff --git a/packages/frontend/src/components/MkSparkle.vue b/packages/frontend/src/components/MkSparkle.vue index b3fc67c0df..2400c5ec7f 100644 --- a/packages/frontend/src/components/MkSparkle.vue +++ b/packages/frontend/src/components/MkSparkle.vue @@ -56,7 +56,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, onUnmounted, ref, shallowRef } from 'vue'; +import { onMounted, onUnmounted, ref, useTemplateRef } from 'vue'; const particles = ref<{ id: string, @@ -66,7 +66,7 @@ const particles = ref<{ dur: number, color: string }[]>([]); -const el = shallowRef<HTMLElement>(); +const el = useTemplateRef('el'); const width = ref(0); const height = ref(0); const colors = ['#FF1493', '#00FFFF', '#FFE202', '#FFE202', '#FFE202']; diff --git a/packages/frontend/src/components/MkSubNoteContent.vue b/packages/frontend/src/components/MkSubNoteContent.vue index 145de3b9d3..0780f6c910 100644 --- a/packages/frontend/src/components/MkSubNoteContent.vue +++ b/packages/frontend/src/components/MkSubNoteContent.vue @@ -5,23 +5,17 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div :class="[$style.root, { [$style.collapsed]: collapsed }]"> - <div :class="{ [$style.clickToOpen]: defaultStore.state.clickToOpen }" @click.stop="defaultStore.state.clickToOpen ? noteclick(note.id) : undefined"> + <div :class="{ [$style.clickToOpen]: prefer.s.clickToOpen }" @click.stop="prefer.s.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.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" :isBlock="true" :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> - <MkButton v-else-if="!defaultStore.state.animatedMfm && allowAnim && animated && !hideFiles" :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="note.text && translating || note.text && translation" :class="$style.translation"> - <MkLoading v-if="translating" mini/> - <div v-else> - <b>{{ i18n.tsx.translatedFrom({ x: translation.sourceLang }) }}: </b> - <Mfm :text="translation.text" :isBlock="true" :author="note.user" :nyaize="'respect'" :emojiUrls="note.emojis"/> - </div> - </div> + <MkButton v-else-if="!prefer.s.animatedMfm && allowAnim && animated && !hideFiles" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-stop ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.stop }}</MkButton> + <SkNoteTranslation :note="note" :translation="translation" :translating="translating"></SkNoteTranslation> <MkA v-if="note.renoteId" :class="$style.rp" :to="`/notes/${note.renoteId}`" @click.stop>RN: ...</MkA> </div> - <details v-if="note.files && note.files.length > 0" :open="!defaultStore.state.collapseFiles && !hideFiles"> + <details v-if="note.files && note.files.length > 0" :open="!prefer.s.collapseFiles && !hideFiles"> <summary>({{ i18n.tsx.withNFiles({ n: note.files.length }) }})</summary> <MkMediaList :mediaList="note.files"/> </details> @@ -47,23 +41,29 @@ import MkMediaList from '@/components/MkMediaList.vue'; import MkPoll from '@/components/MkPoll.vue'; import MkButton from '@/components/MkButton.vue'; import { i18n } from '@/i18n.js'; -import { defaultStore } from '@/store.js'; -import { useRouter } from '@/router/supplier.js'; import * as os from '@/os.js'; -import { checkAnimationFromMfm } from '@/scripts/check-animated-mfm.js'; +import { checkAnimationFromMfm } from '@/utility/check-animated-mfm.js'; +import { useRouter } from '@/router'; +import { prefer } from '@/preferences.js'; +import SkNoteTranslation from '@/components/SkNoteTranslation.vue'; -const props = defineProps<{ +const props = withDefaults(defineProps<{ note: Misskey.entities.Note; translating?: boolean; - translation?: any; + translation?: Misskey.entities.NotesTranslateResponse | false | null; hideFiles?: boolean; expandAllCws?: boolean; -}>(); +}>(), { + translating: false, + translation: null, + hideFiles: false, + expandAllCws: false, +}); const router = useRouter(); function noteclick(id: string) { - const selection = document.getSelection(); + const selection = window.document.getSelection(); if (selection?.toString().length === 0) { router.push(`/notes/${id}`); } @@ -71,9 +71,9 @@ function noteclick(id: string) { const parsed = computed(() => props.note.text ? mfm.parse(props.note.text) : null); const animated = computed(() => parsed.value ? checkAnimationFromMfm(parsed.value) : null); -let allowAnim = ref(defaultStore.state.advancedMfm && defaultStore.state.animatedMfm ? true : false); +let allowAnim = ref(prefer.s.advancedMfm && prefer.s.animatedMfm); -const isLong = defaultStore.state.expandLongNote && !props.hideFiles ? false : shouldCollapsed(props.note, []); +const isLong = prefer.s.expandLongNote && !props.hideFiles ? false : shouldCollapsed(props.note, []); function animatedMFM() { if (allowAnim.value) { @@ -110,7 +110,6 @@ watch(() => props.expandAllCws, (expandAllCws) => { left: 0; width: 100%; height: 64px; - // background: linear-gradient(0deg, var(--MI_THEME-panel), color(from var(--MI_THEME-panel) srgb r g b / 0)); > .fadeLabel { display: inline-block; @@ -141,13 +140,6 @@ watch(() => props.expandAllCws, (expandAllCws) => { color: var(--MI_THEME-renote); } -.translation { - border: solid 0.5px var(--MI_THEME-divider); - border-radius: var(--MI-radius); - padding: 12px; - margin-top: 8px; -} - .showLess { width: 100%; margin-top: 14px; diff --git a/packages/frontend/src/components/MkSuperMenu.vue b/packages/frontend/src/components/MkSuperMenu.vue index 56e8fcfa37..e48ea1f661 100644 --- a/packages/frontend/src/components/MkSuperMenu.vue +++ b/packages/frontend/src/components/MkSuperMenu.vue @@ -4,27 +4,62 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div class="rrevdjwu" :class="{ grid }"> - <div v-for="group in def" class="group"> - <div v-if="group.title" class="title">{{ group.title }}</div> +<div ref="rootEl" class="rrevdjwu" :class="{ grid }"> + <MkInput + v-if="searchIndex && searchIndex.length > 0" + v-model="searchQuery" + :placeholder="i18n.ts.search" + type="search" + style="margin-bottom: 16px;" + @input.passive="searchOnInput" + @keydown="searchOnKeyDown" + > + <template #prefix><i class="ti ti-search"></i></template> + </MkInput> - <div class="items"> - <template v-for="(item, i) in group.items"> - <a v-if="item.type === 'a'" :href="item.href" :target="item.target" class="_button item" :class="{ danger: item.danger, active: item.active }"> - <span v-if="item.icon" class="icon"><i :class="item.icon" class="ti-fw"></i></span> - <span class="text">{{ item.text }}</span> - </a> - <button v-else-if="item.type === 'button'" class="_button item" :class="{ danger: item.danger, active: item.active }" :disabled="item.active" @click="ev => item.action(ev)"> - <span v-if="item.icon" class="icon"><i :class="item.icon" class="ti-fw"></i></span> - <span class="text">{{ item.text }}</span> - </button> - <MkA v-else :to="item.to" class="_button item" :class="{ danger: item.danger, active: item.active }"> - <span v-if="item.icon" class="icon"><i :class="item.icon" class="ti-fw"></i></span> - <span class="text">{{ item.text }}</span> - </MkA> - </template> + <template v-if="rawSearchQuery == ''"> + <div v-for="group in def" class="group"> + <div v-if="group.title" class="title">{{ group.title }}</div> + + <div class="items"> + <template v-for="(item, i) in group.items"> + <a v-if="item.type === 'a'" :href="item.href" :target="item.target" class="_button item" :class="{ danger: item.danger, active: item.active }"> + <span v-if="item.icon" class="icon"><i :class="item.icon" class="ti-fw"></i></span> + <span class="text">{{ item.text }}</span> + </a> + <button v-else-if="item.type === 'button'" class="_button item" :class="{ danger: item.danger, active: item.active }" :disabled="item.active" @click="ev => item.action(ev)"> + <span v-if="item.icon" class="icon"><i :class="item.icon" class="ti-fw"></i></span> + <span class="text">{{ item.text }}</span> + </button> + <MkA v-else :to="item.to" class="_button item" :class="{ danger: item.danger, active: item.active }"> + <span v-if="item.icon" class="icon"><i :class="item.icon" class="ti-fw"></i></span> + <span class="text">{{ item.text }}</span> + </MkA> + </template> + </div> + </div> + </template> + <template v-else> + <div v-for="item, index in searchResult"> + <MkA + :to="item.path + '#' + item.id" + class="_button searchResultItem" + :class="{ selected: searchSelectedIndex !== null && searchSelectedIndex === index }" + > + <span v-if="item.icon" class="icon"><i :class="item.icon" class="ti-fw"></i></span> + <span class="text"> + <template v-if="item.isRoot"> + {{ item.label }} + </template> + <template v-else> + <span style="opacity: 0.7; font-size: 90%;">{{ item.parentLabels.join(' > ') }}</span> + <br> + <span>{{ item.label }}</span> + </template> + </span> + </MkA> </div> - </div> + </template> </div> </template> @@ -45,7 +80,7 @@ export type SuperMenuDef = { text: string; danger?: boolean; active?: boolean; - action: (ev: MouseEvent) => void; + action: (ev: MouseEvent) => void | Promise<void>; } | { type?: 'link'; to: string; @@ -58,10 +93,125 @@ export type SuperMenuDef = { </script> <script lang="ts" setup> -defineProps<{ +import { useTemplateRef, ref, watch, nextTick, computed } from 'vue'; +import { getScrollContainer } from '@@/js/scroll.js'; +import type { SearchIndexItem } from '@/utility/settings-search-index.js'; +import MkInput from '@/components/MkInput.vue'; +import { i18n } from '@/i18n.js'; +import { useRouter } from '@/router.js'; +import { initIntlString, compareStringIncludes } from '@/utility/intl-string.js'; + +const props = defineProps<{ def: SuperMenuDef[]; grid?: boolean; + searchIndex?: SearchIndexItem[]; }>(); + +initIntlString(); + +const router = useRouter(); +const rootEl = useTemplateRef('rootEl'); + +const searchQuery = ref(''); +const rawSearchQuery = ref(''); + +const searchSelectedIndex = ref<null | number>(null); +const searchResult = ref<{ + id: string; + path: string; + label: string; + icon?: string; + isRoot: boolean; + parentLabels: string[]; +}[]>([]); +const searchIndexItemByIdComputed = computed(() => props.searchIndex && new Map<string, SearchIndexItem>(props.searchIndex.map(i => [i.id, i]))); + +watch(searchQuery, (value) => { + rawSearchQuery.value = value; +}); + +watch(rawSearchQuery, (value) => { + searchResult.value = []; + searchSelectedIndex.value = null; + + if (value === '') { + return; + } + + const searchIndexItemById = searchIndexItemByIdComputed.value; + if (searchIndexItemById != null) { + const addSearchResult = (item: SearchIndexItem) => { + let path: string | undefined = item.path; + let icon: string | undefined = item.icon; + const parentLabels: string[] = []; + + for (let current = searchIndexItemById.get(item.parentId ?? ''); + current != null; + current = searchIndexItemById.get(current.parentId ?? '')) { + path ??= current.path; + icon ??= current.icon; + parentLabels.push(current.label); + } + + if (_DEV_ && path == null) throw new Error('path is null for ' + item.id); + + searchResult.value.push({ + id: item.id, + path: path ?? '/', // never gets `/` + label: item.label, + parentLabels: parentLabels.toReversed(), + icon, + isRoot: item.parentId == null, + }); + }; + + for (const item of searchIndexItemById.values()) { + if ( + compareStringIncludes(item.label, value) || + item.keywords.some((x) => compareStringIncludes(x, value)) + ) { + addSearchResult(item); + } + } + } +}); + +function searchOnInput(ev: InputEvent) { + searchSelectedIndex.value = null; + rawSearchQuery.value = (ev.target as HTMLInputElement).value; +} + +function searchOnKeyDown(ev: KeyboardEvent) { + if (ev.isComposing) return; + + if (ev.key === 'Enter' && searchSelectedIndex.value != null) { + ev.preventDefault(); + router.push(searchResult.value[searchSelectedIndex.value].path + '#' + searchResult.value[searchSelectedIndex.value].id); + } else if (ev.key === 'ArrowDown') { + ev.preventDefault(); + const current = searchSelectedIndex.value ?? -1; + searchSelectedIndex.value = current + 1 >= searchResult.value.length ? 0 : current + 1; + } else if (ev.key === 'ArrowUp') { + ev.preventDefault(); + const current = searchSelectedIndex.value ?? 0; + searchSelectedIndex.value = current - 1 < 0 ? searchResult.value.length - 1 : current - 1; + } + + if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') { + nextTick(() => { + if (!rootEl.value) return; + const selectedEl = rootEl.value.querySelector<HTMLElement>('.searchResultItem.selected'); + if (selectedEl != null) { + const scrollContainer = getScrollContainer(selectedEl); + if (!scrollContainer) return; + scrollContainer.scrollTo({ + top: selectedEl.offsetTop - scrollContainer.clientHeight / 2 + selectedEl.clientHeight / 2, + behavior: 'instant', + }); + } + }); + } +} </script> <style lang="scss" scoped> @@ -184,5 +334,52 @@ defineProps<{ } } } + + .searchResultItem { + display: flex; + align-items: center; + width: 100%; + box-sizing: border-box; + padding: 9px 16px 9px 8px; + border-radius: 9px; + font-size: 0.9em; + + &:hover { + text-decoration: none; + background: var(--MI_THEME-panelHighlight); + } + + &.selected { + outline: 2px solid var(--MI_THEME-focus); + } + + &:focus-visible, + &.selected { + outline-offset: -2px; + } + + &.active { + color: var(--MI_THEME-accent); + background: var(--MI_THEME-accentedBg); + } + + &.danger { + color: var(--MI_THEME-error); + } + + > .icon { + width: 32px; + margin-right: 2px; + flex-shrink: 0; + text-align: center; + opacity: 0.8; + } + + > .text { + white-space: normal; + padding-right: 12px; + flex-shrink: 1; + } + } } </style> diff --git a/packages/frontend/src/components/MkHorizontalSwipe.vue b/packages/frontend/src/components/MkSwiper.vue index 196c962a06..c0d6dc463c 100644 --- a/packages/frontend/src/components/MkHorizontalSwipe.vue +++ b/packages/frontend/src/components/MkSwiper.vue @@ -12,39 +12,39 @@ SPDX-License-Identifier: AGPL-3.0-only @touchend.passive="touchEnd" > <Transition - :class="[$style.transitionChildren, { [$style.swiping]: isSwipingForClass }]" + :class="[{ [$style.swiping]: isSwipingForClass, [$style.transitionChildren]: !isUserHome }]" :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> + <div :key="tabModel"> + <slot></slot> + </div> </Transition> </div> </template> <script lang="ts" setup> -import { ref, shallowRef, computed, nextTick, watch } from 'vue'; +import { ref, useTemplateRef, 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'; +import { isHorizontalSwipeSwiping as isSwiping } from '@/utility/touch.js'; +import { prefer } from '@/preferences.js'; -const rootEl = shallowRef<HTMLDivElement>(); +const rootEl = useTemplateRef('rootEl'); -// eslint-disable-next-line no-undef const tabModel = defineModel<string>('tab'); const props = defineProps<{ tabs: Tab[]; + page?: string; }>(); const emit = defineEmits<{ (ev: 'swiped', newKey: string, direction: 'left' | 'right'): void; }>(); -const shouldAnimate = computed(() => defaultStore.reactiveState.enableHorizontalSwipe.value || defaultStore.reactiveState.animation.value); +const shouldAnimate = computed(() => prefer.r.enableHorizontalSwipe.value || prefer.r.animation.value); // ▼ しきい値 ▼ // @@ -70,9 +70,10 @@ const currentTabIndex = computed(() => props.tabs.findIndex(tab => tab.key === t const pullDistance = ref(0); const isSwipingForClass = ref(false); let swipeAborted = false; +const isUserHome = props.page === 'user' && tabModel.value === 'home'; function touchStart(event: TouchEvent) { - if (!defaultStore.reactiveState.enableHorizontalSwipe.value) return; + if (!prefer.r.enableHorizontalSwipe.value) return; if (event.touches.length !== 1) return; @@ -83,7 +84,7 @@ function touchStart(event: TouchEvent) { } function touchMove(event: TouchEvent) { - if (!defaultStore.reactiveState.enableHorizontalSwipe.value) return; + if (!prefer.r.enableHorizontalSwipe.value) return; if (event.touches.length !== 1) return; @@ -101,7 +102,7 @@ function touchMove(event: TouchEvent) { pullDistance.value = 0; isSwiping.value = false; - setTimeout(() => { + window.setTimeout(() => { isSwipingForClass.value = false; }, 400); @@ -134,7 +135,7 @@ function touchEnd(event: TouchEvent) { return; } - if (!defaultStore.reactiveState.enableHorizontalSwipe.value) return; + if (!prefer.r.enableHorizontalSwipe.value) return; if (event.touches.length !== 0) return; diff --git a/packages/frontend/src/components/MkSwitch.button.vue b/packages/frontend/src/components/MkSwitch.button.vue index 581aa4e644..e9a029c993 100644 --- a/packages/frontend/src/components/MkSwitch.button.vue +++ b/packages/frontend/src/components/MkSwitch.button.vue @@ -19,7 +19,8 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { toRefs, Ref } from 'vue'; +import { toRefs } from 'vue'; +import type { Ref } from 'vue'; import { i18n } from '@/i18n.js'; const props = withDefaults(defineProps<{ diff --git a/packages/frontend/src/components/MkSwitch.vue b/packages/frontend/src/components/MkSwitch.vue index 5e6029ee40..92359b773a 100644 --- a/packages/frontend/src/components/MkSwitch.vue +++ b/packages/frontend/src/components/MkSwitch.vue @@ -27,7 +27,8 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { toRefs, Ref } from 'vue'; +import { toRefs } from 'vue'; +import type { Ref } from 'vue'; import XButton from '@/components/MkSwitch.button.vue'; const props = defineProps<{ @@ -99,7 +100,7 @@ const toggle = () => { .caption { margin: 8px 0 0 0; - color: var(--MI_THEME-fgTransparentWeak); + color: color(from var(--MI_THEME-fg) srgb r g b / 0.75); font-size: 0.85em; &:empty { diff --git a/packages/frontend/src/components/MkSystemWebhookEditor.vue b/packages/frontend/src/components/MkSystemWebhookEditor.vue index 485d003f93..cd72204fce 100644 --- a/packages/frontend/src/components/MkSystemWebhookEditor.vue +++ b/packages/frontend/src/components/MkSystemWebhookEditor.vue @@ -20,7 +20,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <div style="display: flex; flex-direction: column; min-height: 100%;"> - <MkSpacer :marginMin="20" :marginMax="28" style="flex-grow: 1;"> + <div class="_spacer" style="--MI_SPACER-min: 20px; --MI_SPACER-max: 28px; flex-grow: 1;"> <MkLoading v-if="loading !== 0"/> <div v-else :class="$style.root" class="_gaps_m"> <MkInput v-model="title"> @@ -79,7 +79,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template #label>{{ i18n.ts.enable }}</template> </MkSwitch> </div> - </MkSpacer> + </div> <div :class="$style.footer" class="_buttonsCenter"> <MkButton primary rounded :disabled="disableSubmitButton" @click="onSubmitClicked"> <i class="ti ti-check"></i> @@ -92,18 +92,18 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script setup lang="ts"> -import { computed, onMounted, ref, shallowRef, toRefs } from 'vue'; +import { computed, onMounted, ref, useTemplateRef, toRefs } from 'vue'; import * as Misskey from 'misskey-js'; -import MkInput from '@/components/MkInput.vue'; -import MkSwitch from '@/components/MkSwitch.vue'; -import { +import type { MkSystemWebhookEditorProps, MkSystemWebhookResult, SystemWebhookEventType, } from '@/components/MkSystemWebhookEditor.impl.js'; +import MkInput from '@/components/MkInput.vue'; +import MkSwitch from '@/components/MkSwitch.vue'; import { i18n } from '@/i18n.js'; import MkButton from '@/components/MkButton.vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import MkModalWindow from '@/components/MkModalWindow.vue'; import MkFolder from '@/components/MkFolder.vue'; import * as os from '@/os.js'; @@ -114,7 +114,7 @@ type EventType = { userCreated: boolean; inactiveModeratorsWarning: boolean; inactiveModeratorsInvitationOnlyChanged: boolean; -} +}; const emit = defineEmits<{ (ev: 'submitted', result: MkSystemWebhookResult): void; @@ -122,7 +122,7 @@ const emit = defineEmits<{ (ev: 'closed'): void; }>(); -const dialogEl = shallowRef<InstanceType<typeof MkModalWindow>>(); +const dialogEl = useTemplateRef('dialogEl'); const props = defineProps<MkSystemWebhookEditorProps>(); @@ -280,7 +280,7 @@ onMounted(async () => { left: 0; padding: 12px; border-top: solid 0.5px var(--MI_THEME-divider); - background: var(--MI_THEME-acrylicBg); + background: color(from var(--MI_THEME-bg) srgb r g b / 0.5); -webkit-backdrop-filter: var(--MI-blur, blur(15px)); backdrop-filter: var(--MI-blur, blur(15px)); } @@ -307,6 +307,6 @@ onMounted(async () => { .description { font-size: 0.85em; padding: 8px 0 0 0; - color: var(--MI_THEME-fgTransparentWeak); + color: color(from var(--MI_THEME-fg) srgb r g b / 0.75); } </style> diff --git a/packages/frontend/src/components/MkTabs.vue b/packages/frontend/src/components/MkTabs.vue new file mode 100644 index 0000000000..a1f30100d0 --- /dev/null +++ b/packages/frontend/src/components/MkTabs.vue @@ -0,0 +1,235 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div :class="$style.tabs"> + <div :class="$style.tabsInner"> + <button + v-for="t in tabs" :ref="(el) => tabRefs[t.key] = (el as HTMLElement)" v-tooltip.noDelay="t.title" + class="_button" :class="[$style.tab, { [$style.active]: t.key != null && t.key === props.tab, [$style.animate]: prefer.s.animation }]" + @mousedown="(ev) => onTabMousedown(t, ev)" @click="(ev) => onTabClick(t, ev)" + > + <div :class="$style.tabInner"> + <i v-if="t.icon" :class="[$style.tabIcon, t.icon]"></i> + <div + v-if="!t.iconOnly || (!prefer.s.animation && t.key === tab)" + :class="$style.tabTitle" + > + {{ t.title }} + </div> + <Transition + v-else mode="in-out" @enter="enter" @afterEnter="afterEnter" @leave="leave" + @afterLeave="afterLeave" + > + <div v-show="t.key === tab" :class="[$style.tabTitle, $style.animate]">{{ t.title }}</div> + </Transition> + </div> + </button> + </div> + <div + ref="tabHighlightEl" + :class="[$style.tabHighlight, { [$style.animate]: prefer.s.animation }]" + ></div> +</div> +</template> + +<script lang="ts"> +export type Tab = { + key: string; + onClick?: (ev: MouseEvent) => void; +} & ( + | { + iconOnly?: false; + title: string; + icon?: string; + } + | { + iconOnly: true; + icon: string; + } +); +</script> + +<script lang="ts" setup> +import { nextTick, onMounted, onUnmounted, useTemplateRef, watch } from 'vue'; +import { prefer } from '@/preferences.js'; + +const props = withDefaults(defineProps<{ + tabs?: Tab[]; + tab?: string; +}>(), { + tabs: () => ([] as Tab[]), +}); + +const emit = defineEmits<{ + (ev: 'update:tab', key: string); + (ev: 'tabClick', key: string); +}>(); + +const tabHighlightEl = useTemplateRef('tabHighlightEl'); +const tabRefs: Record<string, HTMLElement | null> = {}; + +function onTabMousedown(tab: Tab, ev: MouseEvent): void { + // ユーザビリティの観点からmousedown時にはonClickは呼ばない + if (tab.key) { + emit('update:tab', tab.key); + } +} + +function onTabClick(t: Tab, ev: MouseEvent): void { + emit('tabClick', t.key); + + if (t.onClick) { + ev.preventDefault(); + ev.stopPropagation(); + t.onClick(ev); + } + + if (t.key) { + emit('update:tab', t.key); + } +} + +function renderTab() { + const tabEl = props.tab ? tabRefs[props.tab] : undefined; + if (tabEl && tabHighlightEl.value && tabHighlightEl.value.parentElement) { + // offsetWidth や offsetLeft は少数を丸めてしまうため getBoundingClientRect を使う必要がある + // https://developer.mozilla.org/ja/docs/Web/API/HTMLElement/offsetWidth#%E5%80%A4 + const parentRect = tabHighlightEl.value.parentElement.getBoundingClientRect(); + const rect = tabEl.getBoundingClientRect(); + tabHighlightEl.value.style.width = rect.width + 'px'; + tabHighlightEl.value.style.left = (rect.left - parentRect.left + tabHighlightEl.value.parentElement.scrollLeft) + 'px'; + } +} + +let entering = false; + +async function enter(el: Element) { + if (!(el instanceof HTMLElement)) return; + entering = true; + const elementWidth = el.getBoundingClientRect().width; + el.style.width = '0'; + el.style.paddingLeft = '0'; + el.offsetWidth; // reflow + el.style.width = `${elementWidth}px`; + el.style.paddingLeft = ''; + nextTick(() => { + entering = false; + }); + + window.setTimeout(renderTab, 170); +} + +function afterEnter(el: Element) { + if (!(el instanceof HTMLElement)) return; + // element.style.width = ''; +} + +async function leave(el: Element) { + if (!(el instanceof HTMLElement)) return; + const elementWidth = el.getBoundingClientRect().width; + el.style.width = `${elementWidth}px`; + el.style.paddingLeft = ''; + el.offsetWidth; // reflow + el.style.width = '0'; + el.style.paddingLeft = '0'; +} + +function afterLeave(el: Element) { + if (!(el instanceof HTMLElement)) return; + el.style.width = ''; +} + +onMounted(() => { + watch([() => props.tab, () => props.tabs], () => { + nextTick(() => { + if (entering) return; + renderTab(); + }); + }, { + immediate: true, + }); +}); + +onUnmounted(() => { +}); +</script> + +<style lang="scss" module> +.tabs { + --height: 40px; + + display: block; + position: relative; + margin: 0; + height: var(--height); + font-size: 85%; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } +} + +.tabsInner { + display: inline-block; + height: var(--height); + white-space: nowrap; +} + +.tab { + display: inline-block; + position: relative; + padding: 0 10px; + height: 100%; + font-weight: normal; + opacity: 0.7; + + &:hover { + opacity: 1; + } + + &.active { + opacity: 1; + } + + &.animate { + transition: opacity 0.2s ease; + } +} + +.tabInner { + display: flex; + align-items: center; +} + +.tabIcon + .tabTitle { + padding-left: 4px; +} + +.tabTitle { + overflow: hidden; + + &.animate { + transition: width .15s linear, padding-left .15s linear; + } +} + +.tabHighlight { + position: absolute; + bottom: 0; + height: 3px; + background: var(--MI_THEME-accent); + border-radius: 999px; + transition: none; + pointer-events: none; + + &.animate { + transition: width 0.15s ease, left 0.15s ease; + } +} +</style> diff --git a/packages/frontend/src/components/MkTagCloud.vue b/packages/frontend/src/components/MkTagCloud.vue index 87aa046963..9d541c8acb 100644 --- a/packages/frontend/src/components/MkTagCloud.vue +++ b/packages/frontend/src/components/MkTagCloud.vue @@ -15,18 +15,18 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, watch, onBeforeUnmount, ref, shallowRef } from 'vue'; +import { onMounted, watch, onBeforeUnmount, ref, useTemplateRef } from 'vue'; import tinycolor from 'tinycolor2'; const loaded = !!window.TagCanvas; const SAFE_FOR_HTML_ID = 'abcdefghijklmnopqrstuvwxyz'; -const computedStyle = getComputedStyle(document.documentElement); +const computedStyle = getComputedStyle(window.document.documentElement); const idForCanvas = Array.from({ length: 16 }, () => SAFE_FOR_HTML_ID[Math.floor(Math.random() * SAFE_FOR_HTML_ID.length)]).join(''); const idForTags = Array.from({ length: 16 }, () => SAFE_FOR_HTML_ID[Math.floor(Math.random() * SAFE_FOR_HTML_ID.length)]).join(''); const available = ref(false); -const rootEl = shallowRef<HTMLElement | null>(null); -const canvasEl = shallowRef<HTMLCanvasElement | null>(null); -const tagsEl = shallowRef<HTMLElement | null>(null); +const rootEl = useTemplateRef('rootEl'); +const canvasEl = useTemplateRef('canvasEl'); +const tagsEl = useTemplateRef('tagsEl'); const width = ref(300); watch(available, () => { @@ -57,7 +57,7 @@ onMounted(() => { if (loaded) { available.value = true; } else { - document.head.appendChild(Object.assign(document.createElement('script'), { + window.document.head.appendChild(Object.assign(window.document.createElement('script'), { async: true, src: '/client-assets/tagcanvas.min.js', })).addEventListener('load', () => available.value = true); diff --git a/packages/frontend/src/components/MkTagItem.stories.impl.ts b/packages/frontend/src/components/MkTagItem.stories.impl.ts index 3f243ff651..ac932c8342 100644 --- a/packages/frontend/src/components/MkTagItem.stories.impl.ts +++ b/packages/frontend/src/components/MkTagItem.stories.impl.ts @@ -6,7 +6,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ /* eslint-disable import/no-default-export */ import { action } from '@storybook/addon-actions'; -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import MkTagItem from './MkTagItem.vue'; export const Default = { diff --git a/packages/frontend/src/components/MkTextarea.vue b/packages/frontend/src/components/MkTextarea.vue index 9deb6528d1..e36069c50e 100644 --- a/packages/frontend/src/components/MkTextarea.vue +++ b/packages/frontend/src/components/MkTextarea.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div> +<div class="_selectable"> <div :class="$style.label" @click="focus"><slot name="label"></slot></div> <div :class="{ [$style.disabled]: disabled, [$style.focused]: focused, [$style.tall]: tall, [$style.pre]: pre }" style="position: relative;"> <textarea @@ -36,11 +36,12 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs, shallowRef } from 'vue'; +import { onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs, useTemplateRef } from 'vue'; import { debounce } from 'throttle-debounce'; +import type { SuggestionType } from '@/utility/autocomplete.js'; import MkButton from '@/components/MkButton.vue'; import { i18n } from '@/i18n.js'; -import { Autocomplete, SuggestionType } from '@/scripts/autocomplete.js'; +import { Autocomplete } from '@/utility/autocomplete.js'; const props = defineProps<{ modelValue: string | null; @@ -74,7 +75,7 @@ const focused = ref(false); const changed = ref(false); const invalid = ref(false); const filled = computed(() => v.value !== '' && v.value != null); -const inputEl = shallowRef<HTMLTextAreaElement>(); +const inputEl = useTemplateRef('inputEl'); const preview = ref(false); let autocompleteWorker: Autocomplete | null = null; @@ -159,7 +160,7 @@ onUnmounted(() => { .caption { font-size: 0.85em; padding: 8px 0 0 0; - color: var(--MI_THEME-fgTransparentWeak); + color: color(from var(--MI_THEME-fg) srgb r g b / 0.75); &:empty { display: none; diff --git a/packages/frontend/src/components/MkThemePreview.vue b/packages/frontend/src/components/MkThemePreview.vue new file mode 100644 index 0000000000..cc4254a2f6 --- /dev/null +++ b/packages/frontend/src/components/MkThemePreview.vue @@ -0,0 +1,115 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<svg viewBox="0 0 200 150"> + <g fill-rule="evenodd"> + <rect width="200" height="150" :fill="themeVariables.bg"/> + <rect width="64" height="150" :fill="themeVariables.navBg"/> + <rect x="64" width="136" height="41" :fill="themeVariables.pageHeaderBg"/> + <path transform="scale(.26458)" d="m439.77 247.19c-43.673 0-78.832 35.157-78.832 78.83v249.98h407.06v-328.81z" :fill="themeVariables.panel"/> + </g> + <circle cx="32" cy="83" r="21" :fill="themeVariables.accentedBg"/> + <g> + <rect x="120" y="88" width="40" height="6" ry="3" :fill="themeVariables.fg"/> + <rect x="170" y="88" width="20" height="6" ry="3" :fill="themeVariables.mention"/> + <rect x="120" y="108" width="20" height="6" ry="3" :fill="themeVariables.hashtag"/> + <rect x="150" y="108" width="40" height="6" ry="3" :fill="themeVariables.fg"/> + <rect x="120" y="128" width="40" height="6" ry="3" :fill="themeVariables.fg"/> + <rect x="170" y="128" width="20" height="6" ry="3" :fill="themeVariables.link"/> + </g> + <path d="m65.498 40.892h137.7" :stroke="themeVariables.divider" stroke-width="0.75"/> + <g transform="matrix(.60823 0 0 .60823 25.45 75.755)" fill="none" :stroke="themeVariables.accent" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"> + <path d="m0 0h24v24h-24z" fill="none" stroke="none"/> + <path d="m5 12h-2l9-9 9 9h-2"/> + <path d="m5 12v7a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-7"/> + <path d="m9 21v-6a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v6"/> + </g> + <g transform="matrix(.61621 0 0 .61621 25.354 117.92)" fill="none" :stroke="themeVariables.fg" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"> + <path d="m0 0h24v24h-24z" fill="none" stroke="none"/> + <path d="m10 5a2 2 0 1 1 4 0 7 7 0 0 1 4 6v3a4 4 0 0 0 2 3h-16a4 4 0 0 0 2-3v-3a7 7 0 0 1 4-6"/> + <path d="m9 17v1a3 3 0 0 0 6 0v-1"/> + </g> + <circle cx="32" cy="32" r="16" :fill="themeVariables.accent"/> + <circle cx="140" cy="20" r="6" :fill="themeVariables.success"/> + <circle cx="160" cy="20" r="6" :fill="themeVariables.warn"/> + <circle cx="180" cy="20" r="6" :fill="themeVariables.error"/> +</svg> +</template> + +<script setup lang="ts"> +import { ref, watch } from 'vue'; +import lightTheme from '@@/themes/_light.json5'; +import darkTheme from '@@/themes/_dark.json5'; +import type { Theme } from '@/theme.js'; +import { compile } from '@/theme.js'; +import { deepClone } from '@/utility/clone.js'; + +const props = defineProps<{ + theme: Theme; +}>(); + +const themeVariables = ref<{ + bg: string; + panel: string; + fg: string; + mention: string; + hashtag: string; + link: string; + divider: string; + accent: string; + accentedBg: string; + navBg: string; + pageHeaderBg: string; + success: string; + warn: string; + error: string; +}>({ + bg: 'var(--MI_THEME-bg)', + panel: 'var(--MI_THEME-panel)', + fg: 'var(--MI_THEME-fg)', + mention: 'var(--MI_THEME-mention)', + hashtag: 'var(--MI_THEME-hashtag)', + link: 'var(--MI_THEME-link)', + divider: 'var(--MI_THEME-divider)', + accent: 'var(--MI_THEME-accent)', + accentedBg: 'var(--MI_THEME-accentedBg)', + navBg: 'var(--MI_THEME-navBg)', + pageHeaderBg: 'var(--MI_THEME-pageHeaderBg)', + success: 'var(--MI_THEME-success)', + warn: 'var(--MI_THEME-warn)', + error: 'var(--MI_THEME-error)', +}); + +watch(() => props.theme, (theme) => { + if (theme == null) return; + + const _theme = deepClone(theme); + + if (_theme.base != null) { + const base = [lightTheme, darkTheme].find(x => x.id === _theme.base); + if (base) _theme.props = Object.assign({}, base.props, _theme.props); + } + + const compiled = compile(_theme); + + themeVariables.value = { + bg: compiled.bg ?? 'var(--MI_THEME-bg)', + panel: compiled.panel ?? 'var(--MI_THEME-panel)', + fg: compiled.fg ?? 'var(--MI_THEME-fg)', + mention: compiled.mention ?? 'var(--MI_THEME-mention)', + hashtag: compiled.hashtag ?? 'var(--MI_THEME-hashtag)', + link: compiled.link ?? 'var(--MI_THEME-link)', + divider: compiled.divider ?? 'var(--MI_THEME-divider)', + accent: compiled.accent ?? 'var(--MI_THEME-accent)', + accentedBg: compiled.accentedBg ?? 'var(--MI_THEME-accentedBg)', + navBg: compiled.navBg ?? 'var(--MI_THEME-navBg)', + pageHeaderBg: compiled.pageHeaderBg ?? 'var(--MI_THEME-pageHeaderBg)', + success: compiled.success ?? 'var(--MI_THEME-success)', + warn: compiled.warn ?? 'var(--MI_THEME-warn)', + error: compiled.error ?? 'var(--MI_THEME-error)', + }; +}, { immediate: true }); +</script> diff --git a/packages/frontend/src/components/MkTimeline.vue b/packages/frontend/src/components/MkTimeline.vue index 7a9abab62e..48e8c7f377 100644 --- a/packages/frontend/src/components/MkTimeline.vue +++ b/packages/frontend/src/components/MkTimeline.vue @@ -5,29 +5,55 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <MkPullToRefresh ref="prComponent" :refresher="() => reloadTimeline()"> - <MkNotes - v-if="paginationQuery" - ref="tlComponent" - :pagination="paginationQuery" - :noGap="!defaultStore.state.showGapBetweenNotesInTimeline" - @queue="emit('queue', $event)" - @status="prComponent?.setDisabled($event)" - /> + <MkPagination v-if="paginationQuery" ref="pagingComponent" :pagination="paginationQuery" @queue="emit('queue', $event)" @status="prComponent?.setDisabled($event)"> + <template #empty> + <div class="_fullinfo"> + <img :src="infoImageUrl" draggable="false"/> + <div>{{ i18n.ts.noNotes }}</div> + </div> + </template> + + <template #default="{ items: notes }"> + <component + :is="prefer.s.animation ? TransitionGroup : 'div'" + :class="[$style.root, { [$style.noGap]: noGap, '_gaps': !noGap, [$style.reverse]: paginationQuery.reversed }]" + :enterActiveClass="$style.transition_x_enterActive" + :leaveActiveClass="$style.transition_x_leaveActive" + :enterFromClass="$style.transition_x_enterFrom" + :leaveToClass="$style.transition_x_leaveTo" + :moveClass=" $style.transition_x_move" + tag="div" + > + <div v-for="(note, i) in notes" :key="note.id"> + <div v-if="note._shouldInsertAd_" :class="[$style.noteWithAd, { '_gaps': !noGap }]" :data-scroll-anchor="note.id"> + <DynamicNote :class="$style.note" :note="note" :withHardMute="true"/> + <div :class="$style.ad"> + <MkAd :preferForms="['horizontal', 'horizontal-big']"/> + </div> + </div> + <DynamicNote v-else :class="$style.note" :note="note" :withHardMute="true" :data-scroll-anchor="note.id"/> + </div> + </component> + </template> + </MkPagination> </MkPullToRefresh> </template> <script lang="ts" setup> -import { computed, watch, onUnmounted, provide, ref, shallowRef } from 'vue'; +import { computed, watch, onUnmounted, provide, useTemplateRef, TransitionGroup } from 'vue'; import * as Misskey from 'misskey-js'; import type { BasicTimelineType } from '@/timelines.js'; -import MkNotes from '@/components/MkNotes.vue'; +import type { Paging } from '@/components/MkPagination.vue'; import MkPullToRefresh from '@/components/MkPullToRefresh.vue'; import { useStream } from '@/stream.js'; -import * as sound from '@/scripts/sound.js'; -import { $i } from '@/account.js'; +import * as sound from '@/utility/sound.js'; +import { $i } from '@/i.js'; import { instance } from '@/instance.js'; -import { defaultStore } from '@/store.js'; -import { Paging } from '@/components/MkPagination.vue'; +import { prefer } from '@/preferences.js'; +import DynamicNote from '@/components/DynamicNote.vue'; +import MkPagination from '@/components/MkPagination.vue'; +import { i18n } from '@/i18n.js'; +import { infoImageUrl } from '@/instance.js'; const props = withDefaults(defineProps<{ src: BasicTimelineType | 'mentions' | 'directs' | 'list' | 'antenna' | 'channel' | 'role'; @@ -59,24 +85,24 @@ provide('tl_withSensitive', computed(() => props.withSensitive)); provide('inChannel', computed(() => props.src === 'channel')); type TimelineQueryType = { - antennaId?: string, - withRenotes?: boolean, - withReplies?: boolean, - withFiles?: boolean, - withBots?: boolean, - visibility?: string, - listId?: string, - channelId?: string, - roleId?: string -} + antennaId?: string, + withRenotes?: boolean, + withReplies?: boolean, + withFiles?: boolean, + withBots?: boolean, + visibility?: string, + listId?: string, + channelId?: string, + roleId?: string +}; -const prComponent = shallowRef<InstanceType<typeof MkPullToRefresh>>(); -const tlComponent = shallowRef<InstanceType<typeof MkNotes>>(); +const prComponent = useTemplateRef('prComponent'); +const pagingComponent = useTemplateRef('pagingComponent'); let tlNotesCount = 0; -function prepend(note) { - if (tlComponent.value == null) return; +function prepend(note: Misskey.entities.Note) { + if (pagingComponent.value == null) return; tlNotesCount++; @@ -84,7 +110,7 @@ function prepend(note) { note._shouldInsertAd_ = true; } - tlComponent.value.pagingComponent?.prepend(note); + pagingComponent.value.prepend(note); emit('note'); @@ -96,6 +122,7 @@ function prepend(note) { let connection: Misskey.ChannelConnection | null = null; let connection2: Misskey.ChannelConnection | null = null; let paginationQuery: Paging | null = null; +const noGap = !prefer.s.showGapBetweenNotesInTimeline; const stream = useStream(); @@ -266,7 +293,7 @@ function updatePaginationQuery() { } function refreshEndpointAndChannel() { - if (!defaultStore.state.disableStreamingTimeline) { + if (!prefer.s.disableStreamingTimeline) { disconnectChannel(); connectChannel(); } @@ -290,11 +317,11 @@ onUnmounted(() => { function reloadTimeline() { return new Promise<void>((res) => { - if (tlComponent.value == null) return; + if (pagingComponent.value == null) return; tlNotesCount = 0; - tlComponent.value.pagingComponent?.reload().then(() => { + pagingComponent.value.reload().then(() => { res(); }); }); @@ -304,3 +331,56 @@ defineExpose({ reloadTimeline, }); </script> + +<style lang="scss" module> +.transition_x_move, +.transition_x_enterActive, +.transition_x_leaveActive { + transition: opacity 0.3s cubic-bezier(0,.5,.5,1), transform 0.3s cubic-bezier(0,.5,.5,1) !important; +} +.transition_x_enterFrom, +.transition_x_leaveTo { + opacity: 0; + transform: translateY(-50%); +} +.transition_x_leaveActive { + position: absolute; +} + +.reverse { + display: flex; + flex-direction: column-reverse; +} + +.root { + container-type: inline-size; + + &.noGap { + background: var(--MI_THEME-panel); + + .note { + border-bottom: solid 0.5px var(--MI_THEME-divider); + } + + .ad { + padding: 8px; + background-size: auto auto; + background-image: repeating-linear-gradient(45deg, transparent, transparent 8px, var(--MI_THEME-bg) 8px, var(--MI_THEME-bg) 14px); + border-bottom: solid 0.5px var(--MI_THEME-divider); + } + } + + &:not(.noGap) { + background: var(--MI_THEME-bg); + + .note { + background: var(--MI_THEME-panel); + border-radius: var(--MI-radius); + } + } +} + +.ad:empty { + display: none; +} +</style> diff --git a/packages/frontend/src/components/MkTl.vue b/packages/frontend/src/components/MkTl.vue new file mode 100644 index 0000000000..95cc4d2a2a --- /dev/null +++ b/packages/frontend/src/components/MkTl.vue @@ -0,0 +1,173 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div :class="$style.items"> + <template v-for="(item, i) in items" :key="item.id"> + <div :class="$style.left"> + <slot v-if="item.type === 'event'" name="left" :event="item.data" :timestamp="item.timestamp" :delta="item.delta"></slot> + </div> + <div :class="[$style.center, item.type === 'date' ? $style.date : '']"> + <div :class="$style.centerLine"></div> + <div :class="$style.centerPoint"></div> + </div> + <div :class="$style.right"> + <slot v-if="item.type === 'event'" name="right" :event="item.data" :timestamp="item.timestamp" :delta="item.delta"></slot> + <div v-else :class="$style.dateLabel"><i class="ti ti-chevron-up"></i> {{ item.prevText }}</div> + </div> + </template> +</div> +</template> + +<script lang="ts" setup> +import { computed } from 'vue'; + +const props = defineProps<{ + events: { + id: string; + timestamp: number; + data: any; + }[]; +}>(); + +const events = computed(() => { + return props.events.toSorted((a, b) => b.timestamp - a.timestamp); +}); + +function getDateText(dateInstance: Date) { + const year = dateInstance.getFullYear(); + const month = dateInstance.getMonth() + 1; + const date = dateInstance.getDate(); + const hour = dateInstance.getHours(); + return `${year.toString()}/${month.toString()}/${date.toString()} ${hour.toString().padStart(2, '0')}:00:00`; +} + +const items = computed<({ + id: string; + type: 'event'; + timestamp: number; + delta: number; + data: any; +} | { + id: string; + type: 'date'; + prev: Date; + prevText: string; + next: Date | null; + nextText: string; +})[]>(() => { + const results = []; + for (let i = 0; i < events.value.length; i++) { + const item = events.value[i]; + + const date = new Date(item.timestamp); + const nextDate = events.value[i + 1] ? new Date(events.value[i + 1].timestamp) : null; + + results.push({ + id: item.id, + type: 'event', + timestamp: item.timestamp, + delta: i === events.value.length - 1 ? 0 : item.timestamp - events.value[i + 1].timestamp, + data: item.data, + }); + + if ( + i !== events.value.length - 1 && + nextDate != null && ( + date.getFullYear() !== nextDate.getFullYear() || + date.getMonth() !== nextDate.getMonth() || + date.getDate() !== nextDate.getDate() || + date.getHours() !== nextDate.getHours() + ) + ) { + results.push({ + id: `date-${item.id}`, + type: 'date', + prev: date, + prevText: getDateText(date), + next: nextDate, + nextText: getDateText(nextDate), + }); + } + } + return results; + }); +</script> + +<style lang="scss" module> +.root { + +} + +.items { + display: grid; + grid-template-columns: max-content 18px 1fr; + gap: 0 8px; +} + +.item { +} + +.center { + position: relative; + + &.date { + .centerPoint::before { + position: absolute; + content: ""; + top: 0; + left: 0; + right: 0; + bottom: 0; + margin: auto; + width: 7px; + height: 7px; + background: var(--MI_THEME-bg); + border-radius: 50%; + } + } +} + +.centerLine { + position: absolute; + top: 0; + left: 0; + right: 0; + margin: auto; + width: 3px; + height: 100%; + background: color-mix(in srgb, var(--MI_THEME-accent), var(--MI_THEME-bg) 75%); +} +.centerPoint { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + margin: auto; + width: 13px; + height: 13px; + background: color-mix(in srgb, var(--MI_THEME-accent), var(--MI_THEME-bg) 75%); + border-radius: 50%; +} + +.left { + min-width: 0; + align-self: center; + justify-self: right; +} + +.right { + min-width: 0; + align-self: center; +} + +.dateLabel { + opacity: 0.7; + font-size: 90%; + padding: 4px; + margin: 8px 0; +} +</style> diff --git a/packages/frontend/src/components/MkToast.vue b/packages/frontend/src/components/MkToast.vue index 38b537cbc9..571835432e 100644 --- a/packages/frontend/src/components/MkToast.vue +++ b/packages/frontend/src/components/MkToast.vue @@ -6,10 +6,10 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div> <Transition - :enterActiveClass="defaultStore.state.animation ? $style.transition_toast_enterActive : ''" - :leaveActiveClass="defaultStore.state.animation ? $style.transition_toast_leaveActive : ''" - :enterFromClass="defaultStore.state.animation ? $style.transition_toast_enterFrom : ''" - :leaveToClass="defaultStore.state.animation ? $style.transition_toast_leaveTo : ''" + :enterActiveClass="prefer.s.animation ? $style.transition_toast_enterActive : ''" + :leaveActiveClass="prefer.s.animation ? $style.transition_toast_leaveActive : ''" + :enterFromClass="prefer.s.animation ? $style.transition_toast_enterFrom : ''" + :leaveToClass="prefer.s.animation ? $style.transition_toast_leaveTo : ''" appear @afterLeave="emit('closed')" > <div v-if="showing" class="_acrylic" :class="$style.root" :style="{ zIndex }"> @@ -25,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { onMounted, ref } from 'vue'; import * as os from '@/os.js'; -import { defaultStore } from '@/store.js'; +import { prefer } from '@/preferences.js'; withDefaults(defineProps<{ message: string; diff --git a/packages/frontend/src/components/MkTokenGenerateWindow.vue b/packages/frontend/src/components/MkTokenGenerateWindow.vue index 73aef68964..42cb6f1e82 100644 --- a/packages/frontend/src/components/MkTokenGenerateWindow.vue +++ b/packages/frontend/src/components/MkTokenGenerateWindow.vue @@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only > <template #header>{{ title || i18n.ts.generateAccessToken }}</template> - <MkSpacer :marginMin="20" :marginMax="28"> + <div class="_spacer" style="--MI_SPACER-min: 20px; --MI_SPACER-max: 28px;"> <div class="_gaps_m"> <div v-if="information"> <MkInfo warn>{{ information }}</MkInfo> @@ -42,12 +42,12 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> </div> - </MkSpacer> + </div> </MkModalWindow> </template> <script lang="ts" setup> -import { shallowRef, ref } from 'vue'; +import { useTemplateRef, ref } from 'vue'; import * as Misskey from 'misskey-js'; import MkInput from './MkInput.vue'; import MkSwitch from './MkSwitch.vue'; @@ -55,7 +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'; +import { iAmAdmin } from '@/i.js'; const props = withDefaults(defineProps<{ title?: string | null; @@ -77,10 +77,10 @@ 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 dialog = useTemplateRef('dialog'); const name = ref(props.initialName); -const permissionSwitches = ref(<Record<(typeof Misskey.permissions)[number], boolean>>{}); -const permissionSwitchesForAdmin = ref(<Record<(typeof Misskey.permissions)[number], boolean>>{}); +const permissionSwitches = ref({} as Record<(typeof Misskey.permissions)[number], boolean>); +const permissionSwitchesForAdmin = ref({} as Record<(typeof Misskey.permissions)[number], boolean>); if (props.initialPermissions) { for (const kind of props.initialPermissions) { diff --git a/packages/frontend/src/components/MkTooltip.vue b/packages/frontend/src/components/MkTooltip.vue index 22e74aa6d1..16913386c1 100644 --- a/packages/frontend/src/components/MkTooltip.vue +++ b/packages/frontend/src/components/MkTooltip.vue @@ -5,11 +5,12 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <Transition - :enterActiveClass="defaultStore.state.animation ? $style.transition_tooltip_enterActive : ''" - :leaveActiveClass="defaultStore.state.animation ? $style.transition_tooltip_leaveActive : ''" - :enterFromClass="defaultStore.state.animation ? $style.transition_tooltip_enterFrom : ''" - :leaveToClass="defaultStore.state.animation ? $style.transition_tooltip_leaveTo : ''" - appear @afterLeave="emit('closed')" + :enterActiveClass="prefer.s.animation ? $style.transition_tooltip_enterActive : ''" + :leaveActiveClass="prefer.s.animation ? $style.transition_tooltip_leaveActive : ''" + :enterFromClass="prefer.s.animation ? $style.transition_tooltip_enterFrom : ''" + :leaveToClass="prefer.s.animation ? $style.transition_tooltip_leaveTo : ''" + appear :css="prefer.s.animation" + @afterLeave="emit('closed')" > <div v-show="showing" ref="el" :class="$style.root" class="_acrylic _shadow" :style="{ zIndex, maxWidth: maxWidth + 'px' }"> <slot> @@ -23,10 +24,10 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { nextTick, onMounted, onUnmounted, shallowRef } from 'vue'; +import { nextTick, onMounted, onUnmounted, useTemplateRef } from 'vue'; import * as os from '@/os.js'; -import { calcPopupPosition } from '@/scripts/popup-position.js'; -import { defaultStore } from '@/store.js'; +import { calcPopupPosition } from '@/utility/popup-position.js'; +import { prefer } from '@/preferences.js'; const props = withDefaults(defineProps<{ showing: boolean; @@ -51,7 +52,7 @@ const emit = defineEmits<{ // タイミングによっては最初から showing = false な場合があり、その場合に closed 扱いにしないと永久にDOMに残ることになる if (!props.showing) emit('closed'); -const el = shallowRef<HTMLElement>(); +const el = useTemplateRef('el'); const zIndex = os.claimZIndex('high'); function setPosition() { diff --git a/packages/frontend/src/components/MkTutorialDialog.Note.vue b/packages/frontend/src/components/MkTutorialDialog.Note.vue index 53b8db38b2..ddb22f7d8c 100644 --- a/packages/frontend/src/components/MkTutorialDialog.Note.vue +++ b/packages/frontend/src/components/MkTutorialDialog.Note.vue @@ -38,7 +38,7 @@ import * as Misskey from 'misskey-js'; import { ref, reactive } from 'vue'; import { i18n } from '@/i18n.js'; import { globalEvents } from '@/events.js'; -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; import MkNote from '@/components/MkNote.vue'; const props = defineProps<{ diff --git a/packages/frontend/src/components/MkTutorialDialog.Sensitive.vue b/packages/frontend/src/components/MkTutorialDialog.Sensitive.vue index e1fc3e4f26..df184ec315 100644 --- a/packages/frontend/src/components/MkTutorialDialog.Sensitive.vue +++ b/packages/frontend/src/components/MkTutorialDialog.Sensitive.vue @@ -31,7 +31,7 @@ import MkPostForm from '@/components/MkPostForm.vue'; import MkFolder from '@/components/MkFolder.vue'; import MkInfo from '@/components/MkInfo.vue'; import MkNote from '@/components/MkNote.vue'; -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; const emit = defineEmits<{ (ev: 'succeeded'): void; diff --git a/packages/frontend/src/components/MkTutorialDialog.vue b/packages/frontend/src/components/MkTutorialDialog.vue index 11d7c8dc4d..d6abbf6504 100644 --- a/packages/frontend/src/components/MkTutorialDialog.vue +++ b/packages/frontend/src/components/MkTutorialDialog.vue @@ -29,7 +29,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template v-if="page === 0"> <div :class="$style.centerPage"> <MkAnimBg style="position: absolute; top: 0;" :scale="1.5"/> - <MkSpacer :marginMin="20" :marginMax="28"> + <div class="_spacer" style="--MI_SPACER-min: 20px; --MI_SPACER-max: 28px;"> <div class="_gaps" style="text-align: center;"> <i class="ti ti-confetti" style="display: block; margin: auto; font-size: 3em; color: var(--MI_THEME-accent);"></i> <div style="font-size: 120%;">{{ i18n.ts._initialTutorial._landing.title }}</div> @@ -37,15 +37,15 @@ SPDX-License-Identifier: AGPL-3.0-only <MkButton primary rounded gradate style="margin: 16px auto 0 auto;" @click="page++">{{ i18n.ts._initialTutorial.launchTutorial }} <i class="ti ti-arrow-right"></i></MkButton> <MkButton style="margin: 0 auto;" transparent rounded @click="close(true)">{{ i18n.ts.close }}</MkButton> </div> - </MkSpacer> + </div> </div> </template> <template v-else-if="page === 1"> <div style="height: 100cqh; overflow: auto;"> <div :class="$style.pageRoot"> - <MkSpacer :marginMin="20" :marginMax="28" :class="$style.pageMain"> + <div class="_spacer" style="--MI_SPACER-min: 20px; --MI_SPACER-max: 28px;" :class="$style.pageMain"> <XNote phase="aboutNote"/> - </MkSpacer> + </div> <div :class="$style.pageFooter"> <div class="_buttonsCenter"> <MkButton v-if="initialPage !== 1" rounded @click="page--"><i class="ti ti-arrow-left"></i> {{ i18n.ts.goBack }}</MkButton> @@ -58,12 +58,12 @@ SPDX-License-Identifier: AGPL-3.0-only <template v-else-if="page === 2"> <div style="height: 100cqh; overflow: auto;"> <div :class="$style.pageRoot"> - <MkSpacer :marginMin="20" :marginMax="28" :class="$style.pageMain"> + <div class="_spacer" style="--MI_SPACER-min: 20px; --MI_SPACER-max: 28px;" :class="$style.pageMain"> <div class="_gaps"> <XNote phase="howToReact" @reacted="isReactionTutorialPushed = true"/> <div v-if="!isReactionTutorialPushed">{{ i18n.ts._initialTutorial._reaction.reactToContinue }}</div> </div> - </MkSpacer> + </div> <div :class="$style.pageFooter"> <div class="_buttonsCenter"> <MkButton v-if="initialPage !== 2" rounded @click="page--"><i class="ti ti-arrow-left"></i> {{ i18n.ts.goBack }}</MkButton> @@ -76,9 +76,9 @@ SPDX-License-Identifier: AGPL-3.0-only <template v-else-if="page === 3"> <div style="height: 100cqh; overflow: auto;"> <div :class="$style.pageRoot"> - <MkSpacer :marginMin="20" :marginMax="28" :class="$style.pageMain"> + <div class="_spacer" style="--MI_SPACER-min: 20px; --MI_SPACER-max: 28px;" :class="$style.pageMain"> <XTimeline/> - </MkSpacer> + </div> <div :class="$style.pageFooter"> <div class="_buttonsCenter"> <MkButton v-if="initialPage !== 3" rounded @click="page--"><i class="ti ti-arrow-left"></i> {{ i18n.ts.goBack }}</MkButton> @@ -91,9 +91,9 @@ SPDX-License-Identifier: AGPL-3.0-only <template v-else-if="page === 4"> <div style="height: 100cqh; overflow: auto;"> <div :class="$style.pageRoot"> - <MkSpacer :marginMin="20" :marginMax="28" :class="$style.pageMain"> + <div class="_spacer" style="--MI_SPACER-min: 20px; --MI_SPACER-max: 28px;" :class="$style.pageMain"> <XPostNote/> - </MkSpacer> + </div> <div :class="$style.pageFooter"> <div class="_buttonsCenter"> <MkButton v-if="initialPage !== 3" rounded @click="page--"><i class="ti ti-arrow-left"></i> {{ i18n.ts.goBack }}</MkButton> @@ -106,12 +106,12 @@ SPDX-License-Identifier: AGPL-3.0-only <template v-else-if="page === 5"> <div style="height: 100cqh; overflow: auto;"> <div :class="$style.pageRoot"> - <MkSpacer :marginMin="20" :marginMax="28" :class="$style.pageMain"> + <div class="_spacer" style="--MI_SPACER-min: 20px; --MI_SPACER-max: 28px;" :class="$style.pageMain"> <div class="_gaps"> <XSensitive @succeeded="isSensitiveTutorialSucceeded = true"/> <div v-if="!isSensitiveTutorialSucceeded">{{ i18n.ts._initialTutorial._howToMakeAttachmentsSensitive.doItToContinue }}</div> </div> - </MkSpacer> + </div> <div :class="$style.pageFooter"> <div class="_buttonsCenter"> <MkButton v-if="initialPage !== 2" rounded @click="page--"><i class="ti ti-arrow-left"></i> {{ i18n.ts.goBack }}</MkButton> @@ -124,7 +124,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template v-else-if="page === 6"> <div :class="$style.centerPage"> <MkAnimBg style="position: absolute; top: 0;" :scale="1.5"/> - <MkSpacer :marginMin="20" :marginMax="28"> + <div class="_spacer" style="--MI_SPACER-min: 20px; --MI_SPACER-max: 28px;"> <div class="_gaps" style="text-align: center;"> <i class="ti ti-check" style="display: block; margin: auto; font-size: 3em; color: var(--MI_THEME-accent);"></i> <div style="font-size: 120%;">{{ i18n.ts._initialTutorial._done.title }}</div> @@ -139,7 +139,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkButton rounded primary gradate @click="close(false)">{{ i18n.ts.close }}</MkButton> </div> </div> - </MkSpacer> + </div> </div> </template> </Transition> @@ -148,7 +148,8 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ref, shallowRef, watch } from 'vue'; +import { ref, useTemplateRef, watch } from 'vue'; +import { host } from '@@/js/config.js'; import MkModalWindow from '@/components/MkModalWindow.vue'; import MkButton from '@/components/MkButton.vue'; import XNote from '@/components/MkTutorialDialog.Note.vue'; @@ -158,8 +159,7 @@ import XSensitive from '@/components/MkTutorialDialog.Sensitive.vue'; import MkAnimBg from '@/components/MkAnimBg.vue'; import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; -import { host } from '@@/js/config.js'; -import { claimAchievement } from '@/scripts/achievements.js'; +import { claimAchievement } from '@/utility/achievements.js'; import * as os from '@/os.js'; const props = defineProps<{ @@ -170,7 +170,7 @@ const emit = defineEmits<{ (ev: 'closed'): void; }>(); -const dialog = shallowRef<InstanceType<typeof MkModalWindow>>(); +const dialog = useTemplateRef('dialog'); // eslint-disable-next-line vue/no-setup-props-reactivity-loss const page = ref(props.initialPage ?? 0); @@ -249,6 +249,7 @@ async function close(skip: boolean) { .pageFooter { position: sticky; + z-index: 1; bottom: 0; left: 0; flex-shrink: 0; diff --git a/packages/frontend/src/components/MkUpdated.vue b/packages/frontend/src/components/MkUpdated.vue index 7cafb1b0af..3685be6359 100644 --- a/packages/frontend/src/components/MkUpdated.vue +++ b/packages/frontend/src/components/MkUpdated.vue @@ -15,15 +15,15 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, shallowRef } from 'vue'; +import { onMounted, useTemplateRef } from 'vue'; +import { version } from '@@/js/config.js'; import MkModal from '@/components/MkModal.vue'; import MkButton from '@/components/MkButton.vue'; import MkSparkle from '@/components/MkSparkle.vue'; -import { version } from '@@/js/config.js'; import { i18n } from '@/i18n.js'; -import { confetti } from '@/scripts/confetti.js'; +import { confetti } from '@/utility/confetti.js'; -const modal = shallowRef<InstanceType<typeof MkModal>>(); +const modal = useTemplateRef('modal'); const whatIsNew = () => { modal.value?.close(); diff --git a/packages/frontend/src/components/MkUrlPreview.vue b/packages/frontend/src/components/MkUrlPreview.vue index 2f1933a87b..a14c2ecef9 100644 --- a/packages/frontend/src/components/MkUrlPreview.vue +++ b/packages/frontend/src/components/MkUrlPreview.vue @@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only > <iframe v-if="player.url.startsWith('http://') || player.url.startsWith('https://')" - sandbox="allow-popups allow-scripts allow-storage-access-by-user-activation allow-same-origin" + sandbox="allow-popups allow-popups-to-escape-sandbox allow-scripts allow-storage-access-by-user-activation allow-same-origin" scrolling="no" :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" @@ -34,7 +34,7 @@ SPDX-License-Identifier: AGPL-3.0-only sandbox="allow-popups allow-popups-to-escape-sandbox allow-scripts allow-same-origin" scrolling="no" :style="{ position: 'relative', width: '100%', height: `${tweetHeight}px`, border: 0 }" - :src="`https://platform.twitter.com/embed/index.html?embedId=${embedId}&hideCard=false&hideThread=false&lang=en&theme=${defaultStore.state.darkMode ? 'dark' : 'light'}&id=${tweetId}`" + :src="`https://platform.twitter.com/embed/index.html?embedId=${embedId}&hideCard=false&hideThread=false&lang=en&theme=${store.s.darkMode ? 'dark' : 'light'}&id=${tweetId}`" ></iframe> </div> <div :class="$style.action"> @@ -43,10 +43,10 @@ SPDX-License-Identifier: AGPL-3.0-only </MkButton> </div> </template> -<div v-else-if="theNote" :class="[$style.link, { [$style.compact]: compact }]"><XNoteSimple :note="theNote" :class="$style.body"/></div> +<div v-else-if="theNote" :class="[$style.link, { [$style.compact]: compact }]"><DynamicNoteSimple :note="theNote" :class="$style.body"/></div> <div v-else-if="!hidePreview"> - <component :is="self ? 'MkA' : 'a'" :class="[$style.link, { [$style.compact]: compact }]" :[attr]="maybeRelativeUrl" rel="nofollow noopener" :target="target" :title="url"> - <div v-if="thumbnail && !sensitive" :class="$style.thumbnail" :style="defaultStore.state.dataSaver.urlPreview ? '' : { backgroundImage: `url('${thumbnail}')` }"> + <component :is="self ? 'MkA' : 'a'" :class="[$style.link, { [$style.compact]: compact }]" :[attr]="maybeRelativeUrl" rel="nofollow noopener" :target="target" :title="url" @click.prevent="self ? true : warningExternalWebsite(url)" @click.stop> + <div v-if="thumbnail && !sensitive" :class="$style.thumbnail" :style="prefer.s.dataSaver.urlPreview ? '' : { backgroundImage: `url('${thumbnail}')` }"> </div> <article :class="$style.body"> <header :class="$style.header"> @@ -71,6 +71,11 @@ SPDX-License-Identifier: AGPL-3.0-only <i class="ti ti-brand-x"></i> {{ i18n.ts.expandTweet }} </MkButton> </div> + <div v-if="showAsQuote && activityPub && !theNote && $i" :class="$style.action"> + <MkButton :small="true" :disabled="!!fetching || fetchingTheNote" inline @click="() => refresh(true)"> + <i class="ti ti-note"></i> {{ i18n.ts.fetchLinkedNote }} + </MkButton> + </div> <div v-if="!playerEnabled && player.url" :class="$style.action"> <MkButton :small="true" inline @click="playerEnabled = true"> <i class="ti ti-player-play"></i> {{ i18n.ts.enablePlayer }} @@ -84,27 +89,23 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { defineAsyncComponent, onDeactivated, onUnmounted, ref, watch } from 'vue'; +import { defineAsyncComponent, onDeactivated, onUnmounted, ref } from 'vue'; import { url as local } from '@@/js/config.js'; import { versatileLang } from '@@/js/intl-const.js'; import * as Misskey from 'misskey-js'; -import type { summaly } from '@transfem-org/summaly'; -import type MkNoteSimple from '@/components/MkNoteSimple.vue'; -import type SkNoteSimple from '@/components/SkNoteSimple.vue'; +import { maybeMakeRelative } from '@@/js/url.js'; +import type { summaly } from '@misskey-dev/summaly'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; -import { deviceKind } from '@/scripts/device-kind.js'; +import { deviceKind } from '@/utility/device-kind.js'; import MkButton from '@/components/MkButton.vue'; -import { transformPlayerUrl } from '@/scripts/player-url-transform.js'; -import { defaultStore } from '@/store.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { maybeMakeRelative } from '@@/js/url.js'; - -const XNoteSimple = defineAsyncComponent<typeof MkNoteSimple | typeof SkNoteSimple>(() => - defaultStore.state.noteDesign === 'misskey' - ? import('@/components/MkNoteSimple.vue') - : import('@/components/SkNoteSimple.vue'), -); +import { transformPlayerUrl } from '@/utility/player-url-transform.js'; +import { store } from '@/store.js'; +import { prefer } from '@/preferences.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { warningExternalWebsite } from '@/utility/warning-external-website.js'; +import DynamicNoteSimple from '@/components/DynamicNoteSimple.vue'; +import { $i } from '@/i'; type SummalyResult = Awaited<ReturnType<typeof summaly>>; @@ -131,7 +132,7 @@ const maybeRelativeUrl = maybeMakeRelative(props.url, local); const self = maybeRelativeUrl !== props.url; const attr = self ? 'to' : 'href'; const target = self ? null : '_blank'; -const fetching = ref(true); +const fetching = ref<Promise<void> | null>(null); const title = ref<string | null>(null); const description = ref<string | null>(null); const thumbnail = ref<string | null>(null); @@ -139,11 +140,12 @@ const icon = ref<string | null>(null); const sitename = ref<string | null>(null); const sensitive = ref<boolean>(false); const activityPub = ref<string | null>(null); -const player = ref({ +const player = ref<SummalyResult['player']>({ url: null, width: null, height: null, -} as SummalyResult['player']); + allow: [], +}); const playerEnabled = ref(false); const tweetId = ref<string | null>(null); const tweetExpanded = ref(props.detail); @@ -151,29 +153,38 @@ const embedId = `embed${Math.random().toString().replace(/\D/, '')}`; const tweetHeight = ref(150); const unknownUrl = ref(false); const theNote = ref<Misskey.entities.Note | null>(null); +const fetchingTheNote = ref(false); onDeactivated(() => { playerEnabled.value = false; }); -watch(activityPub, async (uri) => { - if (!props.showAsQuote) return; - if (!uri) return; - try { - const response = await misskeyApi('ap/show', { uri }); - if (response.type !== 'Note') return; - const theNoteId = response['object'].id; - if (theNoteId && props.skipNoteIds && props.skipNoteIds.includes(theNoteId)) { - hidePreview.value = true; - return; - } - theNote.value = response['object']; - } catch (err) { - if (_DEV_) { - console.error(`failed to extract note for preview of ${uri}`, err); - } +async function fetchNote() { + if (!props.showAsQuote) return; + if (!activityPub.value) return; + if (theNote.value) return; + if (fetchingTheNote.value) return; + + fetchingTheNote.value = true; + try { + const response = await misskeyApi('ap/show', { uri: activityPub.value }); + if (response.type !== 'Note') return; + const theNoteId = response['object'].id; + if (theNoteId && props.skipNoteIds && props.skipNoteIds.includes(theNoteId)) { + hidePreview.value = true; + return; } -}); + theNote.value = response['object']; + } catch (err) { + if (_DEV_) { + console.error(`failed to extract note for preview of ${activityPub.value}`, err); + } + activityPub.value = null; + theNote.value = null; + } finally { + fetchingTheNote.value = false; + } +} const requestUrl = new URL(props.url); if (!['http:', 'https:'].includes(requestUrl.protocol)) throw new Error('invalid url'); @@ -189,36 +200,52 @@ if (requestUrl.hostname === 'music.youtube.com' && requestUrl.pathname.match('^/ requestUrl.hash = ''; -window.fetch(`/url?url=${encodeURIComponent(requestUrl.href)}&lang=${versatileLang}`) - .then(res => { - if (!res.ok) { - if (_DEV_) { - console.warn(`[HTTP${res.status}] Failed to fetch url preview`); - } - return null; - } +function refresh(withFetch = false) { + const params = new URLSearchParams({ + url: requestUrl.href, + lang: versatileLang, + }); + if (withFetch) { + params.set('fetch', 'true'); + } - return res.json(); - }) - .then((info: SummalyResult | null) => { - if (!info || info.url == null) { - fetching.value = false; - unknownUrl.value = true; - return; - } + const headers = $i ? { Authorization: `Bearer ${$i.token}` } : undefined; + return fetching.value ??= window.fetch(`/url?${params.toString()}`, { headers }) + .then(res => { + if (!res.ok) { + if (_DEV_) { + console.warn(`[HTTP${res.status}] Failed to fetch url preview`); + } + return null; + } - fetching.value = false; - unknownUrl.value = false; + return res.json(); + }) + .then(async (info: SummalyResult & { haveNoteLocally?: boolean } | null) => { + unknownUrl.value = info == null; + title.value = info?.title ?? null; + description.value = info?.description ?? null; + thumbnail.value = info?.thumbnail ?? null; + icon.value = info?.icon ?? null; + sitename.value = info?.sitename ?? null; + player.value = info?.player ?? { + url: null, + width: null, + height: null, + allow: [], + }; + sensitive.value = info?.sensitive ?? false; + activityPub.value = info?.activityPub ?? null; - title.value = info.title; - description.value = info.description; - thumbnail.value = info.thumbnail; - icon.value = info.icon; - sitename.value = info.sitename; - player.value = info.player; - sensitive.value = info.sensitive ?? false; - activityPub.value = info.activityPub; - }); + theNote.value = null; + if (info?.haveNoteLocally) { + await fetchNote(); + } + }) + .finally(() => { + fetching.value = null; + }); +} function adjustTweetHeight(message: MessageEvent) { if (message.origin !== 'https://platform.twitter.com') return; @@ -244,6 +271,9 @@ window.addEventListener('message', adjustTweetHeight); onUnmounted(() => { window.removeEventListener('message', adjustTweetHeight); }); + +// Load initial data +refresh(); </script> <style lang="scss" module> @@ -285,6 +315,7 @@ onUnmounted(() => { box-shadow: 0 0 0 1px var(--MI_THEME-divider); border-radius: var(--MI-radius-sm); overflow: clip; + text-align: left; &:hover { text-decoration: none; diff --git a/packages/frontend/src/components/MkUrlPreviewPopup.vue b/packages/frontend/src/components/MkUrlPreviewPopup.vue index e972973dba..fd36d6a82b 100644 --- a/packages/frontend/src/components/MkUrlPreviewPopup.vue +++ b/packages/frontend/src/components/MkUrlPreviewPopup.vue @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div :class="$style.root" :style="{ zIndex, top: top + 'px', left: left + 'px' }"> - <Transition :name="defaultStore.state.animation ? '_transition_zoom' : ''" @afterLeave="emit('closed')"> + <Transition :name="prefer.s.animation ? '_transition_zoom' : ''" @afterLeave="emit('closed')"> <MkUrlPreview v-if="showing" class="_popup _shadow" :url="url" :showActions="false"/> </Transition> </div> @@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { onMounted, ref } from 'vue'; import MkUrlPreview from '@/components/MkUrlPreview.vue'; import * as os from '@/os.js'; -import { defaultStore } from '@/store.js'; +import { prefer } from '@/preferences.js'; const props = defineProps<{ showing: boolean; diff --git a/packages/frontend/src/components/MkUrlWarningDialog.vue b/packages/frontend/src/components/MkUrlWarningDialog.vue index 3bec6eecdd..01ecba1817 100644 --- a/packages/frontend/src/components/MkUrlWarningDialog.vue +++ b/packages/frontend/src/components/MkUrlWarningDialog.vue @@ -34,7 +34,7 @@ import MkModal from '@/components/MkModal.vue'; import MkButton from '@/components/MkButton.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import { i18n } from '@/i18n.js'; -import { defaultStore } from '@/store.js'; +import { prefer } from '@/preferences.js'; type Result = string | number | true | null; @@ -55,6 +55,7 @@ const domain = computed(() => new URL(props.url).hostname); // 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(); @@ -62,8 +63,8 @@ function done(canceled: boolean, result?: Result): void { // eslint-disable-line async function ok() { const result = true; - if (!defaultStore.state.trustedDomains.includes(domain.value) && trustThisDomain.value) { - await defaultStore.set('trustedDomains', defaultStore.state.trustedDomains.concat(domain.value)); + if (!prefer.s.trustedDomains.includes(domain.value) && trustThisDomain.value) { + prefer.r.trustedDomains.value = prefer.s.trustedDomains.concat(domain.value); } done(false, result); } @@ -77,11 +78,11 @@ function onKeydown(evt: KeyboardEvent) { } onMounted(() => { - document.addEventListener('keydown', onKeydown); + window.document.addEventListener('keydown', onKeydown); }); onBeforeUnmount(() => { - document.removeEventListener('keydown', onKeydown); + window.document.removeEventListener('keydown', onKeydown); }); </script> diff --git a/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue b/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue index fe499fabbf..aaefa5036a 100644 --- a/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue +++ b/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue @@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template v-else #header>New announcement</template> <div> - <MkSpacer :marginMin="20" :marginMax="28"> + <div class="_spacer" style="--MI_SPACER-min: 20px; --MI_SPACER-max: 28px;"> <div class="_gaps_m"> <MkInput v-model="title"> <template #label>{{ i18n.ts.title }}</template> @@ -41,7 +41,7 @@ SPDX-License-Identifier: AGPL-3.0-only </MkSwitch> <MkButton v-if="announcement" danger @click="del()"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton> </div> - </MkSpacer> + </div> <div :class="$style.footer"> <MkButton primary rounded style="margin: 0 auto;" @click="done"><i class="ti ti-check"></i> {{ props.announcement ? i18n.ts.update : i18n.ts.create }}</MkButton> </div> @@ -56,13 +56,13 @@ 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 { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; import MkTextarea from '@/components/MkTextarea.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import MkRadios from '@/components/MkRadios.vue'; -type AdminAnnouncementType = Misskey.entities.AdminAnnouncementsCreateRequest & { id: string; } +type AdminAnnouncementType = Misskey.entities.AdminAnnouncementsCreateRequest & { id: string; }; const props = defineProps<{ user: Misskey.entities.User, diff --git a/packages/frontend/src/components/MkUserCardMini.vue b/packages/frontend/src/components/MkUserCardMini.vue index ce28f6ec5e..e2e42e78f3 100644 --- a/packages/frontend/src/components/MkUserCardMini.vue +++ b/packages/frontend/src/components/MkUserCardMini.vue @@ -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 { misskeyApiGet } from '@/scripts/misskey-api.js'; +import { misskeyApiGet } from '@/utility/misskey-api.js'; import { acct } from '@/filters/user.js'; const props = withDefaults(defineProps<{ diff --git a/packages/frontend/src/components/MkUserInfo.vue b/packages/frontend/src/components/MkUserInfo.vue index 7e805dc904..df358ae3c3 100644 --- a/packages/frontend/src/components/MkUserInfo.vue +++ b/packages/frontend/src/components/MkUserInfo.vue @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div class="_panel" :class="$style.root"> - <div :class="$style.banner" :style="user.bannerUrl ? { backgroundImage: `url(${defaultStore.state.disableShowingAnimatedImages ? getStaticImageUrl(user.bannerUrl) : user.bannerUrl})` } : ''"></div> + <div :class="$style.banner" :style="user.bannerUrl ? { backgroundImage: `url(${prefer.s.disableShowingAnimatedImages ? getStaticImageUrl(user.bannerUrl) : user.bannerUrl})` } : ''"></div> <MkAvatar :class="$style.avatar" :user="user" indicator/> <div :class="$style.title"> <MkA :class="$style.name" :to="userPage(user)"><MkUserName :user="user" :nowrap="false"/></MkA> @@ -47,10 +47,10 @@ import MkFollowButton from '@/components/MkFollowButton.vue'; import number from '@/filters/number.js'; import { userPage } from '@/filters/user.js'; import { i18n } from '@/i18n.js'; -import { $i } from '@/account.js'; -import { isFollowingVisibleForMe, isFollowersVisibleForMe } from '@/scripts/isFfVisibleForMe.js'; -import { getStaticImageUrl } from '@/scripts/media-proxy.js'; -import { defaultStore } from '@/store.js'; +import { $i } from '@/i.js'; +import { isFollowingVisibleForMe, isFollowersVisibleForMe } from '@/utility/isFfVisibleForMe.js'; +import { getStaticImageUrl } from '@/utility/media-proxy.js'; +import { prefer } from '@/preferences.js'; defineProps<{ user: Misskey.entities.UserDetailed; diff --git a/packages/frontend/src/components/MkUserList.vue b/packages/frontend/src/components/MkUserList.vue index 8dc01a08ab..785efe6e33 100644 --- a/packages/frontend/src/components/MkUserList.vue +++ b/packages/frontend/src/components/MkUserList.vue @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkPagination :pagination="pagination" :displayLimit="50"> <template #empty> <div class="_fullinfo"> - <img :src="infoImageUrl" class="_ghost"/> + <img :src="infoImageUrl" draggable="false"/> <div>{{ i18n.ts.noUsers }}</div> </div> </template> @@ -21,8 +21,9 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> +import type { Paging } from '@/components/MkPagination.vue'; import MkUserInfo from '@/components/MkUserInfo.vue'; -import MkPagination, { Paging } from '@/components/MkPagination.vue'; +import MkPagination from '@/components/MkPagination.vue'; import { i18n } from '@/i18n.js'; import { infoImageUrl } from '@/instance.js'; diff --git a/packages/frontend/src/components/MkUserPopup.vue b/packages/frontend/src/components/MkUserPopup.vue index f54b559fcf..bfa51c3baf 100644 --- a/packages/frontend/src/components/MkUserPopup.vue +++ b/packages/frontend/src/components/MkUserPopup.vue @@ -5,15 +5,15 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <Transition - :enterActiveClass="defaultStore.state.animation ? $style.transition_popup_enterActive : ''" - :leaveActiveClass="defaultStore.state.animation ? $style.transition_popup_leaveActive : ''" - :enterFromClass="defaultStore.state.animation ? $style.transition_popup_enterFrom : ''" - :leaveToClass="defaultStore.state.animation ? $style.transition_popup_leaveTo : ''" + :enterActiveClass="prefer.s.animation ? $style.transition_popup_enterActive : ''" + :leaveActiveClass="prefer.s.animation ? $style.transition_popup_leaveActive : ''" + :enterFromClass="prefer.s.animation ? $style.transition_popup_enterFrom : ''" + :leaveToClass="prefer.s.animation ? $style.transition_popup_leaveTo : ''" appear @afterLeave="emit('closed')" > <div v-if="showing" :class="$style.root" class="_popup _shadow" :style="{ zIndex, top: top + 'px', left: left + 'px' }" @mouseover="() => { emit('mouseover'); }" @mouseleave="() => { emit('mouseleave'); }"> <div v-if="user != null"> - <div :class="$style.banner" :style="user.bannerUrl ? { backgroundImage: `url(${defaultStore.state.disableShowingAnimatedImages ? getStaticImageUrl(user.bannerUrl) : user.bannerUrl})` } : ''"> + <div :class="$style.banner" :style="user.bannerUrl ? { backgroundImage: `url(${prefer.s.disableShowingAnimatedImages ? getStaticImageUrl(user.bannerUrl) : user.bannerUrl})` } : ''"> <span v-if="$i && $i.id != user.id && user.isFollowed && user.isFollowing" :class="$style.followed">{{ i18n.ts.mutuals }}</span> <span v-else-if="$i && $i.id != user.id && user.isFollowed" :class="$style.followed">{{ i18n.ts.followsYou }}</span> <span v-else-if="$i && $i.id != user.id && user.isFollowing" :class="$style.followed">{{ i18n.ts.following }}</span> @@ -74,14 +74,14 @@ 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 { misskeyApi } from '@/utility/misskey-api.js'; +import { getUserMenu } from '@/utility/get-user-menu.js'; import number from '@/filters/number.js'; import { i18n } from '@/i18n.js'; -import { defaultStore } from '@/store.js'; -import { $i } from '@/account.js'; -import { isFollowingVisibleForMe, isFollowersVisibleForMe } from '@/scripts/isFfVisibleForMe.js'; -import { getStaticImageUrl } from '@/scripts/media-proxy.js'; +import { prefer } from '@/preferences.js'; +import { $i } from '@/i.js'; +import { isFollowingVisibleForMe, isFollowersVisibleForMe } from '@/utility/isFfVisibleForMe.js'; +import { getStaticImageUrl } from '@/utility/media-proxy.js'; const props = defineProps<{ showing: boolean; @@ -298,7 +298,7 @@ onMounted(() => { .statusItemLabel { font-size: 0.7em; - color: var(--MI_THEME-fgTransparentWeak); + color: color(from var(--MI_THEME-fg) srgb r g b / 0.75); } .menu { diff --git a/packages/frontend/src/components/MkUserSelectDialog.vue b/packages/frontend/src/components/MkUserSelectDialog.vue index 120f19cb7f..34ed1f8fc2 100644 --- a/packages/frontend/src/components/MkUserSelectDialog.vue +++ b/packages/frontend/src/components/MkUserSelectDialog.vue @@ -61,17 +61,17 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, ref, computed, shallowRef } from 'vue'; +import { onMounted, ref, computed, useTemplateRef } from 'vue'; import * as Misskey from 'misskey-js'; +import { host as currentHost, hostname } from '@@/js/config.js'; import MkInput from '@/components/MkInput.vue'; import FormSplit from '@/components/form/split.vue'; import MkModalWindow from '@/components/MkModalWindow.vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { defaultStore } from '@/store.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { store } from '@/store.js'; import { i18n } from '@/i18n.js'; -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; import { instance } from '@/instance.js'; -import { host as currentHost, hostname } from '@@/js/config.js'; const emit = defineEmits<{ (ev: 'ok', selected: Misskey.entities.UserDetailed): void; @@ -94,7 +94,7 @@ const host = ref(''); const users = ref<Misskey.entities.UserLite[]>([]); const recentUsers = ref<Misskey.entities.UserDetailed[]>([]); const selected = ref<Misskey.entities.UserLite | null>(null); -const dialogEl = shallowRef<InstanceType<typeof MkModalWindow>>(); +const dialogEl = useTemplateRef('dialogEl'); function search() { if (username.value === '' && host.value === '') { @@ -128,10 +128,10 @@ async function ok() { dialogEl.value?.close(); // 最近使ったユーザー更新 - let recents = defaultStore.state.recentlyUsedUsers; + let recents = store.s.recentlyUsedUsers; recents = recents.filter(x => x !== selected.value?.id); recents.unshift(selected.value.id); - defaultStore.set('recentlyUsedUsers', recents.splice(0, 16)); + store.set('recentlyUsedUsers', recents.splice(0, 16)); } function cancel() { @@ -141,7 +141,7 @@ function cancel() { onMounted(() => { misskeyApi('users/show', { - userIds: defaultStore.state.recentlyUsedUsers, + userIds: store.s.recentlyUsedUsers, }).then(foundUsers => { let _users = foundUsers; _users = _users.filter((u) => { @@ -198,7 +198,7 @@ onMounted(() => { font-size: 14px; &:hover { - background: var(--MI_THEME-X7); + background: light-dark(rgba(0, 0, 0, 0.05), rgba(255, 255, 255, 0.05)); } &.selected { diff --git a/packages/frontend/src/components/MkUserSetupDialog.Follow.stories.impl.ts b/packages/frontend/src/components/MkUserSetupDialog.Follow.stories.impl.ts index 638bfb4372..52467893a0 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.Follow.stories.impl.ts +++ b/packages/frontend/src/components/MkUserSetupDialog.Follow.stories.impl.ts @@ -4,7 +4,7 @@ */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { HttpResponse, http } from 'msw'; import { commonHandlers } from '../../.storybook/mocks.js'; import { userDetailed } from '../../.storybook/fakes.js'; diff --git a/packages/frontend/src/components/MkUserSetupDialog.Follow.vue b/packages/frontend/src/components/MkUserSetupDialog.Follow.vue index 5153c06139..67a06c70db 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.Follow.vue +++ b/packages/frontend/src/components/MkUserSetupDialog.Follow.vue @@ -38,7 +38,8 @@ 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, { type Paging } from '@/components/MkPagination.vue'; +import MkPagination from '@/components/MkPagination.vue'; +import type { Paging } from '@/components/MkPagination.vue'; const pinnedUsers: Paging = { endpoint: 'pinned-users', diff --git a/packages/frontend/src/components/MkUserSetupDialog.Privacy.stories.impl.ts b/packages/frontend/src/components/MkUserSetupDialog.Privacy.stories.impl.ts index 2a7947c6f8..0ada259d3f 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.Privacy.stories.impl.ts +++ b/packages/frontend/src/components/MkUserSetupDialog.Privacy.stories.impl.ts @@ -4,7 +4,7 @@ */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import MkUserSetupDialog_Privacy from './MkUserSetupDialog.Privacy.vue'; export const Default = { render(args) { diff --git a/packages/frontend/src/components/MkUserSetupDialog.Privacy.vue b/packages/frontend/src/components/MkUserSetupDialog.Privacy.vue index fb4a2b1c78..5e68bfeff3 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.Privacy.vue +++ b/packages/frontend/src/components/MkUserSetupDialog.Privacy.vue @@ -41,7 +41,7 @@ import { i18n } from '@/i18n.js'; import MkSwitch from '@/components/MkSwitch.vue'; import MkInfo from '@/components/MkInfo.vue'; import MkFolder from '@/components/MkFolder.vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; const isLocked = ref(false); const hideOnlineStatus = ref(true); diff --git a/packages/frontend/src/components/MkUserSetupDialog.Profile.stories.impl.ts b/packages/frontend/src/components/MkUserSetupDialog.Profile.stories.impl.ts index c6088a5ae3..cefd48cb01 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.Profile.stories.impl.ts +++ b/packages/frontend/src/components/MkUserSetupDialog.Profile.stories.impl.ts @@ -4,7 +4,7 @@ */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import MkUserSetupDialog_Profile from './MkUserSetupDialog.Profile.vue'; export const Default = { render(args) { diff --git a/packages/frontend/src/components/MkUserSetupDialog.Profile.vue b/packages/frontend/src/components/MkUserSetupDialog.Profile.vue index 7cb48f6afb..30925b854c 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.Profile.vue +++ b/packages/frontend/src/components/MkUserSetupDialog.Profile.vue @@ -37,11 +37,11 @@ import MkInput from '@/components/MkInput.vue'; import MkTextarea from '@/components/MkTextarea.vue'; import FormSlot from '@/components/form/slot.vue'; import MkInfo from '@/components/MkInfo.vue'; -import { chooseFileFromPc } from '@/scripts/select-file.js'; +import { chooseFileFromPc } from '@/utility/select-file.js'; import * as os from '@/os.js'; -import { signinRequired } from '@/account.js'; +import { ensureSignin } from '@/i.js'; -const $i = signinRequired(); +const $i = ensureSignin(); const name = ref($i.name ?? ''); const description = ref($i.description ?? ''); diff --git a/packages/frontend/src/components/MkUserSetupDialog.User.stories.impl.ts b/packages/frontend/src/components/MkUserSetupDialog.User.stories.impl.ts index f0206e0cb4..b424632bdc 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.User.stories.impl.ts +++ b/packages/frontend/src/components/MkUserSetupDialog.User.stories.impl.ts @@ -4,7 +4,7 @@ */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { userDetailed } from '../../.storybook/fakes.js'; import MkUserSetupDialog_User from './MkUserSetupDialog.User.vue'; export const Default = { diff --git a/packages/frontend/src/components/MkUserSetupDialog.User.vue b/packages/frontend/src/components/MkUserSetupDialog.User.vue index 421fdcc9e3..e301c7ce3a 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.User.vue +++ b/packages/frontend/src/components/MkUserSetupDialog.User.vue @@ -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 { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; const props = defineProps<{ user: Misskey.entities.UserDetailed; diff --git a/packages/frontend/src/components/MkUserSetupDialog.stories.impl.ts b/packages/frontend/src/components/MkUserSetupDialog.stories.impl.ts index 3f5ae734bd..751391c2d8 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.stories.impl.ts +++ b/packages/frontend/src/components/MkUserSetupDialog.stories.impl.ts @@ -4,7 +4,7 @@ */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { HttpResponse, http } from 'msw'; import { commonHandlers } from '../../.storybook/mocks.js'; import { userDetailed } from '../../.storybook/fakes.js'; diff --git a/packages/frontend/src/components/MkUserSetupDialog.vue b/packages/frontend/src/components/MkUserSetupDialog.vue index b7261129ef..82214ed5a5 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.vue +++ b/packages/frontend/src/components/MkUserSetupDialog.vue @@ -33,7 +33,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template v-if="page === 0"> <div :class="$style.centerPage"> <MkAnimBg style="position: absolute; top: 0;" :scale="1.5"/> - <MkSpacer :marginMin="20" :marginMax="28"> + <div class="_spacer" style="--MI_SPACER-min: 20px; --MI_SPACER-max: 28px;"> <div class="_gaps" style="text-align: center;"> <i class="ti ti-confetti" style="display: block; margin: auto; font-size: 3em; color: var(--MI_THEME-accent);"></i> <div style="font-size: 120%;">{{ i18n.ts._initialAccountSetting.accountCreated }}</div> @@ -41,15 +41,15 @@ SPDX-License-Identifier: AGPL-3.0-only <MkButton primary rounded gradate style="margin: 16px auto 0 auto;" data-cy-user-setup-continue @click="page++">{{ i18n.ts._initialAccountSetting.profileSetting }} <i class="ti ti-arrow-right"></i></MkButton> <MkButton style="margin: 0 auto;" transparent rounded @click="later(true)">{{ i18n.ts.later }}</MkButton> </div> - </MkSpacer> + </div> </div> </template> <template v-else-if="page === 1"> <div style="height: 100cqh; overflow: auto;"> <div :class="$style.pageRoot"> - <MkSpacer :marginMin="20" :marginMax="28" :class="$style.pageMain"> + <div class="_spacer" style="--MI_SPACER-min: 20px; --MI_SPACER-max: 28px;" :class="$style.pageMain"> <XProfile/> - </MkSpacer> + </div> <div :class="$style.pageFooter"> <div class="_buttonsCenter"> <MkButton rounded data-cy-user-setup-back @click="page--"><i class="ti ti-arrow-left"></i> {{ i18n.ts.goBack }}</MkButton> @@ -62,9 +62,9 @@ SPDX-License-Identifier: AGPL-3.0-only <template v-else-if="page === 2"> <div style="height: 100cqh; overflow: auto;"> <div :class="$style.pageRoot"> - <MkSpacer :marginMin="20" :marginMax="28" :class="$style.pageMain"> + <div class="_spacer" style="--MI_SPACER-min: 20px; --MI_SPACER-max: 28px;" :class="$style.pageMain"> <XPrivacy/> - </MkSpacer> + </div> <div :class="$style.pageFooter"> <div class="_buttonsCenter"> <MkButton rounded data-cy-user-setup-back @click="page--"><i class="ti ti-arrow-left"></i> {{ i18n.ts.goBack }}</MkButton> @@ -76,9 +76,9 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <template v-else-if="page === 3"> <div style="height: 100cqh; overflow: auto;"> - <MkSpacer :marginMin="20" :marginMax="28"> + <div class="_spacer" style="--MI_SPACER-min: 20px; --MI_SPACER-max: 28px;"> <XFollow/> - </MkSpacer> + </div> <div :class="$style.pageFooter"> <div class="_buttonsCenter"> <MkButton rounded data-cy-user-setup-back @click="page--"><i class="ti ti-arrow-left"></i> {{ i18n.ts.goBack }}</MkButton> @@ -89,7 +89,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <template v-else-if="page === 4"> <div :class="$style.centerPage"> - <MkSpacer :marginMin="20" :marginMax="28"> + <div class="_spacer" style="--MI_SPACER-min: 20px; --MI_SPACER-max: 28px;"> <div class="_gaps" style="text-align: center;"> <i class="ti ti-bell-ringing-2" style="display: block; margin: auto; font-size: 3em; color: var(--MI_THEME-accent);"></i> <div style="font-size: 120%;">{{ i18n.ts.pushNotification }}</div> @@ -100,13 +100,13 @@ SPDX-License-Identifier: AGPL-3.0-only <MkButton primary rounded gradate data-cy-user-setup-continue @click="page++">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton> </div> </div> - </MkSpacer> + </div> </div> </template> <template v-else-if="page === 5"> <div :class="$style.centerPage"> <MkAnimBg style="position: absolute; top: 0;" :scale="1.5"/> - <MkSpacer :marginMin="20" :marginMax="28"> + <div class="_spacer" style="--MI_SPACER-min: 20px; --MI_SPACER-max: 28px;"> <div class="_gaps" style="text-align: center;"> <i class="ti ti-check" style="display: block; margin: auto; font-size: 3em; color: var(--MI_THEME-accent);"></i> <div style="font-size: 120%;">{{ i18n.ts._initialAccountSetting.initialAccountSettingCompleted }}</div> @@ -119,7 +119,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkButton rounded primary data-cy-user-setup-continue @click="setupComplete()">{{ i18n.ts.close }}</MkButton> </div> </div> - </MkSpacer> + </div> </div> </template> </Transition> @@ -128,7 +128,8 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ref, shallowRef, watch, nextTick, defineAsyncComponent } from 'vue'; +import { ref, useTemplateRef, watch, nextTick, defineAsyncComponent } from 'vue'; +import { host } from '@@/js/config.js'; import MkModalWindow from '@/components/MkModalWindow.vue'; import MkButton from '@/components/MkButton.vue'; import XProfile from '@/components/MkUserSetupDialog.Profile.vue'; @@ -137,22 +138,20 @@ import XPrivacy from '@/components/MkUserSetupDialog.Privacy.vue'; import MkAnimBg from '@/components/MkAnimBg.vue'; import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; -import { host } from '@@/js/config.js'; import MkPushNotificationAllowButton from '@/components/MkPushNotificationAllowButton.vue'; -import { defaultStore } from '@/store.js'; +import { store } from '@/store.js'; import * as os from '@/os.js'; const emit = defineEmits<{ (ev: 'closed'): void; }>(); -const dialog = shallowRef<InstanceType<typeof MkModalWindow>>(); +const dialog = useTemplateRef('dialog'); -// eslint-disable-next-line vue/no-setup-props-reactivity-loss -const page = ref(defaultStore.state.accountSetupWizard); +const page = ref(store.s.accountSetupWizard); watch(page, () => { - defaultStore.set('accountSetupWizard', page.value); + store.set('accountSetupWizard', page.value); }); async function close(skip: boolean) { @@ -165,11 +164,11 @@ async function close(skip: boolean) { } dialog.value?.close(); - defaultStore.set('accountSetupWizard', -1); + store.set('accountSetupWizard', -1); } function setupComplete() { - defaultStore.set('accountSetupWizard', -1); + store.set('accountSetupWizard', -1); dialog.value?.close(); } @@ -194,7 +193,7 @@ async function later(later: boolean) { } dialog.value?.close(); - defaultStore.set('accountSetupWizard', 0); + store.set('accountSetupWizard', 0); } </script> diff --git a/packages/frontend/src/components/MkVisibilityPicker.vue b/packages/frontend/src/components/MkVisibilityPicker.vue index 5624abbd33..c52bbbd44f 100644 --- a/packages/frontend/src/components/MkVisibilityPicker.vue +++ b/packages/frontend/src/components/MkVisibilityPicker.vue @@ -42,20 +42,21 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { nextTick, shallowRef, ref } from 'vue'; +import { nextTick, useTemplateRef, ref } from 'vue'; import * as Misskey from 'misskey-js'; import MkModal from '@/components/MkModal.vue'; import { i18n } from '@/i18n.js'; -const modal = shallowRef<InstanceType<typeof MkModal>>(); +const modal = useTemplateRef('modal'); const props = withDefaults(defineProps<{ currentVisibility: typeof Misskey.noteVisibilities[number]; isSilenced: boolean; localOnly: boolean; - src?: HTMLElement; + src?: HTMLElement | null; isReplyVisibilitySpecified?: boolean; }>(), { + src: null, }); const emit = defineEmits<{ diff --git a/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue b/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue index d098dad9a1..79c9e739c4 100644 --- a/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue +++ b/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue @@ -13,19 +13,19 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, shallowRef, ref, nextTick } from 'vue'; +import { onMounted, useTemplateRef, ref, nextTick } from 'vue'; import { Chart } from 'chart.js'; import gradient from 'chartjs-plugin-gradient'; import tinycolor from 'tinycolor2'; -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'; -import { initChart } from '@/scripts/init-chart.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { store } from '@/store.js'; +import { useChartTooltip } from '@/use/use-chart-tooltip.js'; +import { chartVLine } from '@/utility/chart-vline.js'; +import { initChart } from '@/utility/init-chart.js'; initChart(); -const chartEl = shallowRef<HTMLCanvasElement | null>(null); +const chartEl = useTemplateRef('chartEl'); const now = new Date(); let chartInstance: Chart | null = null; const chartLimit = 30; @@ -59,9 +59,9 @@ async function renderChart() { await nextTick(); - const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; + const vLineColor = store.s.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; - const computedStyle = getComputedStyle(document.documentElement); + const computedStyle = getComputedStyle(window.document.documentElement); const accent = tinycolor(computedStyle.getPropertyValue('--MI_THEME-accent')).toHexString(); const colorRead = accent; diff --git a/packages/frontend/src/components/MkVisitorDashboard.vue b/packages/frontend/src/components/MkVisitorDashboard.vue index 6d2a44e985..942085f7bb 100644 --- a/packages/frontend/src/components/MkVisitorDashboard.vue +++ b/packages/frontend/src/components/MkVisitorDashboard.vue @@ -28,7 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <div class="_gaps_s" :class="$style.mainActions"> <MkButton :class="$style.mainAction" full rounded gradate data-cy-signup style="margin-right: 12px;" @click="signup()">{{ i18n.ts.joinThisServer }}</MkButton> - <MkButton :class="$style.mainAction" full rounded link to="https://joinsharkey.org/#findaninstance">{{ i18n.ts.exploreOtherServers }}</MkButton> + <MkButton v-if="instance.policies.ltlAvailable" :class="$style.mainAction" full rounded link to="/explore">{{ i18n.ts.explore }}</MkButton> <MkButton :class="$style.mainAction" full rounded data-cy-signin @click="signin()">{{ i18n.ts.login }}</MkButton> </div> </div> @@ -58,7 +58,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 '@/scripts/sanitize-html.js'; +import sanitizeHtml from '@/utility/sanitize-html.js'; import XSigninDialog from '@/components/MkSigninDialog.vue'; import XSignupDialog from '@/components/MkSignupDialog.vue'; import MkButton from '@/components/MkButton.vue'; @@ -66,7 +66,7 @@ import MkTimeline from '@/components/MkTimeline.vue'; import MkInfo from '@/components/MkInfo.vue'; import { instanceName } from '@@/js/config.js'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; import MkNumber from '@/components/MkNumber.vue'; @@ -193,7 +193,7 @@ function showMenu(ev: MouseEvent) { } .statsItemLabel { - color: var(--MI_THEME-fgTransparentWeak); + color: color(from var(--MI_THEME-fg) srgb r g b / 0.75); font-size: 0.9em; } diff --git a/packages/frontend/src/components/MkWaitingDialog.vue b/packages/frontend/src/components/MkWaitingDialog.vue index 34fa6b0723..820cf05e1f 100644 --- a/packages/frontend/src/components/MkWaitingDialog.vue +++ b/packages/frontend/src/components/MkWaitingDialog.vue @@ -14,15 +14,15 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { watch, shallowRef } from 'vue'; +import { watch, useTemplateRef } from 'vue'; import MkModal from '@/components/MkModal.vue'; -const modal = shallowRef<InstanceType<typeof MkModal>>(); +const modal = useTemplateRef('modal'); const props = defineProps<{ success: boolean; showing: boolean; - text?: string; + text?: string | null; }>(); const emit = defineEmits<{ diff --git a/packages/frontend/src/components/MkWidgets.vue b/packages/frontend/src/components/MkWidgets.vue index 3446e3d6e2..a1111bd8c9 100644 --- a/packages/frontend/src/components/MkWidgets.vue +++ b/packages/frontend/src/components/MkWidgets.vue @@ -52,13 +52,13 @@ export type DefaultStoredWidget = { <script lang="ts" setup> import { defineAsyncComponent, ref, computed } from 'vue'; import { v4 as uuid } from 'uuid'; +import { isLink } from '@@/js/is-link.js'; import MkSelect from '@/components/MkSelect.vue'; import MkButton from '@/components/MkButton.vue'; import { widgets as widgetDefs, federationWidgets } from '@/widgets/index.js'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; -import { isLink } from '@@/js/is-link.js'; const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default)); diff --git a/packages/frontend/src/components/MkWindow.vue b/packages/frontend/src/components/MkWindow.vue index 2953f656d4..e5ac791d0b 100644 --- a/packages/frontend/src/components/MkWindow.vue +++ b/packages/frontend/src/components/MkWindow.vue @@ -5,10 +5,10 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <Transition - :enterActiveClass="defaultStore.state.animation ? $style.transition_window_enterActive : ''" - :leaveActiveClass="defaultStore.state.animation ? $style.transition_window_leaveActive : ''" - :enterFromClass="defaultStore.state.animation ? $style.transition_window_enterFrom : ''" - :leaveToClass="defaultStore.state.animation ? $style.transition_window_leaveTo : ''" + :enterActiveClass="prefer.s.animation ? $style.transition_window_enterActive : ''" + :leaveActiveClass="prefer.s.animation ? $style.transition_window_leaveActive : ''" + :enterFromClass="prefer.s.animation ? $style.transition_window_enterFrom : ''" + :leaveToClass="prefer.s.animation ? $style.transition_window_leaveTo : ''" appear @afterLeave="emit('closed')" > @@ -53,12 +53,12 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onBeforeUnmount, onMounted, provide, shallowRef, ref } from 'vue'; +import { onBeforeUnmount, onMounted, provide, useTemplateRef, ref } from 'vue'; import type { MenuItem } from '@/types/menu.js'; -import contains from '@/scripts/contains.js'; +import contains from '@/utility/contains.js'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; -import { defaultStore } from '@/store.js'; +import { prefer } from '@/preferences.js'; type WindowButton = { title: string; @@ -114,7 +114,7 @@ const emit = defineEmits<{ provide('inWindow', true); -const rootEl = shallowRef<HTMLElement | null>(); +const rootEl = useTemplateRef('rootEl'); const showing = ref(true); let beforeClickedAt = 0; const maximized = ref(false); @@ -240,7 +240,7 @@ function onHeaderMousedown(evt: MouseEvent | TouchEvent) { const main = rootEl.value; if (main == null) return; - if (!contains(main, document.activeElement)) main.focus(); + if (!contains(main, window.document.activeElement)) main.focus(); const position = main.getBoundingClientRect(); diff --git a/packages/frontend/src/components/MkYouTubePlayer.vue b/packages/frontend/src/components/MkYouTubePlayer.vue index 1122976436..ab62a5113d 100644 --- a/packages/frontend/src/components/MkYouTubePlayer.vue +++ b/packages/frontend/src/components/MkYouTubePlayer.vue @@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <div class="poamfof"> - <Transition :name="defaultStore.state.animation ? 'fade' : ''" mode="out-in"> + <Transition :name="prefer.s.animation ? 'fade' : ''" mode="out-in"> <div v-if="player.url && (player.url.startsWith('http://') || player.url.startsWith('https://'))" class="player"> <iframe v-if="!fetching" :src="transformPlayerUrl(player.url)" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen></iframe> </div> @@ -25,10 +25,10 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref } from 'vue'; -import MkWindow from '@/components/MkWindow.vue'; import { versatileLang } from '@@/js/intl-const.js'; -import { transformPlayerUrl } from '@/scripts/player-url-transform.js'; -import { defaultStore } from '@/store.js'; +import MkWindow from '@/components/MkWindow.vue'; +import { transformPlayerUrl } from '@/utility/player-url-transform.js'; +import { prefer } from '@/preferences.js'; const props = defineProps<{ url: string; diff --git a/packages/frontend/src/components/SkApprovalUser.vue b/packages/frontend/src/components/SkApprovalUser.vue index 1ef0ac5b17..310d044387 100644 --- a/packages/frontend/src/components/SkApprovalUser.vue +++ b/packages/frontend/src/components/SkApprovalUser.vue @@ -38,7 +38,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'; +import { misskeyApi } from '@/utility/misskey-api.js'; const props = defineProps<{ user: Misskey.entities.User; @@ -65,7 +65,7 @@ const emits = defineEmits<{ async function deleteAccount() { const confirm = await os.confirm({ type: 'warning', - text: i18n.ts.deleteAccountConfirm, + text: i18n.ts.deleteThisAccountConfirm, }); if (confirm.canceled) return; diff --git a/packages/frontend/src/components/SkFetchNote.vue b/packages/frontend/src/components/SkFetchNote.vue index ab702c28f8..a40b99ae8d 100644 --- a/packages/frontend/src/components/SkFetchNote.vue +++ b/packages/frontend/src/components/SkFetchNote.vue @@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { ref, watch } from 'vue'; import * as Misskey from 'misskey-js'; import { i18n } from '@/i18n.js'; -import { misskeyApi } from '@/scripts/misskey-api'; +import { misskeyApi } from '@/utility/misskey-api.js'; import DynamicNote from '@/components/DynamicNote.vue'; const props = withDefaults(defineProps<{ diff --git a/packages/frontend/src/components/SkFlashPlayer.vue b/packages/frontend/src/components/SkFlashPlayer.vue index 2b61974ef7..f7fb42fc9f 100644 --- a/packages/frontend/src/components/SkFlashPlayer.vue +++ b/packages/frontend/src/components/SkFlashPlayer.vue @@ -6,14 +6,14 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div :class="$style.flash_player_container"> <canvas :class="$style.ratio" height="300" width="300"></canvas> - + <div v-if="hide" :class="$style.flash_player_disabled" @click="toggleVisible()"> <div> <b><i class="ph-eye ph-bold ph-lg"></i> {{ i18n.ts.sensitive }}</b> <span>{{ i18n.ts.clickToShow }}</span> </div> </div> - + <div v-else :class="$style.flash_player_enabled"> <div :class="$style.flash_display"> <div v-if="playerHide" :class="$style.player_hide" @click="dismissWarning()"> @@ -59,12 +59,12 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref, onDeactivated } from 'vue'; import * as Misskey from 'misskey-js'; +import type { PublicAPI, PublicAPILike } from '@/types/ruffle/setup'; +import type { PlayerElement } from '@/types/ruffle/player'; import MkEllipsis from '@/components/global/MkEllipsis.vue'; import MkLoading from '@/components/global/MkLoading.vue'; import { i18n } from '@/i18n.js'; -import { defaultStore } from '@/store.js'; -import { PublicAPI, PublicAPILike } from '@/types/ruffle/setup'; // This gives us the types for window.RufflePlayer, etc via side effects -import { PlayerElement } from '@/types/ruffle/player'; +import { prefer } from '@/preferences.js'; const props = defineProps<{ flashFile: Misskey.entities.DriveFile @@ -73,7 +73,7 @@ const props = defineProps<{ const isSensitive = props.flashFile.isSensitive; const url = props.flashFile.url; const comment = props.flashFile.comment ?? ''; -let hide = ref((defaultStore.state.nsfw === 'force') || isSensitive && (defaultStore.state.nsfw !== 'ignore')); +let hide = ref((prefer.s.nsfw === 'force') || isSensitive && (prefer.s.nsfw !== 'ignore')); let playerHide = ref(true); let ruffleContainer = ref<HTMLDivElement>(); let playPauseButtonKey = ref<number>(0); @@ -126,7 +126,7 @@ async function loadRuffle() { 'maxExecutionDuration': 15, 'logLevel': 'error', 'base': null, - 'menu': true, + 'popupMenu': true, 'salign': '', 'forceAlign': false, 'scale': 'showAll', @@ -177,11 +177,11 @@ async function loadContent() { loadingStatus.value = undefined; } catch (error) { try { - await fetch('https://raw.esm.sh/', { + await window.fetch('https://raw.esm.sh/', { mode: 'cors', }); handleError(error); // Unexpected error - } catch (_) { + } catch { // Must be CSP because esm.sh should be online if `loadRuffle()` didn't fail handleError(i18n.ts._flash.cspError); } diff --git a/packages/frontend/src/components/SkFollowingFeedEntry.vue b/packages/frontend/src/components/SkFollowingFeedEntry.vue index 75539f1de7..b71777e032 100644 --- a/packages/frontend/src/components/SkFollowingFeedEntry.vue +++ b/packages/frontend/src/components/SkFollowingFeedEntry.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div :class="$style.root" @click="$emit('select', note.user)"> +<div v-if="!hardMuted" :class="$style.root" @click="$emit('select', note.user)"> <div :class="$style.avatar"> <MkAvatar :class="$style.icon" :user="note.user" indictor/> </div> @@ -18,31 +18,39 @@ SPDX-License-Identifier: AGPL-3.0-only </MkA> </header> <div> - <div v-if="isMuted" :class="[$style.text, $style.muted]">({{ i18n.ts.postFiltered }})</div> + <div v-if="muted" :class="[$style.text, $style.muted]"> + <SkMutedNote :muted="muted" :note="note"></SkMutedNote> + </div> <Mfm v-else :class="$style.text" :text="getNoteSummary(note)" :isBlock="true" :plain="true" :nowrap="false" :isNote="true" nyaize="respect" :author="note.user"/> </div> </div> </div> +<div v-else> + <!-- + MkDateSeparatedList uses TransitionGroup which requires single element in the child elements + so MkNote create empty div instead of no elements + --> +</div> </template> <script lang="ts" setup> import * as Misskey from 'misskey-js'; -import { getNoteSummary } from '@/scripts/get-note-summary.js'; +import { getNoteSummary } from '@/utility/get-note-summary.js'; import { userPage } from '@/filters/user.js'; import { notePage } from '@/filters/note.js'; -import { i18n } from '@/i18n.js'; +import { checkMutes } from '@/utility/check-word-mute'; +import SkMutedNote from '@/components/SkMutedNote.vue'; -withDefaults(defineProps<{ +const props = defineProps<{ note: Misskey.entities.Note, - isMuted: boolean -}>(), { - isMuted: false, -}); +}>(); defineEmits<{ (event: 'select', user: Misskey.entities.UserLite): void }>(); +// eslint-disable-next-line vue/no-setup-props-reactivity-loss +const { muted, hardMuted } = checkMutes(props.note); </script> <style lang="scss" module> diff --git a/packages/frontend/src/components/SkFollowingRecentNotes.vue b/packages/frontend/src/components/SkFollowingRecentNotes.vue index 7e71065082..3eb2ac8572 100644 --- a/packages/frontend/src/components/SkFollowingRecentNotes.vue +++ b/packages/frontend/src/components/SkFollowingRecentNotes.vue @@ -8,14 +8,14 @@ SPDX-License-Identifier: AGPL-3.0-only <MkPagination ref="latestNotesPaging" :pagination="latestNotesPagination" @init="onListReady"> <template #empty> <div class="_fullinfo"> - <img :src="infoImageUrl" class="_ghost" :alt="i18n.ts.noNotes" aria-hidden="true"/> + <img :src="infoImageUrl" draggable="false" :alt="i18n.ts.noNotes" aria-hidden="true"/> <div>{{ i18n.ts.noNotes }}</div> </div> </template> <template #default="{ items: notes }"> <MkDateSeparatedList v-slot="{ item: note }" :items="notes" :class="$style.panel" :noGap="true"> - <SkFollowingFeedEntry v-if="!isHardMuted(note)" :isMuted="isSoftMuted(note)" :note="note" :class="props.selectedUserId == note.userId && $style.selected" @select="u => selectUser(u.id)"/> + <SkFollowingFeedEntry :note="note" :class="props.selectedUserId == note.userId && $style.selected" @select="u => selectUser(u.id)"/> </MkDateSeparatedList> </template> </MkPagination> @@ -23,16 +23,14 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script setup lang="ts"> -import * as Misskey from 'misskey-js'; import { computed, shallowRef } from 'vue'; +import type { Paging } from '@/components/MkPagination.vue'; +import type { FollowingFeedTab } from '@/types/following-feed.js'; import { infoImageUrl } from '@/instance.js'; import { i18n } from '@/i18n.js'; import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue'; -import MkPagination, { Paging } from '@/components/MkPagination.vue'; +import MkPagination from '@/components/MkPagination.vue'; import SkFollowingFeedEntry from '@/components/SkFollowingFeedEntry.vue'; -import { $i } from '@/account.js'; -import { checkWordMute } from '@/scripts/check-word-mute.js'; -import { FollowingFeedTab } from '@/scripts/following-feed-utils.js'; import MkPullToRefresh from '@/components/MkPullToRefresh.vue'; const props = defineProps<{ @@ -83,37 +81,6 @@ const latestNotesPagination: Paging<'notes/following'> = { }; const latestNotesPaging = shallowRef<InstanceType<typeof MkPagination>>(); - -function isSoftMuted(note: Misskey.entities.Note): boolean { - return isMuted(note, $i?.mutedWords); -} - -function isHardMuted(note: Misskey.entities.Note): boolean { - return isMuted(note, $i?.hardMutedWords); -} - -// Match the typing used by Misskey -type Mutes = (string | string[])[] | null | undefined; - -// Adapted from MkNote.ts -function isMuted(note: Misskey.entities.Note, mutes: Mutes): boolean { - return checkMute(note, mutes) - || checkMute(note.reply, mutes) - || checkMute(note.renote, mutes); -} - -// Adapted from check-word-mute.ts -function checkMute(note: Misskey.entities.Note | undefined | null, mutes: Mutes): boolean { - if (!note) { - return false; - } - - if (!mutes || mutes.length < 1) { - return false; - } - - return checkWordMute(note, $i, mutes); -} </script> <style module lang="scss"> diff --git a/packages/frontend/src/components/SkInstanceTicker.vue b/packages/frontend/src/components/SkInstanceTicker.vue index 800b3afc65..d2bac6d681 100644 --- a/packages/frontend/src/components/SkInstanceTicker.vue +++ b/packages/frontend/src/components/SkInstanceTicker.vue @@ -11,10 +11,11 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, type CSSProperties } from 'vue'; import { instanceName as localInstanceName } from '@@/js/config.js'; +import { computed } from 'vue'; +import type { CSSProperties } from 'vue'; import { instance as localInstance } from '@/instance.js'; -import { getProxiedImageUrlNullable } from '@/scripts/media-proxy.js'; +import { getProxiedImageUrlNullable } from '@/utility/media-proxy.js'; const props = defineProps<{ host: string | null; diff --git a/packages/frontend/src/components/SkMfmWindow.vue b/packages/frontend/src/components/SkMfmWindow.vue index a628758a0f..14d309b7ba 100644 --- a/packages/frontend/src/components/SkMfmWindow.vue +++ b/packages/frontend/src/components/SkMfmWindow.vue @@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only MFM Cheatsheet </template> <MkStickyContainer> - <MkSpacer :contentMax="800"> + <div class="_spacer" style="--MI_SPACER-w: 800px;"> <div class="mfm-cheat-sheet"> <div>{{ i18n.ts._mfm.intro }}</div> <br/> @@ -402,7 +402,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> </div> - </MkSpacer> + </div> </MkStickyContainer> </MkWindow> </template> diff --git a/packages/frontend/src/components/SkModPlayer.vue b/packages/frontend/src/components/SkModPlayer.vue index 0933806109..0570273c83 100644 --- a/packages/frontend/src/components/SkModPlayer.vue +++ b/packages/frontend/src/components/SkModPlayer.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: marie and other Sharkey contributors +SPDX-FileCopyrightText: puniko and other Sharkey contributors SPDX-License-Identifier: AGPL-3.0-only --> @@ -44,9 +44,9 @@ SPDX-License-Identifier: AGPL-3.0-only import { ref, nextTick, 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'; -import { isTouchUsing } from '@/scripts/touch.js'; +import { ChiptuneJsPlayer, ChiptuneJsConfig } from '@/utility/chiptune2.js'; +import { isTouchUsing } from '@/utility/touch.js'; +import { prefer } from '@/preferences.js'; const colours = { background: '#000000', @@ -72,7 +72,7 @@ const props = defineProps<{ const isSensitive = props.module.isSensitive; const url = props.module.url; -let hide = ref((defaultStore.state.nsfw === 'force') ? true : isSensitive && (defaultStore.state.nsfw !== 'ignore')); +let hide = ref((prefer.s.nsfw === 'force') ? true : isSensitive && (prefer.s.nsfw !== 'ignore')); let patternHide = ref(false); let playing = ref(false); let displayCanvas = ref<HTMLCanvasElement>(); diff --git a/packages/frontend/src/components/SkMutedNote.vue b/packages/frontend/src/components/SkMutedNote.vue new file mode 100644 index 0000000000..3c072fab3f --- /dev/null +++ b/packages/frontend/src/components/SkMutedNote.vue @@ -0,0 +1,45 @@ +<!-- +SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<I18n v-if="muted === 'sensitiveMute'" :src="i18n.ts.userSaysSomethingSensitive" tag="small"> + <template #name> + <MkUserName :user="note.user"/> + </template> +</I18n> +<I18n v-else-if="prefer.s.showSoftWordMutedWord" :src="i18n.ts.userSaysSomething" tag="small"> + <template #name> + <MkUserName :user="note.user"/> + </template> +</I18n> +<I18n v-else :src="i18n.ts.userSaysSomethingAbout" tag="small"> + <template #name> + <MkUserName :user="note.user"/> + </template> + <template #word> + {{ mutedWords }} + </template> +</I18n> +</template> + +<script setup lang="ts"> +import * as Misskey from 'misskey-js'; +import { computed } from 'vue'; +import { i18n } from '@/i18n.js'; +import { prefer } from '@/preferences.js'; + +const props = defineProps<{ + muted: false | 'sensitiveMute' | string[]; + note: Misskey.entities.Note; +}>(); + +const mutedWords = computed(() => Array.isArray(props.muted) + ? props.muted.join(', ') + : props.muted); +</script> + +<style module lang="scss"> + +</style> diff --git a/packages/frontend/src/components/SkNote.vue b/packages/frontend/src/components/SkNote.vue index d4d4ca699d..5184cbd801 100644 --- a/packages/frontend/src/components/SkNote.vue +++ b/packages/frontend/src/components/SkNote.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 v-show="!isDeleted" ref="rootEl" v-hotkey="keymap" - :class="[$style.root, { [$style.showActionsOnlyHover]: defaultStore.state.showNoteActionsOnlyHover, [$style.skipRender]: defaultStore.state.skipNoteRender }]" + :class="[$style.root, { [$style.showActionsOnlyHover]: prefer.s.showNoteActionsOnlyHover, [$style.skipRender]: prefer.s.skipNoteRender }]" :tabindex="isDeleted ? '-1' : '0'" > <SkNoteSub v-if="appearNote.reply" v-show="!renoteCollapsed && !inReplyToCollapsed" :note="appearNote.reply" :class="$style.replyTo"/> @@ -20,8 +20,6 @@ SPDX-License-Identifier: AGPL-3.0-only <Mfm :text="getNoteSummary(appearNote.reply)" :plain="true" :nowrap="true" :author="appearNote.reply.user" :nyaize="'respect'" :class="$style.collapsedInReplyToText" @click="inReplyToCollapsed = false"/> </div> <div v-if="pinned" :class="$style.tip"><i class="ti ti-pin"></i> {{ i18n.ts.pinnedNote }}</div> - <!--<div v-if="appearNote._prId_" class="tip"><i class="ti ti-speakerphone"></i> {{ i18n.ts.promotion }}<button class="_textButton hide" @click="readPromo()">{{ i18n.ts.hideThisNote }} <i class="ti ti-x"></i></button></div>--> - <!--<div v-if="appearNote._featuredId_" class="tip"><i class="ti ti-bolt"></i> {{ i18n.ts.featured }}</div>--> <div v-if="isRenote" :class="$style.renote"> <div v-if="note.channel" :class="$style.colorBar" :style="{ background: note.channel.color }"></div> <MkAvatar :class="$style.renoteAvatar" :user="note.user" link preview/> @@ -60,7 +58,7 @@ SPDX-License-Identifier: AGPL-3.0-only <SkNoteHeader :note="appearNote" :mini="true"/> </div> </div> - <div :class="[{ [$style.clickToOpen]: defaultStore.state.clickToOpen }]" @click.stop="defaultStore.state.clickToOpen ? noteclick(appearNote.id) : undefined"> + <div :class="[{ [$style.clickToOpen]: prefer.s.clickToOpen }]" @click.stop="prefer.s.clickToOpen ? noteclick(appearNote.id) : undefined"> <div style="container-type: inline-size;"> <p v-if="mergedCW != null" :class="$style.cw"> <Mfm @@ -71,6 +69,7 @@ SPDX-License-Identifier: AGPL-3.0-only :enableEmojiMenu="true" :enableEmojiMenuReaction="true" :isBlock="true" + class="_selectable" /> <MkCwButton v-model="showContent" :text="appearNote.text" :renote="appearNote.renote" :files="appearNote.files" :poll="appearNote.poll" style="margin: 4px 0;" @click.stop/> </p> @@ -89,22 +88,16 @@ SPDX-License-Identifier: AGPL-3.0-only :isAnim="allowAnim" :isBlock="true" /> - <div v-if="translating || translation" :class="$style.translation"> - <MkLoading v-if="translating" mini/> - <div v-else-if="translation"> - <b>{{ i18n.tsx.translatedFrom({ x: translation.sourceLang }) }}: </b> - <Mfm :text="translation.text" :isBlock="true" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis"/> - </div> - </div> + <SkNoteTranslation :note="note" :translation="translation" :translating="translating"></SkNoteTranslation> <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> + <MkButton v-else-if="!prefer.s.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 && appearNote.files.length > 0"> <MkMediaList ref="galleryEl" :mediaList="appearNote.files" @click.stop/> </div> <MkPoll v-if="appearNote.poll" :noteId="appearNote.id" :poll="appearNote.poll" :local="!appearNote.user.host" :author="appearNote.user" :emojiUrls="appearNote.emojis" :class="$style.poll" @click.stop/> <div v-if="isEnabledUrlPreview"> - <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :showAsQuote="!appearNote.user.rejectQuotes" :skipNoteIds="[appearNote.renote?.id]" :class="$style.urlPreview" @click.stop/> + <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :showAsQuote="!appearNote.user.rejectQuotes" :skipNoteIds="selfNoteIds" :class="$style.urlPreview" @click.stop/> </div> <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"> @@ -116,7 +109,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <MkA v-if="appearNote.channel && !inChannel" :class="$style.channel" :to="`/channels/${appearNote.channel.id}`"><i class="ti ti-device-tv"></i> {{ appearNote.channel.name }}</MkA> </div> - <MkReactionsViewer v-if="appearNote.reactionAcceptance !== 'likeOnly'" :note="appearNote" :maxNumber="16" @click.stop @mockUpdateMyReaction="emitUpdReaction"> + <MkReactionsViewer v-if="appearNote.reactionAcceptance !== 'likeOnly'" style="margin-top: 6px;" :note="appearNote" :maxNumber="16" @click.stop @mockUpdateMyReaction="emitUpdReaction"> <template #more> <MkA :to="`/notes/${appearNote.id}/reactions`" :class="[$style.reactionOmitted]">{{ i18n.ts.more }}</MkA> </template> @@ -160,12 +153,15 @@ SPDX-License-Identifier: AGPL-3.0-only <i v-else-if="appearNote.myReaction != null" class="ti ti-minus" style="color: var(--MI_THEME-accent);"></i> <i v-else-if="appearNote.reactionAcceptance === 'likeOnly'" class="ti ti-heart"></i> <i v-else class="ph-smiley ph-bold ph-lg"></i> - <p v-if="(appearNote.reactionAcceptance === 'likeOnly' || defaultStore.state.showReactionsCount) && appearNote.reactionCount > 0" :class="$style.footerButtonCount">{{ number(appearNote.reactionCount) }}</p> + <p v-if="(appearNote.reactionAcceptance === 'likeOnly' || prefer.s.showReactionsCount) && appearNote.reactionCount > 0" :class="$style.footerButtonCount">{{ number(appearNote.reactionCount) }}</p> </button> - <button v-if="defaultStore.state.showClipButtonInNoteFooter" ref="clipButton" :class="$style.footerButton" class="_button" @mousedown.prevent="clip()"> + <button v-if="prefer.s.showClipButtonInNoteFooter" ref="clipButton" :class="$style.footerButton" class="_button" @click.stop="clip()"> <i class="ti ti-paperclip"></i> </button> - <button ref="menuButton" :class="$style.footerButton" class="_button" @mousedown.prevent="showMenu()"> + <button v-if="prefer.s.showTranslationButtonInNoteFooter && $i?.policies.canUseTranslator && instance.translatorAvailable" class="_button" :class="$style.footerButton" :disabled="translating || !!translation" @click.stop="translate()"> + <i class="ti ti-language-hiragana"></i> + </button> + <button ref="menuButton" :class="$style.footerButton" class="_button" @click.stop="showMenu()"> <i class="ti ti-dots"></i> </button> </footer> @@ -173,30 +169,7 @@ SPDX-License-Identifier: AGPL-3.0-only </article> </div> <div v-else-if="!hardMuted" :class="$style.muted" @click="muted = false"> - <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-if="showSoftWordMutedWord !== true" :src="i18n.ts.userSaysSomething" 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.userSaysSomethingAbout" tag="small"> - <template #name> - <MkA v-user-preview="appearNote.userId" :to="userPage(appearNote.user)"> - <MkUserName :user="appearNote.user"/> - </MkA> - </template> - <template #word> - {{ Array.isArray(muted) ? muted.map(words => Array.isArray(words) ? words.join() : words).slice(0, 3).join(' ') : muted }} - </template> - </I18n> + <SkMutedNote :muted="muted" :note="appearNote"></SkMutedNote> </div> <div v-else> <!-- @@ -207,14 +180,18 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, inject, onMounted, ref, shallowRef, Ref, watch, provide } from 'vue'; +import { computed, inject, onMounted, ref, useTemplateRef, watch, provide } from 'vue'; import * as mfm from '@transfem-org/sfm-js'; import * as Misskey from 'misskey-js'; import { isLink } from '@@/js/is-link.js'; import { shouldCollapsed } from '@@/js/collapsed.js'; -import { host } from '@@/js/config.js'; +import * as config from '@@/js/config.js'; import { computeMergedCw } from '@@/js/compute-merged-cw.js'; +import type { Ref } from 'vue'; import type { MenuItem } from '@/types/menu.js'; +import type { OpenOnRemoteOptions } from '@/utility/please-login.js'; +import type { Keymap } from '@/utility/hotkey.js'; +import type { Visibility } from '@/utility/boost-quote.js'; import SkNoteSub from '@/components/SkNoteSub.vue'; import SkNoteHeader from '@/components/SkNoteHeader.vue'; import SkNoteSimple from '@/components/SkNoteSimple.vue'; @@ -226,35 +203,40 @@ import MkPoll from '@/components/MkPoll.vue'; import MkUsersTooltip from '@/components/MkUsersTooltip.vue'; import MkUrlPreview from '@/components/MkUrlPreview.vue'; import MkButton from '@/components/MkButton.vue'; -import { pleaseLogin, type OpenOnRemoteOptions } from '@/scripts/please-login.js'; -import { checkWordMute } from '@/scripts/check-word-mute.js'; +import { pleaseLogin } from '@/utility/please-login.js'; +import { checkMutes } from '@/utility/check-word-mute.js'; import { notePage } from '@/filters/note.js'; import { userPage } from '@/filters/user.js'; import number from '@/filters/number.js'; import * as os from '@/os.js'; -import { misskeyApi, misskeyApiGet } 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'; -import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm.js'; -import { checkAnimationFromMfm } from '@/scripts/check-animated-mfm.js'; -import { $i } from '@/account.js'; +import * as sound from '@/utility/sound.js'; +import { misskeyApi, misskeyApiGet } from '@/utility/misskey-api.js'; +import { reactionPicker } from '@/utility/reaction-picker.js'; +import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js'; +import { checkAnimationFromMfm } from '@/utility/check-animated-mfm.js'; +import { $i } from '@/i.js'; import { i18n } from '@/i18n.js'; -import { getAbuseNoteMenu, getCopyNoteLinkMenu, getNoteClipMenu, getNoteMenu } from '@/scripts/get-note-menu.js'; -import { getNoteVersionsMenu } from '@/scripts/get-note-versions-menu.js'; -import { useNoteCapture } from '@/scripts/use-note-capture.js'; -import { deepClone } from '@/scripts/clone.js'; -import { useTooltip } from '@/scripts/use-tooltip.js'; -import { claimAchievement } from '@/scripts/achievements.js'; -import { getNoteSummary } from '@/scripts/get-note-summary.js'; +import { getAbuseNoteMenu, getCopyNoteLinkMenu, getNoteClipMenu, getNoteMenu, translateNote } from '@/utility/get-note-menu.js'; +import { getNoteVersionsMenu } from '@/utility/get-note-versions-menu.js'; +import { useNoteCapture } from '@/use/use-note-capture.js'; +import { deepClone } from '@/utility/clone.js'; +import { useTooltip } from '@/use/use-tooltip.js'; +import { claimAchievement } from '@/utility/achievements.js'; +import { getNoteSummary } from '@/utility/get-note-summary.js'; import MkRippleEffect from '@/components/MkRippleEffect.vue'; -import { showMovedDialog } from '@/scripts/show-moved-dialog.js'; -import { useRouter } from '@/router/supplier.js'; -import { boostMenuItems, type Visibility, computeRenoteTooltip } from '@/scripts/boost-quote.js'; -import { isEnabledUrlPreview } from '@/instance.js'; -import { type Keymap } from '@/scripts/hotkey.js'; -import { focusPrev, focusNext } from '@/scripts/focus.js'; -import { getAppearNote } from '@/scripts/get-appear-note.js'; +import { showMovedDialog } from '@/utility/show-moved-dialog.js'; +import { boostMenuItems, computeRenoteTooltip } from '@/utility/boost-quote.js'; +import { instance, isEnabledUrlPreview } from '@/instance.js'; +import { focusPrev, focusNext } from '@/utility/focus.js'; +import { getAppearNote } from '@/utility/get-appear-note.js'; +import { prefer } from '@/preferences.js'; +import { getPluginHandlers } from '@/plugin.js'; +import { DI } from '@/di.js'; +import { useRouter } from '@/router.js'; +import SkMutedNote from '@/components/SkMutedNote.vue'; +import SkNoteTranslation from '@/components/SkNoteTranslation.vue'; +import { getSelfNoteIds } from '@/utility/get-self-note-ids.js'; +import { extractPreviewUrls } from '@/utility/extract-preview-urls.js'; const props = withDefaults(defineProps<{ note: Misskey.entities.Note; @@ -265,7 +247,7 @@ const props = withDefaults(defineProps<{ mock: false, }); -provide('mock', props.mock); +provide(DI.mock, props.mock); const emit = defineEmits<{ (ev: 'reaction', emoji: string): void; @@ -274,21 +256,20 @@ const emit = defineEmits<{ const router = useRouter(); -const inTimeline = inject<boolean>('inTimeline', false); -const tl_withSensitive = inject<Ref<boolean>>('tl_withSensitive', ref(true)); const inChannel = inject('inChannel', null); const currentClip = inject<Ref<Misskey.entities.Clip> | null>('currentClip', null); const note = ref(deepClone(props.note)); function noteclick(id: string) { - const selection = document.getSelection(); + const selection = window.document.getSelection(); if (selection?.toString().length === 0) { router.push(`/notes/${id}`); } } // plugin +const noteViewInterruptors = getPluginHandlers('note_view_interruptor'); if (noteViewInterruptors.length > 0) { onMounted(async () => { let result: Misskey.entities.Note | null = deepClone(note.value); @@ -309,76 +290,50 @@ if (noteViewInterruptors.length > 0) { const isRenote = Misskey.note.isPureRenote(note.value); -const rootEl = shallowRef<HTMLElement>(); -const menuButton = shallowRef<HTMLElement>(); -const menuVersionsButton = shallowRef<HTMLElement>(); -const renoteButton = shallowRef<HTMLElement>(); -const renoteTime = shallowRef<HTMLElement>(); -const reactButton = shallowRef<HTMLElement>(); -const quoteButton = shallowRef<HTMLElement>(); -const clipButton = shallowRef<HTMLElement>(); -const likeButton = shallowRef<HTMLElement>(); +const rootEl = useTemplateRef('rootEl'); +const menuButton = useTemplateRef('menuButton'); +const renoteButton = useTemplateRef('renoteButton'); +const renoteTime = useTemplateRef('renoteTime'); +const reactButton = useTemplateRef('reactButton'); +const clipButton = useTemplateRef('clipButton'); +const menuVersionsButton = useTemplateRef('menuVersionsButton'); +const quoteButton = useTemplateRef('quoteButton'); +const likeButton = useTemplateRef('likeButton'); const appearNote = computed(() => getAppearNote(note.value)); -const galleryEl = shallowRef<InstanceType<typeof MkMediaList>>(); +const galleryEl = useTemplateRef('galleryEl'); const isMyRenote = $i && ($i.id === note.value.userId); -const showContent = ref(defaultStore.state.uncollapseCW); +const showContent = ref(prefer.s.uncollapseCW); 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 urls = computed(() => parsed.value ? extractPreviewUrls(props.note, parsed.value) : null); +const selfNoteIds = computed(() => getSelfNoteIds(props.note)); const isLong = shouldCollapsed(appearNote.value, urls.value ?? []); -const collapsed = ref(defaultStore.state.expandLongNote && appearNote.value.cw == null && isLong ? false : appearNote.value.cw == null && isLong); +const collapsed = ref(prefer.s.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, true)); -const showSoftWordMutedWord = computed(() => defaultStore.state.showSoftWordMutedWord); -const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null); +const { muted, hardMuted } = checkMutes(appearNote.value, props.withHardMute); +const translation = ref<Misskey.entities.NotesTranslateResponse | false | null>(null); const translating = ref(false); -const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.value.user.instance); +const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.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 && ( + prefer.s.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 inReplyToCollapsed = ref(defaultStore.state.collapseNotesRepliedTo); -const defaultLike = computed(() => defaultStore.state.like ? defaultStore.state.like : null); +const inReplyToCollapsed = ref(prefer.s.collapseNotesRepliedTo); +const defaultLike = computed(() => prefer.s.like ? prefer.s.like : null); const animated = computed(() => parsed.value ? checkAnimationFromMfm(parsed.value) : null); -const allowAnim = ref(defaultStore.state.advancedMfm && defaultStore.state.animatedMfm ? true : false); - -const mergedCW = computed(() => computeMergedCw(appearNote.value)); - -const renoteTooltip = computeRenoteTooltip(renoted); +const allowAnim = ref(prefer.s.advancedMfm && prefer.s.animatedMfm); const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({ type: 'lookup', - url: `https://${host}/notes/${appearNote.value.id}`, + url: appearNote.value.url ?? appearNote.value.uri ?? `${config.url}/notes/${appearNote.value.id}`, })); -/* 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): Array<string | string[]> | false | 'sensitiveMute'; -*/ -function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly = false): Array<string | string[]> | false | 'sensitiveMute' { - if (mutedWords != null) { - const result = checkWordMute(noteToCheck, $i, mutedWords); - if (Array.isArray(result)) return result; - - const replyResult = noteToCheck.reply && checkWordMute(noteToCheck.reply, $i, mutedWords); - if (Array.isArray(replyResult)) return replyResult; - - const renoteResult = noteToCheck.renote && checkWordMute(noteToCheck.renote, $i, mutedWords); - if (Array.isArray(renoteResult)) return renoteResult; - } - - if (checkOnly) return false; - - if (inTimeline && tl_withSensitive.value === false && noteToCheck.files?.some((v) => v.isSensitive)) { - return 'sensitiveMute'; - } +const mergedCW = computed(() => computeMergedCw(appearNote.value)); - return false; -} +const renoteTooltip = computeRenoteTooltip(renoted); let renoting = false; @@ -393,7 +348,7 @@ const keymap = { }, 'q': () => { if (renoteCollapsed.value) return; - if (canRenote.value && !renoted.value && !renoting) renote(defaultStore.state.visibilityOnBoost); + if (canRenote.value && !renoted.value && !renoting) renote(prefer.s.visibilityOnBoost); }, 'm': () => { if (renoteCollapsed.value) return; @@ -401,9 +356,14 @@ const keymap = { }, 'c': () => { if (renoteCollapsed.value) return; - if (!defaultStore.state.showClipButtonInNoteFooter) return; + if (!prefer.s.showClipButtonInNoteFooter) return; clip(); }, + 't': () => { + if (prefer.s.showTranslationButtonInNoteFooter && $i?.policies.canUseTranslator && instance.translatorAvailable) { + translate(); + } + }, 'o': () => { if (renoteCollapsed.value) return; galleryEl.value?.openGallery(); @@ -431,7 +391,8 @@ const keymap = { }, } as const satisfies Keymap; -provide('react', (reaction: string) => { +provide(DI.mfmEmojiReactCallback, (reaction) => { + sound.playMisskeySfx('reaction'); misskeyApi('notes/reactions/create', { noteId: appearNote.value.id, reaction: reaction, @@ -453,6 +414,8 @@ if (props.mock) { if (!props.mock) { useTooltip(renoteButton, async (showing) => { + if (!renoteButton.value) return; + const renotes = await misskeyApi('notes/renotes', { noteId: appearNote.value.id, limit: 11, @@ -473,6 +436,8 @@ if (!props.mock) { }); useTooltip(quoteButton, async (showing) => { + if (!quoteButton.value) return; + const renotes = await misskeyApi('notes/renotes', { noteId: appearNote.value.id, limit: 11, @@ -531,8 +496,8 @@ if (!props.mock) { function boostVisibility(forceMenu: boolean = false) { if (renoting) return; - if (!defaultStore.state.showVisibilitySelectorOnBoost && !forceMenu) { - renote(defaultStore.state.visibilityOnBoost); + if (!prefer.s.showVisibilitySelectorOnBoost && !forceMenu) { + renote(prefer.s.visibilityOnBoost); } else { os.popupMenu(boostMenuItems(appearNote, renote), renoteButton.value); } @@ -674,7 +639,7 @@ function like(): void { override: defaultLike.value, }); const el = likeButton.value as HTMLElement | null | undefined; - if (el) { + if (el && prefer.s.animation) { const rect = el.getBoundingClientRect(); const x = rect.left + (el.offsetWidth / 2); const y = rect.top + (el.offsetHeight / 2); @@ -709,7 +674,16 @@ function react(viaKeyboard = false): void { } } else { blur(); - reactionPicker.show(reactButton.value ?? null, note.value, reaction => { + reactionPicker.show(reactButton.value ?? null, note.value, async (reaction) => { + if (prefer.s.confirmOnReact) { + const confirm = await os.confirm({ + type: 'question', + text: i18n.tsx.reactAreYouSure({ emoji: reaction.replace('@.', '') }), + }); + + if (confirm.canceled) return; + } + sound.playMisskeySfx('reaction'); if (props.mock) { @@ -781,7 +755,7 @@ function onContextmenu(ev: MouseEvent): void { if (ev.target && isLink(ev.target as HTMLElement)) return; if (window.getSelection()?.toString() !== '') return; - if (defaultStore.state.useReactionPickerForContextMenu) { + if (prefer.s.useReactionPickerForContextMenu) { ev.preventDefault(); react(); } else { @@ -799,11 +773,9 @@ function showMenu(): void { os.popupMenu(menu, menuButton.value).then(focus).finally(cleanup); } -async function menuVersions(viaKeyboard = false): Promise<void> { - const { menu, cleanup } = await getNoteVersionsMenu({ note: note.value, menuVersionsButton }); - os.popupMenu(menu, menuVersionsButton.value, { - viaKeyboard, - }).then(focus).finally(cleanup); +async function menuVersions(): Promise<void> { + const { menu, cleanup } = await getNoteVersionsMenu({ note: note.value }); + os.popupMenu(menu, menuVersionsButton.value).then(focus).finally(cleanup); } async function clip(): Promise<void> { @@ -814,6 +786,12 @@ async function clip(): Promise<void> { os.popupMenu(await getNoteClipMenu({ note: note.value, isDeleted, currentClip: currentClip?.value }), clipButton.value).then(focus); } +async function translate() { + if (props.mock) return; + + await translateNote(appearNote.value.id, translation, translating); +} + function showRenoteMenu(): void { if (props.mock) { return; @@ -906,7 +884,6 @@ function emitUpdReaction(emoji: string, delta: number) { <style lang="scss" module> .root { position: relative; - transition: box-shadow 0.1s ease; font-size: 1.05em; overflow: clip; contain: content; @@ -985,6 +962,8 @@ function emitUpdReaction(emoji: string, delta: number) { } .skipRender { + // TODO: これが有効だとTransitionGroupでnoteを追加するときに一瞬がくっとなってしまうのをどうにかしたい + // Transitionが完了するのを待ってからskipRenderを付与すれば解決しそうだけどパフォーマンス的な影響が不明 content-visibility: auto; contain-intrinsic-size: 0 150px; } @@ -1140,10 +1119,13 @@ function emitUpdReaction(emoji: string, delta: number) { margin: 0 14px 0 0; width: var(--MI-avatar); height: var(--MI-avatar); - position: sticky !important; - top: calc(22px + var(--MI-stickyTop, 0px)); - left: 0; - transition: top 0.5s; + + &.useSticky { + position: sticky !important; + top: calc(22px + var(--MI-stickyTop, 0px)); + left: 0; + transition: top 0.5s; + } &.avatarReplyTo { position: relative !important; @@ -1218,13 +1200,6 @@ function emitUpdReaction(emoji: string, delta: number) { margin-right: 0.5em; } -.translation { - border: solid 0.5px var(--MI_THEME-divider); - border-radius: var(--MI-radius); - padding: 12px; - margin-top: 8px; -} - .urlPreview { margin-top: 8px; } @@ -1376,7 +1351,10 @@ function emitUpdReaction(emoji: string, delta: number) { .avatar { margin: 0 10px 0 0; - top: calc(14px + var(--MI-stickyTop, 0px)); + + &.useSticky { + top: calc(14px + var(--MI-stickyTop, 0px)); + } } } @@ -1427,6 +1405,11 @@ function emitUpdReaction(emoji: string, delta: number) { padding: 8px; text-align: center; opacity: 0.7; + cursor: pointer; +} + +.muted:hover { + background: var(--MI_THEME-buttonBg); } .reactionOmitted { diff --git a/packages/frontend/src/components/SkNoteDetailed.vue b/packages/frontend/src/components/SkNoteDetailed.vue index 3787b54333..b165b95d40 100644 --- a/packages/frontend/src/components/SkNoteDetailed.vue +++ b/packages/frontend/src/components/SkNoteDetailed.vue @@ -106,23 +106,18 @@ SPDX-License-Identifier: AGPL-3.0-only :enableEmojiMenuReaction="true" :isAnim="allowAnim" :isBlock="true" + class="_selectable" /> <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-if="translation"> - <b>{{ i18n.tsx.translatedFrom({ x: translation.sourceLang }) }}: </b> - <Mfm :text="translation.text" :isBlock="true" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis"/> - </div> - </div> + <SkNoteTranslation :note="note" :translation="translation" :translating="translating"></SkNoteTranslation> <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> + <MkButton v-else-if="!prefer.s.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 && appearNote.files.length > 0"> <MkMediaList ref="galleryEl" :mediaList="appearNote.files"/> </div> <MkPoll v-if="appearNote.poll" ref="pollViewer" :noteId="appearNote.id" :poll="appearNote.poll" :local="!appearNote.user.host" :class="$style.poll" :author="appearNote.user" :emojiUrls="appearNote.emojis"/> <div v-if="isEnabledUrlPreview"> - <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" :showAsQuote="!appearNote.user.rejectQuotes" :skipNoteIds="[appearNote.renote?.id]" style="margin-top: 6px;"/> + <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" :showAsQuote="!appearNote.user.rejectQuotes" :skipNoteIds="selfNoteIds" style="margin-top: 6px;"/> </div> <div v-if="appearNote.renote" :class="$style.quote"><SkNoteSimple :note="appearNote.renote" :class="$style.quoteNote" :expandAllCws="props.expandAllCws"/></div> </div> @@ -136,7 +131,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkTime :time="appearNote.createdAt" mode="detail" colored/> </MkA> </div> - <MkReactionsViewer v-if="appearNote.reactionAcceptance !== 'likeOnly'" ref="reactionsViewer" :note="appearNote"/> + <MkReactionsViewer v-if="appearNote.reactionAcceptance !== 'likeOnly'" ref="reactionsViewer" style="margin-top: 6px;" :note="appearNote"/> <footer :class="$style.footer"> <button class="_button" :class="$style.noteFooterButton" @click="reply()"> <i class="ti ti-arrow-back-up"></i> @@ -174,12 +169,15 @@ SPDX-License-Identifier: AGPL-3.0-only <i v-else-if="appearNote.myReaction != null" class="ti ti-minus" style="color: var(--MI_THEME-accent);"></i> <i v-else-if="appearNote.reactionAcceptance === 'likeOnly'" class="ti ti-heart"></i> <i v-else class="ph-smiley ph-bold ph-lg"></i> - <p v-if="(appearNote.reactionAcceptance === 'likeOnly' || defaultStore.state.showReactionsCount) && appearNote.reactionCount > 0" :class="$style.noteFooterButtonCount">{{ number(appearNote.reactionCount) }}</p> + <p v-if="(appearNote.reactionAcceptance === 'likeOnly' || prefer.s.showReactionsCount) && appearNote.reactionCount > 0" :class="$style.noteFooterButtonCount">{{ number(appearNote.reactionCount) }}</p> </button> - <button v-if="defaultStore.state.showClipButtonInNoteFooter" ref="clipButton" class="_button" :class="$style.noteFooterButton" @mousedown.prevent="clip()"> + <button v-if="prefer.s.showClipButtonInNoteFooter" ref="clipButton" class="_button" :class="$style.noteFooterButton" @click.stop="clip()"> <i class="ti ti-paperclip"></i> </button> - <button ref="menuButton" class="_button" :class="$style.noteFooterButton" @mousedown.prevent="showMenu()"> + <button v-if="prefer.s.showTranslationButtonInNoteFooter && $i?.policies.canUseTranslator && instance.translatorAvailable" ref="translationButton" class="_button" :class="$style.noteFooterButton" :disabled="translating || !!translation" @click.stop="translate()"> + <i class="ti ti-language-hiragana"></i> + </button> + <button ref="menuButton" class="_button" :class="$style.noteFooterButton" @click.stop="showMenu()"> <i class="ti ti-dots"></i> </button> </footer> @@ -234,23 +232,21 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> <div v-else class="_panel" :class="$style.muted" @click="muted = false"> - <I18n :src="i18n.ts.userSaysSomething" tag="small"> - <template #name> - <MkA v-user-preview="appearNote.userId" :to="userPage(appearNote.user)"> - <MkUserName :user="appearNote.user"/> - </MkA> - </template> - </I18n> + <SkMutedNote :muted="muted" :note="appearNote"></SkMutedNote> </div> </template> <script lang="ts" setup> -import { computed, inject, onMounted, onUnmounted, onUpdated, provide, ref, shallowRef, watch } from 'vue'; +import { computed, inject, onMounted, onUnmounted, onUpdated, provide, ref, useTemplateRef, watch } from 'vue'; import * as mfm from '@transfem-org/sfm-js'; import * as Misskey from 'misskey-js'; import { isLink } from '@@/js/is-link.js'; -import { host } from '@@/js/config.js'; +import * as config from '@@/js/config.js'; import { computeMergedCw } from '@@/js/compute-merged-cw.js'; +import type { OpenOnRemoteOptions } from '@/utility/please-login.js'; +import type { Paging } from '@/components/MkPagination.vue'; +import type { Keymap } from '@/utility/hotkey.js'; +import type { Visibility } from '@/utility/boost-quote.js'; import SkNoteSub from '@/components/SkNoteSub.vue'; import SkNoteSimple from '@/components/SkNoteSimple.vue'; import MkReactionsViewer from '@/components/MkReactionsViewer.vue'; @@ -261,41 +257,46 @@ import MkPoll from '@/components/MkPoll.vue'; import MkUsersTooltip from '@/components/MkUsersTooltip.vue'; import MkUrlPreview from '@/components/MkUrlPreview.vue'; import SkInstanceTicker from '@/components/SkInstanceTicker.vue'; -import { pleaseLogin, type OpenOnRemoteOptions } from '@/scripts/please-login.js'; -import { checkWordMute } from '@/scripts/check-word-mute.js'; +import { pleaseLogin } from '@/utility/please-login.js'; +import { checkMutes } from '@/utility/check-word-mute.js'; import { userPage } from '@/filters/user.js'; -import number from '@/filters/number.js'; import { notePage } from '@/filters/note.js'; +import number from '@/filters/number.js'; import * as os from '@/os.js'; -import { misskeyApi, misskeyApiGet } 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'; -import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm.js'; -import { $i } from '@/account.js'; +import { misskeyApi, misskeyApiGet } from '@/utility/misskey-api.js'; +import * as sound from '@/utility/sound.js'; +import { reactionPicker } from '@/utility/reaction-picker.js'; +import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js'; +import { $i } from '@/i.js'; import { i18n } from '@/i18n.js'; -import { getNoteClipMenu, getNoteMenu } from '@/scripts/get-note-menu.js'; -import { getNoteVersionsMenu } from '@/scripts/get-note-versions-menu.js'; -import { useNoteCapture } from '@/scripts/use-note-capture.js'; -import { deepClone } from '@/scripts/clone.js'; -import { useTooltip } from '@/scripts/use-tooltip.js'; -import { claimAchievement } from '@/scripts/achievements.js'; -import { checkAnimationFromMfm } from '@/scripts/check-animated-mfm.js'; +import { getNoteClipMenu, getNoteMenu, getRenoteMenu, translateNote } from '@/utility/get-note-menu.js'; +import { getNoteVersionsMenu } from '@/utility/get-note-versions-menu.js'; +import { checkAnimationFromMfm } from '@/utility/check-animated-mfm.js'; +import { useNoteCapture } from '@/use/use-note-capture.js'; +import { deepClone } from '@/utility/clone.js'; +import { useTooltip } from '@/use/use-tooltip.js'; +import { claimAchievement } from '@/utility/achievements.js'; import MkRippleEffect from '@/components/MkRippleEffect.vue'; -import { showMovedDialog } from '@/scripts/show-moved-dialog.js'; +import { showMovedDialog } from '@/utility/show-moved-dialog.js'; import MkUserCardMini from '@/components/MkUserCardMini.vue'; -import MkPagination, { type Paging } from '@/components/MkPagination.vue'; +import MkPagination from '@/components/MkPagination.vue'; import MkReactionIcon from '@/components/MkReactionIcon.vue'; import MkButton from '@/components/MkButton.vue'; -import { boostMenuItems, type Visibility, computeRenoteTooltip } from '@/scripts/boost-quote.js'; -import { isEnabledUrlPreview } from '@/instance.js'; -import { getAppearNote } from '@/scripts/get-appear-note.js'; -import { type Keymap } from '@/scripts/hotkey.js'; +import { boostMenuItems, computeRenoteTooltip } from '@/utility/boost-quote.js'; +import { instance, isEnabledUrlPreview } from '@/instance.js'; +import { getAppearNote } from '@/utility/get-appear-note.js'; +import { prefer } from '@/preferences.js'; +import { getPluginHandlers } from '@/plugin.js'; +import { DI } from '@/di.js'; +import SkMutedNote from '@/components/SkMutedNote.vue'; +import SkNoteTranslation from '@/components/SkNoteTranslation.vue'; +import { getSelfNoteIds } from '@/utility/get-self-note-ids.js'; +import { extractPreviewUrls } from '@/utility/extract-preview-urls'; const props = withDefaults(defineProps<{ note: Misskey.entities.Note; + initialTab?: string; expandAllCws?: boolean; - initialTab: string; }>(), { initialTab: 'replies', }); @@ -305,6 +306,7 @@ const inChannel = inject('inChannel', null); const note = ref(deepClone(props.note)); // plugin +const noteViewInterruptors = getPluginHandlers('note_view_interruptor'); if (noteViewInterruptors.length > 0) { onMounted(async () => { let result: Misskey.entities.Note | null = deepClone(note.value); @@ -325,40 +327,42 @@ if (noteViewInterruptors.length > 0) { const isRenote = Misskey.note.isPureRenote(note.value); -const rootEl = shallowRef<HTMLElement>(); -const noteEl = shallowRef<HTMLElement>(); -const menuButton = shallowRef<HTMLElement>(); -const menuVersionsButton = shallowRef<HTMLElement>(); -const renoteButton = shallowRef<HTMLElement>(); -const renoteTime = shallowRef<HTMLElement>(); -const reactButton = shallowRef<HTMLElement>(); -const quoteButton = shallowRef<HTMLElement>(); -const clipButton = shallowRef<HTMLElement>(); -const likeButton = shallowRef<HTMLElement>(); +const rootEl = useTemplateRef('rootEl'); +const noteEl = useTemplateRef('noteEl'); +const menuButton = useTemplateRef('menuButton'); +const renoteButton = useTemplateRef('renoteButton'); +const renoteTime = useTemplateRef('renoteTime'); +const reactButton = useTemplateRef('reactButton'); +const clipButton = useTemplateRef('clipButton'); +const menuVersionsButton = useTemplateRef('menuVersionsButton'); +const quoteButton = useTemplateRef('quoteButton'); +const likeButton = useTemplateRef('likeButton'); const appearNote = computed(() => getAppearNote(note.value)); -const galleryEl = shallowRef<InstanceType<typeof MkMediaList>>(); +const galleryEl = useTemplateRef('galleryEl'); const isMyRenote = $i && ($i.id === note.value.userId); -const showContent = ref(defaultStore.state.uncollapseCW); +const showContent = ref(prefer.s.uncollapseCW); const isDeleted = ref(false); const renoted = ref(false); -const muted = ref($i ? checkWordMute(appearNote.value, $i, $i.mutedWords) : false); -const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null); +const translation = ref<Misskey.entities.NotesTranslateResponse | false | null>(null); const translating = ref(false); -const parsed = appearNote.value.text ? mfm.parse(appearNote.value.text) : 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 parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : null); +const urls = computed(() => parsed.value ? extractPreviewUrls(props.note, parsed.value) : null); +const selfNoteIds = computed(() => getSelfNoteIds(props.note)); +const animated = computed(() => parsed.value ? checkAnimationFromMfm(parsed.value) : null); +const allowAnim = ref(prefer.s.advancedMfm && prefer.s.animatedMfm ? true : false); +const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.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.visibility === 'followers' && appearNote.value.userId === $i?.id)); -const defaultLike = computed(() => defaultStore.state.like ? defaultStore.state.like : null); +const defaultLike = computed(() => prefer.s.like ? prefer.s.like : null); const mergedCW = computed(() => computeMergedCw(appearNote.value)); const renoteTooltip = computeRenoteTooltip(renoted); +const { muted } = checkMutes(appearNote.value); + watch(() => props.expandAllCws, (expandAllCws) => { if (expandAllCws !== showContent.value) showContent.value = expandAllCws; }); @@ -377,18 +381,23 @@ let renoting = false; const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({ type: 'lookup', - url: `https://${host}/notes/${appearNote.value.id}`, + url: appearNote.value.url ?? appearNote.value.uri ?? `${config.url}/notes/${appearNote.value.id}`, })); const keymap = { 'r': () => reply(), 'e|a|plus': () => react(), - 'q': () => { if (canRenote.value && !renoted.value && !renoting) renote(defaultStore.state.visibilityOnBoost); }, + 'q': () => { if (canRenote.value && !renoted.value && !renoting) renote(prefer.s.visibilityOnBoost); }, 'm': () => showMenu(), 'c': () => { - if (!defaultStore.state.showClipButtonInNoteFooter) return; + if (!prefer.s.showClipButtonInNoteFooter) return; clip(); }, + 't': () => { + if (prefer.s.showTranslationButtonInNoteFooter && $i?.policies.canUseTranslator && instance.translatorAvailable) { + translate(); + } + }, 'o': () => galleryEl.value?.openGallery(), 'v|enter': () => { if (appearNote.value.cw != null) { @@ -401,7 +410,8 @@ const keymap = { }, } as const satisfies Keymap; -provide('react', (reaction: string) => { +provide(DI.mfmEmojiReactCallback, (reaction) => { + sound.playMisskeySfx('reaction'); misskeyApi('notes/reactions/create', { noteId: appearNote.value.id, reaction: reaction, @@ -493,8 +503,8 @@ useTooltip(quoteButton, async (showing) => { function boostVisibility(forceMenu: boolean = false) { if (renoting) return; - if (!defaultStore.state.showVisibilitySelectorOnBoost && !forceMenu) { - renote(defaultStore.state.visibilityOnBoost); + if (!prefer.s.showVisibilitySelectorOnBoost && !forceMenu) { + renote(prefer.s.visibilityOnBoost); } else { os.popupMenu(boostMenuItems(appearNote, renote), renoteButton.value); } @@ -530,7 +540,7 @@ function renote(visibility: Visibility, localOnly: boolean = false) { renoting = true; - if (appearNote.value.channel) { + if (appearNote.value.channel && !appearNote.value.channel.allowRenoteToExternal) { const el = renoteButton.value as HTMLElement | null | undefined; if (el) { const rect = el.getBoundingClientRect(); @@ -548,7 +558,7 @@ function renote(visibility: Visibility, localOnly: boolean = false) { os.toast(i18n.ts.renoted); renoted.value = true; }).finally(() => { renoting = false; }); - } else if (!appearNote.value.channel || appearNote.value.channel.allowRenoteToExternal) { + } else { const el = renoteButton.value as HTMLElement | null | undefined; if (el) { const rect = el.getBoundingClientRect(); @@ -650,7 +660,7 @@ function react(): void { override: defaultLike.value, }); const el = reactButton.value; - if (el) { + if (el && prefer.s.animation) { const rect = el.getBoundingClientRect(); const x = rect.left + (el.offsetWidth / 2); const y = rect.top + (el.offsetHeight / 2); @@ -660,7 +670,16 @@ function react(): void { } } else { blur(); - reactionPicker.show(reactButton.value ?? null, note.value, reaction => { + reactionPicker.show(reactButton.value ?? null, note.value, async (reaction) => { + if (prefer.s.confirmOnReact) { + const confirm = await os.confirm({ + type: 'question', + text: i18n.tsx.reactAreYouSure({ emoji: reaction.replace('@.', '') }), + }); + + if (confirm.canceled) return; + } + sound.playMisskeySfx('reaction'); misskeyApi('notes/reactions/create', { @@ -734,7 +753,7 @@ function onContextmenu(ev: MouseEvent): void { if (ev.target && isLink(ev.target as HTMLElement)) return; if (window.getSelection()?.toString() !== '') return; - if (defaultStore.state.useReactionPickerForContextMenu) { + if (prefer.s.useReactionPickerForContextMenu) { ev.preventDefault(); react(); } else { @@ -749,7 +768,7 @@ function showMenu(): void { } async function menuVersions(): Promise<void> { - const { menu, cleanup } = await getNoteVersionsMenu({ note: note.value, menuVersionsButton }); + const { menu, cleanup } = await getNoteVersionsMenu({ note: note.value }); os.popupMenu(menu, menuVersionsButton.value).then(focus).finally(cleanup); } @@ -757,6 +776,10 @@ async function clip(): Promise<void> { os.popupMenu(await getNoteClipMenu({ note: note.value, isDeleted }), clipButton.value).then(focus); } +async function translate() { + await translateNote(appearNote.value.id, translation, translating); +} + function showRenoteMenu(): void { if (!isMyRenote) return; pleaseLogin({ openOnRemote: pleaseLoginContext.value }); @@ -824,7 +847,7 @@ function loadConversation() { }); } -if (appearNote.value.reply && appearNote.value.reply.replyId && defaultStore.state.autoloadConversation) loadConversation(); +if (appearNote.value.reply && appearNote.value.reply.replyId && prefer.s.autoloadConversation) loadConversation(); function animatedMFM() { if (allowAnim.value) { @@ -845,7 +868,7 @@ function setScrolling() { } onMounted(() => { - document.addEventListener('wheel', setScrolling); + window.document.addEventListener('wheel', setScrolling); isScrolling = false; noteEl.value?.scrollIntoView({ block: 'center' }); }); @@ -853,14 +876,14 @@ onMounted(() => { onUpdated(() => { if (!isScrolling) { noteEl.value?.scrollIntoView({ block: 'center' }); - if (location.hash) { - location.replace(location.hash); // Jump to highlighted reply + if (window.location.hash) { + window.location.replace(window.location.hash); // Jump to highlighted reply } } }); onUnmounted(() => { - document.removeEventListener('wheel', setScrolling); + window.document.removeEventListener('wheel', setScrolling); }); </script> @@ -1087,13 +1110,6 @@ onUnmounted(() => { color: var(--MI_THEME-renote); } -.translation { - border: solid 0.5px var(--MI_THEME-divider); - border-radius: var(--MI-radius); - padding: 12px; - margin-top: 8px; -} - .poll { font-size: 80%; } @@ -1256,6 +1272,11 @@ onUnmounted(() => { padding: 8px; text-align: center; opacity: 0.7; + cursor: pointer; +} + +.muted:hover { + background: var(--MI_THEME-buttonBg); } .badgeRoles { diff --git a/packages/frontend/src/components/SkNoteHeader.vue b/packages/frontend/src/components/SkNoteHeader.vue index cb50e57132..8d099eaf7a 100644 --- a/packages/frontend/src/components/SkNoteHeader.vue +++ b/packages/frontend/src/components/SkNoteHeader.vue @@ -52,9 +52,9 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-if="note.user.isBot" :class="$style.isBot">bot</div> <div :class="$style.classicUsername"><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> - <SkInstanceTicker v-if="showTicker && !isMobile && defaultStore.state.showTickerOnReplies" style="cursor: pointer; max-height: 5px; top: 3px; position: relative; margin-top: 0px !important;" :instance="note.user.instance" :host="note.user.host" @click.stop="showOnRemote()"/> + <SkInstanceTicker v-if="showTicker && !isMobile && prefer.s.showTickerOnReplies" style="cursor: pointer; max-height: 5px; top: 3px; position: relative; margin-top: 0 !important;" :instance="note.user.instance" :host="note.user.host" @click.stop="showOnRemote()"/> <div :class="$style.classicInfo"> <div v-if="mock"> <MkTime :time="note.createdAt" colored/> @@ -75,36 +75,34 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { inject, shallowRef, ref } from 'vue'; +import { inject, ref, shallowRef, useTemplateRef } from 'vue'; import * as Misskey from 'misskey-js'; import { i18n } from '@/i18n.js'; import { notePage } from '@/filters/note.js'; import { userPage } from '@/filters/user.js'; -import { getNoteVersionsMenu } from '@/scripts/get-note-versions-menu.js'; -import SkInstanceTicker from '@/components/SkInstanceTicker.vue'; +import { getNoteVersionsMenu } from '@/utility/get-note-versions-menu.js'; import { popupMenu } from '@/os.js'; -import { defaultStore } from '@/store.js'; -import { useRouter } from '@/router/supplier.js'; -import { deviceKind } from '@/scripts/device-kind.js'; -import MkInstanceTicker from '@/components/MkInstanceTicker.vue'; +import { DI } from '@/di.js'; +import { prefer } from '@/preferences'; +import { useRouter } from '@/router'; +import { deviceKind } from '@/utility/device-kind'; +import SkInstanceTicker from '@/components/SkInstanceTicker.vue'; const props = defineProps<{ note: Misskey.entities.Note; classic?: boolean; }>(); -const menuVersionsButton = shallowRef<HTMLElement>(); +const menuVersionsButton = useTemplateRef('menuVersionsButton'); const router = useRouter(); -const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && props.note.user.instance); +const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && props.note.user.instance); const MOBILE_THRESHOLD = 500; const isMobile = ref(deviceKind === 'smartphone' || window.innerWidth <= MOBILE_THRESHOLD); -async function menuVersions(viaKeyboard = false): Promise<void> { - const { menu, cleanup } = await getNoteVersionsMenu({ note: props.note, menuVersionsButton }); - popupMenu(menu, menuVersionsButton.value, { - viaKeyboard, - }).then(focus).finally(cleanup); +async function menuVersions(): Promise<void> { + const { menu, cleanup } = await getNoteVersionsMenu({ note: props.note }); + popupMenu(menu, menuVersionsButton.value).then(focus).finally(cleanup); } function showOnRemote() { @@ -112,7 +110,7 @@ function showOnRemote() { else window.open(props.note.url ?? props.note.uri); } -const mock = inject<boolean>('mock', false); +const mock = inject(DI.mock, false); </script> <style lang="scss" module> diff --git a/packages/frontend/src/components/SkNoteSimple.vue b/packages/frontend/src/components/SkNoteSimple.vue index 71a5bd4df8..06acc1fc2d 100644 --- a/packages/frontend/src/components/SkNoteSimple.vue +++ b/packages/frontend/src/components/SkNoteSimple.vue @@ -28,7 +28,7 @@ import { computeMergedCw } from '@@/js/compute-merged-cw.js'; import MkNoteHeader from '@/components/MkNoteHeader.vue'; import MkSubNoteContent from '@/components/MkSubNoteContent.vue'; import MkCwButton from '@/components/MkCwButton.vue'; -import { defaultStore } from '@/store.js'; +import { prefer } from '@/preferences.js'; const props = defineProps<{ note: Misskey.entities.Note; @@ -36,7 +36,7 @@ const props = defineProps<{ hideFiles?: boolean; }>(); -let showContent = ref(defaultStore.state.uncollapseCW); +let showContent = ref(prefer.s.uncollapseCW); const mergedCW = computed(() => computeMergedCw(props.note)); diff --git a/packages/frontend/src/components/SkNoteSub.vue b/packages/frontend/src/components/SkNoteSub.vue index 12703e1945..775436cb0f 100644 --- a/packages/frontend/src/components/SkNoteSub.vue +++ b/packages/frontend/src/components/SkNoteSub.vue @@ -40,7 +40,7 @@ SPDX-License-Identifier: AGPL-3.0-only class="_button" :class="$style.noteFooterButton" :style="renoted ? 'color: var(--MI_THEME-accent) !important;' : ''" - @mousedown="renoted ? undoRenote() : boostVisibility($event.shiftKey)" + @click.stop="renoted ? undoRenote() : boostVisibility($event.shiftKey)" > <i class="ph-rocket-launch ph-bold ph-lg"></i> <p v-if="note.renoteCount > 0" :class="$style.noteFooterButtonCount">{{ note.renoteCount }}</p> @@ -50,30 +50,36 @@ SPDX-License-Identifier: AGPL-3.0-only ref="quoteButton" class="_button" :class="$style.noteFooterButton" - @mousedown="quote()" + @click.stop="quote()" > <i class="ph-quotes ph-bold ph-lg"></i> </button> <button v-else class="_button" :class="$style.noteFooterButton" disabled> <i class="ph-prohibit ph-bold ph-lg"></i> </button> - <button v-if="note.myReaction == null && note.reactionAcceptance !== 'likeOnly'" ref="likeButton" :class="$style.noteFooterButton" class="_button" @mousedown="like()"> + <button v-if="note.myReaction == null && note.reactionAcceptance !== 'likeOnly'" ref="likeButton" :class="$style.noteFooterButton" class="_button" @click.stop="like()"> <i class="ph-heart ph-bold ph-lg"></i> </button> - <button v-if="note.myReaction == null" ref="reactButton" :class="$style.noteFooterButton" class="_button" @mousedown="react()"> + <button v-if="note.myReaction == null" ref="reactButton" :class="$style.noteFooterButton" class="_button" @click.stop="react()"> <i v-if="note.reactionAcceptance === 'likeOnly'" class="ph-heart ph-bold ph-lg"></i> <i v-else class="ph-smiley ph-bold ph-lg"></i> </button> <button v-if="note.myReaction != null" ref="reactButton" class="_button" :class="[$style.noteFooterButton, $style.reacted]" @click="undoReact(note)"> <i class="ph-minus ph-bold ph-lg"></i> </button> - <button ref="menuButton" class="_button" :class="$style.noteFooterButton" @mousedown="menu()"> + <button v-if="prefer.s.showClipButtonInNoteFooter" ref="clipButton" :class="$style.noteFooterButton" class="_button" @click.stop="clip()"> + <i class="ti ti-paperclip"></i> + </button> + <button v-if="prefer.s.showTranslationButtonInNoteFooter && $i?.policies.canUseTranslator && instance.translatorAvailable" ref="translationButton" class="_button" :class="$style.noteFooterButton" :disabled="translating || !!translation" @click.stop="translate()"> + <i class="ti ti-language-hiragana"></i> + </button> + <button ref="menuButton" class="_button" :class="$style.noteFooterButton" @click.stop="menu()"> <i class="ph-dots-three ph-bold ph-lg"></i> </button> </footer> </div> </div> - <template v-if="depth < numberOfReplies"> + <template v-if="depth < prefer.s.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" :isReply="props.isReply"/> </template> <div v-else :class="$style.more"> @@ -81,45 +87,41 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> <div v-else :class="$style.muted" @click="muted = false"> - <I18n :src="i18n.ts.userSaysSomething" tag="small"> - <template #name> - <MkA v-user-preview="note.userId" :to="userPage(note.user)"> - <MkUserName :user="note.user"/> - </MkA> - </template> - </I18n> + <SkMutedNote :muted="muted" :note="appearNote"></SkMutedNote> </div> </template> <script lang="ts" setup> -import { computed, ref, shallowRef, watch } from 'vue'; +import { computed, inject, ref, shallowRef, useTemplateRef, watch } from 'vue'; import * as Misskey from 'misskey-js'; import { computeMergedCw } from '@@/js/compute-merged-cw.js'; +import * as config from '@@/js/config.js'; +import type { Ref } from 'vue'; +import type { Visibility } from '@/utility/boost-quote.js'; +import type { OpenOnRemoteOptions } from '@/utility/please-login.js'; import SkNoteHeader from '@/components/SkNoteHeader.vue'; import MkReactionsViewer from '@/components/MkReactionsViewer.vue'; 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 * as sound from '@/utility/sound.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; import { userPage } from '@/filters/user.js'; -import { checkWordMute } from '@/scripts/check-word-mute.js'; -import { defaultStore } from '@/store.js'; -import { host } from '@@/js/config.js'; -import { pleaseLogin, type OpenOnRemoteOptions } from '@/scripts/please-login.js'; -import { showMovedDialog } from '@/scripts/show-moved-dialog.js'; +import { checkMutes } from '@/utility/check-word-mute.js'; +import { pleaseLogin } from '@/utility/please-login.js'; +import { showMovedDialog } from '@/utility/show-moved-dialog.js'; import MkRippleEffect from '@/components/MkRippleEffect.vue'; -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, computeRenoteTooltip } 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; }); +import { reactionPicker } from '@/utility/reaction-picker.js'; +import { claimAchievement } from '@/utility/achievements.js'; +import { getNoteClipMenu, getNoteMenu, translateNote } from '@/utility/get-note-menu.js'; +import { boostMenuItems, computeRenoteTooltip } from '@/utility/boost-quote.js'; +import { prefer } from '@/preferences.js'; +import { useNoteCapture } from '@/use/use-note-capture.js'; +import SkMutedNote from '@/components/SkMutedNote.vue'; +import { instance } from '@/instance'; const props = withDefaults(defineProps<{ note: Misskey.entities.Note; @@ -136,16 +138,19 @@ const props = withDefaults(defineProps<{ depth: 1, isReply: false, detailed: false, + onDeleteCallback: undefined, }); +const canRenote = computed(() => ['public', 'home'].includes(props.note.visibility) || props.note.userId === $i?.id); +const hideLine = computed(() => props.detail); + const el = shallowRef<HTMLElement>(); -const muted = ref($i ? checkWordMute(props.note, $i, $i.mutedWords) : false); -const translation = ref<any>(null); +const translation = ref<Misskey.entities.NotesTranslateResponse | false | null>(null); const translating = ref(false); const isDeleted = ref(false); const renoted = ref(false); -const numberOfReplies = ref(defaultStore.state.numberOfReplies); const reactButton = shallowRef<HTMLElement>(); +const clipButton = useTemplateRef('clipButton'); const renoteButton = shallowRef<HTMLElement>(); const quoteButton = shallowRef<HTMLElement>(); const menuButton = shallowRef<HTMLElement>(); @@ -154,7 +159,7 @@ const likeButton = shallowRef<HTMLElement>(); const renoteTooltip = computeRenoteTooltip(renoted); let appearNote = computed(() => isRenote ? props.note.renote as Misskey.entities.Note : props.note); -const defaultLike = computed(() => defaultStore.state.like ? defaultStore.state.like : null); +const defaultLike = computed(() => prefer.s.like ? prefer.s.like : null); const replies = ref<Misskey.entities.Note[]>([]); const mergedCW = computed(() => computeMergedCw(appearNote.value)); @@ -168,9 +173,11 @@ const isRenote = ( const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({ type: 'lookup', - url: `https://${host}/notes/${appearNote.value.id}`, + url: appearNote.value.url ?? appearNote.value.uri ?? `${config.url}/notes/${appearNote.value.id}`, })); +const currentClip = inject<Ref<Misskey.entities.Clip> | null>('currentClip', null); + async function addReplyTo(replyNote: Misskey.entities.Note) { replies.value.unshift(replyNote); appearNote.value.repliesCount += 1; @@ -184,13 +191,15 @@ async function removeReply(id: Misskey.entities.Note['id']) { } } +const { muted } = checkMutes(appearNote.value); + useNoteCapture({ rootEl: el, note: appearNote, isDeletedRef: isDeleted, // only update replies if we are, in fact, showing replies - onReplyCallback: props.detail && props.depth < numberOfReplies.value ? addReplyTo : undefined, - onDeleteCallback: props.detail && props.depth < numberOfReplies.value ? props.onDeleteCallback : undefined, + onReplyCallback: props.detail && props.depth < prefer.s.numberOfReplies ? addReplyTo : undefined, + onDeleteCallback: props.detail && props.depth < prefer.s.numberOfReplies ? props.onDeleteCallback : undefined, }); if ($i) { @@ -204,22 +213,21 @@ if ($i) { } function focus() { - el.value.focus(); + el.value?.focus(); } -function reply(viaKeyboard = false): void { +async function reply(viaKeyboard = false): Promise<void> { pleaseLogin({ openOnRemote: pleaseLoginContext.value }); showMovedDialog(); - os.post({ + await os.post({ reply: props.note, - channel: props.note.channel, + channel: props.note.channel ?? undefined, animation: !viaKeyboard, - }, () => { - focus(); }); + focus(); } -function react(viaKeyboard = false): void { +function react(): void { pleaseLogin({ openOnRemote: pleaseLoginContext.value }); showMovedDialog(); sound.playMisskeySfx('reaction'); @@ -299,15 +307,15 @@ function undoRenote() : void { } } -let showContent = ref(defaultStore.state.uncollapseCW); +let showContent = ref(prefer.s.uncollapseCW); watch(() => props.expandAllCws, (expandAllCws) => { if (expandAllCws !== showContent.value) showContent.value = expandAllCws; }); function boostVisibility(forceMenu: boolean = false) { - if (!defaultStore.state.showVisibilitySelectorOnBoost && !forceMenu) { - renote(defaultStore.state.visibilityOnBoost); + if (!prefer.s.showVisibilitySelectorOnBoost && !forceMenu) { + renote(prefer.s.visibilityOnBoost); } else { os.popupMenu(boostMenuItems(appearNote, renote), renoteButton.value); } @@ -361,71 +369,50 @@ function quote() { pleaseLogin({ openOnRemote: pleaseLoginContext.value }); showMovedDialog(); - if (appearNote.value.channel) { - os.post({ - renote: appearNote.value, - channel: appearNote.value.channel, - }).then((cancelled) => { - if (cancelled) return; - misskeyApi('notes/renotes', { - noteId: props.note.id, - userId: $i.id, - limit: 1, - quote: true, - }).then((res) => { - if (!(res.length > 0)) return; - const el = quoteButton.value as HTMLElement | null | undefined; - if (el && res.length > 0) { - const rect = el.getBoundingClientRect(); - const x = rect.left + (el.offsetWidth / 2); - const y = rect.top + (el.offsetHeight / 2); - const { dispose } = os.popup(MkRippleEffect, { x, y }, { - end: () => dispose(), - }); - } + os.post({ + renote: appearNote.value, + channel: appearNote.value.channel ?? undefined, + }).then((cancelled) => { + if (cancelled) return; + misskeyApi('notes/renotes', { + noteId: props.note.id, + userId: $i?.id, + limit: 1, + quote: true, + }).then((res) => { + if (!(res.length > 0)) return; + const popupEl = quoteButton.value as HTMLElement | null | undefined; + if (popupEl && res.length > 0) { + const rect = popupEl.getBoundingClientRect(); + const x = rect.left + (popupEl.offsetWidth / 2); + const y = rect.top + (popupEl.offsetHeight / 2); + const { dispose } = os.popup(MkRippleEffect, { x, y }, { + end: () => dispose(), + }); + } - os.toast(i18n.ts.quoted); - }); + os.toast(i18n.ts.quoted); }); - } else { - os.post({ - renote: appearNote.value, - }).then((cancelled) => { - if (cancelled) return; - misskeyApi('notes/renotes', { - noteId: props.note.id, - userId: $i.id, - limit: 1, - quote: true, - }).then((res) => { - if (!(res.length > 0)) return; - const el = quoteButton.value as HTMLElement | null | undefined; - if (el && res.length > 0) { - const rect = el.getBoundingClientRect(); - const x = rect.left + (el.offsetWidth / 2); - const y = rect.top + (el.offsetHeight / 2); - const { dispose } = os.popup(MkRippleEffect, { x, y }, { - end: () => dispose(), - }); - } + }); +} - os.toast(i18n.ts.quoted); - }); - }); - } +function menu(): void { + const { menu, cleanup } = getNoteMenu({ note: props.note, translating, translation, isDeleted }); + os.popupMenu(menu, menuButton.value).then(focus).finally(cleanup); +} + +async function clip(): Promise<void> { + os.popupMenu(await getNoteClipMenu({ note: props.note, isDeleted, currentClip: currentClip?.value }), clipButton.value).then(focus); } -function menu(viaKeyboard = false): void { - const { menu, cleanup } = getNoteMenu({ note: props.note, translating, translation, menuButton, isDeleted }); - os.popupMenu(menu, menuButton.value, { - viaKeyboard, - }).then(focus).finally(cleanup); +async function translate() { + await translateNote(appearNote.value.id, translation, translating); } if (props.detail) { misskeyApi('notes/children', { noteId: props.note.id, - limit: numberOfReplies.value, + limit: prefer.s.numberOfReplies, showQuotes: false, }).then(res => { replies.value = res; @@ -634,6 +621,11 @@ if (props.detail) { border: 1px solid var(--MI_THEME-divider); margin: 8px 8px 0 8px; border-radius: var(--MI-radius-sm); + cursor: pointer; +} + +.muted:hover { + background: var(--MI_THEME-buttonBg); } // avatar container with line diff --git a/packages/frontend/src/components/SkNoteTranslation.vue b/packages/frontend/src/components/SkNoteTranslation.vue new file mode 100644 index 0000000000..170eea80cf --- /dev/null +++ b/packages/frontend/src/components/SkNoteTranslation.vue @@ -0,0 +1,48 @@ +<!-- +SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div v-if="translating || translation != null" :class="$style.translation"> + <MkLoading v-if="translating" mini/> + <div v-else-if="translation && translation.text != null"> + <b v-if="translation.sourceLang">{{ i18n.tsx.translatedFrom({ x: translation.sourceLang }) }}: </b> + <Mfm :text="translation.text" :isBlock="true" :author="note.user" :nyaize="'respect'" :emojiUrls="note.emojis" class="_selectable"/> + </div> + <div v-else>{{ i18n.ts.translationFailed }}</div> +</div> +</template> + +<script setup lang="ts"> +import * as Misskey from 'misskey-js'; +import { watch } from 'vue'; +import { i18n } from '@/i18n.js'; + +const props = withDefaults(defineProps<{ + note: Misskey.entities.Note; + translating?: boolean; + translation?: Misskey.entities.NotesTranslateResponse | false | null; +}>(), { + translating: false, + translation: null, +}); + +if (_DEV_) { + // Prop watch syntax: https://stackoverflow.com/a/59127059 + watch( + [() => props.translation, () => props.translating], + ([translation, translating]) => console.debug('Translation status changed: ', { translation, translating }), + { immediate: true }, + ); +} +</script> + +<style module lang="scss"> +.translation { + border: solid 0.5px var(--MI_THEME-divider); + border-radius: var(--MI-radius); + padding: 12px; + margin-top: 8px; +} +</style> diff --git a/packages/frontend/src/components/SkOldNoteWindow.vue b/packages/frontend/src/components/SkOldNoteWindow.vue index 48b9020402..b6dbec81c5 100644 --- a/packages/frontend/src/components/SkOldNoteWindow.vue +++ b/packages/frontend/src/components/SkOldNoteWindow.vue @@ -29,31 +29,25 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> <div :class="$style.noteHeaderUsername"><MkAcct :user="appearNote.user"/></div> - <MkInstanceTicker v-if="showTicker" :instance="appearNote.user.instance"/> + <MkInstanceTicker v-if="showTicker" :host="appearNote.user.host" :instance="appearNote.user.instance"/> </div> </header> <div :class="$style.noteContent"> <p v-if="appearNote.cw != null" :class="$style.cw"> - <Mfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :isBlock="true" :author="appearNote.user" :nyaize="'account'"/> + <Mfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :isBlock="true" :author="appearNote.user" :nyaize="'respect'"/> <MkCwButton v-model="showContent" :text="appearNote.text" :files="appearNote.files" :poll="appearNote.poll"/> </p> <div v-show="appearNote.cw == null || showContent"> <span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span> <MkA v-if="appearNote.replyId" :class="$style.noteReplyTarget" :to="`/notes/${appearNote.replyId}`"><i class="ph-arrow-bend-left-up ph-bold ph-lg"></i></MkA> - <Mfm v-if="appearNote.text" :text="appearNote.text" :isBlock="true" :author="appearNote.user" :nyaize="'account'" :emojiUrls="appearNote.emojis"/> + <Mfm v-if="appearNote.text" :text="appearNote.text" :isBlock="true" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis"/> <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> - <Mfm :text="translation.text" :isBlock="true" :author="appearNote.user" :nyaize="'account'" :emojiUrls="appearNote.emojis"/> - </div> - </div> - <div v-if="appearNote.files.length > 0"> + <SkNoteTranslation :note="note" :translation="translation" :translating="translating"></SkNoteTranslation> + <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"/> - <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" style="margin-top: 6px;"/> + <MkPoll v-if="appearNote.poll" :noteId="appearNote.id" :poll="appearNote.poll" :local="!appearNote.user.host" :author="appearNote.user" :emojiUrls="appearNote.emojis" :class="$style.poll"/> + <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" :showAsQuote="!appearNote.user.rejectQuotes" :skipNoteIds="selfNoteIds" style="margin-top: 6px;"/> <div v-if="appearNote.renote" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></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> @@ -92,11 +86,14 @@ import MkPoll from '@/components/MkPoll.vue'; import MkUrlPreview from '@/components/MkUrlPreview.vue'; import MkInstanceTicker from '@/components/MkInstanceTicker.vue'; import { userPage } from '@/filters/user.js'; -import { defaultStore, noteViewInterruptors } from '@/store.js'; -import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm.js'; import { i18n } from '@/i18n.js'; -import { deepClone } from '@/scripts/clone.js'; -import { dateTimeFormat } from '@/scripts/intl-const.js'; +import { deepClone } from '@/utility/clone.js'; +import { dateTimeFormat } from '@/utility/intl-const.js'; +import { prefer } from '@/preferences'; +import { getPluginHandlers } from '@/plugin.js'; +import SkNoteTranslation from '@/components/SkNoteTranslation.vue'; +import { getSelfNoteIds } from '@/utility/get-self-note-ids'; +import { extractPreviewUrls } from '@/utility/extract-preview-urls.js'; const props = defineProps<{ note: Misskey.entities.Note; @@ -113,13 +110,21 @@ const inChannel = inject('inChannel', null); let note = ref(deepClone(props.note)); // plugin +const noteViewInterruptors = getPluginHandlers('note_view_interruptor'); if (noteViewInterruptors.length > 0) { onMounted(async () => { - let result = deepClone(note.value); + let result: Misskey.entities.Note | null = deepClone(note.value); for (const interruptor of noteViewInterruptors) { - result = await interruptor.handler(result); + try { + result = await interruptor.handler(result!) as Misskey.entities.Note | null; + if (result === null) { + return; + } + } catch (err) { + console.error(err); + } } - note.value = result; + note.value = result as Misskey.entities.Note; }); } @@ -132,20 +137,20 @@ replaceContent(); const isRenote = ( note.value.renote != null && note.value.text == null && - note.value.fileIds.length === 0 && + !note.value.fileIds?.length && note.value.poll == null ); const el = shallowRef<HTMLElement>(); -let 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 appearNote = computed(() => isRenote ? note.value.renote as Misskey.entities.Note : note.value); +const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : null); const showContent = ref(false); -const translation = ref(null); +const translation = ref<Misskey.entities.NotesTranslateResponse | false | null>(null); const translating = ref(false); -const urls = appearNote.value.text ? extractUrlFromMfm(mfm.parse(appearNote.value.text)).filter(u => u !== renoteUrl && u !== renoteUri) : null; -const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.value.user.instance); +const urls = computed(() => parsed.value ? extractPreviewUrls(props.note, parsed.value) : null); +const selfNoteIds = computed(() => getSelfNoteIds(props.note)); +const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.value.user.instance); </script> @@ -250,13 +255,6 @@ const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultS color: var(--MI_THEME-renote); } -.translation { - border: solid 0.5px var(--MI_THEME-divider); - border-radius: var(--MI-radius); - padding: 12px; - margin-top: 8px; -} - .poll { font-size: 80%; } diff --git a/packages/frontend/src/components/SkOneko.vue b/packages/frontend/src/components/SkOneko.vue index ef7bdd74f0..f855805c70 100644 --- a/packages/frontend/src/components/SkOneko.vue +++ b/packages/frontend/src/components/SkOneko.vue @@ -23,7 +23,7 @@ let mousePosY = 0; let frameCount = 0; let idleTime = 0; -let idleAnimation: string|null = null; +let idleAnimation: string | null = null; let idleAnimationFrame = 0; let lastFrameTimestamp; @@ -97,7 +97,8 @@ function init() { nekoEl.value.style.left = `${nekoPosX - 16}px`; nekoEl.value.style.top = `${nekoPosY - 16}px`; - document.addEventListener('mousemove', (event) => { + // TODO this causes a memory leak because it never gets unbound + window.document.addEventListener('mousemove', (event) => { mousePosX = event.clientX; mousePosY = event.clientY; }); @@ -140,7 +141,6 @@ function idle() { if ( idleTime > 10 && Math.floor(Math.random() * 200) === 0 && - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition idleAnimation == null ) { let avalibleIdleAnimations = ['sleeping', 'scratchSelf']; diff --git a/packages/frontend/src/components/SkPatternTest.vue b/packages/frontend/src/components/SkPatternTest.vue new file mode 100644 index 0000000000..fe82c1df2f --- /dev/null +++ b/packages/frontend/src/components/SkPatternTest.vue @@ -0,0 +1,57 @@ +<!-- +SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<MkFolder> + <template #label>{{ i18n.ts.wordMuteTestLabel }}</template> + + <div class="_gaps"> + <MkTextarea v-model="testWords"> + <template #caption>{{ i18n.ts.wordMuteTestDescription }}</template> + </MkTextarea> + <div><MkButton :disabled="!testWords" @click="testWordMutes">{{ i18n.ts.wordMuteTestTest }}</MkButton></div> + <div v-if="testMatches == null">{{ i18n.ts.wordMuteTestNoResults }}</div> + <div v-else-if="testMatches === ''">{{ i18n.ts.wordMuteTestNoMatch }}</div> + <div v-else>{{ i18n.tsx.wordMuteTestMatch({ words: testMatches }) }}</div> + </div> +</MkFolder> +</template> + +<script setup lang="ts"> +import { ref } from 'vue'; +import { i18n } from '@/i18n'; +import MkFolder from '@/components/MkFolder.vue'; +import MkButton from '@/components/MkButton.vue'; +import MkTextarea from '@/components/MkTextarea.vue'; +import { parseMutes } from '@/utility/parse-mutes'; +import { checkWordMute } from '@/utility/check-word-mute'; + +const props = defineProps<{ + mutedWords?: string | null, +}>(); + +const testWords = ref<string | null>(null); +const testMatches = ref<string | null>(null); + +function testWordMutes() { + if (!testWords.value || !props.mutedWords) { + testMatches.value = null; + return; + } + + try { + const mutes = parseMutes(props.mutedWords); + const matches = checkWordMute(testWords.value, null, mutes); + testMatches.value = matches ? matches.join(', ') : ''; + } catch { + // Error is displayed by above function + testMatches.value = null; + } +} +</script> + +<style module lang="scss"> + +</style> diff --git a/packages/frontend/src/components/SkRemoteFollowersWarning.vue b/packages/frontend/src/components/SkRemoteFollowersWarning.vue index ceebbd59dd..32d57477de 100644 --- a/packages/frontend/src/components/SkRemoteFollowersWarning.vue +++ b/packages/frontend/src/components/SkRemoteFollowersWarning.vue @@ -4,16 +4,17 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkInfo v-if="showRemoteWarning" warn closable @close="close"> +<MkInfo v-if="showRemoteWarning" warn closable @close="closeWarning"> {{ i18n.ts.remoteFollowersWarning }} </MkInfo> </template> <script setup lang="ts"> import { computed } from 'vue'; +import type { FollowingFeedModel } from '@/types/following-feed.js'; import { i18n } from '@/i18n.js'; import MkInfo from '@/components/MkInfo.vue'; -import { followersTab, FollowingFeedModel } from '@/scripts/following-feed-utils.js'; +import { followersTab } from '@/types/following-feed.js'; const props = defineProps<{ model: FollowingFeedModel, @@ -26,7 +27,7 @@ const showRemoteWarning = computed( () => userList.value === followersTab && !remoteWarningDismissed.value, ); -function close() { +function closeWarning() { remoteWarningDismissed.value = true; } </script> diff --git a/packages/frontend/src/components/SkUserRecentNotes.vue b/packages/frontend/src/components/SkUserRecentNotes.vue index 908affcdaf..b66a33f644 100644 --- a/packages/frontend/src/components/SkUserRecentNotes.vue +++ b/packages/frontend/src/components/SkUserRecentNotes.vue @@ -15,14 +15,15 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script setup lang="ts"> -import { computed, onMounted, ref, Ref, watch } from 'vue'; +import { computed, onMounted, ref, watch } from 'vue'; import * as Misskey from 'misskey-js'; +import type { Ref } from 'vue'; +import type { Paging } from '@/components/MkPagination.vue'; import MkLoading from '@/components/global/MkLoading.vue'; import MkNotes from '@/components/MkNotes.vue'; import MkUserInfo from '@/components/MkUserInfo.vue'; import MkPullToRefresh from '@/components/MkPullToRefresh.vue'; -import { Paging } from '@/components/MkPagination.vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; const props = defineProps<{ userId: string; @@ -66,9 +67,9 @@ async function reload(): Promise<void> { // An additional request is needed to "upgrade" the object. misskeyApi('users/show', { userId: props.userId }), - // Wait for 1 second to match the animation effects in MkHorizontalSwipe, MkPullToRefresh, and MkPagination. + // Wait for 1 second to match the animation effects in MkSwiper, MkPullToRefresh, and MkPagination. // Otherwise, the page appears to load "backwards". - new Promise(resolve => setTimeout(resolve, 1000)), + new Promise(resolve => window.setTimeout(resolve, 1000)), ]) .then(([u]) => user.value = u) .catch(error => { diff --git a/packages/frontend/src/components/form/link.vue b/packages/frontend/src/components/form/link.vue index b8837d7133..7ed1bcfa89 100644 --- a/packages/frontend/src/components/form/link.vue +++ b/packages/frontend/src/components/form/link.vue @@ -79,7 +79,7 @@ const props = defineProps<{ margin-right: 0.75em; flex-shrink: 0; text-align: center; - color: var(--MI_THEME-fgTransparentWeak); + color: color(from var(--MI_THEME-fg) srgb r g b / 0.75); &:empty { display: none; diff --git a/packages/frontend/src/components/form/section.vue b/packages/frontend/src/components/form/section.vue index 5fca3acc31..b23ed51a83 100644 --- a/packages/frontend/src/components/form/section.vue +++ b/packages/frontend/src/components/form/section.vue @@ -49,7 +49,7 @@ defineProps<{ .description { font-size: 0.85em; - color: var(--MI_THEME-fgTransparentWeak); + color: color(from var(--MI_THEME-fg) srgb r g b / 0.75); margin: 0 0 8px 0; } </style> diff --git a/packages/frontend/src/components/form/slot.vue b/packages/frontend/src/components/form/slot.vue index da94b7abbb..cc3af9aca4 100644 --- a/packages/frontend/src/components/form/slot.vue +++ b/packages/frontend/src/components/form/slot.vue @@ -35,7 +35,7 @@ function focus() { .caption { font-size: 0.85em; padding: 8px 0 0 0; - color: var(--MI_THEME-fgTransparentWeak); + color: color(from var(--MI_THEME-fg) srgb r g b / 0.75); &:empty { display: none; diff --git a/packages/frontend/src/components/global/MkA.stories.impl.ts b/packages/frontend/src/components/global/MkA.stories.impl.ts index 02e5a7f98c..deb2b8a52b 100644 --- a/packages/frontend/src/components/global/MkA.stories.impl.ts +++ b/packages/frontend/src/components/global/MkA.stories.impl.ts @@ -5,9 +5,9 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { expect, userEvent, within } from '@storybook/test'; -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import MkA from './MkA.vue'; -import { tick } from '@/scripts/test-utils.js'; +import { tick } from '@/utility/test-utils.js'; export const Default = { render(args) { return { diff --git a/packages/frontend/src/components/global/MkA.vue b/packages/frontend/src/components/global/MkA.vue index 23f049ebb4..9a51acc9dc 100644 --- a/packages/frontend/src/components/global/MkA.vue +++ b/packages/frontend/src/components/global/MkA.vue @@ -14,12 +14,12 @@ export type MkABehavior = 'window' | 'browser' | null; </script> <script lang="ts" setup> -import { computed, inject, shallowRef } from 'vue'; -import * as os from '@/os.js'; -import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; +import { computed, inject, useTemplateRef } from 'vue'; import { url } from '@@/js/config.js'; +import * as os from '@/os.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; import { i18n } from '@/i18n.js'; -import { useRouter } from '@/router/supplier.js'; +import { useRouter } from '@/router.js'; const props = withDefaults(defineProps<{ to: string; @@ -32,7 +32,7 @@ const props = withDefaults(defineProps<{ const behavior = props.behavior ?? inject<MkABehavior>('linkNavigationBehavior', null); -const el = shallowRef<HTMLElement>(); +const el = useTemplateRef('el'); defineExpose({ $el: el }); @@ -87,7 +87,7 @@ function openWindow() { function nav(ev: MouseEvent) { if (behavior === 'browser') { - location.href = props.to; + window.location.href = props.to; return; } diff --git a/packages/frontend/src/components/global/MkAcct.stories.impl.ts b/packages/frontend/src/components/global/MkAcct.stories.impl.ts index 04960ec60c..02fc835709 100644 --- a/packages/frontend/src/components/global/MkAcct.stories.impl.ts +++ b/packages/frontend/src/components/global/MkAcct.stories.impl.ts @@ -4,7 +4,7 @@ */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { userDetailed } from '../../../.storybook/fakes.js'; import MkAcct from './MkAcct.vue'; export const Default = { diff --git a/packages/frontend/src/components/global/MkAcct.vue b/packages/frontend/src/components/global/MkAcct.vue index 2f4141b901..ff794d9b6e 100644 --- a/packages/frontend/src/components/global/MkAcct.vue +++ b/packages/frontend/src/components/global/MkAcct.vue @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <span> <span>@{{ user.username }}</span> - <span v-if="user.host || detail || defaultStore.state.showFullAcct" style="opacity: 0.5;">@{{ user.host || host }}</span> + <span v-if="user.host || detail" style="opacity: 0.5;">@{{ user.host || host }}</span> </span> </template> @@ -14,7 +14,6 @@ SPDX-License-Identifier: AGPL-3.0-only import * as Misskey from 'misskey-js'; import { toUnicode } from 'punycode.js'; import { host as hostRaw } from '@@/js/config.js'; -import { defaultStore } from '@/store.js'; defineProps<{ user: Misskey.entities.UserLite; diff --git a/packages/frontend/src/components/global/MkAd.stories.impl.ts b/packages/frontend/src/components/global/MkAd.stories.impl.ts index 8c0b7ef52f..c5a928b5cf 100644 --- a/packages/frontend/src/components/global/MkAd.stories.impl.ts +++ b/packages/frontend/src/components/global/MkAd.stories.impl.ts @@ -5,7 +5,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { expect, userEvent, waitFor, within } from '@storybook/test'; -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import MkAd from './MkAd.vue'; import { i18n } from '@/i18n.js'; diff --git a/packages/frontend/src/components/global/MkAd.vue b/packages/frontend/src/components/global/MkAd.vue index fc6c64d2aa..525c47da45 100644 --- a/packages/frontend/src/components/global/MkAd.vue +++ b/packages/frontend/src/components/global/MkAd.vue @@ -36,7 +36,6 @@ SPDX-License-Identifier: AGPL-3.0-only <button class="_textButton" @click="toggleMenu">{{ i18n.ts._ad.back }}</button> </div> </div> -<div v-else></div> </template> <script lang="ts" setup> @@ -45,14 +44,15 @@ import { url as local, host } from '@@/js/config.js'; import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; import MkButton from '@/components/MkButton.vue'; -import { defaultStore } from '@/store.js'; +import { store } from '@/store.js'; import * as os from '@/os.js'; -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; +import { prefer } from '@/preferences.js'; type Ad = (typeof instance)['ads'][number]; const props = defineProps<{ - prefer: string[]; + preferForms: string[]; specify?: Ad; }>(); @@ -66,12 +66,12 @@ const choseAd = (): Ad | null => { return props.specify; } - const allAds = instance.ads.map(ad => defaultStore.state.mutedAds.includes(ad.id) ? { + const allAds = instance.ads.map(ad => store.s.mutedAds.includes(ad.id) ? { ...ad, ratio: 0, } : ad); - let ads = allAds.filter(ad => props.prefer.includes(ad.place)); + let ads = allAds.filter(ad => props.preferForms.includes(ad.place)); if (ads.length === 0) { ads = allAds.filter(ad => ad.place === 'square'); @@ -107,12 +107,12 @@ const chosen = ref(choseAd()); const self = computed(() => chosen.value?.url.startsWith(local)); -const shouldHide = ref(!defaultStore.state.forceShowAds && $i && $i.policies.canHideAds && (props.specify == null)); +const shouldHide = ref(!prefer.s.forceShowAds && $i && $i.policies.canHideAds && (props.specify == null)); function reduceFrequency(): void { if (chosen.value == null) return; - if (defaultStore.state.mutedAds.includes(chosen.value.id)) return; - defaultStore.push('mutedAds', chosen.value.id); + if (store.s.mutedAds.includes(chosen.value.id)) return; + store.push('mutedAds', chosen.value.id); os.success(); chosen.value = choseAd(); showMenu.value = false; diff --git a/packages/frontend/src/components/global/MkAvatar.stories.impl.ts b/packages/frontend/src/components/global/MkAvatar.stories.impl.ts index 9d2de9f0be..84221842e9 100644 --- a/packages/frontend/src/components/global/MkAvatar.stories.impl.ts +++ b/packages/frontend/src/components/global/MkAvatar.stories.impl.ts @@ -4,7 +4,7 @@ */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { userDetailed } from '../../../.storybook/fakes.js'; import MkAvatar from './MkAvatar.vue'; const common = { diff --git a/packages/frontend/src/components/global/MkAvatar.vue b/packages/frontend/src/components/global/MkAvatar.vue index 1f78b068a2..603b6f12a1 100644 --- a/packages/frontend/src/components/global/MkAvatar.vue +++ b/packages/frontend/src/components/global/MkAvatar.vue @@ -35,6 +35,8 @@ SPDX-License-Identifier: AGPL-3.0-only zIndex: getDecorationZIndex(decoration), }" alt="" + draggable="false" + style="-webkit-user-drag: none;" > </template> </component> @@ -46,14 +48,13 @@ import * as Misskey from 'misskey-js'; import { extractAvgColorFromBlurhash } from '@@/js/extract-avg-color-from-blurhash.js'; import MkImgWithBlurhash from '../MkImgWithBlurhash.vue'; import MkA from './MkA.vue'; -import { getStaticImageUrl } from '@/scripts/media-proxy.js'; +import { getStaticImageUrl } from '@/utility/media-proxy.js'; import { acct, userPage } from '@/filters/user.js'; import MkUserOnlineIndicator from '@/components/MkUserOnlineIndicator.vue'; -import { defaultStore } from '@/store.js'; +import { prefer } from '@/preferences.js'; -const animation = ref(defaultStore.state.animation); -const squareAvatars = ref(defaultStore.state.squareAvatars); -const useBlurEffect = ref(defaultStore.state.useBlurEffect); +const animation = ref(prefer.s.animation); +const squareAvatars = ref(prefer.s.squareAvatars); const props = withDefaults(defineProps<{ user: Misskey.entities.User; @@ -76,7 +77,7 @@ const emit = defineEmits<{ (ev: 'click', v: MouseEvent): void; }>(); -const showDecoration = props.forceShowDecoration || defaultStore.state.showAvatarDecorations; +const showDecoration = props.forceShowDecoration || prefer.s.showAvatarDecorations; const bound = computed(() => props.link ? { to: userPage(props.user), target: props.target } @@ -84,7 +85,7 @@ const bound = computed(() => props.link const url = computed(() => { if (props.user.avatarUrl == null) return null; - if (defaultStore.state.disableShowingAnimatedImages || defaultStore.state.dataSaver.avatar) return getStaticImageUrl(props.user.avatarUrl); + if (prefer.s.disableShowingAnimatedImages || prefer.s.dataSaver.avatar) return getStaticImageUrl(props.user.avatarUrl); return props.user.avatarUrl; }); @@ -94,7 +95,7 @@ function onClick(ev: MouseEvent): void { } function getDecorationUrl(decoration: Omit<Misskey.entities.UserDetailed['avatarDecorations'][number], 'id'>) { - if (defaultStore.state.disableShowingAnimatedImages || defaultStore.state.dataSaver.avatar) return getStaticImageUrl(decoration.url); + if (prefer.s.disableShowingAnimatedImages || prefer.s.dataSaver.avatar) return getStaticImageUrl(decoration.url); return decoration.url; } diff --git a/packages/frontend/src/components/global/MkCondensedLine.stories.impl.ts b/packages/frontend/src/components/global/MkCondensedLine.stories.impl.ts index e15dcba760..15ae489ff8 100644 --- a/packages/frontend/src/components/global/MkCondensedLine.stories.impl.ts +++ b/packages/frontend/src/components/global/MkCondensedLine.stories.impl.ts @@ -4,7 +4,7 @@ */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import MkCondensedLine from './MkCondensedLine.vue'; export const Default = { render(args) { diff --git a/packages/frontend/src/components/global/MkCustomEmoji.stories.impl.ts b/packages/frontend/src/components/global/MkCustomEmoji.stories.impl.ts index 9e6177045d..eded13b686 100644 --- a/packages/frontend/src/components/global/MkCustomEmoji.stories.impl.ts +++ b/packages/frontend/src/components/global/MkCustomEmoji.stories.impl.ts @@ -4,7 +4,7 @@ */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import MkCustomEmoji from './MkCustomEmoji.vue'; export const Default = { render(args) { diff --git a/packages/frontend/src/components/global/MkCustomEmoji.vue b/packages/frontend/src/components/global/MkCustomEmoji.vue index 90fa522f3d..fd43429d6e 100644 --- a/packages/frontend/src/components/global/MkCustomEmoji.vue +++ b/packages/frontend/src/components/global/MkCustomEmoji.vue @@ -9,6 +9,8 @@ SPDX-License-Identifier: AGPL-3.0-only :class="[$style.root, { [$style.normal]: normal, [$style.noStyle]: noStyle }]" src="/client-assets/dummy.png" :title="alt" + draggable="false" + style="-webkit-user-drag: none;" /> <span v-else-if="errored">:{{ customEmojiName }}:</span> <img @@ -18,6 +20,7 @@ SPDX-License-Identifier: AGPL-3.0-only :alt="alt" :title="alt" decoding="async" + draggable="false" @error="errored = true" @load="errored = false" @click="onClick" @@ -27,16 +30,16 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, defineAsyncComponent, inject, ref } from 'vue'; import type { MenuItem } from '@/types/menu.js'; -import { getProxiedImageUrl, getStaticImageUrl } from '@/scripts/media-proxy.js'; -import { defaultStore } from '@/store.js'; +import { getProxiedImageUrl, getStaticImageUrl } from '@/utility/media-proxy.js'; import { customEmojisMap } from '@/custom-emojis.js'; import * as os from '@/os.js'; -import { misskeyApi, misskeyApiGet } from '@/scripts/misskey-api.js'; -import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; -import * as sound from '@/scripts/sound.js'; +import { misskeyApi, misskeyApiGet } from '@/utility/misskey-api.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; import { i18n } from '@/i18n.js'; import MkCustomEmojiDetailedDialog from '@/components/MkCustomEmojiDetailedDialog.vue'; -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; +import { prefer } from '@/preferences.js'; +import { DI } from '@/di.js'; const props = defineProps<{ name: string; @@ -50,7 +53,7 @@ const props = defineProps<{ fallbackToImage?: boolean; }>(); -const react = inject<((name: string) => void) | null>('react', null); +const react = inject(DI.mfmEmojiReactCallback, null); const customEmojiName = computed(() => (props.name[0] === ':' ? props.name.substring(1, props.name.length - 1) : props.name).replace('@.', '')); const isLocal = computed(() => !props.host && (customEmojiName.value.endsWith('@.') || !customEmojiName.value.includes('@'))); @@ -77,7 +80,7 @@ const url = computed(() => { false, true, ); - return defaultStore.reactiveState.disableShowingAnimatedImages.value + return prefer.s.disableShowingAnimatedImages ? getStaticImageUrl(proxied) : proxied; }); @@ -99,7 +102,6 @@ function onClick(ev: MouseEvent) { icon: 'ti ti-copy', action: () => { copyToClipboard(`:${props.name}:`); - os.success(); }, }); @@ -109,7 +111,6 @@ function onClick(ev: MouseEvent) { icon: 'ph-smiley ph-bold ph-lg', action: () => { react(`:${props.name}:`); - sound.playMisskeySfx('reaction'); }, }); } @@ -159,6 +160,7 @@ async function edit(name: string) { .root { height: 2em; vertical-align: middle; + -webkit-user-drag: none; transition: transform 0.2s ease; &:hover { diff --git a/packages/frontend/src/components/global/MkEllipsis.stories.impl.ts b/packages/frontend/src/components/global/MkEllipsis.stories.impl.ts index 6a8fcf4fe3..dafdcbd13f 100644 --- a/packages/frontend/src/components/global/MkEllipsis.stories.impl.ts +++ b/packages/frontend/src/components/global/MkEllipsis.stories.impl.ts @@ -4,7 +4,7 @@ */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import isChromatic from 'chromatic/isChromatic'; import MkEllipsis from './MkEllipsis.vue'; export const Default = { diff --git a/packages/frontend/src/components/global/MkEmoji.stories.impl.ts b/packages/frontend/src/components/global/MkEmoji.stories.impl.ts index 309c015757..1a394ca6bb 100644 --- a/packages/frontend/src/components/global/MkEmoji.stories.impl.ts +++ b/packages/frontend/src/components/global/MkEmoji.stories.impl.ts @@ -4,7 +4,7 @@ */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import MkEmoji from './MkEmoji.vue'; export const Default = { render(args) { diff --git a/packages/frontend/src/components/global/MkEmoji.vue b/packages/frontend/src/components/global/MkEmoji.vue index bd9b1d665a..6abe1f30b6 100644 --- a/packages/frontend/src/components/global/MkEmoji.vue +++ b/packages/frontend/src/components/global/MkEmoji.vue @@ -12,12 +12,12 @@ SPDX-License-Identifier: AGPL-3.0-only import { computed, inject } from 'vue'; import { colorizeEmoji, getEmojiName } from '@@/js/emojilist.js'; import { char2fluentEmojiFilePath, char2twemojiFilePath, char2tossfaceFilePath } from '@@/js/emoji-base.js'; -import { defaultStore } from '@/store.js'; +import type { MenuItem } from '@/types/menu.js'; import * as os from '@/os.js'; -import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; -import * as sound from '@/scripts/sound.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; import { i18n } from '@/i18n.js'; -import type { MenuItem } from '@/types/menu.js'; +import { prefer } from '@/preferences.js'; +import { DI } from '@/di.js'; const props = defineProps<{ emoji: string; @@ -25,11 +25,11 @@ const props = defineProps<{ menuReaction?: boolean; }>(); -const react = inject<((name: string) => void) | null>('react', null); +const react = inject(DI.mfmEmojiReactCallback, null); -const char2path = defaultStore.state.emojiStyle === 'twemoji' ? char2twemojiFilePath : defaultStore.state.emojiStyle === 'tossface' ? char2tossfaceFilePath : char2fluentEmojiFilePath; +const char2path = prefer.s.emojiStyle === 'twemoji' ? char2twemojiFilePath : prefer.s.emojiStyle === 'tossface' ? char2tossfaceFilePath : char2fluentEmojiFilePath; -const useOsNativeEmojis = computed(() => defaultStore.state.emojiStyle === 'native'); +const useOsNativeEmojis = computed(() => prefer.s.emojiStyle === 'native'); const url = computed(() => char2path(props.emoji)); const colorizedNativeEmoji = computed(() => colorizeEmoji(props.emoji)); @@ -52,7 +52,6 @@ function onClick(ev: MouseEvent) { icon: 'ti ti-copy', action: () => { copyToClipboard(props.emoji); - os.success(); }, }); @@ -62,7 +61,6 @@ function onClick(ev: MouseEvent) { icon: 'ph-smiley ph-bold ph-lg', action: () => { react(props.emoji); - sound.playMisskeySfx('reaction'); }, }); } diff --git a/packages/frontend/src/components/global/MkError.stories.impl.ts b/packages/frontend/src/components/global/MkError.stories.impl.ts index daef04cd87..e150493a18 100644 --- a/packages/frontend/src/components/global/MkError.stories.impl.ts +++ b/packages/frontend/src/components/global/MkError.stories.impl.ts @@ -6,7 +6,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { action } from '@storybook/addon-actions'; import { expect, waitFor } from '@storybook/test'; -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import MkError from './MkError.vue'; export const Default = { render(args) { diff --git a/packages/frontend/src/components/global/MkError.stories.meta.ts b/packages/frontend/src/components/global/MkError.stories.meta.ts index cd7fada189..940b445e90 100644 --- a/packages/frontend/src/components/global/MkError.stories.meta.ts +++ b/packages/frontend/src/components/global/MkError.stories.meta.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Meta } from '@storybook/vue3'; +import type { Meta } from '@storybook/vue3'; import MkError from './MkError.vue'; export const argTypes = { diff --git a/packages/frontend/src/components/global/MkError.vue b/packages/frontend/src/components/global/MkError.vue index 77dddaff89..fadfbda7a6 100644 --- a/packages/frontend/src/components/global/MkError.vue +++ b/packages/frontend/src/components/global/MkError.vue @@ -4,9 +4,9 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<Transition :name="defaultStore.state.animation ? '_transition_zoom' : ''" appear> +<Transition :name="prefer.s.animation ? '_transition_zoom' : ''" appear> <div :class="$style.root"> - <img :class="$style.img" :src="serverErrorImageUrl" class="_ghost"/> + <img :class="$style.img" :src="serverErrorImageUrl" draggable="false"/> <p :class="$style.text"><i class="ti ti-alert-triangle"></i> {{ i18n.ts.somethingHappened }}</p> <MkButton :class="$style.button" @click="() => emit('retry')">{{ i18n.ts.retry }}</MkButton> </div> @@ -16,7 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import MkButton from '@/components/MkButton.vue'; import { i18n } from '@/i18n.js'; -import { defaultStore } from '@/store.js'; +import { prefer } from '@/preferences.js'; import { serverErrorImageUrl } from '@/instance.js'; const emit = defineEmits<{ diff --git a/packages/frontend/src/components/global/MkFooterSpacer.vue b/packages/frontend/src/components/global/MkFooterSpacer.vue deleted file mode 100644 index 1a75855fa1..0000000000 --- a/packages/frontend/src/components/global/MkFooterSpacer.vue +++ /dev/null @@ -1,32 +0,0 @@ -<!-- -SPDX-FileCopyrightText: syuilo and misskey-project -SPDX-License-Identifier: AGPL-3.0-only ---> - -<template> -<div :class="[$style.spacer, defaultStore.reactiveState.darkMode.value ? $style.dark : $style.light]"></div> -</template> - -<script lang="ts" setup> -import { defaultStore } from '@/store.js'; -</script> - -<style lang="scss" module> -.spacer { - box-sizing: border-box; - padding: 32px; - margin: 0 auto; - height: 300px; - background-clip: content-box; - background-size: auto auto; - background-color: rgba(255, 255, 255, 0); - - &.light { - background-image: repeating-linear-gradient(135deg, transparent, transparent 16px, #00000010 16px, #00000010 20px ); - } - - &.dark { - background-image: repeating-linear-gradient(135deg, transparent, transparent 16px, #FFFFFF16 16px, #FFFFFF16 20px ); - } -} -</style> diff --git a/packages/frontend/src/components/global/MkLazy.vue b/packages/frontend/src/components/global/MkLazy.vue index 29908f303d..595abb5e48 100644 --- a/packages/frontend/src/components/global/MkLazy.vue +++ b/packages/frontend/src/components/global/MkLazy.vue @@ -11,11 +11,13 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { nextTick, onMounted, onActivated, onBeforeUnmount, ref, shallowRef } from 'vue'; +import { nextTick, onMounted, onActivated, onBeforeUnmount, ref, useTemplateRef } from 'vue'; -const rootEl = shallowRef<HTMLDivElement>(); +const rootEl = useTemplateRef('rootEl'); const showing = ref(false); +defineExpose({ rootEl, showing }); + const emit = defineEmits<{ (ev: 'show'): void, }>(); @@ -36,13 +38,17 @@ const observer = new IntersectionObserver( onMounted(() => { nextTick(() => { - observer.observe(rootEl.value!); + if (rootEl.value) { + observer.observe(rootEl.value); + } }); }); onActivated(() => { nextTick(() => { - observer.observe(rootEl.value!); + if (rootEl.value) { + observer.observe(rootEl.value); + } }); }); diff --git a/packages/frontend/src/components/global/MkLoading.stories.impl.ts b/packages/frontend/src/components/global/MkLoading.stories.impl.ts index c781ad0479..8313f73e4b 100644 --- a/packages/frontend/src/components/global/MkLoading.stories.impl.ts +++ b/packages/frontend/src/components/global/MkLoading.stories.impl.ts @@ -4,7 +4,7 @@ */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import isChromatic from 'chromatic/isChromatic'; import MkLoading from './MkLoading.vue'; export const Default = { diff --git a/packages/frontend/src/components/global/MkMfm.stories.impl.ts b/packages/frontend/src/components/global/MkMfm.stories.impl.ts index 1daf7a29cb..98da531ed4 100644 --- a/packages/frontend/src/components/global/MkMfm.stories.impl.ts +++ b/packages/frontend/src/components/global/MkMfm.stories.impl.ts @@ -2,8 +2,8 @@ * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ - -import { StoryObj } from '@storybook/vue3'; + +import type { StoryObj } from '@storybook/vue3'; import { expect, within } from '@storybook/test'; import MkMfm from './MkMfm.js'; export const Default = { diff --git a/packages/frontend/src/components/global/MkMfm.ts b/packages/frontend/src/components/global/MkMfm.ts index b292f86445..dea486e66d 100644 --- a/packages/frontend/src/components/global/MkMfm.ts +++ b/packages/frontend/src/components/global/MkMfm.ts @@ -3,11 +3,13 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { VNode, h, defineAsyncComponent, SetupContext } from 'vue'; +import { h, defineAsyncComponent } from 'vue'; import * as mfm from '@transfem-org/sfm-js'; import * as Misskey from 'misskey-js'; import { host } from '@@/js/config.js'; import CkFollowMouse from '../CkFollowMouse.vue'; +import type { VNode, SetupContext } from 'vue'; +import type { MkABehavior } from '@/components/global/MkA.vue'; import MkUrl from '@/components/global/MkUrl.vue'; import MkTime from '@/components/global/MkTime.vue'; import MkLink from '@/components/MkLink.vue'; @@ -18,8 +20,8 @@ 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, { MkABehavior } from '@/components/global/MkA.vue'; -import { defaultStore } from '@/store.js'; +import MkA from '@/components/global/MkA.vue'; +import { prefer } from '@/preferences.js'; import { clamp } from '@@/js/math.js'; function safeParseFloat(str: unknown): number | null { @@ -60,12 +62,12 @@ type MfmEvents = { }; // eslint-disable-next-line import/no-default-export -export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEvents>['emit'] }) { +export default function MkMfm(props: MfmProps, { emit }: { emit: SetupContext<MfmEvents>['emit'] }) { // こうしたいところだけど functional component 内では provide は使えない //provide('linkNavigationBehavior', props.linkNavigationBehavior); const isNote = props.isNote ?? true; - const shouldNyaize = props.nyaize === 'respect' && props.author?.isCat && props.author?.speakAsCat && !defaultStore.state.disableCatSpeak; + const shouldNyaize = props.nyaize === 'respect' && props.author?.isCat && props.author?.speakAsCat && !prefer.s.disableCatSpeak; // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (props.text == null || props.text === '') return; @@ -77,13 +79,12 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven 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 useAnim = props.isAnim ?? (prefer.s.advancedMfm && prefer.s.animatedMfm); const isBlock = props.isBlock ?? false; const SkFormula = defineAsyncComponent(() => import('@/components/SkFormula.vue')); @@ -193,17 +194,17 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven } case 'x2': { return h('span', { - class: defaultStore.state.advancedMfm ? 'mfm-x2' : '', + class: prefer.s.advancedMfm ? 'mfm-x2' : '', }, genEl(token.children, scale * 2)); } case 'x3': { return h('span', { - class: defaultStore.state.advancedMfm ? 'mfm-x3' : '', + class: prefer.s.advancedMfm ? 'mfm-x3' : '', }, genEl(token.children, scale * 3)); } case 'x4': { return h('span', { - class: defaultStore.state.advancedMfm ? 'mfm-x4' : '', + class: prefer.s.advancedMfm ? 'mfm-x4' : '', }, genEl(token.children, scale * 4)); } case 'font': { @@ -283,7 +284,7 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven }, genEl(token.children, scale)); } case 'position': { - if (!defaultStore.state.advancedMfm) break; + if (!prefer.s.advancedMfm) break; const x = safeParseFloat(token.props.args.x) ?? 0; const y = safeParseFloat(token.props.args.y) ?? 0; style = `transform: translateX(${x}em) translateY(${y}em);`; @@ -306,7 +307,7 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven break; } case 'scale': { - if (!defaultStore.state.advancedMfm) { + if (!prefer.s.advancedMfm) { style = ''; break; } @@ -425,7 +426,7 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven url: token.props.url, rel: 'nofollow noopener', navigationBehavior: props.linkNavigationBehavior, - }, genEl(token.children, scale, true)))]; + }, () => genEl(token.children, scale, true)))]; } case 'mention': { @@ -443,7 +444,7 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven to: isNote ? `/tags/${encodeURIComponent(token.props.hashtag)}` : `/user-tags/${encodeURIComponent(token.props.hashtag)}`, style: 'color:var(--MI_THEME-hashtag);', behavior: props.linkNavigationBehavior, - }, `#${token.props.hashtag}`))]; + }, () => `#${token.props.hashtag}`))]; } case 'blockCode': { diff --git a/packages/frontend/src/components/global/MkPageHeader.stories.impl.ts b/packages/frontend/src/components/global/MkPageHeader.stories.impl.ts index 1d079edd2c..15938d0495 100644 --- a/packages/frontend/src/components/global/MkPageHeader.stories.impl.ts +++ b/packages/frontend/src/components/global/MkPageHeader.stories.impl.ts @@ -2,11 +2,10 @@ * 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/test'; -import { StoryObj } from '@storybook/vue3'; import MkPageHeader from './MkPageHeader.vue'; +import type { StoryObj } from '@storybook/vue3'; export const Empty = { render(args) { return { @@ -29,7 +28,7 @@ export const Empty = { }; }, async play() { - const wait = new Promise((resolve) => setTimeout(resolve, 800)); + const wait = new Promise((resolve) => window.setTimeout(resolve, 800)); await waitFor(async () => await wait); }, args: { diff --git a/packages/frontend/src/components/global/MkPageHeader.tabs.vue b/packages/frontend/src/components/global/MkPageHeader.tabs.vue index ffa6f13ff6..73c99dfb12 100644 --- a/packages/frontend/src/components/global/MkPageHeader.tabs.vue +++ b/packages/frontend/src/components/global/MkPageHeader.tabs.vue @@ -8,13 +8,13 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="$style.tabsInner"> <button v-for="t in tabs" :ref="(el) => tabRefs[t.key] = (el as HTMLElement)" v-tooltip.noDelay="t.title" - class="_button" :class="[$style.tab, { [$style.active]: t.key != null && t.key === props.tab, [$style.animate]: defaultStore.reactiveState.animation.value }]" + class="_button" :class="[$style.tab, { [$style.active]: t.key != null && t.key === props.tab, [$style.animate]: prefer.s.animation }]" @mousedown="(ev) => onTabMousedown(t, ev)" @click="(ev) => onTabClick(t, ev)" > <div :class="$style.tabInner"> <i v-if="t.icon" :class="[$style.tabIcon, t.icon]"></i> <div - v-if="!t.iconOnly || (!defaultStore.reactiveState.animation.value && t.key === tab)" + v-if="!t.iconOnly || (!prefer.s.animation && t.key === tab)" :class="$style.tabTitle" > {{ t.title }} @@ -30,7 +30,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <div ref="tabHighlightEl" - :class="[$style.tabHighlight, { [$style.animate]: defaultStore.reactiveState.animation.value }]" + :class="[$style.tabHighlight, { [$style.animate]: prefer.s.animation }]" ></div> </div> </template> @@ -41,25 +41,25 @@ export type Tab = { onClick?: (ev: MouseEvent) => void; } & ( | { - iconOnly?: false; - title: string; - icon?: string; - } + iconOnly?: false; + title: string; + icon?: string; + } | { - iconOnly: true; - icon: string; - } + iconOnly: true; + icon: string; + } ); </script> <script lang="ts" setup> -import { nextTick, onMounted, onUnmounted, shallowRef, watch } from 'vue'; -import { defaultStore } from '@/store.js'; +import { nextTick, onMounted, onUnmounted, useTemplateRef, watch } from 'vue'; +import { prefer } from '@/preferences.js'; const props = withDefaults(defineProps<{ tabs?: Tab[]; tab?: string; - rootEl?: HTMLElement; + rootEl?: HTMLElement | null; }>(), { tabs: () => ([] as Tab[]), }); @@ -69,9 +69,9 @@ const emit = defineEmits<{ (ev: 'tabClick', key: string); }>(); -const el = shallowRef<HTMLElement | null>(null); +const el = useTemplateRef('el'); +const tabHighlightEl = useTemplateRef('tabHighlightEl'); const tabRefs: Record<string, HTMLElement | null> = {}; -const tabHighlightEl = shallowRef<HTMLElement | null>(null); function onTabMousedown(tab: Tab, ev: MouseEvent): void { // ユーザビリティの観点からmousedown時にはonClickは呼ばない @@ -133,7 +133,7 @@ async function enter(el: Element) { entering = false; }); - setTimeout(renderTab, 170); + window.setTimeout(renderTab, 170); } function afterEnter(el: Element) { @@ -170,7 +170,7 @@ onMounted(() => { if (props.rootEl) { ro2 = new ResizeObserver((entries, observer) => { - if (document.body.contains(el.value as HTMLElement)) { + if (window.document.body.contains(el.value as HTMLElement)) { nextTick(() => renderTab()); } }); diff --git a/packages/frontend/src/components/global/MkPageHeader.vue b/packages/frontend/src/components/global/MkPageHeader.vue index 1a424f349f..c939f0b44e 100644 --- a/packages/frontend/src/components/global/MkPageHeader.vue +++ b/packages/frontend/src/components/global/MkPageHeader.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div v-if="show" ref="el" :class="[$style.root]" :style="{ background: bg }"> +<div v-if="show" ref="el" :class="[$style.root]"> <div :class="[$style.upper, { [$style.slim]: narrow, [$style.thin]: thin_ }]"> <div v-if="!thin_ && narrow && props.displayMyAvatar && $i" class="_button" :class="$style.buttonsLeft" @click="openAccountMenu"> <MkAvatar :class="$style.avatar" :user="$i"/> @@ -49,18 +49,12 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </template> -<script lang="ts" setup> -import { onMounted, onUnmounted, ref, inject, shallowRef, computed } from 'vue'; -import tinycolor from 'tinycolor2'; -import XTabs, { Tab } from './MkPageHeader.tabs.vue'; -import { scrollToTop } from '@@/js/scroll.js'; -import { globalEvents } from '@/events.js'; -import { injectReactiveMetadata } from '@/scripts/page-metadata.js'; -import { $i, openAccountMenu as openAccountMenu_ } from '@/account.js'; +<script lang="ts"> import type { PageHeaderItem } from '@/types/page-header.js'; -import type { PageMetadata } from '@/scripts/page-metadata.js'; +import type { PageMetadata } from '@/page.js'; +import type { Tab } from './MkPageHeader.tabs.vue'; -const props = withDefaults(defineProps<{ +export type PageHeaderProps = { overridePageMetadata?: PageMetadata; tabs?: Tab[]; tab?: string; @@ -69,7 +63,19 @@ const props = withDefaults(defineProps<{ hideTitle?: boolean; displayMyAvatar?: boolean; displayBackButton?: boolean; -}>(), { +}; +</script> + +<script lang="ts" setup> +import { onMounted, onUnmounted, ref, inject, useTemplateRef, computed } from 'vue'; +import { scrollToTop } from '@@/js/scroll.js'; +import XTabs from './MkPageHeader.tabs.vue'; +import { globalEvents } from '@/events.js'; +import { openAccountMenu as openAccountMenu_ } from '@/accounts.js'; +import { $i } from '@/i.js'; +import { DI } from '@/di.js'; + +const props = withDefaults(defineProps<PageHeaderProps>(), { tabs: () => ([] as Tab[]), }); @@ -77,16 +83,16 @@ const emit = defineEmits<{ (ev: 'update:tab', key: string); }>(); -const displayBackButton = props.displayBackButton && history.state.key !== 'index' && history.length > 1 && inject('shouldBackButton', true); +const displayBackButton = props.displayBackButton && window.history.state.key !== 'index' && window.history.length > 1 && inject('shouldBackButton', true); -const injectedPageMetadata = injectReactiveMetadata(); +//const viewId = inject(DI.viewId); +const injectedPageMetadata = inject(DI.pageMetadata, ref(null)); const pageMetadata = computed(() => props.overridePageMetadata ?? injectedPageMetadata.value); const hideTitle = computed(() => inject('shouldOmitHeaderTitle', false) || props.hideTitle); const thin_ = props.thin || inject('shouldHeaderThin', false); -const el = shallowRef<HTMLElement | undefined>(undefined); -const bg = ref<string | undefined>(undefined); +const el = useTemplateRef('el'); const narrow = ref(false); const hasTabs = computed(() => props.tabs.length > 0); const hasActions = computed(() => props.actions && props.actions.length > 0); @@ -118,23 +124,13 @@ function goBack(): void { window.history.back(); } -const calcBg = () => { - const rawBg = 'var(--MI_THEME-bg)'; - const tinyBg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg); - tinyBg.setAlpha(0.85); - bg.value = tinyBg.toRgbString(); -}; - let ro: ResizeObserver | null; onMounted(() => { - calcBg(); - globalEvents.on('themeChanged', calcBg); - if (el.value && el.value.parentElement) { narrow.value = el.value.parentElement.offsetWidth < 500; ro = new ResizeObserver((entries, observer) => { - if (el.value && el.value.parentElement && document.body.contains(el.value as HTMLElement)) { + if (el.value && el.value.parentElement && window.document.body.contains(el.value as HTMLElement)) { narrow.value = el.value.parentElement.offsetWidth < 500; } }); @@ -143,17 +139,24 @@ onMounted(() => { }); onUnmounted(() => { - globalEvents.off('themeChanged', calcBg); if (ro) ro.disconnect(); }); </script> <style lang="scss" module> .root { + background: color(from var(--MI_THEME-pageHeaderBg) srgb r g b / 0.75); -webkit-backdrop-filter: var(--MI-blur, blur(15px)); backdrop-filter: var(--MI-blur, blur(15px)); - border-bottom: solid 0.5px var(--MI_THEME-divider); + border-bottom: solid 0.5px transparent; width: 100%; + color: var(--MI_THEME-pageHeaderFg); +} + +@container style(--MI_THEME-pageHeaderBg: var(--MI_THEME-bg)) { + .root { + border-bottom: solid 0.5px var(--MI_THEME-divider); + } } .upper, diff --git a/packages/frontend/src/components/global/MkSpacer.vue b/packages/frontend/src/components/global/MkSpacer.vue deleted file mode 100644 index db01c10eb0..0000000000 --- a/packages/frontend/src/components/global/MkSpacer.vue +++ /dev/null @@ -1,57 +0,0 @@ -<!-- -SPDX-FileCopyrightText: syuilo and misskey-project -SPDX-License-Identifier: AGPL-3.0-only ---> - -<template> -<div :class="[$style.root, { [$style.rootMin]: forceSpacerMin }]"> - <div :class="$style.content"> - <slot></slot> - </div> -</div> -</template> - -<script lang="ts" setup> -import { inject } from 'vue'; -import { deviceKind } from '@/scripts/device-kind.js'; - -const props = withDefaults(defineProps<{ - contentMax?: number | null; - marginMin?: number; - marginMax?: number; -}>(), { - contentMax: null, - marginMin: 12, - marginMax: 24, -}); - -const forceSpacerMin = inject('forceSpacerMin', false) || deviceKind === 'smartphone'; -</script> - -<style lang="scss" module> -.root { - box-sizing: border-box; - width: 100%; -} -.rootMin { - padding: v-bind('props.marginMin + "px"') !important; -} - -.content { - margin: 0 auto; - max-width: v-bind('props.contentMax + "px"'); - container-type: inline-size; -} - -@container (max-width: 450px) { - .root { - padding: v-bind('props.marginMin + "px"'); - } -} - -@container (min-width: 451px) { - .root { - padding: v-bind('props.marginMax + "px"'); - } -} -</style> diff --git a/packages/frontend/src/components/global/MkStickyContainer.vue b/packages/frontend/src/components/global/MkStickyContainer.vue index 1aebf487bb..05245716c2 100644 --- a/packages/frontend/src/components/global/MkStickyContainer.vue +++ b/packages/frontend/src/components/global/MkStickyContainer.vue @@ -22,9 +22,8 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, onUnmounted, provide, inject, Ref, ref, watch, useTemplateRef } from 'vue'; - -import { CURRENT_STICKY_BOTTOM, CURRENT_STICKY_TOP } from '@@/js/const.js'; +import { onMounted, onUnmounted, provide, inject, ref, watch, useTemplateRef } from 'vue'; +import { DI } from '@/di.js'; const rootEl = useTemplateRef('rootEl'); const headerEl = useTemplateRef('headerEl'); @@ -32,13 +31,13 @@ const footerEl = useTemplateRef('footerEl'); const headerHeight = ref<string | undefined>(); const childStickyTop = ref(0); -const parentStickyTop = inject<Ref<number>>(CURRENT_STICKY_TOP, ref(0)); -provide(CURRENT_STICKY_TOP, childStickyTop); +const parentStickyTop = inject(DI.currentStickyTop, ref(0)); +provide(DI.currentStickyTop, childStickyTop); const footerHeight = ref<string | undefined>(); const childStickyBottom = ref(0); -const parentStickyBottom = inject<Ref<number>>(CURRENT_STICKY_BOTTOM, ref(0)); -provide(CURRENT_STICKY_BOTTOM, childStickyBottom); +const parentStickyBottom = inject(DI.currentStickyBottom, ref(0)); +provide(DI.currentStickyBottom, childStickyBottom); const calc = () => { // コンポーネントが表示されてないけどKeepAliveで残ってる場合などは null になる diff --git a/packages/frontend/src/components/global/MkTime.stories.impl.ts b/packages/frontend/src/components/global/MkTime.stories.impl.ts index ccf7f200b5..5e62c3fbab 100644 --- a/packages/frontend/src/components/global/MkTime.stories.impl.ts +++ b/packages/frontend/src/components/global/MkTime.stories.impl.ts @@ -5,7 +5,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { expect } from '@storybook/test'; -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import MkTime from './MkTime.vue'; import { i18n } from '@/i18n.js'; import { dateTimeFormat } from '@@/js/intl-const.js'; diff --git a/packages/frontend/src/components/global/MkUrl.stories.impl.ts b/packages/frontend/src/components/global/MkUrl.stories.impl.ts index 34a4adfe49..ea02fdfdd0 100644 --- a/packages/frontend/src/components/global/MkUrl.stories.impl.ts +++ b/packages/frontend/src/components/global/MkUrl.stories.impl.ts @@ -5,7 +5,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { expect, userEvent, waitFor, within } from '@storybook/test'; -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { HttpResponse, http } from 'msw'; import { commonHandlers } from '../../../.storybook/mocks.js'; import MkUrl from './MkUrl.vue'; diff --git a/packages/frontend/src/components/global/MkUrl.vue b/packages/frontend/src/components/global/MkUrl.vue index f4ed7ae427..058f03aeac 100644 --- a/packages/frontend/src/components/global/MkUrl.vue +++ b/packages/frontend/src/components/global/MkUrl.vue @@ -31,10 +31,10 @@ import { defineAsyncComponent, ref } from 'vue'; import { toUnicode as decodePunycode } from 'punycode.js'; import { url as local } from '@@/js/config.js'; import * as os from '@/os.js'; -import { useTooltip } from '@/scripts/use-tooltip.js'; +import { useTooltip } from '@/use/use-tooltip.js'; import { isEnabledUrlPreview } from '@/instance.js'; -import { MkABehavior } from '@/components/global/MkA.vue'; -import { warningExternalWebsite } from '@/scripts/warning-external-website.js'; +import type { MkABehavior } from '@/components/global/MkA.vue'; +import { warningExternalWebsite } from '@/utility/warning-external-website.js'; import { maybeMakeRelative } from '@@/js/url.js'; function safeURIDecode(str: string): string { diff --git a/packages/frontend/src/components/global/MkUserName.stories.impl.ts b/packages/frontend/src/components/global/MkUserName.stories.impl.ts index e39061c291..b46c91c903 100644 --- a/packages/frontend/src/components/global/MkUserName.stories.impl.ts +++ b/packages/frontend/src/components/global/MkUserName.stories.impl.ts @@ -5,7 +5,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { expect } from '@storybook/test'; -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { userDetailed } from '../../../.storybook/fakes.js'; import MkUserName from './MkUserName.vue'; export const Default = { diff --git a/packages/frontend/src/components/global/NestedRouterView.vue b/packages/frontend/src/components/global/NestedRouterView.vue new file mode 100644 index 0000000000..af00347db8 --- /dev/null +++ b/packages/frontend/src/components/global/NestedRouterView.vue @@ -0,0 +1,60 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<Suspense :timeout="0"> + <component :is="currentPageComponent" :key="key" v-bind="Object.fromEntries(currentPageProps)"/> + + <template #fallback> + <MkLoading/> + </template> +</Suspense> +</template> + +<script lang="ts" setup> +import { inject, provide, ref, shallowRef } from 'vue'; +import type { Router } from '@/router.js'; +import type { PathResolvedResult } from '@/lib/nirax.js'; +import MkLoadingPage from '@/pages/_loading_.vue'; +import { DI } from '@/di.js'; + +const props = defineProps<{ + router?: Router; +}>(); + +const router = props.router ?? inject(DI.router); + +if (router == null) { + throw new Error('no router provided'); +} + +const currentDepth = inject(DI.routerCurrentDepth, 0); +provide(DI.routerCurrentDepth, currentDepth + 1); + +function resolveNested(current: PathResolvedResult, d = 0): PathResolvedResult | null { + if (d === currentDepth) { + return current; + } else { + if (current.child) { + return resolveNested(current.child, d + 1); + } else { + return null; + } + } +} + +const current = resolveNested(router.current)!; +const currentPageComponent = shallowRef('component' in current.route ? current.route.component : MkLoadingPage); +const currentPageProps = ref(current.props); +const key = ref(router.getCurrentFullPath()); + +router.useListener('change', ({ resolved }) => { + const current = resolveNested(resolved); + if (current == null || 'redirect' in current.route) return; + currentPageComponent.value = current.route.component; + currentPageProps.value = current.props; + key.value = router.getCurrentFullPath(); +}); +</script> diff --git a/packages/frontend/src/components/global/PageWithAnimBg.vue b/packages/frontend/src/components/global/PageWithAnimBg.vue new file mode 100644 index 0000000000..7106ae20cd --- /dev/null +++ b/packages/frontend/src/components/global/PageWithAnimBg.vue @@ -0,0 +1,29 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div> + <MkAnimBg style="position: absolute;"/> + <div class="_pageScrollable" :class="$style.body"> + <slot></slot> + </div> +</div> +</template> + +<script lang="ts" setup> +import MkAnimBg from '@/components/MkAnimBg.vue'; +</script> + +<style lang="scss" module> +.body { + position: absolute; + top: 0; + width: 100%; + height: 100%; + + // _pageScrollable はパフォーマンス上の理由で背景色が設定されているため + background: transparent !important; +} +</style> diff --git a/packages/frontend/src/components/global/PageWithHeader.vue b/packages/frontend/src/components/global/PageWithHeader.vue new file mode 100644 index 0000000000..d2e59bf4ad --- /dev/null +++ b/packages/frontend/src/components/global/PageWithHeader.vue @@ -0,0 +1,71 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div ref="rootEl" :class="[$style.root, reversed ? '_pageScrollableReversed' : '_pageScrollable']"> + <MkStickyContainer> + <template #header><MkPageHeader v-model:tab="tab" v-bind="pageHeaderProps"/></template> + <div :class="$style.body"> + <MkSwiper v-if="swipable && (props.tabs?.length ?? 1) > 1" v-model:tab="tab" :class="$style.swiper" :tabs="props.tabs" :page="props.page"> + <slot></slot> + </MkSwiper> + <slot v-else></slot> + </div> + <template #footer><slot name="footer"></slot></template> + </MkStickyContainer> +</div> +</template> + +<script lang="ts" setup> +import { computed, useTemplateRef } from 'vue'; +import { scrollInContainer } from '@@/js/scroll.js'; +import type { PageHeaderProps } from './MkPageHeader.vue'; +import { useScrollPositionKeeper } from '@/use/use-scroll-position-keeper.js'; +import MkSwiper from '@/components/MkSwiper.vue'; +import { useRouter } from '@/router.js'; + +const props = withDefaults(defineProps<PageHeaderProps & { + reversed?: boolean; + swipable?: boolean; + page?: string; +}>(), { + reversed: false, + swipable: true, +}); + +const pageHeaderProps = computed(() => { + const { reversed, ...rest } = props; + return rest; +}); + +const tab = defineModel<string>('tab'); +const rootEl = useTemplateRef('rootEl'); + +useScrollPositionKeeper(rootEl); + +const router = useRouter(); + +router.useListener('same', () => { + scrollToTop(); +}); + +function scrollToTop() { + if (rootEl.value) scrollInContainer(rootEl.value, { top: 0, behavior: 'smooth' }); +} + +defineExpose({ + scrollToTop, +}); +</script> + +<style lang="scss" module> +.root { + +} + +.body, .swiper { + min-height: calc(100cqh - (var(--MI-stickyTop, 0px) + var(--MI-stickyBottom, 0px))); +} +</style> diff --git a/packages/frontend/src/components/global/RouterView.vue b/packages/frontend/src/components/global/RouterView.vue index 38bdfc52d4..27f7b18559 100644 --- a/packages/frontend/src/components/global/RouterView.vue +++ b/packages/frontend/src/components/global/RouterView.vue @@ -4,98 +4,68 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<KeepAlive - :max="defaultStore.state.numberOfPageCache" - :exclude="pageCacheController" -> - <Suspense :timeout="0"> - <component :is="currentPageComponent" :key="key" v-bind="Object.fromEntries(currentPageProps)"/> +<div class="_pageContainer" :class="$style.root"> + <KeepAlive :max="prefer.s.numberOfPageCache"> + <Suspense :timeout="0"> + <component :is="currentPageComponent" :key="key" v-bind="Object.fromEntries(currentPageProps)"/> - <template #fallback> - <MkLoading/> - </template> - </Suspense> -</KeepAlive> + <template #fallback> + <MkLoading/> + </template> + </Suspense> + </KeepAlive> +</div> </template> <script lang="ts" setup> -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 { inject, nextTick, onMounted, provide, ref, shallowRef, useTemplateRef } from 'vue'; +import type { Router } from '@/router.js'; +import { prefer } from '@/preferences.js'; import MkLoadingPage from '@/pages/_loading_.vue'; +import { DI } from '@/di.js'; +import { randomId } from '@/utility/random-id.js'; +import { deepEqual } from '@/utility/deep-equal.js'; const props = defineProps<{ - router?: IRouter; - nested?: boolean; + router?: Router; }>(); -const router = props.router ?? inject('router'); +const router = props.router ?? inject(DI.router); if (router == null) { throw new Error('no router provided'); } -const currentDepth = inject('routerCurrentDepth', 0); -provide('routerCurrentDepth', currentDepth + 1); +const viewId = randomId(); +provide(DI.viewId, viewId); -function resolveNested(current: Resolved, d = 0): Resolved | null { - if (!props.nested) return current; +const currentDepth = inject(DI.routerCurrentDepth, 0); +provide(DI.routerCurrentDepth, currentDepth + 1); - if (d === currentDepth) { - return current; - } else { - if (current.child) { - return resolveNested(current.child, d + 1); - } else { - return null; - } - } -} - -const current = resolveNested(router.current)!; +const current = router.current!; const currentPageComponent = shallowRef('component' in current.route ? current.route.component : MkLoadingPage); const currentPageProps = ref(current.props); -const key = ref(router.getCurrentKey() + JSON.stringify(Object.fromEntries(current.props))); - -function onChange({ resolved, key: newKey }) { - const current = resolveNested(resolved); - if (current == null || 'redirect' in current.route) return; - currentPageComponent.value = current.route.component; - currentPageProps.value = current.props; - key.value = newKey + JSON.stringify(Object.fromEntries(current.props)); - - nextTick(() => { - // ページ遷移完了後に再びキャッシュを有効化 - if (clearCacheRequested.value) { - clearCacheRequested.value = false; - } - }); -} +let currentRoutePath = current.route.path; +const key = ref(router.getCurrentFullPath()); -router.addListener('change', onChange); +router.useListener('change', ({ resolved }) => { + if (resolved == null || 'redirect' in resolved.route) return; + if (resolved.route.path === currentRoutePath && deepEqual(resolved.props, currentPageProps.value)) return; -// #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; + function _() { + currentPageComponent.value = resolved.route.component; + currentPageProps.value = resolved.props; + key.value = router.getCurrentFullPath(); + currentRoutePath = resolved.route.path; } -}); -// #endregion - -onBeforeUnmount(() => { - router.removeListener('change', onChange); + _(); }); </script> + +<style lang="scss" module> +.root { + height: 100%; + background-color: var(--MI_THEME-bg); +} +</style> diff --git a/packages/frontend/src/components/global/SearchIcon.vue b/packages/frontend/src/components/global/SearchIcon.vue new file mode 100644 index 0000000000..27a284faf0 --- /dev/null +++ b/packages/frontend/src/components/global/SearchIcon.vue @@ -0,0 +1,14 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<slot></slot> +</template> + +<script lang="ts" setup> +</script> + +<style lang="scss" module> +</style> diff --git a/packages/frontend/src/components/global/SearchKeyword.vue b/packages/frontend/src/components/global/SearchKeyword.vue new file mode 100644 index 0000000000..27a284faf0 --- /dev/null +++ b/packages/frontend/src/components/global/SearchKeyword.vue @@ -0,0 +1,14 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<slot></slot> +</template> + +<script lang="ts" setup> +</script> + +<style lang="scss" module> +</style> diff --git a/packages/frontend/src/components/global/SearchLabel.vue b/packages/frontend/src/components/global/SearchLabel.vue new file mode 100644 index 0000000000..27a284faf0 --- /dev/null +++ b/packages/frontend/src/components/global/SearchLabel.vue @@ -0,0 +1,14 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<slot></slot> +</template> + +<script lang="ts" setup> +</script> + +<style lang="scss" module> +</style> diff --git a/packages/frontend/src/components/global/SearchMarker.vue b/packages/frontend/src/components/global/SearchMarker.vue new file mode 100644 index 0000000000..ded1f9a28b --- /dev/null +++ b/packages/frontend/src/components/global/SearchMarker.vue @@ -0,0 +1,117 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div ref="root" :class="[$style.root, { [$style.highlighted]: highlighted }]"> + <slot :isParentOfTarget="isParentOfTarget"></slot> +</div> +</template> + +<script lang="ts" setup> +import { + onActivated, + onDeactivated, + onMounted, + onBeforeUnmount, + watch, + computed, + ref, + useTemplateRef, + inject, +} from 'vue'; +import { DI } from '@/di.js'; + +const props = defineProps<{ + markerId?: string; + label?: string; + icon?: string; + keywords?: string[]; + children?: string[]; + inlining?: string[]; +}>(); + +const rootEl = useTemplateRef('root'); +const rootElMutationObserver = new MutationObserver(() => { + checkChildren(); +}); +const injectedSearchMarkerId = inject(DI.inAppSearchMarkerId, null); +const searchMarkerId = computed(() => injectedSearchMarkerId?.value ?? window.location.hash.slice(1)); +const highlighted = ref(props.markerId === searchMarkerId.value); +const isParentOfTarget = computed(() => props.children?.includes(searchMarkerId.value)); + +function checkChildren() { + if (isParentOfTarget.value) { + const el = window.document.querySelector(`[data-in-app-search-marker-id="${searchMarkerId.value}"]`); + highlighted.value = el == null; + } +} + +watch([ + searchMarkerId, + () => props.children, +], () => { + if (props.children != null && props.children.length > 0) { + checkChildren(); + } +}, { flush: 'post' }); + +function init() { + checkChildren(); + + if (highlighted.value) { + rootEl.value?.scrollIntoView({ + behavior: 'smooth', + block: 'center', + }); + } + + if (rootEl.value != null) { + rootElMutationObserver.observe(rootEl.value, { + childList: true, + subtree: true, + }); + } +} + +function dispose() { + rootElMutationObserver.disconnect(); +} + +onMounted(init); +onActivated(init); +onDeactivated(dispose); +onBeforeUnmount(dispose); +</script> + +<style lang="scss" module> +.root { + position: relative; +} + +.highlighted { + &::after { + content: ''; + position: absolute; + top: -8px; + left: -8px; + width: calc(100% + 16px); + height: calc(100% + 16px); + border-radius: 6px; + animation: blink 1s 3.5; + pointer-events: none; + } +} + +@keyframes blink { + 0%, 100% { + background: color(from var(--MI_THEME-accent) srgb r g b / 0.1); + border: 1px solid color(from var(--MI_THEME-accent) srgb r g b / 0.75); + } + 50% { + background: transparent; + border: 1px solid transparent; + } +} +</style> diff --git a/packages/frontend/src/components/global/SkLazy.vue b/packages/frontend/src/components/global/SkLazy.vue deleted file mode 100644 index 40add97db7..0000000000 --- a/packages/frontend/src/components/global/SkLazy.vue +++ /dev/null @@ -1,57 +0,0 @@ -<!-- -SPDX-FileCopyrightText: syuilo and misskey-project -SPDX-License-Identifier: AGPL-3.0-only ---> - -<!-- Based on MkLazy.vue --> - -<template> -<div ref="rootEl" :class="$style.root"> - <slot v-if="showing"></slot> - <div v-else :class="$style.placeholder"></div> -</div> -</template> - -<script lang="ts" setup> -import { nextTick, onMounted, onActivated, onBeforeUnmount, ref, shallowRef } from 'vue'; - -const rootEl = shallowRef<HTMLDivElement>(); -const showing = ref(false); - -defineExpose({ rootEl, showing }); - -const observer = new IntersectionObserver(entries => - showing.value = entries.some((entry) => entry.isIntersecting), -); - -onMounted(() => { - nextTick(() => { - if (rootEl.value) { - observer.observe(rootEl.value); - } - }); -}); - -onActivated(() => { - nextTick(() => { - if (rootEl.value) { - observer.observe(rootEl.value); - } - }); -}); - -onBeforeUnmount(() => { - observer.disconnect(); -}); -</script> - -<style lang="scss" module> -.root { - display: block; -} - -.placeholder { - display: block; - min-height: 150px; -} -</style> diff --git a/packages/frontend/src/components/global/StackingRouterView.vue b/packages/frontend/src/components/global/StackingRouterView.vue new file mode 100644 index 0000000000..c95c74aef3 --- /dev/null +++ b/packages/frontend/src/components/global/StackingRouterView.vue @@ -0,0 +1,243 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<TransitionGroup + :enterActiveClass="prefer.s.animation ? $style.transition_x_enterActive : ''" + :leaveActiveClass="prefer.s.animation ? $style.transition_x_leaveActive : ''" + :enterFromClass="prefer.s.animation ? $style.transition_x_enterFrom : ''" + :leaveToClass="prefer.s.animation ? $style.transition_x_leaveTo : ''" + :moveClass="prefer.s.animation ? $style.transition_x_move : ''" + :duration="200" + tag="div" :class="$style.tabs" +> + <div v-for="(tab, i) in tabs" :key="tab.fullPath" :class="$style.tab" :style="{ '--i': i - 1 }"> + <div v-if="i > 0" :class="$style.tabBg" @click="back()"></div> + <div :class="$style.tabFg" @click.stop="back()"> + <div v-if="i > 0" :class="$style.tabMenu"> + <svg :class="$style.tabMenuShape" viewBox="0 0 24 16"> + <g transform="matrix(2.04108e-17,-0.333333,0.222222,1.36072e-17,21.3333,15.9989)"> + <path d="M23.997,-42C47.903,-23.457 47.997,12 47.997,12L-0.003,12L-0.003,-96C-0.003,-96 0.091,-60.543 23.997,-42Z" style="fill:var(--MI_THEME-panel);"/> + </g> + </svg> + <button :class="$style.tabMenuButton" class="_button" @click.stop="mount"><i class="ti ti-rectangle"></i></button> + <button :class="$style.tabMenuButton" class="_button" @click.stop="back"><i class="ti ti-x"></i></button> + </div> + <div v-if="i > 0" :class="$style.tabBorder"></div> + <div :class="$style.tabContent" class="_pageContainer" @click.stop=""> + <Suspense :timeout="0"> + <component :is="tab.component" v-bind="Object.fromEntries(tab.props)"/> + + <template #fallback> + <MkLoading/> + </template> + </Suspense> + </div> + </div> + </div> +</TransitionGroup> +</template> + +<script lang="ts" setup> +import { inject, provide, shallowRef } from 'vue'; +import type { Router } from '@/router.js'; +import { prefer } from '@/preferences.js'; +import MkLoadingPage from '@/pages/_loading_.vue'; +import { DI } from '@/di.js'; +import { deepEqual } from '@/utility/deep-equal.js'; + +const props = defineProps<{ + router?: Router; +}>(); + +const router = props.router ?? inject(DI.router); + +if (router == null) { + throw new Error('no router provided'); +} + +const currentDepth = inject(DI.routerCurrentDepth, 0); +provide(DI.routerCurrentDepth, currentDepth + 1); + +const tabs = shallowRef([{ + fullPath: router.getCurrentFullPath(), + routePath: router.current.route.path, + component: 'component' in router.current.route ? router.current.route.component : MkLoadingPage, + props: router.current.props, +}]); + +function mount() { + const currentTab = tabs.value[tabs.value.length - 1]; + tabs.value = [currentTab]; +} + +function back() { + const prev = tabs.value[tabs.value.length - 2]; + tabs.value = [...tabs.value.slice(0, tabs.value.length - 1)]; + router.replace(prev.fullPath); +} + +router.useListener('change', ({ resolved }) => { + const currentTab = tabs.value[tabs.value.length - 1]; + const routePath = resolved.route.path; + if (resolved == null || 'redirect' in resolved.route) return; + if (resolved.route.path === currentTab.routePath && deepEqual(resolved.props, currentTab.props)) return; + const fullPath = router.getCurrentFullPath(); + + if (tabs.value.some(tab => tab.routePath === routePath && deepEqual(resolved.props, tab.props))) { + const newTabs = []; + for (const tab of tabs.value) { + newTabs.push(tab); + + if (tab.routePath === routePath && deepEqual(resolved.props, tab.props)) { + break; + } + } + tabs.value = newTabs; + return; + } + + tabs.value = tabs.value.length >= prefer.s.numberOfPageCache ? [ + ...tabs.value.slice(1), + { + fullPath: fullPath, + routePath, + component: resolved.route.component, + props: resolved.props, + }, + ] : [...tabs.value, { + fullPath: fullPath, + routePath, + component: resolved.route.component, + props: resolved.props, + }]; +}); + +router.useListener('replace', ({ fullPath }) => { + const currentTab = tabs.value[tabs.value.length - 1]; + currentTab.fullPath = fullPath; + tabs.value = [...tabs.value.slice(0, tabs.value.length - 1), currentTab]; +}); +</script> + +<style lang="scss" module> +.transition_x_move, +.transition_x_enterActive, +.transition_x_leaveActive { + .tabBg { + transition: opacity 0.2s cubic-bezier(0,.5,.5,1), transform 0.2s cubic-bezier(0,.5,.5,1) !important; + } + + .tabFg { + transition: opacity 0.2s cubic-bezier(0,.5,.5,1), transform 0.2s cubic-bezier(0,.5,.5,1) !important; + } +} +.transition_x_enterFrom, +.transition_x_leaveTo { + .tabBg { + opacity: 0; + } + + .tabFg { + opacity: 0; + transform: translateY(100px); + } +} +.transition_x_leaveActive { + .tabFg { + //position: absolute; + } +} + +.tabs { + position: relative; + width: 100%; + height: 100%; +} + +.tab { + overflow: clip; + + &:first-child { + position: relative; + width: 100%; + height: 100%; + + .tabFg { + position: relative; + width: 100%; + height: 100%; + } + + .tabContent { + position: relative; + width: 100%; + height: 100%; + } + } + + &:not(:first-child) { + position: absolute; + top: 0; + left: 0; + height: 100%; + width: 100%; + + .tabBg { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: #0003; + -webkit-backdrop-filter: var(--MI-blur, blur(3px)); + backdrop-filter: var(--MI-blur, blur(3px)); + } + + .tabFg { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: calc(100% - (10px + (20px * var(--i)))); + display: flex; + flex-direction: column; + } + + .tabContent { + flex: 1; + width: 100%; + height: 100%; + background: var(--MI_THEME-bg); + } + } +} + +.tabMenu { + position: relative; + margin-left: auto; + padding: 0 4px; + background: var(--MI_THEME-panel); +} + +.tabMenuShape { + position: absolute; + bottom: -1px; + left: -100%; + height: calc(100% + 1px); + width: 129%; + pointer-events: none; +} + +.tabBorder { + height: 6px; + background: var(--MI_THEME-panel); +} + +.tabMenuButton { + padding: 8px; + font-size: 13px; +} +</style> diff --git a/packages/frontend/src/components/grid/MkDataCell.vue b/packages/frontend/src/components/grid/MkDataCell.vue index e473b7c1af..55de0df690 100644 --- a/packages/frontend/src/components/grid/MkDataCell.vue +++ b/packages/frontend/src/components/grid/MkDataCell.vue @@ -39,10 +39,12 @@ SPDX-License-Identifier: AGPL-3.0-only {{ cell.value }} </div> <div v-else-if="cellType === 'boolean'"> - <div :class="[$style.bool, { - [$style.boolTrue]: cell.value === true, - 'ti ti-check': cell.value === true, - }]"></div> + <div + :class="[$style.bool, { + [$style.boolTrue]: cell.value === true, + 'ti ti-check': cell.value === true, + }]" + ></div> </div> <div v-else-if="cellType === 'image'"> <img @@ -88,13 +90,14 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script setup lang="ts"> -import { computed, defineAsyncComponent, nextTick, onMounted, onUnmounted, ref, shallowRef, toRefs, watch } from 'vue'; -import { GridEventEmitter, Size } from '@/components/grid/grid.js'; -import { useTooltip } from '@/scripts/use-tooltip.js'; +import { computed, defineAsyncComponent, nextTick, onMounted, onUnmounted, ref, useTemplateRef, toRefs, watch } from 'vue'; +import type { Size } from '@/components/grid/grid.js'; +import type { CellValue, GridCell } from '@/components/grid/cell.js'; +import type { GridRowSetting } from '@/components/grid/row.js'; +import { GridEventEmitter } from '@/components/grid/grid.js'; +import { useTooltip } from '@/use/use-tooltip.js'; import * as os from '@/os.js'; -import { CellValue, GridCell } from '@/components/grid/cell.js'; import { equalCellAddress, getCellAddress } from '@/components/grid/grid-utils.js'; -import { GridRowSetting } from '@/components/grid/row.js'; const emit = defineEmits<{ (ev: 'operation:beginEdit', sender: GridCell): void; @@ -110,9 +113,9 @@ const props = defineProps<{ const { cell, bus } = toRefs(props); -const rootEl = shallowRef<InstanceType<typeof HTMLTableCellElement>>(); -const contentAreaEl = shallowRef<InstanceType<typeof HTMLDivElement>>(); -const inputAreaEl = shallowRef<InstanceType<typeof HTMLDivElement>>(); +const rootEl = useTemplateRef('rootEl'); +const contentAreaEl = useTemplateRef('contentAreaEl'); +const inputAreaEl = useTemplateRef('inputAreaEl'); /** 値が編集中かどうか */ const editing = ref<boolean>(false); @@ -342,7 +345,7 @@ $cellHeight: 28px; border: solid 0.5px transparent; &.selected { - border: solid 0.5px var(--MI_THEME-accentLighten); + border: solid 0.5px hsl(from var(--MI_THEME-accent) h s calc(l + 10)); } &.ranged { diff --git a/packages/frontend/src/components/grid/MkDataRow.vue b/packages/frontend/src/components/grid/MkDataRow.vue index 280a14bc4a..a35f93b435 100644 --- a/packages/frontend/src/components/grid/MkDataRow.vue +++ b/packages/frontend/src/components/grid/MkDataRow.vue @@ -37,11 +37,12 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script setup lang="ts"> -import { GridEventEmitter, Size } from '@/components/grid/grid.js'; +import { GridEventEmitter } from '@/components/grid/grid.js'; import MkDataCell from '@/components/grid/MkDataCell.vue'; import MkNumberCell from '@/components/grid/MkNumberCell.vue'; -import { CellValue, GridCell } from '@/components/grid/cell.js'; -import { GridRow, GridRowSetting } from '@/components/grid/row.js'; +import type { Size } from '@/components/grid/grid.js'; +import type { CellValue, GridCell } from '@/components/grid/cell.js'; +import type { GridRow, GridRowSetting } from '@/components/grid/row.js'; const emit = defineEmits<{ (ev: 'operation:beginEdit', sender: GridCell): void; diff --git a/packages/frontend/src/components/grid/MkGrid.stories.impl.ts b/packages/frontend/src/components/grid/MkGrid.stories.impl.ts index 5801012f15..f85bf146e8 100644 --- a/packages/frontend/src/components/grid/MkGrid.stories.impl.ts +++ b/packages/frontend/src/components/grid/MkGrid.stories.impl.ts @@ -5,14 +5,14 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { action } from '@storybook/addon-actions'; -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { ref } from 'vue'; import { commonHandlers } from '../../../.storybook/mocks.js'; import { boolean, choose, country, date, firstName, integer, lastName, text } from '../../../.storybook/fake-utils.js'; import MkGrid from './MkGrid.vue'; -import { GridContext, GridEvent } from '@/components/grid/grid-event.js'; -import { DataSource, GridSetting } from '@/components/grid/grid.js'; -import { GridColumnSetting } from '@/components/grid/column.js'; +import type { GridContext, GridEvent } from '@/components/grid/grid-event.js'; +import type { DataSource, GridSetting } from '@/components/grid/grid.js'; +import type { GridColumnSetting } from '@/components/grid/column.js'; function d(p: { check?: boolean, diff --git a/packages/frontend/src/components/grid/MkGrid.vue b/packages/frontend/src/components/grid/MkGrid.vue index 4dbd4ebcae..f80f037285 100644 --- a/packages/frontend/src/components/grid/MkGrid.vue +++ b/packages/frontend/src/components/grid/MkGrid.vue @@ -38,7 +38,6 @@ SPDX-License-Identifier: AGPL-3.0-only :setting="rowSetting" :bus="bus" :using="row.using" - :class="[lastLine === row.index ? 'last_row' : '']" @operation:beginEdit="onCellEditBegin" @operation:endEdit="onCellEditEnd" @change:value="onChangeCellValue" @@ -50,11 +49,17 @@ SPDX-License-Identifier: AGPL-3.0-only <script setup lang="ts"> import { computed, onMounted, ref, toRefs, watch } from 'vue'; -import { DataSource, GridEventEmitter, GridSetting, GridState, Size } from '@/components/grid/grid.js'; +import type { DataSource, GridSetting, GridState, Size } from '@/components/grid/grid.js'; +import type { CellAddress, CellValue, GridCell } from '@/components/grid/cell.js'; +import type { GridContext, GridEvent } from '@/components/grid/grid-event.js'; +import type { GridColumn } from '@/components/grid/column.js'; +import type { GridRow, GridRowSetting } from '@/components/grid/row.js'; +import type { MenuItem } from '@/types/menu.js'; +import { GridEventEmitter } from '@/components/grid/grid.js'; import MkDataRow from '@/components/grid/MkDataRow.vue'; import MkHeaderRow from '@/components/grid/MkHeaderRow.vue'; import { cellValidation } from '@/components/grid/cell-validators.js'; -import { CELL_ADDRESS_NONE, CellAddress, CellValue, createCell, GridCell, resetCell } from '@/components/grid/cell.js'; +import { CELL_ADDRESS_NONE, createCell, resetCell } from '@/components/grid/cell.js'; import { copyGridDataToClipboard, equalCellAddress, @@ -63,18 +68,16 @@ import { pasteToGridFromClipboard, removeDataFromGrid, } from '@/components/grid/grid-utils.js'; -import { MenuItem } from '@/types/menu.js'; import * as os from '@/os.js'; -import { GridContext, GridEvent } from '@/components/grid/grid-event.js'; -import { createColumn, GridColumn } from '@/components/grid/column.js'; -import { createRow, defaultGridRowSetting, GridRow, GridRowSetting, resetRow } from '@/components/grid/row.js'; -import { handleKeyEvent } from '@/scripts/key-event.js'; +import { createColumn } from '@/components/grid/column.js'; +import { createRow, defaultGridRowSetting, resetRow } from '@/components/grid/row.js'; +import { handleKeyEvent } from '@/utility/key-event.js'; type RowHolder = { row: GridRow, cells: GridCell[], origin: DataSource, -} +}; const emit = defineEmits<{ (ev: 'event', event: GridEvent, context: GridContext): void; @@ -125,7 +128,7 @@ const bus = new GridEventEmitter(); * * @see {@link onResize} */ -const resizeObserver = new ResizeObserver((entries) => setTimeout(() => onResize(entries))); +const resizeObserver = new ResizeObserver((entries) => window.setTimeout(() => onResize(entries))); const rootEl = ref<InstanceType<typeof HTMLTableElement>>(); /** @@ -1297,8 +1300,6 @@ onMounted(() => { </style> <style lang="scss"> -$borderSetting: solid 0.5px var(--MI_THEME-divider); - // 配下コンポーネントを含めて一括してコントロールするため、scopedもmoduleも使用できない .mk_grid_border { --rootBorderSetting: none; @@ -1306,66 +1307,39 @@ $borderSetting: solid 0.5px var(--MI_THEME-divider); border-spacing: 0; - &.mk_grid_root_border { - --rootBorderSetting: #{$borderSetting}; - } - &.mk_grid_root_rounded { --borderRadius: var(--MI-radius); } .mk_grid_thead { + position: sticky; + z-index: 1; + left: 0; + top: 0; + -webkit-backdrop-filter: var(--MI-blur, blur(8px)); + backdrop-filter: var(--MI-blur, blur(20px)); + background: color(from var(--MI_THEME-bg) srgb r g b / 0.5); + .mk_grid_tr { .mk_grid_th { - border-left: $borderSetting; - border-top: var(--rootBorderSetting); - - &:first-child { - // 左上セル - border-left: var(--rootBorderSetting); - border-top-left-radius: var(--borderRadius); - } - &:last-child { - // 右上セル - border-top-right-radius: var(--borderRadius); - border-right: var(--rootBorderSetting); - } } } } .mk_grid_tbody { .mk_grid_tr { - .mk_grid_td, .mk_grid_th { - border-left: $borderSetting; - border-top: $borderSetting; - - &:first-child { - // 左端の列 - border-left: var(--rootBorderSetting); - } + &:nth-child(odd) { + background: var(--MI_THEME-panel); + } - &:last-child { - // 一番右端の列 - border-right: var(--rootBorderSetting); - } + &:nth-child(even) { + background: var(--MI_THEME-bg); } - } - .last_row { .mk_grid_td, .mk_grid_th { - // 一番下の行 - border-bottom: var(--rootBorderSetting); - - &:first-child { - // 左下セル - border-bottom-left-radius: var(--borderRadius); - } - - &:last-child { - // 右下セル - border-bottom-right-radius: var(--borderRadius); + &:hover { + box-shadow: 0 0 0 1px var(--MI_THEME-divider) inset; } } } diff --git a/packages/frontend/src/components/grid/MkHeaderCell.vue b/packages/frontend/src/components/grid/MkHeaderCell.vue index aecfe7eaa3..69a68b6f2c 100644 --- a/packages/frontend/src/components/grid/MkHeaderCell.vue +++ b/packages/frontend/src/components/grid/MkHeaderCell.vue @@ -32,8 +32,9 @@ SPDX-License-Identifier: AGPL-3.0-only <script setup lang="ts"> import { computed, nextTick, onMounted, onUnmounted, ref, toRefs, watch } from 'vue'; -import { GridEventEmitter, Size } from '@/components/grid/grid.js'; -import { GridColumn } from '@/components/grid/column.js'; +import { GridEventEmitter } from '@/components/grid/grid.js'; +import type { Size } from '@/components/grid/grid.js'; +import type { GridColumn } from '@/components/grid/column.js'; const emit = defineEmits<{ (ev: 'operation:beginWidthChange', sender: GridColumn): void; diff --git a/packages/frontend/src/components/grid/MkHeaderRow.vue b/packages/frontend/src/components/grid/MkHeaderRow.vue index 8affa08fd5..225f623b84 100644 --- a/packages/frontend/src/components/grid/MkHeaderRow.vue +++ b/packages/frontend/src/components/grid/MkHeaderRow.vue @@ -29,11 +29,12 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script setup lang="ts"> -import { GridEventEmitter, Size } from '@/components/grid/grid.js'; +import { GridEventEmitter } from '@/components/grid/grid.js'; import MkHeaderCell from '@/components/grid/MkHeaderCell.vue'; import MkNumberCell from '@/components/grid/MkNumberCell.vue'; -import { GridColumn } from '@/components/grid/column.js'; -import { GridRowSetting } from '@/components/grid/row.js'; +import type { Size } from '@/components/grid/grid.js'; +import type { GridColumn } from '@/components/grid/column.js'; +import type { GridRowSetting } from '@/components/grid/row.js'; const emit = defineEmits<{ (ev: 'operation:beginWidthChange', sender: GridColumn): void; diff --git a/packages/frontend/src/components/grid/MkNumberCell.vue b/packages/frontend/src/components/grid/MkNumberCell.vue index 674bba96bc..d3b5956ddd 100644 --- a/packages/frontend/src/components/grid/MkNumberCell.vue +++ b/packages/frontend/src/components/grid/MkNumberCell.vue @@ -19,8 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script setup lang="ts"> - -import { GridRow } from '@/components/grid/row.js'; +import type { GridRow } from '@/components/grid/row.js'; defineProps<{ content: string, diff --git a/packages/frontend/src/components/grid/cell-validators.ts b/packages/frontend/src/components/grid/cell-validators.ts index 949cab2ec6..7310a82c9e 100644 --- a/packages/frontend/src/components/grid/cell-validators.ts +++ b/packages/frontend/src/components/grid/cell-validators.ts @@ -3,9 +3,9 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { CellValue, GridCell } from '@/components/grid/cell.js'; -import { GridColumn } from '@/components/grid/column.js'; -import { GridRow } from '@/components/grid/row.js'; +import type { CellValue, GridCell } from '@/components/grid/cell.js'; +import type { GridColumn } from '@/components/grid/column.js'; +import type { GridRow } from '@/components/grid/row.js'; import { i18n } from '@/i18n.js'; export type ValidatorParams = { @@ -18,25 +18,25 @@ export type ValidatorParams = { export type ValidatorResult = { valid: boolean; message?: string; -} +}; export type GridCellValidator = { name?: string; ignoreViolation?: boolean; validate: (params: ValidatorParams) => ValidatorResult; -} +}; export type ValidateViolation = { valid: boolean; params: ValidatorParams; violations: ValidateViolationItem[]; -} +}; export type ValidateViolationItem = { valid: boolean; validator: GridCellValidator; result: ValidatorResult; -} +}; export function cellValidation(allCells: GridCell[], cell: GridCell, newValue: CellValue): ValidateViolation { const { column, row } = cell; diff --git a/packages/frontend/src/components/grid/cell.ts b/packages/frontend/src/components/grid/cell.ts index 71b7a3e3f1..d347d05bdb 100644 --- a/packages/frontend/src/components/grid/cell.ts +++ b/packages/frontend/src/components/grid/cell.ts @@ -3,19 +3,19 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { ValidateViolation } from '@/components/grid/cell-validators.js'; -import { Size } from '@/components/grid/grid.js'; -import { GridColumn } from '@/components/grid/column.js'; -import { GridRow } from '@/components/grid/row.js'; -import { MenuItem } from '@/types/menu.js'; -import { GridContext } from '@/components/grid/grid-event.js'; +import type { ValidateViolation } from '@/components/grid/cell-validators.js'; +import type { Size } from '@/components/grid/grid.js'; +import type { GridColumn } from '@/components/grid/column.js'; +import type { GridRow } from '@/components/grid/row.js'; +import type { MenuItem } from '@/types/menu.js'; +import type { GridContext } from '@/components/grid/grid-event.js'; export type CellValue = string | boolean | number | undefined | null | Array<unknown> | NonNullable<unknown>; export type CellAddress = { row: number; col: number; -} +}; export const CELL_ADDRESS_NONE: CellAddress = { row: -1, @@ -32,13 +32,13 @@ export type GridCell = { contentSize: Size; setting: GridCellSetting; violation: ValidateViolation; -} +}; export type GridCellContextMenuFactory = (col: GridColumn, row: GridRow, value: CellValue, context: GridContext) => MenuItem[]; export type GridCellSetting = { contextMenuFactory?: GridCellContextMenuFactory; -} +}; export function createCell( column: GridColumn, diff --git a/packages/frontend/src/components/grid/column.ts b/packages/frontend/src/components/grid/column.ts index 2f505756fe..6a694b39ec 100644 --- a/packages/frontend/src/components/grid/column.ts +++ b/packages/frontend/src/components/grid/column.ts @@ -3,13 +3,13 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { GridCellValidator } from '@/components/grid/cell-validators.js'; -import { Size, SizeStyle } from '@/components/grid/grid.js'; import { calcCellWidth } from '@/components/grid/grid-utils.js'; -import { CellValue, GridCell } from '@/components/grid/cell.js'; -import { GridRow } from '@/components/grid/row.js'; -import { MenuItem } from '@/types/menu.js'; -import { GridContext } from '@/components/grid/grid-event.js'; +import type { GridCellValidator } from '@/components/grid/cell-validators.js'; +import type { Size, SizeStyle } from '@/components/grid/grid.js'; +import type { CellValue, GridCell } from '@/components/grid/cell.js'; +import type { GridRow } from '@/components/grid/row.js'; +import type { MenuItem } from '@/types/menu.js'; +import type { GridContext } from '@/components/grid/grid-event.js'; export type ColumnType = 'text' | 'number' | 'date' | 'boolean' | 'image' | 'hidden'; @@ -40,7 +40,7 @@ export type GridColumn = { setting: GridColumnSetting; width: string; contentSize: Size; -} +}; export function createColumn(setting: GridColumnSetting, index: number): GridColumn { return { diff --git a/packages/frontend/src/components/grid/grid-event.ts b/packages/frontend/src/components/grid/grid-event.ts index 074b72b956..e2f1e44055 100644 --- a/packages/frontend/src/components/grid/grid-event.ts +++ b/packages/frontend/src/components/grid/grid-event.ts @@ -3,11 +3,11 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { CellAddress, CellValue, GridCell } from '@/components/grid/cell.js'; -import { GridState } from '@/components/grid/grid.js'; -import { ValidateViolation } from '@/components/grid/cell-validators.js'; -import { GridColumn } from '@/components/grid/column.js'; -import { GridRow } from '@/components/grid/row.js'; +import type { CellAddress, CellValue, GridCell } from '@/components/grid/cell.js'; +import type { GridState } from '@/components/grid/grid.js'; +import type { ValidateViolation } from '@/components/grid/cell-validators.js'; +import type { GridColumn } from '@/components/grid/column.js'; +import type { GridRow } from '@/components/grid/row.js'; export type GridContext = { selectedCell?: GridCell; diff --git a/packages/frontend/src/components/grid/grid-utils.ts b/packages/frontend/src/components/grid/grid-utils.ts index a45bc88926..9e5402354e 100644 --- a/packages/frontend/src/components/grid/grid-utils.ts +++ b/packages/frontend/src/components/grid/grid-utils.ts @@ -3,13 +3,15 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { isRef, Ref } from 'vue'; -import { DataSource, SizeStyle } from '@/components/grid/grid.js'; -import { CELL_ADDRESS_NONE, CellAddress, CellValue, GridCell } from '@/components/grid/cell.js'; -import { GridRow } from '@/components/grid/row.js'; -import { GridContext } from '@/components/grid/grid-event.js'; -import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; -import { GridColumn, GridColumnSetting } from '@/components/grid/column.js'; +import { isRef } from 'vue'; +import type { Ref } from 'vue'; +import type { DataSource, SizeStyle } from '@/components/grid/grid.js'; +import { CELL_ADDRESS_NONE } from '@/components/grid/cell.js'; +import type { CellAddress, CellValue, GridCell } from '@/components/grid/cell.js'; +import type { GridRow } from '@/components/grid/row.js'; +import type { GridContext } from '@/components/grid/grid-event.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; +import type { GridColumn, GridColumnSetting } from '@/components/grid/column.js'; export function isCellElement(elem: HTMLElement): boolean { return elem.hasAttribute('data-grid-cell'); diff --git a/packages/frontend/src/components/grid/grid.ts b/packages/frontend/src/components/grid/grid.ts index b82e12b304..0428e6493c 100644 --- a/packages/frontend/src/components/grid/grid.ts +++ b/packages/frontend/src/components/grid/grid.ts @@ -4,9 +4,9 @@ */ import { EventEmitter } from 'eventemitter3'; -import { CellValue, GridCellSetting } from '@/components/grid/cell.js'; -import { GridColumnSetting } from '@/components/grid/column.js'; -import { GridRowSetting } from '@/components/grid/row.js'; +import type { CellValue, GridCellSetting } from '@/components/grid/cell.js'; +import type { GridColumnSetting } from '@/components/grid/column.js'; +import type { GridRowSetting } from '@/components/grid/row.js'; export type GridSetting = { root?: { @@ -21,7 +21,7 @@ export type GridSetting = { export type DataSource = Record<string, CellValue>; -export type GridState = +export type GridState = ( 'normal' | 'cellSelecting' | 'cellEditing' | @@ -29,19 +29,19 @@ export type GridState = 'colSelecting' | 'rowSelecting' | 'hidden' - ; +); export type Size = { width: number; height: number; -} +}; export type SizeStyle = number | 'auto' | undefined; export type AdditionalStyle = { className?: string; style?: Record<string, string | number>; -} +}; export class GridEventEmitter extends EventEmitter<{ 'forceRefreshContentSize': void; diff --git a/packages/frontend/src/components/grid/row.ts b/packages/frontend/src/components/grid/row.ts index e0a317c9d3..42da22193f 100644 --- a/packages/frontend/src/components/grid/row.ts +++ b/packages/frontend/src/components/grid/row.ts @@ -3,11 +3,11 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { AdditionalStyle } from '@/components/grid/grid.js'; -import { GridCell } from '@/components/grid/cell.js'; -import { GridColumn } from '@/components/grid/column.js'; -import { MenuItem } from '@/types/menu.js'; -import { GridContext } from '@/components/grid/grid-event.js'; +import type { AdditionalStyle } from '@/components/grid/grid.js'; +import type { GridCell } from '@/components/grid/cell.js'; +import type { GridColumn } from '@/components/grid/column.js'; +import type { MenuItem } from '@/types/menu.js'; +import type { GridContext } from '@/components/grid/grid-event.js'; export const defaultGridRowSetting: Required<GridRowSetting> = { showNumber: true, @@ -27,7 +27,7 @@ export type GridRowStyleRuleConditionParams = { export type GridRowStyleRule = { condition: (params: GridRowStyleRuleConditionParams) => boolean; applyStyle: AdditionalStyle; -} +}; export type GridRowContextMenuFactory = (row: GridRow, context: GridContext) => MenuItem[]; @@ -40,7 +40,7 @@ export type GridRowSetting = { events?: { delete?: (rows: GridRow[]) => void; } -} +}; export type GridRow = { index: number; @@ -48,7 +48,7 @@ export type GridRow = { using: boolean; setting: GridRowSetting; additionalStyles: AdditionalStyle[]; -} +}; export function createRow(index: number, using: boolean, setting: GridRowSetting): GridRow { return { diff --git a/packages/frontend/src/components/index.ts b/packages/frontend/src/components/index.ts index b36625ed1b..ec6ea7c569 100644 --- a/packages/frontend/src/components/index.ts +++ b/packages/frontend/src/components/index.ts @@ -3,8 +3,6 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { App } from 'vue'; - import Mfm from './global/MkMfm.js'; import MkA from './global/MkA.vue'; import MkAcct from './global/MkAcct.vue'; @@ -18,14 +16,22 @@ import MkTime from './global/MkTime.vue'; import MkUrl from './global/MkUrl.vue'; import I18n from './global/I18n.vue'; import RouterView from './global/RouterView.vue'; +import NestedRouterView from './global/NestedRouterView.vue'; +import StackingRouterView from './global/StackingRouterView.vue'; import MkLoading from './global/MkLoading.vue'; import MkError from './global/MkError.vue'; import MkAd from './global/MkAd.vue'; import MkPageHeader from './global/MkPageHeader.vue'; -import MkSpacer from './global/MkSpacer.vue'; -import MkFooterSpacer from './global/MkFooterSpacer.vue'; import MkStickyContainer from './global/MkStickyContainer.vue'; import MkLazy from './global/MkLazy.vue'; +import PageWithHeader from './global/PageWithHeader.vue'; +import PageWithAnimBg from './global/PageWithAnimBg.vue'; +import SearchMarker from './global/SearchMarker.vue'; +import SearchLabel from './global/SearchLabel.vue'; +import SearchKeyword from './global/SearchKeyword.vue'; +import SearchIcon from './global/SearchIcon.vue'; + +import type { App } from 'vue'; export default function(app: App) { for (const [key, value] of Object.entries(components)) { @@ -36,6 +42,8 @@ export default function(app: App) { export const components = { I18n: I18n, RouterView: RouterView, + NestedRouterView: NestedRouterView, + StackingRouterView: StackingRouterView, Mfm: Mfm, MkA: MkA, MkAcct: MkAcct, @@ -51,16 +59,22 @@ export const components = { MkError: MkError, MkAd: MkAd, MkPageHeader: MkPageHeader, - MkSpacer: MkSpacer, - MkFooterSpacer: MkFooterSpacer, MkStickyContainer: MkStickyContainer, MkLazy: MkLazy, + PageWithHeader: PageWithHeader, + PageWithAnimBg: PageWithAnimBg, + SearchMarker: SearchMarker, + SearchLabel: SearchLabel, + SearchKeyword: SearchKeyword, + SearchIcon: SearchIcon, }; declare module '@vue/runtime-core' { export interface GlobalComponents { I18n: typeof I18n; RouterView: typeof RouterView; + NestedRouterView: typeof NestedRouterView; + StackingRouterView: typeof StackingRouterView; Mfm: typeof Mfm; MkA: typeof MkA; MkAcct: typeof MkAcct; @@ -76,9 +90,13 @@ declare module '@vue/runtime-core' { MkError: typeof MkError; MkAd: typeof MkAd; MkPageHeader: typeof MkPageHeader; - MkSpacer: typeof MkSpacer; - MkFooterSpacer: typeof MkFooterSpacer; MkStickyContainer: typeof MkStickyContainer; MkLazy: typeof MkLazy; + PageWithHeader: typeof PageWithHeader; + PageWithAnimBg: typeof PageWithAnimBg; + SearchMarker: typeof SearchMarker; + SearchLabel: typeof SearchLabel; + SearchKeyword: typeof SearchKeyword; + SearchIcon: typeof SearchIcon; } } diff --git a/packages/frontend/src/components/page/page.note.vue b/packages/frontend/src/components/page/page.note.vue index 84436e7adb..df26874c17 100644 --- a/packages/frontend/src/components/page/page.note.vue +++ b/packages/frontend/src/components/page/page.note.vue @@ -15,7 +15,7 @@ import { onMounted, ref } from 'vue'; import * as Misskey from 'misskey-js'; import MkNote from '@/components/MkNote.vue'; import MkNoteDetailed from '@/components/MkNoteDetailed.vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; const props = defineProps<{ block: Misskey.entities.PageBlock, diff --git a/packages/frontend/src/components/page/page.text.vue b/packages/frontend/src/components/page/page.text.vue index e5b1eff294..ef3524fe7a 100644 --- a/packages/frontend/src/components/page/page.text.vue +++ b/packages/frontend/src/components/page/page.text.vue @@ -13,10 +13,10 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { defineAsyncComponent } from 'vue'; +import { defineAsyncComponent, computed } from 'vue'; import * as mfm from '@transfem-org/sfm-js'; import * as Misskey from 'misskey-js'; -import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm.js'; +import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js'; import { isEnabledUrlPreview } from '@/instance.js'; const MkUrlPreview = defineAsyncComponent(() => import('@/components/MkUrlPreview.vue')); @@ -26,7 +26,10 @@ const props = defineProps<{ page: Misskey.entities.Page, }>(); -const urls = props.block.text ? extractUrlFromMfm(mfm.parse(props.block.text)) : []; +const urls = computed(() => { + if (!props.block.text) return []; + return extractUrlFromMfm(mfm.parse(props.block.text)); +}); </script> <style lang="scss" module> |