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/src/components | |
| 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/src/components')
48 files changed, 717 insertions, 247 deletions
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]())); |