mirror of
https://iceshrimp.dev/limepotato/jormungandr-bite.git
synced 2024-11-09 11:41:30 -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>
|
||||
</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"
|
||||
|
|
Loading…
Reference in a new issue