diff options
| author | syuilo <Syuilotan@yahoo.co.jp> | 2023-07-21 20:36:07 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2023-07-21 20:36:07 +0900 |
| commit | e64a81aa1d2801516e8eac8dc69aac540489f20b (patch) | |
| tree | 56accbc0f5f71db864e1e975920135fb0a957291 /packages/frontend | |
| parent | Merge pull request #10990 from misskey-dev/develop (diff) | |
| parent | New Crowdin updates (#11336) (diff) | |
| download | misskey-e64a81aa1d2801516e8eac8dc69aac540489f20b.tar.gz misskey-e64a81aa1d2801516e8eac8dc69aac540489f20b.tar.bz2 misskey-e64a81aa1d2801516e8eac8dc69aac540489f20b.zip | |
Merge pull request #11301 from misskey-dev/develop
Release: 13.14.0
Diffstat (limited to 'packages/frontend')
172 files changed, 1891 insertions, 677 deletions
diff --git a/packages/frontend/.storybook/changes.ts b/packages/frontend/.storybook/changes.ts index fc0f0c286b..a1275132be 100644 --- a/packages/frontend/.storybook/changes.ts +++ b/packages/frontend/.storybook/changes.ts @@ -1,7 +1,10 @@ import fs from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; import path from 'node:path'; import micromatch from 'micromatch'; -import main from './main'; +import main from './main.js'; + +const __dirname = fileURLToPath(new URL('.', import.meta.url)); interface Stats { readonly modules: readonly { @@ -13,8 +16,8 @@ interface Stats { }[]; } -fs.readFile( - path.resolve(__dirname, '../storybook-static/preview-stats.json') +await fs.readFile( + new URL('../storybook-static/preview-stats.json', import.meta.url) ).then((buffer) => { const stats: Stats = JSON.parse(buffer.toString()); const keys = new Set(stats.modules.map((stat) => stat.id)); diff --git a/packages/frontend/.storybook/fakes.ts b/packages/frontend/.storybook/fakes.ts index 5fd21cdf0a..a4289cff7d 100644 --- a/packages/frontend/.storybook/fakes.ts +++ b/packages/frontend/.storybook/fakes.ts @@ -115,3 +115,27 @@ export function userDetailed(id = 'someuserid', username = 'miskist', host = 'mi url: null, }; } + +export function inviteCode(isUsed = false, hasExpiration = false, isExpired = false, isCreatedBySystem = false) { + const date = new Date(); + const createdAt = new Date(); + createdAt.setDate(date.getDate() - 1) + const expiresAt = new Date(); + + if (isExpired) { + expiresAt.setHours(date.getHours() - 1) + } else { + expiresAt.setHours(date.getHours() + 1) + } + + return { + id: "9gyqzizw77", + code: "SLF3JKF7UV2H9", + expiresAt: hasExpiration ? expiresAt.toISOString() : null, + createdAt: createdAt.toISOString(), + createdBy: isCreatedBySystem ? null : userDetailed('8i3rvznx32'), + usedBy: isUsed ? userDetailed('3i3r2znx1v') : null, + usedAt: isUsed ? date.toISOString() : null, + used: isUsed, + } +} diff --git a/packages/frontend/.storybook/generate.tsx b/packages/frontend/.storybook/generate.tsx index f442422109..d47d8672c7 100644 --- a/packages/frontend/.storybook/generate.tsx +++ b/packages/frontend/.storybook/generate.tsx @@ -96,7 +96,7 @@ declare global { } } -function toStories(component: string): string { +function toStories(component: string): Promise<string> { const msw = `${component.slice(0, -'.vue'.length)}.msw`; const implStories = `${component.slice(0, -'.vue'.length)}.stories.impl`; const metaStories = `${component.slice(0, -'.vue'.length)}.stories.meta`; @@ -394,18 +394,21 @@ function toStories(component: string): string { } // glob('src/{components,pages,ui,widgets}/**/*.vue') -Promise.all([ - glob('src/components/global/*.vue'), - glob('src/components/Mk{A,B}*.vue'), - glob('src/components/MkDigitalClock.vue'), - glob('src/components/MkGalleryPostPreview.vue'), - glob('src/components/MkSignupServerRules.vue'), - glob('src/components/MkUserSetupDialog.vue'), - glob('src/components/MkUserSetupDialog.*.vue'), - glob('src/pages/user/home.vue'), -]) - .then((globs) => globs.flat()) - .then((components) => Promise.all(components.map((component) => { +(async () => { + const globs = await Promise.all([ + glob('src/components/global/*.vue'), + glob('src/components/Mk{A,B}*.vue'), + glob('src/components/MkDigitalClock.vue'), + glob('src/components/MkGalleryPostPreview.vue'), + glob('src/components/MkSignupServerRules.vue'), + glob('src/components/MkUserSetupDialog.vue'), + glob('src/components/MkUserSetupDialog.*.vue'), + glob('src/components/MkInviteCode.vue'), + glob('src/pages/user/home.vue'), + ]); + const components = globs.flat(); + await Promise.all(components.map(async (component) => { const stories = component.replace(/\.vue$/, '.stories.ts'); - return writeFile(stories, toStories(component)); - }))); + await writeFile(stories, await toStories(component)); + })) +})(); diff --git a/packages/frontend/.storybook/main.ts b/packages/frontend/.storybook/main.ts index 1d0ce5ab63..b64979980a 100644 --- a/packages/frontend/.storybook/main.ts +++ b/packages/frontend/.storybook/main.ts @@ -1,7 +1,11 @@ import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; import type { StorybookConfig } from '@storybook/vue3-vite'; import { type Plugin, mergeConfig } from 'vite'; import turbosnap from 'vite-plugin-turbosnap'; + +const dirname = fileURLToPath(new URL('.', import.meta.url)); + const config = { stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'], addons: [ @@ -9,7 +13,7 @@ const config = { '@storybook/addon-interactions', '@storybook/addon-links', '@storybook/addon-storysource', - resolve(__dirname, '../node_modules/storybook-addon-misskey-theme'), + resolve(dirname, '../node_modules/storybook-addon-misskey-theme'), ], framework: { name: '@storybook/vue3-vite', @@ -28,7 +32,8 @@ const config = { } return mergeConfig(config, { plugins: [ - turbosnap({ + // XXX: https://github.com/IanVS/vite-plugin-turbosnap/issues/8 + (turbosnap as any as typeof turbosnap['default'])({ rootDir: config.root ?? process.cwd(), }), ], diff --git a/packages/frontend/.storybook/package.json b/packages/frontend/.storybook/package.json new file mode 100644 index 0000000000..bedb411a91 --- /dev/null +++ b/packages/frontend/.storybook/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/packages/frontend/.storybook/preload-locale.ts b/packages/frontend/.storybook/preload-locale.ts index a54164742a..2b7362b88d 100644 --- a/packages/frontend/.storybook/preload-locale.ts +++ b/packages/frontend/.storybook/preload-locale.ts @@ -1,9 +1,8 @@ import { writeFile } from 'node:fs/promises'; -import { resolve } from 'node:path'; -import * as locales from '../../../locales'; +import locales from '../../../locales/index.js'; -writeFile( - resolve(__dirname, 'locale.ts'), +await writeFile( + new URL('locale.ts', import.meta.url), `export default ${JSON.stringify(locales['ja-JP'], undefined, 2)} as const;`, 'utf8', ) diff --git a/packages/frontend/.storybook/preload-theme.ts b/packages/frontend/.storybook/preload-theme.ts index 1ff8f71ecd..42fbeff738 100644 --- a/packages/frontend/.storybook/preload-theme.ts +++ b/packages/frontend/.storybook/preload-theme.ts @@ -1,6 +1,5 @@ import { readFile, writeFile } from 'node:fs/promises'; -import { resolve } from 'node:path'; -import * as JSON5 from 'json5'; +import JSON5 from 'json5'; const keys = [ '_dark', @@ -26,9 +25,9 @@ const keys = [ 'd-u0', ] -Promise.all(keys.map((key) => readFile(resolve(__dirname, `../src/themes/${key}.json5`), 'utf8'))).then((sources) => { +await Promise.all(keys.map((key) => readFile(new URL(`../src/themes/${key}.json5`, import.meta.url), 'utf8'))).then((sources) => { writeFile( - resolve(__dirname, './themes.ts'), + new URL('./themes.ts', import.meta.url), `export default ${JSON.stringify( Object.fromEntries(sources.map((source, i) => [keys[i], JSON5.parse(source)])), undefined, diff --git a/packages/frontend/.storybook/preview.ts b/packages/frontend/.storybook/preview.ts index e887acaa2e..67c81c666b 100644 --- a/packages/frontend/.storybook/preview.ts +++ b/packages/frontend/.storybook/preview.ts @@ -3,10 +3,10 @@ import { FORCE_REMOUNT } from '@storybook/core-events'; import { type Preview, setup } from '@storybook/vue3'; import isChromatic from 'chromatic/isChromatic'; import { initialize, mswDecorator } from 'msw-storybook-addon'; -import { userDetailed } from './fakes'; -import locale from './locale'; -import { commonHandlers, onUnhandledRequest } from './mocks'; -import themes from './themes'; +import { userDetailed } from './fakes.js'; +import locale from './locale.js'; +import { commonHandlers, onUnhandledRequest } from './mocks.js'; +import themes from './themes.js'; import '../src/style.scss'; const appInitialized = Symbol(); diff --git a/packages/frontend/.storybook/tsconfig.json b/packages/frontend/.storybook/tsconfig.json index 2db2f1eabe..02465f5afa 100644 --- a/packages/frontend/.storybook/tsconfig.json +++ b/packages/frontend/.storybook/tsconfig.json @@ -1,5 +1,7 @@ { "compilerOptions": { + "target": "es2022", + "module": "Node16", "strict": true, "allowUnusedLabels": false, "allowUnreachableCode": false, diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 506d187901..2819f858c1 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -4,8 +4,9 @@ "scripts": { "watch": "vite", "build": "vite build", - "storybook-dev": "chokidar 'src/**/*.{mdx,ts,vue}' -d 1000 -t 1000 --initial -i '**/*.stories.ts' -c 'pkill -f node_modules/storybook/index.js; node_modules/.bin/tsc -p .storybook && node .storybook/generate.js && node .storybook/preload-locale.js && node .storybook/preload-theme.js && node_modules/.bin/storybook dev -p 6006 --ci'", - "build-storybook": "tsc -p .storybook && node .storybook/generate.js && node .storybook/preload-locale.js && node .storybook/preload-theme.js && storybook build", + "storybook-dev": "nodemon --verbose --watch src --ext \"mdx,ts,vue\" --ignore \"*.stories.ts\" --exec \"pnpm build-storybook-pre && pnpm exec storybook dev -p 6006 --ci\"", + "build-storybook-pre": "tsc -p .storybook && node .storybook/generate.js && node .storybook/preload-locale.js && node .storybook/preload-theme.js", + "build-storybook": "pnpm build-storybook-pre && storybook build", "chromatic": "chromatic", "test": "vitest --run", "test-and-coverage": "vitest --run --coverage", @@ -19,30 +20,30 @@ "@rollup/plugin-json": "6.0.0", "@rollup/plugin-replace": "5.0.2", "@rollup/pluginutils": "5.0.2", - "@syuilo/aiscript": "0.13.3", - "@tabler/icons-webfont": "2.21.0", + "@syuilo/aiscript": "0.15.0", + "@tabler/icons-webfont": "2.25.0", "@vitejs/plugin-vue": "4.2.3", - "@vue-macros/reactivity-transform": "0.3.9", + "@vue-macros/reactivity-transform": "0.3.15", "@vue/compiler-sfc": "3.3.4", "astring": "1.8.6", "autosize": "6.0.1", "broadcast-channel": "5.1.0", "browser-image-resizer": "github:misskey-dev/browser-image-resizer#v2.2.1-misskey.3", - "buraha": "github:misskey-dev/buraha", + "buraha": "0.0.1", "canvas-confetti": "1.6.0", "chart.js": "4.3.0", "chartjs-adapter-date-fns": "3.0.0", "chartjs-chart-matrix": "2.0.1", "chartjs-plugin-gradient": "0.6.1", "chartjs-plugin-zoom": "2.0.1", - "chromatic": "6.18.0", + "chromatic": "6.19.9", "compare-versions": "5.0.3", - "cropperjs": "2.0.0-beta.2", + "cropperjs": "2.0.0-beta.3", "date-fns": "2.30.0", "escape-regexp": "0.0.1", "estree-walker": "^3.0.3", "eventemitter3": "5.0.1", - "gsap": "3.11.5", + "gsap": "3.12.2", "idb-keyval": "6.2.1", "insert-text-at-cursor": "0.3.0", "is-file-animated": "1.0.2", @@ -50,97 +51,94 @@ "matter-js": "0.19.0", "mfm-js": "0.23.3", "misskey-js": "workspace:*", - "photoswipe": "5.3.7", + "photoswipe": "5.3.8", "prismjs": "1.29.0", "punycode": "2.3.0", "querystring": "0.2.1", - "rndstr": "1.0.0", - "rollup": "3.23.0", + "rollup": "3.26.3", "s-age": "1.1.2", - "sanitize-html": "2.10.0", - "sass": "1.62.1", - "seedrandom": "3.0.5", + "sanitize-html": "2.11.0", + "sass": "1.63.6", "strict-event-emitter-types": "2.0.0", "syuilo-password-strength": "0.0.1", "textarea-caret": "3.1.0", - "three": "0.153.0", + "three": "0.154.0", "throttle-debounce": "5.0.0", "tinycolor2": "1.6.0", - "tsc-alias": "1.8.6", + "tsc-alias": "1.8.7", "tsconfig-paths": "4.2.0", "twemoji-parser": "14.0.0", - "typescript": "5.1.3", + "typescript": "5.1.6", "uuid": "9.0.0", "vanilla-tilt": "1.8.0", - "vite": "4.3.9", + "vite": "4.4.4", "vue": "3.3.4", "vue-prism-editor": "2.0.0-alpha.2", "vuedraggable": "next" }, "devDependencies": { - "@storybook/addon-actions": "7.0.18", - "@storybook/addon-essentials": "7.0.18", - "@storybook/addon-interactions": "7.0.18", - "@storybook/addon-links": "7.0.18", - "@storybook/addon-storysource": "7.0.18", - "@storybook/addons": "7.0.18", - "@storybook/blocks": "7.0.18", - "@storybook/core-events": "7.0.18", + "@storybook/addon-actions": "7.0.27", + "@storybook/addon-essentials": "7.0.27", + "@storybook/addon-interactions": "7.0.27", + "@storybook/addon-links": "7.0.27", + "@storybook/addon-storysource": "7.0.27", + "@storybook/addons": "7.0.27", + "@storybook/blocks": "7.0.27", + "@storybook/core-events": "7.0.27", "@storybook/jest": "0.1.0", - "@storybook/manager-api": "7.0.18", - "@storybook/preview-api": "7.0.18", - "@storybook/react": "7.0.18", - "@storybook/react-vite": "7.0.18", - "@storybook/testing-library": "0.1.0", - "@storybook/theming": "7.0.18", - "@storybook/types": "7.0.18", - "@storybook/vue3": "7.0.18", - "@storybook/vue3-vite": "7.0.18", + "@storybook/manager-api": "7.0.27", + "@storybook/preview-api": "7.0.27", + "@storybook/react": "7.0.27", + "@storybook/react-vite": "7.0.27", + "@storybook/testing-library": "0.2.0", + "@storybook/theming": "7.0.27", + "@storybook/types": "7.0.27", + "@storybook/vue3": "7.0.27", + "@storybook/vue3-vite": "7.0.27", "@testing-library/jest-dom": "5.16.5", "@testing-library/vue": "7.0.0", "@types/escape-regexp": "0.0.1", "@types/estree": "1.0.1", - "@types/gulp": "4.0.10", + "@types/gulp": "4.0.13", "@types/gulp-rename": "2.0.2", "@types/matter-js": "0.18.5", "@types/micromatch": "4.0.2", - "@types/node": "20.2.5", + "@types/node": "20.4.2", "@types/punycode": "2.1.0", "@types/sanitize-html": "2.9.0", - "@types/seedrandom": "3.0.5", - "@types/testing-library__jest-dom": "^5.14.6", + "@types/testing-library__jest-dom": "5.14.8", "@types/throttle-debounce": "5.0.0", "@types/tinycolor2": "1.4.3", - "@types/uuid": "9.0.1", + "@types/uuid": "9.0.2", "@types/websocket": "1.0.5", - "@types/ws": "8.5.4", - "@typescript-eslint/eslint-plugin": "5.59.8", - "@typescript-eslint/parser": "5.59.8", - "@vitest/coverage-c8": "0.31.4", + "@types/ws": "8.5.5", + "@typescript-eslint/eslint-plugin": "5.61.0", + "@typescript-eslint/parser": "5.61.0", + "@vitest/coverage-v8": "0.33.0", "@vue/runtime-core": "3.3.4", - "acorn": "^8.8.2", - "chokidar-cli": "3.0.0", + "acorn": "8.10.0", "cross-env": "7.0.3", - "cypress": "12.13.0", - "eslint": "8.41.0", + "cypress": "12.17.1", + "eslint": "8.45.0", "eslint-plugin-import": "2.27.5", - "eslint-plugin-vue": "9.14.1", - "fast-glob": "3.2.12", - "happy-dom": "9.20.3", - "micromatch": "3.1.10", - "msw": "1.2.1", + "eslint-plugin-vue": "9.15.1", + "fast-glob": "3.3.0", + "happy-dom": "10.0.3", + "micromatch": "4.0.5", + "msw": "1.2.2", "msw-storybook-addon": "1.8.0", - "prettier": "2.8.8", + "nodemon": "3.0.1", + "prettier": "3.0.0", "react": "18.2.0", "react-dom": "18.2.0", "start-server-and-test": "2.0.0", - "storybook": "7.0.18", + "storybook": "7.0.27", "storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme", "summaly": "github:misskey-dev/summaly", "vite-plugin-turbosnap": "1.0.2", - "vitest": "0.31.4", + "vitest": "0.33.0", "vitest-fetch-mock": "0.2.2", - "vue-eslint-parser": "9.3.0", - "vue-tsc": "1.6.5" + "vue-eslint-parser": "9.3.1", + "vue-tsc": "1.8.5" } } diff --git a/packages/frontend/src/boot/main-boot.ts b/packages/frontend/src/boot/main-boot.ts index 76e8c50724..0a351cd6e3 100644 --- a/packages/frontend/src/boot/main-boot.ts +++ b/packages/frontend/src/boot/main-boot.ts @@ -13,10 +13,11 @@ import { miLocalStorage } from '@/local-storage'; import { claimAchievement, claimedAchievements } from '@/scripts/achievements'; import { mainRouter } from '@/router'; import { initializeSw } from '@/scripts/initialize-sw'; +import { deckStore } from '@/ui/deck/deck-store'; export async function mainBoot() { const { isClientUpdated } = await common(() => createApp( - new URLSearchParams(window.location.search).has('zen') || (ui === 'deck' && location.pathname !== '/') ? defineAsyncComponent(() => import('@/ui/zen.vue')) : + new URLSearchParams(window.location.search).has('zen') || (ui === 'deck' && deckStore.state.useSimpleUiForNonRootPages && location.pathname !== '/') ? defineAsyncComponent(() => import('@/ui/zen.vue')) : !$i ? defineAsyncComponent(() => import('@/ui/visitor.vue')) : ui === 'deck' ? defineAsyncComponent(() => import('@/ui/deck.vue')) : ui === 'classic' ? defineAsyncComponent(() => import('@/ui/classic.vue')) : @@ -87,7 +88,7 @@ export async function mainBoot() { const now = new Date(); const m = now.getMonth() + 1; const d = now.getDate(); - + if ($i.birthday) { const bm = parseInt($i.birthday.split('-')[1]); const bd = parseInt($i.birthday.split('-')[2]); diff --git a/packages/frontend/src/cache.ts b/packages/frontend/src/cache.ts index c95da64bba..cb315c8ff7 100644 --- a/packages/frontend/src/cache.ts +++ b/packages/frontend/src/cache.ts @@ -4,3 +4,4 @@ import { Cache } from '@/scripts/cache'; export const clipsCache = new Cache<misskey.entities.Clip[]>(Infinity); export const rolesCache = new Cache(Infinity); export const userListsCache = new Cache<misskey.entities.UserList[]>(Infinity); +export const antennasCache = new Cache<misskey.entities.Antenna[]>(Infinity); diff --git a/packages/frontend/src/components/MkAsUi.vue b/packages/frontend/src/components/MkAsUi.vue index 8bfcfa6aa6..7aa8f94c3b 100644 --- a/packages/frontend/src/components/MkAsUi.vue +++ b/packages/frontend/src/components/MkAsUi.vue @@ -39,7 +39,7 @@ <MkAsUi v-if="!g(child).hidden" :component="g(child)" :components="props.components" :size="size"/> </template> </MkFolder> - <div v-else-if="c.type === 'container'" :class="[$style.container, { [$style.fontSerif]: c.font === 'serif', [$style.fontMonospace]: c.font === 'monospace', [$style.containerCenter]: c.align === 'center' }]" :style="{ backgroundColor: c.bgColor ?? null, color: c.fgColor ?? null, borderWidth: c.borderWidth ? `${c.borderWidth}px` : 0, borderColor: c.borderColor ?? 'var(--divider)', padding: c.padding ? `${c.padding}px` : 0, borderRadius: c.rounded ? '8px' : 0 }"> + <div v-else-if="c.type === 'container'" :class="[$style.container, { [$style.fontSerif]: c.font === 'serif', [$style.fontMonospace]: c.font === 'monospace' }]" :style="{ textAlign: c.align ?? null, backgroundColor: c.bgColor ?? null, color: c.fgColor ?? null, borderWidth: c.borderWidth ? `${c.borderWidth}px` : 0, borderColor: c.borderColor ?? 'var(--divider)', padding: c.padding ? `${c.padding}px` : 0, borderRadius: c.rounded ? '8px' : 0 }"> <template v-for="child in c.children" :key="child"> <MkAsUi v-if="!g(child).hidden" :component="g(child)" :components="props.components" :size="size" :align="c.align"/> </template> @@ -102,10 +102,6 @@ function openPostForm() { gap: 12px; } -.containerCenter { - text-align: center; -} - .fontSerif { font-family: serif; } diff --git a/packages/frontend/src/components/MkAutocomplete.vue b/packages/frontend/src/components/MkAutocomplete.vue index fd892d8174..9211d92df7 100644 --- a/packages/frontend/src/components/MkAutocomplete.vue +++ b/packages/frontend/src/components/MkAutocomplete.vue @@ -356,9 +356,7 @@ onMounted(() => { props.textarea.addEventListener('keydown', onKeydown); - for (const el of Array.from(document.querySelectorAll('body *'))) { - el.addEventListener('mousedown', onMousedown); - } + document.body.addEventListener('mousedown', onMousedown); nextTick(() => { exec(); @@ -374,9 +372,7 @@ onMounted(() => { onBeforeUnmount(() => { props.textarea.removeEventListener('keydown', onKeydown); - for (const el of Array.from(document.querySelectorAll('body *'))) { - el.removeEventListener('mousedown', onMousedown); - } + document.body.removeEventListener('mousedown', onMousedown); }); </script> diff --git a/packages/frontend/src/components/MkAvatars.vue b/packages/frontend/src/components/MkAvatars.vue index 630620fc08..437dce0a14 100644 --- a/packages/frontend/src/components/MkAvatars.vue +++ b/packages/frontend/src/components/MkAvatars.vue @@ -1,24 +1,29 @@ <template> <div> - <div v-for="user in users" :key="user.id" style="display:inline-block;width:32px;height:32px;margin-right:8px;"> + <div v-for="user in users.slice(0, limit)" :key="user.id" style="display:inline-block;width:32px;height:32px;margin-right:8px;"> <MkAvatar :user="user" style="width:32px; height:32px;" indicator link preview/> </div> + <div v-if="users.length > limit" style="display: inline-block;">...</div> </div> </template> <script lang="ts" setup> import { onMounted, ref } from 'vue'; import * as os from '@/os'; +import { UserLite } from 'misskey-js/built/entities'; -const props = defineProps<{ +const props = withDefaults(defineProps<{ userIds: string[]; -}>(); + limit?: number; +}>(), { + limit: Infinity, +}); -const users = ref([]); +const users = ref<UserLite[]>([]); onMounted(async () => { users.value = await os.api('users/show', { userIds: props.userIds, - }); + }) as unknown as UserLite[]; }); </script> diff --git a/packages/frontend/src/components/MkContextMenu.vue b/packages/frontend/src/components/MkContextMenu.vue index fb11834f4d..f39c944199 100644 --- a/packages/frontend/src/components/MkContextMenu.vue +++ b/packages/frontend/src/components/MkContextMenu.vue @@ -61,15 +61,11 @@ onMounted(() => { rootEl.style.top = `${top}px`; rootEl.style.left = `${left}px`; - for (const el of Array.from(document.querySelectorAll('body *'))) { - el.addEventListener('mousedown', onMousedown); - } + document.body.addEventListener('mousedown', onMousedown); }); onBeforeUnmount(() => { - for (const el of Array.from(document.querySelectorAll('body *'))) { - el.removeEventListener('mousedown', onMousedown); - } + document.body.removeEventListener('mousedown', onMousedown); }); function onMousedown(evt: Event) { diff --git a/packages/frontend/src/components/MkCropperDialog.vue b/packages/frontend/src/components/MkCropperDialog.vue index 82363499b7..b2d60d36c4 100644 --- a/packages/frontend/src/components/MkCropperDialog.vue +++ b/packages/frontend/src/components/MkCropperDialog.vue @@ -47,6 +47,7 @@ const emit = defineEmits<{ const props = defineProps<{ file: misskey.entities.DriveFile; aspectRatio: number; + uploadFolder?: string | null; }>(); const imgUrl = getProxiedImageUrl(props.file.url, undefined, true); @@ -58,11 +59,17 @@ let loading = $ref(true); const ok = async () => { const promise = new Promise<misskey.entities.DriveFile>(async (res) => { const croppedCanvas = await cropper?.getCropperSelection()?.$toCanvas(); - croppedCanvas.toBlob(blob => { + croppedCanvas?.toBlob(blob => { + if (!blob) return; const formData = new FormData(); formData.append('file', blob); - formData.append('i', $i.token); - if (defaultStore.state.uploadFolder) { + formData.append('name', `cropped_${props.file.name}`); + formData.append('isSensitive', props.file.isSensitive ? 'true' : 'false'); + formData.append('comment', props.file.comment ?? 'null'); + formData.append('i', $i!.token); + if (props.uploadFolder || props.uploadFolder === null) { + formData.append('folderId', props.uploadFolder ?? 'null'); + } else if (defaultStore.state.uploadFolder) { formData.append('folderId', defaultStore.state.uploadFolder); } @@ -82,12 +89,12 @@ const ok = async () => { const f = await promise; emit('ok', f); - dialogEl.close(); + dialogEl!.close(); }; const cancel = () => { emit('cancel'); - dialogEl.close(); + dialogEl!.close(); }; const onImageLoad = () => { @@ -100,7 +107,7 @@ const onImageLoad = () => { }; onMounted(() => { - cropper = new Cropper(imgEl, { + cropper = new Cropper(imgEl!, { }); const computedStyle = getComputedStyle(document.documentElement); @@ -112,13 +119,13 @@ onMounted(() => { selection.outlined = true; window.setTimeout(() => { - cropper.getCropperImage()!.$center('contain'); + cropper!.getCropperImage()!.$center('contain'); selection.$center(); }, 100); // モーダルオープンアニメーションが終わったあとで再度調整 window.setTimeout(() => { - cropper.getCropperImage()!.$center('contain'); + cropper!.getCropperImage()!.$center('contain'); selection.$center(); }, 500); }); diff --git a/packages/frontend/src/components/MkDrive.file.vue b/packages/frontend/src/components/MkDrive.file.vue index f0641161be..8b3f91731a 100644 --- a/packages/frontend/src/components/MkDrive.file.vue +++ b/packages/frontend/src/components/MkDrive.file.vue @@ -19,14 +19,14 @@ </div> <div v-if="file.isSensitive" :class="[$style.label, $style.red]"> <img :class="$style.labelImg" src="/client-assets/label-red.svg"/> - <p :class="$style.labelText">{{ i18n.ts.nsfw }}</p> + <p :class="$style.labelText">{{ i18n.ts.sensitive }}</p> </div> <MkDriveFileThumbnail :class="$style.thumbnail" :file="file" fit="contain"/> <p :class="$style.name"> - <span>{{ file.name.lastIndexOf('.') != -1 ? file.name.substr(0, file.name.lastIndexOf('.')) : file.name }}</span> - <span v-if="file.name.lastIndexOf('.') != -1" style="opacity: 0.5;">{{ file.name.substr(file.name.lastIndexOf('.')) }}</span> + <span>{{ file.name.lastIndexOf('.') != -1 ? file.name.substring(0, file.name.lastIndexOf('.')) : file.name }}</span> + <span v-if="file.name.lastIndexOf('.') != -1" style="opacity: 0.5;">{{ file.name.substring(file.name.lastIndexOf('.')) }}</span> </p> </div> </div> @@ -44,6 +44,7 @@ import { getDriveFileMenu } from '@/scripts/get-drive-file-menu'; const props = withDefaults(defineProps<{ file: Misskey.entities.DriveFile; + folder: Misskey.entities.DriveFolder | null; isSelected?: boolean; selectMode?: boolean; }>(), { @@ -65,12 +66,12 @@ function onClick(ev: MouseEvent) { if (props.selectMode) { emit('chosen', props.file); } else { - os.popupMenu(getDriveFileMenu(props.file), (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined); + os.popupMenu(getDriveFileMenu(props.file, props.folder), (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined); } } function onContextmenu(ev: MouseEvent) { - os.contextMenu(getDriveFileMenu(props.file), ev); + os.contextMenu(getDriveFileMenu(props.file, props.folder), ev); } function onDragstart(ev: DragEvent) { diff --git a/packages/frontend/src/components/MkDrive.folder.vue b/packages/frontend/src/components/MkDrive.folder.vue index 1969342402..13f32ff7af 100644 --- a/packages/frontend/src/components/MkDrive.folder.vue +++ b/packages/frontend/src/components/MkDrive.folder.vue @@ -33,6 +33,7 @@ import * as os from '@/os'; import { i18n } from '@/i18n'; import { defaultStore } from '@/store'; import { claimAchievement } from '@/scripts/achievements'; +import copyToClipboard from '@/scripts/copy-to-clipboard'; const props = withDefaults(defineProps<{ folder: Misskey.entities.DriveFolder; @@ -93,9 +94,9 @@ function onDragover(ev: DragEvent) { switch (ev.dataTransfer.effectAllowed) { case 'all': case 'uninitialized': - case 'copy': - case 'copyLink': - case 'copyMove': + case 'copy': + case 'copyLink': + case 'copyMove': ev.dataTransfer.dropEffect = 'copy'; break; case 'linkMove': @@ -244,7 +245,8 @@ function setAsUploadFolder() { } function onContextmenu(ev: MouseEvent) { - os.contextMenu([{ + let menu; + menu = [{ text: i18n.ts.openInWindow, icon: 'ti ti-app-window', action: () => { @@ -262,7 +264,17 @@ function onContextmenu(ev: MouseEvent) { icon: 'ti ti-trash', danger: true, action: deleteFolder, - }], ev); + }]; + if (defaultStore.state.devMode) { + menu = menu.concat([null, { + icon: 'ti ti-id', + text: i18n.ts.copyFolderId, + action: () => { + copyToClipboard(props.folder.id); + }, + }]); + } + os.contextMenu(menu, ev); } </script> diff --git a/packages/frontend/src/components/MkDrive.navFolder.vue b/packages/frontend/src/components/MkDrive.navFolder.vue index 3349603d3b..df4c209c2b 100644 --- a/packages/frontend/src/components/MkDrive.navFolder.vue +++ b/packages/frontend/src/components/MkDrive.navFolder.vue @@ -61,9 +61,9 @@ function onDragover(ev: DragEvent) { switch (ev.dataTransfer.effectAllowed) { case 'all': case 'uninitialized': - case 'copy': - case 'copyLink': - case 'copyMove': + case 'copy': + case 'copyLink': + case 'copyMove': ev.dataTransfer.dropEffect = 'copy'; break; case 'linkMove': diff --git a/packages/frontend/src/components/MkDrive.vue b/packages/frontend/src/components/MkDrive.vue index 52aef450d9..aff227da40 100644 --- a/packages/frontend/src/components/MkDrive.vue +++ b/packages/frontend/src/components/MkDrive.vue @@ -56,7 +56,7 @@ /> <!-- 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">{{ i18n.ts.loadMore }}</MkButton> + <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 @@ -65,6 +65,7 @@ v-anim="i" :class="$style.file" :file="file" + :folder="folder" :selectMode="select === 'file'" :isSelected="selectedFiles.some(x => x.id === file.id)" @chosen="chooseFile" @@ -201,9 +202,9 @@ function onDragover(ev: DragEvent): any { switch (ev.dataTransfer.effectAllowed) { case 'all': case 'uninitialized': - case 'copy': - case 'copyLink': - case 'copyMove': + case 'copy': + case 'copyLink': + case 'copyMove': ev.dataTransfer.dropEffect = 'copy'; break; case 'linkMove': @@ -559,6 +560,28 @@ async function fetch() { fetching.value = false; } +function fetchMoreFolders() { + fetching.value = true; + + const max = 30; + + os.api('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; @@ -568,7 +591,7 @@ function fetchMoreFiles() { os.api('drive/files', { folderId: folder.value ? folder.value.id : null, type: props.type, - untilId: files.value[files.value.length - 1].id, + untilId: files.value.at(-1)?.id, limit: max + 1, }).then(files => { if (files.length === max + 1) { diff --git a/packages/frontend/src/components/MkFileListForAdmin.vue b/packages/frontend/src/components/MkFileListForAdmin.vue index 71a35ae6e8..77b38b4bbf 100644 --- a/packages/frontend/src/components/MkFileListForAdmin.vue +++ b/packages/frontend/src/components/MkFileListForAdmin.vue @@ -89,7 +89,7 @@ const props = defineProps<{ > .file { position: relative; aspect-ratio: 1; - + > .thumbnail { width: 100%; height: 100%; diff --git a/packages/frontend/src/components/MkFlashPreview.vue b/packages/frontend/src/components/MkFlashPreview.vue index 7c9ae155ab..b5505ac8fd 100644 --- a/packages/frontend/src/components/MkFlashPreview.vue +++ b/packages/frontend/src/components/MkFlashPreview.vue @@ -87,7 +87,7 @@ const props = defineProps<{ @media (max-width: 500px) { font-size: 10px; - + > article { padding: 8px; diff --git a/packages/frontend/src/components/MkImgWithBlurhash.vue b/packages/frontend/src/components/MkImgWithBlurhash.vue index 672a28f6d0..4e36defb7c 100644 --- a/packages/frontend/src/components/MkImgWithBlurhash.vue +++ b/packages/frontend/src/components/MkImgWithBlurhash.vue @@ -22,10 +22,13 @@ import TestWebGL2 from '@/workers/test-webgl2?worker'; import { WorkerMultiDispatch } from '@/scripts/worker-multi-dispatch'; import { extractAvgColorFromBlurhash } from '@/scripts/extract-avg-color-from-blurhash'; -const workerPromise = new Promise<WorkerMultiDispatch | null>(resolve => { +const canvasPromise = new Promise<WorkerMultiDispatch | HTMLCanvasElement>(resolve => { // テスト環境で Web Worker インスタンスは作成できない if (import.meta.env.MODE === 'test') { - resolve(null); + const canvas = document.createElement('canvas'); + canvas.width = 64; + canvas.height = 64; + resolve(canvas); return; } const testWorker = new TestWebGL2(); @@ -38,7 +41,10 @@ const workerPromise = new Promise<WorkerMultiDispatch | null>(resolve => { resolve(workers); if (_DEV_) console.log('WebGL2 in worker is supported!'); } else { - resolve(null); + const canvas = document.createElement('canvas'); + canvas.width = 64; + canvas.height = 64; + resolve(canvas); if (_DEV_) console.log('WebGL2 in worker is not supported...'); } testWorker.terminate(); @@ -70,6 +76,7 @@ const props = withDefaults(defineProps<{ width?: number; cover?: boolean; forceBlurhash?: boolean; + onlyAvgColor?: boolean; // 軽量化のためにBlurhashを使わずに平均色だけを描画 }>(), { transition: null, src: null, @@ -79,6 +86,7 @@ const props = withDefaults(defineProps<{ width: 64, cover: true, forceBlurhash: false, + onlyAvgColor: false, }); const viewId = uuid(); @@ -100,7 +108,7 @@ function waitForDecode() { .then(() => { loaded = true; }, error => { - console.error('Error occured during decoding image', img.value, error); + console.error('Error occurred during decoding image', img.value, error); throw Error(error); }); } else { @@ -139,8 +147,8 @@ function drawImage(bitmap: CanvasImageSource) { ctx.drawImage(bitmap, 0, 0, canvasWidth, canvasHeight); } -async function draw() { - if (!canvas.value || props.hash == null) return; +function drawAvg() { + if (!canvas.value || !props.hash) return; const ctx = canvas.value.getContext('2d'); if (!ctx) return; @@ -149,27 +157,30 @@ async function draw() { ctx.beginPath(); ctx.fillStyle = extractAvgColorFromBlurhash(props.hash) ?? '#888'; ctx.fillRect(0, 0, canvasWidth, canvasHeight); +} + +async function draw() { + if (props.hash == null) return; - const workers = await workerPromise; - if (workers) { - workers.postMessage( + drawAvg(); + + if (props.onlyAvgColor) return; + + const work = await canvasPromise; + if (work instanceof WorkerMultiDispatch) { + work.postMessage( { id: viewId, hash: props.hash, - width: canvasWidth, - height: canvasHeight, }, undefined, ); } else { try { - const work = document.createElement('canvas'); - work.width = canvasWidth; - work.height = canvasHeight; render(props.hash, work); - ctx.drawImage(work, 0, 0, canvasWidth, canvasHeight); + drawImage(work); } catch (error) { - console.error('Error occured during drawing blurhash', error); + console.error('Error occurred during drawing blurhash', error); } } } @@ -179,9 +190,9 @@ function workerOnMessage(event: MessageEvent) { drawImage(event.data.bitmap as ImageBitmap); } -workerPromise.then(worker => { - if (worker) { - worker.addListener(workerOnMessage); +canvasPromise.then(work => { + if (work instanceof WorkerMultiDispatch) { + work.addListener(workerOnMessage); } draw(); @@ -204,8 +215,10 @@ onMounted(() => { }); onUnmounted(() => { - workerPromise.then(worker => { - worker?.removeListener(workerOnMessage); + canvasPromise.then(work => { + if (work instanceof WorkerMultiDispatch) { + work.removeListener(workerOnMessage); + } }); }); </script> diff --git a/packages/frontend/src/components/MkInviteCode.stories.impl.ts b/packages/frontend/src/components/MkInviteCode.stories.impl.ts new file mode 100644 index 0000000000..def0a96e6a --- /dev/null +++ b/packages/frontend/src/components/MkInviteCode.stories.impl.ts @@ -0,0 +1,60 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { StoryObj } from '@storybook/vue3'; +import { rest } from 'msw'; +import { userDetailed, inviteCode } from '../../.storybook/fakes'; +import { commonHandlers } from '../../.storybook/mocks'; +import MkInviteCode from './MkInviteCode.vue'; + +export const Default = { + render(args) { + return { + components: { + MkInviteCode, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '<MkInviteCode v-bind="props" />', + }; + }, + args: { + invite: inviteCode() as any, + }, + parameters: { + layout: 'centered', + msw: { + handlers: [ + ...commonHandlers, + rest.post('/api/users/show', (req, res, ctx) => { + return res(ctx.json(userDetailed(req.params.userId as string))); + }), + ], + }, + }, + decorators: [() => ({ + template: '<div style="width:100cqmin"><story/></div>', + })], +} satisfies StoryObj<typeof MkInviteCode>; + +export const Used = { + ...Default, + args: { + invite: inviteCode(true) as any + }, +} satisfies StoryObj<typeof MkInviteCode>; + +export const Expired = { + ...Default, + args: { + invite: inviteCode(false, true, true) as any + }, +} satisfies StoryObj<typeof MkInviteCode>; diff --git a/packages/frontend/src/components/MkInviteCode.vue b/packages/frontend/src/components/MkInviteCode.vue new file mode 100644 index 0000000000..97bf732356 --- /dev/null +++ b/packages/frontend/src/components/MkInviteCode.vue @@ -0,0 +1,124 @@ +<template> +<MkFolder> + <template #label>{{ invite.code }}</template> + <template #suffix> + <span v-if="invite.used">{{ i18n.ts.used }}</span> + <span v-else-if="isExpired" style="color: var(--error)">{{ i18n.ts.expired }}</span> + <span v-else style="color: var(--success)">{{ i18n.ts.unused }}</span> + </template> + + <div class="_gaps_s" :class="$style.root"> + <div :class="$style.items"> + <div> + <div :class="$style.label">{{ i18n.ts.invitationCode }}</div> + <div>{{ invite.code }}</div> + </div> + <div v-if="moderator"> + <div :class="$style.label">{{ i18n.ts.inviteCodeCreator }}</div> + <div v-if="invite.createdBy" :class="$style.user"> + <MkAvatar :user="invite.createdBy" :class="$style.avatar" link preview/> + <MkUserName :user="invite.createdBy" :nowrap="false"/> + <div v-if="moderator">({{ invite.createdBy.id }})</div> + </div> + <div v-else>system</div> + </div> + <div v-if="invite.used"> + <div :class="$style.label">{{ i18n.ts.registeredUserUsingInviteCode }}</div> + <div v-if="invite.usedBy" :class="$style.user"> + <MkAvatar :user="invite.usedBy" :class="$style.avatar" link preview/> + <MkUserName :user="invite.usedBy" :nowrap="false"/> + <div v-if="moderator">({{ invite.usedBy.id }})</div> + </div> + <div v-else>{{ i18n.ts.unknown }} ({{ i18n.ts.waitingForMailAuth }})</div> + </div> + <div v-if="invite.expiresAt && !invite.used"> + <div :class="$style.label">{{ i18n.ts.expirationDate }}</div> + <div><MkTime :time="invite.expiresAt" mode="absolute"/></div> + </div> + <div v-if="invite.usedAt"> + <div :class="$style.label">{{ i18n.ts.inviteCodeUsedAt }}</div> + <div><MkTime :time="invite.usedAt" mode="absolute"/></div> + </div> + <div v-if="moderator"> + <div :class="$style.label">{{ i18n.ts.createdAt }}</div> + <div><MkTime :time="invite.createdAt" mode="absolute"/></div> + </div> + </div> + <div :class="$style.buttons"> + <MkButton v-if="!invite.used && !isExpired" primary rounded @click="copyInviteCode()"><i class="ti ti-copy"></i> {{ i18n.ts.copy }}</MkButton> + <MkButton v-if="!invite.used || moderator" danger rounded @click="deleteCode()"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton> + </div> + </div> +</MkFolder> +</template> + +<script lang="ts" setup> +import { computed } from 'vue'; +import * as misskey from 'misskey-js'; +import MkFolder from '@/components/MkFolder.vue'; +import MkButton from '@/components/MkButton.vue'; +import copyToClipboard from '@/scripts/copy-to-clipboard'; +import { i18n } from '@/i18n'; +import * as os from '@/os'; + +const props = defineProps<{ + invite: misskey.entities.Invite; + moderator?: boolean; +}>(); + +const emits = defineEmits<{ + (event: 'deleted', value: string): void; +}>(); + +const isExpired = computed(() => { + return props.invite.expiresAt && new Date(props.invite.expiresAt) < new Date(); +}); + +function deleteCode() { + os.apiWithDialog('invite/delete', { + inviteId: props.invite.id, + }); + emits('deleted', props.invite.id); +} + +function copyInviteCode() { + copyToClipboard(props.invite.code); + os.success(); +} +</script> + +<style lang="scss" module> +.root { + text-align: left; +} + +.items { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + grid-gap: 12px; +} + +.label { + font-size: 0.85em; + padding: 0 0 8px 0; + user-select: none; + opacity: 0.7; +} + +.user { + display: flex; + align-items: center; + gap: 8px; +} + +.avatar { + --height: 24px; + width: var(--height); + height: var(--height); +} + +.buttons { + display: flex; + gap: 8px; +} +</style> diff --git a/packages/frontend/src/components/MkLink.vue b/packages/frontend/src/components/MkLink.vue index 2e4f93e848..8e61c70484 100644 --- a/packages/frontend/src/components/MkLink.vue +++ b/packages/frontend/src/components/MkLink.vue @@ -1,6 +1,6 @@ <template> <component - :is="self ? 'MkA' : 'a'" ref="el" style="word-break: break-all;" class="_link" :[attr]="self ? url.substr(local.length) : url" :rel="rel" :target="target" + :is="self ? 'MkA' : 'a'" ref="el" style="word-break: break-all;" class="_link" :[attr]="self ? url.substring(local.length) : url" :rel="rel" :target="target" :title="url" > <slot></slot> diff --git a/packages/frontend/src/components/MkMediaImage.vue b/packages/frontend/src/components/MkMediaImage.vue index b29871c363..7e5c2c8dc3 100644 --- a/packages/frontend/src/components/MkMediaImage.vue +++ b/packages/frontend/src/components/MkMediaImage.vue @@ -20,7 +20,7 @@ <template v-if="hide"> <div :class="$style.hiddenText"> <div :class="$style.hiddenTextWrapper"> - <b v-if="image.isSensitive" style="display: block;"><i class="ti ti-alert-triangle"></i> {{ i18n.ts.sensitive }}{{ defaultStore.state.enableDataSaverMode ? ` (${i18n.ts.image}${image.size ? ' ' + bytes(image.size) : ''})` : '' }}</b> + <b v-if="image.isSensitive" style="display: block;"><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}{{ defaultStore.state.enableDataSaverMode ? ` (${i18n.ts.image}${image.size ? ' ' + bytes(image.size) : ''})` : '' }}</b> <b v-else style="display: block;"><i class="ti ti-photo"></i> {{ defaultStore.state.enableDataSaverMode && image.size ? bytes(image.size) : i18n.ts.image }}</b> <span style="display: block;">{{ i18n.ts.clickToShow }}</span> </div> @@ -30,9 +30,10 @@ <div :class="$style.indicators"> <div v-if="['image/gif', 'image/apng'].includes(image.type)" :class="$style.indicator">GIF</div> <div v-if="image.comment" :class="$style.indicator">ALT</div> - <div v-if="image.isSensitive" :class="$style.indicator" style="color: var(--warn);">NSFW</div> + <div v-if="image.isSensitive" :class="$style.indicator" style="color: var(--warn);" :title="i18n.ts.sensitive"><i class="ti ti-eye-exclamation"></i></div> </div> <button :class="$style.menu" class="_button" @click.stop="showMenu"><i class="ti ti-dots" style="vertical-align: middle;"></i></button> + <i class="ti ti-eye-off" :class="$style.hide" @click.stop="hide = true"></i> </template> </div> </template> @@ -113,6 +114,21 @@ function showMenu(ev: MouseEvent) { align-items: center; } +.hide { + display: block; + position: absolute; + border-radius: 6px; + background-color: var(--fg); + color: var(--accentLighten); + font-size: 12px; + opacity: .5; + padding: 5px 8px; + text-align: center; + cursor: pointer; + top: 12px; + right: 12px; +} + .hiddenTextWrapper { display: table-cell; text-align: center; @@ -137,8 +153,8 @@ function showMenu(ev: MouseEvent) { backdrop-filter: var(--blur, blur(15px)); color: #fff; font-size: 0.8em; - width: 32px; - height: 32px; + width: 28px; + height: 28px; text-align: center; bottom: 10px; right: 10px; diff --git a/packages/frontend/src/components/MkMediaList.vue b/packages/frontend/src/components/MkMediaList.vue index a0a2450054..be0aed6524 100644 --- a/packages/frontend/src/components/MkMediaList.vue +++ b/packages/frontend/src/components/MkMediaList.vue @@ -113,8 +113,10 @@ onMounted(() => { right: 0, }, imageClickAction: 'close', - tapAction: 'toggle-controls', + tapAction: 'close', bgOpacity: 1, + showAnimationDuration: 100, + hideAnimationDuration: 100, pswpModule: PhotoSwipe, }); diff --git a/packages/frontend/src/components/MkMediaVideo.vue b/packages/frontend/src/components/MkMediaVideo.vue index 40bae90b5e..dc5807b2dd 100644 --- a/packages/frontend/src/components/MkMediaVideo.vue +++ b/packages/frontend/src/components/MkMediaVideo.vue @@ -17,8 +17,8 @@ controls @contextmenu.stop > - <source - :src="video.url" + <source + :src="video.url" :type="video.type" > </video> diff --git a/packages/frontend/src/components/MkMiniChart.vue b/packages/frontend/src/components/MkMiniChart.vue index 89050e10f0..e884455709 100644 --- a/packages/frontend/src/components/MkMiniChart.vue +++ b/packages/frontend/src/components/MkMiniChart.vue @@ -59,8 +59,8 @@ function draw(): void { polygonPoints = `0,${ viewBoxY } ${ polylinePoints } ${ viewBoxX },${ viewBoxY }`; - headX = _polylinePoints[_polylinePoints.length - 1][0]; - headY = _polylinePoints[_polylinePoints.length - 1][1]; + headX = _polylinePoints.at(-1)![0]; + headY = _polylinePoints.at(-1)![1]; } watch(() => props.src, draw, { immediate: true }); diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index 7c9ddadbf8..deeae6e940 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -165,6 +165,7 @@ import { getNoteSummary } from '@/scripts/get-note-summary'; import { MenuItem } from '@/types/menu'; import MkRippleEffect from '@/components/MkRippleEffect.vue'; import { showMovedDialog } from '@/scripts/show-moved-dialog'; +import { shouldCollapsed } from '@/scripts/collapsed'; const props = defineProps<{ note: misskey.entities.Note; @@ -204,17 +205,7 @@ let appearNote = $computed(() => isRenote ? note.renote as misskey.entities.Note const isMyRenote = $i && ($i.id === note.userId); const showContent = ref(false); const urls = appearNote.text ? extractUrlFromMfm(mfm.parse(appearNote.text)) : null; -const isLong = (appearNote.cw == null && appearNote.text != null && ( - (appearNote.text.includes('$[x2')) || - (appearNote.text.includes('$[x3')) || - (appearNote.text.includes('$[x4')) || - (appearNote.text.includes('$[scale')) || - (appearNote.text.includes('$[position')) || - (appearNote.text.split('\n').length > 9) || - (appearNote.text.length > 500) || - (appearNote.files.length >= 5) || - (urls && urls.length >= 4) -)); +const isLong = shouldCollapsed(appearNote); const collapsed = ref(appearNote.cw == null && isLong); const isDeleted = ref(false); const muted = ref(checkWordMute(appearNote, $i, defaultStore.state.mutedWords)); @@ -222,7 +213,7 @@ const translation = ref<any>(null); const translating = ref(false); const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.user.instance); const canRenote = computed(() => ['public', 'home'].includes(appearNote.visibility) || appearNote.userId === $i.id); -let renoteCollapsed = $ref(defaultStore.state.collapseRenotes && isRenote && (($i && ($i.id === note.userId)) || (appearNote.myReaction != null))); +let renoteCollapsed = $ref(defaultStore.state.collapseRenotes && isRenote && (($i && ($i.id === note.userId || $i.id === appearNote.userId)) || (appearNote.myReaction != null))); const keymap = { 'r': () => reply(true), @@ -259,6 +250,17 @@ useTooltip(renoteButton, async (showing) => { }, {}, 'closed'); }); +type Visibility = 'public' | 'home' | 'followers' | 'specified'; + +// defaultStore.state.visibilityがstringなためstringも受け付けている +function smallerVisibility(a: Visibility | string, b: Visibility | string): Visibility { + if (a === 'specified' || b === 'specified') return 'specified'; + if (a === 'followers' || b === 'followers') return 'followers'; + if (a === 'home' || b === 'home') return 'home'; + // if (a === 'public' || b === 'public') + return 'public'; +} + function renote(viaKeyboard = false) { pleaseLogin(); showMovedDialog(); @@ -309,7 +311,12 @@ function renote(viaKeyboard = false) { os.popup(MkRippleEffect, { x, y }, {}, 'end'); } + const configuredVisibility = defaultStore.state.rememberNoteVisibility ? defaultStore.state.visibility : defaultStore.state.defaultNoteVisibility; + const localOnly = defaultStore.state.rememberNoteVisibility ? defaultStore.state.localOnly : defaultStore.state.defaultNoteLocalOnly; + os.api('notes/create', { + localOnly, + visibility: smallerVisibility(appearNote.visibility, configuredVisibility), renoteId: appearNote.id, }).then(() => { os.toast(i18n.ts.renoted); diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue index a65039277b..1f8a36b8de 100644 --- a/packages/frontend/src/components/MkNoteDetailed.vue +++ b/packages/frontend/src/components/MkNoteDetailed.vue @@ -293,7 +293,7 @@ function renote(viaKeyboard = false) { const y = rect.top + (el.offsetHeight / 2); os.popup(MkRippleEffect, { x, y }, {}, 'end'); } - + os.api('notes/create', { renoteId: appearNote.id, }).then(() => { diff --git a/packages/frontend/src/components/MkPageWindow.vue b/packages/frontend/src/components/MkPageWindow.vue index 709b5a52df..6e35ad4241 100644 --- a/packages/frontend/src/components/MkPageWindow.vue +++ b/packages/frontend/src/components/MkPageWindow.vue @@ -17,25 +17,27 @@ </template> </template> - <div :class="$style.root" style="container-type: inline-size;"> + <div ref="contents" :class="$style.root" style="container-type: inline-size;"> <RouterView :key="reloadCount" :router="router"/> </div> </MkWindow> </template> <script lang="ts" setup> -import { ComputedRef, onMounted, onUnmounted, provide } from 'vue'; +import { ComputedRef, onMounted, onUnmounted, provide, shallowRef } from 'vue'; import RouterView from '@/components/global/RouterView.vue'; import MkWindow from '@/components/MkWindow.vue'; import { popout as _popout } from '@/scripts/popout'; import copyToClipboard from '@/scripts/copy-to-clipboard'; import { url } from '@/config'; -import { mainRouter, routes } from '@/router'; -import { Router } from '@/nirax'; +import { mainRouter, routes, page } from '@/router'; +import { $i } from '@/account'; +import { Router, useScrollPositionManager } from '@/nirax'; import { i18n } from '@/i18n'; import { PageMetadata, provideMetadataReceiver } from '@/scripts/page-metadata'; import { openingWindowsCount } from '@/os'; import { claimAchievement } from '@/scripts/achievements'; +import { getScrollContainer } from '@/scripts/scroll'; const props = defineProps<{ initialPath: string; @@ -45,8 +47,9 @@ defineEmits<{ (ev: 'closed'): void; }>(); -const router = new Router(routes, props.initialPath); +const router = new Router(routes, props.initialPath, !!$i, page(() => import('@/pages/not-found.vue'))); +const contents = shallowRef<HTMLElement>(); let pageMetadata = $ref<null | ComputedRef<PageMetadata>>(); let windowEl = $shallowRef<InstanceType<typeof MkWindow>>(); const history = $ref<{ path: string; key: any; }[]>([{ @@ -117,7 +120,7 @@ const contextmenu = $computed(() => ([{ function back() { history.pop(); - router.replace(history[history.length - 1].path, history[history.length - 1].key); + router.replace(history.at(-1)!.path, history.at(-1)!.key); } function reload() { @@ -138,6 +141,8 @@ function popout() { windowEl.close(); } +useScrollPositionManager(() => getScrollContainer(contents.value), router); + onMounted(() => { openingWindowsCount.value++; if (openingWindowsCount.value >= 3) { diff --git a/packages/frontend/src/components/MkPagination.vue b/packages/frontend/src/components/MkPagination.vue index 598529bf58..b9a75f6002 100644 --- a/packages/frontend/src/components/MkPagination.vue +++ b/packages/frontend/src/components/MkPagination.vue @@ -21,14 +21,14 @@ <div v-else ref="rootEl"> <div v-show="pagination.reversed && more" key="_more_" class="_margin"> - <MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? fetchMoreAhead : null" :class="$style.more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary rounded @click="fetchMoreAhead"> + <MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? appearFetchMoreAhead : null" :class="$style.more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary rounded @click="fetchMoreAhead"> {{ i18n.ts.loadMore }} </MkButton> <MkLoading v-else class="loading"/> </div> - <slot :items="items" :fetching="fetching || moreFetching"></slot> + <slot :items="Array.from(items.values())" :fetching="fetching || moreFetching"></slot> <div v-show="!pagination.reversed && more" key="_more_" class="_margin"> - <MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? fetchMore : null" :class="$style.more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary rounded @click="fetchMore"> + <MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? appearFetchMore : null" :class="$style.more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary rounded @click="fetchMore"> {{ i18n.ts.loadMore }} </MkButton> <MkLoading v-else class="loading"/> @@ -50,6 +50,7 @@ import { i18n } from '@/i18n'; 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; @@ -71,6 +72,16 @@ export type Paging<E extends keyof misskey.Endpoints = keyof misskey.Endpoints> pageEl?: HTMLElement; }; + +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 { infoImageUrl } from '@/instance'; @@ -94,21 +105,38 @@ let backed = $ref(false); let scrollRemove = $ref<(() => void) | null>(null); -const items = ref<MisskeyEntity[]>([]); -const queue = ref<MisskeyEntity[]>([]); +/** + * 表示するアイテムのソース + * 最新が0番目 + */ +const items = ref<MisskeyEntityMap>(new Map()); + +/** + * タブが非アクティブなどの場合に更新を貯めておく + * 最新が0番目 + */ +const queue = ref<MisskeyEntityMap>(new Map()); + const offset = ref(0); + +/** + * 初期化中かどうか(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.length === 0); +const empty = computed(() => items.value.size === 0); const error = ref(false); const { enableInfiniteScroll, } = defaultStore.reactiveState; const contentEl = $computed(() => props.pagination.pageEl ?? rootEl); -const scrollableElement = $computed(() => getScrollContainer(contentEl)); +const scrollableElement = $computed(() => contentEl ? getScrollContainer(contentEl) : document.body); const visibility = useDocumentVisibility(); @@ -133,9 +161,9 @@ watch([() => props.pagination.reversed, $$(scrollableElement)], () => { }, { immediate: true }); watch($$(rootEl), () => { - scrollObserver.disconnect(); + scrollObserver?.disconnect(); nextTick(() => { - if (rootEl) scrollObserver.observe(rootEl); + if (rootEl) scrollObserver?.observe(rootEl); }); }); @@ -155,12 +183,13 @@ if (props.pagination.params && isRef(props.pagination.params)) { } watch(queue, (a, b) => { - if (a.length === 0 && b.length === 0) return; - emit('queue', queue.value.length); + if (a.size === 0 && b.size === 0) return; + emit('queue', queue.value.size); }, { deep: true }); async function init(): Promise<void> { - queue.value = []; + 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 os.api(props.pagination.endpoint, { @@ -173,11 +202,11 @@ async function init(): Promise<void> { } if (res.length === 0 || props.pagination.noPaging) { - items.value = res; + concatItems(res); more.value = false; } else { if (props.pagination.reversed) moreFetching.value = true; - items.value = res; + concatItems(res); more.value = true; } @@ -191,12 +220,11 @@ async function init(): Promise<void> { } const reload = (): Promise<void> => { - items.value = []; return init(); }; const fetchMore = async (): Promise<void> => { - if (!more.value || fetching.value || moreFetching.value || items.value.length === 0) return; + if (!more.value || fetching.value || moreFetching.value || items.value.size === 0) return; moreFetching.value = true; const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {}; await os.api(props.pagination.endpoint, { @@ -205,7 +233,7 @@ const fetchMore = async (): Promise<void> => { ...(props.pagination.offsetMode ? { offset: offset.value, } : { - untilId: items.value[items.value.length - 1].id, + untilId: Array.from(items.value.keys()).at(-1), }), }).then(res => { for (let i = 0; i < res.length; i++) { @@ -217,7 +245,7 @@ const fetchMore = async (): Promise<void> => { const oldHeight = scrollableElement ? scrollableElement.scrollHeight : getBodyScrollHeight(); const oldScroll = scrollableElement ? scrollableElement.scrollTop : window.scrollY; - items.value = items.value.concat(_res); + items.value = concatMapWithArray(items.value, _res); return nextTick(() => { if (scrollableElement) { @@ -237,7 +265,7 @@ const fetchMore = async (): Promise<void> => { moreFetching.value = false; }); } else { - items.value = items.value.concat(res); + items.value = concatMapWithArray(items.value, res); more.value = false; moreFetching.value = false; } @@ -248,7 +276,7 @@ const fetchMore = async (): Promise<void> => { moreFetching.value = false; }); } else { - items.value = items.value.concat(res); + items.value = concatMapWithArray(items.value, res); more.value = true; moreFetching.value = false; } @@ -260,7 +288,7 @@ const fetchMore = async (): Promise<void> => { }; const fetchMoreAhead = async (): Promise<void> => { - if (!more.value || fetching.value || moreFetching.value || items.value.length === 0) return; + if (!more.value || fetching.value || moreFetching.value || items.value.size === 0) return; moreFetching.value = true; const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {}; await os.api(props.pagination.endpoint, { @@ -269,14 +297,14 @@ const fetchMoreAhead = async (): Promise<void> => { ...(props.pagination.offsetMode ? { offset: offset.value, } : { - sinceId: items.value[items.value.length - 1].id, + sinceId: Array.from(items.value.keys()).at(-1), }), }).then(res => { if (res.length === 0) { - items.value = items.value.concat(res); + items.value = concatMapWithArray(items.value, res); more.value = false; } else { - items.value = items.value.concat(res); + items.value = concatMapWithArray(items.value, res); more.value = true; } offset.value += res.length; @@ -286,7 +314,32 @@ const fetchMoreAhead = async (): Promise<void> => { }); }; -const isTop = (): boolean => isBackTop.value || (props.pagination.reversed ? isBottomVisible : isTopVisible)(contentEl, TOLERANCE); +/** + * 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 isTop = (): boolean => isBackTop.value || (props.pagination.reversed ? isBottomVisible : isTopVisible)(contentEl!, TOLERANCE); watch(visibility, () => { if (visibility.value === 'hidden') { @@ -308,10 +361,15 @@ watch(visibility, () => { } }); +/** + * 最新のものとして1つだけアイテムを追加する + * ストリーミングから降ってきたアイテムはこれで追加する + * @param item アイテム + */ const prepend = (item: MisskeyEntity): void => { - // 初回表示時はunshiftだけでOK - if (!rootEl) { - items.value.unshift(item); + if (items.value.size === 0) { + items.value.set(item.id, item); + fetching.value = false; return; } @@ -319,38 +377,55 @@ const prepend = (item: MisskeyEntity): void => { else prependQueue(item); }; +/** + * 新着アイテムをitemsの先頭に追加し、displayLimitを適用する + * @param newItems 新しいアイテムの配列 + */ function unshiftItems(newItems: MisskeyEntity[]) { - const length = newItems.length + items.value.length; - items.value = [...newItems, ...items.value].slice(0, props.displayLimit); + 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; +} + +/** + * 古いアイテムを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() { - if (queue.value.length === 0) return; - unshiftItems(queue.value); - queue.value = []; + unshiftItems(Array.from(queue.value.values())); + queue.value = new Map(); } function prependQueue(newItem: MisskeyEntity) { - queue.value.unshift(newItem); - if (queue.value.length >= props.displayLimit) { - queue.value.pop(); - } + queue.value = new Map([[newItem.id, newItem], ...queue.value].slice(0, props.displayLimit) as [string, MisskeyEntity][]); } +/* + * アイテムを末尾に追加する(使うの?) + */ const appendItem = (item: MisskeyEntity): void => { - items.value.push(item); + items.value.set(item.id, item); }; -const removeItem = (finder: (item: MisskeyEntity) => boolean) => { - const i = items.value.findIndex(finder); - items.value.splice(i, 1); +const removeItem = (id: string) => { + items.value.delete(id); + queue.value.delete(id); }; const updateItem = (id: MisskeyEntity['id'], replacer: (old: MisskeyEntity) => MisskeyEntity): void => { - const i = items.value.findIndex(item => item.id === id); - items.value[i] = replacer(items.value[i]); + 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)); }; const inited = init(); @@ -364,7 +439,7 @@ onDeactivated(() => { }); function toBottom() { - scrollToBottom(contentEl); + scrollToBottom(contentEl!); } onMounted(() => { @@ -388,7 +463,11 @@ onBeforeUnmount(() => { clearTimeout(timerForSetPause); timerForSetPause = null; } - scrollObserver.disconnect(); + if (preventAppearFetchMoreTimer.value) { + clearTimeout(preventAppearFetchMoreTimer.value); + preventAppearFetchMoreTimer.value = null; + } + scrollObserver?.disconnect(); }); defineExpose({ diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index 5c65569683..f516ccbad8 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -66,7 +66,7 @@ <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"/> + <XPostFormAttaches v-model="files" @detach="detachFile" @changeSensitive="updateFileSensitive" @changeName="updateFileName" @replaceFile="replaceFile"/> <MkPollEditor v-if="poll" v-model="poll" @destroyed="poll = null"/> <MkNotePreview v-if="showPreview" :class="$style.preview" :text="text"/> <div v-if="showingOptions" style="padding: 8px 16px;"> @@ -410,7 +410,11 @@ function updateFileName(file, name) { files[files.findIndex(x => x.id === file.id)].name = name; } -function upload(file: File, name?: string) { +function replaceFile(file: misskey.entities.DriveFile, newFile: misskey.entities.DriveFile): void { + files[files.findIndex(x => x.id === file.id)] = newFile; +} + +function upload(file: File, name?: string): void { uploadFile(file, defaultStore.state.uploadFolder, name).then(res => { files.push(res); }); @@ -560,7 +564,7 @@ async function onPaste(ev: ClipboardEvent) { return; } - quoteId = paste.substr(url.length).match(/^\/notes\/(.+?)\/?$/)[1]; + quoteId = paste.substring(url.length).match(/^\/notes\/(.+?)\/?$/)[1]; }); } } @@ -903,6 +907,7 @@ defineExpose({ display: flex; flex-wrap: nowrap; gap: 4px; + margin-bottom: -10px; } .headerLeft { @@ -1015,10 +1020,12 @@ defineExpose({ .preview { padding: 16px 20px 0 20px; + max-height: 150px; + overflow: auto; } .targetNote { - padding: 0 20px 16px 20px; + padding: 10px 20px 16px 20px; } .withQuote { diff --git a/packages/frontend/src/components/MkPostFormAttaches.vue b/packages/frontend/src/components/MkPostFormAttaches.vue index 18fa142ebc..f419c75cad 100644 --- a/packages/frontend/src/components/MkPostFormAttaches.vue +++ b/packages/frontend/src/components/MkPostFormAttaches.vue @@ -5,7 +5,7 @@ <div :class="$style.file" @click="showFileMenu(element, $event)" @contextmenu.prevent="showFileMenu(element, $event)"> <MkDriveFileThumbnail :data-id="element.id" :class="$style.thumbnail" :file="element" fit="cover"/> <div v-if="element.isSensitive" :class="$style.sensitive"> - <i class="ti ti-alert-triangle" style="margin: auto;"></i> + <i class="ti ti-eye-exclamation" style="margin: auto;"></i> </div> </div> </template> @@ -16,6 +16,7 @@ <script lang="ts" setup> import { defineAsyncComponent } from 'vue'; +import * as misskey from 'misskey-js'; import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue'; import * as os from '@/os'; import { i18n } from '@/i18n'; @@ -30,8 +31,9 @@ const props = defineProps<{ const emit = defineEmits<{ (ev: 'update:modelValue', value: any[]): void; (ev: 'detach', id: string): void; - (ev: 'changeSensitive'): void; - (ev: 'changeName'): 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; @@ -85,8 +87,15 @@ async function describe(file) { }, 'closed'); } -function showFileMenu(file, ev: MouseEvent) { +async function crop(file: misskey.entities.DriveFile): Promise<void> { + const newFile = await os.cropImage(file, { aspectRatio: NaN }); + emit('replaceFile', file, newFile); +} + +function showFileMenu(file: misskey.entities.DriveFile, ev: MouseEvent): void { if (menuShowing) return; + + const isImage = file.type.startsWith('image/'); os.popupMenu([{ text: i18n.ts.renameFile, icon: 'ti ti-forms', @@ -99,7 +108,11 @@ function showFileMenu(file, ev: MouseEvent) { text: i18n.ts.describeFile, icon: 'ti ti-text-caption', action: () => { describe(file); }, - }, { + }, ...isImage ? [{ + text: i18n.ts.cropImage, + icon: 'ti ti-crop', + action: () : void => { crop(file); }, + }] : [], { text: i18n.ts.attachCancel, icon: 'ti ti-circle-x', action: () => { detachMedia(file.id); }, diff --git a/packages/frontend/src/components/MkPostFormDialog.vue b/packages/frontend/src/components/MkPostFormDialog.vue index 98af92c6f8..989c138e81 100644 --- a/packages/frontend/src/components/MkPostFormDialog.vue +++ b/packages/frontend/src/components/MkPostFormDialog.vue @@ -1,6 +1,6 @@ <template> <MkModal ref="modal" :preferType="'dialog'" @click="modal.close()" @closed="onModalClosed()"> - <MkPostForm ref="form" style="margin: 0 auto auto auto;" v-bind="props" autofocus freezeAfterPosted @posted="onPosted" @cancel="modal.close()" @esc="modal.close()"/> + <MkPostForm ref="form" :class="$style.form" v-bind="props" autofocus freezeAfterPosted @posted="onPosted" @cancel="modal.close()" @esc="modal.close()"/> </MkModal> </template> @@ -44,3 +44,10 @@ function onModalClosed() { emit('closed'); } </script> + +<style lang="scss" module> +.form { + max-height: 100%; + margin: 0 auto auto auto; +} +</style> diff --git a/packages/frontend/src/components/MkReactionsViewer.reaction.vue b/packages/frontend/src/components/MkReactionsViewer.reaction.vue index aabebb3abf..69d495d86f 100644 --- a/packages/frontend/src/components/MkReactionsViewer.reaction.vue +++ b/packages/frontend/src/components/MkReactionsViewer.reaction.vue @@ -6,7 +6,7 @@ :class="[$style.root, { [$style.reacted]: note.myReaction == reaction, [$style.canToggle]: canToggle, [$style.large]: defaultStore.state.largeNoteReactions }]" @click="toggleReaction()" > - <MkReactionIcon :class="$style.icon" :reaction="reaction" :emojiUrl="note.reactionEmojis[reaction.substr(1, reaction.length - 2)]"/> + <MkReactionIcon :class="$style.icon" :reaction="reaction" :emojiUrl="note.reactionEmojis[reaction.substring(1, reaction.length - 1)]"/> <span :class="$style.count">{{ count }}</span> </button> </template> diff --git a/packages/frontend/src/components/MkRetentionLineChart.vue b/packages/frontend/src/components/MkRetentionLineChart.vue index 9f56189f3e..276bd6f984 100644 --- a/packages/frontend/src/components/MkRetentionLineChart.vue +++ b/packages/frontend/src/components/MkRetentionLineChart.vue @@ -90,6 +90,7 @@ onMounted(async () => { ticks: { callback: (value, index, values) => value + '%', }, + min: 0, }, }, interaction: { diff --git a/packages/frontend/src/components/MkSignupDialog.rules.vue b/packages/frontend/src/components/MkSignupDialog.rules.vue index b6ffba6cc7..de5195ab4f 100644 --- a/packages/frontend/src/components/MkSignupDialog.rules.vue +++ b/packages/frontend/src/components/MkSignupDialog.rules.vue @@ -9,7 +9,10 @@ <MkInfo warn>{{ i18n.ts.invitationRequiredToRegister }}</MkInfo> </div> - <div style="text-align: center;">{{ i18n.ts.pleaseConfirmBelowBeforeSignup }}</div> + <div style="text-align: center;"> + <div>{{ i18n.ts.pleaseConfirmBelowBeforeSignup }}</div> + <div style="font-weight: bold; margin-top: 0.5em;">{{ i18n.ts.beSureToReadThisAsItIsImportant }}</div> + </div> <MkFolder v-if="availableServerRules" :defaultOpen="true"> <template #label>{{ i18n.ts.serverRules }}</template> @@ -19,7 +22,7 @@ <li v-for="item in instance.serverRules" :class="$style.rule"><div :class="$style.ruleText" v-html="item"></div></li> </ol> - <MkSwitch v-model="agreeServerRules" style="margin-top: 16px;">{{ i18n.ts.agree }}</MkSwitch> + <MkSwitch :modelValue="agreeServerRules" style="margin-top: 16px;" @update:modelValue="updateAgreeServerRules">{{ i18n.ts.agree }}</MkSwitch> </MkFolder> <MkFolder v-if="availableTos" :defaultOpen="true"> @@ -28,7 +31,7 @@ <a :href="instance.tosUrl" class="_link" target="_blank">{{ i18n.ts.termsOfService }} <i class="ti ti-external-link"></i></a> - <MkSwitch v-model="agreeTos" style="margin-top: 16px;">{{ i18n.ts.agree }}</MkSwitch> + <MkSwitch :modelValue="agreeTos" style="margin-top: 16px;" @update:modelValue="updateAgreeTos">{{ i18n.ts.agree }}</MkSwitch> </MkFolder> <MkFolder :defaultOpen="true"> @@ -37,7 +40,7 @@ <a href="https://misskey-hub.net/docs/notes.html" class="_link" target="_blank">{{ i18n.ts.basicNotesBeforeCreateAccount }} <i class="ti ti-external-link"></i></a> - <MkSwitch v-model="agreeNote" style="margin-top: 16px;" data-cy-signup-rules-notes-agree>{{ i18n.ts.agree }}</MkSwitch> + <MkSwitch :modelValue="agreeNote" style="margin-top: 16px;" data-cy-signup-rules-notes-agree @update:modelValue="updateAgreeNote">{{ i18n.ts.agree }}</MkSwitch> </MkFolder> <div v-if="!agreed" style="text-align: center;">{{ i18n.ts.pleaseAgreeAllToContinue }}</div> @@ -52,13 +55,14 @@ </template> <script lang="ts" setup> -import { computed, ref } from 'vue'; +import { computed, onMounted, ref, watch } from 'vue'; import { instance } from '@/instance'; import { i18n } from '@/i18n'; import MkButton from '@/components/MkButton.vue'; import MkFolder from '@/components/MkFolder.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import MkInfo from '@/components/MkInfo.vue'; +import * as os from '@/os'; const availableServerRules = instance.serverRules.length > 0; const availableTos = instance.tosUrl != null; @@ -75,6 +79,48 @@ const emit = defineEmits<{ (ev: 'cancel'): void; (ev: 'done'): void; }>(); + +async function updateAgreeServerRules(v: boolean) { + if (v) { + const confirm = await os.confirm({ + type: 'question', + title: i18n.ts.doYouAgree, + text: i18n.t('iHaveReadXCarefullyAndAgree', { x: i18n.ts.serverRules }), + }); + if (confirm.canceled) return; + agreeServerRules.value = true; + } else { + agreeServerRules.value = false; + } +} + +async function updateAgreeTos(v: boolean) { + if (v) { + const confirm = await os.confirm({ + type: 'question', + title: i18n.ts.doYouAgree, + text: i18n.t('iHaveReadXCarefullyAndAgree', { x: i18n.ts.termsOfService }), + }); + if (confirm.canceled) return; + agreeTos.value = true; + } else { + agreeTos.value = false; + } +} + +async function updateAgreeNote(v: boolean) { + if (v) { + const confirm = await os.confirm({ + type: 'question', + title: i18n.ts.doYouAgree, + text: i18n.t('iHaveReadXCarefullyAndAgree', { x: i18n.ts.basicNotesBeforeCreateAccount }), + }); + if (confirm.canceled) return; + agreeNote.value = true; + } else { + agreeNote.value = false; + } +} </script> <style lang="scss" module> diff --git a/packages/frontend/src/components/MkSparkle.vue b/packages/frontend/src/components/MkSparkle.vue index ba1493aa71..51d70822d3 100644 --- a/packages/frontend/src/components/MkSparkle.vue +++ b/packages/frontend/src/components/MkSparkle.vue @@ -32,7 +32,8 @@ </path> </svg> --> - <svg v-for="particle in particles" :key="particle.id" :width="width" :height="height" :viewBox="`0 0 ${width} ${height}`" xmlns="http://www.w3.org/2000/svg" style="position: absolute; top: -32px; left: -32px;"> + <!-- MFMで上位レイヤーに表示されるため、リンクをクリックできるようにstyleにpointer-events: none;を付与。 --> + <svg v-for="particle in particles" :key="particle.id" :width="width" :height="height" :viewBox="`0 0 ${width} ${height}`" xmlns="http://www.w3.org/2000/svg" style="position: absolute; top: -32px; left: -32px; pointer-events: none;"> <path style="transform-origin: center; transform-box: fill-box;" :transform="`translate(${particle.x} ${particle.y})`" @@ -115,6 +116,5 @@ onUnmounted(() => { .root { position: relative; display: inline-block; - pointer-events: none; } </style> diff --git a/packages/frontend/src/components/MkSubNoteContent.vue b/packages/frontend/src/components/MkSubNoteContent.vue index 3a050889c8..3a032a1167 100644 --- a/packages/frontend/src/components/MkSubNoteContent.vue +++ b/packages/frontend/src/components/MkSubNoteContent.vue @@ -15,9 +15,12 @@ <summary>{{ i18n.ts.poll }}</summary> <MkPoll :note="note"/> </details> - <button v-if="collapsed" :class="$style.fade" class="_button" @click="collapsed = false"> + <button v-if="isLong && collapsed" :class="$style.fade" class="_button" @click="collapsed = false"> <span :class="$style.fadeLabel">{{ i18n.ts.showMore }}</span> </button> + <button v-else-if="isLong && !collapsed" :class="$style.showLess" class="_button" @click="collapsed = true"> + <span :class="$style.showLessLabel">{{ i18n.ts.showLess }}</span> + </button> </div> </template> @@ -28,16 +31,15 @@ import MkMediaList from '@/components/MkMediaList.vue'; import MkPoll from '@/components/MkPoll.vue'; import { i18n } from '@/i18n'; import { $i } from '@/account'; +import { shouldCollapsed } from '@/scripts/collapsed'; const props = defineProps<{ note: misskey.entities.Note; }>(); -const collapsed = $ref( - props.note.cw == null && props.note.text != null && ( - (props.note.text.split('\n').length > 9) || - (props.note.text.length > 500) - )); +const isLong = shouldCollapsed(props.note); + +const collapsed = $ref(isLong); </script> <style lang="scss" module> @@ -86,4 +88,20 @@ const collapsed = $ref( font-style: oblique; color: var(--renote); } + +.showLess { + width: 100%; + margin-top: 14px; + position: sticky; + bottom: calc(var(--stickyBottom, 0px) + 14px); +} + +.showLessLabel { + display: inline-block; + background: var(--popup); + padding: 6px 10px; + font-size: 0.8em; + border-radius: 999px; + box-shadow: 0 2px 6px rgb(0 0 0 / 20%); +} </style> diff --git a/packages/frontend/src/components/MkSuperMenu.vue b/packages/frontend/src/components/MkSuperMenu.vue index 72b70416d9..0bc9b03160 100644 --- a/packages/frontend/src/components/MkSuperMenu.vue +++ b/packages/frontend/src/components/MkSuperMenu.vue @@ -46,7 +46,7 @@ defineProps<{ margin: 0 0 8px 0; font-size: 0.9em; } - + > .items { > .item { display: flex; diff --git a/packages/frontend/src/components/MkUrlPreview.vue b/packages/frontend/src/components/MkUrlPreview.vue index fcad5b8064..f7b1b7dfff 100644 --- a/packages/frontend/src/components/MkUrlPreview.vue +++ b/packages/frontend/src/components/MkUrlPreview.vue @@ -32,7 +32,7 @@ </div> </template> <div v-else> - <component :is="self ? 'MkA' : 'a'" :class="[$style.link, { [$style.compact]: compact }]" :[attr]="self ? url.substr(local.length) : url" rel="nofollow noopener" :target="target" :title="url"> + <component :is="self ? 'MkA' : 'a'" :class="[$style.link, { [$style.compact]: compact }]" :[attr]="self ? url.substring(local.length) : url" rel="nofollow noopener" :target="target" :title="url"> <div v-if="thumbnail" :class="$style.thumbnail" :style="`background-image: url('${thumbnail}')`"> </div> <article :class="$style.body"> @@ -52,19 +52,21 @@ </footer> </article> </component> - <div v-if="tweetId" :class="$style.action"> - <MkButton :small="true" inline @click="tweetExpanded = true"> - <i class="ti ti-brand-twitter"></i> {{ i18n.ts.expandTweet }} - </MkButton> - </div> - <div v-if="!playerEnabled && player.url" :class="$style.action"> - <MkButton :small="true" inline @click="playerEnabled = true"> - <i class="ti ti-player-play"></i> {{ i18n.ts.enablePlayer }} - </MkButton> - <MkButton v-if="!isMobile" :small="true" inline @click="openPlayer()"> - <i class="ti ti-picture-in-picture"></i> {{ i18n.ts.openInWindow }} - </MkButton> - </div> + <template v-if="showActions"> + <div v-if="tweetId" :class="$style.action"> + <MkButton :small="true" inline @click="tweetExpanded = true"> + <i class="ti ti-brand-twitter"></i> {{ i18n.ts.expandTweet }} + </MkButton> + </div> + <div v-if="!playerEnabled && player.url" :class="$style.action"> + <MkButton :small="true" inline @click="playerEnabled = true"> + <i class="ti ti-player-play"></i> {{ i18n.ts.enablePlayer }} + </MkButton> + <MkButton v-if="!isMobile" :small="true" inline @click="openPlayer()"> + <i class="ti ti-picture-in-picture"></i> {{ i18n.ts.openInWindow }} + </MkButton> + </div> + </template> </div> </template> @@ -85,9 +87,11 @@ const props = withDefaults(defineProps<{ url: string; detail?: boolean; compact?: boolean; + showActions?: boolean; }>(), { detail: false, compact: false, + showActions: true, }); const MOBILE_THRESHOLD = 500; diff --git a/packages/frontend/src/components/MkUrlPreviewPopup.vue b/packages/frontend/src/components/MkUrlPreviewPopup.vue index 36a9e2f73f..d360169c82 100644 --- a/packages/frontend/src/components/MkUrlPreviewPopup.vue +++ b/packages/frontend/src/components/MkUrlPreviewPopup.vue @@ -1,7 +1,7 @@ <template> <div :class="$style.root" :style="{ zIndex, top: top + 'px', left: left + 'px' }"> <Transition :name="defaultStore.state.animation ? '_transition_zoom' : ''" @afterLeave="emit('closed')"> - <MkUrlPreview v-if="showing" class="_popup _shadow" :url="url"/> + <MkUrlPreview v-if="showing" class="_popup _shadow" :url="url" :showActions="false"/> </Transition> </div> </template> diff --git a/packages/frontend/src/components/MkUserInfo.vue b/packages/frontend/src/components/MkUserInfo.vue index 172b517511..5e538cc528 100644 --- a/packages/frontend/src/components/MkUserInfo.vue +++ b/packages/frontend/src/components/MkUserInfo.vue @@ -15,13 +15,13 @@ </div> <div :class="$style.status"> <div :class="$style.statusItem"> - <p :class="$style.statusItemLabel">{{ i18n.ts.notes }}</p><span :class="$style.statusItemValue">{{ user.notesCount }}</span> + <p :class="$style.statusItemLabel">{{ i18n.ts.notes }}</p><span :class="$style.statusItemValue">{{ number(user.notesCount) }}</span> </div> - <div :class="$style.statusItem"> - <p :class="$style.statusItemLabel">{{ i18n.ts.following }}</p><span :class="$style.statusItemValue">{{ user.followingCount }}</span> + <div v-if="isFfVisibleForMe(user)" :class="$style.statusItem"> + <p :class="$style.statusItemLabel">{{ i18n.ts.following }}</p><span :class="$style.statusItemValue">{{ number(user.followingCount) }}</span> </div> - <div :class="$style.statusItem"> - <p :class="$style.statusItemLabel">{{ i18n.ts.followers }}</p><span :class="$style.statusItemValue">{{ user.followersCount }}</span> + <div v-if="isFfVisibleForMe(user)" :class="$style.statusItem"> + <p :class="$style.statusItemLabel">{{ i18n.ts.followers }}</p><span :class="$style.statusItemValue">{{ number(user.followersCount) }}</span> </div> </div> <MkFollowButton v-if="$i && user.id != $i.id" :class="$style.follow" :user="user" mini/> @@ -31,9 +31,11 @@ <script lang="ts" setup> import * as misskey from 'misskey-js'; import MkFollowButton from '@/components/MkFollowButton.vue'; +import number from '@/filters/number'; import { userPage } from '@/filters/user'; import { i18n } from '@/i18n'; import { $i } from '@/account'; +import { isFfVisibleForMe } from '@/scripts/isFfVisibleForMe'; defineProps<{ user: misskey.entities.UserDetailed; diff --git a/packages/frontend/src/components/MkUserPopup.vue b/packages/frontend/src/components/MkUserPopup.vue index c3b777a12e..04331ceb50 100644 --- a/packages/frontend/src/components/MkUserPopup.vue +++ b/packages/frontend/src/components/MkUserPopup.vue @@ -30,11 +30,11 @@ <div :class="$style.statusItemLabel">{{ i18n.ts.notes }}</div> <div>{{ number(user.notesCount) }}</div> </div> - <div :class="$style.statusItem"> + <div v-if="isFfVisibleForMe(user)" :class="$style.statusItem"> <div :class="$style.statusItemLabel">{{ i18n.ts.following }}</div> <div>{{ number(user.followingCount) }}</div> </div> - <div :class="$style.statusItem"> + <div v-if="isFfVisibleForMe(user)" :class="$style.statusItem"> <div :class="$style.statusItemLabel">{{ i18n.ts.followers }}</div> <div>{{ number(user.followersCount) }}</div> </div> @@ -61,6 +61,7 @@ import number from '@/filters/number'; import { i18n } from '@/i18n'; import { defaultStore } from '@/store'; import { $i } from '@/account'; +import { isFfVisibleForMe } from '@/scripts/isFfVisibleForMe'; const props = defineProps<{ showing: boolean; @@ -88,7 +89,7 @@ onMounted(() => { user = props.q; } else { const query = props.q.startsWith('@') ? - Acct.parse(props.q.substr(1)) : + Acct.parse(props.q.substring(1)) : { userId: props.q }; os.api('users/show', query).then(res => { @@ -195,7 +196,7 @@ onMounted(() => { .mfm { display: -webkit-box; -webkit-line-clamp: 5; - -webkit-box-orient: vertical; + -webkit-box-orient: vertical; overflow: hidden; } diff --git a/packages/frontend/src/components/MkUserSetupDialog.Follow.stories.impl.ts b/packages/frontend/src/components/MkUserSetupDialog.Follow.stories.impl.ts index 7d5a65f41a..67243b78f3 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.Follow.stories.impl.ts +++ b/packages/frontend/src/components/MkUserSetupDialog.Follow.stories.impl.ts @@ -26,7 +26,7 @@ export const Default = { }; }, args: { - + }, parameters: { layout: 'centered', diff --git a/packages/frontend/src/components/MkUserSetupDialog.Privacy.stories.impl.ts b/packages/frontend/src/components/MkUserSetupDialog.Privacy.stories.impl.ts index 70817d83c3..0726289722 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.Privacy.stories.impl.ts +++ b/packages/frontend/src/components/MkUserSetupDialog.Privacy.stories.impl.ts @@ -23,7 +23,7 @@ export const Default = { }; }, args: { - + }, parameters: { layout: 'centered', diff --git a/packages/frontend/src/components/MkUserSetupDialog.Profile.stories.impl.ts b/packages/frontend/src/components/MkUserSetupDialog.Profile.stories.impl.ts index f4930aa26b..3444605e97 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.Profile.stories.impl.ts +++ b/packages/frontend/src/components/MkUserSetupDialog.Profile.stories.impl.ts @@ -23,7 +23,7 @@ export const Default = { }; }, args: { - + }, parameters: { layout: 'centered', diff --git a/packages/frontend/src/components/MkUserSetupDialog.User.vue b/packages/frontend/src/components/MkUserSetupDialog.User.vue index d66f34f165..b35f27c5b0 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.User.vue +++ b/packages/frontend/src/components/MkUserSetupDialog.User.vue @@ -90,7 +90,7 @@ async function follow() { .mfm { display: -webkit-box; -webkit-line-clamp: 5; - -webkit-box-orient: vertical; + -webkit-box-orient: vertical; overflow: hidden; } diff --git a/packages/frontend/src/components/MkUserSetupDialog.stories.impl.ts b/packages/frontend/src/components/MkUserSetupDialog.stories.impl.ts index 55790602d5..f47f4c13d5 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.stories.impl.ts +++ b/packages/frontend/src/components/MkUserSetupDialog.stories.impl.ts @@ -26,7 +26,7 @@ export const Default = { }; }, args: { - + }, parameters: { layout: 'centered', diff --git a/packages/frontend/src/components/global/MkA.stories.impl.ts b/packages/frontend/src/components/global/MkA.stories.impl.ts index 639ed19af2..6e3ff573cb 100644 --- a/packages/frontend/src/components/global/MkA.stories.impl.ts +++ b/packages/frontend/src/components/global/MkA.stories.impl.ts @@ -29,11 +29,11 @@ export const Default = { const canvas = within(canvasElement); const a = canvas.getByRole<HTMLAnchorElement>('link'); await expect(a.href).toMatch(/^https?:\/\/.*#test$/); - await userEvent.click(a, { button: 2 }); + await userEvent.pointer({ keys: '[MouseRight]', target: a }); await tick(); const menu = canvas.getByRole('menu'); await expect(menu).toBeInTheDocument(); - await userEvent.click(a, { button: 0 }); + await userEvent.click(a); a.blur(); await tick(); await expect(menu).not.toBeInTheDocument(); diff --git a/packages/frontend/src/components/global/MkAd.stories.impl.ts b/packages/frontend/src/components/global/MkAd.stories.impl.ts index 7d8a42a03c..8d15e1f65b 100644 --- a/packages/frontend/src/components/global/MkAd.stories.impl.ts +++ b/packages/frontend/src/components/global/MkAd.stories.impl.ts @@ -1,9 +1,12 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { expect } from '@storybook/jest'; -import { userEvent, within } from '@storybook/testing-library'; +import { userEvent, waitFor, within } from '@storybook/testing-library'; import { StoryObj } from '@storybook/vue3'; -import { i18n } from '@/i18n'; import MkAd from './MkAd.vue'; +import { i18n } from '@/i18n'; + +let lock: Promise<undefined> | undefined; + const common = { render(args) { return { @@ -25,39 +28,57 @@ const common = { template: '<MkAd v-bind="props" />', }; }, + /* FIXME: disabled because it still didn’t pass after applying #11267 async play({ canvasElement, args }) { - const canvas = within(canvasElement); - const a = canvas.getByRole<HTMLAnchorElement>('link'); - await expect(a.href).toMatch(/^https?:\/\/.*#test$/); - const img = within(a).getByRole('img'); - await expect(img).toBeInTheDocument(); - let buttons = canvas.getAllByRole<HTMLButtonElement>('button'); - await expect(buttons).toHaveLength(1); - const i = buttons[0]; - await expect(i).toBeInTheDocument(); - await userEvent.click(i); - await expect(a).not.toBeInTheDocument(); - await expect(i).not.toBeInTheDocument(); - buttons = canvas.getAllByRole<HTMLButtonElement>('button'); - await expect(buttons).toHaveLength(args.__hasReduce ? 2 : 1); - const reduce = args.__hasReduce ? buttons[0] : null; - const back = buttons[args.__hasReduce ? 1 : 0]; - if (reduce) { - await expect(reduce).toBeInTheDocument(); - await expect(reduce).toHaveTextContent(i18n.ts._ad.reduceFrequencyOfThisAd); + if (lock) { + console.warn('This test is unexpectedly running twice in parallel, fix it!'); + console.warn('See also: https://github.com/misskey-dev/misskey/issues/11267'); + await lock; } - await expect(back).toBeInTheDocument(); - await expect(back).toHaveTextContent(i18n.ts._ad.back); - await userEvent.click(back); - if (reduce) { - await expect(reduce).not.toBeInTheDocument(); + + let resolve: (value?: any) => void; + lock = new Promise(r => resolve = r); + + try { + const canvas = within(canvasElement); + const a = canvas.getByRole<HTMLAnchorElement>('link'); + await expect(a.href).toMatch(/^https?:\/\/.*#test$/); + const img = within(a).getByRole('img'); + await expect(img).toBeInTheDocument(); + let buttons = canvas.getAllByRole<HTMLButtonElement>('button'); + await expect(buttons).toHaveLength(1); + const i = buttons[0]; + await expect(i).toBeInTheDocument(); + await userEvent.click(i); + await waitFor(() => expect(canvasElement).toHaveTextContent(i18n.ts._ad.back)); + await expect(a).not.toBeInTheDocument(); + await expect(i).not.toBeInTheDocument(); + buttons = canvas.getAllByRole<HTMLButtonElement>('button'); + await expect(buttons).toHaveLength(args.__hasReduce ? 2 : 1); + const reduce = args.__hasReduce ? buttons[0] : null; + const back = buttons[args.__hasReduce ? 1 : 0]; + if (reduce) { + await expect(reduce).toBeInTheDocument(); + await expect(reduce).toHaveTextContent(i18n.ts._ad.reduceFrequencyOfThisAd); + } + await expect(back).toBeInTheDocument(); + await expect(back).toHaveTextContent(i18n.ts._ad.back); + await userEvent.click(back); + await waitFor(() => expect(canvas.queryByRole('img')).toBeTruthy()); + if (reduce) { + await expect(reduce).not.toBeInTheDocument(); + } + await expect(back).not.toBeInTheDocument(); + const aAgain = canvas.getByRole<HTMLAnchorElement>('link'); + await expect(aAgain).toBeInTheDocument(); + const imgAgain = within(aAgain).getByRole('img'); + await expect(imgAgain).toBeInTheDocument(); + } finally { + resolve!(); + lock = undefined; } - await expect(back).not.toBeInTheDocument(); - const aAgain = canvas.getByRole<HTMLAnchorElement>('link'); - await expect(aAgain).toBeInTheDocument(); - const imgAgain = within(aAgain).getByRole('img'); - await expect(imgAgain).toBeInTheDocument(); }, + */ args: { prefer: [], specify: { diff --git a/packages/frontend/src/components/global/MkAvatar.vue b/packages/frontend/src/components/global/MkAvatar.vue index efe74b7cc3..1952ba9811 100644 --- a/packages/frontend/src/components/global/MkAvatar.vue +++ b/packages/frontend/src/components/global/MkAvatar.vue @@ -1,6 +1,6 @@ <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"> - <img :class="$style.inner" :src="url" :hash="user?.avatarBlurhash" :cover="true"/> + <MkImgWithBlurhash :class="$style.inner" :src="url" :hash="user?.avatarBlurhash" :cover="true" :onlyAvgColor="true"/> <MkUserOnlineIndicator v-if="indicator" :class="$style.indicator" :user="user"/> <div v-if="user.isCat" :class="[$style.ears]"> <div :class="$style.earLeft"> @@ -24,6 +24,7 @@ <script lang="ts" setup> import { watch } from 'vue'; import * as misskey from 'misskey-js'; +import MkImgWithBlurhash from '../MkImgWithBlurhash.vue'; import MkA from './MkA.vue'; import { getStaticImageUrl } from '@/scripts/media-proxy'; import { extractAvgColorFromBlurhash } from '@/scripts/extract-avg-color-from-blurhash'; diff --git a/packages/frontend/src/components/global/MkCustomEmoji.vue b/packages/frontend/src/components/global/MkCustomEmoji.vue index e8a7f17cc6..e7af472682 100644 --- a/packages/frontend/src/components/global/MkCustomEmoji.vue +++ b/packages/frontend/src/components/global/MkCustomEmoji.vue @@ -18,7 +18,7 @@ const props = defineProps<{ useOriginalSize?: boolean; }>(); -const customEmojiName = computed(() => (props.name[0] === ':' ? props.name.substr(1, props.name.length - 2) : props.name).replace('@.', '')); +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 rawUrl = computed(() => { diff --git a/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts b/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts index 2a50a34390..1c417991e0 100644 --- a/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts +++ b/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts @@ -199,7 +199,7 @@ export default function(props: { } const x = Math.min(parseFloat(token.props.args.x ?? '1'), 5); const y = Math.min(parseFloat(token.props.args.y ?? '1'), 5); - style = `transform: scale(${x}, ${y});`; + style = `transform: scale(${x}, ${y});`; scale = scale * Math.max(x, y); break; } @@ -256,7 +256,7 @@ export default function(props: { case 'mention': { return [h(MkMention, { key: Math.random(), - host: (token.props.host == null && props.author && props.author.host != null ? props.author.host : token.props.host) || host, + host: (token.props.host == null && props.author && props.author.host != null ? props.author.host : token.props.host) ?? host, username: token.props.username, })]; } diff --git a/packages/frontend/src/components/global/MkTime.vue b/packages/frontend/src/components/global/MkTime.vue index dfc3c89798..9b02f989b4 100644 --- a/packages/frontend/src/components/global/MkTime.vue +++ b/packages/frontend/src/components/global/MkTime.vue @@ -9,7 +9,7 @@ <script lang="ts" setup> import isChromatic from 'chromatic/isChromatic'; -import { onUnmounted } from 'vue'; +import { onMounted, onUnmounted } from 'vue'; import { i18n } from '@/i18n'; import { dateTimeFormat } from '@/scripts/intl-const'; @@ -29,11 +29,12 @@ const invalid = Number.isNaN(_time); const absolute = !invalid ? dateTimeFormat.format(_time) : i18n.ts._ago.invalid; let now = $ref((props.origin ?? new Date()).getTime()); +const ago = $computed(() => (now - _time) / 1000/*ms*/); + const relative = $computed<string>(() => { if (props.mode === 'absolute') return ''; // absoluteではrelativeを使わないので計算しない if (invalid) return i18n.ts._ago.invalid; - const ago = (now - _time) / 1000/*ms*/; return ( ago >= 31536000 ? i18n.t('_ago.yearsAgo', { n: Math.round(ago / 31536000).toString() }) : ago >= 2592000 ? i18n.t('_ago.monthsAgo', { n: Math.round(ago / 2592000).toString() }) : @@ -47,19 +48,25 @@ const relative = $computed<string>(() => { }); let tickId: number; +let currentInterval: number; function tick() { - now = props.origin ?? (new Date()).getTime(); - const ago = (now - _time) / 1000/*ms*/; - const next = ago < 60 ? 10000 : ago < 3600 ? 60000 : 180000; + now = (new Date()).getTime(); + const nextInterval = ago < 60 ? 10000 : ago < 3600 ? 60000 : 180000; - tickId = window.setTimeout(tick, next); + if (currentInterval !== nextInterval) { + if (tickId) window.clearInterval(tickId); + currentInterval = nextInterval; + tickId = window.setInterval(tick, nextInterval); + } } -if (props.mode === 'relative' || props.mode === 'detail') { - tick(); +if (!invalid && props.origin === null && (props.mode === 'relative' || props.mode === 'detail')) { + onMounted(() => { + tick(); + }); onUnmounted(() => { - window.clearTimeout(tickId); + if (tickId) window.clearInterval(tickId); }); } </script> diff --git a/packages/frontend/src/components/global/i18n.ts b/packages/frontend/src/components/global/i18n.ts index 2708b759aa..6706d08f2f 100644 --- a/packages/frontend/src/components/global/i18n.ts +++ b/packages/frontend/src/components/global/i18n.ts @@ -11,13 +11,13 @@ export default function(props: { src: string; tag?: string; textTag?: string; }, parsed.push(str); break; } else { - if (nextBracketOpen > 0) parsed.push(str.substr(0, nextBracketOpen)); + if (nextBracketOpen > 0) parsed.push(str.substring(0, nextBracketOpen)); parsed.push({ arg: str.substring(nextBracketOpen + 1, nextBracketClose), }); } - str = str.substr(nextBracketClose + 1); + str = str.substring(nextBracketClose + 1); } return h(props.tag ?? 'span', parsed.map(x => typeof x === 'string' ? (props.textTag ? h(props.textTag, x) : x) : slots[x.arg]())); diff --git a/packages/frontend/src/const.ts b/packages/frontend/src/const.ts index ad7fa372e9..1d883c038e 100644 --- a/packages/frontend/src/const.ts +++ b/packages/frontend/src/const.ts @@ -57,6 +57,9 @@ export const ROLE_POLICIES = [ 'ltlAvailable', 'canPublicNote', 'canInvite', + 'inviteLimit', + 'inviteLimitCycle', + 'inviteExpirationTime', 'canManageCustomEmojis', 'canSearchNotes', 'canHideAds', diff --git a/packages/frontend/src/directives/adaptive-bg.ts b/packages/frontend/src/directives/adaptive-bg.ts index 313aad7996..83bcd7089d 100644 --- a/packages/frontend/src/directives/adaptive-bg.ts +++ b/packages/frontend/src/directives/adaptive-bg.ts @@ -10,7 +10,7 @@ export default { return el.parentElement ? getBgColor(el.parentElement) : 'transparent'; } }; - + const parentBg = getBgColor(src.parentElement); const myBg = window.getComputedStyle(src).backgroundColor; diff --git a/packages/frontend/src/directives/adaptive-border.ts b/packages/frontend/src/directives/adaptive-border.ts index 619c9f0b6d..5bd04024be 100644 --- a/packages/frontend/src/directives/adaptive-border.ts +++ b/packages/frontend/src/directives/adaptive-border.ts @@ -10,7 +10,7 @@ export default { return el.parentElement ? getBgColor(el.parentElement) : 'transparent'; } }; - + const parentBg = getBgColor(src.parentElement); const myBg = window.getComputedStyle(src).backgroundColor; diff --git a/packages/frontend/src/directives/panel.ts b/packages/frontend/src/directives/panel.ts index d31dc41ed4..8727183d36 100644 --- a/packages/frontend/src/directives/panel.ts +++ b/packages/frontend/src/directives/panel.ts @@ -10,7 +10,7 @@ export default { return el.parentElement ? getBgColor(el.parentElement) : 'transparent'; } }; - + const parentBg = getBgColor(src.parentElement); const myBg = getComputedStyle(document.documentElement).getPropertyValue('--panel'); diff --git a/packages/frontend/src/filters/date.ts b/packages/frontend/src/filters/date.ts index 706b7d60cc..9bc9bfe8ab 100644 --- a/packages/frontend/src/filters/date.ts +++ b/packages/frontend/src/filters/date.ts @@ -1,4 +1,4 @@ import { dateTimeFormat } from '@/scripts/intl-const'; -export default (d: Date | number | undefined) => dateTimeFormat.format(d); +export default (d: Date | number | undefined) => dateTimeFormat.format(d); export const dateString = (d: string) => dateTimeFormat.format(new Date(d)); diff --git a/packages/frontend/src/local-storage.ts b/packages/frontend/src/local-storage.ts index ca4f21f79b..f9d04f7950 100644 --- a/packages/frontend/src/local-storage.ts +++ b/packages/frontend/src/local-storage.ts @@ -14,7 +14,7 @@ type Keys = 'wallpaper' | 'theme' | 'colorScheme' | - 'useSystemFont' | + 'useSystemFont' | 'fontSize' | 'ui' | 'ui_temp' | diff --git a/packages/frontend/src/nirax.ts b/packages/frontend/src/nirax.ts index 68977ed796..3a03444de2 100644 --- a/packages/frontend/src/nirax.ts +++ b/packages/frontend/src/nirax.ts @@ -1,8 +1,7 @@ // NIRAX --- A lightweight router import { EventEmitter } from 'eventemitter3'; -import { Component, shallowRef, ShallowRef } from 'vue'; -import { pleaseLogin } from '@/scripts/please-login'; +import { Component, onMounted, shallowRef, ShallowRef } from 'vue'; import { safeURIDecode } from '@/scripts/safe-uri-decode'; type RouteDef = { @@ -23,7 +22,7 @@ type ParsedPath = (string | { optional?: boolean; })[]; -export type Resolved = { route: RouteDef; props: Map<string, string>; child?: Resolved; }; +export type Resolved = { route: RouteDef; props: Map<string, string | boolean>; child?: Resolved; }; function parsePath(path: string): ParsedPath { const res = [] as ParsedPath; @@ -75,15 +74,19 @@ export class Router extends EventEmitter<{ public currentRef: ShallowRef<Resolved> = shallowRef(); public currentRoute: ShallowRef<RouteDef> = shallowRef(); private currentPath: string; + private isLoggedIn: boolean; + private notFoundPageComponent: Component; private currentKey = Date.now().toString(); public navHook: ((path: string, flag?: any) => boolean) | null = null; - constructor(routes: Router['routes'], currentPath: Router['currentPath']) { + constructor(routes: Router['routes'], currentPath: Router['currentPath'], isLoggedIn: boolean, notFoundPageComponent: Component) { super(); this.routes = routes; this.currentPath = currentPath; + this.isLoggedIn = isLoggedIn; + this.notFoundPageComponent = notFoundPageComponent; this.navigate(currentPath, null, false); } @@ -159,11 +162,11 @@ export class Router extends EventEmitter<{ if (route.hash != null && hash != null) { props.set(route.hash, safeURIDecode(hash)); } - + if (route.query != null && queryString != null) { const queryObject = [...new URLSearchParams(queryString).entries()] .reduce((obj, entry) => ({ ...obj, [entry[0]]: entry[1] }), {}); - + for (const q in route.query) { const as = route.query[q]; if (queryObject[q]) { @@ -171,7 +174,7 @@ export class Router extends EventEmitter<{ } } } - + return { route, props, @@ -212,8 +215,9 @@ export class Router extends EventEmitter<{ throw new Error('no route found for: ' + path); } - if (res.route.loginRequired) { - pleaseLogin('/'); + if (res.route.loginRequired && !this.isLoggedIn) { + res.route.component = this.notFoundPageComponent; + res.props.set('showLoginPopup', true); } const isSamePath = beforePath === path; @@ -263,13 +267,33 @@ export class Router extends EventEmitter<{ }); } - public replace(path: string, key?: string | null, emitEvent = true) { + public replace(path: string, key?: string | null) { this.navigate(path, key); - if (emitEvent) { - this.emit('replace', { - path, - key: this.currentKey, - }); - } } } + +export function useScrollPositionManager(getScrollContainer: () => HTMLElement, router: Router) { + const scrollPosStore = new Map<string, number>(); + + onMounted(() => { + const scrollContainer = getScrollContainer(); + + scrollContainer.addEventListener('scroll', () => { + scrollPosStore.set(router.getCurrentKey(), scrollContainer.scrollTop); + }, { passive: true }); + + router.addListener('change', ctx => { + const scrollPos = scrollPosStore.get(ctx.key) ?? 0; + scrollContainer.scroll({ top: scrollPos, behavior: 'instant' }); + if (scrollPos !== 0) { + window.setTimeout(() => { // 遷移直後はタイミングによってはコンポーネントが復元し切ってない可能性も考えられるため少し時間を空けて再度スクロール + scrollContainer.scroll({ top: scrollPos, behavior: 'instant' }); + }, 100); + } + }); + + router.addListener('same', () => { + scrollContainer.scroll({ top: 0, behavior: 'smooth' }); + }); + }); +} diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts index c44d348046..1a5ed90541 100644 --- a/packages/frontend/src/os.ts +++ b/packages/frontend/src/os.ts @@ -460,11 +460,13 @@ export async function pickEmoji(src: HTMLElement | null, opts) { export async function cropImage(image: Misskey.entities.DriveFile, options: { aspectRatio: number; + uploadFolder?: string | null; }): Promise<Misskey.entities.DriveFile> { return new Promise((resolve, reject) => { popup(defineAsyncComponent(() => import('@/components/MkCropperDialog.vue')), { file: image, aspectRatio: options.aspectRatio, + uploadFolder: options.uploadFolder, }, { ok: x => { resolve(x); diff --git a/packages/frontend/src/pages/about-misskey.vue b/packages/frontend/src/pages/about-misskey.vue index 0017145fa1..6d2f7e155e 100644 --- a/packages/frontend/src/pages/about-misskey.vue +++ b/packages/frontend/src/pages/about-misskey.vue @@ -88,10 +88,13 @@ <template #label>Special thanks</template> <div class="_gaps" style="text-align: center;"> <div> - <a style="display: inline-block;" class="masknetwork" title="Mask Network" href="https://mask.io/" target="_blank"><img width="200" src="https://misskey-hub.net/sponsors/masknetwork.png" alt="Mask Network"></a> + <a style="display: inline-block;" class="masknetwork" title="Mask Network" href="https://mask.io/" target="_blank"><img width="180" src="https://misskey-hub.net/sponsors/masknetwork.png" alt="Mask Network"></a> </div> <div> - <a style="display: inline-block;" class="dcadvirth" title="DC Advirth" href="https://www.dotchain.ltd/advirth" target="_blank"><img width="200" src="https://misskey-hub.net/sponsors/dcadvirth.png" alt="DC Advirth"></a> + <a style="display: inline-block;" class="skeb" title="Skeb" href="https://skeb.jp/" target="_blank"><img width="180" src="https://misskey-hub.net/sponsors/skeb.svg" alt="Skeb"></a> + </div> + <div> + <a style="display: inline-block;" class="dcadvirth" title="DC Advirth" href="https://www.dotchain.ltd/advirth" target="_blank"><img width="100" src="https://misskey-hub.net/sponsors/dcadvirth.png" alt="DC Advirth"></a> </div> </div> </FormSection> @@ -155,6 +158,30 @@ const patronsWithIcon = [{ }, { name: 'spinlock', icon: 'https://misskey-hub.net/patrons/6a1cebc819d540a78bf20e9e3115baa8.jpg', +}, { + name: 'じゅくま', + icon: 'https://misskey-hub.net/patrons/3e56bdac69dd42f7a06e0f12cf2fc895.jpg', +}, { + name: '清遊あみ', + icon: 'https://misskey-hub.net/patrons/de25195b88e940a388388bea2e7637d8.jpg', +}, { + name: 'Nagi8410', + icon: 'https://misskey-hub.net/patrons/31b102ab4fc540ed806b0461575d38be.jpg', +}, { + name: '山岡士郎', + icon: 'https://misskey-hub.net/patrons/84b9056341684266bb1eda3e680d094d.jpg', +}, { + name: 'よもやまたろう', + icon: 'https://misskey-hub.net/patrons/4273c9cce50d445f8f7d0f16113d6d7f.jpg', +}, { + name: '花咲ももか', + icon: 'https://misskey-hub.net/patrons/8c9b2b9128cb4fee99f04bb4f86f2efa.jpg', +}, { + name: 'カガミ', + icon: 'https://misskey-hub.net/patrons/226ea3a4617749548580ec2d9a263e24.jpg', +}, { + name: 'フランギ・シュウ', + icon: 'https://misskey-hub.net/patrons/3016d37e35f3430b90420176c912d304.jpg', }]; const patrons = [ @@ -250,6 +277,9 @@ const patrons = [ 'binvinyl', '渡志郎', 'ぷーざ', + '越貝鯛丸', + 'Nick / pprmint.', + 'kino3277', ]; let thereIsTreasure = $ref($i && !claimedAchievements.includes('foundTreasure')); diff --git a/packages/frontend/src/pages/about.emojis.vue b/packages/frontend/src/pages/about.emojis.vue index 3744bed10f..cc0bf2eed2 100644 --- a/packages/frontend/src/pages/about.emojis.vue +++ b/packages/frontend/src/pages/about.emojis.vue @@ -20,7 +20,7 @@ <XEmoji v-for="emoji in searchEmojis" :key="emoji.name" :emoji="emoji"/> </div> </MkFoldableSection> - + <MkFoldableSection v-for="category in customEmojiCategories" v-once :key="category"> <template #header>{{ category || i18n.ts.other }}</template> <div :class="$style.emojis"> @@ -56,7 +56,7 @@ function search() { const queryarry = q.match(/\:([a-z0-9_]*)\:/g); if (queryarry) { - searchEmojis = customEmojis.value.filter(emoji => + searchEmojis = customEmojis.value.filter(emoji => queryarry.includes(`:${emoji.name}:`), ); } else { diff --git a/packages/frontend/src/pages/admin-file.vue b/packages/frontend/src/pages/admin-file.vue index 24c863ba62..57e50b692c 100644 --- a/packages/frontend/src/pages/admin-file.vue +++ b/packages/frontend/src/pages/admin-file.vue @@ -32,7 +32,7 @@ <MkUserCardMini :user="file.user"/> </MkA> <div> - <MkSwitch v-model="isSensitive" @update:modelValue="toggleIsSensitive">NSFW</MkSwitch> + <MkSwitch v-model="isSensitive" @update:modelValue="toggleIsSensitive">{{ i18n.ts.sensitive }}</MkSwitch> </div> <div> diff --git a/packages/frontend/src/pages/admin/abuses.vue b/packages/frontend/src/pages/admin/abuses.vue index 3bc5ee9723..9cf96d3d04 100644 --- a/packages/frontend/src/pages/admin/abuses.vue +++ b/packages/frontend/src/pages/admin/abuses.vue @@ -75,7 +75,7 @@ const pagination = { }; function resolved(reportId) { - reports.removeItem(item => item.id === reportId); + reports.removeItem(reportId); } const headerActions = $computed(() => []); diff --git a/packages/frontend/src/pages/admin/ads.vue b/packages/frontend/src/pages/admin/ads.vue index 2c9e18b0bf..9a5bd88b2e 100644 --- a/packages/frontend/src/pages/admin/ads.vue +++ b/packages/frontend/src/pages/admin/ads.vue @@ -36,6 +36,16 @@ <template #label>{{ i18n.ts.expiration }}</template> </MkInput> </FormSplit> + <MkFolder> + <template #label>{{ i18n.ts.advancedSettings }}</template> + <span> + {{ i18n.ts._ad.timezoneinfo }} + <div v-for="(day, index) in daysOfWeek" :key="index"> + <input :id="`ad${ad.id}-${index}`" type="checkbox" :checked="(ad.dayOfWeek & (1 << index)) !== 0" @change="toggleDayOfWeek(ad, index)"> + <label :for="`ad${ad.id}-${index}`">{{ day }}</label> + </div> + </span> + </MkFolder> <MkTextarea v-model="ad.memo"> <template #label>{{ i18n.ts.memo }}</template> </MkTextarea> @@ -59,6 +69,7 @@ import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; import MkTextarea from '@/components/MkTextarea.vue'; import MkRadios from '@/components/MkRadios.vue'; +import MkFolder from '@/components/MkFolder.vue'; import FormSplit from '@/components/form/split.vue'; import * as os from '@/os'; import { i18n } from '@/i18n'; @@ -69,6 +80,7 @@ let ads: any[] = $ref([]); // ISO形式はTZがUTCになってしまうので、TZ分ずらして時間を初期化 const localTime = new Date(); const localTimeDiff = localTime.getTimezoneOffset() * 60 * 1000; +const daysOfWeek: string[] = [i18n.ts._weekday.sunday, i18n.ts._weekday.monday, i18n.ts._weekday.tuesday, i18n.ts._weekday.wednesday, i18n.ts._weekday.thursday, i18n.ts._weekday.friday, i18n.ts._weekday.saturday]; os.api('admin/ad/list').then(adsResponse => { ads = adsResponse.map(r => { @@ -84,6 +96,11 @@ os.api('admin/ad/list').then(adsResponse => { }); }); +// 選択された曜日(index)のビットフラグを操作する +function toggleDayOfWeek(ad, index) { + ad.dayOfWeek ^= 1 << index; +} + function add() { ads.unshift({ id: null, @@ -95,6 +112,7 @@ function add() { imageUrl: null, expiresAt: null, startsAt: null, + dayOfWeek: 0, }); } @@ -105,6 +123,7 @@ function remove(ad) { }).then(({ canceled }) => { if (canceled) return; ads = ads.filter(x => x !== ad); + if (ad.id == null) return; os.apiWithDialog('admin/ad/delete', { id: ad.id, }); diff --git a/packages/frontend/src/pages/admin/index.vue b/packages/frontend/src/pages/admin/index.vue index 8b083bc896..e91f65b5d5 100644 --- a/packages/frontend/src/pages/admin/index.vue +++ b/packages/frontend/src/pages/admin/index.vue @@ -1,6 +1,6 @@ <template> <div ref="el" class="hiyeyicy" :class="{ wide: !narrow }"> - <div v-if="!narrow || currentPage?.route.name == null" class="nav"> + <div v-if="!narrow || currentPage?.route.name == null" class="nav"> <MkSpacer :contentMax="700" :marginMin="16"> <div class="lxpfedzu"> <div class="banner"> @@ -80,7 +80,7 @@ const menuDef = $computed(() => [{ }, ...(instance.disableRegistration ? [{ type: 'button', icon: 'ti ti-user-plus', - text: i18n.ts.invite, + text: i18n.ts.createInviteCode, action: invite, }] : [])], }, { @@ -96,6 +96,11 @@ const menuDef = $computed(() => [{ to: '/admin/users', active: currentPage?.route.name === 'users', }, { + icon: 'ti ti-user-plus', + text: i18n.ts.invite, + to: '/admin/invites', + active: currentPage?.route.name === 'invites', + }, { icon: 'ti ti-badges', text: i18n.ts.roles, to: '/admin/roles', @@ -240,10 +245,10 @@ provideMetadataReceiver((info) => { }); const invite = () => { - os.api('invite').then(x => { + os.api('admin/invite/create').then(x => { os.alert({ type: 'info', - text: x.code, + text: x?.[0].code, }); }).catch(err => { os.alert({ diff --git a/packages/frontend/src/pages/admin/invites.vue b/packages/frontend/src/pages/admin/invites.vue new file mode 100644 index 0000000000..70a9c93713 --- /dev/null +++ b/packages/frontend/src/pages/admin/invites.vue @@ -0,0 +1,126 @@ +<template> +<MkStickyContainer> + <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :contentMax="800"> + <div class="_gaps_m"> + <MkFolder :expanded="false"> + <template #icon><i class="ti ti-plus"></i></template> + <template #label>{{ i18n.ts.createInviteCode }}</template> + + <div class="_gaps_m"> + <MkSwitch v-model="noExpirationDate"> + <template #label>{{ i18n.ts.noExpirationDate }}</template> + </MkSwitch> + <MkInput v-if="!noExpirationDate" v-model="expiresAt" type="datetime-local"> + <template #label>{{ i18n.ts.expirationDate }}</template> + </MkInput> + <MkInput v-model="createCount" type="number"> + <template #label>{{ i18n.ts.createCount }}</template> + </MkInput> + <MkButton primary rounded @click="createWithOptions">{{ i18n.ts.create }}</MkButton> + </div> + </MkFolder> + + <div :class="$style.inputs"> + <MkSelect v-model="type" :class="$style.input"> + <template #label>{{ i18n.ts.state }}</template> + <option value="all">{{ i18n.ts.all }}</option> + <option value="unused">{{ i18n.ts.unused }}</option> + <option value="used">{{ i18n.ts.used }}</option> + <option value="expired">{{ i18n.ts.expired }}</option> + </MkSelect> + <MkSelect v-model="sort" :class="$style.input"> + <template #label>{{ i18n.ts.sort }}</template> + <option value="+createdAt">{{ i18n.ts.createdAt }} ({{ i18n.ts.ascendingOrder }})</option> + <option value="-createdAt">{{ i18n.ts.createdAt }} ({{ i18n.ts.descendingOrder }})</option> + <option value="+usedAt">{{ i18n.ts.usedAt }} ({{ i18n.ts.ascendingOrder }})</option> + <option value="-usedAt">{{ i18n.ts.usedAt }} ({{ i18n.ts.descendingOrder }})</option> + </MkSelect> + </div> + <MkPagination ref="pagingComponent" :pagination="pagination"> + <template #default="{ items }"> + <div class="_gaps_s"> + <MkInviteCode v-for="item in items" :key="item.id" :invite="(item as any)" :onDeleted="deleted" moderator/> + </div> + </template> + </MkPagination> + </div> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { computed, ref, shallowRef } from 'vue'; +import XHeader from './_header_.vue'; +import { i18n } from '@/i18n'; +import * as os from '@/os'; +import MkButton from '@/components/MkButton.vue'; +import MkFolder from '@/components/MkFolder.vue'; +import MkSelect from '@/components/MkSelect.vue'; +import MkInput from '@/components/MkInput.vue'; +import MkSwitch from '@/components/MkSwitch.vue'; +import MkPagination, { Paging } from '@/components/MkPagination.vue'; +import MkInviteCode from '@/components/MkInviteCode.vue'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const pagingComponent = shallowRef<InstanceType<typeof MkPagination>>(); + +let type = ref('all'); +let sort = ref('+createdAt'); + +const pagination: Paging = { + endpoint: 'admin/invite/list' as const, + limit: 10, + params: computed(() => ({ + type: type.value, + sort: sort.value, + })), + offsetMode: true, +}; + +const expiresAt = ref(''); +const noExpirationDate = ref(true); +const createCount = ref(1); + +async function createWithOptions() { + const options = { + expiresAt: noExpirationDate.value ? null : expiresAt.value, + count: createCount.value, + }; + + const tickets = await os.api('admin/invite/create', options); + os.alert({ + type: 'success', + title: i18n.ts.inviteCodeCreated, + text: tickets?.map(x => x.code).join('\n'), + }); + + tickets?.forEach(ticket => pagingComponent.value?.prepend(ticket)); +} + +function deleted(id: string) { + if (pagingComponent.value) { + pagingComponent.value.items.delete(id); + } +} + +const headerActions = $computed(() => []); +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.invite, + icon: 'ti ti-user-plus', +}); +</script> + +<style lang="scss" module> +.inputs { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.input { + flex: 1; +} +</style> diff --git a/packages/frontend/src/pages/admin/moderation.vue b/packages/frontend/src/pages/admin/moderation.vue index e36c9ac91d..13789820a0 100644 --- a/packages/frontend/src/pages/admin/moderation.vue +++ b/packages/frontend/src/pages/admin/moderation.vue @@ -24,7 +24,7 @@ <template #label>{{ i18n.ts.preservedUsernames }}</template> <template #caption>{{ i18n.ts.preservedUsernamesDescription }}</template> </MkTextarea> - + <MkTextarea v-model="sensitiveWords"> <template #label>{{ i18n.ts.sensitiveWords }}</template> <template #caption>{{ i18n.ts.sensitiveWordsDescription }}<br>{{ i18n.ts.sensitiveWordsDescription2 }}</template> diff --git a/packages/frontend/src/pages/admin/other-settings.vue b/packages/frontend/src/pages/admin/other-settings.vue index 15d720a070..13e3588740 100644 --- a/packages/frontend/src/pages/admin/other-settings.vue +++ b/packages/frontend/src/pages/admin/other-settings.vue @@ -3,14 +3,34 @@ <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> <MkSpacer :contentMax="700" :marginMin="16" :marginMax="32"> <FormSuspense :p="init"> - <div class="_gaps_s"> - <MkSwitch v-model="enableChartsForRemoteUser"> - <template #label>{{ i18n.ts.enableChartsForRemoteUser }}</template> - </MkSwitch> + <div class="_gaps"> + <div class="_panel" style="padding: 16px;"> + <MkSwitch v-model="enableServerMachineStats"> + <template #label>{{ i18n.ts.enableServerMachineStats }}</template> + <template #caption>{{ i18n.ts.turnOffToImprovePerformance }}</template> + </MkSwitch> + </div> - <MkSwitch v-model="enableChartsForFederatedInstances"> - <template #label>{{ i18n.ts.enableChartsForFederatedInstances }}</template> - </MkSwitch> + <div class="_panel" style="padding: 16px;"> + <MkSwitch v-model="enableIdenticonGeneration"> + <template #label>{{ i18n.ts.enableIdenticonGeneration }}</template> + <template #caption>{{ i18n.ts.turnOffToImprovePerformance }}</template> + </MkSwitch> + </div> + + <div class="_panel" style="padding: 16px;"> + <MkSwitch v-model="enableChartsForRemoteUser"> + <template #label>{{ i18n.ts.enableChartsForRemoteUser }}</template> + <template #caption>{{ i18n.ts.turnOffToImprovePerformance }}</template> + </MkSwitch> + </div> + + <div class="_panel" style="padding: 16px;"> + <MkSwitch v-model="enableChartsForFederatedInstances"> + <template #label>{{ i18n.ts.enableChartsForFederatedInstances }}</template> + <template #caption>{{ i18n.ts.turnOffToImprovePerformance }}</template> + </MkSwitch> + </div> </div> </FormSuspense> </MkSpacer> @@ -27,17 +47,23 @@ import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; import MkSwitch from '@/components/MkSwitch.vue'; +let enableServerMachineStats: boolean = $ref(false); +let enableIdenticonGeneration: boolean = $ref(false); let enableChartsForRemoteUser: boolean = $ref(false); let enableChartsForFederatedInstances: boolean = $ref(false); async function init() { const meta = await os.api('admin/meta'); + enableServerMachineStats = meta.enableServerMachineStats; + enableIdenticonGeneration = meta.enableIdenticonGeneration; enableChartsForRemoteUser = meta.enableChartsForRemoteUser; enableChartsForFederatedInstances = meta.enableChartsForFederatedInstances; } function save() { os.apiWithDialog('admin/update-meta', { + enableServerMachineStats, + enableIdenticonGeneration, enableChartsForRemoteUser, enableChartsForFederatedInstances, }).then(() => { diff --git a/packages/frontend/src/pages/admin/overview.ap-requests.vue b/packages/frontend/src/pages/admin/overview.ap-requests.vue index ad8e623415..bde5580366 100644 --- a/packages/frontend/src/pages/admin/overview.ap-requests.vue +++ b/packages/frontend/src/pages/admin/overview.ap-requests.vue @@ -259,7 +259,7 @@ onMounted(async () => { }, plugins: [chartVLine(vLineColor)], }); - + fetching = false; }); </script> diff --git a/packages/frontend/src/pages/admin/overview.federation.vue b/packages/frontend/src/pages/admin/overview.federation.vue index ab78c4c393..469d2e6927 100644 --- a/packages/frontend/src/pages/admin/overview.federation.vue +++ b/packages/frontend/src/pages/admin/overview.federation.vue @@ -58,7 +58,7 @@ let federationSubActiveDiff = $ref<number | null>(null); let fetching = $ref(true); const { handler: externalTooltipHandler } = useChartTooltip(); - + onMounted(async () => { const chart = await os.apiGet('charts/federation', { limit: 2, span: 'day' }); federationPubActive = chart.pubActive[0]; diff --git a/packages/frontend/src/pages/admin/overview.queue.vue b/packages/frontend/src/pages/admin/overview.queue.vue index 69ca89e226..7d8d468512 100644 --- a/packages/frontend/src/pages/admin/overview.queue.vue +++ b/packages/frontend/src/pages/admin/overview.queue.vue @@ -85,7 +85,7 @@ onMounted(() => { connection.on('stats', onStats); connection.on('statsLog', onStatsLog); connection.send('requestLog', { - id: Math.random().toString().substr(2, 8), + id: Math.random().toString().substring(2, 10), length: 100, }); }); @@ -122,4 +122,4 @@ onUnmounted(() => { } } } -</style> +</style> diff --git a/packages/frontend/src/pages/admin/overview.stats.vue b/packages/frontend/src/pages/admin/overview.stats.vue index 142e70c698..f746ad14b9 100644 --- a/packages/frontend/src/pages/admin/overview.stats.vue +++ b/packages/frontend/src/pages/admin/overview.stats.vue @@ -73,7 +73,7 @@ let fetching = $ref(true); onMounted(async () => { const [_stats, _onlineUsersCount] = await Promise.all([ os.api('stats', {}), - os.api('get-online-users-count').then(res => res.count), + os.apiGet('get-online-users-count').then(res => res.count), ]); stats = _stats; onlineUsersCount = _onlineUsersCount; diff --git a/packages/frontend/src/pages/admin/overview.vue b/packages/frontend/src/pages/admin/overview.vue index e8295c81b5..41a6d4f5b7 100644 --- a/packages/frontend/src/pages/admin/overview.vue +++ b/packages/frontend/src/pages/admin/overview.vue @@ -30,7 +30,7 @@ <template #header>Federation</template> <XFederation/> </MkFoldableSection> - + <MkFoldableSection class="item"> <template #header>Instances</template> <XInstances/> @@ -156,7 +156,7 @@ onMounted(async () => { nextTick(() => { queueStatsConnection.send('requestLog', { - id: Math.random().toString().substr(2, 8), + id: Math.random().toString().substring(2, 10), length: 100, }); }); diff --git a/packages/frontend/src/pages/admin/queue.chart.vue b/packages/frontend/src/pages/admin/queue.chart.vue index 8e6856fddd..83ca9639e7 100644 --- a/packages/frontend/src/pages/admin/queue.chart.vue +++ b/packages/frontend/src/pages/admin/queue.chart.vue @@ -106,7 +106,7 @@ onMounted(() => { connection.on('stats', onStats); connection.on('statsLog', onStatsLog); connection.send('requestLog', { - id: Math.random().toString().substr(2, 8), + id: Math.random().toString().substring(2, 10), length: 200, }); }); diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue index a1fa9d2932..1ba502ff86 100644 --- a/packages/frontend/src/pages/admin/roles.editor.vue +++ b/packages/frontend/src/pages/admin/roles.editor.vue @@ -1,5 +1,9 @@ <template> <div class="_gaps"> + <MkInput v-if="readonly" :modelValue="role.id" :readonly="true"> + <template #label>ID</template> + </MkInput> + <MkInput v-model="role.name" :readonly="readonly"> <template #label>{{ i18n.ts._role.name }}</template> </MkInput> @@ -171,6 +175,65 @@ </div> </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.inviteLimit, 'inviteLimit'])"> + <template #label>{{ i18n.ts._role._options.inviteLimit }}</template> + <template #suffix> + <span v-if="role.policies.inviteLimit.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span> + <span v-else>{{ role.policies.inviteLimit.value }}</span> + <span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.inviteLimit)"></i></span> + </template> + <div class="_gaps"> + <MkSwitch v-model="role.policies.inviteLimit.useDefault" :readonly="readonly"> + <template #label>{{ i18n.ts._role.useBaseValue }}</template> + </MkSwitch> + <MkInput v-model="role.policies.inviteLimit.value" :disabled="role.policies.inviteLimit.useDefault" type="number" :readonly="readonly"> + </MkInput> + <MkRange v-model="role.policies.inviteLimit.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.inviteLimitCycle, 'inviteLimitCycle'])"> + <template #label>{{ i18n.ts._role._options.inviteLimitCycle }}</template> + <template #suffix> + <span v-if="role.policies.inviteLimitCycle.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span> + <span v-else>{{ role.policies.inviteLimitCycle.value + i18n.ts._time.minute }}</span> + <span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.inviteLimitCycle)"></i></span> + </template> + <div class="_gaps"> + <MkSwitch v-model="role.policies.inviteLimitCycle.useDefault" :readonly="readonly"> + <template #label>{{ i18n.ts._role.useBaseValue }}</template> + </MkSwitch> + <MkInput v-model="role.policies.inviteLimitCycle.value" :disabled="role.policies.inviteLimitCycle.useDefault" type="number" :readonly="readonly"> + <template #suffix>{{ i18n.ts._time.minute }}</template> + </MkInput> + <MkRange v-model="role.policies.inviteLimitCycle.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.inviteExpirationTime, 'inviteExpirationTime'])"> + <template #label>{{ i18n.ts._role._options.inviteExpirationTime }}</template> + <template #suffix> + <span v-if="role.policies.inviteExpirationTime.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span> + <span v-else>{{ role.policies.inviteExpirationTime.value + i18n.ts._time.minute }}</span> + <span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.inviteExpirationTime)"></i></span> + </template> + <div class="_gaps"> + <MkSwitch v-model="role.policies.inviteExpirationTime.useDefault" :readonly="readonly"> + <template #label>{{ i18n.ts._role.useBaseValue }}</template> + </MkSwitch> + <MkInput v-model="role.policies.inviteExpirationTime.value" :disabled="role.policies.inviteExpirationTime.useDefault" type="number" :readonly="readonly"> + <template #suffix>{{ i18n.ts._time.minute }}</template> + </MkInput> + <MkRange v-model="role.policies.inviteExpirationTime.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.canManageCustomEmojis, 'canManageCustomEmojis'])"> <template #label>{{ i18n.ts._role._options.canManageCustomEmojis }}</template> <template #suffix> @@ -210,7 +273,7 @@ </MkRange> </div> </MkFolder> - + <MkFolder v-if="matchQuery([i18n.ts._role._options.driveCapacity, 'driveCapacityMb'])"> <template #label>{{ i18n.ts._role._options.driveCapacity }}</template> <template #suffix> diff --git a/packages/frontend/src/pages/admin/roles.role.vue b/packages/frontend/src/pages/admin/roles.role.vue index 6cbe7ae658..789c9da277 100644 --- a/packages/frontend/src/pages/admin/roles.role.vue +++ b/packages/frontend/src/pages/admin/roles.role.vue @@ -40,7 +40,7 @@ </div> <div v-if="expandedItems.includes(item.id)" :class="$style.userItemSub"> <div>Assigned: <MkTime :time="item.createdAt" mode="detail"/></div> - <div v-if="item.expiresAt">Period: {{ item.expiresAt.toLocaleString() }}</div> + <div v-if="item.expiresAt">Period: {{ new Date(item.expiresAt).toLocaleString() }}</div> <div v-else>Period: {{ i18n.ts.indefinitely }}</div> </div> </div> diff --git a/packages/frontend/src/pages/admin/roles.vue b/packages/frontend/src/pages/admin/roles.vue index 6634d9cba9..cdb6e90505 100644 --- a/packages/frontend/src/pages/admin/roles.vue +++ b/packages/frontend/src/pages/admin/roles.vue @@ -51,6 +51,29 @@ </MkSwitch> </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.inviteLimit, 'inviteLimit'])"> + <template #label>{{ i18n.ts._role._options.inviteLimit }}</template> + <template #suffix>{{ policies.inviteLimit }}</template> + <MkInput v-model="policies.inviteLimit" type="number"> + </MkInput> + </MkFolder> + + <MkFolder v-if="matchQuery([i18n.ts._role._options.inviteLimitCycle, 'inviteLimitCycle'])"> + <template #label>{{ i18n.ts._role._options.inviteLimitCycle }}</template> + <template #suffix>{{ policies.inviteLimitCycle + i18n.ts._time.minute }}</template> + <MkInput v-model="policies.inviteLimitCycle" type="number"> + <template #suffix>{{ i18n.ts._time.minute }}</template> + </MkInput> + </MkFolder> + + <MkFolder v-if="matchQuery([i18n.ts._role._options.inviteExpirationTime, 'inviteExpirationTime'])"> + <template #label>{{ i18n.ts._role._options.inviteExpirationTime }}</template> + <template #suffix>{{ policies.inviteExpirationTime + i18n.ts._time.minute }}</template> + <MkInput v-model="policies.inviteExpirationTime" type="number"> + <template #suffix>{{ i18n.ts._time.minute }}</template> + </MkInput> + </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.canManageCustomEmojis, 'canManageCustomEmojis'])"> <template #label>{{ i18n.ts._role._options.canManageCustomEmojis }}</template> <template #suffix>{{ policies.canManageCustomEmojis ? i18n.ts.yes : i18n.ts.no }}</template> diff --git a/packages/frontend/src/pages/admin/settings.vue b/packages/frontend/src/pages/admin/settings.vue index 4c2fe46f28..bd57c06181 100644 --- a/packages/frontend/src/pages/admin/settings.vue +++ b/packages/frontend/src/pages/admin/settings.vue @@ -37,6 +37,13 @@ <template #label>{{ i18n.ts.cacheRemoteFiles }}</template> <template #caption>{{ i18n.ts.cacheRemoteFilesDescription }}</template> </MkSwitch> + + <template v-if="cacheRemoteFiles"> + <MkSwitch v-model="cacheRemoteSensitiveFiles"> + <template #label>{{ i18n.ts.cacheRemoteSensitiveFiles }}</template> + <template #caption>{{ i18n.ts.cacheRemoteSensitiveFilesDescription }}</template> + </MkSwitch> + </template> </div> </FormSection> @@ -104,7 +111,6 @@ import { fetchInstance } from '@/instance'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; import MkButton from '@/components/MkButton.vue'; -import MkColorInput from '@/components/MkColorInput.vue'; let name: string | null = $ref(null); let description: string | null = $ref(null); @@ -112,13 +118,14 @@ let maintainerName: string | null = $ref(null); let maintainerEmail: string | null = $ref(null); let pinnedUsers: string = $ref(''); let cacheRemoteFiles: boolean = $ref(false); +let cacheRemoteSensitiveFiles: boolean = $ref(false); let enableServiceWorker: boolean = $ref(false); let swPublicKey: any = $ref(null); let swPrivateKey: any = $ref(null); let deeplAuthKey: string = $ref(''); let deeplIsPro: boolean = $ref(false); -async function init() { +async function init(): Promise<void> { const meta = await os.api('admin/meta'); name = meta.name; description = meta.description; @@ -126,6 +133,7 @@ async function init() { maintainerEmail = meta.maintainerEmail; pinnedUsers = meta.pinnedUsers.join('\n'); cacheRemoteFiles = meta.cacheRemoteFiles; + cacheRemoteSensitiveFiles = meta.cacheRemoteSensitiveFiles; enableServiceWorker = meta.enableServiceWorker; swPublicKey = meta.swPublickey; swPrivateKey = meta.swPrivateKey; @@ -133,7 +141,7 @@ async function init() { deeplIsPro = meta.deeplIsPro; } -function save() { +function save(): void { os.apiWithDialog('admin/update-meta', { name, description, @@ -141,6 +149,7 @@ function save() { maintainerEmail, pinnedUsers: pinnedUsers.split('\n'), cacheRemoteFiles, + cacheRemoteSensitiveFiles, enableServiceWorker, swPublicKey, swPrivateKey, diff --git a/packages/frontend/src/pages/channel-editor.vue b/packages/frontend/src/pages/channel-editor.vue index 0a358a141b..cacdab040f 100644 --- a/packages/frontend/src/pages/channel-editor.vue +++ b/packages/frontend/src/pages/channel-editor.vue @@ -25,11 +25,11 @@ <MkFolder :defaultOpen="true"> <template #label>{{ i18n.ts.pinnedNotes }}</template> - + <div class="_gaps"> <MkButton primary rounded @click="addPinnedNote()"><i class="ti ti-plus"></i></MkButton> - <Sortable + <Sortable v-model="pinnedNotes" itemKey="id" :handle="'.' + $style.pinnedNoteHandle" @@ -160,7 +160,7 @@ async function archive() { }); if (canceled) return; - + os.api('channels/update', { channelId: props.channelId, isArchived: true, diff --git a/packages/frontend/src/pages/channel.vue b/packages/frontend/src/pages/channel.vue index bcc0fc6860..2a056f21d4 100644 --- a/packages/frontend/src/pages/channel.vue +++ b/packages/frontend/src/pages/channel.vue @@ -87,7 +87,7 @@ const props = defineProps<{ channelId: string; }>(); -let tab = $ref('timeline'); +let tab = $ref('overview'); let channel = $ref(null); let favorited = $ref(false); let searchQuery = $ref(''); @@ -107,6 +107,9 @@ watch(() => props.channelId, async () => { channelId: props.channelId, }); favorited = channel.isFavorited; + if (favorited || channel.isFollowing) { + tab = 'timeline'; + } }, { immediate: true }); function edit() { diff --git a/packages/frontend/src/pages/clip.vue b/packages/frontend/src/pages/clip.vue index d5313099da..b09f787b5b 100644 --- a/packages/frontend/src/pages/clip.vue +++ b/packages/frontend/src/pages/clip.vue @@ -55,7 +55,7 @@ watch(() => props.clipId, async () => { favorited = clip.isFavorited; }, { immediate: true, -}); +}); provide('currentClip', $$(clip)); diff --git a/packages/frontend/src/pages/custom-emojis-manager.vue b/packages/frontend/src/pages/custom-emojis-manager.vue index 3da6a0d9cb..359bbeadc3 100644 --- a/packages/frontend/src/pages/custom-emojis-manager.vue +++ b/packages/frontend/src/pages/custom-emojis-manager.vue @@ -18,7 +18,7 @@ <MkButton inline @click="setTagBulk">Set tag</MkButton> <MkButton inline @click="addTagBulk">Add tag</MkButton> <MkButton inline @click="removeTagBulk">Remove tag</MkButton> - <MkButton inline @click="setLisenceBulk">Set Lisence</MkButton> + <MkButton inline @click="setLicenseBulk">Set License</MkButton> <MkButton inline danger @click="delBulk">Delete</MkButton> </div> <MkPagination ref="emojisPaginationComponent" :pagination="pagination"> @@ -144,7 +144,7 @@ const edit = (emoji) => { ...result.updated, })); } else if (result.deleted) { - emojisPaginationComponent.value.removeItem((item) => item.id === emoji.id); + emojisPaginationComponent.value.removeItem(emoji.id); } }, }, 'closed'); @@ -221,7 +221,7 @@ const setCategoryBulk = async () => { emojisPaginationComponent.value.reload(); }; -const setLisenceBulk = async () => { +const setLicenseBulk = async () => { const { canceled, result } = await os.inputText({ title: 'License', }); @@ -311,13 +311,13 @@ definePageMetadata(computed(() => ({ .empty { margin: var(--margin); } - + .ldhfsamy { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); grid-gap: 12px; margin: var(--margin) 0; - + > .emoji { display: flex; align-items: center; diff --git a/packages/frontend/src/pages/emoji-edit-dialog.vue b/packages/frontend/src/pages/emoji-edit-dialog.vue index 3208c92738..f49057930c 100644 --- a/packages/frontend/src/pages/emoji-edit-dialog.vue +++ b/packages/frontend/src/pages/emoji-edit-dialog.vue @@ -26,7 +26,7 @@ </div> </div> <MkButton rounded style="margin: 0 auto;" @click="changeImage">{{ i18n.ts.selectFile }}</MkButton> - <MkInput v-model="name"> + <MkInput v-model="name" pattern="[a-z0-9_]"> <template #label>{{ i18n.ts.name }}</template> </MkInput> <MkInput v-model="category" :datalist="customEmojiCategories"> @@ -70,6 +70,7 @@ <script lang="ts" setup> import { computed, watch } from 'vue'; +import * as misskey from 'misskey-js'; import MkModalWindow from '@/components/MkModalWindow.vue'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; @@ -95,7 +96,7 @@ let isSensitive = $ref(props.emoji ? props.emoji.isSensitive : false); let localOnly = $ref(props.emoji ? props.emoji.localOnly : false); let roleIdsThatCanBeUsedThisEmojiAsReaction = $ref(props.emoji ? props.emoji.roleIdsThatCanBeUsedThisEmojiAsReaction : []); let rolesThatCanBeUsedThisEmojiAsReaction = $ref([]); -let file = $ref(); +let file = $ref<misskey.entities.DriveFile>(); watch($$(roleIdsThatCanBeUsedThisEmojiAsReaction), async () => { rolesThatCanBeUsedThisEmojiAsReaction = (await Promise.all(roleIdsThatCanBeUsedThisEmojiAsReaction.map((id) => os.api('admin/roles/show', { roleId: id }).catch(() => null)))).filter(x => x != null); @@ -110,6 +111,10 @@ const emit = defineEmits<{ async function changeImage(ev) { file = await selectFile(ev.currentTarget ?? ev.target, null); + const candidate = file.name.replace(/\.(.+)$/, ''); + if (candidate.match(/^[a-z0-9_]+$/)) { + name = candidate; + } } async function addRole() { diff --git a/packages/frontend/src/pages/flash/flash-edit.vue b/packages/frontend/src/pages/flash/flash-edit.vue index 6a16cd1c4a..86aaad8f53 100644 --- a/packages/frontend/src/pages/flash/flash-edit.vue +++ b/packages/frontend/src/pages/flash/flash-edit.vue @@ -33,7 +33,7 @@ import MkTextarea from '@/components/MkTextarea.vue'; import MkInput from '@/components/MkInput.vue'; import { useRouter } from '@/router'; -const PRESET_DEFAULT = `/// @ 0.13.3 +const PRESET_DEFAULT = `/// @ 0.15.0 var name = "" @@ -51,7 +51,7 @@ Ui:render([ ]) `; -const PRESET_OMIKUJI = `/// @ 0.13.3 +const PRESET_OMIKUJI = `/// @ 0.15.0 // ユーザーごとに日替わりのおみくじのプリセット // 選択肢 @@ -94,7 +94,7 @@ Ui:render([ ]) `; -const PRESET_SHUFFLE = `/// @ 0.13.3 +const PRESET_SHUFFLE = `/// @ 0.15.0 // 巻き戻し可能な文字シャッフルのプリセット let string = "ペペロンチーノ" @@ -173,7 +173,7 @@ var cursor = 0 do() `; -const PRESET_QUIZ = `/// @ 0.13.3 +const PRESET_QUIZ = `/// @ 0.15.0 let title = '地理クイズ' let qas = [{ @@ -286,7 +286,7 @@ qaEls.push(Ui:C:container({ Ui:render(qaEls) `; -const PRESET_TIMELINE = `/// @ 0.13.3 +const PRESET_TIMELINE = `/// @ 0.15.0 // APIリクエストを行いローカルタイムラインを表示するプリセット @fetch() { diff --git a/packages/frontend/src/pages/follow.vue b/packages/frontend/src/pages/follow.vue index d14b663364..2d08b66868 100644 --- a/packages/frontend/src/pages/follow.vue +++ b/packages/frontend/src/pages/follow.vue @@ -20,7 +20,7 @@ async function follow(user): Promise<void> { window.close(); return; } - + os.apiWithDialog('following/create', { userId: user.id, }); diff --git a/packages/frontend/src/pages/gallery/post.vue b/packages/frontend/src/pages/gallery/post.vue index dfa6c0bac0..39b2c2c90b 100644 --- a/packages/frontend/src/pages/gallery/post.vue +++ b/packages/frontend/src/pages/gallery/post.vue @@ -236,6 +236,7 @@ definePageMetadata(computed(() => post ? { border-top: solid 0.5px var(--divider); display: flex; align-items: center; + flex-wrap: wrap; > .avatar { width: 52px; diff --git a/packages/frontend/src/pages/instance-info.vue b/packages/frontend/src/pages/instance-info.vue index 83997b2555..ac765e88b7 100644 --- a/packages/frontend/src/pages/instance-info.vue +++ b/packages/frontend/src/pages/instance-info.vue @@ -49,7 +49,7 @@ <template #value><MkTime v-if="instance.latestRequestReceivedAt" :time="instance.latestRequestReceivedAt"/><span v-else>N/A</span></template> </MkKeyValue> </FormSection> - + <FormSection> <MkKeyValue oneline style="margin: 1em 0;"> <template #key>Following (Pub)</template> diff --git a/packages/frontend/src/pages/invite.vue b/packages/frontend/src/pages/invite.vue new file mode 100644 index 0000000000..c893ad51e8 --- /dev/null +++ b/packages/frontend/src/pages/invite.vue @@ -0,0 +1,114 @@ +<template> +<MkStickyContainer> + <template #header> + <MkPageHeader/> + </template> + <MKSpacer v-if="!instance.disableRegistration || !($i && ($i.isAdmin || $i.policies.canInvite))" :contentMax="1200"> + <div :class="$style.root"> + <img :class="$style.img" :src="serverErrorImageUrl" class="_ghost"/> + <div :class="$style.text"> + <i class="ti ti-alert-triangle"></i> + {{ i18n.ts.nothing }} + </div> + </div> + </MKSpacer> + <MkSpacer v-else :contentMax="800"> + <div class="_gaps_m" style="text-align: center;"> + <div v-if="resetCycle && inviteLimit">{{ i18n.t('inviteLimitResetCycle', { time: resetCycle, limit: inviteLimit }) }}</div> + <MkButton inline primary rounded :disabled="currentInviteLimit !== null && currentInviteLimit <= 0" @click="create"><i class="ti ti-user-plus"></i> {{ i18n.ts.createInviteCode }}</MkButton> + <div v-if="currentInviteLimit !== null">{{ i18n.t('createLimitRemaining', { limit: currentInviteLimit }) }}</div> + + <MkPagination ref="pagingComponent" :pagination="pagination"> + <template #default="{ items }"> + <div class="_gaps_s"> + <MkInviteCode v-for="item in (items as Invite[])" :key="item.id" :invite="item" :onDeleted="deleted"/> + </div> + </template> + </MkPagination> + </div> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { computed, ref, shallowRef } from 'vue'; +import type { Invite } from 'misskey-js/built/entities'; +import { i18n } from '@/i18n'; +import * as os from '@/os'; +import MkButton from '@/components/MkButton.vue'; +import MkPagination, { Paging } from '@/components/MkPagination.vue'; +import MkInviteCode from '@/components/MkInviteCode.vue'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { serverErrorImageUrl, instance } from '@/instance'; +import { $i } from '@/account'; + +const pagingComponent = shallowRef<InstanceType<typeof MkPagination>>(); +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 = { + endpoint: 'invite/list' as const, + limit: 10, +}; + +const resetCycle = computed<null | string>(() => { + if (!inviteLimitCycle) return null; + + const minutes = inviteLimitCycle; + if (minutes < 60) return minutes + i18n.ts._time.minute; + const hours = Math.floor(minutes / 60); + if (hours < 24) return hours + i18n.ts._time.hour; + return Math.floor(hours / 24) + i18n.ts._time.day; +}); + +async function create() { + const ticket = await os.api('invite/create'); + os.alert({ + type: 'success', + title: i18n.ts.inviteCodeCreated, + text: ticket.code, + }); + + pagingComponent.value?.prepend(ticket); + update(); +} + +function deleted(id: string) { + if (pagingComponent.value) { + pagingComponent.value.items.delete(id); + } + update(); +} + +async function update() { + currentInviteLimit.value = (await os.api('invite/limit')).remaining; +} + +update(); + +definePageMetadata({ + title: i18n.ts.invite, + icon: 'ti ti-user-plus', +}); +</script> + +<style lang="scss" module> +.root { + padding: 32px; + text-align: center; + align-items: center; +} + +.text { + margin: 0 0 8px 0; +} + +.img { + vertical-align: bottom; + width: 128px; + height: 128px; + margin-bottom: 16px; + border-radius: 16px; +} +</style> diff --git a/packages/frontend/src/pages/list.vue b/packages/frontend/src/pages/list.vue index 40934fb71d..3307eef359 100644 --- a/packages/frontend/src/pages/list.vue +++ b/packages/frontend/src/pages/list.vue @@ -112,7 +112,7 @@ definePageMetadata(computed(() => list ? { flex: 1; min-width: 0; margin-right: 8px; - + &:hover { text-decoration: none; } diff --git a/packages/frontend/src/pages/my-antennas/create.vue b/packages/frontend/src/pages/my-antennas/create.vue index 355d18fdb5..632c36bbf8 100644 --- a/packages/frontend/src/pages/my-antennas/create.vue +++ b/packages/frontend/src/pages/my-antennas/create.vue @@ -9,6 +9,7 @@ import XAntenna from './editor.vue'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; import { useRouter } from '@/router'; +import { antennasCache } from '@/cache'; const router = useRouter(); @@ -26,13 +27,10 @@ let draft = $ref({ }); function onAntennaCreated() { + antennasCache.delete(); router.push('/my/antennas'); } -const headerActions = $computed(() => []); - -const headerTabs = $computed(() => []); - definePageMetadata({ title: i18n.ts.manageAntennas, icon: 'ti ti-antenna', diff --git a/packages/frontend/src/pages/my-antennas/edit.vue b/packages/frontend/src/pages/my-antennas/edit.vue index da9b2de48f..3fb9690ac1 100644 --- a/packages/frontend/src/pages/my-antennas/edit.vue +++ b/packages/frontend/src/pages/my-antennas/edit.vue @@ -10,6 +10,7 @@ import * as os from '@/os'; import { i18n } from '@/i18n'; import { useRouter } from '@/router'; import { definePageMetadata } from '@/scripts/page-metadata'; +import { antennasCache } from '@/cache'; const router = useRouter(); @@ -20,6 +21,7 @@ const props = defineProps<{ }>(); function onAntennaUpdated() { + antennasCache.delete(); router.push('/my/antennas'); } @@ -27,10 +29,6 @@ os.api('antennas/show', { antennaId: props.antennaId }).then((antennaResponse) = antenna = antennaResponse; }); -const headerActions = $computed(() => []); - -const headerTabs = $computed(() => []); - definePageMetadata({ title: i18n.ts.manageAntennas, icon: 'ti ti-antenna', diff --git a/packages/frontend/src/pages/my-antennas/index.vue b/packages/frontend/src/pages/my-antennas/index.vue index 2ca026b9a1..1e9136f1fa 100644 --- a/packages/frontend/src/pages/my-antennas/index.vue +++ b/packages/frontend/src/pages/my-antennas/index.vue @@ -2,15 +2,20 @@ <MkStickyContainer> <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> <MkSpacer :contentMax="700"> - <div class="ieepwinx"> - <MkButton :link="true" to="/my/antennas/create" primary class="add"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton> + <div> + <div v-if="antennas.length === 0" class="empty"> + <div class="_fullinfo"> + <img :src="infoImageUrl" class="_ghost"/> + <div>{{ i18n.ts.nothing }}</div> + </div> + </div> + + <MkButton :link="true" to="/my/antennas/create" primary :class="$style.add"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton> - <div class=""> - <MkPagination v-slot="{items}" ref="list" :pagination="pagination"> - <MkA v-for="antenna in items" :key="antenna.id" class="ljoevbzj" :to="`/my/antennas/${antenna.id}`"> - <div class="name">{{ antenna.name }}</div> - </MkA> - </MkPagination> + <div v-if="antennas.length > 0" class="_gaps"> + <MkA v-for="antenna in antennas" :key="antenna.id" :class="$style.antenna" :to="`/my/antennas/${antenna.id}`"> + <div class="name">{{ antenna.name }}</div> + </MkA> </div> </div> </MkSpacer> @@ -18,19 +23,31 @@ </template> <script lang="ts" setup> -import { } from 'vue'; -import MkPagination from '@/components/MkPagination.vue'; import MkButton from '@/components/MkButton.vue'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; +import { antennasCache } from '@/cache'; +import { api } from '@/os'; +import { onActivated } from 'vue'; +import { infoImageUrl } from '@/instance'; -const pagination = { - endpoint: 'antennas/list' as const, - noPaging: true, - limit: 10, -}; +const antennas = $computed(() => antennasCache.value.value ?? []); + +function fetch() { + antennasCache.fetch(() => api('antennas/list')); +} -const headerActions = $computed(() => []); +fetch(); + +const headerActions = $computed(() => [{ + asFullButton: true, + icon: 'ti ti-refresh', + text: i18n.ts.reload, + handler: () => { + antennasCache.delete(); + fetch(); + }, +}]); const headerTabs = $computed(() => []); @@ -38,30 +55,30 @@ definePageMetadata({ title: i18n.ts.manageAntennas, icon: 'ti ti-antenna', }); -</script> - -<style lang="scss" scoped> -.ieepwinx { - > .add { - margin: 0 auto 16px auto; - } +onActivated(() => { + antennasCache.fetch(() => api('antennas/list')); +}); +</script> - .ljoevbzj { - display: block; - padding: 16px; - margin-bottom: 8px; - border: solid 1px var(--divider); - border-radius: 6px; +<style lang="scss" module> +.add { + margin: 0 auto 16px auto; +} - &:hover { - border: solid 1px var(--accent); - text-decoration: none; - } +.antenna { + display: block; + padding: 16px; + border: solid 1px var(--divider); + border-radius: 6px; - > .name { - font-weight: bold; - } + &:hover { + border: solid 1px var(--accent); + text-decoration: none; } } + +.name { + font-weight: bold; +} </style> diff --git a/packages/frontend/src/pages/my-lists/index.vue b/packages/frontend/src/pages/my-lists/index.vue index cee241c489..38cee91a51 100644 --- a/packages/frontend/src/pages/my-lists/index.vue +++ b/packages/frontend/src/pages/my-lists/index.vue @@ -3,38 +3,44 @@ <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> <MkSpacer :contentMax="700"> <div class="_gaps"> + <div v-if="items.length === 0" class="empty"> + <div class="_fullinfo"> + <img :src="infoImageUrl" class="_ghost"/> + <div>{{ i18n.ts.nothing }}</div> + </div> + </div> + <MkButton primary rounded style="margin: 0 auto;" @click="create"><i class="ti ti-plus"></i> {{ i18n.ts.createList }}</MkButton> - <MkPagination v-slot="{items}" ref="pagingComponent" :pagination="pagination"> - <div class="_gaps"> - <MkA v-for="list in items" :key="list.id" class="_panel" :class="$style.list" :to="`/my/lists/${ list.id }`"> - <div style="margin-bottom: 4px;">{{ list.name }}</div> - <MkAvatars :userIds="list.userIds"/> - </MkA> - </div> - </MkPagination> + <div v-if="items.length > 0" class="_gaps"> + <MkA v-for="list in items" :key="list.id" class="_panel" :class="$style.list" :to="`/my/lists/${ list.id }`"> + <div style="margin-bottom: 4px;">{{ list.name }} <span :class="$style.nUsers">({{ i18n.t('nUsers', { n: `${list.userIds.length}/${$i?.policies['userEachUserListsLimit']}` }) }})</span></div> + <MkAvatars :userIds="list.userIds" :limit="10"/> + </MkA> + </div> </div> </MkSpacer> </MkStickyContainer> </template> <script lang="ts" setup> -import { } from 'vue'; -import MkPagination from '@/components/MkPagination.vue'; +import { onActivated } from 'vue'; import MkButton from '@/components/MkButton.vue'; import MkAvatars from '@/components/MkAvatars.vue'; import * as os from '@/os'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; import { userListsCache } from '@/cache'; +import { infoImageUrl } from '@/instance'; +import { $i } from '@/account'; -const pagingComponent = $shallowRef<InstanceType<typeof MkPagination>>(); +const items = $computed(() => userListsCache.value.value ?? []); -const pagination = { - endpoint: 'users/lists/list' as const, - noPaging: true, - limit: 10, -}; +function fetch() { + userListsCache.fetch(() => os.api('users/lists/list')); +} + +fetch(); async function create() { const { canceled, result: name } = await os.inputText({ @@ -43,20 +49,28 @@ async function create() { if (canceled) return; await os.apiWithDialog('users/lists/create', { name: name }); userListsCache.delete(); - pagingComponent.reload(); + fetch(); } -const headerActions = $computed(() => []); +const headerActions = $computed(() => [{ + asFullButton: true, + icon: 'ti ti-refresh', + text: i18n.ts.reload, + handler: () => { + userListsCache.delete(); + fetch(); + }, +}]); const headerTabs = $computed(() => []); definePageMetadata({ title: i18n.ts.manageLists, icon: 'ti ti-list', - action: { - icon: 'ti ti-plus', - handler: create, - }, +}); + +onActivated(() => { + fetch(); }); </script> @@ -73,4 +87,9 @@ definePageMetadata({ text-decoration: none; } } + +.nUsers { + font-size: .9em; + opacity: .7; +} </style> diff --git a/packages/frontend/src/pages/my-lists/list.vue b/packages/frontend/src/pages/my-lists/list.vue index dd431e8dc0..36a3a123c5 100644 --- a/packages/frontend/src/pages/my-lists/list.vue +++ b/packages/frontend/src/pages/my-lists/list.vue @@ -20,6 +20,7 @@ <MkFolder defaultOpen> <template #label>{{ i18n.ts.members }}</template> + <template #caption>{{ i18n.t('nUsers', { n: `${list.userIds.length}/${$i?.policies['userEachUserListsLimit']}` }) }}</template> <div class="_gaps_s"> <MkButton rounded primary style="margin: 0 auto;" @click="addUser()">{{ i18n.ts.addUser }}</MkButton> @@ -29,6 +30,10 @@ </MkA> <button class="_button" :class="$style.remove" @click="removeUser(user, $event)"><i class="ti ti-x"></i></button> </div> + <MkButton v-if="!fetching && queueUserIds.length !== 0" v-appear="enableInfiniteScroll ? fetchMoreUsers : null" :class="$style.more" :style="{ cursor: 'pointer' }" primary rounded @click="fetchMoreUsers"> + {{ i18n.ts.loadMore }} + </MkButton> + <MkLoading v-if="fetching" class="loading"/> </div> </MkFolder> </div> @@ -49,34 +54,57 @@ import MkSwitch from '@/components/MkSwitch.vue'; import MkFolder from '@/components/MkFolder.vue'; import MkInput from '@/components/MkInput.vue'; import { userListsCache } from '@/cache'; +import { UserList, UserLite } from 'misskey-js/built/entities'; +import { $i } from '@/account'; +import { defaultStore } from '@/store'; +const { + enableInfiniteScroll, +} = defaultStore.reactiveState; const props = defineProps<{ listId: string; }>(); -let list = $ref(null); -let users = $ref([]); +const FETCH_USERS_LIMIT = 20; + +let list = $ref<UserList | null>(null); +let users = $ref<UserLite[]>([]); +let queueUserIds = $ref<string[]>([]); +let fetching = $ref(true); const isPublic = ref(false); const name = ref(''); function fetchList() { + fetching = true; os.api('users/lists/show', { listId: props.listId, }).then(_list => { list = _list; name.value = list.name; isPublic.value = list.isPublic; + queueUserIds = list.userIds; - os.api('users/show', { - userIds: list.userIds, - }).then(_users => { - users = _users; - }); + return fetchMoreUsers(); + }); +} + +function fetchMoreUsers() { + if (!list) return; + if (fetching && users.length !== 0) return; // fetchingがtrueならやめるが、usersが空なら続行 + fetching = true; + os.api('users/show', { + userIds: queueUserIds.slice(0, FETCH_USERS_LIMIT), + }).then(_users => { + users = users.concat(_users); + queueUserIds = queueUserIds.slice(FETCH_USERS_LIMIT); + }).finally(() => { + fetching = false; }); } function addUser() { os.selectUser().then(user => { + if (!list) return; os.apiWithDialog('users/lists/push', { listId: list.id, userId: user.id, @@ -92,6 +120,7 @@ async function removeUser(user, ev) { icon: 'ti ti-x', danger: true, action: async () => { + if (!list) return; os.api('users/lists/pull', { listId: list.id, userId: user.id, @@ -103,6 +132,7 @@ async function removeUser(user, ev) { } async function deleteList() { + if (!list) return; const { canceled } = await os.confirm({ type: 'warning', text: i18n.t('removeAreYouSure', { x: list.name }), @@ -117,6 +147,7 @@ async function deleteList() { } async function updateSettings() { + if (!list) return; await os.apiWithDialog('users/lists/update', { listId: list.id, name: name.value, @@ -166,6 +197,11 @@ definePageMetadata(computed(() => list ? { align-self: center; } +.more { + margin-left: auto; + margin-right: auto; +} + .footer { -webkit-backdrop-filter: var(--blur, blur(15px)); backdrop-filter: var(--blur, blur(15px)); diff --git a/packages/frontend/src/pages/not-found.vue b/packages/frontend/src/pages/not-found.vue index 43dc41e7cc..d10f221b8c 100644 --- a/packages/frontend/src/pages/not-found.vue +++ b/packages/frontend/src/pages/not-found.vue @@ -10,8 +10,17 @@ <script lang="ts" setup> import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; +import { pleaseLogin } from '@/scripts/please-login'; import { notFoundImageUrl } from '@/instance'; +const props = defineProps<{ + showLoginPopup?: boolean; +}>(); + +if (props.showLoginPopup) { + pleaseLogin('/'); +} + const headerActions = $computed(() => []); const headerTabs = $computed(() => []); diff --git a/packages/frontend/src/pages/registry.keys.vue b/packages/frontend/src/pages/registry.keys.vue index b1d41fe2c7..59f1f7fdb5 100644 --- a/packages/frontend/src/pages/registry.keys.vue +++ b/packages/frontend/src/pages/registry.keys.vue @@ -13,7 +13,7 @@ <template #value>{{ scope.join('/') }}</template> </MkKeyValue> </FormSplit> - + <MkButton primary @click="createKey">{{ i18n.ts._registry.createKey }}</MkButton> <FormSection v-if="keys"> diff --git a/packages/frontend/src/pages/registry.value.vue b/packages/frontend/src/pages/registry.value.vue index 513a2f8feb..ed01381d57 100644 --- a/packages/frontend/src/pages/registry.value.vue +++ b/packages/frontend/src/pages/registry.value.vue @@ -20,7 +20,7 @@ <template #value>{{ key }}</template> </MkKeyValue> </FormSplit> - + <MkTextarea v-model="valueForEditor" tall class="_monospace"> <template #label>{{ i18n.ts.value }} (JSON)</template> </MkTextarea> diff --git a/packages/frontend/src/pages/reset-password.vue b/packages/frontend/src/pages/reset-password.vue index 9d57307314..fbd109b3f0 100644 --- a/packages/frontend/src/pages/reset-password.vue +++ b/packages/frontend/src/pages/reset-password.vue @@ -7,7 +7,7 @@ <template #prefix><i class="ti ti-lock"></i></template> <template #label>{{ i18n.ts.newPassword }}</template> </MkInput> - + <MkButton primary @click="save">{{ i18n.ts.save }}</MkButton> </div> </MkSpacer> diff --git a/packages/frontend/src/pages/search.user.vue b/packages/frontend/src/pages/search.user.vue index bd1389ffef..8e4a4a78c5 100644 --- a/packages/frontend/src/pages/search.user.vue +++ b/packages/frontend/src/pages/search.user.vue @@ -67,7 +67,7 @@ async function search() { endpoint: 'users/search', limit: 10, params: { - query: searchQuery, + query: query, origin: searchOrigin, }, }; diff --git a/packages/frontend/src/pages/settings/deck.vue b/packages/frontend/src/pages/settings/deck.vue index bc0179b3aa..481959fd08 100644 --- a/packages/frontend/src/pages/settings/deck.vue +++ b/packages/frontend/src/pages/settings/deck.vue @@ -1,5 +1,7 @@ <template> <div class="_gaps_m"> + <MkSwitch v-model="useSimpleUiForNonRootPages">{{ i18n.ts._deck.useSimpleUiForNonRootPages }}</MkSwitch> + <MkSwitch v-model="navWindow">{{ i18n.ts.defaultNavigationBehaviour }}: {{ i18n.ts.openInWindow }}</MkSwitch> <MkSwitch v-model="alwaysShowMainColumn">{{ i18n.ts._deck.alwaysShowMainColumn }}</MkSwitch> @@ -21,6 +23,7 @@ import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; const navWindow = computed(deckStore.makeGetterSetter('navWindow')); +const useSimpleUiForNonRootPages = computed(deckStore.makeGetterSetter('useSimpleUiForNonRootPages')); const alwaysShowMainColumn = computed(deckStore.makeGetterSetter('alwaysShowMainColumn')); const columnAlign = computed(deckStore.makeGetterSetter('columnAlign')); diff --git a/packages/frontend/src/pages/settings/drive-cleaner.vue b/packages/frontend/src/pages/settings/drive-cleaner.vue index 8178343bbb..98471e94db 100644 --- a/packages/frontend/src/pages/settings/drive-cleaner.vue +++ b/packages/frontend/src/pages/settings/drive-cleaner.vue @@ -80,7 +80,7 @@ watch(sortModeSelect, () => { sortMode.value = '+size'; fetchDriveInfo(); break; - + case 'createdAtAsc': sortMode.value = '-createdAt'; fetchDriveInfo(); diff --git a/packages/frontend/src/pages/settings/general.vue b/packages/frontend/src/pages/settings/general.vue index 20b36f0fcb..cfe5cd31e7 100644 --- a/packages/frontend/src/pages/settings/general.vue +++ b/packages/frontend/src/pages/settings/general.vue @@ -52,10 +52,10 @@ </MkSelect> <MkSelect v-model="nsfw"> - <template #label>{{ i18n.ts.nsfw }}</template> - <option value="respect">{{ i18n.ts._nsfw.respect }}</option> - <option value="ignore">{{ i18n.ts._nsfw.ignore }}</option> - <option value="force">{{ i18n.ts._nsfw.force }}</option> + <template #label>{{ i18n.ts.displayOfSensitiveMedia }}</template> + <option value="respect">{{ i18n.ts._displayOfSensitiveMedia.respect }}</option> + <option value="ignore">{{ i18n.ts._displayOfSensitiveMedia.ignore }}</option> + <option value="force">{{ i18n.ts._displayOfSensitiveMedia.force }}</option> </MkSelect> <MkRadios v-model="mediaListWithOneImageAppearance"> diff --git a/packages/frontend/src/pages/settings/index.vue b/packages/frontend/src/pages/settings/index.vue index b4f056d8a6..d53519e0d5 100644 --- a/packages/frontend/src/pages/settings/index.vue +++ b/packages/frontend/src/pages/settings/index.vue @@ -166,7 +166,7 @@ const menuDef = computed(() => [{ active: currentPage?.route.name === 'import-export', }, { icon: 'ti ti-plane', - text: `${i18n.ts.accountMigration} (${i18n.ts.experimental})`, + text: `${i18n.ts.accountMigration}`, to: '/settings/migration', active: currentPage?.route.name === 'migration', }, { diff --git a/packages/frontend/src/pages/settings/migration.vue b/packages/frontend/src/pages/settings/migration.vue index 102bc68523..38e0d0abb2 100644 --- a/packages/frontend/src/pages/settings/migration.vue +++ b/packages/frontend/src/pages/settings/migration.vue @@ -1,8 +1,5 @@ <template> <div class="_gaps_m"> - <FormInfo warn> - {{ i18n.ts.thisIsExperimentalFeature }} - </FormInfo> <MkFolder :defaultOpen="true"> <template #icon><i class="ti ti-plane-arrival"></i></template> <template #label>{{ i18n.ts._accountMigration.moveFrom }}</template> diff --git a/packages/frontend/src/pages/settings/mute-block.vue b/packages/frontend/src/pages/settings/mute-block.vue index e0785ab9fe..e1f3c6bed9 100644 --- a/packages/frontend/src/pages/settings/mute-block.vue +++ b/packages/frontend/src/pages/settings/mute-block.vue @@ -55,7 +55,7 @@ </div> <div v-if="expandedMuteItems.includes(item.id)" :class="$style.userItemSub"> <div>Muted at: <MkTime :time="item.createdAt" mode="detail"/></div> - <div v-if="item.expiresAt">Period: {{ item.expiresAt.toLocaleString() }}</div> + <div v-if="item.expiresAt">Period: {{ new Date(item.expiresAt).toLocaleString() }}</div> <div v-else>Period: {{ i18n.ts.indefinitely }}</div> </div> </div> @@ -85,7 +85,7 @@ </div> <div v-if="expandedBlockItems.includes(item.id)" :class="$style.userItemSub"> <div>Blocked at: <MkTime :time="item.createdAt" mode="detail"/></div> - <div v-if="item.expiresAt">Period: {{ item.expiresAt.toLocaleString() }}</div> + <div v-if="item.expiresAt">Period: {{ new Date(item.expiresAt).toLocaleString() }}</div> <div v-else>Period: {{ i18n.ts.indefinitely }}</div> </div> </div> diff --git a/packages/frontend/src/pages/settings/navbar.vue b/packages/frontend/src/pages/settings/navbar.vue index 8780bfbc1e..f0e9a1c3d9 100644 --- a/packages/frontend/src/pages/settings/navbar.vue +++ b/packages/frontend/src/pages/settings/navbar.vue @@ -3,7 +3,7 @@ <FormSlot> <template #label>{{ i18n.ts.navbar }}</template> <MkContainer :showHeader="false"> - <Sortable + <Sortable v-model="items" itemKey="id" :animation="150" diff --git a/packages/frontend/src/pages/settings/preferences-backups.vue b/packages/frontend/src/pages/settings/preferences-backups.vue index e34901cd11..1aa1a5f81c 100644 --- a/packages/frontend/src/pages/settings/preferences-backups.vue +++ b/packages/frontend/src/pages/settings/preferences-backups.vue @@ -144,7 +144,7 @@ function validate(profile: unknown): void { if (!profile.name) throw new Error('Missing required prop: name'); if (!profile.misskeyVersion) throw new Error('Missing required prop: misskeyVersion'); - + // Check if createdAt and updatedAt is Date // https://zenn.dev/lollipop_onl/articles/eoz-judge-js-invalid-date if (!profile.createdAt || Number.isNaN(new Date(profile.createdAt).getTime())) throw new Error('createdAt is falsy or not Date'); @@ -273,7 +273,7 @@ async function applyProfile(id: string): Promise<void> { defaultStore.set(key, settings.hot[key]); } } - + // coldDeviceStorage for (const key of coldDeviceStorageSaveKeys) { if (settings.cold[key] !== undefined) { diff --git a/packages/frontend/src/pages/settings/privacy.vue b/packages/frontend/src/pages/settings/privacy.vue index 7fd4d6d34e..88d109c021 100644 --- a/packages/frontend/src/pages/settings/privacy.vue +++ b/packages/frontend/src/pages/settings/privacy.vue @@ -7,7 +7,7 @@ {{ i18n.ts.makeReactionsPublic }} <template #caption>{{ i18n.ts.makeReactionsPublicDescription }}</template> </MkSwitch> - + <MkSelect v-model="ffVisibility" @update:modelValue="save()"> <template #label>{{ i18n.ts.ffVisibility }}</template> <option value="public">{{ i18n.ts._ffVisibility.public }}</option> @@ -15,7 +15,7 @@ <option value="private">{{ i18n.ts._ffVisibility.private }}</option> <template #caption>{{ i18n.ts.ffVisibilityDescription }}</template> </MkSelect> - + <MkSwitch v-model="hideOnlineStatus" @update:modelValue="save()"> {{ i18n.ts.hideOnlineStatus }} <template #caption>{{ i18n.ts.hideOnlineStatusDescription }}</template> diff --git a/packages/frontend/src/pages/settings/roles.vue b/packages/frontend/src/pages/settings/roles.vue index 05753c9b60..4b842f56fd 100644 --- a/packages/frontend/src/pages/settings/roles.vue +++ b/packages/frontend/src/pages/settings/roles.vue @@ -37,7 +37,7 @@ import MkRolePreview from '@/components/MkRolePreview.vue'; function save() { os.apiWithDialog('i/update', { - + }); } diff --git a/packages/frontend/src/pages/settings/security.vue b/packages/frontend/src/pages/settings/security.vue index 2da84763a3..bd5e2e350a 100644 --- a/packages/frontend/src/pages/settings/security.vue +++ b/packages/frontend/src/pages/settings/security.vue @@ -78,7 +78,7 @@ async function change() { }); return; } - + os.apiWithDialog('i/change-password', { currentPassword, newPassword, diff --git a/packages/frontend/src/pages/user-info.vue b/packages/frontend/src/pages/user-info.vue index 56e8737e1c..f7650285c7 100644 --- a/packages/frontend/src/pages/user-info.vue +++ b/packages/frontend/src/pages/user-info.vue @@ -112,9 +112,17 @@ <MkButton v-if="user.host == null && iAmModerator" primary rounded @click="assignRole"><i class="ti ti-plus"></i> {{ i18n.ts.assign }}</MkButton> <div v-for="role in info.roles" :key="role.id" :class="$style.roleItem"> - <MkRolePreview :class="$style.role" :role="role" :forModeration="true"/> - <button v-if="role.target === 'manual'" class="_button" :class="$style.roleUnassign" @click="unassignRole(role, $event)"><i class="ti ti-x"></i></button> - <button v-else class="_button" :class="$style.roleUnassign" disabled><i class="ti ti-ban"></i></button> + <div :class="$style.roleItemMain"> + <MkRolePreview :class="$style.role" :role="role" :forModeration="true"/> + <button class="_button" :class="$style.roleToggle" @click="toggleRoleItem(role)"><i class="ti ti-chevron-down"></i></button> + <button v-if="role.target === 'manual'" class="_button" :class="$style.roleUnassign" @click="unassignRole(role, $event)"><i class="ti ti-x"></i></button> + <button v-else class="_button" :class="$style.roleUnassign" disabled><i class="ti ti-ban"></i></button> + </div> + <div v-if="expandedRoles.includes(role.id)" :class="$style.roleItemSub"> + <div>Assigned: <MkTime :time="info.roleAssigns.find(a => a.roleId === role.id).createdAt" mode="detail"/></div> + <div v-if="info.roleAssigns.find(a => a.roleId === role.id).expiresAt">Period: {{ new Date(info.roleAssigns.find(a => a.roleId === role.id).expiresAt).toLocaleString() }}</div> + <div v-else>Period: {{ i18n.ts.indefinitely }}</div> + </div> </div> </div> </MkFolder> @@ -220,6 +228,7 @@ const filesPagination = { userId: props.userId, })), }; +let expandedRoles = $ref([]); function createFetcher() { if (iAmModerator) { @@ -384,6 +393,14 @@ async function unassignRole(role, ev) { }], ev.currentTarget ?? ev.target); } +function toggleRoleItem(role) { + if (expandedRoles.includes(role.id)) { + expandedRoles = expandedRoles.filter(x => x !== role.id); + } else { + expandedRoles.push(role.id); + } +} + watch(() => props.userId, () => { init = createFetcher(); }, { @@ -523,11 +540,22 @@ definePageMetadata(computed(() => ({ } .roleItem { +} + +.roleItemMain { display: flex; } .role { flex: 1; + min-width: 0; + margin-right: 8px; +} + +.roleItemSub { + padding: 6px 12px; + font-size: 85%; + color: var(--fgTransparentWeak); } .roleUnassign { diff --git a/packages/frontend/src/pages/user/home.vue b/packages/frontend/src/pages/user/home.vue index 2e69eb367b..b0d42463a0 100644 --- a/packages/frontend/src/pages/user/home.vue +++ b/packages/frontend/src/pages/user/home.vue @@ -44,8 +44,10 @@ </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 }"> - <img v-if="role.iconUrl" style="height: 1.3em; vertical-align: -22%;" :src="role.iconUrl"/> - {{ role.name }} + <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 v-if="iAmModerator" class="moderationNote"> @@ -98,15 +100,15 @@ </dl> </div> <div class="status"> - <MkA v-click-anime :to="userPage(user)"> + <MkA :to="userPage(user)"> <b>{{ number(user.notesCount) }}</b> <span>{{ i18n.ts.notes }}</span> </MkA> - <MkA v-click-anime :to="userPage(user, 'following')"> + <MkA v-if="isFfVisibleForMe(user)" :to="userPage(user, 'following')"> <b>{{ number(user.followingCount) }}</b> <span>{{ i18n.ts.following }}</span> </MkA> - <MkA v-click-anime :to="userPage(user, 'followers')"> + <MkA v-if="isFfVisibleForMe(user)" :to="userPage(user, 'followers')"> <b>{{ number(user.followersCount) }}</b> <span>{{ i18n.ts.followers }}</span> </MkA> @@ -158,6 +160,7 @@ import { dateString } from '@/filters/date'; import { confetti } from '@/scripts/confetti'; import MkNotes from '@/components/MkNotes.vue'; import { api } from '@/os'; +import { isFfVisibleForMe } from '@/scripts/isFfVisibleForMe'; const XPhotos = defineAsyncComponent(() => import('./index.photos.vue')); const XActivity = defineAsyncComponent(() => import('./index.activity.vue')); diff --git a/packages/frontend/src/pizzax.ts b/packages/frontend/src/pizzax.ts index d97bd4be62..996610602b 100644 --- a/packages/frontend/src/pizzax.ts +++ b/packages/frontend/src/pizzax.ts @@ -70,7 +70,7 @@ export class Storage<T extends StateDef> { this.state[k] = v.default; this.reactiveState[k] = ref(v.default); } - + this.ready = this.init(); this.loaded = this.ready.then(() => this.load()); } @@ -81,7 +81,7 @@ export class Storage<T extends StateDef> { const deviceState: State<T> = await get(this.deviceStateKeyName) || {}; const deviceAccountState = $i ? await get(this.deviceAccountStateKeyName) || {} : {}; const registryCache = $i ? await get(this.registryCacheKeyName) || {} : {}; - + for (const [k, v] of Object.entries(this.def) as [keyof T, T[keyof T]['default']][]) { if (v.where === 'device' && Object.prototype.hasOwnProperty.call(deviceState, k)) { this.reactiveState[k].value = this.state[k] = deviceState[k]; @@ -110,7 +110,7 @@ export class Storage<T extends StateDef> { if (!scope || scope.length !== 2 || scope[0] !== 'client' || scope[1] !== this.key || this.state[key] === value) return; this.reactiveState[key].value = this.state[key] = value; - + this.addIdbSetJob(async () => { const cache = await get(this.registryCacheKeyName); if (cache[key] !== value) { @@ -142,7 +142,7 @@ export class Storage<T extends StateDef> { } } } - + return set(this.registryCacheKeyName, cache); }) .then(() => resolve()); @@ -252,7 +252,7 @@ export class Storage<T extends StateDef> { // localStorage => indexedDBのマイグレーション private async migrate() { const deviceState = localStorage.getItem(this.deviceStateKeyName); - if (deviceState) { + if (deviceState) { await set(this.deviceStateKeyName, JSON.parse(deviceState)); localStorage.removeItem(this.deviceStateKeyName); } diff --git a/packages/frontend/src/router.ts b/packages/frontend/src/router.ts index fe9bc5938e..a4276ff4c0 100644 --- a/packages/frontend/src/router.ts +++ b/packages/frontend/src/router.ts @@ -4,7 +4,7 @@ import { $i, iAmModerator } from '@/account'; import MkLoading from '@/pages/_loading_.vue'; import MkError from '@/pages/_error_.vue'; -const page = (loader: AsyncComponentLoader<any>) => defineAsyncComponent({ +export const page = (loader: AsyncComponentLoader<any>) => defineAsyncComponent({ loader: loader, loadingComponent: MkLoading, errorComponent: MkError, @@ -202,6 +202,10 @@ export const routes = [{ path: '/about-misskey', component: page(() => import('./pages/about-misskey.vue')), }, { + path: '/invite', + name: 'invite', + component: page(() => import('./pages/invite.vue')), +}, { path: '/ads', component: page(() => import('./pages/ads.vue')), }, { @@ -429,6 +433,10 @@ export const routes = [{ name: 'server-rules', component: page(() => import('./pages/admin/server-rules.vue')), }, { + path: '/invites', + name: 'invites', + component: page(() => import('./pages/admin/invites.vue')), + }, { path: '/', component: page(() => import('./pages/_empty_.vue')), }], @@ -505,45 +513,16 @@ export const routes = [{ component: page(() => import('./pages/not-found.vue')), }]; -export const mainRouter = new Router(routes, location.pathname + location.search + location.hash); +export const mainRouter = new Router(routes, location.pathname + location.search + location.hash, !!$i, page(() => import('@/pages/not-found.vue'))); window.history.replaceState({ key: mainRouter.getCurrentKey() }, '', location.href); -// TODO: このファイルでスクロール位置も管理する設計だとdeckに対応できないのでなんとかする -// スクロール位置取得+スクロール位置設定関数をprovideする感じでも良いかも - -const scrollPosStore = new Map<string, number>(); - -window.setInterval(() => { - scrollPosStore.set(window.history.state?.key, window.scrollY); -}, 1000); - mainRouter.addListener('push', ctx => { window.history.pushState({ key: ctx.key }, '', ctx.path); - const scrollPos = scrollPosStore.get(ctx.key) ?? 0; - window.scroll({ top: scrollPos, behavior: 'instant' }); - if (scrollPos !== 0) { - window.setTimeout(() => { // 遷移直後はタイミングによってはコンポーネントが復元し切ってない可能性も考えられるため少し時間を空けて再度スクロール - window.scroll({ top: scrollPos, behavior: 'instant' }); - }, 100); - } -}); - -mainRouter.addListener('replace', ctx => { - window.history.replaceState({ key: ctx.key }, '', ctx.path); -}); - -mainRouter.addListener('same', () => { - window.scroll({ top: 0, behavior: 'smooth' }); }); window.addEventListener('popstate', (event) => { - mainRouter.replace(location.pathname + location.search + location.hash, event.state?.key, false); - const scrollPos = scrollPosStore.get(event.state?.key) ?? 0; - window.scroll({ top: scrollPos, behavior: 'instant' }); - window.setTimeout(() => { // 遷移直後はタイミングによってはコンポーネントが復元し切ってない可能性も考えられるため少し時間を空けて再度スクロール - window.scroll({ top: scrollPos, behavior: 'instant' }); - }, 100); + mainRouter.replace(location.pathname + location.search + location.hash, event.state?.key); }); export function useRouter(): Router { diff --git a/packages/frontend/src/scripts/aiscript/api.ts b/packages/frontend/src/scripts/aiscript/api.ts index b6b7445b67..5453fe827d 100644 --- a/packages/frontend/src/scripts/aiscript/api.ts +++ b/packages/frontend/src/scripts/aiscript/api.ts @@ -11,6 +11,7 @@ export function createAiScriptEnv(opts) { USER_NAME: $i ? values.STR($i.name) : values.NULL, USER_USERNAME: $i ? values.STR($i.username) : values.NULL, CUSTOM_EMOJIS: utils.jsToVal(customEmojis.value), + CURRENT_URL: values.STR(window.location.href), 'Mk:dialog': values.FN_NATIVE(async ([title, text, type]) => { await os.alert({ type: type ? type.value : 'info', diff --git a/packages/frontend/src/scripts/aiscript/ui.ts b/packages/frontend/src/scripts/aiscript/ui.ts index 2ca1b164ae..c26ae5a4df 100644 --- a/packages/frontend/src/scripts/aiscript/ui.ts +++ b/packages/frontend/src/scripts/aiscript/ui.ts @@ -510,7 +510,7 @@ export function registerAsUiLib(components: Ref<AsUiComponent>[], done: (root: R // Ui:root.update({ children: [...] }) の糖衣構文 'Ui:render': values.FN_NATIVE(([children], opts) => { utils.assertArray(children); - + rootComponent.value.children = children.value.map(v => { utils.assertObject(v); return v.value.get('id').value; diff --git a/packages/frontend/src/scripts/array.ts b/packages/frontend/src/scripts/array.ts index 4620c8b735..c9a146e707 100644 --- a/packages/frontend/src/scripts/array.ts +++ b/packages/frontend/src/scripts/array.ts @@ -78,8 +78,9 @@ export function maximum(xs: number[]): number { export function groupBy<T>(f: EndoRelation<T>, xs: T[]): T[][] { const groups = [] as T[][]; for (const x of xs) { - if (groups.length !== 0 && f(groups[groups.length - 1][0], x)) { - groups[groups.length - 1].push(x); + const lastGroup = groups.at(-1); + if (lastGroup !== undefined && f(lastGroup[0], x)) { + lastGroup.push(x); } else { groups.push([x]); } diff --git a/packages/frontend/src/scripts/autocomplete.ts b/packages/frontend/src/scripts/autocomplete.ts index 1bae3790f5..564573ae8a 100644 --- a/packages/frontend/src/scripts/autocomplete.ts +++ b/packages/frontend/src/scripts/autocomplete.ts @@ -65,7 +65,7 @@ export class Autocomplete { */ private onInput() { const caretPos = this.textarea.selectionStart; - const text = this.text.substr(0, caretPos).split('\n').pop()!; + const text = this.text.substring(0, caretPos).split('\n').pop()!; const mentionIndex = text.lastIndexOf('@'); const hashtagIndex = text.lastIndexOf('#'); @@ -91,7 +91,7 @@ export class Autocomplete { let opened = false; if (isMention) { - const username = text.substr(mentionIndex + 1); + const username = text.substring(mentionIndex + 1); if (username !== '' && username.match(/^[a-zA-Z0-9_]+$/)) { this.open('user', username); opened = true; @@ -102,7 +102,7 @@ export class Autocomplete { } if (isHashtag && !opened) { - const hashtag = text.substr(hashtagIndex + 1); + const hashtag = text.substring(hashtagIndex + 1); if (!hashtag.includes(' ')) { this.open('hashtag', hashtag); opened = true; @@ -110,7 +110,7 @@ export class Autocomplete { } if (isEmoji && !opened) { - const emoji = text.substr(emojiIndex + 1); + const emoji = text.substring(emojiIndex + 1); if (!emoji.includes(' ')) { this.open('emoji', emoji); opened = true; @@ -118,7 +118,7 @@ export class Autocomplete { } if (isMfmTag && !opened) { - const mfmTag = text.substr(mfmTagIndex + 1); + const mfmTag = text.substring(mfmTagIndex + 1); if (!mfmTag.includes(' ')) { this.open('mfmTag', mfmTag.replace('[', '')); opened = true; @@ -208,9 +208,9 @@ export class Autocomplete { if (type === 'user') { const source = this.text; - const before = source.substr(0, caret); + const before = source.substring(0, caret); const trimmedBefore = before.substring(0, before.lastIndexOf('@')); - const after = source.substr(caret); + const after = source.substring(caret); const acct = value.host === null ? value.username : `${value.username}@${toASCII(value.host)}`; @@ -226,9 +226,9 @@ export class Autocomplete { } else if (type === 'hashtag') { const source = this.text; - const before = source.substr(0, caret); + const before = source.substring(0, caret); const trimmedBefore = before.substring(0, before.lastIndexOf('#')); - const after = source.substr(caret); + const after = source.substring(caret); // 挿入 this.text = `${trimmedBefore}#${value} ${after}`; @@ -242,9 +242,9 @@ export class Autocomplete { } else if (type === 'emoji') { const source = this.text; - const before = source.substr(0, caret); + const before = source.substring(0, caret); const trimmedBefore = before.substring(0, before.lastIndexOf(':')); - const after = source.substr(caret); + const after = source.substring(caret); // 挿入 this.text = trimmedBefore + value + after; @@ -258,9 +258,9 @@ export class Autocomplete { } else if (type === 'mfmTag') { const source = this.text; - const before = source.substr(0, caret); + const before = source.substring(0, caret); const trimmedBefore = before.substring(0, before.lastIndexOf('$')); - const after = source.substr(caret); + const after = source.substring(caret); // 挿入 this.text = `${trimmedBefore}$[${value} ]${after}`; diff --git a/packages/frontend/src/scripts/cache.ts b/packages/frontend/src/scripts/cache.ts index 858e5f03bf..a61d858353 100644 --- a/packages/frontend/src/scripts/cache.ts +++ b/packages/frontend/src/scripts/cache.ts @@ -1,7 +1,8 @@ +import { ref } from "vue"; export class Cache<T> { private cachedAt: number | null = null; - private value: T | undefined; + public value = ref<T | undefined>(); private lifetime: number; constructor(lifetime: Cache<never>['lifetime']) { @@ -10,21 +11,20 @@ export class Cache<T> { public set(value: T): void { this.cachedAt = Date.now(); - this.value = value; + this.value.value = value; } - public get(): T | undefined { + private get(): T | undefined { if (this.cachedAt == null) return undefined; if ((Date.now() - this.cachedAt) > this.lifetime) { - this.value = undefined; + this.value.value = undefined; this.cachedAt = null; return undefined; } - return this.value; + return this.value.value; } public delete() { - this.value = undefined; this.cachedAt = null; } diff --git a/packages/frontend/src/scripts/clone.ts b/packages/frontend/src/scripts/clone.ts index 16fad24129..cf8fa64ba3 100644 --- a/packages/frontend/src/scripts/clone.ts +++ b/packages/frontend/src/scripts/clone.ts @@ -1,5 +1,7 @@ // structredCloneが遅いため // SEE: http://var.blog.jp/archives/86038606.html +// あと、Vue RefをIndexedDBに保存しようとしてstructredCloneを使ったらエラーになった +// https://github.com/misskey-dev/misskey/pull/8098#issuecomment-1114144045 type Cloneable = string | number | boolean | null | { [key: string]: Cloneable } | Cloneable[]; diff --git a/packages/frontend/src/scripts/collapsed.ts b/packages/frontend/src/scripts/collapsed.ts new file mode 100644 index 0000000000..1bf56f233b --- /dev/null +++ b/packages/frontend/src/scripts/collapsed.ts @@ -0,0 +1,19 @@ +import * as mfm from 'mfm-js'; +import * as misskey from 'misskey-js'; +import { extractUrlFromMfm } from './extract-url-from-mfm'; + +export function shouldCollapsed(note: misskey.entities.Note): boolean { + const urls = note.text ? extractUrlFromMfm(mfm.parse(note.text)) : null; + const collapsed = note.cw == null && note.text != null && ( + (note.text.includes('$[x2')) || + (note.text.includes('$[x3')) || + (note.text.includes('$[x4')) || + (note.text.includes('$[scale')) || + (note.text.split('\n').length > 9) || + (note.text.length > 500) || + (note.files.length >= 5) || + (!!urls && urls.length >= 4) + ); + + return collapsed; +} diff --git a/packages/frontend/src/scripts/form.ts b/packages/frontend/src/scripts/form.ts index 7f321cc0ae..635803a2bc 100644 --- a/packages/frontend/src/scripts/form.ts +++ b/packages/frontend/src/scripts/form.ts @@ -1,3 +1,4 @@ +type EnumItem = string | {label: string; value: string;}; export type FormItem = { label?: string; type: 'string'; @@ -20,7 +21,7 @@ export type FormItem = { type: 'enum'; default: string | null; hidden?: boolean; - enum: string[]; + enum: EnumItem[]; } | { label?: string; type: 'radio'; diff --git a/packages/frontend/src/scripts/gen-search-query.ts b/packages/frontend/src/scripts/gen-search-query.ts index da7d622632..956e0f35d0 100644 --- a/packages/frontend/src/scripts/gen-search-query.ts +++ b/packages/frontend/src/scripts/gen-search-query.ts @@ -5,7 +5,7 @@ export async function genSearchQuery(v: any, q: string) { let host: string; let userId: string; if (q.split(' ').some(x => x.startsWith('@'))) { - for (const at of q.split(' ').filter(x => x.startsWith('@')).map(x => x.substr(1))) { + for (const at of q.split(' ').filter(x => x.startsWith('@')).map(x => x.substring(1))) { if (at.includes('.')) { if (at === localHost || at === '.') { host = null; diff --git a/packages/frontend/src/scripts/get-drive-file-menu.ts b/packages/frontend/src/scripts/get-drive-file-menu.ts index 060c8a1a11..9b488087e2 100644 --- a/packages/frontend/src/scripts/get-drive-file-menu.ts +++ b/packages/frontend/src/scripts/get-drive-file-menu.ts @@ -3,6 +3,8 @@ import { defineAsyncComponent } from 'vue'; import { i18n } from '@/i18n'; import copyToClipboard from '@/scripts/copy-to-clipboard'; import * as os from '@/os'; +import { MenuItem } from '@/types/menu'; +import { defaultStore } from '@/store'; function rename(file: Misskey.entities.DriveFile) { os.inputText({ @@ -66,8 +68,10 @@ async function deleteFile(file: Misskey.entities.DriveFile) { }); } -export function getDriveFileMenu(file: Misskey.entities.DriveFile) { - return [{ +export function getDriveFileMenu(file: Misskey.entities.DriveFile, folder?: Misskey.entities.DriveFolder | null): MenuItem[] { + const isImage = file.type.startsWith('image/'); + let menu; + menu = [{ text: i18n.ts.rename, icon: 'ti ti-forms', action: () => rename(file), @@ -79,7 +83,14 @@ export function getDriveFileMenu(file: Misskey.entities.DriveFile) { text: i18n.ts.describeFile, icon: 'ti ti-text-caption', action: () => describe(file), - }, null, { + }, ...isImage ? [{ + text: i18n.ts.cropImage, + icon: 'ti ti-crop', + action: () => os.cropImage(file, { + aspectRatio: NaN, + uploadFolder: folder ? folder.id : folder + }), + }] : [], null, { text: i18n.ts.createNoteFromTheFile, icon: 'ti ti-pencil', action: () => os.post({ @@ -102,4 +113,16 @@ export function getDriveFileMenu(file: Misskey.entities.DriveFile) { danger: true, action: () => deleteFile(file), }]; + + if (defaultStore.state.devMode) { + menu = menu.concat([null, { + icon: 'ti ti-id', + text: i18n.ts.copyFileId, + action: () => { + copyToClipboard(file.id); + }, + }]); + } + + return menu; } diff --git a/packages/frontend/src/scripts/get-user-menu.ts b/packages/frontend/src/scripts/get-user-menu.ts index b055d26473..1c93d58b44 100644 --- a/packages/frontend/src/scripts/get-user-menu.ts +++ b/packages/frontend/src/scripts/get-user-menu.ts @@ -1,14 +1,15 @@ +import { toUnicode } from 'punycode'; import { defineAsyncComponent } from 'vue'; import * as misskey from 'misskey-js'; import { i18n } from '@/i18n'; import copyToClipboard from '@/scripts/copy-to-clipboard'; -import { host } from '@/config'; +import { host, url } from '@/config'; import * as os from '@/os'; import { defaultStore, userActions } from '@/store'; import { $i, iAmModerator } from '@/account'; import { mainRouter } from '@/router'; import { Router } from '@/nirax'; -import { rolesCache, userListsCache } from '@/cache'; +import { antennasCache, rolesCache, userListsCache } from '@/cache'; export function getUserMenu(user: misskey.entities.UserDetailed, router: Router = mainRouter) { const meId = $i ? $i.id : null; @@ -138,6 +139,13 @@ export function getUserMenu(user: misskey.entities.UserDetailed, router: Router copyToClipboard(`${user.host ?? host}/@${user.username}.atom`); }, }, { + icon: 'ti ti-share', + text: i18n.ts.copyProfileUrl, + action: () => { + const canonical = user.host === null ? `@${user.username}` : `@${user.username}@${toUnicode(user.host)}`; + copyToClipboard(`${url}/${canonical}`); + }, + }, { icon: 'ti ti-mail', text: i18n.ts.sendMessage, action: () => { @@ -158,11 +166,39 @@ export function getUserMenu(user: misskey.entities.UserDetailed, router: Router return lists.map(list => ({ text: list.name, - action: () => { - os.apiWithDialog('users/lists/push', { + action: async () => { + await os.apiWithDialog('users/lists/push', { listId: list.id, userId: user.id, }); + userListsCache.delete(); + }, + })); + }, + }, { + type: 'parent', + icon: 'ti ti-antenna', + text: i18n.ts.addToAntenna, + children: async () => { + const antennas = await antennasCache.fetch(() => os.api('antennas/list')); + const canonical = user.host === null ? `@${user.username}` : `@${user.username}@${toUnicode(user.host)}`; + return antennas.filter((a) => a.src === 'users').map(antenna => ({ + text: antenna.name, + action: async () => { + await os.apiWithDialog('antennas/update', { + antennaId: antenna.id, + name: antenna.name, + keywords: antenna.keywords, + excludeKeywords: antenna.excludeKeywords, + src: antenna.src, + userListId: antenna.userListId, + users: [...antenna.users, canonical], + caseSensitive: antenna.caseSensitive, + withReplies: antenna.withReplies, + withFile: antenna.withFile, + notify: antenna.notify, + }); + antennasCache.delete(); }, })); }, @@ -196,7 +232,7 @@ export function getUserMenu(user: misskey.entities.UserDetailed, router: Router default: 'indefinitely', }); if (canceled) return; - + const expiresAt = period === 'indefinitely' ? null : period === 'oneHour' ? Date.now() + (1000 * 60 * 60) : period === 'oneDay' ? Date.now() + (1000 * 60 * 60 * 24) diff --git a/packages/frontend/src/scripts/idle-render.ts b/packages/frontend/src/scripts/idle-render.ts index ccce8b02bf..a1470b82e9 100644 --- a/packages/frontend/src/scripts/idle-render.ts +++ b/packages/frontend/src/scripts/idle-render.ts @@ -1,3 +1,20 @@ +const requestIdleCallback: typeof globalThis.requestIdleCallback = globalThis.requestIdleCallback ?? ((callback) => { + const start = performance.now(); + const timeoutId = setTimeout(() => { + callback({ + didTimeout: false, // polyfill でタイムアウト発火することはない + timeRemaining() { + const diff = performance.now() - start; + return Math.max(0, 50 - diff); // <https://www.w3.org/TR/requestidlecallback/#idle-periods> + }, + }); + }); + return timeoutId; +}); +const cancelIdleCallback: typeof globalThis.cancelIdleCallback = globalThis.cancelIdleCallback ?? ((timeoutId) => { + clearTimeout(timeoutId); +}); + class IdlingRenderScheduler { #renderers: Set<FrameRequestCallback>; #rafId: number; diff --git a/packages/frontend/src/scripts/isFfVisibleForMe.ts b/packages/frontend/src/scripts/isFfVisibleForMe.ts new file mode 100644 index 0000000000..0ddd3f377d --- /dev/null +++ b/packages/frontend/src/scripts/isFfVisibleForMe.ts @@ -0,0 +1,11 @@ +import * as misskey from 'misskey-js'; +import { $i } from '@/account'; + +export function isFfVisibleForMe(user: misskey.entities.UserDetailed): boolean { + if ($i && $i.id === user.id) return true; + + if (user.ffVisibility === 'private') return false; + if (user.ffVisibility === 'followers' && !user.isFollowing) return false; + + return true; +} diff --git a/packages/frontend/src/scripts/lookup.ts b/packages/frontend/src/scripts/lookup.ts index ce5b03fc38..3f357a3c92 100644 --- a/packages/frontend/src/scripts/lookup.ts +++ b/packages/frontend/src/scripts/lookup.ts @@ -6,18 +6,19 @@ import { Router } from '@/nirax'; export async function lookup(router?: Router) { const _router = router ?? mainRouter; - const { canceled, result: query } = await os.inputText({ + const { canceled, result: temp } = await os.inputText({ title: i18n.ts.lookup, }); + const query = temp ? temp.trim() : ''; if (canceled) return; - + if (query.startsWith('@') && !query.includes(' ')) { _router.push(`/${query}`); return; } if (query.startsWith('#')) { - _router.push(`/tags/${encodeURIComponent(query.substr(1))}`); + _router.push(`/tags/${encodeURIComponent(query.substring(1))}`); return; } diff --git a/packages/frontend/src/scripts/sound.ts b/packages/frontend/src/scripts/sound.ts index 7a5dd4dbfa..68136cdcfe 100644 --- a/packages/frontend/src/scripts/sound.ts +++ b/packages/frontend/src/scripts/sound.ts @@ -132,9 +132,7 @@ export function play(type: 'noteMy' | 'note' | 'antenna' | 'channel' | 'notifica } export function playFile(file: string, volume: number) { - const masterVolume = soundConfigStore.state.sound_masterVolume; - if (masterVolume === 0) return; - const audio = setVolume(getAudio(file), volume); + if (audio.volume === 0) return; audio.play(); } diff --git a/packages/frontend/src/scripts/theme-editor.ts b/packages/frontend/src/scripts/theme-editor.ts index 944875ff15..001d87381c 100644 --- a/packages/frontend/src/scripts/theme-editor.ts +++ b/packages/frontend/src/scripts/theme-editor.ts @@ -35,7 +35,7 @@ export const fromThemeString = (str?: string) : ThemeValue => { } else if (str.startsWith('"')) { return { type: 'css', - value: str.substr(1).trim(), + value: str.substring(1).trim(), }; } else { return str; diff --git a/packages/frontend/src/scripts/theme.ts b/packages/frontend/src/scripts/theme.ts index f2e8253565..bc61256cac 100644 --- a/packages/frontend/src/scripts/theme.ts +++ b/packages/frontend/src/scripts/theme.ts @@ -98,7 +98,7 @@ function compile(theme: Theme): Record<string, string> { function getColor(val: string): tinycolor.Instance { // ref (prop) if (val[0] === '@') { - return getColor(theme.props[val.substr(1)]); + return getColor(theme.props[val.substring(1)]); } // ref (const) @@ -109,7 +109,7 @@ function compile(theme: Theme): Record<string, string> { // func else if (val[0] === ':') { const parts = val.split('<'); - const func = parts.shift().substr(1); + const func = parts.shift().substring(1); const arg = parseFloat(parts.shift()); const color = getColor(parts.join('<')); diff --git a/packages/frontend/src/scripts/upload/compress-config.ts b/packages/frontend/src/scripts/upload/compress-config.ts index 793c78ad20..55d469c5e4 100644 --- a/packages/frontend/src/scripts/upload/compress-config.ts +++ b/packages/frontend/src/scripts/upload/compress-config.ts @@ -1,7 +1,15 @@ import isAnimated from 'is-file-animated'; +import { isWebpSupported } from './isWebpSupported'; import type { BrowserImageResizerConfig } from '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' }, @@ -9,7 +17,7 @@ const compressTypeMap = { } as const; export async function getCompressionConfig(file: File): Promise<BrowserImageResizerConfig | undefined> { - const imgConfig = compressTypeMap[file.type]; + const imgConfig = (isWebpSupported() ? compressTypeMap : compressTypeMapFallback)[file.type]; if (!imgConfig || await isAnimated(file)) { return; } diff --git a/packages/frontend/src/scripts/upload/isWebpSupported.ts b/packages/frontend/src/scripts/upload/isWebpSupported.ts new file mode 100644 index 0000000000..cde8b9d785 --- /dev/null +++ b/packages/frontend/src/scripts/upload/isWebpSupported.ts @@ -0,0 +1,10 @@ +let isWebpSupportedCache: boolean | undefined; +export function isWebpSupported() { + if (isWebpSupportedCache === undefined) { + const canvas = document.createElement('canvas'); + canvas.width = 1; + canvas.height = 1; + isWebpSupportedCache = canvas.toDataURL('image/webp').startsWith('data:image/webp'); + } + return isWebpSupportedCache; +} diff --git a/packages/frontend/src/scripts/url.ts b/packages/frontend/src/scripts/url.ts index b6a997449a..07737d6228 100644 --- a/packages/frontend/src/scripts/url.ts +++ b/packages/frontend/src/scripts/url.ts @@ -2,7 +2,7 @@ * 1. 配列に何も入っていない時はクエリを付けない * 2. プロパティがundefinedの時はクエリを付けない * (new URLSearchParams(obj)ではそこまで丁寧なことをしてくれない) - */ + */ export function query(obj: Record<string, any>): string { const params = Object.entries(obj) .filter(([, v]) => Array.isArray(v) ? v.length : v !== undefined) diff --git a/packages/frontend/src/scripts/use-note-capture.ts b/packages/frontend/src/scripts/use-note-capture.ts index 22a01e066a..d057386b13 100644 --- a/packages/frontend/src/scripts/use-note-capture.ts +++ b/packages/frontend/src/scripts/use-note-capture.ts @@ -93,12 +93,12 @@ export function useNoteCapture(props: { function onStreamConnected() { capture(false); } - + capture(true); if (connection) { connection.on('_connected_', onStreamConnected); } - + onUnmounted(() => { decapture(true); if (connection) { diff --git a/packages/frontend/src/style.scss b/packages/frontend/src/style.scss index b376e4c42d..bd74db7c85 100644 --- a/packages/frontend/src/style.scss +++ b/packages/frontend/src/style.scss @@ -7,7 +7,7 @@ --margin: var(--marginFull); --minBottomSpacing: 0px; - + @media (max-width: 500px) { --margin: var(--marginHalf); --minBottomSpacing: calc(72px + max(12px, env(safe-area-inset-bottom, 0px))); @@ -19,7 +19,7 @@ ::selection { color: var(--fgOnAccent); background-color: var(--accent); -} +} html { background-color: var(--bg); @@ -72,7 +72,7 @@ html { } &.useSystemFont { - font-family: 'Hiragino Maru Gothic Pro', sans-serif; + font-family: system-ui; } } diff --git a/packages/frontend/src/themes/l-botanical.json5 b/packages/frontend/src/themes/l-botanical.json5 index 5c98927896..17e9ca246f 100644 --- a/packages/frontend/src/themes/l-botanical.json5 +++ b/packages/frontend/src/themes/l-botanical.json5 @@ -5,7 +5,7 @@ author: 'ThinaticSystem', base: 'light', - + props: { accent: '#77b58c', bg: 'e2deda', diff --git a/packages/frontend/src/ui/_common_/common.ts b/packages/frontend/src/ui/_common_/common.ts index 53042a4ce7..aa2f7b9c55 100644 --- a/packages/frontend/src/ui/_common_/common.ts +++ b/packages/frontend/src/ui/_common_/common.ts @@ -33,7 +33,12 @@ export function openInstanceMenu(ev: MouseEvent) { text: i18n.ts.ads, icon: 'ti ti-ad', to: '/ads', - }, { + }, ($i && ($i.isAdmin || $i.policies.canInvite) && instance.disableRegistration) ? { + type: 'link', + to: '/invite', + text: i18n.ts.invite, + icon: 'ti ti-user-plus', + } : undefined, { type: 'parent', text: i18n.ts.tools, icon: 'ti ti-tool', @@ -52,23 +57,7 @@ export function openInstanceMenu(ev: MouseEvent) { to: '/clicker', text: '🍪👈', icon: 'ti ti-cookie', - }, ($i && ($i.isAdmin || $i.policies.canInvite) && instance.disableRegistration) ? { - text: i18n.ts.invite, - icon: 'ti ti-user-plus', - action: () => { - os.api('invite').then(x => { - os.alert({ - type: 'info', - text: x.code, - }); - }).catch(err => { - os.alert({ - type: 'error', - text: err, - }); - }); - }, - } : undefined, ($i && ($i.isAdmin || $i.policies.canManageCustomEmojis)) ? { + }, ($i && ($i.isAdmin || $i.policies.canManageCustomEmojis)) ? { type: 'link', to: '/custom-emojis-manager', text: i18n.ts.manageCustomEmojis, diff --git a/packages/frontend/src/ui/classic.header.vue b/packages/frontend/src/ui/classic.header.vue index 747d4edcb4..2c41e7759b 100644 --- a/packages/frontend/src/ui/classic.header.vue +++ b/packages/frontend/src/ui/classic.header.vue @@ -82,7 +82,7 @@ function openAccountMenu(ev: MouseEvent) { onMounted(() => { window.addEventListener('resize', () => { settingsWindowed = (window.innerWidth >= WINDOW_THRESHOLD); - }, { passive: true }); + }, { passive: true }); }); </script> @@ -177,7 +177,7 @@ onMounted(() => { > .post { display: inline-block; - + > .button { width: 40px; height: 40px; diff --git a/packages/frontend/src/ui/classic.vue b/packages/frontend/src/ui/classic.vue index d50f2b0454..2c58c29840 100644 --- a/packages/frontend/src/ui/classic.vue +++ b/packages/frontend/src/ui/classic.vue @@ -219,7 +219,7 @@ onMounted(() => { &.fullView { margin: 0; - + > .sidebar { display: none; } diff --git a/packages/frontend/src/ui/deck.vue b/packages/frontend/src/ui/deck.vue index bd5d5beb84..988fda1c2f 100644 --- a/packages/frontend/src/ui/deck.vue +++ b/packages/frontend/src/ui/deck.vue @@ -4,12 +4,13 @@ <div :class="$style.main"> <XStatusBars/> - <div ref="columnsEl" :class="[$style.sections, { [$style.center]: deckStore.reactiveState.columnAlign.value === 'center', [$style.snapScroll]: snapScroll }]" @contextmenu.self.prevent="onContextmenu"> + <div ref="columnsEl" :class="[$style.sections, { [$style.center]: deckStore.reactiveState.columnAlign.value === 'center', [$style.snapScroll]: snapScroll }]" @contextmenu.self.prevent="onContextmenu" @wheel.self="onWheel"> <!-- sectionを利用しているのは、deck.vue側でcolumnに対してfirst-of-typeを効かせるため --> <section v-for="ids in layout" :class="$style.section" :style="columns.filter(c => ids.includes(c.id)).some(c => c.flexible) ? { flex: 1, minWidth: '350px' } : { width: Math.max(...columns.filter(c => ids.includes(c.id)).map(c => c.width)) + 'px' }" + @wheel.self="onWheel" > <component :is="columnComponents[columns.find(c => c.id === id)!.type] ?? XTlColumn" @@ -19,6 +20,7 @@ :class="$style.column" :column="columns.find(c => c.id === id)" :isStacked="ids.length > 1" + @headerWheel="onWheel" /> </section> <div v-if="layout.length === 0" class="_panel" :class="$style.onboarding"> @@ -196,15 +198,14 @@ const onContextmenu = (ev) => { }], ev); }; -document.documentElement.style.overflowY = 'hidden'; -document.documentElement.style.scrollBehavior = 'auto'; -window.addEventListener('wheel', (ev) => { - if (ev.target === columnsEl && ev.deltaX === 0) { - columnsEl.scrollLeft += ev.deltaY; - } else if (getScrollContainer(ev.target as HTMLElement) == null && ev.deltaX === 0) { +function onWheel(ev: WheelEvent) { + if (ev.deltaX === 0) { columnsEl.scrollLeft += ev.deltaY; } -}); +} + +document.documentElement.style.overflowY = 'hidden'; +document.documentElement.style.scrollBehavior = 'auto'; loadDeck(); diff --git a/packages/frontend/src/ui/deck/antenna-column.vue b/packages/frontend/src/ui/deck/antenna-column.vue index d21a9cc580..a1ca32724f 100644 --- a/packages/frontend/src/ui/deck/antenna-column.vue +++ b/packages/frontend/src/ui/deck/antenna-column.vue @@ -44,11 +44,22 @@ async function setAntenna() { }); } -const menu = [{ - icon: 'ti ti-pencil', - text: i18n.ts.selectAntenna, - action: setAntenna, -}]; +function editAntenna() { + os.pageWindow('my/antennas/' + props.column.antennaId); +} + +const menu = [ + { + icon: 'ti ti-pencil', + text: i18n.ts.selectAntenna, + action: setAntenna, + }, + { + icon: 'ti ti-settings', + text: i18n.ts.editAntenna, + action: editAntenna, + }, +]; /* function focus() { diff --git a/packages/frontend/src/ui/deck/column.vue b/packages/frontend/src/ui/deck/column.vue index c8d6744a37..f6c5c8de46 100644 --- a/packages/frontend/src/ui/deck/column.vue +++ b/packages/frontend/src/ui/deck/column.vue @@ -12,6 +12,7 @@ @dragstart="onDragstart" @dragend="onDragend" @contextmenu.prevent.stop="onContextmenu" + @wheel="emit('headerWheel', $event)" > <svg viewBox="0 0 256 128" :class="$style.tabShape"> <g transform="matrix(6.2431,0,0,6.2431,-677.417,-29.3839)"> @@ -56,6 +57,10 @@ const props = withDefaults(defineProps<{ naked: false, }); +const emit = defineEmits<{ + (ev: 'headerWheel', ctx: WheelEvent): void; +}>(); + let body = $shallowRef<HTMLDivElement | null>(); let dragging = $ref(false); diff --git a/packages/frontend/src/ui/deck/deck-store.ts b/packages/frontend/src/ui/deck/deck-store.ts index a6784e9849..4601207858 100644 --- a/packages/frontend/src/ui/deck/deck-store.ts +++ b/packages/frontend/src/ui/deck/deck-store.ts @@ -52,6 +52,10 @@ export const deckStore = markRaw(new Storage('deck', { where: 'deviceAccount', default: true, }, + useSimpleUiForNonRootPages: { + where: 'deviceAccount', + default: true, + }, })); export const loadDeck = async () => { diff --git a/packages/frontend/src/ui/deck/list-column.vue b/packages/frontend/src/ui/deck/list-column.vue index f36dc6151c..3d6256c4fd 100644 --- a/packages/frontend/src/ui/deck/list-column.vue +++ b/packages/frontend/src/ui/deck/list-column.vue @@ -42,9 +42,20 @@ async function setList() { }); } -const menu = [{ - icon: 'ti ti-pencil', - text: i18n.ts.selectList, - action: setList, -}]; +function editList() { + os.pageWindow('my/lists/' + props.column.listId); +} + +const menu = [ + { + icon: 'ti ti-pencil', + text: i18n.ts.selectList, + action: setList, + }, + { + icon: 'ti ti-settings', + text: i18n.ts.editList, + action: editList, + }, +]; </script> diff --git a/packages/frontend/src/ui/deck/main-column.vue b/packages/frontend/src/ui/deck/main-column.vue index 169fac70a2..0413307955 100644 --- a/packages/frontend/src/ui/deck/main-column.vue +++ b/packages/frontend/src/ui/deck/main-column.vue @@ -7,24 +7,29 @@ </template> </template> - <RouterView @contextmenu.stop="onContextmenu"/> + <div ref="contents"> + <RouterView @contextmenu.stop="onContextmenu"/> + </div> </XColumn> </template> <script lang="ts" setup> -import { ComputedRef, provide } from 'vue'; +import { ComputedRef, provide, shallowRef } from 'vue'; import XColumn from './column.vue'; import { deckStore, Column } from '@/ui/deck/deck-store'; import * as os from '@/os'; import { i18n } from '@/i18n'; import { mainRouter } from '@/router'; import { PageMetadata, provideMetadataReceiver } from '@/scripts/page-metadata'; +import { useScrollPositionManager } from '@/nirax'; +import { getScrollContainer } from '@/scripts/scroll'; defineProps<{ column: Column; isStacked: boolean; }>(); +const contents = shallowRef<HTMLElement>(); let pageMetadata = $ref<null | ComputedRef<PageMetadata>>(); provide('router', mainRouter); @@ -61,4 +66,6 @@ function onContextmenu(ev: MouseEvent) { }, }], ev); } + +useScrollPositionManager(() => getScrollContainer(contents.value), mainRouter); </script> diff --git a/packages/frontend/src/ui/minimum.vue b/packages/frontend/src/ui/minimum.vue index e656f00bb2..baba9e4da5 100644 --- a/packages/frontend/src/ui/minimum.vue +++ b/packages/frontend/src/ui/minimum.vue @@ -1,6 +1,8 @@ <template> -<div :class="$style.root" style="container-type: inline-size;"> - <RouterView/> +<div :class="$style.root"> + <div style="container-type: inline-size;"> + <RouterView/> + </div> <XCommon/> </div> diff --git a/packages/frontend/src/ui/universal.vue b/packages/frontend/src/ui/universal.vue index 8abb20300f..9ae43c39d3 100644 --- a/packages/frontend/src/ui/universal.vue +++ b/packages/frontend/src/ui/universal.vue @@ -95,6 +95,7 @@ import { PageMetadata, provideMetadataReceiver } from '@/scripts/page-metadata'; import { deviceKind } from '@/scripts/device-kind'; import { miLocalStorage } from '@/local-storage'; import { CURRENT_STICKY_BOTTOM } from '@/const'; +import { useScrollPositionManager } from '@/nirax'; const XWidgets = defineAsyncComponent(() => import('./universal.widgets.vue')); const XSidebar = defineAsyncComponent(() => import('@/ui/_common_/navbar.vue')); @@ -213,6 +214,8 @@ watch($$(navFooter), () => { }, { immediate: true, }); + +useScrollPositionManager(() => contents.value.rootEl, mainRouter); </script> <style> diff --git a/packages/frontend/src/ui/zen.vue b/packages/frontend/src/ui/zen.vue index d516a5df75..6c8a986411 100644 --- a/packages/frontend/src/ui/zen.vue +++ b/packages/frontend/src/ui/zen.vue @@ -1,6 +1,8 @@ <template> -<div :class="showBottom ? $style.rootWithBottom : $style.root" style="container-type: inline-size;"> - <RouterView/> +<div :class="showBottom ? $style.rootWithBottom : $style.root"> + <div style="container-type: inline-size;"> + <RouterView/> + </div> <XCommon/> </div> diff --git a/packages/frontend/src/widgets/WidgetAiscriptApp.vue b/packages/frontend/src/widgets/WidgetAiscriptApp.vue index 3b67972e40..94d2d922d2 100644 --- a/packages/frontend/src/widgets/WidgetAiscriptApp.vue +++ b/packages/frontend/src/widgets/WidgetAiscriptApp.vue @@ -37,7 +37,7 @@ type WidgetProps = GetFormResultType<typeof widgetPropsDef>; const props = defineProps<WidgetComponentProps<WidgetProps>>(); const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); - + const { widgetProps, configure } = useWidgetPropsManager(name, widgetPropsDef, props, diff --git a/packages/frontend/src/widgets/WidgetClicker.vue b/packages/frontend/src/widgets/WidgetClicker.vue index b7be2e8c83..6a997f7da8 100644 --- a/packages/frontend/src/widgets/WidgetClicker.vue +++ b/packages/frontend/src/widgets/WidgetClicker.vue @@ -25,7 +25,7 @@ type WidgetProps = GetFormResultType<typeof widgetPropsDef>; const props = defineProps<WidgetComponentProps<WidgetProps>>(); const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); - + const { widgetProps, configure } = useWidgetPropsManager(name, widgetPropsDef, props, diff --git a/packages/frontend/src/widgets/WidgetJobQueue.vue b/packages/frontend/src/widgets/WidgetJobQueue.vue index 3c8ffdb55a..36706c37e4 100644 --- a/packages/frontend/src/widgets/WidgetJobQueue.vue +++ b/packages/frontend/src/widgets/WidgetJobQueue.vue @@ -124,7 +124,7 @@ connection.on('stats', onStats); connection.on('statsLog', onStatsLog); connection.send('requestLog', { - id: Math.random().toString().substr(2, 8), + id: Math.random().toString().substring(2, 10), length: 1, }); diff --git a/packages/frontend/src/widgets/WidgetNotifications.vue b/packages/frontend/src/widgets/WidgetNotifications.vue index a24aa9b2e9..d4f9a07cb5 100644 --- a/packages/frontend/src/widgets/WidgetNotifications.vue +++ b/packages/frontend/src/widgets/WidgetNotifications.vue @@ -41,7 +41,7 @@ type WidgetProps = GetFormResultType<typeof widgetPropsDef>; const props = defineProps<WidgetComponentProps<WidgetProps>>(); const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); - + const { widgetProps, configure, save } = useWidgetPropsManager(name, widgetPropsDef, props, diff --git a/packages/frontend/src/widgets/WidgetOnlineUsers.vue b/packages/frontend/src/widgets/WidgetOnlineUsers.vue index c920c3ca53..7e01eba487 100644 --- a/packages/frontend/src/widgets/WidgetOnlineUsers.vue +++ b/packages/frontend/src/widgets/WidgetOnlineUsers.vue @@ -40,7 +40,7 @@ const { widgetProps, configure } = useWidgetPropsManager(name, const onlineUsersCount = ref(0); const tick = () => { - os.api('get-online-users-count').then(res => { + os.apiGet('get-online-users-count').then(res => { onlineUsersCount.value = res.count; }); }; diff --git a/packages/frontend/src/widgets/WidgetTrends.vue b/packages/frontend/src/widgets/WidgetTrends.vue index 36f908d5ea..5fce4aedca 100644 --- a/packages/frontend/src/widgets/WidgetTrends.vue +++ b/packages/frontend/src/widgets/WidgetTrends.vue @@ -53,7 +53,7 @@ const stats = ref([]); const fetching = ref(true); const fetch = () => { - os.api('hashtags/trend').then(res => { + os.apiGet('hashtags/trend').then(res => { stats.value = res; fetching.value = false; }); diff --git a/packages/frontend/src/widgets/server-metric/cpu-mem.vue b/packages/frontend/src/widgets/server-metric/cpu-mem.vue index 80a8e427e1..b9ba400b4d 100644 --- a/packages/frontend/src/widgets/server-metric/cpu-mem.vue +++ b/packages/frontend/src/widgets/server-metric/cpu-mem.vue @@ -100,7 +100,7 @@ onMounted(() => { props.connection.on('stats', onStats); props.connection.on('statsLog', onStatsLog); props.connection.send('requestLog', { - id: Math.random().toString().substr(2, 8), + id: Math.random().toString().substring(2, 10), }); }); @@ -121,10 +121,10 @@ function onStats(connStats) { cpuPolygonPoints = `${viewBoxX - (stats.length - 1)},${viewBoxY} ${cpuPolylinePoints} ${viewBoxX},${viewBoxY}`; memPolygonPoints = `${viewBoxX - (stats.length - 1)},${viewBoxY} ${memPolylinePoints} ${viewBoxX},${viewBoxY}`; - cpuHeadX = cpuPolylinePointsStats[cpuPolylinePointsStats.length - 1][0]; - cpuHeadY = cpuPolylinePointsStats[cpuPolylinePointsStats.length - 1][1]; - memHeadX = memPolylinePointsStats[memPolylinePointsStats.length - 1][0]; - memHeadY = memPolylinePointsStats[memPolylinePointsStats.length - 1][1]; + cpuHeadX = cpuPolylinePointsStats.at(-1)![0]; + cpuHeadY = cpuPolylinePointsStats.at(-1)![1]; + memHeadX = memPolylinePointsStats.at(-1)![0]; + memHeadY = memPolylinePointsStats.at(-1)![1]; cpuP = (connStats.cpu * 100).toFixed(0); memP = (connStats.mem.active / props.meta.mem.total * 100).toFixed(0); diff --git a/packages/frontend/src/widgets/server-metric/index.vue b/packages/frontend/src/widgets/server-metric/index.vue index e019ff540b..1a78f4bcd6 100644 --- a/packages/frontend/src/widgets/server-metric/index.vue +++ b/packages/frontend/src/widgets/server-metric/index.vue @@ -62,7 +62,7 @@ const { widgetProps, configure, save } = useWidgetPropsManager(name, const meta = ref(null); -os.api('server-info', {}).then(res => { +os.apiGet('server-info', {}).then(res => { meta.value = res; }); diff --git a/packages/frontend/src/widgets/server-metric/net.vue b/packages/frontend/src/widgets/server-metric/net.vue index ab8b0fe471..817a422e63 100644 --- a/packages/frontend/src/widgets/server-metric/net.vue +++ b/packages/frontend/src/widgets/server-metric/net.vue @@ -70,7 +70,7 @@ onMounted(() => { props.connection.on('stats', onStats); props.connection.on('statsLog', onStatsLog); props.connection.send('requestLog', { - id: Math.random().toString().substr(2, 8), + id: Math.random().toString().substring(2, 10), }); }); @@ -94,10 +94,10 @@ function onStats(connStats) { inPolygonPoints = `${viewBoxX - (stats.length - 1)},${viewBoxY} ${inPolylinePoints} ${viewBoxX},${viewBoxY}`; outPolygonPoints = `${viewBoxX - (stats.length - 1)},${viewBoxY} ${outPolylinePoints} ${viewBoxX},${viewBoxY}`; - inHeadX = inPolylinePointsStats[inPolylinePointsStats.length - 1][0]; - inHeadY = inPolylinePointsStats[inPolylinePointsStats.length - 1][1]; - outHeadX = outPolylinePointsStats[outPolylinePointsStats.length - 1][0]; - outHeadY = outPolylinePointsStats[outPolylinePointsStats.length - 1][1]; + inHeadX = inPolylinePointsStats.at(-1)![0]; + inHeadY = inPolylinePointsStats.at(-1)![1]; + outHeadX = outPolylinePointsStats.at(-1)![0]; + outHeadY = outPolylinePointsStats.at(-1)![1]; inRecent = connStats.net.rx; outRecent = connStats.net.tx; diff --git a/packages/frontend/src/widgets/server-metric/pie.vue b/packages/frontend/src/widgets/server-metric/pie.vue index 398815a6ae..8f7471061a 100644 --- a/packages/frontend/src/widgets/server-metric/pie.vue +++ b/packages/frontend/src/widgets/server-metric/pie.vue @@ -15,6 +15,7 @@ :stroke-dashoffset="strokeDashoffset" fill="none" stroke-width="0.1" + :class="$style.circle" :stroke="color" /> <text x="50%" y="50%" dy="0.05" text-anchor="middle" :class="$style.text">{{ (value * 100).toFixed(0) }}%</text> diff --git a/packages/frontend/src/workers/draw-blurhash.ts b/packages/frontend/src/workers/draw-blurhash.ts index 5f2168a44a..e0672d5424 100644 --- a/packages/frontend/src/workers/draw-blurhash.ts +++ b/packages/frontend/src/workers/draw-blurhash.ts @@ -1,5 +1,7 @@ import { render } from 'buraha'; +const canvas = new OffscreenCanvas(64, 64); + onmessage = (event) => { // console.log(event.data); if (!('id' in event.data && typeof event.data.id === 'string')) { @@ -8,8 +10,8 @@ onmessage = (event) => { if (!('hash' in event.data && typeof event.data.hash === 'string')) { return; } - const work = new OffscreenCanvas(event.data.width ?? 64, event.data.height ?? 64); - render(event.data.hash, work); - const bitmap = work.transferToImageBitmap(); + + render(event.data.hash, canvas); + const bitmap = canvas.transferToImageBitmap(); postMessage({ id: event.data.id, bitmap }); }; diff --git a/packages/frontend/src/workers/test-webgl2.ts b/packages/frontend/src/workers/test-webgl2.ts index 4769524d9c..8f13c57cae 100644 --- a/packages/frontend/src/workers/test-webgl2.ts +++ b/packages/frontend/src/workers/test-webgl2.ts @@ -1,5 +1,5 @@ -const canvas = new OffscreenCanvas(1, 1); -const gl = canvas.getContext('webgl2'); +const canvas = globalThis.OffscreenCanvas && new OffscreenCanvas(1, 1); +const gl = canvas?.getContext('webgl2'); if (gl) { postMessage({ result: true }); } else { diff --git a/packages/frontend/test/tsconfig.json b/packages/frontend/test/tsconfig.json index 1424fdbdfb..42372eae7d 100644 --- a/packages/frontend/test/tsconfig.json +++ b/packages/frontend/test/tsconfig.json @@ -9,9 +9,9 @@ "noFallthroughCasesInSwitch": true, "declaration": false, "sourceMap": true, - "target": "es2021", + "target": "ES2022", "module": "es2020", - "moduleResolution": "node", + "moduleResolution": "node16", "allowSyntheticDefaultImports": true, "removeComments": false, "noLib": false, @@ -27,7 +27,7 @@ "@/*": ["../src/*"] }, "typeRoots": [ - "../node_modules/@types", + "../node_modules/@types" ], "lib": [ "esnext", @@ -38,6 +38,6 @@ "compileOnSave": false, "include": [ "./**/*.ts", - "../src/**/*.vue", + "../src/**/*.vue" ] } diff --git a/packages/frontend/tsconfig.json b/packages/frontend/tsconfig.json index 514b304246..1dc5beb1a2 100644 --- a/packages/frontend/tsconfig.json +++ b/packages/frontend/tsconfig.json @@ -9,9 +9,9 @@ "noFallthroughCasesInSwitch": true, "declaration": false, "sourceMap": false, - "target": "es2021", + "target": "ES2022", "module": "esnext", - "moduleResolution": "node", + "moduleResolution": "node16", "removeComments": false, "noLib": false, "strict": true, @@ -23,12 +23,12 @@ "useDefineForClassFields": true, "baseUrl": ".", "paths": { - "@/*": ["./src/*"], + "@/*": ["./src/*"] }, "typeRoots": [ "node_modules/@types", "node_modules/@vue-macros", - "@types", + "@types" ], "types": [ "vite/client", @@ -47,6 +47,6 @@ "./**/*.vue" ], "exclude": [ - ".storybook/**/*", + ".storybook/**/*" ] } |