307
packages/client/src/pages/messaging/index.vue
Normal file
307
packages/client/src/pages/messaging/index.vue
Normal file
@@ -0,0 +1,307 @@
|
||||
<template>
|
||||
<MkSpacer :content-max="800">
|
||||
<div class="yweeujhr" v-size="{ max: [400] }">
|
||||
<MkButton @click="start" primary class="start"><i class="fas fa-plus"></i> {{ $ts.startMessaging }}</MkButton>
|
||||
|
||||
<div class="history" v-if="messages.length > 0">
|
||||
<MkA v-for="(message, i) in messages"
|
||||
class="message _block"
|
||||
:class="{ isMe: isMe(message), isRead: message.groupId ? message.reads.includes($i.id) : message.isRead }"
|
||||
:to="message.groupId ? `/my/messaging/group/${message.groupId}` : `/my/messaging/${getAcct(isMe(message) ? message.recipient : message.user)}`"
|
||||
:data-index="i"
|
||||
:key="message.id"
|
||||
v-anim="i"
|
||||
>
|
||||
<div>
|
||||
<MkAvatar class="avatar" :user="message.groupId ? message.user : isMe(message) ? message.recipient : message.user" :show-indicator="true"/>
|
||||
<header v-if="message.groupId">
|
||||
<span class="name">{{ message.group.name }}</span>
|
||||
<MkTime :time="message.createdAt" class="time"/>
|
||||
</header>
|
||||
<header v-else>
|
||||
<span class="name"><MkUserName :user="isMe(message) ? message.recipient : message.user"/></span>
|
||||
<span class="username">@{{ acct(isMe(message) ? message.recipient : message.user) }}</span>
|
||||
<MkTime :time="message.createdAt" class="time"/>
|
||||
</header>
|
||||
<div class="body">
|
||||
<p class="text"><span class="me" v-if="isMe(message)">{{ $ts.you }}:</span>{{ message.text }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</MkA>
|
||||
</div>
|
||||
<div class="_fullinfo" v-if="!fetching && messages.length == 0">
|
||||
<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
|
||||
<div>{{ $ts.noHistory }}</div>
|
||||
</div>
|
||||
<MkLoading v-if="fetching"/>
|
||||
</div>
|
||||
</MkSpacer>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineAsyncComponent, defineComponent, markRaw } from 'vue';
|
||||
import * as Acct from 'misskey-js/built/acct';
|
||||
import MkButton from '@/components/ui/button.vue';
|
||||
import { acct } from '@/filters/user';
|
||||
import * as os from '@/os';
|
||||
import * as symbols from '@/symbols';
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
MkButton
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
[symbols.PAGE_INFO]: {
|
||||
title: this.$ts.messaging,
|
||||
icon: 'fas fa-comments',
|
||||
bg: 'var(--bg)',
|
||||
},
|
||||
fetching: true,
|
||||
moreFetching: false,
|
||||
messages: [],
|
||||
connection: null,
|
||||
};
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.connection = markRaw(os.stream.useChannel('messagingIndex'));
|
||||
|
||||
this.connection.on('message', this.onMessage);
|
||||
this.connection.on('read', this.onRead);
|
||||
|
||||
os.api('messaging/history', { group: false }).then(userMessages => {
|
||||
os.api('messaging/history', { group: true }).then(groupMessages => {
|
||||
const messages = userMessages.concat(groupMessages);
|
||||
messages.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
this.messages = messages;
|
||||
this.fetching = false;
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
beforeUnmount() {
|
||||
this.connection.dispose();
|
||||
},
|
||||
|
||||
methods: {
|
||||
getAcct: Acct.toString,
|
||||
|
||||
isMe(message) {
|
||||
return message.userId == this.$i.id;
|
||||
},
|
||||
|
||||
onMessage(message) {
|
||||
if (message.recipientId) {
|
||||
this.messages = this.messages.filter(m => !(
|
||||
(m.recipientId == message.recipientId && m.userId == message.userId) ||
|
||||
(m.recipientId == message.userId && m.userId == message.recipientId)));
|
||||
|
||||
this.messages.unshift(message);
|
||||
} else if (message.groupId) {
|
||||
this.messages = this.messages.filter(m => m.groupId !== message.groupId);
|
||||
this.messages.unshift(message);
|
||||
}
|
||||
},
|
||||
|
||||
onRead(ids) {
|
||||
for (const id of ids) {
|
||||
const found = this.messages.find(m => m.id == id);
|
||||
if (found) {
|
||||
if (found.recipientId) {
|
||||
found.isRead = true;
|
||||
} else if (found.groupId) {
|
||||
found.reads.push(this.$i.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
start(ev) {
|
||||
os.popupMenu([{
|
||||
text: this.$ts.messagingWithUser,
|
||||
icon: 'fas fa-user',
|
||||
action: () => { this.startUser() }
|
||||
}, {
|
||||
text: this.$ts.messagingWithGroup,
|
||||
icon: 'fas fa-users',
|
||||
action: () => { this.startGroup() }
|
||||
}], ev.currentTarget || ev.target);
|
||||
},
|
||||
|
||||
async startUser() {
|
||||
os.selectUser().then(user => {
|
||||
this.$router.push(`/my/messaging/${Acct.toString(user)}`);
|
||||
});
|
||||
},
|
||||
|
||||
async startGroup() {
|
||||
const groups1 = await os.api('users/groups/owned');
|
||||
const groups2 = await os.api('users/groups/joined');
|
||||
if (groups1.length === 0 && groups2.length === 0) {
|
||||
os.dialog({
|
||||
type: 'warning',
|
||||
title: this.$ts.youHaveNoGroups,
|
||||
text: this.$ts.joinOrCreateGroup,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const { canceled, result: group } = await os.dialog({
|
||||
type: null,
|
||||
title: this.$ts.group,
|
||||
select: {
|
||||
items: groups1.concat(groups2).map(group => ({
|
||||
value: group, text: group.name
|
||||
}))
|
||||
},
|
||||
showCancelButton: true
|
||||
});
|
||||
if (canceled) return;
|
||||
this.$router.push(`/my/messaging/group/${group.id}`);
|
||||
},
|
||||
|
||||
acct
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.yweeujhr {
|
||||
|
||||
> .start {
|
||||
margin: 0 auto var(--margin) auto;
|
||||
}
|
||||
|
||||
> .history {
|
||||
> .message {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
margin-bottom: var(--margin);
|
||||
|
||||
* {
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.avatar {
|
||||
filter: saturate(200%);
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
}
|
||||
|
||||
&.isRead,
|
||||
&.isMe {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&:not(.isMe):not(.isRead) {
|
||||
> div {
|
||||
background-image: url("/client-assets/unread.svg");
|
||||
background-repeat: no-repeat;
|
||||
background-position: 0 center;
|
||||
}
|
||||
}
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
display: block;
|
||||
clear: both;
|
||||
}
|
||||
|
||||
> div {
|
||||
padding: 20px 30px;
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
display: block;
|
||||
clear: both;
|
||||
}
|
||||
|
||||
> header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
|
||||
> .name {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 1em;
|
||||
font-weight: bold;
|
||||
transition: all 0.1s ease;
|
||||
}
|
||||
|
||||
> .username {
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
> .time {
|
||||
margin: 0 0 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
> .avatar {
|
||||
float: left;
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
margin: 0 16px 0 0;
|
||||
border-radius: 8px;
|
||||
transition: all 0.1s ease;
|
||||
}
|
||||
|
||||
> .body {
|
||||
|
||||
> .text {
|
||||
display: block;
|
||||
margin: 0 0 0 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
overflow-wrap: break-word;
|
||||
font-size: 1.1em;
|
||||
color: var(--faceText);
|
||||
|
||||
.me {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
> .image {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 512px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.max-width_400px {
|
||||
> .history {
|
||||
> .message {
|
||||
&:not(.isMe):not(.isRead) {
|
||||
> div {
|
||||
background-image: none;
|
||||
border-left: solid 4px #3aa2dc;
|
||||
}
|
||||
}
|
||||
|
||||
> div {
|
||||
padding: 16px;
|
||||
font-size: 0.9em;
|
||||
|
||||
> .avatar {
|
||||
margin: 0 12px 0 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
348
packages/client/src/pages/messaging/messaging-room.form.vue
Normal file
348
packages/client/src/pages/messaging/messaging-room.form.vue
Normal file
@@ -0,0 +1,348 @@
|
||||
<template>
|
||||
<div class="pemppnzi _block"
|
||||
@dragover.stop="onDragover"
|
||||
@drop.stop="onDrop"
|
||||
>
|
||||
<textarea
|
||||
v-model="text"
|
||||
ref="text"
|
||||
@keypress="onKeypress"
|
||||
@compositionupdate="onCompositionUpdate"
|
||||
@paste="onPaste"
|
||||
:placeholder="$ts.inputMessageHere"
|
||||
></textarea>
|
||||
<div class="file" @click="file = null" v-if="file">{{ file.name }}</div>
|
||||
<button class="send _button" @click="send" :disabled="!canSend || sending" :title="$ts.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"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, defineAsyncComponent } from 'vue';
|
||||
import insertTextAtCursor from 'insert-text-at-cursor';
|
||||
import * as autosize from 'autosize';
|
||||
import { formatTimeString } from '@/scripts/format-time-string';
|
||||
import { selectFile } from '@/scripts/select-file';
|
||||
import * as os from '@/os';
|
||||
import { Autocomplete } from '@/scripts/autocomplete';
|
||||
import { throttle } from 'throttle-debounce';
|
||||
|
||||
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, () => {
|
||||
os.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;
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
text() {
|
||||
this.saveDraft();
|
||||
},
|
||||
file() {
|
||||
this.saveDraft();
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
autosize(this.$refs.text);
|
||||
|
||||
// TODO: detach when unmount
|
||||
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(e: ClipboardEvent) {
|
||||
const data = e.clipboardData;
|
||||
const items = data.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}`;
|
||||
const name = this.$store.state.pasteDialog
|
||||
? await os.dialog({
|
||||
title: this.$ts.enterFileName,
|
||||
input: {
|
||||
default: formatted
|
||||
},
|
||||
allowEmpty: false
|
||||
}).then(({ canceled, result }) => canceled ? false : result)
|
||||
: formatted;
|
||||
if (name) this.upload(file, name);
|
||||
}
|
||||
} else {
|
||||
if (items[0].kind == 'file') {
|
||||
os.dialog({
|
||||
type: 'error',
|
||||
text: this.$ts.onlyOneFileCanBeAttached
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
onDragover(e) {
|
||||
const isFile = e.dataTransfer.items[0].kind == 'file';
|
||||
const isDriveFile = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FILE_;
|
||||
if (isFile || isDriveFile) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move';
|
||||
}
|
||||
},
|
||||
|
||||
onDrop(e): void {
|
||||
// ファイルだったら
|
||||
if (e.dataTransfer.files.length == 1) {
|
||||
e.preventDefault();
|
||||
this.upload(e.dataTransfer.files[0]);
|
||||
return;
|
||||
} else if (e.dataTransfer.files.length > 1) {
|
||||
e.preventDefault();
|
||||
os.dialog({
|
||||
type: 'error',
|
||||
text: this.$ts.onlyOneFileCanBeAttached
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
//#region ドライブのファイル
|
||||
const driveFile = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
|
||||
if (driveFile != null && driveFile != '') {
|
||||
this.file = JSON.parse(driveFile);
|
||||
e.preventDefault();
|
||||
}
|
||||
//#endregion
|
||||
},
|
||||
|
||||
onKeypress(e) {
|
||||
this.typing();
|
||||
if ((e.which == 10 || e.which == 13) && (e.ctrlKey || e.metaKey) && this.canSend) {
|
||||
this.send();
|
||||
}
|
||||
},
|
||||
|
||||
onCompositionUpdate() {
|
||||
this.typing();
|
||||
},
|
||||
|
||||
chooseFile(e) {
|
||||
selectFile(e.currentTarget || e.target, this.$ts.selectFile, false).then(file => {
|
||||
this.file = file;
|
||||
});
|
||||
},
|
||||
|
||||
onChangeFile() {
|
||||
this.upload((this.$refs.file as any).files[0]);
|
||||
},
|
||||
|
||||
upload(file: File, name?: string) {
|
||||
os.upload(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 data = JSON.parse(localStorage.getItem('message_drafts') || '{}');
|
||||
|
||||
data[this.draftKey] = {
|
||||
updatedAt: new Date(),
|
||||
data: {
|
||||
text: this.text,
|
||||
file: this.file
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.setItem('message_drafts', JSON.stringify(data));
|
||||
},
|
||||
|
||||
deleteDraft() {
|
||||
const data = JSON.parse(localStorage.getItem('message_drafts') || '{}');
|
||||
|
||||
delete data[this.draftKey];
|
||||
|
||||
localStorage.setItem('message_drafts', JSON.stringify(data));
|
||||
},
|
||||
|
||||
async insertEmoji(ev) {
|
||||
os.openEmojiPicker(ev.currentTarget || ev.target, {}, this.$refs.text);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.pemppnzi {
|
||||
position: relative;
|
||||
|
||||
> textarea {
|
||||
cursor: auto;
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-width: 100%;
|
||||
max-width: 100%;
|
||||
height: 80px;
|
||||
margin: 0;
|
||||
padding: 16px 16px 0 16px;
|
||||
resize: none;
|
||||
font-size: 1em;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
box-sizing: border-box;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
> .file {
|
||||
padding: 8px;
|
||||
color: #444;
|
||||
background: #eee;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
> .send {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
font-size: 1em;
|
||||
transition: color 0.1s ease;
|
||||
color: var(--accent);
|
||||
|
||||
&:active {
|
||||
color: var(--accentDarken);
|
||||
transition: color 0s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.files {
|
||||
display: block;
|
||||
margin: 0;
|
||||
padding: 0 8px;
|
||||
list-style: none;
|
||||
|
||||
&:after {
|
||||
content: '';
|
||||
display: block;
|
||||
clear: both;
|
||||
}
|
||||
|
||||
> li {
|
||||
display: block;
|
||||
float: left;
|
||||
margin: 4px;
|
||||
padding: 0;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
background-color: #eee;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center center;
|
||||
background-size: cover;
|
||||
cursor: move;
|
||||
|
||||
&:hover {
|
||||
> .remove {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
> .remove {
|
||||
display: none;
|
||||
position: absolute;
|
||||
right: -6px;
|
||||
top: -6px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
._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;
|
||||
}
|
||||
}
|
||||
|
||||
input[type=file] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
350
packages/client/src/pages/messaging/messaging-room.message.vue
Normal file
350
packages/client/src/pages/messaging/messaging-room.message.vue
Normal file
@@ -0,0 +1,350 @@
|
||||
<template>
|
||||
<div class="thvuemwp" :class="{ isMe }" v-size="{ max: [400, 500] }">
|
||||
<MkAvatar class="avatar" :user="message.user" :show-indicator="true"/>
|
||||
<div class="content">
|
||||
<div class="balloon" :class="{ noText: message.text == null }" >
|
||||
<button class="delete-button" v-if="isMe" :title="$ts.delete" @click="del">
|
||||
<img src="/client-assets/remove.png" alt="Delete"/>
|
||||
</button>
|
||||
<div class="content" v-if="!message.isDeleted">
|
||||
<Mfm class="text" v-if="message.text" ref="text" :text="message.text" :i="$i"/>
|
||||
<div class="file" v-if="message.file">
|
||||
<a :href="message.file.url" rel="noopener" target="_blank" :title="message.file.name">
|
||||
<img v-if="message.file.type.split('/')[0] == 'image'" :src="message.file.url" :alt="message.file.name"/>
|
||||
<p v-else>{{ message.file.name }}</p>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="content" v-else>
|
||||
<p class="is-deleted">{{ $ts.deleted }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div></div>
|
||||
<MkUrlPreview v-for="url in urls" :url="url" :key="url" style="margin: 8px 0;"/>
|
||||
<footer>
|
||||
<template v-if="isGroup">
|
||||
<span class="read" v-if="message.reads.length > 0">{{ $ts.messageRead }} {{ message.reads.length }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="read" v-if="isMe && message.isRead">{{ $ts.messageRead }}</span>
|
||||
</template>
|
||||
<MkTime :time="message.createdAt"/>
|
||||
<template v-if="message.is_edited"><i class="fas fa-pencil-alt"></i></template>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import * as mfm from 'mfm-js';
|
||||
import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm';
|
||||
import MkUrlPreview from '@/components/url-preview.vue';
|
||||
import * as os from '@/os';
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
MkUrlPreview
|
||||
},
|
||||
props: {
|
||||
message: {
|
||||
required: true
|
||||
},
|
||||
isGroup: {
|
||||
required: false
|
||||
}
|
||||
},
|
||||
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>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.thvuemwp {
|
||||
$me-balloon-color: var(--accent);
|
||||
|
||||
position: relative;
|
||||
background-color: transparent;
|
||||
display: flex;
|
||||
|
||||
> .avatar {
|
||||
position: sticky;
|
||||
top: calc(var(--stickyTop, 0px) + 16px);
|
||||
display: block;
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
transition: all 0.1s ease;
|
||||
}
|
||||
|
||||
> .content {
|
||||
min-width: 0;
|
||||
|
||||
> .balloon {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0;
|
||||
min-height: 38px;
|
||||
border-radius: 16px;
|
||||
max-width: 100%;
|
||||
|
||||
&:before {
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
}
|
||||
|
||||
& + * {
|
||||
clear: both;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
> .delete-button {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
> .delete-button {
|
||||
display: none;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
|
||||
> img {
|
||||
vertical-align: bottom;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
> .content {
|
||||
max-width: 100%;
|
||||
|
||||
> .is-deleted {
|
||||
display: block;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
overflow-wrap: break-word;
|
||||
font-size: 1em;
|
||||
color: rgba(#000, 0.5);
|
||||
}
|
||||
|
||||
> .text {
|
||||
display: block;
|
||||
margin: 0;
|
||||
padding: 12px 18px;
|
||||
overflow: hidden;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
font-size: 1em;
|
||||
color: rgba(#000, 0.8);
|
||||
|
||||
& + .file {
|
||||
> a {
|
||||
border-radius: 0 0 16px 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .file {
|
||||
> a {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
|
||||
> p {
|
||||
background: #ccc;
|
||||
}
|
||||
}
|
||||
|
||||
> * {
|
||||
display: block;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
max-height: 512px;
|
||||
object-fit: contain;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
> p {
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
color: #555;
|
||||
background: #ddd;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> footer {
|
||||
display: block;
|
||||
margin: 2px 0 0 0;
|
||||
font-size: 0.65em;
|
||||
|
||||
> .read {
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
> i {
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.isMe) {
|
||||
padding-left: var(--margin);
|
||||
|
||||
> .content {
|
||||
padding-left: 16px;
|
||||
padding-right: 32px;
|
||||
|
||||
> .balloon {
|
||||
$color: var(--messageBg);
|
||||
background: $color;
|
||||
|
||||
&.noText {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&:not(.noText):before {
|
||||
left: -14px;
|
||||
border-top: solid 8px transparent;
|
||||
border-right: solid 8px $color;
|
||||
border-bottom: solid 8px transparent;
|
||||
border-left: solid 8px transparent;
|
||||
}
|
||||
|
||||
> .content {
|
||||
> .text {
|
||||
color: var(--fg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> footer {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.isMe {
|
||||
flex-direction: row-reverse;
|
||||
padding-right: var(--margin);
|
||||
|
||||
> .content {
|
||||
padding-right: 16px;
|
||||
padding-left: 32px;
|
||||
text-align: right;
|
||||
|
||||
> .balloon {
|
||||
background: $me-balloon-color;
|
||||
text-align: left;
|
||||
|
||||
::selection {
|
||||
color: var(--accent);
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
&.noText {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&:not(.noText):before {
|
||||
right: -14px;
|
||||
left: auto;
|
||||
border-top: solid 8px transparent;
|
||||
border-right: solid 8px transparent;
|
||||
border-bottom: solid 8px transparent;
|
||||
border-left: solid 8px $me-balloon-color;
|
||||
}
|
||||
|
||||
> .content {
|
||||
|
||||
> p.is-deleted {
|
||||
color: rgba(#fff, 0.5);
|
||||
}
|
||||
|
||||
> .text {
|
||||
&, ::v-deep(*) {
|
||||
color: var(--fgOnAccent) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> footer {
|
||||
text-align: right;
|
||||
|
||||
> .read {
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.max-width_400px {
|
||||
> .avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
> .content {
|
||||
> .balloon {
|
||||
> .content {
|
||||
> .text {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.max-width_500px {
|
||||
> .content {
|
||||
> .balloon {
|
||||
> .content {
|
||||
> .text {
|
||||
padding: 8px 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
470
packages/client/src/pages/messaging/messaging-room.vue
Normal file
470
packages/client/src/pages/messaging/messaging-room.vue
Normal file
@@ -0,0 +1,470 @@
|
||||
<template>
|
||||
<div class="_section"
|
||||
@dragover.prevent.stop="onDragover"
|
||||
@drop.prevent.stop="onDrop"
|
||||
>
|
||||
<div class="_content mk-messaging-room">
|
||||
<div class="body">
|
||||
<MkLoading v-if="fetching"/>
|
||||
<p class="empty" v-if="!fetching && messages.length == 0"><i class="fas fa-info-circle"></i>{{ $ts.noMessagesYet }}</p>
|
||||
<p class="no-history" v-if="!fetching && messages.length > 0 && !existMoreMessages"><i class="fas fa-flag"></i>{{ $ts.noMoreHistory }}</p>
|
||||
<button class="more _button" ref="loadMore" :class="{ fetching: fetchingMoreMessages }" v-show="existMoreMessages" @click="fetchMoreMessages" :disabled="fetchingMoreMessages">
|
||||
<template v-if="fetchingMoreMessages"><i class="fas fa-spinner fa-pulse fa-fw"></i></template>{{ fetchingMoreMessages ? $ts.loading : $ts.loadMore }}
|
||||
</button>
|
||||
<XList class="messages" :items="messages" v-slot="{ item: message }" direction="up" reversed>
|
||||
<XMessage :message="message" :is-group="group != null" :key="message.id"/>
|
||||
</XList>
|
||||
</div>
|
||||
<footer>
|
||||
<div class="typers" v-if="typers.length > 0">
|
||||
<I18n :src="$ts.typingUsers" text-tag="span" class="users">
|
||||
<template #users>
|
||||
<b v-for="user in typers" :key="user.id" class="user">{{ user.username }}</b>
|
||||
</template>
|
||||
</I18n>
|
||||
<MkEllipsis/>
|
||||
</div>
|
||||
<transition name="fade">
|
||||
<div class="new-message" v-show="showIndicator">
|
||||
<button class="_buttonPrimary" @click="onIndicatorClick"><i class="fas fa-arrow-circle-down"></i>{{ $ts.newMessageExists }}</button>
|
||||
</div>
|
||||
</transition>
|
||||
<XForm v-if="!fetching" :user="user" :group="group" ref="form" class="form"/>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, markRaw } from 'vue';
|
||||
import XList from '@/components/date-separated-list.vue';
|
||||
import XMessage from './messaging-room.message.vue';
|
||||
import XForm from './messaging-room.form.vue';
|
||||
import * as Acct from 'misskey-js/built/acct';
|
||||
import { isBottom, onScrollBottom, scroll } from '@/scripts/scroll';
|
||||
import * as os from '@/os';
|
||||
import { popout } from '@/scripts/popout';
|
||||
import * as sound from '@/scripts/sound';
|
||||
import * as symbols from '@/symbols';
|
||||
|
||||
const Component = defineComponent({
|
||||
components: {
|
||||
XMessage,
|
||||
XForm,
|
||||
XList,
|
||||
},
|
||||
|
||||
inject: ['inWindow'],
|
||||
|
||||
props: {
|
||||
userAcct: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
groupId: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
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: {
|
||||
form(): any {
|
||||
return this.$refs.form;
|
||||
}
|
||||
},
|
||||
|
||||
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(os.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で
|
||||
setTimeout(() => this.fetching = false, 300);
|
||||
});
|
||||
},
|
||||
|
||||
onDragover(e) {
|
||||
const isFile = e.dataTransfer.items[0].kind == 'file';
|
||||
const isDriveFile = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FILE_;
|
||||
|
||||
if (isFile || isDriveFile) {
|
||||
e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move';
|
||||
} else {
|
||||
e.dataTransfer.dropEffect = 'none';
|
||||
}
|
||||
},
|
||||
|
||||
onDrop(e): void {
|
||||
// ファイルだったら
|
||||
if (e.dataTransfer.files.length == 1) {
|
||||
this.form.upload(e.dataTransfer.files[0]);
|
||||
return;
|
||||
} else if (e.dataTransfer.files.length > 1) {
|
||||
os.dialog({
|
||||
type: 'error',
|
||||
text: this.$ts.onlyOneFileCanBeAttached
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
//#region ドライブのファイル
|
||||
const driveFile = e.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) clearTimeout(this.timer);
|
||||
|
||||
this.timer = 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;
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mk-messaging-room {
|
||||
> .body {
|
||||
> .empty {
|
||||
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;
|
||||
margin: 16px auto;
|
||||
padding: 0 12px;
|
||||
line-height: 24px;
|
||||
color: #fff;
|
||||
background: rgba(#000, 0.3);
|
||||
border-radius: 12px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(#000, 0.4);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: rgba(#000, 0.5);
|
||||
}
|
||||
|
||||
&.fetching {
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
> i {
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
> .messages {
|
||||
> ::v-deep(*) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> footer {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
|
||||
> .new-message {
|
||||
position: absolute;
|
||||
top: -48px;
|
||||
width: 100%;
|
||||
padding: 8px 0;
|
||||
text-align: center;
|
||||
|
||||
> button {
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
padding: 0 12px 0 30px;
|
||||
line-height: 32px;
|
||||
font-size: 12px;
|
||||
border-radius: 16px;
|
||||
|
||||
> i {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 10px;
|
||||
line-height: 32px;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .typers {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
padding: 0 8px 0 8px;
|
||||
font-size: 0.9em;
|
||||
color: var(--fgTransparentWeak);
|
||||
|
||||
> .users {
|
||||
> .user + .user:before {
|
||||
content: ", ";
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
> .user:last-of-type:after {
|
||||
content: " ";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .form {
|
||||
border-top: solid 0.5px var(--divider);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.fade-enter-active, .fade-leave-active {
|
||||
transition: opacity 0.1s;
|
||||
}
|
||||
|
||||
.fade-enter-from, .fade-leave-to {
|
||||
transition: opacity 0.5s;
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
Reference in New Issue
Block a user