1.0.9 [Pins & Read Receipts]

This commit is contained in:
Troplo 2022-07-31 18:42:36 +10:00
parent 19bca17ba0
commit ab4c172899
12 changed files with 1397 additions and 678 deletions

View file

@ -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
<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>

View 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');
*/
}
}

View file

@ -22,6 +22,10 @@ module.exports = (sequelize, DataTypes) => {
as: "attachments",
foreignKey: "messageId"
})
Message.hasMany(models.ChatAssociation, {
as: "readReceipts",
foreignKey: "lastRead"
})
}
}
Message.init(

51
backend/models/pins.js Normal file
View 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
}

View file

@ -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"
]
},

View file

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

View file

@ -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;
}

View file

@ -338,6 +338,14 @@
}}
</v-toolbar-title>
<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
icon
@click="$store.state.searchPanel = !$store.state.searchPanel"
@ -345,7 +353,7 @@
<v-icon>mdi-magnify</v-icon>
</v-btn>
<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>
</template>
<template v-else>
@ -636,7 +644,8 @@ export default {
value: false,
x: null,
y: null,
item: null
item: null,
id: 0
}
},
selected: [2],
@ -684,16 +693,28 @@ export default {
this.leave.item = this.$store.state.chats.find((chat) => chat.id === id)
this.leave.dialog = true
},
show(e, context, item, id) {
e.preventDefault()
this.context[context].value = false
this.context[context].x = e.clientX
this.context[context].y = e.clientY
this.context[context].item = item
this.context[context].id = id
this.$nextTick(() => {
this.context[context].value = true
})
show(e, context, item, id, state) {
if (!state) {
e.preventDefault()
this.context[context].value = false
this.context[context].x = e.clientX
this.context[context].y = e.clientY
this.context[context].item = item
this.context[context].id = id
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) {
const previousStatus = {

File diff suppressed because it is too large Load diff

View 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>&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-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>

View file

@ -35,6 +35,13 @@ export default new Vuex.Store({
version: process.env.VUE_APP_VERSION,
release: process.env.RELEASE
},
context: {
pins: {
x: null,
y: null,
value: false
}
},
drawer: true,
site: {
release: "stable",

View file

@ -1,5 +1,47 @@
<template>
<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-model="context.message.value"
:position-x="context.message.x"
@ -126,7 +168,11 @@
elevation="0"
>
<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
{{
$store.state.selectedChat?.chat?.type === "direct"
@ -134,6 +180,14 @@
: $store.state.selectedChat?.chat?.name
}}
</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-if="loadingMessages"
indeterminate
@ -141,22 +195,63 @@
style="display: block; width: 100px; margin: 0 auto"
></v-progress-circular>
<template v-for="(message, index) in messages">
<Message
:key="message.keyId"
:message="message"
:jump-to-message="jumpToMessage"
:edit="edit"
:focus-input="focusInput"
:replying="setReply"
:get-name="getName"
:end-edit="endEdit"
:auto-scroll="autoScroll"
:chat="chat"
:index="index"
:show="show"
:set-image-preview="setImagePreview"
:delete-message="deleteMessage"
></Message>
<div :key="'div-' + message.keyId">
<Message
:key="message.keyId"
:message="message"
:jump-to-message="jumpToMessage"
:edit="edit"
:focus-input="focusInput"
:replying="setReply"
:get-name="getName"
:end-edit="endEdit"
:auto-scroll="autoScroll"
:chat="chat"
:index="index"
:show="show"
:set-image-preview="setImagePreview"
: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>
</v-card-text>
<v-card-text>
@ -716,12 +811,21 @@ import CommsInput from "@/components/CommsInput"
import NicknameDialog from "@/components/NicknameDialog"
import UserDialog from "@/components/UserDialog"
import Message from "@/components/Message"
import SimpleMessage from "@/components/SimpleMessage"
export default {
name: "CommunicationsChat",
components: { Message, UserDialog, NicknameDialog, CommsInput },
components: {
SimpleMessage,
Message,
UserDialog,
NicknameDialog,
CommsInput
},
props: ["chat", "loading", "items"],
data: () => ({
pins: [],
pinsLoading: true,
reachedTop: false,
graphOptions: {
responsive: true,
@ -824,7 +928,8 @@ export default {
userPanel: true,
rateLimit: false,
loadingMessages: true,
avoidAutoScroll: false
avoidAutoScroll: false,
lastRead: 0
}),
computed: {
associations() {
@ -845,6 +950,35 @@ export default {
}
},
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() {
this.avoidAutoScroll = false
this.autoScroll()
@ -1198,6 +1332,12 @@ export default {
.addEventListener("scroll", this.scrollEvent)
setInterval(() => {
this.typing()
if (
document.hasFocus() &&
this.messages[this.messages.length - 1]?.id !== this.lastRead
) {
this.markRead()
}
}, 1000)
this.getMessages()
if (localStorage.getItem("userPanel")) {
@ -1212,11 +1352,35 @@ export default {
if (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) => {
if (message.chatId === this.chat.chatId) {
this.messages.push(message)
this.autoScroll()
this.markRead()
if (document.hasFocus()) {
this.markRead()
}
if (this.messages.length > 50 && !this.avoidAutoScroll) {
this.messages.shift()
this.reachedTop = false
@ -1265,6 +1429,11 @@ export default {
})
},
watch: {
"$store.state.context.pins.value"(val) {
if (val) {
this.getPins()
}
},
userPanel() {
localStorage.setItem("userPanel", JSON.stringify(this.userPanel))
},
@ -1287,6 +1456,7 @@ export default {
this.reachedTop = false
this.avoidAutoScroll = false
this.offset = 0
this.pins = []
this.getMessages()
}
},