mirror of
https://github.com/Troplo/Colubrina.git
synced 2024-11-22 19:27:55 +11:00
1.0.9 [Pins & Read Receipts]
This commit is contained in:
parent
19bca17ba0
commit
ab4c172899
12 changed files with 1397 additions and 678 deletions
|
@ -25,8 +25,8 @@ Colubrina is a simple self-hostable chatting platform written in Vue, and Vuetif
|
||||||
- [x] Mobile responsiveness/compatibility
|
- [x] Mobile responsiveness/compatibility
|
||||||
- [x] Email verification
|
- [x] Email verification
|
||||||
- [ ] Password resetting
|
- [ ] Password resetting
|
||||||
- [ ] Channel message pins
|
- [x] Channel message pins
|
||||||
- [ ] Read receipts
|
- [x] Read receipts
|
||||||
|
|
||||||
<img src="https://i.troplo.com/i/d608273e066c.png" alt="Chat" width="45%"></img>
|
<img src="https://i.troplo.com/i/d608273e066c.png" alt="Chat" width="45%"></img>
|
||||||
<img src="https://i.troplo.com/i/e8e2c9d6e349.png" alt="Friends" width="45%"></img>
|
<img src="https://i.troplo.com/i/e8e2c9d6e349.png" alt="Friends" width="45%"></img>
|
||||||
|
|
42
backend/migrations/20220731050926-pins.js
Normal file
42
backend/migrations/20220731050926-pins.js
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
"use strict"
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
async up(queryInterface, Sequelize) {
|
||||||
|
await queryInterface.createTable("Pins", {
|
||||||
|
id: {
|
||||||
|
type: Sequelize.BIGINT,
|
||||||
|
primaryKey: true,
|
||||||
|
autoIncrement: true
|
||||||
|
},
|
||||||
|
pinnedById: {
|
||||||
|
type: Sequelize.BIGINT,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
messageId: {
|
||||||
|
type: Sequelize.BIGINT,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
chatId: {
|
||||||
|
type: Sequelize.BIGINT,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
type: Sequelize.DATE,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
updatedAt: {
|
||||||
|
type: Sequelize.DATE,
|
||||||
|
allowNull: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface, Sequelize) {
|
||||||
|
/**
|
||||||
|
* Add reverting commands here.
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
* await queryInterface.dropTable('users');
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
}
|
|
@ -22,6 +22,10 @@ module.exports = (sequelize, DataTypes) => {
|
||||||
as: "attachments",
|
as: "attachments",
|
||||||
foreignKey: "messageId"
|
foreignKey: "messageId"
|
||||||
})
|
})
|
||||||
|
Message.hasMany(models.ChatAssociation, {
|
||||||
|
as: "readReceipts",
|
||||||
|
foreignKey: "lastRead"
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Message.init(
|
Message.init(
|
||||||
|
|
51
backend/models/pins.js
Normal file
51
backend/models/pins.js
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
"use strict"
|
||||||
|
const { Model } = require("sequelize")
|
||||||
|
module.exports = (sequelize, DataTypes) => {
|
||||||
|
class Pin extends Model {
|
||||||
|
/**
|
||||||
|
* Helper method for defining associations.
|
||||||
|
* This method is not a part of Sequelize lifecycle.
|
||||||
|
* The `models/index` file will call this method automatically.
|
||||||
|
*/
|
||||||
|
static associate(models) {
|
||||||
|
Pin.belongsTo(models.User, {
|
||||||
|
as: "pinnedBy"
|
||||||
|
})
|
||||||
|
Pin.belongsTo(models.Message, {
|
||||||
|
as: "message"
|
||||||
|
})
|
||||||
|
Pin.belongsTo(models.Chat, {
|
||||||
|
as: "chat"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Pin.init(
|
||||||
|
{
|
||||||
|
pinnedById: {
|
||||||
|
type: DataTypes.BIGINT,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
messageId: {
|
||||||
|
type: DataTypes.BIGINT,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
chatId: {
|
||||||
|
type: DataTypes.BIGINT,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
updatedAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sequelize,
|
||||||
|
modelName: "Pin"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return Pin
|
||||||
|
}
|
|
@ -6,6 +6,7 @@ const {
|
||||||
User,
|
User,
|
||||||
Chat,
|
Chat,
|
||||||
ChatAssociation,
|
ChatAssociation,
|
||||||
|
Pin,
|
||||||
Message,
|
Message,
|
||||||
Friend,
|
Friend,
|
||||||
Attachment,
|
Attachment,
|
||||||
|
@ -66,6 +67,18 @@ async function createMessage(req, type, content, association, userId) {
|
||||||
id: message.id
|
id: message.id
|
||||||
},
|
},
|
||||||
include: [
|
include: [
|
||||||
|
{
|
||||||
|
model: ChatAssociation,
|
||||||
|
as: "readReceipts",
|
||||||
|
attributes: ["id"],
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
as: "user",
|
||||||
|
attributes: ["username", "name", "avatar", "id"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
model: Attachment,
|
model: Attachment,
|
||||||
as: "attachments"
|
as: "attachments"
|
||||||
|
@ -124,7 +137,6 @@ async function createMessage(req, type, content, association, userId) {
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
associations.forEach((user) => {
|
associations.forEach((user) => {
|
||||||
console.log(user)
|
|
||||||
io.to(user.dataValues.userId).emit("message", {
|
io.to(user.dataValues.userId).emit("message", {
|
||||||
...messageLookup.dataValues,
|
...messageLookup.dataValues,
|
||||||
associationId: user.dataValues.id,
|
associationId: user.dataValues.id,
|
||||||
|
@ -196,9 +208,7 @@ router.get("/", auth, async (req, res, next) => {
|
||||||
"id",
|
"id",
|
||||||
"createdAt",
|
"createdAt",
|
||||||
"updatedAt",
|
"updatedAt",
|
||||||
|
|
||||||
"status",
|
"status",
|
||||||
|
|
||||||
"admin",
|
"admin",
|
||||||
"status",
|
"status",
|
||||||
"bot"
|
"bot"
|
||||||
|
@ -468,6 +478,170 @@ router.get("/:id", auth, async (req, res, next) => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
router.get("/:id/pins", auth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
let chat = await ChatAssociation.findOne({
|
||||||
|
where: {
|
||||||
|
userId: req.user.id,
|
||||||
|
id: req.params.id
|
||||||
|
},
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: Chat,
|
||||||
|
as: "chat"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
if (chat) {
|
||||||
|
const pins = await Pin.findAll({
|
||||||
|
where: {
|
||||||
|
chatId: chat.chat.id
|
||||||
|
},
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
as: "pinnedBy",
|
||||||
|
required: false,
|
||||||
|
attributes: [
|
||||||
|
"username",
|
||||||
|
"name",
|
||||||
|
"avatar",
|
||||||
|
"id",
|
||||||
|
"createdAt",
|
||||||
|
"updatedAt"
|
||||||
|
],
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: Nickname,
|
||||||
|
as: "nickname",
|
||||||
|
attributes: ["nickname"],
|
||||||
|
required: false,
|
||||||
|
where: {
|
||||||
|
userId: req.user.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: Message,
|
||||||
|
as: "message",
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
as: "user",
|
||||||
|
attributes: [
|
||||||
|
"username",
|
||||||
|
"name",
|
||||||
|
"avatar",
|
||||||
|
"id",
|
||||||
|
"createdAt",
|
||||||
|
"updatedAt"
|
||||||
|
],
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: Nickname,
|
||||||
|
as: "nickname",
|
||||||
|
attributes: ["nickname"],
|
||||||
|
required: false,
|
||||||
|
where: {
|
||||||
|
userId: req.user.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
order: [["id", "DESC"]]
|
||||||
|
})
|
||||||
|
res.json(
|
||||||
|
pins.map((pin) => {
|
||||||
|
const message = pin.dataValues.message.dataValues
|
||||||
|
return {
|
||||||
|
...pin.dataValues,
|
||||||
|
message: {
|
||||||
|
...pin.dataValues.message.dataValues,
|
||||||
|
keyId: `${message.id}-${message.updatedAt.toISOString()}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
throw Errors.invalidParameter("chat association id")
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
next(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.post("/:id/pins", auth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const io = req.app.get("io")
|
||||||
|
const chat = await ChatAssociation.findOne({
|
||||||
|
where: {
|
||||||
|
userId: req.user.id,
|
||||||
|
id: req.params.id
|
||||||
|
},
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: Chat,
|
||||||
|
as: "chat",
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
as: "users",
|
||||||
|
attributes: ["id"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
if (chat?.chat?.type === "direct" || chat?.rank === "admin") {
|
||||||
|
const message = await Message.findOne({
|
||||||
|
where: {
|
||||||
|
id: req.body.messageId,
|
||||||
|
chatId: chat.chat.id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (message) {
|
||||||
|
const checkPin = await Pin.findOne({
|
||||||
|
where: {
|
||||||
|
messageId: message.id,
|
||||||
|
chatId: chat.chat.id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (checkPin) {
|
||||||
|
await checkPin.destroy()
|
||||||
|
res.json({
|
||||||
|
message: "Message unpinned successfully."
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const pin = await Pin.create({
|
||||||
|
chatId: chat.chat.id,
|
||||||
|
messageId: req.body.messageId,
|
||||||
|
pinnedById: req.user.id
|
||||||
|
})
|
||||||
|
await createMessage(
|
||||||
|
req,
|
||||||
|
"pin",
|
||||||
|
`${req.user.username} pinned a message to the chat.`,
|
||||||
|
chat,
|
||||||
|
req.user.id
|
||||||
|
)
|
||||||
|
res.json({
|
||||||
|
...pin.dataValues,
|
||||||
|
message: "Message pinned successfully."
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw Errors.invalidParameter("chat association id")
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
next(e)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
router.put("/:id/read", auth, async (req, res, next) => {
|
router.put("/:id/read", auth, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const io = req.app.get("io")
|
const io = req.app.get("io")
|
||||||
|
@ -481,6 +655,11 @@ router.put("/:id/read", auth, async (req, res, next) => {
|
||||||
model: Chat,
|
model: Chat,
|
||||||
as: "chat",
|
as: "chat",
|
||||||
include: [
|
include: [
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
as: "users",
|
||||||
|
attributes: ["id"]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
model: Message,
|
model: Message,
|
||||||
as: "lastMessages",
|
as: "lastMessages",
|
||||||
|
@ -501,6 +680,20 @@ router.put("/:id/read", auth, async (req, res, next) => {
|
||||||
lastRead: chat.chat.lastMessages[0]?.id || null
|
lastRead: chat.chat.lastMessages[0]?.id || null
|
||||||
})
|
})
|
||||||
res.sendStatus(204)
|
res.sendStatus(204)
|
||||||
|
for (const user of chat.chat.users) {
|
||||||
|
io.to(user.id).emit("readReceipt", {
|
||||||
|
id: chat.id,
|
||||||
|
messageId: chat.chat.lastMessages[0]?.id || null,
|
||||||
|
userId: req.user.id,
|
||||||
|
chatId: chat.chat.id,
|
||||||
|
user: {
|
||||||
|
username: req.user.username,
|
||||||
|
avatar: req.user.avatar,
|
||||||
|
id: req.user.id
|
||||||
|
},
|
||||||
|
previousMessageId: chat.lastRead
|
||||||
|
})
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
throw Errors.invalidParameter("chat association id")
|
throw Errors.invalidParameter("chat association id")
|
||||||
}
|
}
|
||||||
|
@ -877,6 +1070,18 @@ router.post(
|
||||||
id: message.id
|
id: message.id
|
||||||
},
|
},
|
||||||
include: [
|
include: [
|
||||||
|
{
|
||||||
|
model: ChatAssociation,
|
||||||
|
as: "readReceipts",
|
||||||
|
attributes: ["id"],
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
as: "user",
|
||||||
|
attributes: ["username", "name", "avatar", "id"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
model: Attachment,
|
model: Attachment,
|
||||||
as: "attachments"
|
as: "attachments"
|
||||||
|
@ -1130,6 +1335,18 @@ router.post("/:id/message", auth, limiter, async (req, res, next) => {
|
||||||
id: message.id
|
id: message.id
|
||||||
},
|
},
|
||||||
include: [
|
include: [
|
||||||
|
{
|
||||||
|
model: ChatAssociation,
|
||||||
|
as: "readReceipts",
|
||||||
|
attributes: ["id"],
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
as: "user",
|
||||||
|
attributes: ["username", "name", "avatar", "id"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
model: Attachment,
|
model: Attachment,
|
||||||
as: "attachments"
|
as: "attachments"
|
||||||
|
@ -1399,6 +1616,18 @@ router.get("/:id/messages", auth, async (req, res, next) => {
|
||||||
...or
|
...or
|
||||||
},
|
},
|
||||||
include: [
|
include: [
|
||||||
|
{
|
||||||
|
model: ChatAssociation,
|
||||||
|
as: "readReceipts",
|
||||||
|
attributes: ["id"],
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
as: "user",
|
||||||
|
attributes: ["username", "name", "avatar", "id"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
model: Attachment,
|
model: Attachment,
|
||||||
as: "attachments"
|
as: "attachments"
|
||||||
|
@ -1409,12 +1638,10 @@ router.get("/:id/messages", auth, async (req, res, next) => {
|
||||||
attributes: [
|
attributes: [
|
||||||
"username",
|
"username",
|
||||||
"name",
|
"name",
|
||||||
|
|
||||||
"avatar",
|
"avatar",
|
||||||
"id",
|
"id",
|
||||||
"createdAt",
|
"createdAt",
|
||||||
"updatedAt",
|
"updatedAt",
|
||||||
|
|
||||||
"bot"
|
"bot"
|
||||||
],
|
],
|
||||||
include: [
|
include: [
|
||||||
|
@ -1439,12 +1666,10 @@ router.get("/:id/messages", auth, async (req, res, next) => {
|
||||||
attributes: [
|
attributes: [
|
||||||
"username",
|
"username",
|
||||||
"name",
|
"name",
|
||||||
|
|
||||||
"avatar",
|
"avatar",
|
||||||
"id",
|
"id",
|
||||||
"createdAt",
|
"createdAt",
|
||||||
"updatedAt",
|
"updatedAt",
|
||||||
|
|
||||||
"bot"
|
"bot"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "colubrina-chat",
|
"name": "colubrina-chat",
|
||||||
"version": "1.0.8",
|
"version": "1.0.9",
|
||||||
"private": true,
|
"private": true,
|
||||||
"author": "Troplo <troplo@troplo.com>",
|
"author": "Troplo <troplo@troplo.com>",
|
||||||
"license": "GPL-3.0",
|
"license": "GPL-3.0",
|
||||||
|
|
|
@ -1,3 +1,7 @@
|
||||||
|
.message-hover {
|
||||||
|
background-color: var(--v-bg-lighten1);
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
.message-toast {
|
.message-toast {
|
||||||
background-color: rgba(47, 47, 47, 0.9) !important;
|
background-color: rgba(47, 47, 47, 0.9) !important;
|
||||||
}
|
}
|
||||||
|
|
|
@ -338,6 +338,14 @@
|
||||||
}}
|
}}
|
||||||
</v-toolbar-title>
|
</v-toolbar-title>
|
||||||
<v-spacer></v-spacer>
|
<v-spacer></v-spacer>
|
||||||
|
<v-btn
|
||||||
|
icon
|
||||||
|
v-model="$store.state.context.pins.value"
|
||||||
|
@click="show($event, 'pins', null, null, true)"
|
||||||
|
id="pin-button"
|
||||||
|
>
|
||||||
|
<v-icon>mdi-pin-outline</v-icon>
|
||||||
|
</v-btn>
|
||||||
<v-btn
|
<v-btn
|
||||||
icon
|
icon
|
||||||
@click="$store.state.searchPanel = !$store.state.searchPanel"
|
@click="$store.state.searchPanel = !$store.state.searchPanel"
|
||||||
|
@ -345,7 +353,7 @@
|
||||||
<v-icon>mdi-magnify</v-icon>
|
<v-icon>mdi-magnify</v-icon>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-btn icon @click="$store.state.userPanel = !$store.state.userPanel">
|
<v-btn icon @click="$store.state.userPanel = !$store.state.userPanel">
|
||||||
<v-icon>mdi-account-group</v-icon>
|
<v-icon>mdi-account-group-outline</v-icon>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
|
@ -636,7 +644,8 @@ export default {
|
||||||
value: false,
|
value: false,
|
||||||
x: null,
|
x: null,
|
||||||
y: null,
|
y: null,
|
||||||
item: null
|
item: null,
|
||||||
|
id: 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
selected: [2],
|
selected: [2],
|
||||||
|
@ -684,16 +693,28 @@ export default {
|
||||||
this.leave.item = this.$store.state.chats.find((chat) => chat.id === id)
|
this.leave.item = this.$store.state.chats.find((chat) => chat.id === id)
|
||||||
this.leave.dialog = true
|
this.leave.dialog = true
|
||||||
},
|
},
|
||||||
show(e, context, item, id) {
|
show(e, context, item, id, state) {
|
||||||
e.preventDefault()
|
if (!state) {
|
||||||
this.context[context].value = false
|
e.preventDefault()
|
||||||
this.context[context].x = e.clientX
|
this.context[context].value = false
|
||||||
this.context[context].y = e.clientY
|
this.context[context].x = e.clientX
|
||||||
this.context[context].item = item
|
this.context[context].y = e.clientY
|
||||||
this.context[context].id = id
|
this.context[context].item = item
|
||||||
this.$nextTick(() => {
|
this.context[context].id = id
|
||||||
this.context[context].value = true
|
this.$nextTick(() => {
|
||||||
})
|
this.context[context].value = true
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
e.preventDefault()
|
||||||
|
this.$store.state.context[context].value = false
|
||||||
|
this.$store.state.context[context].x = e.clientX
|
||||||
|
this.$store.state.context[context].y = e.clientY
|
||||||
|
this.$store.state.context[context].item = item
|
||||||
|
this.$store.state.context[context].id = id
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.$store.state.context[context].value = true
|
||||||
|
})
|
||||||
|
}
|
||||||
},
|
},
|
||||||
setStatus(status) {
|
setStatus(status) {
|
||||||
const previousStatus = {
|
const previousStatus = {
|
||||||
|
|
File diff suppressed because it is too large
Load diff
106
frontend/src/components/SimpleMessage.vue
Normal file
106
frontend/src/components/SimpleMessage.vue
Normal file
|
@ -0,0 +1,106 @@
|
||||||
|
<template>
|
||||||
|
<div v-if="!message.type" class="rounded-l">
|
||||||
|
<v-toolbar
|
||||||
|
@click="jumpToMessage(message.replyId)"
|
||||||
|
:key="message.keyId + '-reply-toolbar'"
|
||||||
|
elevation="0"
|
||||||
|
height="40"
|
||||||
|
color="card"
|
||||||
|
v-if="message.reply"
|
||||||
|
style="cursor: pointer"
|
||||||
|
>
|
||||||
|
<v-icon class="mr-2">mdi-reply</v-icon>
|
||||||
|
<v-avatar size="24" class="mr-2">
|
||||||
|
<v-img
|
||||||
|
:src="
|
||||||
|
$store.state.baseURL + '/usercontent/' + message.reply.user.avatar
|
||||||
|
"
|
||||||
|
v-if="message.reply.user.avatar"
|
||||||
|
class="elevation-1"
|
||||||
|
/>
|
||||||
|
<v-icon v-else class="elevation-1"> mdi-account </v-icon>
|
||||||
|
</v-avatar>
|
||||||
|
<template v-if="message.reply.attachments.length">
|
||||||
|
<v-icon class="mr-2">mdi-file-image</v-icon>
|
||||||
|
</template>
|
||||||
|
<template
|
||||||
|
v-if="!message.reply.content && message.reply.attachments.length"
|
||||||
|
>
|
||||||
|
Click to view attachment
|
||||||
|
</template>
|
||||||
|
{{ message.reply.content.substring(0, 100) }}
|
||||||
|
</v-toolbar>
|
||||||
|
<v-list-item
|
||||||
|
:key="message.keyId"
|
||||||
|
:class="{
|
||||||
|
'text-xs-right': message.userId === $store.state.user.id,
|
||||||
|
'text-xs-left': message.userId !== $store.state.user.id
|
||||||
|
}"
|
||||||
|
:id="'message-' + index"
|
||||||
|
>
|
||||||
|
<v-avatar size="48" class="mr-2">
|
||||||
|
<v-img
|
||||||
|
:src="$store.state.baseURL + '/usercontent/' + message.user.avatar"
|
||||||
|
v-if="message.user.avatar"
|
||||||
|
class="elevation-1"
|
||||||
|
/>
|
||||||
|
<v-icon v-else class="elevation-1"> mdi-account </v-icon>
|
||||||
|
</v-avatar>
|
||||||
|
<v-list-item-content>
|
||||||
|
<v-list-item-subtitle>
|
||||||
|
{{ getName(message.user) }}
|
||||||
|
<v-chip v-if="message.user.bot" color="calendarNormalActivity" small>
|
||||||
|
<v-icon small>mdi-robot</v-icon>
|
||||||
|
</v-chip>
|
||||||
|
<small>
|
||||||
|
{{ $date(message.createdAt).format("hh:mm A, DD/MM/YYYY") }}
|
||||||
|
</small>
|
||||||
|
<v-tooltip top v-if="message.edited">
|
||||||
|
<template v-slot:activator="{ on, attrs }">
|
||||||
|
<span v-on="on" v-bind="attrs">
|
||||||
|
<v-icon
|
||||||
|
color="grey"
|
||||||
|
small
|
||||||
|
style="
|
||||||
|
margin-bottom: 2px;
|
||||||
|
margin-left: 4px;
|
||||||
|
position: absolute;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
mdi-pencil
|
||||||
|
</v-icon>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<span>
|
||||||
|
{{ $date(message.editedAt).format("DD/MM/YYYY hh:mm:ss A") }}
|
||||||
|
</span>
|
||||||
|
</v-tooltip>
|
||||||
|
</v-list-item-subtitle>
|
||||||
|
<p v-markdown style="overflow-wrap: anywhere">
|
||||||
|
{{ message.content }}
|
||||||
|
</p>
|
||||||
|
</v-list-item-content>
|
||||||
|
</v-list-item>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: "SimpleMessage",
|
||||||
|
props: ["message", "index"],
|
||||||
|
methods: {
|
||||||
|
jumpToMessage(id) {
|
||||||
|
this.$store.dispatch("jumpToMessage", id)
|
||||||
|
},
|
||||||
|
getName(user) {
|
||||||
|
if (user.nickname?.nickname) {
|
||||||
|
return user.nickname.nickname
|
||||||
|
} else {
|
||||||
|
return user.username
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped></style>
|
|
@ -35,6 +35,13 @@ export default new Vuex.Store({
|
||||||
version: process.env.VUE_APP_VERSION,
|
version: process.env.VUE_APP_VERSION,
|
||||||
release: process.env.RELEASE
|
release: process.env.RELEASE
|
||||||
},
|
},
|
||||||
|
context: {
|
||||||
|
pins: {
|
||||||
|
x: null,
|
||||||
|
y: null,
|
||||||
|
value: false
|
||||||
|
}
|
||||||
|
},
|
||||||
drawer: true,
|
drawer: true,
|
||||||
site: {
|
site: {
|
||||||
release: "stable",
|
release: "stable",
|
||||||
|
|
|
@ -1,5 +1,47 @@
|
||||||
<template>
|
<template>
|
||||||
<div id="communications-chat" @dragover.prevent @drop.prevent>
|
<div id="communications-chat" @dragover.prevent @drop.prevent>
|
||||||
|
<v-menu
|
||||||
|
:position-x="$store.state.context.pins.x"
|
||||||
|
:position-y="60"
|
||||||
|
v-model="$store.state.context.pins.value"
|
||||||
|
class="rounded-l"
|
||||||
|
absolute
|
||||||
|
transition="scroll-y-transition"
|
||||||
|
:close-on-content-click="false"
|
||||||
|
>
|
||||||
|
<v-card min-width="350" color="toolbar">
|
||||||
|
<v-toolbar color="toolbar lighten-1">
|
||||||
|
<v-spacer></v-spacer>
|
||||||
|
<v-toolbar-title> Pins </v-toolbar-title>
|
||||||
|
<v-spacer></v-spacer>
|
||||||
|
</v-toolbar>
|
||||||
|
<v-divider></v-divider>
|
||||||
|
<v-container>
|
||||||
|
<v-list dense v-if="pins.length">
|
||||||
|
<v-list-item
|
||||||
|
@click.self="jumpToMessage(pin.message.id)"
|
||||||
|
v-for="(pin, index) in pins"
|
||||||
|
:key="index"
|
||||||
|
>
|
||||||
|
<SimpleMessage
|
||||||
|
:message="pin.message"
|
||||||
|
:index="index"
|
||||||
|
:key="pin.message.keyId"
|
||||||
|
></SimpleMessage>
|
||||||
|
<v-btn icon text @click="removePin(pin.messageId)">
|
||||||
|
<v-icon> mdi-close </v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
<v-list-item v-else-if="pinsLoading">
|
||||||
|
<v-list-item-title> Loading... </v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
|
<v-list-item v-else>
|
||||||
|
<v-list-item-title> This chat has no pins yet. </v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
|
</v-container>
|
||||||
|
</v-card>
|
||||||
|
</v-menu>
|
||||||
<v-menu
|
<v-menu
|
||||||
v-model="context.message.value"
|
v-model="context.message.value"
|
||||||
:position-x="context.message.x"
|
:position-x="context.message.x"
|
||||||
|
@ -126,7 +168,11 @@
|
||||||
elevation="0"
|
elevation="0"
|
||||||
>
|
>
|
||||||
<v-card-text class="flex-grow-1 overflow-y-auto" id="message-list">
|
<v-card-text class="flex-grow-1 overflow-y-auto" id="message-list">
|
||||||
<v-card-title v-if="reachedTop">
|
<v-card-title
|
||||||
|
v-if="
|
||||||
|
reachedTop && $store.state.selectedChat?.chat?.type === 'group'
|
||||||
|
"
|
||||||
|
>
|
||||||
Welcome to the start of
|
Welcome to the start of
|
||||||
{{
|
{{
|
||||||
$store.state.selectedChat?.chat?.type === "direct"
|
$store.state.selectedChat?.chat?.type === "direct"
|
||||||
|
@ -134,6 +180,14 @@
|
||||||
: $store.state.selectedChat?.chat?.name
|
: $store.state.selectedChat?.chat?.name
|
||||||
}}
|
}}
|
||||||
</v-card-title>
|
</v-card-title>
|
||||||
|
<v-card-title v-else-if="reachedTop">
|
||||||
|
Welcome to the start of the conversation with
|
||||||
|
{{
|
||||||
|
$store.state.selectedChat?.chat?.type === "direct"
|
||||||
|
? getDirectRecipient($store.state.selectedChat).username
|
||||||
|
: $store.state.selectedChat?.chat?.name
|
||||||
|
}}
|
||||||
|
</v-card-title>
|
||||||
<v-progress-circular
|
<v-progress-circular
|
||||||
v-if="loadingMessages"
|
v-if="loadingMessages"
|
||||||
indeterminate
|
indeterminate
|
||||||
|
@ -141,22 +195,63 @@
|
||||||
style="display: block; width: 100px; margin: 0 auto"
|
style="display: block; width: 100px; margin: 0 auto"
|
||||||
></v-progress-circular>
|
></v-progress-circular>
|
||||||
<template v-for="(message, index) in messages">
|
<template v-for="(message, index) in messages">
|
||||||
<Message
|
<div :key="'div-' + message.keyId">
|
||||||
:key="message.keyId"
|
<Message
|
||||||
:message="message"
|
:key="message.keyId"
|
||||||
:jump-to-message="jumpToMessage"
|
:message="message"
|
||||||
:edit="edit"
|
:jump-to-message="jumpToMessage"
|
||||||
:focus-input="focusInput"
|
:edit="edit"
|
||||||
:replying="setReply"
|
:focus-input="focusInput"
|
||||||
:get-name="getName"
|
:replying="setReply"
|
||||||
:end-edit="endEdit"
|
:get-name="getName"
|
||||||
:auto-scroll="autoScroll"
|
:end-edit="endEdit"
|
||||||
:chat="chat"
|
:auto-scroll="autoScroll"
|
||||||
:index="index"
|
:chat="chat"
|
||||||
:show="show"
|
:index="index"
|
||||||
:set-image-preview="setImagePreview"
|
:show="show"
|
||||||
:delete-message="deleteMessage"
|
:set-image-preview="setImagePreview"
|
||||||
></Message>
|
:delete-message="deleteMessage"
|
||||||
|
></Message>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
:key="'div2-' + message.keyId"
|
||||||
|
v-if="message.readReceipts.length"
|
||||||
|
>
|
||||||
|
<v-tooltip
|
||||||
|
v-for="association in message.readReceipts"
|
||||||
|
:key="association.id"
|
||||||
|
top
|
||||||
|
>
|
||||||
|
<template v-slot:activator="{ on }">
|
||||||
|
<v-btn
|
||||||
|
icon
|
||||||
|
small
|
||||||
|
fab
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
class="ml-2 mt-2"
|
||||||
|
style="float: right"
|
||||||
|
@click="openUserPanel(association.user)"
|
||||||
|
>
|
||||||
|
<v-avatar size="20" v-on="on" color="primary">
|
||||||
|
<img
|
||||||
|
v-if="association.user.avatar"
|
||||||
|
:src="'/usercontent/' + association.user.avatar"
|
||||||
|
alt="avatar"
|
||||||
|
/>
|
||||||
|
<span v-else>{{
|
||||||
|
association.user.username[0].toUpperCase()
|
||||||
|
}}</span>
|
||||||
|
</v-avatar>
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
<span>
|
||||||
|
{{ association.user.username }} has read up to this point.
|
||||||
|
</span>
|
||||||
|
</v-tooltip>
|
||||||
|
<br />
|
||||||
|
<br v-if="index + 1 <= messages.length" />
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
|
@ -716,12 +811,21 @@ import CommsInput from "@/components/CommsInput"
|
||||||
import NicknameDialog from "@/components/NicknameDialog"
|
import NicknameDialog from "@/components/NicknameDialog"
|
||||||
import UserDialog from "@/components/UserDialog"
|
import UserDialog from "@/components/UserDialog"
|
||||||
import Message from "@/components/Message"
|
import Message from "@/components/Message"
|
||||||
|
import SimpleMessage from "@/components/SimpleMessage"
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "CommunicationsChat",
|
name: "CommunicationsChat",
|
||||||
components: { Message, UserDialog, NicknameDialog, CommsInput },
|
components: {
|
||||||
|
SimpleMessage,
|
||||||
|
Message,
|
||||||
|
UserDialog,
|
||||||
|
NicknameDialog,
|
||||||
|
CommsInput
|
||||||
|
},
|
||||||
props: ["chat", "loading", "items"],
|
props: ["chat", "loading", "items"],
|
||||||
data: () => ({
|
data: () => ({
|
||||||
|
pins: [],
|
||||||
|
pinsLoading: true,
|
||||||
reachedTop: false,
|
reachedTop: false,
|
||||||
graphOptions: {
|
graphOptions: {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
|
@ -824,7 +928,8 @@ export default {
|
||||||
userPanel: true,
|
userPanel: true,
|
||||||
rateLimit: false,
|
rateLimit: false,
|
||||||
loadingMessages: true,
|
loadingMessages: true,
|
||||||
avoidAutoScroll: false
|
avoidAutoScroll: false,
|
||||||
|
lastRead: 0
|
||||||
}),
|
}),
|
||||||
computed: {
|
computed: {
|
||||||
associations() {
|
associations() {
|
||||||
|
@ -845,6 +950,35 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
removePin(id) {
|
||||||
|
this.axios
|
||||||
|
.post(`/api/v1/communications/${this.chat.id}/pins`, {
|
||||||
|
messageId: id
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
this.getPins()
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
AjaxErrorHandler(this.$store)(e)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
getPins() {
|
||||||
|
this.pinsLoading = true
|
||||||
|
this.axios
|
||||||
|
.get(
|
||||||
|
process.env.VUE_APP_BASE_URL +
|
||||||
|
"/api/v1/communications/" +
|
||||||
|
this.$route.params.id +
|
||||||
|
"/pins"
|
||||||
|
)
|
||||||
|
.then((res) => {
|
||||||
|
this.pins = res.data
|
||||||
|
this.pinsLoading = false
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
AjaxErrorHandler(this.$store)(e)
|
||||||
|
})
|
||||||
|
},
|
||||||
forceBottom() {
|
forceBottom() {
|
||||||
this.avoidAutoScroll = false
|
this.avoidAutoScroll = false
|
||||||
this.autoScroll()
|
this.autoScroll()
|
||||||
|
@ -1198,6 +1332,12 @@ export default {
|
||||||
.addEventListener("scroll", this.scrollEvent)
|
.addEventListener("scroll", this.scrollEvent)
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
this.typing()
|
this.typing()
|
||||||
|
if (
|
||||||
|
document.hasFocus() &&
|
||||||
|
this.messages[this.messages.length - 1]?.id !== this.lastRead
|
||||||
|
) {
|
||||||
|
this.markRead()
|
||||||
|
}
|
||||||
}, 1000)
|
}, 1000)
|
||||||
this.getMessages()
|
this.getMessages()
|
||||||
if (localStorage.getItem("userPanel")) {
|
if (localStorage.getItem("userPanel")) {
|
||||||
|
@ -1212,11 +1352,35 @@ export default {
|
||||||
if (drafts[this.$route.params.id]) {
|
if (drafts[this.$route.params.id]) {
|
||||||
this.message = drafts[this.$route.params.id]
|
this.message = drafts[this.$route.params.id]
|
||||||
}
|
}
|
||||||
|
this.$socket.on("readChat", (data) => {
|
||||||
|
if (data.id === this.chat.id) {
|
||||||
|
this.lastRead = data.lastRead
|
||||||
|
}
|
||||||
|
})
|
||||||
|
this.$socket.on("readReceipt", (data) => {
|
||||||
|
if (
|
||||||
|
data.messageId &&
|
||||||
|
data.chatId === this.chat.chatId &&
|
||||||
|
this.messages?.length
|
||||||
|
) {
|
||||||
|
this.messages.forEach((message) => {
|
||||||
|
message.readReceipts = message.readReceipts.filter(
|
||||||
|
(readReceipt) => readReceipt.id !== data.id
|
||||||
|
)
|
||||||
|
})
|
||||||
|
this.messages
|
||||||
|
.find((message) => message.id === data.messageId)
|
||||||
|
.readReceipts?.push(data)
|
||||||
|
this.autoScroll()
|
||||||
|
}
|
||||||
|
})
|
||||||
this.$socket.on("message", (message) => {
|
this.$socket.on("message", (message) => {
|
||||||
if (message.chatId === this.chat.chatId) {
|
if (message.chatId === this.chat.chatId) {
|
||||||
this.messages.push(message)
|
this.messages.push(message)
|
||||||
this.autoScroll()
|
this.autoScroll()
|
||||||
this.markRead()
|
if (document.hasFocus()) {
|
||||||
|
this.markRead()
|
||||||
|
}
|
||||||
if (this.messages.length > 50 && !this.avoidAutoScroll) {
|
if (this.messages.length > 50 && !this.avoidAutoScroll) {
|
||||||
this.messages.shift()
|
this.messages.shift()
|
||||||
this.reachedTop = false
|
this.reachedTop = false
|
||||||
|
@ -1265,6 +1429,11 @@ export default {
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
|
"$store.state.context.pins.value"(val) {
|
||||||
|
if (val) {
|
||||||
|
this.getPins()
|
||||||
|
}
|
||||||
|
},
|
||||||
userPanel() {
|
userPanel() {
|
||||||
localStorage.setItem("userPanel", JSON.stringify(this.userPanel))
|
localStorage.setItem("userPanel", JSON.stringify(this.userPanel))
|
||||||
},
|
},
|
||||||
|
@ -1287,6 +1456,7 @@ export default {
|
||||||
this.reachedTop = false
|
this.reachedTop = false
|
||||||
this.avoidAutoScroll = false
|
this.avoidAutoScroll = false
|
||||||
this.offset = 0
|
this.offset = 0
|
||||||
|
this.pins = []
|
||||||
this.getMessages()
|
this.getMessages()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
Loading…
Reference in a new issue