refactor: チャットルームをComposition API化 (#8850)
* pick form
* pick message
* pick room
* fix lint
* fix scroll?
* fix scroll.ts
* fix directives/sticky-container
* update global/sticky-container.vue
* fix, 🎨
* test.1
This commit is contained in:
parent
b70473ed60
commit
30a39a296d
7 changed files with 585 additions and 661 deletions
|
|
@ -1,222 +1,222 @@
|
|||
<template>
|
||||
<div class="pemppnzi _block"
|
||||
<div
|
||||
class="pemppnzi _block"
|
||||
@dragover.stop="onDragover"
|
||||
@drop.stop="onDrop"
|
||||
>
|
||||
<textarea
|
||||
ref="text"
|
||||
ref="textEl"
|
||||
v-model="text"
|
||||
:placeholder="$ts.inputMessageHere"
|
||||
:placeholder="i18n.ts.inputMessageHere"
|
||||
@keydown="onKeydown"
|
||||
@compositionupdate="onCompositionUpdate"
|
||||
@paste="onPaste"
|
||||
></textarea>
|
||||
<div v-if="file" class="file" @click="file = null">{{ file.name }}</div>
|
||||
<button class="send _button" :disabled="!canSend || sending" :title="$ts.send" @click="send">
|
||||
<template v-if="!sending"><i class="fas fa-paper-plane"></i></template><template v-if="sending"><i class="fas fa-spinner fa-pulse fa-fw"></i></template>
|
||||
</button>
|
||||
<button class="_button" @click="chooseFile"><i class="fas fa-photo-video"></i></button>
|
||||
<button class="_button" @click="insertEmoji"><i class="fas fa-laugh-squint"></i></button>
|
||||
<input ref="file" type="file" @change="onChangeFile"/>
|
||||
<footer>
|
||||
<div v-if="file" class="file" @click="file = null">{{ file.name }}</div>
|
||||
<div class="buttons">
|
||||
<button class="_button" @click="chooseFile"><i class="fas fa-photo-video"></i></button>
|
||||
<button class="_button" @click="insertEmoji"><i class="fas fa-laugh-squint"></i></button>
|
||||
<button class="send _button" :disabled="!canSend || sending" :title="i18n.ts.send" @click="send">
|
||||
<template v-if="!sending"><i class="fas fa-paper-plane"></i></template><template v-if="sending"><i class="fas fa-spinner fa-pulse fa-fw"></i></template>
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
<input ref="fileEl" type="file" @change="onChangeFile"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, defineAsyncComponent } from 'vue';
|
||||
import insertTextAtCursor from 'insert-text-at-cursor';
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, watch } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import autosize from 'autosize';
|
||||
//import insertTextAtCursor from 'insert-text-at-cursor';
|
||||
import { throttle } from 'throttle-debounce';
|
||||
import { formatTimeString } from '@/scripts/format-time-string';
|
||||
import { selectFile } from '@/scripts/select-file';
|
||||
import * as os from '@/os';
|
||||
import { stream } from '@/stream';
|
||||
import { Autocomplete } from '@/scripts/autocomplete';
|
||||
import { throttle } from 'throttle-debounce';
|
||||
import { defaultStore } from '@/store';
|
||||
import { i18n } from '@/i18n';
|
||||
//import { Autocomplete } from '@/scripts/autocomplete';
|
||||
import { uploadFile } from '@/scripts/upload';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
user: {
|
||||
type: Object,
|
||||
requird: false,
|
||||
},
|
||||
group: {
|
||||
type: Object,
|
||||
requird: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
text: null,
|
||||
file: null,
|
||||
sending: false,
|
||||
typing: throttle(3000, () => {
|
||||
stream.send('typingOnMessaging', this.user ? { partner: this.user.id } : { group: this.group.id });
|
||||
}),
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
draftKey(): string {
|
||||
return this.user ? 'user:' + this.user.id : 'group:' + this.group.id;
|
||||
},
|
||||
canSend(): boolean {
|
||||
return (this.text != null && this.text !== '') || this.file != null;
|
||||
},
|
||||
room(): any {
|
||||
return this.$parent;
|
||||
const props = defineProps<{
|
||||
user?: Misskey.entities.UserDetailed | null;
|
||||
group?: Misskey.entities.UserGroup | null;
|
||||
}>();
|
||||
|
||||
let textEl = $ref<HTMLTextAreaElement>();
|
||||
let fileEl = $ref<HTMLInputElement>();
|
||||
|
||||
let text = $ref<string>('');
|
||||
let file = $ref<Misskey.entities.DriveFile | null>(null);
|
||||
let sending = $ref(false);
|
||||
const typing = throttle(3000, () => {
|
||||
stream.send('typingOnMessaging', props.user ? { partner: props.user.id } : { group: props.group?.id });
|
||||
});
|
||||
|
||||
let draftKey = $computed(() => props.user ? 'user:' + props.user.id : 'group:' + props.group?.id);
|
||||
let canSend = $computed(() => (text != null && text !== '') || file != null);
|
||||
|
||||
watch([$$(text), $$(file)], saveDraft);
|
||||
|
||||
async function onPaste(ev: ClipboardEvent) {
|
||||
if (!ev.clipboardData) return;
|
||||
|
||||
const clipboardData = ev.clipboardData;
|
||||
const items = clipboardData.items;
|
||||
|
||||
if (items.length === 1) {
|
||||
if (items[0].kind === 'file') {
|
||||
const pastedFile = items[0].getAsFile();
|
||||
if (!pastedFile) return;
|
||||
const lio = pastedFile.name.lastIndexOf('.');
|
||||
const ext = lio >= 0 ? pastedFile.name.slice(lio) : '';
|
||||
const formatted = formatTimeString(new Date(pastedFile.lastModified), defaultStore.state.pastedFileName).replace(/{{number}}/g, '1') + ext;
|
||||
if (formatted) upload(pastedFile, formatted);
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
text() {
|
||||
this.saveDraft();
|
||||
},
|
||||
file() {
|
||||
this.saveDraft();
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
autosize(this.$refs.text);
|
||||
|
||||
// TODO: detach when unmount
|
||||
// TODO
|
||||
//new Autocomplete(this.$refs.text, this, { model: 'text' });
|
||||
|
||||
// 書きかけの投稿を復元
|
||||
const draft = JSON.parse(localStorage.getItem('message_drafts') || '{}')[this.draftKey];
|
||||
if (draft) {
|
||||
this.text = draft.data.text;
|
||||
this.file = draft.data.file;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async onPaste(evt: ClipboardEvent) {
|
||||
const items = evt.clipboardData.items;
|
||||
|
||||
if (items.length === 1) {
|
||||
if (items[0].kind === 'file') {
|
||||
const file = items[0].getAsFile();
|
||||
const lio = file.name.lastIndexOf('.');
|
||||
const ext = lio >= 0 ? file.name.slice(lio) : '';
|
||||
const formatted = `${formatTimeString(new Date(file.lastModified), this.$store.state.pastedFileName).replace(/{{number}}/g, '1')}${ext}`;
|
||||
if (formatted) this.upload(file, formatted);
|
||||
}
|
||||
} else {
|
||||
if (items[0].kind === 'file') {
|
||||
os.alert({
|
||||
type: 'error',
|
||||
text: this.$ts.onlyOneFileCanBeAttached
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
onDragover(evt) {
|
||||
const isFile = evt.dataTransfer.items[0].kind === 'file';
|
||||
const isDriveFile = evt.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_;
|
||||
if (isFile || isDriveFile) {
|
||||
evt.preventDefault();
|
||||
evt.dataTransfer.dropEffect = evt.dataTransfer.effectAllowed === 'all' ? 'copy' : 'move';
|
||||
}
|
||||
},
|
||||
|
||||
onDrop(evt): void {
|
||||
// ファイルだったら
|
||||
if (evt.dataTransfer.files.length === 1) {
|
||||
evt.preventDefault();
|
||||
this.upload(evt.dataTransfer.files[0]);
|
||||
return;
|
||||
} else if (evt.dataTransfer.files.length > 1) {
|
||||
evt.preventDefault();
|
||||
os.alert({
|
||||
type: 'error',
|
||||
text: this.$ts.onlyOneFileCanBeAttached
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
//#region ドライブのファイル
|
||||
const driveFile = evt.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
|
||||
if (driveFile != null && driveFile !== '') {
|
||||
this.file = JSON.parse(driveFile);
|
||||
evt.preventDefault();
|
||||
}
|
||||
//#endregion
|
||||
},
|
||||
|
||||
onKeydown(evt) {
|
||||
this.typing();
|
||||
if ((evt.which === 10 || evt.which === 13) && (evt.ctrlKey || evt.metaKey) && this.canSend) {
|
||||
this.send();
|
||||
}
|
||||
},
|
||||
|
||||
onCompositionUpdate() {
|
||||
this.typing();
|
||||
},
|
||||
|
||||
chooseFile(evt) {
|
||||
selectFile(evt.currentTarget ?? evt.target, this.$ts.selectFile).then(file => {
|
||||
this.file = file;
|
||||
} else {
|
||||
if (items[0].kind === 'file') {
|
||||
os.alert({
|
||||
type: 'error',
|
||||
text: i18n.ts.onlyOneFileCanBeAttached,
|
||||
});
|
||||
},
|
||||
|
||||
onChangeFile() {
|
||||
this.upload((this.$refs.file as any).files[0]);
|
||||
},
|
||||
|
||||
upload(file: File, name?: string) {
|
||||
uploadFile(file, this.$store.state.uploadFolder, name).then(res => {
|
||||
this.file = res;
|
||||
});
|
||||
},
|
||||
|
||||
send() {
|
||||
this.sending = true;
|
||||
os.api('messaging/messages/create', {
|
||||
userId: this.user ? this.user.id : undefined,
|
||||
groupId: this.group ? this.group.id : undefined,
|
||||
text: this.text ? this.text : undefined,
|
||||
fileId: this.file ? this.file.id : undefined
|
||||
}).then(message => {
|
||||
this.clear();
|
||||
}).catch(err => {
|
||||
console.error(err);
|
||||
}).then(() => {
|
||||
this.sending = false;
|
||||
});
|
||||
},
|
||||
|
||||
clear() {
|
||||
this.text = '';
|
||||
this.file = null;
|
||||
this.deleteDraft();
|
||||
},
|
||||
|
||||
saveDraft() {
|
||||
const drafts = JSON.parse(localStorage.getItem('message_drafts') || '{}');
|
||||
|
||||
drafts[this.draftKey] = {
|
||||
updatedAt: new Date(),
|
||||
data: {
|
||||
text: this.text,
|
||||
file: this.file
|
||||
}
|
||||
};
|
||||
|
||||
localStorage.setItem('message_drafts', JSON.stringify(drafts));
|
||||
},
|
||||
|
||||
deleteDraft() {
|
||||
const drafts = JSON.parse(localStorage.getItem('message_drafts') || '{}');
|
||||
|
||||
delete drafts[this.draftKey];
|
||||
|
||||
localStorage.setItem('message_drafts', JSON.stringify(drafts));
|
||||
},
|
||||
|
||||
async insertEmoji(ev) {
|
||||
os.openEmojiPicker(ev.currentTarget ?? ev.target, {}, this.$refs.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onDragover(ev: DragEvent) {
|
||||
if (!ev.dataTransfer) return;
|
||||
|
||||
const isFile = ev.dataTransfer.items[0].kind === 'file';
|
||||
const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_;
|
||||
if (isFile || isDriveFile) {
|
||||
ev.preventDefault();
|
||||
ev.dataTransfer.dropEffect = ev.dataTransfer.effectAllowed === 'all' ? 'copy' : 'move';
|
||||
}
|
||||
}
|
||||
|
||||
function onDrop(ev: DragEvent): void {
|
||||
if (!ev.dataTransfer) return;
|
||||
|
||||
// ファイルだったら
|
||||
if (ev.dataTransfer.files.length === 1) {
|
||||
ev.preventDefault();
|
||||
upload(ev.dataTransfer.files[0]);
|
||||
return;
|
||||
} else if (ev.dataTransfer.files.length > 1) {
|
||||
ev.preventDefault();
|
||||
os.alert({
|
||||
type: 'error',
|
||||
text: i18n.ts.onlyOneFileCanBeAttached,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
//#region ドライブのファイル
|
||||
const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
|
||||
if (driveFile != null && driveFile !== '') {
|
||||
file = JSON.parse(driveFile);
|
||||
ev.preventDefault();
|
||||
}
|
||||
//#endregion
|
||||
}
|
||||
|
||||
function onKeydown(ev: KeyboardEvent) {
|
||||
typing();
|
||||
if ((ev.key === 'Enter') && (ev.ctrlKey || ev.metaKey) && canSend) {
|
||||
send();
|
||||
}
|
||||
}
|
||||
|
||||
function onCompositionUpdate() {
|
||||
typing();
|
||||
}
|
||||
|
||||
function chooseFile(ev: MouseEvent) {
|
||||
selectFile(ev.currentTarget ?? ev.target, i18n.ts.selectFile).then(selectedFile => {
|
||||
file = selectedFile;
|
||||
});
|
||||
}
|
||||
|
||||
function onChangeFile() {
|
||||
if (fileEl.files![0]) upload(fileEl.files[0]);
|
||||
}
|
||||
|
||||
function upload(fileToUpload: File, name?: string) {
|
||||
uploadFile(fileToUpload, defaultStore.state.uploadFolder, name).then(res => {
|
||||
file = res;
|
||||
});
|
||||
}
|
||||
|
||||
function send() {
|
||||
sending = true;
|
||||
os.api('messaging/messages/create', {
|
||||
userId: props.user ? props.user.id : undefined,
|
||||
groupId: props.group ? props.group.id : undefined,
|
||||
text: text ? text : undefined,
|
||||
fileId: file ? file.id : undefined,
|
||||
}).then(message => {
|
||||
clear();
|
||||
}).catch(err => {
|
||||
console.error(err);
|
||||
}).then(() => {
|
||||
sending = false;
|
||||
});
|
||||
}
|
||||
|
||||
function clear() {
|
||||
text = '';
|
||||
file = null;
|
||||
deleteDraft();
|
||||
}
|
||||
|
||||
function saveDraft() {
|
||||
const drafts = JSON.parse(localStorage.getItem('message_drafts') || '{}');
|
||||
|
||||
drafts[draftKey] = {
|
||||
updatedAt: new Date(),
|
||||
// eslint-disable-next-line id-denylist
|
||||
data: {
|
||||
text: text,
|
||||
file: file,
|
||||
},
|
||||
};
|
||||
|
||||
localStorage.setItem('message_drafts', JSON.stringify(drafts));
|
||||
}
|
||||
|
||||
function deleteDraft() {
|
||||
const drafts = JSON.parse(localStorage.getItem('message_drafts') || '{}');
|
||||
|
||||
delete drafts[draftKey];
|
||||
|
||||
localStorage.setItem('message_drafts', JSON.stringify(drafts));
|
||||
}
|
||||
|
||||
async function insertEmoji(ev: MouseEvent) {
|
||||
os.openEmojiPicker(ev.currentTarget ?? ev.target, {}, textEl);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
autosize(textEl);
|
||||
|
||||
// TODO: detach when unmount
|
||||
// TODO
|
||||
//new Autocomplete(textEl, this, { model: 'text' });
|
||||
|
||||
// 書きかけの投稿を復元
|
||||
const draft = JSON.parse(localStorage.getItem('message_drafts') || '{}')[draftKey];
|
||||
if (draft) {
|
||||
text = draft.data.text;
|
||||
file = draft.data.file;
|
||||
}
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
file,
|
||||
upload,
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
@ -230,7 +230,7 @@ export default defineComponent({
|
|||
width: 100%;
|
||||
min-width: 100%;
|
||||
max-width: 100%;
|
||||
height: 80px;
|
||||
min-height: 80px;
|
||||
margin: 0;
|
||||
padding: 16px 16px 0 16px;
|
||||
resize: none;
|
||||
|
|
@ -245,26 +245,16 @@ export default defineComponent({
|
|||
color: var(--fg);
|
||||
}
|
||||
|
||||
> .file {
|
||||
padding: 8px;
|
||||
color: #444;
|
||||
background: #eee;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
> .send {
|
||||
position: absolute;
|
||||
footer {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
font-size: 1em;
|
||||
transition: color 0.1s ease;
|
||||
color: var(--accent);
|
||||
background: var(--panel);
|
||||
|
||||
&:active {
|
||||
color: var(--accentDarken);
|
||||
transition: color 0s ease;
|
||||
> .file {
|
||||
padding: 8px;
|
||||
color: var(--fg);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -316,21 +306,39 @@ export default defineComponent({
|
|||
}
|
||||
}
|
||||
|
||||
._button {
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
font-size: 1em;
|
||||
font-weight: normal;
|
||||
text-decoration: none;
|
||||
transition: color 0.1s ease;
|
||||
.buttons {
|
||||
display: flex;
|
||||
|
||||
&:hover {
|
||||
color: var(--accent);
|
||||
._button {
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
font-size: 1em;
|
||||
font-weight: normal;
|
||||
text-decoration: none;
|
||||
transition: color 0.1s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
&:active {
|
||||
color: var(--accentDarken);
|
||||
transition: color 0s ease;
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
color: var(--accentDarken);
|
||||
transition: color 0s ease;
|
||||
> .send {
|
||||
margin-left: auto;
|
||||
color: var(--accent);
|
||||
|
||||
&:hover {
|
||||
color: var(--accentLighten);
|
||||
}
|
||||
|
||||
&:active {
|
||||
color: var(--accentDarken);
|
||||
transition: color 0s ease;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue