big milestone!
This commit is contained in:
parent
2b35d6f39b
commit
ac906ca6c9
28 changed files with 1332 additions and 16 deletions
|
|
@ -79,7 +79,7 @@ define(['./workbox-54d0af47'], (function (workbox) { 'use strict';
|
|||
*/
|
||||
workbox.precacheAndRoute([{
|
||||
"url": "index.html",
|
||||
"revision": "0.6qk4tc3oa08"
|
||||
"revision": "0.0k43usmfr4"
|
||||
}], {});
|
||||
workbox.cleanupOutdatedCaches();
|
||||
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
||||
|
|
|
|||
22
public/js/nostr.bundle.js
Normal file
22
public/js/nostr.bundle.js
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import {
|
||||
getPublicKey,
|
||||
generatePrivateKey,
|
||||
nip04,
|
||||
getEventHash,
|
||||
getSignature,
|
||||
verifySignature,
|
||||
nip19,
|
||||
relayInit
|
||||
} from 'nostr-tools';
|
||||
|
||||
window.NostrTools = {
|
||||
getPublicKey,
|
||||
generatePrivateKey,
|
||||
nip04,
|
||||
getEventHash,
|
||||
getSignature,
|
||||
signEvent: getSignature,
|
||||
verifySignature,
|
||||
nip19,
|
||||
relayInit
|
||||
};
|
||||
278
src/components/ChatBox.vue
Normal file
278
src/components/ChatBox.vue
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed, nextTick, onMounted, watch } from 'vue'
|
||||
import { Send, Copy, ExternalLink, Check, AlertCircle } from 'lucide-vue-next'
|
||||
import { useNostrStore } from '@/stores/nostr'
|
||||
import type { DirectMessage } from '@/types/nostr'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Card, CardContent, CardFooter, CardHeader } from '@/components/ui/card'
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const nostrStore = useNostrStore()
|
||||
const input = ref('')
|
||||
const inputLength = computed(() => input.value.trim().length)
|
||||
const messagesEndRef = ref<HTMLDivElement | null>(null)
|
||||
const isSending = ref(false)
|
||||
|
||||
// Group messages by sender and time
|
||||
interface MessageGroup {
|
||||
sent: boolean
|
||||
messages: DirectMessage[]
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
const groupedMessages = computed<MessageGroup[]>(() => {
|
||||
const groups: MessageGroup[] = []
|
||||
let currentGroup: MessageGroup | null = null
|
||||
|
||||
// Sort messages by timestamp first
|
||||
const sortedMessages = [...nostrStore.currentMessages].sort((a, b) => a.created_at - b.created_at)
|
||||
|
||||
for (const message of sortedMessages) {
|
||||
// Start a new group if:
|
||||
// 1. No current group
|
||||
// 2. Different sender than last message
|
||||
// 3. More than 2 minutes since last message
|
||||
if (!currentGroup ||
|
||||
currentGroup.sent !== message.sent ||
|
||||
message.created_at - currentGroup.messages[currentGroup.messages.length - 1].created_at > 120) {
|
||||
currentGroup = {
|
||||
sent: message.sent,
|
||||
messages: [],
|
||||
timestamp: message.created_at
|
||||
}
|
||||
groups.push(currentGroup)
|
||||
}
|
||||
currentGroup.messages.push(message)
|
||||
}
|
||||
return groups
|
||||
})
|
||||
|
||||
// Scroll to bottom when new messages arrive
|
||||
watch(() => nostrStore.currentMessages.length, () => {
|
||||
nextTick(() => {
|
||||
scrollToBottom()
|
||||
})
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
scrollToBottom()
|
||||
})
|
||||
|
||||
function scrollToBottom() {
|
||||
if (messagesEndRef.value) {
|
||||
messagesEndRef.value.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
}
|
||||
|
||||
const sendMessage = async (event: Event) => {
|
||||
event.preventDefault()
|
||||
if (inputLength.value === 0 || !nostrStore.activeChat || isSending.value) return
|
||||
|
||||
try {
|
||||
isSending.value = true
|
||||
await nostrStore.sendMessage(nostrStore.activeChat, input.value)
|
||||
input.value = ''
|
||||
} finally {
|
||||
isSending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const formatTime = (timestamp: number) => {
|
||||
return new Date(timestamp * 1000).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const formatDate = (timestamp: number) => {
|
||||
const date = new Date(timestamp * 1000)
|
||||
const today = new Date()
|
||||
const yesterday = new Date(today)
|
||||
yesterday.setDate(yesterday.getDate() - 1)
|
||||
|
||||
if (date.toDateString() === today.toDateString()) {
|
||||
return 'Today'
|
||||
} else if (date.toDateString() === yesterday.toDateString()) {
|
||||
return 'Yesterday'
|
||||
}
|
||||
return date.toLocaleDateString()
|
||||
}
|
||||
|
||||
const getMessageGroupClasses = (sent: boolean) => {
|
||||
return [
|
||||
'group flex flex-col gap-0.5 animate-in slide-in-from-bottom-2',
|
||||
sent ? 'items-end' : 'items-start'
|
||||
]
|
||||
}
|
||||
|
||||
const getMessageBubbleClasses = (sent: boolean, isFirst: boolean, isLast: boolean) => {
|
||||
return [
|
||||
'relative flex flex-col break-words min-w-[120px] max-w-[85%] md:max-w-[65%] text-sm',
|
||||
sent
|
||||
? 'bg-gradient-to-br from-[#cba6f7] to-[#b4befe] text-[#11111b] hover:brightness-105' // Gradient from Mauve to Lavender
|
||||
: 'bg-gradient-to-br from-[#313244] to-[#45475a] text-[#cdd6f4] hover:brightness-105', // Gradient from Surface0 to Surface1
|
||||
// First message in group
|
||||
isFirst && (sent ? 'rounded-t-2xl rounded-l-2xl' : 'rounded-t-2xl rounded-r-2xl'),
|
||||
// Last message in group
|
||||
isLast && (sent ? 'rounded-b-2xl rounded-l-2xl' : 'rounded-b-2xl rounded-r-2xl'),
|
||||
// Single message
|
||||
isFirst && isLast && 'rounded-2xl',
|
||||
// Middle messages
|
||||
!isFirst && !isLast && (sent ? 'rounded-l-2xl' : 'rounded-r-2xl'),
|
||||
// Add shadow and hover effect
|
||||
'shadow-lg hover:shadow-xl transition-all duration-300 ease-out'
|
||||
]
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card
|
||||
class="flex flex-col h-[calc(100vh-2rem)] bg-gradient-to-b from-[#1e1e2e] to-[#181825] border-[#313244] shadow-2xl overflow-hidden relative z-0">
|
||||
<CardHeader
|
||||
class="flex-shrink-0 flex flex-row items-center justify-between px-6 py-4 border-b border-[#313244]/50 bg-[#181825]/95 backdrop-blur-md relative z-50">
|
||||
<!-- Left side with avatar and name -->
|
||||
<div class="flex items-center gap-5 flex-shrink-0">
|
||||
<div class="relative group">
|
||||
<div
|
||||
class="absolute -inset-0.5 bg-gradient-to-r from-[#cba6f7] to-[#89b4fa] rounded-full opacity-75 group-hover:opacity-100 blur transition duration-200">
|
||||
</div>
|
||||
<Avatar
|
||||
class="relative h-11 w-11 bg-[#313244] ring-2 ring-[#cba6f7] ring-offset-2 ring-offset-[#181825] shadow-md transition-all duration-200 hover:shadow-lg group-hover:scale-105">
|
||||
<AvatarFallback class="text-base font-semibold text-[#cdd6f4]">SA</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
<div class="hidden sm:block">
|
||||
<p class="font-semibold leading-none text-[#cdd6f4] tracking-tight">Support Agent</p>
|
||||
<div class="flex items-center gap-1.5 mt-1.5">
|
||||
<div class="w-2 h-2 rounded-full bg-[#a6e3a1] animate-pulse"></div>
|
||||
<p class="text-sm text-[#a6adc8]">Online</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Center badge -->
|
||||
<div class="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||
<div
|
||||
class="text-xs font-medium text-[#cdd6f4] bg-gradient-to-r from-[#313244]/80 to-[#45475a]/80 backdrop-blur-sm px-4 py-2 rounded-full whitespace-nowrap shadow-lg border border-[#45475a]/50">
|
||||
Customer Support
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right side spacer to maintain layout balance -->
|
||||
<div class="flex items-center gap-5 flex-shrink-0 invisible">
|
||||
<div class="h-11 w-11"></div>
|
||||
<div class="hidden sm:block">
|
||||
<div class="h-5"></div>
|
||||
<div class="h-5 mt-1.5"></div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="flex-1 min-h-0 p-0 bg-gradient-to-b from-[#1e1e2e] to-[#181825] overflow-hidden">
|
||||
<ScrollArea class="h-full" type="hover">
|
||||
<div class="flex flex-col gap-4 p-6">
|
||||
<template v-for="(group, groupIndex) in groupedMessages" :key="groupIndex">
|
||||
<!-- Date separator -->
|
||||
<div v-if="groupIndex === 0 ||
|
||||
formatDate(group.timestamp) !== formatDate(groupedMessages[groupIndex - 1].timestamp)"
|
||||
class="flex justify-center my-8">
|
||||
<div
|
||||
class="px-4 py-1.5 rounded-full bg-gradient-to-r from-[#313244]/30 to-[#45475a]/30 text-xs font-medium text-[#a6adc8] shadow-lg backdrop-blur-sm border border-[#45475a]/20">
|
||||
{{ formatDate(group.timestamp) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Message group -->
|
||||
<div :class="getMessageGroupClasses(group.sent)" class="w-full">
|
||||
<div class="flex flex-col gap-[3px] w-full" :class="{ 'items-end': group.sent }">
|
||||
<div v-for="(message, messageIndex) in group.messages" :key="message.id" :class="[
|
||||
getMessageBubbleClasses(
|
||||
group.sent,
|
||||
messageIndex === 0,
|
||||
messageIndex === group.messages.length - 1
|
||||
),
|
||||
'w-fit backdrop-blur-sm'
|
||||
]">
|
||||
<div class="p-3">
|
||||
{{ message.content }}
|
||||
</div>
|
||||
<div class="px-3 pb-1.5 text-[10px] opacity-50">
|
||||
{{ formatTime(message.created_at) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div ref="messagesEndRef" />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter class="flex-shrink-0 p-4 bg-[#181825]/95 backdrop-blur-md border-t border-[#313244]/50">
|
||||
<form @submit="sendMessage" class="flex items-center gap-2 w-full">
|
||||
<Input
|
||||
v-model="input"
|
||||
type="text"
|
||||
placeholder="Type your message..."
|
||||
class="flex-1 bg-[#1e1e2e] border-[#313244] text-[#cdd6f4] placeholder:text-[#6c7086] focus:ring-2 focus:ring-[#cba6f7] focus:border-[#cba6f7] transition-all duration-300"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
:disabled="inputLength === 0 || isSending"
|
||||
class="bg-gradient-to-r from-[#cba6f7] to-[#89b4fa] text-[#11111b] hover:brightness-110 active:brightness-90 transition-all duration-300 shadow-lg hover:shadow-xl"
|
||||
>
|
||||
<Send class="h-4 w-4" />
|
||||
</Button>
|
||||
</form>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.animate-in {
|
||||
animation: animate-in 0.5s cubic-bezier(0.21, 1.02, 0.73, 1);
|
||||
}
|
||||
|
||||
@keyframes animate-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px) scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Improved focus styles */
|
||||
:focus-visible {
|
||||
outline: 2px solid #cba6f7;
|
||||
outline-offset: 2px;
|
||||
transition: outline-offset 0.2s ease;
|
||||
}
|
||||
|
||||
/* Enhanced button hover states */
|
||||
button:not(:disabled):hover {
|
||||
transform: translateY(-1px) scale(1.01);
|
||||
}
|
||||
|
||||
button:not(:disabled):active {
|
||||
transform: translateY(0) scale(0.99);
|
||||
}
|
||||
|
||||
/* Smooth transitions */
|
||||
* {
|
||||
transition-property: background-color, border-color, color, fill, stroke, opacity, box-shadow, transform, filter;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 300ms;
|
||||
}
|
||||
|
||||
/* Glass morphism effects */
|
||||
.backdrop-blur-sm {
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
}
|
||||
</style>
|
||||
144
src/components/Login.vue
Normal file
144
src/components/Login.vue
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
<template>
|
||||
<div class="flex items-center justify-center min-h-screen p-4 bg-gradient-to-b from-[#1e1e2e] to-[#181825]">
|
||||
<Card class="w-full max-w-md animate-in slide-in-from-bottom duration-500 bg-[#181825] border-[#313244] shadow-2xl">
|
||||
<CardHeader class="space-y-6 pb-8">
|
||||
<div class="flex justify-center">
|
||||
<div class="relative group">
|
||||
<div class="absolute -inset-1 bg-gradient-to-r from-[#cba6f7] to-[#89b4fa] rounded-full opacity-75 group-hover:opacity-100 blur transition duration-300"></div>
|
||||
<div class="relative h-16 w-16 rounded-full bg-gradient-to-br from-[#313244] to-[#45475a] flex items-center justify-center shadow-xl group-hover:shadow-2xl transition duration-300">
|
||||
<KeyRound class="h-8 w-8 text-[#cdd6f4] group-hover:scale-110 transition duration-300" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center space-y-2.5">
|
||||
<CardTitle class="text-2xl font-bold bg-gradient-to-r from-[#cba6f7] to-[#89b4fa] inline-block text-transparent bg-clip-text">
|
||||
Nostr Customer Support
|
||||
</CardTitle>
|
||||
<CardDescription class="text-[#a6adc8]">
|
||||
Login with your Nostr private key or generate a new one
|
||||
</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form @submit.prevent="handleLogin" class="space-y-6">
|
||||
<div class="space-y-4">
|
||||
<div class="relative group">
|
||||
<div class="absolute -inset-0.5 bg-gradient-to-r from-[#313244] to-[#45475a] rounded-xl opacity-75 group-hover:opacity-100 blur transition duration-300"></div>
|
||||
<Input
|
||||
v-model="privkey"
|
||||
type="password"
|
||||
placeholder="Enter your private key"
|
||||
class="relative font-mono text-sm bg-[#1e1e2e] border-[#313244] text-[#cdd6f4] placeholder:text-[#6c7086] focus:ring-2 focus:ring-[#cba6f7] focus:border-[#cba6f7] transition-all duration-300 shadow-lg hover:border-[#45475a] rounded-lg h-11"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<Button
|
||||
type="submit"
|
||||
class="w-full bg-gradient-to-r from-[#cba6f7] to-[#89b4fa] text-[#11111b] hover:brightness-110 active:brightness-90 transition-all duration-300 shadow-lg hover:shadow-xl h-11 rounded-lg font-medium"
|
||||
:disabled="!privkey"
|
||||
>
|
||||
<Key class="h-4 w-4 mr-2" />
|
||||
Login Securely
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@click="generateNewKey"
|
||||
class="w-full bg-gradient-to-r from-[#313244]/90 to-[#45475a]/90 hover:brightness-110 active:brightness-90 text-[#cdd6f4] border-[#45475a]/50 transition-all duration-300 shadow-lg hover:shadow-xl h-11 rounded-lg font-medium backdrop-blur-sm"
|
||||
:disabled="isGenerating"
|
||||
>
|
||||
<template v-if="isGenerating">
|
||||
<div class="h-4 w-4 mr-2 animate-spin rounded-full border-2 border-[#cba6f7] border-r-transparent" />
|
||||
Generating...
|
||||
</template>
|
||||
<template v-else>
|
||||
<ShieldCheck class="h-4 w-4 mr-2" />
|
||||
Generate New Key
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
<CardFooter class="text-center text-sm text-[#6c7086] pb-6">
|
||||
<p class="max-w-[280px] mx-auto">
|
||||
Make sure to save your private key in a secure location. You'll need it to access your chat history.
|
||||
</p>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useNostrStore } from '@/stores/nostr'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from '@/components/ui/card'
|
||||
import { generatePrivateKey } from '@/lib/nostr'
|
||||
import { Key, KeyRound, ShieldCheck } from 'lucide-vue-next'
|
||||
|
||||
const nostrStore = useNostrStore()
|
||||
const privkey = ref('')
|
||||
const isGenerating = ref(false)
|
||||
|
||||
const generateNewKey = async () => {
|
||||
isGenerating.value = true
|
||||
try {
|
||||
privkey.value = generatePrivateKey()
|
||||
} finally {
|
||||
isGenerating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!privkey.value) return
|
||||
await nostrStore.login(privkey.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.animate-in {
|
||||
animation: animate-in 0.5s cubic-bezier(0.21, 1.02, 0.73, 1);
|
||||
}
|
||||
|
||||
@keyframes animate-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px) scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Improved focus styles */
|
||||
:focus-visible {
|
||||
outline: 2px solid #cba6f7;
|
||||
outline-offset: 2px;
|
||||
transition: outline-offset 0.2s ease;
|
||||
}
|
||||
|
||||
/* Enhanced button hover states */
|
||||
button:not(:disabled):hover {
|
||||
transform: translateY(-1px) scale(1.01);
|
||||
}
|
||||
|
||||
button:not(:disabled):active {
|
||||
transform: translateY(0) scale(0.99);
|
||||
}
|
||||
|
||||
/* Smooth transitions */
|
||||
* {
|
||||
transition-property: background-color, border-color, color, fill, stroke, opacity, box-shadow, transform, filter;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 300ms;
|
||||
}
|
||||
|
||||
/* Glass morphism effects */
|
||||
.backdrop-blur-sm {
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
}
|
||||
</style>
|
||||
124
src/components/SupportChat.vue
Normal file
124
src/components/SupportChat.vue
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useNostrStore } from '@/stores/nostr'
|
||||
import { npubToHex } from '@/lib/nostr'
|
||||
import { Card, CardContent, CardFooter, CardHeader } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
|
||||
import { Send, AlertCircle } from 'lucide-vue-next'
|
||||
import ChatBox from './ChatBox.vue'
|
||||
|
||||
const nostrStore = useNostrStore()
|
||||
const input = ref('')
|
||||
const isSending = ref(false)
|
||||
const error = ref('')
|
||||
const SUPPORT_NPUB = import.meta.env.VITE_SUPPORT_NPUB
|
||||
|
||||
if (!SUPPORT_NPUB) {
|
||||
error.value = 'Support public key not configured'
|
||||
}
|
||||
|
||||
const inputLength = computed(() => input.value.trim().length)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
if (!SUPPORT_NPUB) return
|
||||
|
||||
const supportPubkeyHex = npubToHex(SUPPORT_NPUB)
|
||||
nostrStore.activeChat = supportPubkeyHex
|
||||
await nostrStore.subscribeToMessages()
|
||||
} catch (err) {
|
||||
console.error('Failed to initialize support chat:', err)
|
||||
error.value = 'Failed to connect to support. Please try again later.'
|
||||
}
|
||||
})
|
||||
|
||||
const sendMessage = async (event: Event) => {
|
||||
event.preventDefault()
|
||||
if (inputLength.value === 0 || !nostrStore.activeChat || isSending.value) return
|
||||
|
||||
try {
|
||||
isSending.value = true
|
||||
await nostrStore.sendMessage(nostrStore.activeChat, input.value)
|
||||
input.value = ''
|
||||
error.value = ''
|
||||
} catch (err) {
|
||||
console.error('Failed to send message:', err)
|
||||
error.value = 'Failed to send message. Please try again.'
|
||||
} finally {
|
||||
isSending.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card class="flex flex-col h-[calc(100vh-8rem)] bg-card">
|
||||
<!-- Header -->
|
||||
<CardHeader class="flex-shrink-0 flex flex-row items-center space-x-4 px-6 py-4">
|
||||
<div class="flex items-center space-x-4">
|
||||
<Avatar class="h-10 w-10">
|
||||
<AvatarFallback class="bg-primary/10 text-primary">SA</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<h3 class="font-semibold leading-none">Support Agent</h3>
|
||||
<p class="text-sm text-muted-foreground mt-1">Here to help you</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<!-- Chat Area -->
|
||||
<CardContent class="flex-1 p-0 overflow-hidden">
|
||||
<ScrollArea class="h-full" type="hover">
|
||||
<div class="p-6">
|
||||
<!-- Error Message -->
|
||||
<div v-if="error" class="mb-4 p-4 rounded-lg bg-destructive/10 text-destructive flex items-center space-x-2">
|
||||
<AlertCircle class="h-4 w-4" />
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Chat Messages -->
|
||||
<ChatBox />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
|
||||
<!-- Input Area -->
|
||||
<CardFooter class="flex-shrink-0 p-4">
|
||||
<form @submit="sendMessage" class="flex w-full space-x-2">
|
||||
<Input
|
||||
v-model="input"
|
||||
type="text"
|
||||
placeholder="Type your message..."
|
||||
:disabled="!!error || isSending"
|
||||
class="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
:disabled="inputLength === 0 || !!error || isSending"
|
||||
class="w-10 h-10 p-0"
|
||||
>
|
||||
<Send class="h-4 w-4" />
|
||||
</Button>
|
||||
</form>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.animate-in {
|
||||
animation: animate-in 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes animate-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Menu, X, Sun, Moon, Zap } from 'lucide-vue-next'
|
||||
import { Menu, X, Sun, Moon, Zap, MessageSquareText } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useTheme } from '@/components/theme-provider'
|
||||
|
||||
|
|
@ -13,6 +13,7 @@ const navigation = computed(() => [
|
|||
{ name: t('nav.home'), href: '/' },
|
||||
{ name: t('nav.directory'), href: '/directory' },
|
||||
{ name: t('nav.faq'), href: '/faq' },
|
||||
{ name: t('nav.support'), href: '/support', icon: MessageSquareText },
|
||||
])
|
||||
|
||||
const toggleMenu = () => {
|
||||
|
|
@ -45,9 +46,10 @@ const toggleLocale = () => {
|
|||
<!-- Desktop Navigation -->
|
||||
<div class="hidden md:flex gap-4">
|
||||
<router-link v-for="item in navigation" :key="item.name" :to="item.href"
|
||||
class="text-muted-foreground hover:text-foreground transition-colors" :class="{
|
||||
class="text-muted-foreground hover:text-foreground transition-colors flex items-center gap-2" :class="{
|
||||
'text-foreground': $route.path === item.href
|
||||
}">
|
||||
<component v-if="item.icon" :is="item.icon" class="h-4 w-4" />
|
||||
{{ item.name }}
|
||||
</router-link>
|
||||
</div>
|
||||
|
|
@ -80,10 +82,11 @@ const toggleLocale = () => {
|
|||
[@supports(backdrop-filter:blur(24px))]:bg-background/95 [@supports(backdrop-filter:blur(24px))]:backdrop-blur-sm">
|
||||
<div class="container space-y-1 py-3">
|
||||
<router-link v-for="item in navigation" :key="item.name" :to="item.href"
|
||||
class="block px-3 py-2 text-base font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||
class="block px-3 py-2 text-base font-medium text-muted-foreground hover:text-foreground transition-colors flex items-center gap-2"
|
||||
:class="{
|
||||
'text-foreground': $route.path === item.href
|
||||
}" @click="isOpen = false">
|
||||
<component v-if="item.icon" :is="item.icon" class="h-4 w-4" />
|
||||
{{ item.name }}
|
||||
</router-link>
|
||||
</div>
|
||||
|
|
|
|||
16
src/components/ui/avatar/Avatar.vue
Normal file
16
src/components/ui/avatar/Avatar.vue
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="cn('relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
16
src/components/ui/avatar/AvatarFallback.vue
Normal file
16
src/components/ui/avatar/AvatarFallback.vue
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="cn('flex h-full w-full items-center justify-center rounded-full bg-muted', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
18
src/components/ui/avatar/AvatarImage.vue
Normal file
18
src/components/ui/avatar/AvatarImage.vue
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
src?: string
|
||||
alt?: string
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<img
|
||||
:src="src"
|
||||
:alt="alt"
|
||||
:class="cn('aspect-square h-full w-full', props.class)"
|
||||
/>
|
||||
</template>
|
||||
3
src/components/ui/avatar/index.ts
Normal file
3
src/components/ui/avatar/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { default as Avatar } from './Avatar.vue'
|
||||
export { default as AvatarImage } from './AvatarImage.vue'
|
||||
export { default as AvatarFallback } from './AvatarFallback.vue'
|
||||
|
|
@ -9,12 +9,7 @@ const props = defineProps<{
|
|||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'rounded-lg border bg-card text-card-foreground shadow-sm',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
:class="cn('rounded-lg border bg-card text-card-foreground shadow-sm', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,11 @@ const props = defineProps<{
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('p-6 pt-0', props.class)">
|
||||
<div
|
||||
:class="
|
||||
cn('p-6 pt-0', props.class)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,11 @@ const props = defineProps<{
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<p :class="cn('text-sm text-muted-foreground', props.class)">
|
||||
<p
|
||||
:class="
|
||||
cn('text-sm text-muted-foreground', props.class)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</p>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,11 @@ const props = defineProps<{
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('flex items-center p-6 pt-0', props.class)">
|
||||
<div
|
||||
:class="
|
||||
cn('flex items-center p-6 pt-0', props.class)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,11 @@ const props = defineProps<{
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('flex flex-col gap-y-1.5 p-6', props.class)">
|
||||
<div
|
||||
:class="
|
||||
cn('flex flex-col space-y-1.5 p-6', props.class)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
8
src/components/ui/label/index.ts
Normal file
8
src/components/ui/label/index.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { Label as LabelPrimitive } from 'radix-vue'
|
||||
import { tv } from 'tailwind-variants'
|
||||
|
||||
export const labelVariants = tv({
|
||||
base: 'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
|
||||
})
|
||||
|
||||
export const Label = LabelPrimitive.Root
|
||||
29
src/components/ui/scroll-area/ScrollArea.vue
Normal file
29
src/components/ui/scroll-area/ScrollArea.vue
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
ScrollAreaCorner,
|
||||
ScrollAreaRoot,
|
||||
type ScrollAreaRootProps,
|
||||
ScrollAreaViewport,
|
||||
} from 'radix-vue'
|
||||
import { computed, type HTMLAttributes } from 'vue'
|
||||
import ScrollBar from './ScrollBar.vue'
|
||||
|
||||
const props = defineProps<ScrollAreaRootProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ScrollAreaRoot v-bind="delegatedProps" :class="cn('relative overflow-hidden', props.class)">
|
||||
<ScrollAreaViewport class="h-full w-full rounded-[inherit]">
|
||||
<slot />
|
||||
</ScrollAreaViewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaCorner />
|
||||
</ScrollAreaRoot>
|
||||
</template>
|
||||
42
src/components/ui/scroll-area/ScrollBar.vue
Normal file
42
src/components/ui/scroll-area/ScrollBar.vue
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
ScrollAreaScrollbar,
|
||||
ScrollAreaThumb,
|
||||
type ScrollAreaScrollbarProps,
|
||||
} from 'radix-vue'
|
||||
import { computed, type HTMLAttributes } from 'vue'
|
||||
|
||||
const props = defineProps<
|
||||
ScrollAreaScrollbarProps & {
|
||||
class?: HTMLAttributes['class']
|
||||
}
|
||||
>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ScrollAreaScrollbar
|
||||
v-bind="delegatedProps"
|
||||
:class="cn(
|
||||
'flex touch-none select-none transition-colors',
|
||||
props.orientation === 'vertical' &&
|
||||
'h-full w-2.5 border-l border-l-transparent p-[1px]',
|
||||
props.orientation === 'horizontal' &&
|
||||
'h-2.5 border-t border-t-transparent p-[1px]',
|
||||
props.class
|
||||
)"
|
||||
>
|
||||
<ScrollAreaThumb
|
||||
:class="cn(
|
||||
'relative rounded-full bg-border',
|
||||
props.orientation === 'vertical' && 'flex-1'
|
||||
)"
|
||||
/>
|
||||
</ScrollAreaScrollbar>
|
||||
</template>
|
||||
1
src/components/ui/scroll-area/index.ts
Normal file
1
src/components/ui/scroll-area/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default as ScrollArea } from './ScrollArea.vue'
|
||||
|
|
@ -1,9 +1,10 @@
|
|||
export default {
|
||||
nav: {
|
||||
title: 'Atitlán Directory',
|
||||
home: 'Home',
|
||||
directory: 'Directory',
|
||||
faq: 'FAQ',
|
||||
title: 'Atitlán Directory'
|
||||
support: 'Support'
|
||||
},
|
||||
home: {
|
||||
title: 'Find Bitcoin Lightning Acceptors',
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
export default {
|
||||
nav: {
|
||||
title: 'Directorio Atitlán',
|
||||
home: 'Inicio',
|
||||
directory: 'Directorio',
|
||||
faq: 'Preguntas',
|
||||
title: 'Directorio Atitlán'
|
||||
support: 'Soporte'
|
||||
},
|
||||
home: {
|
||||
title: 'Encuentra Aceptadores de Bitcoin Lightning',
|
||||
|
|
|
|||
67
src/lib/nostr-bundle.ts
Normal file
67
src/lib/nostr-bundle.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import {
|
||||
getPublicKey,
|
||||
generateSecretKey,
|
||||
nip04,
|
||||
getEventHash,
|
||||
finalizeEvent,
|
||||
validateEvent,
|
||||
nip19,
|
||||
SimplePool,
|
||||
type Event,
|
||||
type Filter
|
||||
} from 'nostr-tools';
|
||||
import { bytesToHex, hexToBytes } from '@noble/hashes/utils';
|
||||
|
||||
// Expose NostrTools to the window object
|
||||
(window as any).NostrTools = {
|
||||
getPublicKey,
|
||||
generatePrivateKey: () => bytesToHex(generateSecretKey()),
|
||||
nip04,
|
||||
getEventHash,
|
||||
getSignature: (event: Event, privateKey: string) => {
|
||||
const signedEvent = finalizeEvent(event, hexToBytes(privateKey));
|
||||
return signedEvent.sig;
|
||||
},
|
||||
signEvent: (event: Event, privateKey: string) => {
|
||||
const signedEvent = finalizeEvent(event, hexToBytes(privateKey));
|
||||
return signedEvent.sig;
|
||||
},
|
||||
verifySignature: validateEvent,
|
||||
nip19,
|
||||
relayInit: (url: string) => {
|
||||
const pool = new SimplePool();
|
||||
return {
|
||||
connect: async () => {
|
||||
await pool.ensureRelay(url);
|
||||
return true;
|
||||
},
|
||||
sub: (filters: Filter[]) => {
|
||||
return {
|
||||
on: (type: string, callback: (event: Event) => void) => {
|
||||
if (type === 'event') {
|
||||
void pool.subscribeMany(
|
||||
[url],
|
||||
filters,
|
||||
{ onevent: callback }
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
publish: (event: Event) => {
|
||||
return {
|
||||
on: (type: string, cb: (msg?: string) => void) => {
|
||||
if (type === 'ok') {
|
||||
Promise.all(pool.publish([url], event))
|
||||
.then(() => cb())
|
||||
.catch((err: Error) => cb(err.message));
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
close: () => {
|
||||
pool.close([url]);
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
108
src/lib/nostr.ts
Normal file
108
src/lib/nostr.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import type { NostrEvent, NostrRelayConfig } from '../types/nostr'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
NostrTools: {
|
||||
getPublicKey: (privkey: string) => string
|
||||
generatePrivateKey: () => string
|
||||
nip04: {
|
||||
encrypt: (privkey: string, pubkey: string, content: string) => Promise<string>
|
||||
decrypt: (privkey: string, pubkey: string, content: string) => Promise<string>
|
||||
}
|
||||
getEventHash: (event: NostrEvent) => string
|
||||
signEvent: (event: NostrEvent, privkey: string) => Promise<string>
|
||||
getSignature: (event: NostrEvent, privkey: string) => string
|
||||
verifySignature: (event: NostrEvent) => boolean
|
||||
nip19: {
|
||||
decode: (str: string) => { type: string; data: string }
|
||||
npubEncode: (hex: string) => string
|
||||
}
|
||||
relayInit: (url: string) => {
|
||||
connect: () => Promise<void>
|
||||
sub: (filters: any[]) => {
|
||||
on: (event: string, callback: (event: NostrEvent) => void) => void
|
||||
}
|
||||
publish: (event: NostrEvent) => {
|
||||
on: (type: 'ok' | 'failed', cb: (msg?: string) => void) => void
|
||||
}
|
||||
close: () => void
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function connectToRelay(url: string) {
|
||||
const relay = window.NostrTools.relayInit(url)
|
||||
try {
|
||||
await relay.connect()
|
||||
return relay
|
||||
} catch (err) {
|
||||
console.error(`Failed to connect to ${url}:`, err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function publishEvent(event: NostrEvent, relays: NostrRelayConfig[]) {
|
||||
const connectedRelays = await Promise.all(
|
||||
relays.map(relay => connectToRelay(relay.url))
|
||||
)
|
||||
|
||||
const activeRelays = connectedRelays.filter(relay => relay !== null)
|
||||
|
||||
return Promise.all(
|
||||
activeRelays.map(relay =>
|
||||
new Promise((resolve) => {
|
||||
const pub = relay.publish(event)
|
||||
pub.on('ok', () => {
|
||||
resolve(true)
|
||||
})
|
||||
pub.on('failed', () => {
|
||||
resolve(false)
|
||||
})
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export async function encryptMessage(privkey: string, pubkey: string, content: string): Promise<string> {
|
||||
return await window.NostrTools.nip04.encrypt(privkey, pubkey, content)
|
||||
}
|
||||
|
||||
export async function decryptMessage(privkey: string, pubkey: string, content: string): Promise<string> {
|
||||
return await window.NostrTools.nip04.decrypt(privkey, pubkey, content)
|
||||
}
|
||||
|
||||
export function generatePrivateKey(): string {
|
||||
return window.NostrTools.generatePrivateKey()
|
||||
}
|
||||
|
||||
export function getPublicKey(privateKey: string): string {
|
||||
return window.NostrTools.getPublicKey(privateKey)
|
||||
}
|
||||
|
||||
export function getEventHash(event: NostrEvent): string {
|
||||
return window.NostrTools.getEventHash(event)
|
||||
}
|
||||
|
||||
export async function signEvent(event: NostrEvent, privateKey: string): Promise<string> {
|
||||
return window.NostrTools.getSignature(event, privateKey)
|
||||
}
|
||||
|
||||
export function verifySignature(event: NostrEvent): boolean {
|
||||
return window.NostrTools.verifySignature(event)
|
||||
}
|
||||
|
||||
export function npubToHex(npub: string): string {
|
||||
try {
|
||||
const { type, data } = window.NostrTools.nip19.decode(npub)
|
||||
if (type !== 'npub') throw new Error('Invalid npub')
|
||||
return data
|
||||
} catch (err) {
|
||||
console.error('Failed to decode npub:', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
export function hexToNpub(hex: string): string {
|
||||
return window.NostrTools.nip19.npubEncode(hex)
|
||||
}
|
||||
|
|
@ -1,13 +1,18 @@
|
|||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import { i18n } from './i18n'
|
||||
import './assets/index.css'
|
||||
import { registerSW } from 'virtual:pwa-register'
|
||||
import './lib/nostr-bundle'
|
||||
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
|
||||
app.use(router)
|
||||
app.use(i18n)
|
||||
app.use(pinia)
|
||||
|
||||
// Simple periodic service worker updates
|
||||
const intervalMS = 60 * 60 * 1000 // 1 hour
|
||||
|
|
|
|||
51
src/pages/Support.vue
Normal file
51
src/pages/Support.vue
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<script setup lang="ts">
|
||||
import { useNostrStore } from '@/stores/nostr'
|
||||
import SupportChat from '@/components/SupportChat.vue'
|
||||
import Login from '@/components/Login.vue'
|
||||
|
||||
const nostrStore = useNostrStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container max-w-4xl mx-auto py-8 px-4 sm:px-6 lg:px-8">
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col space-y-1.5">
|
||||
<h2 class="text-2xl font-semibold tracking-tight">Customer Support</h2>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Chat with our support team. We're here to help!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="animate-in fade-in-50 slide-in-from-bottom-3">
|
||||
<Login v-if="!nostrStore.isLoggedIn" />
|
||||
<SupportChat v-else />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.animate-in {
|
||||
animation-duration: 0.3s;
|
||||
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
.fade-in-50 {
|
||||
animation-name: fade-in-50;
|
||||
}
|
||||
|
||||
.slide-in-from-bottom-3 {
|
||||
animation-name: slide-in-from-bottom-3;
|
||||
}
|
||||
|
||||
@keyframes fade-in-50 {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slide-in-from-bottom-3 {
|
||||
from { transform: translateY(3px); }
|
||||
to { transform: translateY(0); }
|
||||
}
|
||||
</style>
|
||||
|
|
@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router'
|
|||
import Home from '@/pages/Home.vue'
|
||||
import Directory from '@/pages/Directory.vue'
|
||||
import FAQ from '@/pages/FAQ.vue'
|
||||
import Support from '@/pages/Support.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
|
|
@ -26,6 +27,11 @@ const router = createRouter({
|
|||
name: 'directory-item',
|
||||
component: () => import('@/pages/DirectoryItem.vue'),
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: '/support',
|
||||
name: 'support',
|
||||
component: Support
|
||||
}
|
||||
]
|
||||
})
|
||||
|
|
|
|||
325
src/stores/nostr.ts
Normal file
325
src/stores/nostr.ts
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import type { NostrEvent, NostrProfile, NostrAccount, DirectMessage } from '../types/nostr'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
NostrTools: {
|
||||
getPublicKey: (privkey: string) => string
|
||||
generatePrivateKey: () => string
|
||||
nip04: {
|
||||
encrypt: (privkey: string, pubkey: string, content: string) => Promise<string>
|
||||
decrypt: (privkey: string, pubkey: string, content: string) => Promise<string>
|
||||
}
|
||||
getEventHash: (event: NostrEvent) => string
|
||||
signEvent: (event: NostrEvent, privkey: string) => Promise<string>
|
||||
getSignature: (event: NostrEvent, privkey: string) => string
|
||||
verifySignature: (event: NostrEvent) => boolean
|
||||
nip19: {
|
||||
decode: (str: string) => { type: string; data: string }
|
||||
npubEncode: (hex: string) => string
|
||||
}
|
||||
relayInit: (url: string) => {
|
||||
connect: () => Promise<void>
|
||||
sub: (filters: any[]) => {
|
||||
on: (event: string, callback: (event: NostrEvent) => void) => void
|
||||
}
|
||||
publish: (event: NostrEvent) => {
|
||||
on: (type: 'ok' | 'failed', cb: (msg?: string) => void) => void
|
||||
}
|
||||
close: () => void
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_RELAYS = [
|
||||
'wss://nostr.atitlan.io'
|
||||
]
|
||||
|
||||
// Get support agent's public key from environment variable
|
||||
const SUPPORT_NPUB = import.meta.env.VITE_SUPPORT_NPUB
|
||||
|
||||
// Helper functions
|
||||
async function connectToRelay(url: string) {
|
||||
const relay = window.NostrTools.relayInit(url)
|
||||
try {
|
||||
await relay.connect()
|
||||
return relay
|
||||
} catch (err) {
|
||||
console.error(`Failed to connect to ${url}:`, err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function publishEvent(event: NostrEvent, relays: { url: string }[]) {
|
||||
const promises = relays.map(async ({ url }) => {
|
||||
const relay = window.NostrTools.relayInit(url)
|
||||
try {
|
||||
await relay.connect()
|
||||
const pub = relay.publish(event)
|
||||
return new Promise((resolve, reject) => {
|
||||
pub.on('ok', () => resolve(true))
|
||||
pub.on('failed', reject)
|
||||
})
|
||||
} catch (err) {
|
||||
console.error(`Failed to publish to ${url}:`, err)
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
await Promise.all(promises)
|
||||
}
|
||||
|
||||
export const useNostrStore = defineStore('nostr', () => {
|
||||
// State
|
||||
const account = ref<NostrAccount | null>(JSON.parse(localStorage.getItem('nostr_account') || 'null'))
|
||||
const profiles = ref<Map<string, NostrProfile>>(new Map())
|
||||
const messages = ref<Map<string, DirectMessage[]>>(new Map())
|
||||
const activeChat = ref<string | null>(null)
|
||||
const relayPool = ref<any[]>([])
|
||||
const processedMessageIds = ref(new Set<string>())
|
||||
|
||||
// Watch account changes and persist to localStorage
|
||||
watch(account, (newAccount) => {
|
||||
if (newAccount) {
|
||||
localStorage.setItem('nostr_account', JSON.stringify(newAccount))
|
||||
} else {
|
||||
localStorage.removeItem('nostr_account')
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
// Computed
|
||||
const isLoggedIn = computed(() => !!account.value)
|
||||
const currentMessages = computed(() =>
|
||||
activeChat.value ? messages.value.get(activeChat.value) || [] : []
|
||||
)
|
||||
|
||||
// Initialize connection if account exists
|
||||
async function init() {
|
||||
if (account.value) {
|
||||
// Clear existing state
|
||||
messages.value.clear()
|
||||
profiles.value.clear()
|
||||
processedMessageIds.value.clear()
|
||||
|
||||
// Close existing connections
|
||||
relayPool.value.forEach(relay => relay.close())
|
||||
relayPool.value = []
|
||||
|
||||
// Connect to relays
|
||||
const connectedRelays = await Promise.all(
|
||||
account.value.relays.map(relay => connectToRelay(relay.url))
|
||||
)
|
||||
|
||||
relayPool.value = connectedRelays.filter(relay => relay !== null)
|
||||
|
||||
// Subscribe to messages and load history
|
||||
await Promise.all([
|
||||
subscribeToMessages(),
|
||||
loadProfiles()
|
||||
])
|
||||
|
||||
// Set active chat to support agent
|
||||
activeChat.value = SUPPORT_NPUB
|
||||
}
|
||||
}
|
||||
|
||||
// Actions
|
||||
async function login(privkey: string) {
|
||||
const pubkey = window.NostrTools.getPublicKey(privkey)
|
||||
|
||||
account.value = {
|
||||
pubkey,
|
||||
privkey,
|
||||
relays: DEFAULT_RELAYS.map(url => ({ url, read: true, write: true }))
|
||||
}
|
||||
|
||||
// Initialize connection and load messages
|
||||
await init()
|
||||
}
|
||||
|
||||
async function loadProfiles() {
|
||||
if (!account.value) return
|
||||
|
||||
const filter = {
|
||||
kinds: [0],
|
||||
authors: Array.from(messages.value.keys())
|
||||
}
|
||||
|
||||
if (filter.authors.length === 0) return
|
||||
|
||||
relayPool.value.forEach(relay => {
|
||||
const sub = relay.sub([filter])
|
||||
|
||||
sub.on('event', (event: NostrEvent) => {
|
||||
try {
|
||||
const profile = JSON.parse(event.content)
|
||||
profiles.value.set(event.pubkey, {
|
||||
pubkey: event.pubkey,
|
||||
name: profile.name,
|
||||
picture: profile.picture,
|
||||
about: profile.about,
|
||||
nip05: profile.nip05
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Failed to parse profile:', err)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
account.value = null
|
||||
relayPool.value.forEach(relay => relay.close())
|
||||
relayPool.value = []
|
||||
messages.value.clear()
|
||||
profiles.value.clear()
|
||||
activeChat.value = null
|
||||
}
|
||||
|
||||
async function addMessage(pubkey: string, message: DirectMessage) {
|
||||
// Skip if we've already processed this message
|
||||
if (processedMessageIds.value.has(message.id)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Add message ID to processed set
|
||||
processedMessageIds.value.add(message.id)
|
||||
|
||||
// Add message to the map
|
||||
const userMessages = messages.value.get(pubkey) || []
|
||||
messages.value.set(pubkey, [...userMessages, message])
|
||||
}
|
||||
|
||||
async function sendMessage(to: string, content: string) {
|
||||
if (!account.value) return
|
||||
|
||||
const encrypted = await window.NostrTools.nip04.encrypt(account.value.privkey, to, content)
|
||||
const event: NostrEvent = {
|
||||
kind: 4,
|
||||
pubkey: account.value.pubkey,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [['p', to]],
|
||||
content: encrypted,
|
||||
id: '',
|
||||
sig: ''
|
||||
}
|
||||
|
||||
event.id = window.NostrTools.getEventHash(event)
|
||||
event.sig = await window.NostrTools.signEvent(event, account.value.privkey)
|
||||
|
||||
// Add to local messages first
|
||||
const dm: DirectMessage = {
|
||||
id: event.id,
|
||||
pubkey: to,
|
||||
content,
|
||||
created_at: event.created_at,
|
||||
sent: true
|
||||
}
|
||||
|
||||
await addMessage(to, dm)
|
||||
|
||||
// Then publish to relays
|
||||
await publishEvent(event, account.value.relays)
|
||||
}
|
||||
|
||||
async function subscribeToMessages() {
|
||||
if (!account.value) return
|
||||
|
||||
// Filter for received messages with history
|
||||
const receivedFilter = {
|
||||
kinds: [4],
|
||||
'#p': [account.value.pubkey],
|
||||
since: 0 // Get all historical messages
|
||||
}
|
||||
|
||||
// Filter for sent messages with history
|
||||
const sentFilter = {
|
||||
kinds: [4],
|
||||
authors: [account.value.pubkey],
|
||||
'#p': [SUPPORT_NPUB],
|
||||
since: 0 // Get all historical messages
|
||||
}
|
||||
|
||||
relayPool.value.forEach(relay => {
|
||||
// Subscribe to received messages
|
||||
const receivedSub = relay.sub([receivedFilter])
|
||||
|
||||
receivedSub.on('event', async (event: NostrEvent) => {
|
||||
try {
|
||||
// Skip if we've already processed this message
|
||||
if (processedMessageIds.value.has(event.id)) {
|
||||
return
|
||||
}
|
||||
|
||||
const decrypted = await window.NostrTools.nip04.decrypt(
|
||||
account.value!.privkey,
|
||||
event.pubkey,
|
||||
event.content
|
||||
)
|
||||
|
||||
const dm: DirectMessage = {
|
||||
id: event.id,
|
||||
pubkey: event.pubkey,
|
||||
content: decrypted,
|
||||
created_at: event.created_at,
|
||||
sent: false
|
||||
}
|
||||
|
||||
await addMessage(event.pubkey, dm)
|
||||
|
||||
// Load profile if not already loaded
|
||||
if (!profiles.value.has(event.pubkey)) {
|
||||
await loadProfiles()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to decrypt received message:', err)
|
||||
}
|
||||
})
|
||||
|
||||
// Subscribe to sent messages
|
||||
const sentSub = relay.sub([sentFilter])
|
||||
|
||||
sentSub.on('event', async (event: NostrEvent) => {
|
||||
try {
|
||||
// Skip if we've already processed this message
|
||||
if (processedMessageIds.value.has(event.id)) {
|
||||
return
|
||||
}
|
||||
|
||||
const decrypted = await window.NostrTools.nip04.decrypt(
|
||||
account.value!.privkey,
|
||||
SUPPORT_NPUB,
|
||||
event.content
|
||||
)
|
||||
|
||||
const dm: DirectMessage = {
|
||||
id: event.id,
|
||||
pubkey: SUPPORT_NPUB,
|
||||
content: decrypted,
|
||||
created_at: event.created_at,
|
||||
sent: true
|
||||
}
|
||||
|
||||
await addMessage(SUPPORT_NPUB, dm)
|
||||
} catch (err) {
|
||||
console.error('Failed to decrypt sent message:', err)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
account,
|
||||
profiles,
|
||||
messages,
|
||||
activeChat,
|
||||
isLoggedIn,
|
||||
currentMessages,
|
||||
init,
|
||||
login,
|
||||
logout,
|
||||
sendMessage
|
||||
}
|
||||
})
|
||||
37
src/types/nostr.ts
Normal file
37
src/types/nostr.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
export interface NostrEvent {
|
||||
kind: number
|
||||
pubkey: string
|
||||
created_at: number
|
||||
tags: string[][]
|
||||
content: string
|
||||
id: string
|
||||
sig: string
|
||||
}
|
||||
|
||||
export interface NostrProfile {
|
||||
pubkey: string
|
||||
name?: string
|
||||
picture?: string
|
||||
about?: string
|
||||
nip05?: string
|
||||
}
|
||||
|
||||
export interface NostrRelayConfig {
|
||||
url: string
|
||||
read?: boolean
|
||||
write?: boolean
|
||||
}
|
||||
|
||||
export interface NostrAccount {
|
||||
pubkey: string
|
||||
privkey: string
|
||||
relays: NostrRelayConfig[]
|
||||
}
|
||||
|
||||
export interface DirectMessage {
|
||||
id: string
|
||||
pubkey: string
|
||||
content: string
|
||||
created_at: number
|
||||
sent: boolean
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue