diff options
| author | misskey-release-bot[bot] <157398866+misskey-release-bot[bot]@users.noreply.github.com> | 2025-05-31 12:37:06 +0000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-05-31 12:37:06 +0000 |
| commit | 92b9a5218db145e9bb831cefd36c309de86083b5 (patch) | |
| tree | 2ebad71633f9bbacabbc193254223146f1662aee /packages/frontend | |
| parent | Merge pull request #15933 from misskey-dev/develop (diff) | |
| parent | Release: 2025.5.1 (diff) | |
| download | misskey-92b9a5218db145e9bb831cefd36c309de86083b5.tar.gz misskey-92b9a5218db145e9bb831cefd36c309de86083b5.tar.bz2 misskey-92b9a5218db145e9bb831cefd36c309de86083b5.zip | |
Merge pull request #16005 from misskey-dev/develop
Release: 2025.5.1
Diffstat (limited to 'packages/frontend')
242 files changed, 6751 insertions, 4399 deletions
diff --git a/packages/frontend/@types/global.d.ts b/packages/frontend/@types/global.d.ts index 1025d1bedb..8a067a78ec 100644 --- a/packages/frontend/@types/global.d.ts +++ b/packages/frontend/@types/global.d.ts @@ -10,9 +10,6 @@ declare const _VERSION_: string; declare const _ENV_: string; declare const _DEV_: boolean; declare const _PERF_PREFIX_: string; -declare const _DATA_TRANSFER_DRIVE_FILE_: string; -declare const _DATA_TRANSFER_DRIVE_FOLDER_: string; -declare const _DATA_TRANSFER_DECK_COLUMN_: string; // for dev-mode declare const _LANGS_FULL_: string[][]; diff --git a/packages/frontend/assets/unknown.png b/packages/frontend/assets/unknown.png Binary files differnew file mode 100644 index 0000000000..d27bdfc8b3 --- /dev/null +++ b/packages/frontend/assets/unknown.png diff --git a/packages/frontend/eslint.config.js b/packages/frontend/eslint.config.js index 1b9a9b68c0..8f835975a8 100644 --- a/packages/frontend/eslint.config.js +++ b/packages/frontend/eslint.config.js @@ -30,9 +30,6 @@ export default [ _VERSION_: false, _ENV_: false, _PERF_PREFIX_: false, - _DATA_TRANSFER_DRIVE_FILE_: false, - _DATA_TRANSFER_DRIVE_FOLDER_: false, - _DATA_TRANSFER_DECK_COLUMN_: false, }, parser, parserOptions: { diff --git a/packages/frontend/package.json b/packages/frontend/package.json index ad2a72f7fd..c7b32b5f2d 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -24,12 +24,11 @@ "@rollup/plugin-json": "6.1.0", "@rollup/plugin-replace": "6.0.2", "@rollup/pluginutils": "5.1.4", - "@sentry/vue": "9.14.0", + "@sentry/vue": "9.22.0", "@syuilo/aiscript": "0.19.0", - "@tabler/icons-webfont": "3.31.0", "@twemoji/parser": "15.1.1", - "@vitejs/plugin-vue": "5.2.3", - "@vue/compiler-sfc": "3.5.13", + "@vitejs/plugin-vue": "5.2.4", + "@vue/compiler-sfc": "3.5.14", "aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.15", "analytics": "0.8.16", "astring": "1.9.0", @@ -48,7 +47,8 @@ "estree-walker": "3.0.3", "eventemitter3": "5.0.1", "frontend-shared": "workspace:*", - "idb-keyval": "6.2.1", + "icons-subsetter": "workspace:*", + "idb-keyval": "6.2.2", "insert-text-at-cursor": "0.3.0", "is-file-animated": "1.0.2", "json5": "2.2.3", @@ -60,84 +60,85 @@ "misskey-reversi": "workspace:*", "photoswipe": "5.4.4", "punycode.js": "2.3.1", - "rollup": "4.40.0", - "sanitize-html": "2.16.0", - "sass": "1.87.0", - "shiki": "3.3.0", + "rollup": "4.41.0", + "sanitize-html": "2.17.0", + "sass": "1.89.0", + "shiki": "3.4.2", "strict-event-emitter-types": "2.0.0", "textarea-caret": "3.1.0", "three": "0.176.0", "throttle-debounce": "5.0.2", "tinycolor2": "1.6.0", - "tsc-alias": "1.8.15", + "tsc-alias": "1.8.16", "tsconfig-paths": "4.2.0", "typescript": "5.8.3", "uuid": "11.1.0", "v-code-diff": "1.13.1", - "vite": "6.3.4", - "vue": "3.5.13", + "vite": "6.3.5", + "vue": "3.5.14", "vuedraggable": "next", "wanakana": "5.3.1" }, "devDependencies": { "@misskey-dev/summaly": "5.2.1", - "@storybook/addon-actions": "8.6.12", - "@storybook/addon-essentials": "8.6.12", - "@storybook/addon-interactions": "8.6.12", - "@storybook/addon-links": "8.6.12", - "@storybook/addon-mdx-gfm": "8.6.12", - "@storybook/addon-storysource": "8.6.12", - "@storybook/blocks": "8.6.12", - "@storybook/components": "8.6.12", - "@storybook/core-events": "8.6.12", - "@storybook/manager-api": "8.6.12", - "@storybook/preview-api": "8.6.12", - "@storybook/react": "8.6.12", - "@storybook/react-vite": "8.6.12", - "@storybook/test": "8.6.12", - "@storybook/theming": "8.6.12", - "@storybook/types": "8.6.12", - "@storybook/vue3": "8.6.12", - "@storybook/vue3-vite": "8.6.12", + "@storybook/addon-actions": "8.6.14", + "@storybook/addon-essentials": "8.6.14", + "@storybook/addon-interactions": "8.6.14", + "@storybook/addon-links": "8.6.14", + "@storybook/addon-mdx-gfm": "8.6.14", + "@storybook/addon-storysource": "8.6.14", + "@storybook/blocks": "8.6.14", + "@storybook/components": "8.6.14", + "@storybook/core-events": "8.6.14", + "@storybook/manager-api": "8.6.14", + "@storybook/preview-api": "8.6.14", + "@storybook/react": "8.6.14", + "@storybook/react-vite": "8.6.14", + "@storybook/test": "8.6.14", + "@storybook/theming": "8.6.14", + "@storybook/types": "8.6.14", + "@storybook/vue3": "8.6.14", + "@storybook/vue3-vite": "8.6.14", + "@tabler/icons-webfont": "3.33.0", "@testing-library/vue": "8.1.0", "@types/canvas-confetti": "1.9.0", "@types/estree": "1.0.7", "@types/matter-js": "0.19.8", "@types/micromatch": "4.0.9", - "@types/node": "22.15.2", + "@types/node": "22.15.21", "@types/punycode.js": "npm:@types/punycode@2.1.4", - "@types/sanitize-html": "2.15.0", + "@types/sanitize-html": "2.16.0", "@types/seedrandom": "3.0.8", "@types/throttle-debounce": "5.0.2", "@types/tinycolor2": "1.4.6", "@types/ws": "8.18.1", - "@typescript-eslint/eslint-plugin": "8.31.0", - "@typescript-eslint/parser": "8.31.0", - "@vitest/coverage-v8": "3.1.2", - "@vue/compiler-core": "3.5.13", - "@vue/runtime-core": "3.5.13", + "@typescript-eslint/eslint-plugin": "8.32.1", + "@typescript-eslint/parser": "8.32.1", + "@vitest/coverage-v8": "3.1.4", + "@vue/compiler-core": "3.5.14", + "@vue/runtime-core": "3.5.14", "acorn": "8.14.1", "cross-env": "7.0.3", - "cypress": "14.3.2", + "cypress": "14.4.0", "eslint-plugin-import": "2.31.0", - "eslint-plugin-vue": "10.0.0", + "eslint-plugin-vue": "10.1.0", "fast-glob": "3.3.3", - "happy-dom": "17.4.4", + "happy-dom": "17.4.7", "intersection-observer": "0.12.2", "micromatch": "4.0.8", "minimatch": "10.0.1", - "msw": "2.7.5", + "msw": "2.8.4", "msw-storybook-addon": "2.0.4", "nodemon": "3.1.10", "prettier": "3.5.3", "react": "19.1.0", "react-dom": "19.1.0", "seedrandom": "3.0.5", - "start-server-and-test": "2.0.11", - "storybook": "8.6.12", + "start-server-and-test": "2.0.12", + "storybook": "8.6.14", "storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme", "vite-plugin-turbosnap": "1.0.3", - "vitest": "3.1.2", + "vitest": "3.1.4", "vitest-fetch-mock": "0.4.5", "vue-component-type-helpers": "2.2.10", "vue-eslint-parser": "10.1.3", diff --git a/packages/frontend/src/_boot_.ts b/packages/frontend/src/_boot_.ts index 3241f2dc92..354fb95544 100644 --- a/packages/frontend/src/_boot_.ts +++ b/packages/frontend/src/_boot_.ts @@ -6,7 +6,11 @@ // https://vitejs.dev/config/build-options.html#build-modulepreload import 'vite/modulepreload-polyfill'; -import '@tabler/icons-webfont/dist/tabler-icons.scss'; +if (import.meta.env.DEV) { + await import('@tabler/icons-webfont/dist/tabler-icons.scss'); +} else { + await import('icons-subsetter/built/tabler-icons-frontend.css'); +} import '@/style.scss'; import { mainBoot } from '@/boot/main-boot.js'; diff --git a/packages/frontend/src/aiscript/api.ts b/packages/frontend/src/aiscript/api.ts index 08ba89dd9d..a876e94ee8 100644 --- a/packages/frontend/src/aiscript/api.ts +++ b/packages/frontend/src/aiscript/api.ts @@ -66,6 +66,11 @@ export function createAiScriptEnv(opts: { storageKey: string, token?: string }) }); return confirm.canceled ? values.FALSE : values.TRUE; }), + 'Mk:toast': values.FN_NATIVE(async ([text]) => { + utils.assertString(text); + os.toast(text.value); + return values.NULL; + }), 'Mk:api': values.FN_NATIVE(async ([ep, param, token]) => { utils.assertString(ep); if (ep.value.includes('://') || ep.value.includes('..')) { diff --git a/packages/frontend/src/boot/main-boot.ts b/packages/frontend/src/boot/main-boot.ts index fad6ce3825..ae4e0445db 100644 --- a/packages/frontend/src/boot/main-boot.ts +++ b/packages/frontend/src/boot/main-boot.ts @@ -79,39 +79,6 @@ export async function mainBoot() { } } - const stream = useStream(); - - let reloadDialogShowing = false; - stream.on('_disconnected_', async () => { - if (prefer.s.serverDisconnectedBehavior === 'reload') { - window.location.reload(); - } else if (prefer.s.serverDisconnectedBehavior === 'dialog') { - if (reloadDialogShowing) return; - reloadDialogShowing = true; - const { canceled } = await confirm({ - type: 'warning', - title: i18n.ts.disconnectedFromServer, - text: i18n.ts.reloadConfirm, - }); - reloadDialogShowing = false; - if (!canceled) { - window.location.reload(); - } - } - }); - - stream.on('emojiAdded', emojiData => { - addCustomEmoji(emojiData.emoji); - }); - - stream.on('emojiUpdated', emojiData => { - updateCustomEmojis(emojiData.emojis); - }); - - stream.on('emojiDeleted', emojiData => { - removeCustomEmojis(emojiData.emojis); - }); - launchPlugins(); try { @@ -169,8 +136,6 @@ export async function mainBoot() { } } - stream.on('announcementCreated', onAnnouncementCreated); - if ($i.isDeleted) { alert({ type: 'warning', @@ -348,50 +313,81 @@ export async function mainBoot() { } } - const main = markRaw(stream.useChannel('main', null, 'System')); + if (store.s.realtimeMode) { + const stream = useStream(); - // 自分の情報が更新されたとき - main.on('meUpdated', i => { - updateCurrentAccountPartial(i); - }); + let reloadDialogShowing = false; + stream.on('_disconnected_', async () => { + if (prefer.s.serverDisconnectedBehavior === 'reload') { + window.location.reload(); + } else if (prefer.s.serverDisconnectedBehavior === 'dialog') { + if (reloadDialogShowing) return; + reloadDialogShowing = true; + const { canceled } = await confirm({ + type: 'warning', + title: i18n.ts.disconnectedFromServer, + text: i18n.ts.reloadConfirm, + }); + reloadDialogShowing = false; + if (!canceled) { + window.location.reload(); + } + } + }); - main.on('readAllNotifications', () => { - updateCurrentAccountPartial({ - hasUnreadNotification: false, - unreadNotificationsCount: 0, + stream.on('emojiAdded', emojiData => { + addCustomEmoji(emojiData.emoji); }); - }); - main.on('unreadNotification', () => { - const unreadNotificationsCount = ($i?.unreadNotificationsCount ?? 0) + 1; - updateCurrentAccountPartial({ - hasUnreadNotification: true, - unreadNotificationsCount, + stream.on('emojiUpdated', emojiData => { + updateCustomEmojis(emojiData.emojis); }); - }); - main.on('unreadAntenna', () => { - updateCurrentAccountPartial({ hasUnreadAntenna: true }); - sound.playMisskeySfx('antenna'); - }); + stream.on('emojiDeleted', emojiData => { + removeCustomEmojis(emojiData.emojis); + }); - main.on('newChatMessage', () => { - updateCurrentAccountPartial({ hasUnreadChatMessages: true }); - sound.playMisskeySfx('chatMessage'); - }); + stream.on('announcementCreated', onAnnouncementCreated); - main.on('readAllAnnouncements', () => { - updateCurrentAccountPartial({ hasUnreadAnnouncement: false }); - }); + const main = markRaw(stream.useChannel('main', null, 'System')); + + // 自分の情報が更新されたとき + main.on('meUpdated', i => { + updateCurrentAccountPartial(i); + }); - // 個人宛てお知らせが発行されたとき - main.on('announcementCreated', onAnnouncementCreated); + main.on('readAllNotifications', () => { + updateCurrentAccountPartial({ + hasUnreadNotification: false, + unreadNotificationsCount: 0, + }); + }); - // トークンが再生成されたとき - // このままではMisskeyが利用できないので強制的にサインアウトさせる - main.on('myTokenRegenerated', () => { - signout(); - }); + main.on('unreadNotification', () => { + const unreadNotificationsCount = ($i?.unreadNotificationsCount ?? 0) + 1; + updateCurrentAccountPartial({ + hasUnreadNotification: true, + unreadNotificationsCount, + }); + }); + + main.on('unreadAntenna', () => { + updateCurrentAccountPartial({ hasUnreadAntenna: true }); + sound.playMisskeySfx('antenna'); + }); + + main.on('newChatMessage', () => { + updateCurrentAccountPartial({ hasUnreadChatMessages: true }); + sound.playMisskeySfx('chatMessage'); + }); + + main.on('readAllAnnouncements', () => { + updateCurrentAccountPartial({ hasUnreadAnnouncement: false }); + }); + + // 個人宛てお知らせが発行されたとき + main.on('announcementCreated', onAnnouncementCreated); + } } // shortcut diff --git a/packages/frontend/src/components/MkChannelList.vue b/packages/frontend/src/components/MkChannelList.vue index d0b50f04f2..0968452ca7 100644 --- a/packages/frontend/src/components/MkChannelList.vue +++ b/packages/frontend/src/components/MkChannelList.vue @@ -14,13 +14,13 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import type { Paging } from '@/components/MkPagination.vue'; +import type { PagingCtx } from '@/composables/use-pagination.js'; import MkChannelPreview from '@/components/MkChannelPreview.vue'; import MkPagination from '@/components/MkPagination.vue'; import { i18n } from '@/i18n.js'; const props = withDefaults(defineProps<{ - pagination: Paging; + pagination: PagingCtx; noGap?: boolean; extractor?: (item: any) => any; }>(), { diff --git a/packages/frontend/src/components/MkChart.vue b/packages/frontend/src/components/MkChart.vue index 7e164362c1..4d67bba70d 100644 --- a/packages/frontend/src/components/MkChart.vue +++ b/packages/frontend/src/components/MkChart.vue @@ -51,7 +51,7 @@ import { Chart } from 'chart.js'; import * as Misskey from 'misskey-js'; import { misskeyApiGet } from '@/utility/misskey-api.js'; import { store } from '@/store.js'; -import { useChartTooltip } from '@/use/use-chart-tooltip.js'; +import { useChartTooltip } from '@/composables/use-chart-tooltip.js'; import { chartVLine } from '@/utility/chart-vline.js'; import { alpha } from '@/utility/color.js'; import date from '@/filters/date.js'; diff --git a/packages/frontend/src/components/MkContainer.vue b/packages/frontend/src/components/MkContainer.vue index 1993991106..d4f338e4c8 100644 --- a/packages/frontend/src/components/MkContainer.vue +++ b/packages/frontend/src/components/MkContainer.vue @@ -48,7 +48,6 @@ const props = withDefaults(defineProps<{ thin?: boolean; naked?: boolean; foldable?: boolean; - onUnfold?: () => boolean; // return false to prevent unfolding scrollable?: boolean; expanded?: boolean; maxHeight?: number | null; @@ -103,8 +102,6 @@ const omitObserver = new ResizeObserver((entries, observer) => { }); function showMore() { - if (props.onUnfold && !props.onUnfold()) return; - ignoreOmit.value = true; omitted.value = false; } @@ -154,6 +151,10 @@ onUnmounted(() => { &.naked { background: transparent !important; box-shadow: none !important; + + > .content { + background: transparent !important; + } } &.scrollable { diff --git a/packages/frontend/src/components/MkCropperDialog.vue b/packages/frontend/src/components/MkCropperDialog.vue index ba21394cbc..7f592fba79 100644 --- a/packages/frontend/src/components/MkCropperDialog.vue +++ b/packages/frontend/src/components/MkCropperDialog.vue @@ -15,18 +15,16 @@ SPDX-License-Identifier: AGPL-3.0-only @closed="emit('closed')" > <template #header>{{ i18n.ts.cropImage }}</template> - <template #default="{ width, height }"> - <div class="mk-cropper-dialog" :style="`--vw: ${width}px; --vh: ${height}px;`"> - <Transition name="fade"> - <div v-if="loading" class="loading"> - <MkLoading/> - </div> - </Transition> - <div class="container"> - <img ref="imgEl" :src="imgUrl" style="display: none;" @load="onImageLoad"> + <div class="mk-cropper-dialog" :style="`--vw: 100%; --vh: 100%;`"> + <Transition name="fade"> + <div v-if="loading" class="loading"> + <MkLoading/> </div> + </Transition> + <div class="container"> + <img ref="imgEl" :src="imgUrl" style="display: none;" @load="onImageLoad"> </div> - </template> + </div> </MkModalWindow> </template> @@ -35,27 +33,23 @@ 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 '@/i.js'; import { i18n } from '@/i18n.js'; -import { getProxiedImageUrl } from '@/utility/media-proxy.js'; -import { prefer } from '@/preferences.js'; + +const props = defineProps<{ + imageFile: File | Blob; + aspectRatio: number | null; + uploadFolder?: string | null; +}>(); const emit = defineEmits<{ - (ev: 'ok', cropped: Misskey.entities.DriveFile): void; + (ev: 'ok', cropped: File | Blob): void; (ev: 'cancel'): void; (ev: 'closed'): void; }>(); -const props = defineProps<{ - file: Misskey.entities.DriveFile; - aspectRatio: number; - uploadFolder?: string | null; -}>(); - -const imgUrl = getProxiedImageUrl(props.file.url, undefined, true); +const imgUrl = URL.createObjectURL(props.imageFile); const dialogEl = useTemplateRef('dialogEl'); const imgEl = useTemplateRef('imgEl'); let cropper: Cropper | null = null; @@ -73,31 +67,10 @@ const ok = async () => { const croppedCanvas = await croppedSection?.$toCanvas({ width: widthToRender }); croppedCanvas?.toBlob(blob => { if (!blob) return; - const formData = new FormData(); - formData.append('file', blob); - formData.append('name', `cropped_${props.file.name}`); - formData.append('isSensitive', props.file.isSensitive ? 'true' : 'false'); - 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 && prefer.s.uploadFolder) { - formData.append('folderId', prefer.s.uploadFolder); - } - - window.fetch(apiUrl + '/drive/files/create', { - method: 'POST', - body: formData, - }) - .then(response => response.json()) - .then(f => { - res(f); - }); + res(blob); }); }); - os.promiseDialog(promise); - const f = await promise; emit('ok', f); @@ -126,8 +99,8 @@ onMounted(() => { const selection = cropper.getCropperSelection()!; selection.themeColor = tinycolor(computedStyle.getPropertyValue('--MI_THEME-accent')).toHexString(); - selection.aspectRatio = props.aspectRatio; - selection.initialAspectRatio = props.aspectRatio; + if (props.aspectRatio != null) selection.aspectRatio = props.aspectRatio; + selection.initialAspectRatio = props.aspectRatio ?? 1; selection.outlined = true; window.setTimeout(() => { diff --git a/packages/frontend/src/components/MkDateSeparatedList.vue b/packages/frontend/src/components/MkDateSeparatedList.vue index 1cf6f0b744..82561055bc 100644 --- a/packages/frontend/src/components/MkDateSeparatedList.vue +++ b/packages/frontend/src/components/MkDateSeparatedList.vue @@ -7,8 +7,6 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts"> 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 * as os from '@/os.js'; @@ -19,7 +17,7 @@ import { getDateText } from '@/utility/timeline-date-separate.js'; export default defineComponent({ props: { items: { - type: Array as PropType<MisskeyEntity[]>, + type: Array, required: true, }, direction: { diff --git a/packages/frontend/src/components/MkDrive.file.vue b/packages/frontend/src/components/MkDrive.file.vue index 70ab60cfae..0eca85b3a6 100644 --- a/packages/frontend/src/components/MkDrive.file.vue +++ b/packages/frontend/src/components/MkDrive.file.vue @@ -8,7 +8,6 @@ SPDX-License-Identifier: AGPL-3.0-only :class="[$style.root, { [$style.isSelected]: isSelected }]" draggable="true" :title="title" - @click="onClick" @contextmenu.stop="onContextmenu" @dragstart="onDragstart" @dragend="onDragend" @@ -46,24 +45,18 @@ import * as os from '@/os.js'; import { i18n } from '@/i18n.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(); +import { setDragData } from '@/drag-and-drop.js'; const props = withDefaults(defineProps<{ file: Misskey.entities.DriveFile; folder: Misskey.entities.DriveFolder | null; isSelected?: boolean; - selectMode?: boolean; }>(), { isSelected: false, - selectMode: false, }); const emit = defineEmits<{ - (ev: 'chosen', r: Misskey.entities.DriveFile): void; - (ev: 'dragstart'): void; + (ev: 'dragstart', dragEvent: DragEvent): void; (ev: 'dragend'): void; }>(); @@ -71,18 +64,6 @@ const isDragging = ref(false); const title = computed(() => `${props.file.name}\n${props.file.type} ${bytes(props.file.size)}`); -function onClick(ev: MouseEvent) { - if (props.selectMode) { - emit('chosen', props.file); - } else { - if (deviceKind === 'desktop') { - router.push(`/my/drive/file/${props.file.id}`); - } else { - os.popupMenu(getDriveFileMenu(props.file, props.folder), (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined); - } - } -} - function onContextmenu(ev: MouseEvent) { os.contextMenu(getDriveFileMenu(props.file, props.folder), ev); } @@ -90,11 +71,11 @@ function onContextmenu(ev: MouseEvent) { function onDragstart(ev: DragEvent) { if (ev.dataTransfer) { ev.dataTransfer.effectAllowed = 'move'; - ev.dataTransfer.setData(_DATA_TRANSFER_DRIVE_FILE_, JSON.stringify(props.file)); + setDragData(ev, 'driveFiles', [props.file]); } isDragging.value = true; - emit('dragstart'); + emit('dragstart', ev); } function onDragend() { @@ -114,7 +95,7 @@ function onDragend() { &:hover { background: rgba(#000, 0.05); - > .label { + .label { &::before, &::after { background: #0b65a5; @@ -132,7 +113,7 @@ function onDragend() { &:active { background: rgba(#000, 0.1); - > .label { + .label { &::before, &::after { background: #0b588c; @@ -158,19 +139,19 @@ function onDragend() { background: hsl(from var(--MI_THEME-accent) h s calc(l - 10)); } - > .label { + .label { &::before, &::after { display: none; } } - > .name { - color: #fff; + .name { + color: var(--MI_THEME-fgOnAccent); } - > .thumbnail { - color: #fff; + .thumbnail { + color: var(--MI_THEME-fgOnAccent); } } } @@ -240,8 +221,9 @@ function onDragend() { .name { display: block; - margin: 4px 0 0 0; - font-size: 0.8em; + margin: 8px 0 0 0; + padding: 0 2px; + font-size: 82%; text-align: center; word-break: break-all; color: var(--MI_THEME-fg); diff --git a/packages/frontend/src/components/MkDrive.folder.vue b/packages/frontend/src/components/MkDrive.folder.vue index 9c72691d21..8ba7520f35 100644 --- a/packages/frontend/src/components/MkDrive.folder.vue +++ b/packages/frontend/src/components/MkDrive.folder.vue @@ -8,7 +8,6 @@ SPDX-License-Identifier: AGPL-3.0-only :class="[$style.root, { [$style.draghover]: draghover }]" draggable="true" :title="title" - @click="onClick" @contextmenu.stop="onContextmenu" @mouseover="onMouseover" @mouseout="onMouseout" @@ -19,16 +18,15 @@ SPDX-License-Identifier: AGPL-3.0-only @dragstart="onDragstart" @dragend="onDragend" > - <p :class="$style.name"> - <template v-if="hover"><i :class="$style.icon" class="ti ti-folder ti-fw"></i></template> - <template v-if="!hover"><i :class="$style.icon" class="ti ti-folder ti-fw"></i></template> - {{ folder.name }} - </p> - <p v-if="prefer.s.uploadFolder == folder.id" :class="$style.upload"> + <svg :class="[$style.shape]" viewBox="0 0 200 150" preserveAspectRatio="none"> + <path d="M190,25C195.523,25 200,29.477 200,35C200,58.415 200,116.585 200,140C200,145.523 195.523,150 190,150C155.86,150 44.14,150 10,150C4.477,150 0,145.523 0,140C0,112.727 0,37.273 0,10C0,4.477 4.477,0 10,-0C26.642,0 59.332,0 70.858,0C73.51,-0 76.054,1.054 77.929,2.929C82.74,7.74 92.26,17.26 97.071,22.071C98.946,23.946 101.49,25 104.142,25C118.808,25 168.535,25 190,25Z" style="fill:var(--MI_THEME-accentedBg);"/> + </svg> + <div :class="$style.name">{{ folder.name }}</div> + <div v-if="prefer.s.uploadFolder == folder.id" :class="$style.upload"> {{ i18n.ts.uploadFolder }} - </p> + </div> <button v-if="selectMode" class="_button" :class="$style.checkboxWrapper" @click.prevent.stop="checkboxClicked"> - <div :class="[$style.checkbox, { [$style.checked]: isSelected }]"></div> + <div :class="[$style.checkbox, { [$style.checked]: isSelected, 'ti ti-check': isSelected }]"></div> </button> </div> </template> @@ -43,6 +41,9 @@ import { i18n } from '@/i18n.js'; import { claimAchievement } from '@/utility/achievements.js'; import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; import { prefer } from '@/preferences.js'; +import { globalEvents } from '@/events.js'; +import { checkDragDataType, getDragData, setDragData } from '@/drag-and-drop.js'; +import { selectDriveFolder } from '@/utility/drive.js'; const props = withDefaults(defineProps<{ folder: Misskey.entities.DriveFolder; @@ -56,10 +57,7 @@ const props = withDefaults(defineProps<{ const emit = defineEmits<{ (ev: 'chosen', v: Misskey.entities.DriveFolder): void; (ev: 'unchose', v: Misskey.entities.DriveFolder): void; - (ev: 'move', v: Misskey.entities.DriveFolder): void; - (ev: 'upload', file: File, folder: Misskey.entities.DriveFolder); - (ev: 'removeFile', v: Misskey.entities.DriveFile['id']): void; - (ev: 'removeFolder', v: Misskey.entities.DriveFolder['id']): void; + (ev: 'upload', files: File[], folder: Misskey.entities.DriveFolder); (ev: 'dragstart'): void; (ev: 'dragend'): void; }>(); @@ -78,10 +76,6 @@ function checkboxClicked() { } } -function onClick() { - emit('move', props.folder); -} - function onMouseover() { hover.value = true; } @@ -101,10 +95,7 @@ function onDragover(ev: DragEvent) { } const isFile = ev.dataTransfer.items[0].kind === 'file'; - const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_; - const isDriveFolder = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FOLDER_; - - if (isFile || isDriveFile || isDriveFolder) { + if (isFile || checkDragDataType(ev, ['driveFiles', 'driveFolders'])) { switch (ev.dataTransfer.effectAllowed) { case 'all': case 'uninitialized': @@ -141,55 +132,64 @@ function onDrop(ev: DragEvent) { // ファイルだったら if (ev.dataTransfer.files.length > 0) { - for (const file of Array.from(ev.dataTransfer.files)) { - emit('upload', file, props.folder); - } + emit('upload', Array.from(ev.dataTransfer.files), props.folder); return; } //#region ドライブのファイル - const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); - if (driveFile != null && driveFile !== '') { - const file = JSON.parse(driveFile); - emit('removeFile', file.id); - misskeyApi('drive/files/update', { - fileId: file.id, - folderId: props.folder.id, - }); + { + const droppedData = getDragData(ev, 'driveFiles'); + if (droppedData != null) { + misskeyApi('drive/files/move-bulk', { + fileIds: droppedData.map(f => f.id), + folderId: props.folder.id, + }).then(() => { + globalEvents.emit('driveFilesUpdated', droppedData.map(x => ({ + ...x, + folderId: props.folder.id, + folder: props.folder, + }))); + }); + } } //#endregion //#region ドライブのフォルダ - const driveFolder = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FOLDER_); - if (driveFolder != null && driveFolder !== '') { - const folder = JSON.parse(driveFolder); + { + const droppedData = getDragData(ev, 'driveFolders'); + if (droppedData != null) { + const droppedFolder = droppedData[0]; - // 移動先が自分自身ならreject - if (folder.id === props.folder.id) return; + // 移動先が自分自身ならreject + if (droppedFolder.id === props.folder.id) return; - emit('removeFolder', folder.id); - misskeyApi('drive/folders/update', { - folderId: folder.id, - parentId: props.folder.id, - }).then(() => { - // noop - }).catch(err => { - switch (err.code) { - case 'RECURSIVE_NESTING': - claimAchievement('driveFolderCircularReference'); - os.alert({ - type: 'error', - title: i18n.ts.unableToProcess, - text: i18n.ts.circularReferenceFolder, - }); - break; - default: - os.alert({ - type: 'error', - text: i18n.ts.somethingHappened, - }); - } - }); + misskeyApi('drive/folders/update', { + folderId: droppedFolder.id, + parentId: props.folder.id, + }).then(() => { + globalEvents.emit('driveFoldersUpdated', [droppedFolder].map(x => ({ + ...x, + parentId: props.folder.id, + parent: props.folder, + }))); + }).catch(err => { + switch (err.code) { + case 'RECURSIVE_NESTING': + claimAchievement('driveFolderCircularReference'); + os.alert({ + type: 'error', + title: i18n.ts.unableToProcess, + text: i18n.ts.circularReferenceFolder, + }); + break; + default: + os.alert({ + type: 'error', + text: i18n.ts.somethingHappened, + }); + } + }); + } } //#endregion } @@ -198,7 +198,7 @@ function onDragstart(ev: DragEvent) { if (!ev.dataTransfer) return; ev.dataTransfer.effectAllowed = 'move'; - ev.dataTransfer.setData(_DATA_TRANSFER_DRIVE_FOLDER_, JSON.stringify(props.folder)); + setDragData(ev, 'driveFolders', [props.folder]); isDragging.value = true; // 親ブラウザに対して、ドラッグが開始されたフラグを立てる @@ -211,10 +211,6 @@ function onDragend() { emit('dragend'); } -function go() { - emit('move', props.folder); -} - function rename() { os.inputText({ title: i18n.ts.renameFolder, @@ -225,17 +221,28 @@ function rename() { misskeyApi('drive/folders/update', { folderId: props.folder.id, name: name, + }).then(() => { + globalEvents.emit('driveFoldersUpdated', [{ + ...props.folder, + name: name, + }]); }); }); } function move() { - os.selectDriveFolder(false).then(folder => { + selectDriveFolder(null).then(folder => { if (folder[0] && folder[0].id === props.folder.id) return; misskeyApi('drive/folders/update', { folderId: props.folder.id, parentId: folder[0] ? folder[0].id : null, + }).then(() => { + globalEvents.emit('driveFoldersUpdated', [{ + ...props.folder, + parentId: folder[0] ? folder[0].id : null, + parent: folder[0] ?? null, + }]); }); }); } @@ -247,6 +254,7 @@ function deleteFolder() { if (prefer.s.uploadFolder === props.folder.id) { prefer.commit('uploadFolder', null); } + globalEvents.emit('driveFoldersDeleted', [props.folder]); }).catch(err => { switch (err.id) { case 'b0fc8a17-963c-405d-bfbc-859a487295e1': @@ -311,10 +319,9 @@ function onContextmenu(ev: MouseEvent) { <style lang="scss" module> .root { position: relative; - padding: 8px; - height: 64px; - background: var(--MI_THEME-driveFolderBg); - border-radius: 4px; + height: 90px; + padding: 24px 16px; + box-sizing: border-box; cursor: pointer; &.draghover { @@ -332,6 +339,14 @@ function onContextmenu(ev: MouseEvent) { } } +.shape { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; +} + .checkboxWrapper { position: absolute; border-radius: 50%; @@ -353,16 +368,14 @@ function onContextmenu(ev: MouseEvent) { border-color: var(--MI_THEME-accent); background: var(--MI_THEME-accent); - &::after { - content: "\ea5e"; - font-family: 'tabler-icons'; + &::before { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: #fff; font-size: 12px; - line-height: 22px; + line-height: 18px; } } } @@ -373,7 +386,6 @@ function onContextmenu(ev: MouseEvent) { } .name { - margin: 0; font-size: 0.9em; } @@ -384,7 +396,6 @@ function onContextmenu(ev: MouseEvent) { } .upload { - margin: 4px 4px; font-size: 0.8em; text-align: right; } diff --git a/packages/frontend/src/components/MkDrive.navFolder.vue b/packages/frontend/src/components/MkDrive.navFolder.vue index 7433aea061..224aa2dca7 100644 --- a/packages/frontend/src/components/MkDrive.navFolder.vue +++ b/packages/frontend/src/components/MkDrive.navFolder.vue @@ -6,7 +6,6 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div :class="[$style.root, { [$style.draghover]: draghover }]" - @click="onClick" @dragover.prevent.stop="onDragover" @dragenter="onDragenter" @dragleave="onDragleave" @@ -22,6 +21,8 @@ import { ref } from 'vue'; import * as Misskey from 'misskey-js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; +import { globalEvents } from '@/events.js'; +import { checkDragDataType, getDragData } from '@/drag-and-drop.js'; const props = defineProps<{ folder?: Misskey.entities.DriveFolder; @@ -29,27 +30,11 @@ const props = defineProps<{ }>(); const emit = defineEmits<{ - (ev: 'move', v?: Misskey.entities.DriveFolder): void; - (ev: 'upload', file: File, folder?: Misskey.entities.DriveFolder | null): void; - (ev: 'removeFile', v: Misskey.entities.DriveFile['id']): void; - (ev: 'removeFolder', v: Misskey.entities.DriveFolder['id']): void; + (ev: 'upload', files: File[], folder?: Misskey.entities.DriveFolder | null): void; }>(); -const hover = ref(false); const draghover = ref(false); -function onClick() { - emit('move', props.folder); -} - -function onMouseover() { - hover.value = true; -} - -function onMouseout() { - hover.value = false; -} - function onDragover(ev: DragEvent) { if (!ev.dataTransfer) return; @@ -59,10 +44,7 @@ function onDragover(ev: DragEvent) { } const isFile = ev.dataTransfer.items[0].kind === 'file'; - const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_; - const isDriveFolder = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FOLDER_; - - if (isFile || isDriveFile || isDriveFolder) { + if (isFile || checkDragDataType(ev, ['driveFiles', 'driveFolders'])) { switch (ev.dataTransfer.effectAllowed) { case 'all': case 'uninitialized': @@ -101,35 +83,46 @@ function onDrop(ev: DragEvent) { // ファイルだったら if (ev.dataTransfer.files.length > 0) { - for (const file of Array.from(ev.dataTransfer.files)) { - emit('upload', file, props.folder); - } + emit('upload', Array.from(ev.dataTransfer.files), props.folder); return; } //#region ドライブのファイル - const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); - if (driveFile != null && driveFile !== '') { - const file = JSON.parse(driveFile); - emit('removeFile', file.id); - misskeyApi('drive/files/update', { - fileId: file.id, - folderId: props.folder ? props.folder.id : null, - }); + { + const droppedData = getDragData(ev, 'driveFiles'); + if (droppedData != null) { + misskeyApi('drive/files/move-bulk', { + fileIds: droppedData.map(f => f.id), + folderId: props.folder ? props.folder.id : null, + }).then(() => { + globalEvents.emit('driveFilesUpdated', droppedData.map(x => ({ + ...x, + folderId: props.folder ? props.folder.id : null, + folder: props.folder ?? null, + }))); + }); + } } //#endregion //#region ドライブのフォルダ - const driveFolder = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FOLDER_); - if (driveFolder != null && driveFolder !== '') { - const folder = JSON.parse(driveFolder); - // 移動先が自分自身ならreject - if (props.folder && folder.id === props.folder.id) return; - emit('removeFolder', folder.id); - misskeyApi('drive/folders/update', { - folderId: folder.id, - parentId: props.folder ? props.folder.id : null, - }); + { + const droppedData = getDragData(ev, 'driveFolders'); + if (droppedData != null) { + const droppedFolder = droppedData[0]; + // 移動先が自分自身ならreject + if (props.folder && droppedFolder.id === props.folder.id) return; + misskeyApi('drive/folders/update', { + folderId: droppedFolder.id, + parentId: props.folder ? props.folder.id : null, + }).then(() => { + globalEvents.emit('driveFoldersUpdated', [droppedFolder].map(x => ({ + ...x, + parentId: props.folder ? props.folder.id : null, + parent: props.folder ?? null, + }))); + }); + } } //#endregion } diff --git a/packages/frontend/src/components/MkDrive.vue b/packages/frontend/src/components/MkDrive.vue index a1f76ac563..7e955f1529 100644 --- a/packages/frontend/src/components/MkDrive.vue +++ b/packages/frontend/src/components/MkDrive.vue @@ -4,17 +4,15 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> +<MkStickyContainer style="background: var(--MI_THEME-bg);"> <template #header> <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" + @click="cd(null)" + @upload="onUploadRequested" /> <template v-for="f in hierarchyFolders"> <span :class="[$style.navPathItem, $style.navSeparator]"><i class="ti ti-chevron-right"></i></span> @@ -22,10 +20,8 @@ SPDX-License-Identifier: AGPL-3.0-only :folder="f" :parentFolder="folder" :class="[$style.navPathItem]" - @move="move" - @upload="upload" - @removeFile="removeFile" - @removeFolder="removeFolder" + @click="cd(f)" + @upload="onUploadRequested" /> </template> <span v-if="folder != null" :class="[$style.navPathItem, $style.navSeparator]"><i class="ti ti-chevron-right"></i></span> @@ -35,20 +31,40 @@ SPDX-License-Identifier: AGPL-3.0-only </nav> </template> - <div - ref="main" - :class="[$style.main, { [$style.uploading]: uploadings.length > 0, [$style.fetching]: fetching }]" - @dragover.prevent.stop="onDragover" - @dragenter="onDragenter" - @dragleave="onDragleave" - @drop.prevent.stop="onDrop" - @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"> + <div> + <div v-if="select === 'folder'"> + <template v-if="folder == null"> + <MkButton v-if="!isRootSelected" @click="isRootSelected = true"> + <i class="ti ti-square"></i> {{ i18n.ts.selectThisFolder }} + </MkButton> + <MkButton v-else @click="isRootSelected = false"> + <i class="ti ti-checkbox"></i> {{ i18n.ts.unselectThisFolder }} + </MkButton> + </template> + <template v-else> + <MkButton v-if="!selectedFolders.some(f => f.id === folder!.id)" @click="selectedFolders.push(folder)"> + <i class="ti ti-square"></i> {{ i18n.ts.selectThisFolder }} + </MkButton> + <MkButton v-else @click="selectedFolders = selectedFolders.filter(f => f.id !== folder!.id)"> + <i class="ti ti-checkbox"></i> {{ i18n.ts.unselectThisFolder }} + </MkButton> + </template> + </div> + + <div + ref="main" + :class="[$style.main, { [$style.fetching]: fetching }]" + @dragover.prevent.stop="onDragover" + @dragenter="onDragenter" + @dragleave="onDragleave" + @drop.prevent.stop="onDrop" + @contextmenu.stop="onContextmenu" + > + <MkTip k="drive"><div v-html="i18n.ts.driveAboutTip"></div></MkTip> + + <div :class="$style.folders"> <XFolder - v-for="(f, i) in folders" + v-for="(f, i) in foldersPaginator.items.value" :key="f.id" v-anim="i" :class="$style.folder" @@ -57,52 +73,66 @@ SPDX-License-Identifier: AGPL-3.0-only :isSelected="selectedFolders.some(x => x.id === f.id)" @chosen="chooseFolder" @unchose="unchoseFolder" - @move="move" - @upload="upload" - @removeFile="removeFile" - @removeFolder="removeFolder" - @dragstart="isDragSource = true" - @dragend="isDragSource = false" - /> - <!-- SEE: https://stackoverflow.com/questions/18744164/flex-box-align-last-row-to-grid --> - <div v-for="(n, i) in 16" :key="i" :class="$style.padding"></div> - <MkButton v-if="moreFolders" ref="moreFolders" @click="fetchMoreFolders">{{ i18n.ts.loadMore }}</MkButton> - </div> - <div v-show="files.length > 0" ref="filesContainer" :class="$style.files"> - <XFile - v-for="(file, i) in files" - :key="file.id" - v-anim="i" - :class="$style.file" - :file="file" - :folder="folder" - :selectMode="select === 'file'" - :isSelected="selectedFiles.some(x => x.id === file.id)" - @chosen="chooseFile" + @click="cd(f)" + @upload="onUploadRequested" @dragstart="isDragSource = true" @dragend="isDragSource = false" /> - <!-- SEE: https://stackoverflow.com/questions/18744164/flex-box-align-last-row-to-grid --> - <div v-for="(n, i) in 16" :key="i" :class="$style.padding"></div> - <MkButton v-show="moreFiles" ref="loadMoreFiles" @click="fetchMoreFiles">{{ i18n.ts.loadMore }}</MkButton> </div> - <div v-if="files.length == 0 && folders.length == 0 && !fetching" :class="$style.empty"> + <MkButton v-if="foldersPaginator.canFetchOlder.value" primary rounded @click="foldersPaginator.fetchOlder()">{{ i18n.ts.loadMore }}</MkButton> + + <MkStickyContainer v-for="(item, i) in filesTimeline" :key="`${item.date.getFullYear()}/${item.date.getMonth() + 1}`"> + <template #header> + <div :class="$style.date"> + <span><i class="ti ti-chevron-down"></i> {{ item.date.getFullYear() }}/{{ item.date.getMonth() + 1 }}</span> + </div> + </template> + + <TransitionGroup + tag="div" + :enterActiveClass="prefer.s.animation ? $style.transition_files_enterActive : ''" + :leaveActiveClass="prefer.s.animation ? $style.transition_files_leaveActive : ''" + :enterFromClass="prefer.s.animation ? $style.transition_files_enterFrom : ''" + :leaveToClass="prefer.s.animation ? $style.transition_files_leaveTo : ''" + :moveClass="prefer.s.animation ? $style.transition_files_move : ''" + :class="$style.files" + > + <XFile + v-for="file in item.items" :key="file.id" + :class="$style.file" + :file="file" + :folder="folder" + :isSelected="selectedFiles.some(x => x.id === file.id)" + @click="onFileClick($event, file)" + @dragstart="onFileDragstart(file, $event)" + @dragend="isDragSource = false" + /> + </TransitionGroup> + </MkStickyContainer> + <MkButton v-show="filesPaginator.canFetchOlder.value" :class="$style.loadMore" primary rounded @click="filesPaginator.fetchOlder()">{{ i18n.ts.loadMore }}</MkButton> + + <div v-if="filesPaginator.items.value.length == 0 && foldersPaginator.items.value.length == 0 && !fetching" :class="$style.empty"> <div v-if="draghover">{{ i18n.ts['empty-draghover'] }}</div> <div v-if="!draghover && folder == null"><strong>{{ i18n.ts.emptyDrive }}</strong><br/>{{ i18n.ts['empty-drive-description'] }}</div> <div v-if="!draghover && folder != null">{{ i18n.ts.emptyFolder }}</div> </div> </div> <MkLoading v-if="fetching"/> + <div v-if="draghover" :class="$style.dropzone"></div> </div> - <div v-if="draghover" :class="$style.dropzone"></div> + + <template #footer> + <div v-if="isEditMode" :class="$style.footer"> + <MkButton primary rounded @click="moveFilesBulk()"><i class="ti ti-folder-symlink"></i> {{ i18n.ts.move }}...</MkButton> + </div> + </template> </MkStickyContainer> </template> <script lang="ts" setup> -import { nextTick, onActivated, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue'; +import { nextTick, onActivated, onBeforeUnmount, onMounted, ref, useTemplateRef, watch, computed, TransitionGroup } 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'; @@ -111,14 +141,18 @@ import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { useStream } from '@/stream.js'; import { i18n } from '@/i18n.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 { chooseFileFromPcAndUpload, selectDriveFolder } from '@/utility/drive.js'; import { store } from '@/store.js'; +import { isSeparatorNeeded, getSeparatorInfo, makeDateGroupedTimelineComputedRef } from '@/utility/timeline-date-separate.js'; +import { usePagination } from '@/composables/use-pagination.js'; +import { globalEvents, useGlobalEvent } from '@/events.js'; +import { checkDragDataType, getDragData, setDragData } from '@/drag-and-drop.js'; +import { getDriveFileMenu } from '@/utility/get-drive-file-menu.js'; const props = withDefaults(defineProps<{ - initialFolder?: Misskey.entities.DriveFolder; + initialFolder?: Misskey.entities.DriveFolder['id'] | null; type?: string; multiple?: boolean; select?: 'file' | 'folder' | null; @@ -128,25 +162,13 @@ const props = withDefaults(defineProps<{ }); const emit = defineEmits<{ - (ev: 'selected', v: Misskey.entities.DriveFile | Misskey.entities.DriveFolder): void; - (ev: 'change-selection', v: Misskey.entities.DriveFile[] | Misskey.entities.DriveFolder[]): void; - (ev: 'move-root'): void; + (ev: 'changeSelectedFiles', v: Misskey.entities.DriveFile[]): void; + (ev: 'changeSelectedFolders', v: (Misskey.entities.DriveFolder | null)[]): void; (ev: 'cd', v: Misskey.entities.DriveFolder | null): void; - (ev: 'open-folder', v: Misskey.entities.DriveFolder): void; }>(); -const loadMoreFiles = useTemplateRef('loadMoreFiles'); - const folder = ref<Misskey.entities.DriveFolder | null>(null); -const files = ref<Misskey.entities.DriveFile[]>([]); -const folders = ref<Misskey.entities.DriveFolder[]>([]); -const moreFiles = ref(false); -const moreFolders = ref(false); const hierarchyFolders = ref<Misskey.entities.DriveFolder[]>([]); -const selectedFiles = ref<Misskey.entities.DriveFile[]>([]); -const selectedFolders = ref<Misskey.entities.DriveFolder[]>([]); -const uploadings = uploads; -const connection = useStream().useChannel('drive'); // ドロップされようとしているか const draghover = ref(false); @@ -155,51 +177,87 @@ const draghover = ref(false); // (自分自身の階層にドロップできないようにするためのフラグ) const isDragSource = ref(false); -const fetching = ref(true); +const isEditMode = ref(false); + +const selectedFiles = ref<Misskey.entities.DriveFile[]>([]); +const selectedFolders = ref<Misskey.entities.DriveFolder[]>([]); +const isRootSelected = ref(false); + +watch(selectedFiles, () => { + emit('changeSelectedFiles', selectedFiles.value); +}); + +watch([selectedFolders, isRootSelected], () => { + emit('changeSelectedFolders', isRootSelected.value ? [null, ...selectedFolders.value] : selectedFolders.value); +}); -const ilFilesObserver = new IntersectionObserver( - (entries) => entries.some((entry) => entry.isIntersecting) && !fetching.value && moreFiles.value && fetchMoreFiles(), -); +const fetching = ref(true); const sortModeSelect = ref<NonNullable<Misskey.entities.DriveFilesRequest['sort']>>('+createdAt'); +const filesPaginator = usePagination({ + ctx: { + endpoint: 'drive/files', + limit: 30, + canFetchDetection: 'limit', + params: computed(() => ({ + folderId: folder.value ? folder.value.id : null, + type: props.type, + sort: sortModeSelect.value, + })), + }, + autoInit: false, + autoReInit: false, +}); + +const foldersPaginator = usePagination({ + ctx: { + endpoint: 'drive/folders', + limit: 30, + canFetchDetection: 'limit', + params: computed(() => ({ + folderId: folder.value ? folder.value.id : null, + })), + }, + autoInit: false, + autoReInit: false, +}); + +const filesTimeline = makeDateGroupedTimelineComputedRef(filesPaginator.items, 'month'); + watch(folder, () => emit('cd', folder.value)); watch(sortModeSelect, () => { - fetch(); + initialize(); }); -function onStreamDriveFileCreated(file: Misskey.entities.DriveFile) { - addFile(file, true); +async function initialize() { + fetching.value = true; + await Promise.all([ + foldersPaginator.init(), + filesPaginator.init(), + ]); + fetching.value = false; } -function onStreamDriveFileUpdated(file: Misskey.entities.DriveFile) { - const current = folder.value ? folder.value.id : null; - if (current !== file.folderId) { - removeFile(file); - } else { - addFile(file, true); +function onStreamDriveFileCreated(file: Misskey.entities.DriveFile) { + if (file.folderId === (folder.value?.id ?? null)) { + filesPaginator.prepend(file); } } -function onStreamDriveFileDeleted(fileId: string) { - removeFile(fileId); -} - -function onStreamDriveFolderCreated(createdFolder: Misskey.entities.DriveFolder) { - addFolder(createdFolder, true); -} +function onFileDragstart(file: Misskey.entities.DriveFile, ev: DragEvent) { + if (isEditMode.value) { + if (!selectedFiles.value.some(f => f.id === file.id)) { + selectedFiles.value.push(file); + } -function onStreamDriveFolderUpdated(updatedFolder: Misskey.entities.DriveFolder) { - const current = folder.value ? folder.value.id : null; - if (current !== updatedFolder.parentId) { - removeFolder(updatedFolder); - } else { - addFolder(updatedFolder, true); + if (ev.dataTransfer) { + ev.dataTransfer.effectAllowed = 'move'; + setDragData(ev, 'driveFiles', selectedFiles.value); + } } -} -function onStreamDriveFolderDeleted(folderId: string) { - removeFolder(folderId); + isDragSource.value = true; } function onDragover(ev: DragEvent) { @@ -213,9 +271,7 @@ function onDragover(ev: DragEvent) { } const isFile = ev.dataTransfer.items[0].kind === 'file'; - const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_; - const isDriveFolder = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FOLDER_; - if (isFile || isDriveFile || isDriveFolder) { + if (isFile || checkDragDataType(ev, ['driveFiles', 'driveFolders'])) { switch (ev.dataTransfer.effectAllowed) { case 'all': case 'uninitialized': @@ -254,109 +310,123 @@ function onDrop(ev: DragEvent) { // ドロップされてきたものがファイルだったら if (ev.dataTransfer.files.length > 0) { - for (const file of Array.from(ev.dataTransfer.files)) { - upload(file, folder.value); - } + os.launchUploader(Array.from(ev.dataTransfer.files), { + folderId: folder.value?.id ?? null, + }); return; } //#region ドライブのファイル - const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); - if (driveFile != null && driveFile !== '') { - const file = JSON.parse(driveFile); - if (files.value.some(f => f.id === file.id)) return; - removeFile(file.id); - misskeyApi('drive/files/update', { - fileId: file.id, - folderId: folder.value ? folder.value.id : null, - }); + { + const droppedData = getDragData(ev, 'driveFiles'); + if (droppedData != null) { + misskeyApi('drive/files/move-bulk', { + fileIds: droppedData.map(f => f.id), + folderId: folder.value ? folder.value.id : null, + }).then(() => { + globalEvents.emit('driveFilesUpdated', droppedData.map(x => ({ + ...x, + folderId: folder.value ? folder.value.id : null, + folder: folder.value, + }))); + }); + } } //#endregion //#region ドライブのフォルダ - const driveFolder = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FOLDER_); - if (driveFolder != null && driveFolder !== '') { - const droppedFolder = JSON.parse(driveFolder); - - // 移動先が自分自身ならreject - if (folder.value && droppedFolder.id === folder.value.id) return false; - if (folders.value.some(f => f.id === droppedFolder.id)) return false; - removeFolder(droppedFolder.id); - misskeyApi('drive/folders/update', { - folderId: droppedFolder.id, - parentId: folder.value ? folder.value.id : null, - }).then(() => { - // noop - }).catch(err => { - switch (err.code) { - case 'RECURSIVE_NESTING': - claimAchievement('driveFolderCircularReference'); - os.alert({ - type: 'error', - title: i18n.ts.unableToProcess, - text: i18n.ts.circularReferenceFolder, - }); - break; - default: - os.alert({ - type: 'error', - text: i18n.ts.somethingHappened, - }); - } - }); + { + const droppedData = getDragData(ev, 'driveFolders'); + if (droppedData != null) { + const droppedFolder = droppedData[0]; + // 移動先が自分自身ならreject + if (folder.value && droppedFolder.id === folder.value.id) return false; + if (foldersPaginator.items.value.some(f => f.id === droppedFolder.id)) return false; + misskeyApi('drive/folders/update', { + folderId: droppedFolder.id, + parentId: folder.value ? folder.value.id : null, + }).then(() => { + globalEvents.emit('driveFoldersUpdated', [droppedFolder].map(x => ({ + ...x, + parentId: folder.value ? folder.value.id : null, + parent: folder.value, + }))); + }).catch(err => { + switch (err.code) { + case 'RECURSIVE_NESTING': + claimAchievement('driveFolderCircularReference'); + os.alert({ + type: 'error', + title: i18n.ts.unableToProcess, + text: i18n.ts.circularReferenceFolder, + }); + break; + default: + os.alert({ + type: 'error', + text: i18n.ts.somethingHappened, + }); + } + }); + } } //#endregion } -function urlUpload() { - os.inputText({ +function onUploadRequested(files: File[], folder: Misskey.entities.DriveFolder | null) { + os.launchUploader(files, { + folderId: folder?.id ?? null, + }); +} + +async function urlUpload() { + const { canceled, result: url } = await os.inputText({ title: i18n.ts.uploadFromUrl, type: 'url', placeholder: i18n.ts.uploadFromUrlDescription, - }).then(({ canceled, result: url }) => { - if (canceled || !url) return; - misskeyApi('drive/files/upload-from-url', { - url: url, - folderId: folder.value ? folder.value.id : undefined, - }); + }); + if (canceled || !url) return; - os.alert({ - title: i18n.ts.uploadFromUrlRequested, - text: i18n.ts.uploadFromUrlMayTakeTime, - }); + await os.apiWithDialog('drive/files/upload-from-url', { + url: url, + folderId: folder.value ? folder.value.id : undefined, + }); + + os.alert({ + title: i18n.ts.uploadFromUrlRequested, + text: i18n.ts.uploadFromUrlMayTakeTime, }); } -function createFolder() { - os.inputText({ +async function createFolder() { + const { canceled, result: name } = await os.inputText({ title: i18n.ts.createFolder, placeholder: i18n.ts.folderName, - }).then(({ canceled, result: name }) => { - if (canceled || name == null) return; - misskeyApi('drive/folders/create', { - name: name, - parentId: folder.value ? folder.value.id : undefined, - }).then(createdFolder => { - addFolder(createdFolder, true); - }); }); + if (canceled || name == null) return; + + const createdFolder = await os.apiWithDialog('drive/folders/create', { + name: name, + parentId: folder.value ? folder.value.id : undefined, + }); + + foldersPaginator.prepend(createdFolder); } -function renameFolder(folderToRename: Misskey.entities.DriveFolder) { - os.inputText({ +async function renameFolder(folderToRename: Misskey.entities.DriveFolder) { + const { canceled, result: name } = await os.inputText({ title: i18n.ts.renameFolder, placeholder: i18n.ts.inputNewFolderName, default: folderToRename.name, - }).then(({ canceled, result: name }) => { - if (canceled) return; - misskeyApi('drive/folders/update', { - folderId: folderToRename.id, - name: name, - }).then(updatedFolder => { - // FIXME: 画面を更新するために自分自身に移動 - move(updatedFolder); - }); }); + if (canceled) return; + + const updatedFolder = await os.apiWithDialog('drive/folders/update', { + folderId: folderToRename.id, + name: name, + }); + + globalEvents.emit('driveFoldersUpdated', [updatedFolder]); } function deleteFolder(folderToDelete: Misskey.entities.DriveFolder) { @@ -364,7 +434,8 @@ function deleteFolder(folderToDelete: Misskey.entities.DriveFolder) { folderId: folderToDelete.id, }).then(() => { // 削除時に親フォルダに移動 - move(folderToDelete.parentId); + cd(folderToDelete.parentId); + globalEvents.emit('driveFoldersDeleted', [folderToDelete]); }).catch(err => { switch (err.id) { case 'b0fc8a17-963c-405d-bfbc-859a487295e1': @@ -383,28 +454,38 @@ function deleteFolder(folderToDelete: Misskey.entities.DriveFolder) { }); } -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); - }); -} +function onFileClick(ev: MouseEvent, file: Misskey.entities.DriveFile) { + if (ev.shiftKey) { + isEditMode.value = true; + } -function chooseFile(file: Misskey.entities.DriveFile) { - const isAlreadySelected = selectedFiles.value.some(f => f.id === file.id); - if (props.multiple) { - if (isAlreadySelected) { - selectedFiles.value = selectedFiles.value.filter(f => f.id !== file.id); - } else { - selectedFiles.value.push(file); + if (props.select === 'file' || isEditMode.value) { + const isAlreadySelected = selectedFiles.value.some(f => f.id === file.id); + + if (isEditMode.value) { + if (isAlreadySelected) { + selectedFiles.value = selectedFiles.value.filter(f => f.id !== file.id); + } else { + selectedFiles.value.push(file); + } + return; } - emit('change-selection', selectedFiles.value); - } else { - if (isAlreadySelected) { - emit('selected', file); + + if (props.multiple) { + if (isAlreadySelected) { + selectedFiles.value = selectedFiles.value.filter(f => f.id !== file.id); + } else { + selectedFiles.value.push(file); + } } else { - selectedFiles.value = [file]; - emit('change-selection', [file]); + if (isAlreadySelected) { + //emit('selected', file); + } else { + selectedFiles.value = [file]; + } } + } else { + os.popupMenu(getDriveFileMenu(file, folder.value), (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined); } } @@ -416,23 +497,20 @@ function chooseFolder(folderToChoose: Misskey.entities.DriveFolder) { } else { selectedFolders.value.push(folderToChoose); } - emit('change-selection', selectedFolders.value); } else { if (isAlreadySelected) { - emit('selected', folderToChoose); + //emit('selected', folderToChoose); } else { selectedFolders.value = [folderToChoose]; - emit('change-selection', [folderToChoose]); } } } function unchoseFolder(folderToUnchose: Misskey.entities.DriveFolder) { selectedFolders.value = selectedFolders.value.filter(f => f.id !== folderToUnchose.id); - emit('change-selection', selectedFolders.value); } -function move(target?: Misskey.entities.DriveFolder | Misskey.entities.DriveFolder['id' | 'parentId']) { +function cd(target?: Misskey.entities.DriveFolder | Misskey.entities.DriveFolder['id' | 'parentId']) { if (!target) { goRoot(); return; @@ -455,168 +533,34 @@ function move(target?: Misskey.entities.DriveFolder | Misskey.entities.DriveFold if (folderToMove.parent) dive(folderToMove.parent); - emit('open-folder', folderToMove); - fetch(); + initialize(); }); } -function addFolder(folderToAdd: Misskey.entities.DriveFolder, unshift = false) { - const current = folder.value ? folder.value.id : null; - if (current !== folderToAdd.parentId) return; - - if (folders.value.some(f => f.id === folderToAdd.id)) { - const exist = folders.value.map(f => f.id).indexOf(folderToAdd.id); - folders.value[exist] = folderToAdd; - return; - } - - if (unshift) { - folders.value.unshift(folderToAdd); - } else { - folders.value.push(folderToAdd); - } -} +async function moveFilesBulk() { + if (selectedFiles.value.length === 0) return; -function addFile(fileToAdd: Misskey.entities.DriveFile, unshift = false) { - const current = folder.value ? folder.value.id : null; - if (current !== fileToAdd.folderId) return; + const toFolder = await selectDriveFolder(folder.value ? folder.value.id : null); - if (files.value.some(f => f.id === fileToAdd.id)) { - const exist = files.value.map(f => f.id).indexOf(fileToAdd.id); - files.value[exist] = fileToAdd; - return; - } - - if (unshift) { - files.value.unshift(fileToAdd); - } else { - files.value.push(fileToAdd); - } -} - -function removeFolder(folderToRemove: Misskey.entities.DriveFolder | string) { - const folderIdToRemove = typeof folderToRemove === 'object' ? folderToRemove.id : folderToRemove; - folders.value = folders.value.filter(f => f.id !== folderIdToRemove); -} - -function removeFile(file: Misskey.entities.DriveFile | string) { - const fileId = typeof file === 'object' ? file.id : file; - files.value = files.value.filter(f => f.id !== fileId); -} - -function appendFile(file: Misskey.entities.DriveFile) { - addFile(file); -} - -function appendFolder(folderToAppend: Misskey.entities.DriveFolder) { - addFolder(folderToAppend); -} + await os.apiWithDialog('drive/files/move-bulk', { + fileIds: selectedFiles.value.map(f => f.id), + folderId: toFolder[0] ? toFolder[0].id : null, + }); -/* -function prependFile(file: Misskey.entities.DriveFile) { - addFile(file, true); + globalEvents.emit('driveFilesUpdated', selectedFiles.value.map(x => ({ + ...x, + folderId: toFolder[0] ? toFolder[0].id : null, + folder: toFolder[0] ?? null, + }))); } -function prependFolder(folderToPrepend: Misskey.entities.DriveFolder) { - addFolder(folderToPrepend, true); -} -*/ function goRoot() { // 既にrootにいるなら何もしない if (folder.value == null) return; folder.value = null; hierarchyFolders.value = []; - emit('move-root'); - fetch(); -} - -async function fetch() { - folders.value = []; - files.value = []; - moreFolders.value = false; - moreFiles.value = false; - fetching.value = true; - - const foldersMax = 30; - const filesMax = 30; - - const foldersPromise = misskeyApi('drive/folders', { - folderId: folder.value ? folder.value.id : null, - limit: foldersMax + 1, - }).then(fetchedFolders => { - if (fetchedFolders.length === foldersMax + 1) { - moreFolders.value = true; - fetchedFolders.pop(); - } - return fetchedFolders; - }); - - const filesPromise = misskeyApi('drive/files', { - folderId: folder.value ? folder.value.id : null, - type: props.type, - limit: filesMax + 1, - sort: sortModeSelect.value, - }).then(fetchedFiles => { - if (fetchedFiles.length === filesMax + 1) { - moreFiles.value = true; - fetchedFiles.pop(); - } - return fetchedFiles; - }); - - const [fetchedFolders, fetchedFiles] = await Promise.all([foldersPromise, filesPromise]); - - for (const x of fetchedFolders) appendFolder(x); - for (const x of fetchedFiles) appendFile(x); - - fetching.value = false; -} - -function fetchMoreFolders() { - fetching.value = true; - - const max = 30; - - misskeyApi('drive/folders', { - folderId: folder.value ? folder.value.id : null, - type: props.type, - untilId: folders.value.at(-1)?.id, - limit: max + 1, - }).then(folders => { - if (folders.length === max + 1) { - moreFolders.value = true; - folders.pop(); - } else { - moreFolders.value = false; - } - for (const x of folders) appendFolder(x); - fetching.value = false; - }); -} - -function fetchMoreFiles() { - fetching.value = true; - - const max = 30; - - // ファイル一覧取得 - misskeyApi('drive/files', { - folderId: folder.value ? folder.value.id : null, - type: props.type, - untilId: files.value.at(-1)?.id, - limit: max + 1, - sort: sortModeSelect.value, - }).then(files => { - if (files.length === max + 1) { - moreFiles.value = true; - files.pop(); - } else { - moreFiles.value = false; - } - for (const x of files) appendFile(x); - fetching.value = false; - }); + initialize(); } function getMenu() { @@ -626,16 +570,13 @@ function getMenu() { 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: () => { - chooseFileFromPc(true, { uploadFolder: folder.value?.id, keepOriginal: true }); + chooseFileFromPcAndUpload({ + multiple: true, + folderId: folder.value?.id, + }); }, }, { text: i18n.ts.fromUrl, @@ -699,6 +640,11 @@ function getMenu() { text: i18n.ts.createFolder, icon: 'ti ti-folder-plus', action: () => { createFolder(); }, + }, { type: 'divider' }, { + type: 'switch', + text: i18n.ts.edit, + icon: 'ti ti-pointer', + ref: isEditMode, }); return menu; @@ -712,46 +658,95 @@ function onContextmenu(ev: MouseEvent) { os.contextMenu(getMenu(), ev); } -function closeTip() { - store.set('readDriveTip', true); -} +useGlobalEvent('driveFileCreated', (file) => { + if (file.folderId === (folder.value?.id ?? null)) { + filesPaginator.prepend(file); + } +}); -onMounted(() => { - if (prefer.s.enableInfiniteScroll && loadMoreFiles.value) { - nextTick(() => { - ilFilesObserver.observe(loadMoreFiles.value?.$el); - }); +useGlobalEvent('driveFilesUpdated', (files) => { + for (const f of files) { + if (filesPaginator.items.value.some(x => x.id === f.id)) { + if (f.folderId === (folder.value?.id ?? null)) { + filesPaginator.updateItem(f.id, () => f); + } else { + filesPaginator.removeItem(f.id); + } + } else { + if (f.folderId === (folder.value?.id ?? null)) { + filesPaginator.prepend(f); + } + } + } +}); + +useGlobalEvent('driveFilesDeleted', (files) => { + for (const f of files) { + filesPaginator.removeItem(f.id); + } +}); + +useGlobalEvent('driveFoldersUpdated', (folders) => { + for (const f of folders) { + if (foldersPaginator.items.value.some(x => x.id === f.id)) { + if (f.parentId === (folder.value?.id ?? null)) { + foldersPaginator.updateItem(f.id, () => f); + } else { + foldersPaginator.removeItem(f.id); + } + } else { + if (f.parentId === (folder.value?.id ?? null)) { + foldersPaginator.prepend(f); + } + } } +}); - connection.on('fileCreated', onStreamDriveFileCreated); - connection.on('fileUpdated', onStreamDriveFileUpdated); - connection.on('fileDeleted', onStreamDriveFileDeleted); - connection.on('folderCreated', onStreamDriveFolderCreated); - connection.on('folderUpdated', onStreamDriveFolderUpdated); - connection.on('folderDeleted', onStreamDriveFolderDeleted); +useGlobalEvent('driveFoldersDeleted', (folders) => { + for (const f of folders) { + foldersPaginator.removeItem(f.id); + } +}); + +let connection: Misskey.ChannelConnection<Misskey.Channels['drive']> | null = null; + +onMounted(() => { + if (store.s.realtimeMode) { + connection = useStream().useChannel('drive'); + connection.on('fileCreated', onStreamDriveFileCreated); + } if (props.initialFolder) { - move(props.initialFolder); + cd(props.initialFolder); } else { - fetch(); + initialize(); } }); onActivated(() => { - if (prefer.s.enableInfiniteScroll) { - nextTick(() => { - ilFilesObserver.observe(loadMoreFiles.value?.$el); - }); - } }); onBeforeUnmount(() => { - connection.dispose(); - ilFilesObserver.disconnect(); + if (connection != null) { + connection.dispose(); + } }); </script> <style lang="scss" module> +.transition_files_move, +.transition_files_enterActive, +.transition_files_leaveActive { + transition: all 0.2s ease; +} +.transition_files_enterFrom, +.transition_files_leaveTo { + opacity: 0; +} +.transition_files_leaveActive { + position: absolute; +} + .nav { display: flex; width: 100%; @@ -806,9 +801,7 @@ onBeforeUnmount(() => { } .main { - flex: 1; - overflow: auto; - padding: var(--MI-margin); + min-height: 100cqh; user-select: none; &.fetching { @@ -816,30 +809,41 @@ onBeforeUnmount(() => { opacity: 0.5; pointer-events: none; } - - &.uploading { - height: calc(100% - 38px - 100px); - } } .folders, .files { - display: flex; - flex-wrap: wrap; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + grid-gap: 12px; + padding: 16px 32px; } -.folder, -.file { - flex-grow: 1; - width: 128px; - margin: 4px; - box-sizing: border-box; +@container (max-width: 600px) { + .folders, + .files { + padding: 16px; + } } -.padding { - flex-grow: 1; - pointer-events: none; - width: 128px + 8px; +.date { + padding: 8px 16px; + font-size: 90%; + -webkit-backdrop-filter: var(--MI-blur, blur(8px)); + backdrop-filter: var(--MI-blur, blur(8px)); + background-color: color(from var(--MI_THEME-bg) srgb r g b / 0.85); +} + +.loadMore { + margin: 16px auto; +} + +.footer { + padding: 8px 16px; + font-size: 90%; + -webkit-backdrop-filter: var(--MI-blur, blur(8px)); + backdrop-filter: var(--MI-blur, blur(8px)); + background-color: color(from var(--MI_THEME-bg) srgb r g b / 0.85); } .empty { diff --git a/packages/frontend/src/components/MkDriveSelectDialog.stories.impl.ts b/packages/frontend/src/components/MkDriveFileSelectDialog.stories.impl.ts index fe8f705165..a5073337cd 100644 --- a/packages/frontend/src/components/MkDriveSelectDialog.stories.impl.ts +++ b/packages/frontend/src/components/MkDriveFileSelectDialog.stories.impl.ts @@ -3,5 +3,5 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import MkDriveSelectDialog from './MkDriveSelectDialog.vue'; +import MkDriveSelectDialog from './MkDriveFileSelectDialog.vue'; void MkDriveSelectDialog; diff --git a/packages/frontend/src/components/MkDriveSelectDialog.vue b/packages/frontend/src/components/MkDriveFileSelectDialog.vue index 1b9455e3f3..50b68b3d0f 100644 --- a/packages/frontend/src/components/MkDriveSelectDialog.vue +++ b/packages/frontend/src/components/MkDriveFileSelectDialog.vue @@ -9,43 +9,41 @@ SPDX-License-Identifier: AGPL-3.0-only :width="800" :height="500" :withOkButton="true" - :okButtonDisabled="(type === 'file') && (selected.length === 0)" + :okButtonDisabled="selected.length === 0" @click="cancel()" @close="cancel()" @ok="ok()" @closed="emit('closed')" > <template #header> - {{ multiple ? ((type === 'file') ? i18n.ts.selectFiles : i18n.ts.selectFolders) : ((type === 'file') ? i18n.ts.selectFile : i18n.ts.selectFolder) }} - <span v-if="selected.length > 0" style="margin-left: 8px; opacity: 0.5;">({{ number(selected.length) }})</span> + {{ multiple ? i18n.ts.selectFiles : i18n.ts.selectFile }} + <span v-if="selected.length > 0" style="margin-left: 8px; opacity: 0.5;">({{ selected.length }})</span> </template> - <XDrive :multiple="multiple" :select="type" @changeSelection="onChangeSelection" @selected="ok()"/> + <MkDrive :multiple="multiple" select="file" :initialFolder="initialFolder" @changeSelectedFiles="onChangeSelection"/> </MkModalWindow> </template> <script lang="ts" setup> import { ref, useTemplateRef } from 'vue'; import * as Misskey from 'misskey-js'; -import XDrive from '@/components/MkDrive.vue'; +import MkDrive from '@/components/MkDrive.vue'; import MkModalWindow from '@/components/MkModalWindow.vue'; -import number from '@/filters/number.js'; import { i18n } from '@/i18n.js'; withDefaults(defineProps<{ - type?: 'file' | 'folder'; + initialFolder?: Misskey.entities.DriveFolder['id'] | null; multiple: boolean; }>(), { - type: 'file', }); const emit = defineEmits<{ - (ev: 'done', r?: Misskey.entities.DriveFile[] | Misskey.entities.DriveFolder[]): void; + (ev: 'done', r?: Misskey.entities.DriveFile[]): void; (ev: 'closed'): void; }>(); const dialog = useTemplateRef('dialog'); -const selected = ref<Misskey.entities.DriveFile[] | Misskey.entities.DriveFolder[]>([]); +const selected = ref<Misskey.entities.DriveFile[]>([]); function ok() { emit('done', selected.value); @@ -57,7 +55,7 @@ function cancel() { dialog.value?.close(); } -function onChangeSelection(v: Misskey.entities.DriveFile[] | Misskey.entities.DriveFolder[]) { +function onChangeSelection(v: Misskey.entities.DriveFile[]) { selected.value = v; } </script> diff --git a/packages/frontend/src/components/MkDriveFileThumbnail.vue b/packages/frontend/src/components/MkDriveFileThumbnail.vue index 6e0ae36880..88afdef114 100644 --- a/packages/frontend/src/components/MkDriveFileThumbnail.vue +++ b/packages/frontend/src/components/MkDriveFileThumbnail.vue @@ -11,15 +11,24 @@ SPDX-License-Identifier: AGPL-3.0-only [$style.large]: large, }]" > - <ImgWithBlurhash - v-if="isThumbnailAvailable" + <MkImgWithBlurhash + v-if="isThumbnailAvailable && prefer.s.enableHighQualityImagePlaceholders" :hash="file.blurhash" :src="file.thumbnailUrl" :alt="file.name" :title="file.name" + :class="$style.thumbnail" :cover="fit !== 'contain'" :forceBlurhash="forceBlurhash" /> + <img + v-else-if="isThumbnailAvailable" + :src="file.thumbnailUrl" + :alt="file.name" + :title="file.name" + :class="$style.thumbnail" + :style="{ objectFit: fit }" + /> <i v-else-if="is === 'image'" class="ti ti-photo" :class="$style.icon"></i> <i v-else-if="is === 'video'" class="ti ti-video" :class="$style.icon"></i> <i v-else-if="is === 'audio' || is === 'midi'" class="ti ti-file-music" :class="$style.icon"></i> @@ -36,7 +45,8 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed } from 'vue'; import * as Misskey from 'misskey-js'; -import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue'; +import MkImgWithBlurhash from '@/components/MkImgWithBlurhash.vue'; +import { prefer } from '@/preferences.js'; const props = defineProps<{ file: Misskey.entities.DriveFile; @@ -115,4 +125,8 @@ const isThumbnailAvailable = computed(() => { .large .icon { font-size: 40px; } + +.thumbnail { + width: 100%; +} </style> diff --git a/packages/frontend/src/components/MkDriveFolderSelectDialog.vue b/packages/frontend/src/components/MkDriveFolderSelectDialog.vue new file mode 100644 index 0000000000..2ebab1088f --- /dev/null +++ b/packages/frontend/src/components/MkDriveFolderSelectDialog.vue @@ -0,0 +1,63 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<MkModalWindow + ref="dialog" + :width="800" + :height="500" + :withOkButton="true" + :okButtonDisabled="selected.length === 0" + @click="cancel()" + @close="cancel()" + @ok="ok()" + @closed="emit('closed')" +> + <template #header> + {{ multiple ? i18n.ts.selectFolders : i18n.ts.selectFolder }} + <span v-if="multiple && selected.length > 0" style="margin-left: 8px; opacity: 0.5;">({{ selected.length }})</span> + </template> + <MkDrive :multiple="multiple" select="folder" :initialFolder="initialFolder" @changeSelectedFolders="onChangeSelection"/> +</MkModalWindow> +</template> + +<script lang="ts" setup> +import { ref, useTemplateRef } from 'vue'; +import * as Misskey from 'misskey-js'; +import MkDrive from '@/components/MkDrive.vue'; +import MkModalWindow from '@/components/MkModalWindow.vue'; +import { i18n } from '@/i18n.js'; + +withDefaults(defineProps<{ + initialFolder?: Misskey.entities.DriveFolder['id'] | null; + multiple?: boolean; +}>(), { + initialFolder: null, + multiple: false, +}); + +const emit = defineEmits<{ + (ev: 'done', r?: Misskey.entities.DriveFolder[]): void; + (ev: 'closed'): void; +}>(); + +const dialog = useTemplateRef('dialog'); + +const selected = ref<Misskey.entities.DriveFolder[]>([]); + +function ok() { + emit('done', selected.value); + dialog.value?.close(); +} + +function cancel() { + emit('done'); + dialog.value?.close(); +} + +function onChangeSelection(v: Misskey.entities.DriveFolder[]) { + selected.value = v; +} +</script> diff --git a/packages/frontend/src/components/MkDriveWindow.vue b/packages/frontend/src/components/MkDriveWindow.vue index c0142ec76e..0b8d0bfb8a 100644 --- a/packages/frontend/src/components/MkDriveWindow.vue +++ b/packages/frontend/src/components/MkDriveWindow.vue @@ -14,19 +14,19 @@ SPDX-License-Identifier: AGPL-3.0-only <template #header> {{ i18n.ts.drive }} </template> - <XDrive :initialFolder="initialFolder"/> + <MkDrive :initialFolder="initialFolder"/> </MkWindow> </template> <script lang="ts" setup> import { } from 'vue'; import * as Misskey from 'misskey-js'; -import XDrive from '@/components/MkDrive.vue'; +import MkDrive from '@/components/MkDrive.vue'; import MkWindow from '@/components/MkWindow.vue'; import { i18n } from '@/i18n.js'; defineProps<{ - initialFolder?: Misskey.entities.DriveFolder; + initialFolder?: Misskey.entities.DriveFolder | null; }>(); const emit = defineEmits<{ diff --git a/packages/frontend/src/components/MkEmojiPickerDialog.vue b/packages/frontend/src/components/MkEmojiPickerDialog.vue index 662e2a118d..1627dc8760 100644 --- a/packages/frontend/src/components/MkEmojiPickerDialog.vue +++ b/packages/frontend/src/components/MkEmojiPickerDialog.vue @@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only :hasInteractionWithOtherFocusTrappedEls="true" :transparentBg="true" :manualShowing="manualShowing" - :src="src" + :anchorElement="anchorElement" @click="modal?.close()" @esc="modal?.close()" @opening="opening" @@ -44,7 +44,7 @@ import { prefer } from '@/preferences.js'; const props = withDefaults(defineProps<{ manualShowing?: boolean | null; - src?: HTMLElement; + anchorElement?: HTMLElement; showPinned?: boolean; pinnedEmojis?: string[], asReactionPicker?: boolean; diff --git a/packages/frontend/src/components/MkFoldableSection.vue b/packages/frontend/src/components/MkFoldableSection.vue index b9888d9b64..0fa7bea7ab 100644 --- a/packages/frontend/src/components/MkFoldableSection.vue +++ b/packages/frontend/src/components/MkFoldableSection.vue @@ -31,9 +31,10 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, ref, useTemplateRef, watch } from 'vue'; +import { onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue'; import { miLocalStorage } from '@/local-storage.js'; import { prefer } from '@/preferences.js'; +import { globalEvents } from '@/events.js'; import { getBgColor } from '@/utility/get-bg-color.js'; const miLocalStoragePrefix = 'ui:folder:' as const; @@ -83,8 +84,19 @@ function afterLeave(el: Element) { el.style.height = ''; } +function updateBgColor() { + if (rootEl.value) { + parentBg.value = getBgColor(rootEl.value.parentElement); + } +} + onMounted(() => { - parentBg.value = getBgColor(rootEl.value?.parentElement); + updateBgColor(); + globalEvents.on('themeChanging', updateBgColor); +}); + +onBeforeUnmount(() => { + globalEvents.off('themeChanging', updateBgColor); }); </script> diff --git a/packages/frontend/src/components/MkFolder.vue b/packages/frontend/src/components/MkFolder.vue index e86861c874..9f5bc8da6c 100644 --- a/packages/frontend/src/components/MkFolder.vue +++ b/packages/frontend/src/components/MkFolder.vue @@ -19,13 +19,42 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <div :class="$style.headerRight"> <span :class="$style.headerRightText"><slot name="suffix"></slot></span> - <i v-if="opened" class="ti ti-chevron-up icon"></i> + <i v-if="asPage" class="ti ti-chevron-right icon"></i> + <i v-else-if="opened" class="ti ti-chevron-up icon"></i> <i v-else class="ti ti-chevron-down icon"></i> </div> </button> </template> - <div v-if="openedAtLeastOnce" :class="[$style.body, { [$style.bgSame]: bgSame }]" :style="{ maxHeight: maxHeight ? `${maxHeight}px` : undefined, overflow: maxHeight ? `auto` : undefined }" :aria-hidden="!opened"> + <div v-if="asPage"> + <Teleport v-if="opened" defer :to="`#v-${pageId}-header`"> + <slot name="label"></slot> + </Teleport> + <Teleport v-if="opened" defer :to="`#v-${pageId}-body`"> + <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> + </Teleport> + </div> + + <div v-else-if="openedAtLeastOnce" :class="[$style.body, { [$style.bgSame]: bgSame }]" :style="{ maxHeight: maxHeight ? `${maxHeight}px` : undefined, overflow: maxHeight ? `auto` : undefined }" :aria-hidden="!opened"> <Transition :enterActiveClass="prefer.s.animation ? $style.transition_toggle_enterActive : ''" :leaveActiveClass="prefer.s.animation ? $style.transition_toggle_leaveActive : ''" @@ -70,6 +99,9 @@ SPDX-License-Identifier: AGPL-3.0-only import { nextTick, onMounted, ref, useTemplateRef } from 'vue'; import { prefer } from '@/preferences.js'; import { getBgColor } from '@/utility/get-bg-color.js'; +import { pageFolderTeleportCount, popup } from '@/os.js'; +import MkFolderPage from '@/components/MkFolderPage.vue'; +import { deviceKind } from '@/utility/device-kind.js'; const props = withDefaults(defineProps<{ defaultOpen?: boolean; @@ -77,18 +109,21 @@ const props = withDefaults(defineProps<{ withSpacer?: boolean; spacerMin?: number; spacerMax?: number; + canPage?: boolean; }>(), { defaultOpen: false, maxHeight: null, withSpacer: true, spacerMin: 14, spacerMax: 22, + canPage: true, }); const rootEl = useTemplateRef('rootEl'); +const asPage = props.canPage && deviceKind === 'smartphone' && prefer.s['experimental.enableFolderPageView']; const bgSame = ref(false); -const opened = ref(props.defaultOpen); -const openedAtLeastOnce = ref(props.defaultOpen); +const opened = ref(asPage ? false : props.defaultOpen); +const openedAtLeastOnce = ref(opened.value); //#region interpolate-sizeに対応していないブラウザ向け(TODO: 主要ブラウザが対応したら消す) function enter(el: Element) { @@ -126,7 +161,22 @@ function afterLeave(el: Element) { } //#endregion -function toggle() { +let pageId = pageFolderTeleportCount.value; +pageFolderTeleportCount.value += 1000; + +async function toggle() { + if (asPage && !opened.value) { + pageId++; + const { dispose } = await popup(MkFolderPage, { + pageId, + }, { + closed: () => { + opened.value = false; + dispose(); + }, + }); + } + if (!opened.value) { openedAtLeastOnce.value = true; } diff --git a/packages/frontend/src/components/MkFolderPage.vue b/packages/frontend/src/components/MkFolderPage.vue new file mode 100644 index 0000000000..edc954cf91 --- /dev/null +++ b/packages/frontend/src/components/MkFolderPage.vue @@ -0,0 +1,159 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<Transition + name="x" + :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 : ''" + :duration="300" appear @afterLeave="onClosed" +> + <div v-show="showing" :class="[$style.root]" :style="{ zIndex }"> + <div :class="[$style.bg]" :style="{ zIndex }"></div> + <div :class="[$style.content]" :style="{ zIndex }"> + <div :class="$style.header"> + <button :class="$style.back" class="_button" @click="closePage"><i class="ti ti-chevron-left"></i></button> + <div :id="`v-${pageId}-header`" :class="$style.title"></div> + <div :class="$style.spacer"></div> + </div> + <div :id="`v-${pageId}-body`"></div> + </div> + </div> +</Transition> +</template> + +<script lang="ts" setup> +import { onMounted, ref } from 'vue'; +import { claimZIndex } from '@/os.js'; +import { prefer } from '@/preferences.js'; + +const props = withDefaults(defineProps<{ + pageId: number, +}>(), { + pageId: 0, +}); + +const emit = defineEmits<{ + (_: 'closed'): void +}>(); + +const zIndex = claimZIndex('middle'); +const showing = ref(true); + +function closePage() { + showing.value = false; +} + +function onClosed() { + emit('closed'); +} + +</script> + +<style lang="scss" module> +.transition_x_enterActive { + > .bg { + transition: opacity 0.3s !important; + } + + > .content { + transition: transform 0.3s cubic-bezier(0,0,.25,1) !important; + } +} +.transition_x_leaveActive { + > .bg { + transition: opacity 0.3s !important; + } + + > .content { + transition: transform 0.3s cubic-bezier(0,0,.25,1) !important; + } +} +.transition_x_enterFrom, +.transition_x_leaveTo { + > .bg { + opacity: 0; + } + + > .content { + pointer-events: none; + transform: translateX(100%); + } +} + +.root { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + overflow: clip; +} + +.bg { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: var(--MI_THEME-modalBg); +} + +.content { + position: fixed; + bottom: 0; + left: 0; + right: 0; + width: 100%; + height: 100%; + box-sizing: border-box; + padding-bottom: env(safe-area-inset-bottom, 0px); + margin: auto; + background: var(--MI_THEME-bg); + container-type: size; + overflow: auto; + overscroll-behavior: contain; +} + +.header { + --height: 48px; + + position: sticky; + top: 0; + left: 0; + height: var(--height); + z-index: 1; + display: flex; + align-items: center; + 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); +} + +.back { + display: flex; + align-items: center; + justify-content: center; + width: var(--height); + height: var(--height); + font-size: 16px; + color: var(--MI_THEME-accent); +} + +.title { + margin: 0 auto; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; +} + +.spacer { + width: var(--height); + height: var(--height); +} +</style> diff --git a/packages/frontend/src/components/MkForgotPassword.vue b/packages/frontend/src/components/MkForgotPassword.vue index 57946aaf2b..a2843a3503 100644 --- a/packages/frontend/src/components/MkForgotPassword.vue +++ b/packages/frontend/src/components/MkForgotPassword.vue @@ -39,7 +39,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ref } from 'vue'; +import { ref, useTemplateRef } from 'vue'; import MkModalWindow from '@/components/MkModalWindow.vue'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; @@ -53,7 +53,7 @@ const emit = defineEmits<{ (ev: 'closed'): void; }>(); -const dialog = ref<InstanceType<typeof MkModalWindow>>(); +const dialog = useTemplateRef('dialog'); const username = ref(''); const email = ref(''); diff --git a/packages/frontend/src/components/MkFormDialog.file.vue b/packages/frontend/src/components/MkFormDialog.file.vue index 0a902f3400..a11075c342 100644 --- a/packages/frontend/src/components/MkFormDialog.file.vue +++ b/packages/frontend/src/components/MkFormDialog.file.vue @@ -15,7 +15,7 @@ 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 '@/utility/select-file.js'; +import { selectFile } from '@/utility/drive.js'; import { misskeyApi } from '@/utility/misskey-api.js'; const props = defineProps<{ diff --git a/packages/frontend/src/components/MkFukidashi.vue b/packages/frontend/src/components/MkFukidashi.vue index fba5dc854c..fc3de2845e 100644 --- a/packages/frontend/src/components/MkFukidashi.vue +++ b/packages/frontend/src/components/MkFukidashi.vue @@ -10,7 +10,8 @@ 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 + accented === true && $style.accented, + fullWidth === true && $style.fullWidth, ]" > <div :class="$style.bg"> @@ -32,11 +33,13 @@ withDefaults(defineProps<{ negativeMargin?: boolean; shadow?: boolean; accented?: boolean; + fullWidth?: boolean; }>(), { tail: 'right', negativeMargin: false, shadow: false, accented: false, + fullWidth: false, }); </script> @@ -73,6 +76,14 @@ withDefaults(defineProps<{ margin-right: calc(calc(var(--fukidashi-radius) * .13) * -1); } } + + &.fullWidth { + width: 100%; + + &.content { + width: 100%; + } + } } .bg { @@ -85,6 +96,7 @@ withDefaults(defineProps<{ .content { position: relative; padding: 10px 14px; + box-sizing: border-box; } @container (max-width: 450px) { diff --git a/packages/frontend/src/components/MkGalleryPostPreview.vue b/packages/frontend/src/components/MkGalleryPostPreview.vue index 49a6c65170..e6fae285b3 100644 --- a/packages/frontend/src/components/MkGalleryPostPreview.vue +++ b/packages/frontend/src/components/MkGalleryPostPreview.vue @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkA :to="`/gallery/${post.id}`" class="ttasepnz _panel" tabindex="-1" @pointerenter="enterHover" @pointerleave="leaveHover"> <div class="thumbnail"> <Transition> - <ImgWithBlurhash + <MkImgWithBlurhash class="img layered" :transition="safe ? null : { duration: 500, @@ -34,7 +34,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import * as Misskey from 'misskey-js'; import { computed, ref } from 'vue'; -import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue'; +import MkImgWithBlurhash from '@/components/MkImgWithBlurhash.vue'; import { prefer } from '@/preferences.js'; const props = defineProps<{ diff --git a/packages/frontend/src/components/MkHeatmap.vue b/packages/frontend/src/components/MkHeatmap.vue index 28bb936755..abbf86004b 100644 --- a/packages/frontend/src/components/MkHeatmap.vue +++ b/packages/frontend/src/components/MkHeatmap.vue @@ -18,7 +18,7 @@ import { Chart } from 'chart.js'; import * as Misskey from 'misskey-js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { store } from '@/store.js'; -import { useChartTooltip } from '@/use/use-chart-tooltip.js'; +import { useChartTooltip } from '@/composables/use-chart-tooltip.js'; import { alpha } from '@/utility/color.js'; import { initChart } from '@/utility/init-chart.js'; diff --git a/packages/frontend/src/components/MkInstanceStats.vue b/packages/frontend/src/components/MkInstanceStats.vue index 90391005bc..07d88d6575 100644 --- a/packages/frontend/src/components/MkInstanceStats.vue +++ b/packages/frontend/src/components/MkInstanceStats.vue @@ -89,7 +89,7 @@ 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 '@/use/use-chart-tooltip.js'; +import { useChartTooltip } from '@/composables/use-chart-tooltip.js'; import { $i } from '@/i.js'; import * as os from '@/os.js'; import { misskeyApiGet } from '@/utility/misskey-api.js'; diff --git a/packages/frontend/src/components/MkInstanceTicker.vue b/packages/frontend/src/components/MkInstanceTicker.vue index 380fb7b2d8..62ff806096 100644 --- a/packages/frontend/src/components/MkInstanceTicker.vue +++ b/packages/frontend/src/components/MkInstanceTicker.vue @@ -12,6 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed } from 'vue'; +import tinycolor from 'tinycolor2'; import { instanceName as localInstanceName } from '@@/js/config.js'; import type { CSSProperties } from 'vue'; import { instance as localInstance } from '@/instance.js'; @@ -43,10 +44,33 @@ const faviconUrl = computed(() => { return getProxiedImageUrlNullable(imageSrc); }); +type ITickerColors = { + readonly bg: string; + readonly fg: string; +}; + +const TICKER_YUV_THRESHOLD = 191 as const; +const TICKER_FG_COLOR_LIGHT = '#ffffff' as const; +const TICKER_FG_COLOR_DARK = '#2f2f2fcc' as const; + +function getTickerColors(bgHex: string): ITickerColors { + const tinycolorInstance = tinycolor(bgHex); + const { r, g, b } = tinycolorInstance.toRgb(); + const yuv = 0.299 * r + 0.587 * g + 0.114 * b; + const fgHex = yuv > TICKER_YUV_THRESHOLD ? TICKER_FG_COLOR_DARK : TICKER_FG_COLOR_LIGHT; + + return { + fg: fgHex, + bg: bgHex, + } as const satisfies ITickerColors; +} + const themeColorStyle = computed<CSSProperties>(() => { const themeColor = (props.host == null ? localInstance.themeColor : props.instance?.themeColor) ?? '#777777'; + const colors = getTickerColors(themeColor); return { - background: `linear-gradient(90deg, ${themeColor}, ${themeColor}00)`, + background: `linear-gradient(90deg, ${colors.bg}, ${colors.bg}00)`, + color: colors.fg, }; }); </script> @@ -60,7 +84,6 @@ $height: 2ex; height: $height; border-radius: 4px 0 0 4px; overflow: clip; - color: #fff; // text-shadowは重いから使うな diff --git a/packages/frontend/src/components/MkLaunchPad.vue b/packages/frontend/src/components/MkLaunchPad.vue index 3e5a88a170..584afff55c 100644 --- a/packages/frontend/src/components/MkLaunchPad.vue +++ b/packages/frontend/src/components/MkLaunchPad.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkModal ref="modal" v-slot="{ type, maxHeight }" :preferType="preferedModalType" :anchor="anchor" :transparentBg="true" :src="src" @click="modal?.close()" @closed="emit('closed')" @esc="modal?.close()"> +<MkModal ref="modal" v-slot="{ type, maxHeight }" :preferType="preferedModalType" :anchor="anchor" :transparentBg="true" :anchorElement="anchorElement" @click="modal?.close()" @closed="emit('closed')" @esc="modal?.close()"> <div class="szkkfdyq _popup _shadow" :class="{ asDrawer: type === 'drawer' }" :style="{ maxHeight: maxHeight ? maxHeight + 'px' : '' }"> <div class="main"> <template v-for="item in items" :key="item.text"> @@ -34,7 +34,7 @@ import { deviceKind } from '@/utility/device-kind.js'; import { prefer } from '@/preferences.js'; const props = withDefaults(defineProps<{ - src?: HTMLElement; + anchorElement?: HTMLElement; anchor?: { x: string; y: string; }; }>(), { anchor: () => ({ x: 'right', y: 'center' }), @@ -44,7 +44,7 @@ const emit = defineEmits<{ (ev: 'closed'): void; }>(); -const preferedModalType = (deviceKind === 'desktop' && props.src != null) ? 'popup' : +const preferedModalType = (deviceKind === 'desktop' && props.anchorElement != null) ? 'popup' : deviceKind === 'smartphone' ? 'drawer' : 'dialog'; diff --git a/packages/frontend/src/components/MkLink.vue b/packages/frontend/src/components/MkLink.vue index 4cbf289448..309ef727da 100644 --- a/packages/frontend/src/components/MkLink.vue +++ b/packages/frontend/src/components/MkLink.vue @@ -17,9 +17,9 @@ 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 '@/use/use-tooltip.js'; +import { useTooltip } from '@/composables/use-tooltip.js'; import * as os from '@/os.js'; -import { isEnabledUrlPreview } from '@/instance.js'; +import { isEnabledUrlPreview } from '@/utility/url-preview.js'; import type { MkABehavior } from '@/components/global/MkA.vue'; import { maybeMakeRelative } from '@@/js/url.js'; diff --git a/packages/frontend/src/components/MkMarquee.vue b/packages/frontend/src/components/MkMarquee.vue deleted file mode 100644 index 4a89d21b92..0000000000 --- a/packages/frontend/src/components/MkMarquee.vue +++ /dev/null @@ -1,112 +0,0 @@ -<!-- -SPDX-FileCopyrightText: syuilo and misskey-project -SPDX-License-Identifier: AGPL-3.0-only ---> - -<script lang="ts"> -import { h, onMounted, onUnmounted, ref, watch } from 'vue'; - -export default { - name: 'MarqueeText', - props: { - duration: { - type: Number, - default: 15, - }, - repeat: { - type: Number, - default: 2, - }, - paused: { - type: Boolean, - default: false, - }, - reverse: { - type: Boolean, - default: false, - }, - }, - setup(props) { - const contentEl = ref<HTMLElement>(); - - function calc() { - if (contentEl.value == null) return; - const eachLength = contentEl.value.offsetWidth / props.repeat; - const factor = 3000; - const duration = props.duration / ((1 / eachLength) * factor); - - contentEl.value.style.animationDuration = `${duration}s`; - } - - watch(() => props.duration, calc); - - onMounted(() => { - calc(); - }); - - onUnmounted(() => { - }); - - return { - contentEl, - }; - }, - render({ - $slots, $style, $props: { - duration, repeat, paused, reverse, - }, - }) { - return h('div', { class: [$style.wrap] }, [ - h('span', { - ref: 'contentEl', - class: [ - paused - ? $style.paused - : undefined, - $style.content, - ], - }, Array(repeat).fill( - h('span', { - class: $style.text, - style: { - animationDirection: reverse - ? 'reverse' - : undefined, - }, - }, $slots.default()), - )), - ]); - }, -}; -</script> - -<style lang="scss" module> -.wrap { - overflow: clip; - animation-play-state: running; - - &:hover { - animation-play-state: paused; - } -} -.content { - display: inline-block; - white-space: nowrap; - animation-play-state: inherit; -} -.text { - display: inline-block; - animation-name: marquee; - animation-timing-function: linear; - animation-iteration-count: infinite; - animation-duration: inherit; - animation-play-state: inherit; -} -.paused .text { - animation-play-state: paused; -} -@keyframes marquee { - 0% { transform:translateX(0); } - 100% { transform:translateX(-100%); } -} -</style> diff --git a/packages/frontend/src/components/MkMarqueeText.vue b/packages/frontend/src/components/MkMarqueeText.vue new file mode 100644 index 0000000000..a2c365afe9 --- /dev/null +++ b/packages/frontend/src/components/MkMarqueeText.vue @@ -0,0 +1,89 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div :class="$style.wrap"> + <span + ref="contentEl" + :class="[$style.content, { + [$style.paused]: paused, + [$style.reverse]: reverse, + }]" + > + <span v-for="key in repeat" :key="key" :class="$style.text"> + <slot></slot> + </span> + </span> +</div> +</template> + +<script lang="ts" setup> +import { onMounted, useTemplateRef, watch } from 'vue'; + +const props = withDefaults(defineProps<{ + duration?: number; + repeat?: number; + paused?: boolean; + reverse?: boolean; +}>(), { + duration: 15, + repeat: 2, + paused: false, + reverse: false, +}); + +const contentEl = useTemplateRef('contentEl'); + +function calcDuration() { + if (contentEl.value == null) return; + const eachLength = contentEl.value.offsetWidth / props.repeat; + const factor = 3000; + const duration = props.duration / ((1 / eachLength) * factor); + contentEl.value.style.animationDuration = `${duration}s`; +} + +watch(() => props.duration, calcDuration); + +onMounted(calcDuration); +</script> + +<style lang="scss" module> +.wrap { + overflow: clip; + animation-play-state: running; + + &:hover { + animation-play-state: paused; + } +} + +.content { + display: inline-block; + white-space: nowrap; + animation-play-state: inherit; +} + +.text { + display: inline-block; + animation-name: marquee; + animation-timing-function: linear; + animation-iteration-count: infinite; + animation-duration: inherit; + animation-play-state: inherit; +} + +.paused .text { + animation-play-state: paused; +} + +.reverse .text { + animation-direction: reverse; +} + +@keyframes marquee { + 0% { transform: translateX(0); } + 100% { transform: translateX(-100%); } +} +</style> diff --git a/packages/frontend/src/components/MkMediaImage.vue b/packages/frontend/src/components/MkMediaImage.vue index bb42cbecf9..1e5eb06a31 100644 --- a/packages/frontend/src/components/MkMediaImage.vue +++ b/packages/frontend/src/components/MkMediaImage.vue @@ -17,7 +17,8 @@ SPDX-License-Identifier: AGPL-3.0-only style: 'cursor: zoom-in;' }" > - <ImgWithBlurhash + <MkImgWithBlurhash + v-if="prefer.s.enableHighQualityImagePlaceholders" :hash="image.blurhash" :src="(prefer.s.dataSaver.media && hide) ? null : url" :forceBlurhash="hide" @@ -27,6 +28,20 @@ SPDX-License-Identifier: AGPL-3.0-only :width="image.properties.width" :height="image.properties.height" :style="hide ? 'filter: brightness(0.7);' : null" + :class="$style.image" + /> + <div + v-else-if="prefer.s.dataSaver.media || hide" + :title="image.comment || image.name" + :style="hide ? 'background: #888;' : null" + :class="$style.image" + ></div> + <img + v-else + :src="url" + :alt="image.comment || image.name" + :title="image.comment || image.name" + :class="$style.image" /> </component> <template v-if="hide"> @@ -57,7 +72,7 @@ import type { MenuItem } from '@/types/menu.js'; import { copyToClipboard } from '@/utility/copy-to-clipboard'; import { getStaticImageUrl } from '@/utility/media-proxy.js'; import bytes from '@/filters/bytes.js'; -import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue'; +import MkImgWithBlurhash from '@/components/MkImgWithBlurhash.vue'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; import { $i, iAmModerator } from '@/i.js'; @@ -300,4 +315,12 @@ html[data-color-scheme=light] .visible { font-size: 0.8em; padding: 2px 5px; } + +.image { + display: block; + width: 100%; + height: 100%; + object-fit: contain; + object-position: center; +} </style> diff --git a/packages/frontend/src/components/MkMediaVideo.vue b/packages/frontend/src/components/MkMediaVideo.vue index 0e5f1e28b9..81a5ab27c7 100644 --- a/packages/frontend/src/components/MkMediaVideo.vue +++ b/packages/frontend/src/components/MkMediaVideo.vue @@ -13,8 +13,9 @@ SPDX-License-Identifier: AGPL-3.0-only controlsShowing && $style.active, (video.isSensitive && prefer.s.highlightSensitiveMedia) && $style.sensitive, ]" - @mouseover="onMouseOver" - @mouseleave="onMouseLeave" + @mouseover.passive="onMouseOver" + @mousemove.passive="onMouseMove" + @mouseleave.passive="onMouseLeave" @contextmenu.stop @keydown.stop > @@ -309,7 +310,7 @@ const controlsShowing = computed(() => { return false; }); const isFullscreen = ref(false); -let controlStateTimer: string | number; +let controlStateTimer: number | null = null; // MediaControl: Common State const oncePlayed = ref(false); @@ -342,9 +343,26 @@ function onMouseOver() { window.clearTimeout(controlStateTimer); } isHoverring.value = true; + + controlStateTimer = window.setTimeout(() => { + isHoverring.value = false; + }, 3000); +} + +function onMouseMove() { + if (controlStateTimer) { + window.clearTimeout(controlStateTimer); + } + isHoverring.value = true; + controlStateTimer = window.setTimeout(() => { + isHoverring.value = false; + }, 3000); } function onMouseLeave() { + if (controlStateTimer) { + window.clearTimeout(controlStateTimer); + } controlStateTimer = window.setTimeout(() => { isHoverring.value = false; }, 100); @@ -509,6 +527,10 @@ onDeactivated(() => { window.cancelAnimationFrame(mediaTickFrameId); mediaTickFrameId = null; } + if (controlStateTimer) { + window.clearTimeout(controlStateTimer); + controlStateTimer = null; + } }); </script> diff --git a/packages/frontend/src/components/MkModal.vue b/packages/frontend/src/components/MkModal.vue index 3bcf835ec9..06686ddfc0 100644 --- a/packages/frontend/src/components/MkModal.vue +++ b/packages/frontend/src/components/MkModal.vue @@ -67,7 +67,7 @@ type ModalTypes = 'popup' | 'dialog' | 'drawer'; const props = withDefaults(defineProps<{ manualShowing?: boolean | null; anchor?: { x: string; y: string; }; - src?: HTMLElement | null; + anchorElement?: HTMLElement | null; preferType?: ModalTypes | 'auto'; zPriority?: 'low' | 'middle' | 'high'; noOverlap?: boolean; @@ -76,7 +76,7 @@ const props = withDefaults(defineProps<{ returnFocusTo?: HTMLElement | null; }>(), { manualShowing: null, - src: null, + anchorElement: null, anchor: () => ({ x: 'center', y: 'bottom' }), preferType: 'auto', zPriority: 'low', @@ -110,7 +110,7 @@ const type = computed<ModalTypes>(() => { if ((prefer.s.menuStyle === 'drawer') || (prefer.s.menuStyle === 'auto' && isTouchUsing && deviceKind === 'smartphone')) { return 'drawer'; } else { - return props.src != null ? 'popup' : 'dialog'; + return props.anchorElement != null ? 'popup' : 'dialog'; } } else { return props.preferType!; @@ -149,7 +149,7 @@ function close(opts: { useSendAnimation?: boolean } = {}) { } // eslint-disable-next-line vue/no-mutating-props - if (props.src) props.src.style.pointerEvents = 'auto'; + if (props.anchorElement) props.anchorElement.style.pointerEvents = 'auto'; showing.value = false; emit('close'); } @@ -174,13 +174,13 @@ const MARGIN = 16; const SCROLLBAR_THICKNESS = 16; const align = () => { - if (props.src == null) return; + if (props.anchorElement == null) return; if (type.value === 'drawer') return; if (type.value === 'dialog') return; if (content.value == null) return; - const srcRect = props.src.getBoundingClientRect(); + const anchorRect = props.anchorElement.getBoundingClientRect(); const width = content.value!.offsetWidth; const height = content.value!.offsetHeight; @@ -188,15 +188,15 @@ const align = () => { let left; let top; - const x = srcRect.left + (fixed.value ? 0 : window.scrollX); - const y = srcRect.top + (fixed.value ? 0 : window.scrollY); + const x = anchorRect.left + (fixed.value ? 0 : window.scrollX); + const y = anchorRect.top + (fixed.value ? 0 : window.scrollY); if (props.anchor.x === 'center') { - left = x + (props.src.offsetWidth / 2) - (width / 2); + left = x + (props.anchorElement.offsetWidth / 2) - (width / 2); } else if (props.anchor.x === 'left') { // TODO } else if (props.anchor.x === 'right') { - left = x + props.src.offsetWidth; + left = x + props.anchorElement.offsetWidth; } if (props.anchor.y === 'center') { @@ -204,7 +204,7 @@ const align = () => { } else if (props.anchor.y === 'top') { // TODO } else if (props.anchor.y === 'bottom') { - top = y + props.src.offsetHeight; + top = y + props.anchorElement.offsetHeight; } if (fixed.value) { @@ -214,7 +214,7 @@ const align = () => { } const underSpace = ((window.innerHeight - SCROLLBAR_THICKNESS) - MARGIN) - top; - const upperSpace = (srcRect.top - MARGIN); + const upperSpace = (anchorRect.top - MARGIN); // 画面から縦にはみ出る場合 if (top + height > ((window.innerHeight - SCROLLBAR_THICKNESS) - MARGIN)) { @@ -238,7 +238,7 @@ const align = () => { } const underSpace = ((window.innerHeight - SCROLLBAR_THICKNESS) - MARGIN) - (top - window.scrollY); - const upperSpace = (srcRect.top - MARGIN); + const upperSpace = (anchorRect.top - MARGIN); // 画面から縦にはみ出る場合 if (top + height - window.scrollY > ((window.innerHeight - SCROLLBAR_THICKNESS) - MARGIN)) { @@ -268,15 +268,15 @@ const align = () => { let transformOriginX = 'center'; let transformOriginY = 'center'; - if (top >= srcRect.top + props.src.offsetHeight + (fixed.value ? 0 : window.scrollY)) { + if (top >= anchorRect.top + props.anchorElement.offsetHeight + (fixed.value ? 0 : window.scrollY)) { transformOriginY = 'top'; - } else if ((top + height) <= srcRect.top + (fixed.value ? 0 : window.scrollY)) { + } else if ((top + height) <= anchorRect.top + (fixed.value ? 0 : window.scrollY)) { transformOriginY = 'bottom'; } - if (left >= srcRect.left + props.src.offsetWidth + (fixed.value ? 0 : window.scrollX)) { + if (left >= anchorRect.left + props.anchorElement.offsetWidth + (fixed.value ? 0 : window.scrollX)) { transformOriginX = 'left'; - } else if ((left + width) <= srcRect.left + (fixed.value ? 0 : window.scrollX)) { + } else if ((left + width) <= anchorRect.left + (fixed.value ? 0 : window.scrollX)) { transformOriginX = 'right'; } @@ -317,12 +317,12 @@ const alignObserver = new ResizeObserver((entries, observer) => { }); onMounted(() => { - watch(() => props.src, async () => { - if (props.src) { + watch(() => props.anchorElement, async () => { + if (props.anchorElement) { // eslint-disable-next-line vue/no-mutating-props - props.src.style.pointerEvents = 'none'; + props.anchorElement.style.pointerEvents = 'none'; } - fixed.value = (type.value === 'drawer') || (getFixedContainer(props.src) != null); + fixed.value = (type.value === 'drawer') || (getFixedContainer(props.anchorElement) != null); await nextTick(); @@ -339,7 +339,7 @@ onMounted(() => { } } else { releaseFocusTrap?.(); - focusParent(props.returnFocusTo ?? props.src, true, false); + focusParent(props.returnFocusTo ?? props.anchorElement, true, false); } }, { immediate: true }); diff --git a/packages/frontend/src/components/MkModalWindow.vue b/packages/frontend/src/components/MkModalWindow.vue index 19989e375b..fd4262c17d 100644 --- a/packages/frontend/src/components/MkModalWindow.vue +++ b/packages/frontend/src/components/MkModalWindow.vue @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <MkModal ref="modal" :preferType="'dialog'" @click="onBgClick" @closed="emit('closed')" @esc="emit('esc')"> <div ref="rootEl" :class="$style.root" :style="{ width: `${width}px`, height: `min(${height}px, 100%)` }"> - <div ref="headerEl" :class="$style.header"> + <div :class="$style.header"> <button v-if="withOkButton && withCloseButton" :class="$style.headerButton" class="_button" @click="emit('close')"><i class="ti ti-x"></i></button> <span :class="$style.title"> <slot name="header"></slot> @@ -15,7 +15,10 @@ SPDX-License-Identifier: AGPL-3.0-only <button v-if="withOkButton" :class="$style.headerButton" class="_button" :disabled="okButtonDisabled" @click="emit('ok')"><i class="ti ti-check"></i></button> </div> <div :class="$style.body"> - <slot :width="bodyWidth" :height="bodyHeight"></slot> + <slot></slot> + </div> + <div v-if="$slots.footer" :class="$style.footer"> + <slot name="footer"></slot> </div> </div> </MkModal> @@ -48,10 +51,6 @@ const emit = defineEmits<{ }>(); const modal = useTemplateRef('modal'); -const rootEl = useTemplateRef('rootEl'); -const headerEl = useTemplateRef('headerEl'); -const bodyWidth = ref(0); -const bodyHeight = ref(0); function close() { modal.value?.close(); @@ -61,23 +60,6 @@ function onBgClick() { emit('click'); } -const ro = new ResizeObserver((entries, observer) => { - if (rootEl.value == null || headerEl.value == null) return; - bodyWidth.value = rootEl.value.offsetWidth; - bodyHeight.value = rootEl.value.offsetHeight - headerEl.value.offsetHeight; -}); - -onMounted(() => { - if (rootEl.value == null || headerEl.value == null) return; - bodyWidth.value = rootEl.value.offsetWidth; - bodyHeight.value = rootEl.value.offsetHeight - headerEl.value.offsetHeight; - ro.observe(rootEl.value); -}); - -onUnmounted(() => { - ro.disconnect(); -}); - defineExpose({ close, }); @@ -143,7 +125,14 @@ defineExpose({ .body { flex: 1; overflow: auto; - background: var(--MI_THEME-panel); + background: var(--MI_THEME-bg); container-type: size; } + +.footer { + padding: 12px 16px; + overflow: auto; + background: var(--MI_THEME-bg); + border-top: 1px solid var(--MI_THEME-divider); +} </style> diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index 980636f551..4a78d00665 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -6,11 +6,10 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div v-if="!hardMuted && muted === false" - v-show="!isDeleted" ref="rootEl" v-hotkey="keymap" :class="[$style.root, { [$style.showActionsOnlyHover]: prefer.s.showNoteActionsOnlyHover, [$style.skipRender]: prefer.s.skipNoteRender }]" - :tabindex="isDeleted ? '-1' : '0'" + tabindex="0" > <MkNoteSub v-if="appearNote.reply && !renoteCollapsed" :note="appearNote.reply" :class="$style.replyTo"/> <div v-if="pinned" :class="$style.tip"><i class="ti ti-pin"></i> {{ i18n.ts.pinnedNote }}</div> @@ -84,10 +83,19 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> </div> - <div v-if="appearNote.files && appearNote.files.length > 0"> + <div v-if="appearNote.files && appearNote.files.length > 0" style="margin-top: 8px;"> <MkMediaList ref="galleryEl" :mediaList="appearNote.files"/> </div> - <MkPoll v-if="appearNote.poll" :noteId="appearNote.id" :poll="appearNote.poll" :author="appearNote.user" :emojiUrls="appearNote.emojis" :class="$style.poll"/> + <MkPoll + v-if="appearNote.poll" + :noteId="appearNote.id" + :multiple="appearNote.poll.multiple" + :expiresAt="appearNote.poll.expiresAt" + :choices="$appearNote.pollChoices" + :author="appearNote.user" + :emojiUrls="appearNote.emojis" + :class="$style.poll" + /> <div v-if="isEnabledUrlPreview"> <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :class="$style.urlPreview"/> </div> @@ -101,7 +109,16 @@ 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'" style="margin-top: 6px;" :note="appearNote" :maxNumber="16" @mockUpdateMyReaction="emitUpdReaction"> + <MkReactionsViewer + v-if="appearNote.reactionAcceptance !== 'likeOnly'" + style="margin-top: 6px;" + :reactions="$appearNote.reactions" + :reactionEmojis="$appearNote.reactionEmojis" + :myReaction="$appearNote.myReaction" + :noteId="appearNote.id" + :maxNumber="16" + @mockUpdateMyReaction="emitUpdReaction" + > <template #more> <MkA :to="`/notes/${appearNote.id}/reactions`" :class="[$style.reactionOmitted]">{{ i18n.ts.more }}</MkA> </template> @@ -125,11 +142,11 @@ SPDX-License-Identifier: AGPL-3.0-only <i class="ti ti-ban"></i> </button> <button ref="reactButton" :class="$style.footerButton" class="_button" @click="toggleReact()"> - <i v-if="appearNote.reactionAcceptance === 'likeOnly' && appearNote.myReaction != null" class="ti ti-heart-filled" style="color: var(--MI_THEME-love);"></i> - <i v-else-if="appearNote.myReaction != null" class="ti ti-minus" style="color: var(--MI_THEME-accent);"></i> + <i v-if="appearNote.reactionAcceptance === 'likeOnly' && $appearNote.myReaction != null" class="ti ti-heart-filled" style="color: var(--MI_THEME-love);"></i> + <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="ti ti-plus"></i> - <p v-if="(appearNote.reactionAcceptance === 'likeOnly' || prefer.s.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="prefer.s.showClipButtonInNoteFooter" ref="clipButton" :class="$style.footerButton" class="_button" @mousedown.prevent="clip()"> <i class="ti ti-paperclip"></i> @@ -176,7 +193,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, inject, onMounted, ref, useTemplateRef, watch, provide } from 'vue'; +import { computed, inject, onMounted, ref, useTemplateRef, provide } from 'vue'; import * as mfm from 'mfm-js'; import * as Misskey from 'misskey-js'; import { isLink } from '@@/js/is-link.js'; @@ -210,19 +227,20 @@ import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js'; import { $i } from '@/i.js'; import { i18n } from '@/i18n.js'; import { getAbuseNoteMenu, getCopyNoteLinkMenu, getNoteClipMenu, getNoteMenu, getRenoteMenu } from '@/utility/get-note-menu.js'; -import { useNoteCapture } from '@/use/use-note-capture.js'; +import { noteEvents, useNoteCapture } from '@/composables/use-note-capture.js'; import { deepClone } from '@/utility/clone.js'; -import { useTooltip } from '@/use/use-tooltip.js'; +import { useTooltip } from '@/composables/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 '@/utility/show-moved-dialog.js'; -import { isEnabledUrlPreview } from '@/instance.js'; +import { isEnabledUrlPreview } from '@/utility/url-preview.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 { globalEvents } from '@/events.js'; const props = withDefaults(defineProps<{ note: Misskey.entities.Note; @@ -245,29 +263,31 @@ 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)); +let note = 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); + let result: Misskey.entities.Note | null = deepClone(note); for (const interruptor of noteViewInterruptors) { try { result = await interruptor.handler(result!) as Misskey.entities.Note | null; - if (result === null) { - isDeleted.value = true; - return; - } } catch (err) { console.error(err); } } - note.value = result as Misskey.entities.Note; + note = result as Misskey.entities.Note; }); } -const isRenote = Misskey.note.isPureRenote(note.value); +const isRenote = Misskey.note.isPureRenote(note); +const appearNote = getAppearNote(note); +const { $note: $appearNote, subscribe: subscribeManuallyToNoteCapture } = useNoteCapture({ + note: appearNote, + parentNote: note, + mock: props.mock, +}); const rootEl = useTemplateRef('rootEl'); const menuButton = useTemplateRef('menuButton'); @@ -275,32 +295,30 @@ const renoteButton = useTemplateRef('renoteButton'); const renoteTime = useTemplateRef('renoteTime'); const reactButton = useTemplateRef('reactButton'); const clipButton = useTemplateRef('clipButton'); -const appearNote = computed(() => getAppearNote(note.value)); const galleryEl = useTemplateRef('galleryEl'); -const isMyRenote = $i && ($i.id === note.value.userId); +const isMyRenote = $i && ($i.id === note.userId); const showContent = ref(false); -const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : null); -const urls = computed(() => parsed.value ? extractUrlFromMfm(parsed.value).filter((url) => appearNote.value.renote?.url !== url && appearNote.value.renote?.uri !== url) : null); -const isLong = shouldCollapsed(appearNote.value, urls.value ?? []); -const collapsed = ref(appearNote.value.cw == null && isLong); -const isDeleted = ref(false); -const muted = ref(checkMute(appearNote.value, $i?.mutedWords)); -const hardMuted = ref(props.withHardMute && checkMute(appearNote.value, $i?.hardMutedWords, true)); +const parsed = computed(() => appearNote.text ? mfm.parse(appearNote.text) : null); +const urls = computed(() => parsed.value ? extractUrlFromMfm(parsed.value).filter((url) => appearNote.renote?.url !== url && appearNote.renote?.uri !== url) : null); +const isLong = shouldCollapsed(appearNote, urls.value ?? []); +const collapsed = ref(appearNote.cw == null && isLong); +const muted = ref(checkMute(appearNote, $i?.mutedWords)); +const hardMuted = ref(props.withHardMute && checkMute(appearNote, $i?.hardMutedWords, true)); const showSoftWordMutedWord = computed(() => prefer.s.showSoftWordMutedWord); const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null); const translating = ref(false); -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 showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.user.instance); +const canRenote = computed(() => ['public', 'home'].includes(appearNote.visibility) || (appearNote.visibility === 'followers' && appearNote.userId === $i?.id)); const renoteCollapsed = ref( 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) + ($i && ($i.id === note.userId || $i.id === appearNote.userId)) || // `||` must be `||`! See https://github.com/misskey-dev/misskey/issues/13131 + ($appearNote.myReaction != null) ), ); const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({ type: 'lookup', - url: `https://${host}/notes/${appearNote.value.id}`, + url: `https://${host}/notes/${appearNote.id}`, })); /* Overload FunctionにLintが対応していないのでコメントアウト @@ -357,7 +375,7 @@ const keymap = { 'v|enter': () => { if (renoteCollapsed.value) { renoteCollapsed.value = false; - } else if (appearNote.value.cw != null) { + } else if (appearNote.cw != null) { showContent.value = !showContent.value; } else if (isLong) { collapsed.value = !collapsed.value; @@ -380,28 +398,20 @@ const keymap = { provide(DI.mfmEmojiReactCallback, (reaction) => { sound.playMisskeySfx('reaction'); misskeyApi('notes/reactions/create', { - noteId: appearNote.value.id, + noteId: appearNote.id, reaction: reaction, + }).then(() => { + noteEvents.emit(`reacted:${appearNote.id}`, { + userId: $i!.id, + reaction: reaction, + }); }); }); -if (props.mock) { - watch(() => props.note, (to) => { - note.value = deepClone(to); - }, { deep: true }); -} else { - useNoteCapture({ - rootEl: rootEl, - note: appearNote, - pureNote: note, - isDeletedRef: isDeleted, - }); -} - if (!props.mock) { useTooltip(renoteButton, async (showing) => { const renotes = await misskeyApi('notes/renotes', { - noteId: appearNote.value.id, + noteId: appearNote.id, limit: 11, }); @@ -412,19 +422,19 @@ if (!props.mock) { const { dispose } = os.popup(MkUsersTooltip, { showing, users, - count: appearNote.value.renoteCount, + count: appearNote.renoteCount, targetElement: renoteButton.value, }, { closed: () => dispose(), }); }); - if (appearNote.value.reactionAcceptance === 'likeOnly') { + if (appearNote.reactionAcceptance === 'likeOnly') { useTooltip(reactButton, async (showing) => { const reactions = await misskeyApiGet('notes/reactions', { - noteId: appearNote.value.id, + noteId: appearNote.id, limit: 10, - _cacheKey_: appearNote.value.reactionCount, + _cacheKey_: $appearNote.reactionCount, }); const users = reactions.map(x => x.user); @@ -435,7 +445,7 @@ if (!props.mock) { showing, reaction: '❤️', users, - count: appearNote.value.reactionCount, + count: $appearNote.reactionCount, targetElement: reactButton.value!, }, { closed: () => dispose(), @@ -448,10 +458,12 @@ function renote(viaKeyboard = false) { pleaseLogin({ openOnRemote: pleaseLoginContext.value }); showMovedDialog(); - const { menu } = getRenoteMenu({ note: note.value, renoteButton, mock: props.mock }); + const { menu } = getRenoteMenu({ note: note, renoteButton, mock: props.mock }); os.popupMenu(menu, renoteButton.value, { viaKeyboard, }); + + subscribeManuallyToNoteCapture(); } function reply(): void { @@ -460,8 +472,8 @@ function reply(): void { return; } os.post({ - reply: appearNote.value, - channel: appearNote.value.channel, + reply: appearNote, + channel: appearNote.channel, }).then(() => { focus(); }); @@ -470,7 +482,7 @@ function reply(): void { function react(): void { pleaseLogin({ openOnRemote: pleaseLoginContext.value }); showMovedDialog(); - if (appearNote.value.reactionAcceptance === 'likeOnly') { + if (appearNote.reactionAcceptance === 'likeOnly') { sound.playMisskeySfx('reaction'); if (props.mock) { @@ -478,8 +490,13 @@ function react(): void { } misskeyApi('notes/reactions/create', { - noteId: appearNote.value.id, + noteId: appearNote.id, reaction: '❤️', + }).then(() => { + noteEvents.emit(`reacted:${appearNote.id}`, { + userId: $i!.id, + reaction: '❤️', + }); }); const el = reactButton.value; if (el && prefer.s.animation) { @@ -492,7 +509,7 @@ function react(): void { } } else { blur(); - reactionPicker.show(reactButton.value ?? null, note.value, async (reaction) => { + reactionPicker.show(reactButton.value ?? null, note, async (reaction) => { if (prefer.s.confirmOnReact) { const confirm = await os.confirm({ type: 'question', @@ -506,14 +523,23 @@ function react(): void { if (props.mock) { emit('reaction', reaction); + $appearNote.reactions[reaction] = 1; + $appearNote.reactionCount++; + $appearNote.myReaction = reaction; return; } misskeyApi('notes/reactions/create', { - noteId: appearNote.value.id, + noteId: appearNote.id, reaction: reaction, + }).then(() => { + noteEvents.emit(`reacted:${appearNote.id}`, { + userId: $i!.id, + reaction: reaction, + }); }); - if (appearNote.value.text && appearNote.value.text.length > 100 && (Date.now() - new Date(appearNote.value.createdAt).getTime() < 1000 * 3)) { + + if (appearNote.text && appearNote.text.length > 100 && (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 3)) { claimAchievement('reactWithoutRead'); } }, () => { @@ -522,8 +548,8 @@ function react(): void { } } -function undoReact(targetNote: Misskey.entities.Note): void { - const oldReaction = targetNote.myReaction; +function undoReact(): void { + const oldReaction = $appearNote.myReaction; if (!oldReaction) return; if (props.mock) { @@ -532,15 +558,20 @@ function undoReact(targetNote: Misskey.entities.Note): void { } misskeyApi('notes/reactions/delete', { - noteId: targetNote.id, + noteId: appearNote.id, + }).then(() => { + noteEvents.emit(`unreacted:${appearNote.id}`, { + userId: $i!.id, + reaction: oldReaction, + }); }); } function toggleReact() { - if (appearNote.value.myReaction == null) { + if ($appearNote.myReaction == null) { react(); } else { - undoReact(appearNote.value); + undoReact(); } } @@ -556,7 +587,7 @@ function onContextmenu(ev: MouseEvent): void { ev.preventDefault(); react(); } else { - const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, isDeleted, currentClip: currentClip?.value }); + const { menu, cleanup } = getNoteMenu({ note: note, translating, translation, currentClip: currentClip?.value }); os.contextMenu(menu, ev).then(focus).finally(cleanup); } } @@ -566,7 +597,7 @@ function showMenu(): void { return; } - const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, isDeleted, currentClip: currentClip?.value }); + const { menu, cleanup } = getNoteMenu({ note: note, translating, translation, currentClip: currentClip?.value }); os.popupMenu(menu, menuButton.value).then(focus).finally(cleanup); } @@ -575,7 +606,7 @@ async function clip(): Promise<void> { return; } - os.popupMenu(await getNoteClipMenu({ note: note.value, isDeleted, currentClip: currentClip?.value }), clipButton.value).then(focus); + os.popupMenu(await getNoteClipMenu({ note: note, currentClip: currentClip?.value }), clipButton.value).then(focus); } function showRenoteMenu(): void { @@ -590,9 +621,10 @@ function showRenoteMenu(): void { danger: true, action: () => { misskeyApi('notes/delete', { - noteId: note.value.id, + noteId: note.id, + }).then(() => { + globalEvents.emit('noteDeleted', note.id); }); - isDeleted.value = true; }, }; } @@ -601,23 +633,23 @@ function showRenoteMenu(): void { type: 'link', text: i18n.ts.renoteDetails, icon: 'ti ti-info-circle', - to: notePage(note.value), + to: notePage(note), }; if (isMyRenote) { pleaseLogin({ openOnRemote: pleaseLoginContext.value }); os.popupMenu([ renoteDetailsMenu, - getCopyNoteLinkMenu(note.value, i18n.ts.copyLinkRenote), + getCopyNoteLinkMenu(note, i18n.ts.copyLinkRenote), { type: 'divider' }, getUnrenote(), ], renoteTime.value); } else { os.popupMenu([ renoteDetailsMenu, - getCopyNoteLinkMenu(note.value, i18n.ts.copyLinkRenote), + getCopyNoteLinkMenu(note, i18n.ts.copyLinkRenote), { type: 'divider' }, - getAbuseNoteMenu(note.value, i18n.ts.reportAbuseRenote), + getAbuseNoteMenu(note, i18n.ts.reportAbuseRenote), ($i?.isModerator || $i?.isAdmin) ? getUnrenote() : undefined, ], renoteTime.value); } @@ -641,9 +673,8 @@ function focusAfter() { function readPromo() { misskeyApi('promo/read', { - noteId: appearNote.value.id, + noteId: appearNote.id, }); - isDeleted.value = true; } function emitUpdReaction(emoji: string, delta: number) { diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue index 17a348affe..e090901875 100644 --- a/packages/frontend/src/components/MkNoteDetailed.vue +++ b/packages/frontend/src/components/MkNoteDetailed.vue @@ -5,12 +5,11 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div - v-if="!muted" - v-show="!isDeleted" + v-if="!muted && !isDeleted" ref="rootEl" v-hotkey="keymap" :class="$style.root" - :tabindex="isDeleted ? '-1' : '0'" + tabindex="0" > <div v-if="appearNote.reply && appearNote.reply.replyId"> <div v-if="!conversationLoaded" style="padding: 16px"> @@ -110,7 +109,16 @@ SPDX-License-Identifier: AGPL-3.0-only <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" :class="$style.poll" :author="appearNote.user" :emojiUrls="appearNote.emojis"/> + <MkPoll + v-if="appearNote.poll" + :noteId="appearNote.id" + :multiple="appearNote.poll.multiple" + :expiresAt="appearNote.poll.expiresAt" + :choices="$appearNote.pollChoices" + :author="appearNote.user" + :emojiUrls="appearNote.emojis" + :class="$style.poll" + /> <div v-if="isEnabledUrlPreview"> <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" style="margin-top: 6px;"/> </div> @@ -124,7 +132,16 @@ SPDX-License-Identifier: AGPL-3.0-only <MkTime :time="appearNote.createdAt" mode="detail" colored/> </MkA> </div> - <MkReactionsViewer v-if="appearNote.reactionAcceptance !== 'likeOnly'" ref="reactionsViewer" style="margin-top: 6px;" :note="appearNote"/> + <MkReactionsViewer + v-if="appearNote.reactionAcceptance !== 'likeOnly'" + style="margin-top: 6px;" + :reactions="$appearNote.reactions" + :reactionEmojis="$appearNote.reactionEmojis" + :myReaction="$appearNote.myReaction" + :noteId="appearNote.id" + :maxNumber="16" + @mockUpdateMyReaction="emitUpdReaction" + /> <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> @@ -143,11 +160,11 @@ SPDX-License-Identifier: AGPL-3.0-only <i class="ti ti-ban"></i> </button> <button ref="reactButton" :class="$style.noteFooterButton" class="_button" @click="toggleReact()"> - <i v-if="appearNote.reactionAcceptance === 'likeOnly' && appearNote.myReaction != null" class="ti ti-heart-filled" style="color: var(--MI_THEME-love);"></i> - <i v-else-if="appearNote.myReaction != null" class="ti ti-minus" style="color: var(--MI_THEME-accent);"></i> + <i v-if="appearNote.reactionAcceptance === 'likeOnly' && $appearNote.myReaction != null" class="ti ti-heart-filled" style="color: var(--MI_THEME-love);"></i> + <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="ti ti-plus"></i> - <p v-if="(appearNote.reactionAcceptance === 'likeOnly' || prefer.s.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="prefer.s.showClipButtonInNoteFooter" ref="clipButton" class="_button" :class="$style.noteFooterButton" @mousedown.prevent="clip()"> <i class="ti ti-paperclip"></i> @@ -182,9 +199,9 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <div v-else-if="tab === 'reactions'" :class="$style.tab_reactions"> <div :class="$style.reactionTabs"> - <button v-for="reaction in Object.keys(appearNote.reactions)" :key="reaction" :class="[$style.reactionTab, { [$style.reactionTabActive]: reactionTabType === reaction }]" class="_button" @click="reactionTabType = reaction"> + <button v-for="reaction in Object.keys($appearNote.reactions)" :key="reaction" :class="[$style.reactionTab, { [$style.reactionTabActive]: reactionTabType === reaction }]" class="_button" @click="reactionTabType = reaction"> <MkReactionIcon :reaction="reaction"/> - <span style="margin-left: 4px;">{{ appearNote.reactions[reaction] }}</span> + <span style="margin-left: 4px;">{{ $appearNote.reactions[reaction] }}</span> </button> </div> <MkPagination v-if="reactionTabType" :key="reactionTabType" :pagination="reactionsPagination" :disableAutoLoad="true"> @@ -199,7 +216,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> </div> -<div v-else class="_panel" :class="$style.muted" @click="muted = false"> +<div v-else-if="muted" 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)"> @@ -217,7 +234,6 @@ import * as Misskey from 'misskey-js'; import { isLink } from '@@/js/is-link.js'; import { host } from '@@/js/config.js'; import type { OpenOnRemoteOptions } from '@/utility/please-login.js'; -import type { Paging } from '@/components/MkPagination.vue'; import type { Keymap } from '@/utility/hotkey.js'; import MkNoteSub from '@/components/MkNoteSub.vue'; import MkNoteSimple from '@/components/MkNoteSimple.vue'; @@ -242,9 +258,9 @@ import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js'; import { $i } from '@/i.js'; import { i18n } from '@/i18n.js'; import { getNoteClipMenu, getNoteMenu, getRenoteMenu } from '@/utility/get-note-menu.js'; -import { useNoteCapture } from '@/use/use-note-capture.js'; +import { noteEvents, useNoteCapture } from '@/composables/use-note-capture.js'; import { deepClone } from '@/utility/clone.js'; -import { useTooltip } from '@/use/use-tooltip.js'; +import { useTooltip } from '@/composables/use-tooltip.js'; import { claimAchievement } from '@/utility/achievements.js'; import MkRippleEffect from '@/components/MkRippleEffect.vue'; import { showMovedDialog } from '@/utility/show-moved-dialog.js'; @@ -252,11 +268,12 @@ import MkUserCardMini from '@/components/MkUserCardMini.vue'; import MkPagination from '@/components/MkPagination.vue'; import MkReactionIcon from '@/components/MkReactionIcon.vue'; import MkButton from '@/components/MkButton.vue'; -import { isEnabledUrlPreview } from '@/instance.js'; +import { isEnabledUrlPreview } from '@/utility/url-preview.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 { globalEvents, useGlobalEvent } from '@/events.js'; const props = withDefaults(defineProps<{ note: Misskey.entities.Note; @@ -267,29 +284,30 @@ const props = withDefaults(defineProps<{ const inChannel = inject('inChannel', null); -const note = ref(deepClone(props.note)); +let note = 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); + let result: Misskey.entities.Note | null = deepClone(note); for (const interruptor of noteViewInterruptors) { try { result = await interruptor.handler(result!) as Misskey.entities.Note | null; - if (result === null) { - isDeleted.value = true; - return; - } } catch (err) { console.error(err); } } - note.value = result as Misskey.entities.Note; + note = result as Misskey.entities.Note; }); } -const isRenote = Misskey.note.isPureRenote(note.value); +const isRenote = Misskey.note.isPureRenote(note); +const appearNote = getAppearNote(note); +const { $note: $appearNote, subscribe: subscribeManuallyToNoteCapture } = useNoteCapture({ + note: appearNote, + parentNote: note, +}); const rootEl = useTemplateRef('rootEl'); const menuButton = useTemplateRef('menuButton'); @@ -297,24 +315,29 @@ const renoteButton = useTemplateRef('renoteButton'); const renoteTime = useTemplateRef('renoteTime'); const reactButton = useTemplateRef('reactButton'); const clipButton = useTemplateRef('clipButton'); -const appearNote = computed(() => getAppearNote(note.value)); const galleryEl = useTemplateRef('galleryEl'); -const isMyRenote = $i && ($i.id === note.value.userId); +const isMyRenote = $i && ($i.id === note.userId); const showContent = ref(false); const isDeleted = ref(false); -const muted = ref($i ? checkWordMute(appearNote.value, $i, $i.mutedWords) : false); +const muted = ref($i ? checkWordMute(appearNote, $i, $i.mutedWords) : false); const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null); const translating = ref(false); -const parsed = appearNote.value.text ? mfm.parse(appearNote.value.text) : null; -const urls = parsed ? extractUrlFromMfm(parsed).filter((url) => appearNote.value.renote?.url !== url && appearNote.value.renote?.uri !== url) : null; -const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.value.user.instance); +const parsed = appearNote.text ? mfm.parse(appearNote.text) : null; +const urls = parsed ? extractUrlFromMfm(parsed).filter((url) => appearNote.renote?.url !== url && appearNote.renote?.uri !== url) : null; +const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.user.instance); const conversation = ref<Misskey.entities.Note[]>([]); const replies = ref<Misskey.entities.Note[]>([]); -const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || appearNote.value.userId === $i?.id); +const canRenote = computed(() => ['public', 'home'].includes(appearNote.visibility) || appearNote.userId === $i?.id); + +useGlobalEvent('noteDeleted', (noteId) => { + if (noteId === note.id || noteId === appearNote.id) { + isDeleted.value = true; + } +}); const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({ type: 'lookup', - url: `https://${host}/notes/${appearNote.value.id}`, + url: `https://${host}/notes/${appearNote.id}`, })); const keymap = { @@ -328,7 +351,7 @@ const keymap = { }, 'o': () => galleryEl.value?.openGallery(), 'v|enter': () => { - if (appearNote.value.cw != null) { + if (appearNote.cw != null) { showContent.value = !showContent.value; } }, @@ -341,41 +364,39 @@ const keymap = { provide(DI.mfmEmojiReactCallback, (reaction) => { sound.playMisskeySfx('reaction'); misskeyApi('notes/reactions/create', { - noteId: appearNote.value.id, + noteId: appearNote.id, reaction: reaction, + }).then(() => { + noteEvents.emit(`reacted:${appearNote.id}`, { + userId: $i!.id, + reaction: reaction, + }); }); }); const tab = ref(props.initialTab); const reactionTabType = ref<string | null>(null); -const renotesPagination = computed<Paging>(() => ({ +const renotesPagination = computed(() => ({ endpoint: 'notes/renotes', limit: 10, params: { - noteId: appearNote.value.id, + noteId: appearNote.id, }, })); -const reactionsPagination = computed<Paging>(() => ({ +const reactionsPagination = computed(() => ({ endpoint: 'notes/reactions', limit: 10, params: { - noteId: appearNote.value.id, + noteId: appearNote.id, type: reactionTabType.value, }, })); -useNoteCapture({ - rootEl: rootEl, - note: appearNote, - pureNote: note, - isDeletedRef: isDeleted, -}); - useTooltip(renoteButton, async (showing) => { const renotes = await misskeyApi('notes/renotes', { - noteId: appearNote.value.id, + noteId: appearNote.id, limit: 11, }); @@ -386,19 +407,19 @@ useTooltip(renoteButton, async (showing) => { const { dispose } = os.popup(MkUsersTooltip, { showing, users, - count: appearNote.value.renoteCount, + count: appearNote.renoteCount, targetElement: renoteButton.value, }, { closed: () => dispose(), }); }); -if (appearNote.value.reactionAcceptance === 'likeOnly') { +if (appearNote.reactionAcceptance === 'likeOnly') { useTooltip(reactButton, async (showing) => { const reactions = await misskeyApiGet('notes/reactions', { - noteId: appearNote.value.id, + noteId: appearNote.id, limit: 10, - _cacheKey_: appearNote.value.reactionCount, + _cacheKey_: $appearNote.reactionCount, }); const users = reactions.map(x => x.user); @@ -409,7 +430,7 @@ if (appearNote.value.reactionAcceptance === 'likeOnly') { showing, reaction: '❤️', users, - count: appearNote.value.reactionCount, + count: $appearNote.reactionCount, targetElement: reactButton.value!, }, { closed: () => dispose(), @@ -421,16 +442,19 @@ function renote() { pleaseLogin({ openOnRemote: pleaseLoginContext.value }); showMovedDialog(); - const { menu } = getRenoteMenu({ note: note.value, renoteButton }); + const { menu } = getRenoteMenu({ note: note, renoteButton }); os.popupMenu(menu, renoteButton.value); + + // リノート後は反応が来る可能性があるので手動で購読する + subscribeManuallyToNoteCapture(); } function reply(): void { pleaseLogin({ openOnRemote: pleaseLoginContext.value }); showMovedDialog(); os.post({ - reply: appearNote.value, - channel: appearNote.value.channel, + reply: appearNote, + channel: appearNote.channel, }).then(() => { focus(); }); @@ -439,12 +463,17 @@ function reply(): void { function react(): void { pleaseLogin({ openOnRemote: pleaseLoginContext.value }); showMovedDialog(); - if (appearNote.value.reactionAcceptance === 'likeOnly') { + if (appearNote.reactionAcceptance === 'likeOnly') { sound.playMisskeySfx('reaction'); misskeyApi('notes/reactions/create', { - noteId: appearNote.value.id, + noteId: appearNote.id, reaction: '❤️', + }).then(() => { + noteEvents.emit(`reacted:${appearNote.id}`, { + userId: $i!.id, + reaction: '❤️', + }); }); const el = reactButton.value; if (el && prefer.s.animation) { @@ -457,7 +486,7 @@ function react(): void { } } else { blur(); - reactionPicker.show(reactButton.value ?? null, note.value, async (reaction) => { + reactionPicker.show(reactButton.value ?? null, note, async (reaction) => { if (prefer.s.confirmOnReact) { const confirm = await os.confirm({ type: 'question', @@ -470,10 +499,15 @@ function react(): void { sound.playMisskeySfx('reaction'); misskeyApi('notes/reactions/create', { - noteId: appearNote.value.id, + noteId: appearNote.id, reaction: reaction, + }).then(() => { + noteEvents.emit(`reacted:${appearNote.id}`, { + userId: $i!.id, + reaction: reaction, + }); }); - if (appearNote.value.text && appearNote.value.text.length > 100 && (Date.now() - new Date(appearNote.value.createdAt).getTime() < 1000 * 3)) { + if (appearNote.text && appearNote.text.length > 100 && (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 3)) { claimAchievement('reactWithoutRead'); } }, () => { @@ -487,14 +521,19 @@ function undoReact(targetNote: Misskey.entities.Note): void { if (!oldReaction) return; misskeyApi('notes/reactions/delete', { noteId: targetNote.id, + }).then(() => { + noteEvents.emit(`unreacted:${appearNote.id}`, { + userId: $i!.id, + reaction: oldReaction, + }); }); } function toggleReact() { - if (appearNote.value.myReaction == null) { + if (appearNote.myReaction == null) { react(); } else { - undoReact(appearNote.value); + undoReact(appearNote); } } @@ -506,18 +545,18 @@ function onContextmenu(ev: MouseEvent): void { ev.preventDefault(); react(); } else { - const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, isDeleted }); + const { menu, cleanup } = getNoteMenu({ note: note, translating, translation }); os.contextMenu(menu, ev).then(focus).finally(cleanup); } } function showMenu(): void { - const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, isDeleted }); + const { menu, cleanup } = getNoteMenu({ note: note, translating, translation }); os.popupMenu(menu, menuButton.value).then(focus).finally(cleanup); } async function clip(): Promise<void> { - os.popupMenu(await getNoteClipMenu({ note: note.value, isDeleted }), clipButton.value).then(focus); + os.popupMenu(await getNoteClipMenu({ note: note }), clipButton.value).then(focus); } function showRenoteMenu(): void { @@ -529,9 +568,10 @@ function showRenoteMenu(): void { danger: true, action: () => { misskeyApi('notes/delete', { - noteId: note.value.id, + noteId: note.id, + }).then(() => { + globalEvents.emit('noteDeleted', note.id); }); - isDeleted.value = true; }, }], renoteTime.value); } @@ -549,7 +589,7 @@ const repliesLoaded = ref(false); function loadReplies() { repliesLoaded.value = true; misskeyApi('notes/children', { - noteId: appearNote.value.id, + noteId: appearNote.id, limit: 30, }).then(res => { replies.value = res; @@ -560,9 +600,9 @@ const conversationLoaded = ref(false); function loadConversation() { conversationLoaded.value = true; - if (appearNote.value.replyId == null) return; + if (appearNote.replyId == null) return; misskeyApi('notes/conversation', { - noteId: appearNote.value.replyId, + noteId: appearNote.replyId, }).then(res => { conversation.value = res.reverse(); }); diff --git a/packages/frontend/src/components/MkNoteMediaGrid.vue b/packages/frontend/src/components/MkNoteMediaGrid.vue index 764d9f6a32..7e900b28fa 100644 --- a/packages/frontend/src/components/MkNoteMediaGrid.vue +++ b/packages/frontend/src/components/MkNoteMediaGrid.vue @@ -100,6 +100,7 @@ const showingFiles = ref<Set<string>>(new Set()); font-size: 0.8em; text-align: center; padding: 8px; + border-radius: calc(var(--MI-radius) / 2); box-sizing: border-box; color: #fff; background: rgba(0, 0, 0, 0.5); diff --git a/packages/frontend/src/components/MkNotes.vue b/packages/frontend/src/components/MkNotesTimeline.vue index 509099e0b9..a500bab8e6 100644 --- a/packages/frontend/src/components/MkNotes.vue +++ b/packages/frontend/src/components/MkNotesTimeline.vue @@ -4,13 +4,21 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkPagination ref="pagingComponent" :pagination="pagination" :disableAutoLoad="disableAutoLoad"> +<MkPagination ref="pagingComponent" :pagination="pagination" :disableAutoLoad="disableAutoLoad" :pullToRefresh="pullToRefresh"> <template #empty><MkResult type="empty" :text="i18n.ts.noNotes"/></template> <template #default="{ items: notes }"> - <div :class="[$style.root, { [$style.noGap]: noGap, '_gaps': !noGap, [$style.reverse]: pagination.reversed }]"> + <div :class="[$style.root, { [$style.noGap]: noGap, '_gaps': !noGap }]"> <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"> + <div v-if="i > 0 && isSeparatorNeeded(pagingComponent.paginator.items.value[i -1].createdAt, note.createdAt)" :data-scroll-anchor="note.id"> + <div :class="$style.date"> + <span><i class="ti ti-chevron-up"></i> {{ getSeparatorInfo(pagingComponent.paginator.items.value[i -1].createdAt, note.createdAt).prevText }}</span> + <span style="height: 1em; width: 1px; background: var(--MI_THEME-divider);"></span> + <span>{{ getSeparatorInfo(pagingComponent.paginator.items.value[i -1].createdAt, note.createdAt).nextText }} <i class="ti ti-chevron-down"></i></span> + </div> + <MkNote :class="$style.note" :note="note" :withHardMute="true"/> + </div> + <div v-else-if="note._shouldInsertAd_" :class="[$style.noteWithAd, { '_gaps': !noGap }]" :data-scroll-anchor="note.id"> <MkNote :class="$style.note" :note="note" :withHardMute="true"/> <div :class="$style.ad"> <MkAd :preferForms="['horizontal', 'horizontal-big']"/> @@ -23,32 +31,40 @@ SPDX-License-Identifier: AGPL-3.0-only </MkPagination> </template> -<script lang="ts" setup> +<script lang="ts" setup generic="T extends PagingCtx"> import { useTemplateRef } from 'vue'; -import type { Paging } from '@/components/MkPagination.vue'; +import type { PagingCtx } from '@/composables/use-pagination.js'; import MkNote from '@/components/MkNote.vue'; import MkPagination from '@/components/MkPagination.vue'; import { i18n } from '@/i18n.js'; +import { globalEvents, useGlobalEvent } from '@/events.js'; +import { isSeparatorNeeded, getSeparatorInfo } from '@/utility/timeline-date-separate.js'; -const props = defineProps<{ - pagination: Paging; +const props = withDefaults(defineProps<{ + pagination: T; noGap?: boolean; disableAutoLoad?: boolean; -}>(); + pullToRefresh?: boolean; +}>(), { + pullToRefresh: true, +}); const pagingComponent = useTemplateRef('pagingComponent'); +useGlobalEvent('noteDeleted', (noteId) => { + pagingComponent.value?.paginator.removeItem(noteId); +}); + +function reload() { + return pagingComponent.value?.paginator.reload(); +} + defineExpose({ - pagingComponent, + reload, }); </script> <style lang="scss" module> -.reverse { - display: flex; - flex-direction: column-reverse; -} - .root { container-type: inline-size; @@ -77,6 +93,18 @@ defineExpose({ } } +.date { + display: flex; + font-size: 85%; + align-items: center; + justify-content: center; + gap: 1em; + opacity: 0.75; + padding: 8px 8px; + margin: 0 auto; + border-bottom: solid 0.5px var(--MI_THEME-divider); +} + .ad:empty { display: none; } diff --git a/packages/frontend/src/components/MkNotifications.vue b/packages/frontend/src/components/MkNotifications.vue deleted file mode 100644 index 3c88b8af0d..0000000000 --- a/packages/frontend/src/components/MkNotifications.vue +++ /dev/null @@ -1,142 +0,0 @@ -<!-- -SPDX-FileCopyrightText: syuilo and misskey-project -SPDX-License-Identifier: AGPL-3.0-only ---> - -<template> -<component :is="prefer.s.enablePullToRefresh ? MkPullToRefresh : 'div'" :refresher="() => reload()"> - <MkPagination ref="pagingComponent" :pagination="pagination"> - <template #empty><MkResult type="empty" :text="i18n.ts.noNotifications"/></template> - - <template #default="{ items: notifications }"> - <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" - > - <template v-for="(notification, i) in notifications" :key="notification.id"> - <MkNote 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"/> - </template> - </component> - </template> - </MkPagination> -</component> -</template> - -<script lang="ts" setup> -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 MkNote from '@/components/MkNote.vue'; -import { useStream } from '@/stream.js'; -import { i18n } from '@/i18n.js'; -import MkPullToRefresh from '@/components/MkPullToRefresh.vue'; -import { prefer } from '@/preferences.js'; - -const props = defineProps<{ - excludeTypes?: typeof notificationTypes[number][]; -}>(); - -const pagingComponent = useTemplateRef('pagingComponent'); - -const pagination = computed(() => prefer.r.useGroupedNotifications.value ? { - endpoint: 'i/notifications-grouped' as const, - limit: 20, - params: computed(() => ({ - excludeTypes: props.excludeTypes ?? undefined, - })), -} : { - endpoint: 'i/notifications' as const, - limit: 20, - params: computed(() => ({ - excludeTypes: props.excludeTypes ?? undefined, - })), -}); - -function onNotification(notification) { - const isMuted = props.excludeTypes ? props.excludeTypes.includes(notification.type) : false; - if (isMuted || window.document.visibilityState === 'visible') { - useStream().send('readNotification'); - } - - if (!isMuted) { - pagingComponent.value?.prepend(notification); - } -} - -function reload() { - return new Promise<void>((res) => { - pagingComponent.value?.reload().then(() => { - res(); - }); - }); -} - -let connection: Misskey.ChannelConnection<Misskey.Channels['main']>; - -onMounted(() => { - connection = useStream().useChannel('main'); - connection.on('notification', onNotification); - connection.on('notificationFlushed', reload); -}); - -onUnmounted(() => { - if (connection) connection.dispose(); -}); - -defineExpose({ - reload, -}); -</script> - -<style lang="scss" module> -.transition_x_move { - transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1); -} - -.transition_x_enterActive { - transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1); - - &.item, - .item { - /* Skip Note Rendering有効時、TransitionGroupで通知を追加するときに一瞬がくっとなる問題を抑制する */ - content-visibility: visible !important; - } -} - -.transition_x_leaveActive { - transition: height 0.2s cubic-bezier(0,.5,.5,1), opacity 0.2s cubic-bezier(0,.5,.5,1); -} - -.transition_x_enterFrom { - opacity: 0; - transform: translateY(max(-64px, -100%)); -} - -@supports (interpolate-size: allow-keywords) { - .transition_x_enterFrom { - interpolate-size: allow-keywords; // heightのtransitionを動作させるために必要 - height: 0; - } -} - -.transition_x_leaveTo { - opacity: 0; -} - -.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/MkPagination.vue b/packages/frontend/src/components/MkPagination.vue index 54da5a889d..681abd2eff 100644 --- a/packages/frontend/src/components/MkPagination.vue +++ b/packages/frontend/src/components/MkPagination.vue @@ -4,483 +4,82 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<Transition - :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"/> +<component :is="prefer.s.enablePullToRefresh && pullToRefresh ? MkPullToRefresh : 'div'" :refresher="() => paginator.reload()"> + <!-- :css="prefer.s.animation" にしたいけどバグる(おそらくvueのバグ) https://github.com/misskey-dev/misskey/issues/16078 --> + <Transition + :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="paginator.fetching.value"/> - <MkError v-else-if="error" @retry="init()"/> + <MkError v-else-if="paginator.error.value" @retry="paginator.init()"/> - <div v-else-if="empty" key="_empty_"> - <slot name="empty"><MkResult type="empty"/></slot> - </div> - - <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/> + <div v-else-if="paginator.items.value.length === 0" key="_empty_"> + <slot name="empty"><MkResult type="empty"/></slot> </div> - <slot :items="Array.from(items.values())" :fetching="fetching || moreFetching"></slot> - <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/> + + <div v-else ref="rootEl" class="_gaps"> + <div v-show="pagination.reversed && paginator.canFetchOlder.value" key="_more_"> + <MkButton v-if="!paginator.fetchingOlder.value" v-appear="(prefer.s.enableInfiniteScroll && !props.disableAutoLoad) ? appearFetchMoreAhead : null" :class="$style.more" :wait="paginator.fetchingOlder.value" primary rounded @click="paginator.fetchNewer"> + {{ i18n.ts.loadMore }} + </MkButton> + <MkLoading v-else/> + </div> + <slot :items="paginator.items.value" :fetching="paginator.fetching.value || paginator.fetchingOlder.value"></slot> + <div v-show="!pagination.reversed && paginator.canFetchOlder.value" key="_more_"> + <MkButton v-if="!paginator.fetchingOlder.value" v-appear="(prefer.s.enableInfiniteScroll && !props.disableAutoLoad) ? appearFetchMore : null" :class="$style.more" :wait="paginator.fetchingOlder.value" primary rounded @click="paginator.fetchOlder"> + {{ i18n.ts.loadMore }} + </MkButton> + <MkLoading v-else/> + </div> </div> - </div> -</Transition> + </Transition> +</component> </template> -<script lang="ts"> -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, isHeadVisible, getBodyScrollHeight, getScrollContainer, onScrollBottom, scrollToBottom, scrollInContainer, isTailVisible } from '@@/js/scroll.js'; -import type { ComputedRef } from 'vue'; -import type { MisskeyEntity } from '@/types/date-separated-list.js'; -import { misskeyApi } from '@/utility/misskey-api.js'; +<script lang="ts" setup generic="T extends PagingCtx"> +import type { PagingCtx } from '@/composables/use-pagination.js'; +import type { UnwrapRef } from 'vue'; +import MkButton from '@/components/MkButton.vue'; import { i18n } from '@/i18n.js'; import { prefer } from '@/preferences.js'; +import { usePagination } from '@/composables/use-pagination.js'; +import MkPullToRefresh from '@/components/MkPullToRefresh.vue'; -const SECOND_FETCH_LIMIT = 30; -const TOLERANCE = 16; -const APPEAR_MINIMUM_INTERVAL = 600; - -export type Paging<E extends keyof Misskey.Endpoints = keyof Misskey.Endpoints> = { - endpoint: E; - limit: number; - params?: Misskey.Endpoints[E]['req'] | ComputedRef<Misskey.Endpoints[E]['req']>; - - /** - * 検索APIのような、ページング不可なエンドポイントを利用する場合 - * (そのようなAPIをこの関数で使うのは若干矛盾してるけど) - */ - noPaging?: boolean; - - /** - * items 配列の中身を逆順にする(新しい方が最後) - */ - reversed?: boolean; - - offsetMode?: boolean; -}; - -type MisskeyEntityMap = Map<string, MisskeyEntity>; - -function arrayToEntries(entities: MisskeyEntity[]): [string, MisskeyEntity][] { - return entities.map(en => [en.id, en]); -} - -function concatMapWithArray(map: MisskeyEntityMap, entities: MisskeyEntity[]): MisskeyEntityMap { - return new Map([...map, ...arrayToEntries(entities)]); -} - -</script> -<script lang="ts" setup> -import MkButton from '@/components/MkButton.vue'; +type Paginator = ReturnType<typeof usePagination<T['endpoint']>>; const props = withDefaults(defineProps<{ - pagination: Paging; + pagination: T; disableAutoLoad?: boolean; displayLimit?: number; + pullToRefresh?: boolean; }>(), { displayLimit: 20, + pullToRefresh: true, }); -const emit = defineEmits<{ - (ev: 'queue', count: number): void; - (ev: 'status', error: boolean): void; -}>(); - -const rootEl = useTemplateRef('rootEl'); - -// 遡り中かどうか -const backed = ref(false); - -const scrollRemove = ref<(() => void) | null>(null); - -/** - * 表示するアイテムのソース - * 最新が0番目 - */ -const items = ref<MisskeyEntityMap>(new Map()); - -/** - * タブが非アクティブなどの場合に更新を貯めておく - * 最新が0番目 - */ -const queue = ref<MisskeyEntityMap>(new Map()); - -/** - * 初期化中かどうか(trueならMkLoadingで全て隠す) - */ -const fetching = ref(true); - -const moreFetching = ref(false); -const more = ref(false); -const preventAppearFetchMore = ref(false); -const preventAppearFetchMoreTimer = ref<number | null>(null); -const isBackTop = ref(false); -const empty = computed(() => items.value.size === 0); -const error = ref(false); -const { - enableInfiniteScroll, -} = prefer.r; - -const scrollableElement = computed(() => rootEl.value ? getScrollContainer(rootEl.value) : window.document.body); - -const visibility = useDocumentVisibility(); - -let isPausingUpdate = false; -let timerForSetPause: number | null = null; -const BACKGROUND_PAUSE_WAIT_SEC = 10; - -// 先頭が表示されているかどうかを検出 -// https://qiita.com/mkataigi/items/0154aefd2223ce23398e -const scrollObserver = ref<IntersectionObserver>(); - -watch([() => props.pagination.reversed, scrollableElement], () => { - if (scrollObserver.value) scrollObserver.value.disconnect(); - - scrollObserver.value = new IntersectionObserver(entries => { - backed.value = entries[0].isIntersecting; - }, { - root: scrollableElement.value, - rootMargin: props.pagination.reversed ? '-100% 0px 100% 0px' : '100% 0px -100% 0px', - threshold: 0.01, - }); -}, { immediate: true }); - -watch(rootEl, () => { - scrollObserver.value?.disconnect(); - nextTick(() => { - if (rootEl.value) scrollObserver.value?.observe(rootEl.value); - }); -}); - -watch([backed, rootEl], () => { - if (!backed.value) { - if (!rootEl.value) return; - - scrollRemove.value = props.pagination.reversed - ? onScrollBottom(rootEl.value, executeQueue, TOLERANCE) - : onScrollTop(rootEl.value, (topVisible) => { if (topVisible) executeQueue(); }, TOLERANCE); - } else { - if (scrollRemove.value) scrollRemove.value(); - scrollRemove.value = null; - } -}); - -// パラメータに何らかの変更があった際、再読込したい(チャンネル等のIDが変わったなど) -watch(() => [props.pagination.endpoint, props.pagination.params], init, { deep: true }); - -watch(queue, (a, b) => { - if (a.size === 0 && b.size === 0) return; - emit('queue', queue.value.size); -}, { deep: true }); - -watch(error, (n, o) => { - if (n === o) return; - emit('status', n); -}); - -async function init(): Promise<void> { - items.value = new Map(); - queue.value = new Map(); - fetching.value = true; - const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {}; - await misskeyApi<MisskeyEntity[]>(props.pagination.endpoint, { - ...params, - limit: props.pagination.limit ?? 10, - allowPartial: true, - }).then(res => { - for (let i = 0; i < res.length; i++) { - const item = res[i]; - if (i === 3) item._shouldInsertAd_ = true; - } - - if (res.length === 0 || props.pagination.noPaging) { - concatItems(res); - more.value = false; - } else { - if (props.pagination.reversed) moreFetching.value = true; - concatItems(res); - more.value = true; - } - - error.value = false; - fetching.value = false; - }, err => { - error.value = true; - fetching.value = false; - }); -} - -const reload = (): Promise<void> => { - return init(); -}; - -const fetchMore = async (): Promise<void> => { - if (!more.value || fetching.value || moreFetching.value || items.value.size === 0) return; - moreFetching.value = true; - const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {}; - await misskeyApi<MisskeyEntity[]>(props.pagination.endpoint, { - ...params, - limit: SECOND_FETCH_LIMIT, - ...(props.pagination.offsetMode ? { - offset: items.value.size, - } : { - untilId: Array.from(items.value.keys()).at(-1), - }), - }).then(res => { - for (let i = 0; i < res.length; i++) { - const item = res[i]; - if (i === 10) item._shouldInsertAd_ = true; - } - - const reverseConcat = _res => { - const oldHeight = scrollableElement.value ? scrollableElement.value.scrollHeight : getBodyScrollHeight(); - const oldScroll = scrollableElement.value ? scrollableElement.value.scrollTop : window.scrollY; - - items.value = concatMapWithArray(items.value, _res); - - return nextTick(() => { - if (scrollableElement.value) { - scrollInContainer(scrollableElement.value, { top: oldScroll + (scrollableElement.value.scrollHeight - oldHeight), behavior: 'instant' }); - } else { - window.scroll({ top: oldScroll + (getBodyScrollHeight() - oldHeight), behavior: 'instant' }); - } - - return nextTick(); - }); - }; - - if (res.length === 0) { - if (props.pagination.reversed) { - reverseConcat(res).then(() => { - more.value = false; - moreFetching.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; - }); - } else { - items.value = concatMapWithArray(items.value, res); - more.value = true; - moreFetching.value = false; - } - } - }, err => { - moreFetching.value = false; - }); -}; - -const fetchMoreAhead = async (): Promise<void> => { - if (!more.value || fetching.value || moreFetching.value || items.value.size === 0) return; - moreFetching.value = true; - const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {}; - await misskeyApi<MisskeyEntity[]>(props.pagination.endpoint, { - ...params, - limit: SECOND_FETCH_LIMIT, - ...(props.pagination.offsetMode ? { - offset: items.value.size, - } : { - sinceId: Array.from(items.value.keys()).at(-1), - }), - }).then(res => { - if (res.length === 0) { - items.value = concatMapWithArray(items.value, res); - more.value = false; - } else { - items.value = concatMapWithArray(items.value, res); - more.value = true; - } - moreFetching.value = false; - }, err => { - moreFetching.value = false; - }); -}; - -/** - * Appear(IntersectionObserver)によってfetchMoreが呼ばれる場合、 - * APPEAR_MINIMUM_INTERVALミリ秒以内に2回fetchMoreが呼ばれるのを防ぐ - */ -const fetchMoreApperTimeoutFn = (): void => { - preventAppearFetchMore.value = false; - preventAppearFetchMoreTimer.value = null; -}; -const fetchMoreAppearTimeout = (): void => { - preventAppearFetchMore.value = true; - preventAppearFetchMoreTimer.value = window.setTimeout(fetchMoreApperTimeoutFn, APPEAR_MINIMUM_INTERVAL); -}; - -const appearFetchMore = async (): Promise<void> => { - if (preventAppearFetchMore.value) return; - await fetchMore(); - fetchMoreAppearTimeout(); -}; - -const appearFetchMoreAhead = async (): Promise<void> => { - if (preventAppearFetchMore.value) return; - await fetchMoreAhead(); - fetchMoreAppearTimeout(); -}; - -const isHead = (): boolean => isBackTop.value || (props.pagination.reversed ? isTailVisible : isHeadVisible)(rootEl.value!, TOLERANCE); - -watch(visibility, () => { - if (visibility.value === 'hidden') { - timerForSetPause = window.setTimeout(() => { - isPausingUpdate = true; - timerForSetPause = null; - }, - BACKGROUND_PAUSE_WAIT_SEC * 1000); - } else { // 'visible' - if (timerForSetPause) { - window.clearTimeout(timerForSetPause); - timerForSetPause = null; - } else { - isPausingUpdate = false; - if (isHead()) { - executeQueue(); - } - } - } +const paginator: Paginator = usePagination({ + ctx: props.pagination, }); -/** - * 最新のものとして1つだけアイテムを追加する - * ストリーミングから降ってきたアイテムはこれで追加する - * @param item アイテム - */ -function prepend(item: MisskeyEntity): void { - if (items.value.size === 0) { - items.value.set(item.id, item); - fetching.value = false; - return; - } - - if (_DEV_) console.log(isHead(), isPausingUpdate); - - if (isHead() && !isPausingUpdate) unshiftItems([item]); - else prependQueue(item); -} - -/** - * 新着アイテムをitemsの先頭に追加し、displayLimitを適用する - * @param newItems 新しいアイテムの配列 - */ -function unshiftItems(newItems: MisskeyEntity[]) { - const length = newItems.length + items.value.size; - items.value = new Map([...arrayToEntries(newItems), ...items.value].slice(0, props.displayLimit)); - - if (length >= props.displayLimit) more.value = true; +function appearFetchMoreAhead() { + paginator.fetchNewer(); } -/** - * 古いアイテムをitemsの末尾に追加し、displayLimitを適用する - * @param oldItems 古いアイテムの配列 - */ -function concatItems(oldItems: MisskeyEntity[]) { - const length = oldItems.length + items.value.size; - items.value = new Map([...items.value, ...arrayToEntries(oldItems)].slice(0, props.displayLimit)); - - if (length >= props.displayLimit) more.value = true; -} - -function executeQueue() { - unshiftItems(Array.from(queue.value.values())); - queue.value = new Map(); -} - -function prependQueue(newItem: MisskeyEntity) { - queue.value = new Map([[newItem.id, newItem], ...queue.value].slice(0, props.displayLimit) as [string, MisskeyEntity][]); -} - -/* - * アイテムを末尾に追加する(使うの?) - */ -const appendItem = (item: MisskeyEntity): void => { - items.value.set(item.id, item); -}; - -const removeItem = (id: string) => { - items.value.delete(id); - queue.value.delete(id); -}; - -const updateItem = (id: MisskeyEntity['id'], replacer: (old: MisskeyEntity) => MisskeyEntity): void => { - const item = items.value.get(id); - if (item) items.value.set(id, replacer(item)); - - const queueItem = queue.value.get(id); - if (queueItem) queue.value.set(id, replacer(queueItem)); -}; - -onActivated(() => { - isBackTop.value = false; -}); - -onDeactivated(() => { - isBackTop.value = props.pagination.reversed ? window.scrollY >= (rootEl.value ? rootEl.value.scrollHeight - window.innerHeight : 0) : window.scrollY === 0; -}); - -function toBottom() { - scrollToBottom(rootEl.value!); +function appearFetchMore() { + paginator.fetchOlder(); } -onBeforeMount(() => { - init().then(() => { - if (props.pagination.reversed) { - nextTick(() => { - window.setTimeout(toBottom, 800); - - // scrollToBottomでmoreFetchingボタンが画面外まで出るまで - // more = trueを遅らせる - window.setTimeout(() => { - moreFetching.value = false; - }, 2000); - }); - } - }); -}); - -onBeforeUnmount(() => { - if (timerForSetPause) { - window.clearTimeout(timerForSetPause); - timerForSetPause = null; - } - if (preventAppearFetchMoreTimer.value) { - window.clearTimeout(preventAppearFetchMoreTimer.value); - preventAppearFetchMoreTimer.value = null; - } - scrollObserver.value?.disconnect(); -}); +defineSlots<{ + empty: () => void; + default: (props: { items: UnwrapRef<Paginator['items']> }) => void; +}>(); defineExpose({ - items, - queue, - backed: backed.value, - more, - reload, - prepend, - append: appendItem, - removeItem, - updateItem, + paginator: paginator, }); </script> diff --git a/packages/frontend/src/components/MkPoll.vue b/packages/frontend/src/components/MkPoll.vue index 2d3ec45bca..359ee08812 100644 --- a/packages/frontend/src/components/MkPoll.vue +++ b/packages/frontend/src/components/MkPoll.vue @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div :class="{ [$style.done]: closed || isVoted }"> <ul :class="$style.choices"> - <li v-for="(choice, i) in poll.choices" :key="i" :class="$style.choice" @click="vote(i)"> + <li v-for="(choice, i) in choices" :key="i" :class="$style.choice" @click="vote(i)"> <div :class="$style.bg" :style="{ 'width': `${showResult ? (choice.votes / total * 100) : 0}%` }"></div> <span :class="$style.fg"> <template v-if="choice.isVoted"><i class="ti ti-check" style="margin-right: 4px; color: var(--MI_THEME-accent);"></i></template> @@ -40,7 +40,9 @@ import { i18n } from '@/i18n.js'; const props = defineProps<{ noteId: string; - poll: NonNullable<Misskey.entities.Note['poll']>; + multiple: NonNullable<Misskey.entities.Note['poll']>['multiple']; + expiresAt: NonNullable<Misskey.entities.Note['poll']>['expiresAt']; + choices: NonNullable<Misskey.entities.Note['poll']>['choices']; readOnly?: boolean; emojiUrls?: Record<string, string>; author?: Misskey.entities.UserLite; @@ -48,9 +50,9 @@ const props = defineProps<{ const remaining = ref(-1); -const total = computed(() => sum(props.poll.choices.map(x => x.votes))); +const total = computed(() => sum(props.choices.map(x => x.votes))); const closed = computed(() => remaining.value === 0); -const isVoted = computed(() => !props.poll.multiple && props.poll.choices.some(c => c.isVoted)); +const isVoted = computed(() => !props.multiple && props.choices.some(c => c.isVoted)); const timer = computed(() => i18n.tsx._poll[ remaining.value >= 86400 ? 'remainingDays' : remaining.value >= 3600 ? 'remainingHours' : @@ -70,9 +72,9 @@ const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({ })); // 期限付きアンケート -if (props.poll.expiresAt) { +if (props.expiresAt) { const tick = () => { - remaining.value = Math.floor(Math.max(new Date(props.poll.expiresAt!).getTime() - Date.now(), 0) / 1000); + remaining.value = Math.floor(Math.max(new Date(props.expiresAt!).getTime() - Date.now(), 0) / 1000); if (remaining.value === 0) { showResult.value = true; } @@ -91,7 +93,7 @@ const vote = async (id) => { const { canceled } = await os.confirm({ type: 'question', - text: i18n.tsx.voteConfirm({ choice: props.poll.choices[id].text }), + text: i18n.tsx.voteConfirm({ choice: props.choices[id].text }), }); if (canceled) return; @@ -99,7 +101,7 @@ const vote = async (id) => { noteId: props.noteId, choice: id, }); - if (!showResult.value) showResult.value = !props.poll.multiple; + if (!showResult.value) showResult.value = !props.multiple; }; </script> diff --git a/packages/frontend/src/components/MkPopupMenu.vue b/packages/frontend/src/components/MkPopupMenu.vue index 232cc005e1..4942ffe232 100644 --- a/packages/frontend/src/components/MkPopupMenu.vue +++ b/packages/frontend/src/components/MkPopupMenu.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkModal ref="modal" v-slot="{ type, maxHeight }" :manualShowing="manualShowing" :zPriority="'high'" :src="src" :transparentBg="true" :returnFocusTo="returnFocusTo" @click="click" @close="onModalClose" @closed="onModalClosed"> +<MkModal ref="modal" v-slot="{ type, maxHeight }" :manualShowing="manualShowing" :zPriority="'high'" :anchorElement="anchorElement" :transparentBg="true" :returnFocusTo="returnFocusTo" @click="click" @close="onModalClose" @closed="onModalClosed"> <MkMenu :items="items" :align="align" :width="width" :max-height="maxHeight" :asDrawer="type === 'drawer'" :returnFocusTo="returnFocusTo" :class="{ [$style.drawer]: type === 'drawer' }" @close="onMenuClose" @hide="hide"/> </MkModal> </template> @@ -19,7 +19,7 @@ defineProps<{ items: MenuItem[]; align?: 'center' | string; width?: number; - src?: HTMLElement | null; + anchorElement?: HTMLElement | null; returnFocusTo?: HTMLElement | null; }>(); diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index c4857b7f65..982ed88003 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -71,7 +71,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-if="maxTextLength - textLength < 100" :class="['_acrylic', $style.textCount, { [$style.textOver]: textLength > maxTextLength }]">{{ maxTextLength - textLength }}</div> </div> <input v-show="withHashtags" ref="hashtagsInputEl" v-model="hashtags" :class="$style.hashtags" :placeholder="i18n.ts.hashtags" list="hashtags"> - <XPostFormAttaches v-model="files" @detach="detachFile" @changeSensitive="updateFileSensitive" @changeName="updateFileName" @replaceFile="replaceFile"/> + <XPostFormAttaches v-model="files" @detach="detachFile" @changeSensitive="updateFileSensitive" @changeName="updateFileName"/> <MkPollEditor v-if="poll" v-model="poll" @destroyed="poll = null"/> <MkNotePreview v-if="showPreview" :class="$style.preview" :text="text" :files="files" :poll="poll ?? undefined" :useCw="useCw" :cw="cw" :user="postAccount ?? $i"/> <div v-if="showingOptions" style="padding: 8px 16px;"> @@ -120,14 +120,13 @@ import { formatTimeString } from '@/utility/format-time-string.js'; import { Autocomplete } from '@/utility/autocomplete.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; -import { selectFiles } from '@/utility/select-file.js'; +import { selectFiles } from '@/utility/drive.js'; import { store } from '@/store.js'; import MkInfo from '@/components/MkInfo.vue'; import { i18n } from '@/i18n.js'; import { instance } from '@/instance.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'; @@ -137,6 +136,8 @@ import { mfmFunctionPicker } from '@/utility/mfm-function-picker.js'; import { prefer } from '@/preferences.js'; import { getPluginHandlers } from '@/plugin.js'; import { DI } from '@/di.js'; +import { globalEvents } from '@/events.js'; +import { checkDragDataType, getDragData } from '@/drag-and-drop.js'; const $i = ensureSignin(); @@ -458,18 +459,6 @@ function updateFileName(file, name) { files.value[files.value.findIndex(x => x.id === file.id)].name = name; } -function replaceFile(file: Misskey.entities.DriveFile, newFile: Misskey.entities.DriveFile): void { - files.value[files.value.findIndex(x => x.id === file.id)] = newFile; -} - -function upload(file: File, name?: string): void { - if (props.mock) return; - - uploadFile(file, prefer.s.uploadFolder, name).then(res => { - files.value.push(res); - }); -} - function setVisibility() { if (props.channel) { visibility.value = 'public'; @@ -481,7 +470,7 @@ function setVisibility() { currentVisibility: visibility.value, isSilenced: $i.isSilenced, localOnly: localOnly.value, - src: visibilityButton.value, + anchorElement: visibilityButton.value, ...(props.reply ? { isReplyVisibilitySpecified: props.reply.visibility === 'specified' } : {}), }, { changeVisibility: v => { @@ -650,16 +639,25 @@ async function onPaste(ev: ClipboardEvent) { if (props.mock) return; if (!ev.clipboardData) return; + let pastedFiles: File[] = []; for (const { item, i } of Array.from(ev.clipboardData.items, (data, x) => ({ item: data, i: x }))) { if (item.kind === 'file') { const file = item.getAsFile(); if (!file) continue; const lio = file.name.lastIndexOf('.'); const ext = lio >= 0 ? file.name.slice(lio) : ''; - const formatted = `${formatTimeString(new Date(file.lastModified), pastedFileName).replace(/{{number}}/g, `${i + 1}`)}${ext}`; - upload(file, formatted); + const formattedName = `${formatTimeString(new Date(file.lastModified), pastedFileName).replace(/{{number}}/g, `${i + 1}`)}${ext}`; + const renamedFile = new File([file], formattedName, { type: file.type }); + pastedFiles.push(renamedFile); } } + if (pastedFiles.length > 0) { + ev.preventDefault(); + os.launchUploader(pastedFiles, {}).then(driveFiles => { + files.value.push(...driveFiles); + }); + return; + } const paste = ev.clipboardData.getData('text'); @@ -692,7 +690,9 @@ async function onPaste(ev: ClipboardEvent) { const fileName = formatTimeString(new Date(), pastedFileName).replace(/{{number}}/g, '0'); const file = new File([paste], `${fileName}.txt`, { type: 'text/plain' }); - upload(file, `${fileName}.txt`); + os.launchUploader([file], {}).then(driveFiles => { + files.value.push(...driveFiles); + }); }); } } @@ -700,8 +700,7 @@ async function onPaste(ev: ClipboardEvent) { function onDragover(ev) { if (!ev.dataTransfer.items[0]) return; const isFile = ev.dataTransfer.items[0].kind === 'file'; - const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_; - if (isFile || isDriveFile) { + if (isFile || checkDragDataType(ev, ['driveFiles'])) { ev.preventDefault(); draghover.value = true; switch (ev.dataTransfer.effectAllowed) { @@ -737,16 +736,19 @@ function onDrop(ev: DragEvent): void { // ファイルだったら if (ev.dataTransfer && ev.dataTransfer.files.length > 0) { ev.preventDefault(); - for (const x of Array.from(ev.dataTransfer.files)) upload(x); + os.launchUploader(Array.from(ev.dataTransfer.files), {}).then(driveFiles => { + files.value.push(...driveFiles); + }); return; } //#region ドライブのファイル - const driveFile = ev.dataTransfer?.getData(_DATA_TRANSFER_DRIVE_FILE_); - if (driveFile != null && driveFile !== '') { - const file = JSON.parse(driveFile); - files.value.push(file); - ev.preventDefault(); + { + const droppedData = getDragData(ev, 'driveFiles'); + if (droppedData != null) { + files.value.push(...droppedData); + ev.preventDefault(); + } } //#endregion } @@ -883,12 +885,15 @@ async function post(ev?: MouseEvent) { } posting.value = true; - misskeyApi('notes/create', postData, token).then(() => { + misskeyApi('notes/create', postData, token).then((res) => { if (props.freezeAfterPosted) { posted.value = true; } else { clear(); } + + globalEvents.emit('notePosted', res.createdNote); + nextTick(() => { deleteDraft(); emit('posted'); diff --git a/packages/frontend/src/components/MkPostFormAttaches.vue b/packages/frontend/src/components/MkPostFormAttaches.vue index e8404cbd4f..dd594ef7f1 100644 --- a/packages/frontend/src/components/MkPostFormAttaches.vue +++ b/packages/frontend/src/components/MkPostFormAttaches.vue @@ -43,6 +43,7 @@ import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; import { prefer } from '@/preferences.js'; import { DI } from '@/di.js'; +import { globalEvents } from '@/events.js'; const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default)); @@ -58,7 +59,6 @@ const emit = defineEmits<{ (ev: 'detach', id: string): void; (ev: 'changeSensitive', file: Misskey.entities.DriveFile, isSensitive: boolean): void; (ev: 'changeName', file: Misskey.entities.DriveFile, newName: string): void; - (ev: 'replaceFile', file: Misskey.entities.DriveFile, newFile: Misskey.entities.DriveFile): void; }>(); let menuShowing = false; @@ -82,12 +82,13 @@ async function detachAndDeleteMedia(file: Misskey.entities.DriveFile) { type: 'warning', text: i18n.tsx.driveFileDeleteConfirm({ name: file.name }), }); - if (canceled) return; - os.apiWithDialog('drive/files/delete', { + await os.apiWithDialog('drive/files/delete', { fileId: file.id, }); + + globalEvents.emit('driveFilesDeleted', [file]); } function toggleSensitive(file) { @@ -142,13 +143,6 @@ async function describe(file: Misskey.entities.DriveFile) { }); } -async function crop(file: Misskey.entities.DriveFile): Promise<void> { - if (mock) return; - - const newFile = await os.cropImage(file, { aspectRatio: NaN }); - emit('replaceFile', file, newFile); -} - function showFileMenu(file: Misskey.entities.DriveFile, ev: MouseEvent | KeyboardEvent): void { if (menuShowing) return; @@ -172,10 +166,6 @@ function showFileMenu(file: Misskey.entities.DriveFile, ev: MouseEvent | Keyboar if (isImage) { menuItems.push({ - text: i18n.ts.cropImage, - icon: 'ti ti-crop', - action: () : void => { crop(file); }, - }, { text: i18n.ts.preview, icon: 'ti ti-photo-search', action: () => { diff --git a/packages/frontend/src/components/MkPreview.vue b/packages/frontend/src/components/MkPreview.vue index d8dfbd1655..6c7bf6be6b 100644 --- a/packages/frontend/src/components/MkPreview.vue +++ b/packages/frontend/src/components/MkPreview.vue @@ -18,8 +18,8 @@ SPDX-License-Identifier: AGPL-3.0-only <MkRadio v-model="radio" value="pleroma">Pleroma</MkRadio> </div> <div :class="$style.preview__content1__button"> - <MkButton inline>This is</MkButton> - <MkButton inline primary>the button</MkButton> + <MkButton inline>This is</MkButton> + <MkButton inline primary>the button</MkButton> </div> </div> <div :class="$style.preview__content2" style="pointer-events: none;"> @@ -36,14 +36,15 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref } from 'vue'; +import * as config from '@@/js/config.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 MkRadio from '@/components/MkRadio.vue'; import * as os from '@/os.js'; -import * as config from '@@/js/config.js'; import { $i } from '@/i.js'; +import { chooseDriveFile } from '@/utility/drive.js'; const text = ref(''); const flag = ref(true); @@ -79,7 +80,9 @@ const openForm = async () => { }; const openDrive = async () => { - await os.selectDriveFile(false); + await chooseDriveFile({ + multiple: false, + }); }; const selectUser = async () => { diff --git a/packages/frontend/src/components/MkRadio.vue b/packages/frontend/src/components/MkRadio.vue index f16c8f6c2a..a7d77dd118 100644 --- a/packages/frontend/src/components/MkRadio.vue +++ b/packages/frontend/src/components/MkRadio.vue @@ -48,7 +48,8 @@ function toggle(): void { <style lang="scss" module> .root { position: relative; - display: inline-block; + display: inline-flex; + align-items: center; text-align: left; cursor: pointer; padding: 7px 10px; @@ -102,7 +103,8 @@ function toggle(): void { } .button { - position: absolute; + position: relative; + display: inline-block; width: 14px; height: 14px; background: none; @@ -126,7 +128,7 @@ function toggle(): void { } .label { - margin-left: 28px; + margin-left: 8px; display: block; line-height: 20px; cursor: pointer; diff --git a/packages/frontend/src/components/MkRadios.vue b/packages/frontend/src/components/MkRadios.vue index 884890bf70..8b641d0f93 100644 --- a/packages/frontend/src/components/MkRadios.vue +++ b/packages/frontend/src/components/MkRadios.vue @@ -5,14 +5,18 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts"> import { defineComponent, h, ref, watch } from 'vue'; -import type { VNode } from 'vue'; import MkRadio from './MkRadio.vue'; +import type { VNode } from 'vue'; export default defineComponent({ props: { modelValue: { required: false, }, + vertical: { + type: Boolean, + default: false, + }, }, setup(props, context) { const value = ref(props.modelValue); @@ -34,7 +38,10 @@ export default defineComponent({ options = options.filter(vnode => !(typeof vnode.type === 'symbol' && vnode.type.description === 'v-cmt' && vnode.children === 'v-if')); return () => h('div', { - class: 'novjtcto', + class: [ + 'novjtcto', + ...(props.vertical ? ['vertical'] : []), + ], }, [ ...(label ? [h('div', { class: 'label', @@ -71,7 +78,7 @@ export default defineComponent({ > .body { display: flex; - gap: 12px; + gap: 10px; flex-wrap: wrap; } @@ -84,5 +91,11 @@ export default defineComponent({ display: none; } } + + &.vertical { + > .body { + flex-direction: column; + } + } } </style> diff --git a/packages/frontend/src/components/MkRange.vue b/packages/frontend/src/components/MkRange.vue index 4b2e6910db..f36e68b687 100644 --- a/packages/frontend/src/components/MkRange.vue +++ b/packages/frontend/src/components/MkRange.vue @@ -9,6 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only <slot name="label"></slot> </div> <div v-adaptive-border class="body"> + <slot name="prefix"></slot> <div ref="containerEl" class="container"> <div class="track"> <div class="highlight" :style="{ width: (steppedRawValue * 100) + '%' }"></div> @@ -25,6 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only @touchstart="onMousedown" ></div> </div> + <slot name="suffix"></slot> </div> <div class="caption"> <slot name="caption"></slot> @@ -224,12 +226,17 @@ function onMousedown(ev: MouseEvent | TouchEvent) { $thumbWidth: 20px; > .body { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; padding: 7px 12px; background: var(--MI_THEME-panel); border: solid 1px var(--MI_THEME-panel); border-radius: 6px; > .container { + flex: 1; position: relative; height: $thumbHeight; diff --git a/packages/frontend/src/components/MkReactionIcon.vue b/packages/frontend/src/components/MkReactionIcon.vue index 453253f0fc..36d1103549 100644 --- a/packages/frontend/src/components/MkReactionIcon.vue +++ b/packages/frontend/src/components/MkReactionIcon.vue @@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { defineAsyncComponent, useTemplateRef } from 'vue'; -import { useTooltip } from '@/use/use-tooltip.js'; +import { useTooltip } from '@/composables/use-tooltip.js'; import * as os from '@/os.js'; const props = defineProps<{ diff --git a/packages/frontend/src/components/MkReactionsViewer.reaction.vue b/packages/frontend/src/components/MkReactionsViewer.reaction.vue index 951447f15a..7d76dffa5a 100644 --- a/packages/frontend/src/components/MkReactionsViewer.reaction.vue +++ b/packages/frontend/src/components/MkReactionsViewer.reaction.vue @@ -8,11 +8,11 @@ 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]: prefer.s.reactionsDisplaySize === 'small', [$style.large]: prefer.s.reactionsDisplaySize === 'large' }]" + :class="[$style.root, { [$style.reacted]: myReaction == reaction, [$style.canToggle]: canToggle, [$style.small]: prefer.s.reactionsDisplaySize === 'small', [$style.large]: prefer.s.reactionsDisplaySize === 'large' }]" @click="toggleReaction()" @contextmenu.prevent.stop="menu" > - <MkReactionIcon style="pointer-events: none;" :class="prefer.s.limitWidthOfReaction ? $style.limitWidth : ''" :reaction="reaction" :emojiUrl="note.reactionEmojis[reaction.substring(1, reaction.length - 1)]"/> + <MkReactionIcon style="pointer-events: none;" :class="prefer.s.limitWidthOfReaction ? $style.limitWidth : ''" :reaction="reaction" :emojiUrl="reactionEmojis[reaction.substring(1, reaction.length - 1)]"/> <span :class="$style.count">{{ count }}</span> </button> </template> @@ -22,26 +22,30 @@ 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 type { MenuItem } from '@/types/menu'; import XDetails from '@/components/MkReactionsViewer.details.vue'; import MkReactionIcon from '@/components/MkReactionIcon.vue'; import * as os from '@/os.js'; import { misskeyApi, misskeyApiGet } from '@/utility/misskey-api.js'; -import { useTooltip } from '@/use/use-tooltip.js'; +import { useTooltip } from '@/composables/use-tooltip.js'; import { $i } from '@/i.js'; import MkReactionEffect from '@/components/MkReactionEffect.vue'; -import { claimAchievement } from '@/utility/achievements.js'; import { i18n } from '@/i18n.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'; +import { noteEvents } from '@/composables/use-note-capture.js'; +import { mute as muteEmoji, unmute as unmuteEmoji, checkMuted as isEmojiMuted } from '@/utility/emoji-mute.js'; const props = defineProps<{ + noteId: Misskey.entities.Note['id']; reaction: string; + reactionEmojis: Misskey.entities.Note['reactionEmojis']; + myReaction: Misskey.entities.Note['myReaction']; count: number; isInitial: boolean; - note: Misskey.entities.Note; }>(); const mock = inject(DI.mock, false); @@ -56,14 +60,17 @@ const emojiName = computed(() => props.reaction.replace(/:/g, '').replace(/@\./, const emoji = computed(() => customEmojisMap.get(emojiName.value) ?? getUnicodeEmoji(props.reaction)); const canToggle = computed(() => { - return !props.reaction.match(/@\w/) && $i && emoji.value && checkReactionPermissions($i, props.note, emoji.value); + // TODO + //return !props.reaction.match(/@\w/) && $i && emoji.value && checkReactionPermissions($i, props.note, emoji.value); + return !props.reaction.match(/@\w/) && $i && emoji.value; }); const canGetInfo = computed(() => !props.reaction.match(/@\w/) && props.reaction.includes(':')); +const isLocalCustomEmoji = props.reaction[0] === ':' && props.reaction.includes('@.'); async function toggleReaction() { if (!canToggle.value) return; - const oldReaction = props.note.myReaction; + const oldReaction = props.myReaction; if (oldReaction) { const confirm = await os.confirm({ type: 'warning', @@ -81,12 +88,22 @@ async function toggleReaction() { } misskeyApi('notes/reactions/delete', { - noteId: props.note.id, + noteId: props.noteId, }).then(() => { + noteEvents.emit(`unreacted:${props.noteId}`, { + userId: $i!.id, + reaction: oldReaction, + }); if (oldReaction !== props.reaction) { misskeyApi('notes/reactions/create', { - noteId: props.note.id, + noteId: props.noteId, reaction: props.reaction, + }).then(() => { + noteEvents.emit(`reacted:${props.noteId}`, { + userId: $i!.id, + reaction: props.reaction, + emoji: emoji.value, + }); }); } }); @@ -108,31 +125,72 @@ async function toggleReaction() { } misskeyApi('notes/reactions/create', { - noteId: props.note.id, + noteId: props.noteId, reaction: props.reaction, + }).then(() => { + noteEvents.emit(`reacted:${props.noteId}`, { + userId: $i!.id, + reaction: props.reaction, + emoji: emoji.value, + }); }); - if (props.note.text && props.note.text.length > 100 && (Date.now() - new Date(props.note.createdAt).getTime() < 1000 * 3)) { - claimAchievement('reactWithoutRead'); - } + // TODO: 上位コンポーネントでやる + //if (props.note.text && props.note.text.length > 100 && (Date.now() - new Date(props.note.createdAt).getTime() < 1000 * 3)) { + // claimAchievement('reactWithoutRead'); + //} } } async function menu(ev) { - if (!canGetInfo.value) return; + let menuItems: MenuItem[] = []; - os.popupMenu([{ - text: i18n.ts.info, - icon: 'ti ti-info-circle', - action: async () => { - const { dispose } = os.popup(MkCustomEmojiDetailedDialog, { - emoji: await misskeyApiGet('emoji', { - name: props.reaction.replace(/:/g, '').replace(/@\./, ''), - }), - }, { - closed: () => dispose(), - }); - }, - }], ev.currentTarget ?? ev.target); + if (canGetInfo.value) { + menuItems.push({ + text: i18n.ts.info, + icon: 'ti ti-info-circle', + action: async () => { + const { dispose } = os.popup(MkCustomEmojiDetailedDialog, { + emoji: await misskeyApiGet('emoji', { + name: props.reaction.replace(/:/g, '').replace(/@\./, ''), + }), + }, { + closed: () => dispose(), + }); + }, + }); + } + + if (isEmojiMuted(props.reaction).value) { + menuItems.push({ + text: i18n.ts.emojiUnmute, + icon: 'ti ti-mood-smile', + action: () => { + os.confirm({ + type: 'question', + title: i18n.tsx.unmuteX({ x: isLocalCustomEmoji ? `:${emojiName.value}:` : props.reaction }), + }).then(({ canceled }) => { + if (canceled) return; + unmuteEmoji(props.reaction); + }); + }, + }); + } else { + menuItems.push({ + text: i18n.ts.emojiMute, + icon: 'ti ti-mood-off', + action: () => { + os.confirm({ + type: 'question', + title: i18n.tsx.muteX({ x: isLocalCustomEmoji ? `:${emojiName.value}:` : props.reaction }), + }).then(({ canceled }) => { + if (canceled) return; + muteEmoji(props.reaction); + }); + }, + }); + } + + os.popupMenu(menuItems, ev.currentTarget ?? ev.target); } function anime() { @@ -157,7 +215,7 @@ onMounted(() => { if (!mock) { useTooltip(buttonEl, async (showing) => { const reactions = await misskeyApiGet('notes/reactions', { - noteId: props.note.id, + noteId: props.noteId, type: props.reaction, limit: 10, _cacheKey_: props.count, diff --git a/packages/frontend/src/components/MkReactionsViewer.vue b/packages/frontend/src/components/MkReactionsViewer.vue index e8cf6c36db..725978179e 100644 --- a/packages/frontend/src/components/MkReactionsViewer.vue +++ b/packages/frontend/src/components/MkReactionsViewer.vue @@ -13,7 +13,17 @@ SPDX-License-Identifier: AGPL-3.0-only :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"/> + <XReaction + v-for="[reaction, count] in _reactions" + :key="reaction" + :reaction="reaction" + :reactionEmojis="props.reactionEmojis" + :count="count" + :isInitial="initialReactions.has(reaction)" + :noteId="props.noteId" + :myReaction="props.myReaction" + @reactionToggled="onMockToggleReaction" + /> <slot v-if="hasMoreReactions" name="more"/> </component> </template> @@ -27,7 +37,10 @@ import { prefer } from '@/preferences.js'; import { DI } from '@/di.js'; const props = withDefaults(defineProps<{ - note: Misskey.entities.Note; + noteId: Misskey.entities.Note['id']; + reactions: Misskey.entities.Note['reactions']; + reactionEmojis: Misskey.entities.Note['reactionEmojis']; + myReaction: Misskey.entities.Note['myReaction']; maxNumber?: number; }>(), { maxNumber: Infinity, @@ -39,33 +52,33 @@ const emit = defineEmits<{ (ev: 'mockUpdateMyReaction', emoji: string, delta: number): void; }>(); -const initialReactions = new Set(Object.keys(props.note.reactions)); +const initialReactions = new Set(Object.keys(props.reactions)); -const reactions = ref<[string, number][]>([]); +const _reactions = ref<[string, number][]>([]); const hasMoreReactions = ref(false); -if (props.note.myReaction && !Object.keys(reactions.value).includes(props.note.myReaction)) { - reactions.value[props.note.myReaction] = props.note.reactions[props.note.myReaction]; +if (props.myReaction && !Object.keys(_reactions.value).includes(props.myReaction)) { + _reactions.value[props.myReaction] = props.reactions[props.myReaction]; } function onMockToggleReaction(emoji: string, count: number) { if (!mock) return; - const i = reactions.value.findIndex((item) => item[0] === emoji); + const i = _reactions.value.findIndex((item) => item[0] === emoji); if (i < 0) return; - emit('mockUpdateMyReaction', emoji, (count - reactions.value[i][1])); + emit('mockUpdateMyReaction', emoji, (count - _reactions.value[i][1])); } -watch([() => props.note.reactions, () => props.maxNumber], ([newSource, maxNumber]) => { +watch([() => props.reactions, () => props.maxNumber], ([newSource, maxNumber]) => { let newReactions: [string, number][] = []; hasMoreReactions.value = Object.keys(newSource).length > maxNumber; - for (let i = 0; i < reactions.value.length; i++) { - const reaction = reactions.value[i][0]; + for (let i = 0; i < _reactions.value.length; i++) { + const reaction = _reactions.value[i][0]; if (reaction in newSource && newSource[reaction] !== 0) { - reactions.value[i][1] = newSource[reaction]; - newReactions.push(reactions.value[i]); + _reactions.value[i][1] = newSource[reaction]; + newReactions.push(_reactions.value[i]); } } @@ -79,11 +92,11 @@ watch([() => props.note.reactions, () => props.maxNumber], ([newSource, maxNumbe newReactions = newReactions.slice(0, props.maxNumber); - if (props.note.myReaction && !newReactions.map(([x]) => x).includes(props.note.myReaction)) { - newReactions.push([props.note.myReaction, newSource[props.note.myReaction]]); + if (props.myReaction && !newReactions.map(([x]) => x).includes(props.myReaction)) { + newReactions.push([props.myReaction, newSource[props.myReaction]]); } - reactions.value = newReactions; + _reactions.value = newReactions; }, { immediate: true, deep: true }); </script> diff --git a/packages/frontend/src/components/MkRemoteEmojiEditDialog.vue b/packages/frontend/src/components/MkRemoteEmojiEditDialog.vue index cb50df1743..abe6466971 100644 --- a/packages/frontend/src/components/MkRemoteEmojiEditDialog.vue +++ b/packages/frontend/src/components/MkRemoteEmojiEditDialog.vue @@ -56,7 +56,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, ref } from 'vue'; +import { computed, ref, useTemplateRef } from 'vue'; import MkKeyValue from '@/components/MkKeyValue.vue'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; @@ -81,7 +81,7 @@ const emit = defineEmits<{ (ev: 'closed'): void }>(); -const windowEl = ref<InstanceType<typeof MkWindow> | null>(null); +const windowEl = useTemplateRef('windowEl'); const name = computed(() => props.emoji.name); const host = computed(() => props.emoji.host); diff --git a/packages/frontend/src/components/MkRetentionHeatmap.vue b/packages/frontend/src/components/MkRetentionHeatmap.vue index 1ab2397337..a204bc3bf1 100644 --- a/packages/frontend/src/components/MkRetentionHeatmap.vue +++ b/packages/frontend/src/components/MkRetentionHeatmap.vue @@ -17,7 +17,7 @@ import { onMounted, nextTick, useTemplateRef, ref } from 'vue'; import { Chart } from 'chart.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { store } from '@/store.js'; -import { useChartTooltip } from '@/use/use-chart-tooltip.js'; +import { useChartTooltip } from '@/composables/use-chart-tooltip.js'; import { alpha } from '@/utility/color.js'; import { initChart } from '@/utility/init-chart.js'; diff --git a/packages/frontend/src/components/MkRetentionLineChart.vue b/packages/frontend/src/components/MkRetentionLineChart.vue index ba66ffecc0..21c20f944b 100644 --- a/packages/frontend/src/components/MkRetentionLineChart.vue +++ b/packages/frontend/src/components/MkRetentionLineChart.vue @@ -12,7 +12,7 @@ import { onMounted, useTemplateRef } from 'vue'; import { Chart } from 'chart.js'; import tinycolor from 'tinycolor2'; import { store } from '@/store.js'; -import { useChartTooltip } from '@/use/use-chart-tooltip.js'; +import { useChartTooltip } from '@/composables/use-chart-tooltip.js'; import { chartVLine } from '@/utility/chart-vline.js'; import { alpha } from '@/utility/color.js'; import { initChart } from '@/utility/init-chart.js'; diff --git a/packages/frontend/src/components/MkRoleSelectDialog.vue b/packages/frontend/src/components/MkRoleSelectDialog.vue index 6888824437..fc7ba50fb3 100644 --- a/packages/frontend/src/components/MkRoleSelectDialog.vue +++ b/packages/frontend/src/components/MkRoleSelectDialog.vue @@ -43,7 +43,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script setup lang="ts"> -import { computed, ref, toRefs } from 'vue'; +import { computed, ref, toRefs, useTemplateRef } from 'vue'; import * as Misskey from 'misskey-js'; import { i18n } from '@/i18n.js'; import MkButton from '@/components/MkButton.vue'; @@ -74,7 +74,7 @@ const props = withDefaults(defineProps<{ const { initialRoleIds, infoMessage, title, publicOnly } = toRefs(props); -const windowEl = ref<InstanceType<typeof MkModalWindow>>(); +const windowEl = useTemplateRef('windowEl'); const roles = ref<Misskey.entities.Role[]>([]); const selectedRoleIds = ref<string[]>(initialRoleIds.value ?? []); const fetching = ref(false); diff --git a/packages/frontend/src/components/MkServerSetupWizard.vue b/packages/frontend/src/components/MkServerSetupWizard.vue new file mode 100644 index 0000000000..65e0d6d9de --- /dev/null +++ b/packages/frontend/src/components/MkServerSetupWizard.vue @@ -0,0 +1,356 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div :class="$style.root" class="_gaps_m"> + <MkInput v-model="q_name" data-cy-server-name> + <template #label>{{ i18n.ts.instanceName }}</template> + </MkInput> + + <MkFolder :defaultOpen="true"> + <template #label>{{ i18n.ts._serverSetupWizard.howWillYouUseMisskey }}</template> + <template #icon><i class="ti ti-settings-question"></i></template> + + <div class="_gaps_s"> + <MkRadios v-model="q_use" :vertical="true"> + <option value="single"> + <div><i class="ti ti-user"></i> <b>{{ i18n.ts._serverSetupWizard._use.single }}</b></div> + <div>{{ i18n.ts._serverSetupWizard._use.single_description }}</div> + </option> + <option value="group"> + <div><i class="ti ti-lock"></i> <b>{{ i18n.ts._serverSetupWizard._use.group }}</b></div> + <div>{{ i18n.ts._serverSetupWizard._use.group_description }}</div> + </option> + <option value="open"> + <div><i class="ti ti-world"></i> <b>{{ i18n.ts._serverSetupWizard._use.open }}</b></div> + <div>{{ i18n.ts._serverSetupWizard._use.open_description }}</div> + </option> + </MkRadios> + + <MkInfo v-if="q_use === 'single'">{{ i18n.ts._serverSetupWizard._use.single_youCanCreateMultipleAccounts }}</MkInfo> + <MkInfo v-if="q_use === 'open'" warn><b>{{ i18n.ts.advice }}:</b> {{ i18n.ts._serverSetupWizard.openServerAdvice }}</MkInfo> + <MkInfo v-if="q_use === 'open'" warn><b>{{ i18n.ts.advice }}:</b> {{ i18n.ts._serverSetupWizard.openServerAntiSpamAdvice }}</MkInfo> + </div> + </MkFolder> + + <MkFolder v-if="q_use !== 'single'" :defaultOpen="true"> + <template #label>{{ i18n.ts._serverSetupWizard.howManyUsersDoYouExpect }}</template> + <template #icon><i class="ti ti-users"></i></template> + + <div class="_gaps_s"> + <MkRadios v-model="q_scale" :vertical="true"> + <option value="small"><i class="ti ti-user"></i> {{ i18n.ts._serverSetupWizard._scale.small }}</option> + <option value="medium"><i class="ti ti-users"></i> {{ i18n.ts._serverSetupWizard._scale.medium }}</option> + <option value="large"><i class="ti ti-users-group"></i> {{ i18n.ts._serverSetupWizard._scale.large }}</option> + </MkRadios> + + <MkInfo v-if="q_scale === 'large'"><b>{{ i18n.ts.advice }}:</b> {{ i18n.ts._serverSetupWizard.largeScaleServerAdvice }}</MkInfo> + </div> + </MkFolder> + + <MkFolder :defaultOpen="true"> + <template #label>{{ i18n.ts._serverSetupWizard.doYouConnectToFediverse }}</template> + <template #icon><i class="ti ti-planet"></i></template> + + <div class="_gaps_s"> + <div>{{ i18n.ts._serverSetupWizard.doYouConnectToFediverse_description1 }}<br>{{ i18n.ts._serverSetupWizard.doYouConnectToFediverse_description2 }}</div> + + <MkRadios v-model="q_federation" :vertical="true"> + <option value="yes">{{ i18n.ts.yes }}</option> + <option value="no">{{ i18n.ts.no }}</option> + </MkRadios> + + <MkInfo v-if="q_federation === 'yes'">{{ i18n.ts._serverSetupWizard.youCanConfigureMoreFederationSettingsLater }}</MkInfo> + </div> + </MkFolder> + + <MkFolder v-if="q_use === 'open' || q_federation === 'yes'" :defaultOpen="true"> + <template #label>{{ i18n.ts._serverSetupWizard.adminInfo }}</template> + <template #icon><i class="ti ti-mail"></i></template> + + <div class="_gaps_s"> + <div>{{ i18n.ts._serverSetupWizard.adminInfo_description }}</div> + + <MkInfo warn>{{ i18n.ts._serverSetupWizard.adminInfo_mustBeFilled }}</MkInfo> + + <MkInput v-model="q_adminName"> + <template #label>{{ i18n.ts.maintainerName }}</template> + </MkInput> + + <MkInput v-model="q_adminEmail" type="email"> + <template #label>{{ i18n.ts.maintainerEmail }}</template> + </MkInput> + </div> + </MkFolder> + + <MkFolder :defaultOpen="true" :maxHeight="300"> + <template #label>{{ i18n.ts._serverSetupWizard.followingSettingsAreRecommended }}</template> + <template #icon><i class="ti ti-adjustments-alt"></i></template> + + <div class="_gaps_s"> + <div> + <div><b>{{ i18n.ts._serverSettings.singleUserMode }}:</b></div> + <div>{{ serverSettings.singleUserMode ? i18n.ts.yes : i18n.ts.no }}</div> + </div> + <div> + <div><b>{{ i18n.ts._serverSettings.openRegistration }}:</b></div> + <div>{{ !serverSettings.disableRegistration ? i18n.ts.yes : i18n.ts.no }}</div> + </div> + <div> + <div><b>{{ i18n.ts.emailRequiredForSignup }}:</b></div> + <div>{{ serverSettings.emailRequiredForSignup ? i18n.ts.yes : i18n.ts.no }}</div> + </div> + <div> + <div><b>Log IP:</b></div> + <div>{{ serverSettings.enableIpLogging ? i18n.ts.yes : i18n.ts.no }}</div> + </div> + <div> + <div><b>{{ i18n.ts.federation }}:</b></div> + <div>{{ serverSettings.federation === 'none' ? i18n.ts.no : i18n.ts.all }}</div> + </div> + <div> + <div><b>FTT:</b></div> + <div>{{ serverSettings.enableFanoutTimeline ? i18n.ts.yes : i18n.ts.no }}</div> + </div> + <div> + <div><b>FTT/{{ i18n.ts._serverSettings.fanoutTimelineDbFallback }}:</b></div> + <div>{{ serverSettings.enableFanoutTimelineDbFallback ? i18n.ts.yes : i18n.ts.no }}</div> + </div> + <div> + <div><b>RBT:</b></div> + <div>{{ serverSettings.enableReactionsBuffering ? i18n.ts.yes : i18n.ts.no }}</div> + </div> + + <div> + <div><b>{{ i18n.ts._role.baseRole }}/{{ i18n.ts._role._options.rateLimitFactor }}:</b></div> + <div>{{ defaultPolicies.rateLimitFactor }}</div> + </div> + <div> + <div><b>{{ i18n.ts._role.baseRole }}/{{ i18n.ts._role._options.driveCapacity }}:</b></div> + <div>{{ defaultPolicies.driveCapacityMb }} MB</div> + </div> + <div> + <div><b>{{ i18n.ts._role.baseRole }}/{{ i18n.ts._role._options.userListMax }}:</b></div> + <div>{{ defaultPolicies.userListLimit }}</div> + </div> + <div> + <div><b>{{ i18n.ts._role.baseRole }}/{{ i18n.ts._role._options.antennaMax }}:</b></div> + <div>{{ defaultPolicies.antennaLimit }}</div> + </div> + <div> + <div><b>{{ i18n.ts._role.baseRole }}/{{ i18n.ts._role._options.webhookMax }}:</b></div> + <div>{{ defaultPolicies.webhookLimit }}</div> + </div> + <div> + <div><b>{{ i18n.ts._role.baseRole }}/{{ i18n.ts._role._options.canImportFollowing }}:</b></div> + <div>{{ defaultPolicies.canImportFollowing ? i18n.ts.yes : i18n.ts.no }}</div> + </div> + <div> + <div><b>{{ i18n.ts._role.baseRole }}/{{ i18n.ts._role._options.canImportMuting }}:</b></div> + <div>{{ defaultPolicies.canImportMuting ? i18n.ts.yes : i18n.ts.no }}</div> + </div> + <div> + <div><b>{{ i18n.ts._role.baseRole }}/{{ i18n.ts._role._options.canImportBlocking }}:</b></div> + <div>{{ defaultPolicies.canImportBlocking ? i18n.ts.yes : i18n.ts.no }}</div> + </div> + <div> + <div><b>{{ i18n.ts._role.baseRole }}/{{ i18n.ts._role._options.canImportUserLists }}:</b></div> + <div>{{ defaultPolicies.canImportUserLists ? i18n.ts.yes : i18n.ts.no }}</div> + </div> + <div> + <div><b>{{ i18n.ts._role.baseRole }}/{{ i18n.ts._role._options.canImportAntennas }}:</b></div> + <div>{{ defaultPolicies.canImportAntennas ? i18n.ts.yes : i18n.ts.no }}</div> + </div> + </div> + + <template #footer> + <MkButton gradate large rounded data-cy-server-setup-wizard-apply style="margin: 0 auto;" @click="applySettings"> + <i class="ti ti-check"></i> {{ i18n.ts._serverSetupWizard.applyTheseSettings }} + </MkButton> + </template> + </MkFolder> +</div> +</template> + +<script setup lang="ts"> +import { computed, ref } from 'vue'; +import * as Misskey from 'misskey-js'; +import { ROLE_POLICIES } from '@@/js/const.js'; +import MkButton from '@/components/MkButton.vue'; +import MkInput from '@/components/MkInput.vue'; +import * as os from '@/os.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { i18n } from '@/i18n.js'; +import MkFolder from '@/components/MkFolder.vue'; +import MkRadios from '@/components/MkRadios.vue'; +import MkInfo from '@/components/MkInfo.vue'; + +const emit = defineEmits<{ + (ev: 'finished'): void; +}>(); + +const props = withDefaults(defineProps<{ + token?: string; +}>(), { +}); + +const q_name = ref(''); +const q_use = ref('single'); +const q_scale = ref('small'); +const q_federation = ref('yes'); +const q_adminName = ref(''); +const q_adminEmail = ref(''); + +const serverSettings = computed<Misskey.entities.AdminUpdateMetaRequest>(() => { + let enableReactionsBuffering; + if (q_use.value === 'single') { + enableReactionsBuffering = false; + } else { + enableReactionsBuffering = q_scale.value !== 'small'; + } + + return { + singleUserMode: q_use.value === 'single', + disableRegistration: q_use.value !== 'open', + emailRequiredForSignup: q_use.value === 'open', + enableIpLogging: q_use.value === 'open', + federation: q_federation.value === 'yes' ? 'all' : 'none', + enableFanoutTimeline: true, + enableFanoutTimelineDbFallback: q_use.value === 'single', + enableReactionsBuffering, + }; +}); + +const defaultPolicies = computed<Partial<Record<typeof ROLE_POLICIES[number], any>>>(() => { + let driveCapacityMb; + if (q_use.value === 'single') { + driveCapacityMb = 8192; + } else if (q_use.value === 'group') { + driveCapacityMb = 1000; + } else if (q_use.value === 'open') { + driveCapacityMb = 100; + } + + let rateLimitFactor; + if (q_use.value === 'single') { + rateLimitFactor = 0.3; + } else if (q_use.value === 'group') { + rateLimitFactor = 0.7; + } else if (q_use.value === 'open') { + if (q_scale.value === 'small') { + rateLimitFactor = 1; + } else if (q_scale.value === 'medium') { + rateLimitFactor = 1.25; + } else if (q_scale.value === 'large') { + rateLimitFactor = 1.5; + } + } + + let userListLimit; + if (q_use.value === 'single') { + userListLimit = 100; + } else if (q_use.value === 'group') { + userListLimit = 5; + } else if (q_use.value === 'open') { + userListLimit = 3; + } + + let antennaLimit; + if (q_use.value === 'single') { + antennaLimit = 100; + } else if (q_use.value === 'group') { + antennaLimit = 5; + } else if (q_use.value === 'open') { + antennaLimit = 0; + } + + let webhookLimit; + if (q_use.value === 'single') { + webhookLimit = 100; + } else if (q_use.value === 'group') { + webhookLimit = 0; + } else if (q_use.value === 'open') { + webhookLimit = 0; + } + + let canImportFollowing; + if (q_use.value === 'single') { + canImportFollowing = true; + } else { + canImportFollowing = false; + } + + let canImportMuting; + if (q_use.value === 'single') { + canImportMuting = true; + } else { + canImportMuting = false; + } + + let canImportBlocking; + if (q_use.value === 'single') { + canImportBlocking = true; + } else { + canImportBlocking = false; + } + + let canImportUserLists; + if (q_use.value === 'single') { + canImportUserLists = true; + } else { + canImportUserLists = false; + } + + let canImportAntennas; + if (q_use.value === 'single') { + canImportAntennas = true; + } else { + canImportAntennas = false; + } + + return { + rateLimitFactor, + driveCapacityMb, + userListLimit, + antennaLimit, + webhookLimit, + canImportFollowing, + canImportMuting, + canImportBlocking, + canImportUserLists, + canImportAntennas, + }; +}); + +function applySettings() { + const _close = os.waiting(); + Promise.all([ + misskeyApi('admin/update-meta', { + ...serverSettings.value, + name: q_name.value === '' ? undefined : q_name.value, + maintainerName: q_adminName.value === '' ? undefined : q_adminName.value, + maintainerEmail: q_adminEmail.value === '' ? undefined : q_adminEmail.value, + }, props.token), + misskeyApi('admin/roles/update-default-policies', { + policies: defaultPolicies.value, + }, props.token), + ]).then(() => { + emit('finished'); + }).catch((err) => { + os.alert({ + type: 'error', + title: err.code, + text: err.message, + }); + }).finally(() => { + _close(); + }); +} +</script> + +<style lang="scss" module> +.root { +} +</style> diff --git a/packages/frontend/src/components/MkStreamingNotesTimeline.vue b/packages/frontend/src/components/MkStreamingNotesTimeline.vue new file mode 100644 index 0000000000..576a0cf8cc --- /dev/null +++ b/packages/frontend/src/components/MkStreamingNotesTimeline.vue @@ -0,0 +1,531 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<component :is="prefer.s.enablePullToRefresh ? MkPullToRefresh : 'div'" :refresher="() => reloadTimeline()"> + <MkLoading v-if="paginator.fetching.value"/> + + <MkError v-else-if="paginator.error.value" @retry="paginator.init()"/> + + <div v-else-if="paginator.items.value.length === 0" key="_empty_"> + <slot name="empty"><MkResult type="empty" :text="i18n.ts.noNotes"/></slot> + </div> + + <div v-else ref="rootEl"> + <div v-if="paginator.queuedAheadItemsCount.value > 0" :class="$style.new"> + <div :class="$style.newBg1"></div> + <div :class="$style.newBg2"></div> + <button class="_button" :class="$style.newButton" @click="releaseQueue()"><i class="ti ti-circle-arrow-up"></i> {{ i18n.ts.newNote }}</button> + </div> + <component + :is="prefer.s.animation ? TransitionGroup : 'div'" + :class="$style.notes" + :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" + > + <template v-for="(note, i) in paginator.items.value" :key="note.id"> + <div v-if="i > 0 && isSeparatorNeeded(paginator.items.value[i -1].createdAt, note.createdAt)" :data-scroll-anchor="note.id"> + <div :class="$style.date"> + <span><i class="ti ti-chevron-up"></i> {{ getSeparatorInfo(paginator.items.value[i -1].createdAt, note.createdAt).prevText }}</span> + <span style="height: 1em; width: 1px; background: var(--MI_THEME-divider);"></span> + <span>{{ getSeparatorInfo(paginator.items.value[i -1].createdAt, note.createdAt).nextText }} <i class="ti ti-chevron-down"></i></span> + </div> + <MkNote :class="$style.note" :note="note" :withHardMute="true"/> + </div> + <div v-else-if="note._shouldInsertAd_" :data-scroll-anchor="note.id"> + <MkNote :class="$style.note" :note="note" :withHardMute="true"/> + <div :class="$style.ad"> + <MkAd :preferForms="['horizontal', 'horizontal-big']"/> + </div> + </div> + <MkNote v-else :class="$style.note" :note="note" :withHardMute="true" :data-scroll-anchor="note.id"/> + </template> + </component> + <button v-show="paginator.canFetchOlder.value" key="_more_" v-appear="prefer.s.enableInfiniteScroll ? paginator.fetchOlder : null" :disabled="paginator.fetchingOlder.value" class="_button" :class="$style.more" @click="paginator.fetchOlder"> + <div v-if="!paginator.fetchingOlder.value">{{ i18n.ts.loadMore }}</div> + <MkLoading v-else :inline="true"/> + </button> + </div> +</component> +</template> + +<script lang="ts" setup> +import { computed, watch, onUnmounted, provide, useTemplateRef, TransitionGroup, onMounted, shallowRef, ref } from 'vue'; +import * as Misskey from 'misskey-js'; +import { useInterval } from '@@/js/use-interval.js'; +import { getScrollContainer, scrollToTop } from '@@/js/scroll.js'; +import type { BasicTimelineType } from '@/timelines.js'; +import type { PagingCtx } from '@/composables/use-pagination.js'; +import { usePagination } from '@/composables/use-pagination.js'; +import MkPullToRefresh from '@/components/MkPullToRefresh.vue'; +import { useStream } from '@/stream.js'; +import * as sound from '@/utility/sound.js'; +import { $i } from '@/i.js'; +import { instance } from '@/instance.js'; +import { prefer } from '@/preferences.js'; +import { store } from '@/store.js'; +import MkNote from '@/components/MkNote.vue'; +import MkButton from '@/components/MkButton.vue'; +import { i18n } from '@/i18n.js'; +import { globalEvents, useGlobalEvent } from '@/events.js'; +import { isSeparatorNeeded, getSeparatorInfo } from '@/utility/timeline-date-separate.js'; + +const props = withDefaults(defineProps<{ + src: BasicTimelineType | 'mentions' | 'directs' | 'list' | 'antenna' | 'channel' | 'role'; + list?: string; + antenna?: string; + channel?: string; + role?: string; + sound?: boolean; + withRenotes?: boolean; + withReplies?: boolean; + withSensitive?: boolean; + onlyFiles?: boolean; +}>(), { + withRenotes: true, + withReplies: false, + withSensitive: true, + onlyFiles: false, +}); + +provide('inTimeline', true); +provide('tl_withSensitive', computed(() => props.withSensitive)); +provide('inChannel', computed(() => props.src === 'channel')); + +function isTop() { + if (scrollContainer == null) return true; + if (rootEl.value == null) return true; + const scrollTop = scrollContainer.scrollTop; + const tlTop = rootEl.value.offsetTop - scrollContainer.offsetTop; + return scrollTop <= tlTop; +} + +let scrollContainer: HTMLElement | null = null; + +function onScrollContainerScroll() { + if (isTop()) { + paginator.releaseQueue(); + } +} + +const rootEl = useTemplateRef('rootEl'); +watch(rootEl, (el) => { + if (el && scrollContainer == null) { + scrollContainer = getScrollContainer(el); + if (scrollContainer == null) return; + scrollContainer.addEventListener('scroll', onScrollContainerScroll, { passive: true }); // ほんとはscrollendにしたいけどiosが非対応 + } +}, { immediate: true }); + +onUnmounted(() => { + if (scrollContainer) { + scrollContainer.removeEventListener('scroll', onScrollContainerScroll); + } +}); + +type TimelineQueryType = { + antennaId?: string, + withRenotes?: boolean, + withReplies?: boolean, + withFiles?: boolean, + visibility?: string, + listId?: string, + channelId?: string, + roleId?: string +}; + +let adInsertionCounter = 0; + +const MIN_POLLING_INTERVAL = 1000 * 10; +const POLLING_INTERVAL = + prefer.s.pollingInterval === 1 ? MIN_POLLING_INTERVAL * 1.5 * 1.5 : + prefer.s.pollingInterval === 2 ? MIN_POLLING_INTERVAL * 1.5 : + prefer.s.pollingInterval === 3 ? MIN_POLLING_INTERVAL : + MIN_POLLING_INTERVAL; + +if (!store.s.realtimeMode) { + // TODO: 先頭のノートの作成日時が1日以上前であれば流速が遅いTLと見做してインターバルを通常より延ばす + useInterval(async () => { + paginator.fetchNewer({ + toQueue: !isTop(), + }); + }, POLLING_INTERVAL, { + immediate: false, + afterMounted: true, + }); + + useGlobalEvent('notePosted', (note) => { + paginator.fetchNewer({ + toQueue: !isTop(), + }); + }); +} + +useGlobalEvent('noteDeleted', (noteId) => { + paginator.removeItem(noteId); +}); + +function releaseQueue() { + paginator.releaseQueue(); + scrollToTop(rootEl.value); +} + +function prepend(note: Misskey.entities.Note) { + adInsertionCounter++; + + if (instance.notesPerOneAd > 0 && adInsertionCounter % instance.notesPerOneAd === 0) { + note._shouldInsertAd_ = true; + } + + if (isTop()) { + paginator.prepend(note); + } else { + paginator.enqueue(note); + } + + if (props.sound) { + sound.playMisskeySfx($i && (note.userId === $i.id) ? 'noteMy' : 'note'); + } +} + +let connection: Misskey.ChannelConnection | null = null; +let connection2: Misskey.ChannelConnection | null = null; +let paginationQuery: PagingCtx; + +const stream = store.s.realtimeMode ? useStream() : null; + +function connectChannel() { + if (props.src === 'antenna') { + if (props.antenna == null) return; + connection = stream.useChannel('antenna', { + antennaId: props.antenna, + }); + } else if (props.src === 'home') { + connection = stream.useChannel('homeTimeline', { + withRenotes: props.withRenotes, + withFiles: props.onlyFiles ? true : undefined, + }); + connection2 = stream.useChannel('main'); + } else if (props.src === 'local') { + connection = stream.useChannel('localTimeline', { + withRenotes: props.withRenotes, + withReplies: props.withReplies, + withFiles: props.onlyFiles ? true : undefined, + }); + } else if (props.src === 'social') { + connection = stream.useChannel('hybridTimeline', { + withRenotes: props.withRenotes, + withReplies: props.withReplies, + withFiles: props.onlyFiles ? true : undefined, + }); + } else if (props.src === 'global') { + connection = stream.useChannel('globalTimeline', { + withRenotes: props.withRenotes, + withFiles: props.onlyFiles ? true : undefined, + }); + } else if (props.src === 'mentions') { + connection = stream.useChannel('main'); + connection.on('mention', prepend); + } else if (props.src === 'directs') { + const onNote = note => { + if (note.visibility === 'specified') { + prepend(note); + } + }; + connection = stream.useChannel('main'); + connection.on('mention', onNote); + } else if (props.src === 'list') { + if (props.list == null) return; + connection = stream.useChannel('userList', { + withRenotes: props.withRenotes, + withFiles: props.onlyFiles ? true : undefined, + listId: props.list, + }); + } else if (props.src === 'channel') { + if (props.channel == null) return; + connection = stream.useChannel('channel', { + channelId: props.channel, + }); + } else if (props.src === 'role') { + if (props.role == null) return; + connection = stream.useChannel('roleTimeline', { + roleId: props.role, + }); + } + if (props.src !== 'directs' && props.src !== 'mentions') connection?.on('note', prepend); +} + +function disconnectChannel() { + if (connection) connection.dispose(); + if (connection2) connection2.dispose(); +} + +function updatePaginationQuery() { + let endpoint: keyof Misskey.Endpoints | null; + let query: TimelineQueryType | null; + + if (props.src === 'antenna') { + endpoint = 'antennas/notes'; + query = { + antennaId: props.antenna, + }; + } else if (props.src === 'home') { + endpoint = 'notes/timeline'; + query = { + withRenotes: props.withRenotes, + withFiles: props.onlyFiles ? true : undefined, + }; + } else if (props.src === 'local') { + endpoint = 'notes/local-timeline'; + query = { + withRenotes: props.withRenotes, + withReplies: props.withReplies, + withFiles: props.onlyFiles ? true : undefined, + }; + } else if (props.src === 'social') { + endpoint = 'notes/hybrid-timeline'; + query = { + withRenotes: props.withRenotes, + withReplies: props.withReplies, + withFiles: props.onlyFiles ? true : undefined, + }; + } else if (props.src === 'global') { + endpoint = 'notes/global-timeline'; + query = { + withRenotes: props.withRenotes, + withFiles: props.onlyFiles ? true : undefined, + }; + } else if (props.src === 'mentions') { + endpoint = 'notes/mentions'; + query = null; + } else if (props.src === 'directs') { + endpoint = 'notes/mentions'; + query = { + visibility: 'specified', + }; + } else if (props.src === 'list') { + endpoint = 'notes/user-list-timeline'; + query = { + withRenotes: props.withRenotes, + withFiles: props.onlyFiles ? true : undefined, + listId: props.list, + }; + } else if (props.src === 'channel') { + endpoint = 'channels/timeline'; + query = { + channelId: props.channel, + }; + } else if (props.src === 'role') { + endpoint = 'roles/notes'; + query = { + roleId: props.role, + }; + } else { + throw new Error('Unrecognized timeline type: ' + props.src); + } + + paginationQuery = { + endpoint: endpoint, + limit: 10, + params: query, + }; +} + +function refreshEndpointAndChannel() { + if (store.s.realtimeMode) { + disconnectChannel(); + connectChannel(); + } + + updatePaginationQuery(); +} + +// デッキのリストカラムでwithRenotesを変更した場合に自動的に更新されるようにさせる +// IDが切り替わったら切り替え先のTLを表示させたい +watch(() => [props.list, props.antenna, props.channel, props.role, props.withRenotes], refreshEndpointAndChannel); + +// withSensitiveはクライアントで完結する処理のため、単にリロードするだけでOK +watch(() => props.withSensitive, reloadTimeline); + +// 初回表示用 +refreshEndpointAndChannel(); + +const paginator = usePagination({ + ctx: paginationQuery, + useShallowRef: true, +}); + +onUnmounted(() => { + disconnectChannel(); +}); + +function reloadTimeline() { + return new Promise<void>((res) => { + adInsertionCounter = 0; + + paginator.reload().then(() => { + res(); + }); + }); +} + +defineExpose({ + reloadTimeline, +}); +</script> + +<style lang="scss" module> +.transition_x_move { + transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1); +} + +.transition_x_enterActive { + transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1); + + &.note, + .note { + /* Skip Note Rendering有効時、TransitionGroupでnoteを追加するときに一瞬がくっとなる問題を抑制する */ + content-visibility: visible !important; + } +} + +.transition_x_leaveActive { + transition: height 0.2s cubic-bezier(0,.5,.5,1), opacity 0.2s cubic-bezier(0,.5,.5,1); +} + +.transition_x_enterFrom { + opacity: 0; + transform: translateY(max(-64px, -100%)); +} + +@supports (interpolate-size: allow-keywords) { + .transition_x_leaveTo { + interpolate-size: allow-keywords; // heightのtransitionを動作させるために必要 + height: 0; + } +} + +.transition_x_leaveTo { + opacity: 0; +} + +.notes { + container-type: inline-size; + background: var(--MI_THEME-panel); +} + +.note { + border-bottom: solid 0.5px var(--MI_THEME-divider); +} + +.new { + --gapFill: 0.5px; // 上位ヘッダーの高さにフォントの関係などで少数が含まれると、レンダリングエンジンによっては隙間が表示されてしまうため、隙間を隠すために少しずらす + + position: sticky; + top: calc(var(--MI-stickyTop, 0px) - var(--gapFill)); + z-index: 1000; + width: 100%; + box-sizing: border-box; + padding: calc(10px + var(--gapFill)) 0 10px 0; +} + +/* 疑似progressive blur */ +.newBg1, .newBg2 { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; +} + +.newBg1 { + height: 100%; + -webkit-backdrop-filter: var(--MI-blur, blur(2px)); + backdrop-filter: var(--MI-blur, blur(2px)); + mask-image: linear-gradient( /* 疑似Easing Linear Gradients */ + to top, + rgb(0 0 0 / 0%) 0%, + rgb(0 0 0 / 4.9%) 7.75%, + rgb(0 0 0 / 10.4%) 11.25%, + rgb(0 0 0 / 45%) 23.55%, + rgb(0 0 0 / 55%) 26.45%, + rgb(0 0 0 / 89.6%) 38.75%, + rgb(0 0 0 / 95.1%) 42.25%, + rgb(0 0 0 / 100%) 50% + ); +} + +.newBg2 { + height: 75%; + -webkit-backdrop-filter: var(--MI-blur, blur(4px)); + backdrop-filter: var(--MI-blur, blur(4px)); + mask-image: linear-gradient( /* 疑似Easing Linear Gradients */ + to top, + rgb(0 0 0 / 0%) 0%, + rgb(0 0 0 / 4.9%) 15.5%, + rgb(0 0 0 / 10.4%) 22.5%, + rgb(0 0 0 / 45%) 47.1%, + rgb(0 0 0 / 55%) 52.9%, + rgb(0 0 0 / 89.6%) 77.5%, + rgb(0 0 0 / 95.1%) 91.9%, + rgb(0 0 0 / 100%) 100% + ); +} + +.newButton { + position: relative; + display: block; + padding: 6px 12px; + border-radius: 999px; + width: max-content; + margin: auto; + background: var(--MI_THEME-accent); + color: var(--MI_THEME-fgOnAccent); + font-size: 90%; + + &:hover { + background: hsl(from var(--MI_THEME-accent) h s calc(l + 5)); + } + + &:active { + background: hsl(from var(--MI_THEME-accent) h s calc(l - 5)); + } +} + +.date { + display: flex; + font-size: 85%; + align-items: center; + justify-content: center; + gap: 1em; + opacity: 0.75; + padding: 8px 8px; + margin: 0 auto; + 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); + + &:empty { + display: none; + } +} + +.more { + display: block; + width: 100%; + box-sizing: border-box; + padding: 16px; + background: var(--MI_THEME-panel); +} +</style> diff --git a/packages/frontend/src/components/MkStreamingNotificationsTimeline.vue b/packages/frontend/src/components/MkStreamingNotificationsTimeline.vue new file mode 100644 index 0000000000..b12effd0d1 --- /dev/null +++ b/packages/frontend/src/components/MkStreamingNotificationsTimeline.vue @@ -0,0 +1,199 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<component :is="prefer.s.enablePullToRefresh ? MkPullToRefresh : 'div'" :refresher="() => reload()"> + <MkLoading v-if="paginator.fetching.value"/> + + <MkError v-else-if="paginator.error.value" @retry="paginator.init()"/> + + <div v-else-if="paginator.items.value.length === 0" key="_empty_"> + <slot name="empty"><MkResult type="empty" :text="i18n.ts.noNotifications"/></slot> + </div> + + <div v-else ref="rootEl"> + <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 paginator.items.value" :key="notification.id" :data-scroll-anchor="notification.id" :class="$style.item"> + <div v-if="i > 0 && isSeparatorNeeded(paginator.items.value[i -1].createdAt, notification.createdAt)" :class="$style.date"> + <span><i class="ti ti-chevron-up"></i> {{ getSeparatorInfo(paginator.items.value[i -1].createdAt, notification.createdAt).prevText }}</span> + <span style="height: 1em; width: 1px; background: var(--MI_THEME-divider);"></span> + <span>{{ getSeparatorInfo(paginator.items.value[i -1].createdAt, notification.createdAt).nextText }} <i class="ti ti-chevron-down"></i></span> + </div> + <MkNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :class="$style.content" :note="notification.note" :withHardMute="true"/> + <XNotification v-else :class="$style.content" :notification="notification" :withTime="true" :full="true"/> + </div> + </component> + <button v-show="paginator.canFetchOlder.value" key="_more_" v-appear="prefer.s.enableInfiniteScroll ? paginator.fetchOlder : null" :disabled="paginator.fetchingOlder.value" class="_button" :class="$style.more" @click="paginator.fetchOlder"> + <div v-if="!paginator.fetchingOlder.value">{{ i18n.ts.loadMore }}</div> + <MkLoading v-else/> + </button> + </div> +</component> +</template> + +<script lang="ts" setup> +import { onUnmounted, onMounted, computed, useTemplateRef, TransitionGroup } from 'vue'; +import * as Misskey from 'misskey-js'; +import { useInterval } from '@@/js/use-interval.js'; +import type { notificationTypes } from '@@/js/const.js'; +import XNotification from '@/components/MkNotification.vue'; +import MkNote from '@/components/MkNote.vue'; +import { useStream } from '@/stream.js'; +import { i18n } from '@/i18n.js'; +import MkPullToRefresh from '@/components/MkPullToRefresh.vue'; +import { prefer } from '@/preferences.js'; +import { store } from '@/store.js'; +import { usePagination } from '@/composables/use-pagination.js'; +import { isSeparatorNeeded, getSeparatorInfo } from '@/utility/timeline-date-separate.js'; + +const props = defineProps<{ + excludeTypes?: typeof notificationTypes[number][]; +}>(); + +const rootEl = useTemplateRef('rootEl'); + +const paginator = usePagination({ + ctx: prefer.s.useGroupedNotifications ? { + endpoint: 'i/notifications-grouped' as const, + limit: 20, + params: computed(() => ({ + excludeTypes: props.excludeTypes ?? undefined, + })), + } : { + endpoint: 'i/notifications' as const, + limit: 20, + params: computed(() => ({ + excludeTypes: props.excludeTypes ?? undefined, + })), + }, +}); + +const MIN_POLLING_INTERVAL = 1000 * 10; +const POLLING_INTERVAL = + prefer.s.pollingInterval === 1 ? MIN_POLLING_INTERVAL * 1.5 * 1.5 : + prefer.s.pollingInterval === 2 ? MIN_POLLING_INTERVAL * 1.5 : + prefer.s.pollingInterval === 3 ? MIN_POLLING_INTERVAL : + MIN_POLLING_INTERVAL; + +if (!store.s.realtimeMode) { + useInterval(async () => { + paginator.fetchNewer({ + toQueue: false, + }); + }, POLLING_INTERVAL, { + immediate: false, + afterMounted: true, + }); +} + +function onNotification(notification) { + const isMuted = props.excludeTypes ? props.excludeTypes.includes(notification.type) : false; + if (isMuted || window.document.visibilityState === 'visible') { + if (store.s.realtimeMode) { + useStream().send('readNotification'); + } + } + + if (!isMuted) { + paginator.prepend(notification); + } +} + +function reload() { + return paginator.reload(); +} + +let connection: Misskey.ChannelConnection<Misskey.Channels['main']> | null = null; + +onMounted(() => { + if (store.s.realtimeMode) { + connection = useStream().useChannel('main'); + connection.on('notification', onNotification); + connection.on('notificationFlushed', reload); + } +}); + +onUnmounted(() => { + if (connection) connection.dispose(); +}); + +defineExpose({ + reload, +}); +</script> + +<style lang="scss" module> +.transition_x_move { + transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1); +} + +.transition_x_enterActive { + transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1); + + &.content, + .content { + /* Skip Note Rendering有効時、TransitionGroupで通知を追加するときに一瞬がくっとなる問題を抑制する */ + content-visibility: visible !important; + } +} + +.transition_x_leaveActive { + transition: height 0.2s cubic-bezier(0,.5,.5,1), opacity 0.2s cubic-bezier(0,.5,.5,1); +} + +.transition_x_enterFrom { + opacity: 0; + transform: translateY(max(-64px, -100%)); +} + +@supports (interpolate-size: allow-keywords) { + .transition_x_enterFrom { + interpolate-size: allow-keywords; // heightのtransitionを動作させるために必要 + height: 0; + } +} + +.transition_x_leaveTo { + opacity: 0; +} + +.notifications { + container-type: inline-size; + background: var(--MI_THEME-panel); +} + +.item { + border-bottom: solid 0.5px var(--MI_THEME-divider); +} + +.date { + display: flex; + font-size: 85%; + align-items: center; + justify-content: center; + gap: 1em; + opacity: 0.75; + padding: 8px 8px; + margin: 0 auto; + border-bottom: solid 0.5px var(--MI_THEME-divider); +} + +.more { + display: block; + width: 100%; + box-sizing: border-box; + padding: 16px; + background: var(--MI_THEME-panel); + border-top: solid 0.5px var(--MI_THEME-divider); +} +</style> diff --git a/packages/frontend/src/components/MkSubNoteContent.vue b/packages/frontend/src/components/MkSubNoteContent.vue index 5fb37ce8dc..06b19880d2 100644 --- a/packages/frontend/src/components/MkSubNoteContent.vue +++ b/packages/frontend/src/components/MkSubNoteContent.vue @@ -18,8 +18,16 @@ SPDX-License-Identifier: AGPL-3.0-only </details> <details v-if="note.poll"> <summary>{{ i18n.ts.poll }}</summary> - <MkPoll :noteId="note.id" :poll="note.poll" :author="note.user" :emojiUrls="note.emojis"/> + <MkPoll + :noteId="note.id" + :multiple="note.poll.multiple" + :expiresAt="note.poll.expiresAt" + :choices="note.poll.choices" + :author="note.user" + :emojiUrls="note.emojis" + /> </details> + <MkA v-if="note.hasPoll && note.poll == null" :to="`/notes/${note.id}`">({{ i18n.ts.poll }})</MkA> <button v-if="isLong && collapsed" :class="$style.fade" class="_button" @click="collapsed = false"> <span :class="$style.fadeLabel">{{ i18n.ts.showMore }}</span> </button> diff --git a/packages/frontend/src/components/MkTimeline.vue b/packages/frontend/src/components/MkTimeline.vue deleted file mode 100644 index 6a265aa836..0000000000 --- a/packages/frontend/src/components/MkTimeline.vue +++ /dev/null @@ -1,372 +0,0 @@ -<!-- -SPDX-FileCopyrightText: syuilo and misskey-project -SPDX-License-Identifier: AGPL-3.0-only ---> - -<template> -<component :is="prefer.s.enablePullToRefresh ? MkPullToRefresh : 'div'" :refresher="() => reloadTimeline()"> - <MkPagination v-if="paginationQuery" ref="pagingComponent" :pagination="paginationQuery" @queue="emit('queue', $event)"> - <template #empty><MkResult type="empty" :text="i18n.ts.noNotes"/></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" - > - <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"> - <MkNote :class="$style.note" :note="note" :withHardMute="true"/> - <div :class="$style.ad"> - <MkAd :preferForms="['horizontal', 'horizontal-big']"/> - </div> - </div> - <MkNote v-else :class="$style.note" :note="note" :withHardMute="true" :data-scroll-anchor="note.id"/> - </template> - </component> - </template> - </MkPagination> -</component> -</template> - -<script lang="ts" setup> -import { computed, watch, onUnmounted, provide, useTemplateRef, TransitionGroup } from 'vue'; -import * as Misskey from 'misskey-js'; -import type { BasicTimelineType } from '@/timelines.js'; -import type { Paging } from '@/components/MkPagination.vue'; -import MkPullToRefresh from '@/components/MkPullToRefresh.vue'; -import { useStream } from '@/stream.js'; -import * as sound from '@/utility/sound.js'; -import { $i } from '@/i.js'; -import { instance } from '@/instance.js'; -import { prefer } from '@/preferences.js'; -import MkNote from '@/components/MkNote.vue'; -import MkPagination from '@/components/MkPagination.vue'; -import { i18n } from '@/i18n.js'; - -const props = withDefaults(defineProps<{ - src: BasicTimelineType | 'mentions' | 'directs' | 'list' | 'antenna' | 'channel' | 'role'; - list?: string; - antenna?: string; - channel?: string; - role?: string; - sound?: boolean; - withRenotes?: boolean; - withReplies?: boolean; - withSensitive?: boolean; - onlyFiles?: boolean; -}>(), { - withRenotes: true, - withReplies: false, - withSensitive: true, - onlyFiles: false, -}); - -const emit = defineEmits<{ - (ev: 'note'): void; - (ev: 'queue', count: number): void; -}>(); - -provide('inTimeline', true); -provide('tl_withSensitive', computed(() => props.withSensitive)); -provide('inChannel', computed(() => props.src === 'channel')); - -type TimelineQueryType = { - antennaId?: string, - withRenotes?: boolean, - withReplies?: boolean, - withFiles?: boolean, - visibility?: string, - listId?: string, - channelId?: string, - roleId?: string -}; - -const pagingComponent = useTemplateRef('pagingComponent'); - -let tlNotesCount = 0; - -function prepend(note) { - if (pagingComponent.value == null) return; - - tlNotesCount++; - - if (instance.notesPerOneAd > 0 && tlNotesCount % instance.notesPerOneAd === 0) { - note._shouldInsertAd_ = true; - } - - pagingComponent.value.prepend(note); - - emit('note'); - - if (props.sound) { - sound.playMisskeySfx($i && (note.userId === $i.id) ? 'noteMy' : '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(); - -function connectChannel() { - if (props.src === 'antenna') { - if (props.antenna == null) return; - connection = stream.useChannel('antenna', { - antennaId: props.antenna, - }); - } else if (props.src === 'home') { - connection = stream.useChannel('homeTimeline', { - withRenotes: props.withRenotes, - withFiles: props.onlyFiles ? true : undefined, - }); - connection2 = stream.useChannel('main'); - } else if (props.src === 'local') { - connection = stream.useChannel('localTimeline', { - withRenotes: props.withRenotes, - withReplies: props.withReplies, - withFiles: props.onlyFiles ? true : undefined, - }); - } else if (props.src === 'social') { - connection = stream.useChannel('hybridTimeline', { - withRenotes: props.withRenotes, - withReplies: props.withReplies, - withFiles: props.onlyFiles ? true : undefined, - }); - } else if (props.src === 'global') { - connection = stream.useChannel('globalTimeline', { - withRenotes: props.withRenotes, - withFiles: props.onlyFiles ? true : undefined, - }); - } else if (props.src === 'mentions') { - connection = stream.useChannel('main'); - connection.on('mention', prepend); - } else if (props.src === 'directs') { - const onNote = note => { - if (note.visibility === 'specified') { - prepend(note); - } - }; - connection = stream.useChannel('main'); - connection.on('mention', onNote); - } else if (props.src === 'list') { - if (props.list == null) return; - connection = stream.useChannel('userList', { - withRenotes: props.withRenotes, - withFiles: props.onlyFiles ? true : undefined, - listId: props.list, - }); - } else if (props.src === 'channel') { - if (props.channel == null) return; - connection = stream.useChannel('channel', { - channelId: props.channel, - }); - } else if (props.src === 'role') { - if (props.role == null) return; - connection = stream.useChannel('roleTimeline', { - roleId: props.role, - }); - } - if (props.src !== 'directs' && props.src !== 'mentions') connection?.on('note', prepend); -} - -function disconnectChannel() { - if (connection) connection.dispose(); - if (connection2) connection2.dispose(); -} - -function updatePaginationQuery() { - let endpoint: keyof Misskey.Endpoints | null; - let query: TimelineQueryType | null; - - if (props.src === 'antenna') { - endpoint = 'antennas/notes'; - query = { - antennaId: props.antenna, - }; - } else if (props.src === 'home') { - endpoint = 'notes/timeline'; - query = { - withRenotes: props.withRenotes, - withFiles: props.onlyFiles ? true : undefined, - }; - } else if (props.src === 'local') { - endpoint = 'notes/local-timeline'; - query = { - withRenotes: props.withRenotes, - withReplies: props.withReplies, - withFiles: props.onlyFiles ? true : undefined, - }; - } else if (props.src === 'social') { - endpoint = 'notes/hybrid-timeline'; - query = { - withRenotes: props.withRenotes, - withReplies: props.withReplies, - withFiles: props.onlyFiles ? true : undefined, - }; - } else if (props.src === 'global') { - endpoint = 'notes/global-timeline'; - query = { - withRenotes: props.withRenotes, - withFiles: props.onlyFiles ? true : undefined, - }; - } else if (props.src === 'mentions') { - endpoint = 'notes/mentions'; - query = null; - } else if (props.src === 'directs') { - endpoint = 'notes/mentions'; - query = { - visibility: 'specified', - }; - } else if (props.src === 'list') { - endpoint = 'notes/user-list-timeline'; - query = { - withRenotes: props.withRenotes, - withFiles: props.onlyFiles ? true : undefined, - listId: props.list, - }; - } else if (props.src === 'channel') { - endpoint = 'channels/timeline'; - query = { - channelId: props.channel, - }; - } else if (props.src === 'role') { - endpoint = 'roles/notes'; - query = { - roleId: props.role, - }; - } else { - endpoint = null; - query = null; - } - - if (endpoint && query) { - paginationQuery = { - endpoint: endpoint, - limit: 10, - params: query, - }; - } else { - paginationQuery = null; - } -} - -function refreshEndpointAndChannel() { - if (!prefer.s.disableStreamingTimeline) { - disconnectChannel(); - connectChannel(); - } - - updatePaginationQuery(); -} - -// デッキのリストカラムでwithRenotesを変更した場合に自動的に更新されるようにさせる -// IDが切り替わったら切り替え先のTLを表示させたい -watch(() => [props.list, props.antenna, props.channel, props.role, props.withRenotes], refreshEndpointAndChannel); - -// withSensitiveはクライアントで完結する処理のため、単にリロードするだけでOK -watch(() => props.withSensitive, reloadTimeline); - -// 初回表示用 -refreshEndpointAndChannel(); - -onUnmounted(() => { - disconnectChannel(); -}); - -function reloadTimeline() { - return new Promise<void>((res) => { - if (pagingComponent.value == null) return; - - tlNotesCount = 0; - - pagingComponent.value.reload().then(() => { - res(); - }); - }); -} - -defineExpose({ - reloadTimeline, -}); -</script> - -<style lang="scss" module> -.transition_x_move { - transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1); -} - -.transition_x_enterActive { - transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1); - - &.note, - .note { - /* Skip Note Rendering有効時、TransitionGroupでnoteを追加するときに一瞬がくっとなる問題を抑制する */ - content-visibility: visible !important; - } -} - -.transition_x_leaveActive { - transition: height 0.2s cubic-bezier(0,.5,.5,1), opacity 0.2s cubic-bezier(0,.5,.5,1); -} - -.transition_x_enterFrom { - opacity: 0; - transform: translateY(max(-64px, -100%)); -} - -@supports (interpolate-size: allow-keywords) { - .transition_x_leaveTo { - interpolate-size: allow-keywords; // heightのtransitionを動作させるために必要 - height: 0; - } -} - -.transition_x_leaveTo { - opacity: 0; -} - -.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 index 95cc4d2a2a..30bf5389be 100644 --- a/packages/frontend/src/components/MkTl.vue +++ b/packages/frontend/src/components/MkTl.vue @@ -21,15 +21,19 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </template> -<script lang="ts" setup> +<script lang="ts"> +export type TlEvent<E = any> = { + id: string; + timestamp: number; + data: E; +}; +</script> + +<script lang="ts" setup generic="T extends unknown"> import { computed } from 'vue'; const props = defineProps<{ - events: { - id: string; - timestamp: number; - data: any; - }[]; + events: TlEvent<T>[]; }>(); const events = computed(() => { @@ -44,12 +48,12 @@ function getDateText(dateInstance: Date) { return `${year.toString()}/${month.toString()}/${date.toString()} ${hour.toString().padStart(2, '0')}:00:00`; } -const items = computed<({ +type TlItem<T> = ({ id: string; type: 'event'; timestamp: number; - delta: number; - data: any; + delta: number + data: T; } | { id: string; type: 'date'; @@ -57,8 +61,10 @@ const items = computed<({ prevText: string; next: Date | null; nextText: string; -})[]>(() => { - const results = []; +}); + +const items = computed<TlItem<T>[]>(() => { + const results: TlItem<T>[] = []; for (let i = 0; i < events.value.length; i++) { const item = events.value[i]; @@ -97,19 +103,12 @@ const items = computed<({ </script> <style lang="scss" module> -.root { - -} - .items { display: grid; grid-template-columns: max-content 18px 1fr; gap: 0 8px; } -.item { -} - .center { position: relative; @@ -140,6 +139,7 @@ const items = computed<({ height: 100%; background: color-mix(in srgb, var(--MI_THEME-accent), var(--MI_THEME-bg) 75%); } + .centerPoint { position: absolute; top: 0; diff --git a/packages/frontend/src/components/MkTutorialDialog.Note.vue b/packages/frontend/src/components/MkTutorialDialog.Note.vue index 59e1b096ae..95f53e7635 100644 --- a/packages/frontend/src/components/MkTutorialDialog.Note.vue +++ b/packages/frontend/src/components/MkTutorialDialog.Note.vue @@ -76,8 +76,6 @@ const onceReacted = ref<boolean>(false); function addReaction(emoji) { onceReacted.value = true; emit('reacted'); - exampleNote.reactions[emoji] = 1; - exampleNote.myReaction = emoji; doNotification(emoji); } diff --git a/packages/frontend/src/components/MkUploaderDialog.vue b/packages/frontend/src/components/MkUploaderDialog.vue new file mode 100644 index 0000000000..3f5f0776a8 --- /dev/null +++ b/packages/frontend/src/components/MkUploaderDialog.vue @@ -0,0 +1,589 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<MkModalWindow + ref="dialog" + :width="800" + :height="500" + @close="cancel()" + @closed="emit('closed')" +> + <template #header> + <i class="ti ti-upload"></i> {{ i18n.tsx.uploadNFiles({ n: files.length }) }} + </template> + + <div :class="$style.root"> + <div :class="[$style.overallProgress, canRetry ? $style.overallProgressError : null]" :style="{ '--op': `${overallProgress}%` }"></div> + + <div class="_gaps_s _spacer"> + <MkTip k="uploader"> + {{ i18n.ts._uploader.tip }} + </MkTip> + + <div class="_gaps_s"> + <div + v-for="ctx in items" + :key="ctx.id" + v-panel + :class="[$style.item, ctx.waiting ? $style.itemWaiting : null, ctx.uploaded ? $style.itemCompleted : null, ctx.uploadFailed ? $style.itemFailed : null]" + :style="{ '--p': ctx.progress != null ? `${ctx.progress.value / ctx.progress.max * 100}%` : '0%' }" + > + <div :class="$style.itemInner"> + <div :class="$style.itemActionWrapper"> + <MkButton :iconOnly="true" rounded @click="showMenu($event, ctx)"><i class="ti ti-dots"></i></MkButton> + </div> + <div :class="$style.itemThumbnail" :style="{ backgroundImage: `url(${ ctx.thumbnail })` }"></div> + <div :class="$style.itemBody"> + <div><MkCondensedLine :minScale="2 / 3">{{ ctx.name }}</MkCondensedLine></div> + <div :class="$style.itemInfo"> + <span>{{ ctx.file.type }}</span> + <span>{{ bytes(ctx.file.size) }}</span> + <span v-if="ctx.compressedSize">({{ i18n.tsx._uploader.compressedToX({ x: bytes(ctx.compressedSize) }) }} = {{ i18n.tsx._uploader.savedXPercent({ x: Math.round((1 - ctx.compressedSize / ctx.file.size) * 100) }) }})</span> + </div> + <div> + </div> + </div> + <div :class="$style.itemIconWrapper"> + <MkSystemIcon v-if="ctx.uploading" :class="$style.itemIcon" type="waiting"/> + <MkSystemIcon v-else-if="ctx.uploaded" :class="$style.itemIcon" type="success"/> + <MkSystemIcon v-else-if="ctx.uploadFailed" :class="$style.itemIcon" type="error"/> + </div> + </div> + </div> + </div> + + <div v-if="props.multiple"> + <MkButton style="margin: auto;" :iconOnly="true" rounded @click="chooseFile($event)"><i class="ti ti-plus"></i></MkButton> + </div> + + <MkSelect + v-if="items.length > 0" + v-model="compressionLevel" + :items="[ + { value: 0, label: i18n.ts.none }, + { value: 1, label: i18n.ts.low }, + { value: 2, label: i18n.ts.middle }, + { value: 3, label: i18n.ts.high }, + ]" + > + <template #label>{{ i18n.ts.compress }}</template> + </MkSelect> + + <div>{{ i18n.tsx._uploader.maxFileSizeIsX({ x: $i.policies.maxFileSizeMb + 'MB' }) }}</div> + + <!-- クライアントで検出するMIME typeとサーバーで検出するMIME typeが異なる場合があり、混乱の元になるのでとりあえず隠しとく --> + <!-- https://github.com/misskey-dev/misskey/issues/16091 --> + <!--<div>{{ i18n.ts._uploader.allowedTypes }}: {{ $i.policies.uploadableFileTypes.join(', ') }}</div>--> + </div> + </div> + + <template #footer> + <div class="_buttonsCenter"> + <MkButton v-if="isUploading" rounded @click="abortWithConfirm()"><i class="ti ti-x"></i> {{ i18n.ts.abort }}</MkButton> + <MkButton v-else-if="!firstUploadAttempted" primary rounded @click="upload()"><i class="ti ti-upload"></i> {{ i18n.ts.upload }}</MkButton> + + <MkButton v-if="canRetry" rounded @click="upload()"><i class="ti ti-reload"></i> {{ i18n.ts.retry }}</MkButton> + <MkButton v-if="canDone" rounded @click="done()"><i class="ti ti-arrow-right"></i> {{ i18n.ts.done }}</MkButton> + </div> + </template> +</MkModalWindow> +</template> + +<script lang="ts" setup> +import { computed, markRaw, onMounted, ref, useTemplateRef, watch } from 'vue'; +import * as Misskey from 'misskey-js'; +import { v4 as uuid } from 'uuid'; +import { readAndCompressImage } from '@misskey-dev/browser-image-resizer'; +import isAnimated from 'is-file-animated'; +import type { MenuItem } from '@/types/menu.js'; +import MkModalWindow from '@/components/MkModalWindow.vue'; +import { i18n } from '@/i18n.js'; +import { prefer } from '@/preferences.js'; +import MkButton from '@/components/MkButton.vue'; +import bytes from '@/filters/bytes.js'; +import MkSelect from '@/components/MkSelect.vue'; +import { isWebpSupported } from '@/utility/isWebpSupported.js'; +import { uploadFile, UploadAbortedError } from '@/utility/drive.js'; +import * as os from '@/os.js'; +import { ensureSignin } from '@/i.js'; + +const $i = ensureSignin(); + +const COMPRESSION_SUPPORTED_TYPES = [ + 'image/jpeg', + 'image/png', + 'image/webp', + 'image/svg+xml', +]; + +const CROPPING_SUPPORTED_TYPES = [ + 'image/jpeg', + 'image/png', + 'image/webp', +]; + +const mimeTypeMap = { + 'image/webp': 'webp', + 'image/jpeg': 'jpg', + 'image/png': 'png', +} as const; + +const props = withDefaults(defineProps<{ + files: File[]; + folderId?: string | null; + multiple?: boolean; +}>(), { + multiple: true, +}); + +const emit = defineEmits<{ + (ev: 'done', driveFiles: Misskey.entities.DriveFile[]): void; + (ev: 'canceled'): void; + (ev: 'closed'): void; +}>(); + +const items = ref<{ + id: string; + name: string; + progress: { max: number; value: number } | null; + thumbnail: string; + waiting: boolean; + uploading: boolean; + uploaded: Misskey.entities.DriveFile | null; + uploadFailed: boolean; + aborted: boolean; + compressedSize?: number | null; + compressedImage?: Blob | null; + file: File; + abort?: (() => void) | null; +}[]>([]); + +const dialog = useTemplateRef('dialog'); + +const firstUploadAttempted = ref(false); +const isUploading = computed(() => items.value.some(item => item.uploading)); +const canRetry = computed(() => firstUploadAttempted.value && !items.value.some(item => item.uploading || item.waiting) && items.value.some(item => item.uploaded == null)); +const canDone = computed(() => items.value.some(item => item.uploaded != null)); +const overallProgress = computed(() => { + const max = items.value.length; + if (max === 0) return 0; + const v = items.value.reduce((acc, item) => { + if (item.uploaded) return acc + 1; + if (item.progress) return acc + (item.progress.value / item.progress.max); + return acc; + }, 0); + return Math.round((v / max) * 100); +}); + +const compressionLevel = ref<0 | 1 | 2 | 3>(2); +const compressionSettings = computed(() => { + if (compressionLevel.value === 1) { + return { + maxWidth: 2000, + maxHeight: 2000, + }; + } else if (compressionLevel.value === 2) { + return { + maxWidth: 2000 * 0.75, // =1500 + maxHeight: 2000 * 0.75, // =1500 + }; + } else if (compressionLevel.value === 3) { + return { + maxWidth: 2000 * 0.75 * 0.75, // =1125 + maxHeight: 2000 * 0.75 * 0.75, // =1125 + }; + } else { + return null; + } +}); + +watch(items, () => { + if (items.value.length === 0) { + emit('canceled'); + dialog.value?.close(); + return; + } + + if (items.value.every(item => item.uploaded)) { + emit('done', items.value.map(item => item.uploaded!)); + dialog.value?.close(); + } +}, { deep: true }); + +async function cancel() { + const { canceled } = await os.confirm({ + type: 'question', + text: i18n.ts._uploader.abortConfirm, + okText: i18n.ts.yes, + cancelText: i18n.ts.no, + }); + if (canceled) return; + + abortAll(); + emit('canceled'); + dialog.value?.close(); +} + +async function abortWithConfirm() { + const { canceled } = await os.confirm({ + type: 'question', + text: i18n.ts._uploader.abortConfirm, + okText: i18n.ts.yes, + cancelText: i18n.ts.no, + }); + if (canceled) return; + + abortAll(); +} + +async function done() { + if (items.value.some(item => item.uploaded == null)) { + const { canceled } = await os.confirm({ + type: 'question', + text: i18n.ts._uploader.doneConfirm, + okText: i18n.ts.yes, + cancelText: i18n.ts.no, + }); + if (canceled) return; + } + + emit('done', items.value.filter(item => item.uploaded != null).map(item => item.uploaded!)); + dialog.value?.close(); +} + +function showMenu(ev: MouseEvent, item: typeof items.value[0]) { + const menu: MenuItem[] = []; + + menu.push({ + icon: 'ti ti-cursor-text', + text: i18n.ts.rename, + action: async () => { + const { result, canceled } = await os.inputText({ + type: 'text', + title: i18n.ts.rename, + placeholder: item.name, + default: item.name, + }); + if (canceled) return; + if (result.trim() === '') return; + + item.name = result; + }, + }); + + if (CROPPING_SUPPORTED_TYPES.includes(item.file.type) && !item.waiting && !item.uploading && !item.uploaded) { + menu.push({ + icon: 'ti ti-crop', + text: i18n.ts.cropImage, + action: async () => { + const cropped = await os.cropImageFile(item.file, { aspectRatio: null }); + items.value.splice(items.value.indexOf(item), 1, { + ...item, + file: markRaw(cropped), + thumbnail: window.URL.createObjectURL(cropped), + }); + }, + }); + } + + if (!item.waiting && !item.uploading && !item.uploaded) { + menu.push({ + icon: 'ti ti-x', + text: i18n.ts.remove, + action: () => { + items.value.splice(items.value.indexOf(item), 1); + }, + }); + } else if (item.uploading) { + menu.push({ + icon: 'ti ti-cloud-pause', + text: i18n.ts.abort, + danger: true, + action: () => { + if (item.abort != null) { + item.abort(); + } + }, + }); + } + + os.popupMenu(menu, ev.currentTarget ?? ev.target); +} + +async function upload() { // エラーハンドリングなどを考慮してシーケンシャルにやる + firstUploadAttempted.value = true; + + items.value = items.value.map(item => ({ + ...item, + aborted: false, + uploadFailed: false, + waiting: false, + uploading: false, + })); + + for (const item of items.value.filter(item => item.uploaded == null)) { + // アップロード処理途中で値が変わる場合(途中で全キャンセルされたりなど)もあるので、Array filterではなくここでチェック + if (item.aborted) { + continue; + } + + item.waiting = true; + item.uploadFailed = false; + + const shouldCompress = item.compressedImage == null && compressionLevel.value !== 0 && compressionSettings.value && COMPRESSION_SUPPORTED_TYPES.includes(item.file.type) && !(await isAnimated(item.file)); + + if (shouldCompress) { + const config = { + mimeType: isWebpSupported() ? 'image/webp' : 'image/jpeg', + maxWidth: compressionSettings.value.maxWidth, + maxHeight: compressionSettings.value.maxHeight, + quality: isWebpSupported() ? 0.85 : 0.8, + }; + + try { + const result = await readAndCompressImage(item.file, config); + if (result.size < item.file.size || item.file.type === 'image/webp') { + // The compression may not always reduce the file size + // (and WebP is not browser safe yet) + item.compressedImage = markRaw(result); + item.compressedSize = result.size; + item.name = item.file.type !== config.mimeType ? `${item.name}.${mimeTypeMap[config.mimeType]}` : item.name; + } + } catch (err) { + console.error('Failed to resize image', err); + } + } + + item.uploading = true; + + const { filePromise, abort } = uploadFile(item.compressedImage ?? item.file, { + name: item.name, + folderId: props.folderId, + onProgress: (progress) => { + item.waiting = false; + if (item.progress == null) { + item.progress = { max: progress.total, value: progress.loaded }; + } else { + item.progress.value = progress.loaded; + item.progress.max = progress.total; + } + }, + }); + + item.abort = () => { + item.abort = null; + abort(); + item.uploading = false; + item.waiting = false; + item.uploadFailed = true; + }; + + await filePromise.then((file) => { + item.uploaded = file; + item.abort = null; + }).catch(err => { + item.uploadFailed = true; + item.progress = null; + if (!(err instanceof UploadAbortedError)) { + throw err; + } + }).finally(() => { + item.uploading = false; + item.waiting = false; + }); + } +} + +function abortAll() { + for (const item of items.value) { + if (item.uploaded != null) { + continue; + } + + if (item.abort != null) { + item.abort(); + } + item.aborted = true; + item.uploadFailed = true; + } +} + +async function chooseFile(ev: MouseEvent) { + const newFiles = await os.chooseFileFromPc({ multiple: true }); + + for (const file of newFiles) { + initializeFile(file); + } +} + +function initializeFile(file: File) { + const id = uuid(); + const filename = file.name ?? 'untitled'; + const extension = filename.split('.').length > 1 ? '.' + filename.split('.').pop() : ''; + items.value.push({ + id, + name: prefer.s.keepOriginalFilename ? filename : id + extension, + progress: null, + thumbnail: window.URL.createObjectURL(file), + waiting: false, + uploading: false, + aborted: false, + uploaded: null, + uploadFailed: false, + file: markRaw(file), + }); +} + +onMounted(() => { + for (const file of props.files) { + initializeFile(file); + } +}); +</script> + +<style lang="scss" module> +.root { + position: relative; +} + +.overallProgress { + position: absolute; + top: 0; + left: 0; + width: var(--op); + height: 4px; + background: var(--MI_THEME-accent); + border-radius: 0 999px 999px 0; + transition: width 0.2s ease; + + &.overallProgressError { + background: var(--MI_THEME-warn); + } +} + +.item { + position: relative; + border-radius: 10px; + overflow: clip; + + &::before { + content: ''; + display: block; + position: absolute; + top: 0; + left: 0; + width: var(--p); + height: 100%; + background: color(from var(--MI_THEME-accent) srgb r g b / 0.5); + transition: width 0.2s ease, left 0.2s ease; + } + + &.itemWaiting { + &::after { + --c: color(from var(--MI_THEME-accent) srgb r g b / 0.25); + + content: ''; + display: block; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: linear-gradient(-45deg, transparent 25%, var(--c) 25%,var(--c) 50%, transparent 50%, transparent 75%, var(--c) 75%, var(--c)); + background-size: 25px 25px; + animation: stripe .8s infinite linear; + } + } + + &.itemCompleted { + &::before { + left: 100%; + width: var(--p); + } + + .itemBody { + color: var(--MI_THEME-accent); + } + } + + &.itemFailed { + .itemBody { + color: var(--MI_THEME-error); + } + } +} + +@keyframes stripe { + 0% { background-position-x: 0; } + 100% { background-position-x: -25px; } +} + +.itemInner { + position: relative; + z-index: 1; + padding: 8px 16px; + display: flex; + align-items: center; + gap: 12px; +} + +.itemThumbnail { + width: 70px; + height: 70px; + background-color: var(--MI_THEME-bg); + background-size: contain; + background-position: center; + background-repeat: no-repeat; + border-radius: 6px; +} + +.itemBody { + flex: 1; + min-width: 0; +} + +.itemInfo { + opacity: 0.7; + margin-top: 4px; + font-size: 90%; + display: flex; + gap: 8px; +} + +.itemIcon { + width: 35px; +} + +@container (max-width: 500px) { + .itemInner { + flex-direction: column; + gap: 8px; + } + + .itemBody { + font-size: 90%; + text-align: center; + width: 100%; + min-width: 0; + } + + .itemActionWrapper { + position: absolute; + top: 8px; + left: 8px; + } + + .itemInfo { + justify-content: center; + } + + .itemIconWrapper { + position: absolute; + top: 8px; + right: 8px; + } +} +</style> diff --git a/packages/frontend/src/components/MkUrlPreview.vue b/packages/frontend/src/components/MkUrlPreview.vue index 71c8a6a6e8..7c0c06398b 100644 --- a/packages/frontend/src/components/MkUrlPreview.vue +++ b/packages/frontend/src/components/MkUrlPreview.vue @@ -45,7 +45,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <div v-else> <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="prefer.s.dataSaver.urlPreview ? '' : { backgroundImage: `url('${thumbnail}')` }"> + <div v-if="thumbnail && !sensitive" :class="$style.thumbnail" :style="prefer.s.dataSaver.urlPreviewThumbnail ? '' : { backgroundImage: `url('${thumbnail}')` }"> </div> <article :class="$style.body"> <header :class="$style.header"> @@ -91,7 +91,7 @@ import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; import { deviceKind } from '@/utility/device-kind.js'; import MkButton from '@/components/MkButton.vue'; -import { transformPlayerUrl } from '@/utility/player-url-transform.js'; +import { transformPlayerUrl } from '@/utility/url-preview.js'; import { store } from '@/store.js'; import { prefer } from '@/preferences.js'; import { maybeMakeRelative } from '@@/js/url.js'; diff --git a/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue b/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue index aaefa5036a..8ec48dcc3f 100644 --- a/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue +++ b/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue @@ -50,7 +50,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ref } from 'vue'; +import { ref, useTemplateRef } from 'vue'; import * as Misskey from 'misskey-js'; import MkModalWindow from '@/components/MkModalWindow.vue'; import MkButton from '@/components/MkButton.vue'; @@ -74,7 +74,7 @@ const emit = defineEmits<{ (ev: 'closed'): void }>(); -const dialog = ref<InstanceType<typeof MkModalWindow> | null>(null); +const dialog = useTemplateRef('dialog'); const title = ref(props.announcement ? props.announcement.title : ''); const text = ref(props.announcement ? props.announcement.text : ''); const icon = ref(props.announcement ? props.announcement.icon : 'info'); diff --git a/packages/frontend/src/components/MkUserList.vue b/packages/frontend/src/components/MkUserList.vue index 90087cb000..1d4cdfd5cb 100644 --- a/packages/frontend/src/components/MkUserList.vue +++ b/packages/frontend/src/components/MkUserList.vue @@ -16,13 +16,13 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import type { Paging } from '@/components/MkPagination.vue'; +import type { PagingCtx } from '@/composables/use-pagination.js'; import MkUserInfo from '@/components/MkUserInfo.vue'; import MkPagination from '@/components/MkPagination.vue'; import { i18n } from '@/i18n.js'; const props = withDefaults(defineProps<{ - pagination: Paging; + pagination: PagingCtx; noGap?: boolean; extractor?: (item: any) => any; }>(), { diff --git a/packages/frontend/src/components/MkUserSetupDialog.Follow.vue b/packages/frontend/src/components/MkUserSetupDialog.Follow.vue index 67a06c70db..1441d69a6a 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.Follow.vue +++ b/packages/frontend/src/components/MkUserSetupDialog.Follow.vue @@ -39,15 +39,15 @@ import { i18n } from '@/i18n.js'; import MkFolder from '@/components/MkFolder.vue'; import XUser from '@/components/MkUserSetupDialog.User.vue'; import MkPagination from '@/components/MkPagination.vue'; -import type { Paging } from '@/components/MkPagination.vue'; +import type { PagingCtx } from '@/composables/use-pagination.js'; -const pinnedUsers: Paging = { +const pinnedUsers: PagingCtx = { endpoint: 'pinned-users', noPaging: true, limit: 10, }; -const popularUsers: Paging = { +const popularUsers: PagingCtx = { endpoint: 'users', limit: 10, noPaging: true, diff --git a/packages/frontend/src/components/MkUserSetupDialog.Profile.vue b/packages/frontend/src/components/MkUserSetupDialog.Profile.vue index 30925b854c..4e96eff82e 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.Profile.vue +++ b/packages/frontend/src/components/MkUserSetupDialog.Profile.vue @@ -37,7 +37,6 @@ 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 '@/utility/select-file.js'; import * as os from '@/os.js'; import { ensureSignin } from '@/i.js'; @@ -49,7 +48,7 @@ const description = ref($i.description ?? ''); watch(name, () => { os.apiWithDialog('i/update', { // 空文字列をnullにしたいので??は使うな - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + name: name.value || null, }, undefined, { '0b3f9f6a-2f4d-4b1f-9fb4-49d3a2fd7191': { @@ -62,36 +61,37 @@ watch(name, () => { watch(description, () => { os.apiWithDialog('i/update', { // 空文字列をnullにしたいので??は使うな - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + description: description.value || null, }); }); -function setAvatar(ev) { - chooseFileFromPc(false).then(async (files) => { - const file = files[0]; +async function setAvatar(ev) { + const files = await os.chooseFileFromPc({ multiple: false }); + const file = files[0]; - let originalOrCropped = file; + let originalOrCropped = file; - const { canceled } = await os.confirm({ - type: 'question', - text: i18n.ts.cropImageAsk, - okText: i18n.ts.cropYes, - cancelText: i18n.ts.cropNo, + const { canceled } = await os.confirm({ + type: 'question', + text: i18n.ts.cropImageAsk, + okText: i18n.ts.cropYes, + cancelText: i18n.ts.cropNo, + }); + + if (!canceled) { + originalOrCropped = await os.cropImageFile(file, { + aspectRatio: 1, }); + } - if (!canceled) { - originalOrCropped = await os.cropImage(file, { - aspectRatio: 1, - }); - } + const driveFile = (await os.launchUploader([originalOrCropped], { multiple: false }))[0]; - const i = await os.apiWithDialog('i/update', { - avatarId: originalOrCropped.id, - }); - $i.avatarId = i.avatarId; - $i.avatarUrl = i.avatarUrl; + const i = await os.apiWithDialog('i/update', { + avatarId: driveFile.id, }); + $i.avatarId = i.avatarId; + $i.avatarUrl = i.avatarUrl; } </script> diff --git a/packages/frontend/src/components/MkVisibilityPicker.vue b/packages/frontend/src/components/MkVisibilityPicker.vue index cb402b1a57..3801195da6 100644 --- a/packages/frontend/src/components/MkVisibilityPicker.vue +++ b/packages/frontend/src/components/MkVisibilityPicker.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkModal ref="modal" v-slot="{ type }" :zPriority="'high'" :src="src" @click="modal?.close()" @closed="emit('closed')" @esc="modal?.close()"> +<MkModal ref="modal" v-slot="{ type }" :zPriority="'high'" :anchorElement="anchorElement" @click="modal?.close()" @closed="emit('closed')" @esc="modal?.close()"> <div class="_popup" :class="{ [$style.root]: true, [$style.asDrawer]: type === 'drawer' }"> <div :class="[$style.label, $style.item]"> {{ i18n.ts.visibility }} @@ -53,7 +53,7 @@ const props = withDefaults(defineProps<{ currentVisibility: typeof Misskey.noteVisibilities[number]; isSilenced: boolean; localOnly: boolean; - src?: HTMLElement; + anchorElement?: HTMLElement; isReplyVisibilitySpecified?: boolean; }>(), { }); diff --git a/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue b/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue index 79c9e739c4..6aaee76565 100644 --- a/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue +++ b/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue @@ -19,7 +19,7 @@ import gradient from 'chartjs-plugin-gradient'; import tinycolor from 'tinycolor2'; import { misskeyApi } from '@/utility/misskey-api.js'; import { store } from '@/store.js'; -import { useChartTooltip } from '@/use/use-chart-tooltip.js'; +import { useChartTooltip } from '@/composables/use-chart-tooltip.js'; import { chartVLine } from '@/utility/chart-vline.js'; import { initChart } from '@/utility/init-chart.js'; diff --git a/packages/frontend/src/components/MkVisitorDashboard.vue b/packages/frontend/src/components/MkVisitorDashboard.vue index 1a4d14a3f0..a809e9040d 100644 --- a/packages/frontend/src/components/MkVisitorDashboard.vue +++ b/packages/frontend/src/components/MkVisitorDashboard.vue @@ -43,7 +43,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-if="instance.policies.ltlAvailable" :class="[$style.tl, $style.panel]"> <div :class="$style.tlHeader">{{ i18n.ts.letsLookAtTimeline }}</div> <div :class="$style.tlBody"> - <MkTimeline src="local"/> + <MkStreamingNotesTimeline src="local"/> </div> </div> <div :class="$style.panel"> @@ -58,7 +58,7 @@ import * as Misskey from 'misskey-js'; import XSigninDialog from '@/components/MkSigninDialog.vue'; import XSignupDialog from '@/components/MkSignupDialog.vue'; import MkButton from '@/components/MkButton.vue'; -import MkTimeline from '@/components/MkTimeline.vue'; +import MkStreamingNotesTimeline from '@/components/MkStreamingNotesTimeline.vue'; import MkInfo from '@/components/MkInfo.vue'; import { instanceName } from '@@/js/config.js'; import * as os from '@/os.js'; diff --git a/packages/frontend/src/components/MkYouTubePlayer.vue b/packages/frontend/src/components/MkYouTubePlayer.vue index ab62a5113d..2375bcc9eb 100644 --- a/packages/frontend/src/components/MkYouTubePlayer.vue +++ b/packages/frontend/src/components/MkYouTubePlayer.vue @@ -27,7 +27,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { ref } from 'vue'; import { versatileLang } from '@@/js/intl-const.js'; import MkWindow from '@/components/MkWindow.vue'; -import { transformPlayerUrl } from '@/utility/player-url-transform.js'; +import { transformPlayerUrl } from '@/utility/url-preview.js'; import { prefer } from '@/preferences.js'; const props = defineProps<{ diff --git a/packages/frontend/src/components/global/MkAvatar.vue b/packages/frontend/src/components/global/MkAvatar.vue index 97c2069a2f..8a9cc5286a 100644 --- a/packages/frontend/src/components/global/MkAvatar.vue +++ b/packages/frontend/src/components/global/MkAvatar.vue @@ -5,7 +5,8 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <component :is="link ? MkA : 'span'" v-user-preview="preview ? user.id : undefined" v-bind="bound" class="_noSelect" :class="[$style.root, { [$style.animation]: animation, [$style.cat]: user.isCat, [$style.square]: squareAvatars }]" :style="{ color }" :title="acct(user)" @click="onClick"> - <MkImgWithBlurhash :class="$style.inner" :src="url" :hash="user.avatarBlurhash" :cover="true" :onlyAvgColor="true"/> + <MkImgWithBlurhash v-if="prefer.s.enableHighQualityImagePlaceholders" :class="$style.inner" :src="url" :hash="user.avatarBlurhash" :cover="true" :onlyAvgColor="true"/> + <img v-else :class="$style.inner" :src="url" alt="" decoding="async" style="pointer-events: none;"/> <MkUserOnlineIndicator v-if="indicator" :class="$style.indicator" :user="user"/> <div v-if="user.isCat" :class="[$style.ears]"> <div :class="$style.earLeft"> diff --git a/packages/frontend/src/components/global/MkCustomEmoji.vue b/packages/frontend/src/components/global/MkCustomEmoji.vue index dda45ceaa2..ed114d8d31 100644 --- a/packages/frontend/src/components/global/MkCustomEmoji.vue +++ b/packages/frontend/src/components/global/MkCustomEmoji.vue @@ -5,7 +5,16 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <img - v-if="errored && fallbackToImage" + v-if="shouldMute" + :class="[$style.root, { [$style.normal]: normal, [$style.noStyle]: noStyle }]" + src="/client-assets/unknown.png" + :title="alt" + draggable="false" + style="-webkit-user-drag: none;" + @click="onClick" +/> +<img + v-else-if="errored && fallbackToImage" :class="[$style.root, { [$style.normal]: normal, [$style.noStyle]: noStyle }]" src="/client-assets/dummy.png" :title="alt" @@ -40,6 +49,7 @@ import MkCustomEmojiDetailedDialog from '@/components/MkCustomEmojiDetailedDialo import { $i } from '@/i.js'; import { prefer } from '@/preferences.js'; import { DI } from '@/di.js'; +import { makeEmojiMuteKey, mute as muteEmoji, unmute as unmuteEmoji, checkMuted as checkEmojiMuted } from '@/utility/emoji-mute'; const props = defineProps<{ name: string; @@ -51,12 +61,16 @@ const props = defineProps<{ menu?: boolean; menuReaction?: boolean; fallbackToImage?: boolean; + ignoreMuted?: boolean; }>(); const react = inject(DI.mfmEmojiReactCallback); 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('@'))); +const emojiCodeToMute = makeEmojiMuteKey(props); +const isMuted = checkEmojiMuted(emojiCodeToMute); +const shouldMute = computed(() => !props.ignoreMuted && isMuted.value); const rawUrl = computed(() => { if (props.url) { @@ -95,14 +109,18 @@ function onClick(ev: MouseEvent) { menuItems.push({ type: 'label', text: `:${props.name}:`, - }, { - text: i18n.ts.copy, - icon: 'ti ti-copy', - action: () => { - copyToClipboard(`:${props.name}:`); - }, }); + if (isLocal.value) { + menuItems.push({ + text: i18n.ts.copy, + icon: 'ti ti-copy', + action: () => { + copyToClipboard(`:${props.name}:`); + }, + }); + } + if (props.menuReaction && react) { menuItems.push({ text: i18n.ts.doReaction, @@ -113,21 +131,43 @@ function onClick(ev: MouseEvent) { }); } - menuItems.push({ - text: i18n.ts.info, - icon: 'ti ti-info-circle', - action: async () => { - const { dispose } = os.popup(MkCustomEmojiDetailedDialog, { - emoji: await misskeyApiGet('emoji', { - name: customEmojiName.value, - }), - }, { - closed: () => dispose(), - }); - }, - }); + if (isLocal.value) { + menuItems.push({ + type: 'divider', + }, { + text: i18n.ts.info, + icon: 'ti ti-info-circle', + action: async () => { + const { dispose } = os.popup(MkCustomEmojiDetailedDialog, { + emoji: await misskeyApiGet('emoji', { + name: customEmojiName.value, + }), + }, { + closed: () => dispose(), + }); + }, + }); + } - if ($i?.isModerator ?? $i?.isAdmin) { + if (isMuted.value) { + menuItems.push({ + text: i18n.ts.emojiUnmute, + icon: 'ti ti-mood-smile', + action: async () => { + await unmute(); + }, + }); + } else { + menuItems.push({ + text: i18n.ts.emojiMute, + icon: 'ti ti-mood-off', + action: async () => { + await mute(); + }, + }); + } + + if (($i?.isModerator ?? $i?.isAdmin) && isLocal.value) { menuItems.push({ text: i18n.ts.edit, icon: 'ti ti-pencil', @@ -152,6 +192,36 @@ async function edit(name: string) { }); } +function mute() { + const titleEmojiName = isLocal.value + ? `:${customEmojiName.value}:` + : emojiCodeToMute; + os.confirm({ + type: 'question', + title: i18n.tsx.muteX({ x: titleEmojiName }), + }).then(({ canceled }) => { + if (canceled) { + return; + } + muteEmoji(emojiCodeToMute); + }); +} + +function unmute() { + const titleEmojiName = isLocal.value + ? `:${customEmojiName.value}:` + : emojiCodeToMute; + os.confirm({ + type: 'question', + title: i18n.tsx.unmuteX({ x: titleEmojiName }), + }).then(({ canceled }) => { + if (canceled) { + return; + } + unmuteEmoji(emojiCodeToMute); + }); +} + </script> <style lang="scss" module> diff --git a/packages/frontend/src/components/global/MkEmoji.vue b/packages/frontend/src/components/global/MkEmoji.vue index fa55fd888b..792f9c7d6f 100644 --- a/packages/frontend/src/components/global/MkEmoji.vue +++ b/packages/frontend/src/components/global/MkEmoji.vue @@ -4,7 +4,8 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<img v-if="!useOsNativeEmojis" :class="$style.root" :src="url" :alt="props.emoji" decoding="async" @pointerenter="computeTitle" @click="onClick"/> +<img v-if="shouldMute" :class="$style.root" src="/client-assets/unknown.png" :alt="props.emoji" decoding="async" @pointerenter="computeTitle" @click="onClick"/> +<img v-else-if="!useOsNativeEmojis" :class="$style.root" :src="url" :alt="props.emoji" decoding="async" @pointerenter="computeTitle" @click="onClick"/> <span v-else :alt="props.emoji" @pointerenter="computeTitle" @click="onClick">{{ colorizedNativeEmoji }}</span> </template> @@ -18,11 +19,13 @@ import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; import { i18n } from '@/i18n.js'; import { prefer } from '@/preferences.js'; import { DI } from '@/di.js'; +import { mute as muteEmoji, unmute as unmuteEmoji, checkMuted as checkMutedEmoji } from '@/utility/emoji-mute.js'; const props = defineProps<{ emoji: string; menu?: boolean; menuReaction?: boolean; + ignoreMuted?: boolean; }>(); const react = inject(DI.mfmEmojiReactCallback, null); @@ -32,12 +35,38 @@ const char2path = prefer.s.emojiStyle === 'twemoji' ? char2twemojiFilePath : cha const useOsNativeEmojis = computed(() => prefer.s.emojiStyle === 'native'); const url = computed(() => char2path(props.emoji)); const colorizedNativeEmoji = computed(() => colorizeEmoji(props.emoji)); +const isMuted = checkMutedEmoji(props.emoji); +const shouldMute = computed(() => isMuted.value && !props.ignoreMuted); // Searching from an array with 2000 items for every emoji felt like too energy-consuming, so I decided to do it lazily on pointerenter function computeTitle(event: PointerEvent): void { (event.target as HTMLElement).title = getEmojiName(props.emoji); } +function mute() { + os.confirm({ + type: 'question', + title: i18n.tsx.muteX({ x: props.emoji }), + }).then(({ canceled }) => { + if (canceled) { + return; + } + muteEmoji(props.emoji); + }); +} + +function unmute() { + os.confirm({ + type: 'question', + title: i18n.tsx.unmuteX({ x: props.emoji }), + }).then(({ canceled }) => { + if (canceled) { + return; + } + unmuteEmoji(props.emoji); + }); +} + function onClick(ev: MouseEvent) { if (props.menu) { const menuItems: MenuItem[] = []; @@ -63,6 +92,22 @@ function onClick(ev: MouseEvent) { }); } + menuItems.push({ + type: 'divider', + }, isMuted.value ? { + text: i18n.ts.emojiUnmute, + icon: 'ti ti-mood-smile', + action: () => { + unmute(); + }, + } : { + text: i18n.ts.emojiMute, + icon: 'ti ti-mood-off', + action: () => { + mute(); + }, + }); + os.popupMenu(menuItems, ev.currentTarget ?? ev.target); } } diff --git a/packages/frontend/src/components/global/MkMfm.ts b/packages/frontend/src/components/global/MkMfm.ts index 337e326ccd..3ad2fda0ee 100644 --- a/packages/frontend/src/components/global/MkMfm.ts +++ b/packages/frontend/src/components/global/MkMfm.ts @@ -435,6 +435,8 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven normal: props.plain, host: props.author.host, useOriginalSize: scale >= 2.5, + menu: props.enableEmojiMenu, + menuReaction: false, })]; } } diff --git a/packages/frontend/src/components/global/MkResult.vue b/packages/frontend/src/components/global/MkResult.vue index fdfc7091e8..fc8206f814 100644 --- a/packages/frontend/src/components/global/MkResult.vue +++ b/packages/frontend/src/components/global/MkResult.vue @@ -41,6 +41,7 @@ const props = defineProps<{ .img { vertical-align: bottom; height: 128px; + aspect-ratio: 1; margin-bottom: 16px; border-radius: 16px; } diff --git a/packages/frontend/src/components/global/MkSystemIcon.vue b/packages/frontend/src/components/global/MkSystemIcon.vue index 3285d5a940..d2ef0fb2d8 100644 --- a/packages/frontend/src/components/global/MkSystemIcon.vue +++ b/packages/frontend/src/components/global/MkSystemIcon.vue @@ -5,28 +5,32 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <svg v-if="type === 'info'" :class="[$style.icon, $style.info]" viewBox="0 0 160 160"> - <path d="M80,108L80,72" style="--l:37;" :class="[$style.line, $style.anim]"/> - <path d="M80,52L80,52" :class="[$style.line, $style.fade]"/> - <circle cx="80" cy="80" r="56" style="--l:350;" :class="[$style.line, $style.anim]"/> + <path d="M80,108L80,72" style="--l:37;" :class="[$style.line, $style.animLine]"/> + <path d="M80,52L80,52" :class="[$style.line, $style.animFade]"/> + <circle cx="80" cy="80" r="56" style="--l:350;" :class="[$style.line, $style.animCircle]"/> </svg> <svg v-else-if="type === 'question'" :class="[$style.icon, $style.question]" viewBox="0 0 160 160"> - <path d="M80,92L79.991,84C88.799,83.98 96,76.962 96,68C96,59.038 88.953,52 79.991,52C71.03,52 64,59.038 64,68" style="--l:85;" :class="[$style.line, $style.anim]"/> - <path d="M80,108L80,108" :class="[$style.line, $style.fade]"/> - <circle cx="80" cy="80" r="56" style="--l:350;" :class="[$style.line, $style.anim]"/> + <path d="M80,92L79.991,84C88.799,83.98 96,76.962 96,68C96,59.038 88.953,52 79.991,52C71.03,52 64,59.038 64,68" style="--l:85;" :class="[$style.line, $style.animLine]"/> + <path d="M80,108L80,108" :class="[$style.line, $style.animFade]"/> + <circle cx="80" cy="80" r="56" style="--l:350;" :class="[$style.line, $style.animCircle]"/> </svg> <svg v-else-if="type === 'success'" :class="[$style.icon, $style.success]" viewBox="0 0 160 160"> - <path d="M62,80L74,92L98,68" style="--l:50;" :class="[$style.line, $style.anim]"/> - <circle cx="80" cy="80" r="56" style="--l:350;" :class="[$style.line, $style.anim]"/> + <path d="M62,80L74,92L98,68" style="--l:50;" :class="[$style.line, $style.animLine]"/> + <circle cx="80" cy="80" r="56" style="--l:350;" :class="[$style.line, $style.animCircle]"/> </svg> <svg v-else-if="type === 'warn'" :class="[$style.icon, $style.warn]" viewBox="0 0 160 160"> - <path d="M80,64L80,88" style="--l:27;" :class="[$style.line, $style.anim]"/> - <path d="M80,108L80,108" :class="[$style.line, $style.fade]"/> - <path d="M92,28L144,116C148.709,124.65 144.083,135.82 136,136L24,136C15.917,135.82 11.291,124.65 16,116L68,28C73.498,19.945 86.771,19.945 92,28Z" style="--l:390;" :class="[$style.line, $style.anim]"/> + <path d="M80,64L80,88" style="--l:27;" :class="[$style.line, $style.animLine]"/> + <path d="M80,108L80,108" :class="[$style.line, $style.animFade]"/> + <path d="M92,28L144,116C148.709,124.65 144.083,135.82 136,136L24,136C15.917,135.82 11.291,124.65 16,116L68,28C73.498,19.945 86.771,19.945 92,28Z" style="--l:395;" :class="[$style.line, $style.animLine]"/> </svg> <svg v-else-if="type === 'error'" :class="[$style.icon, $style.error]" viewBox="0 0 160 160"> - <path d="M63,63L96,96" style="--l:47;--duration:0.3s;" :class="[$style.line, $style.anim]"/> - <path d="M96,63L63,96" style="--l:47;--duration:0.3s;--delay:0.2s;" :class="[$style.line, $style.anim]"/> - <circle cx="80" cy="80" r="56" style="--l:350;" :class="[$style.line, $style.anim]"/> + <path d="M63,63L96,96" style="--l:47;--duration:0.3s;" :class="[$style.line, $style.animLine]"/> + <path d="M96,63L63,96" style="--l:47;--duration:0.3s;--delay:0.2s;" :class="[$style.line, $style.animLine]"/> + <circle cx="80" cy="80" r="56" style="--l:350;" :class="[$style.line, $style.animCircle]"/> +</svg> +<svg v-else-if="type === 'waiting'" :class="[$style.icon, $style.waiting]" viewBox="0 0 160 160"> + <circle cx="80" cy="80" r="56" style="--l:350;" :class="[$style.line, $style.animCircleWaiting]"/> + <circle cx="80" cy="80" r="56" style="opacity: 0.25;" :class="[$style.line]"/> </svg> </template> @@ -34,7 +38,7 @@ SPDX-License-Identifier: AGPL-3.0-only import {} from 'vue'; const props = defineProps<{ - type: 'info' | 'question' | 'success' | 'warn' | 'error'; + type: 'info' | 'question' | 'success' | 'warn' | 'error' | 'waiting'; }>(); </script> @@ -62,32 +66,49 @@ const props = defineProps<{ &.error { color: var(--MI_THEME-error); } + + &.waiting { + color: var(--MI_THEME-accent); + } } .line { fill: none; stroke: currentColor; stroke-width: 8px; + shape-rendering: geometricPrecision; } -.fill { - fill: currentColor; +.animLine { + stroke-dasharray: var(--l); + stroke-dashoffset: var(--l); + animation: line var(--duration, 0.5s) cubic-bezier(0,0,.25,1) 1 forwards; + animation-delay: var(--delay, 0s); } -.anim { +.animCircle { stroke-dasharray: var(--l); stroke-dashoffset: var(--l); - animation: line-animation var(--duration, 0.5s) cubic-bezier(0,0,.25,1) 1 forwards; + animation: line var(--duration, 0.5s) cubic-bezier(0,0,.25,1) 1 forwards; animation-delay: var(--delay, 0s); + transform-origin: center; + transform: rotate(-90deg); +} + +.animCircleWaiting { + stroke-dasharray: var(--l); + stroke-dashoffset: calc(var(--l) / 1.5); + animation: waiting 0.75s linear infinite; + transform-origin: center; } -.fade { +.animFade { opacity: 0; animation: fade-in var(--duration, 0.5s) cubic-bezier(0,0,.25,1) 1 forwards; animation-delay: var(--delay, 0s); } -@keyframes line-animation { +@keyframes line { 0% { stroke-dashoffset: var(--l); opacity: 0; @@ -98,6 +119,15 @@ const props = defineProps<{ } } +@keyframes waiting { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + @keyframes fade-in { 0% { opacity: 0; diff --git a/packages/frontend/src/components/global/MkTip.vue b/packages/frontend/src/components/global/MkTip.vue new file mode 100644 index 0000000000..384511a0ed --- /dev/null +++ b/packages/frontend/src/components/global/MkTip.vue @@ -0,0 +1,48 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div v-if="!store.r.tips.value[props.k]" :class="[$style.root, { [$style.warn]: warn }]" class="_selectable _gaps_s"> + <div style="font-weight: bold;"><i class="ti ti-bulb"></i> {{ i18n.ts.tip }}:</div> + <div><slot></slot></div> + <MkButton primary rounded small @click="closeTip()"><i class="ti ti-check"></i> {{ i18n.ts.gotIt }}</MkButton> +</div> +</template> + +<script lang="ts" setup> +import { i18n } from '@/i18n.js'; +import { store } from '@/store.js'; +import MkButton from '@/components/MkButton.vue'; + +const props = withDefaults(defineProps<{ + k: keyof (typeof store['s']['tips']); + warn?: boolean; +}>(), { + warn: false, +}); + +function closeTip() { + store.set('tips', { + ...store.r.tips.value, + [props.k]: true, + }); +} +</script> + +<style lang="scss" module> +.root { + padding: 12px 14px; + font-size: 90%; + background: var(--MI_THEME-infoBg); + color: var(--MI_THEME-infoFg); + border-radius: var(--MI-radius); + + &.warn { + background: var(--MI_THEME-infoWarnBg); + color: var(--MI_THEME-infoWarnFg); + } +} + +</style> diff --git a/packages/frontend/src/components/global/MkUrl.vue b/packages/frontend/src/components/global/MkUrl.vue index 49f716d886..1da16b8923 100644 --- a/packages/frontend/src/components/global/MkUrl.vue +++ b/packages/frontend/src/components/global/MkUrl.vue @@ -29,8 +29,8 @@ 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 '@/use/use-tooltip.js'; -import { isEnabledUrlPreview } from '@/instance.js'; +import { useTooltip } from '@/composables/use-tooltip.js'; +import { isEnabledUrlPreview } from '@/utility/url-preview.js'; import type { MkABehavior } from '@/components/global/MkA.vue'; import { maybeMakeRelative } from '@@/js/url.js'; diff --git a/packages/frontend/src/components/global/PageWithHeader.vue b/packages/frontend/src/components/global/PageWithHeader.vue index 33a34e0b67..d90afb652e 100644 --- a/packages/frontend/src/components/global/PageWithHeader.vue +++ b/packages/frontend/src/components/global/PageWithHeader.vue @@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only 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 { useScrollPositionKeeper } from '@/composables/use-scroll-position-keeper.js'; import MkSwiper from '@/components/MkSwiper.vue'; import { useRouter } from '@/router.js'; import { prefer } from '@/preferences.js'; diff --git a/packages/frontend/src/components/grid/MkDataCell.vue b/packages/frontend/src/components/grid/MkDataCell.vue index 55de0df690..444509e6b3 100644 --- a/packages/frontend/src/components/grid/MkDataCell.vue +++ b/packages/frontend/src/components/grid/MkDataCell.vue @@ -95,7 +95,7 @@ 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 { useTooltip } from '@/composables/use-tooltip.js'; import * as os from '@/os.js'; import { equalCellAddress, getCellAddress } from '@/components/grid/grid-utils.js'; diff --git a/packages/frontend/src/components/grid/MkGrid.vue b/packages/frontend/src/components/grid/MkGrid.vue index f80f037285..a175485a7e 100644 --- a/packages/frontend/src/components/grid/MkGrid.vue +++ b/packages/frontend/src/components/grid/MkGrid.vue @@ -48,7 +48,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script setup lang="ts"> -import { computed, onMounted, ref, toRefs, watch } from 'vue'; +import { computed, onMounted, ref, toRefs, useTemplateRef, watch } from 'vue'; 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'; @@ -130,7 +130,7 @@ const bus = new GridEventEmitter(); */ const resizeObserver = new ResizeObserver((entries) => window.setTimeout(() => onResize(entries))); -const rootEl = ref<InstanceType<typeof HTMLTableElement>>(); +const rootEl = useTemplateRef('rootEl'); /** * グリッドの最も上位にある状態。 */ diff --git a/packages/frontend/src/components/grid/MkHeaderCell.vue b/packages/frontend/src/components/grid/MkHeaderCell.vue index 69a68b6f2c..e1faed904a 100644 --- a/packages/frontend/src/components/grid/MkHeaderCell.vue +++ b/packages/frontend/src/components/grid/MkHeaderCell.vue @@ -31,10 +31,10 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script setup lang="ts"> -import { computed, nextTick, onMounted, onUnmounted, ref, toRefs, watch } from 'vue'; -import { GridEventEmitter } from '@/components/grid/grid.js'; +import { computed, nextTick, onMounted, onUnmounted, ref, toRefs, useTemplateRef, watch } from 'vue'; import type { Size } from '@/components/grid/grid.js'; import type { GridColumn } from '@/components/grid/column.js'; +import { GridEventEmitter } from '@/components/grid/grid.js'; const emit = defineEmits<{ (ev: 'operation:beginWidthChange', sender: GridColumn): void; @@ -50,8 +50,8 @@ const props = defineProps<{ const { column, bus } = toRefs(props); -const rootEl = ref<InstanceType<typeof HTMLTableCellElement>>(); -const contentEl = ref<InstanceType<typeof HTMLDivElement>>(); +const rootEl = useTemplateRef('rootEl'); +const contentEl = useTemplateRef('contentEl'); const resizing = ref<boolean>(false); diff --git a/packages/frontend/src/components/index.ts b/packages/frontend/src/components/index.ts index 9981772ae8..19766e8575 100644 --- a/packages/frontend/src/components/index.ts +++ b/packages/frontend/src/components/index.ts @@ -26,6 +26,7 @@ import MkStickyContainer from './global/MkStickyContainer.vue'; import MkLazy from './global/MkLazy.vue'; import MkResult from './global/MkResult.vue'; import MkSystemIcon from './global/MkSystemIcon.vue'; +import MkTip from './global/MkTip.vue'; import PageWithHeader from './global/PageWithHeader.vue'; import PageWithAnimBg from './global/PageWithAnimBg.vue'; import SearchMarker from './global/SearchMarker.vue'; @@ -65,6 +66,7 @@ export const components = { MkLazy: MkLazy, MkResult: MkResult, MkSystemIcon: MkSystemIcon, + MkTip: MkTip, PageWithHeader: PageWithHeader, PageWithAnimBg: PageWithAnimBg, SearchMarker: SearchMarker, @@ -98,6 +100,7 @@ declare module '@vue/runtime-core' { MkLazy: typeof MkLazy; MkResult: typeof MkResult; MkSystemIcon: typeof MkSystemIcon; + MkTip: typeof MkTip; PageWithHeader: typeof PageWithHeader; PageWithAnimBg: typeof PageWithAnimBg; SearchMarker: typeof SearchMarker; diff --git a/packages/frontend/src/components/page/page.text.vue b/packages/frontend/src/components/page/page.text.vue index 7702e250e4..a00eb0b5ca 100644 --- a/packages/frontend/src/components/page/page.text.vue +++ b/packages/frontend/src/components/page/page.text.vue @@ -17,7 +17,7 @@ import { defineAsyncComponent } from 'vue'; import * as mfm from 'mfm-js'; import * as Misskey from 'misskey-js'; import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js'; -import { isEnabledUrlPreview } from '@/instance.js'; +import { isEnabledUrlPreview } from '@/utility/url-preview.js'; const MkUrlPreview = defineAsyncComponent(() => import('@/components/MkUrlPreview.vue')); diff --git a/packages/frontend/src/use/use-chart-tooltip.ts b/packages/frontend/src/composables/use-chart-tooltip.ts index bba64fc6ee..bba64fc6ee 100644 --- a/packages/frontend/src/use/use-chart-tooltip.ts +++ b/packages/frontend/src/composables/use-chart-tooltip.ts diff --git a/packages/frontend/src/use/use-form.ts b/packages/frontend/src/composables/use-form.ts index 1c93557413..1c93557413 100644 --- a/packages/frontend/src/use/use-form.ts +++ b/packages/frontend/src/composables/use-form.ts diff --git a/packages/frontend/src/use/use-leave-guard.ts b/packages/frontend/src/composables/use-leave-guard.ts index 395c12a756..395c12a756 100644 --- a/packages/frontend/src/use/use-leave-guard.ts +++ b/packages/frontend/src/composables/use-leave-guard.ts diff --git a/packages/frontend/src/components/hook/useLoading.ts b/packages/frontend/src/composables/use-loading.ts index 6c6ff6ae0d..6c6ff6ae0d 100644 --- a/packages/frontend/src/components/hook/useLoading.ts +++ b/packages/frontend/src/composables/use-loading.ts diff --git a/packages/frontend/src/use/use-mutation-observer.ts b/packages/frontend/src/composables/use-mutation-observer.ts index 7b774022dc..7b774022dc 100644 --- a/packages/frontend/src/use/use-mutation-observer.ts +++ b/packages/frontend/src/composables/use-mutation-observer.ts diff --git a/packages/frontend/src/composables/use-note-capture.ts b/packages/frontend/src/composables/use-note-capture.ts new file mode 100644 index 0000000000..90a5922b3e --- /dev/null +++ b/packages/frontend/src/composables/use-note-capture.ts @@ -0,0 +1,339 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { onUnmounted, reactive } from 'vue'; +import * as Misskey from 'misskey-js'; +import { EventEmitter } from 'eventemitter3'; +import type { Reactive } from 'vue'; +import { useStream } from '@/stream.js'; +import { $i } from '@/i.js'; +import { store } from '@/store.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { prefer } from '@/preferences.js'; +import { globalEvents } from '@/events.js'; + +export const noteEvents = new EventEmitter<{ + [ev: `reacted:${string}`]: (ctx: { userId: Misskey.entities.User['id']; reaction: string; emoji?: { name: string; url: string; }; }) => void; + [ev: `unreacted:${string}`]: (ctx: { userId: Misskey.entities.User['id']; reaction: string; emoji?: { name: string; url: string; }; }) => void; + [ev: `pollVoted:${string}`]: (ctx: { userId: Misskey.entities.User['id']; choice: string; }) => void; +}>(); + +const fetchEvent = new EventEmitter<{ + [id: string]: Pick<Misskey.entities.Note, 'reactions' | 'reactionEmojis'>; +}>(); + +const pollingQueue = new Map<string, { + referenceCount: number; + lastAddedAt: number; +}>(); + +function pollingEnqueue(note: Pick<Misskey.entities.Note, 'id' | 'createdAt'>) { + if (pollingQueue.has(note.id)) { + const data = pollingQueue.get(note.id)!; + pollingQueue.set(note.id, { + ...data, + referenceCount: data.referenceCount + 1, + lastAddedAt: Date.now(), + }); + } else { + pollingQueue.set(note.id, { + referenceCount: 1, + lastAddedAt: Date.now(), + }); + } +} + +function pollingDequeue(note: Pick<Misskey.entities.Note, 'id' | 'createdAt'>) { + const data = pollingQueue.get(note.id); + if (data == null) return; + + if (data.referenceCount === 1) { + pollingQueue.delete(note.id); + } else { + pollingQueue.set(note.id, { + ...data, + referenceCount: data.referenceCount - 1, + }); + } +} + +const CAPTURE_MAX = 30; +const MIN_POLLING_INTERVAL = 1000 * 10; +const POLLING_INTERVAL = + prefer.s.pollingInterval === 1 ? MIN_POLLING_INTERVAL * 1.5 * 1.5 : + prefer.s.pollingInterval === 2 ? MIN_POLLING_INTERVAL * 1.5 : + prefer.s.pollingInterval === 3 ? MIN_POLLING_INTERVAL : + MIN_POLLING_INTERVAL; + +window.setInterval(() => { + const ids = [...pollingQueue.entries()] + .filter(([k, v]) => Date.now() - v.lastAddedAt < 1000 * 60 * 5) // 追加されてから一定時間経過したものは省く + .map(([k, v]) => k) + .sort((a, b) => (a > b ? -1 : 1)) // 新しいものを優先するためにIDで降順ソート + .slice(0, CAPTURE_MAX); + + if (ids.length === 0) return; + if (window.document.hidden) return; + + // まとめてリクエストするのではなく、個別にHTTPリクエスト投げてCDNにキャッシュさせた方がサーバーの負荷低減には良いかもしれない? + misskeyApi('notes/show-partial-bulk', { + noteIds: ids, + }).then((items) => { + for (const item of items) { + fetchEvent.emit(item.id, { + reactions: item.reactions, + reactionEmojis: item.reactionEmojis, + }); + } + }); +}, POLLING_INTERVAL); + +function pollingSubscribe(props: { + note: Pick<Misskey.entities.Note, 'id' | 'createdAt'>; + $note: ReactiveNoteData; +}) { + const { note, $note } = props; + + function onFetched(data: Pick<Misskey.entities.Note, 'reactions' | 'reactionEmojis'>): void { + $note.reactions = data.reactions; + $note.reactionCount = Object.values(data.reactions).reduce((a, b) => a + b, 0); + $note.reactionEmojis = data.reactionEmojis; + } + + pollingEnqueue(note); + fetchEvent.on(note.id, onFetched); + + onUnmounted(() => { + pollingDequeue(note); + fetchEvent.off(note.id, onFetched); + }); +} + +function realtimeSubscribe(props: { + note: Pick<Misskey.entities.Note, 'id' | 'createdAt'>; +}): void { + const note = props.note; + const connection = useStream(); + + function onStreamNoteUpdated(noteData): void { + const { type, id, body } = noteData; + + if (id !== note.id) return; + + switch (type) { + case 'reacted': { + noteEvents.emit(`reacted:${id}`, { + userId: body.userId, + reaction: body.reaction, + emoji: body.emoji, + }); + break; + } + + case 'unreacted': { + noteEvents.emit(`unreacted:${id}`, { + userId: body.userId, + reaction: body.reaction, + emoji: body.emoji, + }); + break; + } + + case 'pollVoted': { + noteEvents.emit(`pollVoted:${id}`, { + userId: body.userId, + choice: body.choice, + }); + break; + } + + case 'deleted': { + globalEvents.emit('noteDeleted', id); + break; + } + } + } + + function capture(withHandler = false): void { + connection.send('sr', { id: note.id }); + if (withHandler) connection.on('noteUpdated', onStreamNoteUpdated); + } + + function decapture(withHandler = false): void { + connection.send('un', { id: note.id }); + if (withHandler) connection.off('noteUpdated', onStreamNoteUpdated); + } + + function onStreamConnected() { + capture(false); + } + + capture(true); + connection.on('_connected_', onStreamConnected); + + onUnmounted(() => { + decapture(true); + connection.off('_connected_', onStreamConnected); + }); +} + +export type ReactiveNoteData = { + reactions: Misskey.entities.Note['reactions']; + reactionCount: Misskey.entities.Note['reactionCount']; + reactionEmojis: Misskey.entities.Note['reactionEmojis']; + myReaction: Misskey.entities.Note['myReaction']; + pollChoices: NonNullable<Misskey.entities.Note['poll']>['choices']; +}; + +const noReaction = Symbol(); + +export function useNoteCapture(props: { + note: Misskey.entities.Note; + parentNote: Misskey.entities.Note | null; + mock?: boolean; +}): { + $note: Reactive<ReactiveNoteData>; + subscribe: () => void; +} { + const { note, parentNote, mock } = props; + + const $note = reactive<ReactiveNoteData>({ + reactions: Object.entries(note.reactions).reduce((acc, [name, count]) => { + // Normalize reactions + const normalizedName = name.replace(/^:(\w+):$/, ':$1@.:'); + if (acc[normalizedName] == null) { + acc[normalizedName] = count; + } else { + acc[normalizedName] += count; + } + return acc; + }, {} as Misskey.entities.Note['reactions']), + reactionCount: note.reactionCount, + reactionEmojis: note.reactionEmojis, + myReaction: note.myReaction, + pollChoices: note.poll?.choices ?? [], + }); + + noteEvents.on(`reacted:${note.id}`, onReacted); + noteEvents.on(`unreacted:${note.id}`, onUnreacted); + noteEvents.on(`pollVoted:${note.id}`, onPollVoted); + + // 操作がダブっていないかどうかを簡易的に記録するためのMap + const reactionUserMap = new Map<Misskey.entities.User['id'], string | typeof noReaction>(); + let latestPollVotedKey: string | null = null; + + function onReacted(ctx: { userId: Misskey.entities.User['id']; reaction: string; emoji?: { name: string; url: string; }; }): void { + const normalizedName = ctx.reaction.replace(/^:(\w+):$/, ':$1@.:'); + + if (reactionUserMap.has(ctx.userId) && reactionUserMap.get(ctx.userId) === normalizedName) return; + reactionUserMap.set(ctx.userId, normalizedName); + + if (ctx.emoji && !(ctx.emoji.name in $note.reactionEmojis)) { + $note.reactionEmojis[ctx.emoji.name] = ctx.emoji.url; + } + + const currentCount = $note.reactions[normalizedName] || 0; + + $note.reactions[normalizedName] = currentCount + 1; + $note.reactionCount += 1; + + if ($i && (ctx.userId === $i.id)) { + $note.myReaction = normalizedName; + } + } + + function onUnreacted(ctx: { userId: Misskey.entities.User['id']; reaction: string; emoji?: { name: string; url: string; }; }): void { + const normalizedName = ctx.reaction.replace(/^:(\w+):$/, ':$1@.:'); + + // 確実に一度リアクションされて取り消されている場合のみ処理をとめる(APIで初回読み込み→Streamでアップデート等の場合、reactionUserMapに情報がないため) + if (reactionUserMap.has(ctx.userId) && reactionUserMap.get(ctx.userId) === noReaction) return; + reactionUserMap.set(ctx.userId, noReaction); + + const currentCount = $note.reactions[normalizedName] || 0; + + $note.reactions[normalizedName] = Math.max(0, currentCount - 1); + $note.reactionCount = Math.max(0, $note.reactionCount - 1); + if ($note.reactions[normalizedName] === 0) delete $note.reactions[normalizedName]; + + if ($i && (ctx.userId === $i.id)) { + $note.myReaction = null; + } + } + + function onPollVoted(ctx: { userId: Misskey.entities.User['id']; choice: string; }): void { + const newPollVotedKey = `${ctx.userId}:${ctx.choice}`; + if (newPollVotedKey === latestPollVotedKey) return; + latestPollVotedKey = newPollVotedKey; + + const choices = [...$note.pollChoices]; + choices[ctx.choice] = { + ...choices[ctx.choice], + votes: choices[ctx.choice].votes + 1, + ...($i && (ctx.userId === $i.id) ? { + isVoted: true, + } : {}), + }; + + $note.pollChoices = choices; + } + + function subscribe() { + if (mock) { + // モックモードでは購読しない + return; + } + + if ($i && store.s.realtimeMode) { + realtimeSubscribe({ + note, + }); + } else { + pollingSubscribe({ + note, + $note, + }); + } + } + + onUnmounted(() => { + noteEvents.off(`reacted:${note.id}`, onReacted); + noteEvents.off(`unreacted:${note.id}`, onUnreacted); + noteEvents.off(`pollVoted:${note.id}`, onPollVoted); + }); + + // 投稿からある程度経過している(=タイムラインを遡って表示した)ノートは、イベントが発生する可能性が低いためそもそも購読しない + // ただし「リノートされたばかりの過去のノート」(= parentNoteが存在し、かつparentNoteの投稿日時が最近)はイベント発生が考えられるため購読する + // TODO: デバイスとサーバーの時計がズレていると不具合の元になるため、ズレを検知して警告を表示するなどのケアが必要かもしれない + if (parentNote == null) { + if ((Date.now() - new Date(note.createdAt).getTime()) > 1000 * 60 * 5) { // 5min + // リノートで表示されているノートでもないし、投稿からある程度経過しているので自動で購読しない + return { + $note, + subscribe: () => { + subscribe(); + }, + }; + } + } else { + if ((Date.now() - new Date(parentNote.createdAt).getTime()) > 1000 * 60 * 5) { // 5min + // リノートで表示されているノートだが、リノートされてからある程度経過しているので自動で購読しない + return { + $note, + subscribe: () => { + subscribe(); + }, + }; + } + } + + subscribe(); + + return { + $note, + subscribe: () => { + // すでに購読しているので何もしない + }, + }; +} diff --git a/packages/frontend/src/composables/use-pagination.ts b/packages/frontend/src/composables/use-pagination.ts new file mode 100644 index 0000000000..6a9f00bb91 --- /dev/null +++ b/packages/frontend/src/composables/use-pagination.ts @@ -0,0 +1,281 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { computed, isRef, onMounted, ref, shallowRef, triggerRef, watch } from 'vue'; +import * as Misskey from 'misskey-js'; +import type { ComputedRef, DeepReadonly, Ref, ShallowRef } from 'vue'; +import { misskeyApi } from '@/utility/misskey-api.js'; + +const MAX_ITEMS = 30; +const MAX_QUEUE_ITEMS = 100; +const FIRST_FETCH_LIMIT = 15; +const SECOND_FETCH_LIMIT = 30; + +export type MisskeyEntity = { + id: string; + createdAt: string; + _shouldInsertAd_?: boolean; + [x: string]: any; +}; + +export type PagingCtx<E extends keyof Misskey.Endpoints = keyof Misskey.Endpoints> = { + endpoint: E; + limit?: number; + params?: Misskey.Endpoints[E]['req'] | ComputedRef<Misskey.Endpoints[E]['req']>; + + /** + * 検索APIのような、ページング不可なエンドポイントを利用する場合 + * (そのようなAPIをこの関数で使うのは若干矛盾してるけど) + */ + noPaging?: boolean; + + offsetMode?: boolean; + + baseId?: MisskeyEntity['id']; + direction?: 'newer' | 'older'; + + // 一部のAPIはさらに遡れる場合でもパフォーマンス上の理由でlimit以下の結果を返す場合があり、その場合はsafe、それ以外はlimitにすることを推奨 + canFetchDetection?: 'safe' | 'limit'; +}; + +export function usePagination<Endpoint extends keyof Misskey.Endpoints, T extends { id: string; } = (Misskey.Endpoints[Endpoint]['res'] extends (infer I)[] ? I extends { id: string } ? I : { id: string } : { id: string })>(props: { + ctx: PagingCtx<Endpoint>; + autoInit?: boolean; + autoReInit?: boolean; + useShallowRef?: boolean; +}) { + const items = props.useShallowRef ? shallowRef<T[]>([]) : ref<T[]>([]); + let aheadQueue: T[] = []; + const queuedAheadItemsCount = ref(0); + const fetching = ref(true); + const fetchingOlder = ref(false); + const canFetchOlder = ref(false); + const error = ref(false); + + if (props.autoReInit !== false) { + watch(() => [props.ctx.endpoint, props.ctx.params], init, { deep: true }); + } + + function getNewestId(): string | null | undefined { + // 様々な要因により並び順は保証されないのでソートが必要 + if (aheadQueue.length > 0) { + return aheadQueue.map(x => x.id).sort().at(-1); + } + return items.value.map(x => x.id).sort().at(-1); + } + + function getOldestId(): string | null | undefined { + // 様々な要因により並び順は保証されないのでソートが必要 + return items.value.map(x => x.id).sort().at(0); + } + + async function init(): Promise<void> { + items.value = []; + aheadQueue = []; + queuedAheadItemsCount.value = 0; + fetching.value = true; + const params = props.ctx.params ? isRef(props.ctx.params) ? props.ctx.params.value : props.ctx.params : {}; + + await misskeyApi<T[]>(props.ctx.endpoint, { + ...params, + limit: props.ctx.limit ?? FIRST_FETCH_LIMIT, + allowPartial: true, + ...(props.ctx.baseId && props.ctx.direction === 'newer' ? { + sinceId: props.ctx.baseId, + } : props.ctx.baseId && props.ctx.direction === 'older' ? { + untilId: props.ctx.baseId, + } : {}), + }).then(res => { + // 逆順で返ってくるので + if (props.ctx.baseId && props.ctx.direction === 'newer') { + res.reverse(); + } + + for (let i = 0; i < res.length; i++) { + const item = res[i]; + if (i === 3) item._shouldInsertAd_ = true; + } + + pushItems(res); + + if (props.ctx.canFetchDetection === 'limit') { + if (res.length < FIRST_FETCH_LIMIT) { + canFetchOlder.value = false; + } else { + canFetchOlder.value = true; + } + } else if (props.ctx.canFetchDetection === 'safe' || props.ctx.canFetchDetection == null) { + if (res.length === 0 || props.ctx.noPaging) { + canFetchOlder.value = false; + } else { + canFetchOlder.value = true; + } + } + + error.value = false; + fetching.value = false; + }, err => { + error.value = true; + fetching.value = false; + }); + } + + function reload(): Promise<void> { + return init(); + } + + async function fetchOlder(): Promise<void> { + if (!canFetchOlder.value || fetching.value || fetchingOlder.value || items.value.length === 0) return; + fetchingOlder.value = true; + const params = props.ctx.params ? isRef(props.ctx.params) ? props.ctx.params.value : props.ctx.params : {}; + await misskeyApi<T[]>(props.ctx.endpoint, { + ...params, + limit: SECOND_FETCH_LIMIT, + ...(props.ctx.offsetMode ? { + offset: items.value.length, + } : { + untilId: getOldestId(), + }), + }).then(res => { + for (let i = 0; i < res.length; i++) { + const item = res[i]; + if (i === 10) item._shouldInsertAd_ = true; + } + + pushItems(res); + + if (props.ctx.canFetchDetection === 'limit') { + if (res.length < FIRST_FETCH_LIMIT) { + canFetchOlder.value = false; + } else { + canFetchOlder.value = true; + } + } else if (props.ctx.canFetchDetection === 'safe' || props.ctx.canFetchDetection == null) { + if (res.length === 0) { + canFetchOlder.value = false; + } else { + canFetchOlder.value = true; + } + } + }).finally(() => { + fetchingOlder.value = false; + }); + } + + async function fetchNewer(options: { + toQueue?: boolean; + } = {}): Promise<void> { + const params = props.ctx.params ? isRef(props.ctx.params) ? props.ctx.params.value : props.ctx.params : {}; + await misskeyApi<T[]>(props.ctx.endpoint, { + ...params, + limit: SECOND_FETCH_LIMIT, + ...(props.ctx.offsetMode ? { + offset: items.value.length, + } : { + sinceId: getNewestId(), + }), + }).then(res => { + if (res.length === 0) return; // これやらないと余計なre-renderが走る + + if (options.toQueue) { + aheadQueue.unshift(...res.toReversed()); + if (aheadQueue.length > MAX_QUEUE_ITEMS) { + aheadQueue = aheadQueue.slice(0, MAX_QUEUE_ITEMS); + } + queuedAheadItemsCount.value = aheadQueue.length; + } else { + unshiftItems(res.toReversed()); + } + }); + } + + function trim(trigger = true) { + if (items.value.length >= MAX_ITEMS) canFetchOlder.value = true; + items.value = items.value.slice(0, MAX_ITEMS); + if (props.useShallowRef && trigger) triggerRef(items); + } + + function unshiftItems(newItems: T[]) { + if (newItems.length === 0) return; // これやらないと余計なre-renderが走る + items.value.unshift(...newItems.filter(x => !items.value.some(y => y.id === x.id))); // ストリーミングやポーリングのタイミングによっては重複することがあるため + trim(false); + if (props.useShallowRef) triggerRef(items); + } + + function pushItems(oldItems: T[]) { + if (oldItems.length === 0) return; // これやらないと余計なre-renderが走る + items.value.push(...oldItems); + if (props.useShallowRef) triggerRef(items); + } + + function prepend(item: T) { + if (items.value.some(x => x.id === item.id)) return; + items.value.unshift(item); + trim(false); + if (props.useShallowRef) triggerRef(items); + } + + function enqueue(item: T) { + aheadQueue.unshift(item); + if (aheadQueue.length > MAX_QUEUE_ITEMS) { + aheadQueue.pop(); + } + queuedAheadItemsCount.value = aheadQueue.length; + } + + function releaseQueue() { + if (aheadQueue.length === 0) return; // これやらないと余計なre-renderが走る + unshiftItems(aheadQueue); + aheadQueue = []; + queuedAheadItemsCount.value = 0; + } + + function removeItem(id: string) { + // TODO: queueからも消す + + const index = items.value.findIndex(x => x.id === id); + if (index !== -1) { + items.value.splice(index, 1); + if (props.useShallowRef) triggerRef(items); + } + } + + function updateItem(id: string, updator: (item: T) => T) { + // TODO: queueのも更新 + + const index = items.value.findIndex(x => x.id === id); + if (index !== -1) { + const item = items.value[index]!; + items.value[index] = updator(item); + if (props.useShallowRef) triggerRef(items); + } + } + + if (props.autoInit !== false) { + onMounted(() => { + init(); + }); + } + + return { + items: items as DeepReadonly<ShallowRef<T[]>>, + queuedAheadItemsCount, + fetching, + fetchingOlder, + canFetchOlder, + init, + reload, + fetchOlder, + fetchNewer, + unshiftItems, + prepend, + trim, + removeItem, + updateItem, + enqueue, + releaseQueue, + error, + }; +} diff --git a/packages/frontend/src/use/use-scroll-position-keeper.ts b/packages/frontend/src/composables/use-scroll-position-keeper.ts index b584171cbe..b584171cbe 100644 --- a/packages/frontend/src/use/use-scroll-position-keeper.ts +++ b/packages/frontend/src/composables/use-scroll-position-keeper.ts diff --git a/packages/frontend/src/use/use-tooltip.ts b/packages/frontend/src/composables/use-tooltip.ts index af76a3a1e8..af76a3a1e8 100644 --- a/packages/frontend/src/use/use-tooltip.ts +++ b/packages/frontend/src/composables/use-tooltip.ts diff --git a/packages/frontend/src/drag-and-drop.ts b/packages/frontend/src/drag-and-drop.ts new file mode 100644 index 0000000000..3c6f22f24b --- /dev/null +++ b/packages/frontend/src/drag-and-drop.ts @@ -0,0 +1,48 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as Misskey from 'misskey-js'; + +type DragDataMap = { + driveFiles: Misskey.entities.DriveFile[]; + driveFolders: Misskey.entities.DriveFolder[]; + deckColumn: string; +}; + +// NOTE: dataTransfer の format は大文字小文字区別されないっぽいので toLowerCase が必要 + +export function setDragData<T extends keyof DragDataMap>( + event: DragEvent, + type: T, + data: DragDataMap[T], +) { + if (event.dataTransfer == null) return; + + event.dataTransfer.setData(`misskey/${type}`.toLowerCase(), JSON.stringify(data)); +} + +export function getDragData<T extends keyof DragDataMap>( + event: DragEvent, + type: T, +): DragDataMap[T] | null { + if (event.dataTransfer == null) return null; + + const data = event.dataTransfer.getData(`misskey/${type}`.toLowerCase()); + if (data == null || data === '') return null; + + return JSON.parse(data); +} + +export function checkDragDataType( + event: DragEvent, + types: (keyof DragDataMap)[], +): boolean { + if (event.dataTransfer == null) return false; + + const dataType = event.dataTransfer.types[0]; + if (dataType == null || dataType === '') return false; + + return types.some((type) => `misskey/${type}`.toLowerCase() === dataType.toLowerCase()); +} diff --git a/packages/frontend/src/events.ts b/packages/frontend/src/events.ts index dfd3d4120c..649561cd75 100644 --- a/packages/frontend/src/events.ts +++ b/packages/frontend/src/events.ts @@ -5,9 +5,29 @@ import { EventEmitter } from 'eventemitter3'; import * as Misskey from 'misskey-js'; +import { onBeforeUnmount } from 'vue'; -export const globalEvents = new EventEmitter<{ +type Events = { themeChanging: () => void; themeChanged: () => void; clientNotification: (notification: Misskey.entities.Notification) => void; -}>(); + notePosted: (note: Misskey.entities.Note) => void; + noteDeleted: (noteId: Misskey.entities.Note['id']) => void; + driveFileCreated: (file: Misskey.entities.DriveFile) => void; + driveFilesUpdated: (files: Misskey.entities.DriveFile[]) => void; + driveFilesDeleted: (files: Misskey.entities.DriveFile[]) => void; + driveFoldersUpdated: (folders: Misskey.entities.DriveFolder[]) => void; + driveFoldersDeleted: (folders: Misskey.entities.DriveFolder[]) => void; +}; + +export const globalEvents = new EventEmitter<Events>(); + +export function useGlobalEvent<T extends keyof Events>( + event: T, + callback: Events[T], +): void { + globalEvents.on(event, callback); + onBeforeUnmount(() => { + globalEvents.off(event, callback); + }); +} diff --git a/packages/frontend/src/instance.ts b/packages/frontend/src/instance.ts index 2943e60e43..a5397f0c0d 100644 --- a/packages/frontend/src/instance.ts +++ b/packages/frontend/src/instance.ts @@ -29,8 +29,6 @@ if (providedAt > cachedAt) { export const instance: Misskey.entities.MetaDetailed = reactive(cachedMeta ?? {}); -export const isEnabledUrlPreview = computed(() => instance.enableUrlPreview ?? true); - export async function fetchInstance(force = false): Promise<Misskey.entities.MetaDetailed> { if (!force) { const cachedAt = miLocalStorage.getItem('instanceCachedAt') ? parseInt(miLocalStorage.getItem('instanceCachedAt')!) : 0; diff --git a/packages/frontend/src/lib/pizzax.ts b/packages/frontend/src/lib/pizzax.ts index a232ced75e..20d44032df 100644 --- a/packages/frontend/src/lib/pizzax.ts +++ b/packages/frontend/src/lib/pizzax.ts @@ -12,7 +12,6 @@ import { $i } from '@/i.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { get, set } from '@/utility/idb-proxy.js'; import { store } from '@/store.js'; -import { useStream } from '@/stream.js'; import { deepClone } from '@/utility/clone.js'; import { deepMerge } from '@/utility/merge.js'; @@ -129,25 +128,6 @@ export class Pizzax<T extends StateDef> { if (where === 'deviceAccount' && !($i && userId !== $i.id)) return; this.r[key].value = this.s[key] = value; }); - - if ($i) { - const connection = useStream().useChannel('main'); - - // streamingのuser storage updateイベントを監視して更新 - connection.on('registryUpdated', ({ scope, key, value }: { scope?: string[], key: keyof T, value: T[typeof key]['default'] }) => { - if (!scope || scope.length !== 2 || scope[0] !== 'client' || scope[1] !== this.key || this.s[key] === value) return; - - this.r[key].value = this.s[key] = value; - - this.addIdbSetJob(async () => { - const cache = await get(this.registryCacheKeyName); - if (cache[key] !== value) { - cache[key] = value; - await set(this.registryCacheKeyName, cache); - } - }); - }); - } } private load(): Promise<void> { diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts index 813b49635d..08291a5595 100644 --- a/packages/frontend/src/os.ts +++ b/packages/frontend/src/os.ts @@ -547,18 +547,36 @@ export function success(): Promise<void> { }); } -export function waiting(text?: string | null): Promise<void> { - return new Promise(resolve => { - const showing = ref(true); - const { dispose } = popup(MkWaitingDialog, { - success: false, - showing: showing, - text, - }, { - done: () => resolve(), - closed: () => dispose(), - }); +export function waiting(options: { text?: string } = {}) { + window.document.body.setAttribute('inert', 'true'); + + const showing = ref(true); + const isSuccess = ref(false); + + function done(doneOptions: { success?: boolean } = {}) { + if (doneOptions.success) { + isSuccess.value = true; + window.setTimeout(() => { + showing.value = false; + }, 1000); + } else { + showing.value = false; + } + } + + // NOTE: dynamic importすると挙動がおかしくなる(showingの変更が伝播しない) + const { dispose } = popup(MkWaitingDialog, { + success: isSuccess, + showing: showing, + text: options.text, + }, { + closed: () => { + window.document.body.removeAttribute('inert'); + dispose(); + }, }); + + return done; } export function form<F extends Form>(title: string, f: F): Promise<{ canceled: true, result?: undefined } | { canceled?: false, result: GetFormResultType<F> }> { @@ -586,38 +604,6 @@ export async function selectUser(opts: { includeSelf?: boolean; localOnly?: bool }); } -export async function selectDriveFile(multiple: boolean): Promise<Misskey.entities.DriveFile[]> { - return new Promise(resolve => { - const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkDriveSelectDialog.vue')), { - type: 'file', - multiple, - }, { - done: files => { - if (files) { - resolve(files); - } - }, - closed: () => dispose(), - }); - }); -} - -export async function selectDriveFolder(multiple: boolean): Promise<Misskey.entities.DriveFolder[]> { - return new Promise(resolve => { - const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkDriveSelectDialog.vue')), { - type: 'folder', - multiple, - }, { - done: folders => { - if (folders) { - resolve(folders); - } - }, - closed: () => dispose(), - }); - }); -} - export async function selectRole(params: ComponentProps<typeof MkRoleSelectDialog_TypeReferenceOnly>): Promise< { canceled: true; result: undefined; } | { canceled: false; result: Misskey.entities.Role[] } @@ -635,10 +621,10 @@ export async function selectRole(params: ComponentProps<typeof MkRoleSelectDialo }); } -export async function pickEmoji(src: HTMLElement, opts: ComponentProps<typeof MkEmojiPickerDialog_TypeReferenceOnly>): Promise<string> { +export async function pickEmoji(anchorElement: HTMLElement, opts: ComponentProps<typeof MkEmojiPickerDialog_TypeReferenceOnly>): Promise<string> { return new Promise(resolve => { const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerDialog.vue')), { - src, + anchorElement, ...opts, }, { done: emoji => { @@ -649,15 +635,13 @@ export async function pickEmoji(src: HTMLElement, opts: ComponentProps<typeof Mk }); } -export async function cropImage(image: Misskey.entities.DriveFile, options: { - aspectRatio: number; - uploadFolder?: string | null; -}): Promise<Misskey.entities.DriveFile> { +export async function cropImageFile(imageFile: File | Blob, options: { + aspectRatio: number | null; +}): Promise<File> { return new Promise(resolve => { const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkCropperDialog.vue')), { - file: image, + imageFile: imageFile, aspectRatio: options.aspectRatio, - uploadFolder: options.uploadFolder, }, { ok: x => { resolve(x); @@ -667,20 +651,20 @@ export async function cropImage(image: Misskey.entities.DriveFile, options: { }); } -export function popupMenu(items: MenuItem[], src?: HTMLElement | EventTarget | null, options?: { +export function popupMenu(items: MenuItem[], anchorElement?: HTMLElement | EventTarget | null, options?: { align?: string; width?: number; onClosing?: () => void; }): Promise<void> { - if (!(src instanceof HTMLElement)) { - src = null; + if (!(anchorElement instanceof HTMLElement)) { + anchorElement = null; } - let returnFocusTo = getHTMLElementOrNull(src) ?? getHTMLElementOrNull(window.document.activeElement); + let returnFocusTo = getHTMLElementOrNull(anchorElement) ?? getHTMLElementOrNull(window.document.activeElement); return new Promise(resolve => nextTick(() => { const { dispose } = popup(MkPopupMenu, { items, - src, + anchorElement, width: options?.width, align: options?.align, returnFocusTo, @@ -769,3 +753,54 @@ export function checkExistence(fileData: ArrayBuffer): Promise<any> { }); }); }*/ + +export function chooseFileFromPc( + options: { + multiple?: boolean; + } = {}, +): Promise<File[]> { + return new Promise((res, rej) => { + const input = window.document.createElement('input'); + input.type = 'file'; + input.multiple = options.multiple ?? false; + input.onchange = () => { + if (!input.files) return res([]); + + res(Array.from(input.files)); + + // 一応廃棄 + (window as any).__misskey_input_ref__ = null; + }; + + // https://qiita.com/fukasawah/items/b9dc732d95d99551013d + // iOS Safari で正常に動かす為のおまじない + (window as any).__misskey_input_ref__ = input; + + input.click(); + }); +} + +export function launchUploader( + files: File[], + options?: { + folderId?: string | null; + multiple?: boolean; + }, +): Promise<Misskey.entities.DriveFile[]> { + return new Promise((res, rej) => { + if (files.length === 0) return rej(); + const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkUploaderDialog.vue')), { + files: markRaw(files), + folderId: options?.folderId, + multiple: options?.multiple, + }, { + done: driveFiles => { + if (driveFiles.length === 0) return rej(); + res(driveFiles); + }, + closed: () => dispose(), + }); + }); +} + +export const pageFolderTeleportCount = ref(0); diff --git a/packages/frontend/src/pages/about-misskey.vue b/packages/frontend/src/pages/about-misskey.vue index 7c63c8c1ef..7916cc7834 100644 --- a/packages/frontend/src/pages/about-misskey.vue +++ b/packages/frontend/src/pages/about-misskey.vue @@ -390,6 +390,7 @@ const patrons = [ 'まゆつな空高', 'asata', 'ruru', + 'みりめい', ]; const thereIsTreasure = ref($i && !claimedAchievements.includes('foundTreasure')); diff --git a/packages/frontend/src/pages/about.federation.vue b/packages/frontend/src/pages/about.federation.vue index 97743995bf..7e5abb4b34 100644 --- a/packages/frontend/src/pages/about.federation.vue +++ b/packages/frontend/src/pages/about.federation.vue @@ -55,7 +55,7 @@ import { computed, ref } from 'vue'; import MkInput from '@/components/MkInput.vue'; import MkSelect from '@/components/MkSelect.vue'; import MkPagination from '@/components/MkPagination.vue'; -import type { Paging } from '@/components/MkPagination.vue'; +import type { PagingCtx } from '@/composables/use-pagination.js'; import MkInstanceCardMini from '@/components/MkInstanceCardMini.vue'; import FormSplit from '@/components/form/split.vue'; import { i18n } from '@/i18n.js'; @@ -81,7 +81,7 @@ const pagination = { state.value === 'notResponding' ? { notResponding: true } : {}), })), -} as Paging; +} as PagingCtx; function getStatus(instance) { if (instance.isSuspended) return 'Suspended'; diff --git a/packages/frontend/src/pages/admin/abuses.vue b/packages/frontend/src/pages/admin/abuses.vue index 3dc5c2ef7e..4dbb573ceb 100644 --- a/packages/frontend/src/pages/admin/abuses.vue +++ b/packages/frontend/src/pages/admin/abuses.vue @@ -11,9 +11,9 @@ SPDX-License-Identifier: AGPL-3.0-only <MkButton link to="/admin/abuse-report-notification-recipient" primary>{{ i18n.ts.notificationSetting }}</MkButton> </div> - <MkInfo v-if="!store.r.abusesTutorial.value" closable @close="closeTutorial()"> + <MkTip k="abuses"> {{ i18n.ts._abuseUserReport.resolveTutorial }} - </MkInfo> + </MkTip> <div :class="$style.inputs" class="_gaps"> <MkSelect v-model="state" style="margin: 0; flex: 1;"> @@ -65,7 +65,6 @@ import XAbuseReport from '@/components/MkAbuseReport.vue'; import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; import MkButton from '@/components/MkButton.vue'; -import MkInfo from '@/components/MkInfo.vue'; import { store } from '@/store.js'; const reports = useTemplateRef('reports'); @@ -87,11 +86,7 @@ const pagination = { }; function resolved(reportId) { - reports.value?.removeItem(reportId); -} - -function closeTutorial() { - store.set('abusesTutorial', false); + reports.value?.paginator.removeItem(reportId); } const headerActions = computed(() => []); diff --git a/packages/frontend/src/pages/admin/bot-protection.vue b/packages/frontend/src/pages/admin/bot-protection.vue index 2bd734f7d3..6c580f87f1 100644 --- a/packages/frontend/src/pages/admin/bot-protection.vue +++ b/packages/frontend/src/pages/admin/bot-protection.vue @@ -163,7 +163,7 @@ import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { fetchInstance } from '@/instance.js'; import { i18n } from '@/i18n.js'; -import { useForm } from '@/use/use-form.js'; +import { useForm } from '@/composables/use-form.js'; import MkFormFooter from '@/components/MkFormFooter.vue'; import MkFolder from '@/components/MkFolder.vue'; import MkInfo from '@/components/MkInfo.vue'; diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.vue b/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.vue index 59b780bff6..68c7048ae1 100644 --- a/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.vue +++ b/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.vue @@ -87,9 +87,9 @@ import MkButton from '@/components/MkButton.vue'; import { validators } from '@/components/grid/cell-validators.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import MkPagingButtons from '@/components/MkPagingButtons.vue'; -import { selectFile } from '@/utility/select-file.js'; +import { selectFile } from '@/utility/drive.js'; import { copyGridDataToClipboard, removeDataFromGrid } from '@/components/grid/grid-utils.js'; -import { useLoading } from '@/components/hook/useLoading.js'; +import { useLoading } from '@/composables/use-loading.js'; type GridItem = { checked: boolean; diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.register.vue b/packages/frontend/src/pages/admin/custom-emojis-manager.register.vue index e8e944df32..621ec8a6a8 100644 --- a/packages/frontend/src/pages/admin/custom-emojis-manager.register.vue +++ b/packages/frontend/src/pages/admin/custom-emojis-manager.register.vue @@ -35,20 +35,9 @@ SPDX-License-Identifier: AGPL-3.0-only <XRegisterLogs :logs="requestLogs"/> </MkFolder> - <div - :class="[$style.uploadBox, [isDragOver ? $style.dragOver : {}]]" - @dragover.prevent="isDragOver = true" - @dragleave.prevent="isDragOver = false" - @drop.prevent.stop="onDrop" - > - <div style="margin-top: 1em"> - {{ i18n.ts._customEmojisManager._local._register.emojiInputAreaCaption }} - </div> - <ul> - <li>{{ i18n.ts._customEmojisManager._local._register.emojiInputAreaList1 }}</li> - <li><a @click.prevent="onFileSelectClicked">{{ i18n.ts._customEmojisManager._local._register.emojiInputAreaList2 }}</a></li> - <li><a @click.prevent="onDriveSelectClicked">{{ i18n.ts._customEmojisManager._local._register.emojiInputAreaList3 }}</a></li> - </ul> + <div class="_buttonsCenter"> + <MkButton primary rounded @click="onFileSelectClicked">{{ i18n.ts.upload }}</MkButton> + <MkButton primary rounded @click="onDriveSelectClicked">{{ i18n.ts.fromDrive }}</MkButton> </div> <div v-if="gridItems.length > 0" :class="$style.gridArea"> @@ -94,8 +83,7 @@ import MkFolder from '@/components/MkFolder.vue'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; import { validators } from '@/components/grid/cell-validators.js'; -import { chooseFileFromDrive, chooseFileFromPc } from '@/utility/select-file.js'; -import { uploadFile } from '@/utility/upload.js'; +import { chooseDriveFile, chooseFileFromPcAndUpload } from '@/utility/drive.js'; import { extractDroppedItems, flattenDroppedFiles } from '@/utility/file-drop.js'; import XRegisterLogs from '@/pages/admin/custom-emojis-manager.logs.vue'; import { copyGridDataToClipboard } from '@/components/grid/grid-utils.js'; @@ -311,75 +299,21 @@ async function onClearClicked() { } } -async function onDrop(ev: DragEvent) { - isDragOver.value = false; - - const droppedFiles = await extractDroppedItems(ev).then(it => flattenDroppedFiles(it)); - const confirm = await os.confirm({ - type: 'info', - text: i18n.tsx._customEmojisManager._local._register.confirmUploadEmojisDescription({ count: droppedFiles.length }), - }); - if (confirm.canceled) { - return; - } - - const uploadedItems = Array.of<{ droppedFile: DroppedFile, driveFile: Misskey.entities.DriveFile }>(); - try { - uploadedItems.push( - ...await os.promiseDialog( - Promise.all( - droppedFiles.map(async (it) => ({ - droppedFile: it, - driveFile: await uploadFile( - it.file, - selectedFolderId.value, - it.file.name.replace(/\.[^.]+$/, ''), - true, - ), - }), - ), - ), - () => { - }, - () => { - }, - ), - ); - } catch (err) { - // ダイアログは共通部品側で出ているはずなので何もしない - return; - } - - const items = uploadedItems.map(({ droppedFile, driveFile }) => { - const item = fromDriveFile(driveFile); - if (directoryToCategory.value) { - item.category = droppedFile.path - .replace(/^\//, '') - .replace(/\/[^/]+$/, '') - .replace(droppedFile.file.name, ''); - } - return item; - }); - - gridItems.value.push(...items); -} - async function onFileSelectClicked() { - const driveFiles = await chooseFileFromPc( - true, - { - uploadFolder: selectedFolderId.value, - keepOriginal: true, - // 拡張子は消す - nameConverter: (file) => file.name.replace(/\.[a-zA-Z0-9]+$/, ''), - }, - ); + const driveFiles = await chooseFileFromPcAndUpload({ + multiple: true, + folderId: selectedFolderId.value, + // 拡張子は消す + nameConverter: (file) => file.name.replace(/\.[a-zA-Z0-9]+$/, ''), + }); gridItems.value.push(...driveFiles.map(fromDriveFile)); } async function onDriveSelectClicked() { - const driveFiles = await chooseFileFromDrive(true); + const driveFiles = await chooseDriveFile({ + multiple: true, + }); gridItems.value.push(...driveFiles.map(fromDriveFile)); } @@ -436,23 +370,6 @@ onMounted(async () => { background-color: var(--MI_THEME-infoWarnBg); } -.uploadBox { - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - width: 100%; - height: auto; - border: 0.5px dotted var(--MI_THEME-accentedBg); - border-radius: var(--MI-radius); - background-color: var(--MI_THEME-accentedBg); - box-sizing: border-box; - - &.dragOver { - cursor: copy; - } -} - .gridArea { padding-top: 8px; padding-bottom: 8px; diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.remote.vue b/packages/frontend/src/pages/admin/custom-emojis-manager.remote.vue index 2fd7e331a2..d9c9227105 100644 --- a/packages/frontend/src/pages/admin/custom-emojis-manager.remote.vue +++ b/packages/frontend/src/pages/admin/custom-emojis-manager.remote.vue @@ -159,7 +159,7 @@ import * as os from '@/os.js'; import { deviceKind } from '@/utility/device-kind.js'; import MkPagingButtons from '@/components/MkPagingButtons.vue'; import MkSortOrderEditor from '@/components/MkSortOrderEditor.vue'; -import { useLoading } from '@/components/hook/useLoading.js'; +import { useLoading } from '@/composables/use-loading.js'; type GridItem = { checked: boolean; diff --git a/packages/frontend/src/pages/admin/federation-job-queue.chart.chart.vue b/packages/frontend/src/pages/admin/federation-job-queue.chart.chart.vue index 5dd2887024..9a311b5772 100644 --- a/packages/frontend/src/pages/admin/federation-job-queue.chart.chart.vue +++ b/packages/frontend/src/pages/admin/federation-job-queue.chart.chart.vue @@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { onMounted, useTemplateRef } from 'vue'; import { Chart } from 'chart.js'; import { store } from '@/store.js'; -import { useChartTooltip } from '@/use/use-chart-tooltip.js'; +import { useChartTooltip } from '@/composables/use-chart-tooltip.js'; import { chartVLine } from '@/utility/chart-vline.js'; import { alpha } from '@/utility/color.js'; import { initChart } from '@/utility/init-chart.js'; diff --git a/packages/frontend/src/pages/admin/invites.vue b/packages/frontend/src/pages/admin/invites.vue index 072175f3af..f1584fc864 100644 --- a/packages/frontend/src/pages/admin/invites.vue +++ b/packages/frontend/src/pages/admin/invites.vue @@ -55,7 +55,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, ref, useTemplateRef } from 'vue'; -import type { Paging } from '@/components/MkPagination.vue'; +import type { PagingCtx } from '@/composables/use-pagination.js'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; @@ -73,7 +73,7 @@ const pagingComponent = useTemplateRef('pagingComponent'); const type = ref('all'); const sort = ref('+createdAt'); -const pagination: Paging = { +const pagination: PagingCtx = { endpoint: 'admin/invite/list' as const, limit: 10, params: computed(() => ({ @@ -100,12 +100,12 @@ async function createWithOptions() { text: tickets.map(x => x.code).join('\n'), }); - tickets.forEach(ticket => pagingComponent.value?.prepend(ticket)); + tickets.forEach(ticket => pagingComponent.value?.paginator.prepend(ticket)); } function deleted(id: string) { if (pagingComponent.value) { - pagingComponent.value.items.delete(id); + pagingComponent.value.paginator.removeItem(id); } } diff --git a/packages/frontend/src/pages/admin/job-queue.chart.vue b/packages/frontend/src/pages/admin/job-queue.chart.vue index f42b35105e..a1920e277b 100644 --- a/packages/frontend/src/pages/admin/job-queue.chart.vue +++ b/packages/frontend/src/pages/admin/job-queue.chart.vue @@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { onMounted, useTemplateRef, watch } from 'vue'; import { Chart } from 'chart.js'; import { store } from '@/store.js'; -import { useChartTooltip } from '@/use/use-chart-tooltip.js'; +import { useChartTooltip } from '@/composables/use-chart-tooltip.js'; import { chartVLine } from '@/utility/chart-vline.js'; import { alpha } from '@/utility/color.js'; import { initChart } from '@/utility/init-chart.js'; @@ -48,6 +48,8 @@ watch(() => props.dataSet, () => { }); onMounted(() => { + if (chartEl.value == null) return; + const vLineColor = store.s.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; chartInstance = new Chart(chartEl.value, { diff --git a/packages/frontend/src/pages/admin/job-queue.job.vue b/packages/frontend/src/pages/admin/job-queue.job.vue index 71efab0272..7d8cdde8b9 100644 --- a/packages/frontend/src/pages/admin/job-queue.job.vue +++ b/packages/frontend/src/pages/admin/job-queue.job.vue @@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <template #suffix> <MkTime :time="job.finishedOn ?? job.processedOn ?? job.timestamp" mode="relative"/> - <span v-if="job.progress != null && job.progress > 0" style="margin-left: 1em;">{{ Math.floor(job.progress * 100) }}%</span> + <span v-if="job.progress != null && typeof job.progress === 'number' && job.progress > 0" style="margin-left: 1em;">{{ Math.floor(job.progress * 100) }}%</span> <span v-if="job.opts.attempts != null && job.opts.attempts > 0 && job.attempts > 1" style="margin-left: 1em; color: var(--MI_THEME-warn); font-variant-numeric: diagonal-fractions;">{{ job.attempts }}/{{ job.opts.attempts }}</span> <span v-if="job.isFailed && job.finishedOn != null" style="margin-left: 1em; color: var(--MI_THEME-error)"><i class="ti ti-circle-x"></i></span> <span v-else-if="job.isFailed" style="margin-left: 1em; color: var(--MI_THEME-warn)"><i class="ti ti-alert-triangle"></i></span> @@ -61,7 +61,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkButton rounded @click="copyRaw()"><i class="ti ti-copy"></i> Copy raw</MkButton> <MkButton rounded @click="refresh()"><i class="ti ti-reload"></i> Refresh view</MkButton> <MkButton rounded @click="promoteJob()"><i class="ti ti-player-track-next"></i> Promote</MkButton> - <MkButton rounded @click="moveJob"><i class="ti ti-arrow-right"></i> Move to</MkButton> + <!-- <MkButton rounded @click="moveJob"><i class="ti ti-arrow-right"></i> Move to</MkButton> --> <MkButton danger rounded style="margin-left: auto;" @click="removeJob()"><i class="ti ti-trash"></i> Remove</MkButton> </div> </template> @@ -96,7 +96,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template #key>Attempts</template> <template #value>{{ job.attempts }} of {{ job.opts.attempts }}</template> </MkKeyValue> - <MkKeyValue v-if="job.progress != null && job.progress > 0"> + <MkKeyValue v-if="job.progress != null && typeof job.progress === 'number' && job.progress > 0"> <template #key>Progress</template> <template #value>{{ Math.floor(job.progress * 100) }}%</template> </MkKeyValue> @@ -150,7 +150,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkButton><i class="ti ti-device-floppy"></i> Update</MkButton> </div> <div v-else-if="tab === 'result'"> - <MkCode :code="job.returnValue"/> + <MkCode :code="String(job.returnValue)"/> </div> <div v-else-if="tab === 'error'" class="_gaps_s"> <MkCode v-for="log in job.stacktrace" :code="log" lang="stacktrace"/> @@ -159,22 +159,20 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ref, computed, watch } from 'vue'; +import { ref, computed } from 'vue'; +import * as Misskey from 'misskey-js'; import JSON5 from 'json5'; -import type { Ref } from 'vue'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; import MkButton from '@/components/MkButton.vue'; -import { misskeyApi } from '@/utility/misskey-api.js'; import MkTabs from '@/components/MkTabs.vue'; import MkFolder from '@/components/MkFolder.vue'; import MkCode from '@/components/MkCode.vue'; import MkKeyValue from '@/components/MkKeyValue.vue'; import MkCodeEditor from '@/components/MkCodeEditor.vue'; import MkTl from '@/components/MkTl.vue'; -import kmg from '@/filters/kmg.js'; -import bytes from '@/filters/bytes.js'; import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; +import type { TlEvent } from '@/components/MkTl.vue'; function msSMH(v: number | null) { if (v == null) return 'N/A'; @@ -189,25 +187,34 @@ function msSMH(v: number | null) { } const props = defineProps<{ - job: any; - queueType: string; + job: Misskey.entities.QueueJob; + queueType: typeof Misskey.queueTypes[number]; }>(); const emit = defineEmits<{ - (ev: 'needRefresh'): void, + (ev: 'needRefresh'): void; }>(); const tab = ref('info'); const editData = ref(JSON5.stringify(props.job.data, null, '\t')); const canEdit = true; + +type TlType = TlEvent<{ + type: 'created' | 'processed' | 'finished'; +} | { + type: 'attempt'; + attempt: number; +}>; + const timeline = computed(() => { - const events = [{ + const events: TlType[] = [{ id: 'created', timestamp: props.job.timestamp, data: { type: 'created', }, }]; + if (props.job.attempts > 1) { for (let i = 1; i < props.job.attempts; i++) { events.push({ @@ -261,9 +268,10 @@ async function removeJob() { os.apiWithDialog('admin/queue/remove-job', { queue: props.queueType, jobId: props.job.id }); } -function moveJob() { - // TODO -} +// TODO +// function moveJob() { +// +// } function refresh() { emit('needRefresh'); diff --git a/packages/frontend/src/pages/admin/job-queue.vue b/packages/frontend/src/pages/admin/job-queue.vue index 3d405c566f..8fae3bbb1c 100644 --- a/packages/frontend/src/pages/admin/job-queue.vue +++ b/packages/frontend/src/pages/admin/job-queue.vue @@ -37,9 +37,9 @@ SPDX-License-Identifier: AGPL-3.0-only <template #footer> <div class="_buttons"> <MkButton rounded @click="promoteAllJobs"><i class="ti ti-player-track-next"></i> Promote all jobs</MkButton> - <MkButton rounded @click="createJob"><i class="ti ti-plus"></i> Add job</MkButton> - <MkButton v-if="queueInfo.isPaused" rounded @click="resumeQueue"><i class="ti ti-player-play"></i> Resume queue</MkButton> - <MkButton v-else rounded danger @click="pauseQueue"><i class="ti ti-player-pause"></i> Pause queue</MkButton> + <!-- <MkButton rounded @click="createJob"><i class="ti ti-plus"></i> Add job</MkButton> --> + <!-- <MkButton v-if="queueInfo.isPaused" rounded @click="resumeQueue"><i class="ti ti-player-play"></i> Resume queue</MkButton> --> + <!-- <MkButton v-else rounded danger @click="pauseQueue"><i class="ti ti-player-pause"></i> Pause queue</MkButton> --> <MkButton rounded danger @click="clearQueue"><i class="ti ti-trash"></i> Empty queue</MkButton> </div> </template> @@ -172,12 +172,11 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref, computed, watch } from 'vue'; -import JSON5 from 'json5'; +import * as Misskey from 'misskey-js'; import { debounce } from 'throttle-debounce'; import { useInterval } from '@@/js/use-interval.js'; import XChart from './job-queue.chart.vue'; import XJob from './job-queue.job.vue'; -import type { Ref } from 'vue'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; @@ -185,32 +184,18 @@ import MkButton from '@/components/MkButton.vue'; import { misskeyApi } from '@/utility/misskey-api.js'; import MkTabs from '@/components/MkTabs.vue'; import MkFolder from '@/components/MkFolder.vue'; -import MkCode from '@/components/MkCode.vue'; import MkKeyValue from '@/components/MkKeyValue.vue'; import MkTl from '@/components/MkTl.vue'; import kmg from '@/filters/kmg.js'; import MkInput from '@/components/MkInput.vue'; import bytes from '@/filters/bytes.js'; -import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; -const QUEUE_TYPES = [ - 'system', - 'endedPollNotification', - 'deliver', - 'inbox', - 'db', - 'relationship', - 'objectStorage', - 'userWebhookDeliver', - 'systemWebhookDeliver', -] as const; - -const tab: Ref<typeof QUEUE_TYPES[number] | '-'> = ref('-'); -const jobState = ref('all'); -const jobs = ref([]); +const tab = ref<typeof Misskey.queueTypes[number] | '-'>('-'); +const jobState = ref<'all' | 'latest' | 'completed' | 'failed' | 'active' | 'delayed' | 'wait' | 'paused'>('all'); +const jobs = ref<Misskey.entities.QueueJob[]>([]); const jobsFetching = ref(true); -const queueInfos = ref([]); -const queueInfo = ref(); +const queueInfos = ref<Misskey.entities.AdminQueueQueuesResponse>([]); +const queueInfo = ref<Misskey.entities.AdminQueueQueueStatsResponse | null>(null); const searchQuery = ref(''); async function fetchQueues() { @@ -230,11 +215,11 @@ async function fetchJobs() { queue: tab.value, state: state === 'all' ? ['completed', 'failed', 'active', 'delayed', 'wait'] : state === 'latest' ? ['completed', 'failed'] : [state], search: searchQuery.value.trim() === '' ? undefined : searchQuery.value, - }).then(res => { + }).then((res: Misskey.entities.AdminQueueJobsResponse) => { if (state === 'all') { res.sort((a, b) => (a.processedOn ?? a.timestamp) > (b.processedOn ?? b.timestamp) ? -1 : 1); } else if (state === 'latest') { - res.sort((a, b) => a.processedOn > b.processedOn ? -1 : 1); + res.sort((a, b) => a.processedOn! > b.processedOn! ? -1 : 1); } else if (state === 'delayed') { res.sort((a, b) => (a.processedOn ?? a.timestamp) > (b.processedOn ?? b.timestamp) ? -1 : 1); } @@ -276,6 +261,8 @@ useInterval(() => { }); async function clearQueue() { + if (tab.value === '-') return; + const { canceled } = await os.confirm({ type: 'warning', title: i18n.ts.areYouSure, @@ -289,6 +276,8 @@ async function clearQueue() { } async function promoteAllJobs() { + if (tab.value === '-') return; + const { canceled } = await os.confirm({ type: 'warning', title: i18n.ts.areYouSure, @@ -302,13 +291,15 @@ async function promoteAllJobs() { } async function removeJobs() { + if (tab.value === '-' || jobState.value === 'latest') return; + const { canceled } = await os.confirm({ type: 'warning', title: i18n.ts.areYouSure, }); if (canceled) return; - os.apiWithDialog('admin/queue/clear', { queue: tab.value, state: jobState.value }); + os.apiWithDialog('admin/queue/clear', { queue: tab.value, state: jobState.value === 'all' ? '*' : jobState.value }); fetchCurrentQueue(); fetchJobs(); @@ -324,16 +315,18 @@ async function refreshJob(jobId: string) { const headerActions = computed(() => []); -const headerTabs = computed(() => - [{ - key: '-', - title: i18n.ts.overview, - icon: 'ti ti-dashboard', - }].concat(QUEUE_TYPES.map((t) => ({ - key: t, - title: t, - }))), -); +const headerTabs = computed<{ + key: string; + title: string; + icon?: string; +}[]>(() => [{ + key: '-', + title: i18n.ts.jobQueue, + icon: 'ti ti-list-check', +}, ...Misskey.queueTypes.map((q) => ({ + key: q, + title: q, +}))]); definePage(() => ({ title: i18n.ts.jobQueue, diff --git a/packages/frontend/src/pages/admin/moderation.vue b/packages/frontend/src/pages/admin/moderation.vue index 2157b4ca14..819f229c10 100644 --- a/packages/frontend/src/pages/admin/moderation.vue +++ b/packages/frontend/src/pages/admin/moderation.vue @@ -17,9 +17,20 @@ SPDX-License-Identifier: AGPL-3.0-only </MkSwitch> <MkSwitch v-model="emailRequiredForSignup" @change="onChange_emailRequiredForSignup"> - <template #label>{{ i18n.ts.emailRequiredForSignup }}</template> + <template #label>{{ i18n.ts.emailRequiredForSignup }} ({{ i18n.ts.recommended }})</template> </MkSwitch> + <MkSelect v-model="ugcVisibilityForVisitor" @update:modelValue="onChange_ugcVisibilityForVisitor"> + <template #label>{{ i18n.ts._serverSettings.userGeneratedContentsVisibilityForVisitor }}</template> + <option value="all">{{ i18n.ts._serverSettings._userGeneratedContentsVisibilityForVisitor.all }}</option> + <option value="local">{{ i18n.ts._serverSettings._userGeneratedContentsVisibilityForVisitor.localOnly }} ({{ i18n.ts.recommended }})</option> + <option value="none">{{ i18n.ts._serverSettings._userGeneratedContentsVisibilityForVisitor.none }}</option> + <template #caption> + <div>{{ i18n.ts._serverSettings.userGeneratedContentsVisibilityForVisitor_description }}</div> + <div><i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> {{ i18n.ts._serverSettings.userGeneratedContentsVisibilityForVisitor_description2 }}</div> + </template> + </MkSelect> + <FormLink to="/admin/server-rules">{{ i18n.ts.serverRules }}</FormLink> <MkFolder> @@ -137,9 +148,11 @@ import { definePage } from '@/page.js'; import MkButton from '@/components/MkButton.vue'; import FormLink from '@/components/form/link.vue'; import MkFolder from '@/components/MkFolder.vue'; +import MkSelect from '@/components/MkSelect.vue'; const enableRegistration = ref<boolean>(false); const emailRequiredForSignup = ref<boolean>(false); +const ugcVisibilityForVisitor = ref<string>('all'); const sensitiveWords = ref<string>(''); const prohibitedWords = ref<string>(''); const prohibitedWordsForNameOfUser = ref<string>(''); @@ -153,6 +166,7 @@ async function init() { const meta = await misskeyApi('admin/meta'); enableRegistration.value = !meta.disableRegistration; emailRequiredForSignup.value = meta.emailRequiredForSignup; + ugcVisibilityForVisitor.value = meta.ugcVisibilityForVisitor; sensitiveWords.value = meta.sensitiveWords.join('\n'); prohibitedWords.value = meta.prohibitedWords.join('\n'); prohibitedWordsForNameOfUser.value = meta.prohibitedWordsForNameOfUser.join('\n'); @@ -189,6 +203,14 @@ function onChange_emailRequiredForSignup(value: boolean) { }); } +function onChange_ugcVisibilityForVisitor(value: string) { + os.apiWithDialog('admin/update-meta', { + ugcVisibilityForVisitor: value, + }).then(() => { + fetchInstance(true); + }); +} + function save_preservedUsernames() { os.apiWithDialog('admin/update-meta', { preservedUsernames: preservedUsernames.value.split('\n'), diff --git a/packages/frontend/src/pages/admin/overview.active-users.vue b/packages/frontend/src/pages/admin/overview.active-users.vue index 5b7f669f6b..f74356599b 100644 --- a/packages/frontend/src/pages/admin/overview.active-users.vue +++ b/packages/frontend/src/pages/admin/overview.active-users.vue @@ -18,7 +18,7 @@ import { Chart } from 'chart.js'; import gradient from 'chartjs-plugin-gradient'; import { misskeyApi } from '@/utility/misskey-api.js'; import { store } from '@/store.js'; -import { useChartTooltip } from '@/use/use-chart-tooltip.js'; +import { useChartTooltip } from '@/composables/use-chart-tooltip.js'; import { chartVLine } from '@/utility/chart-vline.js'; import { initChart } from '@/utility/init-chart.js'; diff --git a/packages/frontend/src/pages/admin/overview.ap-requests.vue b/packages/frontend/src/pages/admin/overview.ap-requests.vue index 4c06d94d6d..96ea4749dc 100644 --- a/packages/frontend/src/pages/admin/overview.ap-requests.vue +++ b/packages/frontend/src/pages/admin/overview.ap-requests.vue @@ -25,7 +25,7 @@ import { Chart } from 'chart.js'; import gradient from 'chartjs-plugin-gradient'; import isChromatic from 'chromatic'; import { misskeyApi } from '@/utility/misskey-api.js'; -import { useChartTooltip } from '@/use/use-chart-tooltip.js'; +import { useChartTooltip } from '@/composables/use-chart-tooltip.js'; import { chartVLine } from '@/utility/chart-vline.js'; import { store } from '@/store.js'; import { alpha } from '@/utility/color.js'; diff --git a/packages/frontend/src/pages/admin/overview.federation.vue b/packages/frontend/src/pages/admin/overview.federation.vue index 6d6d431863..50f12cbf45 100644 --- a/packages/frontend/src/pages/admin/overview.federation.vue +++ b/packages/frontend/src/pages/admin/overview.federation.vue @@ -54,7 +54,7 @@ import { misskeyApiGet } from '@/utility/misskey-api.js'; import number from '@/filters/number.js'; import MkNumberDiff from '@/components/MkNumberDiff.vue'; import { i18n } from '@/i18n.js'; -import { useChartTooltip } from '@/use/use-chart-tooltip.js'; +import { useChartTooltip } from '@/composables/use-chart-tooltip.js'; const topSubInstancesForPie = ref<InstanceForPie[] | null>(null); const topPubInstancesForPie = ref<InstanceForPie[] | null>(null); diff --git a/packages/frontend/src/pages/admin/overview.pie.vue b/packages/frontend/src/pages/admin/overview.pie.vue index 86c5eff4da..ec2b558cee 100644 --- a/packages/frontend/src/pages/admin/overview.pie.vue +++ b/packages/frontend/src/pages/admin/overview.pie.vue @@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { onMounted, useTemplateRef } from 'vue'; import { Chart } from 'chart.js'; -import { useChartTooltip } from '@/use/use-chart-tooltip.js'; +import { useChartTooltip } from '@/composables/use-chart-tooltip.js'; import { initChart } from '@/utility/init-chart.js'; export type InstanceForPie = { diff --git a/packages/frontend/src/pages/admin/overview.queue.chart.vue b/packages/frontend/src/pages/admin/overview.queue.chart.vue index 6fc941a848..9b9618c4ac 100644 --- a/packages/frontend/src/pages/admin/overview.queue.chart.vue +++ b/packages/frontend/src/pages/admin/overview.queue.chart.vue @@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { onMounted, useTemplateRef } from 'vue'; import { Chart } from 'chart.js'; import { store } from '@/store.js'; -import { useChartTooltip } from '@/use/use-chart-tooltip.js'; +import { useChartTooltip } from '@/composables/use-chart-tooltip.js'; import { chartVLine } from '@/utility/chart-vline.js'; import { alpha } from '@/utility/color.js'; import { initChart } from '@/utility/init-chart.js'; diff --git a/packages/frontend/src/pages/admin/performance.vue b/packages/frontend/src/pages/admin/performance.vue index a272b9adea..c28621b11e 100644 --- a/packages/frontend/src/pages/admin/performance.vue +++ b/packages/frontend/src/pages/admin/performance.vue @@ -117,7 +117,7 @@ import MkSwitch from '@/components/MkSwitch.vue'; import MkFolder from '@/components/MkFolder.vue'; import MkInput from '@/components/MkInput.vue'; import MkLink from '@/components/MkLink.vue'; -import { useForm } from '@/use/use-form.js'; +import { useForm } from '@/composables/use-form.js'; import MkFormFooter from '@/components/MkFormFooter.vue'; const meta = await misskeyApi('admin/meta'); diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue index 2473d4e90d..24c3160fdd 100644 --- a/packages/frontend/src/pages/admin/roles.editor.vue +++ b/packages/frontend/src/pages/admin/roles.editor.vue @@ -406,6 +406,29 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.uploadableFileTypes, 'uploadableFileTypes'])"> + <template #label>{{ i18n.ts._role._options.uploadableFileTypes }}</template> + <template #suffix> + <span v-if="role.policies.uploadableFileTypes.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span> + <span v-else>...</span> + <span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.uploadableFileTypes)"></i></span> + </template> + <div class="_gaps"> + <MkSwitch v-model="role.policies.uploadableFileTypes.useDefault" :readonly="readonly"> + <template #label>{{ i18n.ts._role.useBaseValue }}</template> + </MkSwitch> + <MkTextarea :modelValue="role.policies.uploadableFileTypes.value.join('\n')" :disabled="role.policies.uploadableFileTypes.useDefault" :readonly="readonly" @update:modelValue="role.policies.uploadableFileTypes.value = $event.split('\n')"> + <template #caption> + <div>{{ i18n.ts._role._options.uploadableFileTypes_caption }}</div> + <div><i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> {{ i18n.tsx._role._options.uploadableFileTypes_caption2({ x: 'application/octet-stream' }) }}</div> + </template> + </MkTextarea> + <MkRange v-model="role.policies.uploadableFileTypes.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''"> + <template #label>{{ i18n.ts._role.priority }}</template> + </MkRange> + </div> + </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.alwaysMarkNsfw, 'alwaysMarkNsfw'])"> <template #label>{{ i18n.ts._role._options.alwaysMarkNsfw }}</template> <template #suffix> diff --git a/packages/frontend/src/pages/admin/roles.vue b/packages/frontend/src/pages/admin/roles.vue index 83bebb6cea..70e8153544 100644 --- a/packages/frontend/src/pages/admin/roles.vue +++ b/packages/frontend/src/pages/admin/roles.vue @@ -122,7 +122,7 @@ SPDX-License-Identifier: AGPL-3.0-only </MkSwitch> </MkFolder> - <MkFolder v-if="matchQuery([i18n.ts._role._options.canUseTranslator, 'canSearchNotes'])"> + <MkFolder v-if="matchQuery([i18n.ts._role._options.canUseTranslator, 'canUseTranslator'])"> <template #label>{{ i18n.ts._role._options.canUseTranslator }}</template> <template #suffix>{{ policies.canUseTranslator ? i18n.ts.yes : i18n.ts.no }}</template> <MkSwitch v-model="policies.canUseTranslator"> @@ -146,6 +146,17 @@ SPDX-License-Identifier: AGPL-3.0-only </MkInput> </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.uploadableFileTypes, 'uploadableFileTypes'])"> + <template #label>{{ i18n.ts._role._options.uploadableFileTypes }}</template> + <template #suffix>...</template> + <MkTextarea :modelValue="policies.uploadableFileTypes.join('\n')" @update:modelValue="v => policies.uploadableFileTypes = v.split('\n')"> + <template #caption> + <div>{{ i18n.ts._role._options.uploadableFileTypes_caption }}</div> + <div><i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> {{ i18n.tsx._role._options.uploadableFileTypes_caption2({ x: 'application/octet-stream' }) }}</div> + </template> + </MkTextarea> + </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.alwaysMarkNsfw, 'alwaysMarkNsfw'])"> <template #label>{{ i18n.ts._role._options.alwaysMarkNsfw }}</template> <template #suffix>{{ policies.alwaysMarkNsfw ? i18n.ts.yes : i18n.ts.no }}</template> @@ -312,6 +323,7 @@ import { definePage } from '@/page.js'; import { instance, fetchInstance } from '@/instance.js'; import MkFoldableSection from '@/components/MkFoldableSection.vue'; import { useRouter } from '@/router.js'; +import MkTextarea from '@/components/MkTextarea.vue'; const router = useRouter(); const baseRoleQ = ref(''); diff --git a/packages/frontend/src/pages/admin/security.vue b/packages/frontend/src/pages/admin/security.vue index ffb34f6e52..9e907a4469 100644 --- a/packages/frontend/src/pages/admin/security.vue +++ b/packages/frontend/src/pages/admin/security.vue @@ -133,7 +133,7 @@ import { misskeyApi } from '@/utility/misskey-api.js'; import { fetchInstance } from '@/instance.js'; import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; -import { useForm } from '@/use/use-form.js'; +import { useForm } from '@/composables/use-form.js'; import MkFormFooter from '@/components/MkFormFooter.vue'; const meta = await misskeyApi('admin/meta'); diff --git a/packages/frontend/src/pages/admin/settings.vue b/packages/frontend/src/pages/admin/settings.vue index 5914e243c6..f6a2eb1c27 100644 --- a/packages/frontend/src/pages/admin/settings.vue +++ b/packages/frontend/src/pages/admin/settings.vue @@ -87,28 +87,6 @@ SPDX-License-Identifier: AGPL-3.0-only </MkFolder> <MkFolder> - <template #icon><i class="ti ti-cloud"></i></template> - <template #label>{{ i18n.ts.files }}</template> - <template v-if="filesForm.modified.value" #footer> - <MkFormFooter :form="filesForm"/> - </template> - - <div class="_gaps"> - <MkSwitch v-model="filesForm.state.cacheRemoteFiles"> - <template #label>{{ i18n.ts.cacheRemoteFiles }}<span v-if="filesForm.modifiedStates.cacheRemoteFiles" class="_modified">{{ i18n.ts.modified }}</span></template> - <template #caption>{{ i18n.ts.cacheRemoteFilesDescription }}{{ i18n.ts.youCanCleanRemoteFilesCache }}</template> - </MkSwitch> - - <template v-if="filesForm.state.cacheRemoteFiles"> - <MkSwitch v-model="filesForm.state.cacheRemoteSensitiveFiles"> - <template #label>{{ i18n.ts.cacheRemoteSensitiveFiles }}<span v-if="filesForm.modifiedStates.cacheRemoteSensitiveFiles" class="_modified">{{ i18n.ts.modified }}</span></template> - <template #caption>{{ i18n.ts.cacheRemoteSensitiveFilesDescription }}</template> - </MkSwitch> - </template> - </div> - </MkFolder> - - <MkFolder> <template #icon><i class="ti ti-world-cog"></i></template> <template #label>ServiceWorker</template> <template v-if="serviceWorkerForm.modified.value" #footer> @@ -168,6 +146,11 @@ SPDX-License-Identifier: AGPL-3.0-only </MkSwitch> <template v-if="urlPreviewForm.state.urlPreviewEnabled"> + <MkSwitch v-model="urlPreviewForm.state.urlPreviewAllowRedirect"> + <template #label>{{ i18n.ts._urlPreviewSetting.allowRedirect }}<span v-if="urlPreviewForm.modifiedStates.urlPreviewAllowRedirect" class="_modified">{{ i18n.ts.modified }}</span></template> + <template #caption>{{ i18n.ts._urlPreviewSetting.allowRedirectDescription }}</template> + </MkSwitch> + <MkSwitch v-model="urlPreviewForm.state.urlPreviewRequireContentLength"> <template #label>{{ i18n.ts._urlPreviewSetting.requireContentLength }}<span v-if="urlPreviewForm.modifiedStates.urlPreviewRequireContentLength" class="_modified">{{ i18n.ts.modified }}</span></template> <template #caption>{{ i18n.ts._urlPreviewSetting.requireContentLengthDescription }}</template> @@ -255,6 +238,36 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> </MkFolder> + + <MkSwitch v-model="federationForm.state.signToActivityPubGet"> + <template #label>{{ i18n.ts._serverSettings.signToActivityPubGet }}<span v-if="federationForm.modifiedStates.signToActivityPubGet" class="_modified">{{ i18n.ts.modified }}</span></template> + <template #caption>{{ i18n.ts._serverSettings.signToActivityPubGet_description }}</template> + </MkSwitch> + + <MkSwitch v-model="federationForm.state.proxyRemoteFiles"> + <template #label>{{ i18n.ts._serverSettings.proxyRemoteFiles }}<span v-if="federationForm.modifiedStates.proxyRemoteFiles" class="_modified">{{ i18n.ts.modified }}</span></template> + <template #caption>{{ i18n.ts._serverSettings.proxyRemoteFiles_description }}</template> + </MkSwitch> + + <MkSwitch v-model="federationForm.state.allowExternalApRedirect"> + <template #label>{{ i18n.ts._serverSettings.allowExternalApRedirect }}<span v-if="federationForm.modifiedStates.allowExternalApRedirect" class="_modified">{{ i18n.ts.modified }}</span></template> + <template #caption> + <div>{{ i18n.ts._serverSettings.allowExternalApRedirect_description }}</div> + <div>{{ i18n.ts.needToRestartServerToApply }}</div> + </template> + </MkSwitch> + + <MkSwitch v-model="federationForm.state.cacheRemoteFiles"> + <template #label>{{ i18n.ts.cacheRemoteFiles }}<span v-if="federationForm.modifiedStates.cacheRemoteFiles" class="_modified">{{ i18n.ts.modified }}</span></template> + <template #caption>{{ i18n.ts.cacheRemoteFilesDescription }}{{ i18n.ts.youCanCleanRemoteFilesCache }}</template> + </MkSwitch> + + <template v-if="federationForm.state.cacheRemoteFiles"> + <MkSwitch v-model="federationForm.state.cacheRemoteSensitiveFiles"> + <template #label>{{ i18n.ts.cacheRemoteSensitiveFiles }}<span v-if="federationForm.modifiedStates.cacheRemoteSensitiveFiles" class="_modified">{{ i18n.ts.modified }}</span></template> + <template #caption>{{ i18n.ts.cacheRemoteSensitiveFilesDescription }}</template> + </MkSwitch> + </template> </div> </MkFolder> @@ -280,7 +293,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ref, computed, reactive } from 'vue'; +import { computed } from 'vue'; import MkSwitch from '@/components/MkSwitch.vue'; import MkInput from '@/components/MkInput.vue'; import MkTextarea from '@/components/MkTextarea.vue'; @@ -293,8 +306,7 @@ import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; import MkButton from '@/components/MkButton.vue'; import MkFolder from '@/components/MkFolder.vue'; -import MkKeyValue from '@/components/MkKeyValue.vue'; -import { useForm } from '@/use/use-form.js'; +import { useForm } from '@/composables/use-form.js'; import MkFormFooter from '@/components/MkFormFooter.vue'; import MkRadios from '@/components/MkRadios.vue'; @@ -338,17 +350,6 @@ const pinnedUsersForm = useForm({ fetchInstance(true); }); -const filesForm = useForm({ - cacheRemoteFiles: meta.cacheRemoteFiles, - cacheRemoteSensitiveFiles: meta.cacheRemoteSensitiveFiles, -}, async (state) => { - await os.apiWithDialog('admin/update-meta', { - cacheRemoteFiles: state.cacheRemoteFiles, - cacheRemoteSensitiveFiles: state.cacheRemoteSensitiveFiles, - }); - fetchInstance(true); -}); - const serviceWorkerForm = useForm({ enableServiceWorker: meta.enableServiceWorker, swPublicKey: meta.swPublickey ?? '', @@ -373,6 +374,7 @@ const adForm = useForm({ const urlPreviewForm = useForm({ urlPreviewEnabled: meta.urlPreviewEnabled, + urlPreviewAllowRedirect: meta.urlPreviewAllowRedirect, urlPreviewTimeout: meta.urlPreviewTimeout, urlPreviewMaximumContentLength: meta.urlPreviewMaximumContentLength, urlPreviewRequireContentLength: meta.urlPreviewRequireContentLength, @@ -381,6 +383,7 @@ const urlPreviewForm = useForm({ }, async (state) => { await os.apiWithDialog('admin/update-meta', { urlPreviewEnabled: state.urlPreviewEnabled, + urlPreviewAllowRedirect: state.urlPreviewAllowRedirect, urlPreviewTimeout: state.urlPreviewTimeout, urlPreviewMaximumContentLength: state.urlPreviewMaximumContentLength, urlPreviewRequireContentLength: state.urlPreviewRequireContentLength, @@ -394,11 +397,21 @@ const federationForm = useForm({ federation: meta.federation, federationHosts: meta.federationHosts.join('\n'), deliverSuspendedSoftware: meta.deliverSuspendedSoftware, + signToActivityPubGet: meta.signToActivityPubGet, + proxyRemoteFiles: meta.proxyRemoteFiles, + allowExternalApRedirect: meta.allowExternalApRedirect, + cacheRemoteFiles: meta.cacheRemoteFiles, + cacheRemoteSensitiveFiles: meta.cacheRemoteSensitiveFiles, }, async (state) => { await os.apiWithDialog('admin/update-meta', { federation: state.federation, federationHosts: state.federationHosts.split('\n'), deliverSuspendedSoftware: state.deliverSuspendedSoftware, + signToActivityPubGet: state.signToActivityPubGet, + proxyRemoteFiles: state.proxyRemoteFiles, + allowExternalApRedirect: state.allowExternalApRedirect, + cacheRemoteFiles: state.cacheRemoteFiles, + cacheRemoteSensitiveFiles: state.cacheRemoteSensitiveFiles, }); fetchInstance(true); }); diff --git a/packages/frontend/src/pages/admin/users.vue b/packages/frontend/src/pages/admin/users.vue index 6eb3c04dde..56cf8876f0 100644 --- a/packages/frontend/src/pages/admin/users.vue +++ b/packages/frontend/src/pages/admin/users.vue @@ -121,7 +121,7 @@ async function addUser() { username: username, password: password, }).then(res => { - paginationComponent.value?.reload(); + paginationComponent.value?.paginator.reload(); }); } diff --git a/packages/frontend/src/pages/announcements.vue b/packages/frontend/src/pages/announcements.vue index bb4730c606..2c671c6b34 100644 --- a/packages/frontend/src/pages/announcements.vue +++ b/packages/frontend/src/pages/announcements.vue @@ -44,7 +44,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ref, computed } from 'vue'; +import { ref, computed, useTemplateRef } from 'vue'; import MkPagination from '@/components/MkPagination.vue'; import MkButton from '@/components/MkButton.vue'; import MkInfo from '@/components/MkInfo.vue'; @@ -71,7 +71,7 @@ const paginationPast = { }, }; -const paginationEl = ref<InstanceType<typeof MkPagination>>(); +const paginationEl = useTemplateRef('paginationEl'); const tab = ref('current'); @@ -86,10 +86,10 @@ async function read(target) { } if (!paginationEl.value) return; - paginationEl.value.updateItem(target.id, a => { - a.isRead = true; - return a; - }); + paginationEl.value.paginator.updateItem(target.id, a => ({ + ...a, + isRead: true, + })); misskeyApi('i/read-announcement', { announcementId: target.id }); updateCurrentAccountPartial({ unreadAnnouncements: $i!.unreadAnnouncements.filter(a => a.id !== target.id), diff --git a/packages/frontend/src/pages/antenna-timeline.vue b/packages/frontend/src/pages/antenna-timeline.vue index 89ab1bf99a..7d2393dba5 100644 --- a/packages/frontend/src/pages/antenna-timeline.vue +++ b/packages/frontend/src/pages/antenna-timeline.vue @@ -6,17 +6,13 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <PageWithHeader :actions="headerActions" :tabs="headerTabs"> <div class="_spacer" style="--MI_SPACER-w: 800px;"> - <div ref="rootEl"> - <div v-if="queue > 0" :class="$style.new"><button class="_buttonPrimary" :class="$style.newButton" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div> - <div :class="$style.tl"> - <MkTimeline - ref="tlEl" :key="antennaId" - src="antenna" - :antenna="antennaId" - :sound="true" - @queue="queueUpdated" - /> - </div> + <div :class="$style.tl"> + <MkStreamingNotesTimeline + ref="tlEl" :key="antennaId" + src="antenna" + :antenna="antennaId" + :sound="true" + /> </div> </div> </PageWithHeader> @@ -25,8 +21,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, watch, ref, useTemplateRef } from 'vue'; import * as Misskey from 'misskey-js'; -import { scrollInContainer } from '@@/js/scroll.js'; -import MkTimeline from '@/components/MkTimeline.vue'; +import MkStreamingNotesTimeline from '@/components/MkStreamingNotesTimeline.vue'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { definePage } from '@/page.js'; @@ -40,18 +35,8 @@ const props = defineProps<{ }>(); const antenna = ref<Misskey.entities.Antenna | null>(null); -const queue = ref(0); -const rootEl = useTemplateRef('rootEl'); const tlEl = useTemplateRef('tlEl'); -function queueUpdated(q) { - queue.value = q; -} - -function top() { - scrollInContainer(rootEl.value, { top: 0 }); -} - async function timetravel() { const { canceled, result: date } = await os.inputDate({ title: i18n.ts.date, @@ -94,25 +79,6 @@ definePage(() => ({ </script> <style lang="scss" module> -.new { - position: sticky; - top: calc(var(--MI-stickyTop, 0px) + 16px); - z-index: 1000; - width: 100%; - margin: calc(-0.675em - 8px) 0; - - &:first-child { - margin-top: calc(-0.675em - 8px - var(--MI-margin)); - } -} - -.newButton { - display: block; - margin: var(--MI-margin) auto 0 auto; - padding: 8px 16px; - border-radius: 32px; -} - .tl { background: var(--MI_THEME-bg); border-radius: var(--MI-radius); diff --git a/packages/frontend/src/pages/avatar-decoration-edit-dialog.vue b/packages/frontend/src/pages/avatar-decoration-edit-dialog.vue index cb0e1666f8..ddc4e89ef1 100644 --- a/packages/frontend/src/pages/avatar-decoration-edit-dialog.vue +++ b/packages/frontend/src/pages/avatar-decoration-edit-dialog.vue @@ -60,7 +60,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, watch, ref } from 'vue'; +import { computed, watch, ref, useTemplateRef } from 'vue'; import * as Misskey from 'misskey-js'; import MkWindow from '@/components/MkWindow.vue'; import MkButton from '@/components/MkButton.vue'; @@ -86,7 +86,7 @@ const emit = defineEmits<{ (ev: 'closed'): void }>(); -const windowEl = ref<InstanceType<typeof MkWindow> | null>(null); +const windowEl = useTemplateRef('windowEl'); const url = ref<string>(props.avatarDecoration ? props.avatarDecoration.url : ''); const name = ref<string>(props.avatarDecoration ? props.avatarDecoration.name : ''); const description = ref<string>(props.avatarDecoration ? props.avatarDecoration.description : ''); diff --git a/packages/frontend/src/pages/channel-editor.vue b/packages/frontend/src/pages/channel-editor.vue index 009514cdc8..355b5464a1 100644 --- a/packages/frontend/src/pages/channel-editor.vue +++ b/packages/frontend/src/pages/channel-editor.vue @@ -73,7 +73,7 @@ import * as Misskey from 'misskey-js'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; import MkColorInput from '@/components/MkColorInput.vue'; -import { selectFile } from '@/utility/select-file.js'; +import { selectFile } from '@/utility/drive.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { definePage } from '@/page.js'; diff --git a/packages/frontend/src/pages/channel.vue b/packages/frontend/src/pages/channel.vue index 1c411d2a2e..6eb390f743 100644 --- a/packages/frontend/src/pages/channel.vue +++ b/packages/frontend/src/pages/channel.vue @@ -37,10 +37,10 @@ SPDX-License-Identifier: AGPL-3.0-only <!-- スマホ・タブレットの場合、キーボードが表示されると投稿が見づらくなるので、デスクトップ場合のみ自動でフォーカスを当てる --> <MkPostForm v-if="$i && prefer.r.showFixedPostFormInChannel.value" :channel="channel" class="post-form _panel" fixed :autofocus="deviceKind === 'desktop'"/> - <MkTimeline :key="channelId" src="channel" :channel="channelId" @before="before" @after="after" @note="miLocalStorage.setItemAsJson(`channelLastReadedAt:${channel.id}`, Date.now())"/> + <MkStreamingNotesTimeline :key="channelId" src="channel" :channel="channelId"/> </div> <div v-else-if="tab === 'featured'"> - <MkNotes :pagination="featuredPagination"/> + <MkNotesTimeline :pagination="featuredPagination"/> </div> <div v-else-if="tab === 'search'"> <div v-if="notesSearchAvailable" class="_gaps"> @@ -50,7 +50,7 @@ SPDX-License-Identifier: AGPL-3.0-only </MkInput> <MkButton primary rounded style="margin-top: 8px;" @click="search()">{{ i18n.ts.search }}</MkButton> </div> - <MkNotes v-if="searchPagination" :key="searchKey" :pagination="searchPagination"/> + <MkNotesTimeline v-if="searchPagination" :key="searchKey" :pagination="searchPagination"/> </div> <div v-else> <MkInfo warn>{{ i18n.ts.notesSearchNotAvailable }}</MkInfo> @@ -73,9 +73,10 @@ SPDX-License-Identifier: AGPL-3.0-only import { computed, watch, ref } from 'vue'; import * as Misskey from 'misskey-js'; import { url } from '@@/js/config.js'; +import { useInterval } from '@@/js/use-interval.js'; import type { PageHeaderItem } from '@/types/page-header.js'; import MkPostForm from '@/components/MkPostForm.vue'; -import MkTimeline from '@/components/MkTimeline.vue'; +import MkStreamingNotesTimeline from '@/components/MkStreamingNotesTimeline.vue'; import XChannelFollowButton from '@/components/MkChannelFollowButton.vue'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; @@ -83,7 +84,7 @@ import { $i, iAmModerator } from '@/i.js'; import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; import { deviceKind } from '@/utility/device-kind.js'; -import MkNotes from '@/components/MkNotes.vue'; +import MkNotesTimeline from '@/components/MkNotesTimeline.vue'; import { favoritedChannelsCache } from '@/cache.js'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; @@ -118,6 +119,14 @@ const featuredPagination = computed(() => ({ }, })); +useInterval(() => { + if (channel.value == null) return; + miLocalStorage.setItemAsJson(`channelLastReadedAt:${channel.value.id}`, Date.now()); +}, 3000, { + immediate: true, + afterMounted: true, +}); + watch(() => props.channelId, async () => { channel.value = await misskeyApi('channels/show', { channelId: props.channelId, diff --git a/packages/frontend/src/pages/chat/XMessage.vue b/packages/frontend/src/pages/chat/XMessage.vue index def6ec7d14..95f6d870dc 100644 --- a/packages/frontend/src/pages/chat/XMessage.vue +++ b/packages/frontend/src/pages/chat/XMessage.vue @@ -6,9 +6,9 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div :class="[$style.root, { [$style.isMe]: isMe }]"> <MkAvatar :class="$style.avatar" :user="message.fromUser!" :link="!isMe" :preview="false"/> - <div :class="$style.body" @contextmenu.stop="onContextmenu"> + <div :class="[$style.body, message.file != null ? $style.fullWidth : null]" @contextmenu.stop="onContextmenu"> <div :class="$style.header"><MkUserName v-if="!isMe && prefer.s['chat.showSenderName'] && message.fromUser != null" :user="message.fromUser"/></div> - <MkFukidashi :class="$style.fukidashi" :tail="isMe ? 'right' : 'left'" :accented="isMe"> + <MkFukidashi :class="$style.fukidashi" :tail="isMe ? 'right' : 'left'" :fullWidth="message.file != null" :accented="isMe"> <Mfm v-if="message.text" ref="text" @@ -259,6 +259,10 @@ function showMenu(ev: MouseEvent, contextmenu = false) { .body { margin: 0 12px; + + &.fullWidth { + width: 100%; + } } .header { diff --git a/packages/frontend/src/pages/chat/room.form.vue b/packages/frontend/src/pages/chat/room.form.vue index 9389b16ce7..7e3be67230 100644 --- a/packages/frontend/src/pages/chat/room.form.vue +++ b/packages/frontend/src/pages/chat/room.form.vue @@ -38,15 +38,15 @@ import { onMounted, watch, ref, shallowRef, computed, nextTick, readonly, onBefo import * as Misskey from 'misskey-js'; //import insertTextAtCursor from 'insert-text-at-cursor'; import { formatTimeString } from '@/utility/format-time-string.js'; -import { selectFile } from '@/utility/select-file.js'; +import { selectFile } from '@/utility/drive.js'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; -import { uploadFile } from '@/utility/upload.js'; import { miLocalStorage } from '@/local-storage.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { prefer } from '@/preferences.js'; import { Autocomplete } from '@/utility/autocomplete.js'; import { emojiPicker } from '@/utility/emoji-picker.js'; +import { checkDragDataType, getDragData } from '@/drag-and-drop.js'; const props = defineProps<{ user?: Misskey.entities.UserDetailed | null; @@ -84,8 +84,11 @@ async function onPaste(ev: ClipboardEvent) { if (!pastedFile) return; const lio = pastedFile.name.lastIndexOf('.'); const ext = lio >= 0 ? pastedFile.name.slice(lio) : ''; - const formatted = formatTimeString(new Date(pastedFile.lastModified), pastedFileName).replace(/{{number}}/g, '1') + ext; - if (formatted) upload(pastedFile, formatted); + const formattedName = formatTimeString(new Date(pastedFile.lastModified), pastedFileName).replace(/{{number}}/g, '1') + ext; + const renamedFile = new File([pastedFile], formattedName, { type: pastedFile.type }); + os.launchUploader([renamedFile], { multiple: false }).then(driveFiles => { + file.value = driveFiles[0]; + }); } } else { if (items[0].kind === 'file') { @@ -101,8 +104,7 @@ function onDragover(ev: DragEvent) { if (!ev.dataTransfer) return; const isFile = ev.dataTransfer.items[0].kind === 'file'; - const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_; - if (isFile || isDriveFile) { + if (isFile || checkDragDataType(ev, ['driveFiles'])) { ev.preventDefault(); switch (ev.dataTransfer.effectAllowed) { case 'all': @@ -129,7 +131,7 @@ function onDrop(ev: DragEvent): void { // ファイルだったら if (ev.dataTransfer.files.length === 1) { ev.preventDefault(); - upload(ev.dataTransfer.files[0]); + os.launchUploader([Array.from(ev.dataTransfer.files)[0]], { multiple: false }); return; } else if (ev.dataTransfer.files.length > 1) { ev.preventDefault(); @@ -141,10 +143,12 @@ function onDrop(ev: DragEvent): void { } //#region ドライブのファイル - const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); - if (driveFile != null && driveFile !== '') { - file.value = JSON.parse(driveFile); - ev.preventDefault(); + { + const droppedData = getDragData(ev, 'driveFiles'); + if (droppedData != null) { + file.value = droppedData[0]; + ev.preventDefault(); + } } //#endregion } @@ -172,13 +176,11 @@ function chooseFile(ev: MouseEvent) { function onChangeFile() { if (fileEl.value == null || fileEl.value.files == null) return; - if (fileEl.value.files[0]) upload(fileEl.value.files[0]); -} - -function upload(fileToUpload: File, name?: string) { - uploadFile(fileToUpload, prefer.s.uploadFolder, name).then(res => { - file.value = res; - }); + if (fileEl.value.files[0]) { + os.launchUploader(Array.from(fileEl.value.files), { multiple: false }).then(driveFiles => { + file.value = driveFiles[0]; + }); + } } function send() { diff --git a/packages/frontend/src/pages/chat/room.vue b/packages/frontend/src/pages/chat/room.vue index e05125a3b2..ac13c5fac6 100644 --- a/packages/frontend/src/pages/chat/room.vue +++ b/packages/frontend/src/pages/chat/room.vue @@ -80,7 +80,7 @@ SPDX-License-Identifier: AGPL-3.0-only </button> </div> </Transition> - <XForm v-if="!initializing" :user="user" :room="room" :class="$style.form"/> + <XForm v-if="initialized" :user="user" :room="room" :class="$style.form"/> </div> </div> </template> @@ -108,7 +108,7 @@ import { definePage } from '@/page.js'; import { prefer } from '@/preferences.js'; import MkButton from '@/components/MkButton.vue'; import { useRouter } from '@/router.js'; -import { useMutationObserver } from '@/use/use-mutation-observer.js'; +import { useMutationObserver } from '@/composables/use-mutation-observer.js'; import MkInfo from '@/components/MkInfo.vue'; import { makeDateSeparatedTimelineComputedRef } from '@/utility/timeline-date-separate.js'; @@ -127,7 +127,8 @@ export type NormalizedChatMessage = Omit<Misskey.entities.ChatMessageLite, 'from })[]; }; -const initializing = ref(true); +const initializing = ref(false); +const initialized = ref(false); const moreFetching = ref(false); const messages = ref<NormalizedChatMessage[]>([]); const canFetchMore = ref(false); @@ -171,7 +172,10 @@ function normalizeMessage(message: Misskey.entities.ChatMessageLite | Misskey.en async function initialize() { const LIMIT = 20; + if (initializing.value) return; + initializing.value = true; + initialized.value = false; if (props.userId) { const [u, m] = await Promise.all([ @@ -194,13 +198,44 @@ async function initialize() { connection.value.on('react', onReact); connection.value.on('unreact', onUnreact); } else { - const [r, m] = await Promise.all([ + const [rResult, mResult] = await Promise.allSettled([ misskeyApi('chat/rooms/show', { roomId: props.roomId }), misskeyApi('chat/messages/room-timeline', { roomId: props.roomId, limit: LIMIT }), ]); - room.value = r as Misskey.entities.ChatRoomsShowResponse; - messages.value = (m as Misskey.entities.ChatMessagesRoomTimelineResponse).map(x => normalizeMessage(x)); + if (rResult.status === 'rejected') { + os.alert({ + type: 'error', + text: i18n.ts.somethingHappened, + }); + initializing.value = false; + return; + } + + const r = rResult.value as Misskey.entities.ChatRoomsShowResponse; + + if (r.invitationExists) { + const confirm = await os.confirm({ + type: 'question', + title: r.name, + text: i18n.ts._chat.youAreNotAMemberOfThisRoomButInvited + '\n' + i18n.ts._chat.doYouAcceptInvitation, + }); + if (confirm.canceled) { + initializing.value = false; + router.push('/chat'); + return; + } else { + await os.apiWithDialog('chat/rooms/join', { roomId: r.id }); + initializing.value = false; + initialize(); + return; + } + } + + const m = mResult.status === 'fulfilled' ? mResult.value as Misskey.entities.ChatMessagesRoomTimelineResponse : []; + + room.value = r; + messages.value = m.map(x => normalizeMessage(x)); if (messages.value.length === LIMIT) { canFetchMore.value = true; @@ -217,6 +252,7 @@ async function initialize() { window.document.addEventListener('visibilitychange', onVisibilitychange); + initialized.value = true; initializing.value = false; } @@ -319,6 +355,12 @@ onMounted(() => { initialize(); }); +onActivated(() => { + if (!initialized.value) { + initialize(); + } +}); + onBeforeUnmount(() => { connection.value?.dispose(); window.document.removeEventListener('visibilitychange', onVisibilitychange); @@ -410,7 +452,7 @@ const headerActions = computed<PageHeaderItem[]>(() => [{ }]); definePage(computed(() => { - if (!initializing.value) { + if (initialized.value) { if (user.value) { return { userName: user.value, diff --git a/packages/frontend/src/pages/clip.vue b/packages/frontend/src/pages/clip.vue index 68c5d6c270..dc043e2ce1 100644 --- a/packages/frontend/src/pages/clip.vue +++ b/packages/frontend/src/pages/clip.vue @@ -23,7 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> - <MkNotes :pagination="pagination" :detail="true"/> + <MkNotesTimeline :pagination="pagination" :detail="true"/> </div> </div> </PageWithHeader> @@ -34,7 +34,7 @@ import { computed, watch, provide, ref } from 'vue'; import * as Misskey from 'misskey-js'; import { url } from '@@/js/config.js'; import type { MenuItem } from '@/types/menu.js'; -import MkNotes from '@/components/MkNotes.vue'; +import MkNotesTimeline from '@/components/MkNotesTimeline.vue'; import { $i } from '@/i.js'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; diff --git a/packages/frontend/src/pages/custom-emojis-manager.vue b/packages/frontend/src/pages/custom-emojis-manager.vue index 16a95c6753..c2bc621f6a 100644 --- a/packages/frontend/src/pages/custom-emojis-manager.vue +++ b/packages/frontend/src/pages/custom-emojis-manager.vue @@ -78,7 +78,7 @@ import MkPagination from '@/components/MkPagination.vue'; import MkRemoteEmojiEditDialog from '@/components/MkRemoteEmojiEditDialog.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import FormSplit from '@/components/form/split.vue'; -import { selectFile } from '@/utility/select-file.js'; +import { selectFile } from '@/utility/drive.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { getProxiedImageUrl } from '@/utility/media-proxy.js'; @@ -115,7 +115,7 @@ const selectAll = () => { if (selectedEmojis.value.length > 0) { selectedEmojis.value = []; } else { - selectedEmojis.value = Array.from(emojisPaginationComponent.value?.items.values(), item => item.id); + selectedEmojis.value = emojisPaginationComponent.value?.paginator.items.value.map(item => item.id); } }; @@ -132,7 +132,7 @@ const add = async (ev: MouseEvent) => { }, { done: result => { if (result.created) { - emojisPaginationComponent.value?.prepend(result.created); + emojisPaginationComponent.value?.paginator.prepend(result.created); } }, closed: () => dispose(), @@ -145,12 +145,12 @@ const edit = (emoji) => { }, { done: result => { if (result.updated) { - emojisPaginationComponent.value?.updateItem(result.updated.id, (oldEmoji) => ({ + emojisPaginationComponent.value?.paginator.updateItem(result.updated.id, (oldEmoji) => ({ ...oldEmoji, ...result.updated, })); } else if (result.deleted) { - emojisPaginationComponent.value?.removeItem(emoji.id); + emojisPaginationComponent.value?.paginator.removeItem(emoji.id); } }, closed: () => dispose(), @@ -242,7 +242,7 @@ const setCategoryBulk = async () => { ids: selectedEmojis.value, category: result, }); - emojisPaginationComponent.value?.reload(); + emojisPaginationComponent.value?.paginator.reload(); }; const setLicenseBulk = async () => { @@ -254,7 +254,7 @@ const setLicenseBulk = async () => { ids: selectedEmojis.value, license: result, }); - emojisPaginationComponent.value?.reload(); + emojisPaginationComponent.value?.paginator.reload(); }; const addTagBulk = async () => { @@ -266,7 +266,7 @@ const addTagBulk = async () => { ids: selectedEmojis.value, aliases: result.split(' '), }); - emojisPaginationComponent.value?.reload(); + emojisPaginationComponent.value?.paginator.reload(); }; const removeTagBulk = async () => { @@ -278,7 +278,7 @@ const removeTagBulk = async () => { ids: selectedEmojis.value, aliases: result.split(' '), }); - emojisPaginationComponent.value?.reload(); + emojisPaginationComponent.value?.paginator.reload(); }; const setTagBulk = async () => { @@ -290,7 +290,7 @@ const setTagBulk = async () => { ids: selectedEmojis.value, aliases: result.split(' '), }); - emojisPaginationComponent.value?.reload(); + emojisPaginationComponent.value?.paginator.reload(); }; const delBulk = async () => { @@ -302,7 +302,7 @@ const delBulk = async () => { await os.apiWithDialog('admin/emoji/delete-bulk', { ids: selectedEmojis.value, }); - emojisPaginationComponent.value?.reload(); + emojisPaginationComponent.value?.paginator.reload(); }; const headerActions = computed(() => [{ diff --git a/packages/frontend/src/pages/debug.vue b/packages/frontend/src/pages/debug.vue index 4dae1b57a9..5cd68c2c3a 100644 --- a/packages/frontend/src/pages/debug.vue +++ b/packages/frontend/src/pages/debug.vue @@ -18,11 +18,12 @@ SPDX-License-Identifier: AGPL-3.0-only ]" ></MkSelect> - <MkSystemIcon v-if="iconType === 'info'" type="info" style="width: 60px;"/> - <MkSystemIcon v-if="iconType === 'question'" type="question" style="width: 60px;"/> - <MkSystemIcon v-if="iconType === 'success'" type="success" style="width: 60px;"/> - <MkSystemIcon v-if="iconType === 'warn'" type="warn" style="width: 60px;"/> - <MkSystemIcon v-if="iconType === 'error'" type="error" style="width: 60px;"/> + <MkSystemIcon v-if="iconType === 'info'" type="info" style="width: 150px;"/> + <MkSystemIcon v-if="iconType === 'question'" type="question" style="width: 150px;"/> + <MkSystemIcon v-if="iconType === 'success'" type="success" style="width: 150px;"/> + <MkSystemIcon v-if="iconType === 'warn'" type="warn" style="width: 150px;"/> + <MkSystemIcon v-if="iconType === 'error'" type="error" style="width: 150px;"/> + <MkSystemIcon v-if="iconType === 'waiting'" type="waiting" style="width: 150px;"/> <MkSelect v-model="iconType" :items="[ { label: 'info', value: 'info' }, @@ -30,6 +31,7 @@ SPDX-License-Identifier: AGPL-3.0-only { label: 'success', value: 'success' }, { label: 'warn', value: 'warn' }, { label: 'error', value: 'error' }, + { label: 'waiting', value: 'waiting' }, ]" ></MkSelect> diff --git a/packages/frontend/src/pages/drive.file.info.vue b/packages/frontend/src/pages/drive.file.info.vue index 21be0b18a9..e8ac13c223 100644 --- a/packages/frontend/src/pages/drive.file.info.vue +++ b/packages/frontend/src/pages/drive.file.info.vue @@ -20,9 +20,6 @@ SPDX-License-Identifier: AGPL-3.0-only <button v-tooltip="i18n.ts.createNoteFromTheFile" class="_button" :class="$style.fileQuickActionsOthersButton" @click="postThis()"> <i class="ti ti-pencil"></i> </button> - <button v-if="isImage" v-tooltip="i18n.ts.cropImage" class="_button" :class="$style.fileQuickActionsOthersButton" @click="crop()"> - <i class="ti ti-crop"></i> - </button> <button v-if="file.isSensitive" v-tooltip="i18n.ts.unmarkAsSensitive" class="_button" :class="$style.fileQuickActionsOthersButton" @click="toggleSensitive()"> <i class="ti ti-eye"></i> </button> @@ -83,6 +80,8 @@ import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { useRouter } from '@/router.js'; +import { selectDriveFolder } from '@/utility/drive.js'; +import { globalEvents } from '@/events.js'; const router = useRouter(); @@ -127,19 +126,10 @@ function postThis() { }); } -function crop() { - if (!file.value) return; - - os.cropImage(file.value, { - aspectRatio: NaN, - uploadFolder: file.value.folderId ?? null, - }); -} - function move() { if (!file.value) return; - os.selectDriveFolder(false).then(folder => { + selectDriveFolder(null).then(folder => { misskeyApi('drive/files/update', { fileId: file.value.id, folderId: folder[0] ? folder[0].id : null, @@ -210,12 +200,14 @@ async function deleteFile() { type: 'warning', text: i18n.tsx.driveFileDeleteConfirm({ name: file.value.name }), }); - if (canceled) return; + await os.apiWithDialog('drive/files/delete', { fileId: file.value.id, }); + globalEvents.emit('driveFilesDeleted', [file.value]); + router.push('/my/drive'); } diff --git a/packages/frontend/src/pages/drive.file.notes.vue b/packages/frontend/src/pages/drive.file.notes.vue index d7519896cc..cf45470588 100644 --- a/packages/frontend/src/pages/drive.file.notes.vue +++ b/packages/frontend/src/pages/drive.file.notes.vue @@ -6,16 +6,16 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div class="_gaps"> <MkInfo>{{ i18n.ts._fileViewer.thisPageCanBeSeenFromTheAuthor }}</MkInfo> - <MkNotes ref="tlComponent" :pagination="pagination"/> + <MkNotesTimeline ref="tlComponent" :pagination="pagination"/> </div> </template> <script lang="ts" setup> import { ref, computed } from 'vue'; import { i18n } from '@/i18n.js'; -import type { Paging } from '@/components/MkPagination.vue'; +import type { PagingCtx } from '@/composables/use-pagination.js'; import MkInfo from '@/components/MkInfo.vue'; -import MkNotes from '@/components/MkNotes.vue'; +import MkNotesTimeline from '@/components/MkNotesTimeline.vue'; const props = defineProps<{ fileId: string; @@ -23,7 +23,7 @@ const props = defineProps<{ const realFileId = computed(() => props.fileId); -const pagination = ref<Paging>({ +const pagination = ref<PagingCtx>({ endpoint: 'drive/files/attached-notes', limit: 10, params: { diff --git a/packages/frontend/src/pages/drive.vue b/packages/frontend/src/pages/drive.vue index bee54f3fd2..38939f9503 100644 --- a/packages/frontend/src/pages/drive.vue +++ b/packages/frontend/src/pages/drive.vue @@ -5,14 +5,14 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div> - <XDrive @cd="x => folder = x"/> + <MkDrive @cd="x => folder = x"/> </div> </template> <script lang="ts" setup> import { computed, ref } from 'vue'; import * as Misskey from 'misskey-js'; -import XDrive from '@/components/MkDrive.vue'; +import MkDrive from '@/components/MkDrive.vue'; import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; diff --git a/packages/frontend/src/pages/emoji-edit-dialog.vue b/packages/frontend/src/pages/emoji-edit-dialog.vue index 9eb24aa70e..41de457427 100644 --- a/packages/frontend/src/pages/emoji-edit-dialog.vue +++ b/packages/frontend/src/pages/emoji-edit-dialog.vue @@ -79,7 +79,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, watch, ref } from 'vue'; +import { computed, watch, ref, useTemplateRef } from 'vue'; import * as Misskey from 'misskey-js'; import MkWindow from '@/components/MkWindow.vue'; import MkButton from '@/components/MkButton.vue'; @@ -91,7 +91,7 @@ import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; import { customEmojiCategories } from '@/custom-emojis.js'; import MkSwitch from '@/components/MkSwitch.vue'; -import { selectFile } from '@/utility/select-file.js'; +import { selectFile } from '@/utility/drive.js'; import MkRolePreview from '@/components/MkRolePreview.vue'; const props = defineProps<{ @@ -103,7 +103,7 @@ const emit = defineEmits<{ (ev: 'closed'): void }>(); -const windowEl = ref<InstanceType<typeof MkWindow> | null>(null); +const windowEl = useTemplateRef('windowEl'); const name = ref<string>(props.emoji ? props.emoji.name : ''); const category = ref<string>(props.emoji?.category ? props.emoji.category : ''); const aliases = ref<string>(props.emoji ? props.emoji.aliases.join(' ') : ''); diff --git a/packages/frontend/src/pages/explore.featured.vue b/packages/frontend/src/pages/explore.featured.vue index a47e3efbc8..b8eb7eb8d5 100644 --- a/packages/frontend/src/pages/explore.featured.vue +++ b/packages/frontend/src/pages/explore.featured.vue @@ -9,14 +9,14 @@ SPDX-License-Identifier: AGPL-3.0-only <option value="notes">{{ i18n.ts.notes }}</option> <option value="polls">{{ i18n.ts.poll }}</option> </MkTab> - <MkNotes v-if="tab === 'notes'" :pagination="paginationForNotes"/> - <MkNotes v-else-if="tab === 'polls'" :pagination="paginationForPolls"/> + <MkNotesTimeline v-if="tab === 'notes'" :pagination="paginationForNotes"/> + <MkNotesTimeline v-else-if="tab === 'polls'" :pagination="paginationForPolls"/> </div> </template> <script lang="ts" setup> import { ref } from 'vue'; -import MkNotes from '@/components/MkNotes.vue'; +import MkNotesTimeline from '@/components/MkNotesTimeline.vue'; import MkTab from '@/components/MkTab.vue'; import { i18n } from '@/i18n.js'; diff --git a/packages/frontend/src/pages/follow-requests.vue b/packages/frontend/src/pages/follow-requests.vue index 9b4e3faaef..e02abdc393 100644 --- a/packages/frontend/src/pages/follow-requests.vue +++ b/packages/frontend/src/pages/follow-requests.vue @@ -36,7 +36,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import * as Misskey from 'misskey-js'; import { useTemplateRef, computed, ref } from 'vue'; -import type { Paging } from '@/components/MkPagination.vue'; +import type { PagingCtx } from '@/composables/use-pagination.js'; import MkPagination from '@/components/MkPagination.vue'; import MkButton from '@/components/MkButton.vue'; import { userPage, acct } from '@/filters/user.js'; @@ -47,7 +47,7 @@ import { $i } from '@/i.js'; const paginationComponent = useTemplateRef('paginationComponent'); -const pagination = computed<Paging>(() => tab.value === 'list' ? { +const pagination = computed<PagingCtx>(() => tab.value === 'list' ? { endpoint: 'following/requests/list', limit: 10, } : { @@ -57,19 +57,19 @@ const pagination = computed<Paging>(() => tab.value === 'list' ? { function accept(user: Misskey.entities.UserLite) { os.apiWithDialog('following/requests/accept', { userId: user.id }).then(() => { - paginationComponent.value?.reload(); + paginationComponent.value?.paginator.reload(); }); } function reject(user: Misskey.entities.UserLite) { os.apiWithDialog('following/requests/reject', { userId: user.id }).then(() => { - paginationComponent.value?.reload(); + paginationComponent.value?.paginator.reload(); }); } function cancel(user: Misskey.entities.UserLite) { os.apiWithDialog('following/requests/cancel', { userId: user.id }).then(() => { - paginationComponent.value?.reload(); + paginationComponent.value?.paginator.reload(); }); } diff --git a/packages/frontend/src/pages/gallery/edit.vue b/packages/frontend/src/pages/gallery/edit.vue index caae30f9fd..1b8c14a156 100644 --- a/packages/frontend/src/pages/gallery/edit.vue +++ b/packages/frontend/src/pages/gallery/edit.vue @@ -44,7 +44,7 @@ import MkInput from '@/components/MkInput.vue'; import MkTextarea from '@/components/MkTextarea.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import FormSuspense from '@/components/form/suspense.vue'; -import { selectFiles } from '@/utility/select-file.js'; +import { selectFiles } from '@/utility/drive.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { definePage } from '@/page.js'; diff --git a/packages/frontend/src/pages/instance-info.vue b/packages/frontend/src/pages/instance-info.vue index 96a43f67e8..0057106411 100644 --- a/packages/frontend/src/pages/instance-info.vue +++ b/packages/frontend/src/pages/instance-info.vue @@ -133,7 +133,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { ref, computed, watch } from 'vue'; import * as Misskey from 'misskey-js'; import type { ChartSrc } from '@/components/MkChart.vue'; -import type { Paging } from '@/components/MkPagination.vue'; +import type { PagingCtx } from '@/composables/use-pagination.js'; import MkChart from '@/components/MkChart.vue'; import MkObjectView from '@/components/MkObjectView.vue'; import FormLink from '@/components/form/link.vue'; @@ -180,7 +180,7 @@ const usersPagination = { hostname: props.host, }, offsetMode: true, -} satisfies Paging; +} satisfies PagingCtx; if (iAmModerator) { watch(moderationNote, async () => { diff --git a/packages/frontend/src/pages/invite.vue b/packages/frontend/src/pages/invite.vue index 406c08bcf2..98e3190e4b 100644 --- a/packages/frontend/src/pages/invite.vue +++ b/packages/frontend/src/pages/invite.vue @@ -29,7 +29,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, ref, useTemplateRef } from 'vue'; import * as Misskey from 'misskey-js'; -import type { Paging } from '@/components/MkPagination.vue'; +import type { PagingCtx } from '@/composables/use-pagination.js'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; @@ -45,7 +45,7 @@ const currentInviteLimit = ref<null | number>(null); const inviteLimit = (($i != null && $i.policies.inviteLimit) || (($i == null && instance.policies.inviteLimit))) as number; const inviteLimitCycle = (($i != null && $i.policies.inviteLimitCycle) || ($i == null && instance.policies.inviteLimitCycle)) as number; -const pagination: Paging = { +const pagination: PagingCtx = { endpoint: 'invite/list' as const, limit: 10, }; @@ -68,13 +68,13 @@ async function create() { text: ticket.code, }); - pagingComponent.value?.prepend(ticket); + pagingComponent.value?.paginator.prepend(ticket); update(); } function deleted(id: string) { if (pagingComponent.value) { - pagingComponent.value.items.delete(id); + pagingComponent.value.paginator.removeItem(id); } update(); } diff --git a/packages/frontend/src/pages/my-clips/index.vue b/packages/frontend/src/pages/my-clips/index.vue index 9e427ecf35..c386ed7239 100644 --- a/packages/frontend/src/pages/my-clips/index.vue +++ b/packages/frontend/src/pages/my-clips/index.vue @@ -5,7 +5,10 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs" :swipable="true"> - <div class="_spacer" style="--MI_SPACER-w: 700px;"> + <div class="_spacer _gaps" style="--MI_SPACER-w: 700px;"> + <MkTip k="clips"> + {{ i18n.ts._clip.tip }} + </MkTip> <div v-if="tab === 'my'" class="_gaps"> <MkButton primary rounded class="add" @click="create"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton> @@ -73,15 +76,15 @@ async function create() { clipsCache.delete(); - pagingComponent.value?.reload(); + pagingComponent.value?.paginator.reload(); } function onClipCreated() { - pagingComponent.value?.reload(); + pagingComponent.value?.paginator.reload(); } function onClipDeleted() { - pagingComponent.value?.reload(); + pagingComponent.value?.paginator.reload(); } const headerActions = computed(() => []); diff --git a/packages/frontend/src/pages/my-lists/index.vue b/packages/frontend/src/pages/my-lists/index.vue index 41afabff99..fb31cd542c 100644 --- a/packages/frontend/src/pages/my-lists/index.vue +++ b/packages/frontend/src/pages/my-lists/index.vue @@ -7,6 +7,10 @@ SPDX-License-Identifier: AGPL-3.0-only <PageWithHeader :actions="headerActions" :tabs="headerTabs"> <div class="_spacer" style="--MI_SPACER-w: 700px;"> <div class="_gaps"> + <MkTip k="userLists"> + {{ i18n.ts._userLists.tip }} + </MkTip> + <MkResult v-if="items.length === 0" type="empty"/> <MkButton primary rounded style="margin: 0 auto;" @click="create"><i class="ti ti-plus"></i> {{ i18n.ts.createList }}</MkButton> diff --git a/packages/frontend/src/pages/my-lists/list.vue b/packages/frontend/src/pages/my-lists/list.vue index 0b76fb4725..06abe3d7fd 100644 --- a/packages/frontend/src/pages/my-lists/list.vue +++ b/packages/frontend/src/pages/my-lists/list.vue @@ -52,7 +52,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, ref, watch } from 'vue'; +import { computed, ref, useTemplateRef, watch } from 'vue'; import * as Misskey from 'misskey-js'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; @@ -80,7 +80,7 @@ const props = defineProps<{ listId: string; }>(); -const paginationEl = ref<InstanceType<typeof MkPagination>>(); +const paginationEl = useTemplateRef('paginationEl'); const list = ref<Misskey.entities.UserList | null>(null); const isPublic = ref(false); const name = ref(''); @@ -109,7 +109,7 @@ function addUser() { listId: list.value.id, userId: user.id, }).then(() => { - paginationEl.value?.reload(); + paginationEl.value?.paginator.reload(); }); }); } @@ -125,7 +125,7 @@ async function removeUser(item, ev) { listId: list.value.id, userId: item.userId, }).then(() => { - paginationEl.value?.removeItem(item.id); + paginationEl.value?.paginator.removeItem(item.id); }); }, }], ev.currentTarget ?? ev.target); @@ -147,7 +147,7 @@ async function showMembershipMenu(item, ev) { userId: item.userId, withReplies, }).then(() => { - paginationEl.value!.updateItem(item.id, (old) => ({ + paginationEl.value!.paginator.updateItem(item.id, (old) => ({ ...old, withReplies, })); diff --git a/packages/frontend/src/pages/note.vue b/packages/frontend/src/pages/note.vue index 0f1dbc4432..8a645e417c 100644 --- a/packages/frontend/src/pages/note.vue +++ b/packages/frontend/src/pages/note.vue @@ -6,42 +6,40 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <PageWithHeader :actions="headerActions" :tabs="headerTabs"> <div class="_spacer" style="--MI_SPACER-w: 800px;"> - <div> - <Transition :name="prefer.s.animation ? 'fade' : ''" mode="out-in"> - <div v-if="note"> - <div v-if="showNext" class="_margin"> - <MkNotes class="" :pagination="showNext === 'channel' ? nextChannelPagination : nextUserPagination" :noGap="true" :disableAutoLoad="true"/> - </div> + <Transition :name="prefer.s.animation ? 'fade' : ''" mode="out-in"> + <div v-if="note"> + <div v-if="showNext" class="_margin"> + <MkNotesTimeline :pullToRefresh="false" class="" :pagination="showNext === 'channel' ? nextChannelPagination : nextUserPagination" :noGap="true" :disableAutoLoad="true"/> + </div> - <div class="_margin"> - <div v-if="!showNext" class="_buttons" :class="$style.loadNext"> - <MkButton v-if="note.channelId" rounded :class="$style.loadButton" @click="showNext = 'channel'"><i class="ti ti-chevron-up"></i> <i class="ti ti-device-tv"></i></MkButton> - <MkButton rounded :class="$style.loadButton" @click="showNext = 'user'"><i class="ti ti-chevron-up"></i> <i class="ti ti-user"></i></MkButton> - </div> - <div class="_margin _gaps_s"> - <MkRemoteCaution v-if="note.user.host != null" :href="note.url ?? note.uri"/> - <MkNoteDetailed :key="note.id" v-model:note="note" :initialTab="initialTab" :class="$style.note"/> - </div> - <div v-if="clips && clips.length > 0" class="_margin"> - <div style="font-weight: bold; padding: 12px;">{{ i18n.ts.clip }}</div> - <div class="_gaps"> - <MkClipPreview v-for="item in clips" :key="item.id" :clip="item"/> - </div> - </div> - <div v-if="!showPrev" class="_buttons" :class="$style.loadPrev"> - <MkButton v-if="note.channelId" rounded :class="$style.loadButton" @click="showPrev = 'channel'"><i class="ti ti-chevron-down"></i> <i class="ti ti-device-tv"></i></MkButton> - <MkButton rounded :class="$style.loadButton" @click="showPrev = 'user'"><i class="ti ti-chevron-down"></i> <i class="ti ti-user"></i></MkButton> + <div class="_margin"> + <div v-if="!showNext" class="_buttons" :class="$style.loadNext"> + <MkButton v-if="note.channelId" rounded :class="$style.loadButton" @click="showNext = 'channel'"><i class="ti ti-chevron-up"></i> <i class="ti ti-device-tv"></i></MkButton> + <MkButton rounded :class="$style.loadButton" @click="showNext = 'user'"><i class="ti ti-chevron-up"></i> <i class="ti ti-user"></i></MkButton> + </div> + <div class="_margin _gaps_s"> + <MkRemoteCaution v-if="note.user.host != null" :href="note.url ?? note.uri"/> + <MkNoteDetailed :key="note.id" v-model:note="note" :initialTab="initialTab" :class="$style.note"/> + </div> + <div v-if="clips && clips.length > 0" class="_margin"> + <div style="font-weight: bold; padding: 12px;">{{ i18n.ts.clip }}</div> + <div class="_gaps"> + <MkClipPreview v-for="item in clips" :key="item.id" :clip="item"/> </div> </div> - - <div v-if="showPrev" class="_margin"> - <MkNotes class="" :pagination="showPrev === 'channel' ? prevChannelPagination : prevUserPagination" :noGap="true"/> + <div v-if="!showPrev" class="_buttons" :class="$style.loadPrev"> + <MkButton v-if="note.channelId" rounded :class="$style.loadButton" @click="showPrev = 'channel'"><i class="ti ti-chevron-down"></i> <i class="ti ti-device-tv"></i></MkButton> + <MkButton rounded :class="$style.loadButton" @click="showPrev = 'user'"><i class="ti ti-chevron-down"></i> <i class="ti ti-user"></i></MkButton> </div> </div> - <MkError v-else-if="error" @retry="fetchNote()"/> - <MkLoading v-else/> - </Transition> - </div> + + <div v-if="showPrev" class="_margin"> + <MkNotesTimeline :pullToRefresh="false" class="" :pagination="showPrev === 'channel' ? prevChannelPagination : prevUserPagination" :noGap="true"/> + </div> + </div> + <MkError v-else-if="error" @retry="fetchNote()"/> + <MkLoading v-else/> + </Transition> </div> </PageWithHeader> </template> @@ -50,9 +48,9 @@ SPDX-License-Identifier: AGPL-3.0-only import { computed, watch, ref } from 'vue'; import * as Misskey from 'misskey-js'; import { host } from '@@/js/config.js'; -import type { Paging } from '@/components/MkPagination.vue'; +import type { PagingCtx } from '@/composables/use-pagination.js'; import MkNoteDetailed from '@/components/MkNoteDetailed.vue'; -import MkNotes from '@/components/MkNotes.vue'; +import MkNotesTimeline from '@/components/MkNotesTimeline.vue'; import MkRemoteCaution from '@/components/MkRemoteCaution.vue'; import MkButton from '@/components/MkButton.vue'; import { misskeyApi } from '@/utility/misskey-api.js'; @@ -80,26 +78,27 @@ const showPrev = ref<'user' | 'channel' | false>(false); const showNext = ref<'user' | 'channel' | false>(false); const error = ref(); -const prevUserPagination: Paging = { +const prevUserPagination: PagingCtx = { endpoint: 'users/notes', limit: 10, + baseId: props.noteId, + direction: 'older', params: computed(() => note.value ? ({ userId: note.value.userId, - untilId: note.value.id, }) : undefined), }; -const nextUserPagination: Paging = { - reversed: true, +const nextUserPagination: PagingCtx = { endpoint: 'users/notes', limit: 10, + baseId: props.noteId, + direction: 'newer', params: computed(() => note.value ? ({ userId: note.value.userId, - sinceId: note.value.id, }) : undefined), }; -const prevChannelPagination: Paging = { +const prevChannelPagination: PagingCtx = { endpoint: 'channels/timeline', limit: 10, params: computed(() => note.value ? ({ @@ -108,7 +107,7 @@ const prevChannelPagination: Paging = { }) : undefined), }; -const nextChannelPagination: Paging = { +const nextChannelPagination: PagingCtx = { reversed: true, endpoint: 'channels/timeline', limit: 10, diff --git a/packages/frontend/src/pages/notifications.vue b/packages/frontend/src/pages/notifications.vue index 5cb71945dd..db911c1202 100644 --- a/packages/frontend/src/pages/notifications.vue +++ b/packages/frontend/src/pages/notifications.vue @@ -7,13 +7,13 @@ SPDX-License-Identifier: AGPL-3.0-only <PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs" :swipable="true"> <div class="_spacer" style="--MI_SPACER-w: 800px;"> <div v-if="tab === 'all'"> - <XNotifications :class="$style.notifications" :excludeTypes="excludeTypes"/> + <MkStreamingNotificationsTimeline :class="$style.notifications" :excludeTypes="excludeTypes"/> </div> <div v-else-if="tab === 'mentions'"> - <MkNotes :pagination="mentionsPagination"/> + <MkNotesTimeline :pagination="mentionsPagination"/> </div> <div v-else-if="tab === 'directNotes'"> - <MkNotes :pagination="directNotesPagination"/> + <MkNotesTimeline :pagination="directNotesPagination"/> </div> </div> </PageWithHeader> @@ -22,8 +22,8 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, ref } from 'vue'; import { notificationTypes } from '@@/js/const.js'; -import XNotifications from '@/components/MkNotifications.vue'; -import MkNotes from '@/components/MkNotes.vue'; +import MkStreamingNotificationsTimeline from '@/components/MkStreamingNotificationsTimeline.vue'; +import MkNotesTimeline from '@/components/MkNotesTimeline.vue'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; diff --git a/packages/frontend/src/pages/page-editor/els/page-editor.el.image.vue b/packages/frontend/src/pages/page-editor/els/page-editor.el.image.vue index 1b98425719..13dedeafb2 100644 --- a/packages/frontend/src/pages/page-editor/els/page-editor.el.image.vue +++ b/packages/frontend/src/pages/page-editor/els/page-editor.el.image.vue @@ -20,14 +20,14 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -/* eslint-disable vue/no-mutating-props */ + import { onMounted, ref } from 'vue'; import * as Misskey from 'misskey-js'; import XContainer from '../page-editor.container.vue'; import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue'; -import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; +import { chooseDriveFile } from '@/utility/drive.js'; const props = defineProps<{ modelValue: Misskey.entities.PageBlock & { type: 'image' }; @@ -41,7 +41,7 @@ const emit = defineEmits<{ const file = ref<Misskey.entities.DriveFile | null>(null); async function choose() { - os.selectDriveFile(false).then((fileResponse) => { + chooseDriveFile({ multiple: false }).then((fileResponse) => { file.value = fileResponse[0]; emit('update:modelValue', { ...props.modelValue, diff --git a/packages/frontend/src/pages/page-editor/page-editor.vue b/packages/frontend/src/pages/page-editor/page-editor.vue index f1b1c2f1d8..49d9150852 100644 --- a/packages/frontend/src/pages/page-editor/page-editor.vue +++ b/packages/frontend/src/pages/page-editor/page-editor.vue @@ -71,7 +71,7 @@ import MkSwitch from '@/components/MkSwitch.vue'; import MkInput from '@/components/MkInput.vue'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; -import { selectFile } from '@/utility/select-file.js'; +import { selectFile } from '@/utility/drive.js'; import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; import { $i } from '@/i.js'; diff --git a/packages/frontend/src/pages/role.vue b/packages/frontend/src/pages/role.vue index 9d01edb255..42639cde9e 100644 --- a/packages/frontend/src/pages/role.vue +++ b/packages/frontend/src/pages/role.vue @@ -16,7 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> <div v-else-if="tab === 'timeline'" class="_spacer" style="--MI_SPACER-w: 700px;"> - <MkTimeline v-if="visible" ref="timeline" src="role" :role="props.roleId"/> + <MkStreamingNotesTimeline v-if="visible" ref="timeline" src="role" :role="props.roleId"/> <MkResult v-else-if="!visible" type="empty" :text="i18n.ts.nothing"/> </div> </PageWithHeader> @@ -29,7 +29,7 @@ import { misskeyApi } from '@/utility/misskey-api.js'; import MkUserList from '@/components/MkUserList.vue'; import { definePage } from '@/page.js'; import { i18n } from '@/i18n.js'; -import MkTimeline from '@/components/MkTimeline.vue'; +import MkStreamingNotesTimeline from '@/components/MkStreamingNotesTimeline.vue'; const props = withDefaults(defineProps<{ roleId: string; diff --git a/packages/frontend/src/pages/search.note.vue b/packages/frontend/src/pages/search.note.vue index 17cf272a36..352564bc9c 100644 --- a/packages/frontend/src/pages/search.note.vue +++ b/packages/frontend/src/pages/search.note.vue @@ -105,7 +105,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkFoldableSection v-if="notePagination"> <template #header>{{ i18n.ts.searchResult }}</template> - <MkNotes :key="`searchNotes:${key}`" :pagination="notePagination"/> + <MkNotesTimeline :key="`searchNotes:${key}`" :pagination="notePagination"/> </MkFoldableSection> </div> </template> @@ -113,7 +113,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, ref, shallowRef, toRef } from 'vue'; import type * as Misskey from 'misskey-js'; -import type { Paging } from '@/components/MkPagination.vue'; +import type { PagingCtx } from '@/composables/use-pagination.js'; import { $i } from '@/i.js'; import { host as localHost } from '@@/js/config.js'; import { i18n } from '@/i18n.js'; @@ -125,7 +125,7 @@ import { useRouter } from '@/router.js'; import MkButton from '@/components/MkButton.vue'; import MkFoldableSection from '@/components/MkFoldableSection.vue'; import MkInput from '@/components/MkInput.vue'; -import MkNotes from '@/components/MkNotes.vue'; +import MkNotesTimeline from '@/components/MkNotesTimeline.vue'; import MkRadios from '@/components/MkRadios.vue'; import MkUserCardMini from '@/components/MkUserCardMini.vue'; @@ -144,7 +144,7 @@ const props = withDefaults(defineProps<{ const router = useRouter(); const key = ref(0); -const notePagination = ref<Paging<'notes/search'>>(); +const notePagination = ref<PagingCtx<'notes/search'>>(); const searchQuery = ref(toRef(props, 'query').value); const hostInput = ref(toRef(props, 'host').value); diff --git a/packages/frontend/src/pages/search.user.vue b/packages/frontend/src/pages/search.user.vue index 101de6a64f..d98b58c748 100644 --- a/packages/frontend/src/pages/search.user.vue +++ b/packages/frontend/src/pages/search.user.vue @@ -27,7 +27,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref, toRef } from 'vue'; import type { Endpoints } from 'misskey-js'; -import type { Paging } from '@/components/MkPagination.vue'; +import type { PagingCtx } from '@/composables/use-pagination.js'; import MkUserList from '@/components/MkUserList.vue'; import MkInput from '@/components/MkInput.vue'; import MkRadios from '@/components/MkRadios.vue'; @@ -50,7 +50,7 @@ const props = withDefaults(defineProps<{ const router = useRouter(); const key = ref(0); -const userPagination = ref<Paging<'users/search'>>(); +const userPagination = ref<PagingCtx<'users/search'>>(); const searchQuery = ref(toRef(props, 'query').value); const searchOrigin = ref(toRef(props, 'origin').value); diff --git a/packages/frontend/src/pages/settings/account-data.vue b/packages/frontend/src/pages/settings/account-data.vue index 14bea577a3..d175c0dc32 100644 --- a/packages/frontend/src/pages/settings/account-data.vue +++ b/packages/frontend/src/pages/settings/account-data.vue @@ -164,7 +164,7 @@ import MkFolder from '@/components/MkFolder.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; -import { selectFile } from '@/utility/select-file.js'; +import { selectFile } from '@/utility/drive.js'; import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; import { $i } from '@/i.js'; diff --git a/packages/frontend/src/pages/settings/apps.vue b/packages/frontend/src/pages/settings/apps.vue index 33c17e5d7f..ec45eb3487 100644 --- a/packages/frontend/src/pages/settings/apps.vue +++ b/packages/frontend/src/pages/settings/apps.vue @@ -49,7 +49,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ref, computed } from 'vue'; +import { ref, computed, useTemplateRef } from 'vue'; import * as Misskey from 'misskey-js'; import FormPagination from '@/components/MkPagination.vue'; import { misskeyApi } from '@/utility/misskey-api.js'; @@ -59,7 +59,7 @@ import MkKeyValue from '@/components/MkKeyValue.vue'; import MkButton from '@/components/MkButton.vue'; import MkFolder from '@/components/MkFolder.vue'; -const list = ref<InstanceType<typeof FormPagination>>(); +const list = useTemplateRef('list'); const pagination = { endpoint: 'i/apps' as const, @@ -72,7 +72,7 @@ const pagination = { function revoke(token) { misskeyApi('i/revoke-token', { tokenId: token.id }).then(() => { - list.value?.reload(); + list.value?.paginator.reload(); }); } diff --git a/packages/frontend/src/pages/settings/deck.vue b/packages/frontend/src/pages/settings/deck.vue index 39055268d4..22bd8cbc80 100644 --- a/packages/frontend/src/pages/settings/deck.vue +++ b/packages/frontend/src/pages/settings/deck.vue @@ -98,7 +98,7 @@ import { definePage } from '@/page.js'; import { prefer } from '@/preferences.js'; import MkPreferenceContainer from '@/components/MkPreferenceContainer.vue'; import { reloadAsk } from '@/utility/reload-ask.js'; -import { selectFile } from '@/utility/select-file.js'; +import { selectFile } from '@/utility/drive.js'; const navWindow = prefer.model('deck.navWindow'); const useSimpleUiForNonRootPages = prefer.model('deck.useSimpleUiForNonRootPages'); diff --git a/packages/frontend/src/pages/settings/drive.vue b/packages/frontend/src/pages/settings/drive.vue index 2130cbc868..d62e487341 100644 --- a/packages/frontend/src/pages/settings/drive.vue +++ b/packages/frontend/src/pages/settings/drive.vue @@ -99,6 +99,7 @@ import { ensureSignin } from '@/i.js'; import { prefer } from '@/preferences.js'; import MkPreferenceContainer from '@/components/MkPreferenceContainer.vue'; import MkFeatureBanner from '@/components/MkFeatureBanner.vue'; +import { selectDriveFolder } from '@/utility/drive.js'; const $i = ensureSignin(); @@ -138,7 +139,7 @@ if (prefer.s.uploadFolder) { } function chooseUploadFolder() { - os.selectDriveFolder(false).then(async folder => { + selectDriveFolder(null).then(async folder => { prefer.commit('uploadFolder', folder[0] ? folder[0].id : null); os.success(); if (prefer.s.uploadFolder) { diff --git a/packages/frontend/src/pages/settings/mute-block.emoji-mute.vue b/packages/frontend/src/pages/settings/mute-block.emoji-mute.vue new file mode 100644 index 0000000000..601ca7ee49 --- /dev/null +++ b/packages/frontend/src/pages/settings/mute-block.emoji-mute.vue @@ -0,0 +1,105 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div :class="$style.emojis"> + <div v-for="emoji in emojis" :key="`emojiMute-${emoji}`" :class="$style.emoji" @click="onEmojiClick($event, emoji)"> + <MkCustomEmoji + v-if="emoji.startsWith(':')" + :name="customEmojiName(emoji)" + :host="customEmojiHost(emoji)" + :normal="true" + :menu="false" + :menuReaction="false" + :ignoreMuted="true" + /> + <MkEmoji + v-else + :emoji="emoji" + :menu="false" + :menuReaction="false" + :ignoreMuted="true" + ></MkEmoji> + </div> +</div> + +<MkButton primary inline @click="add"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton> +</template> + +<script lang="ts" setup> +import type { MenuItem } from '@/types/menu'; +import MkButton from '@/components/MkButton.vue'; +import * as os from '@/os.js'; +import { i18n } from '@/i18n.js'; +import { prefer } from '@/preferences.js'; +import { + mute as muteEmoji, + unmute as unmuteEmoji, + extractCustomEmojiName as customEmojiName, + extractCustomEmojiHost as customEmojiHost, +} from '@/utility/emoji-mute.js'; + +const emojis = prefer.model('mutingEmojis'); + +function getHTMLElement(ev: MouseEvent): HTMLElement { + const target = ev.currentTarget ?? ev.target; + return target as HTMLElement; +} + +function add(ev: MouseEvent) { + os.pickEmoji(getHTMLElement(ev), { showPinned: false }).then((emoji) => { + if (emoji) { + muteEmoji(emoji); + } + }); +} + +function onEmojiClick(ev: MouseEvent, emoji: string) { + const menuItems : MenuItem[] = [{ + type: 'label', + text: emoji, + }, { + text: i18n.ts.emojiUnmute, + icon: 'ti ti-mood-off', + action: () => unmute(emoji), + }]; + os.popupMenu(menuItems, ev.currentTarget ?? ev.target); +} + +function unmute(emoji: string) { + os.confirm({ + type: 'question', + title: i18n.tsx.unmuteX({ x: emoji }), + }).then(({ canceled }) => { + if (canceled) { + return; + } + unmuteEmoji(emoji); + }); +} +</script> +<style module> +.emojis { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; + + &:empty { + display: none; + } +} + +.emoji { + display: inline-flex; + height: 42px; + padding: 0 6px; + font-size: 1.5em; + border-radius: 6px; + align-items: center; + justify-content: center; + background: var(--MI_THEME-buttonBg); +} +</style> diff --git a/packages/frontend/src/pages/settings/mute-block.vue b/packages/frontend/src/pages/settings/mute-block.vue index 7c2376249e..9b24501cce 100644 --- a/packages/frontend/src/pages/settings/mute-block.vue +++ b/packages/frontend/src/pages/settings/mute-block.vue @@ -50,6 +50,20 @@ SPDX-License-Identifier: AGPL-3.0-only </SearchMarker> <SearchMarker + :label="i18n.ts.emojiMute" + :keywords="['emoji', 'mute', 'hide']" + > + <MkFolder> + <template #icon><i class="ti ti-mood-off"></i></template> + <template #label>{{ i18n.ts.emojiMute }}</template> + + <div class="_gaps_m"> + <XEmojiMute/> + </div> + </mkfolder> + </SearchMarker> + + <SearchMarker :label="i18n.ts.instanceMute" :keywords="['note', 'server', 'instance', 'host', 'federation', 'mute', 'hide']" > @@ -163,6 +177,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref, computed, watch } from 'vue'; +import XEmojiMute from './mute-block.emoji-mute.vue'; import XInstanceMute from './mute-block.instance-mute.vue'; import XWordMute from './mute-block.word-mute.vue'; import MkPagination from '@/components/MkPagination.vue'; diff --git a/packages/frontend/src/pages/settings/navbar.vue b/packages/frontend/src/pages/settings/navbar.vue index b322b03a21..ef698fcd6e 100644 --- a/packages/frontend/src/pages/settings/navbar.vue +++ b/packages/frontend/src/pages/settings/navbar.vue @@ -69,6 +69,7 @@ import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; import { prefer } from '@/preferences.js'; import { PREF_DEF } from '@/preferences/def.js'; +import { getInitialPrefValue } from '@/preferences/manager.js'; const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default)); @@ -106,7 +107,7 @@ async function save() { } function reset() { - items.value = PREF_DEF.menu.default.map(x => ({ + items.value = getInitialPrefValue('menu').map(x => ({ id: Math.random().toString(), type: x, })); diff --git a/packages/frontend/src/pages/settings/notifications.vue b/packages/frontend/src/pages/settings/notifications.vue index e42e6613ac..4e8d88ab74 100644 --- a/packages/frontend/src/pages/settings/notifications.vue +++ b/packages/frontend/src/pages/settings/notifications.vue @@ -38,11 +38,12 @@ SPDX-License-Identifier: AGPL-3.0-only </FormSection> <FormSection> <div class="_gaps_m"> - <FormLink @click="readAllNotifications">{{ i18n.ts.markAsReadAllNotifications }}</FormLink> + <FormLink to="/settings/sounds">{{ i18n.ts.notificationSoundSettings }}</FormLink> </div> </FormSection> <FormSection> - <div class="_gaps_m"> + <div class="_gaps_s"> + <FormLink @click="readAllNotifications">{{ i18n.ts.markAsReadAllNotifications }}</FormLink> <FormLink @click="testNotification">{{ i18n.ts._notification.sendTestNotification }}</FormLink> <FormLink @click="flushNotification">{{ i18n.ts._notification.flushNotification }}</FormLink> </div> diff --git a/packages/frontend/src/pages/settings/other.vue b/packages/frontend/src/pages/settings/other.vue index 83a6aa167c..f09cc9c9bc 100644 --- a/packages/frontend/src/pages/settings/other.vue +++ b/packages/frontend/src/pages/settings/other.vue @@ -96,6 +96,9 @@ SPDX-License-Identifier: AGPL-3.0-only <MkSwitch v-model="stackingRouterView"> <template #label>Enable stacking router view</template> </MkSwitch> + <MkSwitch v-model="enableFolderPageView"> + <template #label>Enable folder page view</template> + </MkSwitch> </div> </MkFolder> </SearchMarker> @@ -120,6 +123,11 @@ SPDX-License-Identifier: AGPL-3.0-only <hr> + <MkButton @click="resetAllTips"><i class="ti ti-bulb"></i> {{ i18n.ts.redisplayAllTips }}</MkButton> + <MkButton @click="hideAllTips"><i class="ti ti-bulb-off"></i> {{ i18n.ts.hideAllTips }}</MkButton> + + <hr> + <FormSlot> <MkButton danger @click="migrate"><i class="ti ti-refresh"></i> {{ i18n.ts.migrateOldSettings }}</MkButton> <template #caption>{{ i18n.ts.migrateOldSettings_description }}</template> @@ -149,6 +157,7 @@ import { prefer } from '@/preferences.js'; import MkRolePreview from '@/components/MkRolePreview.vue'; import { signout } from '@/signout.js'; import { migrateOldSettings } from '@/pref-migrate.js'; +import { store, TIPS } from '@/store.js'; const $i = ensureSignin(); @@ -157,6 +166,7 @@ const enableCondensedLine = prefer.model('enableCondensedLine'); const skipNoteRender = prefer.model('skipNoteRender'); const devMode = prefer.model('devMode'); const stackingRouterView = prefer.model('experimental.stackingRouterView'); +const enableFolderPageView = prefer.model('experimental.enableFolderPageView'); watch(skipNoteRender, async () => { await reloadAsk({ reason: i18n.ts.reloadToApplySetting, unison: true }); @@ -190,6 +200,20 @@ function migrate() { migrateOldSettings(); } +function resetAllTips() { + store.set('tips', {}); + os.success(); +} + +function hideAllTips() { + const v = {}; + for (const k of TIPS) { + v[k] = true; + } + store.set('tips', v); + os.success(); +} + const headerActions = computed(() => []); const headerTabs = computed(() => []); diff --git a/packages/frontend/src/pages/settings/preferences.vue b/packages/frontend/src/pages/settings/preferences.vue index 4d718d21b4..18dfc2250c 100644 --- a/packages/frontend/src/pages/settings/preferences.vue +++ b/packages/frontend/src/pages/settings/preferences.vue @@ -41,6 +41,26 @@ SPDX-License-Identifier: AGPL-3.0-only </MkRadios> </SearchMarker> + <SearchMarker :keywords="['realtimemode']"> + <MkSwitch v-model="realtimeMode"> + <template #label><i class="ti ti-bolt"></i> <SearchLabel>{{ i18n.ts.realtimeMode }}</SearchLabel></template> + <template #caption><SearchKeyword>{{ i18n.ts._settings.realtimeMode_description }}</SearchKeyword></template> + </MkSwitch> + </SearchMarker> + + <MkDisableSection :disabled="realtimeMode"> + <SearchMarker :keywords="['polling', 'interval']"> + <MkPreferenceContainer k="pollingInterval"> + <MkRange v-model="pollingInterval" :min="1" :max="3" :step="1" easing :showTicks="true" :textConverter="(v) => v === 1 ? i18n.ts.low : v === 2 ? i18n.ts.middle : v === 3 ? i18n.ts.high : ''"> + <template #label><SearchLabel>{{ i18n.ts._settings.contentsUpdateFrequency }}</SearchLabel></template> + <template #caption><SearchKeyword>{{ i18n.ts._settings.contentsUpdateFrequency_description }}</SearchKeyword><br><SearchKeyword>{{ i18n.ts._settings.contentsUpdateFrequency_description2 }}</SearchKeyword></template> + <template #prefix><i class="ti ti-player-play"></i></template> + <template #suffix><i class="ti ti-player-track-next"></i></template> + </MkRange> + </MkPreferenceContainer> + </SearchMarker> + </MkDisableSection> + <div class="_gaps_s"> <SearchMarker :keywords="['titlebar', 'show']"> <MkPreferenceContainer k="showTitlebar"> @@ -148,22 +168,6 @@ SPDX-License-Identifier: AGPL-3.0-only </MkPreferenceContainer> </SearchMarker> - <SearchMarker :keywords="['note', 'timeline', 'gap']"> - <MkPreferenceContainer k="showGapBetweenNotesInTimeline"> - <MkSwitch v-model="showGapBetweenNotesInTimeline"> - <template #label><SearchLabel>{{ i18n.ts.showGapBetweenNotesInTimeline }}</SearchLabel></template> - </MkSwitch> - </MkPreferenceContainer> - </SearchMarker> - - <SearchMarker :keywords="['disable', 'streaming', 'timeline']"> - <MkPreferenceContainer k="disableStreamingTimeline"> - <MkSwitch v-model="disableStreamingTimeline"> - <template #label><SearchLabel>{{ i18n.ts.disableStreamingTimeline }}</SearchLabel></template> - </MkSwitch> - </MkPreferenceContainer> - </SearchMarker> - <SearchMarker :keywords="['pinned', 'list']"> <MkFolder> <template #label><SearchLabel>{{ i18n.ts.pinnedList }}</SearchLabel></template> @@ -553,6 +557,15 @@ SPDX-License-Identifier: AGPL-3.0-only <template #icon><SearchIcon><i class="ti ti-battery-vertical-eco"></i></SearchIcon></template> <div class="_gaps_s"> + <SearchMarker :keywords="['animation', 'motion', 'reduce']"> + <MkPreferenceContainer k="animation"> + <MkSwitch :modelValue="!reduceAnimation" @update:modelValue="v => reduceAnimation = !v"> + <template #label><SearchLabel>{{ i18n.ts._settings.uiAnimations }}</SearchLabel></template> + <template #caption><SearchKeyword>{{ i18n.ts.turnOffToImprovePerformance }}</SearchKeyword></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + <SearchMarker :keywords="['blur']"> <MkPreferenceContainer k="useBlurEffect"> <MkSwitch v-model="useBlurEffect"> @@ -571,6 +584,15 @@ SPDX-License-Identifier: AGPL-3.0-only </MkPreferenceContainer> </SearchMarker> + <SearchMarker :keywords="['blurhash', 'image', 'photo', 'picture', 'thumbnail', 'placeholder']"> + <MkPreferenceContainer k="enableHighQualityImagePlaceholders"> + <MkSwitch v-model="enableHighQualityImagePlaceholders"> + <template #label><SearchLabel>{{ i18n.ts._settings.enableHighQualityImagePlaceholders }}</SearchLabel></template> + <template #caption><SearchKeyword>{{ i18n.ts.turnOffToImprovePerformance }}</SearchKeyword></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + <SearchMarker :keywords="['sticky']"> <MkPreferenceContainer k="useStickyIcons"> <MkSwitch v-model="useStickyIcons"> @@ -579,6 +601,24 @@ SPDX-License-Identifier: AGPL-3.0-only </MkSwitch> </MkPreferenceContainer> </SearchMarker> + + <MkInfo> + <div class="_gaps_s"> + <div>{{ i18n.ts._clientPerformanceIssueTip.title }}</div> + <div> + <div><b>{{ i18n.ts._clientPerformanceIssueTip.makeSureDisabledAdBlocker }}</b></div> + <div>{{ i18n.ts._clientPerformanceIssueTip.makeSureDisabledAdBlocker_description }}</div> + </div> + <div> + <div><b>{{ i18n.ts._clientPerformanceIssueTip.makeSureDisabledCustomCss }}</b></div> + <div>{{ i18n.ts._clientPerformanceIssueTip.makeSureDisabledCustomCss_description }}</div> + </div> + <div> + <div><b>{{ i18n.ts._clientPerformanceIssueTip.makeSureDisabledAddons }}</b></div> + <div>{{ i18n.ts._clientPerformanceIssueTip.makeSureDisabledAddons_description }}</div> + </div> + </div> + </MkInfo> </div> </MkFolder> </SearchMarker> @@ -604,9 +644,13 @@ SPDX-License-Identifier: AGPL-3.0-only {{ i18n.ts._dataSaver._avatar.title }} <template #caption>{{ i18n.ts._dataSaver._avatar.description }}</template> </MkSwitch> - <MkSwitch v-model="dataSaver.urlPreview"> - {{ i18n.ts._dataSaver._urlPreview.title }} - <template #caption>{{ i18n.ts._dataSaver._urlPreview.description }}</template> + <MkSwitch v-model="dataSaver.disableUrlPreview" :disabled="!instance.enableUrlPreview"> + {{ i18n.ts._dataSaver._disableUrlPreview.title }} + <template #caption>{{ i18n.ts._dataSaver._disableUrlPreview.description }}</template> + </MkSwitch> + <MkSwitch v-model="dataSaver.urlPreviewThumbnail" :disabled="!instance.enableUrlPreview || dataSaver.disableUrlPreview"> + {{ i18n.ts._dataSaver._urlPreviewThumbnail.title }} + <template #caption>{{ i18n.ts._dataSaver._urlPreviewThumbnail.description }}</template> </MkSwitch> <MkSwitch v-model="dataSaver.code"> {{ i18n.ts._dataSaver._code.title }} @@ -734,7 +778,7 @@ import MkRadios from '@/components/MkRadios.vue'; import MkRange from '@/components/MkRange.vue'; import MkFolder from '@/components/MkFolder.vue'; import MkButton from '@/components/MkButton.vue'; -import FormSection from '@/components/form/section.vue'; +import MkDisableSection from '@/components/MkDisableSection.vue'; import FormLink from '@/components/form/link.vue'; import MkLink from '@/components/MkLink.vue'; import MkInfo from '@/components/MkInfo.vue'; @@ -757,8 +801,10 @@ const $i = ensureSignin(); const lang = ref(miLocalStorage.getItem('lang')); const dataSaver = ref(prefer.s.dataSaver); +const realtimeMode = computed(store.makeGetterSetter('realtimeMode')); const overridedDeviceKind = prefer.model('overridedDeviceKind'); +const pollingInterval = prefer.model('pollingInterval'); const showTitlebar = prefer.model('showTitlebar'); const keepCw = prefer.model('keepCw'); const serverDisconnectedBehavior = prefer.model('serverDisconnectedBehavior'); @@ -777,7 +823,6 @@ const showFixedPostFormInChannel = prefer.model('showFixedPostFormInChannel'); const numberOfPageCache = prefer.model('numberOfPageCache'); const enableInfiniteScroll = prefer.model('enableInfiniteScroll'); const useReactionPickerForContextMenu = prefer.model('useReactionPickerForContextMenu'); -const disableStreamingTimeline = prefer.model('disableStreamingTimeline'); const useGroupedNotifications = prefer.model('useGroupedNotifications'); const alwaysConfirmFollow = prefer.model('alwaysConfirmFollow'); const confirmWhenRevealingSensitiveMedia = prefer.model('confirmWhenRevealingSensitiveMedia'); @@ -785,7 +830,6 @@ const confirmOnReact = prefer.model('confirmOnReact'); const defaultNoteVisibility = prefer.model('defaultNoteVisibility'); const defaultNoteLocalOnly = prefer.model('defaultNoteLocalOnly'); const rememberNoteVisibility = prefer.model('rememberNoteVisibility'); -const showGapBetweenNotesInTimeline = prefer.model('showGapBetweenNotesInTimeline'); const notificationPosition = prefer.model('notificationPosition'); const notificationStackAxis = prefer.model('notificationStackAxis'); const instanceTicker = prefer.model('instanceTicker'); @@ -804,6 +848,7 @@ const defaultFollowWithReplies = prefer.model('defaultFollowWithReplies'); const chatShowSenderName = prefer.model('chat.showSenderName'); const chatSendOnEnter = prefer.model('chat.sendOnEnter'); const useStickyIcons = prefer.model('useStickyIcons'); +const enableHighQualityImagePlaceholders = prefer.model('enableHighQualityImagePlaceholders'); const reduceAnimation = prefer.model('animation', v => !v, v => !v); const animatedMfm = prefer.model('animatedMfm'); const disableShowingAnimatedImages = prefer.model('disableShowingAnimatedImages'); @@ -843,13 +888,13 @@ watch(useSystemFont, () => { watch([ hemisphere, lang, + realtimeMode, + pollingInterval, enableInfiniteScroll, showNoteActionsOnlyHover, overridedDeviceKind, - disableStreamingTimeline, alwaysConfirmFollow, confirmWhenRevealingSensitiveMedia, - showGapBetweenNotesInTimeline, mediaListWithOneImageAppearance, reactionsDisplaySize, limitWidthOfReaction, @@ -862,6 +907,7 @@ watch([ enableSeasonalScreenEffect, chatShowSenderName, useStickyIcons, + enableHighQualityImagePlaceholders, keepScreenOn, contextMenu, fontSize, @@ -869,6 +915,7 @@ watch([ makeEveryTextElementsSelectable, enableHorizontalSwipe, enablePullToRefresh, + reduceAnimation, ], async () => { await reloadAsk({ reason: i18n.ts.reloadToApplySetting, unison: true }); }); diff --git a/packages/frontend/src/pages/settings/profile.vue b/packages/frontend/src/pages/settings/profile.vue index 30b7cf9a86..cd1565f39e 100644 --- a/packages/frontend/src/pages/settings/profile.vue +++ b/packages/frontend/src/pages/settings/profile.vue @@ -161,7 +161,7 @@ import MkSelect from '@/components/MkSelect.vue'; import FormSplit from '@/components/form/split.vue'; import MkFolder from '@/components/MkFolder.vue'; import FormSlot from '@/components/form/slot.vue'; -import { selectFile } from '@/utility/select-file.js'; +import { chooseDriveFile } from '@/utility/drive.js'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; import { ensureSignin } from '@/i.js'; @@ -257,54 +257,100 @@ function save() { } function changeAvatar(ev) { - selectFile(ev.currentTarget ?? ev.target, i18n.ts.avatar).then(async (file) => { - let originalOrCropped = file; - - const { canceled } = await os.confirm({ - type: 'question', - text: i18n.ts.cropImageAsk, - okText: i18n.ts.cropYes, - cancelText: i18n.ts.cropNo, - }); - - if (!canceled) { - originalOrCropped = await os.cropImage(file, { - aspectRatio: 1, - }); - } - + async function done(driveFile) { const i = await os.apiWithDialog('i/update', { - avatarId: originalOrCropped.id, + avatarId: driveFile.id, }); $i.avatarId = i.avatarId; $i.avatarUrl = i.avatarUrl; claimAchievement('profileFilled'); - }); -} + } -function changeBanner(ev) { - selectFile(ev.currentTarget ?? ev.target, i18n.ts.banner).then(async (file) => { - let originalOrCropped = file; + os.popupMenu([{ + text: i18n.ts.avatar, + type: 'label', + }, { + text: i18n.ts.upload, + icon: 'ti ti-upload', + action: async () => { + const files = await os.chooseFileFromPc({ multiple: false }); + const file = files[0]; - const { canceled } = await os.confirm({ - type: 'question', - text: i18n.ts.cropImageAsk, - okText: i18n.ts.cropYes, - cancelText: i18n.ts.cropNo, - }); + let originalOrCropped = file; - if (!canceled) { - originalOrCropped = await os.cropImage(file, { - aspectRatio: 2, + const { canceled } = await os.confirm({ + type: 'question', + text: i18n.ts.cropImageAsk, + okText: i18n.ts.cropYes, + cancelText: i18n.ts.cropNo, + }); + + if (!canceled) { + originalOrCropped = await os.cropImageFile(file, { + aspectRatio: 1, + }); + } + + const driveFile = (await os.launchUploader([originalOrCropped], { multiple: false }))[0]; + done(driveFile); + }, + }, { + text: i18n.ts.fromDrive, + icon: 'ti ti-cloud', + action: () => { + chooseDriveFile({ multiple: false }).then(files => { + done(files[0]); }); - } + }, + }], ev.currentTarget ?? ev.target); +} +function changeBanner(ev) { + async function done(driveFile) { const i = await os.apiWithDialog('i/update', { - bannerId: originalOrCropped.id, + bannerId: driveFile.id, }); $i.bannerId = i.bannerId; $i.bannerUrl = i.bannerUrl; - }); + } + + os.popupMenu([{ + text: i18n.ts.banner, + type: 'label', + }, { + text: i18n.ts.upload, + icon: 'ti ti-upload', + action: async () => { + const files = await os.chooseFileFromPc({ multiple: false }); + const file = files[0]; + + let originalOrCropped = file; + + const { canceled } = await os.confirm({ + type: 'question', + text: i18n.ts.cropImageAsk, + okText: i18n.ts.cropYes, + cancelText: i18n.ts.cropNo, + }); + + if (!canceled) { + originalOrCropped = await os.cropImageFile(file, { + aspectRatio: 2, + }); + } + + const driveFile = (await os.launchUploader([originalOrCropped], { multiple: false }))[0]; + done(driveFile); + }, + }, { + text: i18n.ts.fromDrive, + icon: 'ti ti-cloud', + action: () => { + chooseDriveFile({ multiple: false }).then(files => { + done(files[0]); + }); + }, + }], ev.currentTarget ?? ev.target); } const headerActions = computed(() => []); diff --git a/packages/frontend/src/pages/settings/sounds.sound.vue b/packages/frontend/src/pages/settings/sounds.sound.vue index 1bac19fe47..ffbbefa122 100644 --- a/packages/frontend/src/pages/settings/sounds.sound.vue +++ b/packages/frontend/src/pages/settings/sounds.sound.vue @@ -40,7 +40,7 @@ import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { playMisskeySfxFile, soundsTypes, getSoundDuration } from '@/utility/sound.js'; -import { selectFile } from '@/utility/select-file.js'; +import { selectFile } from '@/utility/drive.js'; const props = defineProps<{ type: SoundType; diff --git a/packages/frontend/src/pages/settings/sounds.vue b/packages/frontend/src/pages/settings/sounds.vue index 4461ee1ab1..590db19bca 100644 --- a/packages/frontend/src/pages/settings/sounds.vue +++ b/packages/frontend/src/pages/settings/sounds.vue @@ -75,6 +75,7 @@ import MkSwitch from '@/components/MkSwitch.vue'; import MkPreferenceContainer from '@/components/MkPreferenceContainer.vue'; import { PREF_DEF } from '@/preferences/def.js'; import MkFeatureBanner from '@/components/MkFeatureBanner.vue'; +import { getInitialPrefValue } from '@/preferences/manager.js'; const notUseSound = prefer.model('sound.notUseSound'); const useSoundOnlyWhenActive = prefer.model('sound.useSoundOnlyWhenActive'); @@ -113,7 +114,7 @@ async function updated(type: keyof typeof sounds.value, sound) { function reset() { for (const sound of Object.keys(sounds.value) as Array<keyof typeof sounds.value>) { - const v = PREF_DEF[`sound.on.${sound}`].default; + const v = getInitialPrefValue(`sound.on.${sound}`); prefer.commit(`sound.on.${sound}`, v); sounds.value[sound] = v; } diff --git a/packages/frontend/src/pages/settings/theme.vue b/packages/frontend/src/pages/settings/theme.vue index 45b97e19c4..f3a6458109 100644 --- a/packages/frontend/src/pages/settings/theme.vue +++ b/packages/frontend/src/pages/settings/theme.vue @@ -9,8 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-adaptive-border class="rfqxtzch _panel"> <div class="toggle"> <div class="toggleWrapper"> - <input id="dn" v-model="darkMode" type="checkbox" class="dn"/> - <label for="dn" class="toggle"> + <div class="toggle" :class="store.r.darkMode.value ? 'checked' : null" @click="toggleDarkMode()"> <span class="before">{{ i18n.ts.light }}</span> <span class="after">{{ i18n.ts.dark }}</span> <span class="toggle__handler"> @@ -24,7 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only <span class="star star--4"></span> <span class="star star--5"></span> <span class="star star--6"></span> - </label> + </div> </div> </div> <div class="sync"> @@ -37,7 +36,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <div class="_gaps"> - <template v-if="!darkMode"> + <template v-if="!store.r.darkMode.value"> <SearchMarker :keywords="['light', 'theme']"> <MkFolder :defaultOpen="true" :max-height="500"> <template #icon><i class="ti ti-sun"></i></template> @@ -205,6 +204,7 @@ import JSON5 from 'json5'; import defaultLightTheme from '@@/themes/l-light.json5'; import defaultDarkTheme from '@@/themes/d-green-lime.json5'; import type { Theme } from '@/theme.js'; +import * as os from '@/os.js'; import MkSwitch from '@/components/MkSwitch.vue'; import FormSection from '@/components/form/section.vue'; import FormLink from '@/components/form/link.vue'; @@ -257,7 +257,6 @@ const lightThemeId = computed({ }, }); -const darkMode = computed(store.makeGetterSetter('darkMode')); const syncDeviceDarkMode = prefer.model('syncDeviceDarkMode'); const themesCount = installedThemes.value.length; @@ -267,6 +266,21 @@ watch(syncDeviceDarkMode, () => { } }); +async function toggleDarkMode() { + const value = !store.r.darkMode.value; + if (syncDeviceDarkMode.value) { + const { canceled } = await os.confirm({ + text: i18n.tsx.switchDarkModeManuallyWhenSyncEnabledConfirm({ x: i18n.ts.syncDeviceDarkMode }), + }); + if (canceled) return; + + syncDeviceDarkMode.value = false; + store.set('darkMode', value); + } else { + store.set('darkMode', value); + } +} + const themesSyncEnabled = ref(prefer.isSyncEnabled('themes')); function changeThemesSyncEnabled(value: boolean) { @@ -365,16 +379,6 @@ definePage(() => ({ overflow: clip; padding: 0 100px; vertical-align: bottom; - - input { - position: absolute; - left: -99em; - } - } - - .dn:focus-visible ~ .toggle { - outline: 2px solid var(--MI_THEME-focus); - outline-offset: 2px; } .toggle { @@ -403,6 +407,61 @@ definePage(() => ({ right: -68px; color: var(--MI_THEME-fg); } + + &.checked { + background-color: #749DD6; + + > .before { + color: var(--MI_THEME-fg); + } + + > .after { + color: var(--MI_THEME-accent); + } + + .toggle__handler { + background-color: #FFE5B5; + transform: translate3d(40px, 0, 0) rotate(0); + + .crater { opacity: 1; } + } + + .star--1 { + width: 2px; + height: 2px; + } + + .star--2 { + width: 4px; + height: 4px; + transform: translate3d(-5px, 0, 0); + } + + .star--3 { + width: 2px; + height: 2px; + transform: translate3d(-7px, 0, 0); + } + + .star--4, + .star--5, + .star--6 { + opacity: 1; + transform: translate3d(0,0,0); + } + + .star--4 { + transition: all 300ms 200ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important; + } + + .star--5 { + transition: all 300ms 300ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important; + } + + .star--6 { + transition: all 300ms 400ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important; + } + } } .toggle__handler { @@ -513,63 +572,6 @@ definePage(() => ({ height: 2px; transform: translate3d(3px,0,0); } - - input:checked { - + .toggle { - background-color: #749DD6; - - > .before { - color: var(--MI_THEME-fg); - } - - > .after { - color: var(--MI_THEME-accent); - } - - .toggle__handler { - background-color: #FFE5B5; - transform: translate3d(40px, 0, 0) rotate(0); - - .crater { opacity: 1; } - } - - .star--1 { - width: 2px; - height: 2px; - } - - .star--2 { - width: 4px; - height: 4px; - transform: translate3d(-5px, 0, 0); - } - - .star--3 { - width: 2px; - height: 2px; - transform: translate3d(-7px, 0, 0); - } - - .star--4, - .star--5, - .star--6 { - opacity: 1; - transform: translate3d(0,0,0); - } - - .star--4 { - transition: all 300ms 200ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important; - } - - .star--5 { - transition: all 300ms 300ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important; - } - - .star--6 { - transition: all 300ms 400ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important; - } - } - } } > .sync { diff --git a/packages/frontend/src/pages/tag.vue b/packages/frontend/src/pages/tag.vue index e1dffd4f2d..d1e5db5a5b 100644 --- a/packages/frontend/src/pages/tag.vue +++ b/packages/frontend/src/pages/tag.vue @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <PageWithHeader :actions="headerActions" :tabs="headerTabs"> <div class="_spacer" style="--MI_SPACER-w: 800px;"> - <MkNotes ref="notes" class="" :pagination="pagination"/> + <MkNotesTimeline ref="tlComponent" class="" :pagination="pagination"/> </div> <template v-if="$i" #footer> <div :class="$style.footer"> @@ -19,8 +19,8 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, ref } from 'vue'; -import MkNotes from '@/components/MkNotes.vue'; +import { computed, ref, useTemplateRef } from 'vue'; +import MkNotesTimeline from '@/components/MkNotesTimeline.vue'; import MkButton from '@/components/MkButton.vue'; import { definePage } from '@/page.js'; import { i18n } from '@/i18n.js'; @@ -40,7 +40,8 @@ const pagination = { tag: props.tag, })), }; -const notes = ref<InstanceType<typeof MkNotes>>(); + +const tlComponent = useTemplateRef('tlComponent'); async function post() { store.set('postFormHashtags', props.tag); @@ -48,7 +49,7 @@ async function post() { await os.post(); store.set('postFormHashtags', ''); store.set('postFormWithHashtags', false); - notes.value?.pagingComponent?.reload(); + tlComponent.value?.reload(); } const headerActions = computed(() => [{ diff --git a/packages/frontend/src/pages/theme-editor.vue b/packages/frontend/src/pages/theme-editor.vue index e8007b9779..a2ee04b555 100644 --- a/packages/frontend/src/pages/theme-editor.vue +++ b/packages/frontend/src/pages/theme-editor.vue @@ -90,7 +90,7 @@ import { addTheme, applyTheme } from '@/theme.js'; import * as os from '@/os.js'; import { store } from '@/store.js'; import { i18n } from '@/i18n.js'; -import { useLeaveGuard } from '@/use/use-leave-guard.js'; +import { useLeaveGuard } from '@/composables/use-leave-guard.js'; import { definePage } from '@/page.js'; import { prefer } from '@/preferences.js'; diff --git a/packages/frontend/src/pages/timeline.vue b/packages/frontend/src/pages/timeline.vue index efe2689579..5696d1dd89 100644 --- a/packages/frontend/src/pages/timeline.vue +++ b/packages/frontend/src/pages/timeline.vue @@ -4,14 +4,13 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<PageWithHeader ref="pageComponent" v-model:tab="src" :actions="headerActions" :tabs="$i ? headerTabs : headerTabsWhenNotLogin" :swipable="true" :displayMyAvatar="true"> +<PageWithHeader v-model:tab="src" :actions="headerActions" :tabs="$i ? headerTabs : headerTabsWhenNotLogin" :swipable="true" :displayMyAvatar="true"> <div class="_spacer" style="--MI_SPACER-w: 800px;"> - <MkInfo v-if="isBasicTimeline(src) && !store.r.timelineTutorials.value[src]" style="margin-bottom: var(--MI-margin);" closable @close="closeTutorial()"> + <MkTip v-if="isBasicTimeline(src)" :k="`tl.${src}`" style="margin-bottom: var(--MI-margin);"> {{ i18n.ts._timelineDescription[src] }} - </MkInfo> + </MkTip> <MkPostForm v-if="prefer.r.showFixedPostForm.value" :class="$style.postForm" class="_panel" fixed style="margin-bottom: var(--MI-margin);"/> - <div v-if="queue > 0" :class="$style.new"><button class="_buttonPrimary" :class="$style.newButton" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div> - <MkTimeline + <MkStreamingNotesTimeline ref="tlComponent" :key="src + withRenotes + withReplies + onlyFiles + withSensitive" :class="$style.tl" @@ -22,7 +21,6 @@ SPDX-License-Identifier: AGPL-3.0-only :withSensitive="withSensitive" :onlyFiles="onlyFiles" :sound="true" - @queue="queueUpdated" /> </div> </PageWithHeader> @@ -33,8 +31,7 @@ import { computed, watch, provide, useTemplateRef, ref, onMounted, onActivated } import type { Tab } from '@/components/global/MkPageHeader.tabs.vue'; import type { MenuItem } from '@/types/menu.js'; import type { BasicTimelineType } from '@/timelines.js'; -import MkTimeline from '@/components/MkTimeline.vue'; -import MkInfo from '@/components/MkInfo.vue'; +import MkStreamingNotesTimeline from '@/components/MkStreamingNotesTimeline.vue'; import MkPostForm from '@/components/MkPostForm.vue'; import * as os from '@/os.js'; import { store } from '@/store.js'; @@ -51,11 +48,9 @@ import { prefer } from '@/preferences.js'; provide('shouldOmitHeaderTitle', true); const tlComponent = useTemplateRef('tlComponent'); -const pageComponent = useTemplateRef('pageComponent'); type TimelinePageSrc = BasicTimelineType | `list:${string}`; -const queue = ref(0); const srcWhenNotSignin = ref<'local' | 'global'>(isAvailableBasicTimeline('local') ? 'local' : 'global'); const src = computed<TimelinePageSrc>({ get: () => ($i ? store.r.tl.value.src : srcWhenNotSignin.value), @@ -110,18 +105,6 @@ const withSensitive = computed<boolean>({ set: (x) => saveTlFilter('withSensitive', x), }); -watch(src, () => { - queue.value = 0; -}); - -function queueUpdated(q: number): void { - queue.value = q; -} - -function top(): void { - if (pageComponent.value) pageComponent.value.scrollToTop(); -} - async function chooseList(ev: MouseEvent): Promise<void> { const lists = await userListsCache.fetch(); const items: MenuItem[] = [ @@ -220,13 +203,6 @@ function focus(): void { tlComponent.value.focus(); } -function closeTutorial(): void { - if (!isBasicTimeline(src.value)) return; - const before = store.s.timelineTutorials; - before[src.value] = true; - store.set('timelineTutorials', before); -} - function switchTlIfNeeded() { if (isBasicTimeline(src.value) && !isAvailableBasicTimeline(src.value)) { src.value = availableBasicTimelines()[0]; diff --git a/packages/frontend/src/pages/user-list-timeline.vue b/packages/frontend/src/pages/user-list-timeline.vue index e05e35d533..f166495258 100644 --- a/packages/frontend/src/pages/user-list-timeline.vue +++ b/packages/frontend/src/pages/user-list-timeline.vue @@ -6,17 +6,13 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <PageWithHeader :actions="headerActions" :tabs="headerTabs"> <div class="_spacer" style="--MI_SPACER-w: 800px;"> - <div ref="rootEl"> - <div v-if="queue > 0" :class="$style.new"><button class="_buttonPrimary" :class="$style.newButton" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div> - <div :class="$style.tl"> - <MkTimeline - ref="tlEl" :key="listId" - src="list" - :list="listId" - :sound="true" - @queue="queueUpdated" - /> - </div> + <div :class="$style.tl"> + <MkStreamingNotesTimeline + ref="tlEl" :key="listId" + src="list" + :list="listId" + :sound="true" + /> </div> </div> </PageWithHeader> @@ -25,8 +21,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, watch, ref, useTemplateRef } from 'vue'; import * as Misskey from 'misskey-js'; -import { scrollInContainer } from '@@/js/scroll.js'; -import MkTimeline from '@/components/MkTimeline.vue'; +import MkStreamingNotesTimeline from '@/components/MkStreamingNotesTimeline.vue'; import { misskeyApi } from '@/utility/misskey-api.js'; import { definePage } from '@/page.js'; import { i18n } from '@/i18n.js'; @@ -39,9 +34,6 @@ const props = defineProps<{ }>(); const list = ref<Misskey.entities.UserList | null>(null); -const queue = ref(0); -const tlEl = useTemplateRef('tlEl'); -const rootEl = useTemplateRef('rootEl'); watch(() => props.listId, async () => { list.value = await misskeyApi('users/lists/show', { @@ -49,14 +41,6 @@ watch(() => props.listId, async () => { }); }, { immediate: true }); -function queueUpdated(q) { - queue.value = q; -} - -function top() { - scrollInContainer(rootEl.value, { top: 0 }); -} - function settings() { router.push(`/my/lists/${props.listId}`); } @@ -76,25 +60,6 @@ definePage(() => ({ </script> <style lang="scss" module> -.new { - position: sticky; - top: calc(var(--MI-stickyTop, 0px) + 16px); - z-index: 1000; - width: 100%; - margin: calc(-0.675em - 8px) 0; - - &:first-child { - margin-top: calc(-0.675em - 8px - var(--MI-margin)); - } -} - -.newButton { - display: block; - margin: var(--MI-margin) auto 0 auto; - padding: 8px 16px; - border-radius: 32px; -} - .tl { background: var(--MI_THEME-bg); border-radius: var(--MI-radius); diff --git a/packages/frontend/src/pages/user/activity.following.vue b/packages/frontend/src/pages/user/activity.following.vue index f5d2002669..f2a5ad8e75 100644 --- a/packages/frontend/src/pages/user/activity.following.vue +++ b/packages/frontend/src/pages/user/activity.following.vue @@ -21,7 +21,7 @@ import gradient from 'chartjs-plugin-gradient'; import type { ChartDataset } from 'chart.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { store } from '@/store.js'; -import { useChartTooltip } from '@/use/use-chart-tooltip.js'; +import { useChartTooltip } from '@/composables/use-chart-tooltip.js'; import { chartVLine } from '@/utility/chart-vline.js'; import { initChart } from '@/utility/init-chart.js'; import { chartLegend } from '@/utility/chart-legend.js'; diff --git a/packages/frontend/src/pages/user/activity.notes.vue b/packages/frontend/src/pages/user/activity.notes.vue index 01c62810d4..ddde84ef25 100644 --- a/packages/frontend/src/pages/user/activity.notes.vue +++ b/packages/frontend/src/pages/user/activity.notes.vue @@ -21,7 +21,7 @@ import gradient from 'chartjs-plugin-gradient'; import type { ChartDataset } from 'chart.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { store } from '@/store.js'; -import { useChartTooltip } from '@/use/use-chart-tooltip.js'; +import { useChartTooltip } from '@/composables/use-chart-tooltip.js'; import { chartVLine } from '@/utility/chart-vline.js'; import { initChart } from '@/utility/init-chart.js'; import { chartLegend } from '@/utility/chart-legend.js'; diff --git a/packages/frontend/src/pages/user/activity.pv.vue b/packages/frontend/src/pages/user/activity.pv.vue index ed12b1b5c7..34e1fe3abf 100644 --- a/packages/frontend/src/pages/user/activity.pv.vue +++ b/packages/frontend/src/pages/user/activity.pv.vue @@ -21,7 +21,7 @@ import gradient from 'chartjs-plugin-gradient'; import type { ChartDataset } from 'chart.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { store } from '@/store.js'; -import { useChartTooltip } from '@/use/use-chart-tooltip.js'; +import { useChartTooltip } from '@/composables/use-chart-tooltip.js'; import { chartVLine } from '@/utility/chart-vline.js'; import { initChart } from '@/utility/init-chart.js'; import { chartLegend } from '@/utility/chart-legend.js'; diff --git a/packages/frontend/src/pages/user/files.vue b/packages/frontend/src/pages/user/files.vue index 91ebcad0b2..51ae809aac 100644 --- a/packages/frontend/src/pages/user/files.vue +++ b/packages/frontend/src/pages/user/files.vue @@ -4,15 +4,15 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> - <div class="_spacer" style="--MI_SPACER-w: 1100px;"> - <div :class="$style.root"> - <MkPagination v-slot="{items}" :pagination="pagination"> - <div :class="$style.stream"> - <MkNoteMediaGrid v-for="note in items" :note="note" square/> - </div> - </MkPagination> - </div> +<div class="_spacer" style="--MI_SPACER-w: 1100px;"> + <div :class="$style.root"> + <MkPagination v-slot="{items}" :pagination="pagination"> + <div :class="$style.stream"> + <MkNoteMediaGrid v-for="note in items" :note="note" square/> + </div> + </MkPagination> </div> +</div> </template> <script lang="ts" setup> diff --git a/packages/frontend/src/pages/user/home.vue b/packages/frontend/src/pages/user/home.vue index 50bb1de24f..ea77444afd 100644 --- a/packages/frontend/src/pages/user/home.vue +++ b/packages/frontend/src/pages/user/home.vue @@ -4,158 +4,160 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div class="_spacer" :style="{ '--MI_SPACER-w': narrow ? '800px' : '1100px' }"> - <div ref="rootEl" class="ftskorzw" :class="{ wide: !narrow }" style="container-type: inline-size;"> - <div class="main _gaps"> - <!-- TODO --> - <!-- <div class="punished" v-if="user.isSuspended"><i class="ti ti-alert-triangle" style="margin-right: 8px;"></i> {{ i18n.ts.userSuspended }}</div> --> - <!-- <div class="punished" v-if="user.isSilenced"><i class="ti ti-alert-triangle" style="margin-right: 8px;"></i> {{ i18n.ts.userSilenced }}</div> --> +<component :is="prefer.s.enablePullToRefresh ? MkPullToRefresh : 'div'" :refresher="() => reload()"> + <div class="_spacer" :style="{ '--MI_SPACER-w': narrow ? '800px' : '1100px' }"> + <div ref="rootEl" class="ftskorzw" :class="{ wide: !narrow }" style="container-type: inline-size;"> + <div class="main _gaps"> + <!-- TODO --> + <!-- <div class="punished" v-if="user.isSuspended"><i class="ti ti-alert-triangle" style="margin-right: 8px;"></i> {{ i18n.ts.userSuspended }}</div> --> + <!-- <div class="punished" v-if="user.isSilenced"><i class="ti ti-alert-triangle" style="margin-right: 8px;"></i> {{ i18n.ts.userSilenced }}</div> --> - <div class="profile _gaps"> - <MkAccountMoved v-if="user.movedTo" :movedTo="user.movedTo"/> - <MkRemoteCaution v-if="user.host != null" :href="user.url ?? user.uri!"/> - <MkInfo v-if="user.host == null && user.username.includes('.')">{{ i18n.ts.isSystemAccount }}</MkInfo> + <div class="profile _gaps"> + <MkAccountMoved v-if="user.movedTo" :movedTo="user.movedTo"/> + <MkRemoteCaution v-if="user.host != null" :href="user.url ?? user.uri!"/> + <MkInfo v-if="user.host == null && user.username.includes('.')">{{ i18n.ts.isSystemAccount }}</MkInfo> - <div :key="user.id" class="main _panel"> - <div class="banner-container" :style="style"> - <div ref="bannerEl" class="banner" :style="style"></div> - <div class="fade"></div> + <div :key="user.id" class="main _panel"> + <div class="banner-container" :style="style"> + <div ref="bannerEl" class="banner" :style="style"></div> + <div class="fade"></div> + <div class="title"> + <MkUserName class="name" :user="user" :nowrap="true"/> + <div class="bottom"> + <span class="username"><MkAcct :user="user" :detail="true"/></span> + <span v-if="user.isAdmin" :title="i18n.ts.isAdmin" style="color: var(--MI_THEME-badge);"><i class="ti ti-shield"></i></span> + <span v-if="user.isLocked" :title="i18n.ts.isLocked"><i class="ti ti-lock"></i></span> + <span v-if="user.isBot" :title="i18n.ts.isBot"><i class="ti ti-robot"></i></span> + <button v-if="$i && !isEditingMemo && !memoDraft" class="_button add-note-button" @click="showMemoTextarea"> + <i class="ti ti-edit"/> {{ i18n.ts.addMemo }} + </button> + </div> + </div> + <span v-if="$i && $i.id != user.id && user.isFollowed" class="followed">{{ i18n.ts.followsYou }}</span> + <div class="actions"> + <button class="menu _button" @click="menu"><i class="ti ti-dots"></i></button> + <MkFollowButton v-if="$i?.id != user.id" v-model:user="user" :inline="true" :transparent="false" :full="true" class="koudoku"/> + </div> + </div> + <MkAvatar class="avatar" :user="user" indicator/> <div class="title"> - <MkUserName class="name" :user="user" :nowrap="true"/> + <MkUserName :user="user" :nowrap="false" class="name"/> <div class="bottom"> <span class="username"><MkAcct :user="user" :detail="true"/></span> <span v-if="user.isAdmin" :title="i18n.ts.isAdmin" style="color: var(--MI_THEME-badge);"><i class="ti ti-shield"></i></span> <span v-if="user.isLocked" :title="i18n.ts.isLocked"><i class="ti ti-lock"></i></span> <span v-if="user.isBot" :title="i18n.ts.isBot"><i class="ti ti-robot"></i></span> - <button v-if="$i && !isEditingMemo && !memoDraft" class="_button add-note-button" @click="showMemoTextarea"> - <i class="ti ti-edit"/> {{ i18n.ts.addMemo }} - </button> </div> </div> - <span v-if="$i && $i.id != user.id && user.isFollowed" class="followed">{{ i18n.ts.followsYou }}</span> - <div class="actions"> - <button class="menu _button" @click="menu"><i class="ti ti-dots"></i></button> - <MkFollowButton v-if="$i?.id != user.id" v-model:user="user" :inline="true" :transparent="false" :full="true" class="koudoku"/> + <div v-if="user.followedMessage != null" class="followedMessage"> + <MkFukidashi class="fukidashi" :tail="narrow ? 'none' : 'left'" negativeMargin> + <div class="messageHeader">{{ i18n.ts.messageToFollower }}</div> + <div><MkSparkle><Mfm :plain="true" :text="user.followedMessage" :author="user" class="_selectable"/></MkSparkle></div> + </MkFukidashi> </div> - </div> - <MkAvatar class="avatar" :user="user" indicator/> - <div class="title"> - <MkUserName :user="user" :nowrap="false" class="name"/> - <div class="bottom"> - <span class="username"><MkAcct :user="user" :detail="true"/></span> - <span v-if="user.isAdmin" :title="i18n.ts.isAdmin" style="color: var(--MI_THEME-badge);"><i class="ti ti-shield"></i></span> - <span v-if="user.isLocked" :title="i18n.ts.isLocked"><i class="ti ti-lock"></i></span> - <span v-if="user.isBot" :title="i18n.ts.isBot"><i class="ti ti-robot"></i></span> + <div v-if="user.roles.length > 0" class="roles"> + <span v-for="role in user.roles" :key="role.id" v-tooltip="role.description" class="role" :style="{ '--color': role.color }"> + <MkA v-adaptive-bg :to="`/roles/${role.id}`"> + <img v-if="role.iconUrl" style="height: 1.3em; vertical-align: -22%;" :src="role.iconUrl"/> + {{ role.name }} + </MkA> + </span> </div> - </div> - <div v-if="user.followedMessage != null" class="followedMessage"> - <MkFukidashi class="fukidashi" :tail="narrow ? 'none' : 'left'" negativeMargin> - <div class="messageHeader">{{ i18n.ts.messageToFollower }}</div> - <div><MkSparkle><Mfm :plain="true" :text="user.followedMessage" :author="user" class="_selectable"/></MkSparkle></div> - </MkFukidashi> - </div> - <div v-if="user.roles.length > 0" class="roles"> - <span v-for="role in user.roles" :key="role.id" v-tooltip="role.description" class="role" :style="{ '--color': role.color }"> - <MkA v-adaptive-bg :to="`/roles/${role.id}`"> - <img v-if="role.iconUrl" style="height: 1.3em; vertical-align: -22%;" :src="role.iconUrl"/> - {{ role.name }} + <div v-if="iAmModerator" class="moderationNote"> + <MkTextarea v-if="editModerationNote || (moderationNote != null && moderationNote !== '')" v-model="moderationNote" manualSave> + <template #label>{{ i18n.ts.moderationNote }}</template> + <template #caption>{{ i18n.ts.moderationNoteDescription }}</template> + </MkTextarea> + <div v-else> + <MkButton small @click="editModerationNote = true">{{ i18n.ts.addModerationNote }}</MkButton> + </div> + </div> + <div v-if="isEditingMemo || memoDraft" class="memo" :class="{'no-memo': !memoDraft}"> + <div class="heading" v-text="i18n.ts.memo"/> + <textarea + ref="memoTextareaEl" + v-model="memoDraft" + rows="1" + @focus="isEditingMemo = true" + @blur="updateMemo" + @input="adjustMemoTextarea" + /> + </div> + <div class="description"> + <MkOmit> + <Mfm v-if="user.description" :text="user.description" :isNote="false" :author="user" class="_selectable"/> + <p v-else class="empty">{{ i18n.ts.noAccountDescription }}</p> + </MkOmit> + </div> + <div class="fields system"> + <dl v-if="user.location" class="field"> + <dt class="name"><i class="ti ti-map-pin ti-fw"></i> {{ i18n.ts.location }}</dt> + <dd class="value">{{ user.location }}</dd> + </dl> + <dl v-if="user.birthday" class="field"> + <dt class="name"><i class="ti ti-cake ti-fw"></i> {{ i18n.ts.birthday }}</dt> + <dd class="value">{{ user.birthday.replace('-', '/').replace('-', '/') }} ({{ i18n.tsx.yearsOld({ age }) }})</dd> + </dl> + <dl class="field"> + <dt class="name"><i class="ti ti-calendar ti-fw"></i> {{ i18n.ts.registeredDate }}</dt> + <dd class="value">{{ dateString(user.createdAt) }} (<MkTime :time="user.createdAt"/>)</dd> + </dl> + </div> + <div v-if="user.fields.length > 0" class="fields"> + <dl v-for="(field, i) in user.fields" :key="i" class="field"> + <dt class="name"> + <Mfm :text="field.name" :author="user" :plain="true" :colored="false" class="_selectable"/> + </dt> + <dd class="value"> + <Mfm :text="field.value" :author="user" :colored="false" class="_selectable"/> + <i v-if="user.verifiedLinks.includes(field.value)" v-tooltip:dialog="i18n.ts.verifiedLink" class="ti ti-circle-check" :class="$style.verifiedLink"></i> + </dd> + </dl> + </div> + <div class="status"> + <MkA :to="userPage(user)"> + <b>{{ number(user.notesCount) }}</b> + <span>{{ i18n.ts.notes }}</span> + </MkA> + <MkA v-if="isFollowingVisibleForMe(user)" :to="userPage(user, 'following')"> + <b>{{ number(user.followingCount) }}</b> + <span>{{ i18n.ts.following }}</span> + </MkA> + <MkA v-if="isFollowersVisibleForMe(user)" :to="userPage(user, 'followers')"> + <b>{{ number(user.followersCount) }}</b> + <span>{{ i18n.ts.followers }}</span> </MkA> - </span> - </div> - <div v-if="iAmModerator" class="moderationNote"> - <MkTextarea v-if="editModerationNote || (moderationNote != null && moderationNote !== '')" v-model="moderationNote" manualSave> - <template #label>{{ i18n.ts.moderationNote }}</template> - <template #caption>{{ i18n.ts.moderationNoteDescription }}</template> - </MkTextarea> - <div v-else> - <MkButton small @click="editModerationNote = true">{{ i18n.ts.addModerationNote }}</MkButton> </div> </div> - <div v-if="isEditingMemo || memoDraft" class="memo" :class="{'no-memo': !memoDraft}"> - <div class="heading" v-text="i18n.ts.memo"/> - <textarea - ref="memoTextareaEl" - v-model="memoDraft" - rows="1" - @focus="isEditingMemo = true" - @blur="updateMemo" - @input="adjustMemoTextarea" - /> - </div> - <div class="description"> - <MkOmit> - <Mfm v-if="user.description" :text="user.description" :isNote="false" :author="user" class="_selectable"/> - <p v-else class="empty">{{ i18n.ts.noAccountDescription }}</p> - </MkOmit> - </div> - <div class="fields system"> - <dl v-if="user.location" class="field"> - <dt class="name"><i class="ti ti-map-pin ti-fw"></i> {{ i18n.ts.location }}</dt> - <dd class="value">{{ user.location }}</dd> - </dl> - <dl v-if="user.birthday" class="field"> - <dt class="name"><i class="ti ti-cake ti-fw"></i> {{ i18n.ts.birthday }}</dt> - <dd class="value">{{ user.birthday.replace('-', '/').replace('-', '/') }} ({{ i18n.tsx.yearsOld({ age }) }})</dd> - </dl> - <dl class="field"> - <dt class="name"><i class="ti ti-calendar ti-fw"></i> {{ i18n.ts.registeredDate }}</dt> - <dd class="value">{{ dateString(user.createdAt) }} (<MkTime :time="user.createdAt"/>)</dd> - </dl> - </div> - <div v-if="user.fields.length > 0" class="fields"> - <dl v-for="(field, i) in user.fields" :key="i" class="field"> - <dt class="name"> - <Mfm :text="field.name" :author="user" :plain="true" :colored="false" class="_selectable"/> - </dt> - <dd class="value"> - <Mfm :text="field.value" :author="user" :colored="false" class="_selectable"/> - <i v-if="user.verifiedLinks.includes(field.value)" v-tooltip:dialog="i18n.ts.verifiedLink" class="ti ti-circle-check" :class="$style.verifiedLink"></i> - </dd> - </dl> + </div> + + <div class="contents _gaps"> + <div v-if="user.pinnedNotes.length > 0" class="_gaps"> + <MkNote v-for="note in user.pinnedNotes" :key="note.id" class="note _panel" :note="note" :pinned="true"/> </div> - <div class="status"> - <MkA :to="userPage(user)"> - <b>{{ number(user.notesCount) }}</b> - <span>{{ i18n.ts.notes }}</span> - </MkA> - <MkA v-if="isFollowingVisibleForMe(user)" :to="userPage(user, 'following')"> - <b>{{ number(user.followingCount) }}</b> - <span>{{ i18n.ts.following }}</span> - </MkA> - <MkA v-if="isFollowersVisibleForMe(user)" :to="userPage(user, 'followers')"> - <b>{{ number(user.followersCount) }}</b> - <span>{{ i18n.ts.followers }}</span> - </MkA> + <MkInfo v-else-if="$i && $i.id === user.id">{{ i18n.ts.userPagePinTip }}</MkInfo> + <template v-if="narrow"> + <MkLazy> + <XFiles :key="user.id" :user="user" @showMore="emit('showMoreFiles')"/> + </MkLazy> + <MkLazy> + <XActivity :key="user.id" :user="user"/> + </MkLazy> + </template> + <div v-if="!disableNotes"> + <MkLazy> + <XTimeline :user="user"/> + </MkLazy> </div> </div> </div> - - <div class="contents _gaps"> - <div v-if="user.pinnedNotes.length > 0" class="_gaps"> - <MkNote v-for="note in user.pinnedNotes" :key="note.id" class="note _panel" :note="note" :pinned="true"/> - </div> - <MkInfo v-else-if="$i && $i.id === user.id">{{ i18n.ts.userPagePinTip }}</MkInfo> - <template v-if="narrow"> - <MkLazy> - <XFiles :key="user.id" :user="user" @unfold="emit('unfoldFiles')"/> - </MkLazy> - <MkLazy> - <XActivity :key="user.id" :user="user"/> - </MkLazy> - </template> - <div v-if="!disableNotes"> - <MkLazy> - <XTimeline :user="user"/> - </MkLazy> - </div> + <div v-if="!narrow" class="sub _gaps" style="container-type: inline-size;"> + <XFiles :key="user.id" :user="user" @showMore="emit('showMoreFiles')"/> + <XActivity :key="user.id" :user="user"/> </div> </div> - <div v-if="!narrow" class="sub _gaps" style="container-type: inline-size;"> - <XFiles :key="user.id" :user="user" @unfold="emit('unfoldFiles')"/> - <XActivity :key="user.id" :user="user"/> - </div> </div> -</div> +</component> </template> <script lang="ts" setup> @@ -185,6 +187,7 @@ import { useRouter } from '@/router.js'; import { getStaticImageUrl } from '@/utility/media-proxy.js'; import MkSparkle from '@/components/MkSparkle.vue'; import { prefer } from '@/preferences.js'; +import MkPullToRefresh from '@/components/MkPullToRefresh.vue'; function calcAge(birthdate: string): number { const date = new Date(birthdate); @@ -207,14 +210,14 @@ const XTimeline = defineAsyncComponent(() => import('./index.timeline.vue')); const props = withDefaults(defineProps<{ user: Misskey.entities.UserDetailed; - /** Test only; MkNotes currently causes problems in vitest */ + /** Test only; MkNotesTimeline currently causes problems in vitest */ disableNotes: boolean; }>(), { disableNotes: false, }); const emit = defineEmits<{ - (ev: 'unfoldFiles'): void; + (ev: 'showMoreFiles'): void; }>(); const router = useRouter(); @@ -299,6 +302,10 @@ watch([props.user], () => { memoDraft.value = props.user.memo; }); +async function reload() { + // TODO +} + onMounted(() => { window.requestAnimationFrame(parallaxLoop); narrow.value = rootEl.value!.clientWidth < 1000; diff --git a/packages/frontend/src/pages/user/index.files.vue b/packages/frontend/src/pages/user/index.files.vue index 6c3b8408fb..58f6b0ca45 100644 --- a/packages/frontend/src/pages/user/index.files.vue +++ b/packages/frontend/src/pages/user/index.files.vue @@ -4,13 +4,16 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkContainer :max-height="300" :foldable="true" :onUnfold="unfoldContainer"> +<MkContainer> <template #icon><i class="ti ti-photo"></i></template> <template #header>{{ i18n.ts.files }}</template> <div :class="$style.root"> <MkLoading v-if="fetching"/> - <div v-if="!fetching && notes.length > 0" :class="$style.stream"> - <MkNoteMediaGrid v-for="note in notes" :note="note"/> + <div v-if="!fetching && notes.length > 0" class="_gaps_s"> + <div :class="$style.stream"> + <MkNoteMediaGrid v-for="note in notes" :note="note"/> + </div> + <MkButton rounded full @click="emit('showMore')">{{ i18n.ts.showMore }} <i class="ti ti-arrow-right"></i></MkButton> </div> <p v-if="!fetching && notes.length == 0" :class="$style.empty">{{ i18n.ts.nothing }}</p> </div> @@ -21,6 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { onMounted, ref } from 'vue'; import * as Misskey from 'misskey-js'; import { misskeyApi } from '@/utility/misskey-api.js'; +import MkButton from '@/components/MkButton.vue'; import MkContainer from '@/components/MkContainer.vue'; import { i18n } from '@/i18n.js'; import MkNoteMediaGrid from '@/components/MkNoteMediaGrid.vue'; @@ -30,17 +34,12 @@ const props = defineProps<{ }>(); const emit = defineEmits<{ - (ev: 'unfold'): void; + (ev: 'showMore'): void; }>(); const fetching = ref(true); const notes = ref<Misskey.entities.Note[]>([]); -function unfoldContainer(): boolean { - emit('unfold'); - return false; -} - onMounted(() => { misskeyApi('users/notes', { userId: props.user.id, @@ -62,39 +61,9 @@ onMounted(() => { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); grid-gap: 6px; -} - -.img { - position: relative; - height: 128px; - border-radius: 6px; - overflow: clip; -} -.empty { - margin: 0; - padding: 16px; - text-align: center; -} - -.sensitiveImg { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - filter: brightness(0.7); -} -.sensitive { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - display: grid; - place-items: center; - font-size: 0.8em; - color: #fff; - cursor: pointer; + >:nth-child(n+9) { + display: none; + } } </style> diff --git a/packages/frontend/src/pages/user/index.timeline.vue b/packages/frontend/src/pages/user/index.timeline.vue index 49d015a530..d8eca07a42 100644 --- a/packages/frontend/src/pages/user/index.timeline.vue +++ b/packages/frontend/src/pages/user/index.timeline.vue @@ -8,19 +8,19 @@ SPDX-License-Identifier: AGPL-3.0-only <template #header> <MkTab v-model="tab" :class="$style.tab"> <option value="featured">{{ i18n.ts.featured }}</option> - <option :value="null">{{ i18n.ts.notes }}</option> + <option value="notes">{{ i18n.ts.notes }}</option> <option value="all">{{ i18n.ts.all }}</option> <option value="files">{{ i18n.ts.withFiles }}</option> </MkTab> </template> - <MkNotes :noGap="true" :pagination="pagination" :class="$style.tl"/> + <MkNotesTimeline :key="tab" :noGap="true" :pagination="pagination" :pullToRefresh="false" :class="$style.tl"/> </MkStickyContainer> </template> <script lang="ts" setup> import { ref, computed } from 'vue'; import * as Misskey from 'misskey-js'; -import MkNotes from '@/components/MkNotes.vue'; +import MkNotesTimeline from '@/components/MkNotesTimeline.vue'; import MkTab from '@/components/MkTab.vue'; import { i18n } from '@/i18n.js'; @@ -28,7 +28,7 @@ const props = defineProps<{ user: Misskey.entities.UserDetailed; }>(); -const tab = ref<string | null>('all'); +const tab = ref<string>('all'); const pagination = computed(() => tab.value === 'featured' ? { endpoint: 'users/featured-notes' as const, diff --git a/packages/frontend/src/pages/user/index.vue b/packages/frontend/src/pages/user/index.vue index d6e477d0ae..11e26b26f9 100644 --- a/packages/frontend/src/pages/user/index.vue +++ b/packages/frontend/src/pages/user/index.vue @@ -6,10 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <PageWithHeader v-model:tab="tab" :tabs="headerTabs" :actions="headerActions" :swipable="true"> <div v-if="user"> - <XHome v-if="tab === 'home'" :user="user" @unfoldFiles="() => { tab = 'files'; }"/> - <div v-else-if="tab === 'notes'" class="_spacer" style="--MI_SPACER-w: 800px;"> - <XTimeline :user="user"/> - </div> + <XHome v-if="tab === 'home'" :user="user" @showMoreFiles="() => { tab = 'files'; }"/> + <XNotes v-else-if="tab === 'notes'" :user="user"/> <XFiles v-else-if="tab === 'files'" :user="user"/> <XActivity v-else-if="tab === 'activity'" :user="user"/> <XAchievements v-else-if="tab === 'achievements'" :user="user"/> @@ -37,7 +35,7 @@ import { $i } from '@/i.js'; import { serverContext, assertServerContext } from '@/server-context.js'; const XHome = defineAsyncComponent(() => import('./home.vue')); -const XTimeline = defineAsyncComponent(() => import('./index.timeline.vue')); +const XNotes = defineAsyncComponent(() => import('./notes.vue')); const XFiles = defineAsyncComponent(() => import('./files.vue')); const XActivity = defineAsyncComponent(() => import('./activity.vue')); const XAchievements = defineAsyncComponent(() => import('./achievements.vue')); diff --git a/packages/frontend/src/pages/user/notes.vue b/packages/frontend/src/pages/user/notes.vue new file mode 100644 index 0000000000..c97177b6a5 --- /dev/null +++ b/packages/frontend/src/pages/user/notes.vue @@ -0,0 +1,67 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div class="_spacer" style="--MI_SPACER-w: 800px;"> + <div :class="$style.root"> + <MkStickyContainer> + <template #header> + <MkTab v-model="tab" :class="$style.tab"> + <option value="featured">{{ i18n.ts.featured }}</option> + <option value="notes">{{ i18n.ts.notes }}</option> + <option value="all">{{ i18n.ts.all }}</option> + <option value="files">{{ i18n.ts.withFiles }}</option> + </MkTab> + </template> + <MkNotesTimeline :key="tab" :noGap="true" :pagination="pagination" :class="$style.tl"/> + </MkStickyContainer> + </div> +</div> +</template> + +<script lang="ts" setup> +import { ref, computed } from 'vue'; +import * as Misskey from 'misskey-js'; +import MkNotesTimeline from '@/components/MkNotesTimeline.vue'; +import MkTab from '@/components/MkTab.vue'; +import { i18n } from '@/i18n.js'; + +const props = defineProps<{ + user: Misskey.entities.UserDetailed; +}>(); + +const tab = ref<string>('all'); + +const pagination = computed(() => tab.value === 'featured' ? { + endpoint: 'users/featured-notes' as const, + limit: 10, + params: { + userId: props.user.id, + }, +} : { + endpoint: 'users/notes' as const, + limit: 10, + params: { + userId: props.user.id, + withRenotes: tab.value === 'all', + withReplies: tab.value === 'all', + withChannelNotes: tab.value === 'all', + withFiles: tab.value === 'files', + }, +}); +</script> + +<style lang="scss" module> +.tab { + padding: calc(var(--MI-margin) / 2) 0; + background: var(--MI_THEME-bg); +} + +.tl { + background: var(--MI_THEME-bg); + border-radius: var(--MI-radius); + overflow: clip; +} +</style> diff --git a/packages/frontend/src/pages/welcome.entrance.a.vue b/packages/frontend/src/pages/welcome.entrance.a.vue index d131c17340..c2cf937c71 100644 --- a/packages/frontend/src/pages/welcome.entrance.a.vue +++ b/packages/frontend/src/pages/welcome.entrance.a.vue @@ -17,13 +17,13 @@ SPDX-License-Identifier: AGPL-3.0-only <MkVisitorDashboard/> </div> <div v-if="instances && instances.length > 0" :class="$style.federation"> - <MarqueeText :duration="40"> + <MkMarqueeText :duration="40"> <MkA v-for="instance in instances" :key="instance.id" :class="$style.federationInstance" :to="`/instance-info/${instance.host}`" behavior="window"> <!--<MkInstanceCardMini :instance="instance"/>--> <img v-if="instance.iconUrl" :class="$style.federationInstanceIcon" :src="getInstanceIcon(instance)" alt=""/> <span class="_monospace">{{ instance.host }}</span> </MkA> - </MarqueeText> + </MkMarqueeText> </div> </div> </template> @@ -32,7 +32,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { ref } from 'vue'; import * as Misskey from 'misskey-js'; import XTimeline from './welcome.timeline.vue'; -import MarqueeText from '@/components/MkMarquee.vue'; +import MkMarqueeText from '@/components/MkMarqueeText.vue'; import MkFeaturedPhotos from '@/components/MkFeaturedPhotos.vue'; import misskeysvg from '/client-assets/misskey.svg'; import { misskeyApiGet } from '@/utility/misskey-api.js'; diff --git a/packages/frontend/src/pages/welcome.setup.vue b/packages/frontend/src/pages/welcome.setup.vue index 69a654595a..675e82a71d 100644 --- a/packages/frontend/src/pages/welcome.setup.vue +++ b/packages/frontend/src/pages/welcome.setup.vue @@ -6,39 +6,126 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <PageWithAnimBg> <div :class="$style.formContainer"> - <form :class="$style.form" class="_panel" @submit.prevent="submit()"> + <div :class="$style.form" class="_panel"> + <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="z-index:1;position:relative" viewBox="0 0 854 300"> + <defs> + <linearGradient id="linear" x1="0%" y1="0%" x2="100%" y2="0%"> + <stop offset="0%" stop-color="#86b300"/><stop offset="100%" stop-color="#4ab300"/> + </linearGradient> + </defs> + + <g transform="translate(427, 150) scale(1, 1) translate(-427, -150)"> + <path d="" fill="url(#linear)" opacity="0.4"> + <animate + attributeName="d" + dur="20s" + repeatCount="indefinite" + keyTimes="0;0.333;0.667;1" + calcmod="spline" + keySplines="0.2 0 0.2 1;0.2 0 0.2 1;0.2 0 0.2 1" + begin="0s" + values="M0 0L 0 220Q 213.5 260 427 230T 854 255L 854 0 Z;M0 0L 0 245Q 213.5 260 427 240T 854 230L 854 0 Z;M0 0L 0 265Q 213.5 235 427 265T 854 230L 854 0 Z;M0 0L 0 220Q 213.5 260 427 230T 854 255L 854 0 Z" + > + </animate> + </path> + <path d="" fill="url(#linear)" opacity="0.4"> + <animate + attributeName="d" + dur="20s" + repeatCount="indefinite" + keyTimes="0;0.333;0.667;1" + calcmod="spline" + keySplines="0.2 0 0.2 1;0.2 0 0.2 1;0.2 0 0.2 1" + begin="-10s" + values="M0 0L 0 235Q 213.5 280 427 250T 854 260L 854 0 Z;M0 0L 0 250Q 213.5 220 427 220T 854 240L 854 0 Z;M0 0L 0 245Q 213.5 225 427 250T 854 265L 854 0 Z;M0 0L 0 235Q 213.5 280 427 250T 854 260L 854 0 Z" + > + </animate> + </path> + </g> + </svg> <div :class="$style.title"> <div>Welcome to Misskey!</div> <div :class="$style.version">v{{ version }}</div> </div> - <div class="_gaps_m" style="padding: 32px;"> - <div>{{ i18n.ts.intro }}</div> - <MkInput v-model="setupPassword" type="password" data-cy-admin-initial-password> - <template #label>{{ i18n.ts.initialPasswordForSetup }} <div v-tooltip:dialog="i18n.ts.initialPasswordForSetupDescription" class="_button _help"><i class="ti ti-help-circle"></i></div></template> - <template #prefix><i class="ti ti-lock"></i></template> - </MkInput> - <MkInput v-model="username" pattern="^[a-zA-Z0-9_]{1,20}$" :spellcheck="false" required data-cy-admin-username> - <template #label>{{ i18n.ts.username }}</template> - <template #prefix>@</template> - <template #suffix>@{{ host }}</template> - </MkInput> - <MkInput v-model="password" type="password" data-cy-admin-password> - <template #label>{{ i18n.ts.password }}</template> - <template #prefix><i class="ti ti-lock"></i></template> - </MkInput> - <div> - <MkButton gradate large rounded type="submit" :disabled="submitting" data-cy-admin-ok style="margin: 0 auto;"> - {{ submitting ? i18n.ts.processing : i18n.ts.done }}<MkEllipsis v-if="submitting"/> + <div style="padding: 16px 32px 32px 32px;"> + <form v-if="!accountCreated" class="_gaps_m" @submit.prevent="createAccount()"> + <div style="text-align: center;" class="_gaps_s"> + <div><b>{{ i18n.ts._serverSetupWizard.installCompleted }}</b></div> + <div>{{ i18n.ts._serverSetupWizard.firstCreateAccount }}</div> + </div> + <MkInput v-model="setupPassword" type="password" data-cy-admin-initial-password> + <template #label>{{ i18n.ts.initialPasswordForSetup }} <div v-tooltip:dialog="i18n.ts.initialPasswordForSetupDescription" class="_button _help"><i class="ti ti-help-circle"></i></div></template> + <template #prefix><i class="ti ti-lock"></i></template> + </MkInput> + <MkInput v-model="username" pattern="^[a-zA-Z0-9_]{1,20}$" :spellcheck="false" required data-cy-admin-username> + <template #label>{{ i18n.ts.username }} <div v-tooltip:dialog="i18n.ts.usernameInfo" class="_button _help"><i class="ti ti-help-circle"></i></div></template> + <template #prefix>@</template> + <template #suffix>@{{ host }}</template> + </MkInput> + <MkInput v-model="password" type="password" data-cy-admin-password> + <template #label>{{ i18n.ts.password }}</template> + <template #prefix><i class="ti ti-lock"></i></template> + </MkInput> + <div> + <MkButton gradate large rounded :disabled="accountCreating" data-cy-admin-ok style="margin: 0 auto;" type="submit"> + {{ accountCreating ? i18n.ts.processing : i18n.ts.next }}<MkEllipsis v-if="accountCreating"/> + </MkButton> + </div> + </form> + <div v-else-if="step === 0" class="_gaps_m"> + <div style="text-align: center;" class="_gaps_s"> + <div><b>{{ i18n.ts._serverSetupWizard.accountCreated }}</b></div> + </div> + <MkButton gradate large rounded data-cy-next style="margin: 0 auto;" @click="step++"> + {{ i18n.ts.next }} + </MkButton> + </div> + <div v-else-if="step === 1" class="_gaps_m"> + <div style="text-align: center;" class="_gaps_s"> + <div><b>{{ i18n.ts._serverSetupWizard.donationRequest }}</b></div> + <div>{{ i18n.ts._serverSetupWizard._donationRequest.text1 }}<br>{{ i18n.ts._serverSetupWizard._donationRequest.text2 }}<br>{{ i18n.ts._serverSetupWizard._donationRequest.text3 }}</div> + </div> + <MkLink target="_blank" url="https://misskey-hub.net/docs/donate/" style="margin: 0 auto;">{{ i18n.ts.learnMore }}</MkLink> + <div class="_buttonsCenter"> + <MkButton gradate large rounded data-cy-next style="margin: 0 auto;" @click="step++"> + {{ i18n.ts.next }} + </MkButton> + </div> + </div> + <div v-else-if="step === 2" class="_gaps_m"> + <div style="text-align: center;" class="_gaps_s"> + <div style="font-size: 120%;"><b>{{ i18n.ts._serverSetupWizard.serverSetting }}</b></div> + <div>{{ i18n.ts._serverSetupWizard.youCanEasilyConfigureOptimalServerSettingsWithThisWizard }}</div> + <div>{{ i18n.ts._serverSetupWizard.settingsYouMakeHereCanBeChangedLater }}</div> + </div> + + <MkServerSetupWizard :token="token" @finished="onWizardFinished"/> + + <MkButton rounded style="margin: 0 auto;" @click="skipSettings"> + {{ i18n.ts._serverSetupWizard.skipSettings }} </MkButton> </div> + <div v-else-if="step === 3" class="_gaps_m"> + <div style="text-align: center;" class="_gaps_s"> + <div><b>{{ i18n.ts._serverSetupWizard.settingsCompleted }}</b></div> + <div>{{ i18n.ts._serverSetupWizard.settingsCompleted_description }}</div> + <div>{{ i18n.ts._serverSetupWizard.settingsCompleted_description2 }}</div> + </div> + <div class="_buttonsCenter"> + <MkButton gradate large rounded data-cy-next style="margin: 0 auto;" @click="finish"> + {{ i18n.ts.start }} + </MkButton> + </div> + </div> </div> - </form> + </div> </div> </PageWithAnimBg> </template> <script lang="ts" setup> -import { ref } from 'vue'; +import { computed, ref } from 'vue'; +import * as Misskey from 'misskey-js'; import { host, version } from '@@/js/config.js'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; @@ -46,24 +133,33 @@ import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; import { login } from '@/accounts.js'; +import MkLink from '@/components/MkLink.vue'; +import MkServerSetupWizard from '@/components/MkServerSetupWizard.vue'; const username = ref(''); const password = ref(''); const setupPassword = ref(''); -const submitting = ref(false); +const accountCreating = ref(false); +const accountCreated = ref(false); +const step = ref(0); + +let token; -function submit() { - if (submitting.value) return; - submitting.value = true; +function createAccount() { + if (accountCreating.value) return; + accountCreating.value = true; + + const _close = os.waiting(); misskeyApi('admin/accounts/create', { username: username.value, password: password.value, setupPassword: setupPassword.value === '' ? null : setupPassword.value, }).then(res => { - return login(res.token); + token = res.token; + accountCreated.value = true; }).catch((err) => { - submitting.value = false; + accountCreating.value = false; let title = i18n.ts.somethingHappened; let text = err.message + '\n' + err.id; @@ -81,8 +177,22 @@ function submit() { title, text, }); + }).finally(() => { + _close(); }); } + +function onWizardFinished() { + step.value++; +} + +function skipSettings() { + step.value++; +} + +function finish() { + login(token); +} </script> <style lang="scss" module> @@ -90,8 +200,7 @@ function submit() { min-height: 100svh; padding: 32px 32px 64px 32px; box-sizing: border-box; - display: grid; - place-content: center; + align-content: center; } .form { @@ -100,16 +209,21 @@ function submit() { border-radius: var(--MI-radius); box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1); overflow: clip; - max-width: 500px; + max-width: 550px; + margin: 0 auto; } .title { + position: absolute; + top: 16px; + left: 0; + right: 0; + z-index: 1; margin: 0; font-size: 1.5em; text-align: center; padding: 32px; - background: var(--MI_THEME-accentedBg); - color: var(--MI_THEME-accent); + color: #fff; font-weight: bold; } diff --git a/packages/frontend/src/pages/welcome.timeline.note.vue b/packages/frontend/src/pages/welcome.timeline.note.vue index 680fe08c14..b4a24637c9 100644 --- a/packages/frontend/src/pages/welcome.timeline.note.vue +++ b/packages/frontend/src/pages/welcome.timeline.note.vue @@ -23,11 +23,9 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-if="note.files && note.files.length > 0" :class="$style.richcontent"> <MkMediaList :mediaList="note.files.slice(0, 4)"/> </div> - <div v-if="note.poll"> - <MkPoll :noteId="note.id" :poll="note.poll" :readOnly="true"/> - </div> <div v-if="note.reactionCount > 0" :class="$style.reactions"> - <MkReactionsViewer :note="note" :maxNumber="16"/> + <!-- TODO --> + <!--<MkReactionsViewer :note="note" :maxNumber="16"/>--> </div> </div> </div> diff --git a/packages/frontend/src/pref-migrate.ts b/packages/frontend/src/pref-migrate.ts index 414bb9c5aa..648349c6fe 100644 --- a/packages/frontend/src/pref-migrate.ts +++ b/packages/frontend/src/pref-migrate.ts @@ -15,7 +15,7 @@ import { i18n } from '@/i18n.js'; // TODO: そのうち消す export function migrateOldSettings() { - os.waiting(i18n.ts.settingsMigrating); + os.waiting({ text: i18n.ts.settingsMigrating }); store.loaded.then(async () => { misskeyApi('i/registry/get', { scope: ['client'], key: 'themes' }).catch(() => []).then((themes: any) => { @@ -93,7 +93,6 @@ export function migrateOldSettings() { prefer.commit('showFixedPostFormInChannel', store.s.showFixedPostFormInChannel); prefer.commit('enableInfiniteScroll', store.s.enableInfiniteScroll); prefer.commit('useReactionPickerForContextMenu', store.s.useReactionPickerForContextMenu); - prefer.commit('showGapBetweenNotesInTimeline', store.s.showGapBetweenNotesInTimeline); prefer.commit('instanceTicker', store.s.instanceTicker); prefer.commit('emojiPickerScale', store.s.emojiPickerScale); prefer.commit('emojiPickerWidth', store.s.emojiPickerWidth); @@ -115,7 +114,6 @@ export function migrateOldSettings() { prefer.commit('notificationStackAxis', store.s.notificationStackAxis); prefer.commit('enableCondensedLine', store.s.enableCondensedLine); prefer.commit('keepScreenOn', store.s.keepScreenOn); - prefer.commit('disableStreamingTimeline', store.s.disableStreamingTimeline); prefer.commit('useGroupedNotifications', store.s.useGroupedNotifications); prefer.commit('dataSaver', store.s.dataSaver); prefer.commit('enableSeasonalScreenEffect', store.s.enableSeasonalScreenEffect); diff --git a/packages/frontend/src/preferences.ts b/packages/frontend/src/preferences.ts index 73c89e23af..2208f75094 100644 --- a/packages/frontend/src/preferences.ts +++ b/packages/frontend/src/preferences.ts @@ -86,7 +86,7 @@ const storageProvider: StorageProvider = { }); }, - cloudGets: async (ctx) => { + cloudGetBulk: async (ctx) => { // TODO: 値の取得を1つのリクエストで済ませたい(バックエンド側でAPIの新設が必要) const fetchings = ctx.needs.map(need => storageProvider.cloudGet(need).then(res => [need.key, res] as const)); const cloudDatas = await Promise.all(fetchings); diff --git a/packages/frontend/src/preferences/def.ts b/packages/frontend/src/preferences/def.ts index 96f43bb2f6..86d5c8af98 100644 --- a/packages/frontend/src/preferences/def.ts +++ b/packages/frontend/src/preferences/def.ts @@ -5,6 +5,7 @@ import * as Misskey from 'misskey-js'; import { hemisphere } from '@@/js/intl-const.js'; +import { v4 as uuid } from 'uuid'; import type { Theme } from '@/theme.js'; import type { SoundType } from '@/utility/sound.js'; import type { Plugin } from '@/plugin.js'; @@ -12,6 +13,7 @@ import type { DeviceKind } from '@/utility/device-kind.js'; import type { DeckProfile } from '@/deck.js'; import type { PreferencesDefinition } from './manager.js'; import { DEFAULT_DEVICE_KIND } from '@/utility/device-kind.js'; +import { deepEqual } from '@/utility/deep-equal.js'; /** サウンド設定 */ export type SoundStore = { @@ -49,15 +51,15 @@ export const PREF_DEF = { }, widgets: { accountDependent: true, - default: [{ + default: () => [{ name: 'calendar', - id: 'a', place: 'right', data: {}, + id: uuid(), place: 'right', data: {}, }, { name: 'notifications', - id: 'b', place: 'right', data: {}, + id: uuid(), place: 'right', data: {}, }, { name: 'trends', - id: 'c', place: 'right', data: {}, + id: uuid(), place: 'right', data: {}, }] as { name: string; id: string; @@ -76,8 +78,8 @@ export const PREF_DEF = { emojiPalettes: { serverDependent: true, - default: [{ - id: 'a', + default: () => [{ + id: uuid(), name: '', emojis: ['👍', '❤️', '😆', '🤔', '😮', '🎉', '💢', '😥', '😇', '🍮'], }] as { @@ -85,6 +87,22 @@ export const PREF_DEF = { name: string; emojis: string[]; }[], + mergeStrategy: (a, b) => { + const mergedItems = [] as (typeof a)[]; + for (const x of a.concat(b)) { + const sameIdItem = mergedItems.find(y => y.id === x.id); + if (sameIdItem != null) { + if (deepEqual(x, sameIdItem)) { // 完全な重複は無視 + continue; + } else { // IDは同じなのに内容が違う場合はマージ不可とする + throw new Error(); + } + } else { + mergedItems.push(x); + } + } + return mergedItems; + }, }, emojiPaletteForReaction: { serverDependent: true, @@ -100,6 +118,22 @@ export const PREF_DEF = { }, themes: { default: [] as Theme[], + mergeStrategy: (a, b) => { + const mergedItems = [] as (typeof a)[]; + for (const x of a.concat(b)) { + const sameIdItem = mergedItems.find(y => y.id === x.id); + if (sameIdItem != null) { + if (deepEqual(x, sameIdItem)) { // 完全な重複は無視 + continue; + } else { // IDは同じなのに内容が違う場合はマージ不可とする + throw new Error(); + } + } else { + mergedItems.push(x); + } + } + return mergedItems; + }, }, lightTheme: { default: null as Theme | null, @@ -194,14 +228,17 @@ export const PREF_DEF = { default: 'auto' as 'auto' | 'popup' | 'drawer', }, useBlurEffectForModal: { - default: DEFAULT_DEVICE_KIND === 'desktop', + default: true, }, useBlurEffect: { - default: DEFAULT_DEVICE_KIND === 'desktop', + default: true, }, useStickyIcons: { default: true, }, + enableHighQualityImagePlaceholders: { + default: true, + }, showFixedPostForm: { default: false, }, @@ -214,9 +251,6 @@ export const PREF_DEF = { useReactionPickerForContextMenu: { default: false, }, - showGapBetweenNotesInTimeline: { - default: false, - }, instanceTicker: { default: 'remote' as 'none' | 'remote' | 'always', }, @@ -241,6 +275,12 @@ export const PREF_DEF = { numberOfPageCache: { default: 3, }, + pollingInterval: { + // 1 ... 低 + // 2 ... 中 + // 3 ... 高 + default: 2, + }, showNoteActionsOnlyHover: { default: false, }, @@ -277,9 +317,6 @@ export const PREF_DEF = { keepScreenOn: { default: false, }, - disableStreamingTimeline: { - default: false, - }, useGroupedNotifications: { default: true, }, @@ -287,9 +324,10 @@ export const PREF_DEF = { default: { media: false, avatar: false, - urlPreview: false, + urlPreviewThumbnail: false, + disableUrlPreview: false, code: false, - } as Record<string, boolean>, + } satisfies Record<string, boolean>, }, hemisphere: { default: hemisphere as 'N' | 'S', @@ -341,10 +379,23 @@ export const PREF_DEF = { }, plugins: { default: [] as Plugin[], + mergeStrategy: (a, b) => { + const sameIdExists = a.some(x => b.some(y => x.installId === y.installId)); + if (sameIdExists) throw new Error(); + const sameNameExists = a.some(x => b.some(y => x.name === y.name)); + if (sameNameExists) throw new Error(); + return a.concat(b); + }, + }, + mutingEmojis: { + default: [] as string[], + mergeStrategy: (a, b) => { + return [...new Set(a.concat(b))]; + }, }, 'sound.masterVolume': { - default: 0.3, + default: 0.5, }, 'sound.notUseSound': { default: false, @@ -410,4 +461,7 @@ export const PREF_DEF = { 'experimental.stackingRouterView': { default: false, }, + 'experimental.enableFolderPageView': { + default: false, + }, } satisfies PreferencesDefinition; diff --git a/packages/frontend/src/preferences/manager.ts b/packages/frontend/src/preferences/manager.ts index f96aa2f368..cede145e74 100644 --- a/packages/frontend/src/preferences/manager.ts +++ b/packages/frontend/src/preferences/manager.ts @@ -22,7 +22,10 @@ import { deepEqual } from '@/utility/deep-equal.js'; //}; type PREF = typeof PREF_DEF; -type ValueOf<K extends keyof PREF> = PREF[K]['default']; +type DefaultValues = { + [K in keyof PREF]: PREF[K]['default'] extends (...args: any) => infer R ? R : PREF[K]['default']; +}; +type ValueOf<K extends keyof PREF> = DefaultValues[K]; type Scope = Partial<{ server: string | null; // host @@ -79,17 +82,30 @@ export type PreferencesProfile = { export type StorageProvider = { save: (ctx: { profile: PreferencesProfile; }) => void; - cloudGets: <K extends keyof PREF>(ctx: { needs: { key: K; scope: Scope; }[] }) => Promise<Partial<Record<K, ValueOf<K>>>>; + cloudGetBulk: <K extends keyof PREF>(ctx: { needs: { key: K; scope: Scope; }[] }) => Promise<Partial<Record<K, ValueOf<K>>>>; cloudGet: <K extends keyof PREF>(ctx: { key: K; scope: Scope; }) => Promise<{ value: ValueOf<K>; } | null>; cloudSet: <K extends keyof PREF>(ctx: { key: K; scope: Scope; value: ValueOf<K>; }) => Promise<void>; }; -export type PreferencesDefinition = Record<string, { - default: any; +type PreferencesDefinitionRecord<Default, T = Default extends (...args: any) => infer R ? R : Default> = { + default: Default; accountDependent?: boolean; serverDependent?: boolean; -}>; + mergeStrategy?: (a: T, b: T) => T; +}; + +export type PreferencesDefinition = Record<string, PreferencesDefinitionRecord<any>>; + +export function getInitialPrefValue<K extends keyof PREF>(k: K): ValueOf<K> { + if (typeof PREF_DEF[k].default === 'function') { // factory + return PREF_DEF[k].default(); + } else { + return PREF_DEF[k].default; + } +} +// TODO: PreferencesManagerForGuest のような非ログイン専用のクラスを分離すれば$iのnullチェックやaccountがnullであるスコープのレコード挿入などが不要になり綺麗になるかもしれない +// NOTE: accountDependentな設定は初期状態であってもアカウントごとのスコープでレコードを作成しておかないと、サーバー同期する際に正しく動作しなくなる export class PreferencesManager { private storageProvider: StorageProvider; public profile: PreferencesProfile; @@ -125,11 +141,11 @@ export class PreferencesManager { // TODO: 定期的にクラウドの値をフェッチ } - private isAccountDependentKey<K extends keyof PREF>(key: K): boolean { + private static isAccountDependentKey<K extends keyof PREF>(key: K): boolean { return (PREF_DEF as PreferencesDefinition)[key].accountDependent === true; } - private isServerDependentKey<K extends keyof PREF>(key: K): boolean { + private static isServerDependentKey<K extends keyof PREF>(key: K): boolean { return (PREF_DEF as PreferencesDefinition)[key].serverDependent === true; } @@ -152,7 +168,7 @@ export class PreferencesManager { const record = this.getMatchedRecordOf(key); - if (parseScope(record[0]).account == null && this.isAccountDependentKey(key)) { + if (parseScope(record[0]).account == null && PreferencesManager.isAccountDependentKey(key)) { this.profile.preferences[key].push([makeScope({ server: host, account: $i!.id, @@ -161,7 +177,7 @@ export class PreferencesManager { return; } - if (parseScope(record[0]).server == null && this.isServerDependentKey(key)) { + if (parseScope(record[0]).server == null && PreferencesManager.isServerDependentKey(key)) { this.profile.preferences[key].push([makeScope({ server: host, }), v, {}]); @@ -240,7 +256,7 @@ export class PreferencesManager { } } - const cloudValues = await this.storageProvider.cloudGets({ needs }); + const cloudValues = await this.storageProvider.cloudGetBulk({ needs }); for (const _key in PREF_DEF) { const key = _key as keyof PREF; @@ -262,7 +278,19 @@ export class PreferencesManager { public static newProfile(): PreferencesProfile { const data = {} as PreferencesProfile['preferences']; for (const key in PREF_DEF) { - data[key] = [[makeScope({}), PREF_DEF[key].default, {}]]; + const v = getInitialPrefValue(key as keyof typeof PREF_DEF); + if (PreferencesManager.isAccountDependentKey(key as keyof typeof PREF_DEF)) { + data[key] = $i ? [[makeScope({}), v, {}], [makeScope({ + server: host, + account: $i.id, + }), v, {}]] : [[makeScope({}), v, {}]]; + } else if (PreferencesManager.isServerDependentKey(key as keyof typeof PREF_DEF)) { + data[key] = [[makeScope({ + server: host, + }), v, {}]]; + } else { + data[key] = [[makeScope({}), v, {}]]; + } } return { id: uuid(), @@ -279,18 +307,36 @@ export class PreferencesManager { for (const key in PREF_DEF) { const records = profileLike.preferences[key]; if (records == null || records.length === 0) { - data[key] = [[makeScope({}), PREF_DEF[key].default, {}]]; + const v = getInitialPrefValue(key as keyof typeof PREF_DEF); + if (PreferencesManager.isAccountDependentKey(key as keyof typeof PREF_DEF)) { + data[key] = $i ? [[makeScope({}), v, {}], [makeScope({ + server: host, + account: $i.id, + }), v, {}]] : [[makeScope({}), v, {}]]; + } else if (PreferencesManager.isServerDependentKey(key as keyof typeof PREF_DEF)) { + data[key] = [[makeScope({ + server: host, + }), v, {}]]; + } else { + data[key] = [[makeScope({}), v, {}]]; + } continue; } else { - data[key] = records; - - // alpha段階ではmetaが無かったのでマイグレート - // TODO: そのうち消す - for (const record of data[key] as any[][]) { - if (record.length === 2) { - record.push({}); - } + if ($i && PreferencesManager.isAccountDependentKey(key as keyof typeof PREF_DEF) && !records.some(([scope]) => parseScope(scope).server === host && parseScope(scope).account === $i!.id)) { + data[key] = records.concat([[makeScope({ + server: host, + account: $i.id, + }), getInitialPrefValue(key as keyof typeof PREF_DEF), {}]]); + continue; } + if ($i && PreferencesManager.isServerDependentKey(key as keyof typeof PREF_DEF) && !records.some(([scope]) => parseScope(scope).server === host)) { + data[key] = records.concat([[makeScope({ + server: host, + }), getInitialPrefValue(key as keyof typeof PREF_DEF), {}]]); + continue; + } + + data[key] = records; } } @@ -328,7 +374,7 @@ export class PreferencesManager { public setAccountOverride<K extends keyof PREF>(key: K) { if ($i == null) return; - if (this.isAccountDependentKey(key)) throw new Error('already account-dependent'); + if (PreferencesManager.isAccountDependentKey(key)) throw new Error('already account-dependent'); if (this.isAccountOverrided(key)) return; const records = this.profile.preferences[key]; @@ -342,7 +388,7 @@ export class PreferencesManager { public clearAccountOverride<K extends keyof PREF>(key: K) { if ($i == null) return; - if (this.isAccountDependentKey(key)) throw new Error('cannot clear override for this account-dependent property'); + if (PreferencesManager.isAccountDependentKey(key)) throw new Error('cannot clear override for this account-dependent property'); const records = this.profile.preferences[key]; @@ -363,14 +409,22 @@ export class PreferencesManager { public async enableSync<K extends keyof PREF>(key: K): Promise<{ enabled: boolean; } | null> { if (this.isSyncEnabled(key)) return Promise.resolve(null); - const record = this.getMatchedRecordOf(key); - - const existing = await this.storageProvider.cloudGet({ key, scope: record[0] }); - if (existing != null && !deepEqual(existing.value, record[1])) { - const { canceled, result } = await os.select({ + // undefined ... cancel + async function resolveConflict(local: ValueOf<K>, remote: ValueOf<K>): Promise<ValueOf<K> | undefined> { + const merge = (PREF_DEF as PreferencesDefinition)[key].mergeStrategy; + let mergedValue: ValueOf<K> | undefined = undefined; // null と区別したいため + try { + if (merge != null) mergedValue = merge(local, remote); + } catch (err) { + // nop + } + const { canceled, result: choice } = await os.select({ title: i18n.ts.preferenceSyncConflictTitle, text: i18n.ts.preferenceSyncConflictText, - items: [{ + items: [...(mergedValue !== undefined ? [{ + text: i18n.ts.preferenceSyncConflictChoiceMerge, + value: 'merge', + }] : []), { text: i18n.ts.preferenceSyncConflictChoiceServer, value: 'remote', }, { @@ -380,23 +434,53 @@ export class PreferencesManager { text: i18n.ts.preferenceSyncConflictChoiceCancel, value: null, }], - default: 'remote', + default: mergedValue !== undefined ? 'merge' : 'remote', }); - if (canceled || result == null) return { enabled: false }; + if (canceled || choice == null) return undefined; - if (result === 'remote') { - this.commit(key, existing.value); - } else if (result === 'local') { - // nop + if (choice === 'remote') { + return remote; + } else if (choice === 'local') { + return local; + } else if (choice === 'merge') { + return mergedValue!; } } + const record = this.getMatchedRecordOf(key); + + let newValue = record[1]; + + const existing = await this.storageProvider.cloudGet({ key, scope: record[0] }); + if (existing != null && !deepEqual(record[1], existing.value)) { + const resolvedValue = await resolveConflict(record[1], existing.value); + if (resolvedValue === undefined) return { enabled: false }; // canceled + newValue = resolvedValue; + } + + this.commit(key, newValue); + + const done = os.waiting(); + + try { + await this.storageProvider.cloudSet({ key, scope: record[0], value: newValue }); + } catch (err) { + done(); + + os.alert({ + type: 'error', + title: i18n.ts.somethingHappened, + text: err, + }); + + return { enabled: false }; + } + + done({ success: true }); + record[2].sync = true; this.save(); - // awaitの必要性は無い - this.storageProvider.cloudSet({ key, scope: record[0], value: this.s[key] }); - return { enabled: true }; } @@ -457,7 +541,7 @@ export class PreferencesManager { text: i18n.ts.resetToDefaultValue, danger: true, action: () => { - this.commit(key, PREF_DEF[key].default); + this.commit(key, getInitialPrefValue(key)); }, }, { type: 'divider', diff --git a/packages/frontend/src/preferences/utility.ts b/packages/frontend/src/preferences/utility.ts index af5b178df6..a6687251af 100644 --- a/packages/frontend/src/preferences/utility.ts +++ b/packages/frontend/src/preferences/utility.ts @@ -140,7 +140,7 @@ function importProfile() { export async function cloudBackup() { if ($i == null) return; if (!canAutoBackup()) { - throw new Error('Profile name is not set'); + throw new Error('cannot auto backup for this profile'); } await misskeyApi('i/registry/set', { diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts index 5ff9c1c7fe..6f9b5786ee 100644 --- a/packages/frontend/src/store.ts +++ b/packages/frontend/src/store.ts @@ -14,6 +14,18 @@ import { miLocalStorage } from '@/local-storage.js'; import { Pizzax } from '@/lib/pizzax.js'; import { DEFAULT_DEVICE_KIND } from '@/utility/device-kind.js'; +export const TIPS = [ + 'drive', + 'uploader', + 'clips', + 'userLists', + 'tl.home', + 'tl.local', + 'tl.social', + 'tl.global', + 'abuses', +] as const; + /** * 「状態」を管理するストア(not「設定」) */ @@ -22,22 +34,9 @@ export const store = markRaw(new Pizzax('base', { where: 'account', default: 0, }, - timelineTutorials: { - where: 'account', - default: { - home: false, - local: false, - social: false, - global: false, - }, - }, - abusesTutorial: { - where: 'account', - default: false, - }, - readDriveTip: { - where: 'account', - default: false, + tips: { + where: 'device', + default: {} as Partial<Record<typeof TIPS[number], boolean>>, // true = 既読 }, memo: { where: 'account', @@ -80,6 +79,10 @@ export const store = markRaw(new Pizzax('base', { where: 'device', default: false, }, + realtimeMode: { + where: 'device', + default: true, + }, recentlyUsedEmojis: { where: 'device', default: [] as string[], @@ -378,10 +381,6 @@ export const store = markRaw(new Pizzax('base', { where: 'device', default: false, }, - disableStreamingTimeline: { - where: 'device', - default: false, - }, useGroupedNotifications: { where: 'device', default: true, diff --git a/packages/frontend/src/style.scss b/packages/frontend/src/style.scss index 341f5cb621..6a9aa08b30 100644 --- a/packages/frontend/src/style.scss +++ b/packages/frontend/src/style.scss @@ -90,10 +90,35 @@ html { } } -html._themeChanging_ { +html._themeChangingFallback_ { &, * { - transition: background 1s ease, border 1s ease !important; + transition: background 0.5s ease, border 0.5s ease !important; + } +} + +html._themeChanging_ { + view-transition-name: theme-changing; +} + +html::view-transition-new(theme-changing) { + z-index: 4000000; +} + +html::view-transition-old(theme-changing) { + z-index: 4000001; + animation: themeChangingOld 0.5s ease; + animation-fill-mode: forwards; +} + +@keyframes themeChangingOld { + 0% { + opacity: 1; } + + 100% { + opacity: 0; + } + } html, @@ -314,7 +339,6 @@ rt { max-width: 100%; &:disabled { - opacity: 0.5; cursor: default; } } diff --git a/packages/frontend/src/theme.ts b/packages/frontend/src/theme.ts index 268f879d17..e48eb04103 100644 --- a/packages/frontend/src/theme.ts +++ b/packages/frontend/src/theme.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { ref } from 'vue'; +import { ref, nextTick } from 'vue'; import tinycolor from 'tinycolor2'; import lightTheme from '@@/themes/_light.json5'; import darkTheme from '@@/themes/_dark.json5'; @@ -15,6 +15,7 @@ import { globalEvents } from '@/events.js'; import { miLocalStorage } from '@/local-storage.js'; import { $i } from '@/i.js'; import { prefer } from '@/preferences.js'; +import { deepEqual } from '@/utility/deep-equal.js'; export type Theme = { id: string; @@ -88,20 +89,7 @@ export async function removeTheme(theme: Theme): Promise<void> { prefer.commit('themes', themes); } -let timeout: number | null = null; - -export function applyTheme(theme: Theme, persist = true) { - if (timeout) window.clearTimeout(timeout); - - window.document.documentElement.classList.add('_themeChanging_'); - - timeout = window.setTimeout(() => { - window.document.documentElement.classList.remove('_themeChanging_'); - - // 色計算など再度行えるようにクライアント全体に通知 - globalEvents.emit('themeChanged'); - }, 1000); - +function applyThemeInternal(theme: Theme, persist: boolean) { const colorScheme = theme.base === 'dark' ? 'dark' : 'light'; window.document.documentElement.dataset.colorScheme = colorScheme; @@ -139,6 +127,41 @@ export function applyTheme(theme: Theme, persist = true) { globalEvents.emit('themeChanging'); } +let timeout: number | null = null; +let currentTheme: Theme | null = null; + +export function applyTheme(theme: Theme, persist = true) { + if (timeout) { + window.clearTimeout(timeout); + timeout = null; + } + + if (deepEqual(currentTheme, theme)) return; + currentTheme = theme; + + if (window.document.startViewTransition != null && prefer.s.animation) { + window.document.documentElement.classList.add('_themeChanging_'); + window.document.startViewTransition(async () => { + applyThemeInternal(theme, persist); + await nextTick(); + }).finished.then(() => { + window.document.documentElement.classList.remove('_themeChanging_'); + // 色計算など再度行えるようにクライアント全体に通知 + globalEvents.emit('themeChanged'); + }); + } else { + // TODO: ViewTransition API が主要ブラウザで対応したら消す + window.document.documentElement.classList.add('_themeChangingFallback_'); + timeout = window.setTimeout(() => { + window.document.documentElement.classList.remove('_themeChangingFallback_'); + // 色計算など再度行えるようにクライアント全体に通知 + globalEvents.emit('themeChanged'); + }, 500); + + applyThemeInternal(theme, persist); + } +} + export function compile(theme: Theme): Record<string, string> { function getColor(val: string): tinycolor.Instance { if (val[0] === '@') { // ref (prop) diff --git a/packages/frontend/src/types/date-separated-list.ts b/packages/frontend/src/types/date-separated-list.ts deleted file mode 100644 index af685cff12..0000000000 --- a/packages/frontend/src/types/date-separated-list.ts +++ /dev/null @@ -1,11 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -export type MisskeyEntity = { - id: string; - createdAt: string; - _shouldInsertAd_?: boolean; - [x: string]: any; -}; diff --git a/packages/frontend/src/ui/_common_/common.vue b/packages/frontend/src/ui/_common_/common.vue index 5fe99e0d14..da20d23cfd 100644 --- a/packages/frontend/src/ui/_common_/common.vue +++ b/packages/frontend/src/ui/_common_/common.vue @@ -26,7 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only :leaveToClass="prefer.s.animation ? $style.transition_menuDrawer_leaveTo : ''" > <div v-if="drawerMenuShowing" :class="$style.menuDrawer"> - <XDrawerMenu/> + <XNavbar style="height: 100%;" :asDrawer="true" :showWidgetButton="false"/> </div> </Transition> @@ -65,8 +65,6 @@ SPDX-License-Identifier: AGPL-3.0-only v-on="popup.events" /> -<XUpload v-if="uploads.length > 0"/> - <component :is="prefer.s.animation ? TransitionGroup : 'div'" tag="div" @@ -105,17 +103,16 @@ import { swInject } from './sw-inject.js'; import XNotification from './notification.vue'; import { popups } from '@/os.js'; import { pendingApiRequestsCount } from '@/utility/misskey-api.js'; -import { uploads } from '@/utility/upload.js'; import * as sound from '@/utility/sound.js'; import { $i } from '@/i.js'; import { useStream } from '@/stream.js'; import { i18n } from '@/i18n.js'; import { prefer } from '@/preferences.js'; import { globalEvents } from '@/events.js'; -import XDrawerMenu from '@/ui/_common_/navbar-for-mobile.vue'; +import { store } from '@/store.js'; +import XNavbar from '@/ui/_common_/navbar.vue'; const XStreamIndicator = defineAsyncComponent(() => import('./stream-indicator.vue')); -const XUpload = defineAsyncComponent(() => import('./upload.vue')); const XWidgets = defineAsyncComponent(() => import('./widgets.vue')); const drawerMenuShowing = defineModel<boolean>('drawerMenuShowing'); @@ -129,7 +126,9 @@ function onNotification(notification: Misskey.entities.Notification, isClient = if (window.document.visibilityState === 'visible') { if (!isClient && notification.type !== 'test') { // サーバーサイドのテスト通知の際は自動で既読をつけない(テストできないので) - useStream().send('readNotification'); + if (store.s.realtimeMode) { + useStream().send('readNotification'); + } } notifications.value.unshift(notification); @@ -146,11 +145,12 @@ function onNotification(notification: Misskey.entities.Notification, isClient = } if ($i) { - const connection = useStream().useChannel('main', null, 'UI'); - connection.on('notification', onNotification); + if (store.s.realtimeMode) { + const connection = useStream().useChannel('main'); + connection.on('notification', onNotification); + } globalEvents.on('clientNotification', notification => onNotification(notification, true)); - //#region Listen message from SW if ('serviceWorker' in navigator) { swInject(); } @@ -226,12 +226,6 @@ if ($i) { left: 0; z-index: 1001; height: 100dvh; - width: 240px; - box-sizing: border-box; - contain: strict; - overflow: auto; - overscroll-behavior: contain; - background: var(--MI_THEME-navBg); } .widgetsDrawerBg { diff --git a/packages/frontend/src/ui/_common_/mobile-footer-menu.vue b/packages/frontend/src/ui/_common_/mobile-footer-menu.vue index 88c6191e5a..e2993230be 100644 --- a/packages/frontend/src/ui/_common_/mobile-footer-menu.vue +++ b/packages/frontend/src/ui/_common_/mobile-footer-menu.vue @@ -136,7 +136,7 @@ watch(rootEl, () => { } .itemIcon { - font-size: 14px; + font-size: 15px; } .itemIndicator { diff --git a/packages/frontend/src/ui/_common_/navbar-for-mobile.vue b/packages/frontend/src/ui/_common_/navbar-for-mobile.vue deleted file mode 100644 index 826e03751a..0000000000 --- a/packages/frontend/src/ui/_common_/navbar-for-mobile.vue +++ /dev/null @@ -1,273 +0,0 @@ -<!-- -SPDX-FileCopyrightText: syuilo and misskey-project -SPDX-License-Identifier: AGPL-3.0-only ---> - -<template> -<div :class="$style.root"> - <div :class="$style.top"> - <div :class="$style.banner" :style="{ backgroundImage: `url(${ instance.bannerUrl })` }"></div> - <button class="_button" :class="$style.instance" @click="openInstanceMenu"> - <img :src="instance.iconUrl || instance.faviconUrl || '/favicon.ico'" alt="" :class="$style.instanceIcon"/> - </button> - </div> - <div :class="$style.middle"> - <MkA :class="$style.item" :activeClass="$style.active" to="/" exact> - <i :class="$style.itemIcon" class="ti ti-home ti-fw"></i><span :class="$style.itemText">{{ i18n.ts.timeline }}</span> - </MkA> - <template v-for="item in prefer.r.menu.value"> - <div v-if="item === '-'" :class="$style.divider"></div> - <component :is="navbarItemDef[item].to ? 'MkA' : 'button'" v-else-if="navbarItemDef[item] && (navbarItemDef[item].show !== false)" class="_button" :class="[$style.item, { [$style.active]: navbarItemDef[item].active }]" :activeClass="$style.active" :to="navbarItemDef[item].to" v-on="navbarItemDef[item].action ? { click: navbarItemDef[item].action } : {}"> - <i class="ti-fw" :class="[$style.itemIcon, navbarItemDef[item].icon]"></i><span :class="$style.itemText">{{ navbarItemDef[item].title }}</span> - <span v-if="navbarItemDef[item].indicated" :class="$style.itemIndicator" class="_blink"> - <span v-if="navbarItemDef[item].indicateValue" class="_indicateCounter" :class="$style.itemIndicateValueIcon">{{ navbarItemDef[item].indicateValue }}</span> - <i v-else class="_indicatorCircle"></i> - </span> - </component> - </template> - <div :class="$style.divider"></div> - <MkA v-if="$i.isAdmin || $i.isModerator" :class="$style.item" :activeClass="$style.active" to="/admin"> - <i :class="$style.itemIcon" class="ti ti-dashboard ti-fw"></i><span :class="$style.itemText">{{ i18n.ts.controlPanel }}</span> - </MkA> - <button :class="$style.item" class="_button" @click="more"> - <i :class="$style.itemIcon" class="ti ti-grid-dots ti-fw"></i><span :class="$style.itemText">{{ i18n.ts.more }}</span> - <span v-if="otherMenuItemIndicated" :class="$style.itemIndicator" class="_blink"><i class="_indicatorCircle"></i></span> - </button> - <MkA :class="$style.item" :activeClass="$style.active" to="/settings"> - <i :class="$style.itemIcon" class="ti ti-settings ti-fw"></i><span :class="$style.itemText">{{ i18n.ts.settings }}</span> - </MkA> - </div> - <div :class="$style.bottom"> - <button class="_button" :class="$style.post" data-cy-open-post-form @click="os.post"> - <i :class="$style.postIcon" class="ti ti-pencil ti-fw"></i><span style="position: relative;">{{ i18n.ts.note }}</span> - </button> - <button class="_button" :class="$style.account" @click="openAccountMenu"> - <MkAvatar :user="$i" :class="$style.avatar"/><MkAcct :class="$style.acct" class="_nowrap" :user="$i"/> - </button> - </div> -</div> -</template> - -<script lang="ts" setup> -import { computed, defineAsyncComponent } from 'vue'; -import { openInstanceMenu } from './common.js'; -import * as os from '@/os.js'; -import { navbarItemDef } from '@/navbar.js'; -import { prefer } from '@/preferences.js'; -import { i18n } from '@/i18n.js'; -import { instance } from '@/instance.js'; -import { openAccountMenu as openAccountMenu_ } from '@/accounts.js'; -import { $i } from '@/i.js'; - -const otherMenuItemIndicated = computed(() => { - for (const def in navbarItemDef) { - if (prefer.r.menu.value.includes(def)) continue; - if (navbarItemDef[def].indicated) return true; - } - return false; -}); - -function openAccountMenu(ev: MouseEvent) { - openAccountMenu_({ - withExtraOperation: true, - }, ev); -} - -function more() { - const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkLaunchPad.vue')), {}, { - closed: () => dispose(), - }); -} -</script> - -<style lang="scss" module> -.root { - --nav-bg-transparent: color(from var(--MI_THEME-navBg) srgb r g b / 0.5); - - display: flex; - flex-direction: column; -} - -.top { - position: sticky; - top: 0; - z-index: 1; - padding: 20px 0; - background: var(--nav-bg-transparent); - -webkit-backdrop-filter: var(--MI-blur, blur(8px)); - backdrop-filter: var(--MI-blur, blur(8px)); -} - -.banner { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - background-size: cover; - background-position: center center; - -webkit-mask-image: linear-gradient(0deg, rgba(0,0,0,0) 15%, rgba(0,0,0,0.75) 100%); - mask-image: linear-gradient(0deg, rgba(0,0,0,0) 15%, rgba(0,0,0,0.75) 100%); -} - -.instance { - position: relative; - display: block; - text-align: center; - width: 100%; -} - -.instanceIcon { - display: inline-block; - width: 38px; - aspect-ratio: 1; - border-radius: 8px; -} - -.bottom { - position: sticky; - bottom: 0; - padding: 20px 0; - background: var(--nav-bg-transparent); - -webkit-backdrop-filter: var(--MI-blur, blur(8px)); - backdrop-filter: var(--MI-blur, blur(8px)); -} - -.post { - position: relative; - display: block; - width: 100%; - height: 40px; - color: var(--MI_THEME-fgOnAccent); - font-weight: bold; - text-align: left; - - &::before { - content: ""; - display: block; - width: calc(100% - 38px); - height: 100%; - margin: auto; - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - border-radius: 999px; - background: linear-gradient(90deg, var(--MI_THEME-buttonGradateA), var(--MI_THEME-buttonGradateB)); - } - - &:hover, &.active { - &::before { - background: hsl(from var(--MI_THEME-accent) h s calc(l + 10)); - } - } -} - -.postIcon { - position: relative; - margin-left: 30px; - margin-right: 8px; - width: 32px; -} - -.account { - position: relative; - display: flex; - align-items: center; - padding-left: 30px; - width: 100%; - text-align: left; - box-sizing: border-box; - margin-top: 16px; -} - -.avatar { - display: block; - flex-shrink: 0; - position: relative; - width: 32px; - aspect-ratio: 1; - margin-right: 8px; -} - -.acct { - display: block; - flex-shrink: 1; - padding-right: 8px; -} - -.middle { - flex: 1; -} - -.divider { - margin: 16px 16px; - border-top: solid 0.5px var(--MI_THEME-divider); -} - -.item { - position: relative; - display: block; - padding-left: 24px; - line-height: 2.85rem; - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; - width: 100%; - text-align: left; - box-sizing: border-box; - color: var(--MI_THEME-navFg); - - &:hover { - text-decoration: none; - color: light-dark(hsl(from var(--MI_THEME-navFg) h s calc(l - 17)), hsl(from var(--MI_THEME-navFg) h s calc(l + 17))); - } - - &.active { - color: var(--MI_THEME-navActive); - } - - &:hover, &.active { - &::before { - content: ""; - display: block; - width: calc(100% - 24px); - height: 100%; - margin: auto; - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - border-radius: 999px; - background: var(--MI_THEME-accentedBg); - } - } -} - -.itemIcon { - position: relative; - width: 32px; - margin-right: 8px; -} - -.itemIndicator { - position: absolute; - top: 0; - left: 20px; - color: var(--MI_THEME-navIndicator); - font-size: 8px; - - &:has(.itemIndicateValueIcon) { - animation: none; - left: auto; - right: 20px; - } -} - -.itemText { - position: relative; - font-size: 0.9em; -} -</style> diff --git a/packages/frontend/src/ui/_common_/navbar-h.vue b/packages/frontend/src/ui/_common_/navbar-h.vue index 13fc592e70..24e2b28f1c 100644 --- a/packages/frontend/src/ui/_common_/navbar-h.vue +++ b/packages/frontend/src/ui/_common_/navbar-h.vue @@ -73,7 +73,7 @@ const otherNavItemIndicated = computed<boolean>(() => { function more(ev: MouseEvent) { const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkLaunchPad.vue')), { - src: ev.currentTarget ?? ev.target, + anchorElement: ev.currentTarget ?? ev.target, anchor: { x: 'center', y: 'bottom' }, }, { closed: () => dispose(), diff --git a/packages/frontend/src/ui/_common_/navbar.vue b/packages/frontend/src/ui/_common_/navbar.vue index ce8efa3324..c8b7491895 100644 --- a/packages/frontend/src/ui/_common_/navbar.vue +++ b/packages/frontend/src/ui/_common_/navbar.vue @@ -10,6 +10,9 @@ SPDX-License-Identifier: AGPL-3.0-only <button v-tooltip.noDelay.right="instance.name ?? i18n.ts.instance" class="_button" :class="$style.instance" @click="openInstanceMenu"> <img :src="instance.iconUrl || instance.faviconUrl || '/favicon.ico'" alt="" :class="$style.instanceIcon" style="viewTransitionName: navbar-serverIcon;"/> </button> + <button v-if="!iconOnly" v-tooltip.noDelay.right="i18n.ts.realtimeMode" class="_button" :class="[$style.realtimeMode, store.r.realtimeMode.value ? $style.on : null]" @click="toggleRealtimeMode"> + <i class="ti ti-bolt ti-fw"></i> + </button> </div> <div :class="$style.middle"> <MkA v-tooltip.noDelay.right="i18n.ts.timeline" :class="$style.item" :activeClass="$style.active" to="/" exact> @@ -50,6 +53,9 @@ SPDX-License-Identifier: AGPL-3.0-only <button v-if="showWidgetButton" class="_button" :class="[$style.widget]" @click="() => emit('widgetButtonClick')"> <i class="ti ti-apps ti-fw"></i> </button> + <button v-if="iconOnly" v-tooltip.noDelay.right="i18n.ts.realtimeMode" class="_button" :class="[$style.realtimeMode, store.r.realtimeMode.value ? $style.on : null]" @click="toggleRealtimeMode"> + <i class="ti ti-bolt ti-fw"></i> + </button> <button v-tooltip.noDelay.right="i18n.ts.note" class="_button" :class="[$style.post]" data-cy-open-post-form @click="() => { os.post(); }"> <i class="ti ti-pencil ti-fw" :class="$style.postIcon"></i><span :class="$style.postText">{{ i18n.ts.note }}</span> </button> @@ -76,16 +82,18 @@ SPDX-License-Identifier: AGPL-3.0-only </svg> <button class="_button" :class="$style.subButtonClickable" @click="menuEdit"><i :class="$style.subButtonIcon" class="ti ti-settings-2"></i></button> </div> - <div :class="$style.subButtonGapFill"></div> - <div :class="$style.subButtonGapFillDivider"></div> - <div :class="[$style.subButton, $style.toggleButton]"> - <svg viewBox="0 0 16 64" :class="$style.subButtonShape"> - <g transform="matrix(0.333333,0,0,0.222222,0.000895785,21.3333)"> - <path d="M47.488,7.995C47.79,10.11 47.943,12.266 47.943,14.429C47.997,26.989 47.997,84 47.997,84C47.997,84 44.018,118.246 23.997,133.5C-0.374,152.07 -0.003,192 -0.003,192L-0.003,-96C-0.003,-96 0.151,-56.216 23.997,-37.5C40.861,-24.265 46.043,-1.243 47.488,7.995Z" style="fill:var(--MI_THEME-navBg);"/> - </g> - </svg> - <button class="_button" :class="$style.subButtonClickable" @click="toggleIconOnly"><i v-if="iconOnly" class="ti ti-chevron-right" :class="$style.subButtonIcon"></i><i v-else class="ti ti-chevron-left" :class="$style.subButtonIcon"></i></button> - </div> + <template v-if="!props.asDrawer"> + <div :class="$style.subButtonGapFill"></div> + <div :class="$style.subButtonGapFillDivider"></div> + <div :class="[$style.subButton, $style.toggleButton]"> + <svg viewBox="0 0 16 64" :class="$style.subButtonShape"> + <g transform="matrix(0.333333,0,0,0.222222,0.000895785,21.3333)"> + <path d="M47.488,7.995C47.79,10.11 47.943,12.266 47.943,14.429C47.997,26.989 47.997,84 47.997,84C47.997,84 44.018,118.246 23.997,133.5C-0.374,152.07 -0.003,192 -0.003,192L-0.003,-96C-0.003,-96 0.151,-56.216 23.997,-37.5C40.861,-24.265 46.043,-1.243 47.488,7.995Z" style="fill:var(--MI_THEME-navBg);"/> + </g> + </svg> + <button class="_button" :class="$style.subButtonClickable" @click="toggleIconOnly"><i v-if="iconOnly" class="ti ti-chevron-right" :class="$style.subButtonIcon"></i><i v-else class="ti ti-chevron-left" :class="$style.subButtonIcon"></i></button> + </div> + </template> </div> </div> </template> @@ -108,15 +116,16 @@ const router = useRouter(); const props = defineProps<{ showWidgetButton?: boolean; + asDrawer?: boolean; }>(); const emit = defineEmits<{ (ev: 'widgetButtonClick'): void; }>(); -const forceIconOnly = ref(window.innerWidth <= 1279); +const forceIconOnly = ref(!props.asDrawer && window.innerWidth <= 1279); const iconOnly = computed(() => { - return forceIconOnly.value || (store.r.menuDisplay.value === 'sideIcon'); + return !props.asDrawer && (forceIconOnly.value || (store.r.menuDisplay.value === 'sideIcon')); }); const otherMenuItemIndicated = computed(() => { @@ -147,6 +156,20 @@ function toggleIconOnly() { } } +function toggleRealtimeMode(ev: MouseEvent) { + os.popupMenu([{ + type: 'label', + text: i18n.ts.realtimeMode, + }, { + text: store.s.realtimeMode ? i18n.ts.turnItOff : i18n.ts.turnItOn, + icon: store.s.realtimeMode ? 'ti ti-bolt-off' : 'ti ti-bolt', + action: () => { + store.set('realtimeMode', !store.s.realtimeMode); + window.location.reload(); + }, + }], ev.currentTarget ?? ev.target); +} + function openAccountMenu(ev: MouseEvent) { openAccountMenu_({ withExtraOperation: true, @@ -157,7 +180,7 @@ function more(ev: MouseEvent) { const target = getHTMLElementOrNull(ev.currentTarget ?? ev.target); if (!target) return; const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkLaunchPad.vue')), { - src: target, + anchorElement: target, }, { closed: () => dispose(), }); @@ -191,21 +214,108 @@ function menuEdit() { overscroll-behavior: contain; background: var(--MI_THEME-navBg); contain: strict; + + /* 画面が縦に長い、設置している項目数が少ないなどの環境においても確実にbottomを最下部に表示するため */ display: flex; flex-direction: column; - direction: rtl; // スクロールバーを左に表示したいため + + direction: rtl; /* スクロールバーを左に表示したいため */ } .top { + flex-shrink: 0; direction: ltr; + + /* 疑似progressive blur */ + &::before { + position: absolute; + z-index: -1; + inset: 0; + content: ""; + backdrop-filter: blur(8px); + mask-image: linear-gradient( + to top, + rgb(0 0 0 / 0%) 0%, + rgb(0 0 0 / 4.9%) 7.75%, + rgb(0 0 0 / 10.4%) 11.25%, + rgb(0 0 0 / 45%) 23.55%, + rgb(0 0 0 / 55%) 26.45%, + rgb(0 0 0 / 89.6%) 38.75%, + rgb(0 0 0 / 95.1%) 42.25%, + rgb(0 0 0 / 100%) 50% + ); + } + + &::after { + position: absolute; + z-index: -1; + inset: 0; + bottom: 25%; + content: ""; + backdrop-filter: blur(16px); + mask-image: linear-gradient( + to top, + rgb(0 0 0 / 0%) 0%, + rgb(0 0 0 / 4.9%) 15.5%, + rgb(0 0 0 / 10.4%) 22.5%, + rgb(0 0 0 / 45%) 47.1%, + rgb(0 0 0 / 55%) 52.9%, + rgb(0 0 0 / 89.6%) 77.5%, + rgb(0 0 0 / 95.1%) 91.9%, + rgb(0 0 0 / 100%) 100% + ); + } } .middle { + flex: 1; direction: ltr; } .bottom { + flex-shrink: 0; direction: ltr; + + /* 疑似progressive blur */ + &::before { + position: absolute; + z-index: -1; + inset: -30px 0 0 0; + content: ""; + backdrop-filter: blur(8px); + mask-image: linear-gradient( + to bottom, + rgb(0 0 0 / 0%) 0%, + rgb(0 0 0 / 4.9%) 7.75%, + rgb(0 0 0 / 10.4%) 11.25%, + rgb(0 0 0 / 45%) 23.55%, + rgb(0 0 0 / 55%) 26.45%, + rgb(0 0 0 / 89.6%) 38.75%, + rgb(0 0 0 / 95.1%) 42.25%, + rgb(0 0 0 / 100%) 50% + ); + pointer-events: none; + } + + &::after { + position: absolute; + z-index: -1; + inset: 0; + top: 25%; + content: ""; + backdrop-filter: blur(16px); + mask-image: linear-gradient( + to bottom, + rgb(0 0 0 / 0%) 0%, + rgb(0 0 0 / 4.9%) 15.5%, + rgb(0 0 0 / 10.4%) 22.5%, + rgb(0 0 0 / 45%) 47.1%, + rgb(0 0 0 / 55%) 52.9%, + rgb(0 0 0 / 89.6%) 77.5%, + rgb(0 0 0 / 95.1%) 91.9%, + rgb(0 0 0 / 100%) 100% + ); + } } .subButtons { @@ -290,29 +400,19 @@ function menuEdit() { } .top { + --top-height: 80px; + position: sticky; top: 0; z-index: 1; - padding: 20px 0; - background: var(--nav-bg-transparent); - -webkit-backdrop-filter: var(--MI-blur, blur(8px)); - backdrop-filter: var(--MI-blur, blur(8px)); + display: flex; + height: var(--top-height); + padding-left: 6px; } .instance { position: relative; - display: block; - text-align: center; - width: 100%; - - &:focus-visible { - outline: none; - - > .instanceIcon { - outline: 2px solid var(--MI_THEME-focus); - outline-offset: 2px; - } - } + width: var(--top-height); } .instanceIcon { @@ -322,13 +422,20 @@ function menuEdit() { border-radius: 8px; } + .realtimeMode { + display: inline-block; + width: var(--top-height); + margin-left: auto; + + &.on { + color: var(--MI_THEME-accent); + } + } + .bottom { position: sticky; bottom: 0; padding-top: 20px; - background: var(--nav-bg-transparent); - -webkit-backdrop-filter: var(--MI-blur, blur(8px)); - backdrop-filter: var(--MI-blur, blur(8px)); } .post { @@ -416,10 +523,6 @@ function menuEdit() { padding-right: 8px; } - .middle { - flex: 1; - } - .divider { margin: 16px 16px; border-top: solid 0.5px var(--MI_THEME-divider); @@ -520,9 +623,6 @@ function menuEdit() { top: 0; z-index: 1; padding: 20px 0; - background: var(--nav-bg-transparent); - -webkit-backdrop-filter: var(--MI-blur, blur(8px)); - backdrop-filter: var(--MI-blur, blur(8px)); } .instance { @@ -551,9 +651,6 @@ function menuEdit() { position: sticky; bottom: 0; padding-top: 20px; - background: var(--nav-bg-transparent); - -webkit-backdrop-filter: var(--MI-blur, blur(8px)); - backdrop-filter: var(--MI-blur, blur(8px)); } .widget { @@ -564,6 +661,18 @@ function menuEdit() { text-align: center; } + .realtimeMode { + display: block; + position: relative; + width: 100%; + height: 52px; + text-align: center; + + &.on { + color: var(--MI_THEME-accent); + } + } + .post { display: block; position: relative; @@ -637,10 +746,6 @@ function menuEdit() { display: none; } - .middle { - flex: 1; - } - .divider { margin: 8px auto; width: calc(100% - 32px); @@ -650,7 +755,7 @@ function menuEdit() { .item { display: block; position: relative; - padding: 18px 0; + padding: 16px 0; width: 100%; text-align: center; diff --git a/packages/frontend/src/ui/_common_/statusbar-federation.vue b/packages/frontend/src/ui/_common_/statusbar-federation.vue index 16e72fa227..7248e8826b 100644 --- a/packages/frontend/src/ui/_common_/statusbar-federation.vue +++ b/packages/frontend/src/ui/_common_/statusbar-federation.vue @@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only :leaveToClass="$style.transition_change_leaveTo" mode="default" > - <MarqueeText :key="key" :duration="marqueeDuration" :reverse="marqueeReverse"> + <MkMarqueeText :key="key" :duration="marqueeDuration" :reverse="marqueeReverse"> <span v-for="instance in instances" :key="instance.id" :class="[$style.item, { [$style.colored]: colored }]" :style="{ background: colored ? instance.themeColor : null }"> <img :class="$style.icon" :src="getInstanceIcon(instance)" alt=""/> <MkA :to="`/instance-info/${instance.host}`" :class="$style.host" class="_monospace"> @@ -21,7 +21,7 @@ SPDX-License-Identifier: AGPL-3.0-only </MkA> <span></span> </span> - </MarqueeText> + </MkMarqueeText> </Transition> </template> <template v-else-if="display === 'oneByOne'"> @@ -33,7 +33,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref } from 'vue'; import * as Misskey from 'misskey-js'; -import MarqueeText from '@/components/MkMarquee.vue'; +import MkMarqueeText from '@/components/MkMarqueeText.vue'; import { misskeyApi } from '@/utility/misskey-api.js'; import { useInterval } from '@@/js/use-interval.js'; import { getProxiedImageUrlNullable } from '@/utility/media-proxy.js'; diff --git a/packages/frontend/src/ui/_common_/statusbar-rss.vue b/packages/frontend/src/ui/_common_/statusbar-rss.vue index 4da89a181e..7db0d5267d 100644 --- a/packages/frontend/src/ui/_common_/statusbar-rss.vue +++ b/packages/frontend/src/ui/_common_/statusbar-rss.vue @@ -13,11 +13,11 @@ SPDX-License-Identifier: AGPL-3.0-only :leaveToClass="$style.transition_change_leaveTo" mode="default" > - <MarqueeText :key="key" :duration="marqueeDuration" :reverse="marqueeReverse"> + <MkMarqueeText :key="key" :duration="marqueeDuration" :reverse="marqueeReverse"> <span v-for="item in items" :class="$style.item"> <a :href="item.link" rel="nofollow noopener" target="_blank" :title="item.title">{{ item.title }}</a><span :class="$style.divider"></span> </span> - </MarqueeText> + </MkMarqueeText> </Transition> </template> <template v-else-if="display === 'oneByOne'"> @@ -29,7 +29,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref } from 'vue'; import * as Misskey from 'misskey-js'; -import MarqueeText from '@/components/MkMarquee.vue'; +import MkMarqueeText from '@/components/MkMarqueeText.vue'; import { useInterval } from '@@/js/use-interval.js'; import { shuffle } from '@/utility/shuffle.js'; diff --git a/packages/frontend/src/ui/_common_/statusbar-user-list.vue b/packages/frontend/src/ui/_common_/statusbar-user-list.vue index c5bee51162..13139a1064 100644 --- a/packages/frontend/src/ui/_common_/statusbar-user-list.vue +++ b/packages/frontend/src/ui/_common_/statusbar-user-list.vue @@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only :leaveToClass="$style.transition_change_leaveTo" mode="default" > - <MarqueeText :key="key" :duration="marqueeDuration" :reverse="marqueeReverse"> + <MkMarqueeText :key="key" :duration="marqueeDuration" :reverse="marqueeReverse"> <span v-for="note in notes" :key="note.id" :class="$style.item"> <img :class="$style.avatar" :src="note.user.avatarUrl" decoding="async"/> <MkA :class="$style.text" :to="notePage(note)"> @@ -21,7 +21,7 @@ SPDX-License-Identifier: AGPL-3.0-only </MkA> <span :class="$style.divider"></span> </span> - </MarqueeText> + </MkMarqueeText> </Transition> </template> <template v-else-if="display === 'oneByOne'"> @@ -33,7 +33,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref, watch } from 'vue'; import * as Misskey from 'misskey-js'; -import MarqueeText from '@/components/MkMarquee.vue'; +import MkMarqueeText from '@/components/MkMarqueeText.vue'; import { misskeyApi } from '@/utility/misskey-api.js'; import { useInterval } from '@@/js/use-interval.js'; import { getNoteSummary } from '@/utility/get-note-summary.js'; diff --git a/packages/frontend/src/ui/_common_/stream-indicator.vue b/packages/frontend/src/ui/_common_/stream-indicator.vue index 5f7600881f..35508b7ce6 100644 --- a/packages/frontend/src/ui/_common_/stream-indicator.vue +++ b/packages/frontend/src/ui/_common_/stream-indicator.vue @@ -20,6 +20,7 @@ import { i18n } from '@/i18n.js'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; import { prefer } from '@/preferences.js'; +import { store } from '@/store.js'; const zIndex = os.claimZIndex('high'); @@ -37,11 +38,13 @@ function reload() { window.location.reload(); } -useStream().on('_disconnected_', onDisconnected); +if (store.s.realtimeMode) { + useStream().on('_disconnected_', onDisconnected); -onUnmounted(() => { - useStream().off('_disconnected_', onDisconnected); -}); + onUnmounted(() => { + useStream().off('_disconnected_', onDisconnected); + }); +} </script> <style lang="scss" module> diff --git a/packages/frontend/src/ui/_common_/upload.vue b/packages/frontend/src/ui/_common_/upload.vue deleted file mode 100644 index 3e5653e46d..0000000000 --- a/packages/frontend/src/ui/_common_/upload.vue +++ /dev/null @@ -1,134 +0,0 @@ -<!-- -SPDX-FileCopyrightText: syuilo and misskey-project -SPDX-License-Identifier: AGPL-3.0-only ---> - -<template> -<div class="mk-uploader _acrylic" :style="{ zIndex }"> - <ol v-if="uploads.length > 0"> - <li v-for="ctx in uploads" :key="ctx.id"> - <div class="img" :style="{ backgroundImage: `url(${ ctx.img })` }"></div> - <div class="top"> - <p class="name"><MkLoading :em="true"/>{{ ctx.name }}</p> - <p class="status"> - <span v-if="ctx.progressValue === undefined" class="initing">{{ i18n.ts.waiting }}<MkEllipsis/></span> - <span v-if="ctx.progressValue !== undefined" class="kb">{{ String(Math.floor(ctx.progressValue / 1024)).replace(/(\d)(?=(\d\d\d)+(?!\d))/g, '$1,') }}<i>KB</i> / {{ String(Math.floor(ctx.progressMax / 1024)).replace(/(\d)(?=(\d\d\d)+(?!\d))/g, '$1,') }}<i>KB</i></span> - <span v-if="ctx.progressValue !== undefined" class="percentage">{{ Math.floor((ctx.progressValue / ctx.progressMax) * 100) }}</span> - </p> - </div> - <progress :value="ctx.progressValue || 0" :max="ctx.progressMax || 0" :class="{ initing: ctx.progressValue === undefined, waiting: ctx.progressValue !== undefined && ctx.progressValue === ctx.progressMax }"></progress> - </li> - </ol> -</div> -</template> - -<script lang="ts" setup> -import { } from 'vue'; -import * as os from '@/os.js'; -import { uploads } from '@/utility/upload.js'; -import { i18n } from '@/i18n.js'; - -const zIndex = os.claimZIndex('high'); -</script> - -<style lang="scss" scoped> -.mk-uploader { - position: fixed; - right: 16px; - width: 260px; - top: 32px; - padding: 16px 20px; - pointer-events: none; - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); - border-radius: 8px; -} -.mk-uploader:empty { - display: none; -} -.mk-uploader > ol { - display: block; - margin: 0; - padding: 0; - list-style: none; -} -.mk-uploader > ol > li { - display: grid; - margin: 8px 0 0 0; - padding: 0; - height: 36px; - width: 100%; - border-top: solid 8px transparent; - grid-template-columns: 36px calc(100% - 44px); - grid-template-rows: 1fr 8px; - column-gap: 8px; - box-sizing: content-box; -} -.mk-uploader > ol > li:first-child { - margin: 0; - box-shadow: none; - border-top: none; -} -.mk-uploader > ol > li > .img { - display: block; - background-size: cover; - background-position: center center; - grid-column: 1/2; - grid-row: 1/3; -} -.mk-uploader > ol > li > .top { - display: flex; - grid-column: 2/3; - grid-row: 1/2; -} -.mk-uploader > ol > li > .top > .name { - display: block; - padding: 0 8px 0 0; - margin: 0; - font-size: 0.8em; - white-space: nowrap; - text-overflow: ellipsis; - overflow: hidden; - flex-shrink: 1; -} -.mk-uploader > ol > li > .top > .name > i { - margin-right: 4px; -} -.mk-uploader > ol > li > .top > .status { - display: block; - margin: 0 0 0 auto; - padding: 0; - font-size: 0.8em; - flex-shrink: 0; -} -.mk-uploader > ol > li > .top > .status > .initing { -} -.mk-uploader > ol > li > .top > .status > .kb { -} -.mk-uploader > ol > li > .top > .status > .percentage { - display: inline-block; - width: 48px; - text-align: right; -} -.mk-uploader > ol > li > .top > .status > .percentage:after { - content: '%'; -} -.mk-uploader > ol > li > progress { - display: block; - background: transparent; - border: none; - border-radius: 4px; - overflow: hidden; - grid-column: 2/3; - grid-row: 2/3; - z-index: 2; - width: 100%; - height: 8px; -} -.mk-uploader > ol > li > progress::-webkit-progress-value { - background: var(--MI_THEME-accent); -} -.mk-uploader > ol > li > progress::-webkit-progress-bar { - //background: var(--MI_THEME-accentAlpha01); - background: transparent; -} -</style> diff --git a/packages/frontend/src/ui/deck/antenna-column.vue b/packages/frontend/src/ui/deck/antenna-column.vue index 194b56c842..716f0ba995 100644 --- a/packages/frontend/src/ui/deck/antenna-column.vue +++ b/packages/frontend/src/ui/deck/antenna-column.vue @@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only <i class="ti ti-antenna"></i><span style="margin-left: 8px;">{{ column.name || antennaName || i18n.ts._deck._columns.antenna }}</span> </template> - <MkTimeline v-if="column.antennaId" ref="timeline" src="antenna" :antenna="column.antennaId" @note="onNote"/> + <MkStreamingNotesTimeline v-if="column.antennaId" ref="timeline" src="antenna" :antenna="column.antennaId"/> </XColumn> </template> @@ -21,13 +21,12 @@ import type { Column } from '@/deck.js'; import type { MenuItem } from '@/types/menu.js'; import type { SoundStore } from '@/preferences/def.js'; import { updateColumn } from '@/deck.js'; -import MkTimeline from '@/components/MkTimeline.vue'; +import MkStreamingNotesTimeline from '@/components/MkStreamingNotesTimeline.vue'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; import { antennasCache } from '@/cache.js'; import { soundSettingsButton } from '@/ui/deck/tl-note-notification.js'; -import * as sound from '@/utility/sound.js'; const props = defineProps<{ column: Column; @@ -96,10 +95,6 @@ function editAntenna() { os.pageWindow('my/antennas/' + props.column.antennaId); } -function onNote() { - sound.playMisskeySfxFile(soundSetting.value); -} - const menu: MenuItem[] = [ { icon: 'ti ti-pencil', diff --git a/packages/frontend/src/ui/deck/channel-column.vue b/packages/frontend/src/ui/deck/channel-column.vue index c2644da707..3439a2a56e 100644 --- a/packages/frontend/src/ui/deck/channel-column.vue +++ b/packages/frontend/src/ui/deck/channel-column.vue @@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div style="padding: 8px; text-align: center;"> <MkButton primary gradate rounded inline small @click="post"><i class="ti ti-pencil"></i></MkButton> </div> - <MkTimeline ref="timeline" src="channel" :channel="column.channelId" @note="onNote"/> + <MkStreamingNotesTimeline ref="timeline" src="channel" :channel="column.channelId"/> </template> </XColumn> </template> @@ -26,14 +26,13 @@ import type { Column } from '@/deck.js'; import type { MenuItem } from '@/types/menu.js'; import type { SoundStore } from '@/preferences/def.js'; import { updateColumn } from '@/deck.js'; -import MkTimeline from '@/components/MkTimeline.vue'; +import MkStreamingNotesTimeline from '@/components/MkStreamingNotesTimeline.vue'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; import { favoritedChannelsCache } from '@/cache.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; import { soundSettingsButton } from '@/ui/deck/tl-note-notification.js'; -import * as sound from '@/utility/sound.js'; const props = defineProps<{ column: Column; @@ -90,10 +89,6 @@ async function post() { }); } -function onNote() { - sound.playMisskeySfxFile(soundSetting.value); -} - const menu: MenuItem[] = [{ icon: 'ti ti-pencil', text: i18n.ts.selectChannel, diff --git a/packages/frontend/src/ui/deck/column.vue b/packages/frontend/src/ui/deck/column.vue index 2085c73e03..4e79b301e3 100644 --- a/packages/frontend/src/ui/deck/column.vue +++ b/packages/frontend/src/ui/deck/column.vue @@ -51,6 +51,7 @@ import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; import { prefer } from '@/preferences.js'; import { DI } from '@/di.js'; +import { checkDragDataType, getDragData, setDragData } from '@/drag-and-drop.js'; provide('shouldHeaderThin', true); provide('shouldOmitHeaderTitle', true); @@ -262,7 +263,7 @@ function goTop() { function onDragstart(ev) { ev.dataTransfer.effectAllowed = 'move'; - ev.dataTransfer.setData(_DATA_TRANSFER_DECK_COLUMN_, props.column.id); + setDragData(ev, 'deckColumn', props.column.id); // Chromeのバグで、Dragstartハンドラ内ですぐにDOMを変更する(=リアクティブなプロパティを変更する)とDragが終了してしまう // SEE: https://stackoverflow.com/questions/19639969/html5-dragend-event-firing-immediately @@ -281,7 +282,7 @@ function onDragover(ev) { // 自分自身にはドロップさせない ev.dataTransfer.dropEffect = 'none'; } else { - const isDeckColumn = ev.dataTransfer.types[0] === _DATA_TRANSFER_DECK_COLUMN_; + const isDeckColumn = checkDragDataType(ev, ['deckColumn']); ev.dataTransfer.dropEffect = isDeckColumn ? 'move' : 'none'; @@ -297,8 +298,8 @@ function onDrop(ev) { draghover.value = false; os.deckGlobalEvents.emit('column.dragEnd'); - const id = ev.dataTransfer.getData(_DATA_TRANSFER_DECK_COLUMN_); - if (id != null && id !== '') { + const id = getDragData(ev, 'deckColumn'); + if (id != null) { swapColumn(props.column.id, id); } } diff --git a/packages/frontend/src/ui/deck/direct-column.vue b/packages/frontend/src/ui/deck/direct-column.vue index 772188d773..c8b174da09 100644 --- a/packages/frontend/src/ui/deck/direct-column.vue +++ b/packages/frontend/src/ui/deck/direct-column.vue @@ -7,15 +7,15 @@ SPDX-License-Identifier: AGPL-3.0-only <XColumn :column="column" :isStacked="isStacked" :refresher="() => reloadTimeline()"> <template #header><i class="ti ti-mail" style="margin-right: 8px;"></i>{{ column.name || i18n.ts._deck._columns.direct }}</template> - <MkNotes ref="tlComponent" :pagination="pagination"/> + <MkNotesTimeline ref="tlComponent" :pagination="pagination"/> </XColumn> </template> <script lang="ts" setup> -import { ref } from 'vue'; +import { ref, useTemplateRef } from 'vue'; import XColumn from './column.vue'; import type { Column } from '@/deck.js'; -import MkNotes from '@/components/MkNotes.vue'; +import MkNotesTimeline from '@/components/MkNotesTimeline.vue'; import { i18n } from '@/i18n.js'; defineProps<{ @@ -31,11 +31,11 @@ const pagination = { }, }; -const tlComponent = ref<InstanceType<typeof MkNotes>>(); +const tlComponent = useTemplateRef('tlComponent'); function reloadTimeline() { return new Promise<void>((res) => { - tlComponent.value?.pagingComponent?.reload().then(() => { + tlComponent.value?.reload().then(() => { res(); }); }); diff --git a/packages/frontend/src/ui/deck/list-column.vue b/packages/frontend/src/ui/deck/list-column.vue index a8f17feb23..5b7390b1b2 100644 --- a/packages/frontend/src/ui/deck/list-column.vue +++ b/packages/frontend/src/ui/deck/list-column.vue @@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only <i class="ti ti-list"></i><span style="margin-left: 8px;">{{ (column.name || listName) ?? i18n.ts._deck._columns.list }}</span> </template> - <MkTimeline v-if="column.listId" ref="timeline" src="list" :list="column.listId" :withRenotes="withRenotes" @note="onNote"/> + <MkStreamingNotesTimeline v-if="column.listId" ref="timeline" src="list" :list="column.listId" :withRenotes="withRenotes"/> </XColumn> </template> @@ -21,13 +21,12 @@ import type { Column } from '@/deck.js'; import type { MenuItem } from '@/types/menu.js'; import type { SoundStore } from '@/preferences/def.js'; import { updateColumn } from '@/deck.js'; -import MkTimeline from '@/components/MkTimeline.vue'; +import MkStreamingNotesTimeline from '@/components/MkStreamingNotesTimeline.vue'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; import { userListsCache } from '@/cache.js'; import { soundSettingsButton } from '@/ui/deck/tl-note-notification.js'; -import * as sound from '@/utility/sound.js'; const props = defineProps<{ column: Column; @@ -102,10 +101,6 @@ function editList() { os.pageWindow('my/lists/' + props.column.listId); } -function onNote() { - sound.playMisskeySfxFile(soundSetting.value); -} - const menu: MenuItem[] = [ { icon: 'ti ti-pencil', diff --git a/packages/frontend/src/ui/deck/mentions-column.vue b/packages/frontend/src/ui/deck/mentions-column.vue index ffd0307940..640e933f23 100644 --- a/packages/frontend/src/ui/deck/mentions-column.vue +++ b/packages/frontend/src/ui/deck/mentions-column.vue @@ -7,27 +7,27 @@ SPDX-License-Identifier: AGPL-3.0-only <XColumn :column="column" :isStacked="isStacked" :refresher="() => reloadTimeline()"> <template #header><i class="ti ti-at" style="margin-right: 8px;"></i>{{ column.name || i18n.ts._deck._columns.mentions }}</template> - <MkNotes ref="tlComponent" :pagination="pagination"/> + <MkNotesTimeline ref="tlComponent" :pagination="pagination"/> </XColumn> </template> <script lang="ts" setup> -import { ref } from 'vue'; +import { ref, useTemplateRef } from 'vue'; import XColumn from './column.vue'; import type { Column } from '@/deck.js'; -import MkNotes from '@/components/MkNotes.vue'; -import { i18n } from '../../i18n.js'; +import { i18n } from '@/i18n.js'; +import MkNotesTimeline from '@/components/MkNotesTimeline.vue'; defineProps<{ column: Column; isStacked: boolean; }>(); -const tlComponent = ref<InstanceType<typeof MkNotes>>(); +const tlComponent = useTemplateRef('tlComponent'); function reloadTimeline() { return new Promise<void>((res) => { - tlComponent.value?.pagingComponent?.reload().then(() => { + tlComponent.value?.reload().then(() => { res(); }); }); diff --git a/packages/frontend/src/ui/deck/notifications-column.vue b/packages/frontend/src/ui/deck/notifications-column.vue index 8378dddfef..0e84ed3572 100644 --- a/packages/frontend/src/ui/deck/notifications-column.vue +++ b/packages/frontend/src/ui/deck/notifications-column.vue @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only <XColumn :column="column" :isStacked="isStacked" :menu="menu" :refresher="async () => { await notificationsComponent?.reload() }"> <template #header><i class="ti ti-bell" style="margin-right: 8px;"></i>{{ column.name || i18n.ts._deck._columns.notifications }}</template> - <XNotifications ref="notificationsComponent" :excludeTypes="props.column.excludeTypes"/> + <MkStreamingNotificationsTimeline ref="notificationsComponent" :excludeTypes="props.column.excludeTypes"/> </XColumn> </template> @@ -16,7 +16,7 @@ import { defineAsyncComponent, useTemplateRef } from 'vue'; import XColumn from './column.vue'; import type { Column } from '@/deck.js'; import { updateColumn } from '@/deck.js'; -import XNotifications from '@/components/MkNotifications.vue'; +import MkStreamingNotificationsTimeline from '@/components/MkStreamingNotificationsTimeline.vue'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; diff --git a/packages/frontend/src/ui/deck/role-timeline-column.vue b/packages/frontend/src/ui/deck/role-timeline-column.vue index 468b3e49e0..ff00dfa6e0 100644 --- a/packages/frontend/src/ui/deck/role-timeline-column.vue +++ b/packages/frontend/src/ui/deck/role-timeline-column.vue @@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only <i class="ti ti-badge"></i><span style="margin-left: 8px;">{{ column.name || roleName || i18n.ts._deck._columns.roleTimeline }}</span> </template> - <MkTimeline v-if="column.roleId" ref="timeline" src="role" :role="column.roleId" @note="onNote"/> + <MkStreamingNotesTimeline v-if="column.roleId" ref="timeline" src="role" :role="column.roleId"/> </XColumn> </template> @@ -20,12 +20,11 @@ import type { Column } from '@/deck.js'; import type { MenuItem } from '@/types/menu.js'; import type { SoundStore } from '@/preferences/def.js'; import { updateColumn } from '@/deck.js'; -import MkTimeline from '@/components/MkTimeline.vue'; +import MkStreamingNotesTimeline from '@/components/MkStreamingNotesTimeline.vue'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; import { soundSettingsButton } from '@/ui/deck/tl-note-notification.js'; -import * as sound from '@/utility/sound.js'; const props = defineProps<{ column: Column; @@ -68,10 +67,6 @@ async function setRole() { }); } -function onNote() { - sound.playMisskeySfxFile(soundSetting.value); -} - const menu: MenuItem[] = [{ icon: 'ti ti-pencil', text: i18n.ts.role, diff --git a/packages/frontend/src/ui/deck/tl-column.vue b/packages/frontend/src/ui/deck/tl-column.vue index 6759135654..97208f1c6a 100644 --- a/packages/frontend/src/ui/deck/tl-column.vue +++ b/packages/frontend/src/ui/deck/tl-column.vue @@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only </p> <p :class="$style.disabledDescription">{{ i18n.ts._disabledTimeline.description }}</p> </div> - <MkTimeline + <MkStreamingNotesTimeline v-else-if="column.tl" ref="timeline" :key="column.tl + withRenotes + withReplies + onlyFiles" @@ -26,7 +26,6 @@ SPDX-License-Identifier: AGPL-3.0-only :withReplies="withReplies" :withSensitive="withSensitive" :onlyFiles="onlyFiles" - @note="onNote" /> </XColumn> </template> @@ -38,12 +37,11 @@ import type { Column } from '@/deck.js'; import type { MenuItem } from '@/types/menu.js'; import type { SoundStore } from '@/preferences/def.js'; import { removeColumn, updateColumn } from '@/deck.js'; -import MkTimeline from '@/components/MkTimeline.vue'; +import MkStreamingNotesTimeline from '@/components/MkStreamingNotesTimeline.vue'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; import { hasWithReplies, isAvailableBasicTimeline, basicTimelineIconClass } from '@/timelines.js'; import { soundSettingsButton } from '@/ui/deck/tl-note-notification.js'; -import * as sound from '@/utility/sound.js'; const props = defineProps<{ column: Column; @@ -117,10 +115,6 @@ async function setType() { }); } -function onNote() { - sound.playMisskeySfxFile(soundSetting.value); -} - const menu = computed<MenuItem[]>(() => { const menuItems: MenuItem[] = []; diff --git a/packages/frontend/src/use/use-note-capture.ts b/packages/frontend/src/use/use-note-capture.ts deleted file mode 100644 index 97aec4c1f0..0000000000 --- a/packages/frontend/src/use/use-note-capture.ts +++ /dev/null @@ -1,124 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { onUnmounted } from 'vue'; -import type { Ref, ShallowRef } from 'vue'; -import * as Misskey from 'misskey-js'; -import { useStream } from '@/stream.js'; -import { $i } from '@/i.js'; - -export function useNoteCapture(props: { - rootEl: ShallowRef<HTMLElement | undefined>; - note: Ref<Misskey.entities.Note>; - pureNote: Ref<Misskey.entities.Note>; - isDeletedRef: Ref<boolean>; -}) { - const note = props.note; - const pureNote = props.pureNote; - const connection = $i ? useStream() : null; - - function onStreamNoteUpdated(noteData): void { - const { type, id, body } = noteData; - - if ((id !== note.value.id) && (id !== pureNote.value.id)) return; - - switch (type) { - case 'reacted': { - const reaction = body.reaction; - - if (body.emoji && !(body.emoji.name in note.value.reactionEmojis)) { - note.value.reactionEmojis[body.emoji.name] = body.emoji.url; - } - - // TODO: reactionsプロパティがない場合ってあったっけ? なければ || {} は消せる - const currentCount = (note.value.reactions || {})[reaction] || 0; - - note.value.reactions[reaction] = currentCount + 1; - note.value.reactionCount += 1; - - if ($i && (body.userId === $i.id)) { - note.value.myReaction = reaction; - } - break; - } - - case 'unreacted': { - const reaction = body.reaction; - - // TODO: reactionsプロパティがない場合ってあったっけ? なければ || {} は消せる - const currentCount = (note.value.reactions || {})[reaction] || 0; - - note.value.reactions[reaction] = Math.max(0, currentCount - 1); - note.value.reactionCount = Math.max(0, note.value.reactionCount - 1); - if (note.value.reactions[reaction] === 0) delete note.value.reactions[reaction]; - - if ($i && (body.userId === $i.id)) { - note.value.myReaction = null; - } - break; - } - - case 'pollVoted': { - const choice = body.choice; - - const choices = [...note.value.poll.choices]; - choices[choice] = { - ...choices[choice], - votes: choices[choice].votes + 1, - ...($i && (body.userId === $i.id) ? { - isVoted: true, - } : {}), - }; - - note.value.poll.choices = choices; - break; - } - - case 'deleted': { - props.isDeletedRef.value = true; - break; - } - } - } - - function capture(withHandler = false): void { - if (connection) { - // TODO: このノートがストリーミング経由で流れてきた場合のみ sr する - connection.send(window.document.body.contains(props.rootEl.value ?? null as Node | null) ? 'sr' : 's', { id: note.value.id }); - if (pureNote.value.id !== note.value.id) connection.send('s', { id: pureNote.value.id }); - if (withHandler) connection.on('noteUpdated', onStreamNoteUpdated); - } - } - - function decapture(withHandler = false): void { - if (connection) { - connection.send('un', { - id: note.value.id, - }); - if (pureNote.value.id !== note.value.id) { - connection.send('un', { - id: pureNote.value.id, - }); - } - if (withHandler) connection.off('noteUpdated', onStreamNoteUpdated); - } - } - - function onStreamConnected() { - capture(false); - } - - capture(true); - if (connection) { - connection.on('_connected_', onStreamConnected); - } - - onUnmounted(() => { - decapture(true); - if (connection) { - connection.off('_connected_', onStreamConnected); - } - }); -} diff --git a/packages/frontend/src/utility/code-highlighter.ts b/packages/frontend/src/utility/code-highlighter.ts index 4f2aff9d4c..7dca18d58f 100644 --- a/packages/frontend/src/utility/code-highlighter.ts +++ b/packages/frontend/src/utility/code-highlighter.ts @@ -4,7 +4,7 @@ */ import { createHighlighterCore } from 'shiki/core'; -import { createOnigurumaEngine } from 'shiki/engine/oniguruma'; +import { createJavaScriptRegexEngine } from 'shiki/engine/javascript'; import darkPlus from 'shiki/themes/dark-plus.mjs'; import { bundledThemesInfo } from 'shiki/themes'; import { bundledLanguagesInfo } from 'shiki/langs'; @@ -71,7 +71,7 @@ async function initHighlighter() { const jsLangInfo = bundledLanguagesInfo.find(t => t.id === 'javascript'); const highlighter = await createHighlighterCore({ - engine: createOnigurumaEngine(() => import('shiki/onig.wasm?init')), + engine: createJavaScriptRegexEngine({ forgiving: true }), themes, langs: [ ...(jsLangInfo ? [async () => await jsLangInfo.import()] : []), diff --git a/packages/frontend/src/utility/drive.ts b/packages/frontend/src/utility/drive.ts new file mode 100644 index 0000000000..f171a4d14d --- /dev/null +++ b/packages/frontend/src/utility/drive.ts @@ -0,0 +1,301 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { defineAsyncComponent } from 'vue'; +import * as Misskey from 'misskey-js'; +import { apiUrl } from '@@/js/config.js'; +import * as os from '@/os.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { useStream } from '@/stream.js'; +import { i18n } from '@/i18n.js'; +import { prefer } from '@/preferences.js'; +import { $i } from '@/i.js'; +import { instance } from '@/instance.js'; +import { globalEvents } from '@/events.js'; +import { getProxiedImageUrl } from '@/utility/media-proxy.js'; + +type UploadReturnType = { + filePromise: Promise<Misskey.entities.DriveFile>; + abort: () => void; +}; + +export class UploadAbortedError extends Error { + constructor() { + super('Upload aborted'); + } +} + +export function uploadFile(file: File | Blob, options: { + name?: string; + folderId?: string | null; + onProgress?: (ctx: { total: number; loaded: number; }) => void; +} = {}): UploadReturnType { + const xhr = new XMLHttpRequest(); + const abortController = new AbortController(); + const { signal } = abortController; + + const filePromise = new Promise<Misskey.entities.DriveFile>((resolve, reject) => { + if ($i == null) return reject(); + + // こっち側で検出するMIME typeとサーバーで検出するMIME typeは異なる場合があるため、こっち側ではやらないことにする + // https://github.com/misskey-dev/misskey/issues/16091 + //const allowedMimeTypes = $i.policies.uploadableFileTypes; + //const isAllowedMimeType = allowedMimeTypes.some(mimeType => { + // if (mimeType === '*' || mimeType === '*/*') return true; + // if (mimeType.endsWith('/*')) return file.type.startsWith(mimeType.slice(0, -1)); + // return file.type === mimeType; + //}); + //if (!isAllowedMimeType) { + // os.alert({ + // type: 'error', + // title: i18n.ts.failedToUpload, + // text: i18n.ts.cannotUploadBecauseUnallowedFileType, + // }); + // return reject(); + //} + + if ((file.size > instance.maxFileSize) || (file.size > ($i.policies.maxFileSizeMb * 1024 * 1024))) { + os.alert({ + type: 'error', + title: i18n.ts.failedToUpload, + text: i18n.ts.cannotUploadBecauseExceedsFileSizeLimit, + }); + return reject(); + } + + signal.addEventListener('abort', () => { + reject(new UploadAbortedError()); + }, { once: true }); + + xhr.open('POST', apiUrl + '/drive/files/create', true); + xhr.onload = ((ev: ProgressEvent<XMLHttpRequest>) => { + if (xhr.status !== 200 || ev.target == null || ev.target.response == null) { + if (xhr.status === 413) { + os.alert({ + type: 'error', + title: i18n.ts.failedToUpload, + text: i18n.ts.cannotUploadBecauseExceedsFileSizeLimit, + }); + } else if (ev.target?.response) { + const res = JSON.parse(ev.target.response); + if (res.error?.id === 'bec5bd69-fba3-43c9-b4fb-2894b66ad5d2') { + os.alert({ + type: 'error', + title: i18n.ts.failedToUpload, + text: i18n.ts.cannotUploadBecauseInappropriate, + }); + } else if (res.error?.id === 'd08dbc37-a6a9-463a-8c47-96c32ab5f064') { + os.alert({ + type: 'error', + title: i18n.ts.failedToUpload, + text: i18n.ts.cannotUploadBecauseNoFreeSpace, + }); + } else if (res.error?.id === '4becd248-7f2c-48c4-a9f0-75edc4f9a1ea') { + os.alert({ + type: 'error', + title: i18n.ts.failedToUpload, + text: i18n.ts.cannotUploadBecauseUnallowedFileType, + }); + } else { + os.alert({ + type: 'error', + title: i18n.ts.failedToUpload, + text: `${res.error?.message}\n${res.error?.code}\n${res.error?.id}`, + }); + } + } else { + os.alert({ + type: 'error', + title: 'Failed to upload', + text: `${JSON.stringify(ev.target?.response)}, ${JSON.stringify(xhr.response)}`, + }); + } + + reject(); + return; + } + + const driveFile = JSON.parse(ev.target.response); + globalEvents.emit('driveFileCreated', driveFile); + resolve(driveFile); + }) as (ev: ProgressEvent<EventTarget>) => any; + + if (options.onProgress) { + xhr.upload.onprogress = ev => { + if (ev.lengthComputable && options.onProgress != null) { + options.onProgress({ + total: ev.total, + loaded: ev.loaded, + }); + } + }; + } + + const formData = new FormData(); + formData.append('i', $i.token); + formData.append('force', 'true'); + formData.append('file', file); + formData.append('name', options.name ?? (file instanceof File ? file.name : 'untitled')); + if (options.folderId) formData.append('folderId', options.folderId); + + xhr.send(formData); + }); + + const abort = () => { + xhr.abort(); + abortController.abort(); + }; + + return { filePromise, abort }; +} + +export function chooseFileFromPcAndUpload( + options: { + multiple?: boolean; + folderId?: string | null; + } = {}, +): Promise<Misskey.entities.DriveFile[]> { + return new Promise((res, rej) => { + os.chooseFileFromPc({ multiple: options.multiple }).then(files => { + if (files.length === 0) return; + os.launchUploader(files, { + folderId: options.folderId, + }).then(driveFiles => { + res(driveFiles); + }); + }); + }); +} + +export function chooseDriveFile(options: { + multiple?: boolean; +} = {}): Promise<Misskey.entities.DriveFile[]> { + return new Promise(resolve => { + const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkDriveFileSelectDialog.vue')), { + multiple: options.multiple ?? false, + }, { + done: files => { + if (files) { + resolve(files); + } + }, + closed: () => dispose(), + }); + }); +} + +export function chooseFileFromUrl(): Promise<Misskey.entities.DriveFile> { + return new Promise((res, rej) => { + os.inputText({ + title: i18n.ts.uploadFromUrl, + type: 'url', + placeholder: i18n.ts.uploadFromUrlDescription, + }).then(({ canceled, result: url }) => { + if (canceled) return; + + const marker = Math.random().toString(); // TODO: UUIDとか使う + + // TODO: no websocketモード対応 + const connection = useStream().useChannel('main'); + connection.on('urlUploadFinished', urlResponse => { + if (urlResponse.marker === marker) { + res(urlResponse.file); + connection.dispose(); + } + }); + + misskeyApi('drive/files/upload-from-url', { + url: url, + folderId: prefer.s.uploadFolder, + marker, + }); + + os.alert({ + title: i18n.ts.uploadFromUrlRequested, + text: i18n.ts.uploadFromUrlMayTakeTime, + }); + }); + }); +} + +function select(anchorElement: HTMLElement | EventTarget | null, label: string | null, multiple: boolean): Promise<Misskey.entities.DriveFile[]> { + return new Promise((res, rej) => { + os.popupMenu([label ? { + text: label, + type: 'label', + } : undefined, { + text: i18n.ts.upload, + icon: 'ti ti-upload', + action: () => chooseFileFromPcAndUpload({ multiple }).then(files => res(files)), + }, { + text: i18n.ts.fromDrive, + icon: 'ti ti-cloud', + action: () => chooseDriveFile({ multiple }).then(files => res(files)), + }, { + text: i18n.ts.fromUrl, + icon: 'ti ti-link', + action: () => chooseFileFromUrl().then(file => res([file])), + }], anchorElement); + }); +} + +export function selectFile(anchorElement: HTMLElement | EventTarget | null, label: string | null = null): Promise<Misskey.entities.DriveFile> { + return select(anchorElement, label, false).then(files => files[0]); +} + +export function selectFiles(anchorElement: HTMLElement | EventTarget | null, label: string | null = null): Promise<Misskey.entities.DriveFile[]> { + return select(anchorElement, label, true); +} + +export async function createCroppedImageDriveFileFromImageDriveFile(imageDriveFile: Misskey.entities.DriveFile, options: { + aspectRatio: number | null; +}): Promise<Misskey.entities.DriveFile> { + return new Promise((resolve, reject) => { + const imgUrl = getProxiedImageUrl(imageDriveFile.url, undefined, true); + const image = new Image(); + image.src = imgUrl; + image.onload = () => { + const canvas = window.document.createElement('canvas'); + const ctx = canvas.getContext('2d')!; + canvas.width = image.width; + canvas.height = image.height; + ctx.drawImage(image, 0, 0); + canvas.toBlob(blob => { + if (blob == null) { + reject(); + return; + } + + os.cropImageFile(blob, { + aspectRatio: options.aspectRatio, + }).then(croppedImageFile => { + const { filePromise } = uploadFile(croppedImageFile, { + name: imageDriveFile.name, + folderId: imageDriveFile.folderId, + }); + + filePromise.then(driveFile => { + resolve(driveFile); + }); + }); + }); + }; + }); +} + +export async function selectDriveFolder(initialFolder: Misskey.entities.DriveFolder['id'] | null): Promise<Misskey.entities.DriveFolder[]> { + return new Promise(resolve => { + const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkDriveFolderSelectDialog.vue')), { + initialFolder, + }, { + done: folders => { + if (folders) { + resolve(folders); + } + }, + closed: () => dispose(), + }); + }); +} diff --git a/packages/frontend/src/utility/emoji-mute.ts b/packages/frontend/src/utility/emoji-mute.ts new file mode 100644 index 0000000000..a058bcc242 --- /dev/null +++ b/packages/frontend/src/utility/emoji-mute.ts @@ -0,0 +1,61 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { computed } from 'vue'; +import { prefer } from '@/preferences.js'; + +// custom絵文字の情報からキーを作成する +export function makeEmojiMuteKey(props: { name: string; host?: string | null }) { + return props.name.startsWith(':') ? props.name : `:${props.name}${props.host ? `@${props.host}` : ''}:`; +} + +export function extractCustomEmojiName (name:string) { + return (name[0] === ':' ? name.substring(1, name.length - 1) : name).replace('@.', '').split('@')[0]; +} + +export function extractCustomEmojiHost (name:string) { + // nameは:emojiName@host:の形式 + // 取り出したい部分はhostなので、@以降を取り出す + const index = name.indexOf('@'); + if (index === -1) { + return null; + } + const host = name.substring(index + 1, name.length - 1); + if (host === '' || host === '.') { + return null; + } + return host; +} + +export function mute(emoji: string) { + const isCustomEmoji = emoji.startsWith(':') && emoji.endsWith(':'); + const emojiMuteKey = isCustomEmoji ? + makeEmojiMuteKey({ name: extractCustomEmojiName(emoji), host: extractCustomEmojiHost(emoji) }) : + emoji; + const mutedEmojis = prefer.r.mutingEmojis.value; + if (!mutedEmojis.includes(emojiMuteKey)) { + return prefer.commit('mutingEmojis', [...mutedEmojis, emojiMuteKey]); + } + throw new Error('Emoji is already muted', { cause: `${emojiMuteKey} is Already Muted` }); +} + +export function unmute(emoji:string) { + const isCustomEmoji = emoji.startsWith(':') && emoji.endsWith(':'); + const emojiMuteKey = isCustomEmoji ? + makeEmojiMuteKey({ name: extractCustomEmojiName(emoji), host: extractCustomEmojiHost(emoji) }) : + emoji; + const mutedEmojis = prefer.r.mutingEmojis.value; + console.log('unmute', emoji, emojiMuteKey); + console.log('mutedEmojis', mutedEmojis); + prefer.commit('mutingEmojis', mutedEmojis.filter((e) => e !== emojiMuteKey)); +} + +export function checkMuted(emoji: string) { + const isCustomEmoji = emoji.startsWith(':') && emoji.endsWith(':'); + const emojiMuteKey = isCustomEmoji ? + makeEmojiMuteKey({ name: extractCustomEmojiName(emoji), host: extractCustomEmojiHost(emoji) }) : + emoji; + return computed(() => prefer.r.mutingEmojis.value.includes(emojiMuteKey)); +} diff --git a/packages/frontend/src/utility/emoji-picker.ts b/packages/frontend/src/utility/emoji-picker.ts index 6279786b2d..c663776de3 100644 --- a/packages/frontend/src/utility/emoji-picker.ts +++ b/packages/frontend/src/utility/emoji-picker.ts @@ -15,7 +15,7 @@ import { prefer } from '@/preferences.js'; * 一度表示したダイアログを連続で使用できることが望ましいシーンでの利用が想定される。 */ class EmojiPicker { - private src: Ref<HTMLElement | null> = ref(null); + private anchorElement: Ref<HTMLElement | null> = ref(null); private manualShowing = ref(false); private onChosen?: (emoji: string) => void; private onClosed?: () => void; @@ -34,7 +34,7 @@ class EmojiPicker { }); await popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerDialog.vue')), { - src: this.src, + anchorElement: this.anchorElement, pinnedEmojis: emojisRef, asReactionPicker: false, manualShowing: this.manualShowing, @@ -47,18 +47,18 @@ class EmojiPicker { this.manualShowing.value = false; }, closed: () => { - this.src.value = null; + this.anchorElement.value = null; if (this.onClosed) this.onClosed(); }, }); } public show( - src: HTMLElement, + anchorElement: HTMLElement, onChosen?: EmojiPicker['onChosen'], onClosed?: EmojiPicker['onClosed'], ) { - this.src.value = src; + this.anchorElement.value = anchorElement; this.manualShowing.value = true; this.onChosen = onChosen; this.onClosed = onClosed; diff --git a/packages/frontend/src/utility/get-drive-file-menu.ts b/packages/frontend/src/utility/get-drive-file-menu.ts index 3c6cbba002..4b4bab3125 100644 --- a/packages/frontend/src/utility/get-drive-file-menu.ts +++ b/packages/frontend/src/utility/get-drive-file-menu.ts @@ -5,12 +5,14 @@ import * as Misskey from 'misskey-js'; import { defineAsyncComponent } from 'vue'; +import { selectDriveFolder } from './drive.js'; import type { MenuItem } from '@/types/menu.js'; import { i18n } from '@/i18n.js'; import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { prefer } from '@/preferences.js'; +import { globalEvents } from '@/events.js'; function rename(file: Misskey.entities.DriveFile) { os.inputText({ @@ -42,7 +44,7 @@ function describe(file: Misskey.entities.DriveFile) { } function move(file: Misskey.entities.DriveFile) { - os.selectDriveFolder(false).then(folder => { + selectDriveFolder(null).then(folder => { misskeyApi('drive/files/update', { fileId: file.id, folderId: folder[0] ? folder[0].id : null, @@ -77,11 +79,13 @@ async function deleteFile(file: Misskey.entities.DriveFile) { type: 'warning', text: i18n.tsx.driveFileDeleteConfirm({ name: file.name }), }); - if (canceled) return; - misskeyApi('drive/files/delete', { + + await os.apiWithDialog('drive/files/delete', { fileId: file.id, }); + + globalEvents.emit('driveFilesDeleted', [file]); } export function getDriveFileMenu(file: Misskey.entities.DriveFile, folder?: Misskey.entities.DriveFolder | null): MenuItem[] { @@ -112,17 +116,6 @@ export function getDriveFileMenu(file: Misskey.entities.DriveFile, folder?: Miss action: () => describe(file), }); - if (isImage) { - menuItems.push({ - text: i18n.ts.cropImage, - icon: 'ti ti-crop', - action: () => os.cropImage(file, { - aspectRatio: NaN, - uploadFolder: folder ? folder.id : folder, - }), - }); - } - menuItems.push({ type: 'divider' }, { text: i18n.ts.createNoteFromTheFile, icon: 'ti ti-pencil', diff --git a/packages/frontend/src/utility/get-note-menu.ts b/packages/frontend/src/utility/get-note-menu.ts index dd8bdf43d7..dc813cb78e 100644 --- a/packages/frontend/src/utility/get-note-menu.ts +++ b/packages/frontend/src/utility/get-note-menu.ts @@ -25,10 +25,10 @@ import { getAppearNote } from '@/utility/get-appear-note.js'; import { genEmbedCode } from '@/utility/get-embed-code.js'; import { prefer } from '@/preferences.js'; import { getPluginHandlers } from '@/plugin.js'; +import { globalEvents } from '@/events.js'; export async function getNoteClipMenu(props: { note: Misskey.entities.Note; - isDeleted: Ref<boolean>; currentClip?: Misskey.entities.Clip; }) { function getClipName(clip: Misskey.entities.Clip) { @@ -68,7 +68,6 @@ export async function getNoteClipMenu(props: { } })); }); - if (props.currentClip?.id === clip.id) props.isDeleted.value = true; } } else if (err.id === 'f0dba960-ff73-4615-8df4-d6ac5d9dc118') { os.alert({ @@ -178,7 +177,6 @@ export function getNoteMenu(props: { note: Misskey.entities.Note; translation: Ref<Misskey.entities.NotesTranslateResponse | null>; translating: Ref<boolean>; - isDeleted: Ref<boolean>; currentClip?: Misskey.entities.Clip; }) { const appearNote = getAppearNote(props.note); @@ -194,6 +192,8 @@ export function getNoteMenu(props: { misskeyApi('notes/delete', { noteId: appearNote.id, + }).then(() => { + globalEvents.emit('noteDeleted', appearNote.id); }); if (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 60 && appearNote.userId === $i.id) { @@ -211,6 +211,8 @@ export function getNoteMenu(props: { misskeyApi('notes/delete', { noteId: appearNote.id, + }).then(() => { + globalEvents.emit('noteDeleted', appearNote.id); }); os.post({ initialNote: appearNote, renote: appearNote.renote, reply: appearNote.reply, channel: appearNote.channel }); @@ -251,7 +253,6 @@ export function getNoteMenu(props: { async function unclip(): Promise<void> { if (!props.currentClip) return; os.apiWithDialog('clips/remove-note', { clipId: props.currentClip.id, noteId: appearNote.id }); - props.isDeleted.value = true; } async function promote(): Promise<void> { @@ -569,8 +570,9 @@ export function getRenoteMenu(props: { misskeyApi('notes/create', { renoteId: appearNote.id, channelId: appearNote.channelId, - }).then(() => { + }).then((res) => { os.toast(i18n.ts.renoted); + globalEvents.emit('notePosted', res.createdNote); }); } }, @@ -617,8 +619,9 @@ export function getRenoteMenu(props: { localOnly, visibility, renoteId: appearNote.id, - }).then(() => { + }).then((res) => { os.toast(i18n.ts.renoted); + globalEvents.emit('notePosted', res.createdNote); }); } }, @@ -658,8 +661,9 @@ export function getRenoteMenu(props: { misskeyApi('notes/create', { renoteId: appearNote.id, channelId: channel.id, - }).then(() => { + }).then((res) => { os.toast(i18n.tsx.renotedToX({ name: channel.name })); + globalEvents.emit('notePosted', res.createdNote); }); } }, diff --git a/packages/frontend/src/utility/upload/isWebpSupported.ts b/packages/frontend/src/utility/isWebpSupported.ts index affd81fd57..affd81fd57 100644 --- a/packages/frontend/src/utility/upload/isWebpSupported.ts +++ b/packages/frontend/src/utility/isWebpSupported.ts diff --git a/packages/frontend/src/utility/mfm-function-picker.ts b/packages/frontend/src/utility/mfm-function-picker.ts index a2f777f623..09802d580b 100644 --- a/packages/frontend/src/utility/mfm-function-picker.ts +++ b/packages/frontend/src/utility/mfm-function-picker.ts @@ -4,20 +4,20 @@ */ import { nextTick } from 'vue'; +import { MFM_TAGS } from '@@/js/const.js'; import type { Ref } from 'vue'; +import type { MenuItem } from '@/types/menu.js'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; -import { MFM_TAGS } from '@@/js/const.js'; -import type { MenuItem } from '@/types/menu.js'; /** * MFMの装飾のリストを表示する */ -export function mfmFunctionPicker(src: HTMLElement | EventTarget | null, textArea: HTMLInputElement | HTMLTextAreaElement, textRef: Ref<string>) { +export function mfmFunctionPicker(anchorElement: HTMLElement | EventTarget | null, textArea: HTMLInputElement | HTMLTextAreaElement, textRef: Ref<string>) { os.popupMenu([{ text: i18n.ts.addMfmFunction, type: 'label', - }, ...getFunctionList(textArea, textRef)], src); + }, ...getFunctionList(textArea, textRef)], anchorElement); } function getFunctionList(textArea: HTMLInputElement | HTMLTextAreaElement, textRef: Ref<string>): MenuItem[] { diff --git a/packages/frontend/src/utility/misskey-api.ts b/packages/frontend/src/utility/misskey-api.ts index 72ba54ade3..f10249878f 100644 --- a/packages/frontend/src/utility/misskey-api.ts +++ b/packages/frontend/src/utility/misskey-api.ts @@ -9,24 +9,12 @@ import { apiUrl } from '@@/js/config.js'; import { $i } from '@/i.js'; export const pendingApiRequestsCount = ref(0); -export type Endpoint = keyof Misskey.Endpoints; - -export type Request<E extends Endpoint> = Misskey.Endpoints[E]['req']; - -export type AnyRequest<E extends Endpoint | (string & unknown)> = - (E extends Endpoint ? Request<E> : never) | object; - -export type Response<E extends Endpoint | (string & unknown), P extends AnyRequest<E>> = - E extends Endpoint - ? P extends Request<E> ? Misskey.api.SwitchCaseResponseType<E, P> : never - : object; - // Implements Misskey.api.ApiClient.request export function misskeyApi< ResT = void, - E extends Endpoint | NonNullable<string> = Endpoint, - P extends AnyRequest<E> = E extends Endpoint ? Request<E> : never, - _ResT = ResT extends void ? Response<E, P> : ResT, + E extends keyof Misskey.Endpoints = keyof Misskey.Endpoints, + P extends Misskey.Endpoints[E]['req'] = Misskey.Endpoints[E]['req'], + _ResT = ResT extends void ? Misskey.api.SwitchCaseResponseType<E, P> : ResT, >( endpoint: E, data: P & { i?: string | null; } = {} as any, diff --git a/packages/frontend/src/utility/popup-position.ts b/packages/frontend/src/utility/popup-position.ts index 3dad41a8b3..676dfb7507 100644 --- a/packages/frontend/src/utility/popup-position.ts +++ b/packages/frontend/src/utility/popup-position.ts @@ -39,6 +39,10 @@ export function calcPopupPosition(el: HTMLElement, props: { left = window.innerWidth - contentWidth + window.scrollX - 1; } + if (left < window.scrollX) { + left = window.scrollX; + } + return [left, top]; }; @@ -60,6 +64,10 @@ export function calcPopupPosition(el: HTMLElement, props: { left = window.innerWidth - contentWidth + window.scrollX - 1; } + if (left < window.scrollX) { + left = window.scrollX; + } + return [left, top]; }; @@ -81,6 +89,10 @@ export function calcPopupPosition(el: HTMLElement, props: { top = window.innerHeight - contentHeight + window.scrollY - 1; } + if (left < window.scrollX) { + left = window.scrollX; + } + return [left, top]; }; @@ -110,6 +122,10 @@ export function calcPopupPosition(el: HTMLElement, props: { top = window.innerHeight - contentHeight + window.scrollY - 1; } + if (left < window.scrollX) { + left = window.scrollX; + } + return [left, top]; }; diff --git a/packages/frontend/src/utility/reaction-picker.ts b/packages/frontend/src/utility/reaction-picker.ts index 7c159fa2da..e33346101d 100644 --- a/packages/frontend/src/utility/reaction-picker.ts +++ b/packages/frontend/src/utility/reaction-picker.ts @@ -10,7 +10,7 @@ import { popup } from '@/os.js'; import { prefer } from '@/preferences.js'; class ReactionPicker { - private src: Ref<HTMLElement | null> = ref(null); + private anchorElement: Ref<HTMLElement | null> = ref(null); private manualShowing = ref(false); private targetNote: Ref<Misskey.entities.Note | null> = ref(null); private onChosen?: (reaction: string) => void; @@ -30,7 +30,7 @@ class ReactionPicker { }); await popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerDialog.vue')), { - src: this.src, + anchorElement: this.anchorElement, pinnedEmojis: reactionsRef, asReactionPicker: true, targetNote: this.targetNote, @@ -43,14 +43,14 @@ class ReactionPicker { this.manualShowing.value = false; }, closed: () => { - this.src.value = null; + this.anchorElement.value = null; if (this.onClosed) this.onClosed(); }, }); } - public show(src: HTMLElement | null, targetNote: Misskey.entities.Note | null, onChosen?: ReactionPicker['onChosen'], onClosed?: ReactionPicker['onClosed']) { - this.src.value = src; + public show(anchorElement: HTMLElement | null, targetNote: Misskey.entities.Note | null, onChosen?: ReactionPicker['onChosen'], onClosed?: ReactionPicker['onClosed']) { + this.anchorElement.value = anchorElement; this.targetNote.value = targetNote; this.manualShowing.value = true; this.onChosen = onChosen; diff --git a/packages/frontend/src/utility/select-file.ts b/packages/frontend/src/utility/select-file.ts deleted file mode 100644 index 731ef58302..0000000000 --- a/packages/frontend/src/utility/select-file.ts +++ /dev/null @@ -1,128 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { ref } from 'vue'; -import * as Misskey from 'misskey-js'; -import * as os from '@/os.js'; -import { misskeyApi } from '@/utility/misskey-api.js'; -import { useStream } from '@/stream.js'; -import { i18n } from '@/i18n.js'; -import { uploadFile } from '@/utility/upload.js'; -import { prefer } from '@/preferences.js'; - -export function chooseFileFromPc( - multiple: boolean, - options?: { - uploadFolder?: string | null; - keepOriginal?: boolean; - nameConverter?: (file: File) => string | undefined; - }, -): Promise<Misskey.entities.DriveFile[]> { - const uploadFolder = options?.uploadFolder ?? prefer.s.uploadFolder; - const keepOriginal = options?.keepOriginal ?? false; - const nameConverter = options?.nameConverter ?? (() => undefined); - - return new Promise((res, rej) => { - const input = window.document.createElement('input'); - input.type = 'file'; - input.multiple = multiple; - input.onchange = () => { - if (!input.files) return res([]); - const promises = Array.from( - input.files, - file => uploadFile(file, uploadFolder, nameConverter(file), keepOriginal), - ); - - Promise.all(promises).then(driveFiles => { - res(driveFiles); - }).catch(err => { - // アップロードのエラーは uploadFile 内でハンドリングされているためアラートダイアログを出したりはしてはいけない - }); - - // 一応廃棄 - (window as any).__misskey_input_ref__ = null; - }; - - // https://qiita.com/fukasawah/items/b9dc732d95d99551013d - // iOS Safari で正常に動かす為のおまじない - (window as any).__misskey_input_ref__ = input; - - input.click(); - }); -} - -export function chooseFileFromDrive(multiple: boolean): Promise<Misskey.entities.DriveFile[]> { - return new Promise((res, rej) => { - os.selectDriveFile(multiple).then(files => { - res(files); - }); - }); -} - -export function chooseFileFromUrl(): Promise<Misskey.entities.DriveFile> { - return new Promise((res, rej) => { - os.inputText({ - title: i18n.ts.uploadFromUrl, - type: 'url', - placeholder: i18n.ts.uploadFromUrlDescription, - }).then(({ canceled, result: url }) => { - if (canceled) return; - - const marker = Math.random().toString(); // TODO: UUIDとか使う - - const connection = useStream().useChannel('main'); - connection.on('urlUploadFinished', urlResponse => { - if (urlResponse.marker === marker) { - res(urlResponse.file); - connection.dispose(); - } - }); - - misskeyApi('drive/files/upload-from-url', { - url: url, - folderId: prefer.s.uploadFolder, - marker, - }); - - os.alert({ - title: i18n.ts.uploadFromUrlRequested, - text: i18n.ts.uploadFromUrlMayTakeTime, - }); - }); - }); -} - -function select(src: HTMLElement | EventTarget | null, label: string | null, multiple: boolean): Promise<Misskey.entities.DriveFile[]> { - return new Promise((res, rej) => { - os.popupMenu([label ? { - text: label, - type: 'label', - } : undefined, { - text: i18n.ts.upload + ' (' + i18n.ts.compress + ')', - icon: 'ti ti-upload', - action: () => chooseFileFromPc(multiple, { keepOriginal: false }).then(files => res(files)), - }, { - text: i18n.ts.upload, - icon: 'ti ti-upload', - action: () => chooseFileFromPc(multiple, { keepOriginal: true }).then(files => res(files)), - }, { - text: i18n.ts.fromDrive, - icon: 'ti ti-cloud', - action: () => chooseFileFromDrive(multiple).then(files => res(files)), - }, { - text: i18n.ts.fromUrl, - icon: 'ti ti-link', - action: () => chooseFileFromUrl().then(file => res([file])), - }], src); - }); -} - -export function selectFile(src: HTMLElement | EventTarget | null, label: string | null = null): Promise<Misskey.entities.DriveFile> { - return select(src, label, false).then(files => files[0]); -} - -export function selectFiles(src: HTMLElement | EventTarget | null, label: string | null = null): Promise<Misskey.entities.DriveFile[]> { - return select(src, label, true); -} diff --git a/packages/frontend/src/utility/sound.ts b/packages/frontend/src/utility/sound.ts index d3f82a37f2..8e79841647 100644 --- a/packages/frontend/src/utility/sound.ts +++ b/packages/frontend/src/utility/sound.ts @@ -6,6 +6,7 @@ import type { SoundStore } from '@/preferences/def.js'; import { prefer } from '@/preferences.js'; import { PREF_DEF } from '@/preferences/def.js'; +import { getInitialPrefValue } from '@/preferences/manager.js'; let ctx: AudioContext; const cache = new Map<string, AudioBuffer>(); @@ -133,7 +134,8 @@ export function playMisskeySfx(operationType: OperationType) { playMisskeySfxFile(sound).then((succeed) => { if (!succeed && sound.type === '_driveFile_') { // ドライブファイルが存在しない場合はデフォルトのサウンドを再生する - const soundName = PREF_DEF[`sound_${operationType}`].default.type as Exclude<SoundType, '_driveFile_'>; + const default_ = getInitialPrefValue(`sound.on.${operationType}`); + const soundName = default_.type as Exclude<SoundType, '_driveFile_'>; if (_DEV_) console.log(`Failed to play sound: ${sound.fileUrl}, so play default sound: ${soundName}`); playMisskeySfxFileInternal({ type: soundName, diff --git a/packages/frontend/src/utility/timeline-date-separate.ts b/packages/frontend/src/utility/timeline-date-separate.ts index e1bc9790b9..33ddea048b 100644 --- a/packages/frontend/src/utility/timeline-date-separate.ts +++ b/packages/frontend/src/utility/timeline-date-separate.ts @@ -4,7 +4,7 @@ */ import { computed } from 'vue'; -import type { Ref } from 'vue'; +import type { Ref, ShallowRef } from 'vue'; export function getDateText(dateInstance: Date) { const date = dateInstance.getDate(); @@ -12,6 +12,37 @@ export function getDateText(dateInstance: Date) { return `${month.toString()}/${date.toString()}`; } +// TODO: いちいちDateインスタンス作成するのは無駄感あるから文字列のまま解析したい +export function isSeparatorNeeded( + prev: string | null, + next: string | null, +) { + if (prev == null || next == null) return false; + const prevDate = new Date(prev); + const nextDate = new Date(next); + return ( + prevDate.getFullYear() !== nextDate.getFullYear() || + prevDate.getMonth() !== nextDate.getMonth() || + prevDate.getDate() !== nextDate.getDate() + ); +} + +// TODO: いちいちDateインスタンス作成するのは無駄感あるから文字列のまま解析したい +export function getSeparatorInfo( + prev: string | null, + next: string | null, +) { + if (prev == null || next == null) return null; + const prevDate = new Date(prev); + const nextDate = new Date(next); + return { + prevDate, + prevText: getDateText(prevDate), + nextDate, + nextText: getDateText(nextDate), + }; +} + export type DateSeparetedTimelineItem<T> = { id: string; type: 'item'; @@ -25,7 +56,7 @@ export type DateSeparetedTimelineItem<T> = { nextText: string; }; -export function makeDateSeparatedTimelineComputedRef<T extends { id: string; createdAt: string; }>(items: Ref<T[]>) { +export function makeDateSeparatedTimelineComputedRef<T extends { id: string; createdAt: string; }>(items: Ref<T[]> | ShallowRef<T[]>) { return computed<DateSeparetedTimelineItem<T>[]>(() => { const tl: DateSeparetedTimelineItem<T>[] = []; for (let i = 0; i < items.value.length; i++) { @@ -61,3 +92,35 @@ export function makeDateSeparatedTimelineComputedRef<T extends { id: string; cre return tl; }); } + +export type DateGroupedTimelineItem<T> = { + date: Date; + items: T[]; +}; + +export function makeDateGroupedTimelineComputedRef<T extends { id: string; createdAt: string; }>(items: Ref<T[]> | ShallowRef<T[]>, span: 'day' | 'month' = 'day') { + return computed<DateGroupedTimelineItem<T>[]>(() => { + const tl: DateGroupedTimelineItem<T>[] = []; + for (let i = 0; i < items.value.length; i++) { + const item = items.value[i]; + const date = new Date(item.createdAt); + const nextDate = items.value[i + 1] ? new Date(items.value[i + 1].createdAt) : null; + + if (tl.length === 0 || ( + span === 'day' && tl[tl.length - 1].date.getTime() !== date.getTime() + ) || ( + span === 'month' && ( + tl[tl.length - 1].date.getFullYear() !== date.getFullYear() || + tl[tl.length - 1].date.getMonth() !== date.getMonth() + ) + )) { + tl.push({ + date, + items: [], + }); + } + tl[tl.length - 1].items.push(item); + } + return tl; + }); +} diff --git a/packages/frontend/src/utility/upload.ts b/packages/frontend/src/utility/upload.ts deleted file mode 100644 index 03240749e9..0000000000 --- a/packages/frontend/src/utility/upload.ts +++ /dev/null @@ -1,162 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { reactive, ref } from 'vue'; -import * as Misskey from 'misskey-js'; -import { v4 as uuid } from 'uuid'; -import { readAndCompressImage } from '@misskey-dev/browser-image-resizer'; -import { apiUrl } from '@@/js/config.js'; -import { getCompressionConfig } from './upload/compress-config.js'; -import { $i } from '@/i.js'; -import { alert } from '@/os.js'; -import { i18n } from '@/i18n.js'; -import { instance } from '@/instance.js'; -import { prefer } from '@/preferences.js'; - -type Uploading = { - id: string; - name: string; - progressMax: number | undefined; - progressValue: number | undefined; - img: string; -}; -export const uploads = ref<Uploading[]>([]); - -const mimeTypeMap = { - 'image/webp': 'webp', - 'image/jpeg': 'jpg', - 'image/png': 'png', -} as const; - -export function uploadFile( - file: File, - folder?: string | Misskey.entities.DriveFolder | null, - name?: string, - keepOriginal = false, -): Promise<Misskey.entities.DriveFile> { - if ($i == null) throw new Error('Not logged in'); - - const _folder = typeof folder === 'string' ? folder : folder?.id; - - if ((file.size > instance.maxFileSize) || (file.size > ($i.policies.maxFileSizeMb * 1024 * 1024))) { - alert({ - type: 'error', - title: i18n.ts.failedToUpload, - text: i18n.ts.cannotUploadBecauseExceedsFileSizeLimit, - }); - return Promise.reject(); - } - - return new Promise((resolve, reject) => { - const id = uuid(); - - const reader = new FileReader(); - reader.onload = async (): Promise<void> => { - const filename = name ?? file.name ?? 'untitled'; - const extension = filename.split('.').length > 1 ? '.' + filename.split('.').pop() : ''; - - const ctx = reactive<Uploading>({ - id, - name: prefer.s.keepOriginalFilename ? filename : id + extension, - progressMax: undefined, - progressValue: undefined, - img: window.URL.createObjectURL(file), - }); - - uploads.value.push(ctx); - - const config = !keepOriginal ? await getCompressionConfig(file) : undefined; - let resizedImage: Blob | undefined; - if (config) { - try { - const resized = await readAndCompressImage(file, config); - if (resized.size < file.size || file.type === 'image/webp') { - // The compression may not always reduce the file size - // (and WebP is not browser safe yet) - resizedImage = resized; - } - if (_DEV_) { - const saved = ((1 - resized.size / file.size) * 100).toFixed(2); - console.log(`Image compression: before ${file.size} bytes, after ${resized.size} bytes, saved ${saved}%`); - } - - ctx.name = file.type !== config.mimeType ? `${ctx.name}.${mimeTypeMap[config.mimeType]}` : ctx.name; - } catch (err) { - console.error('Failed to resize image', err); - } - } - - const formData = new FormData(); - formData.append('i', $i!.token); - formData.append('force', 'true'); - formData.append('file', resizedImage ?? file); - formData.append('name', ctx.name); - if (_folder) formData.append('folderId', _folder); - - const xhr = new XMLHttpRequest(); - xhr.open('POST', apiUrl + '/drive/files/create', true); - xhr.onload = ((ev: ProgressEvent<XMLHttpRequest>) => { - if (xhr.status !== 200 || ev.target == null || ev.target.response == null) { - // TODO: 消すのではなくて(ネットワーク的なエラーなら)再送できるようにしたい - uploads.value = uploads.value.filter(x => x.id !== id); - - if (xhr.status === 413) { - alert({ - type: 'error', - title: i18n.ts.failedToUpload, - text: i18n.ts.cannotUploadBecauseExceedsFileSizeLimit, - }); - } else if (ev.target?.response) { - const res = JSON.parse(ev.target.response); - if (res.error?.id === 'bec5bd69-fba3-43c9-b4fb-2894b66ad5d2') { - alert({ - type: 'error', - title: i18n.ts.failedToUpload, - text: i18n.ts.cannotUploadBecauseInappropriate, - }); - } else if (res.error?.id === 'd08dbc37-a6a9-463a-8c47-96c32ab5f064') { - alert({ - type: 'error', - title: i18n.ts.failedToUpload, - text: i18n.ts.cannotUploadBecauseNoFreeSpace, - }); - } else { - alert({ - type: 'error', - title: i18n.ts.failedToUpload, - text: `${res.error?.message}\n${res.error?.code}\n${res.error?.id}`, - }); - } - } else { - alert({ - type: 'error', - title: 'Failed to upload', - text: `${JSON.stringify(ev.target?.response)}, ${JSON.stringify(xhr.response)}`, - }); - } - - reject(); - return; - } - - const driveFile = JSON.parse(ev.target.response); - - resolve(driveFile); - - uploads.value = uploads.value.filter(x => x.id !== id); - }) as (ev: ProgressEvent<EventTarget>) => any; - - xhr.upload.onprogress = ev => { - if (ev.lengthComputable) { - ctx.progressMax = ev.total; - ctx.progressValue = ev.loaded; - } - }; - - xhr.send(formData); - }; - reader.readAsArrayBuffer(file); - }); -} diff --git a/packages/frontend/src/utility/upload/compress-config.ts b/packages/frontend/src/utility/upload/compress-config.ts deleted file mode 100644 index 3046b7f518..0000000000 --- a/packages/frontend/src/utility/upload/compress-config.ts +++ /dev/null @@ -1,36 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import isAnimated from 'is-file-animated'; -import { isWebpSupported } from './isWebpSupported.js'; -import type { BrowserImageResizerConfigWithConvertedOutput } from '@misskey-dev/browser-image-resizer'; - -const compressTypeMap = { - 'image/jpeg': { quality: 0.90, mimeType: 'image/webp' }, - 'image/png': { quality: 1, mimeType: 'image/webp' }, - 'image/webp': { quality: 0.90, mimeType: 'image/webp' }, - 'image/svg+xml': { quality: 1, mimeType: 'image/webp' }, -} as const; - -const compressTypeMapFallback = { - 'image/jpeg': { quality: 0.85, mimeType: 'image/jpeg' }, - 'image/png': { quality: 1, mimeType: 'image/png' }, - 'image/webp': { quality: 0.85, mimeType: 'image/jpeg' }, - 'image/svg+xml': { quality: 1, mimeType: 'image/png' }, -} as const; - -export async function getCompressionConfig(file: File): Promise<BrowserImageResizerConfigWithConvertedOutput | undefined> { - const imgConfig = (isWebpSupported() ? compressTypeMap : compressTypeMapFallback)[file.type]; - if (!imgConfig || await isAnimated(file)) { - return; - } - - return { - maxWidth: 2048, - maxHeight: 2048, - debug: true, - ...imgConfig, - }; -} diff --git a/packages/frontend/src/utility/player-url-transform.ts b/packages/frontend/src/utility/url-preview.ts index 39c6df6500..5ed809a5af 100644 --- a/packages/frontend/src/utility/player-url-transform.ts +++ b/packages/frontend/src/utility/url-preview.ts @@ -2,7 +2,12 @@ * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ +import { computed } from 'vue'; import { hostname } from '@@/js/config.js'; +import { instance } from '@/instance.js'; +import { prefer } from '@/preferences.js'; + +export const isEnabledUrlPreview = computed(() => (instance.enableUrlPreview && !prefer.r.dataSaver.value.disableUrlPreview)); export function transformPlayerUrl(url: string): string { const urlObj = new URL(url); @@ -10,7 +15,7 @@ export function transformPlayerUrl(url: string): string { const urlParams = new URLSearchParams(urlObj.search); - if (urlObj.hostname === 'player.twitch.tv') { + if (urlObj.hostname === 'player.twitch.tv' || urlObj.hostname === 'clips.twitch.tv') { // TwitchはCSPの制約あり // https://dev.twitch.tv/docs/embed/video-and-clips/ urlParams.set('parent', hostname); diff --git a/packages/frontend/src/widgets/WidgetNotifications.vue b/packages/frontend/src/widgets/WidgetNotifications.vue index c5e1324ef5..b21a82179e 100644 --- a/packages/frontend/src/widgets/WidgetNotifications.vue +++ b/packages/frontend/src/widgets/WidgetNotifications.vue @@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template #func="{ buttonStyleClass }"><button class="_button" :class="buttonStyleClass" @click="configureNotification()"><i class="ti ti-settings"></i></button></template> <div> - <XNotifications :excludeTypes="widgetProps.excludeTypes"/> + <MkStreamingNotificationsTimeline :excludeTypes="widgetProps.excludeTypes"/> </div> </MkContainer> </template> @@ -21,7 +21,7 @@ import { useWidgetPropsManager } from './widget.js'; import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; import type { GetFormResultType } from '@/utility/form.js'; import MkContainer from '@/components/MkContainer.vue'; -import XNotifications from '@/components/MkNotifications.vue'; +import MkStreamingNotificationsTimeline from '@/components/MkStreamingNotificationsTimeline.vue'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; diff --git a/packages/frontend/src/widgets/WidgetRssTicker.vue b/packages/frontend/src/widgets/WidgetRssTicker.vue index b5be4d35c2..7fe7c6111a 100644 --- a/packages/frontend/src/widgets/WidgetRssTicker.vue +++ b/packages/frontend/src/widgets/WidgetRssTicker.vue @@ -15,11 +15,11 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <div v-else> <Transition :name="$style.change" mode="default" appear> - <MarqueeText :key="key" :duration="widgetProps.duration" :reverse="widgetProps.reverse"> + <MkMarqueeText :key="key" :duration="widgetProps.duration" :reverse="widgetProps.reverse"> <span v-for="item in items" :key="item.link" :class="$style.item"> <a :href="item.link" rel="nofollow noopener" target="_blank" :title="item.title">{{ item.title }}</a><span :class="$style.divider"></span> </span> - </MarqueeText> + </MkMarqueeText> </Transition> </div> </div> @@ -31,7 +31,7 @@ import { ref, watch, computed } from 'vue'; import * as Misskey from 'misskey-js'; import { useWidgetPropsManager } from './widget.js'; import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; -import MarqueeText from '@/components/MkMarquee.vue'; +import MarqueeText from '@/components/MkMarqueeText.vue'; import type { GetFormResultType } from '@/utility/form.js'; import MkContainer from '@/components/MkContainer.vue'; import { shuffle } from '@/utility/shuffle.js'; diff --git a/packages/frontend/src/widgets/WidgetSlideshow.vue b/packages/frontend/src/widgets/WidgetSlideshow.vue index 2ccbb7a28f..3fe8cfa7e6 100644 --- a/packages/frontend/src/widgets/WidgetSlideshow.vue +++ b/packages/frontend/src/widgets/WidgetSlideshow.vue @@ -26,6 +26,7 @@ import type { GetFormResultType } from '@/utility/form.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; +import { selectDriveFolder } from '@/utility/drive.js'; const name = 'slideshow'; @@ -93,7 +94,7 @@ const fetch = () => { }; const choose = () => { - os.selectDriveFolder(false).then(folder => { + selectDriveFolder(null).then(folder => { if (folder[0] == null) { return; } diff --git a/packages/frontend/src/widgets/WidgetTimeline.vue b/packages/frontend/src/widgets/WidgetTimeline.vue index 47dec05303..9cbeb9cf2e 100644 --- a/packages/frontend/src/widgets/WidgetTimeline.vue +++ b/packages/frontend/src/widgets/WidgetTimeline.vue @@ -25,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only <p :class="$style.disabledDescription">{{ i18n.ts._disabledTimeline.description }}</p> </div> <div v-else> - <MkTimeline :key="widgetProps.src === 'list' ? `list:${widgetProps.list.id}` : widgetProps.src === 'antenna' ? `antenna:${widgetProps.antenna.id}` : widgetProps.src" :src="widgetProps.src" :list="widgetProps.list ? widgetProps.list.id : null" :antenna="widgetProps.antenna ? widgetProps.antenna.id : null"/> + <MkStreamingNotesTimeline :key="widgetProps.src === 'list' ? `list:${widgetProps.list.id}` : widgetProps.src === 'antenna' ? `antenna:${widgetProps.antenna.id}` : widgetProps.src" :src="widgetProps.src" :list="widgetProps.list ? widgetProps.list.id : null" :antenna="widgetProps.antenna ? widgetProps.antenna.id : null"/> </div> </MkContainer> </template> @@ -38,7 +38,7 @@ import type { GetFormResultType } from '@/utility/form.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import MkContainer from '@/components/MkContainer.vue'; -import MkTimeline from '@/components/MkTimeline.vue'; +import MkStreamingNotesTimeline from '@/components/MkStreamingNotesTimeline.vue'; import { i18n } from '@/i18n.js'; import { availableBasicTimelines, isAvailableBasicTimeline, isBasicTimeline, basicTimelineIconClass } from '@/timelines.js'; import type { MenuItem } from '@/types/menu.js'; diff --git a/packages/frontend/test/init.ts b/packages/frontend/test/init.ts index 3b6b4d581b..e38338cf95 100644 --- a/packages/frontend/test/init.ts +++ b/packages/frontend/test/init.ts @@ -5,6 +5,8 @@ import { vi } from 'vitest'; import createFetchMock from 'vitest-fetch-mock'; +import type { Ref } from 'vue'; +import { ref } from 'vue'; const fetchMocker = createFetchMock(vi); fetchMocker.enableMocks(); @@ -27,13 +29,24 @@ export const preferState: Record<string, unknown> = { code: false, }, + mutingEmojis: [], }; +export let preferReactive: Record<string, Ref<unknown>> = {}; + +for (const key in preferState) { + if (preferState[key] !== undefined) { + preferReactive[key] = ref(preferState[key]); + } +} + // XXX: store somehow becomes undefined in vitest? vi.mock('@/preferences.js', () => { + return { prefer: { s: preferState, + r: preferReactive, }, }; }); diff --git a/packages/frontend/vite.config.ts b/packages/frontend/vite.config.ts index aa7bf24174..b0ccbfb65c 100644 --- a/packages/frontend/vite.config.ts +++ b/packages/frontend/vite.config.ts @@ -81,9 +81,15 @@ export function getConfig(): UserConfig { return { base: '/vite/', + // The console is shared with backend, so clearing the console will also clear the backend log. + clearScreen: false, + server: { - host, + // The backend allows access from any addresses, so vite also allows access from any addresses. + host: '0.0.0.0', + allowedHosts: host ? [host] : undefined, port: 5173, + strictPort: true, hmr: { // バックエンド経由での起動時、Viteは5173経由でアセットを参照していると思い込んでいるが実際は3000から配信される // そのため、バックエンドのWSサーバーにHMRのWSリクエストが吸収されてしまい、正しくHMRが機能しない @@ -148,9 +154,6 @@ export function getConfig(): UserConfig { _ENV_: JSON.stringify(process.env.NODE_ENV), _DEV_: process.env.NODE_ENV !== 'production', _PERF_PREFIX_: JSON.stringify('Misskey:'), - _DATA_TRANSFER_DRIVE_FILE_: JSON.stringify('mk_drive_file'), - _DATA_TRANSFER_DRIVE_FOLDER_: JSON.stringify('mk_drive_folder'), - _DATA_TRANSFER_DECK_COLUMN_: JSON.stringify('mk_deck_column'), __VUE_OPTIONS_API__: true, __VUE_PROD_DEVTOOLS__: false, }, |