-
-
-
+
@@ -111,7 +111,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';
@@ -128,7 +128,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';
@@ -222,11 +222,6 @@ onUnmounted(() => {
uploader.dispose();
});
-uploader.events.on('itemUploaded', ctx => {
- files.value.push(ctx.item.uploaded!);
- uploader.removeItem(ctx.item);
-});
-
const draftKey = computed((): string => {
let key = targetChannel.value ? `channel:${targetChannel.value.id}` : '';
@@ -283,26 +278,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');
});
// cannot save pure renote as draft
@@ -466,6 +469,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;
@@ -483,21 +509,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 (targetChannel.value) {
visibility.value = 'public';
@@ -907,6 +997,9 @@ async function post(ev?: MouseEvent) {
}
}
+ // ここからは投稿の中身を触らせない
+ posting.value = true;
+
if (uploader.items.value.some(x => x.uploaded == null)) {
await uploadFiles();
@@ -918,7 +1011,12 @@ async function post(ev?: MouseEvent) {
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: replyTargetNote.value ? replyTargetNote.value.id : undefined,
renoteId: renoteTargetNote.value ? renoteTargetNote.value.id : quoteId.value ? quoteId.value : undefined,
channelId: targetChannel.value ? targetChannel.value.id : undefined,
@@ -957,6 +1055,13 @@ async function post(ev?: MouseEvent) {
}
}
+ // アップロードキャンセルでファイルの総数が変わるなどのコンディション変化が
+ // あるため、canPostを再評価する
+ if (!computePostDataCondition('req')) {
+ posting.value = false;
+ return;
+ }
+
let token: string | undefined = undefined;
if (postAccount.value) {
@@ -973,7 +1078,6 @@ async function post(ev?: MouseEvent) {
}
}
- posting.value = true;
misskeyApi('notes/create', postData, token).then((res) => {
if (props.freezeAfterPosted) {
posted.value = true;
@@ -985,6 +1089,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[];
diff --git a/packages/frontend/src/components/MkPostFormAttaches.vue b/packages/frontend/src/components/MkPostFormAttaches.vue
index f429db94df..e1d79a79e1 100644
--- a/packages/frontend/src/components/MkPostFormAttaches.vue
+++ b/packages/frontend/src/components/MkPostFormAttaches.vue
@@ -5,18 +5,46 @@ SPDX-License-Identifier: AGPL-3.0-only
-
emit('update:modelValue', v)">
+ emit('update:modelValue', v)"
+ >
-
-
+
+
+
+
+
+
+
+
+
@@ -32,11 +60,26 @@ SPDX-License-Identifier: AGPL-3.0-only
+
+
@@ -222,13 +282,16 @@ function showFileMenu(file: Misskey.entities.DriveFile, ev: MouseEvent | Keyboar
width: 64px;
height: 64px;
margin-right: 4px;
- border-radius: 4px;
+ border-radius: 8px;
overflow: hidden;
- cursor: move;
&:focus-visible {
outline-offset: 4px;
}
+
+ &.dragEnabled {
+ cursor: move;
+ }
}
.thumbnail {
@@ -238,6 +301,24 @@ function showFileMenu(file: Misskey.entities.DriveFile, ev: MouseEvent | Keyboar
color: var(--MI_THEME-fg);
}
+.uploaderThumbnail {
+ object-fit: cover;
+ object-position: center;
+}
+
+.uploaderThumbnailIcon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.icon {
+ pointer-events: none;
+ margin: auto;
+ font-size: 32px;
+ color: #777;
+}
+
.sensitive {
display: flex;
position: absolute;
@@ -263,4 +344,89 @@ 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;
+ cursor: pointer;
+}
+
+.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';
+ }
+}