feat: implement message grouping, improve styling

This commit is contained in:
SauceyRed 2025-05-29 22:19:31 +02:00
parent 6a3c8e8982
commit 53687a0ec3
Signed by: sauceyred
GPG key ID: 270B096EF6E9A462
4 changed files with 155 additions and 59 deletions

View file

@ -49,4 +49,9 @@ a {
border-radius: .3rem; border-radius: .3rem;
} }
.invisible {
visibility: hidden;
}
</style> </style>

View file

@ -1,6 +1,6 @@
<template> <template>
<div class="message"> <div v-if="props.type == 'normal'" class="message normal-message" :class="{ 'message-margin-bottom': props.marginBottom }">
<div> <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="username">
<Icon v-else name="lucide:user" class="message-author-avatar" /> <Icon v-else name="lucide:user" class="message-author-avatar" />
</div> </div>
@ -11,10 +11,6 @@
</span> </span>
<span class="message-date" :title="date.toString()"> <span class="message-date" :title="date.toString()">
{{ messageDate }} {{ messageDate }}
<!--
<div class="message-date-hover" v-if="showHover">
</div>
-->
</span> </span>
</div> </div>
<div class="message-text"> <div class="message-text">
@ -22,17 +18,45 @@
</div> </div>
</div> </div>
</div> </div>
<div v-else ref="messageElement" class="message compact-message">
<div class="left-column">
<div>
<span :class="{ 'invisible': dateHidden }" class="message-date" :title="date.toString()">
{{ messageDate }}
</span>
</div>
</div>
<div class="message-data">
<div class="message-text">
{{ text }}
</div>
</div>
</div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
const props = defineProps<{ class?: string, img?: string | null, username: string, text: string, timestamp: number, format: "12" | "24" }>(); const props = defineProps<{
class?: string,
img?: string | null,
username: string,
text: string,
timestamp: number,
format: "12" | "24",
type: "normal" | "compact",
marginBottom: boolean
}>();
const messageDate = ref<string>(); const messageDate = ref<string>();
const showHover = ref(false);
const messageElement = ref<HTMLDivElement>();
const dateHidden = ref<boolean>(true);
const date = new Date(props.timestamp); const date = new Date(props.timestamp);
console.log("message:", props.text); console.log("Message.vue: message:", props.text);
console.log("Message.vue: message type:", props.type);
let dateHour = date.getHours(); let dateHour = date.getHours();
let dateMinute = date.getMinutes(); let dateMinute = date.getMinutes();
if (props.format == "12") { if (props.format == "12") {
@ -47,7 +71,19 @@ if (props.format == "12") {
} }
} else { } else {
messageDate.value = `${dateHour}:${dateMinute < 10 ? "0" + dateMinute : dateMinute}` messageDate.value = `${dateHour}:${dateMinute < 10 ? "0" + dateMinute : dateMinute}`
} }
onMounted(() => {
messageElement.value?.addEventListener("mouseenter", (e: Event) => {
console.log("mouse enter");
dateHidden.value = false;
});
messageElement.value?.addEventListener("mouseleave", (e: Event) => {
console.log("mouse leave");
dateHidden.value = true;
});
});
//function toggleTooltip(e: Event) { //function toggleTooltip(e: Event) {
// showHover.value = !showHover.value; // showHover.value = !showHover.value;
@ -59,12 +95,15 @@ if (props.format == "12") {
.message { .message {
text-align: left; text-align: left;
/* border: 1px solid lightcoral; */ /* border: 1px solid lightcoral; */
margin-bottom: 1dvh;
display: grid; display: grid;
grid-template-columns: auto 1fr; grid-template-columns: 1fr 19fr;
align-items: center; align-items: center;
} }
.message-margin-bottom {
margin-bottom: 1dvh;
}
.message-metadata { .message-metadata {
display: flex; display: flex;
gap: .5dvw; gap: .5dvw;
@ -86,22 +125,25 @@ if (props.format == "12") {
} }
.message-author-avatar { .message-author-avatar {
margin-right: 1dvw; height: 2.3em;
width: 3em; width: 2.3em;
border-radius: 50%; border-radius: 50%;
} }
.left-column {
margin-right: .5dvw;
text-align: center;
align-content: center;
}
.author-username { .author-username {
margin-right: .5dvw; margin-right: .5dvw;
color: white; color: white;
} }
.message-date { .message-date {
font-size: small; font-size: .7em;
color: rgb(150, 150, 150); color: rgb(150, 150, 150);
}
.message-date:hover {
cursor: default; cursor: default;
} }

View file

@ -1,18 +1,23 @@
<template> <template>
<div id="message-area"> <div id="message-area">
<div id="messages" ref="messagesElement"> <div id="messages" ref="messagesElement">
<Message v-for="message of messages" :username="message.user.display_name ?? message.user.username" :text="message.message" <div v-for="(message, i) of messages">
:timestamp="uuidToTimestamp(message.uuid)" :img="message.user.avatar" format="12" /> <Message :username="message.user.display_name ?? message.user.username"
: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'" />
</div>
</div>
<div id="message-box" class="rounded-corners">
<form id="message-form" @submit="sendMessage">
<input v-model="messageInput" id="message-box-input" class="rounded-corners" type="text"
name="message-input" autocomplete="off">
<button id="submit-button" type="submit">
<Icon name="lucide:send" />
</button>
</form>
</div>
</div> </div>
<div id="message-box" class="rounded-corners">
<form id="message-form" @submit="sendMessage">
<input v-model="messageInput" id="message-box-input" class="rounded-corners" type="text" name="message-input" autocomplete="off">
<button id="submit-button" type="submit">
<Icon name="lucide:send" />
</button>
</form>
</div>
</div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
@ -21,12 +26,59 @@ import scrollToBottom from '~/utils/scrollToBottom';
const props = defineProps<{ channelUrl: string, amount?: number, offset?: number }>(); const props = defineProps<{ channelUrl: string, amount?: number, offset?: number }>();
const messageTimestamps = ref<Record<string, number>>({});
const messagesType = ref<Record<string, "normal" | "compact">>({});
const messagesRes: MessageResponse[] | undefined = await fetchWithApi( const messagesRes: MessageResponse[] | undefined = await fetchWithApi(
`${props.channelUrl}/messages`, `${props.channelUrl}/messages`,
{ query: { "amount": props.amount ?? 100, "offset": props.offset ?? 0 } } { query: { "amount": props.amount ?? 100, "offset": props.offset ?? 0 } }
); );
if (messagesRes) { if (messagesRes) {
messagesRes.reverse(); messagesRes.reverse();
console.log("messages res:", messagesRes.map(msg => msg.message));
const firstMessageByUsers = ref<Record<string, MessageResponse | undefined>>({});
for (const message of messagesRes) {
messageTimestamps.value[message.uuid] = uuidToTimestamp(message.uuid);
console.log("message:", message.message);
const firstByUser = firstMessageByUsers.value[message.user.uuid];
if (firstByUser) {
console.log("first by user exists");
if (message.user.uuid != firstByUser.user.uuid) {
console.log("message is by new user, setting their first message")
firstMessageByUsers.value[message.user.uuid] = message;
console.log("RETURNING FALSE");
messagesType.value[message.uuid] = "normal";
continue;
}
} else {
console.log("first by user doesn't exist");
console.log(`setting first post by user ${message.user.username} to "${message.message}" with timestamp ${messageTimestamps.value[message.uuid]}`);
firstMessageByUsers.value[message.user.uuid] = message;
console.log("RETURNING FALSE");
messagesType.value[message.uuid] = "normal";
continue;
}
const messageGroupingMaxDifference = useRuntimeConfig().public.messageGroupingMaxDifference;
const prevTimestamp = messageTimestamps.value[firstByUser.uuid];
const timestamp = messageTimestamps.value[message.uuid];
console.log("first message timestamp:", prevTimestamp);
console.log("timestamp:", timestamp);
const diff = (timestamp - prevTimestamp);
console.log("min diff:", messageGroupingMaxDifference);
console.log("diff:", diff);
const lessThanMax = diff <= messageGroupingMaxDifference;
console.log("group?", lessThanMax);
if (!lessThanMax) {
console.log("diff exceeds max");
console.log(`setting first post by user ${message.user.username} to "${message.message}" with timestamp ${messageTimestamps.value[message.uuid]}`)
firstMessageByUsers.value[message.user.uuid] = message;
messagesType.value[message.uuid] = "normal";
continue;
}
console.log("RETURNING " + lessThanMax.toString().toUpperCase());
messagesType.value[message.uuid] = "compact";
}
} }
const messages = ref<MessageResponse[]>([]); const messages = ref<MessageResponse[]>([]);
@ -49,29 +101,28 @@ if (accessToken && apiBase) {
do { do {
console.log("Trying to connect to channel WebSocket..."); console.log("Trying to connect to channel WebSocket...");
ws = new WebSocket(`${apiBase.replace("http", "ws").replace("3000", "8080")}/${props.channelUrl}/socket`, ws = new WebSocket(`${apiBase.replace("http", "ws").replace("3000", "8080")}/${props.channelUrl}/socket`,
["Authorization", accessToken] ["Authorization", accessToken]
); );
if (ws) break; if (ws) break;
await sleep(5000); await sleep(5000);
} while (!ws); } while (!ws);
ws.addEventListener("open", (event) => {
console.log("WebSocket connected!");
});
ws.addEventListener("message", async (event) => {
console.log("event data:", event.data);
messages.value?.push(
JSON.parse(event.data)
);
await nextTick();
if (messagesElement.value) {
console.log("scrolling to bottom");
scrollToBottom(messagesElement);
}
});
ws.addEventListener("open", (event) => {
console.log("WebSocket connected!");
});
ws.addEventListener("message", async (event) => {
console.log("event data:", event.data);
console.log("message uuid:", event.data.uuid);
const parsedData = JSON.parse(event.data);
messageTimestamps.value[parsedData.uuid] = uuidToTimestamp(parsedData.uuid);
messages.value.push(parsedData);
await nextTick();
if (messagesElement.value) {
console.log("scrolling to bottom");
scrollToBottom(messagesElement);
}
});
} else { } else {
await refresh(); await refresh();
@ -82,7 +133,7 @@ function sendMessage(e: Event) {
const text = messageInput.value; const text = messageInput.value;
console.log("text:", text); console.log("text:", text);
if (text) { if (text) {
ws.send(text); ws.send(text);
messageInput.value = ""; messageInput.value = "";
console.log("MESSAGE SENT!!!"); console.log("MESSAGE SENT!!!");
} }
@ -97,13 +148,11 @@ onMounted(async () => {
</script> </script>
<style scoped> <style scoped>
#message-area { #message-area {
display: grid; display: grid;
grid-template-columns: 1fr; grid-template-columns: 1fr;
grid-template-rows: 8fr 1fr; grid-template-rows: 8fr 1fr;
justify-content: space-between; justify-content: space-between;
padding-top: 3dvh;
padding-left: 1dvw; padding-left: 1dvw;
padding-right: 1dvw; padding-right: 1dvw;
overflow: hidden; overflow: hidden;
@ -118,7 +167,7 @@ onMounted(async () => {
padding-bottom: 1dvh; padding-bottom: 1dvh;
padding-top: 1dvh; padding-top: 1dvh;
margin-bottom: 1dvh; margin-bottom: 1dvh;
margin-top: 1dvh; margin-top: 2dvh;
} }
#message-form { #message-form {
@ -152,5 +201,4 @@ onMounted(async () => {
#submit-button:hover { #submit-button:hover {
color: rgb(255, 255, 255); color: rgb(255, 255, 255);
} }
</style> </style>

View file

@ -26,7 +26,8 @@ export default defineNuxtConfig({
}, },
runtimeConfig: { runtimeConfig: {
public: { public: {
apiVersion: 1 apiVersion: 1,
messageGroupingMaxDifference: 300000
} }
}, },
/* nitro: { /* nitro: {