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] Mobile responsiveness/compatibility
- [x] Email verification - [x] Email verification
- [ ] Password resetting - [ ] Password resetting
- [ ] Channel message pins - [x] Channel message pins
- [ ] Read receipts - [x] Read receipts
<img src="https://i.troplo.com/i/d608273e066c.png" alt="Chat" width="45%"></img> <img src="https://i.troplo.com/i/d608273e066c.png" alt="Chat" width="45%"></img>
<img src="https://i.troplo.com/i/e8e2c9d6e349.png" alt="Friends" width="45%"></img> <img src="https://i.troplo.com/i/e8e2c9d6e349.png" alt="Friends" width="45%"></img>

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", as: "attachments",
foreignKey: "messageId" foreignKey: "messageId"
}) })
Message.hasMany(models.ChatAssociation, {
as: "readReceipts",
foreignKey: "lastRead"
})
} }
} }
Message.init( Message.init(

51
backend/models/pins.js Normal file
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, User,
Chat, Chat,
ChatAssociation, ChatAssociation,
Pin,
Message, Message,
Friend, Friend,
Attachment, Attachment,
@ -66,6 +67,18 @@ async function createMessage(req, type, content, association, userId) {
id: message.id id: message.id
}, },
include: [ include: [
{
model: ChatAssociation,
as: "readReceipts",
attributes: ["id"],
include: [
{
model: User,
as: "user",
attributes: ["username", "name", "avatar", "id"]
}
]
},
{ {
model: Attachment, model: Attachment,
as: "attachments" as: "attachments"
@ -124,7 +137,6 @@ async function createMessage(req, type, content, association, userId) {
] ]
}) })
associations.forEach((user) => { associations.forEach((user) => {
console.log(user)
io.to(user.dataValues.userId).emit("message", { io.to(user.dataValues.userId).emit("message", {
...messageLookup.dataValues, ...messageLookup.dataValues,
associationId: user.dataValues.id, associationId: user.dataValues.id,
@ -196,9 +208,7 @@ router.get("/", auth, async (req, res, next) => {
"id", "id",
"createdAt", "createdAt",
"updatedAt", "updatedAt",
"status", "status",
"admin", "admin",
"status", "status",
"bot" "bot"
@ -468,6 +478,170 @@ router.get("/:id", auth, async (req, res, next) => {
} }
}) })
router.get("/:id/pins", auth, async (req, res, next) => {
try {
let chat = await ChatAssociation.findOne({
where: {
userId: req.user.id,
id: req.params.id
},
include: [
{
model: Chat,
as: "chat"
}
]
})
if (chat) {
const pins = await Pin.findAll({
where: {
chatId: chat.chat.id
},
include: [
{
model: User,
as: "pinnedBy",
required: false,
attributes: [
"username",
"name",
"avatar",
"id",
"createdAt",
"updatedAt"
],
include: [
{
model: Nickname,
as: "nickname",
attributes: ["nickname"],
required: false,
where: {
userId: req.user.id
}
}
]
},
{
model: Message,
as: "message",
include: [
{
model: User,
as: "user",
attributes: [
"username",
"name",
"avatar",
"id",
"createdAt",
"updatedAt"
],
include: [
{
model: Nickname,
as: "nickname",
attributes: ["nickname"],
required: false,
where: {
userId: req.user.id
}
}
]
}
]
}
],
order: [["id", "DESC"]]
})
res.json(
pins.map((pin) => {
const message = pin.dataValues.message.dataValues
return {
...pin.dataValues,
message: {
...pin.dataValues.message.dataValues,
keyId: `${message.id}-${message.updatedAt.toISOString()}`
}
}
})
)
} else {
throw Errors.invalidParameter("chat association id")
}
} catch (err) {
next(err)
}
})
router.post("/:id/pins", auth, async (req, res, next) => {
try {
const io = req.app.get("io")
const chat = await ChatAssociation.findOne({
where: {
userId: req.user.id,
id: req.params.id
},
include: [
{
model: Chat,
as: "chat",
include: [
{
model: User,
as: "users",
attributes: ["id"]
}
]
}
]
})
if (chat?.chat?.type === "direct" || chat?.rank === "admin") {
const message = await Message.findOne({
where: {
id: req.body.messageId,
chatId: chat.chat.id
}
})
if (message) {
const checkPin = await Pin.findOne({
where: {
messageId: message.id,
chatId: chat.chat.id
}
})
if (checkPin) {
await checkPin.destroy()
res.json({
message: "Message unpinned successfully."
})
return
}
const pin = await Pin.create({
chatId: chat.chat.id,
messageId: req.body.messageId,
pinnedById: req.user.id
})
await createMessage(
req,
"pin",
`${req.user.username} pinned a message to the chat.`,
chat,
req.user.id
)
res.json({
...pin.dataValues,
message: "Message pinned successfully."
})
}
} else {
throw Errors.invalidParameter("chat association id")
}
} catch (e) {
next(e)
}
})
router.put("/:id/read", auth, async (req, res, next) => { router.put("/:id/read", auth, async (req, res, next) => {
try { try {
const io = req.app.get("io") const io = req.app.get("io")
@ -481,6 +655,11 @@ router.put("/:id/read", auth, async (req, res, next) => {
model: Chat, model: Chat,
as: "chat", as: "chat",
include: [ include: [
{
model: User,
as: "users",
attributes: ["id"]
},
{ {
model: Message, model: Message,
as: "lastMessages", as: "lastMessages",
@ -501,6 +680,20 @@ router.put("/:id/read", auth, async (req, res, next) => {
lastRead: chat.chat.lastMessages[0]?.id || null lastRead: chat.chat.lastMessages[0]?.id || null
}) })
res.sendStatus(204) res.sendStatus(204)
for (const user of chat.chat.users) {
io.to(user.id).emit("readReceipt", {
id: chat.id,
messageId: chat.chat.lastMessages[0]?.id || null,
userId: req.user.id,
chatId: chat.chat.id,
user: {
username: req.user.username,
avatar: req.user.avatar,
id: req.user.id
},
previousMessageId: chat.lastRead
})
}
} else { } else {
throw Errors.invalidParameter("chat association id") throw Errors.invalidParameter("chat association id")
} }
@ -877,6 +1070,18 @@ router.post(
id: message.id id: message.id
}, },
include: [ include: [
{
model: ChatAssociation,
as: "readReceipts",
attributes: ["id"],
include: [
{
model: User,
as: "user",
attributes: ["username", "name", "avatar", "id"]
}
]
},
{ {
model: Attachment, model: Attachment,
as: "attachments" as: "attachments"
@ -1130,6 +1335,18 @@ router.post("/:id/message", auth, limiter, async (req, res, next) => {
id: message.id id: message.id
}, },
include: [ include: [
{
model: ChatAssociation,
as: "readReceipts",
attributes: ["id"],
include: [
{
model: User,
as: "user",
attributes: ["username", "name", "avatar", "id"]
}
]
},
{ {
model: Attachment, model: Attachment,
as: "attachments" as: "attachments"
@ -1399,6 +1616,18 @@ router.get("/:id/messages", auth, async (req, res, next) => {
...or ...or
}, },
include: [ include: [
{
model: ChatAssociation,
as: "readReceipts",
attributes: ["id"],
include: [
{
model: User,
as: "user",
attributes: ["username", "name", "avatar", "id"]
}
]
},
{ {
model: Attachment, model: Attachment,
as: "attachments" as: "attachments"
@ -1409,12 +1638,10 @@ router.get("/:id/messages", auth, async (req, res, next) => {
attributes: [ attributes: [
"username", "username",
"name", "name",
"avatar", "avatar",
"id", "id",
"createdAt", "createdAt",
"updatedAt", "updatedAt",
"bot" "bot"
], ],
include: [ include: [
@ -1439,12 +1666,10 @@ router.get("/:id/messages", auth, async (req, res, next) => {
attributes: [ attributes: [
"username", "username",
"name", "name",
"avatar", "avatar",
"id", "id",
"createdAt", "createdAt",
"updatedAt", "updatedAt",
"bot" "bot"
] ]
}, },

View file

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

View file

@ -1,3 +1,7 @@
.message-hover {
background-color: var(--v-bg-lighten1);
border-radius: 5px;
}
.message-toast { .message-toast {
background-color: rgba(47, 47, 47, 0.9) !important; background-color: rgba(47, 47, 47, 0.9) !important;
} }

View file

@ -338,6 +338,14 @@
}} }}
</v-toolbar-title> </v-toolbar-title>
<v-spacer></v-spacer> <v-spacer></v-spacer>
<v-btn
icon
v-model="$store.state.context.pins.value"
@click="show($event, 'pins', null, null, true)"
id="pin-button"
>
<v-icon>mdi-pin-outline</v-icon>
</v-btn>
<v-btn <v-btn
icon icon
@click="$store.state.searchPanel = !$store.state.searchPanel" @click="$store.state.searchPanel = !$store.state.searchPanel"
@ -345,7 +353,7 @@
<v-icon>mdi-magnify</v-icon> <v-icon>mdi-magnify</v-icon>
</v-btn> </v-btn>
<v-btn icon @click="$store.state.userPanel = !$store.state.userPanel"> <v-btn icon @click="$store.state.userPanel = !$store.state.userPanel">
<v-icon>mdi-account-group</v-icon> <v-icon>mdi-account-group-outline</v-icon>
</v-btn> </v-btn>
</template> </template>
<template v-else> <template v-else>
@ -636,7 +644,8 @@ export default {
value: false, value: false,
x: null, x: null,
y: null, y: null,
item: null item: null,
id: 0
} }
}, },
selected: [2], selected: [2],
@ -684,7 +693,8 @@ export default {
this.leave.item = this.$store.state.chats.find((chat) => chat.id === id) this.leave.item = this.$store.state.chats.find((chat) => chat.id === id)
this.leave.dialog = true this.leave.dialog = true
}, },
show(e, context, item, id) { show(e, context, item, id, state) {
if (!state) {
e.preventDefault() e.preventDefault()
this.context[context].value = false this.context[context].value = false
this.context[context].x = e.clientX this.context[context].x = e.clientX
@ -694,6 +704,17 @@ export default {
this.$nextTick(() => { this.$nextTick(() => {
this.context[context].value = true this.context[context].value = true
}) })
} else {
e.preventDefault()
this.$store.state.context[context].value = false
this.$store.state.context[context].x = e.clientX
this.$store.state.context[context].y = e.clientY
this.$store.state.context[context].item = item
this.$store.state.context[context].id = id
this.$nextTick(() => {
this.$store.state.context[context].value = true
})
}
}, },
setStatus(status) { setStatus(status) {
const previousStatus = { const previousStatus = {

View file

@ -1,4 +1,6 @@
<template> <template>
<div>
<v-hover v-slot="{ hover }">
<div> <div>
<template v-if="!message.type"> <template v-if="!message.type">
<v-toolbar <v-toolbar
@ -14,7 +16,9 @@
<v-avatar size="24" class="mr-2"> <v-avatar size="24" class="mr-2">
<v-img <v-img
:src=" :src="
$store.state.baseURL + '/usercontent/' + message.reply.user.avatar $store.state.baseURL +
'/usercontent/' +
message.reply.user.avatar
" "
v-if="message.reply.user.avatar" v-if="message.reply.user.avatar"
class="elevation-1" class="elevation-1"
@ -33,16 +37,15 @@
</v-toolbar> </v-toolbar>
<v-list-item <v-list-item
:key="message.keyId" :key="message.keyId"
:class="{ :class="{ 'message-hover': hover }"
'text-xs-right': message.userId === $store.state.user.id,
'text-xs-left': message.userId !== $store.state.user.id
}"
:id="'message-' + index" :id="'message-' + index"
@contextmenu="show($event, 'message', message)" @contextmenu="show($event, 'message', message)"
> >
<v-avatar size="48" class="mr-2"> <v-avatar size="48" class="mr-2">
<v-img <v-img
:src="$store.state.baseURL + '/usercontent/' + message.user.avatar" :src="
$store.state.baseURL + '/usercontent/' + message.user.avatar
"
v-if="message.user.avatar" v-if="message.user.avatar"
class="elevation-1" class="elevation-1"
/> />
@ -78,7 +81,9 @@
</span> </span>
</template> </template>
<span> <span>
{{ $date(message.editedAt).format("DD/MM/YYYY hh:mm:ss A") }} {{
$date(message.editedAt).format("DD/MM/YYYY hh:mm:ss A")
}}
</span> </span>
</v-tooltip> </v-tooltip>
</v-list-item-subtitle> </v-list-item-subtitle>
@ -369,7 +374,7 @@
v-if="edit.id === message.id" v-if="edit.id === message.id"
></CommsInput> ></CommsInput>
</v-list-item-content> </v-list-item-content>
<v-list-item-action v-if="!$vuetify.breakpoint.mobile"> <v-list-item-action v-if="!$vuetify.breakpoint.mobile && hover">
<v-list-item-subtitle> <v-list-item-subtitle>
<v-btn <v-btn
icon icon
@ -415,6 +420,16 @@
> >
<v-icon> mdi-reply </v-icon> <v-icon> mdi-reply </v-icon>
</v-btn> </v-btn>
<v-btn
icon
v-if="chat.rank === 'admin' || chat.chat.type === 'direct'"
@click="
pinMessage()
focusInput()
"
>
<v-icon> mdi-pin </v-icon>
</v-btn>
</v-list-item-subtitle> </v-list-item-subtitle>
</v-list-item-action> </v-list-item-action>
</v-list-item> </v-list-item>
@ -596,7 +611,66 @@
</v-list-item-action> </v-list-item-action>
</v-list-item> </v-list-item>
</template> </template>
<template v-else-if="message.type === 'system'"> <template v-else-if="message.type === 'pin'">
<v-list-item :key="message.keyId" :id="'message-' + index">
<v-icon color="grey" class="mr-2 ml-1"> mdi-pin-outline </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>
<v-list-item :key="message.keyId" :id="'message-' + index"> <v-list-item :key="message.keyId" :id="'message-' + index">
<v-icon color="grey" class="mr-2 ml-1"> mdi-pencil </v-icon> <v-icon color="grey" class="mr-2 ml-1"> mdi-pencil </v-icon>
<v-list-item-content> <v-list-item-content>
@ -656,6 +730,8 @@
</v-list-item> </v-list-item>
</template> </template>
</div> </div>
</v-hover>
</div>
</template> </template>
<script> <script>
@ -671,6 +747,7 @@ import {
PointElement, PointElement,
LineElement LineElement
} from "chart.js" } from "chart.js"
import AjaxErrorHandler from "@/lib/errorHandler"
ChartJS.register( ChartJS.register(
Title, Title,
Tooltip, Tooltip,
@ -749,6 +826,18 @@ export default {
} }
}, },
methods: { methods: {
pinMessage() {
this.axios
.post(`/api/v1/communications/${this.chat.id}/pins`, {
messageId: this.message.id
})
.then((res) => {
this.$toast.success(res.data.message)
})
.catch((e) => {
AjaxErrorHandler(this.$store)(e)
})
},
friendlySize(size) { friendlySize(size) {
if (size < 1024) { if (size < 1024) {
return size + " bytes" return size + " bytes"

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, version: process.env.VUE_APP_VERSION,
release: process.env.RELEASE release: process.env.RELEASE
}, },
context: {
pins: {
x: null,
y: null,
value: false
}
},
drawer: true, drawer: true,
site: { site: {
release: "stable", release: "stable",

View file

@ -1,5 +1,47 @@
<template> <template>
<div id="communications-chat" @dragover.prevent @drop.prevent> <div id="communications-chat" @dragover.prevent @drop.prevent>
<v-menu
:position-x="$store.state.context.pins.x"
:position-y="60"
v-model="$store.state.context.pins.value"
class="rounded-l"
absolute
transition="scroll-y-transition"
:close-on-content-click="false"
>
<v-card min-width="350" color="toolbar">
<v-toolbar color="toolbar lighten-1">
<v-spacer></v-spacer>
<v-toolbar-title> Pins </v-toolbar-title>
<v-spacer></v-spacer>
</v-toolbar>
<v-divider></v-divider>
<v-container>
<v-list dense v-if="pins.length">
<v-list-item
@click.self="jumpToMessage(pin.message.id)"
v-for="(pin, index) in pins"
:key="index"
>
<SimpleMessage
:message="pin.message"
:index="index"
:key="pin.message.keyId"
></SimpleMessage>
<v-btn icon text @click="removePin(pin.messageId)">
<v-icon> mdi-close </v-icon>
</v-btn>
</v-list-item>
</v-list>
<v-list-item v-else-if="pinsLoading">
<v-list-item-title> Loading... </v-list-item-title>
</v-list-item>
<v-list-item v-else>
<v-list-item-title> This chat has no pins yet. </v-list-item-title>
</v-list-item>
</v-container>
</v-card>
</v-menu>
<v-menu <v-menu
v-model="context.message.value" v-model="context.message.value"
:position-x="context.message.x" :position-x="context.message.x"
@ -126,7 +168,11 @@
elevation="0" elevation="0"
> >
<v-card-text class="flex-grow-1 overflow-y-auto" id="message-list"> <v-card-text class="flex-grow-1 overflow-y-auto" id="message-list">
<v-card-title v-if="reachedTop"> <v-card-title
v-if="
reachedTop && $store.state.selectedChat?.chat?.type === 'group'
"
>
Welcome to the start of Welcome to the start of
{{ {{
$store.state.selectedChat?.chat?.type === "direct" $store.state.selectedChat?.chat?.type === "direct"
@ -134,6 +180,14 @@
: $store.state.selectedChat?.chat?.name : $store.state.selectedChat?.chat?.name
}} }}
</v-card-title> </v-card-title>
<v-card-title v-else-if="reachedTop">
Welcome to the start of the conversation with
{{
$store.state.selectedChat?.chat?.type === "direct"
? getDirectRecipient($store.state.selectedChat).username
: $store.state.selectedChat?.chat?.name
}}
</v-card-title>
<v-progress-circular <v-progress-circular
v-if="loadingMessages" v-if="loadingMessages"
indeterminate indeterminate
@ -141,6 +195,7 @@
style="display: block; width: 100px; margin: 0 auto" style="display: block; width: 100px; margin: 0 auto"
></v-progress-circular> ></v-progress-circular>
<template v-for="(message, index) in messages"> <template v-for="(message, index) in messages">
<div :key="'div-' + message.keyId">
<Message <Message
:key="message.keyId" :key="message.keyId"
:message="message" :message="message"
@ -157,6 +212,46 @@
:set-image-preview="setImagePreview" :set-image-preview="setImagePreview"
:delete-message="deleteMessage" :delete-message="deleteMessage"
></Message> ></Message>
</div>
<div
:key="'div2-' + message.keyId"
v-if="message.readReceipts.length"
>
<v-tooltip
v-for="association in message.readReceipts"
:key="association.id"
top
>
<template v-slot:activator="{ on }">
<v-btn
icon
small
fab
width="20"
height="20"
class="ml-2 mt-2"
style="float: right"
@click="openUserPanel(association.user)"
>
<v-avatar size="20" v-on="on" color="primary">
<img
v-if="association.user.avatar"
:src="'/usercontent/' + association.user.avatar"
alt="avatar"
/>
<span v-else>{{
association.user.username[0].toUpperCase()
}}</span>
</v-avatar>
</v-btn>
</template>
<span>
{{ association.user.username }} has read up to this point.
</span>
</v-tooltip>
<br />
<br v-if="index + 1 <= messages.length" />
</div>
</template> </template>
</v-card-text> </v-card-text>
<v-card-text> <v-card-text>
@ -716,12 +811,21 @@ import CommsInput from "@/components/CommsInput"
import NicknameDialog from "@/components/NicknameDialog" import NicknameDialog from "@/components/NicknameDialog"
import UserDialog from "@/components/UserDialog" import UserDialog from "@/components/UserDialog"
import Message from "@/components/Message" import Message from "@/components/Message"
import SimpleMessage from "@/components/SimpleMessage"
export default { export default {
name: "CommunicationsChat", name: "CommunicationsChat",
components: { Message, UserDialog, NicknameDialog, CommsInput }, components: {
SimpleMessage,
Message,
UserDialog,
NicknameDialog,
CommsInput
},
props: ["chat", "loading", "items"], props: ["chat", "loading", "items"],
data: () => ({ data: () => ({
pins: [],
pinsLoading: true,
reachedTop: false, reachedTop: false,
graphOptions: { graphOptions: {
responsive: true, responsive: true,
@ -824,7 +928,8 @@ export default {
userPanel: true, userPanel: true,
rateLimit: false, rateLimit: false,
loadingMessages: true, loadingMessages: true,
avoidAutoScroll: false avoidAutoScroll: false,
lastRead: 0
}), }),
computed: { computed: {
associations() { associations() {
@ -845,6 +950,35 @@ export default {
} }
}, },
methods: { methods: {
removePin(id) {
this.axios
.post(`/api/v1/communications/${this.chat.id}/pins`, {
messageId: id
})
.then(() => {
this.getPins()
})
.catch((e) => {
AjaxErrorHandler(this.$store)(e)
})
},
getPins() {
this.pinsLoading = true
this.axios
.get(
process.env.VUE_APP_BASE_URL +
"/api/v1/communications/" +
this.$route.params.id +
"/pins"
)
.then((res) => {
this.pins = res.data
this.pinsLoading = false
})
.catch((e) => {
AjaxErrorHandler(this.$store)(e)
})
},
forceBottom() { forceBottom() {
this.avoidAutoScroll = false this.avoidAutoScroll = false
this.autoScroll() this.autoScroll()
@ -1198,6 +1332,12 @@ export default {
.addEventListener("scroll", this.scrollEvent) .addEventListener("scroll", this.scrollEvent)
setInterval(() => { setInterval(() => {
this.typing() this.typing()
if (
document.hasFocus() &&
this.messages[this.messages.length - 1]?.id !== this.lastRead
) {
this.markRead()
}
}, 1000) }, 1000)
this.getMessages() this.getMessages()
if (localStorage.getItem("userPanel")) { if (localStorage.getItem("userPanel")) {
@ -1212,11 +1352,35 @@ export default {
if (drafts[this.$route.params.id]) { if (drafts[this.$route.params.id]) {
this.message = drafts[this.$route.params.id] this.message = drafts[this.$route.params.id]
} }
this.$socket.on("readChat", (data) => {
if (data.id === this.chat.id) {
this.lastRead = data.lastRead
}
})
this.$socket.on("readReceipt", (data) => {
if (
data.messageId &&
data.chatId === this.chat.chatId &&
this.messages?.length
) {
this.messages.forEach((message) => {
message.readReceipts = message.readReceipts.filter(
(readReceipt) => readReceipt.id !== data.id
)
})
this.messages
.find((message) => message.id === data.messageId)
.readReceipts?.push(data)
this.autoScroll()
}
})
this.$socket.on("message", (message) => { this.$socket.on("message", (message) => {
if (message.chatId === this.chat.chatId) { if (message.chatId === this.chat.chatId) {
this.messages.push(message) this.messages.push(message)
this.autoScroll() this.autoScroll()
if (document.hasFocus()) {
this.markRead() this.markRead()
}
if (this.messages.length > 50 && !this.avoidAutoScroll) { if (this.messages.length > 50 && !this.avoidAutoScroll) {
this.messages.shift() this.messages.shift()
this.reachedTop = false this.reachedTop = false
@ -1265,6 +1429,11 @@ export default {
}) })
}, },
watch: { watch: {
"$store.state.context.pins.value"(val) {
if (val) {
this.getPins()
}
},
userPanel() { userPanel() {
localStorage.setItem("userPanel", JSON.stringify(this.userPanel)) localStorage.setItem("userPanel", JSON.stringify(this.userPanel))
}, },
@ -1287,6 +1456,7 @@ export default {
this.reachedTop = false this.reachedTop = false
this.avoidAutoScroll = false this.avoidAutoScroll = false
this.offset = 0 this.offset = 0
this.pins = []
this.getMessages() this.getMessages()
} }
}, },