revert Remove messaging!
This commit is contained in:
limepotato 2024-07-18 01:44:19 +02:00 committed by Iceshrimp development
parent 0e4cf8fc9e
commit a5fd5628e2
No known key found for this signature in database
GPG key ID: 7249C94AE229BEAF
5 changed files with 1589 additions and 0 deletions

View file

@ -0,0 +1,312 @@
<template>
<MkStickyContainer>
<template #header
><MkPageHeader
v-model:tab="tab"
:actions="headerActions"
:tabs="headerTabs"
/></template>
<div>
<MkSpacer :content-max="800">
<swiper
:round-lengths="true"
:touch-angle="25"
:threshold="10"
:centeredSlides="true"
:modules="[Virtual]"
:space-between="20"
:virtual="true"
:allow-touch-move="
defaultStore.state.swipeOnMobile &&
(deviceKind !== 'desktop' ||
defaultStore.state.swipeOnDesktop)
"
@swiper="setSwiperRef"
@slide-change="onSlideChange"
>
<swiper-slide>
<div class="_content yweeujhr dms">
<MkButton
primary
class="start"
v-if="!isMobile"
@click="startUser"
><i class="ph-plus ph-bold ph-lg"></i>
{{ i18n.ts.startMessaging }}</MkButton
>
<MkPagination
v-slot="{ items }"
:pagination="dmsPagination"
>
<MkChatPreview
v-for="message in items"
:key="message.id"
class="yweeujhr message _block"
:message="message"
/>
</MkPagination>
</div>
</swiper-slide>
<swiper-slide>
<div class="_content yweeujhr groups">
<div v-if="!isMobile" class="groupsbuttons">
<MkButton
primary
class="start"
:link="true"
to="/my/groups"
><i
class="ph-user-circle-gear ph-bold ph-lg"
></i>
{{ i18n.ts.manageGroups }}</MkButton
>
<MkButton
primary
class="start"
@click="startGroup"
><i class="ph-plus ph-bold ph-lg"></i>
{{ i18n.ts.startMessaging }}</MkButton
>
</div>
<MkPagination
v-slot="{ items }"
:pagination="groupsPagination"
>
<MkChatPreview
v-for="message in items"
:key="message.id"
class="yweeujhr message _block"
:message="message"
/>
</MkPagination>
</div>
</swiper-slide>
</swiper>
</MkSpacer>
</div>
</MkStickyContainer>
</template>
<script lang="ts" setup>
import { ref, markRaw, onMounted, onUnmounted, watch } from "vue";
import * as Acct from "iceshrimp-js/built/acct";
import { Virtual } from "swiper/modules";
import { Swiper, SwiperSlide } from "swiper/vue";
import MkButton from "@/components/MkButton.vue";
import MkChatPreview from "@/components/MkChatPreview.vue";
import MkPagination from "@/components/MkPagination.vue";
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";
import { deviceKind } from "@/scripts/device-kind";
import { defaultStore } from "@/store";
import "swiper/scss";
import "swiper/scss/virtual";
const router = useRouter();
let messages = $ref([]);
let connection = $ref(null);
const tabs = ["dms", "groups"];
let tab = $ref(tabs[0]);
watch($$(tab), () => syncSlide(tabs.indexOf(tab)));
const MOBILE_THRESHOLD = 500;
const isMobile = ref(
deviceKind === "smartphone" || window.innerWidth <= MOBILE_THRESHOLD,
);
window.addEventListener("resize", () => {
isMobile.value =
deviceKind === "smartphone" || window.innerWidth <= MOBILE_THRESHOLD;
});
async function readAllMessagingMessages() {
await os.apiWithDialog("i/read-all-messaging-messages");
}
const headerActions = $computed(() => [
{
icon: "ph-checks ph-bold ph-lg",
text: i18n.ts.markAllAsRead,
handler: readAllMessagingMessages,
},
]);
const headerTabs = $computed(() => [
{
key: "dms",
title: i18n.ts._messaging.dms,
icon: "ph-user ph-bold ph-lg",
},
{
key: "groups",
title: i18n.ts._messaging.groups,
icon: "ph-users-three ph-bold ph-lg",
},
]);
definePageMetadata({
title: i18n.ts.messaging,
icon: "ph-chats-teardrop ph-bold ph-lg",
});
const dmsPagination = {
endpoint: "messaging/history" as const,
limit: 15,
params: {
group: false,
},
};
const groupsPagination = {
endpoint: "messaging/history" as const,
limit: 5,
params: {
group: true,
},
};
function onMessage(message): void {
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): void {
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 startMenu(ev) {
os.popupMenu(
[
{
text: i18n.ts.messagingWithUser,
icon: "ph-user ph-bold ph-lg",
action: () => {
startUser();
},
},
{
text: i18n.ts.messagingWithGroup,
icon: "ph-users-three ph-bold ph-lg",
action: () => {
startGroup();
},
},
],
ev.currentTarget ?? ev.target,
);
}
async function startUser(): void {
os.selectUser().then((user) => {
router.push(`/my/messaging/${Acct.toString(user)}`);
});
}
async function startGroup(): void {
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}`);
}
let swiperRef = null;
function setSwiperRef(swiper) {
swiperRef = swiper;
syncSlide(tabs.indexOf(tab));
}
function onSlideChange() {
tab = tabs[swiperRef.activeIndex];
}
function syncSlide(index) {
swiperRef.slideTo(index);
}
onMounted(() => {
syncSlide(tabs.indexOf(swiperRef.activeIndex));
connection = markRaw(stream.useChannel("messagingIndex"));
connection.on("message", onMessage);
connection.on("read", onRead);
os.api("messaging/history", { group: false, limit: 5 }).then(
(userMessages) => {
os.api("messaging/history", { group: true, limit: 5 }).then(
(groupMessages) => {
const _messages = userMessages.concat(groupMessages);
_messages.sort(
(a, b) =>
new Date(b.createdAt).getTime() -
new Date(a.createdAt).getTime(),
);
messages = _messages;
},
);
},
);
});
onUnmounted(() => {
if (connection) connection.dispose();
});
</script>
<style lang="scss" scoped>
.yweeujhr {
> .start {
margin: 0 auto var(--margin) auto;
}
> .groupsbuttons {
max-width: 100%;
display: flex;
justify-content: center;
margin-bottom: 1rem;
}
}
</style>

View file

@ -0,0 +1,415 @@
<template>
<div
class="pemppnzi _block"
@dragover.stop="onDragover"
@drop.stop="onDrop"
>
<textarea
ref="textEl"
v-model="text"
:placeholder="i18n.ts.inputMessageHere"
@keydown="onKeydown"
@compositionupdate="onCompositionUpdate"
@paste="onPaste"
></textarea>
<footer>
<div v-if="file" class="file" @click="file = null">
{{ file.name }}
</div>
<div class="buttons">
<button
class="_button"
@click="chooseFile"
:aria-label="i18n.t('attachFile')"
>
<i class="ph-upload ph-bold ph-lg"></i>
</button>
<button
class="_button"
@click="insertEmoji"
:aria-label="i18n.t('chooseEmoji')"
>
<i class="ph-smiley ph-bold ph-lg"></i>
</button>
<button
class="send _button"
:disabled="!canSend || sending"
:title="i18n.ts.send"
:aria-label="i18n.ts.send"
@click="send"
>
<template v-if="!sending"
><i
class="ph-paper-plane-tilt ph-bold ph-lg"
></i></template
><template v-if="sending"
><i
class="ph-circle-notch ph-bold ph-lg fa-pulse ph-fw ph-lg"
></i
></template>
</button>
</div>
</footer>
<input ref="fileEl" type="file" @change="onChangeFile" />
</div>
</template>
<script lang="ts" setup>
import { onMounted, watch } from "vue";
import * as Misskey from "iceshrimp-js";
import autosize from "autosize";
//import insertTextAtCursor from 'insert-text-at-cursor';
import { throttle } from "throttle-debounce";
import { Autocomplete } from "@/scripts/autocomplete";
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 { uploadFile } from "@/scripts/upload";
const props = defineProps<{
user?: Misskey.entities.UserDetailed | null;
group?: Misskey.entities.UserGroup | null;
}>();
let textEl = $ref<HTMLTextAreaElement>();
let fileEl = $ref<HTMLInputElement>();
let text = $ref<string>("");
let file = $ref<Misskey.entities.DriveFile | null>(null);
let sending = $ref(false);
const typing = throttle(3000, () => {
stream.send(
"typingOnMessaging",
props.user ? { partner: props.user.id } : { group: props.group?.id },
);
});
let draftKey = $computed(() =>
props.user ? "user:" + props.user.id : "group:" + props.group?.id,
);
let canSend = $computed(
() => (text != null && text.trim() !== "") || 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();
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();
let sendOnEnter =
localStorage.getItem("enterSendsMessage") === "true" ||
defaultStore.state.enterSendsMessage;
if (sendOnEnter) {
if (ev.key === "Enter" && (ev.ctrlKey || ev.metaKey)) {
textEl.value += "\n";
} else if (
ev.key === "Enter" &&
!ev.shiftKey &&
!("ontouchstart" in document.documentElement) &&
canSend
) {
ev.preventDefault();
send();
}
} else {
if (ev.key === "Enter" && (ev.ctrlKey || ev.metaKey) && canSend) {
ev.preventDefault();
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(),
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
new Autocomplete(textEl, $$(text));
// 稿
const draft = JSON.parse(localStorage.getItem("message_drafts") || "{}")[
draftKey
];
if (draft) {
text = draft.data.text;
file = draft.data.file;
}
});
defineExpose({
file,
upload,
});
</script>
<style lang="scss" scoped>
.pemppnzi {
position: relative;
margin-top: 1rem;
> 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;
background: transparent;
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;
}
}
> .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;
}
}
}
input[type="file"] {
display: none;
}
}
</style>

View file

@ -0,0 +1,377 @@
<template>
<div v-size="{ max: [400, 500] }" class="thvuemwp" :class="{ isMe }">
<MkAvatar
v-if="!isMe"
class="avatar"
:user="message.user"
:show-indicator="true"
/>
<div class="content">
<div class="balloon" :class="{ noText: message.text == null }">
<button
v-if="isMe"
class="delete-button"
:title="i18n.ts.delete"
@click="del"
>
<i
style="color: var(--accentLighten)"
class="ph-x-circle ph-fill ph-lg"
></i>
</button>
<div v-if="!message.isDeleted" class="content">
<Mfm
v-if="message.text"
ref="text"
class="text"
:text="message.text"
:i="$i"
/>
</div>
<div v-else class="content">
<p class="is-deleted">{{ i18n.ts.deleted }}</p>
</div>
</div>
<div v-if="message.file" class="file" width="400px">
<XMediaList
v-if="
message.file.type.split('/')[0] == 'image' ||
message.file.type.split('/')[0] == 'video'
"
:in-dm="true"
width="400px"
:media-list="[message.file]"
style="border-radius: 5px"
/>
<a
v-else
:href="message.file.url"
rel="noopener"
target="_blank"
:title="message.file.name"
>
<p>{{ message.file.name }}</p>
</a>
</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"
>{{ i18n.ts.messageRead }}
{{ message.reads.length }}</span
>
</template>
<template v-else>
<span v-if="isMe && message.isRead" class="read">{{
i18n.ts.messageRead
}}</span>
</template>
<MkTime :time="message.createdAt" />
<template v-if="message.is_edited"
><i class="ph-pencil ph-bold ph-lg"></i
></template>
</footer>
</div>
</div>
</template>
<script lang="ts" setup>
import {} from "vue";
import * as mfm from "mfm-js";
import type * as Misskey from "iceshrimp-js";
import XMediaList from "@/components/MkMediaList.vue";
import { extractUrlFromMfm } from "@/scripts/extract-url-from-mfm";
import MkUrlPreview from "@/components/MkUrlPreview.vue";
import * as os from "@/os";
import { $i } from "@/account";
import { i18n } from "@/i18n";
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);
--plyr-color-main: var(--accent);
position: relative;
background-color: transparent;
display: flex;
> .avatar {
position: sticky;
top: calc(var(--stickyTop, 0px) + 20px);
display: block;
width: 45px;
height: 45px;
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%;
& + * {
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: #6e6a86;
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(--messagingIsNotMe);
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;
}
}
}
}
&.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>

View file

@ -0,0 +1,464 @@
<template>
<div
ref="rootEl"
class="_section"
@dragover.prevent.stop="onDragover"
@drop.prevent.stop="onDrop"
>
<div class="_content mk-messaging-room">
<MkSpacer :content-max="800">
<div class="body">
<MkPagination
v-if="pagination"
ref="pagingComponent"
:key="userAcct || groupId"
:pagination="pagination"
>
<template #empty>
<div class="_fullinfo">
<img
:src="instance.images.info"
class="_ghost"
alt="Info"
/>
<div>{{ i18n.ts.noMessagesYet }}</div>
</div>
</template>
<template
#default="{ items: messages, fetching: pFetching }"
>
<XList
aria-live="polite"
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>
<footer>
<div
v-if="typers.length > 0"
class="typers"
aria-live="polite"
>
<I18n
:src="i18n.ts.typingUsers"
text-tag="span"
class="users"
>
<template #users>
<b
v-for="typer in typers"
:key="typer.id"
class="user"
>{{ typer.username }}</b
>
</template>
</I18n>
<MkEllipsis />
</div>
<transition :name="animation ? 'fade' : ''">
<div v-show="showIndicator" class="new-message">
<button
class="_buttonPrimary"
@click="onIndicatorClick"
>
<i
class="fas ph-fw ph-lg ph-arrow-circle-down-bold ph-lg"
></i
>{{ i18n.ts.newMessageExists }}
</button>
</div>
</transition>
<XForm
v-if="!fetching"
ref="formEl"
:user="user"
:group="group"
class="form"
/>
</footer>
</MkSpacer>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, watch, onMounted, nextTick, onBeforeUnmount } from "vue";
import * as Misskey from "iceshrimp-js";
import * as Acct from "iceshrimp-js/built/acct";
import XMessage from "./messaging-room.message.vue";
import XForm from "./messaging-room.form.vue";
import XList 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";
import {instance} from "@/instance";
const props = defineProps<{
userAcct?: string;
groupId?: string;
}>();
let rootEl = $ref<HTMLDivElement>();
let formEl = $ref<InstanceType<typeof XForm>>();
let pagingComponent = $ref<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(() => {
// 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() {
if (window.location.href.includes("my/messaging/")) {
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();
definePageMetadata(
computed(() => ({
title: group != null ? group.name : user?.name ?? 'Chat',
icon: "ph-chats-teardrop-bold ph-lg",
})),
);
});
onBeforeUnmount(() => {
connection?.dispose();
document.removeEventListener("visibilitychange", onVisibilitychange);
if (scrollRemove) scrollRemove();
});
</script>
<style lang="scss" scoped>
XMessage:last-of-type {
margin-bottom: 4rem;
}
.mk-messaging-room {
position: relative;
overflow: auto;
> .body {
.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 {
padding: 8px 0;
> ::v-deep(*) {
margin-bottom: 16px;
}
}
}
> footer {
width: 100%;
position: sticky;
z-index: 2;
bottom: 0;
padding-top: 8px;
bottom: calc(env(safe-area-inset-bottom, 0px) + 8px);
> .new-message {
width: 100%;
padding-bottom: 8px;
text-align: center;
> button {
display: inline-block;
margin: 0;
padding: 0 12px;
line-height: 32px;
font-size: 12px;
border-radius: 16px;
> i {
display: inline-block;
margin-right: 8px;
}
}
}
> .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 {
max-height: 12em;
overflow-y: scroll;
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>

View file

@ -82,6 +82,27 @@
></span>
</div>
</button>
<button
:aria-label="i18n.t('messaging')"
class="button messaging _button"
@click="
mainRouter.push('/my/messaging');
updateButtonState();
"
>
<div
class="button-wrapper"
:class="buttonAnimIndex === 2 ? 'on' : ''"
>
<i class="ph-chats-teardrop ph-bold ph-lg"></i
><span
v-if="$i?.hasUnreadMessagingMessage"
class="indicator"
:class="{ animateIndicator: $store.state.animation }"
><i class="ph-circle ph-fill"></i
></span>
</div>
</button>
<button
:aria-label="i18n.t('_deck._columns.widgets')"
class="button widget _button"