diff --git a/README.md b/README.md index c17d4c2..858a861 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Colubrina is a simple self-hostable chatting platform written in Vue, and Vuetif - [x] Authentication - [x] Admin panel - [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] Group creation and modification - [x] Direct message groups diff --git a/backend/index.js b/backend/index.js index 32ec033..203678d 100644 --- a/backend/index.js +++ b/backend/index.js @@ -21,6 +21,7 @@ app.use("/api/v1/admin", require("./routes/admin.js")) app.use("/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/associations", require("./routes/associations.js")) app.get("/api/v1/state", async (req, res) => { res.json({ release: process.env.RELEASE, diff --git a/backend/lib/socket.js b/backend/lib/socket.js index 3b0651b..e7bb235 100644 --- a/backend/lib/socket.js +++ b/backend/lib/socket.js @@ -98,15 +98,18 @@ module.exports = { } }) socket.on("disconnect", async function () { - friends.forEach((friend) => { - io.to(friend.friendId).emit("userStatus", { - userId: user.id, + const clients = io.sockets.adapter.rooms.get(user.id) || new Set() + if (!clients.size || clients.size === 0) { + friends.forEach((friend) => { + io.to(friend.friendId).emit("userStatus", { + userId: user.id, + status: "offline" + }) + }) + await user.update({ status: "offline" }) - }) - await user.update({ - status: "offline" - }) + } }) } else { socket.join(-1) diff --git a/backend/routes/associations.js b/backend/routes/associations.js new file mode 100644 index 0000000..efdd2fe --- /dev/null +++ b/backend/routes/associations.js @@ -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 diff --git a/backend/routes/communications.js b/backend/routes/communications.js index 9b40c76..b89c521 100644 --- a/backend/routes/communications.js +++ b/backend/routes/communications.js @@ -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) => { try { 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) => { try { 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"]], limit: 50 }) @@ -2096,10 +1574,10 @@ router.post("/create", auth, async (req, res, next) => { type }) 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 if (type === "group") { - if (req.body.users[i] === req.user.id) { + if (id === req.user.id) { rank = "admin" } else { rank = "member" @@ -2107,14 +1585,17 @@ router.post("/create", auth, async (req, res, next) => { } else { rank = "member" } - const c1 = await ChatAssociation.create({ + await ChatAssociation.create({ chatId: chat.id, - userId: req.body.users[i], + userId: id, rank }) + } + for (const id of req.body.users) { const association = await ChatAssociation.findOne({ where: { - id: c1.id + chatId: chat.id, + userId: id }, 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({ where: { diff --git a/frontend/package.json b/frontend/package.json index 00b3281..0b9b587 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "colubrina-chat", - "version": "1.0.4", + "version": "1.0.5", "private": true, "author": "Troplo ", "license": "GPL-3.0", diff --git a/frontend/src/components/Header.vue b/frontend/src/components/Header.vue index 0c8bddf..252624c 100644 --- a/frontend/src/components/Header.vue +++ b/frontend/src/components/Header.vue @@ -742,7 +742,7 @@ export default { removeUserFromGroup(user) { this.axios .delete( - "/api/v1/communications/association/" + + "/api/v1/association/" + this.settings.item.id + "/" + user.id @@ -757,7 +757,7 @@ export default { giveUserAdmin(user) { this.axios .put( - "/api/v1/communications/association/" + + "/api/v1/association/" + this.settings.item.id + "/" + user.id, @@ -798,7 +798,7 @@ export default { addMembersToGroup() { this.axios .post( - "/api/v1/communications/association/" + this.settings.item.chat.id, + "/api/v1/association/" + this.settings.item.chat.id, { users: this.settings.addMembers.users } @@ -817,7 +817,7 @@ export default { }, leaveGroup() { this.axios - .delete("/api/v1/communications/association/" + this.leave.item.id) + .delete("/api/v1/association/" + this.leave.item.id) .then(() => { this.leave.dialog = false this.$store.state.chats = this.$store.state.chats.filter( diff --git a/frontend/src/components/Message.vue b/frontend/src/components/Message.vue new file mode 100644 index 0000000..03fc35e --- /dev/null +++ b/frontend/src/components/Message.vue @@ -0,0 +1,693 @@ + + + + + diff --git a/frontend/src/main.js b/frontend/src/main.js index 1e23fbc..6e80720 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -18,7 +18,7 @@ const md = require("markdown-it")({ html: false, // Enable HTML tags in source xhtmlOut: false, // Use '/' to close single tags (
). // This is only for full CommonMark compatibility. - breaks: false, // Convert '\n' in paragraphs into
+ breaks: true, // Convert '\n' in paragraphs into
langPrefix: "language-", // CSS language prefix for fenced blocks. Can be // useful for external highlighters. linkify: true, // Autoconvert URL-like text to links diff --git a/frontend/src/views/Communications/Communications.vue b/frontend/src/views/Communications/Communications.vue index f133096..16b674e 100644 --- a/frontend/src/views/Communications/Communications.vue +++ b/frontend/src/views/Communications/Communications.vue @@ -22,7 +22,7 @@ export default { selectedChat() { try { return this.$store.state.chats.find( - (item) => item.id === JSON.parse(this.$route.params.id) + (item) => item.id === parseInt(this.$route.params.id) ) } catch { return null diff --git a/frontend/src/views/Communications/CommunicationsChat.vue b/frontend/src/views/Communications/CommunicationsChat.vue index eb63a55..9c3effb 100644 --- a/frontend/src/views/Communications/CommunicationsChat.vue +++ b/frontend/src/views/Communications/CommunicationsChat.vue @@ -125,710 +125,37 @@ color="card" elevation="0" > - + + + Welcome to the start of + {{ + $store.state.selectedChat?.chat?.type === "direct" + ? getDirectRecipient($store.state.selectedChat).username + : $store.state.selectedChat?.chat?.name + }} + + @@ -1353,34 +680,16 @@