diff --git a/README.md b/README.md index 2ce3d9d..d0132f6 100644 --- a/README.md +++ b/README.md @@ -25,8 +25,8 @@ Colubrina is a simple self-hostable chatting platform written in Vue, and Vuetif - [x] Mobile responsiveness/compatibility - [x] Email verification - [ ] Password resetting -- [ ] Channel message pins -- [ ] Read receipts +- [x] Channel message pins +- [x] Read receipts Chat Friends diff --git a/backend/migrations/20220731050926-pins.js b/backend/migrations/20220731050926-pins.js new file mode 100644 index 0000000..31d8387 --- /dev/null +++ b/backend/migrations/20220731050926-pins.js @@ -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'); + */ + } +} diff --git a/backend/models/messages.js b/backend/models/messages.js index 3b89583..f082819 100644 --- a/backend/models/messages.js +++ b/backend/models/messages.js @@ -22,6 +22,10 @@ module.exports = (sequelize, DataTypes) => { as: "attachments", foreignKey: "messageId" }) + Message.hasMany(models.ChatAssociation, { + as: "readReceipts", + foreignKey: "lastRead" + }) } } Message.init( diff --git a/backend/models/pins.js b/backend/models/pins.js new file mode 100644 index 0000000..5c2bd20 --- /dev/null +++ b/backend/models/pins.js @@ -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 +} diff --git a/backend/routes/communications.js b/backend/routes/communications.js index e4b416d..d40e27d 100644 --- a/backend/routes/communications.js +++ b/backend/routes/communications.js @@ -6,6 +6,7 @@ const { User, Chat, ChatAssociation, + Pin, Message, Friend, Attachment, @@ -66,6 +67,18 @@ async function createMessage(req, type, content, association, userId) { id: message.id }, include: [ + { + model: ChatAssociation, + as: "readReceipts", + attributes: ["id"], + include: [ + { + model: User, + as: "user", + attributes: ["username", "name", "avatar", "id"] + } + ] + }, { model: Attachment, as: "attachments" @@ -124,7 +137,6 @@ async function createMessage(req, type, content, association, userId) { ] }) associations.forEach((user) => { - console.log(user) io.to(user.dataValues.userId).emit("message", { ...messageLookup.dataValues, associationId: user.dataValues.id, @@ -196,9 +208,7 @@ router.get("/", auth, async (req, res, next) => { "id", "createdAt", "updatedAt", - "status", - "admin", "status", "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) => { try { const io = req.app.get("io") @@ -481,6 +655,11 @@ router.put("/:id/read", auth, async (req, res, next) => { model: Chat, as: "chat", include: [ + { + model: User, + as: "users", + attributes: ["id"] + }, { model: Message, as: "lastMessages", @@ -501,6 +680,20 @@ router.put("/:id/read", auth, async (req, res, next) => { lastRead: chat.chat.lastMessages[0]?.id || null }) 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 { throw Errors.invalidParameter("chat association id") } @@ -877,6 +1070,18 @@ router.post( id: message.id }, include: [ + { + model: ChatAssociation, + as: "readReceipts", + attributes: ["id"], + include: [ + { + model: User, + as: "user", + attributes: ["username", "name", "avatar", "id"] + } + ] + }, { model: Attachment, as: "attachments" @@ -1130,6 +1335,18 @@ router.post("/:id/message", auth, limiter, async (req, res, next) => { id: message.id }, include: [ + { + model: ChatAssociation, + as: "readReceipts", + attributes: ["id"], + include: [ + { + model: User, + as: "user", + attributes: ["username", "name", "avatar", "id"] + } + ] + }, { model: Attachment, as: "attachments" @@ -1399,6 +1616,18 @@ router.get("/:id/messages", auth, async (req, res, next) => { ...or }, include: [ + { + model: ChatAssociation, + as: "readReceipts", + attributes: ["id"], + include: [ + { + model: User, + as: "user", + attributes: ["username", "name", "avatar", "id"] + } + ] + }, { model: Attachment, as: "attachments" @@ -1409,12 +1638,10 @@ router.get("/:id/messages", auth, async (req, res, next) => { attributes: [ "username", "name", - "avatar", "id", "createdAt", "updatedAt", - "bot" ], include: [ @@ -1439,12 +1666,10 @@ router.get("/:id/messages", auth, async (req, res, next) => { attributes: [ "username", "name", - "avatar", "id", "createdAt", "updatedAt", - "bot" ] }, diff --git a/frontend/package.json b/frontend/package.json index 77e7571..90f2312 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "colubrina-chat", - "version": "1.0.8", + "version": "1.0.9", "private": true, "author": "Troplo ", "license": "GPL-3.0", diff --git a/frontend/src/assets/styles.css b/frontend/src/assets/styles.css index 445b360..880e701 100644 --- a/frontend/src/assets/styles.css +++ b/frontend/src/assets/styles.css @@ -1,3 +1,7 @@ +.message-hover { + background-color: var(--v-bg-lighten1); + border-radius: 5px; +} .message-toast { background-color: rgba(47, 47, 47, 0.9) !important; } diff --git a/frontend/src/components/Header.vue b/frontend/src/components/Header.vue index 5336cf6..1e94f92 100644 --- a/frontend/src/components/Header.vue +++ b/frontend/src/components/Header.vue @@ -338,6 +338,14 @@ }} + + mdi-pin-outline + mdi-magnify - mdi-account-group + mdi-account-group-outline