This commit is contained in:
Troplo 2022-08-14 22:06:56 +10:00
parent eaf54b2863
commit cbe784875b
21 changed files with 5452 additions and 10834 deletions

View file

@ -23,6 +23,7 @@ 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.use("/api/v1/polls", require("./routes/polls.js"))
app.get("/api/v1/state", async (req, res) => {
res.json({
release: req.app.locals.config.release,

View file

@ -1,5 +1,6 @@
let { Sequelize } = require("../models")
let Errors = require("./errors")
const multer = require("multer")
module.exports = function (err, req, res, next) {
if (err instanceof Sequelize.ValidationError) {
@ -10,6 +11,14 @@ module.exports = function (err, req, res, next) {
})
} else {
console.error(err)
if (err instanceof multer.MulterError) {
if (err.code === "LIMIT_FILE_SIZE") {
return res.status(400).json({
errors: [Errors.fileTooLarge]
})
}
}
res.status(500).json({
errors: [Errors.unknown]
})

View file

@ -29,7 +29,10 @@ let Errors = {
"The file you are trying to upload is not a valid file type.",
400
],
fileTooLarge: ["The file you are trying to upload is too large.", 400],
fileTooLarge: [
"The file you are trying to upload is too large. Maximum 50MB.",
400
],
invalidPassword: [
"Your password must be at least 8 characters in length.",
400

View file

@ -0,0 +1,68 @@
"use strict"
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable("Polls", {
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: Sequelize.BIGINT
},
messageId: {
type: Sequelize.BIGINT
},
userId: {
type: Sequelize.BIGINT
},
title: {
type: Sequelize.STRING
},
description: {
type: Sequelize.TEXT
},
options: {
type: Sequelize.JSON,
defaultValue: []
},
createdAt: {
allowNull: false,
type: Sequelize.DATE
},
updatedAt: {
allowNull: false,
type: Sequelize.DATE
}
})
await queryInterface.addIndex("Polls", ["messageId"])
await queryInterface.addIndex("Polls", ["userId"])
await queryInterface.createTable("PollAnswers", {
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: Sequelize.BIGINT
},
pollId: {
type: Sequelize.BIGINT
},
userId: {
type: Sequelize.BIGINT
},
answer: {
type: Sequelize.STRING
}
})
await queryInterface.addIndex("PollAnswers", ["pollId"])
await queryInterface.addIndex("PollAnswers", ["userId"])
},
async down(queryInterface, Sequelize) {
/**
* Add reverting commands here.
*
* Example:
* await queryInterface.dropTable('users');
*/
}
}

View file

@ -0,0 +1,23 @@
"use strict"
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn("PollAnswers", "createdAt", {
type: Sequelize.DATE,
allowNull: false
})
await queryInterface.addColumn("PollAnswers", "updatedAt", {
type: Sequelize.DATE,
allowNull: false
})
},
async down(queryInterface, Sequelize) {
/**
* Add reverting commands here.
*
* Example:
* await queryInterface.dropTable('users');
*/
}
}

View file

@ -13,6 +13,10 @@ module.exports = (sequelize, DataTypes) => {
foreignKey: "userId",
as: "user"
})
Chat.hasOne(models.ChatAssociation, {
foreignKey: "chatId",
as: "association"
})
Chat.hasMany(models.ChatAssociation, {
foreignKey: "chatId",
as: "associations"

View file

@ -26,6 +26,10 @@ module.exports = (sequelize, DataTypes) => {
as: "readReceipts",
foreignKey: "lastRead"
})
Message.hasOne(models.Poll, {
as: "poll",
foreignKey: "messageId"
})
}
}
Message.init(

View file

@ -0,0 +1,46 @@
"use strict"
const { Model } = require("sequelize")
module.exports = (sequelize, DataTypes) => {
class PollAnswer 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.
*/
// eslint-disable-next-line no-unused-vars
static associate(models) {
PollAnswer.belongsTo(models.Poll, {
as: "poll"
})
PollAnswer.belongsTo(models.User, {
as: "user"
})
}
}
PollAnswer.init(
{
pollId: {
type: DataTypes.BIGINT
},
userId: {
type: DataTypes.BIGINT
},
answer: {
type: DataTypes.STRING
},
createdAt: {
allowNull: false,
type: DataTypes.DATE
},
updatedAt: {
allowNull: false,
type: DataTypes.DATE
}
},
{
sequelize,
modelName: "PollAnswer"
}
)
return PollAnswer
}

54
backend/models/polls.js Normal file
View file

@ -0,0 +1,54 @@
"use strict"
const { Model } = require("sequelize")
module.exports = (sequelize, DataTypes) => {
class Poll 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.
*/
// eslint-disable-next-line no-unused-vars
static associate(models) {
Poll.belongsTo(models.Message, {
as: "message"
})
Poll.hasMany(models.PollAnswer, {
as: "answers",
foreignKey: "pollId"
})
}
}
Poll.init(
{
messageId: {
type: DataTypes.BIGINT
},
userId: {
type: DataTypes.BIGINT
},
title: {
type: DataTypes.STRING
},
description: {
type: DataTypes.TEXT
},
options: {
type: DataTypes.JSON,
defaultValue: []
},
createdAt: {
allowNull: false,
type: DataTypes.DATE
},
updatedAt: {
allowNull: false,
type: DataTypes.DATE
}
},
{
sequelize,
modelName: "Poll"
}
)
return Poll
}

View file

@ -42,7 +42,8 @@
"sqlite3": "^5.0.10",
"ua-parser-js": "^1.0.2",
"uglify-js": "^2.6.0",
"umzug": "^3.1.1"
"umzug": "^3.1.1",
"uuid": "^8.3.2"
},
"resolutions": {
"constantinople": "^3.1.1",

View file

@ -10,7 +10,9 @@ const {
Message,
Friend,
Attachment,
Nickname
Nickname,
Poll,
PollAnswer
} = require("../models")
const { Op } = require("sequelize")
const rateLimit = require("express-rate-limit")
@ -19,6 +21,7 @@ const cryptoRandomString = require("crypto-random-string")
const path = require("path")
const fs = require("fs")
const FileType = require("file-type")
const { v4: uuidv4 } = require("uuid")
const limiter = rateLimit({
windowMs: 10 * 1000,
@ -43,7 +46,7 @@ const storage = multer.diskStorage({
const upload = multer({
storage: storage,
limits: { fileSize: 12 * 1024 * 1024 }
limits: { fileSize: 50 * 1024 * 1024 }
})
const resolveEmbeds = require("../lib/resolveEmbeds.js")
@ -93,7 +96,6 @@ async function createMessage(req, type, content, association, userId) {
attributes: [
"username",
"name",
"avatar",
"id",
"createdAt",
@ -112,7 +114,6 @@ async function createMessage(req, type, content, association, userId) {
attributes: [
"username",
"name",
"avatar",
"id",
"createdAt",
@ -127,7 +128,6 @@ async function createMessage(req, type, content, association, userId) {
attributes: [
"username",
"name",
"avatar",
"id",
"createdAt",
@ -1369,11 +1369,61 @@ router.post("/:id/message", auth, limiter, async (req, res, next) => {
embeds,
replyId: reply.id
})
if (req.body.embeds?.length && !req.user.bot) {
if (req.body.embeds.length > 1) {
throw Errors.invalidParameter("embeds", "Maximum length is 1")
}
for (const embed of req.body.embeds) {
if (embed.type === "poll-v1") {
if (!embed.title) {
throw Errors.invalidParameter("embeds", "title is required")
}
if (embed.options?.length < 2) {
throw Errors.invalidParameter("embeds", "options is required")
}
if (embed.options.length > 4) {
throw Errors.invalidParameter(
"embeds",
"Maximum length is 4 for options"
)
}
await Poll.create({
title: embed.title,
description: embed.description,
userId: req.user.id,
messageId: message.id,
options: embed.options.map((option) => {
return {
value: option.toString(),
id: uuidv4()
}
})
})
}
}
}
const messageLookup = await Message.findOne({
where: {
id: message.id
},
include: [
{
model: Poll,
as: "poll",
include: [
{
model: PollAnswer,
as: "answers",
include: [
{
model: User,
as: "user",
attributes: ["username", "avatar", "id"]
}
]
}
]
},
{
model: ChatAssociation,
as: "readReceipts",
@ -1611,12 +1661,10 @@ router.get("/:id/messages", auth, async (req, res, next) => {
attributes: [
"username",
"name",
"avatar",
"id",
"createdAt",
"updatedAt",
"admin",
"bot"
]
@ -1653,6 +1701,24 @@ router.get("/:id/messages", auth, async (req, res, next) => {
...or
},
include: [
{
model: Poll,
as: "poll",
include: [
{
model: PollAnswer,
as: "answers",
include: [
{
model: User,
as: "user",
required: false,
attributes: ["username", "avatar", "id"]
}
]
}
]
},
{
model: ChatAssociation,
as: "readReceipts",

View file

@ -32,6 +32,9 @@ router.get("/:mid/:index/:securityToken", async (req, res, next) => {
res.setHeader("cache-control", "public, max-age=604800")
res.end(response.data, "binary")
})
.catch(() => {
res.status(404).end()
})
} catch (e) {
next(e)
}
@ -65,6 +68,9 @@ router.get("/:mid/:index/:securityToken.:extension", async (req, res, next) => {
res.setHeader("cache-control", "public, max-age=604800")
res.end(response.data, "binary")
})
.catch(() => {
res.status(404).end()
})
} catch (e) {
next(e)
}

112
backend/routes/polls.js Normal file
View file

@ -0,0 +1,112 @@
const express = require("express")
const router = express.Router()
const Errors = require("../lib/errors.js")
const {
User,
Chat,
ChatAssociation,
Poll,
PollAnswer,
Message
} = require("../models")
const auth = require("../lib/authorize")
const rateLimit = require("express-rate-limit")
const limiter = rateLimit({
windowMs: 20 * 1000,
max: 8,
message: Errors.rateLimit,
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req, res) => req.user?.id || req.ip
})
router.post("/:id/vote", auth, limiter, async (req, res, next) => {
try {
const io = req.app.get("io")
const poll = await Poll.findOne({
where: {
id: req.params.id
},
include: [
{
model: Message,
as: "message",
include: [
{
model: Chat,
as: "chat",
include: [
{
model: ChatAssociation,
as: "association",
where: {
userId: req.user.id
}
},
{
model: ChatAssociation,
as: "associations"
}
]
}
]
}
]
})
if (!poll) throw Errors.invalidParameter("poll id")
let answer = await PollAnswer.findOne({
where: {
pollId: poll.id,
userId: req.user.id
}
})
const validate = poll.options.find(
(option) => option.id === req.body.option
)
if (!validate) throw Errors.invalidParameter("option")
if (answer) {
if (answer?.answer === req.body.option) {
for (const association of poll.message.chat.associations) {
io.to(association.userId).emit(`pollAnswer-${poll.messageId}`, {
poll: poll,
answer: null,
id: answer.id
})
}
await answer.destroy()
res.sendStatus(204)
return
}
await answer.update({
answer: req.body.option
})
for (const association of poll.message.chat.associations) {
io.to(association.userId).emit(`pollAnswer-${poll.messageId}`, {
poll: poll,
answer: answer,
id: answer.id
})
}
res.sendStatus(204)
} else {
answer = await PollAnswer.create({
pollId: poll.id,
userId: req.user.id,
answer: req.body.option
})
for (const association of poll.message.chat.associations) {
io.to(association.userId).emit(`pollAnswer-${poll.messageId}`, {
poll: poll,
answer: answer,
id: answer.id
})
}
res.sendStatus(204)
}
} catch (e) {
next(e)
}
})
module.exports = router

View file

@ -1,6 +1,6 @@
{
"name": "colubrina",
"version": "1.0.22",
"version": "1.0.23",
"description": "Simple instant communication.",
"private": true,
"author": "Troplo <troplo@troplo.com>",
@ -50,9 +50,7 @@
"vue-toastification": "^1.7.14",
"vue2-ace-editor": "^0.0.15",
"vuetify": "^2.6.4",
"vuex": "^3.4.0",
"vue-cli-plugin-electron-builder": "^2.1.1",
"electron-devtools-installer": "^3.1.0"
"vuex": "^3.4.0"
},
"devDependencies": {
"@babel/plugin-proposal-optional-chaining": "^7.16.7",
@ -75,7 +73,9 @@
"vue-template-compiler": "^2.6.11",
"vuetify-loader": "^1.7.0",
"webpack-auto-inject-version-next": "1.2.4",
"webpack-bundle-analyzer": "^4.5.0"
"webpack-bundle-analyzer": "^4.5.0",
"vue-cli-plugin-electron-builder": "^2.1.1",
"electron-devtools-installer": "^3.1.0"
},
"license": "GPL-3.0",
"resolutions": {

View file

@ -1,5 +1,81 @@
<template>
<div>
<v-dialog v-model="poll.dialog" max-width="500">
<v-card class="mb-0" color="card">
<v-toolbar color="toolbar">
<v-toolbar-title> Poll </v-toolbar-title>
</v-toolbar>
<v-card-text>
<v-container fluid>
<v-text-field v-model="poll.title" label="Title"></v-text-field>
<v-textarea
v-model="poll.description"
label="Description"
></v-textarea>
<v-text-field
v-for="(value, index) in poll.options"
:key="index"
v-model="poll.options[index]"
:label="`Option ${index + 1}`"
:maxlength="30"
:append-outer-icon="poll.options.length > 2 ? 'mdi-close' : ''"
@click:append-outer="poll.options.splice(index, 1)"
></v-text-field>
<v-btn
@click="poll.options.push('')"
v-if="poll.options.length <= 3"
text
block
>
<v-icon> mdi-plus </v-icon>
</v-btn>
</v-container>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="red" text @click="poll.dialog = false"> Cancel </v-btn>
<v-btn
color="blue darken-1"
text
@click="
createPoll()
poll.dialog = false
"
>
Add to message
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-toolbar
elevation="0"
outlined
height="40"
color="card"
v-for="(embed, index) in embeds"
style="cursor: pointer; overflow: hidden"
class="mb-2"
:key="index"
>
<v-toolbar-title>
<v-icon> mdi-attachment </v-icon>
{{ embedName(embed.type) }}
</v-toolbar-title>
<v-spacer></v-spacer>
<v-btn icon @click="embeds.splice(index, 1)" small>
<v-icon> mdi-close </v-icon>
</v-btn>
</v-toolbar>
<v-progress-linear
v-model="uploadPercentage"
v-if="uploading"
height="15"
color="toolbar"
disabled
class="rounded-xl"
>
<small>{{ uploadPercentage }}%</small>
</v-progress-linear>
<v-toolbar
elevation="0"
outlined
@ -62,15 +138,110 @@
<v-icon> mdi-send </v-icon>
</v-btn>
</template>
<template v-slot:prepend>
<v-file-input
style="margin-top: -18px"
single-line
hide-input
v-model="file"
@change="getURLForImage"
v-if="!edit"
></v-file-input>
<template v-slot:prepend-inner>
<v-menu
:nudge-top="10"
:nudge-left="5"
:close-delay="100"
:close-on-content-click="false"
bottom
offset-y
top
>
<template v-slot:activator="{ on }">
<v-btn
v-on="on"
id="attachment-button"
icon
style="margin-top: -2px; margin-left: -2px"
small
@dblclick.stop="openFileInput"
>
<v-icon>mdi-plus-circle</v-icon>
</v-btn>
</template>
<div>
<v-list>
<v-list-item @click="poll.dialog = true">
<v-icon class="mr-2"> mdi-poll </v-icon>
Create a poll
</v-list-item>
<v-list-item @click="openFileInput">
<v-file-input
style="margin-top: -10px"
single-line
hide-input
v-model="file"
@change="getURLForImage"
v-if="!edit"
ref="file-input"
st
></v-file-input>
Upload a file
</v-list-item>
</v-list>
</div>
</v-menu>
<v-menu
:nudge-top="10"
:nudge-left="5"
:close-delay="100"
:close-on-content-click="false"
bottom
offset-y
top
v-if="false"
>
<template v-slot:activator="{ on }">
<v-btn
id="emoji-button"
icon
v-on="on"
style="
margin-top: -2px;
margin-left: 1px;
filter: grayscale(100%);
"
small
@dblclick.stop="openFileInput"
>
<img
style="width: 1.65em; height: 1.65em"
class="emoji"
draggable="false"
alt="😀"
src="https://twemoji.maxcdn.com/v/14.0.2/svg/1f600.svg"
/>
</v-btn>
</template>
<v-card width="300" height="300">
<v-tabs vertical height="300">
<v-tab v-for="category in categories" :key="category">
{{ category }}
</v-tab>
<v-tab-item
v-for="category in categories"
:key="category + '-item'"
>
<v-card height="300">
<v-container fluid>
<v-row>
<v-col
v-for="emoji in emojisByCategory[category]"
:key="emoji.emoji"
sm="4"
v-html="twemoji(emoji.emoji)"
@click="addEmoji(emoji.emoji)"
style="cursor: pointer"
>
</v-col>
</v-row>
</v-container>
</v-card>
</v-tab-item>
</v-tabs>
</v-card>
</v-menu>
</template>
</v-textarea>
</div>
@ -78,6 +249,8 @@
<script>
import AjaxErrorHandler from "@/lib/errorHandler"
import twemoji from "twemoji"
const emojis = require("../lib/emojis.json")
export default {
name: "CommsInput",
@ -113,7 +286,16 @@ export default {
},
data() {
return {
uploadPercentage: 0,
uploading: false,
poll: {
dialog: false,
title: "",
options: ["", ""],
description: ""
},
message: "",
embeds: [],
file: null,
blobURL: null,
mentions: false,
@ -122,7 +304,57 @@ export default {
completionWord: ""
}
},
/*
computed: {
emojis() {
return emojis
},
categories() {
return this.emojis
.map((emoji) => emoji.category)
.filter((category, index, array) => {
return array.indexOf(category) === index
})
},
emojisByCategory() {
return this.categories.reduce((acc, category) => {
acc[category] = this.emojis.filter((emoji) => {
return emoji.category === category
})
return acc
}, {})
}
},*/
methods: {
embedName(type) {
if (type === "poll-v1") {
return "Interactive Poll"
} else if (type === "embed-v1") {
return "Standard Embed"
} else {
return type
}
},
createPoll() {
this.embeds.push({
type: "poll-v1",
title: this.poll.title,
options: this.poll.options,
description: this.poll.description
})
},
addEmoji(emoji) {
this.message += emoji
},
twemoji(emoji) {
return twemoji.parse(emoji, {
folder: "svg",
ext: ".svg"
})
},
openFileInput() {
this.$refs["file-input"].$refs.input.click()
},
tabCompletion() {
if (!this.completions.length) {
const word = this.message.split(" ").pop().toLowerCase()
@ -260,7 +492,6 @@ export default {
let message = this.message
this.message = ""
if (this.file || message.length > 0) {
const emojis = require("../lib/emojis.json")
message = message.replaceAll(
/:([a-zA-Z0-9_\-+]+):/g,
(match, group1) => {
@ -283,25 +514,36 @@ export default {
"/message",
{
message: message,
replyId: this.replying?.id
replyId: this.replying?.id,
embeds: this.embeds
}
)
.then(() => {
this.focusInput()
this.autoScroll()
this.endSend()
this.embeds = []
})
.catch((e) => {
console.log(e)
AjaxErrorHandler(this.$store)(e)
})
} else {
if (this.uploading) return
if (this.file.size > 50 * 1024 * 1024) {
this.$toast.error(
"The file you are trying to upload is too large. Maximum 50MB."
)
return
}
this.uploading = true
const formData = new FormData()
formData.append("message", message)
if (this.replying) {
formData.append("replyId", this.replying.id)
}
formData.append("file", this.file)
formData.append("embeds", this.embeds)
this.axios
.post(
process.env.VUE_APP_BASE_URL +
@ -312,7 +554,14 @@ export default {
{
headers: {
"Content-Type": "multipart/form-data"
}
},
onUploadProgress: function (progressEvent) {
this.uploadPercentage = parseInt(
Math.round(
(progressEvent.loaded / progressEvent.total) * 100
)
)
}.bind(this)
}
)
.then(() => {
@ -320,8 +569,13 @@ export default {
this.autoScroll()
this.endSend()
this.file = null
this.uploading = false
this.uploadPercentage = 0
this.embeds = []
})
.catch((e) => {
this.uploading = false
this.uploadPercentage = 0
console.log(e)
AjaxErrorHandler(this.$store)(e)
})

View file

@ -36,7 +36,6 @@
{{ message.reply.content.substring(0, 100) }}
</v-toolbar>
<v-list-item
class="max-v-list-height"
:key="message.keyId"
:class="{
'message-hover': hover,
@ -46,7 +45,9 @@
:dense="lastMessage"
:id="'message-' + index"
@contextmenu="show($event, 'message', message)"
:style="lastMessage ? 'margin-bottom: -5px; margin-top: -5px;' : ''"
:style="
lastMessage ? 'margin-bottom: -10px; margin-top: -10px;' : ''
"
>
<v-avatar size="45" class="mr-2" v-if="!lastMessage">
<v-img
@ -130,6 +131,47 @@
no-gutters
>
<v-card
:min-width="!$vuetify.breakpoint.mobile ? 400 : 0"
elevation="0"
color="card"
v-if="embed.type === 'image'"
>
<v-hover v-slot="{ hover }">
<div>
<v-img
@click="setImagePreview(embed)"
contain
:max-width="500"
:max-height="500"
:src="$store.state.baseURL + embed.mediaProxyLink"
>
<template v-slot:placeholder>
<v-row
class="fill-height ma-0"
align="center"
justify="center"
>
<v-progress-circular
indeterminate
width="500"
height="500"
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>
<v-card
v-if="embed.type !== 'image'"
elevation="0"
:color="
embed.type === 'embed-v1' ? embed.backgroundColor : 'bg'
@ -186,36 +228,6 @@
</p>
</v-col>
</v-row>
<template v-else-if="embed.type === 'image'">
<v-hover v-slot="{ hover }">
<v-img
@click="setImagePreview(embed)"
contain
:aspect-ratio="16 / 9"
:src="$store.state.baseURL + 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>
</v-hover>
</template>
<v-row v-else-if="embed.type === 'embed-v1'">
<v-col
cols="12"
@ -306,6 +318,59 @@
</v-container>
</v-card>
</v-row>
<v-row v-if="message.poll" no-gutters>
<v-card
elevation="0"
:max-width="500"
:min-width="!$vuetify.breakpoint.mobile ? 400 : 200"
class="ml-1 mb-1 mr-1 rounded-l"
color="card lighten-1"
>
<v-toolbar color="toolbar" height="45">
<v-toolbar-title>
Poll: {{ message.poll.title }}
</v-toolbar-title>
</v-toolbar>
<v-card-text>
{{ message.poll.description }}
<v-progress-linear
v-for="option in message.poll.options"
:key="option.id"
block
class="mb-1 rounded-xl"
height="30"
text
:value="
percentageVotes.find(
(percentage) => percentage.id === option.id
).percentage
"
color="success darken-1"
background-opacity="0.2"
outlined
style="text-transform: none; cursor: pointer"
@click="votePoll(option.id)"
>
<span style="float: left !important">
<v-icon v-if="option.id === myVote?.answer">
mdi-check-circle
</v-icon>
{{ option.value }} ({{
percentageVotes.find(
(percentage) => percentage.id === option.id
).percentage
}}% /
{{
message.poll.answers.filter(
(answer) => answer.answer === option.id
)?.length || 0
}})
</span>
</v-progress-linear>
{{ message.poll.answers.length }} votes
</v-card-text>
</v-card>
</v-row>
</template>
<template v-if="edit.id !== message.id">
<v-card
@ -330,7 +395,8 @@
<v-img
@click="setImagePreview(attachment)"
contain
:aspect-ratio="16 / 9"
:max-width="500"
:max-height="500"
:src="
$store.state.baseURL +
'/usercontent/' +
@ -345,6 +411,8 @@
>
<v-progress-circular
indeterminate
width="500"
height="500"
color="grey lighten-5"
></v-progress-circular>
</v-row>
@ -899,6 +967,24 @@ export default {
}
},
computed: {
myVote() {
return this.message.poll.answers.find(
(vote) => vote.userId === this.$store.state.user.id
)
},
percentageVotes() {
return this.message.poll.options.map((option) => {
return {
id: option.id,
percentage:
((this.message.poll.answers?.filter(
(answer) => answer?.answer === option.id
).length || 0) /
this.message.poll.answers.length) *
100 || 0
}
})
},
mentioned() {
return this.message.content
.toLowerCase()
@ -906,6 +992,15 @@ export default {
}
},
methods: {
votePoll(option) {
this.axios
.post(`/api/v1/polls/${this.message.poll.id}/vote`, {
option
})
.catch((e) => {
AjaxErrorHandler(this.$store)(e)
})
},
pinMessage() {
this.axios
.post(`/api/v1/communications/${this.chat.id}/pins`, {
@ -929,6 +1024,18 @@ export default {
return (size / 1073741824).toFixed(2) + " GB"
}
}
},
mounted() {
if (this.message.poll) {
this.$socket.on(`pollAnswer-${this.message.id}`, (data) => {
this.message.poll.answers = this.message.poll.answers.filter(
(answer) => answer.id !== data.id
)
if (data.answer) {
this.message.poll.answers.push(data.answer)
}
})
}
}
}
</script>

File diff suppressed because it is too large Load diff

View file

@ -74,9 +74,16 @@ export default new Vuex.Store({
wsRegistered: false,
lastChat: "friends",
searchPanel: false,
userPanel: false
userPanel: false,
messages: {}
},
mutations: {
setMessages(state, { id, messages }) {
state.messages[id] = messages
},
appendMessage(state, { id, message }) {
state.messages[id].push(message)
},
setSelectedChat(state, chat) {
state.selectedChat = chat
},

View file

@ -23,14 +23,6 @@ export default {
}
}
},
mounted() {
if (!this.$route.params.id) {
this.$router.push("/communications/" + this.$store.state.lastChat)
} else {
this.$store.commit("setLastChat", this.$route.params.id || "friends")
}
this.$store.commit("setSelectedChat", this.selectedChat)
},
watch: {
selectedChat() {
this.$store.commit("setSelectedChat", this.selectedChat)

View file

@ -97,7 +97,6 @@
:max-width="1000"
:max-height="600"
:min-height="300"
aspect-ratio="16/9"
contain
></v-img>
<v-container>
@ -1065,7 +1064,11 @@ export default {
})
},
async getMessages() {
this.loadingMessages = true
if (!this.$store.state.messages[this.chat.id]) {
this.loadingMessages = true
} else {
this.autoScroll()
}
await this.axios
.get(
process.env.VUE_APP_BASE_URL +
@ -1079,6 +1082,10 @@ export default {
this.reachedTop = true
}
this.messages.unshift(...res.data)
/* this.$store.commit("setMessages", {
id: this.chat.id,
messages: this.messages
})*/
this.loadingMessages = false
this.markRead()
this.$nextTick(() => {
@ -1229,7 +1236,7 @@ export default {
drafts[oldVal] = ""
}
this.message = drafts[val] || ""
this.messages = []
this.messages = this.$store.state.messages[val] || []
this.usersTyping = []
this.replying = null
this.reachedTop = false

View file

@ -88,7 +88,7 @@
</v-toolbar>
<v-card color="card" elevation="0">
<v-list color="card">
<v-list-item v-if="computePendingIncoming.length === 0">
<v-list-item v-if="!computePendingIncoming.length">
<v-list-item-content>
<v-list-item-title>
You currently do not have any incoming friend requests.
@ -141,7 +141,7 @@
</v-toolbar>
<v-card color="card" elevation="0">
<v-list color="card">
<v-list-item v-if="computePendingOutgoing.length === 0">
<v-list-item v-if="!computePendingOutgoing.length">
<v-list-item-content>
<v-list-item-title>
You currently do not have any outgoing friend requests.
@ -191,7 +191,7 @@
</v-toolbar>
<v-card color="card" elevation="0">
<v-list color="card">
<v-list-item v-if="computeAccepted.length === 0">
<v-list-item v-if="!computeAccepted.length">
<v-list-item-content>
<v-list-item-title>
You currently do not have any friends.
@ -374,7 +374,6 @@ export default {
}
},
async mounted() {
await this.$store.dispatch("doInit")
this.getFriends()
this.getUsers()
this.$socket.on("friendRequest", () => {