merchant chat functional almost out of WIP

This commit is contained in:
Tiago Vasconcelos 2022-10-19 22:08:48 +01:00
parent ebe8d8b0b9
commit 532e12ec83
7 changed files with 461 additions and 79 deletions

View file

@ -408,8 +408,10 @@ async def create_diagonalley_market_stalls(
async def update_diagonalley_market(market_id):
pass
### CHAT / MESSAGES
async def create_chat_message(data: CreateChatMessage):
print("DATA", data)
await db.execute(
@ -441,3 +443,14 @@ async def get_diagonalley_chat_messages(room_name: str):
)
return [ChatMessage(**row) for row in rows]
async def get_diagonalley_chat_by_merchant(ids: List[str]) -> List[ChatMessage]:
q = ",".join(["?"] * len(ids))
rows = await db.fetchall(
f"SELECT * FROM diagonalley.messages WHERE id_conversation IN ({q})",
(*ids,),
)
print(ids, q, rows)
return [ChatMessage(**row) for row in rows]

View file

@ -78,9 +78,9 @@ class Notifier:
async def _notify(self, message: str, room_name: str):
"""Notifier"""
d = json.loads(message)
d["room_name"] = room_name
print("hey", d)
db_msg = CreateChatMessage.parse_obj(d)
print("NOT:", db_msg)
await create_chat_message(data=db_msg)

View file

@ -345,20 +345,6 @@
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
<q-card>
<q-card-section>
<q-btn
unelevated
v-if="stalls.length > 0"
color="primary"
@click="productDialog.show = true"
>+ Product <q-tooltip> List a product </q-tooltip></q-btn
>
<q-btn
unelevated
v-else
color="primary"
@click="errorMessage('First set shipping zone(s), then create a stall.')"
>+ Product <q-tooltip> List a product </q-tooltip></q-btn
>
<q-btn unelevated color="primary" @click="zoneDialog.show = true"
>+ Shipping Zone<q-tooltip> Create a shipping zone </q-tooltip></q-btn
>
@ -382,7 +368,26 @@
Create a market stall to list products on
</q-tooltip></q-btn
>
<q-btn unelevated color="primary" @click="marketDialog.show = true"
<q-btn
unelevated
v-if="stalls.length > 0"
color="primary"
@click="productDialog.show = true"
>+ Product <q-tooltip> List a product </q-tooltip></q-btn
>
<q-btn
unelevated
v-else
color="primary"
@click="errorMessage('First set shipping zone(s), then create a stall.')"
>+ Product <q-tooltip> List a product </q-tooltip></q-btn
>
<q-btn
class="float-right"
unelevated
flat
color="primary"
@click="marketDialog.show = true"
>Create Market
<q-tooltip>
Makes a simple frontend shop for your stalls (not NOSTR)</q-tooltip
@ -415,6 +420,7 @@
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th auto-width></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props">
{{ col.label }}
@ -434,6 +440,23 @@
:icon="props.expand ? 'remove' : 'add'"
/>
</q-td>
<q-td auto-width>
<q-btn
size="sm"
color="green"
dense
icon="chat"
@click="chatRoom(props.row.invoiceid)"
>
<q-badge
v-if="props.row.unread"
color="red"
rounded
floating
style="padding: 6px; border-radius: 6px"
/>
</q-btn>
</q-td>
<q-td v-for="col in props.cols" :key="col.name" :props="props">
{{ col.value }}
</q-td>
@ -824,22 +847,114 @@
<q-list> {% include "diagonalley/_api_docs.html" %} </q-list>
</q-card-section>
</q-card>
<!-- CHAT BOX -->
<q-card>
<q-card-section>
<h6 class="text-subtitle1 q-my-none">Messages</h6>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<div class="column q-ma-md q-pb-lg" style="height: 350px">
<div class="col q-pb-md">
</q-card-section>
<q-card-section>
<q-select
v-model="customerKey"
:options="Object.keys(messages)"
label="Customers"
@input="chatRoom(customerKey)"
></q-select>
</q-card-section>
<div class="chat-container q-pa-md" ref="chatCard">
<div class="chat-box">
<!-- <p v-if="Object.keys(messages).length === 0">No messages yet</p> -->
<div class="chat-messages">
<q-chat-message
:key="index"
v-for="(message, index) in orderMessages"
:name="message.pubkey == keys.pubkey ? 'me' : 'customer'"
:text="[message.msg]"
:sent="message.pubkey == keys.pubkey ? true : false"
:bg-color="message.pubkey == keys.pubkey ? 'white' : 'light-green-2'"
/>
</div>
</div>
<q-form @submit="sendMessage" class="full-width chat-input">
<q-input
ref="newMessage"
v-model="newMessage"
placeholder="Message"
class="full-width"
dense
outlined
>
<template>
<q-btn
round
dense
flat
type="submit"
icon="send"
color="primary"
/>
</template>
</q-input>
</q-form>
</div>
</q-card>
<!-- <q-card>
<q-card-section>
<h6 class="text-subtitle1 q-my-none">Messages</h6>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<div
ref="chatCard"
class="q-ma-md q-pb-lg"
style="height: 350px"
>
<div class="q-pb-md">
<q-select
v-model="customerKey"
style="width: 80%"
:options="customerKeys"
:options="Object.keys(messages)"
label="Customers"
@input="getMessages(customerKey)"
@input="chatRoom(customerKey)"
></q-select>
<div class="chat-container q-pa-md">
<div class="chat-box">
<p v-if="Object.keys(messages).length === 0">No messages yet</p>
<div class="chat-messages">
<q-chat-message
:key="index"
v-for="(message, index) in orderMessages"
:name="message.pubkey == keys.pubkey ? 'me' : 'customer'"
:text="[message.msg]"
:sent="message.pubkey == keys.pubkey ? true : false"
:bg-color="message.pubkey == keys.pubkey ? 'white' : 'light-green-2'"
/>
</div>
</div>
<q-form @submit="sendMessage" class="full-width chat-input">
<q-input
ref="newMessage"
v-model="newMessage"
placeholder="Message"
class="full-width"
dense
outlined
>
<template>
<q-btn
round
dense
flat
type="submit"
icon="send"
color="primary"
/>
</template>
</q-input>
</q-form>
</div>
</div>
<div class="col-8 q-px-md">
<div v-for="message in customerMessages">
@ -860,11 +975,10 @@
</div>
</div>
</q-card-section>
</q-card>
</q-card> -->
</div>
</div>
<!-- </div>
</div> -->
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script src="https://cdn.jsdelivr.net/npm/pica@6.1.1/dist/pica.min.js"></script>
@ -873,6 +987,15 @@
const pica = window.pica()
function imgSizeFit(img, maxWidth = 1024, maxHeight = 768) {
let ratio = Math.min(
1,
maxWidth / img.naturalWidth,
maxHeight / img.naturalHeight
)
return {width: img.naturalWidth * ratio, height: img.naturalHeight * ratio}
}
const mapStalls = obj => {
obj._data = _.clone(obj)
return obj
@ -891,6 +1014,7 @@
new Date(obj.time * 1000),
'YYYY-MM-DD HH:mm'
)
// obj.unread = false
return obj
}
const mapKeys = obj => {
@ -933,8 +1057,12 @@
customerKeys: [],
customerKey: '',
customerMessages: {},
messages: {},
newMessage: '',
orderMessages: {},
shippedModel: false,
shippingZoneOptions: [
'Free (digital)',
'Worldwide',
'Europe',
'Australia',
@ -999,17 +1127,17 @@
ordersTable: {
columns: [
/*{
name: 'product',
align: 'left',
label: 'Product',
field: 'product'
},
{
name: 'quantity',
align: 'left',
label: 'Quantity',
field: 'quantity'
},*/
name: 'product',
align: 'left',
label: 'Product',
field: 'product'
},
{
name: 'quantity',
align: 'left',
label: 'Quantity',
field: 'quantity'
},*/
{
name: 'id',
align: 'left',
@ -1443,9 +1571,10 @@
let image = new Image()
image.src = blobURL
image.onload = async () => {
let fit = imgSizeFit(image)
let canvas = document.createElement('canvas')
canvas.setAttribute('width', 760)
canvas.setAttribute('height', 490)
canvas.setAttribute('width', fit.width)
canvas.setAttribute('height', fit.height)
await pica.resize(image, canvas, {
quality: 0,
alpha: true,
@ -1657,7 +1786,7 @@
.then(response => {
if (response.data) {
this.markets = response.data.map(mapMarkets)
console.log(this.markets)
// console.log(this.markets)
}
})
.catch(error => {
@ -1756,10 +1885,10 @@
////////////////////////////////////////
////////////////ORDERS//////////////////
////////////////////////////////////////
getOrders: function () {
getOrders: async function () {
var self = this
LNbits.api
await LNbits.api
.request(
'GET',
'/diagonalley/api/v1/orders?all_wallets=true',
@ -1768,7 +1897,6 @@
.then(function (response) {
if (response.data) {
self.orders = response.data.map(mapOrders)
console.log(self.orders)
}
})
.catch(function (error) {
@ -1839,21 +1967,190 @@
},
exportOrdersCSV: function () {
LNbits.utils.exportCSV(this.ordersTable.columns, this.orders)
},
/// CHAT
async getAllMessages() {
await LNbits.api
.request(
'GET',
`/diagonalley/api/v1/chat/messages/merchant?orders=${this.orders
.map(o => o.invoiceid)
.toString()}`,
this.g.user.wallets[0].adminkey
)
.then(res => {
this.messages = _.groupBy(res.data, 'id_conversation')
this.checkUnreadMessages()
console.log('Get new messages!')
})
.catch(error => {
LNbits.utils.notifyApiError(error)
})
},
updateLastSeenMsg(id) {
let data = this.$q.localStorage.getItem(
`lnbits.diagonalley.${this.g.user.id}`
)
let chat = {
...data.chat,
[`${id}`]: {
timestamp: Object.keys(this.orderMessages)[
Object.keys(this.orderMessages).length - 1
]
}
}
console.log({chat})
this.$q.localStorage.set(`lnbits.diagonalley.${this.g.user.id}`, {
...data,
chat
})
this.checkUnreadMessages()
},
checkUnreadMessages() {
let lastMsgs = this.$q.localStorage.getItem(
`lnbits.diagonalley.${this.g.user.id}`
).chat
for (let key in this.messages) {
let idx = this.orders.findIndex(f => f.invoiceid == key)
if (!lastMsgs[key]) {
this.updateLastSeenMsg(key)
//this.orders[idx].unread = true
return
}
console.log(
'Key',
key,
'saved:',
lastMsgs[key].timestamp,
'messages: ',
Math.max(...this.messages[key].map(c => c.timestamp)),
lastMsgs[key].timestamp <
Math.max(...this.messages[key].map(c => c.timestamp))
)
if (
lastMsgs[key].timestamp <
Math.max(...this.messages[key].map(c => c.timestamp))
) {
this.$set(this.orders[idx], 'unread', true)
// this.orders[idx].unread = true
} else {
this.$set(this.orders[idx], 'unread', false)
// this.orders[idx].unread = false
}
console.log('Order:', this.orders[idx])
}
},
clearMessage() {
this.newMessage = ''
this.$refs.newMessage.focus()
},
sendMessage() {
let message = {
msg: this.newMessage,
pubkey: this.keys.pubkey
}
this.ws.send(JSON.stringify(message))
this.clearMessage()
},
chatRoom(id) {
this.startChat(id)
this.orderMessages = {}
this.messages[id].map(m => {
this.$set(this.orderMessages, m.timestamp, {
msg: m.msg,
pubkey: m.pubkey
})
})
this.$refs.chatCard.scrollIntoView({
behavior: 'smooth',
inline: 'nearest'
})
this.updateLastSeenMsg(id)
//"ea2fbf6c91aa228603681e2cc34bb06e34e6d1375fa4d6c35756182b2fa3307f"
//"c7435a04875c26e28db91a377bd6e991dbfefeefea8258415f3ae0c716ed2335"
},
startChat(room_name) {
if (this.ws) {
this.ws.close()
}
if (location.protocol == 'https:') {
ws_scheme = 'wss://'
} else {
ws_scheme = 'ws://'
}
ws = new WebSocket(
ws_scheme + location.host + '/diagonalley/ws/' + room_name
)
function checkWebSocket(event) {
if (ws.readyState === WebSocket.CLOSED) {
console.log('WebSocket CLOSED: Reopening')
ws = new WebSocket(
ws_scheme + location.host + '/diagonalley/ws/' + room_name
)
}
}
ws.onmessage = event => {
let event_data = JSON.parse(event.data)
this.$set(this.orderMessages, Date.now(), event_data)
this.updateLastSeenMsg(room_name)
}
ws.onclose = event => {
this.updateLastSeenMsg(room_name)
}
this.ws = ws
}
},
created: function () {
async created() {
if (this.g.user.wallets.length) {
this.getStalls()
this.getProducts()
this.getZones()
this.getOrders()
await this.getOrders()
this.getMarkets()
this.customerKeys = [
'cb4c0164fe03fcdadcbfb4f76611c71620790944c24f21a1cd119395cdedfe1b',
'a9c17358a6dc4ceb3bb4d883eb87967a66b3453a0f3199f0b1c8eef8070c6a07'
]
await this.getAllMessages()
let keys = this.$q.localStorage.getItem(
`lnbits.diagonalley.${this.g.user.id}`
)
if (keys) {
this.keys = keys
}
setInterval(() => {
this.getAllMessages()
}, 300000)
}
}
})
</script>
<style scoped>
.chat-container {
position: relative;
display: grid;
grid-template-rows: 1fr auto;
height: calc(100vh - 140px);
}
.chat-box {
display: flex;
flex-direction: column-reverse;
padding: 1rem;
overflow-y: auto;
}
.chat-messages {
width: auto;
}
.chat-input {
position: relative;
display: flex;
align-items: end;
margin-top: 1rem;
}
</style>
{% endblock %}

View file

@ -68,7 +68,7 @@
dense
emit-value
v-model="selectedOrder"
:options="user.orders"
:options="Object.keys(user.orders)"
label="Order"
hint="Select an order from this merchant"
@input="val => { changeOrder() }"
@ -187,7 +187,7 @@
msg: this.newMessage,
pubkey: this.user.keys.publickey
}
ws.send(JSON.stringify(message))
this.ws.send(JSON.stringify(message))
this.clearMessage()
},
@ -236,10 +236,16 @@
LNbits.utils.notifyApiError(error)
})
},
changeOrder() {
console.log(this.selectedOrder)
async changeOrder() {
this.products = this.user.orders[this.selectedOrder]
this.messages = {}
await this.getMessages(this.selectedOrder)
this.startChat(this.selectedOrder)
},
startChat(room_name) {
if (this.ws) {
this.ws.close()
}
if (location.protocol == 'https:') {
ws_scheme = 'wss://'
} else {
@ -268,51 +274,51 @@
}
},
async created() {
this.stall = JSON.parse('{{ stall | tojson }}')
let order_details = JSON.parse('{{ order | tojson }}')
let products = JSON.parse('{{ products | tojson }}')
let order_id = '{{ order_id }}'
this.stall = JSON.parse('{{ stall | tojson }}')
this.products = order_details.map(o => {
let product = products.find(p => p.id == o.product_id)
return {
quantity: o.quantity,
name: product.product,
image: product.image,
price: product.price
}
})
let data = this.$q.localStorage.getItem(`lnbits.diagonalley.data`)
try {
if (data) {
this.user = data
//add chat key (merchant pubkey) if not set
if (!this.user.chats[`${order_id}`]) {
this.$set(this.user.chats, order_id, [])
if (!this.user.orders[`${order_id}`]) {
this.$set(this.user.orders, order_id, this.products)
}
//this.$q.localStorage.set(`lnbits.diagonalley.data`, this.user)
} else {
// generate keys
await this.generateKeys()
// populate user data
this.user.chats = {
[`${order_id}`]: []
this.user.orders = {
[`${order_id}`]: this.products
}
this.user.orders = []
//this.user.orders = []
}
this.order_details = order_details
this.products = order_details.map(o => {
let product = products.find(p => p.id == o.product_id)
return {
quantity: o.quantity,
name: product.product,
image: product.image,
price: product.price
}
})
//this.order_details = order_details
this.user.orders = [...new Set([...this.user.orders, order_id])]
//this.user.orders = [...new Set([...this.user.orders, order_id])]
this.selectedOrder = order_id
await this.getMessages(order_id)
this.$q.localStorage.set(`lnbits.diagonalley.data`, this.user)
this.startChat(order_id)
console.log(this.products)
console.log(this.messages)
} catch (e) {
console.error(e)
}

View file

@ -125,7 +125,7 @@
>
</div>
<div v-if="item.categories" class="text-subtitle1">
<q-chip v-for="cat in item.categories.split(',')" dense
<q-chip v-for="(cat, i) in item.categories.split(',')" :key="i" dense
>{{cat}}</q-chip
>
</div>
@ -409,8 +409,18 @@
if (res.data.paid) {
this.$q.notify({
type: 'positive',
message: 'Sats received, thanks!',
icon: 'thumb_up'
multiLine: true,
message:
"Sats received, thanks! You'l be redirected to the order page...",
icon: 'thumb_up',
actions: [
{
label: 'See Order',
handler: () => {
window.location.href = `/diagonalley/order/?merch=${this.stall.id}&invoice_id=${this.qrCodeDialog.data.payment_hash}`
}
}
]
})
clearInterval(this.qrCodeDialog.paymentChecker)
this.resetCart()

View file

@ -94,7 +94,9 @@ async def display(request: Request, market_id):
@diagonalley_ext.get("/order", response_class=HTMLResponse)
async def chat_page(request: Request, merch: str = Query(...), invoice_id: str = Query(...)):
async def chat_page(
request: Request, merch: str = Query(...), invoice_id: str = Query(...)
):
stall = await get_diagonalley_stall(merch)
order = await get_diagonalley_order_invoiceid(invoice_id)
_order = await get_diagonalley_order_details(order.id)
@ -110,9 +112,9 @@ async def chat_page(request: Request, merch: str = Query(...), invoice_id: str =
"publickey": stall.publickey,
"wallet": stall.wallet,
},
"order_id": order.id,
"order_id": order.invoiceid,
"order": [details.dict() for details in _order],
"products": [product.dict() for product in products]
"products": [product.dict() for product in products],
},
)
@ -123,6 +125,41 @@ async def chat_page(request: Request, merch: str = Query(...), invoice_id: str =
notifier = Notifier()
# class ConnectionManager:
# def __init__(self):
# self.active_connections: List[WebSocket] = []
# async def connect(self, websocket: WebSocket, room_name: str):
# await websocket.accept()
# websocket.id = room_name
# self.active_connections.append(websocket)
# def disconnect(self, websocket: WebSocket):
# self.active_connections.remove(websocket)
# async def send_personal_message(self, message: str, room_name: str):
# for connection in self.active_connections:
# if connection.id == room_name:
# await connection.send_text(message)
# async def broadcast(self, message: str):
# for connection in self.active_connections:
# await connection.send_text(message)
# manager = ConnectionManager()
# @diagonalley_ext.websocket("/ws/{room_name}")
# async def websocket_endpoint(websocket: WebSocket, room_name: str):
# await manager.connect(websocket, room_name)
# try:
# while True:
# data = await websocket.receive_text()
# except WebSocketDisconnect:
# manager.disconnect(websocket)
@diagonalley_ext.websocket("/ws/{room_name}")
async def websocket_endpoint(
websocket: WebSocket, room_name: str, background_tasks: BackgroundTasks
@ -143,7 +180,7 @@ async def websocket_endpoint(
if websocket not in room_members:
print("Sender not in room member: Reconnecting...")
await notifier.connect(websocket, room_name)
print("ENDPOINT", data)
await notifier._notify(data, room_name)
except WebSocketDisconnect:

View file

@ -1,10 +1,10 @@
from base64 import urlsafe_b64encode
from http import HTTPStatus
from typing import List
from typing import List, Union
from uuid import uuid4
from fastapi import Request
from fastapi.param_functions import Query
from fastapi.param_functions import Body, Query
from fastapi.params import Depends
from loguru import logger
from secp256k1 import PrivateKey, PublicKey
@ -34,6 +34,7 @@ from .crud import (
delete_diagonalley_product,
delete_diagonalley_stall,
delete_diagonalley_zone,
get_diagonalley_chat_by_merchant,
get_diagonalley_chat_messages,
get_diagonalley_latest_chat_messages,
get_diagonalley_market,
@ -255,6 +256,14 @@ async def api_diagonalley_orders(
return {"message": "We could not retrieve the orders."}
@diagonalley_ext.get("/api/v1/orders/{order_id}")
async def api_diagonalley_order_by_id(order_id: str):
order = (await get_diagonalley_order(order_id)).dict()
order["details"] = await get_diagonalley_order_details(order_id)
return order
@diagonalley_ext.post("/api/v1/orders")
async def api_diagonalley_order_create(data: createOrder):
ref = urlsafe_short_hash()
@ -488,6 +497,16 @@ async def api_diagonalley_generate_keys():
## MESSAGES/CHAT
@diagonalley_ext.get("/api/v1/chat/messages/merchant")
async def api_get_merchant_messages(
orders: str = Query(...), wallet: WalletTypeInfo = Depends(require_admin_key)
):
return [
msg.dict() for msg in await get_diagonalley_chat_by_merchant(orders.split(","))
]
@diagonalley_ext.get("/api/v1/chat/messages/{room_name}")
async def api_get_latest_chat_msg(room_name: str, all_messages: bool = Query(False)):
if all_messages: