This commit is contained in:
Troplo 2022-07-30 15:14:26 +10:00
parent 3562843c10
commit 52d82acde3
12 changed files with 1362 additions and 1334 deletions

View file

@ -11,7 +11,7 @@ Colubrina is a simple self-hostable chatting platform written in Vue, and Vuetif
- [x] Authentication - [x] Authentication
- [x] Admin panel - [x] Admin panel
- [x] CLI - [x] CLI
- [ ] Scroll up to see more messages/jump to searched message - [x] (partially complete) Scroll up to see more messages/jump to searched message
- [x] User profile cards - [x] User profile cards
- [x] Group creation and modification - [x] Group creation and modification
- [x] Direct message groups - [x] Direct message groups

View file

@ -21,6 +21,7 @@ app.use("/api/v1/admin", require("./routes/admin.js"))
app.use("/usercontent", require("./routes/usercontent.js")) app.use("/usercontent", require("./routes/usercontent.js"))
app.use("/api/v1/usercontent", require("./routes/usercontent.js")) app.use("/api/v1/usercontent", require("./routes/usercontent.js"))
app.use("/api/v1/mediaproxy", require("./routes/mediaproxy.js")) app.use("/api/v1/mediaproxy", require("./routes/mediaproxy.js"))
app.use("/api/v1/associations", require("./routes/associations.js"))
app.get("/api/v1/state", async (req, res) => { app.get("/api/v1/state", async (req, res) => {
res.json({ res.json({
release: process.env.RELEASE, release: process.env.RELEASE,

View file

@ -98,15 +98,18 @@ module.exports = {
} }
}) })
socket.on("disconnect", async function () { socket.on("disconnect", async function () {
friends.forEach((friend) => { const clients = io.sockets.adapter.rooms.get(user.id) || new Set()
io.to(friend.friendId).emit("userStatus", { if (!clients.size || clients.size === 0) {
userId: user.id, friends.forEach((friend) => {
io.to(friend.friendId).emit("userStatus", {
userId: user.id,
status: "offline"
})
})
await user.update({
status: "offline" status: "offline"
}) })
}) }
await user.update({
status: "offline"
})
}) })
} else { } else {
socket.join(-1) socket.join(-1)

View file

@ -0,0 +1,514 @@
const express = require("express")
const router = express.Router()
const Errors = require("../lib/errors.js")
const auth = require("../lib/authorize.js")
const { User, Message, ChatAssociation, Chat, Attachment, Friend} = require("../models")
router.delete(
"/association/:id/:associationId",
auth,
async (req, res, next) => {
try {
const io = req.app.get("io")
const chat = await ChatAssociation.findOne({
where: {
id: req.params.id,
userId: req.user.id,
rank: "admin"
},
include: [
{
model: Chat,
as: "chat",
include: [
{
model: User,
as: "users",
attributes: ["id", "username", "createdAt", "updatedAt"]
}
]
}
]
})
const association = await ChatAssociation.findOne({
where: {
id: req.params.associationId,
chatId: chat.chat.id
},
include: [
{
model: User,
as: "user",
attributes: ["id", "username", "createdAt", "updatedAt"]
}
]
})
if (!chat) {
throw Errors.chatNotFoundOrNotAdmin
}
if (!association) {
throw Errors.chatNotFoundOrNotAdmin
}
if(association.chat)
await association.destroy()
res.sendStatus(204)
const message = await Message.create({
userId: 0,
chatId: chat.chat.id,
content: `${association.user.username} has been removed by ${req.user.username}.`,
type: "leave"
})
const associations = await ChatAssociation.findAll({
where: {
chatId: chat.chat.id
},
include: [
{
model: User,
as: "user",
attributes: [
"username",
"name",
"avatar",
"id",
"createdAt",
"updatedAt"
]
}
]
})
const messageLookup = await Message.findOne({
where: {
id: message.id
},
include: [
{
model: Attachment,
as: "attachments"
},
{
model: Message,
as: "reply",
include: [
{
model: User,
as: "user",
attributes: [
"username",
"name",
"avatar",
"id",
"createdAt",
"updatedAt"
]
}
]
},
{
model: Chat,
as: "chat",
include: [
{
model: User,
as: "users",
attributes: [
"username",
"name",
"avatar",
"id",
"createdAt",
"updatedAt"
]
}
]
},
{
model: User,
as: "user",
attributes: [
"username",
"name",
"avatar",
"id",
"createdAt",
"updatedAt"
]
}
]
})
associations.forEach((association) => {
io.to(association.userId).emit("message", {
...messageLookup.dataValues,
associationId: association.id,
keyId: `${message.id}-${message.updatedAt.toISOString()}`
})
})
} catch (err) {
next(err)
}
}
)
router.put("/association/:id/:associationId", auth, async (req, res, next) => {
try {
const chat = await ChatAssociation.findOne({
where: {
id: req.params.id,
userId: req.user.id,
rank: "admin"
},
include: [
{
model: Chat,
as: "chat",
include: [
{
model: User,
as: "users",
attributes: ["id", "username", "createdAt", "updatedAt"]
}
]
}
]
})
const association = await ChatAssociation.findOne({
where: {
id: req.params.associationId,
chatId: chat.chat.id
}
})
if (!chat) {
throw Errors.chatNotFoundOrNotAdmin
}
if (!association) {
throw Errors.chatNotFoundOrNotAdmin
}
if (association.rank === "admin") {
throw Errors.chatNotFoundOrNotAdmin
}
await association.update({
rank: req.body.rank
})
res.sendStatus(204)
} catch (err) {
next(err)
}
})
router.post("/association/:id", auth, async (req, res, next) => {
try {
const io = req.app.get("io")
const chat = await ChatAssociation.findOne({
where: {
userId: req.user.id,
chatId: req.params.id,
rank: "admin"
},
include: [
{
model: Chat,
as: "chat",
include: [
{
model: User,
as: "users",
attributes: ["id", "username", "createdAt", "updatedAt"]
}
]
}
]
})
if (chat) {
if (req.body.users.length > 10) {
throw Errors.invalidParameter(
"User",
"The maximum number of users is 10"
)
}
if (!req.body.users.length) {
throw Errors.invalidParameter(
"User",
"You need at least 1 user to create a chat"
)
}
if (req.body.users.includes(req.user.id)) {
throw Errors.invalidParameter(
"User",
"You cannot create a DM with yourself"
)
}
const friends = await Friend.findAll({
where: {
userId: req.user.id,
friendId: req.body.users,
status: "accepted"
}
})
if (friends.length !== req.body.users.length) {
throw Errors.invalidParameter(
"User",
"You are not friends with this user"
)
}
const users = await ChatAssociation.findAll({
where: {
userId: req.body.users,
chatId: req.params.id
}
})
if (users.length > 0) {
throw Errors.invalidParameter(
"User",
"One or more users are already in this chat"
)
}
const associations = await ChatAssociation.findAll({
where: {
chatId: chat.chatId
},
include: [
{
model: User,
as: "user",
attributes: [
"username",
"name",
"avatar",
"id",
"createdAt",
"updatedAt"
]
}
]
})
for (let i = 0; i < req.body.users.length; i++) {
const c1 = await ChatAssociation.create({
chatId: chat.chat.id,
userId: req.body.users[i],
rank: "member"
})
const association = await ChatAssociation.findOne({
where: {
id: c1.id
},
include: [
{
model: Chat,
as: "chat",
include: [
{
model: User,
as: "users",
attributes: [
"username",
"name",
"avatar",
"id",
"createdAt",
"updatedAt",
"status"
]
}
]
},
{
model: User,
as: "user",
attributes: ["id", "username", "createdAt", "updatedAt"]
}
]
})
io.to(req.body.users[i]).emit("chatAdded", association)
const message = await Message.create({
userId: 0,
chatId: chat.chatId,
content: `${association.user.username} has been added by ${req.user.username}.`,
type: "join"
})
const messageLookup = await Message.findOne({
where: {
id: message.id
},
include: [
{
model: Attachment,
as: "attachments"
},
{
model: Message,
as: "reply",
include: [
{
model: User,
as: "user",
attributes: [
"username",
"name",
"avatar",
"id",
"createdAt",
"updatedAt"
]
}
]
},
{
model: Chat,
as: "chat",
include: [
{
model: User,
as: "users",
attributes: [
"username",
"name",
"avatar",
"id",
"createdAt",
"updatedAt"
]
}
]
},
{
model: User,
as: "user",
attributes: [
"username",
"name",
"avatar",
"id",
"createdAt",
"updatedAt"
]
}
]
})
associations.forEach((association) => {
io.to(association.userId).emit("message", {
...messageLookup.dataValues,
associationId: association.id,
keyId: `${message.id}-${message.updatedAt.toISOString()}`
})
})
}
res.sendStatus(204)
} else {
throw Errors.chatNotFoundOrNotAdmin
}
} catch (err) {
next(err)
}
})
router.delete("/association/:id", 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
}
})
if (chat) {
await chat.destroy()
res.sendStatus(204)
const message = await Message.create({
userId: 0,
chatId: chat.chatId,
content: `${req.user.username} has left the group.`,
type: "leave"
})
const associations = await ChatAssociation.findAll({
where: {
chatId: chat.chatId
},
include: [
{
model: User,
as: "user",
attributes: [
"username",
"name",
"avatar",
"id",
"createdAt",
"updatedAt"
]
}
]
})
const messageLookup = await Message.findOne({
where: {
id: message.id
},
include: [
{
model: Attachment,
as: "attachments"
},
{
model: Message,
as: "reply",
include: [
{
model: User,
as: "user",
attributes: [
"username",
"name",
"avatar",
"id",
"createdAt",
"updatedAt"
]
}
]
},
{
model: Chat,
as: "chat",
include: [
{
model: User,
as: "users",
attributes: [
"username",
"name",
"avatar",
"id",
"createdAt",
"updatedAt"
]
}
]
},
{
model: User,
as: "user",
attributes: [
"username",
"name",
"avatar",
"id",
"createdAt",
"updatedAt"
]
}
]
})
associations.forEach((association) => {
io.to(association.userId).emit("message", {
...messageLookup.dataValues,
associationId: association.id,
keyId: `${message.id}-${message.updatedAt.toISOString()}`
})
})
} else {
throw Errors.invalidParameter("chat association id")
}
} catch (err) {
next(err)
}
})
module.exports = router

View file

@ -346,417 +346,6 @@ router.get("/mutual/:id/groups", auth, async (req, res, next) => {
} }
}) })
router.delete(
"/association/:id/:associationId",
auth,
async (req, res, next) => {
try {
const io = req.app.get("io")
const chat = await ChatAssociation.findOne({
where: {
id: req.params.id,
userId: req.user.id,
rank: "admin"
},
include: [
{
model: Chat,
as: "chat",
include: [
{
model: User,
as: "users",
attributes: ["id", "username", "createdAt", "updatedAt"]
}
]
}
]
})
const association = await ChatAssociation.findOne({
where: {
id: req.params.associationId,
chatId: chat.chat.id
},
include: [
{
model: Chat,
as: "chat"
},
{
model: User,
as: "user",
attributes: ["id", "username", "createdAt", "updatedAt"]
}
]
})
if (!chat) {
throw Errors.chatNotFoundOrNotAdmin
}
if (!association) {
throw Errors.chatNotFoundOrNotAdmin
}
if(association.chat.type === "direct") {
throw Errors.invalidParameter("association id", "You cannot leave direct messages")
}
await association.destroy()
res.sendStatus(204)
const message = await Message.create({
userId: 0,
chatId: chat.chat.id,
content: `${association.user.username} has been removed by ${req.user.username}.`,
type: "leave"
})
const associations = await ChatAssociation.findAll({
where: {
chatId: chat.chat.id
},
include: [
{
model: User,
as: "user",
attributes: [
"username",
"name",
"avatar",
"id",
"createdAt",
"updatedAt"
]
}
]
})
const messageLookup = await Message.findOne({
where: {
id: message.id
},
include: [
{
model: Attachment,
as: "attachments"
},
{
model: Message,
as: "reply",
include: [
{
model: User,
as: "user",
attributes: [
"username",
"name",
"avatar",
"id",
"createdAt",
"updatedAt"
]
}
]
},
{
model: Chat,
as: "chat",
include: [
{
model: User,
as: "users",
attributes: [
"username",
"name",
"avatar",
"id",
"createdAt",
"updatedAt"
]
}
]
},
{
model: User,
as: "user",
attributes: [
"username",
"name",
"avatar",
"id",
"createdAt",
"updatedAt"
]
}
]
})
associations.forEach((association) => {
io.to(association.userId).emit("message", {
...messageLookup.dataValues,
associationId: association.id,
keyId: `${message.id}-${message.updatedAt.toISOString()}`
})
})
} catch (err) {
next(err)
}
}
)
router.put("/association/:id/:associationId", auth, async (req, res, next) => {
try {
const chat = await ChatAssociation.findOne({
where: {
id: req.params.id,
userId: req.user.id,
rank: "admin"
},
include: [
{
model: Chat,
as: "chat",
include: [
{
model: User,
as: "users",
attributes: ["id", "username", "createdAt", "updatedAt"]
}
]
}
]
})
const association = await ChatAssociation.findOne({
where: {
id: req.params.associationId,
chatId: chat.chat.id
}
})
if (!chat) {
throw Errors.chatNotFoundOrNotAdmin
}
if (!association) {
throw Errors.chatNotFoundOrNotAdmin
}
if (association.rank === "admin") {
throw Errors.chatNotFoundOrNotAdmin
}
await association.update({
rank: req.body.rank
})
res.sendStatus(204)
} catch (err) {
next(err)
}
})
router.post("/association/:id", auth, async (req, res, next) => {
try {
const io = req.app.get("io")
const chat = await ChatAssociation.findOne({
where: {
userId: req.user.id,
chatId: req.params.id,
rank: "admin"
},
include: [
{
model: Chat,
as: "chat",
include: [
{
model: User,
as: "users",
attributes: ["id", "username", "createdAt", "updatedAt"]
}
]
}
]
})
if (chat) {
if (req.body.users.length > 10) {
throw Errors.invalidParameter(
"User",
"The maximum number of users is 10"
)
}
if (!req.body.users.length) {
throw Errors.invalidParameter(
"User",
"You need at least 1 user to create a chat"
)
}
if (req.body.users.includes(req.user.id)) {
throw Errors.invalidParameter(
"User",
"You cannot create a DM with yourself"
)
}
const friends = await Friend.findAll({
where: {
userId: req.user.id,
friendId: req.body.users,
status: "accepted"
}
})
if (friends.length !== req.body.users.length) {
throw Errors.invalidParameter(
"User",
"You are not friends with this user"
)
}
const users = await ChatAssociation.findAll({
where: {
userId: req.body.users,
chatId: req.params.id
}
})
if (users.length > 0) {
throw Errors.invalidParameter(
"User",
"One or more users are already in this chat"
)
}
const associations = await ChatAssociation.findAll({
where: {
chatId: chat.chatId
},
include: [
{
model: User,
as: "user",
attributes: [
"username",
"name",
"avatar",
"id",
"createdAt",
"updatedAt"
]
}
]
})
for (let i = 0; i < req.body.users.length; i++) {
const c1 = await ChatAssociation.create({
chatId: chat.chat.id,
userId: req.body.users[i],
rank: "member"
})
const association = await ChatAssociation.findOne({
where: {
id: c1.id
},
include: [
{
model: Chat,
as: "chat",
include: [
{
model: User,
as: "users",
attributes: [
"username",
"name",
"avatar",
"id",
"createdAt",
"updatedAt",
"status"
]
}
]
},
{
model: User,
as: "user",
attributes: ["id", "username", "createdAt", "updatedAt"]
}
]
})
io.to(req.body.users[i]).emit("chatAdded", association)
const message = await Message.create({
userId: 0,
chatId: chat.chatId,
content: `${association.user.username} has been added by ${req.user.username}.`,
type: "join"
})
const messageLookup = await Message.findOne({
where: {
id: message.id
},
include: [
{
model: Attachment,
as: "attachments"
},
{
model: Message,
as: "reply",
include: [
{
model: User,
as: "user",
attributes: [
"username",
"name",
"avatar",
"id",
"createdAt",
"updatedAt"
]
}
]
},
{
model: Chat,
as: "chat",
include: [
{
model: User,
as: "users",
attributes: [
"username",
"name",
"avatar",
"id",
"createdAt",
"updatedAt"
]
}
]
},
{
model: User,
as: "user",
attributes: [
"username",
"name",
"avatar",
"id",
"createdAt",
"updatedAt"
]
}
]
})
associations.forEach((association) => {
io.to(association.userId).emit("message", {
...messageLookup.dataValues,
associationId: association.id,
keyId: `${message.id}-${message.updatedAt.toISOString()}`
})
})
}
res.sendStatus(204)
} else {
throw Errors.chatNotFoundOrNotAdmin
}
} catch (err) {
next(err)
}
})
router.get("/users", auth, async (req, res, next) => { router.get("/users", auth, async (req, res, next) => {
try { try {
const users = await User.findAll({ const users = await User.findAll({
@ -1127,117 +716,6 @@ router.get("/:id/search", auth, async (req, res, next) => {
} }
}) })
router.delete("/association/:id", 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
}
})
if (chat) {
await chat.destroy()
res.sendStatus(204)
const message = await Message.create({
userId: 0,
chatId: chat.chatId,
content: `${req.user.username} has left the group.`,
type: "leave"
})
const associations = await ChatAssociation.findAll({
where: {
chatId: chat.chatId
},
include: [
{
model: User,
as: "user",
attributes: [
"username",
"name",
"avatar",
"id",
"createdAt",
"updatedAt"
]
}
]
})
const messageLookup = await Message.findOne({
where: {
id: message.id
},
include: [
{
model: Attachment,
as: "attachments"
},
{
model: Message,
as: "reply",
include: [
{
model: User,
as: "user",
attributes: [
"username",
"name",
"avatar",
"id",
"createdAt",
"updatedAt"
]
}
]
},
{
model: Chat,
as: "chat",
include: [
{
model: User,
as: "users",
attributes: [
"username",
"name",
"avatar",
"id",
"createdAt",
"updatedAt"
]
}
]
},
{
model: User,
as: "user",
attributes: [
"username",
"name",
"avatar",
"id",
"createdAt",
"updatedAt"
]
}
]
})
associations.forEach((association) => {
io.to(association.userId).emit("message", {
...messageLookup.dataValues,
associationId: association.id,
keyId: `${message.id}-${message.updatedAt.toISOString()}`
})
})
} else {
throw Errors.invalidParameter("chat association id")
}
} catch (err) {
next(err)
}
})
router.delete("/:id/message/:mId", auth, async (req, res, next) => { router.delete("/:id/message/:mId", auth, async (req, res, next) => {
try { try {
const io = req.app.get("io") const io = req.app.get("io")
@ -1944,7 +1422,7 @@ router.get("/:id/messages", auth, async (req, res, next) => {
] ]
} }
], ],
offset: req.query.offset || 0, offset: parseInt(req.query.offset) || 0,
order: [["id", "DESC"]], order: [["id", "DESC"]],
limit: 50 limit: 50
}) })
@ -2096,10 +1574,10 @@ router.post("/create", auth, async (req, res, next) => {
type type
}) })
req.body.users.push(req.user.id) req.body.users.push(req.user.id)
for (let i = 0; i < req.body.users.length; i++) { for (const id of req.body.users) {
let rank let rank
if (type === "group") { if (type === "group") {
if (req.body.users[i] === req.user.id) { if (id === req.user.id) {
rank = "admin" rank = "admin"
} else { } else {
rank = "member" rank = "member"
@ -2107,14 +1585,17 @@ router.post("/create", auth, async (req, res, next) => {
} else { } else {
rank = "member" rank = "member"
} }
const c1 = await ChatAssociation.create({ await ChatAssociation.create({
chatId: chat.id, chatId: chat.id,
userId: req.body.users[i], userId: id,
rank rank
}) })
}
for (const id of req.body.users) {
const association = await ChatAssociation.findOne({ const association = await ChatAssociation.findOne({
where: { where: {
id: c1.id chatId: chat.id,
userId: id
}, },
include: [ include: [
{ {
@ -2178,7 +1659,7 @@ router.post("/create", auth, async (req, res, next) => {
} }
] ]
}) })
io.to(req.body.users[i]).emit("chatAdded", association) io.to(id).emit("chatAdded", association)
} }
const association = await ChatAssociation.findOne({ const association = await ChatAssociation.findOne({
where: { where: {

View file

@ -1,6 +1,6 @@
{ {
"name": "colubrina-chat", "name": "colubrina-chat",
"version": "1.0.4", "version": "1.0.5",
"private": true, "private": true,
"author": "Troplo <troplo@troplo.com>", "author": "Troplo <troplo@troplo.com>",
"license": "GPL-3.0", "license": "GPL-3.0",

View file

@ -742,7 +742,7 @@ export default {
removeUserFromGroup(user) { removeUserFromGroup(user) {
this.axios this.axios
.delete( .delete(
"/api/v1/communications/association/" + "/api/v1/association/" +
this.settings.item.id + this.settings.item.id +
"/" + "/" +
user.id user.id
@ -757,7 +757,7 @@ export default {
giveUserAdmin(user) { giveUserAdmin(user) {
this.axios this.axios
.put( .put(
"/api/v1/communications/association/" + "/api/v1/association/" +
this.settings.item.id + this.settings.item.id +
"/" + "/" +
user.id, user.id,
@ -798,7 +798,7 @@ export default {
addMembersToGroup() { addMembersToGroup() {
this.axios this.axios
.post( .post(
"/api/v1/communications/association/" + this.settings.item.chat.id, "/api/v1/association/" + this.settings.item.chat.id,
{ {
users: this.settings.addMembers.users users: this.settings.addMembers.users
} }
@ -817,7 +817,7 @@ export default {
}, },
leaveGroup() { leaveGroup() {
this.axios this.axios
.delete("/api/v1/communications/association/" + this.leave.item.id) .delete("/api/v1/association/" + this.leave.item.id)
.then(() => { .then(() => {
this.leave.dialog = false this.leave.dialog = false
this.$store.state.chats = this.$store.state.chats.filter( this.$store.state.chats = this.$store.state.chats.filter(

View file

@ -0,0 +1,693 @@
<template>
<div>
<template v-if="!message.type">
<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"
@contextmenu="show($event, 'message', message)"
>
<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>&nbsp;
</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-if="edit.id !== message.id"
v-markdown
style="overflow-wrap: anywhere"
>
{{ message.content }}
</p>
<template v-if="edit.id !== message.id">
<v-row
v-for="(embed, index) in message.embeds"
:key="index"
:id="'embed-' + index"
no-gutters
>
<v-card
elevaion="0"
:color="
embed.type === 'embed-v1' ? embed.backgroundColor : 'bg'
"
:max-width="400"
:min-width="!$vuetify.breakpoint.mobile ? 300 : 0"
class="ml-1 rounded-xl mb-1 mr-1"
>
<v-container fluid>
<v-row v-if="embed.type === 'openGraph'">
<v-col
cols="12"
class="text-xs-center"
v-if="embed.openGraph.ogImage"
>
<v-img
:src="
embed.openGraph.ogImage?.url ||
embed.openGraph.ogImage[0]?.url
"
class="elevation-1"
contain
:aspect-ratio="16 / 9"
>
<template v-slot:placeholder>
<v-row
class="fill-height ma-0"
align="center"
justify="center"
>
<v-progress-circular
indeterminate
color="grey lighten-5"
></v-progress-circular>
</v-row>
</template>
</v-img>
</v-col>
<v-col cols="12" class="text-xs-center">
<h4>
{{ embed.openGraph.ogSiteName }}
</h4>
<a
:href="embed.link"
target="_blank"
style="text-decoration: none"
>
<h3>
{{ embed.openGraph.ogTitle }}
</h3>
</a>
<p v-if="embed.openGraph.ogDescription">
{{ embed.openGraph.ogDescription }}
</p>
</v-col>
</v-row>
<template v-else-if="embed.type === 'image'">
<v-hover v-slot="{ hover }">
<div>
<v-img
@click="setImagePreview(embed)"
contain
:aspect-ratio="16 / 9"
:src="embed.mediaProxyLink"
>
<template v-slot:placeholder>
<v-row
class="fill-height ma-0"
align="center"
justify="center"
>
<v-progress-circular
indeterminate
color="grey lighten-5"
></v-progress-circular>
</v-row>
</template>
<template v-slot:default>
<v-fade-transition v-if="hover">
<v-overlay absolute>
<v-icon large>mdi-arrow-expand-all</v-icon>
</v-overlay>
</v-fade-transition>
</template>
</v-img>
</div>
</v-hover>
<v-card-actions>
MediaProxy Image
<v-spacer />
<v-btn text icon :href="embed.url" target="_blank">
<v-icon> mdi-download </v-icon>
</v-btn>
</v-card-actions>
</template>
<v-row v-else-if="embed.type === 'embed-v1'">
<v-col
cols="12"
class="text-xs-center"
v-if="embed.headerImage"
>
<v-img
:src="
embed.openGraph.headerImage?.url ||
embed.openGraph.headerImage[0]?.url
"
class="elevation-1"
contain
:aspect-ratio="16 / 9"
>
<template v-slot:placeholder>
<v-row
class="fill-height ma-0"
align="center"
justify="center"
>
<v-progress-circular
indeterminate
color="grey lighten-5"
></v-progress-circular>
</v-row>
</template>
</v-img>
</v-col>
<v-col cols="12" class="text-xs-center">
<h4 v-if="embed.title">
{{ embed.title }}
</h4>
<p v-if="embed.description">
{{ embed.description }}
</p>
<v-row
v-for="(graph, index) in embed.graphs"
:key="'graph-' + index"
>
<v-col cols="12" class="text-xs-center">
<h3>
{{ graph.name }}
</h3>
<p>Chart data could not be loaded.</p>
</v-col>
</v-row>
<v-row
v-for="(field, index) in embed.fields"
:key="'field-' + index"
:id="'field-' + index"
class="mt-1"
>
<v-col
cols="12"
class="text-xs-center"
style="white-space: pre-wrap"
>
<h4>{{ field.name }}</h4>
<p>{{ field.value }}</p>
</v-col>
</v-row>
<a
:href="embed.link.url"
v-if="embed.link"
target="_blank"
style="text-decoration: none"
>
<h3>
{{ embed.link.title }}
</h3>
</a>
<small v-if="embed.footer">
{{ embed.footer }}
</small>
</v-col>
</v-row>
<v-row v-else>
<v-container>
<h4>You must update Colubrina to see this embed.</h4>
</v-container>
</v-row>
</v-container>
</v-card>
</v-row>
</template>
<template v-if="edit.id !== message.id">
<v-card
v-for="(attachment, index) in message.attachments"
:key="attachment.id"
:id="'attachment-' + index"
max-width="40%"
elevaion="0"
color="card"
>
<v-hover
v-slot="{ hover }"
v-if="
attachment.extension === 'jpg' ||
attachment.extension === 'png' ||
attachment.extension === 'jpeg' ||
attachment.extension === 'gif'
"
>
<div>
<v-img
@click="setImagePreview(attachment)"
contain
:aspect-ratio="16 / 9"
:src="
$store.state.baseURL +
'/usercontent/' +
attachment.attachment
"
>
<template v-slot:placeholder>
<v-row
class="fill-height ma-0"
align="center"
justify="center"
>
<v-progress-circular
indeterminate
color="grey lighten-5"
></v-progress-circular>
</v-row>
</template>
<template v-slot:default>
<v-fade-transition v-if="hover">
<v-overlay absolute>
<v-icon large>mdi-arrow-expand-all</v-icon>
</v-overlay>
</v-fade-transition>
</template>
</v-img>
</div>
</v-hover>
<v-card-text v-else>
<v-icon class="mr-2" :size="48">
{{ fileTypes[attachment.extension] || "mdi-file" }}
</v-icon>
<span>
{{ attachment.name }}
</span>
</v-card-text>
<v-card-actions>
{{ attachment.name }} -
{{ friendlySize(attachment.size) }}
<v-spacer />
<v-btn
text
icon
:href="
$store.state.baseURL +
'/usercontent/' +
attachment.attachment
"
target="_blank"
>
<v-icon> mdi-download </v-icon>
</v-btn>
</v-card-actions>
</v-card>
</template>
<CommsInput
:edit="edit"
:chat="chat"
:auto-scroll="autoScroll"
:end-edit="endEdit"
v-if="edit.id === message.id"
></CommsInput>
</v-list-item-content>
<v-list-item-action v-if="!$vuetify.breakpoint.mobile">
<v-list-item-subtitle>
<v-btn
icon
v-if="message.userId === $store.state.user.id"
@click="deleteMessage(message)"
>
<v-icon> mdi-delete </v-icon>
</v-btn>
<v-btn
icon
@click="
edit.content = message.content
edit.editing = true
edit.id = message.id
"
v-if="
message.userId === $store.state.user.id &&
edit.id !== message.id
"
>
<v-icon> mdi-pencil </v-icon>
</v-btn>
<v-btn
icon
@click="
edit.content = ''
edit.editing = false
edit.id = null
"
v-if="
message.userId === $store.state.user.id &&
edit.id === message.id
"
>
<v-icon> mdi-close </v-icon>
</v-btn>
<v-btn
icon
@click="
replying(message)
focusInput()
"
>
<v-icon> mdi-reply </v-icon>
</v-btn>
</v-list-item-subtitle>
</v-list-item-action>
</v-list-item>
</template>
<template v-else-if="message.type === 'leave'">
<v-list-item :key="message.keyId" :id="'message-' + index">
<v-icon color="red" class="mr-2 ml-1"> mdi-arrow-left </v-icon>
<v-list-item-content>
{{ message.content }}
</v-list-item-content>
<v-list-item-action>
<v-list-item-subtitle>
{{ $date(message.createdAt).format("DD/MM/YYYY hh:mm A") }}
</v-list-item-subtitle>
<v-list-item-subtitle>
<v-btn
icon
v-if="message.userId === $store.state.user.id"
@click="deleteMessage(message)"
>
<v-icon> mdi-delete </v-icon>
</v-btn>
<v-btn
icon
@click="
edit.content = message.content
edit.editing = true
edit.id = message.id
"
v-if="
message.userId === $store.state.user.id &&
edit.id !== message.id
"
>
<v-icon> mdi-pencil </v-icon>
</v-btn>
<v-btn
icon
@click="
edit.content = ''
edit.editing = false
edit.id = null
"
v-if="
message.userId === $store.state.user.id &&
edit.id === message.id
"
>
<v-icon> mdi-close </v-icon>
</v-btn>
<v-btn
icon
@click="
replying(message)
focusInput()
"
>
<v-icon> mdi-reply </v-icon>
</v-btn>
</v-list-item-subtitle>
</v-list-item-action>
</v-list-item>
</template>
<template v-else-if="message.type === 'join'">
<v-list-item :key="message.keyId" :id="'message-' + index">
<v-icon color="success" class="mr-2 ml-1"> mdi-arrow-right </v-icon>
<v-list-item-content>
{{ message.content }}
</v-list-item-content>
<v-list-item-action>
<v-list-item-subtitle>
{{ $date(message.createdAt).format("DD/MM/YYYY hh:mm A") }}
</v-list-item-subtitle>
<v-list-item-subtitle>
<v-btn
icon
v-if="message.userId === $store.state.user.id"
@click="deleteMessage(message)"
>
<v-icon> mdi-delete </v-icon>
</v-btn>
<v-btn
icon
@click="
edit.content = message.content
edit.editing = true
edit.id = message.id
"
v-if="
message.userId === $store.state.user.id &&
edit.id !== message.id
"
>
<v-icon> mdi-pencil </v-icon>
</v-btn>
<v-btn
icon
@click="
edit.content = ''
edit.editing = false
edit.id = null
"
v-if="
message.userId === $store.state.user.id &&
edit.id === message.id
"
>
<v-icon> mdi-close </v-icon>
</v-btn>
<v-btn
icon
@click="
replying(message)
focusInput()
"
>
<v-icon> mdi-reply </v-icon>
</v-btn>
</v-list-item-subtitle>
</v-list-item-action>
</v-list-item>
</template>
<template v-else-if="message.type === 'rename'">
<v-list-item :key="message.keyId" :id="'message-' + index">
<v-icon color="grey" class="mr-2 ml-1"> mdi-pencil </v-icon>
<v-list-item-content>
{{ message.content }}
</v-list-item-content>
<v-list-item-action>
<v-list-item-subtitle>
{{ $date(message.createdAt).format("DD/MM/YYYY hh:mm A") }}
</v-list-item-subtitle>
<v-list-item-subtitle>
<v-btn
icon
v-if="message.userId === $store.state.user.id"
@click="deleteMessage(message)"
>
<v-icon> mdi-delete </v-icon>
</v-btn>
<v-btn
icon
@click="
edit.content = message.content
edit.editing = true
edit.id = message.id
"
v-if="
message.userId === $store.state.user.id &&
edit.id !== message.id
"
>
<v-icon> mdi-pencil </v-icon>
</v-btn>
<v-btn
icon
@click="
edit.content = ''
edit.editing = false
edit.id = null
"
v-if="
message.userId === $store.state.user.id &&
edit.id === message.id
"
>
<v-icon> mdi-close </v-icon>
</v-btn>
<v-btn
icon
@click="
replying(message)
focusInput()
"
>
<v-icon> mdi-reply </v-icon>
</v-btn>
</v-list-item-subtitle>
</v-list-item-action>
</v-list-item>
</template>
<template v-else-if="message.type === 'system'">
<v-list-item :key="message.keyId" :id="'message-' + index">
<v-icon color="grey" class="mr-2 ml-1"> mdi-pencil </v-icon>
<v-list-item-content>
{{ message.content }}
</v-list-item-content>
<v-list-item-action>
<v-list-item-subtitle>
{{ $date(message.createdAt).format("DD/MM/YYYY hh:mm A") }}
</v-list-item-subtitle>
<v-list-item-subtitle>
<v-btn
icon
v-if="message.userId === $store.state.user.id"
@click="deleteMessage(message)"
>
<v-icon> mdi-delete </v-icon>
</v-btn>
<v-btn
icon
@click="
edit.content = message.content
edit.editing = true
edit.id = message.id
"
v-if="
message.userId === $store.state.user.id &&
edit.id !== message.id
"
>
<v-icon> mdi-pencil </v-icon>
</v-btn>
<v-btn
icon
@click="
edit.content = ''
edit.editing = false
edit.id = null
"
v-if="
message.userId === $store.state.user.id &&
edit.id === message.id
"
>
<v-icon> mdi-close </v-icon>
</v-btn>
<v-btn
icon
@click="
replying(message)
focusInput()
"
>
<v-icon> mdi-reply </v-icon>
</v-btn>
</v-list-item-subtitle>
</v-list-item-action>
</v-list-item>
</template>
</div>
</template>
<script>
import CommsInput from "./CommsInput.vue"
export default {
name: "Message",
props: [
"message",
"edit",
"jumpToMessage",
"focusInput",
"replying",
"getName",
"chat",
"endEdit",
"autoScroll",
"index",
"show"
],
components: {
CommsInput
},
methods: {
friendlySize(size) {
if (size < 1024) {
return size + " bytes"
} else if (size < 1048576) {
return (size / 1024).toFixed(2) + " KB"
} else if (size < 1073741824) {
return (size / 1048576).toFixed(2) + " MB"
} else {
return (size / 1073741824).toFixed(2) + " GB"
}
}
}
}
</script>
<style scoped></style>

View file

@ -18,7 +18,7 @@ const md = require("markdown-it")({
html: false, // Enable HTML tags in source html: false, // Enable HTML tags in source
xhtmlOut: false, // Use '/' to close single tags (<br />). xhtmlOut: false, // Use '/' to close single tags (<br />).
// This is only for full CommonMark compatibility. // This is only for full CommonMark compatibility.
breaks: false, // Convert '\n' in paragraphs into <br> breaks: true, // Convert '\n' in paragraphs into <br>
langPrefix: "language-", // CSS language prefix for fenced blocks. Can be langPrefix: "language-", // CSS language prefix for fenced blocks. Can be
// useful for external highlighters. // useful for external highlighters.
linkify: true, // Autoconvert URL-like text to links linkify: true, // Autoconvert URL-like text to links

View file

@ -22,7 +22,7 @@ export default {
selectedChat() { selectedChat() {
try { try {
return this.$store.state.chats.find( return this.$store.state.chats.find(
(item) => item.id === JSON.parse(this.$route.params.id) (item) => item.id === parseInt(this.$route.params.id)
) )
} catch { } catch {
return null return null

View file

@ -125,710 +125,37 @@
color="card" color="card"
elevation="0" elevation="0"
> >
<v-card-text class="flex-grow-1 overflow-y-auto"> <v-card-text class="flex-grow-1 overflow-y-auto" id="message-list">
<v-list two-line color="card" ref="message-list"> <v-list two-line color="card" ref="message-list">
<v-card-title v-if="reachedTop">
Welcome to the start of
{{
$store.state.selectedChat?.chat?.type === "direct"
? getDirectRecipient($store.state.selectedChat).username
: $store.state.selectedChat?.chat?.name
}}
</v-card-title>
<v-progress-circular
v-if="loadingMessages"
indeterminate
size="128"
class="justify-center"
></v-progress-circular>
<template v-for="(message, index) in messages"> <template v-for="(message, index) in messages">
<template v-if="!message.type"> <Message
<v-toolbar :key="message.keyId"
@click="jumpToMessage(message.replyId)" :message="message"
:key="message.keyId + '-reply-toolbar'" :jump-to-message="jumpToMessage"
elevation="0" :edit="edit"
height="40" :focus-input="focusInput"
color="card" :replying="setReply"
v-if="message.reply" :get-name="getName"
style="cursor: pointer" :end-edit="endEdit"
> :auto-scroll="autoScroll"
<v-icon class="mr-2">mdi-reply</v-icon> :chat="chat"
<v-avatar size="24" class="mr-2"> :index="index"
<v-img :show="show"
:src=" ></Message>
$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"
@contextmenu="show($event, 'message', message)"
>
<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>&nbsp;
</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-if="edit.id !== message.id"
v-markdown
style="overflow-wrap: anywhere"
>
{{ message.content }}
</p>
<template v-if="edit.id !== message.id">
<v-row
v-for="(embed, index) in message.embeds"
:key="index"
:id="'embed-' + index"
no-gutters
>
<v-card
elevaion="0"
:color="
embed.type === 'embed-v1'
? embed.backgroundColor
: 'bg'
"
:max-width="400"
:min-width="!$vuetify.breakpoint.mobile ? 300 : 0"
class="ml-1 rounded-xl mb-1 mr-1"
>
<v-container fluid>
<v-row v-if="embed.type === 'openGraph'">
<v-col
cols="12"
class="text-xs-center"
v-if="embed.openGraph.ogImage"
>
<v-img
:src="
embed.openGraph.ogImage?.url ||
embed.openGraph.ogImage[0]?.url
"
class="elevation-1"
contain
:aspect-ratio="16 / 9"
>
<template v-slot:placeholder>
<v-row
class="fill-height ma-0"
align="center"
justify="center"
>
<v-progress-circular
indeterminate
color="grey lighten-5"
></v-progress-circular>
</v-row>
</template>
</v-img>
</v-col>
<v-col cols="12" class="text-xs-center">
<h4>
{{ embed.openGraph.ogSiteName }}
</h4>
<a
:href="embed.link"
target="_blank"
style="text-decoration: none"
>
<h3>
{{ embed.openGraph.ogTitle }}
</h3>
</a>
<p v-if="embed.openGraph.ogDescription">
{{ embed.openGraph.ogDescription }}
</p>
</v-col>
</v-row>
<template v-else-if="embed.type === 'image'">
<v-hover v-slot="{ hover }">
<div>
<v-img
@click="setImagePreview(embed)"
contain
:aspect-ratio="16 / 9"
:src="embed.mediaProxyLink"
>
<template v-slot:placeholder>
<v-row
class="fill-height ma-0"
align="center"
justify="center"
>
<v-progress-circular
indeterminate
color="grey lighten-5"
></v-progress-circular>
</v-row>
</template>
<template v-slot:default>
<v-fade-transition v-if="hover">
<v-overlay absolute>
<v-icon large
>mdi-arrow-expand-all</v-icon
>
</v-overlay>
</v-fade-transition>
</template>
</v-img>
</div>
</v-hover>
<v-card-actions>
MediaProxy Image
<v-spacer />
<v-btn
text
icon
:href="embed.url"
target="_blank"
>
<v-icon> mdi-download </v-icon>
</v-btn>
</v-card-actions>
</template>
<v-row v-else-if="embed.type === 'embed-v1'">
<v-col
cols="12"
class="text-xs-center"
v-if="embed.headerImage"
>
<v-img
:src="
embed.openGraph.headerImage?.url ||
embed.openGraph.headerImage[0]?.url
"
class="elevation-1"
contain
:aspect-ratio="16 / 9"
>
<template v-slot:placeholder>
<v-row
class="fill-height ma-0"
align="center"
justify="center"
>
<v-progress-circular
indeterminate
color="grey lighten-5"
></v-progress-circular>
</v-row>
</template>
</v-img>
</v-col>
<v-col cols="12" class="text-xs-center">
<h4 v-if="embed.title">
{{ embed.title }}
</h4>
<p v-if="embed.description">
{{ embed.description }}
</p>
<v-row
v-for="(graph, index) in embed.graphs"
:key="'graph-' + index"
>
<v-col cols="12" class="text-xs-center">
<h3>
{{ graph.name }}
</h3>
<Chart
:chart-data="graph.data"
v-if="graph.data"
:options="graphOptions"
></Chart>
<p v-else>
Chart data could not be loaded.
</p>
</v-col>
</v-row>
<v-row
v-for="(field, index) in embed.fields"
:key="'field-' + index"
:id="'field-' + index"
class="mt-1"
>
<v-col
cols="12"
class="text-xs-center"
style="white-space: pre-wrap"
>
<h4>{{ field.name }}</h4>
<p>{{ field.value }}</p>
</v-col>
</v-row>
<a
:href="embed.link.url"
v-if="embed.link"
target="_blank"
style="text-decoration: none"
>
<h3>
{{ embed.link.title }}
</h3>
</a>
<small v-if="embed.footer">
{{ embed.footer }}
</small>
</v-col>
</v-row>
<v-row v-else>
<v-container>
<h4>
You must update Colubrina to see this embed.
</h4>
</v-container>
</v-row>
</v-container>
</v-card>
</v-row>
</template>
<template v-if="edit.id !== message.id">
<v-card
v-for="(attachment, index) in message.attachments"
:key="attachment.id"
:id="'attachment-' + index"
max-width="40%"
elevaion="0"
color="card"
>
<v-hover
v-slot="{ hover }"
v-if="
attachment.extension === 'jpg' ||
attachment.extension === 'png' ||
attachment.extension === 'jpeg' ||
attachment.extension === 'gif'
"
>
<div>
<v-img
@click="setImagePreview(attachment)"
contain
:aspect-ratio="16 / 9"
:src="
$store.state.baseURL +
'/usercontent/' +
attachment.attachment
"
>
<template v-slot:placeholder>
<v-row
class="fill-height ma-0"
align="center"
justify="center"
>
<v-progress-circular
indeterminate
color="grey lighten-5"
></v-progress-circular>
</v-row>
</template>
<template v-slot:default>
<v-fade-transition v-if="hover">
<v-overlay absolute>
<v-icon large
>mdi-arrow-expand-all</v-icon
>
</v-overlay>
</v-fade-transition>
</template>
</v-img>
</div>
</v-hover>
<v-card-text v-else>
<v-icon class="mr-2" :size="48">
{{
fileTypes[attachment.extension] || "mdi-file"
}}
</v-icon>
<span>
{{ attachment.name }}
</span>
</v-card-text>
<v-card-actions>
{{ attachment.name }} -
{{ friendlySize(attachment.size) }}
<v-spacer />
<v-btn
text
icon
:href="
$store.state.baseURL +
'/usercontent/' +
attachment.attachment
"
target="_blank"
>
<v-icon> mdi-download </v-icon>
</v-btn>
</v-card-actions>
</v-card>
</template>
<CommsInput
:edit="edit"
:chat="chat"
:auto-scroll="autoScroll"
:end-edit="endEdit"
v-if="edit.id === message.id"
></CommsInput>
</v-list-item-content>
<v-list-item-action v-if="!$vuetify.breakpoint.mobile">
<v-list-item-subtitle>
<v-btn
icon
v-if="message.userId === $store.state.user.id"
@click="deleteMessage(message)"
>
<v-icon> mdi-delete </v-icon>
</v-btn>
<v-btn
icon
@click="
edit.content = message.content
edit.editing = true
edit.id = message.id
"
v-if="
message.userId === $store.state.user.id &&
edit.id !== message.id
"
>
<v-icon> mdi-pencil </v-icon>
</v-btn>
<v-btn
icon
@click="
edit.content = ''
edit.editing = false
edit.id = null
"
v-if="
message.userId === $store.state.user.id &&
edit.id === message.id
"
>
<v-icon> mdi-close </v-icon>
</v-btn>
<v-btn
icon
@click="
replying = message
focusInput()
"
>
<v-icon> mdi-reply </v-icon>
</v-btn>
</v-list-item-subtitle>
</v-list-item-action>
</v-list-item>
</template>
<template v-else-if="message.type === 'leave'">
<v-list-item :key="message.keyId" :id="'message-' + index">
<v-icon color="red" class="mr-2 ml-1">
mdi-arrow-left
</v-icon>
<v-list-item-content>
{{ message.content }}
</v-list-item-content>
<v-list-item-action>
<v-list-item-subtitle>
{{
$date(message.createdAt).format("DD/MM/YYYY hh:mm A")
}}
</v-list-item-subtitle>
<v-list-item-subtitle>
<v-btn
icon
v-if="message.userId === $store.state.user.id"
@click="deleteMessage(message)"
>
<v-icon> mdi-delete </v-icon>
</v-btn>
<v-btn
icon
@click="
edit.content = message.content
edit.editing = true
edit.id = message.id
"
v-if="
message.userId === $store.state.user.id &&
edit.id !== message.id
"
>
<v-icon> mdi-pencil </v-icon>
</v-btn>
<v-btn
icon
@click="
edit.content = ''
edit.editing = false
edit.id = null
"
v-if="
message.userId === $store.state.user.id &&
edit.id === message.id
"
>
<v-icon> mdi-close </v-icon>
</v-btn>
<v-btn
icon
@click="
replying = message
focusInput()
"
>
<v-icon> mdi-reply </v-icon>
</v-btn>
</v-list-item-subtitle>
</v-list-item-action>
</v-list-item>
</template>
<template v-else-if="message.type === 'join'">
<v-list-item :key="message.keyId" :id="'message-' + index">
<v-icon color="success" class="mr-2 ml-1">
mdi-arrow-right
</v-icon>
<v-list-item-content>
{{ message.content }}
</v-list-item-content>
<v-list-item-action>
<v-list-item-subtitle>
{{
$date(message.createdAt).format("DD/MM/YYYY hh:mm A")
}}
</v-list-item-subtitle>
<v-list-item-subtitle>
<v-btn
icon
v-if="message.userId === $store.state.user.id"
@click="deleteMessage(message)"
>
<v-icon> mdi-delete </v-icon>
</v-btn>
<v-btn
icon
@click="
edit.content = message.content
edit.editing = true
edit.id = message.id
"
v-if="
message.userId === $store.state.user.id &&
edit.id !== message.id
"
>
<v-icon> mdi-pencil </v-icon>
</v-btn>
<v-btn
icon
@click="
edit.content = ''
edit.editing = false
edit.id = null
"
v-if="
message.userId === $store.state.user.id &&
edit.id === message.id
"
>
<v-icon> mdi-close </v-icon>
</v-btn>
<v-btn
icon
@click="
replying = message
focusInput()
"
>
<v-icon> mdi-reply </v-icon>
</v-btn>
</v-list-item-subtitle>
</v-list-item-action>
</v-list-item>
</template>
<template v-else-if="message.type === 'rename'">
<v-list-item :key="message.keyId" :id="'message-' + index">
<v-icon color="grey" class="mr-2 ml-1"> mdi-pencil </v-icon>
<v-list-item-content>
{{ message.content }}
</v-list-item-content>
<v-list-item-action>
<v-list-item-subtitle>
{{
$date(message.createdAt).format("DD/MM/YYYY hh:mm A")
}}
</v-list-item-subtitle>
<v-list-item-subtitle>
<v-btn
icon
v-if="message.userId === $store.state.user.id"
@click="deleteMessage(message)"
>
<v-icon> mdi-delete </v-icon>
</v-btn>
<v-btn
icon
@click="
edit.content = message.content
edit.editing = true
edit.id = message.id
"
v-if="
message.userId === $store.state.user.id &&
edit.id !== message.id
"
>
<v-icon> mdi-pencil </v-icon>
</v-btn>
<v-btn
icon
@click="
edit.content = ''
edit.editing = false
edit.id = null
"
v-if="
message.userId === $store.state.user.id &&
edit.id === message.id
"
>
<v-icon> mdi-close </v-icon>
</v-btn>
<v-btn
icon
@click="
replying = message
focusInput()
"
>
<v-icon> mdi-reply </v-icon>
</v-btn>
</v-list-item-subtitle>
</v-list-item-action>
</v-list-item>
</template>
<template v-else-if="message.type === 'system'">
<v-list-item :key="message.keyId" :id="'message-' + index">
<v-icon color="grey" class="mr-2 ml-1"> mdi-pencil </v-icon>
<v-list-item-content>
{{ message.content }}
</v-list-item-content>
<v-list-item-action>
<v-list-item-subtitle>
{{
$date(message.createdAt).format("DD/MM/YYYY hh:mm A")
}}
</v-list-item-subtitle>
<v-list-item-subtitle>
<v-btn
icon
v-if="message.userId === $store.state.user.id"
@click="deleteMessage(message)"
>
<v-icon> mdi-delete </v-icon>
</v-btn>
<v-btn
icon
@click="
edit.content = message.content
edit.editing = true
edit.id = message.id
"
v-if="
message.userId === $store.state.user.id &&
edit.id !== message.id
"
>
<v-icon> mdi-pencil </v-icon>
</v-btn>
<v-btn
icon
@click="
edit.content = ''
edit.editing = false
edit.id = null
"
v-if="
message.userId === $store.state.user.id &&
edit.id === message.id
"
>
<v-icon> mdi-close </v-icon>
</v-btn>
<v-btn
icon
@click="
replying = message
focusInput()
"
>
<v-icon> mdi-reply </v-icon>
</v-btn>
</v-list-item-subtitle>
</v-list-item-action>
</v-list-item>
</template>
</template> </template>
</v-list> </v-list>
</v-card-text> </v-card-text>
@ -1353,34 +680,16 @@
<script> <script>
import AjaxErrorHandler from "@/lib/errorHandler" import AjaxErrorHandler from "@/lib/errorHandler"
import CommsInput from "@/components/CommsInput" import CommsInput from "@/components/CommsInput"
import { Line as Chart } from "vue-chartjs/legacy"
import {
Chart as ChartJS,
Title,
Tooltip,
Legend,
CategoryScale,
LinearScale,
PointElement,
LineElement
} from "chart.js"
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"
ChartJS.register(
Title,
Tooltip,
Legend,
CategoryScale,
LinearScale,
PointElement,
LineElement
)
export default { export default {
name: "CommunicationsChat", name: "CommunicationsChat",
components: { UserDialog, NicknameDialog, CommsInput, Chart }, components: { Message, UserDialog, NicknameDialog, CommsInput },
props: ["chat", "loading", "items"], props: ["chat", "loading", "items"],
data: () => ({ data: () => ({
reachedTop: false,
graphOptions: { graphOptions: {
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
@ -1388,7 +697,7 @@ export default {
display: false display: false
} }
}, },
page: 1, offset: 0,
nickname: { nickname: {
dialog: false, dialog: false,
nickname: "", nickname: "",
@ -1479,22 +788,51 @@ export default {
blobURL: "", blobURL: "",
autoScrollRetry: 0, autoScrollRetry: 0,
searchPanel: false, searchPanel: false,
userPanel: true userPanel: true,
rateLimit: false,
loadingMessages: true,
avoidAutoScroll: false
}), }),
computed: { computed: {
associations() { associations() {
return this.chat.chat.associations.slice().sort((a, b) => { if (this.chat) {
if (a.lastRead > b.lastRead) { return this.chat.chat.associations.slice().sort((a, b) => {
return -1 if (a.lastRead > b.lastRead) {
} else if (a.lastRead < b.lastRead) { return -1
return 1 } else if (a.lastRead < b.lastRead) {
} else { return 1
return 0 } else {
} return 0
}) }
})
} else {
console.log("Chat could not be found (associations)")
return []
}
} }
}, },
methods: { methods: {
scrollEvent(e) {
this.avoidAutoScroll =
e.target.scrollTop + e.target.offsetHeight !== e.target.scrollHeight
if (
e.target.scrollTop === 0 &&
!this.rateLimit &&
!this.reachedTop &&
!this.loadingMessages
) {
this.rateLimit = true
this.offset += 50
this.loadingMessages = true
this.getMessages()
setTimeout(() => {
this.rateLimit = false
}, 250)
}
},
setReply(message) {
this.replying = message
},
markAsRead() { markAsRead() {
if (this.items) { if (this.items) {
try { try {
@ -1708,45 +1046,47 @@ export default {
this.edit.id = "" this.edit.id = ""
this.focusInput() this.focusInput()
}, },
autoScroll(smooth = true) { autoScroll() {
this.$nextTick(() => { if (!this.avoidAutoScroll) {
try { this.$nextTick(() => {
const lastIndex = this.messages.length - 1 try {
const lastMessage = document.querySelector(`#message-${lastIndex}`) const lastIndex = this.messages.length - 1
if (smooth) { const lastMessage = document.querySelector(`#message-${lastIndex}`)
lastMessage.scrollIntoView({
behavior: "smooth"
})
} else {
lastMessage.scrollIntoView() lastMessage.scrollIntoView()
this.autoScrollRetry = 0
} catch (e) {
console.log("Could not auto scroll, retrying...")
if (this.autoScrollRetry < 20) {
setTimeout(() => {
this.autoScroll()
}, 50)
this.autoScrollRetry++
} else {
console.log("Could not auto scroll, retry limit reached")
}
} }
this.autoScrollRetry = 0 })
} catch (e) { }
console.log("Could not auto scroll, retrying...")
if (this.autoScrollRetry < 20) {
setTimeout(() => {
this.autoScroll(smooth)
}, 50)
this.autoScrollRetry++
} else {
console.log("Could not auto scroll, retry limit reached")
}
}
})
}, },
getMessages() { getMessages() {
this.loadingMessages = true
this.axios this.axios
.get( .get(
process.env.VUE_APP_BASE_URL + process.env.VUE_APP_BASE_URL +
"/api/v1/communications/" + "/api/v1/communications/" +
this.$route.params.id + this.$route.params.id +
"/messages?limit=50" "/messages?limit=50&offset=" +
this.offset
) )
.then((res) => { .then((res) => {
this.messages = res.data if (!res.data.length) {
this.reachedTop = true
}
this.messages.unshift(...res.data)
this.loadingMessages = false
this.markRead() this.markRead()
this.$nextTick(() => { this.$nextTick(() => {
this.autoScroll(false) this.autoScroll()
}) })
}) })
.catch((e) => { .catch((e) => {
@ -1764,15 +1104,9 @@ export default {
} }
}, },
mounted() { mounted() {
/* // check if document.querySelector(`#message-${lastIndex}`) is scrolled to the top, and load new messages document
window.addEventListener("scroll", () => { .getElementById("message-list")
const lastIndex = this.messages.length - 1 .addEventListener("scroll", this.scrollEvent)
const lastMessage = document.querySelector(`#message-${lastIndex}`)
if (lastMessage && lastMessage.getBoundingClientRect().top < 0) {
this.page += 1
this.getMessages()
}
})*/
setInterval(() => { setInterval(() => {
this.typing() this.typing()
}, 1000) }, 1000)
@ -1794,8 +1128,9 @@ export default {
this.messages.push(message) this.messages.push(message)
this.autoScroll() this.autoScroll()
this.markRead() this.markRead()
if (this.messages.length > 50) { if (this.messages.length > 50 && !this.avoidAutoScroll) {
this.messages.shift() this.messages.shift()
this.reachedTop = false
} }
} }
}) })
@ -1816,7 +1151,7 @@ export default {
if (index !== -1) { if (index !== -1) {
this.messages[index].keyId = message.id + "-" + message.editedAt this.messages[index].keyId = message.id + "-" + message.editedAt
this.messages[index].embeds = message.embeds this.messages[index].embeds = message.embeds
this.autoScroll(true) this.autoScroll()
} }
} }
}) })
@ -1860,6 +1195,7 @@ export default {
this.messages = [] this.messages = []
this.usersTyping = [] this.usersTyping = []
this.replying = null this.replying = null
this.offset = 0
this.getMessages() this.getMessages()
} }
} }

View file

@ -1258,10 +1258,10 @@
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.11.tgz#d421b6c527a3037f7c84433fd2c4229e016863d3" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.11.tgz#d421b6c527a3037f7c84433fd2c4229e016863d3"
integrity sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ== integrity sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==
"@types/mime@^1": "@types/mime@*":
version "1.3.2" version "3.0.0"
resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.2.tgz#93e25bf9ee75fe0fd80b594bc4feb0e862111b5a" resolved "https://registry.yarnpkg.com/@types/mime/-/mime-3.0.0.tgz#e9a9903894405c6a6551f1774df4e64d9804d69c"
integrity sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw== integrity sha512-fccbsHKqFDXClBZTDLA43zl0+TbxyIwyzIzwwhvoJvhNjOErCdeX2xJbURimv2EbSVUGav001PaCJg4mZxMl4w==
"@types/minimatch@*": "@types/minimatch@*":
version "3.0.5" version "3.0.5"
@ -1299,11 +1299,11 @@
integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw== integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==
"@types/serve-static@*": "@types/serve-static@*":
version "1.13.10" version "1.15.0"
resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.13.10.tgz#f5e0ce8797d2d7cc5ebeda48a52c96c4fa47a8d9" resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.15.0.tgz#c7930ff61afb334e121a9da780aac0d9b8f34155"
integrity sha512-nCkHGI4w7ZgAdNkrEu0bv+4xNV/XDqW+DydknebMOQwkpDGx8G+HTlj7R7ABI8i8nKxVw0wtKPi1D+lPOkh4YQ== integrity sha512-z5xyF6uh8CbjAu9760KDKsH2FcDxZ2tFCsA4HIMWE6IkiYMXfVoa+4f9KX+FN0ZLsaMw1WNG2ETLA6N+/YA+cg==
dependencies: dependencies:
"@types/mime" "^1" "@types/mime" "*"
"@types/node" "*" "@types/node" "*"
"@types/source-list-map@*": "@types/source-list-map@*":
@ -2584,7 +2584,7 @@ browserify-zlib@^0.2.0:
dependencies: dependencies:
pako "~1.0.5" pako "~1.0.5"
browserslist@^4.0.0, browserslist@^4.12.0, browserslist@^4.20.2, browserslist@^4.21.2: browserslist@^4.0.0, browserslist@^4.12.0, browserslist@^4.20.2, browserslist@^4.21.3:
version "4.21.3" version "4.21.3"
resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.21.3.tgz#5df277694eb3c48bc5c4b05af3e8b7e09c5a6d1a" resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.21.3.tgz#5df277694eb3c48bc5c4b05af3e8b7e09c5a6d1a"
integrity sha512-898rgRXLAyRkM1GryrrBHGkqA5hlpkV5MhtZwg9QXeiyLUYs2k00Un05aX5l2/yJIOObYKOpS2JNo8nJDE7fWQ== integrity sha512-898rgRXLAyRkM1GryrrBHGkqA5hlpkV5MhtZwg9QXeiyLUYs2k00Un05aX5l2/yJIOObYKOpS2JNo8nJDE7fWQ==
@ -3206,11 +3206,11 @@ copyfiles@^2.4.0:
yargs "^16.1.0" yargs "^16.1.0"
core-js-compat@^3.21.0, core-js-compat@^3.22.1, core-js-compat@^3.6.5: core-js-compat@^3.21.0, core-js-compat@^3.22.1, core-js-compat@^3.6.5:
version "3.24.0" version "3.24.1"
resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.24.0.tgz#885958fac38bf3f4464a90f2663b4620f6aee6e3" resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.24.1.tgz#d1af84a17e18dfdd401ee39da9996f9a7ba887de"
integrity sha512-F+2E63X3ff/nj8uIrf8Rf24UDGIz7p838+xjEp+Bx3y8OWXj+VTPPZNCtdqovPaS9o7Tka5mCH01Zn5vOd6UQg== integrity sha512-XhdNAGeRnTpp8xbD+sR/HFDK9CbeeeqXT6TuofXh3urqEevzkWmLRgrVoykodsw8okqo2pu1BOmuCKrHx63zdw==
dependencies: dependencies:
browserslist "^4.21.2" browserslist "^4.21.3"
semver "7.0.0" semver "7.0.0"
core-js@^2.4.0: core-js@^2.4.0:
@ -3219,9 +3219,9 @@ core-js@^2.4.0:
integrity sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ== integrity sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==
core-js@^3.6.5: core-js@^3.6.5:
version "3.24.0" version "3.24.1"
resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.24.0.tgz#4928d4e99c593a234eb1a1f9abd3122b04d3ac57" resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.24.1.tgz#cf7724d41724154010a6576b7b57d94c5d66e64f"
integrity sha512-IeOyT8A6iK37Ep4kZDD423mpi6JfPRoPUdQwEWYiGolvn4o6j2diaRzNfDfpTdu3a5qMbrGUzKUpYpRY8jXCkQ== integrity sha512-0QTBSYSUZ6Gq21utGzkfITDylE8jWC9Ne1D2MrhvlsZBI1x39OdDIVbzSqtgMndIy6BlHxBXpMGqzZmnztg2rg==
core-util-is@1.0.2: core-util-is@1.0.2:
version "1.0.2" version "1.0.2"
@ -9520,9 +9520,9 @@ vuetify-loader@^1.7.0:
loader-utils "^2.0.0" loader-utils "^2.0.0"
vuetify@^2.6.4: vuetify@^2.6.4:
version "2.6.7" version "2.6.8"
resolved "https://registry.yarnpkg.com/vuetify/-/vuetify-2.6.7.tgz#9c6fb7d20e1c4b07417084cbe5996c2dadf099df" resolved "https://registry.yarnpkg.com/vuetify/-/vuetify-2.6.8.tgz#3f4cfa56b7eff4bad1818cd1812b47f6754e140a"
integrity sha512-1XxCv6mt1UsKaW7rqow0hF/jOw1ijT/fHH9euWLHgSVIcPAx8D2AY5ihTCBbMmV53Hj/YD0DUvzyk6cM/OGHvw== integrity sha512-CbJsIGfye++an5/I5ypmYgf74vxt5j0NJ/7UUIDXRYXZsM9YuEpnqo97Ob4LD6QEli1gJa6WXWS8pXLWk0ArPQ==
vuex@^3.4.0: vuex@^3.4.0:
version "3.6.2" version "3.6.2"