mirror of
https://iceshrimp.dev/limepotato/jormungandr-bite.git
synced 2024-11-22 01:47:39 -07:00
parent
0e4cf8fc9e
commit
a5fd5628e2
5 changed files with 1589 additions and 0 deletions
312
packages/client/src/pages/messaging/index.vue
Normal file
312
packages/client/src/pages/messaging/index.vue
Normal 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>
|
415
packages/client/src/pages/messaging/messaging-room.form.vue
Normal file
415
packages/client/src/pages/messaging/messaging-room.form.vue
Normal 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>
|
377
packages/client/src/pages/messaging/messaging-room.message.vue
Normal file
377
packages/client/src/pages/messaging/messaging-room.message.vue
Normal 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>
|
464
packages/client/src/pages/messaging/messaging-room.vue
Normal file
464
packages/client/src/pages/messaging/messaging-room.vue
Normal 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>
|
|
@ -82,6 +82,27 @@
|
||||||
></span>
|
></span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</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
|
<button
|
||||||
:aria-label="i18n.t('_deck._columns.widgets')"
|
:aria-label="i18n.t('_deck._columns.widgets')"
|
||||||
class="button widget _button"
|
class="button widget _button"
|
||||||
|
|
Loading…
Reference in a new issue