mirror of
https://github.com/sim1222/misskey.git
synced 2025-08-08 09:43:51 +09:00
@ -1,305 +0,0 @@
|
||||
<template>
|
||||
<MkStickyContainer>
|
||||
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
|
||||
<MkSpacer :content-max="800">
|
||||
<div class="yweeujhr">
|
||||
<MkButton primary class="start" @click="start"><i class="ti ti-plus"></i> {{ $ts.startMessaging }}</MkButton>
|
||||
|
||||
<div v-if="messages.length > 0" class="history">
|
||||
<MkA
|
||||
v-for="(message, i) in messages"
|
||||
:key="message.id"
|
||||
v-anim="i"
|
||||
class="message _panel"
|
||||
: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"
|
||||
>
|
||||
<div>
|
||||
<MkAvatar class="avatar" :user="message.groupId ? message.user : isMe(message) ? message.recipient : message.user" indicator link preview/>
|
||||
<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 v-if="isMe(message)" class="me">{{ $ts.you }}:</span>{{ message.text }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</MkA>
|
||||
</div>
|
||||
<div v-if="!fetching && messages.length == 0" class="_fullinfo">
|
||||
<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
|
||||
<div>{{ $ts.noHistory }}</div>
|
||||
</div>
|
||||
<MkLoading v-if="fetching"/>
|
||||
</div>
|
||||
</MkSpacer>
|
||||
</MkStickyContainer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { defineAsyncComponent, defineComponent, inject, markRaw, onMounted, onUnmounted } from 'vue';
|
||||
import * as Acct from 'misskey-js/built/acct';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import { acct } from '@/filters/user';
|
||||
import * as os from '@/os';
|
||||
import { stream } from '@/stream';
|
||||
import { useRouter } from '@/router';
|
||||
import { i18n } from '@/i18n';
|
||||
import { definePageMetadata } from '@/scripts/page-metadata';
|
||||
import { $i } from '@/account';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
let fetching = $ref(true);
|
||||
let moreFetching = $ref(false);
|
||||
let messages = $ref([]);
|
||||
let connection = $ref(null);
|
||||
|
||||
const getAcct = Acct.toString;
|
||||
|
||||
function isMe(message) {
|
||||
return message.userId === $i.id;
|
||||
}
|
||||
|
||||
function onMessage(message) {
|
||||
if (message.recipientId) {
|
||||
messages = messages.filter(m => !(
|
||||
(m.recipientId === message.recipientId && m.userId === message.userId) ||
|
||||
(m.recipientId === message.userId && m.userId === message.recipientId)));
|
||||
|
||||
messages.unshift(message);
|
||||
} else if (message.groupId) {
|
||||
messages = messages.filter(m => m.groupId !== message.groupId);
|
||||
messages.unshift(message);
|
||||
}
|
||||
}
|
||||
|
||||
function onRead(ids) {
|
||||
for (const id of ids) {
|
||||
const found = messages.find(m => m.id === id);
|
||||
if (found) {
|
||||
if (found.recipientId) {
|
||||
found.isRead = true;
|
||||
} else if (found.groupId) {
|
||||
found.reads.push($i.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function start(ev) {
|
||||
os.popupMenu([{
|
||||
text: i18n.ts.messagingWithUser,
|
||||
icon: 'ti ti-user',
|
||||
action: () => { startUser(); },
|
||||
}, {
|
||||
text: i18n.ts.messagingWithGroup,
|
||||
icon: 'ti ti-users',
|
||||
action: () => { startGroup(); },
|
||||
}], ev.currentTarget ?? ev.target);
|
||||
}
|
||||
|
||||
async function startUser() {
|
||||
os.selectUser().then(user => {
|
||||
router.push(`/my/messaging/${Acct.toString(user)}`);
|
||||
});
|
||||
}
|
||||
|
||||
async function 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.alert({
|
||||
type: 'warning',
|
||||
title: i18n.ts.youHaveNoGroups,
|
||||
text: i18n.ts.joinOrCreateGroup,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const { canceled, result: group } = await os.select({
|
||||
title: i18n.ts.group,
|
||||
items: groups1.concat(groups2).map(group => ({
|
||||
value: group, text: group.name,
|
||||
})),
|
||||
});
|
||||
if (canceled) return;
|
||||
router.push(`/my/messaging/group/${group.id}`);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
connection = markRaw(stream.useChannel('messagingIndex'));
|
||||
|
||||
connection.on('message', onMessage);
|
||||
connection.on('read', 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());
|
||||
messages = _messages;
|
||||
fetching = false;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (connection) connection.dispose();
|
||||
});
|
||||
|
||||
const headerActions = $computed(() => []);
|
||||
|
||||
const headerTabs = $computed(() => []);
|
||||
|
||||
definePageMetadata({
|
||||
title: i18n.ts.messaging,
|
||||
icon: 'ti ti-messages',
|
||||
});
|
||||
</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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@container (max-width: 400px) {
|
||||
.yweeujhr {
|
||||
> .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>
|
@ -1,366 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
:class="$style['root']"
|
||||
@dragover.stop="onDragover"
|
||||
@drop.stop="onDrop"
|
||||
>
|
||||
<textarea
|
||||
:class="$style['textarea']"
|
||||
class="_acrylic"
|
||||
ref="textEl"
|
||||
v-model="text"
|
||||
:placeholder="i18n.ts.inputMessageHere"
|
||||
@keydown="onKeydown"
|
||||
@compositionupdate="onCompositionUpdate"
|
||||
@paste="onPaste"
|
||||
></textarea>
|
||||
<footer :class="$style['footer']">
|
||||
<div v-if="file" :class="$style['file']" @click="file = null">{{ file.name }}</div>
|
||||
<div :class="$style['buttons']">
|
||||
<button class="_button" :class="$style['button']" @click="chooseFile"><i class="ti ti-photo-plus"></i></button>
|
||||
<button class="_button" :class="$style['button']" @click="insertEmoji"><i class="ti ti-mood-happy"></i></button>
|
||||
<button class="_button" :class="[$style['button'], $style['send']]" :disabled="!canSend || sending" :title="i18n.ts.send" @click="send">
|
||||
<template v-if="!sending"><i class="ti ti-send"></i></template><template v-if="sending"><MkLoading :em="true"/></template>
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
<input :class="$style['file-input']" ref="fileEl" type="file" @change="onChangeFile"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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 { defaultStore } from '@/store';
|
||||
import { i18n } from '@/i18n';
|
||||
//import { Autocomplete } from '@/scripts/autocomplete';
|
||||
import { uploadFile } from '@/scripts/upload';
|
||||
import { miLocalStorage } from '@/local-storage';
|
||||
|
||||
const props = defineProps<{
|
||||
user?: Misskey.entities.UserDetailed | null;
|
||||
group?: Misskey.entities.UserGroup | null;
|
||||
}>();
|
||||
|
||||
let textEl = $shallowRef<HTMLTextAreaElement>();
|
||||
let fileEl = $shallowRef<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);
|
||||
}
|
||||
} else {
|
||||
if (items[0].kind === 'file') {
|
||||
os.alert({
|
||||
type: 'error',
|
||||
text: i18n.ts.onlyOneFileCanBeAttached,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
switch (ev.dataTransfer.effectAllowed) {
|
||||
case 'all':
|
||||
case 'uninitialized':
|
||||
case 'copy':
|
||||
case 'copyLink':
|
||||
case 'copyMove':
|
||||
ev.dataTransfer.dropEffect = 'copy';
|
||||
break;
|
||||
case 'linkMove':
|
||||
case 'move':
|
||||
ev.dataTransfer.dropEffect = 'move';
|
||||
break;
|
||||
default:
|
||||
ev.dataTransfer.dropEffect = 'none';
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(miLocalStorage.getItem('message_drafts') || '{}');
|
||||
|
||||
drafts[draftKey] = {
|
||||
updatedAt: new Date(),
|
||||
// eslint-disable-next-line id-denylist
|
||||
data: {
|
||||
text: text,
|
||||
file: file,
|
||||
},
|
||||
};
|
||||
|
||||
miLocalStorage.setItem('message_drafts', JSON.stringify(drafts));
|
||||
}
|
||||
|
||||
function deleteDraft() {
|
||||
const drafts = JSON.parse(miLocalStorage.getItem('message_drafts') || '{}');
|
||||
|
||||
delete drafts[draftKey];
|
||||
|
||||
miLocalStorage.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(miLocalStorage.getItem('message_drafts') || '{}')[draftKey];
|
||||
if (draft) {
|
||||
text = draft.data.text;
|
||||
file = draft.data.file;
|
||||
}
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
file,
|
||||
upload,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
cursor: auto;
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-width: 100%;
|
||||
max-width: 100%;
|
||||
min-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;
|
||||
box-sizing: border-box;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.footer {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.file {
|
||||
padding: 8px;
|
||||
color: var(--fg);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
/*
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.file-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;
|
||||
}
|
||||
*/
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
.send {
|
||||
margin-left: auto;
|
||||
color: var(--accent);
|
||||
|
||||
&:hover {
|
||||
color: var(--accentLighten);
|
||||
}
|
||||
|
||||
&:active {
|
||||
color: var(--accentDarken);
|
||||
transition: color 0s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.file-input {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
@ -1,338 +0,0 @@
|
||||
<template>
|
||||
<div class="thvuemwp" :class="{ isMe }">
|
||||
<MkAvatar class="avatar" :user="message.user" indicator link preview/>
|
||||
<div class="content">
|
||||
<div class="balloon" :class="{ noText: message.text == null }">
|
||||
<button v-if="isMe" class="delete-button" :title="$ts.delete" @click="del">
|
||||
<img src="/client-assets/remove.png" alt="Delete"/>
|
||||
</button>
|
||||
<div v-if="!message.isDeleted" class="content">
|
||||
<Mfm v-if="message.text" ref="text" class="text" :text="message.text" :i="$i"/>
|
||||
<div v-if="message.file" class="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 v-else class="content">
|
||||
<p class="is-deleted">{{ $ts.deleted }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div></div>
|
||||
<MkUrlPreview v-for="url in urls" :key="url" :url="url" style="margin: 8px 0;"/>
|
||||
<footer>
|
||||
<template v-if="isGroup">
|
||||
<span v-if="message.reads.length > 0" class="read">{{ $ts.messageRead }} {{ message.reads.length }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span v-if="isMe && message.isRead" class="read">{{ $ts.messageRead }}</span>
|
||||
</template>
|
||||
<MkTime :time="message.createdAt"/>
|
||||
<template v-if="message.is_edited"><i class="ti ti-pencil"></i></template>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { } from 'vue';
|
||||
import * as mfm from 'mfm-js';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm';
|
||||
import MkUrlPreview from '@/components/MkUrlPreview.vue';
|
||||
import * as os from '@/os';
|
||||
import { $i } from '@/account';
|
||||
|
||||
const props = defineProps<{
|
||||
message: Misskey.entities.MessagingMessage;
|
||||
isGroup?: boolean;
|
||||
}>();
|
||||
|
||||
const isMe = $computed(() => props.message.userId === $i?.id);
|
||||
const urls = $computed(() => props.message.text ? extractUrlFromMfm(mfm.parse(props.message.text)) : []);
|
||||
|
||||
function del(): void {
|
||||
os.api('messaging/messages/delete', {
|
||||
messageId: props.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);
|
||||
right: var(--margin); // 削除時にposition: absoluteになったときに使う
|
||||
|
||||
> .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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@container (max-width: 400px) {
|
||||
.thvuemwp {
|
||||
> .avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
> .content {
|
||||
> .balloon {
|
||||
> .content {
|
||||
> .text {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@container (max-width: 500px) {
|
||||
.thvuemwp {
|
||||
> .content {
|
||||
> .balloon {
|
||||
> .content {
|
||||
> .text {
|
||||
padding: 8px 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
@ -1,415 +0,0 @@
|
||||
<template>
|
||||
<MkStickyContainer>
|
||||
<template #header>
|
||||
<MkPageHeader />
|
||||
</template>
|
||||
<div
|
||||
ref="rootEl"
|
||||
:class="$style['root']"
|
||||
@dragover.prevent.stop="onDragover"
|
||||
@drop.prevent.stop="onDrop"
|
||||
>
|
||||
<div :class="$style['body']">
|
||||
<MkPagination v-if="pagination" ref="pagingComponent" :key="userAcct || groupId" :pagination="pagination">
|
||||
<template #empty>
|
||||
<div class="_fullinfo">
|
||||
<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
|
||||
<div>{{ i18n.ts.noMessagesYet }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #default="{ items: messages, fetching: pFetching }">
|
||||
<MkDateSeparatedList
|
||||
v-if="messages.length > 0"
|
||||
v-slot="{ item: message }"
|
||||
:class="{ [$style['messages']]: true, 'deny-move-transition': pFetching }"
|
||||
:items="messages"
|
||||
direction="up"
|
||||
reversed
|
||||
>
|
||||
<XMessage :key="message.id" :message="message" :is-group="group != null"/>
|
||||
</MkDateSeparatedList>
|
||||
</template>
|
||||
</MkPagination>
|
||||
</div>
|
||||
<footer :class="$style['footer']">
|
||||
<div v-if="typers.length > 0" :class="$style['typers']">
|
||||
<I18n :src="i18n.ts.typingUsers" text-tag="span">
|
||||
<template #users>
|
||||
<b v-for="typer in typers" :key="typer.id" :class="$style['user']">{{ typer.username }}</b>
|
||||
</template>
|
||||
</I18n>
|
||||
<MkEllipsis/>
|
||||
</div>
|
||||
<Transition :name="animation ? 'fade' : ''">
|
||||
<div v-show="showIndicator" :class="$style['new-message']">
|
||||
<button class="_buttonPrimary" @click="onIndicatorClick" :class="$style['new-message-button']">
|
||||
<i class="fas ti-fw fa-arrow-circle-down" :class="$style['new-message-icon']"></i>{{ i18n.ts.newMessageExists }}
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
<XForm v-if="!fetching" ref="formEl" :user="user" :group="group" :class="$style['form']"/>
|
||||
</footer>
|
||||
</div>
|
||||
</MkStickyContainer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, watch, onMounted, nextTick, onBeforeUnmount } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import * as Acct from 'misskey-js/built/acct';
|
||||
import XMessage from './messaging-room.message.vue';
|
||||
import XForm from './messaging-room.form.vue';
|
||||
import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue';
|
||||
import MkPagination, { Paging } from '@/components/MkPagination.vue';
|
||||
import { isBottomVisible, onScrollBottom, scrollToBottom } from '@/scripts/scroll';
|
||||
import * as os from '@/os';
|
||||
import { stream } from '@/stream';
|
||||
import * as sound from '@/scripts/sound';
|
||||
import { i18n } from '@/i18n';
|
||||
import { $i } from '@/account';
|
||||
import { defaultStore } from '@/store';
|
||||
import { definePageMetadata } from '@/scripts/page-metadata';
|
||||
|
||||
const props = defineProps<{
|
||||
userAcct?: string;
|
||||
groupId?: string;
|
||||
}>();
|
||||
|
||||
let rootEl = $shallowRef<HTMLDivElement>();
|
||||
let formEl = $shallowRef<InstanceType<typeof XForm>>();
|
||||
let pagingComponent = $shallowRef<InstanceType<typeof MkPagination>>();
|
||||
|
||||
let fetching = $ref(true);
|
||||
let user: Misskey.entities.UserDetailed | null = $ref(null);
|
||||
let group: Misskey.entities.UserGroup | null = $ref(null);
|
||||
let typers: Misskey.entities.User[] = $ref([]);
|
||||
let connection: Misskey.ChannelConnection<Misskey.Channels['messaging']> | null = $ref(null);
|
||||
let showIndicator = $ref(false);
|
||||
const {
|
||||
animation,
|
||||
} = defaultStore.reactiveState;
|
||||
|
||||
let pagination: Paging | null = $ref(null);
|
||||
|
||||
watch([() => props.userAcct, () => props.groupId], () => {
|
||||
if (connection) connection.dispose();
|
||||
fetch();
|
||||
});
|
||||
|
||||
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(() => {
|
||||
pagingComponent.inited.then(() => {
|
||||
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) {
|
||||
switch (ev.dataTransfer.effectAllowed) {
|
||||
case 'all':
|
||||
case 'uninitialized':
|
||||
case 'copy':
|
||||
case 'copyLink':
|
||||
case 'copyMove':
|
||||
ev.dataTransfer.dropEffect = 'copy';
|
||||
break;
|
||||
case 'linkMove':
|
||||
case 'move':
|
||||
ev.dataTransfer.dropEffect = 'move';
|
||||
break;
|
||||
default:
|
||||
ev.dataTransfer.dropEffect = 'none';
|
||||
break;
|
||||
}
|
||||
} 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();
|
||||
});
|
||||
|
||||
definePageMetadata(computed(() => !fetching ? user ? {
|
||||
userName: user,
|
||||
avatar: user,
|
||||
} : {
|
||||
title: group?.name,
|
||||
icon: 'ti ti-users',
|
||||
} : null));
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
display: content;
|
||||
}
|
||||
|
||||
.body {
|
||||
min-height: 80%;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
> i {
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.fetching {
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.messages {
|
||||
padding: 16px 0 0;
|
||||
|
||||
> * {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
width: 100%;
|
||||
position: sticky;
|
||||
z-index: 2;
|
||||
padding-top: 8px;
|
||||
bottom: var(--minBottomSpacing);
|
||||
}
|
||||
|
||||
.new-message {
|
||||
width: 100%;
|
||||
padding-bottom: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.new-message-button {
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
padding: 0 12px;
|
||||
line-height: 32px;
|
||||
font-size: 12px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.new-message-icon {
|
||||
display: inline-block;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.typers {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
padding: 0 8px 0 8px;
|
||||
font-size: 0.9em;
|
||||
color: var(--fgTransparentWeak);
|
||||
}
|
||||
|
||||
|
||||
.user + .user:before {
|
||||
content: ", ";
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.user:last-of-type:after {
|
||||
content: " ";
|
||||
}
|
||||
|
||||
.form {
|
||||
max-height: 12em;
|
||||
overflow-y: scroll;
|
||||
border-top: solid 0.5px var(--divider);
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
.fade-enter-active, .fade-leave-active {
|
||||
transition: opacity 0.1s;
|
||||
}
|
||||
|
||||
.fade-enter-from, .fade-leave-to {
|
||||
transition: opacity 0.5s;
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
@ -5,7 +5,6 @@
|
||||
<div class="_gaps_m">
|
||||
<FormLink @click="readAllNotifications">{{ i18n.ts.markAsReadAllNotifications }}</FormLink>
|
||||
<FormLink @click="readAllUnreadNotes">{{ i18n.ts.markAsReadAllUnreadNotes }}</FormLink>
|
||||
<FormLink @click="readAllMessagingMessages">{{ i18n.ts.markAsReadAllTalkMessages }}</FormLink>
|
||||
</div>
|
||||
</FormSection>
|
||||
<FormSection>
|
||||
@ -47,10 +46,6 @@ async function readAllUnreadNotes() {
|
||||
await os.api('i/read-all-unread-notes');
|
||||
}
|
||||
|
||||
async function readAllMessagingMessages() {
|
||||
await os.api('i/read-all-messaging-messages');
|
||||
}
|
||||
|
||||
async function readAllNotifications() {
|
||||
await os.api('notifications/mark-all-as-read');
|
||||
}
|
||||
|
Reference in New Issue
Block a user