-
-
-
+
@@ -110,7 +110,7 @@ import * as Misskey from 'misskey-js';
import insertTextAtCursor from 'insert-text-at-cursor';
import { toASCII } from 'punycode.js';
import { host, url } from '@@/js/config.js';
-import MkUploaderItems from './MkUploaderItems.vue';
+import type { Attach } from './MkPostFormAttaches.vue';
import type { ShallowRef } from 'vue';
import type { PostFormProps } from '@/types/post-form.js';
import type { MenuItem } from '@/types/menu.js';
@@ -127,7 +127,7 @@ import { formatTimeString } from '@/utility/format-time-string.js';
import { Autocomplete } from '@/utility/autocomplete.js';
import * as os from '@/os.js';
import { misskeyApi } from '@/utility/misskey-api.js';
-import { chooseDriveFile } from '@/utility/drive.js';
+import { chooseDriveFile, chooseFileFromUrl } from '@/utility/drive.js';
import { store } from '@/store.js';
import MkInfo from '@/components/MkInfo.vue';
import { i18n } from '@/i18n.js';
@@ -213,11 +213,6 @@ const uploader = useUploader({
multiple: true,
});
-uploader.events.on('itemUploaded', ctx => {
- files.value.push(ctx.item.uploaded!);
- uploader.removeItem(ctx.item);
-});
-
const draftKey = computed((): string => {
let key = props.channel ? `channel:${props.channel.id}` : '';
@@ -274,26 +269,34 @@ const cwTextLength = computed((): number => {
const maxCwTextLength = 100;
+/**
+ * Computes whether the post data meets the required conditions for submission or enabling the post button.
+ *
+ * @param cond - Specifies the context in which the condition is being checked ('button' for UI button state, 'req' for request validation).
+ */
+function computePostDataCondition(cond: 'button' | 'req') {
+ return !uploader.uploading.value && (
+ 1 <= textLength.value ||
+ 1 <= files.value.length ||
+ (cond === 'button' ? (1 <= uploader.items.value.length) : false) ||
+ poll.value != null ||
+ renoteTargetNote.value != null ||
+ quoteId.value != null
+ ) &&
+ (textLength.value <= maxTextLength.value) &&
+ (
+ useCw.value ?
+ (
+ cw.value != null && cw.value.trim() !== '' &&
+ cwTextLength.value <= maxCwTextLength
+ ) : true
+ ) &&
+ (files.value.length <= 16) &&
+ (!poll.value || poll.value.choices.length >= 2);
+}
+
const canPost = computed((): boolean => {
- return !props.mock && !posting.value && !posted.value && !uploader.uploading.value && (uploader.items.value.length === 0 || uploader.readyForUpload.value) &&
- (
- 1 <= textLength.value ||
- 1 <= files.value.length ||
- 1 <= uploader.items.value.length ||
- poll.value != null ||
- renoteTargetNote.value != null ||
- quoteId.value != null
- ) &&
- (textLength.value <= maxTextLength.value) &&
- (
- useCw.value ?
- (
- cw.value != null && cw.value.trim() !== '' &&
- cwTextLength.value <= maxCwTextLength
- ) : true
- ) &&
- (files.value.length <= 16) &&
- (!poll.value || poll.value.choices.length >= 2);
+ return !props.mock && !posting.value && !posted.value && !uploader.uploading.value && (uploader.items.value.length === 0 || uploader.readyForUpload.value) && computePostDataCondition('button');
});
const withHashtags = computed(store.makeGetterSetter('postFormWithHashtags'));
@@ -452,6 +455,29 @@ function focus() {
}
}
+function chooseFileFrom(ev: MouseEvent) {
+ const anchorElement = ev.currentTarget ?? ev.target;
+ os.popupMenu([{
+ text: i18n.ts.attachFile,
+ type: 'label',
+ }, {
+ text: i18n.ts.upload,
+ icon: 'ti ti-upload',
+ action: () => chooseFileFromPc(ev),
+ }, {
+ text: i18n.ts.fromDrive,
+ icon: 'ti ti-cloud',
+ action: () => chooseFileFromDrive(ev),
+ }, {
+ text: i18n.ts.fromUrl,
+ icon: 'ti ti-link',
+ action: () => chooseFileFromUrl().then(file => {
+ attachOrder.set(file.id, files.value.length);
+ files.value.push(file);
+ }),
+ }], anchorElement);
+}
+
function chooseFileFromPc(ev: MouseEvent) {
if (props.mock) return;
@@ -469,21 +495,85 @@ function chooseFileFromDrive(ev: MouseEvent) {
});
}
-function detachFile(id) {
- files.value = files.value.filter(x => x.id !== id);
+uploader.events.on('itemUploaded', ({ item }) => {
+ if (!item.uploaded) return;
+ const attachesOrder = attaches.value.findIndex(f => f.id === item.id);
+ const attachOrderOrder = attachOrder.get(item.id);
+ if (attachesOrder >= 0 || attachOrderOrder != null) {
+ const index = attachesOrder >= 0 ? attachOrderOrder ?? attachesOrder : attachOrderOrder ?? attaches.value.length;
+ attachOrder.delete(item.id);
+ attachOrder.set(item.uploaded.id, index);
+ }
+ files.value.push(item.uploaded);
+ uploader.removeItem(item);
+});
+
+function detachAttaches(id: string) {
+ const attach = attaches.value.find(a => a.id === id);
+ if (!attach) return;
+ attachOrder.delete(attach.id);
+ if (attach.type === 'driveFile') {
+ files.value = files.value.filter(f => f.id !== attach.id);
+ } else if (attach.type === 'uploaderItem') {
+ uploader.removeItem(attach.file);
+ }
}
-function updateFileSensitive(file, sensitive) {
+function handleUploaderItemAbort(id: string) {
+ if (props.mock) return;
+ const item = uploader.items.value.find(i => i.id === id);
+ if (!item) return;
+ if (posting.value && attaches.value.length > 1) {
+ // このアップロードが止まってもファイルがなくならない場合はアイテムを削除して投稿を続行
+ detachAttaches(id);
+ }
+}
+
+function updateFileSensitive(file: Misskey.entities.DriveFile, sensitive: boolean) {
if (props.mock) {
emit('fileChangeSensitive', file.id, sensitive);
}
files.value[files.value.findIndex(x => x.id === file.id)].isSensitive = sensitive;
}
-function updateFileName(file, name) {
+function updateFileName(file: Misskey.entities.DriveFile, name: string) {
files.value[files.value.findIndex(x => x.id === file.id)].name = name;
}
+const attachOrder = new Map
();
+
+const attaches = computed({
+ get: () => {
+ const _attaches = [
+ ...files.value.map(f => ({ id: f.id, type: 'driveFile' as const, file: f })),
+ ...uploader.items.value.filter(i => i.uploaded == null).map(i => ({ id: i.id, type: 'uploaderItem' as const, file: i })),
+ ];
+ _attaches.forEach((a, i) => {
+ if (!attachOrder.has(a.id)) {
+ attachOrder.set(a.id, i);
+ }
+ });
+ return _attaches.sort((a, b) => {
+ const aOrder = attachOrder.get(a.id) ?? 0;
+ const bOrder = attachOrder.get(b.id) ?? 0;
+ return aOrder - bOrder;
+ });
+ },
+ set: (newAttaches: Attach[]) => {
+ attachOrder.clear();
+ newAttaches.forEach((a, i) => {
+ attachOrder.set(a.id, i);
+ });
+ files.value = newAttaches.filter(a => a.type === 'driveFile').map(a => a.file);
+ uploader.items.value = newAttaches.filter(a => a.type === 'uploaderItem').map(a => a.file);
+ },
+});
+
+function handleShowUploaderMenu(item: UploaderItem, ev: MouseEvent | KeyboardEvent) {
+ if (props.mock) return;
+ os.popupMenu(uploader.getMenu(item), ev.currentTarget ?? ev.target);
+}
+
function setVisibility() {
if (props.channel) {
visibility.value = 'public';
@@ -875,13 +965,21 @@ async function post(ev?: MouseEvent) {
}
}
+ // ここからは投稿の中身を触らせない
+ posting.value = true;
+
if (uploader.items.value.some(x => x.uploaded == null)) {
await uploadFiles();
}
let postData = {
text: text.value === '' ? null : text.value,
- fileIds: files.value.length > 0 ? files.value.map(f => f.id) : undefined,
+ fileIds: files.value.length > 0 ? files.value.map(f => f.id).sort((a, b) => {
+ // itemUploadedイベントではfilesの順番を入れ替えないため、ここでもソートして確実に順番を整える
+ const aOrder = attachOrder.get(a) ?? 0;
+ const bOrder = attachOrder.get(b) ?? 0;
+ return aOrder - bOrder;
+ }) : undefined,
replyId: props.reply ? props.reply.id : undefined,
renoteId: renoteTargetNote.value ? renoteTargetNote.value.id : quoteId.value ? quoteId.value : undefined,
channelId: props.channel ? props.channel.id : undefined,
@@ -920,6 +1018,13 @@ async function post(ev?: MouseEvent) {
}
}
+ // アップロードキャンセルでファイルの総数が変わるなどのコンディション変化が
+ // あるため、canPostを再評価する
+ if (!computePostDataCondition('req')) {
+ posting.value = false;
+ return;
+ }
+
let token: string | undefined = undefined;
if (postAccount.value) {
@@ -927,7 +1032,6 @@ async function post(ev?: MouseEvent) {
token = storedAccounts.find(x => x.id === postAccount.value?.id)?.token;
}
- posting.value = true;
misskeyApi('notes/create', postData, token).then((res) => {
if (props.freezeAfterPosted) {
posted.value = true;
diff --git a/packages/frontend/src/components/MkPostFormAttaches.vue b/packages/frontend/src/components/MkPostFormAttaches.vue
index f429db94df..7cd26faa2d 100644
--- a/packages/frontend/src/components/MkPostFormAttaches.vue
+++ b/packages/frontend/src/components/MkPostFormAttaches.vue
@@ -5,18 +5,49 @@ SPDX-License-Identifier: AGPL-3.0-only
-
emit('update:modelValue', v)">
+ emit('update:modelValue', v)"
+ >
-
-
+
+
+
+
+
+
+
+
+
+
+
+
@@ -32,11 +63,26 @@ SPDX-License-Identifier: AGPL-3.0-only
+
+
@@ -238,6 +301,26 @@ function showFileMenu(file: Misskey.entities.DriveFile, ev: MouseEvent | Keyboar
color: var(--MI_THEME-fg);
}
+.uploaderThumbnail {
+ object-fit: cover;
+ object-position: center;
+ border-radius: 8px;
+}
+
+.uploaderThumbnailIcon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 8px;
+}
+
+.icon {
+ pointer-events: none;
+ margin: auto;
+ font-size: 32px;
+ color: #777;
+}
+
.sensitive {
display: flex;
position: absolute;
@@ -263,4 +346,88 @@ function showFileMenu(file: Misskey.entities.DriveFile, ev: MouseEvent | Keyboar
color: var(--MI_THEME-error);
}
}
+
+.uploadProgressWrapper {
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ z-index: 1;
+
+ &::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: rgba(0, 0, 0, 0.75);
+ mask-image: linear-gradient(#000, #000), url("data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgMTAwIDEwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4gPGNpcmNsZSBjeD0iNTAiIGN5PSI1MCIgcj0iNTAiIC8+PC9zdmc+");
+ mask-position: center;
+ mask-repeat: no-repeat;
+ mask-size: 100% 100%, 90px 90px;
+ mask-composite: exclude;
+ transition: mask-size 0.2s ease;
+ }
+}
+
+.uploadProgressSvg {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ width: 32px;
+ height: 32px;
+ transform: translate(-50%, -50%);
+ pointer-events: none;
+ opacity: 0;
+ transition: opacity 0.2s ease;
+}
+
+.uploadProgressFg {
+ fill: none;
+ stroke-width: 32;
+ stroke: rgba(0, 0, 0, 0.75);
+ stroke-dashoffset: 25;
+ transition: stroke-dasharray 0.2s ease;
+}
+
+.uploadAbortButton {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ width: 32px;
+ height: 32px;
+ border-radius: 50%;
+ transform: translate(-50%, -50%);
+ font-size: 16px;
+ line-height: 32px;
+ text-align: center;
+ background-color: rgba(0, 0, 0, 0.75);
+ color: #fff;
+ opacity: 0;
+ transition: opacity 0.2s ease;
+}
+
+.uploadProgressWrapper:global(.uploading) {
+ backdrop-filter: brightness(1.5);
+
+ &::before {
+ mask-size: 100% 100%, 36px 36px;
+ }
+
+ .uploadProgressSvg {
+ opacity: 1;
+ }
+}
+
+.file:hover .uploadProgressWrapper:global(.uploading) {
+ .uploadProgressSvg {
+ opacity: 0;
+ }
+
+ .uploadAbortButton {
+ opacity: 1;
+ }
+}
diff --git a/packages/frontend/src/utility/file-type.ts b/packages/frontend/src/utility/file-type.ts
new file mode 100644
index 0000000000..cb694f7ea8
--- /dev/null
+++ b/packages/frontend/src/utility/file-type.ts
@@ -0,0 +1,57 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and other misskey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export type DetectableFileType =
+ | 'image'
+ | 'video'
+ | 'midi'
+ | 'audio'
+ | 'csv'
+ | 'pdf'
+ | 'textfile'
+ | 'archive'
+ | 'unknown';
+
+export function getFileType(type: string): DetectableFileType {
+ if (type.startsWith('image/')) return 'image';
+ if (type.startsWith('video/')) return 'video';
+ if (type === 'audio/midi') return 'midi';
+ if (type.startsWith('audio/')) return 'audio';
+ if (type.endsWith('/csv')) return 'csv';
+ if (type.endsWith('/pdf')) return 'pdf';
+ if (type.startsWith('text/')) return 'textfile';
+ if ([
+ 'application/zip',
+ 'application/x-cpio',
+ 'application/x-bzip',
+ 'application/x-bzip2',
+ 'application/java-archive',
+ 'application/x-rar-compressed',
+ 'application/x-tar',
+ 'application/gzip',
+ 'application/x-7z-compressed',
+ ].some(archiveType => archiveType === type)) return 'archive';
+ return 'unknown';
+}
+
+export function getFileTypeIcon(type: DetectableFileType) {
+ switch (type) {
+ case 'image': return 'ti ti-photo';
+ case 'video': return 'ti ti-video';
+
+ case 'audio':
+ case 'midi':
+ return 'ti ti-file-music';
+
+ case 'csv':
+ case 'pdf':
+ case 'textfile':
+ return 'ti ti-file-text';
+
+ case 'archive': return 'ti ti-file-zip';
+
+ default: return 'ti ti-file';
+ }
+}
From 5963e8c48449eccb37794fa56ac8d5ca7f49e2cb Mon Sep 17 00:00:00 2001
From: kakkokari-gtyih <67428053+kakkokari-gtyih@users.noreply.github.com>
Date: Sat, 7 Jun 2025 16:31:00 +0900
Subject: [PATCH 2/5] :art:
---
packages/frontend/src/components/MkPostFormAttaches.vue | 4 +---
1 file changed, 1 insertion(+), 3 deletions(-)
diff --git a/packages/frontend/src/components/MkPostFormAttaches.vue b/packages/frontend/src/components/MkPostFormAttaches.vue
index 7cd26faa2d..68e7d5398d 100644
--- a/packages/frontend/src/components/MkPostFormAttaches.vue
+++ b/packages/frontend/src/components/MkPostFormAttaches.vue
@@ -285,7 +285,7 @@ function showFileMenu(attach: Attach, ev: MouseEvent | KeyboardEvent): void {
width: 64px;
height: 64px;
margin-right: 4px;
- border-radius: 4px;
+ border-radius: 8px;
overflow: hidden;
cursor: move;
@@ -304,14 +304,12 @@ function showFileMenu(attach: Attach, ev: MouseEvent | KeyboardEvent): void {
.uploaderThumbnail {
object-fit: cover;
object-position: center;
- border-radius: 8px;
}
.uploaderThumbnailIcon {
display: flex;
align-items: center;
justify-content: center;
- border-radius: 8px;
}
.icon {
From 6e3494a3898f2ca6104b7e2de2e5c48a7a9cadd5 Mon Sep 17 00:00:00 2001
From: kakkokari-gtyih <67428053+kakkokari-gtyih@users.noreply.github.com>
Date: Sat, 7 Jun 2025 16:39:29 +0900
Subject: [PATCH 3/5] fix: better cursor handling
---
.../frontend/src/components/MkPostFormAttaches.vue | 10 +++++++---
1 file changed, 7 insertions(+), 3 deletions(-)
diff --git a/packages/frontend/src/components/MkPostFormAttaches.vue b/packages/frontend/src/components/MkPostFormAttaches.vue
index 68e7d5398d..cafa3d53c4 100644
--- a/packages/frontend/src/components/MkPostFormAttaches.vue
+++ b/packages/frontend/src/components/MkPostFormAttaches.vue
@@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only
>
-
+
@@ -287,11 +287,14 @@ function showFileMenu(attach: Attach, ev: MouseEvent | KeyboardEvent): void {
margin-right: 4px;
border-radius: 8px;
overflow: hidden;
- cursor: move;
&:focus-visible {
outline-offset: 4px;
}
+
+ &.dragEnabled {
+ cursor: move;
+ }
}
.thumbnail {
@@ -405,6 +408,7 @@ function showFileMenu(attach: Attach, ev: MouseEvent | KeyboardEvent): void {
color: #fff;
opacity: 0;
transition: opacity 0.2s ease;
+ cursor: pointer;
}
.uploadProgressWrapper:global(.uploading) {
From 46915d2ae3c67fc30fd937c5916de9ef858f8c05 Mon Sep 17 00:00:00 2001
From: kakkokari-gtyih <67428053+kakkokari-gtyih@users.noreply.github.com>
Date: Sat, 7 Jun 2025 16:43:57 +0900
Subject: [PATCH 4/5] fix: better post-posting handling
---
packages/frontend/src/components/MkPostForm.vue | 1 +
1 file changed, 1 insertion(+)
diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue
index b5f0d25ae5..ed06b62c2e 100644
--- a/packages/frontend/src/components/MkPostForm.vue
+++ b/packages/frontend/src/components/MkPostForm.vue
@@ -1043,6 +1043,7 @@ async function post(ev?: MouseEvent) {
nextTick(() => {
deleteDraft();
+ attachOrder.clear();
emit('posted');
if (postData.text && postData.text !== '') {
const hashtags_ = mfm.parse(postData.text).map(x => x.type === 'hashtag' && x.props.hashtag).filter(x => x) as string[];
From 6d58e2c3e53f35526bc60f5ddecbe33e3d1da18b Mon Sep 17 00:00:00 2001
From: kakkokari-gtyih <67428053+kakkokari-gtyih@users.noreply.github.com>
Date: Sat, 7 Jun 2025 16:46:53 +0900
Subject: [PATCH 5/5] fix: better v-if handling
---
packages/frontend/src/components/MkPostFormAttaches.vue | 7 ++-----
1 file changed, 2 insertions(+), 5 deletions(-)
diff --git a/packages/frontend/src/components/MkPostFormAttaches.vue b/packages/frontend/src/components/MkPostFormAttaches.vue
index cafa3d53c4..e1d79a79e1 100644
--- a/packages/frontend/src/components/MkPostFormAttaches.vue
+++ b/packages/frontend/src/components/MkPostFormAttaches.vue
@@ -25,14 +25,11 @@ SPDX-License-Identifier: AGPL-3.0-only
@contextmenu.prevent="showFileMenu(element, $event)"
>
-
+
-
-
-
-