summaryrefslogtreecommitdiff
path: root/packages/frontend/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'packages/frontend/src/components')
-rw-r--r--packages/frontend/src/components/MkAsUi.vue6
-rw-r--r--packages/frontend/src/components/MkAutocomplete.vue8
-rw-r--r--packages/frontend/src/components/MkAvatars.vue15
-rw-r--r--packages/frontend/src/components/MkContextMenu.vue8
-rw-r--r--packages/frontend/src/components/MkCropperDialog.vue23
-rw-r--r--packages/frontend/src/components/MkDrive.file.vue11
-rw-r--r--packages/frontend/src/components/MkDrive.folder.vue22
-rw-r--r--packages/frontend/src/components/MkDrive.navFolder.vue6
-rw-r--r--packages/frontend/src/components/MkDrive.vue33
-rw-r--r--packages/frontend/src/components/MkFileListForAdmin.vue2
-rw-r--r--packages/frontend/src/components/MkFlashPreview.vue2
-rw-r--r--packages/frontend/src/components/MkImgWithBlurhash.vue55
-rw-r--r--packages/frontend/src/components/MkInviteCode.stories.impl.ts60
-rw-r--r--packages/frontend/src/components/MkInviteCode.vue124
-rw-r--r--packages/frontend/src/components/MkLink.vue2
-rw-r--r--packages/frontend/src/components/MkMediaImage.vue24
-rw-r--r--packages/frontend/src/components/MkMediaList.vue4
-rw-r--r--packages/frontend/src/components/MkMediaVideo.vue4
-rw-r--r--packages/frontend/src/components/MkMiniChart.vue4
-rw-r--r--packages/frontend/src/components/MkNote.vue31
-rw-r--r--packages/frontend/src/components/MkNoteDetailed.vue2
-rw-r--r--packages/frontend/src/components/MkPageWindow.vue17
-rw-r--r--packages/frontend/src/components/MkPagination.vue169
-rw-r--r--packages/frontend/src/components/MkPostForm.vue15
-rw-r--r--packages/frontend/src/components/MkPostFormAttaches.vue23
-rw-r--r--packages/frontend/src/components/MkPostFormDialog.vue9
-rw-r--r--packages/frontend/src/components/MkReactionsViewer.reaction.vue2
-rw-r--r--packages/frontend/src/components/MkRetentionLineChart.vue1
-rw-r--r--packages/frontend/src/components/MkSignupDialog.rules.vue56
-rw-r--r--packages/frontend/src/components/MkSparkle.vue4
-rw-r--r--packages/frontend/src/components/MkSubNoteContent.vue30
-rw-r--r--packages/frontend/src/components/MkSuperMenu.vue2
-rw-r--r--packages/frontend/src/components/MkUrlPreview.vue32
-rw-r--r--packages/frontend/src/components/MkUrlPreviewPopup.vue2
-rw-r--r--packages/frontend/src/components/MkUserInfo.vue12
-rw-r--r--packages/frontend/src/components/MkUserPopup.vue9
-rw-r--r--packages/frontend/src/components/MkUserSetupDialog.Follow.stories.impl.ts2
-rw-r--r--packages/frontend/src/components/MkUserSetupDialog.Privacy.stories.impl.ts2
-rw-r--r--packages/frontend/src/components/MkUserSetupDialog.Profile.stories.impl.ts2
-rw-r--r--packages/frontend/src/components/MkUserSetupDialog.User.vue2
-rw-r--r--packages/frontend/src/components/MkUserSetupDialog.stories.impl.ts2
-rw-r--r--packages/frontend/src/components/global/MkA.stories.impl.ts4
-rw-r--r--packages/frontend/src/components/global/MkAd.stories.impl.ts83
-rw-r--r--packages/frontend/src/components/global/MkAvatar.vue3
-rw-r--r--packages/frontend/src/components/global/MkCustomEmoji.vue2
-rw-r--r--packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts4
-rw-r--r--packages/frontend/src/components/global/MkTime.vue25
-rw-r--r--packages/frontend/src/components/global/i18n.ts4
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]()));