Merge branch 'main' into friends-list
This commit is contained in:
commit
457405186a
12 changed files with 347 additions and 30 deletions
44
components/ContextMenu.vue
Normal file
44
components/ContextMenu.vue
Normal file
|
@ -0,0 +1,44 @@
|
|||
<template>
|
||||
<div v-for="item of props.menuItems" class="context-menu-item" @click="runCallback(item)">
|
||||
{{ item.name }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { ContextMenuItem } from '~/types/interfaces';
|
||||
|
||||
const props = defineProps<{ menuItems: ContextMenuItem[], cursorX: number, cursorY: number }>();
|
||||
|
||||
onMounted(() => {
|
||||
const contextMenu = document.getElementById("context-menu");
|
||||
if (contextMenu) {
|
||||
contextMenu.style.left = props.cursorX.toString() + "px";
|
||||
contextMenu.style.top = props.cursorY.toString() + "px";
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
function runCallback(item: ContextMenuItem) {
|
||||
removeContextMenu();
|
||||
item.callback();
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
#context-menu {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 10dvw;
|
||||
border: .15rem solid cyan;
|
||||
background-color: var(--background-color);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.context-menu-item:hover {
|
||||
background-color: rgb(50, 50, 50);
|
||||
}
|
||||
|
||||
</style>
|
|
@ -1,13 +1,17 @@
|
|||
<template>
|
||||
<div v-if="props.type == 'normal'" :id="props.last ? 'last-message' : undefined" class="message normal-message">
|
||||
<div v-if="props.type == 'normal' || props.replyMessage" ref="messageElement" @contextmenu="createContextMenu($event, menuItems)" :id="props.last ? 'last-message' : undefined"
|
||||
class="message normal-message" :class="{ 'mentioned': (props.replyMessage || props.isMentioned) && props.message.user.uuid != props.me.uuid && props.replyMessage?.user.uuid == props.me.uuid }" :data-message-id="props.messageId"
|
||||
:editing.sync="props.editing" :replying-to.sync="props.replyingTo">
|
||||
<MessageReply v-if="props.replyMessage" :author="props.replyMessage.user.display_name || props.replyMessage.user.username" :text="props.replyMessage?.message"
|
||||
:id="props.message.uuid" :reply-id="props.replyMessage.uuid" max-width="reply" />
|
||||
<div class="left-column">
|
||||
<img v-if="props.img" class="message-author-avatar" :src="props.img" :alt="username" />
|
||||
<img v-if="props.img" class="message-author-avatar" :src="props.img" :alt="author?.display_name || author?.username" />
|
||||
<Icon v-else name="lucide:user" class="message-author-avatar" />
|
||||
</div>
|
||||
<div class="message-data">
|
||||
<div class="message-metadata">
|
||||
<span class="message-author-username" tabindex="0">
|
||||
{{ username }}
|
||||
{{ author?.display_name || author?.username }}
|
||||
</span>
|
||||
<span class="message-date" :title="date.toString()">
|
||||
<span v-if="getDayDifference(date, currentDate) === 1">Yesterday at</span>
|
||||
|
@ -18,7 +22,9 @@
|
|||
<div class="message-text" v-html="sanitized" tabindex="0"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else ref="messageElement" :id="props.last ? 'last-message' : undefined" class="message grouped-message" :class="{ 'message-margin-bottom': props.marginBottom }">
|
||||
<div v-else ref="messageElement" @contextmenu="createContextMenu($event, menuItems)" :id="props.last ? 'last-message' : undefined"
|
||||
class="message grouped-message" :class="{ 'message-margin-bottom': props.marginBottom, 'mentioned': props.replyMessage || props.isMentioned }"
|
||||
:data-message-id="props.messageId" :editing.sync="props.editing" :replying-to.sync="props.replyingTo">
|
||||
<div class="left-column">
|
||||
<span :class="{ 'invisible': dateHidden }" class="message-date side-message-date" :title="date.toString()">
|
||||
{{ date.toLocaleTimeString(undefined, { timeStyle: "short" }) }}
|
||||
|
@ -33,18 +39,9 @@
|
|||
<script lang="ts" setup>
|
||||
import DOMPurify from 'dompurify';
|
||||
import { parse } from 'marked';
|
||||
import type { MessageProps } from '~/types/props';
|
||||
|
||||
const props = defineProps<{
|
||||
class?: string,
|
||||
img?: string | null,
|
||||
username: string,
|
||||
text: string,
|
||||
timestamp: number,
|
||||
format: "12" | "24",
|
||||
type: "normal" | "grouped",
|
||||
marginBottom: boolean,
|
||||
last: boolean
|
||||
}>();
|
||||
const props = defineProps<MessageProps>();
|
||||
|
||||
const messageElement = ref<HTMLDivElement>();
|
||||
|
||||
|
@ -53,8 +50,9 @@ const dateHidden = ref<boolean>(true);
|
|||
const date = new Date(props.timestamp);
|
||||
const currentDate: Date = new Date()
|
||||
|
||||
console.log("message:", props.text);
|
||||
console.log("author:", props.username);
|
||||
console.log("[MSG] message to render:", props.message);
|
||||
console.log("author:", props.author);
|
||||
console.log("[MSG] reply message:", props.replyMessage);
|
||||
|
||||
const sanitized = ref<string>();
|
||||
|
||||
|
@ -72,20 +70,31 @@ onMounted(async () => {
|
|||
});
|
||||
console.log("adding listeners")
|
||||
await nextTick();
|
||||
messageElement.value?.addEventListener("mouseenter", (e: Event) => {
|
||||
dateHidden.value = false;
|
||||
});
|
||||
|
||||
messageElement.value?.addEventListener("mouseleave", (e: Event) => {
|
||||
dateHidden.value = true;
|
||||
});
|
||||
console.log("added listeners");
|
||||
if (messageElement.value?.classList.contains("grouped-message")) {
|
||||
messageElement.value?.addEventListener("mouseenter", (e: Event) => {
|
||||
dateHidden.value = false;
|
||||
});
|
||||
|
||||
messageElement.value?.addEventListener("mouseleave", (e: Event) => {
|
||||
dateHidden.value = true;
|
||||
});
|
||||
console.log("added listeners");
|
||||
}
|
||||
});
|
||||
|
||||
//function toggleTooltip(e: Event) {
|
||||
// showHover.value = !showHover.value;
|
||||
//}
|
||||
|
||||
const menuItems = [
|
||||
{ name: "Reply", callback: () => { if (messageElement.value) replyToMessage(messageElement.value, props) } }
|
||||
]
|
||||
|
||||
console.log("me:", props.me);
|
||||
if (props.author?.uuid == props.me.uuid) {
|
||||
menuItems.push({ name: "Edit", callback: () => { if (messageElement.value) editMessage(messageElement.value, props) } });
|
||||
}
|
||||
|
||||
function getDayDifference(date1: Date, date2: Date) {
|
||||
const midnight1 = new Date(date1.getFullYear(), date1.getMonth(), date1.getDate());
|
||||
const midnight2 = new Date(date2.getFullYear(), date2.getMonth(), date2.getDate());
|
||||
|
@ -111,6 +120,11 @@ function getDayDifference(date1: Date, date2: Date) {
|
|||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.message-reply-preview {
|
||||
grid-row: 1;
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.message:hover {
|
||||
background-color: var(--chat-highlighted-background-color);
|
||||
}
|
||||
|
@ -140,6 +154,8 @@ function getDayDifference(date1: Date, date2: Date) {
|
|||
flex-direction: column;
|
||||
height: fit-content;
|
||||
width: 100%;
|
||||
grid-row: 2;
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.message-author {
|
||||
|
@ -158,6 +174,8 @@ function getDayDifference(date1: Date, date2: Date) {
|
|||
justify-content: center;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
grid-row: 2;
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.author-username {
|
||||
|
@ -184,6 +202,15 @@ function getDayDifference(date1: Date, date2: Date) {
|
|||
width: 20px;
|
||||
}
|
||||
*/
|
||||
|
||||
.mentioned {
|
||||
background-color: rgba(0, 255, 166, 0.123);
|
||||
}
|
||||
|
||||
.mentioned:hover {
|
||||
background-color: rgba(90, 255, 200, 0.233);
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<style module>
|
||||
|
|
|
@ -5,7 +5,9 @@
|
|||
:text="message.message" :timestamp="messageTimestamps[message.uuid]" :img="message.user.avatar"
|
||||
format="12" :type="messagesType[message.uuid]"
|
||||
:margin-bottom="(messages[i + 1] && messagesType[messages[i + 1].uuid] == 'normal') ?? false"
|
||||
:last="i == messages.length - 1" />
|
||||
:last="i == messages.length - 1" :message-id="message.uuid" :author="message.user" :me="me"
|
||||
:message="message" :is-reply="message.reply_to"
|
||||
:reply-message="message.reply_to ? getReplyMessage(message.reply_to) : undefined" />
|
||||
</div>
|
||||
<div id="message-box" class="rounded-corners">
|
||||
<form id="message-form" @submit="sendMessage">
|
||||
|
@ -37,11 +39,13 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { MessageResponse, ScrollPosition } from '~/types/interfaces';
|
||||
import type { MessageResponse, ScrollPosition, UserResponse } from '~/types/interfaces';
|
||||
import scrollToBottom from '~/utils/scrollToBottom';
|
||||
|
||||
const props = defineProps<{ channelUrl: string, amount?: number, offset?: number }>();
|
||||
|
||||
const me = await fetchWithApi("/me") as UserResponse;
|
||||
|
||||
const messageTimestamps = ref<Record<string, number>>({});
|
||||
const messagesType = ref<Record<string, "normal" | "grouped">>({});
|
||||
const messageGroupingMaxDifference = useRuntimeConfig().public.messageGroupingMaxDifference
|
||||
|
@ -114,6 +118,7 @@ if (messagesRes) {
|
|||
messagesRes.reverse();
|
||||
console.log("messages res:", messagesRes.map(msg => msg.message));
|
||||
for (const message of messagesRes) {
|
||||
console.log("[MSG] processing message:", message);
|
||||
groupMessage(message);
|
||||
}
|
||||
}
|
||||
|
@ -185,6 +190,7 @@ if (accessToken && apiBase) {
|
|||
console.log("event data:", event.data);
|
||||
console.log("message uuid:", event.data.uuid);
|
||||
const parsedData = JSON.parse(event.data);
|
||||
console.log("[MSG] parsed message:", parsedData);
|
||||
|
||||
console.log("parsed message type:", messagesType.value[parsedData.uuid]);
|
||||
console.log("parsed message timestamp:", messageTimestamps.value[parsedData.uuid]);
|
||||
|
@ -203,11 +209,18 @@ if (accessToken && apiBase) {
|
|||
function sendMessage(e: Event) {
|
||||
e.preventDefault();
|
||||
if (messageInput.value && messageInput.value.trim() !== "") {
|
||||
const message = {
|
||||
const message: Record<string, string> = {
|
||||
message: messageInput.value.trim().replace(/\n/g, "<br>") // trim, and replace \n with <br>
|
||||
}
|
||||
|
||||
console.log("message:", message);
|
||||
const messageReply = document.getElementById("message-reply") as HTMLDivElement;
|
||||
console.log("[MSG] message reply:", messageReply);
|
||||
if (messageReply && messageReply.dataset.messageId) {
|
||||
console.log("[MSG] message is a reply");
|
||||
message.reply_to = messageReply.dataset.messageId;
|
||||
}
|
||||
|
||||
console.log("[MSG] sent message:", message);
|
||||
ws.send(JSON.stringify(message));
|
||||
|
||||
// reset input field
|
||||
|
@ -220,10 +233,22 @@ function sendMessage(e: Event) {
|
|||
}
|
||||
}
|
||||
|
||||
function getReplyMessage(id: string) {
|
||||
console.log("[REPLYMSG] id:", id);
|
||||
const messagesValues = Object.values(messages.value);
|
||||
console.log("[REPLYMSG] messages values:", messagesValues);
|
||||
for (const message of messagesValues) {
|
||||
console.log("[REPLYMSG] message:", message);
|
||||
console.log("[REPLYMSG] IDs match?", message.uuid == id);
|
||||
if (message.uuid == id) return message;
|
||||
}
|
||||
}
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
onMounted(async () => {
|
||||
if (import.meta.server) return;
|
||||
console.log("[MSG] messages keys:", Object.values(messages.value));
|
||||
if (messagesElement.value) {
|
||||
scrollToBottom(messagesElement.value);
|
||||
let fetched = false;
|
||||
|
@ -301,6 +326,7 @@ router.beforeEach((to, from, next) => {
|
|||
}
|
||||
|
||||
#message-box {
|
||||
margin-top: auto; /* force it to the bottom of the screen */
|
||||
margin-bottom: 2dvh;
|
||||
margin-left: 1dvw;
|
||||
margin-right: 1dvw;
|
||||
|
|
92
components/MessageReply.vue
Normal file
92
components/MessageReply.vue
Normal file
|
@ -0,0 +1,92 @@
|
|||
<template>
|
||||
<div :id="props.maxWidth == 'full' ? 'message-reply' : undefined" :class="{ 'message-reply-preview' : props.maxWidth == 'reply' }"
|
||||
:data-message-id="props.id" @click="scrollToReply">
|
||||
<p id="reply-text">Replying to <span id="reply-author-field">{{ props.author }}:</span> <span v-html="sanitized"></span></p>
|
||||
<!-- <span id="message-reply-cancel"><Icon name="lucide:x" /></span> -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
|
||||
import DOMPurify from "dompurify";
|
||||
import { parse } from "marked";
|
||||
|
||||
const props = defineProps<{ author: string, text: string, id: string, replyId: string, maxWidth: "full" | "reply" }>();
|
||||
|
||||
const existingReply = document.getElementById("message-reply");
|
||||
|
||||
if (existingReply) {
|
||||
existingReply.remove();
|
||||
}
|
||||
|
||||
console.log("text:", props.text);
|
||||
|
||||
const sanitized = ref<string>();
|
||||
|
||||
onMounted(async () => {
|
||||
|
||||
const parsed = await parse(props.text.trim().replaceAll("<br>", " "), { gfm: true });
|
||||
|
||||
sanitized.value = DOMPurify.sanitize(parsed, {
|
||||
ALLOWED_TAGS: [],
|
||||
ALLOW_DATA_ATTR: false,
|
||||
ALLOW_SELF_CLOSE_IN_ATTR: false,
|
||||
ALLOWED_ATTR: [],
|
||||
KEEP_CONTENT: true
|
||||
});
|
||||
|
||||
console.log("sanitized:", sanitized.value);
|
||||
|
||||
const messageBoxInput = document.getElementById("message-textbox-input") as HTMLDivElement;
|
||||
if (messageBoxInput) {
|
||||
messageBoxInput.focus();
|
||||
}
|
||||
});
|
||||
|
||||
function scrollToReply(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
console.log("clicked on reply box");
|
||||
const reply = document.querySelector(`.message[data-message-id="${props.replyId}"]`);
|
||||
if (reply) {
|
||||
console.log("reply:", reply);
|
||||
console.log("scrolling into view");
|
||||
reply.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
#message-reply, .message-reply-preview {
|
||||
display: flex;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--padding-color);
|
||||
margin-bottom: .5rem;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#message-reply {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.message-reply-preview {
|
||||
width: 30%;
|
||||
margin-left: .5dvw;
|
||||
}
|
||||
|
||||
#reply-text {
|
||||
color: rgb(150, 150, 150);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-bottom: 0;
|
||||
|
||||
}
|
||||
|
||||
#reply-author-field {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
</style>
|
Loading…
Add table
Add a link
Reference in a new issue