Files
plexus/client/src/components/MessageList.vue

269 lines
10 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { ref, onUpdated, nextTick } from 'vue';
import { useChatStore } from '../stores/chat';
import { storeToRefs } from 'pinia';
import { Send, Hash, Smile, ExternalLink, Copy, Check } from 'lucide-vue-next';
const chatStore = useChatStore();
const { currentMessages, currentChannel, walletAddress } = storeToRefs(chatStore);
const newMessage = ref('');
const messagesContainer = ref(null);
const showEmojiPicker = ref(null);
const copiedTxId = ref(null);
const EMOJIS = ['👍', '❤️', '🔥', '😂', '😮', '😢', '🚀', '💎'];
const toggleReaction = (messageId, emoji) => {
console.log('Toggling reaction:', messageId, emoji);
chatStore.toggleReaction(messageId, emoji);
showEmojiPicker.value = null;
};
const getReactionCount = (reactions, emoji) => {
return reactions?.filter(r => r.emoji === emoji).length || 0;
};
const hasUserReacted = (reactions, emoji) => {
return reactions?.some(r => r.emoji === emoji && r.wallet_address === walletAddress.value);
};
const getUniqueEmojis = (reactions) => {
if (!reactions) return [];
return [...new Set(reactions.map(r => r.emoji))];
};
const scrollToBottom = () => {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight;
}
};
onUpdated(() => {
scrollToBottom();
});
const send = () => {
if (!newMessage.value.trim()) return;
chatStore.sendMessage(newMessage.value);
newMessage.value = '';
nextTick(scrollToBottom);
};
const formatTime = (isoString) => {
return new Date(isoString).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
};
const copyTxId = (txId) => {
navigator.clipboard.writeText(txId);
copiedTxId.value = txId;
setTimeout(() => { copiedTxId.value = null; }, 2000);
};
const emit = defineEmits(['view-profile']);
</script>
<template>
<div class="flex-1 flex flex-col h-full bg-discord-dark relative z-10">
<!-- Header (Desktop only) -->
<div class="hidden md:flex h-12 border-b border-black/20 items-center px-4 shadow-sm bg-discord-dark/95 backdrop-blur-sm">
<div class="text-sm font-bold text-white flex items-center gap-2">
<Hash size="18" class="text-gray-400" />
{{ currentChannel }}
</div>
</div>
<!-- Messages -->
<div
ref="messagesContainer"
class="flex-1 overflow-y-auto p-4 space-y-1 scroll-smooth custom-scrollbar"
>
<!-- Beginning of conversation marker -->
<div class="py-12 px-4 border-b border-white/5 mb-8">
<div class="w-16 h-16 rounded-2xl bg-gradient-to-br from-violet-600 to-indigo-600 flex items-center justify-center text-white mb-4 shadow-xl shadow-violet-600/20">
<Hash size="32" />
</div>
<h2 class="text-3xl font-bold text-white mb-2">
Welcome to #{{ currentChannel }}!
</h2>
<p class="text-gray-400 text-base max-w-md leading-relaxed">
This is the very beginning of the <span class="text-violet-400 font-semibold">#{{ currentChannel }}</span> channel. Use this space to connect, share, and grow with the community.
</p>
</div>
<div
v-for="(msg, index) in currentMessages"
:key="msg.id || msg.tempId"
:class="[
'group flex gap-4 px-4 py-2 transition-all relative rounded-lg',
msg.status === 'failed' ? 'bg-red-500/5 border border-red-500/20' : 'hover:bg-white/[0.02]'
]"
>
<!-- Avatar -->
<div class="w-10 flex-shrink-0">
<div
v-if="index === 0 || currentMessages[index-1].walletAddress !== msg.walletAddress"
class="w-10 h-10 rounded-full flex items-center justify-center text-white font-bold text-sm shadow-lg border border-white/10 mt-1 cursor-pointer hover:opacity-80 transition-opacity"
:class="msg.walletAddress === walletAddress ? 'bg-gradient-to-br from-violet-500 to-fuchsia-600' : 'bg-discord-sidebar'"
@click="emit('view-profile', msg.walletAddress)"
>
{{ msg.username?.substring(0, 2).toUpperCase() }}
</div>
<div
v-else
class="w-10 text-[10px] text-crypto-muted opacity-0 group-hover:opacity-100 text-right pr-2 pt-1.5 transition-opacity"
>
{{ formatTime(msg.timestamp) }}
</div>
</div>
<!-- Content -->
<div class="flex-1 min-w-0">
<div
v-if="index === 0 || currentMessages[index-1].walletAddress !== msg.walletAddress"
class="flex items-center gap-2 mb-1 flex-wrap"
>
<span
:class="['text-sm font-bold hover:underline cursor-pointer', msg.walletAddress === walletAddress ? 'text-violet-400' : 'text-white']"
@click="emit('view-profile', msg.walletAddress)"
>
{{ msg.username }}
</span>
<span class="text-[10px] text-crypto-muted">{{ formatTime(msg.timestamp) }}</span>
</div>
<!-- Message Content & Status -->
<div class="flex items-start gap-2">
<div :class="['text-sm leading-relaxed break-words flex-1', msg.status === 'failed' ? 'text-red-400 line-through' : 'text-gray-100']">
{{ msg.content }}
</div>
<!-- Transaction ID & Status Pill for all messages -->
<div class="flex items-center gap-2 flex-shrink-0 mt-1">
<span
v-if="msg.txId && msg.status !== 'failed'"
class="flex items-center gap-1.5 text-[10px] font-mono text-gray-500 bg-black/20 px-1.5 py-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity"
>
<ExternalLink size="10" />
{{ msg.txId.slice(0, 8) }}
<button
class="hover:text-fuchsia-400 transition-colors"
@click="copyTxId(msg.txId)"
>
<Check v-if="copiedTxId === msg.txId" size="10" class="text-green-400" />
<Copy v-else size="10" />
</button>
</span>
<div
v-if="msg.status"
class="led"
:class="{
'led-orange animate-pulse': msg.status === 'pending',
'led-green': msg.status === 'validated',
'led-red': msg.status === 'failed'
}"
/>
</div>
</div>
<!-- Failed message notice -->
<div v-if="msg.status === 'failed'" class="text-[10px] text-red-400 mt-1 flex items-center gap-1">
Transaction failed - message not saved
</div>
<!-- Reactions Display -->
<div
v-if="msg.reactions && msg.reactions.length > 0"
class="flex flex-wrap gap-1 mt-2"
>
<button
v-for="emoji in getUniqueEmojis(msg.reactions)"
:key="emoji"
:class="['flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs border transition-all',
hasUserReacted(msg.reactions, emoji)
? 'bg-violet-500/20 border-violet-500/50 text-violet-300 shadow-sm shadow-violet-500/20'
: 'bg-white/5 border-white/10 text-gray-400 hover:bg-white/10 hover:border-white/20']"
@click="toggleReaction(msg.id, emoji)"
>
<span>{{ emoji }}</span>
<span class="font-bold">{{ getReactionCount(msg.reactions, emoji) }}</span>
</button>
</div>
</div>
<!-- Hover Actions -->
<div
v-if="msg.status !== 'failed' && msg.id"
class="absolute right-4 -top-4 opacity-0 group-hover:opacity-100 transition-all z-20 flex gap-1 bg-discord-sidebar border border-white/10 rounded-xl p-1.5 shadow-2xl"
>
<button
class="p-2 hover:bg-white/10 rounded-lg text-gray-400 hover:text-white transition-all"
title="Add Reaction"
@click="showEmojiPicker = showEmojiPicker === msg.id ? null : msg.id"
>
<Smile size="16" />
</button>
<!-- Emoji Picker Popover -->
<div
v-if="showEmojiPicker === msg.id"
class="absolute right-0 bottom-full mb-2 bg-discord-sidebar border border-white/10 rounded-xl p-2 shadow-2xl flex gap-1 z-30 animate-pop-in"
>
<button
v-for="emoji in EMOJIS"
:key="emoji"
class="hover:scale-125 transition-transform p-1.5 text-lg hover:bg-white/10 rounded-lg"
@click="toggleReaction(msg.id, emoji)"
>
{{ emoji }}
</button>
</div>
</div>
</div>
</div>
<!-- Input -->
<div class="p-4 bg-discord-dark border-t border-white/5">
<div class="relative bg-discord-sidebar/50 rounded-xl border border-white/5 p-1 transition-all focus-within:border-violet-500/30 focus-within:bg-discord-sidebar/80 focus-within:shadow-lg focus-within:shadow-violet-500/5">
<input
v-model="newMessage"
type="text"
:placeholder="`Message #${currentChannel}`"
class="w-full bg-transparent text-white placeholder-gray-500 py-3 pl-4 pr-12 focus:outline-none"
@keyup.enter="send"
>
<button
class="absolute right-2 top-1/2 -translate-y-1/2 p-2.5 text-gray-400 hover:text-violet-400 hover:bg-violet-600/10 rounded-lg transition-all"
@click="send"
>
<Send size="20" />
</button>
</div>
<div class="mt-3 flex items-center gap-6 px-1">
<div class="flex items-center gap-2 text-[10px] text-gray-500 uppercase tracking-widest font-bold">
<div class="led led-orange w-2 h-2 animate-pulse" /> Pending
</div>
<div class="flex items-center gap-2 text-[10px] text-gray-500 uppercase tracking-widest font-bold">
<div class="led led-green w-2 h-2" /> Validated
</div>
<div class="flex items-center gap-2 text-[10px] text-gray-500 uppercase tracking-widest font-bold">
<div class="led led-red w-2 h-2" /> Failed (not saved)
</div>
</div>
</div>
</div>
</template>
<style scoped>
.animate-pop-in {
animation: pop-in 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
@keyframes pop-in {
0% { transform: scale(0.5); opacity: 0; }
100% { transform: scale(1); opacity: 1; }
}
</style>