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
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "misskey",
|
"name": "misskey",
|
||||||
"version": "12.111.1",
|
"version": "12.111.1-test.1",
|
||||||
"codename": "indigo",
|
"codename": "indigo",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|
|
@ -1,71 +1,63 @@
|
||||||
<template>
|
<template>
|
||||||
<div ref="rootEl">
|
<div ref="rootEl">
|
||||||
<slot name="header"></slot>
|
<slot name="header"></slot>
|
||||||
<div ref="bodyEl">
|
<div ref="bodyEl" :data-sticky-container-header-height="headerHeight">
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts" setup>
|
||||||
import { defineComponent, onMounted, onUnmounted, ref } from 'vue';
|
import { onMounted, onUnmounted } from 'vue';
|
||||||
|
|
||||||
export default defineComponent({
|
const props = withDefaults(defineProps<{
|
||||||
props: {
|
autoSticky?: boolean;
|
||||||
autoSticky: {
|
}>(), {
|
||||||
type: Boolean,
|
autoSticky: false,
|
||||||
required: false,
|
});
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
setup(props, context) {
|
const rootEl = $ref<HTMLElement>();
|
||||||
const rootEl = ref<HTMLElement>(null);
|
const bodyEl = $ref<HTMLElement>();
|
||||||
const bodyEl = ref<HTMLElement>(null);
|
|
||||||
|
|
||||||
const calc = () => {
|
let headerHeight = $ref<string | undefined>();
|
||||||
const currentStickyTop = getComputedStyle(rootEl.value).getPropertyValue('--stickyTop') || '0px';
|
|
||||||
|
|
||||||
const header = rootEl.value.children[0];
|
const calc = () => {
|
||||||
if (header === bodyEl.value) {
|
const currentStickyTop = getComputedStyle(rootEl).getPropertyValue('--stickyTop') || '0px';
|
||||||
bodyEl.value.style.setProperty('--stickyTop', currentStickyTop);
|
|
||||||
} else {
|
|
||||||
bodyEl.value.style.setProperty('--stickyTop', `calc(${currentStickyTop} + ${header.offsetHeight}px)`);
|
|
||||||
|
|
||||||
if (props.autoSticky) {
|
const header = rootEl.children[0] as HTMLElement;
|
||||||
header.style.setProperty('--stickyTop', currentStickyTop);
|
if (header === bodyEl) {
|
||||||
header.style.position = 'sticky';
|
bodyEl.style.setProperty('--stickyTop', currentStickyTop);
|
||||||
header.style.top = 'var(--stickyTop)';
|
} else {
|
||||||
header.style.zIndex = '1';
|
bodyEl.style.setProperty('--stickyTop', `calc(${currentStickyTop} + ${header.offsetHeight}px)`);
|
||||||
}
|
headerHeight = header.offsetHeight.toString();
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(() => {
|
if (props.autoSticky) {
|
||||||
calc();
|
header.style.setProperty('--stickyTop', currentStickyTop);
|
||||||
|
header.style.position = 'sticky';
|
||||||
|
header.style.top = 'var(--stickyTop)';
|
||||||
|
header.style.zIndex = '1';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const observer = new MutationObserver(() => {
|
const observer = new MutationObserver(() => {
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
calc();
|
calc();
|
||||||
}, 100);
|
}, 100);
|
||||||
});
|
});
|
||||||
|
|
||||||
observer.observe(rootEl.value, {
|
onMounted(() => {
|
||||||
attributes: false,
|
calc();
|
||||||
childList: true,
|
|
||||||
subtree: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
observer.observe(rootEl, {
|
||||||
observer.disconnect();
|
attributes: false,
|
||||||
});
|
childList: true,
|
||||||
});
|
subtree: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
onUnmounted(() => {
|
||||||
rootEl,
|
observer.disconnect();
|
||||||
bodyEl,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
@ -5,8 +5,10 @@ export default {
|
||||||
//const query = binding.value;
|
//const query = binding.value;
|
||||||
|
|
||||||
const header = src.children[0];
|
const header = src.children[0];
|
||||||
|
const body = src.children[1];
|
||||||
const currentStickyTop = getComputedStyle(src).getPropertyValue('--stickyTop') || '0px';
|
const currentStickyTop = getComputedStyle(src).getPropertyValue('--stickyTop') || '0px';
|
||||||
src.style.setProperty('--stickyTop', `calc(${currentStickyTop} + ${header.offsetHeight}px)`);
|
src.style.setProperty('--stickyTop', `calc(${currentStickyTop} + ${header.offsetHeight}px)`);
|
||||||
|
if (body) body.dataset.stickyContainerHeaderHeight = header.offsetHeight.toString();
|
||||||
header.style.setProperty('--stickyTop', currentStickyTop);
|
header.style.setProperty('--stickyTop', currentStickyTop);
|
||||||
header.style.position = 'sticky';
|
header.style.position = 'sticky';
|
||||||
header.style.top = 'var(--stickyTop)';
|
header.style.top = 'var(--stickyTop)';
|
||||||
|
|
|
@ -1,222 +1,222 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="pemppnzi _block"
|
<div
|
||||||
|
class="pemppnzi _block"
|
||||||
@dragover.stop="onDragover"
|
@dragover.stop="onDragover"
|
||||||
@drop.stop="onDrop"
|
@drop.stop="onDrop"
|
||||||
>
|
>
|
||||||
<textarea
|
<textarea
|
||||||
ref="text"
|
ref="textEl"
|
||||||
v-model="text"
|
v-model="text"
|
||||||
:placeholder="$ts.inputMessageHere"
|
:placeholder="i18n.ts.inputMessageHere"
|
||||||
@keydown="onKeydown"
|
@keydown="onKeydown"
|
||||||
@compositionupdate="onCompositionUpdate"
|
@compositionupdate="onCompositionUpdate"
|
||||||
@paste="onPaste"
|
@paste="onPaste"
|
||||||
></textarea>
|
></textarea>
|
||||||
<div v-if="file" class="file" @click="file = null">{{ file.name }}</div>
|
<footer>
|
||||||
<button class="send _button" :disabled="!canSend || sending" :title="$ts.send" @click="send">
|
<div v-if="file" class="file" @click="file = null">{{ file.name }}</div>
|
||||||
<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>
|
<div class="buttons">
|
||||||
</button>
|
<button class="_button" @click="chooseFile"><i class="fas fa-photo-video"></i></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>
|
||||||
<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">
|
||||||
<input ref="file" type="file" @change="onChangeFile"/>
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts" setup>
|
||||||
import { defineComponent, defineAsyncComponent } from 'vue';
|
import { onMounted, watch } from 'vue';
|
||||||
import insertTextAtCursor from 'insert-text-at-cursor';
|
import * as Misskey from 'misskey-js';
|
||||||
import autosize from 'autosize';
|
import autosize from 'autosize';
|
||||||
|
//import insertTextAtCursor from 'insert-text-at-cursor';
|
||||||
|
import { throttle } from 'throttle-debounce';
|
||||||
import { formatTimeString } from '@/scripts/format-time-string';
|
import { formatTimeString } from '@/scripts/format-time-string';
|
||||||
import { selectFile } from '@/scripts/select-file';
|
import { selectFile } from '@/scripts/select-file';
|
||||||
import * as os from '@/os';
|
import * as os from '@/os';
|
||||||
import { stream } from '@/stream';
|
import { stream } from '@/stream';
|
||||||
import { Autocomplete } from '@/scripts/autocomplete';
|
import { defaultStore } from '@/store';
|
||||||
import { throttle } from 'throttle-debounce';
|
import { i18n } from '@/i18n';
|
||||||
|
//import { Autocomplete } from '@/scripts/autocomplete';
|
||||||
import { uploadFile } from '@/scripts/upload';
|
import { uploadFile } from '@/scripts/upload';
|
||||||
|
|
||||||
export default defineComponent({
|
const props = defineProps<{
|
||||||
props: {
|
user?: Misskey.entities.UserDetailed | null;
|
||||||
user: {
|
group?: Misskey.entities.UserGroup | null;
|
||||||
type: Object,
|
}>();
|
||||||
requird: false,
|
|
||||||
},
|
let textEl = $ref<HTMLTextAreaElement>();
|
||||||
group: {
|
let fileEl = $ref<HTMLInputElement>();
|
||||||
type: Object,
|
|
||||||
requird: false,
|
let text = $ref<string>('');
|
||||||
},
|
let file = $ref<Misskey.entities.DriveFile | null>(null);
|
||||||
},
|
let sending = $ref(false);
|
||||||
data() {
|
const typing = throttle(3000, () => {
|
||||||
return {
|
stream.send('typingOnMessaging', props.user ? { partner: props.user.id } : { group: props.group?.id });
|
||||||
text: null,
|
});
|
||||||
file: null,
|
|
||||||
sending: false,
|
let draftKey = $computed(() => props.user ? 'user:' + props.user.id : 'group:' + props.group?.id);
|
||||||
typing: throttle(3000, () => {
|
let canSend = $computed(() => (text != null && text !== '') || file != null);
|
||||||
stream.send('typingOnMessaging', this.user ? { partner: this.user.id } : { group: this.group.id });
|
|
||||||
}),
|
watch([$$(text), $$(file)], saveDraft);
|
||||||
};
|
|
||||||
},
|
async function onPaste(ev: ClipboardEvent) {
|
||||||
computed: {
|
if (!ev.clipboardData) return;
|
||||||
draftKey(): string {
|
|
||||||
return this.user ? 'user:' + this.user.id : 'group:' + this.group.id;
|
const clipboardData = ev.clipboardData;
|
||||||
},
|
const items = clipboardData.items;
|
||||||
canSend(): boolean {
|
|
||||||
return (this.text != null && this.text !== '') || this.file != null;
|
if (items.length === 1) {
|
||||||
},
|
if (items[0].kind === 'file') {
|
||||||
room(): any {
|
const pastedFile = items[0].getAsFile();
|
||||||
return this.$parent;
|
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);
|
||||||
}
|
}
|
||||||
},
|
} else {
|
||||||
watch: {
|
if (items[0].kind === 'file') {
|
||||||
text() {
|
os.alert({
|
||||||
this.saveDraft();
|
type: 'error',
|
||||||
},
|
text: i18n.ts.onlyOneFileCanBeAttached,
|
||||||
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;
|
|
||||||
});
|
});
|
||||||
},
|
|
||||||
|
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
|
@ -230,7 +230,7 @@ export default defineComponent({
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 100%;
|
min-width: 100%;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
height: 80px;
|
min-height: 80px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 16px 16px 0 16px;
|
padding: 16px 16px 0 16px;
|
||||||
resize: none;
|
resize: none;
|
||||||
|
@ -245,26 +245,16 @@ export default defineComponent({
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
}
|
}
|
||||||
|
|
||||||
> .file {
|
footer {
|
||||||
padding: 8px;
|
position: sticky;
|
||||||
color: #444;
|
|
||||||
background: #eee;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
> .send {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
right: 0;
|
background: var(--panel);
|
||||||
margin: 0;
|
|
||||||
padding: 16px;
|
|
||||||
font-size: 1em;
|
|
||||||
transition: color 0.1s ease;
|
|
||||||
color: var(--accent);
|
|
||||||
|
|
||||||
&:active {
|
> .file {
|
||||||
color: var(--accentDarken);
|
padding: 8px;
|
||||||
transition: color 0s ease;
|
color: var(--fg);
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -316,21 +306,39 @@ export default defineComponent({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
._button {
|
.buttons {
|
||||||
margin: 0;
|
display: flex;
|
||||||
padding: 16px;
|
|
||||||
font-size: 1em;
|
|
||||||
font-weight: normal;
|
|
||||||
text-decoration: none;
|
|
||||||
transition: color 0.1s ease;
|
|
||||||
|
|
||||||
&:hover {
|
._button {
|
||||||
color: var(--accent);
|
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 {
|
> .send {
|
||||||
color: var(--accentDarken);
|
margin-left: auto;
|
||||||
transition: color 0s ease;
|
color: var(--accent);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--accentLighten);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
color: var(--accentDarken);
|
||||||
|
transition: color 0s ease;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -35,45 +35,28 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts" setup>
|
||||||
import { defineComponent } from 'vue';
|
import { } from 'vue';
|
||||||
import * as mfm from 'mfm-js';
|
import * as mfm from 'mfm-js';
|
||||||
|
import * as Misskey from 'misskey-js';
|
||||||
import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm';
|
import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm';
|
||||||
import MkUrlPreview from '@/components/url-preview.vue';
|
import MkUrlPreview from '@/components/url-preview.vue';
|
||||||
import * as os from '@/os';
|
import * as os from '@/os';
|
||||||
|
import { $i } from '@/account';
|
||||||
|
|
||||||
export default defineComponent({
|
const props = defineProps<{
|
||||||
components: {
|
message: Misskey.entities.MessagingMessage;
|
||||||
MkUrlPreview
|
isGroup?: boolean;
|
||||||
},
|
}>();
|
||||||
props: {
|
|
||||||
message: {
|
const isMe = $computed(() => props.message.userId === $i?.id);
|
||||||
required: true
|
const urls = $computed(() => props.message.text ? extractUrlFromMfm(mfm.parse(props.message.text)) : []);
|
||||||
},
|
|
||||||
isGroup: {
|
function del(): void {
|
||||||
required: false
|
os.api('messaging/messages/delete', {
|
||||||
}
|
messageId: props.message.id,
|
||||||
},
|
});
|
||||||
computed: {
|
}
|
||||||
isMe(): boolean {
|
|
||||||
return this.message.userId === this.$i.id;
|
|
||||||
},
|
|
||||||
urls(): string[] {
|
|
||||||
if (this.message.text) {
|
|
||||||
return extractUrlFromMfm(mfm.parse(this.message.text));
|
|
||||||
} else {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
del() {
|
|
||||||
os.api('messaging/messages/delete', {
|
|
||||||
messageId: this.message.id
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
@ -266,6 +249,7 @@ export default defineComponent({
|
||||||
&.isMe {
|
&.isMe {
|
||||||
flex-direction: row-reverse;
|
flex-direction: row-reverse;
|
||||||
padding-right: var(--margin);
|
padding-right: var(--margin);
|
||||||
|
right: var(--margin); // 削除時にposition: absoluteになったときに使う
|
||||||
|
|
||||||
> .content {
|
> .content {
|
||||||
padding-right: 16px;
|
padding-right: 16px;
|
||||||
|
|
|
@ -1,379 +1,302 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="_section"
|
<div
|
||||||
|
ref="rootEl"
|
||||||
|
class="_section"
|
||||||
@dragover.prevent.stop="onDragover"
|
@dragover.prevent.stop="onDragover"
|
||||||
@drop.prevent.stop="onDrop"
|
@drop.prevent.stop="onDrop"
|
||||||
>
|
>
|
||||||
<div class="_content mk-messaging-room">
|
<div class="_content mk-messaging-room">
|
||||||
<div class="body">
|
<div class="body">
|
||||||
<MkLoading v-if="fetching"/>
|
<MkPagination v-if="pagination" ref="pagingComponent" :key="userAcct || groupId" :pagination="pagination">
|
||||||
<p v-if="!fetching && messages.length == 0" class="empty"><i class="fas fa-info-circle"></i>{{ $ts.noMessagesYet }}</p>
|
<template #empty>
|
||||||
<p v-if="!fetching && messages.length > 0 && !existMoreMessages" class="no-history"><i class="fas fa-flag"></i>{{ $ts.noMoreHistory }}</p>
|
<div class="_fullinfo">
|
||||||
<button v-show="existMoreMessages" ref="loadMore" class="more _button" :class="{ fetching: fetchingMoreMessages }" :disabled="fetchingMoreMessages" @click="fetchMoreMessages">
|
<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
|
||||||
<template v-if="fetchingMoreMessages"><i class="fas fa-spinner fa-pulse fa-fw"></i></template>{{ fetchingMoreMessages ? $ts.loading : $ts.loadMore }}
|
<div>{{ i18n.ts.noMessagesYet }}</div>
|
||||||
</button>
|
</div>
|
||||||
<XList v-if="messages.length > 0" v-slot="{ item: message }" class="messages" :items="messages" direction="up" reversed>
|
</template>
|
||||||
<XMessage :key="message.id" :message="message" :is-group="group != null"/>
|
|
||||||
</XList>
|
<template #default="{ items: messages, fetching: pFetching }">
|
||||||
|
<XList
|
||||||
|
v-if="messages.length > 0"
|
||||||
|
v-slot="{ item: message }"
|
||||||
|
:class="{ messages: true, 'deny-move-transition': pFetching }"
|
||||||
|
:items="messages"
|
||||||
|
direction="up"
|
||||||
|
reversed
|
||||||
|
>
|
||||||
|
<XMessage :key="message.id" :message="message" :is-group="group != null"/>
|
||||||
|
</XList>
|
||||||
|
</template>
|
||||||
|
</MkPagination>
|
||||||
</div>
|
</div>
|
||||||
<footer>
|
<footer>
|
||||||
<div v-if="typers.length > 0" class="typers">
|
<div v-if="typers.length > 0" class="typers">
|
||||||
<I18n :src="$ts.typingUsers" text-tag="span" class="users">
|
<I18n :src="i18n.ts.typingUsers" text-tag="span" class="users">
|
||||||
<template #users>
|
<template #users>
|
||||||
<b v-for="user in typers" :key="user.id" class="user">{{ user.username }}</b>
|
<b v-for="typer in typers" :key="typer.id" class="user">{{ typer.username }}</b>
|
||||||
</template>
|
</template>
|
||||||
</I18n>
|
</I18n>
|
||||||
<MkEllipsis/>
|
<MkEllipsis/>
|
||||||
</div>
|
</div>
|
||||||
<transition :name="$store.state.animation ? 'fade' : ''">
|
<transition :name="animation ? 'fade' : ''">
|
||||||
<div v-show="showIndicator" class="new-message">
|
<div v-show="showIndicator" class="new-message">
|
||||||
<button class="_buttonPrimary" @click="onIndicatorClick"><i class="fas fa-arrow-circle-down"></i>{{ $ts.newMessageExists }}</button>
|
<button class="_buttonPrimary" @click="onIndicatorClick"><i class="fas fa-fw fa-arrow-circle-down"></i>{{ i18n.ts.newMessageExists }}</button>
|
||||||
</div>
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
<XForm v-if="!fetching" ref="form" :user="user" :group="group" class="form"/>
|
<XForm v-if="!fetching" ref="formEl" :user="user" :group="group" class="form"/>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts" setup>
|
||||||
import { computed, defineComponent, markRaw } from 'vue';
|
import { computed, watch, onMounted, nextTick, onBeforeUnmount } from 'vue';
|
||||||
import XList from '@/components/date-separated-list.vue';
|
import * as Misskey from 'misskey-js';
|
||||||
|
import * as Acct from 'misskey-js/built/acct';
|
||||||
import XMessage from './messaging-room.message.vue';
|
import XMessage from './messaging-room.message.vue';
|
||||||
import XForm from './messaging-room.form.vue';
|
import XForm from './messaging-room.form.vue';
|
||||||
import * as Acct from 'misskey-js/built/acct';
|
import XList from '@/components/date-separated-list.vue';
|
||||||
import { isBottom, onScrollBottom, scroll } from '@/scripts/scroll';
|
import MkPagination, { Paging } from '@/components/ui/pagination.vue';
|
||||||
|
import { isBottomVisible, onScrollBottom, scrollToBottom } from '@/scripts/scroll';
|
||||||
import * as os from '@/os';
|
import * as os from '@/os';
|
||||||
import { stream } from '@/stream';
|
import { stream } from '@/stream';
|
||||||
import { popout } from '@/scripts/popout';
|
|
||||||
import * as sound from '@/scripts/sound';
|
import * as sound from '@/scripts/sound';
|
||||||
import * as symbols from '@/symbols';
|
import * as symbols from '@/symbols';
|
||||||
|
import { i18n } from '@/i18n';
|
||||||
|
import { $i } from '@/account';
|
||||||
|
import { defaultStore } from '@/store';
|
||||||
|
|
||||||
const Component = defineComponent({
|
const props = defineProps<{
|
||||||
components: {
|
userAcct?: string;
|
||||||
XMessage,
|
groupId?: string;
|
||||||
XForm,
|
}>();
|
||||||
XList,
|
|
||||||
},
|
|
||||||
|
|
||||||
inject: ['inWindow'],
|
let rootEl = $ref<HTMLDivElement>();
|
||||||
|
let formEl = $ref<InstanceType<typeof XForm>>();
|
||||||
|
let pagingComponent = $ref<InstanceType<typeof MkPagination>>();
|
||||||
|
|
||||||
props: {
|
let fetching = $ref(true);
|
||||||
userAcct: {
|
let user: Misskey.entities.UserDetailed | null = $ref(null);
|
||||||
type: String,
|
let group: Misskey.entities.UserGroup | null = $ref(null);
|
||||||
required: false,
|
let typers: Misskey.entities.User[] = $ref([]);
|
||||||
},
|
let connection: Misskey.ChannelConnection<Misskey.Channels['messaging']> | null = $ref(null);
|
||||||
groupId: {
|
let showIndicator = $ref(false);
|
||||||
type: String,
|
const {
|
||||||
required: false,
|
animation,
|
||||||
},
|
} = defaultStore.reactiveState;
|
||||||
},
|
|
||||||
|
|
||||||
data() {
|
let pagination: Paging | null = $ref(null);
|
||||||
return {
|
|
||||||
[symbols.PAGE_INFO]: computed(() => !this.fetching ? this.user ? {
|
|
||||||
userName: this.user,
|
|
||||||
avatar: this.user,
|
|
||||||
action: {
|
|
||||||
icon: 'fas fa-ellipsis-h',
|
|
||||||
handler: this.menu,
|
|
||||||
},
|
|
||||||
} : {
|
|
||||||
title: this.group.name,
|
|
||||||
icon: 'fas fa-users',
|
|
||||||
action: {
|
|
||||||
icon: 'fas fa-ellipsis-h',
|
|
||||||
handler: this.menu,
|
|
||||||
},
|
|
||||||
} : null),
|
|
||||||
fetching: true,
|
|
||||||
user: null,
|
|
||||||
group: null,
|
|
||||||
fetchingMoreMessages: false,
|
|
||||||
messages: [],
|
|
||||||
existMoreMessages: false,
|
|
||||||
connection: null,
|
|
||||||
showIndicator: false,
|
|
||||||
timer: null,
|
|
||||||
typers: [],
|
|
||||||
ilObserver: new IntersectionObserver(
|
|
||||||
(entries) => entries.some((entry) => entry.isIntersecting)
|
|
||||||
&& !this.fetching
|
|
||||||
&& !this.fetchingMoreMessages
|
|
||||||
&& this.existMoreMessages
|
|
||||||
&& this.fetchMoreMessages()
|
|
||||||
),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
computed: {
|
watch([() => props.userAcct, () => props.groupId], () => {
|
||||||
form(): any {
|
if (connection) connection.dispose();
|
||||||
return this.$refs.form;
|
fetch();
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
watch: {
|
|
||||||
userAcct: 'fetch',
|
|
||||||
groupId: 'fetch',
|
|
||||||
},
|
|
||||||
|
|
||||||
mounted() {
|
|
||||||
this.fetch();
|
|
||||||
if (this.$store.state.enableInfiniteScroll) {
|
|
||||||
this.$nextTick(() => this.ilObserver.observe(this.$refs.loadMore as Element));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
beforeUnmount() {
|
|
||||||
this.connection.dispose();
|
|
||||||
|
|
||||||
document.removeEventListener('visibilitychange', this.onVisibilitychange);
|
|
||||||
|
|
||||||
this.ilObserver.disconnect();
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
async fetch() {
|
|
||||||
this.fetching = true;
|
|
||||||
if (this.userAcct) {
|
|
||||||
const user = await os.api('users/show', Acct.parse(this.userAcct));
|
|
||||||
this.user = user;
|
|
||||||
} else {
|
|
||||||
const group = await os.api('users/groups/show', { groupId: this.groupId });
|
|
||||||
this.group = group;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.connection = markRaw(stream.useChannel('messaging', {
|
|
||||||
otherparty: this.user ? this.user.id : undefined,
|
|
||||||
group: this.group ? this.group.id : undefined,
|
|
||||||
}));
|
|
||||||
|
|
||||||
this.connection.on('message', this.onMessage);
|
|
||||||
this.connection.on('read', this.onRead);
|
|
||||||
this.connection.on('deleted', this.onDeleted);
|
|
||||||
this.connection.on('typers', typers => {
|
|
||||||
this.typers = typers.filter(u => u.id !== this.$i.id);
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener('visibilitychange', this.onVisibilitychange);
|
|
||||||
|
|
||||||
this.fetchMessages().then(() => {
|
|
||||||
this.scrollToBottom();
|
|
||||||
|
|
||||||
// もっと見るの交差検知を発火させないためにfetchは
|
|
||||||
// スクロールが終わるまでfalseにしておく
|
|
||||||
// scrollendのようなイベントはないのでsetTimeoutで
|
|
||||||
window.setTimeout(() => this.fetching = false, 300);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
onDragover(evt) {
|
|
||||||
const isFile = evt.dataTransfer.items[0].kind === 'file';
|
|
||||||
const isDriveFile = evt.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_;
|
|
||||||
|
|
||||||
if (isFile || isDriveFile) {
|
|
||||||
evt.dataTransfer.dropEffect = evt.dataTransfer.effectAllowed === 'all' ? 'copy' : 'move';
|
|
||||||
} else {
|
|
||||||
evt.dataTransfer.dropEffect = 'none';
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
onDrop(evt): void {
|
|
||||||
// ファイルだったら
|
|
||||||
if (evt.dataTransfer.files.length === 1) {
|
|
||||||
this.form.upload(evt.dataTransfer.files[0]);
|
|
||||||
return;
|
|
||||||
} else if (evt.dataTransfer.files.length > 1) {
|
|
||||||
os.alert({
|
|
||||||
type: 'error',
|
|
||||||
text: this.$ts.onlyOneFileCanBeAttached
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
//#region ドライブのファイル
|
|
||||||
const driveFile = evt.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
|
|
||||||
if (driveFile != null && driveFile !== '') {
|
|
||||||
const file = JSON.parse(driveFile);
|
|
||||||
this.form.file = file;
|
|
||||||
}
|
|
||||||
//#endregion
|
|
||||||
},
|
|
||||||
|
|
||||||
fetchMessages() {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const max = this.existMoreMessages ? 20 : 10;
|
|
||||||
|
|
||||||
os.api('messaging/messages', {
|
|
||||||
userId: this.user ? this.user.id : undefined,
|
|
||||||
groupId: this.group ? this.group.id : undefined,
|
|
||||||
limit: max + 1,
|
|
||||||
untilId: this.existMoreMessages ? this.messages[0].id : undefined
|
|
||||||
}).then(messages => {
|
|
||||||
if (messages.length === max + 1) {
|
|
||||||
this.existMoreMessages = true;
|
|
||||||
messages.pop();
|
|
||||||
} else {
|
|
||||||
this.existMoreMessages = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.messages.unshift.apply(this.messages, messages.reverse());
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
fetchMoreMessages() {
|
|
||||||
this.fetchingMoreMessages = true;
|
|
||||||
this.fetchMessages().then(() => {
|
|
||||||
this.fetchingMoreMessages = false;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
onMessage(message) {
|
|
||||||
sound.play('chat');
|
|
||||||
|
|
||||||
const _isBottom = isBottom(this.$el, 64);
|
|
||||||
|
|
||||||
this.messages.push(message);
|
|
||||||
if (message.userId !== this.$i.id && !document.hidden) {
|
|
||||||
this.connection.send('read', {
|
|
||||||
id: message.id
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_isBottom) {
|
|
||||||
// Scroll to bottom
|
|
||||||
this.$nextTick(() => {
|
|
||||||
this.scrollToBottom();
|
|
||||||
});
|
|
||||||
} else if (message.userId !== this.$i.id) {
|
|
||||||
// Notify
|
|
||||||
this.notifyNewMessage();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
onRead(x) {
|
|
||||||
if (this.user) {
|
|
||||||
if (!Array.isArray(x)) x = [x];
|
|
||||||
for (const id of x) {
|
|
||||||
if (this.messages.some(x => x.id === id)) {
|
|
||||||
const exist = this.messages.map(x => x.id).indexOf(id);
|
|
||||||
this.messages[exist] = {
|
|
||||||
...this.messages[exist],
|
|
||||||
isRead: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (this.group) {
|
|
||||||
for (const id of x.ids) {
|
|
||||||
if (this.messages.some(x => x.id === id)) {
|
|
||||||
const exist = this.messages.map(x => x.id).indexOf(id);
|
|
||||||
this.messages[exist] = {
|
|
||||||
...this.messages[exist],
|
|
||||||
reads: [...this.messages[exist].reads, x.userId]
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
onDeleted(id) {
|
|
||||||
const msg = this.messages.find(m => m.id === id);
|
|
||||||
if (msg) {
|
|
||||||
this.messages = this.messages.filter(m => m.id !== msg.id);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
scrollToBottom() {
|
|
||||||
scroll(this.$el, { top: this.$el.offsetHeight });
|
|
||||||
},
|
|
||||||
|
|
||||||
onIndicatorClick() {
|
|
||||||
this.showIndicator = false;
|
|
||||||
this.scrollToBottom();
|
|
||||||
},
|
|
||||||
|
|
||||||
notifyNewMessage() {
|
|
||||||
this.showIndicator = true;
|
|
||||||
|
|
||||||
onScrollBottom(this.$el, () => {
|
|
||||||
this.showIndicator = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (this.timer) window.clearTimeout(this.timer);
|
|
||||||
|
|
||||||
this.timer = window.setTimeout(() => {
|
|
||||||
this.showIndicator = false;
|
|
||||||
}, 4000);
|
|
||||||
},
|
|
||||||
|
|
||||||
onVisibilitychange() {
|
|
||||||
if (document.hidden) return;
|
|
||||||
for (const message of this.messages) {
|
|
||||||
if (message.userId !== this.$i.id && !message.isRead) {
|
|
||||||
this.connection.send('read', {
|
|
||||||
id: message.id
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
menu(ev) {
|
|
||||||
const path = this.groupId ? `/my/messaging/group/${this.groupId}` : `/my/messaging/${this.userAcct}`;
|
|
||||||
|
|
||||||
os.popupMenu([this.inWindow ? undefined : {
|
|
||||||
text: this.$ts.openInWindow,
|
|
||||||
icon: 'fas fa-window-maximize',
|
|
||||||
action: () => {
|
|
||||||
os.pageWindow(path);
|
|
||||||
this.$router.back();
|
|
||||||
},
|
|
||||||
}, this.inWindow ? undefined : {
|
|
||||||
text: this.$ts.popout,
|
|
||||||
icon: 'fas fa-external-link-alt',
|
|
||||||
action: () => {
|
|
||||||
popout(path);
|
|
||||||
this.$router.back();
|
|
||||||
},
|
|
||||||
}], ev.currentTarget ?? ev.target);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export default Component;
|
async function fetch() {
|
||||||
|
fetching = true;
|
||||||
|
|
||||||
|
if (props.userAcct) {
|
||||||
|
const acct = Acct.parse(props.userAcct);
|
||||||
|
user = await os.api('users/show', { username: acct.username, host: acct.host || undefined });
|
||||||
|
group = null;
|
||||||
|
|
||||||
|
pagination = {
|
||||||
|
endpoint: 'messaging/messages',
|
||||||
|
limit: 20,
|
||||||
|
params: {
|
||||||
|
userId: user.id,
|
||||||
|
},
|
||||||
|
reversed: true,
|
||||||
|
pageEl: $$(rootEl).value,
|
||||||
|
};
|
||||||
|
connection = stream.useChannel('messaging', {
|
||||||
|
otherparty: user.id,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
user = null;
|
||||||
|
group = await os.api('users/groups/show', { groupId: props.groupId });
|
||||||
|
|
||||||
|
pagination = {
|
||||||
|
endpoint: 'messaging/messages',
|
||||||
|
limit: 20,
|
||||||
|
params: {
|
||||||
|
groupId: group?.id,
|
||||||
|
},
|
||||||
|
reversed: true,
|
||||||
|
pageEl: $$(rootEl).value,
|
||||||
|
};
|
||||||
|
connection = stream.useChannel('messaging', {
|
||||||
|
group: group?.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
connection.on('message', onMessage);
|
||||||
|
connection.on('read', onRead);
|
||||||
|
connection.on('deleted', onDeleted);
|
||||||
|
connection.on('typers', _typers => {
|
||||||
|
typers = _typers.filter(u => u.id !== $i?.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('visibilitychange', onVisibilitychange);
|
||||||
|
|
||||||
|
nextTick(() => {
|
||||||
|
thisScrollToBottom();
|
||||||
|
window.setTimeout(() => {
|
||||||
|
fetching = false;
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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.dataTransfer.dropEffect = ev.dataTransfer.effectAllowed === 'all' ? 'copy' : 'move';
|
||||||
|
} else {
|
||||||
|
ev.dataTransfer.dropEffect = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDrop(ev: DragEvent): void {
|
||||||
|
if (!ev.dataTransfer) return;
|
||||||
|
|
||||||
|
// ファイルだったら
|
||||||
|
if (ev.dataTransfer.files.length === 1) {
|
||||||
|
formEl.upload(ev.dataTransfer.files[0]);
|
||||||
|
return;
|
||||||
|
} else if (ev.dataTransfer.files.length > 1) {
|
||||||
|
os.alert({
|
||||||
|
type: 'error',
|
||||||
|
text: i18n.ts.onlyOneFileCanBeAttached,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
//#region ドライブのファイル
|
||||||
|
const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
|
||||||
|
if (driveFile != null && driveFile !== '') {
|
||||||
|
const file = JSON.parse(driveFile);
|
||||||
|
formEl.file = file;
|
||||||
|
}
|
||||||
|
//#endregion
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMessage(message) {
|
||||||
|
sound.play('chat');
|
||||||
|
|
||||||
|
const _isBottom = isBottomVisible(rootEl, 64);
|
||||||
|
|
||||||
|
pagingComponent.prepend(message);
|
||||||
|
if (message.userId !== $i?.id && !document.hidden) {
|
||||||
|
connection?.send('read', {
|
||||||
|
id: message.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_isBottom) {
|
||||||
|
// Scroll to bottom
|
||||||
|
nextTick(() => {
|
||||||
|
thisScrollToBottom();
|
||||||
|
});
|
||||||
|
} else if (message.userId !== $i?.id) {
|
||||||
|
// Notify
|
||||||
|
notifyNewMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRead(x) {
|
||||||
|
if (user) {
|
||||||
|
if (!Array.isArray(x)) x = [x];
|
||||||
|
for (const id of x) {
|
||||||
|
if (pagingComponent.items.some(y => y.id === id)) {
|
||||||
|
const exist = pagingComponent.items.map(y => y.id).indexOf(id);
|
||||||
|
pagingComponent.items[exist] = {
|
||||||
|
...pagingComponent.items[exist],
|
||||||
|
isRead: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (group) {
|
||||||
|
for (const id of x.ids) {
|
||||||
|
if (pagingComponent.items.some(y => y.id === id)) {
|
||||||
|
const exist = pagingComponent.items.map(y => y.id).indexOf(id);
|
||||||
|
pagingComponent.items[exist] = {
|
||||||
|
...pagingComponent.items[exist],
|
||||||
|
reads: [...pagingComponent.items[exist].reads, x.userId],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDeleted(id) {
|
||||||
|
const msg = pagingComponent.items.find(m => m.id === id);
|
||||||
|
if (msg) {
|
||||||
|
pagingComponent.items = pagingComponent.items.filter(m => m.id !== msg.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function thisScrollToBottom() {
|
||||||
|
scrollToBottom($$(rootEl).value, { behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function onIndicatorClick() {
|
||||||
|
showIndicator = false;
|
||||||
|
thisScrollToBottom();
|
||||||
|
}
|
||||||
|
|
||||||
|
let scrollRemove: (() => void) | null = $ref(null);
|
||||||
|
|
||||||
|
function notifyNewMessage() {
|
||||||
|
showIndicator = true;
|
||||||
|
|
||||||
|
scrollRemove = onScrollBottom(rootEl, () => {
|
||||||
|
showIndicator = false;
|
||||||
|
scrollRemove = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onVisibilitychange() {
|
||||||
|
if (document.hidden) return;
|
||||||
|
for (const message of pagingComponent.items) {
|
||||||
|
if (message.userId !== $i?.id && !message.isRead) {
|
||||||
|
connection?.send('read', {
|
||||||
|
id: message.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetch();
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
connection?.dispose();
|
||||||
|
document.removeEventListener('visibilitychange', onVisibilitychange);
|
||||||
|
if (scrollRemove) scrollRemove();
|
||||||
|
});
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
[symbols.PAGE_INFO]: computed(() => !fetching ? user ? {
|
||||||
|
userName: user,
|
||||||
|
avatar: user,
|
||||||
|
} : {
|
||||||
|
title: group?.name,
|
||||||
|
icon: 'fas fa-users',
|
||||||
|
} : null),
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.mk-messaging-room {
|
.mk-messaging-room {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
> .body {
|
> .body {
|
||||||
> .empty {
|
.more {
|
||||||
width: 100%;
|
|
||||||
margin: 0;
|
|
||||||
padding: 16px 8px 8px 8px;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 0.8em;
|
|
||||||
opacity: 0.5;
|
|
||||||
|
|
||||||
i {
|
|
||||||
margin-right: 4px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
> .no-history {
|
|
||||||
display: block;
|
|
||||||
margin: 0;
|
|
||||||
padding: 16px;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 0.8em;
|
|
||||||
color: var(--messagingRoomInfo);
|
|
||||||
opacity: 0.5;
|
|
||||||
|
|
||||||
i {
|
|
||||||
margin-right: 4px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
> .more {
|
|
||||||
display: block;
|
display: block;
|
||||||
margin: 16px auto;
|
margin: 16px auto;
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
|
@ -399,7 +322,9 @@ export default Component;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
> .messages {
|
.messages {
|
||||||
|
padding: 8px 0;
|
||||||
|
|
||||||
> ::v-deep(*) {
|
> ::v-deep(*) {
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
@ -408,29 +333,31 @@ export default Component;
|
||||||
|
|
||||||
> footer {
|
> footer {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
position: relative;
|
position: sticky;
|
||||||
|
z-index: 2;
|
||||||
|
bottom: 0;
|
||||||
|
padding-top: 8px;
|
||||||
|
|
||||||
|
@media (max-width: 500px) {
|
||||||
|
bottom: calc(env(safe-area-inset-bottom, 0px) + 92px);
|
||||||
|
}
|
||||||
|
|
||||||
> .new-message {
|
> .new-message {
|
||||||
position: absolute;
|
|
||||||
top: -48px;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 8px 0;
|
padding-bottom: 8px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
||||||
> button {
|
> button {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0 12px 0 30px;
|
padding: 0 12px;
|
||||||
line-height: 32px;
|
line-height: 32px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
|
|
||||||
> i {
|
> i {
|
||||||
position: absolute;
|
display: inline-block;
|
||||||
top: 0;
|
margin-right: 8px;
|
||||||
left: 10px;
|
|
||||||
line-height: 32px;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -455,6 +382,8 @@ export default Component;
|
||||||
}
|
}
|
||||||
|
|
||||||
> .form {
|
> .form {
|
||||||
|
max-height: 12em;
|
||||||
|
overflow-y: scroll;
|
||||||
border-top: solid 0.5px var(--divider);
|
border-top: solid 0.5px var(--divider);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,13 @@
|
||||||
type ScrollBehavior = 'auto' | 'smooth' | 'instant';
|
type ScrollBehavior = 'auto' | 'smooth' | 'instant';
|
||||||
|
|
||||||
export function getScrollContainer(el: Element | null): Element | null {
|
export function getScrollContainer(el: HTMLElement | null): HTMLElement | null {
|
||||||
if (el == null || el.tagName === 'BODY') return null;
|
if (el == null || el.tagName === 'HTML') return null;
|
||||||
const overflow = window.getComputedStyle(el).getPropertyValue('overflow');
|
const overflow = window.getComputedStyle(el).getPropertyValue('overflow');
|
||||||
if (overflow.endsWith('auto')) { // xとyを個別に指定している場合、hidden auto みたいな値になる
|
if (
|
||||||
|
// xとyを個別に指定している場合、`hidden scroll`みたいな値になる
|
||||||
|
overflow.endsWith('scroll') ||
|
||||||
|
overflow.endsWith('auto')
|
||||||
|
) {
|
||||||
return el;
|
return el;
|
||||||
} else {
|
} else {
|
||||||
return getScrollContainer(el.parentElement);
|
return getScrollContainer(el.parentElement);
|
||||||
|
@ -22,6 +26,11 @@ export function isTopVisible(el: Element | null): boolean {
|
||||||
return scrollTop <= topPosition;
|
return scrollTop <= topPosition;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isBottomVisible(el: HTMLElement, tolerance = 1, container = getScrollContainer(el)) {
|
||||||
|
if (container) return el.scrollHeight <= container.clientHeight + Math.abs(container.scrollTop) + tolerance;
|
||||||
|
return el.scrollHeight <= window.innerHeight + window.scrollY + tolerance;
|
||||||
|
}
|
||||||
|
|
||||||
export function onScrollTop(el: Element, cb) {
|
export function onScrollTop(el: Element, cb) {
|
||||||
const container = getScrollContainer(el) || window;
|
const container = getScrollContainer(el) || window;
|
||||||
const onScroll = ev => {
|
const onScroll = ev => {
|
||||||
|
|
Loading…
Reference in a new issue