diff options
| author | tamaina <tamaina@hotmail.co.jp> | 2022-01-21 21:32:50 +0900 |
|---|---|---|
| committer | tamaina <tamaina@hotmail.co.jp> | 2022-01-21 21:32:50 +0900 |
| commit | b5a20494f6032f840475bb69c79ccc2ffac11b7c (patch) | |
| tree | 9da47076c540446cf818f456eca697d423fd5ef4 /packages/client/src | |
| parent | Merge branch 'develop' into pizzax-indexeddb (diff) | |
| parent | chore(client): add #misskey button (diff) | |
| download | misskey-b5a20494f6032f840475bb69c79ccc2ffac11b7c.tar.gz misskey-b5a20494f6032f840475bb69c79ccc2ffac11b7c.tar.bz2 misskey-b5a20494f6032f840475bb69c79ccc2ffac11b7c.zip | |
Merge branch 'develop' into pizzax-indexeddb
Diffstat (limited to 'packages/client/src')
49 files changed, 2029 insertions, 2115 deletions
diff --git a/packages/client/src/account.ts b/packages/client/src/account.ts index 4c83b78c91..5a935e1dc7 100644 --- a/packages/client/src/account.ts +++ b/packages/client/src/account.ts @@ -16,6 +16,8 @@ const data = localStorage.getItem('account'); // TODO: 外部からはreadonlyに export const $i = data ? reactive(JSON.parse(data) as Account) : null; +export const iAmModerator = $i != null && ($i.isAdmin || $i.isModerator); + export async function signout() { waiting(); localStorage.removeItem('account'); @@ -127,7 +129,12 @@ export async function login(token: Account['token'], redirect?: string) { unisonReload(); } -export async function openAccountMenu(ev: MouseEvent) { +export async function openAccountMenu(opts: { + includeCurrentAccount?: boolean; + withExtraOperation: boolean; + active?: misskey.entities.UserDetailed['id']; + onChoose?: (account: misskey.entities.UserDetailed) => void; +}, ev: MouseEvent) { function showSigninDialog() { popup(import('@/components/signin-dialog.vue'), {}, { done: res => { @@ -146,7 +153,7 @@ export async function openAccountMenu(ev: MouseEvent) { }, 'closed'); } - async function switchAccount(account: any) { + async function switchAccount(account: misskey.entities.UserDetailed) { const storedAccounts = await getAccounts(); const token = storedAccounts.find(x => x.id === account.id).token; switchAccountWithToken(token); @@ -159,48 +166,58 @@ export async function openAccountMenu(ev: MouseEvent) { const storedAccounts = await getAccounts().then(accounts => accounts.filter(x => x.id !== $i.id)); const accountsPromise = api('users/show', { userIds: storedAccounts.map(x => x.id) }); + function createItem(account: misskey.entities.UserDetailed) { + return { + type: 'user', + user: account, + active: opts.active != null ? opts.active === account.id : false, + action: () => { + if (opts.onChoose) { + opts.onChoose(account); + } else { + switchAccount(account); + } + }, + }; + } + const accountItemPromises = storedAccounts.map(a => new Promise(res => { accountsPromise.then(accounts => { const account = accounts.find(x => x.id === a.id); if (account == null) return res(null); - res({ - type: 'user', - user: account, - action: () => { switchAccount(account); } - }); + res(createItem(account)); }); })); - popupMenu([...[{ - type: 'link', - text: i18n.locale.profile, - to: `/@${ $i.username }`, - avatar: $i, - }, null, ...accountItemPromises, { - icon: 'fas fa-plus', - text: i18n.locale.addAccount, - action: () => { - popupMenu([{ - text: i18n.locale.existingAccount, - action: () => { showSigninDialog(); }, - }, { - text: i18n.locale.createAccount, - action: () => { createAccount(); }, - }], ev.currentTarget || ev.target); - }, - }, { - type: 'link', - icon: 'fas fa-users', - text: i18n.locale.manageAccounts, - to: `/settings/accounts`, - }]], ev.currentTarget || ev.target, { - align: 'left' - }); -} - -// このファイルに書きたくないけどここに書かないと何故かVeturが認識しない -declare module '@vue/runtime-core' { - interface ComponentCustomProperties { - $i: typeof $i; + if (opts.withExtraOperation) { + popupMenu([...[{ + type: 'link', + text: i18n.locale.profile, + to: `/@${ $i.username }`, + avatar: $i, + }, null, ...(opts.includeCurrentAccount ? [createItem($i)] : []), ...accountItemPromises, { + icon: 'fas fa-plus', + text: i18n.locale.addAccount, + action: () => { + popupMenu([{ + text: i18n.locale.existingAccount, + action: () => { showSigninDialog(); }, + }, { + text: i18n.locale.createAccount, + action: () => { createAccount(); }, + }], ev.currentTarget || ev.target); + }, + }, { + type: 'link', + icon: 'fas fa-users', + text: i18n.locale.manageAccounts, + to: `/settings/accounts`, + }]], ev.currentTarget || ev.target, { + align: 'left' + }); + } else { + popupMenu([...(opts.includeCurrentAccount ? [createItem($i)] : []), ...accountItemPromises], ev.currentTarget || ev.target, { + align: 'left' + }); } } diff --git a/packages/client/src/components/abuse-report.vue b/packages/client/src/components/abuse-report.vue new file mode 100644 index 0000000000..b67cef209b --- /dev/null +++ b/packages/client/src/components/abuse-report.vue @@ -0,0 +1,102 @@ +<template> +<div class="bcekxzvu _card _gap"> + <div class="_content target"> + <MkAvatar class="avatar" :user="report.targetUser" :show-indicator="true"/> + <MkA class="info" :to="userPage(report.targetUser)" v-user-preview="report.targetUserId"> + <MkUserName class="name" :user="report.targetUser"/> + <MkAcct class="acct" :user="report.targetUser" style="display: block;"/> + </MkA> + </div> + <div class="_content"> + <div> + <Mfm :text="report.comment"/> + </div> + <hr/> + <div>{{ $ts.reporter }}: <MkAcct :user="report.reporter"/></div> + <div v-if="report.assignee"> + {{ $ts.moderator }}: + <MkAcct :user="report.assignee"/> + </div> + <div><MkTime :time="report.createdAt"/></div> + </div> + <div class="_footer"> + <MkSwitch v-model="forward" :disabled="report.targetUser.host == null || report.resolved"> + {{ $ts.forwardReport }} + <template #caption>{{ $ts.forwardReportIsAnonymous }}</template> + </MkSwitch> + <MkButton v-if="!report.resolved" primary @click="resolve">{{ $ts.abuseMarkAsResolved }}</MkButton> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; + +import MkButton from '@/components/ui/button.vue'; +import MkSwitch from '@/components/form/switch.vue'; +import { acct, userPage } from '@/filters/user'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + MkButton, + MkSwitch, + }, + + emits: ['resolved'], + + props: { + report: { + type: Object, + required: true, + } + } + + data() { + return { + forward: this.report.forwarded, + }; + } + + methods: { + acct, + userPage, + + resolve() { + os.apiWithDialog('admin/resolve-abuse-user-report', { + forward: this.forward, + reportId: this.report.id, + }).then(() => { + this.$emit('resolved', this.report.id); + }); + } + } +}); +</script> + +<style lang="scss" scoped> +.bcekxzvu { + > .target { + display: flex; + width: 100%; + box-sizing: border-box; + text-align: left; + align-items: center; + + > .avatar { + width: 42px; + height: 42px; + } + + > .info { + margin-left: 0.3em; + padding: 0 8px; + flex: 1; + + > .name { + font-weight: bold; + } + } + } +} +</style> diff --git a/packages/client/src/components/captcha.vue b/packages/client/src/components/captcha.vue index 7fe499dc86..770804cf44 100644 --- a/packages/client/src/components/captcha.vue +++ b/packages/client/src/components/captcha.vue @@ -55,12 +55,10 @@ const variable = computed(() => { const loaded = computed(() => !!window[variable.value]); const src = computed(() => { - const endpoint = ({ - hcaptcha: 'https://hcaptcha.com/1', - recaptcha: 'https://www.recaptcha.net/recaptcha', - } as Record<CaptchaProvider, string>)[props.provider]; - - return `${typeof endpoint === 'string' ? endpoint : 'about:invalid'}/api.js?render=explicit`; + switch (props.provider) { + case 'hcaptcha': return 'https://js.hcaptcha.com/1/api.js?render=explicit&recaptchacompat=off'; + case 'recaptcha': return 'https://www.recaptcha.net/recaptcha/api.js?render=explicit'; + } }); const captcha = computed<Captcha>(() => window[variable.value] || {} as unknown as Captcha); diff --git a/packages/client/src/components/dialog.vue b/packages/client/src/components/dialog.vue index 9cd5234684..b6b649cde9 100644 --- a/packages/client/src/components/dialog.vue +++ b/packages/client/src/components/dialog.vue @@ -1,5 +1,5 @@ <template> -<MkModal ref="modal" :prefer-type="'dialog'" :z-priority="'high'" @click="done(true)" @closed="$emit('closed')"> +<MkModal ref="modal" :prefer-type="'dialog'" :z-priority="'high'" @click="done(true)" @closed="emit('closed')"> <div class="mk-dialog"> <div v-if="icon" class="icon"> <i :class="icon"></i> @@ -28,8 +28,8 @@ </template> </MkSelect> <div v-if="(showOkButton || showCancelButton) && !actions" class="buttons"> - <MkButton v-if="showOkButton" inline primary :autofocus="!input && !select" @click="ok">{{ (showCancelButton || input || select) ? $ts.ok : $ts.gotIt }}</MkButton> - <MkButton v-if="showCancelButton || input || select" inline @click="cancel">{{ $ts.cancel }}</MkButton> + <MkButton v-if="showOkButton" inline primary :autofocus="!input && !select" @click="ok">{{ (showCancelButton || input || select) ? i18n.locale.ok : i18n.locale.gotIt }}</MkButton> + <MkButton v-if="showCancelButton || input || select" inline @click="cancel">{{ i18n.locale.cancel }}</MkButton> </div> <div v-if="actions" class="buttons"> <MkButton v-for="action in actions" :key="action.text" inline :primary="action.primary" @click="() => { action.callback(); close(); }">{{ action.text }}</MkButton> @@ -44,6 +44,7 @@ import MkModal from '@/components/ui/modal.vue'; import MkButton from '@/components/ui/button.vue'; import MkInput from '@/components/form/input.vue'; import MkSelect from '@/components/form/select.vue'; +import { i18n } from '@/i18n'; type Input = { type: HTMLInputElement['type']; diff --git a/packages/client/src/components/drive-file-thumbnail.vue b/packages/client/src/components/drive-file-thumbnail.vue index e94b6b8bcb..81b80e7e8e 100644 --- a/packages/client/src/components/drive-file-thumbnail.vue +++ b/packages/client/src/components/drive-file-thumbnail.vue @@ -14,71 +14,42 @@ </div> </template> -<script lang="ts"> -import { defineComponent } from 'vue'; +<script lang="ts" setup> +import { computed } from 'vue'; +import * as Misskey from 'misskey-js'; import ImgWithBlurhash from '@/components/img-with-blurhash.vue'; -import { ColdDeviceStorage } from '@/store'; -export default defineComponent({ - components: { - ImgWithBlurhash - }, - props: { - file: { - type: Object, - required: true - }, - fit: { - type: String, - required: false, - default: 'cover' - }, - }, - data() { - return { - isContextmenuShowing: false, - isDragging: false, +const props = defineProps<{ + file: Misskey.entities.DriveFile; + fit: string; +}>(); - }; - }, - computed: { - is(): 'image' | 'video' | 'midi' | 'audio' | 'csv' | 'pdf' | 'textfile' | 'archive' | 'unknown' { - if (this.file.type.startsWith('image/')) return 'image'; - if (this.file.type.startsWith('video/')) return 'video'; - if (this.file.type === 'audio/midi') return 'midi'; - if (this.file.type.startsWith('audio/')) return 'audio'; - if (this.file.type.endsWith('/csv')) return 'csv'; - if (this.file.type.endsWith('/pdf')) return 'pdf'; - if (this.file.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(e => e === this.file.type)) return 'archive'; - return 'unknown'; - }, - isThumbnailAvailable(): boolean { - return this.file.thumbnailUrl - ? (this.is === 'image' || this.is === 'video') - : false; - }, - }, - mounted() { - const audioTag = this.$refs.volumectrl as HTMLAudioElement; - if (audioTag) audioTag.volume = ColdDeviceStorage.get('mediaVolume'); - }, - methods: { - volumechange() { - const audioTag = this.$refs.volumectrl as HTMLAudioElement; - ColdDeviceStorage.set('mediaVolume', audioTag.volume); - } - } +const is = computed(() => { + if (props.file.type.startsWith('image/')) return 'image'; + if (props.file.type.startsWith('video/')) return 'video'; + if (props.file.type === 'audio/midi') return 'midi'; + if (props.file.type.startsWith('audio/')) return 'audio'; + if (props.file.type.endsWith('/csv')) return 'csv'; + if (props.file.type.endsWith('/pdf')) return 'pdf'; + if (props.file.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(e => e === props.file.type)) return 'archive'; + return 'unknown'; +}); + +const isThumbnailAvailable = computed(() => { + return props.file.thumbnailUrl + ? (is.value === 'image' as const || is.value === 'video') + : false; }); </script> diff --git a/packages/client/src/components/drive-select-dialog.vue b/packages/client/src/components/drive-select-dialog.vue index 75537dfe3e..6d84511277 100644 --- a/packages/client/src/components/drive-select-dialog.vue +++ b/packages/client/src/components/drive-select-dialog.vue @@ -7,64 +7,51 @@ @click="cancel()" @close="cancel()" @ok="ok()" - @closed="$emit('closed')" + @closed="emit('closed')" > <template #header> - {{ multiple ? ((type === 'file') ? $ts.selectFiles : $ts.selectFolders) : ((type === 'file') ? $ts.selectFile : $ts.selectFolder) }} + {{ multiple ? ((type === 'file') ? i18n.locale.selectFiles : i18n.locale.selectFolders) : ((type === 'file') ? i18n.locale.selectFile : i18n.locale.selectFolder) }} <span v-if="selected.length > 0" style="margin-left: 8px; opacity: 0.5;">({{ number(selected.length) }})</span> </template> <XDrive :multiple="multiple" :select="type" @changeSelection="onChangeSelection" @selected="ok()"/> </XModalWindow> </template> -<script lang="ts"> -import { defineComponent } from 'vue'; +<script lang="ts" setup> +import { ref } from 'vue'; +import * as Misskey from 'misskey-js'; import XDrive from './drive.vue'; import XModalWindow from '@/components/ui/modal-window.vue'; import number from '@/filters/number'; +import { i18n } from '@/i18n'; -export default defineComponent({ - components: { - XDrive, - XModalWindow, - }, - - props: { - type: { - type: String, - required: false, - default: 'file' - }, - multiple: { - type: Boolean, - default: false - } - }, +withDefaults(defineProps<{ + type?: 'file' | 'folder'; + multiple: boolean; +}>(), { + type: 'file', +}); - emits: ['done', 'closed'], +const emit = defineEmits<{ + (e: 'done', r?: Misskey.entities.DriveFile[]): void; + (e: 'closed'): void; +}>(); - data() { - return { - selected: [] - }; - }, +const dialog = ref<InstanceType<typeof XModalWindow>>(); - methods: { - ok() { - this.$emit('done', this.selected); - this.$refs.dialog.close(); - }, +const selected = ref<Misskey.entities.DriveFile[]>([]); - cancel() { - this.$emit('done'); - this.$refs.dialog.close(); - }, +function ok() { + emit('done', selected.value); + dialog.value?.close(); +} - onChangeSelection(xs) { - this.selected = xs; - }, +function cancel() { + emit('done'); + dialog.value?.close(); +} - number - } -}); +function onChangeSelection(files: Misskey.entities.DriveFile[]) { + selected.value = files; +} </script> diff --git a/packages/client/src/components/drive-window.vue b/packages/client/src/components/drive-window.vue index 43f07ebe76..8b60bf7794 100644 --- a/packages/client/src/components/drive-window.vue +++ b/packages/client/src/components/drive-window.vue @@ -3,42 +3,27 @@ :initial-width="800" :initial-height="500" :can-resize="true" - @closed="$emit('closed')" + @closed="emit('closed')" > <template #header> - {{ $ts.drive }} + {{ i18n.locale.drive }} </template> <XDrive :initial-folder="initialFolder"/> </XWindow> </template> -<script lang="ts"> -import { defineComponent } from 'vue'; +<script lang="ts" setup> +import { } from 'vue'; +import * as Misskey from 'misskey-js'; import XDrive from './drive.vue'; import XWindow from '@/components/ui/window.vue'; +import { i18n } from '@/i18n'; -export default defineComponent({ - components: { - XDrive, - XWindow, - }, +defineProps<{ + initialFolder?: Misskey.entities.DriveFolder; +}>(); - props: { - initialFolder: { - type: Object, - required: false - }, - }, - - emits: ['closed'], - - data() { - return { - }; - }, - - methods: { - - } -}); +const emit = defineEmits<{ + (e: 'closed'): void; +}>(); </script> diff --git a/packages/client/src/components/drive.file.vue b/packages/client/src/components/drive.file.vue index 511647229e..fd6a813838 100644 --- a/packages/client/src/components/drive.file.vue +++ b/packages/client/src/components/drive.file.vue @@ -8,17 +8,17 @@ @dragstart="onDragstart" @dragend="onDragend" > - <div v-if="$i.avatarId == file.id" class="label"> + <div v-if="$i?.avatarId == file.id" class="label"> <img src="/client-assets/label.svg"/> - <p>{{ $ts.avatar }}</p> + <p>{{ i18n.locale.avatar }}</p> </div> - <div v-if="$i.bannerId == file.id" class="label"> + <div v-if="$i?.bannerId == file.id" class="label"> <img src="/client-assets/label.svg"/> - <p>{{ $ts.banner }}</p> + <p>{{ i18n.locale.banner }}</p> </div> <div v-if="file.isSensitive" class="label red"> <img src="/client-assets/label-red.svg"/> - <p>{{ $ts.nsfw }}</p> + <p>{{ i18n.locale.nsfw }}</p> </div> <MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain"/> @@ -30,179 +30,155 @@ </div> </template> -<script lang="ts"> -import { defineComponent } from 'vue'; +<script lang="ts" setup> +import { computed, ref } from 'vue'; +import * as Misskey from 'misskey-js'; import copyToClipboard from '@/scripts/copy-to-clipboard'; import MkDriveFileThumbnail from './drive-file-thumbnail.vue'; import bytes from '@/filters/bytes'; import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { $i } from '@/account'; -export default defineComponent({ - components: { - MkDriveFileThumbnail - }, +const props = withDefaults(defineProps<{ + file: Misskey.entities.DriveFile; + isSelected?: boolean; + selectMode?: boolean; +}>(), { + isSelected: false, + selectMode: false, +}); - props: { - file: { - type: Object, - required: true, - }, - isSelected: { - type: Boolean, - required: false, - default: false, - }, - selectMode: { - type: Boolean, - required: false, - default: false, - } - }, +const emit = defineEmits<{ + (e: 'chosen', r: Misskey.entities.DriveFile): void; + (e: 'dragstart'): void; + (e: 'dragend'): void; +}>(); - emits: ['chosen'], +const isDragging = ref(false); - data() { - return { - isDragging: false - }; - }, +const title = computed(() => `${props.file.name}\n${props.file.type} ${bytes(props.file.size)}`); - computed: { - // TODO: parentへの参照を無くす - browser(): any { - return this.$parent; - }, - title(): string { - return `${this.file.name}\n${this.file.type} ${bytes(this.file.size)}`; - } - }, - - methods: { - getMenu() { - return [{ - text: this.$ts.rename, - icon: 'fas fa-i-cursor', - action: this.rename - }, { - text: this.file.isSensitive ? this.$ts.unmarkAsSensitive : this.$ts.markAsSensitive, - icon: this.file.isSensitive ? 'fas fa-eye' : 'fas fa-eye-slash', - action: this.toggleSensitive - }, { - text: this.$ts.describeFile, - icon: 'fas fa-i-cursor', - action: this.describe - }, null, { - text: this.$ts.copyUrl, - icon: 'fas fa-link', - action: this.copyUrl - }, { - type: 'a', - href: this.file.url, - target: '_blank', - text: this.$ts.download, - icon: 'fas fa-download', - download: this.file.name - }, null, { - text: this.$ts.delete, - icon: 'fas fa-trash-alt', - danger: true, - action: this.deleteFile - }]; - }, +function getMenu() { + return [{ + text: i18n.locale.rename, + icon: 'fas fa-i-cursor', + action: rename + }, { + text: props.file.isSensitive ? i18n.locale.unmarkAsSensitive : i18n.locale.markAsSensitive, + icon: props.file.isSensitive ? 'fas fa-eye' : 'fas fa-eye-slash', + action: toggleSensitive + }, { + text: i18n.locale.describeFile, + icon: 'fas fa-i-cursor', + action: describe + }, null, { + text: i18n.locale.copyUrl, + icon: 'fas fa-link', + action: copyUrl + }, { + type: 'a', + href: props.file.url, + target: '_blank', + text: i18n.locale.download, + icon: 'fas fa-download', + download: props.file.name + }, null, { + text: i18n.locale.delete, + icon: 'fas fa-trash-alt', + danger: true, + action: deleteFile + }]; +} - onClick(ev) { - if (this.selectMode) { - this.$emit('chosen', this.file); - } else { - os.popupMenu(this.getMenu(), ev.currentTarget || ev.target); - } - }, +function onClick(ev: MouseEvent) { + if (props.selectMode) { + emit('chosen', props.file); + } else { + os.popupMenu(getMenu(), (ev.currentTarget || ev.target || undefined) as HTMLElement | undefined); + } +} - onContextmenu(e) { - os.contextMenu(this.getMenu(), e); - }, +function onContextmenu(e: MouseEvent) { + os.contextMenu(getMenu(), e); +} - onDragstart(e) { - e.dataTransfer.effectAllowed = 'move'; - e.dataTransfer.setData(_DATA_TRANSFER_DRIVE_FILE_, JSON.stringify(this.file)); - this.isDragging = true; +function onDragstart(e: DragEvent) { + if (e.dataTransfer) { + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData(_DATA_TRANSFER_DRIVE_FILE_, JSON.stringify(props.file)); + } + isDragging.value = true; - // 親ブラウザに対して、ドラッグが開始されたフラグを立てる - // (=あなたの子供が、ドラッグを開始しましたよ) - this.browser.isDragSource = true; - }, + emit('dragstart'); +} - onDragend(e) { - this.isDragging = false; - this.browser.isDragSource = false; - }, +function onDragend() { + isDragging.value = false; + emit('dragend'); +} - rename() { - os.inputText({ - title: this.$ts.renameFile, - placeholder: this.$ts.inputNewFileName, - default: this.file.name, - allowEmpty: false - }).then(({ canceled, result: name }) => { - if (canceled) return; - os.api('drive/files/update', { - fileId: this.file.id, - name: name - }); - }); - }, +function rename() { + os.inputText({ + title: i18n.locale.renameFile, + placeholder: i18n.locale.inputNewFileName, + default: props.file.name, + }).then(({ canceled, result: name }) => { + if (canceled) return; + os.api('drive/files/update', { + fileId: props.file.id, + name: name + }); + }); +} - describe() { - os.popup(import('@/components/media-caption.vue'), { - title: this.$ts.describeFile, - input: { - placeholder: this.$ts.inputNewDescription, - default: this.file.comment !== null ? this.file.comment : '', - }, - image: this.file - }, { - done: result => { - if (!result || result.canceled) return; - let comment = result.result; - os.api('drive/files/update', { - fileId: this.file.id, - comment: comment.length == 0 ? null : comment - }); - } - }, 'closed'); +function describe() { + os.popup(import('@/components/media-caption.vue'), { + title: i18n.locale.describeFile, + input: { + placeholder: i18n.locale.inputNewDescription, + default: props.file.comment !== null ? props.file.comment : '', }, - - toggleSensitive() { + image: props.file + }, { + done: result => { + if (!result || result.canceled) return; + let comment = result.result; os.api('drive/files/update', { - fileId: this.file.id, - isSensitive: !this.file.isSensitive + fileId: props.file.id, + comment: comment.length == 0 ? null : comment }); - }, - - copyUrl() { - copyToClipboard(this.file.url); - os.success(); - }, - - addApp() { - alert('not implemented yet'); - }, + } + }, 'closed'); +} - async deleteFile() { - const { canceled } = await os.confirm({ - type: 'warning', - text: this.$t('driveFileDeleteConfirm', { name: this.file.name }), - }); - if (canceled) return; +function toggleSensitive() { + os.api('drive/files/update', { + fileId: props.file.id, + isSensitive: !props.file.isSensitive + }); +} - os.api('drive/files/delete', { - fileId: this.file.id - }); - }, +function copyUrl() { + copyToClipboard(props.file.url); + os.success(); +} +/* +function addApp() { + alert('not implemented yet'); +} +*/ +async function deleteFile() { + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.t('driveFileDeleteConfirm', { name: props.file.name }), + }); - bytes - } -}); + if (canceled) return; + os.api('drive/files/delete', { + fileId: props.file.id + }); +} </script> <style lang="scss" scoped> diff --git a/packages/client/src/components/drive.folder.vue b/packages/client/src/components/drive.folder.vue index aaba736cf8..20a6343cfe 100644 --- a/packages/client/src/components/drive.folder.vue +++ b/packages/client/src/components/drive.folder.vue @@ -19,243 +19,233 @@ <template v-if="!hover"><i class="fas fa-folder fa-fw"></i></template> {{ folder.name }} </p> - <p v-if="$store.state.uploadFolder == folder.id" class="upload"> - {{ $ts.uploadFolder }} + <p v-if="defaultStore.state.uploadFolder == folder.id" class="upload"> + {{ i18n.locale.uploadFolder }} </p> <button v-if="selectMode" class="checkbox _button" :class="{ checked: isSelected }" @click.prevent.stop="checkboxClicked"></button> </div> </template> -<script lang="ts"> -import { defineComponent } from 'vue'; +<script lang="ts" setup> +import { computed, ref } from 'vue'; +import * as Misskey from 'misskey-js'; import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { defaultStore } from '@/store'; -export default defineComponent({ - props: { - folder: { - type: Object, - required: true, - }, - isSelected: { - type: Boolean, - required: false, - default: false, - }, - selectMode: { - type: Boolean, - required: false, - default: false, - } - }, +const props = withDefaults(defineProps<{ + folder: Misskey.entities.DriveFolder; + isSelected?: boolean; + selectMode?: boolean; +}>(), { + isSelected: false, + selectMode: false, +}); - emits: ['chosen'], +const emit = defineEmits<{ + (ev: 'chosen', v: Misskey.entities.DriveFolder): void; + (ev: 'move', v: Misskey.entities.DriveFolder): void; + (ev: 'upload', file: File, folder: Misskey.entities.DriveFolder); + (ev: 'removeFile', v: Misskey.entities.DriveFile['id']): void; + (ev: 'removeFolder', v: Misskey.entities.DriveFolder['id']): void; + (ev: 'dragstart'): void; + (ev: 'dragend'): void; +}>(); - data() { - return { - hover: false, - draghover: false, - isDragging: false, - }; - }, +const hover = ref(false); +const draghover = ref(false); +const isDragging = ref(false); - computed: { - browser(): any { - return this.$parent; - }, - title(): string { - return this.folder.name; - } - }, +const title = computed(() => props.folder.name); - methods: { - checkboxClicked(e) { - this.$emit('chosen', this.folder); - }, +function checkboxClicked() { + emit('chosen', props.folder); +} - onClick() { - this.browser.move(this.folder); - }, +function onClick() { + emit('move', props.folder); +} - onMouseover() { - this.hover = true; - }, +function onMouseover() { + hover.value = true; +} - onMouseout() { - this.hover = false - }, +function onMouseout() { + hover.value = false +} - onDragover(e) { - // 自分自身がドラッグされている場合 - if (this.isDragging) { - // 自分自身にはドロップさせない - e.dataTransfer.dropEffect = 'none'; - return; - } +function onDragover(ev: DragEvent) { + if (!ev.dataTransfer) return; - const isFile = e.dataTransfer.items[0].kind == 'file'; - const isDriveFile = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FILE_; - const isDriveFolder = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FOLDER_; + // 自分自身がドラッグされている場合 + if (isDragging.value) { + // 自分自身にはドロップさせない + ev.dataTransfer.dropEffect = 'none'; + return; + } - if (isFile || isDriveFile || isDriveFolder) { - e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move'; - } else { - e.dataTransfer.dropEffect = 'none'; - } - }, + const isFile = ev.dataTransfer.items[0].kind == 'file'; + const isDriveFile = ev.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FILE_; + const isDriveFolder = ev.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FOLDER_; - onDragenter() { - if (!this.isDragging) this.draghover = true; - }, + if (isFile || isDriveFile || isDriveFolder) { + ev.dataTransfer.dropEffect = ev.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move'; + } else { + ev.dataTransfer.dropEffect = 'none'; + } +} - onDragleave() { - this.draghover = false; - }, +function onDragenter() { + if (!isDragging.value) draghover.value = true; +} - onDrop(e) { - this.draghover = false; +function onDragleave() { + draghover.value = false; +} - // ファイルだったら - if (e.dataTransfer.files.length > 0) { - for (const file of Array.from(e.dataTransfer.files)) { - this.browser.upload(file, this.folder); - } - return; - } +function onDrop(ev: DragEvent) { + draghover.value = false; - //#region ドライブのファイル - const driveFile = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); - if (driveFile != null && driveFile != '') { - const file = JSON.parse(driveFile); - this.browser.removeFile(file.id); - os.api('drive/files/update', { - fileId: file.id, - folderId: this.folder.id - }); - } - //#endregion + if (!ev.dataTransfer) return; + + // ファイルだったら + if (ev.dataTransfer.files.length > 0) { + for (const file of Array.from(ev.dataTransfer.files)) { + emit('upload', file, props.folder); + } + return; + } - //#region ドライブのフォルダ - const driveFolder = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FOLDER_); - if (driveFolder != null && driveFolder != '') { - const folder = JSON.parse(driveFolder); + //#region ドライブのファイル + const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); + if (driveFile != null && driveFile != '') { + const file = JSON.parse(driveFile); + emit('removeFile', file.id); + os.api('drive/files/update', { + fileId: file.id, + folderId: props.folder.id + }); + } + //#endregion - // 移動先が自分自身ならreject - if (folder.id == this.folder.id) return; + //#region ドライブのフォルダ + const driveFolder = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FOLDER_); + if (driveFolder != null && driveFolder != '') { + const folder = JSON.parse(driveFolder); - this.browser.removeFolder(folder.id); - os.api('drive/folders/update', { - folderId: folder.id, - parentId: this.folder.id - }).then(() => { - // noop - }).catch(err => { - switch (err) { - case 'detected-circular-definition': - os.alert({ - title: this.$ts.unableToProcess, - text: this.$ts.circularReferenceFolder - }); - break; - default: - os.alert({ - type: 'error', - text: this.$ts.somethingHappened - }); - } - }); + // 移動先が自分自身ならreject + if (folder.id == props.folder.id) return; + + emit('removeFolder', folder.id); + os.api('drive/folders/update', { + folderId: folder.id, + parentId: props.folder.id + }).then(() => { + // noop + }).catch(err => { + switch (err) { + case 'detected-circular-definition': + os.alert({ + title: i18n.locale.unableToProcess, + text: i18n.locale.circularReferenceFolder + }); + break; + default: + os.alert({ + type: 'error', + text: i18n.locale.somethingHappened + }); } - //#endregion - }, + }); + } + //#endregion +} - onDragstart(e) { - e.dataTransfer.effectAllowed = 'move'; - e.dataTransfer.setData(_DATA_TRANSFER_DRIVE_FOLDER_, JSON.stringify(this.folder)); - this.isDragging = true; +function onDragstart(ev: DragEvent) { + if (!ev.dataTransfer) return; - // 親ブラウザに対して、ドラッグが開始されたフラグを立てる - // (=あなたの子供が、ドラッグを開始しましたよ) - this.browser.isDragSource = true; - }, + ev.dataTransfer.effectAllowed = 'move'; + ev.dataTransfer.setData(_DATA_TRANSFER_DRIVE_FOLDER_, JSON.stringify(props.folder)); + isDragging.value = true; - onDragend() { - this.isDragging = false; - this.browser.isDragSource = false; - }, + // 親ブラウザに対して、ドラッグが開始されたフラグを立てる + // (=あなたの子供が、ドラッグを開始しましたよ) + emit('dragstart'); +} - go() { - this.browser.move(this.folder.id); - }, +function onDragend() { + isDragging.value = false; + emit('dragend'); +} - newWindow() { - this.browser.newWindow(this.folder); - }, +function go() { + emit('move', props.folder.id); +} - rename() { - os.inputText({ - title: this.$ts.renameFolder, - placeholder: this.$ts.inputNewFolderName, - default: this.folder.name - }).then(({ canceled, result: name }) => { - if (canceled) return; - os.api('drive/folders/update', { - folderId: this.folder.id, - name: name - }); - }); - }, +function rename() { + os.inputText({ + title: i18n.locale.renameFolder, + placeholder: i18n.locale.inputNewFolderName, + default: props.folder.name + }).then(({ canceled, result: name }) => { + if (canceled) return; + os.api('drive/folders/update', { + folderId: props.folder.id, + name: name + }); + }); +} - deleteFolder() { - os.api('drive/folders/delete', { - folderId: this.folder.id - }).then(() => { - if (this.$store.state.uploadFolder === this.folder.id) { - this.$store.set('uploadFolder', null); - } - }).catch(err => { - switch(err.id) { - case 'b0fc8a17-963c-405d-bfbc-859a487295e1': - os.alert({ - type: 'error', - title: this.$ts.unableToDelete, - text: this.$ts.hasChildFilesOrFolders - }); - break; - default: - os.alert({ - type: 'error', - text: this.$ts.unableToDelete - }); - } - }); - }, +function deleteFolder() { + os.api('drive/folders/delete', { + folderId: props.folder.id + }).then(() => { + if (defaultStore.state.uploadFolder === props.folder.id) { + defaultStore.set('uploadFolder', null); + } + }).catch(err => { + switch(err.id) { + case 'b0fc8a17-963c-405d-bfbc-859a487295e1': + os.alert({ + type: 'error', + title: i18n.locale.unableToDelete, + text: i18n.locale.hasChildFilesOrFolders + }); + break; + default: + os.alert({ + type: 'error', + text: i18n.locale.unableToDelete + }); + } + }); +} - setAsUploadFolder() { - this.$store.set('uploadFolder', this.folder.id); - }, +function setAsUploadFolder() { + defaultStore.set('uploadFolder', props.folder.id); +} - onContextmenu(e) { - os.contextMenu([{ - text: this.$ts.openInWindow, - icon: 'fas fa-window-restore', - action: () => { - os.popup(import('./drive-window.vue'), { - initialFolder: this.folder - }, { - }, 'closed'); - } - }, null, { - text: this.$ts.rename, - icon: 'fas fa-i-cursor', - action: this.rename - }, null, { - text: this.$ts.delete, - icon: 'fas fa-trash-alt', - danger: true, - action: this.deleteFolder - }], e); - }, - } -}); +function onContextmenu(ev: MouseEvent) { + os.contextMenu([{ + text: i18n.locale.openInWindow, + icon: 'fas fa-window-restore', + action: () => { + os.popup(import('./drive-window.vue'), { + initialFolder: props.folder + }, { + }, 'closed'); + } + }, null, { + text: i18n.locale.rename, + icon: 'fas fa-i-cursor', + action: rename, + }, null, { + text: i18n.locale.delete, + icon: 'fas fa-trash-alt', + danger: true, + action: deleteFolder, + }], ev); +} </script> <style lang="scss" scoped> diff --git a/packages/client/src/components/drive.nav-folder.vue b/packages/client/src/components/drive.nav-folder.vue index 4f0e6ce0e9..7c35c5d3da 100644 --- a/packages/client/src/components/drive.nav-folder.vue +++ b/packages/client/src/components/drive.nav-folder.vue @@ -8,114 +8,111 @@ @drop.stop="onDrop" > <i v-if="folder == null" class="fas fa-cloud"></i> - <span>{{ folder == null ? $ts.drive : folder.name }}</span> + <span>{{ folder == null ? i18n.locale.drive : folder.name }}</span> </div> </template> -<script lang="ts"> -import { defineComponent } from 'vue'; +<script lang="ts" setup> +import { ref } from 'vue'; +import * as Misskey from 'misskey-js'; import * as os from '@/os'; +import { i18n } from '@/i18n'; -export default defineComponent({ - props: { - folder: { - type: Object, - required: false, - } - }, +const props = defineProps<{ + folder?: Misskey.entities.DriveFolder; + parentFolder: Misskey.entities.DriveFolder | null; +}>(); - data() { - return { - hover: false, - draghover: false, - }; - }, +const emit = defineEmits<{ + (e: 'move', v?: Misskey.entities.DriveFolder): void; + (e: 'upload', file: File, folder?: Misskey.entities.DriveFolder | null): void; + (e: 'removeFile', v: Misskey.entities.DriveFile['id']): void; + (e: 'removeFolder', v: Misskey.entities.DriveFolder['id']): void; +}>(); - computed: { - browser(): any { - return this.$parent; - } - }, +const hover = ref(false); +const draghover = ref(false); - methods: { - onClick() { - this.browser.move(this.folder); - }, +function onClick() { + emit('move', props.folder); +} - onMouseover() { - this.hover = true; - }, +function onMouseover() { + hover.value = true; +} - onMouseout() { - this.hover = false; - }, +function onMouseout() { + hover.value = false; +} - onDragover(e) { - // このフォルダがルートかつカレントディレクトリならドロップ禁止 - if (this.folder == null && this.browser.folder == null) { - e.dataTransfer.dropEffect = 'none'; - } +function onDragover(e: DragEvent) { + if (!e.dataTransfer) return; - const isFile = e.dataTransfer.items[0].kind == 'file'; - const isDriveFile = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FILE_; - const isDriveFolder = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FOLDER_; + // このフォルダがルートかつカレントディレクトリならドロップ禁止 + if (props.folder == null && props.parentFolder == null) { + e.dataTransfer.dropEffect = 'none'; + } - if (isFile || isDriveFile || isDriveFolder) { - e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move'; - } else { - e.dataTransfer.dropEffect = 'none'; - } + const isFile = e.dataTransfer.items[0].kind == 'file'; + const isDriveFile = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FILE_; + const isDriveFolder = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FOLDER_; - return false; - }, + if (isFile || isDriveFile || isDriveFolder) { + e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move'; + } else { + e.dataTransfer.dropEffect = 'none'; + } - onDragenter() { - if (this.folder || this.browser.folder) this.draghover = true; - }, + return false; +} - onDragleave() { - if (this.folder || this.browser.folder) this.draghover = false; - }, +function onDragenter() { + if (props.folder || props.parentFolder) draghover.value = true; +} - onDrop(e) { - this.draghover = false; +function onDragleave() { + if (props.folder || props.parentFolder) draghover.value = false; +} - // ファイルだったら - if (e.dataTransfer.files.length > 0) { - for (const file of Array.from(e.dataTransfer.files)) { - this.browser.upload(file, this.folder); - } - return; - } +function onDrop(e: DragEvent) { + draghover.value = false; - //#region ドライブのファイル - const driveFile = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); - if (driveFile != null && driveFile != '') { - const file = JSON.parse(driveFile); - this.browser.removeFile(file.id); - os.api('drive/files/update', { - fileId: file.id, - folderId: this.folder ? this.folder.id : null - }); - } - //#endregion + if (!e.dataTransfer) return; - //#region ドライブのフォルダ - const driveFolder = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FOLDER_); - if (driveFolder != null && driveFolder != '') { - const folder = JSON.parse(driveFolder); - // 移動先が自分自身ならreject - if (this.folder && folder.id == this.folder.id) return; - this.browser.removeFolder(folder.id); - os.api('drive/folders/update', { - folderId: folder.id, - parentId: this.folder ? this.folder.id : null - }); - } - //#endregion + // ファイルだったら + if (e.dataTransfer.files.length > 0) { + for (const file of Array.from(e.dataTransfer.files)) { + emit('upload', file, props.folder); } + return; + } + + //#region ドライブのファイル + const driveFile = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); + if (driveFile != null && driveFile != '') { + const file = JSON.parse(driveFile); + emit('removeFile', file.id); + os.api('drive/files/update', { + fileId: file.id, + folderId: props.folder ? props.folder.id : null + }); } -}); + //#endregion + + //#region ドライブのフォルダ + const driveFolder = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FOLDER_); + if (driveFolder != null && driveFolder != '') { + const folder = JSON.parse(driveFolder); + // 移動先が自分自身ならreject + if (props.folder && folder.id == props.folder.id) return; + emit('removeFolder', folder.id); + os.api('drive/folders/update', { + folderId: folder.id, + parentId: props.folder ? props.folder.id : null + }); + } + //#endregion +} </script> <style lang="scss" scoped> diff --git a/packages/client/src/components/drive.vue b/packages/client/src/components/drive.vue index f8d3d810b7..e27b0a5fbb 100644 --- a/packages/client/src/components/drive.vue +++ b/packages/client/src/components/drive.vue @@ -2,10 +2,24 @@ <div class="yfudmmck"> <nav> <div class="path" @contextmenu.prevent.stop="() => {}"> - <XNavFolder :class="{ current: folder == null }"/> + <XNavFolder + :class="{ current: folder == null }" + :parent-folder="folder" + @move="move" + @upload="upload" + @removeFile="removeFile" + @removeFolder="removeFolder" + /> <template v-for="f in hierarchyFolders"> <span class="separator"><i class="fas fa-angle-right"></i></span> - <XNavFolder :folder="f"/> + <XNavFolder + :folder="f" + :parent-folder="folder" + @move="move" + @upload="upload" + @removeFile="removeFile" + @removeFolder="removeFolder" + /> </template> <span v-if="folder != null" class="separator"><i class="fas fa-angle-right"></i></span> <span v-if="folder != null" class="folder current">{{ folder.name }}</span> @@ -22,617 +36,601 @@ > <div ref="contents" class="contents"> <div v-show="folders.length > 0" ref="foldersContainer" class="folders"> - <XFolder v-for="(f, i) in folders" :key="f.id" v-anim="i" class="folder" :folder="f" :select-mode="select === 'folder'" :is-selected="selectedFolders.some(x => x.id === f.id)" @chosen="chooseFolder"/> + <XFolder + v-for="(f, i) in folders" + :key="f.id" + v-anim="i" + class="folder" + :folder="f" + :select-mode="select === 'folder'" + :is-selected="selectedFolders.some(x => x.id === f.id)" + @chosen="chooseFolder" + @move="move" + @upload="upload" + @removeFile="removeFile" + @removeFolder="removeFolder" + @dragstart="isDragSource = true" + @dragend="isDragSource = false" + /> <!-- SEE: https://stackoverflow.com/questions/18744164/flex-box-align-last-row-to-grid --> <div v-for="(n, i) in 16" :key="i" class="padding"></div> - <MkButton v-if="moreFolders" ref="moreFolders">{{ $ts.loadMore }}</MkButton> + <MkButton v-if="moreFolders" ref="moreFolders">{{ i18n.locale.loadMore }}</MkButton> </div> <div v-show="files.length > 0" ref="filesContainer" class="files"> - <XFile v-for="(file, i) in files" :key="file.id" v-anim="i" class="file" :file="file" :select-mode="select === 'file'" :is-selected="selectedFiles.some(x => x.id === file.id)" @chosen="chooseFile"/> + <XFile + v-for="(file, i) in files" + :key="file.id" + v-anim="i" + class="file" + :file="file" + :select-mode="select === 'file'" + :is-selected="selectedFiles.some(x => x.id === file.id)" + @chosen="chooseFile" + @dragstart="isDragSource = true" + @dragend="isDragSource = false" + /> <!-- SEE: https://stackoverflow.com/questions/18744164/flex-box-align-last-row-to-grid --> <div v-for="(n, i) in 16" :key="i" class="padding"></div> - <MkButton v-show="moreFiles" ref="loadMoreFiles" @click="fetchMoreFiles">{{ $ts.loadMore }}</MkButton> + <MkButton v-show="moreFiles" ref="loadMoreFiles" @click="fetchMoreFiles">{{ i18n.locale.loadMore }}</MkButton> </div> <div v-if="files.length == 0 && folders.length == 0 && !fetching" class="empty"> - <p v-if="draghover">{{ $t('empty-draghover') }}</p> - <p v-if="!draghover && folder == null"><strong>{{ $ts.emptyDrive }}</strong><br/>{{ $t('empty-drive-description') }}</p> - <p v-if="!draghover && folder != null">{{ $ts.emptyFolder }}</p> + <p v-if="draghover">{{ i18n.t('empty-draghover') }}</p> + <p v-if="!draghover && folder == null"><strong>{{ i18n.locale.emptyDrive }}</strong><br/>{{ i18n.t('empty-drive-description') }}</p> + <p v-if="!draghover && folder != null">{{ i18n.locale.emptyFolder }}</p> </div> </div> <MkLoading v-if="fetching"/> </div> <div v-if="draghover" class="dropzone"></div> - <input ref="fileInput" type="file" accept="*/*" multiple="multiple" tabindex="-1" @change="onChangeFileInput"/> + <input ref="fileInput" type="file" accept="*/*" multiple tabindex="-1" @change="onChangeFileInput"/> </div> </template> -<script lang="ts"> -import { defineComponent, markRaw } from 'vue'; +<script lang="ts" setup> +import { markRaw, nextTick, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue'; +import * as Misskey from 'misskey-js'; import XNavFolder from './drive.nav-folder.vue'; import XFolder from './drive.folder.vue'; import XFile from './drive.file.vue'; import MkButton from './ui/button.vue'; import * as os from '@/os'; import { stream } from '@/stream'; +import { defaultStore } from '@/store'; +import { i18n } from '@/i18n'; -export default defineComponent({ - components: { - XNavFolder, - XFolder, - XFile, - MkButton, - }, - - props: { - initialFolder: { - type: Object, - required: false - }, - type: { - type: String, - required: false, - default: undefined - }, - multiple: { - type: Boolean, - required: false, - default: false - }, - select: { - type: String, - required: false, - default: null - } - }, - - emits: ['selected', 'change-selection', 'move-root', 'cd', 'open-folder'], - - data() { - return { - /** - * 現在の階層(フォルダ) - * * null でルートを表す - */ - folder: null, +const props = withDefaults(defineProps<{ + initialFolder?: Misskey.entities.DriveFolder; + type?: string; + multiple?: boolean; + select?: 'file' | 'folder' | null; +}>(), { + multiple: false, + select: null, +}); - files: [], - folders: [], - moreFiles: false, - moreFolders: false, - hierarchyFolders: [], - selectedFiles: [], - selectedFolders: [], - uploadings: os.uploads, - connection: null, +const emit = defineEmits<{ + (e: 'selected', v: Misskey.entities.DriveFile | Misskey.entities.DriveFolder): void; + (e: 'change-selection', v: Misskey.entities.DriveFile[] | Misskey.entities.DriveFolder[]): void; + (e: 'move-root'): void; + (e: 'cd', v: Misskey.entities.DriveFolder | null): void; + (e: 'open-folder', v: Misskey.entities.DriveFolder): void; +}>(); - /** - * ドロップされようとしているか - */ - draghover: false, +const loadMoreFiles = ref<InstanceType<typeof MkButton>>(); +const fileInput = ref<HTMLInputElement>(); - /** - * 自信の所有するアイテムがドラッグをスタートさせたか - * (自分自身の階層にドロップできないようにするためのフラグ) - */ - isDragSource: false, +const folder = ref<Misskey.entities.DriveFolder | null>(null); +const files = ref<Misskey.entities.DriveFile[]>([]); +const folders = ref<Misskey.entities.DriveFolder[]>([]); +const moreFiles = ref(false); +const moreFolders = ref(false); +const hierarchyFolders = ref<Misskey.entities.DriveFolder[]>([]); +const selectedFiles = ref<Misskey.entities.DriveFile[]>([]); +const selectedFolders = ref<Misskey.entities.DriveFolder[]>([]); +const uploadings = os.uploads; +const connection = stream.useChannel('drive'); - fetching: true, +// ドロップされようとしているか +const draghover = ref(false); - ilFilesObserver: new IntersectionObserver( - (entries) => entries.some((entry) => entry.isIntersecting) - && !this.fetching && this.moreFiles && - this.fetchMoreFiles() - ), - moreFilesElement: null as Element, +// 自身の所有するアイテムがドラッグをスタートさせたか +// (自分自身の階層にドロップできないようにするためのフラグ) +const isDragSource = ref(false); - }; - }, +const fetching = ref(true); - watch: { - folder() { - this.$emit('cd', this.folder); - } - }, +const ilFilesObserver = new IntersectionObserver( + (entries) => entries.some((entry) => entry.isIntersecting) && !fetching.value && moreFiles.value && fetchMoreFiles() +) - mounted() { - if (this.$store.state.enableInfiniteScroll && this.$refs.loadMoreFiles) { - this.$nextTick(() => { - this.ilFilesObserver.observe((this.$refs.loadMoreFiles as Vue).$el) - }); - } +watch(folder, () => emit('cd', folder.value)); - this.connection = markRaw(stream.useChannel('drive')); - - this.connection.on('fileCreated', this.onStreamDriveFileCreated); - this.connection.on('fileUpdated', this.onStreamDriveFileUpdated); - this.connection.on('fileDeleted', this.onStreamDriveFileDeleted); - this.connection.on('folderCreated', this.onStreamDriveFolderCreated); - this.connection.on('folderUpdated', this.onStreamDriveFolderUpdated); - this.connection.on('folderDeleted', this.onStreamDriveFolderDeleted); +function onStreamDriveFileCreated(file: Misskey.entities.DriveFile) { + addFile(file, true); +} - if (this.initialFolder) { - this.move(this.initialFolder); - } else { - this.fetch(); - } - }, +function onStreamDriveFileUpdated(file: Misskey.entities.DriveFile) { + const current = folder.value ? folder.value.id : null; + if (current != file.folderId) { + removeFile(file); + } else { + addFile(file, true); + } +} - activated() { - if (this.$store.state.enableInfiniteScroll) { - this.$nextTick(() => { - this.ilFilesObserver.observe((this.$refs.loadMoreFiles as Vue).$el) - }); - } - }, +function onStreamDriveFileDeleted(fileId: string) { + removeFile(fileId); +} - beforeUnmount() { - this.connection.dispose(); - this.ilFilesObserver.disconnect(); - }, +function onStreamDriveFolderCreated(createdFolder: Misskey.entities.DriveFolder) { + addFolder(createdFolder, true); +} - methods: { - onStreamDriveFileCreated(file) { - this.addFile(file, true); - }, +function onStreamDriveFolderUpdated(updatedFolder: Misskey.entities.DriveFolder) { + const current = folder.value ? folder.value.id : null; + if (current != updatedFolder.parentId) { + removeFolder(updatedFolder); + } else { + addFolder(updatedFolder, true); + } +} - onStreamDriveFileUpdated(file) { - const current = this.folder ? this.folder.id : null; - if (current != file.folderId) { - this.removeFile(file); - } else { - this.addFile(file, true); - } - }, +function onStreamDriveFolderDeleted(folderId: string) { + removeFolder(folderId); +} - onStreamDriveFileDeleted(fileId) { - this.removeFile(fileId); - }, +function onDragover(e: DragEvent): any { + if (!e.dataTransfer) return; - onStreamDriveFolderCreated(folder) { - this.addFolder(folder, true); - }, + // ドラッグ元が自分自身の所有するアイテムだったら + if (isDragSource.value) { + // 自分自身にはドロップさせない + e.dataTransfer.dropEffect = 'none'; + return; + } - onStreamDriveFolderUpdated(folder) { - const current = this.folder ? this.folder.id : null; - if (current != folder.parentId) { - this.removeFolder(folder); - } else { - this.addFolder(folder, true); - } - }, + const isFile = e.dataTransfer.items[0].kind == 'file'; + const isDriveFile = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FILE_; + const isDriveFolder = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FOLDER_; + if (isFile || isDriveFile || isDriveFolder) { + e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move'; + } else { + e.dataTransfer.dropEffect = 'none'; + } - onStreamDriveFolderDeleted(folderId) { - this.removeFolder(folderId); - }, + return false; +} - onDragover(e): any { - // ドラッグ元が自分自身の所有するアイテムだったら - if (this.isDragSource) { - // 自分自身にはドロップさせない - e.dataTransfer.dropEffect = 'none'; - return; - } +function onDragenter() { + if (!isDragSource.value) draghover.value = true; +} - const isFile = e.dataTransfer.items[0].kind == 'file'; - const isDriveFile = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FILE_; - const isDriveFolder = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FOLDER_; +function onDragleave() { + draghover.value = false; +} - if (isFile || isDriveFile || isDriveFolder) { - e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move'; - } else { - e.dataTransfer.dropEffect = 'none'; - } +function onDrop(e: DragEvent): any { + draghover.value = false; - return false; - }, + if (!e.dataTransfer) return; - onDragenter(e) { - if (!this.isDragSource) this.draghover = true; - }, + // ドロップされてきたものがファイルだったら + if (e.dataTransfer.files.length > 0) { + for (const file of Array.from(e.dataTransfer.files)) { + upload(file, folder.value); + } + return; + } - onDragleave(e) { - this.draghover = false; - }, + //#region ドライブのファイル + const driveFile = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); + if (driveFile != null && driveFile != '') { + const file = JSON.parse(driveFile); + if (files.value.some(f => f.id == file.id)) return; + removeFile(file.id); + os.api('drive/files/update', { + fileId: file.id, + folderId: folder.value ? folder.value.id : null + }); + } + //#endregion - onDrop(e): any { - this.draghover = false; + //#region ドライブのフォルダ + const driveFolder = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FOLDER_); + if (driveFolder != null && driveFolder != '') { + const droppedFolder = JSON.parse(driveFolder); - // ドロップされてきたものがファイルだったら - if (e.dataTransfer.files.length > 0) { - for (const file of Array.from(e.dataTransfer.files)) { - this.upload(file, this.folder); - } - return; + // 移動先が自分自身ならreject + if (folder.value && droppedFolder.id == folder.value.id) return false; + if (folders.value.some(f => f.id == droppedFolder.id)) return false; + removeFolder(droppedFolder.id); + os.api('drive/folders/update', { + folderId: droppedFolder.id, + parentId: folder.value ? folder.value.id : null + }).then(() => { + // noop + }).catch(err => { + switch (err) { + case 'detected-circular-definition': + os.alert({ + title: i18n.locale.unableToProcess, + text: i18n.locale.circularReferenceFolder + }); + break; + default: + os.alert({ + type: 'error', + text: i18n.locale.somethingHappened + }); } + }); + } + //#endregion +} - //#region ドライブのファイル - const driveFile = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); - if (driveFile != null && driveFile != '') { - const file = JSON.parse(driveFile); - if (this.files.some(f => f.id == file.id)) return; - this.removeFile(file.id); - os.api('drive/files/update', { - fileId: file.id, - folderId: this.folder ? this.folder.id : null - }); - } - //#endregion +function selectLocalFile() { + fileInput.value?.click(); +} - //#region ドライブのフォルダ - const driveFolder = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FOLDER_); - if (driveFolder != null && driveFolder != '') { - const folder = JSON.parse(driveFolder); +function urlUpload() { + os.inputText({ + title: i18n.locale.uploadFromUrl, + type: 'url', + placeholder: i18n.locale.uploadFromUrlDescription + }).then(({ canceled, result: url }) => { + if (canceled || !url) return; + os.api('drive/files/upload-from-url', { + url: url, + folderId: folder.value ? folder.value.id : undefined + }); - // 移動先が自分自身ならreject - if (this.folder && folder.id == this.folder.id) return false; - if (this.folders.some(f => f.id == folder.id)) return false; - this.removeFolder(folder.id); - os.api('drive/folders/update', { - folderId: folder.id, - parentId: this.folder ? this.folder.id : null - }).then(() => { - // noop - }).catch(err => { - switch (err) { - case 'detected-circular-definition': - os.alert({ - title: this.$ts.unableToProcess, - text: this.$ts.circularReferenceFolder - }); - break; - default: - os.alert({ - type: 'error', - text: this.$ts.somethingHappened - }); - } - }); - } - //#endregion - }, + os.alert({ + title: i18n.locale.uploadFromUrlRequested, + text: i18n.locale.uploadFromUrlMayTakeTime + }); + }); +} - selectLocalFile() { - (this.$refs.fileInput as any).click(); - }, +function createFolder() { + os.inputText({ + title: i18n.locale.createFolder, + placeholder: i18n.locale.folderName + }).then(({ canceled, result: name }) => { + if (canceled) return; + os.api('drive/folders/create', { + name: name, + parentId: folder.value ? folder.value.id : undefined + }).then(createdFolder => { + addFolder(createdFolder, true); + }); + }); +} - urlUpload() { - os.inputText({ - title: this.$ts.uploadFromUrl, - type: 'url', - placeholder: this.$ts.uploadFromUrlDescription - }).then(({ canceled, result: url }) => { - if (canceled) return; - os.api('drive/files/upload-from-url', { - url: url, - folderId: this.folder ? this.folder.id : undefined - }); +function renameFolder(folderToRename: Misskey.entities.DriveFolder) { + os.inputText({ + title: i18n.locale.renameFolder, + placeholder: i18n.locale.inputNewFolderName, + default: folderToRename.name + }).then(({ canceled, result: name }) => { + if (canceled) return; + os.api('drive/folders/update', { + folderId: folderToRename.id, + name: name + }).then(updatedFolder => { + // FIXME: 画面を更新するために自分自身に移動 + move(updatedFolder); + }); + }); +} +function deleteFolder(folderToDelete: Misskey.entities.DriveFolder) { + os.api('drive/folders/delete', { + folderId: folderToDelete.id + }).then(() => { + // 削除時に親フォルダに移動 + move(folderToDelete.parentId); + }).catch(err => { + switch(err.id) { + case 'b0fc8a17-963c-405d-bfbc-859a487295e1': os.alert({ - title: this.$ts.uploadFromUrlRequested, - text: this.$ts.uploadFromUrlMayTakeTime + type: 'error', + title: i18n.locale.unableToDelete, + text: i18n.locale.hasChildFilesOrFolders }); - }); - }, - - createFolder() { - os.inputText({ - title: this.$ts.createFolder, - placeholder: this.$ts.folderName - }).then(({ canceled, result: name }) => { - if (canceled) return; - os.api('drive/folders/create', { - name: name, - parentId: this.folder ? this.folder.id : undefined - }).then(folder => { - this.addFolder(folder, true); + break; + default: + os.alert({ + type: 'error', + text: i18n.locale.unableToDelete }); - }); - }, + } + }); +} - renameFolder(folder) { - os.inputText({ - title: this.$ts.renameFolder, - placeholder: this.$ts.inputNewFolderName, - default: folder.name - }).then(({ canceled, result: name }) => { - if (canceled) return; - os.api('drive/folders/update', { - folderId: folder.id, - name: name - }).then(folder => { - // FIXME: 画面を更新するために自分自身に移動 - this.move(folder); - }); - }); - }, +function onChangeFileInput() { + if (!fileInput.value?.files) return; + for (const file of Array.from(fileInput.value.files)) { + upload(file, folder.value); + } +} - deleteFolder(folder) { - os.api('drive/folders/delete', { - folderId: folder.id - }).then(() => { - // 削除時に親フォルダに移動 - this.move(folder.parentId); - }).catch(err => { - switch(err.id) { - case 'b0fc8a17-963c-405d-bfbc-859a487295e1': - os.alert({ - type: 'error', - title: this.$ts.unableToDelete, - text: this.$ts.hasChildFilesOrFolders - }); - break; - default: - os.alert({ - type: 'error', - text: this.$ts.unableToDelete - }); - } - }); - }, +function upload(file: File, folderToUpload?: Misskey.entities.DriveFolder | null) { + os.upload(file, (folderToUpload && typeof folderToUpload == 'object') ? folderToUpload.id : null).then(res => { + addFile(res, true); + }); +} - onChangeFileInput() { - for (const file of Array.from((this.$refs.fileInput as any).files)) { - this.upload(file, this.folder); - } - }, +function chooseFile(file: Misskey.entities.DriveFile) { + const isAlreadySelected = selectedFiles.value.some(f => f.id == file.id); + if (props.multiple) { + if (isAlreadySelected) { + selectedFiles.value = selectedFiles.value.filter(f => f.id != file.id); + } else { + selectedFiles.value.push(file); + } + emit('change-selection', selectedFiles.value); + } else { + if (isAlreadySelected) { + emit('selected', file); + } else { + selectedFiles.value = [file]; + emit('change-selection', [file]); + } + } +} - upload(file, folder) { - if (folder && typeof folder == 'object') folder = folder.id; - os.upload(file, folder).then(res => { - this.addFile(res, true); - }); - }, +function chooseFolder(folderToChoose: Misskey.entities.DriveFolder) { + const isAlreadySelected = selectedFolders.value.some(f => f.id == folderToChoose.id); + if (props.multiple) { + if (isAlreadySelected) { + selectedFolders.value = selectedFolders.value.filter(f => f.id != folderToChoose.id); + } else { + selectedFolders.value.push(folderToChoose); + } + emit('change-selection', selectedFolders.value); + } else { + if (isAlreadySelected) { + emit('selected', folderToChoose); + } else { + selectedFolders.value = [folderToChoose]; + emit('change-selection', [folderToChoose]); + } + } +} - chooseFile(file) { - const isAlreadySelected = this.selectedFiles.some(f => f.id == file.id); - if (this.multiple) { - if (isAlreadySelected) { - this.selectedFiles = this.selectedFiles.filter(f => f.id != file.id); - } else { - this.selectedFiles.push(file); - } - this.$emit('change-selection', this.selectedFiles); - } else { - if (isAlreadySelected) { - this.$emit('selected', file); - } else { - this.selectedFiles = [file]; - this.$emit('change-selection', [file]); - } - } - }, +function move(target?: Misskey.entities.DriveFolder) { + if (!target) { + goRoot(); + return; + } else if (typeof target == 'object') { + target = target.id; + } - chooseFolder(folder) { - const isAlreadySelected = this.selectedFolders.some(f => f.id == folder.id); - if (this.multiple) { - if (isAlreadySelected) { - this.selectedFolders = this.selectedFolders.filter(f => f.id != folder.id); - } else { - this.selectedFolders.push(folder); - } - this.$emit('change-selection', this.selectedFolders); - } else { - if (isAlreadySelected) { - this.$emit('selected', folder); - } else { - this.selectedFolders = [folder]; - this.$emit('change-selection', [folder]); - } - } - }, + fetching.value = true; - move(target) { - if (target == null) { - this.goRoot(); - return; - } else if (typeof target == 'object') { - target = target.id; - } + os.api('drive/folders/show', { + folderId: target + }).then(folderToMove => { + folder.value = folderToMove; + hierarchyFolders.value = []; - this.fetching = true; + const dive = folderToDive => { + hierarchyFolders.value.unshift(folderToDive); + if (folderToDive.parent) dive(folderToDive.parent); + }; + + if (folderToMove.parent) dive(folderToMove.parent); - os.api('drive/folders/show', { - folderId: target - }).then(folder => { - this.folder = folder; - this.hierarchyFolders = []; + emit('open-folder', folderToMove); + fetch(); + }); +} - const dive = folder => { - this.hierarchyFolders.unshift(folder); - if (folder.parent) dive(folder.parent); - }; +function addFolder(folderToAdd: Misskey.entities.DriveFolder, unshift = false) { + const current = folder.value ? folder.value.id : null; + if (current != folderToAdd.parentId) return; - if (folder.parent) dive(folder.parent); + if (folders.value.some(f => f.id == folderToAdd.id)) { + const exist = folders.value.map(f => f.id).indexOf(folderToAdd.id); + folders.value[exist] = folderToAdd; + return; + } - this.$emit('open-folder', folder); - this.fetch(); - }); - }, + if (unshift) { + folders.value.unshift(folderToAdd); + } else { + folders.value.push(folderToAdd); + } +} - addFolder(folder, unshift = false) { - const current = this.folder ? this.folder.id : null; - if (current != folder.parentId) return; +function addFile(fileToAdd: Misskey.entities.DriveFile, unshift = false) { + const current = folder.value ? folder.value.id : null; + if (current != fileToAdd.folderId) return; - if (this.folders.some(f => f.id == folder.id)) { - const exist = this.folders.map(f => f.id).indexOf(folder.id); - this.folders[exist] = folder; - return; - } + if (files.value.some(f => f.id == fileToAdd.id)) { + const exist = files.value.map(f => f.id).indexOf(fileToAdd.id); + files.value[exist] = fileToAdd; + return; + } - if (unshift) { - this.folders.unshift(folder); - } else { - this.folders.push(folder); - } - }, + if (unshift) { + files.value.unshift(fileToAdd); + } else { + files.value.push(fileToAdd); + } +} - addFile(file, unshift = false) { - const current = this.folder ? this.folder.id : null; - if (current != file.folderId) return; +function removeFolder(folderToRemove: Misskey.entities.DriveFolder | string) { + const folderIdToRemove = typeof folderToRemove === 'object' ? folderToRemove.id : folderToRemove; + folders.value = folders.value.filter(f => f.id != folderIdToRemove); +} - if (this.files.some(f => f.id == file.id)) { - const exist = this.files.map(f => f.id).indexOf(file.id); - this.files[exist] = file; - return; - } +function removeFile(file: Misskey.entities.DriveFile | string) { + const fileId = typeof file === 'object' ? file.id : file; + files.value = files.value.filter(f => f.id != fileId); +} - if (unshift) { - this.files.unshift(file); - } else { - this.files.push(file); - } - }, +function appendFile(file: Misskey.entities.DriveFile) { + addFile(file); +} - removeFolder(folder) { - if (typeof folder == 'object') folder = folder.id; - this.folders = this.folders.filter(f => f.id != folder); - }, +function appendFolder(folderToAppend: Misskey.entities.DriveFolder) { + addFolder(folderToAppend); +} +/* +function prependFile(file: Misskey.entities.DriveFile) { + addFile(file, true); +} - removeFile(file) { - if (typeof file == 'object') file = file.id; - this.files = this.files.filter(f => f.id != file); - }, +function prependFolder(folderToPrepend: Misskey.entities.DriveFolder) { + addFolder(folderToPrepend, true); +} +*/ +function goRoot() { + // 既にrootにいるなら何もしない + if (folder.value == null) return; - appendFile(file) { - this.addFile(file); - }, + folder.value = null; + hierarchyFolders.value = []; + emit('move-root'); + fetch(); +} - appendFolder(folder) { - this.addFolder(folder); - }, +async function fetch() { + folders.value = []; + files.value = []; + moreFolders.value = false; + moreFiles.value = false; + fetching.value = true; - prependFile(file) { - this.addFile(file, true); - }, + const foldersMax = 30; + const filesMax = 30; - prependFolder(folder) { - this.addFolder(folder, true); - }, + const foldersPromise = os.api('drive/folders', { + folderId: folder.value ? folder.value.id : null, + limit: foldersMax + 1 + }).then(fetchedFolders => { + if (fetchedFolders.length == foldersMax + 1) { + moreFolders.value = true; + fetchedFolders.pop(); + } + return fetchedFolders; + }); - goRoot() { - // 既にrootにいるなら何もしない - if (this.folder == null) return; + const filesPromise = os.api('drive/files', { + folderId: folder.value ? folder.value.id : null, + type: props.type, + limit: filesMax + 1 + }).then(fetchedFiles => { + if (fetchedFiles.length == filesMax + 1) { + moreFiles.value = true; + fetchedFiles.pop(); + } + return fetchedFiles; + }); - this.folder = null; - this.hierarchyFolders = []; - this.$emit('move-root'); - this.fetch(); - }, + const [fetchedFolders, fetchedFiles] = await Promise.all([foldersPromise, filesPromise]); - fetch() { - this.folders = []; - this.files = []; - this.moreFolders = false; - this.moreFiles = false; - this.fetching = true; + for (const x of fetchedFolders) appendFolder(x); + for (const x of fetchedFiles) appendFile(x); - let fetchedFolders = null; - let fetchedFiles = null; + fetching.value = false; +} - const foldersMax = 30; - const filesMax = 30; +function fetchMoreFiles() { + fetching.value = true; - // フォルダ一覧取得 - os.api('drive/folders', { - folderId: this.folder ? this.folder.id : null, - limit: foldersMax + 1 - }).then(folders => { - if (folders.length == foldersMax + 1) { - this.moreFolders = true; - folders.pop(); - } - fetchedFolders = folders; - complete(); - }); + const max = 30; - // ファイル一覧取得 - os.api('drive/files', { - folderId: this.folder ? this.folder.id : null, - type: this.type, - limit: filesMax + 1 - }).then(files => { - if (files.length == filesMax + 1) { - this.moreFiles = true; - files.pop(); - } - fetchedFiles = files; - complete(); - }); + // ファイル一覧取得 + os.api('drive/files', { + folderId: folder.value ? folder.value.id : null, + type: props.type, + untilId: files.value[files.value.length - 1].id, + limit: max + 1 + }).then(files => { + if (files.length == max + 1) { + moreFiles.value = true; + files.pop(); + } else { + moreFiles.value = false; + } + for (const x of files) appendFile(x); + fetching.value = false; + }); +} - let flag = false; - const complete = () => { - if (flag) { - for (const x of fetchedFolders) this.appendFolder(x); - for (const x of fetchedFiles) this.appendFile(x); - this.fetching = false; - } else { - flag = true; - } - }; - }, +function getMenu() { + return [{ + text: i18n.locale.addFile, + type: 'label' + }, { + text: i18n.locale.upload, + icon: 'fas fa-upload', + action: () => { selectLocalFile(); } + }, { + text: i18n.locale.fromUrl, + icon: 'fas fa-link', + action: () => { urlUpload(); } + }, null, { + text: folder.value ? folder.value.name : i18n.locale.drive, + type: 'label' + }, folder.value ? { + text: i18n.locale.renameFolder, + icon: 'fas fa-i-cursor', + action: () => { renameFolder(folder.value); } + } : undefined, folder.value ? { + text: i18n.locale.deleteFolder, + icon: 'fas fa-trash-alt', + action: () => { deleteFolder(folder.value as Misskey.entities.DriveFolder); } + } : undefined, { + text: i18n.locale.createFolder, + icon: 'fas fa-folder-plus', + action: () => { createFolder(); } + }]; +} - fetchMoreFiles() { - this.fetching = true; +function showMenu(ev: MouseEvent) { + os.popupMenu(getMenu(), (ev.currentTarget || ev.target || undefined) as HTMLElement | undefined); +} - const max = 30; +function onContextmenu(ev: MouseEvent) { + os.contextMenu(getMenu(), ev); +} - // ファイル一覧取得 - os.api('drive/files', { - folderId: this.folder ? this.folder.id : null, - type: this.type, - untilId: this.files[this.files.length - 1].id, - limit: max + 1 - }).then(files => { - if (files.length == max + 1) { - this.moreFiles = true; - files.pop(); - } else { - this.moreFiles = false; - } - for (const x of files) this.appendFile(x); - this.fetching = false; - }); - }, +onMounted(() => { + if (defaultStore.state.enableInfiniteScroll && loadMoreFiles.value) { + nextTick(() => { + ilFilesObserver.observe(loadMoreFiles.value?.$el) + }); + } - getMenu() { - return [{ - text: this.$ts.addFile, - type: 'label' - }, { - text: this.$ts.upload, - icon: 'fas fa-upload', - action: () => { this.selectLocalFile(); } - }, { - text: this.$ts.fromUrl, - icon: 'fas fa-link', - action: () => { this.urlUpload(); } - }, null, { - text: this.folder ? this.folder.name : this.$ts.drive, - type: 'label' - }, this.folder ? { - text: this.$ts.renameFolder, - icon: 'fas fa-i-cursor', - action: () => { this.renameFolder(this.folder); } - } : undefined, this.folder ? { - text: this.$ts.deleteFolder, - icon: 'fas fa-trash-alt', - action: () => { this.deleteFolder(this.folder); } - } : undefined, { - text: this.$ts.createFolder, - icon: 'fas fa-folder-plus', - action: () => { this.createFolder(); } - }]; - }, + connection.on('fileCreated', onStreamDriveFileCreated); + connection.on('fileUpdated', onStreamDriveFileUpdated); + connection.on('fileDeleted', onStreamDriveFileDeleted); + connection.on('folderCreated', onStreamDriveFolderCreated); + connection.on('folderUpdated', onStreamDriveFolderUpdated); + connection.on('folderDeleted', onStreamDriveFolderDeleted); - showMenu(ev) { - os.popupMenu(this.getMenu(), ev.currentTarget || ev.target); - }, + if (props.initialFolder) { + move(props.initialFolder); + } else { + fetch(); + } +}); - onContextmenu(ev) { - os.contextMenu(this.getMenu(), ev); - }, +onActivated(() => { + if (defaultStore.state.enableInfiniteScroll) { + nextTick(() => { + ilFilesObserver.observe(loadMoreFiles.value?.$el) + }); } }); + +onBeforeUnmount(() => { + connection.dispose(); + ilFilesObserver.disconnect(); +}); </script> <style lang="scss" scoped> diff --git a/packages/client/src/components/emoji-picker-dialog.vue b/packages/client/src/components/emoji-picker-dialog.vue index 51c634dd8e..f06a24636c 100644 --- a/packages/client/src/components/emoji-picker-dialog.vue +++ b/packages/client/src/components/emoji-picker-dialog.vue @@ -1,58 +1,65 @@ <template> -<MkModal ref="modal" v-slot="{ type, maxHeight }" :z-priority="'middle'" :prefer-type="asReactionPicker && $store.state.reactionPickerUseDrawerForMobile === false ? 'popup' : 'auto'" :transparent-bg="true" :manual-showing="manualShowing" :src="src" @click="$refs.modal.close()" @opening="opening" @close="$emit('close')" @closed="$emit('closed')"> - <MkEmojiPicker ref="picker" class="ryghynhb _popup _shadow" :class="{ drawer: type === 'drawer' }" :show-pinned="showPinned" :as-reaction-picker="asReactionPicker" :as-drawer="type === 'drawer'" :max-height="maxHeight" @chosen="chosen"/> +<MkModal + ref="modal" + v-slot="{ type, maxHeight }" + :z-priority="'middle'" + :prefer-type="asReactionPicker && defaultStore.state.reactionPickerUseDrawerForMobile === false ? 'popup' : 'auto'" + :transparent-bg="true" + :manual-showing="manualShowing" + :src="src" + @click="modal?.close()" + @opening="opening" + @close="emit('close')" + @closed="emit('closed')" +> + <MkEmojiPicker + ref="picker" + class="ryghynhb _popup _shadow" + :class="{ drawer: type === 'drawer' }" + :show-pinned="showPinned" + :as-reaction-picker="asReactionPicker" + :as-drawer="type === 'drawer'" + :max-height="maxHeight" + @chosen="chosen" + /> </MkModal> </template> -<script lang="ts"> -import { defineComponent, markRaw } from 'vue'; +<script lang="ts" setup> +import { ref } from 'vue'; import MkModal from '@/components/ui/modal.vue'; import MkEmojiPicker from '@/components/emoji-picker.vue'; +import { defaultStore } from '@/store'; -export default defineComponent({ - components: { - MkModal, - MkEmojiPicker, - }, - - props: { - manualShowing: { - type: Boolean, - required: false, - default: null, - }, - src: { - required: false - }, - showPinned: { - required: false, - default: true - }, - asReactionPicker: { - required: false - }, - }, - - emits: ['done', 'close', 'closed'], +withDefaults(defineProps<{ + manualShowing?: boolean; + src?: HTMLElement; + showPinned?: boolean; + asReactionPicker?: boolean; +}>(), { + manualShowing: false, + showPinned: true, + asReactionPicker: false, +}); - data() { - return { +const emit = defineEmits<{ + (e: 'done', v: any): void; + (e: 'close'): void; + (e: 'closed'): void; +}>(); - }; - }, +const modal = ref<InstanceType<typeof MkModal>>(); +const picker = ref<InstanceType<typeof MkEmojiPicker>>(); - methods: { - chosen(emoji: any) { - this.$emit('done', emoji); - this.$refs.modal.close(); - }, +function chosen(emoji: any) { + emit('done', emoji); + modal.value?.close(); +} - opening() { - this.$refs.picker.reset(); - this.$refs.picker.focus(); - } - } -}); +function opening() { + picker.value?.reset(); + picker.value?.focus(); +} </script> <style lang="scss" scoped> diff --git a/packages/client/src/components/emoji-picker-window.vue b/packages/client/src/components/emoji-picker-window.vue index 0ffa0c1187..4d27fb48ba 100644 --- a/packages/client/src/components/emoji-picker-window.vue +++ b/packages/client/src/components/emoji-picker-window.vue @@ -5,50 +5,33 @@ :can-resize="false" :mini="true" :front="true" - @closed="$emit('closed')" + @closed="emit('closed')" > <MkEmojiPicker :show-pinned="showPinned" :as-reaction-picker="asReactionPicker" @chosen="chosen"/> </MkWindow> </template> -<script lang="ts"> -import { defineComponent, markRaw } from 'vue'; +<script lang="ts" setup> +import { } from 'vue'; import MkWindow from '@/components/ui/window.vue'; import MkEmojiPicker from '@/components/emoji-picker.vue'; -export default defineComponent({ - components: { - MkWindow, - MkEmojiPicker, - }, - - props: { - src: { - required: false - }, - showPinned: { - required: false, - default: true - }, - asReactionPicker: { - required: false - }, - }, - - emits: ['chosen', 'closed'], - - data() { - return { +withDefaults(defineProps<{ + src?: HTMLElement; + showPinned?: boolean; + asReactionPicker?: boolean; +}>(), { + showPinned: true, +}); - }; - }, +const emit = defineEmits<{ + (e: 'chosen', v: any): void; + (e: 'closed'): void; +}>(); - methods: { - chosen(emoji: any) { - this.$emit('chosen', emoji); - }, - } -}); +function chosen(emoji: any) { + emit('chosen', emoji); +} </script> <style lang="scss" scoped> diff --git a/packages/client/src/components/emoji-picker.section.vue b/packages/client/src/components/emoji-picker.section.vue index 08c4f6813d..1026e894d1 100644 --- a/packages/client/src/components/emoji-picker.section.vue +++ b/packages/client/src/components/emoji-picker.section.vue @@ -7,7 +7,7 @@ <button v-for="emoji in emojis" :key="emoji" class="_button" - @click="chosen(emoji, $event)" + @click="emit('chosen', emoji, $event)" > <MkEmoji :emoji="emoji" :normal="true"/> </button> @@ -15,35 +15,19 @@ </section> </template> -<script lang="ts"> -import { defineComponent, markRaw } from 'vue'; -import { getStaticImageUrl } from '@/scripts/get-static-image-url'; +<script lang="ts" setup> +import { ref } from 'vue'; -export default defineComponent({ - props: { - emojis: { - required: true, - }, - initialShown: { - required: false - } - }, +const props = defineProps<{ + emojis: string[]; + initialShown?: boolean; +}>(); - emits: ['chosen'], +const emit = defineEmits<{ + (e: 'chosen', v: string, ev: MouseEvent): void; +}>(); - data() { - return { - getStaticImageUrl, - shown: this.initialShown, - }; - }, - - methods: { - chosen(emoji: any, ev) { - this.$parent.chosen(emoji, ev); - }, - } -}); +const shown = ref(!!props.initialShown); </script> <style lang="scss" scoped> diff --git a/packages/client/src/components/emoji-picker.vue b/packages/client/src/components/emoji-picker.vue index a8eed1ca21..96670fa58c 100644 --- a/packages/client/src/components/emoji-picker.vue +++ b/packages/client/src/components/emoji-picker.vue @@ -1,18 +1,18 @@ <template> -<div class="omfetrab" :class="['w' + width, 'h' + height, { big, asDrawer }]" :style="{ maxHeight: maxHeight ? maxHeight + 'px' : null }"> - <input ref="search" v-model.trim="q" class="search" data-prevent-emoji-insert :class="{ filled: q != null && q != '' }" :placeholder="$ts.search" @paste.stop="paste" @keyup.enter="done()"> +<div class="omfetrab" :class="['w' + width, 'h' + height, { big, asDrawer }]" :style="{ maxHeight: maxHeight ? maxHeight + 'px' : undefined }"> + <input ref="search" v-model.trim="q" class="search" data-prevent-emoji-insert :class="{ filled: q != null && q != '' }" :placeholder="i18n.locale.search" @paste.stop="paste" @keyup.enter="done()"> <div ref="emojis" class="emojis"> <section class="result"> <div v-if="searchResultCustom.length > 0"> <button v-for="emoji in searchResultCustom" - :key="emoji" + :key="emoji.id" class="_button" :title="emoji.name" tabindex="0" @click="chosen(emoji, $event)" > - <MkEmoji v-if="emoji.char != null" :emoji="emoji.char"/> - <img v-else :src="$store.state.disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url"/> + <!--<MkEmoji v-if="emoji.char != null" :emoji="emoji.char"/>--> + <img :src="disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url"/> </button> </div> <div v-if="searchResultUnicode.length > 0"> @@ -43,9 +43,9 @@ </section> <section> - <header class="_acrylic"><i class="far fa-clock fa-fw"></i> {{ $ts.recentUsed }}</header> + <header class="_acrylic"><i class="far fa-clock fa-fw"></i> {{ i18n.locale.recentUsed }}</header> <div> - <button v-for="emoji in $store.state.recentlyUsedEmojis" + <button v-for="emoji in recentlyUsedEmojis" :key="emoji" class="_button" @click="chosen(emoji, $event)" @@ -56,12 +56,12 @@ </section> </div> <div> - <header class="_acrylic">{{ $ts.customEmojis }}</header> - <XSection v-for="category in customEmojiCategories" :key="'custom:' + category" :initial-shown="false" :emojis="customEmojis.filter(e => e.category === category).map(e => ':' + e.name + ':')">{{ category || $ts.other }}</XSection> + <header class="_acrylic">{{ i18n.locale.customEmojis }}</header> + <XSection v-for="category in customEmojiCategories" :key="'custom:' + category" :initial-shown="false" :emojis="customEmojis.filter(e => e.category === category).map(e => ':' + e.name + ':')" @chosen="chosen">{{ category || i18n.locale.other }}</XSection> </div> <div> - <header class="_acrylic">{{ $ts.emoji }}</header> - <XSection v-for="category in categories" :emojis="emojilist.filter(e => e.category === category).map(e => e.char)">{{ category }}</XSection> + <header class="_acrylic">{{ i18n.locale.emoji }}</header> + <XSection v-for="category in categories" :emojis="emojilist.filter(e => e.category === category).map(e => e.char)" @chosen="chosen">{{ category }}</XSection> </div> </div> <div class="tabs"> @@ -73,277 +73,272 @@ </div> </template> -<script lang="ts"> -import { defineComponent, markRaw } from 'vue'; -import { emojilist } from '@/scripts/emojilist'; +<script lang="ts" setup> +import { ref, computed, watch, onMounted } from 'vue'; +import * as Misskey from 'misskey-js'; +import { emojilist, UnicodeEmojiDef, unicodeEmojiCategories as categories } from '@/scripts/emojilist'; import { getStaticImageUrl } from '@/scripts/get-static-image-url'; import Ripple from '@/components/ripple.vue'; import * as os from '@/os'; import { isTouchUsing } from '@/scripts/touch'; import { isMobile } from '@/scripts/is-mobile'; -import { emojiCategories } from '@/instance'; +import { emojiCategories, instance } from '@/instance'; import XSection from './emoji-picker.section.vue'; +import { i18n } from '@/i18n'; +import { defaultStore } from '@/store'; -export default defineComponent({ - components: { - XSection - }, - - props: { - showPinned: { - required: false, - default: true, - }, - asReactionPicker: { - required: false, - }, - maxHeight: { - type: Number, - required: false, - }, - asDrawer: { - type: Boolean, - required: false - }, - }, +const props = withDefaults(defineProps<{ + showPinned?: boolean; + asReactionPicker?: boolean; + maxHeight?: number; + asDrawer?: boolean; +}>(), { + showPinned: true, +}); - emits: ['chosen'], +const emit = defineEmits<{ + (e: 'chosen', v: string): void; +}>(); - data() { - return { - emojilist: markRaw(emojilist), - getStaticImageUrl, - pinned: this.$store.reactiveState.reactions, - width: this.asReactionPicker ? this.$store.state.reactionPickerWidth : 3, - height: this.asReactionPicker ? this.$store.state.reactionPickerHeight : 2, - big: this.asReactionPicker ? isTouchUsing : false, - customEmojiCategories: emojiCategories, - customEmojis: this.$instance.emojis, - q: null, - searchResultCustom: [], - searchResultUnicode: [], - tab: 'index', - categories: ['face', 'people', 'animals_and_nature', 'food_and_drink', 'activity', 'travel_and_places', 'objects', 'symbols', 'flags'], - }; - }, +const search = ref<HTMLInputElement>(); +const emojis = ref<HTMLDivElement>(); - watch: { - q() { - this.$refs.emojis.scrollTop = 0; +const { + reactions: pinned, + reactionPickerWidth, + reactionPickerHeight, + disableShowingAnimatedImages, + recentlyUsedEmojis, +} = defaultStore.reactiveState; - if (this.q == null || this.q === '') { - this.searchResultCustom = []; - this.searchResultUnicode = []; - return; - } +const width = computed(() => props.asReactionPicker ? reactionPickerWidth.value : 3); +const height = computed(() => props.asReactionPicker ? reactionPickerHeight.value : 2); +const big = props.asReactionPicker ? isTouchUsing : false; +const customEmojiCategories = emojiCategories; +const customEmojis = instance.emojis; +const q = ref<string | null>(null); +const searchResultCustom = ref<Misskey.entities.CustomEmoji[]>([]); +const searchResultUnicode = ref<UnicodeEmojiDef[]>([]); +const tab = ref<'index' | 'custom' | 'unicode' | 'tags'>('index'); - const q = this.q.replace(/:/g, ''); +watch(q, () => { + if (emojis.value) emojis.value.scrollTop = 0; - const searchCustom = () => { - const max = 8; - const emojis = this.customEmojis; - const matches = new Set(); + if (q.value == null || q.value === '') { + searchResultCustom.value = []; + searchResultUnicode.value = []; + return; + } - const exactMatch = emojis.find(e => e.name === q); - if (exactMatch) matches.add(exactMatch); + const newQ = q.value.replace(/:/g, ''); - if (q.includes(' ')) { // AND検索 - const keywords = q.split(' '); + const searchCustom = () => { + const max = 8; + const emojis = customEmojis; + const matches = new Set<Misskey.entities.CustomEmoji>(); - // 名前にキーワードが含まれている - for (const emoji of emojis) { - if (keywords.every(keyword => emoji.name.includes(keyword))) { - matches.add(emoji); - if (matches.size >= max) break; - } - } - if (matches.size >= max) return matches; + const exactMatch = emojis.find(e => e.name === newQ); + if (exactMatch) matches.add(exactMatch); - // 名前またはエイリアスにキーワードが含まれている - for (const emoji of emojis) { - if (keywords.every(keyword => emoji.name.includes(keyword) || emoji.aliases.some(alias => alias.includes(keyword)))) { - matches.add(emoji); - if (matches.size >= max) break; - } - } - } else { - for (const emoji of emojis) { - if (emoji.name.startsWith(q)) { - matches.add(emoji); - if (matches.size >= max) break; - } - } - if (matches.size >= max) return matches; + if (newQ.includes(' ')) { // AND検索 + const keywords = newQ.split(' '); - for (const emoji of emojis) { - if (emoji.aliases.some(alias => alias.startsWith(q))) { - matches.add(emoji); - if (matches.size >= max) break; - } - } - if (matches.size >= max) return matches; + // 名前にキーワードが含まれている + for (const emoji of emojis) { + if (keywords.every(keyword => emoji.name.includes(keyword))) { + matches.add(emoji); + if (matches.size >= max) break; + } + } + if (matches.size >= max) return matches; - for (const emoji of emojis) { - if (emoji.name.includes(q)) { - matches.add(emoji); - if (matches.size >= max) break; - } - } - if (matches.size >= max) return matches; + // 名前またはエイリアスにキーワードが含まれている + for (const emoji of emojis) { + if (keywords.every(keyword => emoji.name.includes(keyword) || emoji.aliases.some(alias => alias.includes(keyword)))) { + matches.add(emoji); + if (matches.size >= max) break; + } + } + } else { + for (const emoji of emojis) { + if (emoji.name.startsWith(newQ)) { + matches.add(emoji); + if (matches.size >= max) break; + } + } + if (matches.size >= max) return matches; - for (const emoji of emojis) { - if (emoji.aliases.some(alias => alias.includes(q))) { - matches.add(emoji); - if (matches.size >= max) break; - } - } + for (const emoji of emojis) { + if (emoji.aliases.some(alias => alias.startsWith(newQ))) { + matches.add(emoji); + if (matches.size >= max) break; } + } + if (matches.size >= max) return matches; - return matches; - }; + for (const emoji of emojis) { + if (emoji.name.includes(newQ)) { + matches.add(emoji); + if (matches.size >= max) break; + } + } + if (matches.size >= max) return matches; - const searchUnicode = () => { - const max = 8; - const emojis = this.emojilist; - const matches = new Set(); + for (const emoji of emojis) { + if (emoji.aliases.some(alias => alias.includes(newQ))) { + matches.add(emoji); + if (matches.size >= max) break; + } + } + } - const exactMatch = emojis.find(e => e.name === q); - if (exactMatch) matches.add(exactMatch); + return matches; + }; - if (q.includes(' ')) { // AND検索 - const keywords = q.split(' '); + const searchUnicode = () => { + const max = 8; + const emojis = emojilist; + const matches = new Set<UnicodeEmojiDef>(); - // 名前にキーワードが含まれている - for (const emoji of emojis) { - if (keywords.every(keyword => emoji.name.includes(keyword))) { - matches.add(emoji); - if (matches.size >= max) break; - } - } - if (matches.size >= max) return matches; + const exactMatch = emojis.find(e => e.name === newQ); + if (exactMatch) matches.add(exactMatch); - // 名前またはエイリアスにキーワードが含まれている - for (const emoji of emojis) { - if (keywords.every(keyword => emoji.name.includes(keyword) || emoji.keywords.some(alias => alias.includes(keyword)))) { - matches.add(emoji); - if (matches.size >= max) break; - } - } - } else { - for (const emoji of emojis) { - if (emoji.name.startsWith(q)) { - matches.add(emoji); - if (matches.size >= max) break; - } - } - if (matches.size >= max) return matches; + if (newQ.includes(' ')) { // AND検索 + const keywords = newQ.split(' '); - for (const emoji of emojis) { - if (emoji.keywords.some(keyword => keyword.startsWith(q))) { - matches.add(emoji); - if (matches.size >= max) break; - } - } - if (matches.size >= max) return matches; + // 名前にキーワードが含まれている + for (const emoji of emojis) { + if (keywords.every(keyword => emoji.name.includes(keyword))) { + matches.add(emoji); + if (matches.size >= max) break; + } + } + if (matches.size >= max) return matches; - for (const emoji of emojis) { - if (emoji.name.includes(q)) { - matches.add(emoji); - if (matches.size >= max) break; - } - } - if (matches.size >= max) return matches; + // 名前またはエイリアスにキーワードが含まれている + for (const emoji of emojis) { + if (keywords.every(keyword => emoji.name.includes(keyword) || emoji.keywords.some(alias => alias.includes(keyword)))) { + matches.add(emoji); + if (matches.size >= max) break; + } + } + } else { + for (const emoji of emojis) { + if (emoji.name.startsWith(newQ)) { + matches.add(emoji); + if (matches.size >= max) break; + } + } + if (matches.size >= max) return matches; - for (const emoji of emojis) { - if (emoji.keywords.some(keyword => keyword.includes(q))) { - matches.add(emoji); - if (matches.size >= max) break; - } - } + for (const emoji of emojis) { + if (emoji.keywords.some(keyword => keyword.startsWith(newQ))) { + matches.add(emoji); + if (matches.size >= max) break; } + } + if (matches.size >= max) return matches; - return matches; - }; + for (const emoji of emojis) { + if (emoji.name.includes(newQ)) { + matches.add(emoji); + if (matches.size >= max) break; + } + } + if (matches.size >= max) return matches; - this.searchResultCustom = Array.from(searchCustom()); - this.searchResultUnicode = Array.from(searchUnicode()); + for (const emoji of emojis) { + if (emoji.keywords.some(keyword => keyword.includes(newQ))) { + matches.add(emoji); + if (matches.size >= max) break; + } + } } - }, - mounted() { - this.focus(); - }, + return matches; + }; - methods: { - focus() { - if (!isMobile && !isTouchUsing) { - this.$refs.search.focus({ - preventScroll: true - }); - } - }, + searchResultCustom.value = Array.from(searchCustom()); + searchResultUnicode.value = Array.from(searchUnicode()); +}); - reset() { - this.$refs.emojis.scrollTop = 0; - this.q = ''; - }, +function focus() { + if (!isMobile && !isTouchUsing) { + search.value?.focus({ + preventScroll: true + }); + } +} - getKey(emoji: any) { - return typeof emoji === 'string' ? emoji : (emoji.char || `:${emoji.name}:`); - }, +function reset() { + if (emojis.value) emojis.value.scrollTop = 0; + q.value = ''; +} - chosen(emoji: any, ev) { - if (ev) { - const el = ev.currentTarget || ev.target; - const rect = el.getBoundingClientRect(); - const x = rect.left + (el.offsetWidth / 2); - const y = rect.top + (el.offsetHeight / 2); - os.popup(Ripple, { x, y }, {}, 'end'); - } +function getKey(emoji: string | Misskey.entities.CustomEmoji | UnicodeEmojiDef): string { + return typeof emoji === 'string' ? emoji : (emoji.char || `:${emoji.name}:`); +} - const key = this.getKey(emoji); - this.$emit('chosen', key); +function chosen(emoji: any, ev?: MouseEvent) { + const el = ev && (ev.currentTarget || ev.target) as HTMLElement | null | undefined; + if (el) { + const rect = el.getBoundingClientRect(); + const x = rect.left + (el.offsetWidth / 2); + const y = rect.top + (el.offsetHeight / 2); + os.popup(Ripple, { x, y }, {}, 'end'); + } - // 最近使った絵文字更新 - if (!this.pinned.includes(key)) { - let recents = this.$store.state.recentlyUsedEmojis; - recents = recents.filter((e: any) => e !== key); - recents.unshift(key); - this.$store.set('recentlyUsedEmojis', recents.splice(0, 32)); - } - }, + const key = getKey(emoji); + emit('chosen', key); - paste(event) { - const paste = (event.clipboardData || window.clipboardData).getData('text'); - if (this.done(paste)) { - event.preventDefault(); - } - }, + // 最近使った絵文字更新 + if (!pinned.value.includes(key)) { + let recents = defaultStore.state.recentlyUsedEmojis; + recents = recents.filter((e: any) => e !== key); + recents.unshift(key); + defaultStore.set('recentlyUsedEmojis', recents.splice(0, 32)); + } +} - done(query) { - if (query == null) query = this.q; - if (query == null) return; - const q = query.replace(/:/g, ''); - const exactMatchCustom = this.customEmojis.find(e => e.name === q); - if (exactMatchCustom) { - this.chosen(exactMatchCustom); - return true; - } - const exactMatchUnicode = this.emojilist.find(e => e.char === q || e.name === q); - if (exactMatchUnicode) { - this.chosen(exactMatchUnicode); - return true; - } - if (this.searchResultCustom.length > 0) { - this.chosen(this.searchResultCustom[0]); - return true; - } - if (this.searchResultUnicode.length > 0) { - this.chosen(this.searchResultUnicode[0]); - return true; - } - }, +function paste(event: ClipboardEvent) { + const paste = (event.clipboardData || window.clipboardData).getData('text'); + if (done(paste)) { + event.preventDefault(); } +} + +function done(query?: any): boolean | void { + if (query == null) query = q.value; + if (query == null || typeof query !== 'string') return; + + const q2 = query.replace(/:/g, ''); + const exactMatchCustom = customEmojis.find(e => e.name === q2); + if (exactMatchCustom) { + chosen(exactMatchCustom); + return true; + } + const exactMatchUnicode = emojilist.find(e => e.char === q2 || e.name === q2); + if (exactMatchUnicode) { + chosen(exactMatchUnicode); + return true; + } + if (searchResultCustom.value.length > 0) { + chosen(searchResultCustom.value[0]); + return true; + } + if (searchResultUnicode.value.length > 0) { + chosen(searchResultUnicode.value[0]); + return true; + } +} + +onMounted(() => { + focus(); +}); + +defineExpose({ + focus, + reset, }); </script> diff --git a/packages/client/src/components/featured-photos.vue b/packages/client/src/components/featured-photos.vue index af5892c98e..e58b5d2849 100644 --- a/packages/client/src/components/featured-photos.vue +++ b/packages/client/src/components/featured-photos.vue @@ -2,25 +2,15 @@ <div v-if="meta" class="xfbouadm" :style="{ backgroundImage: `url(${ meta.backgroundImageUrl })` }"></div> </template> -<script lang="ts"> -import { defineComponent } from 'vue'; +<script lang="ts" setup> +import { ref } from 'vue'; +import * as Misskey from 'misskey-js'; import * as os from '@/os'; -export default defineComponent({ - components: { - }, +const meta = ref<Misskey.entities.DetailedInstanceMetadata>(); - data() { - return { - meta: null, - }; - }, - - created() { - os.api('meta', { detail: true }).then(meta => { - this.meta = meta; - }); - }, +os.api('meta', { detail: true }).then(gotMeta => { + meta.value = gotMeta; }); </script> diff --git a/packages/client/src/components/follow-button.vue b/packages/client/src/components/follow-button.vue index b16b22f26f..345edb6441 100644 --- a/packages/client/src/components/follow-button.vue +++ b/packages/client/src/components/follow-button.vue @@ -6,129 +6,110 @@ > <template v-if="!wait"> <template v-if="hasPendingFollowRequestFromYou && user.isLocked"> - <span v-if="full">{{ $ts.followRequestPending }}</span><i class="fas fa-hourglass-half"></i> + <span v-if="full">{{ i18n.locale.followRequestPending }}</span><i class="fas fa-hourglass-half"></i> </template> <template v-else-if="hasPendingFollowRequestFromYou && !user.isLocked"> <!-- つまりリモートフォローの場合。 --> - <span v-if="full">{{ $ts.processing }}</span><i class="fas fa-spinner fa-pulse"></i> + <span v-if="full">{{ i18n.locale.processing }}</span><i class="fas fa-spinner fa-pulse"></i> </template> <template v-else-if="isFollowing"> - <span v-if="full">{{ $ts.unfollow }}</span><i class="fas fa-minus"></i> + <span v-if="full">{{ i18n.locale.unfollow }}</span><i class="fas fa-minus"></i> </template> <template v-else-if="!isFollowing && user.isLocked"> - <span v-if="full">{{ $ts.followRequest }}</span><i class="fas fa-plus"></i> + <span v-if="full">{{ i18n.locale.followRequest }}</span><i class="fas fa-plus"></i> </template> <template v-else-if="!isFollowing && !user.isLocked"> - <span v-if="full">{{ $ts.follow }}</span><i class="fas fa-plus"></i> + <span v-if="full">{{ i18n.locale.follow }}</span><i class="fas fa-plus"></i> </template> </template> <template v-else> - <span v-if="full">{{ $ts.processing }}</span><i class="fas fa-spinner fa-pulse fa-fw"></i> + <span v-if="full">{{ i18n.locale.processing }}</span><i class="fas fa-spinner fa-pulse fa-fw"></i> </template> </button> </template> -<script lang="ts"> -import { defineComponent, markRaw } from 'vue'; +<script lang="ts" setup> +import { onBeforeUnmount, onMounted, ref } from 'vue'; +import * as Misskey from 'misskey-js'; import * as os from '@/os'; import { stream } from '@/stream'; +import { i18n } from '@/i18n'; -export default defineComponent({ - props: { - user: { - type: Object, - required: true - }, - full: { - type: Boolean, - required: false, - default: false, - }, - large: { - type: Boolean, - required: false, - default: false, - }, - }, - - data() { - return { - isFollowing: this.user.isFollowing, - hasPendingFollowRequestFromYou: this.user.hasPendingFollowRequestFromYou, - wait: false, - connection: null, - }; - }, - - created() { - // 渡されたユーザー情報が不完全な場合 - if (this.user.isFollowing == null) { - os.api('users/show', { - userId: this.user.id - }).then(u => { - this.isFollowing = u.isFollowing; - this.hasPendingFollowRequestFromYou = u.hasPendingFollowRequestFromYou; - }); - } - }, - - mounted() { - this.connection = markRaw(stream.useChannel('main')); +const props = withDefaults(defineProps<{ + user: Misskey.entities.UserDetailed, + full?: boolean, + large?: boolean, +}>(), { + full: false, + large: false, +}); - this.connection.on('follow', this.onFollowChange); - this.connection.on('unfollow', this.onFollowChange); - }, +const isFollowing = ref(props.user.isFollowing); +const hasPendingFollowRequestFromYou = ref(props.user.hasPendingFollowRequestFromYou); +const wait = ref(false); +const connection = stream.useChannel('main'); - beforeUnmount() { - this.connection.dispose(); - }, +if (props.user.isFollowing == null) { + os.api('users/show', { + userId: props.user.id + }).then(u => { + isFollowing.value = u.isFollowing; + hasPendingFollowRequestFromYou.value = u.hasPendingFollowRequestFromYou; + }); +} - methods: { - onFollowChange(user) { - if (user.id == this.user.id) { - this.isFollowing = user.isFollowing; - this.hasPendingFollowRequestFromYou = user.hasPendingFollowRequestFromYou; - } - }, +function onFollowChange(user: Misskey.entities.UserDetailed) { + if (user.id == props.user.id) { + isFollowing.value = user.isFollowing; + hasPendingFollowRequestFromYou.value = user.hasPendingFollowRequestFromYou; + } +} - async onClick() { - this.wait = true; +async function onClick() { + wait.value = true; - try { - if (this.isFollowing) { - const { canceled } = await os.confirm({ - type: 'warning', - text: this.$t('unfollowConfirm', { name: this.user.name || this.user.username }), - }); + try { + if (isFollowing.value) { + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.t('unfollowConfirm', { name: props.user.name || props.user.username }), + }); - if (canceled) return; + if (canceled) return; - await os.api('following/delete', { - userId: this.user.id - }); - } else { - if (this.hasPendingFollowRequestFromYou) { - await os.api('following/requests/cancel', { - userId: this.user.id - }); - } else if (this.user.isLocked) { - await os.api('following/create', { - userId: this.user.id - }); - this.hasPendingFollowRequestFromYou = true; - } else { - await os.api('following/create', { - userId: this.user.id - }); - this.hasPendingFollowRequestFromYou = true; - } - } - } catch (e) { - console.error(e); - } finally { - this.wait = false; + await os.api('following/delete', { + userId: props.user.id + }); + } else { + if (hasPendingFollowRequestFromYou.value) { + await os.api('following/requests/cancel', { + userId: props.user.id + }); + } else if (props.user.isLocked) { + await os.api('following/create', { + userId: props.user.id + }); + hasPendingFollowRequestFromYou.value = true; + } else { + await os.api('following/create', { + userId: props.user.id + }); + hasPendingFollowRequestFromYou.value = true; } } + } catch (e) { + console.error(e); + } finally { + wait.value = false; } +} + +onMounted(() => { + connection.on('follow', onFollowChange); + connection.on('unfollow', onFollowChange); +}); + +onBeforeUnmount(() => { + connection.dispose(); }); </script> diff --git a/packages/client/src/components/forgot-password.vue b/packages/client/src/components/forgot-password.vue index b03a6133b4..c74e1ac75e 100644 --- a/packages/client/src/components/forgot-password.vue +++ b/packages/client/src/components/forgot-password.vue @@ -2,72 +2,64 @@ <XModalWindow ref="dialog" :width="370" :height="400" - @close="$refs.dialog.close()" - @closed="$emit('closed')" + @close="dialog.close()" + @closed="emit('closed')" > - <template #header>{{ $ts.forgotPassword }}</template> + <template #header>{{ i18n.locale.forgotPassword }}</template> - <form v-if="$instance.enableEmail" class="bafeceda" @submit.prevent="onSubmit"> + <form v-if="instance.enableEmail" class="bafeceda" @submit.prevent="onSubmit"> <div class="main _formRoot"> <MkInput v-model="username" class="_formBlock" type="text" pattern="^[a-zA-Z0-9_]+$" spellcheck="false" autofocus required> - <template #label>{{ $ts.username }}</template> + <template #label>{{ i18n.locale.username }}</template> <template #prefix>@</template> </MkInput> <MkInput v-model="email" class="_formBlock" type="email" spellcheck="false" required> - <template #label>{{ $ts.emailAddress }}</template> - <template #caption>{{ $ts._forgotPassword.enterEmail }}</template> + <template #label>{{ i18n.locale.emailAddress }}</template> + <template #caption>{{ i18n.locale._forgotPassword.enterEmail }}</template> </MkInput> - <MkButton class="_formBlock" type="submit" :disabled="processing" primary style="margin: 0 auto;">{{ $ts.send }}</MkButton> + <MkButton class="_formBlock" type="submit" :disabled="processing" primary style="margin: 0 auto;">{{ i18n.locale.send }}</MkButton> </div> <div class="sub"> - <MkA to="/about" class="_link">{{ $ts._forgotPassword.ifNoEmail }}</MkA> + <MkA to="/about" class="_link">{{ i18n.locale._forgotPassword.ifNoEmail }}</MkA> </div> </form> - <div v-else> - {{ $ts._forgotPassword.contactAdmin }} + <div v-else class="bafecedb"> + {{ i18n.locale._forgotPassword.contactAdmin }} </div> </XModalWindow> </template> -<script lang="ts"> -import { defineComponent } from 'vue'; +<script lang="ts" setup> +import { } from 'vue'; import XModalWindow from '@/components/ui/modal-window.vue'; import MkButton from '@/components/ui/button.vue'; import MkInput from '@/components/form/input.vue'; import * as os from '@/os'; +import { instance } from '@/instance'; +import { i18n } from '@/i18n'; -export default defineComponent({ - components: { - XModalWindow, - MkButton, - MkInput, - }, +const emit = defineEmits<{ + (e: 'done'): void; + (e: 'closed'): void; +}>(); - emits: ['done', 'closed'], +let dialog: InstanceType<typeof XModalWindow> = $ref(); - data() { - return { - username: '', - email: '', - processing: false, - }; - }, +let username = $ref(''); +let email = $ref(''); +let processing = $ref(false); - methods: { - async onSubmit() { - this.processing = true; - await os.apiWithDialog('request-reset-password', { - username: this.username, - email: this.email, - }); - - this.$emit('done'); - this.$refs.dialog.close(); - } - } -}); +async function onSubmit() { + processing = true; + await os.apiWithDialog('request-reset-password', { + username, + email, + }); + emit('done'); + dialog.close(); +} </script> <style lang="scss" scoped> @@ -81,4 +73,8 @@ export default defineComponent({ padding: 24px; } } + +.bafecedb { + padding: 24px; +} </style> diff --git a/packages/client/src/components/global/avatar.vue b/packages/client/src/components/global/avatar.vue index 9e8979fe56..27cfb6e4d4 100644 --- a/packages/client/src/components/global/avatar.vue +++ b/packages/client/src/components/global/avatar.vue @@ -35,9 +35,9 @@ const emit = defineEmits<{ (e: 'click', ev: MouseEvent): void; }>(); -const url = defaultStore.state.disableShowingAnimatedImages +const url = $computed(() => defaultStore.state.disableShowingAnimatedImages ? getStaticImageUrl(props.user.avatarUrl) - : props.user.avatarUrl; + : props.user.avatarUrl); function onClick(ev: MouseEvent) { emit('click', ev); diff --git a/packages/client/src/components/modal-page-window.vue b/packages/client/src/components/modal-page-window.vue index 3de1980820..2e17d5d030 100644 --- a/packages/client/src/components/modal-page-window.vue +++ b/packages/client/src/components/modal-page-window.vue @@ -153,8 +153,8 @@ export default defineComponent({ this.$refs.window.close(); }, - onContextmenu(e) { - os.contextMenu(this.contextmenu, e); + onContextmenu(ev: MouseEvent) { + os.contextMenu(this.contextmenu, ev); } }, }); diff --git a/packages/client/src/components/note-detailed.vue b/packages/client/src/components/note-detailed.vue index 07e9920f65..a3b30f726e 100644 --- a/packages/client/src/components/note-detailed.vue +++ b/packages/client/src/components/note-detailed.vue @@ -4,6 +4,7 @@ v-show="!isDeleted" v-hotkey="keymap" v-size="{ max: [500, 450, 350, 300] }" + ref="el" class="lxwezrsl _block" :tabindex="!isDeleted ? '-1' : null" :class="{ renote: isRenote }" @@ -222,21 +223,21 @@ function undoReact(note): void { }); } -function onContextmenu(e): void { +function onContextmenu(ev: MouseEvent): void { const isLink = (el: HTMLElement) => { if (el.tagName === 'A') return true; if (el.parentElement) { return isLink(el.parentElement); } }; - if (isLink(e.target)) return; + if (isLink(ev.target)) return; if (window.getSelection().toString() !== '') return; if (defaultStore.state.useReactionPickerForContextMenu) { - e.preventDefault(); + ev.preventDefault(); react(); } else { - os.contextMenu(getNoteMenu({ note: props.note, translating, translation, menuButton }), e).then(focus); + os.contextMenu(getNoteMenu({ note: props.note, translating, translation, menuButton }), ev).then(focus); } } diff --git a/packages/client/src/components/note.vue b/packages/client/src/components/note.vue index b309afe051..fc89c2777b 100644 --- a/packages/client/src/components/note.vue +++ b/packages/client/src/components/note.vue @@ -211,21 +211,21 @@ function undoReact(note): void { }); } -function onContextmenu(e): void { +function onContextmenu(ev: MouseEvent): void { const isLink = (el: HTMLElement) => { if (el.tagName === 'A') return true; if (el.parentElement) { return isLink(el.parentElement); } }; - if (isLink(e.target)) return; + if (isLink(ev.target)) return; if (window.getSelection().toString() !== '') return; if (defaultStore.state.useReactionPickerForContextMenu) { - e.preventDefault(); + ev.preventDefault(); react(); } else { - os.contextMenu(getNoteMenu({ note: props.note, translating, translation, menuButton }), e).then(focus); + os.contextMenu(getNoteMenu({ note: props.note, translating, translation, menuButton }), ev).then(focus); } } diff --git a/packages/client/src/components/notes.vue b/packages/client/src/components/notes.vue index aec478ac95..41bec5a579 100644 --- a/packages/client/src/components/notes.vue +++ b/packages/client/src/components/notes.vue @@ -32,9 +32,7 @@ const props = defineProps<{ const pagingComponent = ref<InstanceType<typeof MkPagination>>(); defineExpose({ - prepend: (note) => { - pagingComponent.value?.prepend(note); - }, + pagingComponent, }); </script> diff --git a/packages/client/src/components/post-form-attaches.vue b/packages/client/src/components/post-form-attaches.vue index 0782ce22e5..0c8181b481 100644 --- a/packages/client/src/components/post-form-attaches.vue +++ b/packages/client/src/components/post-form-attaches.vue @@ -10,7 +10,7 @@ </div> </template> </XDraggable> - <p class="remain">{{ 4 - files.length }}/4</p> + <p class="remain">{{ 16 - files.length }}/16</p> </div> </template> @@ -41,7 +41,6 @@ export default defineComponent({ data() { return { menu: null as Promise<null> | null, - }; }, @@ -99,10 +98,12 @@ export default defineComponent({ }, { done: result => { if (!result || result.canceled) return; - let comment = result.result; + let comment = result.result.length == 0 ? null : result.result; os.api('drive/files/update', { fileId: file.id, - comment: comment.length == 0 ? null : comment + comment: comment, + }).then(() => { + file.comment = comment; }); } }, 'closed'); diff --git a/packages/client/src/components/post-form.vue b/packages/client/src/components/post-form.vue index 3fcb1d906b..0dcec26932 100644 --- a/packages/client/src/components/post-form.vue +++ b/packages/client/src/components/post-form.vue @@ -8,25 +8,28 @@ > <header> <button v-if="!fixed" class="cancel _button" @click="cancel"><i class="fas fa-times"></i></button> + <button v-click-anime v-tooltip="i18n.locale.switchAccount" class="account _button" @click="openAccountMenu"> + <MkAvatar :user="postAccount ?? $i" class="avatar"/> + </button> <div> <span class="text-count" :class="{ over: textLength > maxTextLength }">{{ maxTextLength - textLength }}</span> <span v-if="localOnly" class="local-only"><i class="fas fa-biohazard"></i></span> - <button ref="visibilityButton" v-tooltip="$ts.visibility" class="_button visibility" :disabled="channel != null" @click="setVisibility"> + <button ref="visibilityButton" v-tooltip="i18n.locale.visibility" class="_button visibility" :disabled="channel != null" @click="setVisibility"> <span v-if="visibility === 'public'"><i class="fas fa-globe"></i></span> <span v-if="visibility === 'home'"><i class="fas fa-home"></i></span> <span v-if="visibility === 'followers'"><i class="fas fa-unlock"></i></span> <span v-if="visibility === 'specified'"><i class="fas fa-envelope"></i></span> </button> - <button v-tooltip="$ts.previewNoteText" class="_button preview" :class="{ active: showPreview }" @click="showPreview = !showPreview"><i class="fas fa-file-code"></i></button> + <button v-tooltip="i18n.locale.previewNoteText" class="_button preview" :class="{ active: showPreview }" @click="showPreview = !showPreview"><i class="fas fa-file-code"></i></button> <button class="submit _buttonGradate" :disabled="!canPost" data-cy-open-post-form-submit @click="post">{{ submitText }}<i :class="reply ? 'fas fa-reply' : renote ? 'fas fa-quote-right' : 'fas fa-paper-plane'"></i></button> </div> </header> <div class="form" :class="{ fixed }"> <XNoteSimple v-if="reply" class="preview" :note="reply"/> <XNoteSimple v-if="renote" class="preview" :note="renote"/> - <div v-if="quoteId" class="with-quote"><i class="fas fa-quote-left"></i> {{ $ts.quoteAttached }}<button @click="quoteId = null"><i class="fas fa-times"></i></button></div> + <div v-if="quoteId" class="with-quote"><i class="fas fa-quote-left"></i> {{ i18n.locale.quoteAttached }}<button @click="quoteId = null"><i class="fas fa-times"></i></button></div> <div v-if="visibility === 'specified'" class="to-specified"> - <span style="margin-right: 8px;">{{ $ts.recipient }}</span> + <span style="margin-right: 8px;">{{ i18n.locale.recipient }}</span> <div class="visibleUsers"> <span v-for="u in visibleUsers" :key="u.id"> <MkAcct :user="u"/> @@ -35,21 +38,21 @@ <button class="_buttonPrimary" @click="addVisibleUser"><i class="fas fa-plus fa-fw"></i></button> </div> </div> - <MkInfo v-if="hasNotSpecifiedMentions" warn class="hasNotSpecifiedMentions">{{ $ts.notSpecifiedMentionWarning }} - <button class="_textButton" @click="addMissingMention()">{{ $ts.add }}</button></MkInfo> - <input v-show="useCw" ref="cwInputEl" v-model="cw" class="cw" :placeholder="$ts.annotation" @keydown="onKeydown"> + <MkInfo v-if="hasNotSpecifiedMentions" warn class="hasNotSpecifiedMentions">{{ i18n.locale.notSpecifiedMentionWarning }} - <button class="_textButton" @click="addMissingMention()">{{ i18n.locale.add }}</button></MkInfo> + <input v-show="useCw" ref="cwInputEl" v-model="cw" class="cw" :placeholder="i18n.locale.annotation" @keydown="onKeydown"> <textarea ref="textareaEl" v-model="text" class="text" :class="{ withCw: useCw }" :disabled="posting" :placeholder="placeholder" data-cy-post-form-text @keydown="onKeydown" @paste="onPaste" @compositionupdate="onCompositionUpdate" @compositionend="onCompositionEnd"/> - <input v-show="withHashtags" ref="hashtagsInputEl" v-model="hashtags" class="hashtags" :placeholder="$ts.hashtags" list="hashtags"> + <input v-show="withHashtags" ref="hashtagsInputEl" v-model="hashtags" class="hashtags" :placeholder="i18n.locale.hashtags" list="hashtags"> <XPostFormAttaches class="attaches" :files="files" @updated="updateFiles" @detach="detachFile" @changeSensitive="updateFileSensitive" @changeName="updateFileName"/> <XPollEditor v-if="poll" :poll="poll" @destroyed="poll = null" @updated="onPollUpdate"/> <XNotePreview v-if="showPreview" class="preview" :text="text"/> <footer> - <button v-tooltip="$ts.attachFile" class="_button" @click="chooseFileFrom"><i class="fas fa-photo-video"></i></button> - <button v-tooltip="$ts.poll" class="_button" :class="{ active: poll }" @click="togglePoll"><i class="fas fa-poll-h"></i></button> - <button v-tooltip="$ts.useCw" class="_button" :class="{ active: useCw }" @click="useCw = !useCw"><i class="fas fa-eye-slash"></i></button> - <button v-tooltip="$ts.mention" class="_button" @click="insertMention"><i class="fas fa-at"></i></button> - <button v-tooltip="$ts.hashtags" class="_button" :class="{ active: withHashtags }" @click="withHashtags = !withHashtags"><i class="fas fa-hashtag"></i></button> - <button v-tooltip="$ts.emoji" class="_button" @click="insertEmoji"><i class="fas fa-laugh-squint"></i></button> - <button v-if="postFormActions.length > 0" v-tooltip="$ts.plugin" class="_button" @click="showActions"><i class="fas fa-plug"></i></button> + <button v-tooltip="i18n.locale.attachFile" class="_button" @click="chooseFileFrom"><i class="fas fa-photo-video"></i></button> + <button v-tooltip="i18n.locale.poll" class="_button" :class="{ active: poll }" @click="togglePoll"><i class="fas fa-poll-h"></i></button> + <button v-tooltip="i18n.locale.useCw" class="_button" :class="{ active: useCw }" @click="useCw = !useCw"><i class="fas fa-eye-slash"></i></button> + <button v-tooltip="i18n.locale.mention" class="_button" @click="insertMention"><i class="fas fa-at"></i></button> + <button v-tooltip="i18n.locale.hashtags" class="_button" :class="{ active: withHashtags }" @click="withHashtags = !withHashtags"><i class="fas fa-hashtag"></i></button> + <button v-tooltip="i18n.locale.emoji" class="_button" @click="insertEmoji"><i class="fas fa-laugh-squint"></i></button> + <button v-if="postFormActions.length > 0" v-tooltip="i18n.locale.plugin" class="_button" @click="showActions"><i class="fas fa-plug"></i></button> </footer> <datalist id="hashtags"> <option v-for="hashtag in recentHashtags" :key="hashtag" :value="hashtag"/> @@ -83,7 +86,7 @@ import { throttle } from 'throttle-debounce'; import MkInfo from '@/components/ui/info.vue'; import { i18n } from '@/i18n'; import { instance } from '@/instance'; -import { $i } from '@/account'; +import { $i, getAccounts, openAccountMenu as openAccountMenu_ } from '@/account'; const modal = inject('modal'); @@ -339,8 +342,8 @@ function focus() { } function chooseFileFrom(ev) { - selectFiles(ev.currentTarget || ev.target, i18n.locale.attachFile).then(files => { - for (const file of files) { + selectFiles(ev.currentTarget || ev.target, i18n.locale.attachFile).then(files_ => { + for (const file of files_) { files.push(file); } }); @@ -350,8 +353,8 @@ function detachFile(id) { files = files.filter(x => x.id != id); } -function updateFiles(files) { - files = files; +function updateFiles(_files) { + files = _files; } function updateFileSensitive(file, sensitive) { @@ -553,8 +556,15 @@ async function post() { } } + let token = undefined; + + if (postAccount) { + const storedAccounts = await getAccounts(); + token = storedAccounts.find(x => x.id === postAccount.id)?.token; + } + posting = true; - os.api('notes/create', data).then(() => { + os.api('notes/create', data, token).then(() => { clear(); nextTick(() => { deleteDraft(); @@ -565,6 +575,7 @@ async function post() { localStorage.setItem('hashtags', JSON.stringify(unique(hashtags.concat(history)))); } posting = false; + postAccount = null; }); }).catch(err => { posting = false; @@ -585,7 +596,7 @@ function insertMention() { }); } -async function insertEmoji(ev) { +async function insertEmoji(ev: MouseEvent) { os.openEmojiPicker(ev.currentTarget || ev.target, {}, textareaEl); } @@ -602,6 +613,23 @@ function showActions(ev) { })), ev.currentTarget || ev.target); } +let postAccount = $ref<misskey.entities.UserDetailed | null>(null); + +function openAccountMenu(ev: MouseEvent) { + openAccountMenu_({ + withExtraOperation: false, + includeCurrentAccount: true, + active: postAccount != null ? postAccount.id : $i.id, + onChoose: (account) => { + if (account.id === $i.id) { + postAccount = null; + } else { + postAccount = account; + } + }, + }, ev); +} + onMounted(() => { if (props.autofocus) { focus(); @@ -678,6 +706,19 @@ onMounted(() => { line-height: 66px; } + > .account { + height: 100%; + aspect-ratio: 1/1; + display: inline-flex; + vertical-align: bottom; + + > .avatar { + width: 28px; + height: 28px; + margin: auto; + } + } + > div { position: absolute; top: 0; diff --git a/packages/client/src/components/timeline.vue b/packages/client/src/components/timeline.vue index a7af02c30b..59956b9526 100644 --- a/packages/client/src/components/timeline.vue +++ b/packages/client/src/components/timeline.vue @@ -25,10 +25,10 @@ const emit = defineEmits<{ provide('inChannel', computed(() => props.src === 'channel')); -const tlComponent = ref<InstanceType<typeof XNotes>>(); +const tlComponent: InstanceType<typeof XNotes> = $ref(); const prepend = note => { - tlComponent.value.prepend(note); + tlComponent.pagingComponent?.prepend(note); emit('note'); @@ -38,16 +38,16 @@ const prepend = note => { }; const onUserAdded = () => { - tlComponent.value.reload(); + tlComponent.pagingComponent?.reload(); }; const onUserRemoved = () => { - tlComponent.value.reload(); + tlComponent.pagingComponent?.reload(); }; const onChangeFollowing = () => { - if (!tlComponent.value.backed) { - tlComponent.value.reload(); + if (!tlComponent.pagingComponent?.backed) { + tlComponent.pagingComponent?.reload(); } }; diff --git a/packages/client/src/components/ui/menu.vue b/packages/client/src/components/ui/menu.vue index 6f3f277b11..41165c8d33 100644 --- a/packages/client/src/components/ui/menu.vue +++ b/packages/client/src/components/ui/menu.vue @@ -24,7 +24,7 @@ <span>{{ item.text }}</span> <span v-if="item.indicate" class="indicator"><i class="fas fa-circle"></i></span> </a> - <button v-else-if="item.type === 'user'" :tabindex="i" class="_button item" @click="clicked(item.action, $event)"> + <button v-else-if="item.type === 'user'" :tabindex="i" class="_button item" :class="{ active: item.active }" :disabled="item.active" @click="clicked(item.action, $event)"> <MkAvatar :user="item.user" class="avatar"/><MkUserName :user="item.user"/> <span v-if="item.indicate" class="indicator"><i class="fas fa-circle"></i></span> </button> diff --git a/packages/client/src/components/ui/pagination.vue b/packages/client/src/components/ui/pagination.vue index 571ef71eab..9c18fc5ce5 100644 --- a/packages/client/src/components/ui/pagination.vue +++ b/packages/client/src/components/ui/pagination.vue @@ -73,12 +73,11 @@ const queue = ref<Item[]>([]); const offset = ref(0); const fetching = ref(true); const moreFetching = ref(false); -const inited = ref(false); const more = ref(false); const backed = ref(false); // 遡り中か否か const isBackTop = ref(false); -const empty = computed(() => items.value.length === 0 && !fetching.value && inited.value); -const error = computed(() => !fetching.value && !inited.value); +const empty = computed(() => items.value.length === 0); +const error = ref(false); const init = async (): Promise<void> => { queue.value = []; @@ -105,9 +104,10 @@ const init = async (): Promise<void> => { more.value = false; } offset.value = res.length; - inited.value = true; + error.value = false; fetching.value = false; }, e => { + error.value = true; fetching.value = false; }); }; @@ -183,30 +183,36 @@ const fetchMoreAhead = async (): Promise<void> => { }; const prepend = (item: Item): void => { - if (rootEl.value == null) return; - if (props.pagination.reversed) { - const container = getScrollContainer(rootEl.value); - if (container == null) return; // TODO? + if (rootEl.value) { + const container = getScrollContainer(rootEl.value); + if (container == null) return; // TODO? - const pos = getScrollPosition(rootEl.value); - const viewHeight = container.clientHeight; - const height = container.scrollHeight; - const isBottom = (pos + viewHeight > height - 32); - if (isBottom) { - // オーバーフローしたら古いアイテムは捨てる - if (items.value.length >= props.displayLimit) { - // このやり方だとVue 3.2以降アニメーションが動かなくなる - //items.value = items.value.slice(-props.displayLimit); - while (items.value.length >= props.displayLimit) { - items.value.shift(); + const pos = getScrollPosition(rootEl.value); + const viewHeight = container.clientHeight; + const height = container.scrollHeight; + const isBottom = (pos + viewHeight > height - 32); + if (isBottom) { + // オーバーフローしたら古いアイテムは捨てる + if (items.value.length >= props.displayLimit) { + // このやり方だとVue 3.2以降アニメーションが動かなくなる + //items.value = items.value.slice(-props.displayLimit); + while (items.value.length >= props.displayLimit) { + items.value.shift(); + } + more.value = true; } - more.value = true; } } items.value.push(item); // TODO } else { + // 初回表示時はunshiftだけでOK + if (!rootEl.value) { + items.value.unshift(item); + return; + } + const isTop = isBackTop.value || (document.body.contains(rootEl.value) && isTopVisible(rootEl.value)); if (isTop) { @@ -264,6 +270,7 @@ onDeactivated(() => { defineExpose({ items, + backed, reload, fetchMoreAhead, prepend, diff --git a/packages/client/src/components/ui/window.vue b/packages/client/src/components/ui/window.vue index bd33289ccc..fa32ecfdef 100644 --- a/packages/client/src/components/ui/window.vue +++ b/packages/client/src/components/ui/window.vue @@ -147,9 +147,9 @@ export default defineComponent({ } }, - onContextmenu(e) { + onContextmenu(ev: MouseEvent) { if (this.contextmenu) { - os.contextMenu(this.contextmenu, e); + os.contextMenu(this.contextmenu, ev); } }, diff --git a/packages/client/src/os.ts b/packages/client/src/os.ts index dd7fdea4bd..378523e1bc 100644 --- a/packages/client/src/os.ts +++ b/packages/client/src/os.ts @@ -541,7 +541,7 @@ export const uploads = ref<{ img: string; }[]>([]); -export function upload(file: File, folder?: any, name?: string) { +export function upload(file: File, folder?: any, name?: string): Promise<Misskey.entities.DriveFile> { if (folder && typeof folder == 'object') folder = folder.id; return new Promise((resolve, reject) => { diff --git a/packages/client/src/pages/about-misskey.vue b/packages/client/src/pages/about-misskey.vue index 855a21e493..8119f33051 100644 --- a/packages/client/src/pages/about-misskey.vue +++ b/packages/client/src/pages/about-misskey.vue @@ -3,36 +3,39 @@ <MkSpacer :content-max="600" :margin-min="20"> <div class="_formRoot znqjceqz"> <div id="debug"></div> - <div ref="about" v-panel class="_formBlock about" :class="{ playing: easterEggEngine != null }"> + <div ref="containerEl" v-panel class="_formBlock about" :class="{ playing: easterEggEngine != null }"> <img src="/client-assets/about-icon.png" alt="" class="icon" draggable="false" @load="iconLoaded" @click="gravity"/> <div class="misskey">Misskey</div> <div class="version">v{{ version }}</div> <span v-for="emoji in easterEggEmojis" :key="emoji.id" class="emoji" :data-physics-x="emoji.left" :data-physics-y="emoji.top" :class="{ _physics_circle_: !emoji.emoji.startsWith(':') }"><MkEmoji class="emoji" :emoji="emoji.emoji" :custom-emojis="$instance.emojis" :is-reaction="false" :normal="true" :no-style="true"/></span> </div> <div class="_formBlock" style="text-align: center;"> - {{ $ts._aboutMisskey.about }}<br><a href="https://misskey-hub.net/docs/misskey.html" target="_blank" class="_link">{{ $ts.learnMore }}</a> + {{ i18n.locale._aboutMisskey.about }}<br><a href="https://misskey-hub.net/docs/misskey.html" target="_blank" class="_link">{{ i18n.locale.learnMore }}</a> + </div> + <div class="_formBlock" style="text-align: center;"> + <MkButton primary rounded inline @click="iLoveMisskey">I <Mfm text="$[jelly ❤]"/> #Misskey</MkButton> </div> <FormSection> <div class="_formLinks"> <FormLink to="https://github.com/misskey-dev/misskey" external> <template #icon><i class="fas fa-code"></i></template> - {{ $ts._aboutMisskey.source }} + {{ i18n.locale._aboutMisskey.source }} <template #suffix>GitHub</template> </FormLink> <FormLink to="https://crowdin.com/project/misskey" external> <template #icon><i class="fas fa-language"></i></template> - {{ $ts._aboutMisskey.translation }} + {{ i18n.locale._aboutMisskey.translation }} <template #suffix>Crowdin</template> </FormLink> <FormLink to="https://www.patreon.com/syuilo" external> <template #icon><i class="fas fa-hand-holding-medical"></i></template> - {{ $ts._aboutMisskey.donate }} + {{ i18n.locale._aboutMisskey.donate }} <template #suffix>Patreon</template> </FormLink> </div> </FormSection> <FormSection> - <template #label>{{ $ts._aboutMisskey.contributors }}</template> + <template #label>{{ i18n.locale._aboutMisskey.contributors }}</template> <div class="_formLinks"> <FormLink to="https://github.com/syuilo" external>@syuilo</FormLink> <FormLink to="https://github.com/AyaMorisawa" external>@AyaMorisawa</FormLink> @@ -44,27 +47,30 @@ <FormLink to="https://github.com/u1-liquid" external>@u1-liquid</FormLink> <FormLink to="https://github.com/marihachi" external>@marihachi</FormLink> </div> - <template #caption><MkLink url="https://github.com/misskey-dev/misskey/graphs/contributors">{{ $ts._aboutMisskey.allContributors }}</MkLink></template> + <template #caption><MkLink url="https://github.com/misskey-dev/misskey/graphs/contributors">{{ i18n.locale._aboutMisskey.allContributors }}</MkLink></template> </FormSection> <FormSection> - <template #label><Mfm text="$[jelly ❤]"/> {{ $ts._aboutMisskey.patrons }}</template> + <template #label><Mfm text="$[jelly ❤]"/> {{ i18n.locale._aboutMisskey.patrons }}</template> <div v-for="patron in patrons" :key="patron">{{ patron }}</div> - <template #caption>{{ $ts._aboutMisskey.morePatrons }}</template> + <template #caption>{{ i18n.locale._aboutMisskey.morePatrons }}</template> </FormSection> </div> </MkSpacer> </div> </template> -<script lang="ts"> -import { defineComponent } from 'vue'; +<script lang="ts" setup> +import { nextTick, onBeforeUnmount } from 'vue'; import { version } from '@/config'; import FormLink from '@/components/form/link.vue'; import FormSection from '@/components/form/section.vue'; -import MkKeyValue from '@/components/key-value.vue'; +import MkButton from '@/components/ui/button.vue'; import MkLink from '@/components/link.vue'; import { physics } from '@/scripts/physics'; import * as symbols from '@/symbols'; +import { i18n } from '@/i18n'; +import { defaultStore } from '@/store'; +import * as os from '@/os'; const patrons = [ 'まっちゃとーにゅ', @@ -145,59 +151,53 @@ const patrons = [ '蝉暮せせせ', ]; -export default defineComponent({ - components: { - FormSection, - FormLink, - MkKeyValue, - MkLink, - }, +let easterEggReady = false; +let easterEggEmojis = $ref([]); +let easterEggEngine = $ref(null); +const containerEl = $ref<HTMLElement>(); - data() { - return { - [symbols.PAGE_INFO]: { - title: this.$ts.aboutMisskey, - icon: null - }, - version, - patrons, - easterEggReady: false, - easterEggEmojis: [], - easterEggEngine: null, - } - }, +function iconLoaded() { + const emojis = defaultStore.state.reactions; + const containerWidth = containerEl.offsetWidth; + for (let i = 0; i < 32; i++) { + easterEggEmojis.push({ + id: i.toString(), + top: -(128 + (Math.random() * 256)), + left: (Math.random() * containerWidth), + emoji: emojis[Math.floor(Math.random() * emojis.length)], + }); + } - beforeUnmount() { - if (this.easterEggEngine) { - this.easterEggEngine.stop(); - } - }, + nextTick(() => { + easterEggReady = true; + }); +} - methods: { - iconLoaded() { - const emojis = this.$store.state.reactions; - const containerWidth = this.$refs.about.offsetWidth; - for (let i = 0; i < 32; i++) { - this.easterEggEmojis.push({ - id: i.toString(), - top: -(128 + (Math.random() * 256)), - left: (Math.random() * containerWidth), - emoji: emojis[Math.floor(Math.random() * emojis.length)], - }); - } +function gravity() { + if (!easterEggReady) return; + easterEggReady = false; + easterEggEngine = physics(containerEl); +} - this.$nextTick(() => { - this.easterEggReady = true; - }); - }, +function iLoveMisskey() { + os.post({ + initialText: 'I $[jelly ❤] #Misskey', + }); +} - gravity() { - if (!this.easterEggReady) return; - this.easterEggReady = false; - this.easterEggEngine = physics(this.$refs.about); - } +onBeforeUnmount(() => { + if (easterEggEngine) { + easterEggEngine.stop(); } }); + +defineExpose({ + [symbols.PAGE_INFO]: { + title: i18n.locale.aboutMisskey, + icon: null, + bg: 'var(--bg)', + }, +}); </script> <style lang="scss" scoped> diff --git a/packages/client/src/pages/admin/abuses.vue b/packages/client/src/pages/admin/abuses.vue index 31cdef492a..92f93797ce 100644 --- a/packages/client/src/pages/admin/abuses.vue +++ b/packages/client/src/pages/admin/abuses.vue @@ -34,27 +34,7 @@ --> <MkPagination v-slot="{items}" ref="reports" :pagination="pagination" style="margin-top: var(--margin);"> - <div v-for="report in items" :key="report.id" class="bcekxzvu _card _gap"> - <div class="_content target"> - <MkAvatar class="avatar" :user="report.targetUser" :show-indicator="true"/> - <div class="info"> - <MkUserName class="name" :user="report.targetUser"/> - <div class="acct">@{{ acct(report.targetUser) }}</div> - </div> - </div> - <div class="_content"> - <div> - <Mfm :text="report.comment"/> - </div> - <hr> - <div>Reporter: <MkAcct :user="report.reporter"/></div> - <div><MkTime :time="report.createdAt"/></div> - </div> - <div class="_footer"> - <div v-if="report.assignee">Assignee: <MkAcct :user="report.assignee"/></div> - <MkButton v-if="!report.resolved" primary @click="resolve(report)">{{ $ts.abuseMarkAsResolved }}</MkButton> - </div> - </div> + <XAbuseReport v-for="report in items" :key="report.id" :report="report" @resolved="resolved"/> </MkPagination> </div> </div> @@ -64,20 +44,19 @@ <script lang="ts"> import { computed, defineComponent } from 'vue'; -import MkButton from '@/components/ui/button.vue'; import MkInput from '@/components/form/input.vue'; import MkSelect from '@/components/form/select.vue'; import MkPagination from '@/components/ui/pagination.vue'; -import { acct } from '@/filters/user'; +import XAbuseReport from '@/components/abuse-report.vue'; import * as os from '@/os'; import * as symbols from '@/symbols'; export default defineComponent({ components: { - MkButton, MkInput, MkSelect, MkPagination, + XAbuseReport, }, emits: ['info'], @@ -107,14 +86,8 @@ export default defineComponent({ }, methods: { - acct, - - resolve(report) { - os.apiWithDialog('admin/resolve-abuse-user-report', { - reportId: report.id, - }).then(() => { - this.$refs.reports.removeItem(item => item.id === report.id); - }); + resolved(reportId) { + this.$refs.reports.removeItem(item => item.id === reportId); }, } }); @@ -124,29 +97,4 @@ export default defineComponent({ .lcixvhis { margin: var(--margin); } - -.bcekxzvu { - > .target { - display: flex; - width: 100%; - box-sizing: border-box; - text-align: left; - align-items: center; - - > .avatar { - width: 42px; - height: 42px; - } - - > .info { - margin-left: 0.3em; - padding: 0 8px; - flex: 1; - - > .name { - font-weight: bold; - } - } - } -} </style> diff --git a/packages/client/src/pages/instance-info.vue b/packages/client/src/pages/instance-info.vue index 475107ab6d..fa36db0659 100644 --- a/packages/client/src/pages/instance-info.vue +++ b/packages/client/src/pages/instance-info.vue @@ -28,7 +28,7 @@ <FormSection v-if="iAmModerator"> <template #label>Moderation</template> <FormSwitch v-model="suspended" class="_formBlock" @update:modelValue="toggleSuspend">{{ $ts.stopActivityDelivery }}</FormSwitch> - <FormSwitch :model-value="isBlocked" class="switch" @update:modelValue="changeBlock">{{ $ts.blockThisInstance }}</FormSwitch> + <FormSwitch v-model="isBlocked" class="_formBlock" @update:modelValue="toggleBlock">{{ $ts.blockThisInstance }}</FormSwitch> </FormSection> <FormSection> @@ -104,15 +104,14 @@ </MkSpacer> </template> -<script lang="ts"> -import { defineAsyncComponent, defineComponent } from 'vue'; +<script lang="ts" setup> +import { } from 'vue'; +import * as misskey from 'misskey-js'; import MkChart from '@/components/chart.vue'; import MkObjectView from '@/components/object-view.vue'; -import FormTextarea from '@/components/form/textarea.vue'; import FormLink from '@/components/form/link.vue'; import MkLink from '@/components/link.vue'; import FormSection from '@/components/form/section.vue'; -import FormButton from '@/components/ui/button.vue'; import MkKeyValue from '@/components/key-value.vue'; import MkSelect from '@/components/form/select.vue'; import FormSwitch from '@/components/form/switch.vue'; @@ -120,87 +119,57 @@ import * as os from '@/os'; import number from '@/filters/number'; import bytes from '@/filters/bytes'; import * as symbols from '@/symbols'; +import { iAmModerator } from '@/account'; -export default defineComponent({ - components: { - FormTextarea, - MkObjectView, - FormButton, - FormLink, - FormSection, - FormSwitch, - MkKeyValue, - MkSelect, - MkChart, - MkLink, - }, - - props: { - host: { - type: String, - required: true - } - }, +const props = defineProps<{ + host: string; +}>(); - data() { - return { - [symbols.PAGE_INFO]: { - title: this.host, - icon: 'fas fa-info-circle', - bg: 'var(--bg)', - actions: [{ - text: `https://${this.host}`, - icon: 'fas fa-external-link-alt', - handler: () => { - window.open(`https://${this.host}`, '_blank'); - } - }], - }, - instance: null, - suspended: false, - chartSrc: 'instance-requests', - chartSpan: 'hour', - } - }, - - computed: { - iAmModerator(): boolean { - return this.$i && (this.$i.isAdmin || this.$i.isModerator); - }, - - isBlocked() { - return this.instance && this.$instance && this.$instance.blockedHosts && this.$instance.blockedHosts.includes(this.instance.host); - } - }, +let meta = $ref<misskey.entities.DetailedInstanceMetadata | null>(null); +let instance = $ref<misskey.entities.Instance | null>(null); +let suspended = $ref(false); +let isBlocked = $ref(false); +let chartSrc = $ref('instance-requests'); +let chartSpan = $ref('hour'); - mounted() { - this.fetch(); - }, +async function fetch() { + meta = await os.api('meta', { detail: true }); + instance = await os.api('federation/show-instance', { + host: props.host, + }); + suspended = instance.isSuspended; + isBlocked = meta.blockedHosts.includes(instance.host); +} - methods: { - number, - bytes, +async function toggleBlock(ev) { + if (meta == null) return; + await os.api('admin/update-meta', { + blockedHosts: isBlocked ? meta.blockedHosts.concat([instance.host]) : meta.blockedHosts.filter(x => x !== instance.host) + }); +} - async fetch() { - this.instance = await os.api('federation/show-instance', { - host: this.host - }); - this.suspended = this.instance.isSuspended; - }, +async function toggleSuspend(v) { + await os.api('admin/federation/update-instance', { + host: instance.host, + isSuspended: suspended, + }); +} - changeBlock(e) { - os.api('admin/update-meta', { - blockedHosts: this.isBlocked ? this.meta.blockedHosts.concat([this.instance.host]) : this.meta.blockedHosts.filter(x => x !== this.instance.host) - }); - }, +fetch(); - async toggleSuspend(v) { - await os.api('admin/federation/update-instance', { - host: this.instance.host, - isSuspended: this.suspended - }); - }, - } +defineExpose({ + [symbols.PAGE_INFO]: { + title: props.host, + icon: 'fas fa-info-circle', + bg: 'var(--bg)', + actions: [{ + text: `https://${props.host}`, + icon: 'fas fa-external-link-alt', + handler: () => { + window.open(`https://${props.host}`, '_blank'); + } + }], + }, }); </script> diff --git a/packages/client/src/pages/settings/profile.vue b/packages/client/src/pages/settings/profile.vue index 9a2395872e..f875146a2c 100644 --- a/packages/client/src/pages/settings/profile.vue +++ b/packages/client/src/pages/settings/profile.vue @@ -3,50 +3,50 @@ <div class="llvierxe" :style="{ backgroundImage: $i.bannerUrl ? `url(${ $i.bannerUrl })` : null }"> <div class="avatar _acrylic"> <MkAvatar class="avatar" :user="$i" :disable-link="true" @click="changeAvatar"/> - <MkButton primary class="avatarEdit" @click="changeAvatar">{{ $ts._profile.changeAvatar }}</MkButton> + <MkButton primary class="avatarEdit" @click="changeAvatar">{{ i18n.locale._profile.changeAvatar }}</MkButton> </div> - <MkButton primary class="bannerEdit" @click="changeBanner">{{ $ts._profile.changeBanner }}</MkButton> + <MkButton primary class="bannerEdit" @click="changeBanner">{{ i18n.locale._profile.changeBanner }}</MkButton> </div> - <FormInput v-model="name" :max="30" manual-save class="_formBlock"> - <template #label>{{ $ts._profile.name }}</template> + <FormInput v-model="profile.name" :max="30" manual-save class="_formBlock"> + <template #label>{{ i18n.locale._profile.name }}</template> </FormInput> - <FormTextarea v-model="description" :max="500" tall manual-save class="_formBlock"> - <template #label>{{ $ts._profile.description }}</template> - <template #caption>{{ $ts._profile.youCanIncludeHashtags }}</template> + <FormTextarea v-model="profile.description" :max="500" tall manual-save class="_formBlock"> + <template #label>{{ i18n.locale._profile.description }}</template> + <template #caption>{{ i18n.locale._profile.youCanIncludeHashtags }}</template> </FormTextarea> - <FormInput v-model="location" manual-save class="_formBlock"> - <template #label>{{ $ts.location }}</template> + <FormInput v-model="profile.location" manual-save class="_formBlock"> + <template #label>{{ i18n.locale.location }}</template> <template #prefix><i class="fas fa-map-marker-alt"></i></template> </FormInput> - <FormInput v-model="birthday" type="date" manual-save class="_formBlock"> - <template #label>{{ $ts.birthday }}</template> + <FormInput v-model="profile.birthday" type="date" manual-save class="_formBlock"> + <template #label>{{ i18n.locale.birthday }}</template> <template #prefix><i class="fas fa-birthday-cake"></i></template> </FormInput> - <FormSelect v-model="lang" class="_formBlock"> - <template #label>{{ $ts.language }}</template> + <FormSelect v-model="profile.lang" class="_formBlock"> + <template #label>{{ i18n.locale.language }}</template> <option v-for="x in langs" :key="x[0]" :value="x[0]">{{ x[1] }}</option> </FormSelect> <FormSlot> - <MkButton @click="editMetadata">{{ $ts._profile.metadataEdit }}</MkButton> - <template #caption>{{ $ts._profile.metadataDescription }}</template> + <MkButton @click="editMetadata">{{ i18n.locale._profile.metadataEdit }}</MkButton> + <template #caption>{{ i18n.locale._profile.metadataDescription }}</template> </FormSlot> - <FormSwitch v-model="isCat" class="_formBlock">{{ $ts.flagAsCat }}<template #caption>{{ $ts.flagAsCatDescription }}</template></FormSwitch> + <FormSwitch v-model="profile.isCat" class="_formBlock">{{ i18n.locale.flagAsCat }}<template #caption>{{ i18n.locale.flagAsCatDescription }}</template></FormSwitch> - <FormSwitch v-model="isBot" class="_formBlock">{{ $ts.flagAsBot }}<template #caption>{{ $ts.flagAsBotDescription }}</template></FormSwitch> + <FormSwitch v-model="profile.isBot" class="_formBlock">{{ i18n.locale.flagAsBot }}<template #caption>{{ i18n.locale.flagAsBotDescription }}</template></FormSwitch> - <FormSwitch v-model="alwaysMarkNsfw" class="_formBlock">{{ $ts.alwaysMarkSensitive }}</FormSwitch> + <FormSwitch v-model="profile.alwaysMarkNsfw" class="_formBlock">{{ i18n.locale.alwaysMarkSensitive }}</FormSwitch> </div> </template> -<script lang="ts"> -import { defineComponent } from 'vue'; +<script lang="ts" setup> +import { defineComponent, reactive, watch } from 'vue'; import MkButton from '@/components/ui/button.vue'; import FormInput from '@/components/form/input.vue'; import FormTextarea from '@/components/form/textarea.vue'; @@ -57,194 +57,149 @@ import { host, langs } from '@/config'; import { selectFile } from '@/scripts/select-file'; import * as os from '@/os'; import * as symbols from '@/symbols'; +import { i18n } from '@/i18n'; +import { $i } from '@/account'; -export default defineComponent({ - components: { - MkButton, - FormInput, - FormTextarea, - FormSwitch, - FormSelect, - FormSlot, - }, - - emits: ['info'], +const profile = reactive({ + name: $i.name, + description: $i.description, + location: $i.location, + birthday: $i.birthday, + lang: $i.lang, + isBot: $i.isBot, + isCat: $i.isCat, + alwaysMarkNsfw: $i.alwaysMarkNsfw, +}); - data() { - return { - [symbols.PAGE_INFO]: { - title: this.$ts.profile, - icon: 'fas fa-user', - bg: 'var(--bg)', - }, - host, - langs, - name: null, - description: null, - birthday: null, - lang: null, - location: null, - fieldName0: null, - fieldValue0: null, - fieldName1: null, - fieldValue1: null, - fieldName2: null, - fieldValue2: null, - fieldName3: null, - fieldValue3: null, - avatarId: null, - bannerId: null, - isBot: false, - isCat: false, - alwaysMarkNsfw: false, - saving: false, - } - }, +const additionalFields = reactive({ + fieldName0: $i.fields[0] ? $i.fields[0].name : null, + fieldValue0: $i.fields[0] ? $i.fields[0].value : null, + fieldName1: $i.fields[1] ? $i.fields[1].name : null, + fieldValue1: $i.fields[1] ? $i.fields[1].value : null, + fieldName2: $i.fields[2] ? $i.fields[2].name : null, + fieldValue2: $i.fields[2] ? $i.fields[2].value : null, + fieldName3: $i.fields[3] ? $i.fields[3].name : null, + fieldValue3: $i.fields[3] ? $i.fields[3].value : null, +}); - created() { - this.name = this.$i.name; - this.description = this.$i.description; - this.location = this.$i.location; - this.birthday = this.$i.birthday; - this.lang = this.$i.lang; - this.avatarId = this.$i.avatarId; - this.bannerId = this.$i.bannerId; - this.isBot = this.$i.isBot; - this.isCat = this.$i.isCat; - this.alwaysMarkNsfw = this.$i.alwaysMarkNsfw; +watch(() => profile, () => { + save(); +}, { + deep: true, +}); - this.fieldName0 = this.$i.fields[0] ? this.$i.fields[0].name : null; - this.fieldValue0 = this.$i.fields[0] ? this.$i.fields[0].value : null; - this.fieldName1 = this.$i.fields[1] ? this.$i.fields[1].name : null; - this.fieldValue1 = this.$i.fields[1] ? this.$i.fields[1].value : null; - this.fieldName2 = this.$i.fields[2] ? this.$i.fields[2].name : null; - this.fieldValue2 = this.$i.fields[2] ? this.$i.fields[2].value : null; - this.fieldName3 = this.$i.fields[3] ? this.$i.fields[3].name : null; - this.fieldValue3 = this.$i.fields[3] ? this.$i.fields[3].value : null; +function save() { + os.apiWithDialog('i/update', { + name: profile.name || null, + description: profile.description || null, + location: profile.location || null, + birthday: profile.birthday || null, + lang: profile.lang || null, + isBot: !!profile.isBot, + isCat: !!profile.isCat, + alwaysMarkNsfw: !!profile.alwaysMarkNsfw, + }); +} - this.$watch('name', this.save); - this.$watch('description', this.save); - this.$watch('location', this.save); - this.$watch('birthday', this.save); - this.$watch('lang', this.save); - this.$watch('isBot', this.save); - this.$watch('isCat', this.save); - this.$watch('alwaysMarkNsfw', this.save); - }, +function changeAvatar(ev) { + selectFile(ev.currentTarget || ev.target, i18n.locale.avatar).then(async (file) => { + const i = await os.apiWithDialog('i/update', { + avatarId: file.id, + }); + $i.avatarId = i.avatarId; + $i.avatarUrl = i.avatarUrl; + }); +} - methods: { - changeAvatar(e) { - selectFile(e.currentTarget || e.target, this.$ts.avatar).then(file => { - os.api('i/update', { - avatarId: file.id, - }); - }); - }, +function changeBanner(ev) { + selectFile(ev.currentTarget || ev.target, i18n.locale.banner).then(async (file) => { + const i = await os.apiWithDialog('i/update', { + bannerId: file.id, + }); + $i.bannerId = i.bannerId; + $i.bannerUrl = i.bannerUrl; + }); +} - changeBanner(e) { - selectFile(e.currentTarget || e.target, this.$ts.banner).then(file => { - os.api('i/update', { - bannerId: file.id, - }); - }); +async function editMetadata() { + const { canceled, result } = await os.form(i18n.locale._profile.metadata, { + fieldName0: { + type: 'string', + label: i18n.locale._profile.metadataLabel + ' 1', + default: additionalFields.fieldName0, }, + fieldValue0: { + type: 'string', + label: i18n.locale._profile.metadataContent + ' 1', + default: additionalFields.fieldValue0, + }, + fieldName1: { + type: 'string', + label: i18n.locale._profile.metadataLabel + ' 2', + default: additionalFields.fieldName1, + }, + fieldValue1: { + type: 'string', + label: i18n.locale._profile.metadataContent + ' 2', + default: additionalFields.fieldValue1, + }, + fieldName2: { + type: 'string', + label: i18n.locale._profile.metadataLabel + ' 3', + default: additionalFields.fieldName2, + }, + fieldValue2: { + type: 'string', + label: i18n.locale._profile.metadataContent + ' 3', + default: additionalFields.fieldValue2, + }, + fieldName3: { + type: 'string', + label: i18n.locale._profile.metadataLabel + ' 4', + default: additionalFields.fieldName3, + }, + fieldValue3: { + type: 'string', + label: i18n.locale._profile.metadataContent + ' 4', + default: additionalFields.fieldValue3, + }, + }); + if (canceled) return; - async editMetadata() { - const { canceled, result } = await os.form(this.$ts._profile.metadata, { - fieldName0: { - type: 'string', - label: this.$ts._profile.metadataLabel + ' 1', - default: this.fieldName0, - }, - fieldValue0: { - type: 'string', - label: this.$ts._profile.metadataContent + ' 1', - default: this.fieldValue0, - }, - fieldName1: { - type: 'string', - label: this.$ts._profile.metadataLabel + ' 2', - default: this.fieldName1, - }, - fieldValue1: { - type: 'string', - label: this.$ts._profile.metadataContent + ' 2', - default: this.fieldValue1, - }, - fieldName2: { - type: 'string', - label: this.$ts._profile.metadataLabel + ' 3', - default: this.fieldName2, - }, - fieldValue2: { - type: 'string', - label: this.$ts._profile.metadataContent + ' 3', - default: this.fieldValue2, - }, - fieldName3: { - type: 'string', - label: this.$ts._profile.metadataLabel + ' 4', - default: this.fieldName3, - }, - fieldValue3: { - type: 'string', - label: this.$ts._profile.metadataContent + ' 4', - default: this.fieldValue3, - }, - }); - if (canceled) return; - - this.fieldName0 = result.fieldName0; - this.fieldValue0 = result.fieldValue0; - this.fieldName1 = result.fieldName1; - this.fieldValue1 = result.fieldValue1; - this.fieldName2 = result.fieldName2; - this.fieldValue2 = result.fieldValue2; - this.fieldName3 = result.fieldName3; - this.fieldValue3 = result.fieldValue3; - - const fields = [ - { name: this.fieldName0, value: this.fieldValue0 }, - { name: this.fieldName1, value: this.fieldValue1 }, - { name: this.fieldName2, value: this.fieldValue2 }, - { name: this.fieldName3, value: this.fieldValue3 }, - ]; + additionalFields.fieldName0 = result.fieldName0; + additionalFields.fieldValue0 = result.fieldValue0; + additionalFields.fieldName1 = result.fieldName1; + additionalFields.fieldValue1 = result.fieldValue1; + additionalFields.fieldName2 = result.fieldName2; + additionalFields.fieldValue2 = result.fieldValue2; + additionalFields.fieldName3 = result.fieldName3; + additionalFields.fieldValue3 = result.fieldValue3; - os.api('i/update', { - fields, - }).then(i => { - os.success(); - }).catch(err => { - os.alert({ - type: 'error', - text: err.id - }); - }); - }, + const fields = [ + { name: additionalFields.fieldName0, value: additionalFields.fieldValue0 }, + { name: additionalFields.fieldName1, value: additionalFields.fieldValue1 }, + { name: additionalFields.fieldName2, value: additionalFields.fieldValue2 }, + { name: additionalFields.fieldName3, value: additionalFields.fieldValue3 }, + ]; - save() { - this.saving = true; + os.api('i/update', { + fields, + }).then(i => { + os.success(); + }).catch(err => { + os.alert({ + type: 'error', + text: err.id + }); + }); +} - os.apiWithDialog('i/update', { - name: this.name || null, - description: this.description || null, - location: this.location || null, - birthday: this.birthday || null, - lang: this.lang || null, - isBot: !!this.isBot, - isCat: !!this.isCat, - alwaysMarkNsfw: !!this.alwaysMarkNsfw, - }).then(i => { - this.saving = false; - this.$i.avatarId = i.avatarId; - this.$i.avatarUrl = i.avatarUrl; - this.$i.bannerId = i.bannerId; - this.$i.bannerUrl = i.bannerUrl; - }).catch(err => { - this.saving = false; - }); - }, - } +defineExpose({ + [symbols.PAGE_INFO]: { + title: i18n.locale.profile, + icon: 'fas fa-user', + bg: 'var(--bg)', + }, }); </script> diff --git a/packages/client/src/pages/tag.vue b/packages/client/src/pages/tag.vue index 8d8dc0a65c..045f1ef259 100644 --- a/packages/client/src/pages/tag.vue +++ b/packages/client/src/pages/tag.vue @@ -1,6 +1,6 @@ <template> <div class="_section"> - <XNotes ref="notes" class="_content" :pagination="pagination"/> + <XNotes class="_content" :pagination="pagination"/> </div> </template> diff --git a/packages/client/src/pages/user/index.timeline.vue b/packages/client/src/pages/user/index.timeline.vue index 7396a76efe..a1329a7411 100644 --- a/packages/client/src/pages/user/index.timeline.vue +++ b/packages/client/src/pages/user/index.timeline.vue @@ -5,7 +5,7 @@ <option value="replies">{{ $ts.notesAndReplies }}</option> <option value="files">{{ $ts.withFiles }}</option> </MkTab> - <XNotes ref="timeline" :no-gap="true" :pagination="pagination"/> + <XNotes :no-gap="true" :pagination="pagination"/> </div> </template> diff --git a/packages/client/src/scripts/emojilist.ts b/packages/client/src/scripts/emojilist.ts index de7591f5a0..bd8689e4f8 100644 --- a/packages/client/src/scripts/emojilist.ts +++ b/packages/client/src/scripts/emojilist.ts @@ -1,7 +1,11 @@ -// initial converted from https://github.com/muan/emojilib/commit/242fe68be86ed6536843b83f7e32f376468b38fb -export const emojilist = require('../emojilist.json') as { +export const unicodeEmojiCategories = ['face', 'people', 'animals_and_nature', 'food_and_drink', 'activity', 'travel_and_places', 'objects', 'symbols', 'flags'] as const; + +export type UnicodeEmojiDef = { name: string; keywords: string[]; char: string; - category: 'people' | 'animals_and_nature' | 'food_and_drink' | 'activity' | 'travel_and_places' | 'objects' | 'symbols' | 'flags'; -}[]; + category: typeof unicodeEmojiCategories[number]; +} + +// initial converted from https://github.com/muan/emojilib/commit/242fe68be86ed6536843b83f7e32f376468b38fb +export const emojilist = require('../emojilist.json') as UnicodeEmojiDef[]; diff --git a/packages/client/src/scripts/get-note-menu.ts b/packages/client/src/scripts/get-note-menu.ts index 61120d53ba..3634f39632 100644 --- a/packages/client/src/scripts/get-note-menu.ts +++ b/packages/client/src/scripts/get-note-menu.ts @@ -252,7 +252,7 @@ export function getNoteMenu(props: { icon: 'fas fa-exclamation-circle', text: i18n.locale.reportAbuse, action: () => { - const u = `${url}/notes/${appearNote.id}`; + const u = appearNote.url || appearNote.uri || `${url}/notes/${appearNote.id}`; os.popup(import('@/components/abuse-report-window.vue'), { user: appearNote.user, initialComment: `Note: ${u}\n-----\n` diff --git a/packages/client/src/scripts/get-user-menu.ts b/packages/client/src/scripts/get-user-menu.ts index ebe101bc0f..7b910a0083 100644 --- a/packages/client/src/scripts/get-user-menu.ts +++ b/packages/client/src/scripts/get-user-menu.ts @@ -5,7 +5,7 @@ import * as Acct from 'misskey-js/built/acct'; import * as os from '@/os'; import { userActions } from '@/store'; import { router } from '@/router'; -import { $i } from '@/account'; +import { $i, iAmModerator } from '@/account'; export function getUserMenu(user) { const meId = $i ? $i.id : null; @@ -175,7 +175,7 @@ export function getUserMenu(user) { action: reportAbuse }]); - if ($i && ($i.isAdmin || $i.isModerator)) { + if (iAmModerator) { menu = menu.concat([null, { icon: 'fas fa-microphone-slash', text: user.isSilenced ? i18n.locale.unsilence : i18n.locale.silence, diff --git a/packages/client/src/scripts/touch.ts b/packages/client/src/scripts/touch.ts index 06b4f8b2ed..5251bc2e27 100644 --- a/packages/client/src/scripts/touch.ts +++ b/packages/client/src/scripts/touch.ts @@ -14,6 +14,10 @@ if (isTouchSupported) { }, { passive: true }); window.addEventListener('touchend', () => { + // 子要素のtouchstartイベントでstopPropagation()が呼ばれると親要素に伝搬されずタッチされたと判定されないため、 + // touchendイベントでもtouchstartイベントと同様にtrueにする + isTouchUsing = true; + isScreenTouching = false; }, { passive: true }); } diff --git a/packages/client/src/scripts/use-leave-guard.ts b/packages/client/src/scripts/use-leave-guard.ts index 21899af59a..3984256251 100644 --- a/packages/client/src/scripts/use-leave-guard.ts +++ b/packages/client/src/scripts/use-leave-guard.ts @@ -1,4 +1,5 @@ import { inject, onUnmounted, Ref } from 'vue'; +import { onBeforeRouteLeave } from 'vue-router'; import { i18n } from '@/i18n'; import * as os from '@/os'; @@ -16,6 +17,17 @@ export function useLeaveGuard(enabled: Ref<boolean>) { return canceled; }); + } else { + onBeforeRouteLeave(async (to, from) => { + if (!enabled.value) return true; + + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.locale.leaveConfirm, + }); + + return !canceled; + }); } /* diff --git a/packages/client/src/ui/_common_/sidebar-for-mobile.vue b/packages/client/src/ui/_common_/sidebar-for-mobile.vue index 5babdb98a8..afcc50725b 100644 --- a/packages/client/src/ui/_common_/sidebar-for-mobile.vue +++ b/packages/client/src/ui/_common_/sidebar-for-mobile.vue @@ -61,7 +61,11 @@ export default defineComponent({ otherMenuItemIndicated, post: os.post, search, - openAccountMenu, + openAccountMenu:(ev) => { + openAccountMenu({ + withExtraOperation: true, + }, ev); + }, more: () => { os.popup(import('@/components/launch-pad.vue'), {}, { }, 'closed'); diff --git a/packages/client/src/ui/_common_/sidebar.vue b/packages/client/src/ui/_common_/sidebar.vue index fa712ba45d..94baacbee9 100644 --- a/packages/client/src/ui/_common_/sidebar.vue +++ b/packages/client/src/ui/_common_/sidebar.vue @@ -76,7 +76,11 @@ export default defineComponent({ iconOnly, post: os.post, search, - openAccountMenu, + openAccountMenu:(ev) => { + openAccountMenu({ + withExtraOperation: true, + }, ev); + }, more: () => { os.popup(import('@/components/launch-pad.vue'), {}, { }, 'closed'); diff --git a/packages/client/src/ui/classic.header.vue b/packages/client/src/ui/classic.header.vue index 3563e8a888..699b992668 100644 --- a/packages/client/src/ui/classic.header.vue +++ b/packages/client/src/ui/classic.header.vue @@ -105,7 +105,11 @@ export default defineComponent({ }, 'closed'); }, - openAccountMenu, + openAccountMenu:(ev) => { + openAccountMenu({ + withExtraOperation: true, + }, ev); + }, } }); </script> diff --git a/packages/client/src/ui/classic.side.vue b/packages/client/src/ui/classic.side.vue index ede658626a..f816834141 100644 --- a/packages/client/src/ui/classic.side.vue +++ b/packages/client/src/ui/classic.side.vue @@ -72,7 +72,7 @@ export default defineComponent({ this.props = {}; }, - onContextmenu(e) { + onContextmenu(ev: MouseEvent) { os.contextMenu([{ type: 'label', text: this.path, @@ -103,7 +103,7 @@ export default defineComponent({ action: () => { copyToClipboard(this.url); } - }], e); + }], ev); } } }); diff --git a/packages/client/src/ui/classic.sidebar.vue b/packages/client/src/ui/classic.sidebar.vue index cc9d7a9b48..afbca06c8e 100644 --- a/packages/client/src/ui/classic.sidebar.vue +++ b/packages/client/src/ui/classic.sidebar.vue @@ -125,7 +125,11 @@ export default defineComponent({ }, 'closed'); }, - openAccountMenu, + openAccountMenu:(ev) => { + openAccountMenu({ + withExtraOperation: true, + }, ev); + }, } }); </script> diff --git a/packages/client/src/ui/classic.vue b/packages/client/src/ui/classic.vue index 41da973152..1603ea6399 100644 --- a/packages/client/src/ui/classic.vue +++ b/packages/client/src/ui/classic.vue @@ -167,15 +167,15 @@ export default defineComponent({ if (window._scroll) window._scroll(); }, - onContextmenu(e) { + onContextmenu(ev: MouseEvent) { const isLink = (el: HTMLElement) => { if (el.tagName === 'A') return true; if (el.parentElement) { return isLink(el.parentElement); } }; - if (isLink(e.target)) return; - if (['INPUT', 'TEXTAREA', 'IMG', 'VIDEO', 'CANVAS'].includes(e.target.tagName) || e.target.attributes['contenteditable']) return; + if (isLink(ev.target)) return; + if (['INPUT', 'TEXTAREA', 'IMG', 'VIDEO', 'CANVAS'].includes(ev.target.tagName) || ev.target.attributes['contenteditable']) return; if (window.getSelection().toString() !== '') return; const path = this.$route.path; os.contextMenu([{ @@ -193,7 +193,7 @@ export default defineComponent({ action: () => { os.pageWindow(path); } - }], e); + }], ev); }, onAiClick(ev) { diff --git a/packages/client/src/ui/deck/column.vue b/packages/client/src/ui/deck/column.vue index 1982d92ad3..f1ce3ca838 100644 --- a/packages/client/src/ui/deck/column.vue +++ b/packages/client/src/ui/deck/column.vue @@ -207,8 +207,8 @@ export default defineComponent({ return items; }, - onContextmenu(e) { - os.contextMenu(this.getMenu(), e); + onContextmenu(ev: MouseEvent) { + os.contextMenu(this.getMenu(), ev); }, goTop() { diff --git a/packages/client/src/ui/deck/main-column.vue b/packages/client/src/ui/deck/main-column.vue index 744056881c..b77d6f35cc 100644 --- a/packages/client/src/ui/deck/main-column.vue +++ b/packages/client/src/ui/deck/main-column.vue @@ -64,15 +64,15 @@ export default defineComponent({ history.back(); }, - onContextmenu(e) { + onContextmenu(ev: MouseEvent) { const isLink = (el: HTMLElement) => { if (el.tagName === 'A') return true; if (el.parentElement) { return isLink(el.parentElement); } }; - if (isLink(e.target)) return; - if (['INPUT', 'TEXTAREA', 'IMG', 'VIDEO', 'CANVAS'].includes(e.target.tagName) || e.target.attributes['contenteditable']) return; + if (isLink(ev.target)) return; + if (['INPUT', 'TEXTAREA', 'IMG', 'VIDEO', 'CANVAS'].includes(ev.target.tagName) || ev.target.attributes['contenteditable']) return; if (window.getSelection().toString() !== '') return; const path = this.$route.path; os.contextMenu([{ @@ -84,7 +84,7 @@ export default defineComponent({ action: () => { os.pageWindow(path); } - }], e); + }], ev); }, } }); |