forked from kaverti/website
0.176-prerelease2
This commit is contained in:
parent
22a9a437cc
commit
4d810b0eb9
|
@ -0,0 +1,242 @@
|
|||
let Type = require('../lib/validation/type');
|
||||
let validationError = require('../lib/errors/validationError');
|
||||
let {
|
||||
User,
|
||||
Conversation,
|
||||
Message,
|
||||
UserConversation,
|
||||
Sequelize
|
||||
} = require('../models');
|
||||
let sequelizeInstance = require('../models').sequelize;
|
||||
|
||||
exports.create = async function (userIds, name) {
|
||||
//Remove duplicates
|
||||
let userIdsSet = new Set(
|
||||
userIds.filter(id => typeof id === 'number')
|
||||
);
|
||||
let userPromises = [];
|
||||
let users;
|
||||
|
||||
userIdsSet.forEach(id => {
|
||||
userPromises.push(User.findById(id));
|
||||
});
|
||||
users = (await Promise.all(userPromises)).filter(user => user !== null);
|
||||
|
||||
if(users.length > 1) {
|
||||
let notNullUserIds = users.map(u => u.id).sort().join(',');
|
||||
let existingConversation = await Conversation.findOne({
|
||||
where: {
|
||||
groupUsers: notNullUserIds
|
||||
}
|
||||
});
|
||||
|
||||
if(existingConversation) {
|
||||
throw validationError({
|
||||
message: 'Channel conversation already exists, you cannot make a duplicate.',
|
||||
value: userIds
|
||||
});
|
||||
} else {
|
||||
let conversation = await Conversation.create({ name, groupUsers: notNullUserIds });
|
||||
await conversation.addUsers(users);
|
||||
|
||||
return conversation.toJSON();
|
||||
}
|
||||
} else {
|
||||
throw validationError({
|
||||
message: 'At least two users required for a conversation',
|
||||
value: userIds
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
userId of current user
|
||||
page to search on (offset)
|
||||
searchString of usernames given by user
|
||||
*/
|
||||
exports.getFromUser = async function (userId, page, searchString) {
|
||||
let usernames = (searchString || '').split(/\s+/);
|
||||
let replacementsObj = {
|
||||
offset: (page || 0) * 10,
|
||||
usernamesLen: usernames.length,
|
||||
usernames: usernames.join('|'),
|
||||
userId
|
||||
};
|
||||
|
||||
let orClause = '';
|
||||
if(usernames[0]) {
|
||||
orClause = 'OR users.username RLIKE :usernames'
|
||||
replacementsObj.usernamesLen++;
|
||||
}
|
||||
|
||||
let sql = `
|
||||
SELECT
|
||||
conversations.*,
|
||||
messages.content as 'Messages.content',
|
||||
messages.createdAt as 'Messages.createdAt',
|
||||
users.username as 'User.username',
|
||||
users.id as 'User.id',
|
||||
userconversations.lastRead as 'lastRead'
|
||||
|
||||
FROM
|
||||
(
|
||||
SELECT ConversationId
|
||||
FROM userconversations
|
||||
INNER JOIN users
|
||||
ON userconversations.UserId = users.id
|
||||
WHERE users.id = :userId ${orClause}
|
||||
GROUP BY userconversations.ConversationId
|
||||
HAVING count(*) = :usernamesLen
|
||||
) q
|
||||
JOIN conversations
|
||||
ON conversations.id = q.ConversationId
|
||||
JOIN messages
|
||||
ON conversations.id = messages.ConversationId AND messages.id in
|
||||
(
|
||||
SELECT MAX(id)
|
||||
FROM messages
|
||||
WHERE messages.ConversationId = conversations.id
|
||||
)
|
||||
JOIN users
|
||||
ON users.id = messages.UserId
|
||||
JOIN userconversations
|
||||
ON userconversations.UserId = users.id AND userconversations.ConversationId = conversations.id
|
||||
|
||||
ORDER BY messages.id DESC
|
||||
|
||||
LIMIT 10
|
||||
OFFSET :offset
|
||||
`;
|
||||
|
||||
let conversations = await sequelizeInstance.query(sql, {
|
||||
replacements: replacementsObj,
|
||||
type: sequelizeInstance.QueryTypes.SELECT,
|
||||
model: Conversation
|
||||
});
|
||||
|
||||
let mappedPropertiesPromises = conversations.map(async conversation => {
|
||||
let json = await conversation.setName(userId);
|
||||
|
||||
json.Messages = [{
|
||||
content: json['Messages.content'],
|
||||
createdAt: json['Messages.createdAt'],
|
||||
User: {
|
||||
id: json['User.id'],
|
||||
username: json['User.username']
|
||||
}
|
||||
}];
|
||||
|
||||
delete json['Messages.content'];
|
||||
delete json['Messages.createdAt'];
|
||||
delete json['User.id'];
|
||||
delete json['User.username'];
|
||||
|
||||
return json;
|
||||
});
|
||||
let mappedProperties = await Promise.all(mappedPropertiesPromises);
|
||||
|
||||
return {
|
||||
Conversations: mappedProperties,
|
||||
continuePagination: mappedProperties.length === 10
|
||||
};
|
||||
};
|
||||
|
||||
exports.get = async function (userId, conversationId, page) {
|
||||
let messageCount = await Message.count({
|
||||
where: {
|
||||
ConversationId: conversationId
|
||||
}
|
||||
});
|
||||
|
||||
let offset = messageCount - (page || 1) * 10;
|
||||
let limit = 10;
|
||||
if(offset < 0) {
|
||||
limit = 10 + offset;
|
||||
offset = 0;
|
||||
}
|
||||
|
||||
let conversation = await Conversation.findById(conversationId, {
|
||||
include: [
|
||||
{
|
||||
model: User,
|
||||
where: { id: userId },
|
||||
attributes: { exclude: ['hash'] }
|
||||
},
|
||||
{
|
||||
model: Message,
|
||||
include: [
|
||||
{
|
||||
model: User,
|
||||
attributes: { exclude: ['hash'] }
|
||||
}
|
||||
],
|
||||
limit,
|
||||
offset,
|
||||
order: [['id', 'ASC']]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
if(!conversation) {
|
||||
throw validationError({
|
||||
message: 'Either the conversation doesn\'t exist or you\'re not part of the conversation'
|
||||
});
|
||||
} else {
|
||||
let json = await conversation.setName(userId);
|
||||
json.continuePagination = !!offset;
|
||||
|
||||
return json;
|
||||
}
|
||||
};
|
||||
|
||||
exports.getUserIds = async function (conversationId) {
|
||||
let conversation = await Conversation.findById(conversationId, {
|
||||
include: [{ model: User }]
|
||||
});
|
||||
|
||||
if(!conversation) {
|
||||
throw validationError({
|
||||
message: 'The conversation doesn\'t exist'
|
||||
});
|
||||
} else {
|
||||
return conversation.Users.map(user => user.id);
|
||||
}
|
||||
};
|
||||
|
||||
exports.updateLastRead = async function (conversationId, userId) {
|
||||
let res = await UserConversation.update({
|
||||
lastRead: new Date()
|
||||
}, {
|
||||
where: { ConversationId: conversationId, UserId: userId }
|
||||
});
|
||||
|
||||
//Affected rows should always be 1
|
||||
if(res[0] !== 1) {
|
||||
throw validationError({
|
||||
message: 'Either the conversationId or UserId is invalid'
|
||||
});
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
exports.updateName = async function (conversationId, userId, name) {
|
||||
let conversation = await Conversation.findById(conversationId, {
|
||||
include: [{ model: User }]
|
||||
});
|
||||
|
||||
if(
|
||||
!conversation ||
|
||||
conversation.Users.find(u => u.id === userId) === undefined
|
||||
) {
|
||||
throw validationError({
|
||||
message: 'Either the conversationId or userId is invalid'
|
||||
});
|
||||
}
|
||||
|
||||
let res = await Conversation.update({ name }, {
|
||||
where: { id: conversationId }
|
||||
});
|
||||
|
||||
return true;
|
||||
};
|
|
@ -0,0 +1,40 @@
|
|||
let Type = require('../lib/validation/type');
|
||||
let validationError = require('../lib/errors/validationError');
|
||||
let { Message, User, Conversation, Sequelize } = require('../models');
|
||||
|
||||
/* params
|
||||
userId: integer
|
||||
conversationId: integer
|
||||
content: string
|
||||
*/
|
||||
exports.create = async function (params) {
|
||||
let { content, userId, conversationId } = params;
|
||||
|
||||
let user = await User.findById(userId);
|
||||
if(!user) {
|
||||
throw validationError({
|
||||
message: 'User does not exist',
|
||||
path: 'UserId'
|
||||
});
|
||||
}
|
||||
|
||||
let conversation = await Conversation.findById(conversationId, {
|
||||
include: [{
|
||||
model: User,
|
||||
where: { id: userId }
|
||||
}]
|
||||
});
|
||||
if(!conversation) {
|
||||
throw validationError({
|
||||
message: 'Conversation does not exist or user is not part of conversation',
|
||||
path: 'ConversationId'
|
||||
});
|
||||
}
|
||||
|
||||
let message = await Message.create({ content });
|
||||
await message.setUser(user);
|
||||
await message.setConversation(conversation);
|
||||
await Conversation.update({ updatedAt: new Date() }, { where: { id: conversationId } });
|
||||
|
||||
return message;
|
||||
}
|
|
@ -0,0 +1,76 @@
|
|||
let validationError = require('../lib/errors/validationError.js');
|
||||
let { User, Sequelize } = require('../models');
|
||||
let bcrypt = require('bcryptjs');
|
||||
|
||||
exports.create = async function (username , password) {
|
||||
let user = await User.create({ username, hash: password });
|
||||
let userJson = user.toJSON();
|
||||
delete userJson.hash;
|
||||
|
||||
return userJson;
|
||||
};
|
||||
|
||||
exports.get = async function (userId) {
|
||||
let user = await User.findById(userId, {
|
||||
attributes: { exclude: ['hash'] }
|
||||
});
|
||||
|
||||
if(user) {
|
||||
return user.toJSON();
|
||||
} else {
|
||||
throw validationError({
|
||||
message: 'User does not exist',
|
||||
value: userId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
exports.getAllBeginningWith = async function (username) {
|
||||
let users = await User.findAll({
|
||||
attributes: { exclude: ['hash'] },
|
||||
where: {
|
||||
username: {
|
||||
[Sequelize.Op.like]: username + '%'
|
||||
}
|
||||
},
|
||||
limit: 10
|
||||
});
|
||||
|
||||
let sorted = users.sort((a, b) => {
|
||||
if(a.username.length !== b.username.length) {
|
||||
return a.username.length > b.username.length
|
||||
} else {
|
||||
return a.username.localeCompare(b.username);
|
||||
}
|
||||
});
|
||||
|
||||
return sorted.map(user => user.toJSON());
|
||||
}
|
||||
|
||||
exports.login = async function (username, password) {
|
||||
let user = await User.findById({
|
||||
where: { username }
|
||||
});
|
||||
|
||||
if(!user) {
|
||||
throw validationError({
|
||||
message: 'Username is incorrect',
|
||||
path: 'username',
|
||||
value: username
|
||||
});
|
||||
} else {
|
||||
let res = await bcrypt.compare(password, user.hash);
|
||||
|
||||
if(res) {
|
||||
let userJson = user.toJSON();
|
||||
delete userJson.hash;
|
||||
return userJson;
|
||||
} else {
|
||||
throw validationError({
|
||||
message: 'Password is incorrect',
|
||||
path: 'hash',
|
||||
value: password
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
|
@ -1,2 +1,3 @@
|
|||
VUE_APP_APIENDPOINT="/api/"
|
||||
VUE_APP_APIVERSION="v1"
|
||||
VUE_APP_GATEWAYENDPOINT="http://localhost:23981"
|
||||
|
|
|
@ -44,7 +44,6 @@
|
|||
border-left: 8px solid #bab7b7;
|
||||
padding: 1rem;
|
||||
}
|
||||
@import './assets/buefy/buefy-dark.css';
|
||||
</style>
|
||||
<template>
|
||||
<div id='app'>
|
||||
|
@ -623,6 +622,7 @@
|
|||
this.$store.commit('setDevMode', res.data.developerMode)
|
||||
this.$store.commit('setTheme', res.data.theme)
|
||||
this.$store.commit('setExecutive', res.data.executive)
|
||||
this.$store.commit('setUserId', res.data.id)
|
||||
this.axios.get(process.env.VUE_APP_APIENDPOINT + process.env.VUE_APP_APIVERSION + '/' + 'kaverti/state')
|
||||
.then(res => {
|
||||
this.$store.commit('setSettings', res.data)
|
||||
|
@ -654,6 +654,7 @@
|
|||
this.$store.commit('setDevMode', res.data.developerMode)
|
||||
this.$store.commit('setTheme', res.data.theme)
|
||||
this.$store.commit('setExecutive', res.data.executive)
|
||||
this.$store.commit('setUserId', res.data.id)
|
||||
this.closeConn()
|
||||
}).catch(err => {
|
||||
this.showConn()
|
||||
|
@ -757,6 +758,7 @@
|
|||
this.$store.commit('setDevMode', res.data.developerMode)
|
||||
this.$store.commit('setTheme', res.data.theme)
|
||||
this.$store.commit('setExecutive', res.data.executive)
|
||||
this.$store.commit('setUserId', res.data.id)
|
||||
this.closeConn()
|
||||
}).catch(err => {
|
||||
this.showConn()
|
||||
|
@ -808,6 +810,7 @@
|
|||
this.$store.commit('setDevMode', res.data.developerMode)
|
||||
this.$store.commit('setTheme', res.data.theme)
|
||||
this.$store.commit('setExecutive', res.data.executive)
|
||||
this.$store.commit('setUserId', res.data.id)
|
||||
this.closeConn()
|
||||
}).catch(err => {
|
||||
this.showConn()
|
||||
|
@ -837,6 +840,7 @@
|
|||
this.$store.commit('setDevMode', res.data.developerMode)
|
||||
this.$store.commit('setTheme', res.data.theme)
|
||||
this.$store.commit('setExecutive', res.data.executive)
|
||||
this.$store.commit('setUserId', res.data.id)
|
||||
this.closeConn()
|
||||
}).catch(err => {
|
||||
this.showConn()
|
||||
|
@ -865,6 +869,7 @@
|
|||
this.$store.commit('setKoins', res.data.koins)
|
||||
this.$store.commit('setTheme', res.data.theme)
|
||||
this.$store.commit('setExecutive', res.data.executive)
|
||||
this.$store.commit('setUserId', res.data.id)
|
||||
if(res.data.theme === "dark") {
|
||||
this.darkTheme()
|
||||
}
|
||||
|
@ -927,6 +932,23 @@
|
|||
@import url('https://fonts.googleapis.com/css?family=Lato:400,400i,500,500i,700');
|
||||
@import './assets/scss/variables.scss';
|
||||
@import './assets/sass/primary';
|
||||
@import './assets/scss/elements.scss';
|
||||
@import './assets/scss/transitions.scss';
|
||||
|
||||
@import url('https://fonts.googleapis.com/css?family=Lato:400,700&subset=latin-ext');
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body, #app {
|
||||
color: $text-primary;
|
||||
font-family: $font-family;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
}
|
||||
$primary: #0ba8e6 ;
|
||||
$colors: (
|
||||
"primary": #0ba8e6
|
||||
|
|
|
@ -0,0 +1,84 @@
|
|||
@import './variables.scss';
|
||||
|
||||
.input {
|
||||
border: thin solid $gray-2;
|
||||
border-radius: 0.25rem;
|
||||
font-family: $font-family;
|
||||
font-weight: 300;
|
||||
height: 1.5rem;
|
||||
outline: none;
|
||||
padding: 0.25rem 0.5rem;
|
||||
transition: box-shadow 0.2s, border-color 0.2s;
|
||||
width: 100%;
|
||||
|
||||
&:hover, &:active {
|
||||
border-color: $gray-3;
|
||||
}
|
||||
&:focus {
|
||||
border-color: $gray-2;
|
||||
box-shadow: 0 0 0 2px $gray-2;
|
||||
}
|
||||
}
|
||||
.input--textarea {
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
height: unset;
|
||||
width: unset;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.label .input {
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
|
||||
.button {
|
||||
background-color: #fff;
|
||||
border: thin solid $gray-2;
|
||||
border-radius: 0.25rem;
|
||||
font-family: $font-family;
|
||||
padding: 0.5rem 1rem;
|
||||
transition: border-color 0.2s, box-shadow 0.2s, background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: $gray-hover;
|
||||
}
|
||||
&:active {
|
||||
background-color: $gray-0;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
&:focus {
|
||||
box-shadow: 0 0 0 2px $gray-2;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&::-moz-focus-inner {
|
||||
border: 0;
|
||||
}
|
||||
}
|
||||
.button--blue {
|
||||
background-color: $blue-2;
|
||||
border-color: $blue-5;
|
||||
color: #fff;
|
||||
|
||||
&:hover { background-color: darken($blue-2, 5%) }
|
||||
&:active { background-color: darken($blue-2, 7.5%) }
|
||||
&:focus { box-shadow: 0 0 0 2px $blue-5; }
|
||||
}
|
||||
.button--blue_border {
|
||||
border-color: $blue-5;
|
||||
color: $blue-5;
|
||||
&:focus {
|
||||
border-color: $blue-4;
|
||||
box-shadow: 0 0 0 2px $blue-4;
|
||||
}
|
||||
}
|
||||
.button--red {
|
||||
background-color: $red-1;
|
||||
border-color: $red-5;
|
||||
color: #fff;
|
||||
|
||||
&:hover { background-color: darken($red-1, 5%) }
|
||||
&:active { background-color: darken($red-1, 7.5%) }
|
||||
&:focus { box-shadow: 0 0 0 2px $red-5; }
|
||||
}
|
|
@ -0,0 +1,59 @@
|
|||
//Fade enter
|
||||
.transition-fade-enter, .transition-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
.transition-fade-enter-active, .transition-fade-leave-active {
|
||||
transition: opacity 0.2s linear;
|
||||
}
|
||||
|
||||
//Slide fade enter
|
||||
.transition-slide-fade-enter, .transition-slide-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-1rem) rotateX(-20deg);
|
||||
}
|
||||
.transition-slide-fade-enter-active, .transition-slide-fade-leave-active {
|
||||
transition: opacity 0.2s linear, transform 0.2s ease;
|
||||
}
|
||||
|
||||
//Horizontal slide enter
|
||||
.transition-horizontal-slide-enter {
|
||||
opacity: 0;
|
||||
transform: translateX(-1rem);
|
||||
}
|
||||
.transition-horizontal-slide-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(1rem);
|
||||
}
|
||||
.transition-horizontal-slide-enter-active, .transition-horizontal-slide-leave-active {
|
||||
transition: opacity 0.2s linear, transform 0.2s ease;
|
||||
}
|
||||
|
||||
//Grow
|
||||
.transition-grow-enter, .transition-grow-leave-to {
|
||||
max-width: 0;
|
||||
opacity: 0;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
.transition-grow-enter-to, .transition-grow-leave {
|
||||
max-width: 10rem;
|
||||
opacity: 1;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
.transition-grow-enter-active, .transition-grow-leave-active {
|
||||
transition: max-width 0.2s ease-in-out, opacity 0.2s;
|
||||
}
|
||||
|
||||
//Slide up
|
||||
.transition-slide-up-enter, .transition-slide-up-leave-to {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.transition-slide-up-enter-to, .transition-slide-up-leave {
|
||||
max-height: 3rem;
|
||||
opacity: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
.transition-slide-up-enter-active, .transition-slide-up-leave-active {
|
||||
transition: max-height 0.2s, opacity 0.2s;
|
||||
}
|
|
@ -222,3 +222,34 @@ $breakpoint--phone-thread: 500px;
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
//Colours
|
||||
$gray-hover: #F5F5F5; //on hover for background-color from white
|
||||
$gray-0: #EEEEEE; //off-white
|
||||
$gray-1: #E0E0E0; //lightest
|
||||
$gray-2: #BDBDBD; //lighter
|
||||
$gray-3: #9E9E9E; //default
|
||||
$gray-4: #757575; //darker
|
||||
$gray-5: #424242; //darkest
|
||||
|
||||
$blue-1: #64B5F6;
|
||||
$blue-2: #2196F3;
|
||||
$blue-3: #1E88E5;
|
||||
$blue-4: #1976D2;
|
||||
$blue-5: #115cd0;
|
||||
|
||||
$red-0: #EF5350;
|
||||
$red-1: #F44336;
|
||||
$red-2: #E53935;
|
||||
$red-3: #D32F2F;
|
||||
$red-4: #C62828;
|
||||
$red-5: #B71C1C;
|
||||
|
||||
|
||||
|
||||
//Text colours
|
||||
$text-primary: rgba(black, 0.87);
|
||||
$text-secondary: rgba(black, 0.54);
|
||||
|
||||
//Font
|
||||
$font-family: 'Lato', sans-serif;
|
||||
|
|
|
@ -0,0 +1,43 @@
|
|||
<template>
|
||||
<div class='loading_dots'>
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
@import '../assets/scss/variables.scss';
|
||||
|
||||
@keyframes glow {
|
||||
0% {
|
||||
background-color: $gray-4;
|
||||
}
|
||||
100% {
|
||||
background-color: $gray-2;
|
||||
transform: translateY(0.25rem);
|
||||
}
|
||||
}
|
||||
|
||||
$duration: 0.5s;
|
||||
|
||||
.loading_dots {
|
||||
display: inline-flex;
|
||||
|
||||
span {
|
||||
animation: $duration infinite alternate glow;
|
||||
background-color: $gray-4;
|
||||
border-radius: 100%;
|
||||
height: 0.75rem;
|
||||
margin: 0 0.125rem;
|
||||
width: 0.75rem;
|
||||
|
||||
&:nth-child(2) {
|
||||
animation-delay: $duration * 1/3;
|
||||
}
|
||||
&:nth-child(3) {
|
||||
animation-delay: $duration * 2/3;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,37 @@
|
|||
<template>
|
||||
<div class='loading_icon'>
|
||||
<div class='loading_icon__icon'></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
@import '../assets/scss/variables.scss';
|
||||
|
||||
@keyframes rotate {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.loading_icon {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 0.25rem 0;
|
||||
|
||||
@at-root #{&}__icon {
|
||||
animation-duration: 1s;
|
||||
animation-iteration-count: infinite;
|
||||
animation-name: rotate;
|
||||
animation-timing-function: linear;
|
||||
background-color: #fff;
|
||||
border: 0.25rem solid $gray-2;
|
||||
border-top-color: $blue-3;
|
||||
border-radius: 100%;
|
||||
height: 2rem;
|
||||
width: 2rem;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,115 @@
|
|||
<template>
|
||||
<div class='c_menu'>
|
||||
<div
|
||||
class='c_menu__slot'
|
||||
:class='{ "c_menu__slot--show": showMenu }'
|
||||
@click='showMenu = true'
|
||||
ref='slot'
|
||||
>
|
||||
<slot></slot>
|
||||
</div>
|
||||
<div
|
||||
class='c_menu__menu'
|
||||
:style='{ "left": left, "right": right }'
|
||||
:class='{ "c_menu__menu--show": showMenu }'
|
||||
>
|
||||
<div
|
||||
class='c_menu__item'
|
||||
:v-for='item in items'
|
||||
@click='showMenu = false; $emit(item.event)'
|
||||
>
|
||||
{{item.text}}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class='c_menu__overlay'
|
||||
:class='{ "c_menu__overlay--show": showMenu }'
|
||||
@click='showMenu = false'
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'c-menu',
|
||||
props: ['items'],
|
||||
data () {
|
||||
return {
|
||||
showMenu: false,
|
||||
left: '0',
|
||||
right: null
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
let rect = this.$refs.slot.getBoundingClientRect();
|
||||
let rem = 16;
|
||||
|
||||
if(rect.right + 10*rem + 0.5*rem > window.innerWidth) {
|
||||
this.left = null;
|
||||
this.right = '0';
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
@import '../assets/scss/variables.scss';
|
||||
|
||||
.c_menu {
|
||||
position: relative;
|
||||
|
||||
@at-root #{&}__slot {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@at-root #{&}__overlay {
|
||||
height: 100%;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
z-index: 2;
|
||||
|
||||
@at-root #{&}--show {
|
||||
pointer-events: all;
|
||||
}
|
||||
}
|
||||
@at-root #{&}__menu {
|
||||
background-color: #fff;
|
||||
border: 1px solid $gray-2;
|
||||
border-radius: 0.25rem;
|
||||
box-shadow: 0 0.25rem 0.25rem rgba(0, 0, 0, 0.2);
|
||||
margin-top: -0.5rem;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
padding: 0.25rem 0;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: calc(100% + 0.25rem);
|
||||
transition: margin-top 0.2s, opacity 0.2s;
|
||||
width: 10rem;
|
||||
z-index: 3;
|
||||
|
||||
@at-root #{&}--show {
|
||||
margin-top: 0;
|
||||
pointer-events: all;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@at-root #{&}__item {
|
||||
cursor: default;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.5rem 1rem;
|
||||
text-align: left;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: $gray-hover;
|
||||
}
|
||||
&:active {
|
||||
background-color: $gray-0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,90 @@
|
|||
<template>
|
||||
<div
|
||||
class='c_modal'
|
||||
:class='{ "c_modal--show": value }'
|
||||
@click.self='$emit("input", false)'
|
||||
>
|
||||
<div
|
||||
class='c_modal__window'
|
||||
:class='{ "c_modal__window--show": value }'
|
||||
:style='{ "width": width || "20rem" }'
|
||||
>
|
||||
<div class='c_modal__main'>
|
||||
<slot name='main'></slot>
|
||||
</div>
|
||||
<div class='c_modal__footer'>
|
||||
<slot name='footer'></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'c-modal',
|
||||
props: ['value', 'width']
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
@import '../assets/scss/variables.scss';
|
||||
|
||||
.c_modal {
|
||||
align-items: center;
|
||||
background-color: rgba($gray-5, 0.5);
|
||||
display: grid;
|
||||
grid-template-rows: auto 2rem;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
left: 0;
|
||||
padding-top: 2rem;
|
||||
opacity: 0;
|
||||
overflow-y: auto;
|
||||
pointer-events: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
transition: opacity 0.2s;
|
||||
width: 100%;
|
||||
z-index: 4;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
@at-root #{&}--show {
|
||||
opacity: 1;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
@at-root #{&}__window {
|
||||
background-color: #fff;
|
||||
border-radius: 0.25rem;
|
||||
transform: scale(1.1);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
|
||||
@at-root #{&}--show {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 0 1rem rgba($gray-5, 0.5), 0 1rem 1rem rgba($gray-5, 0.25);
|
||||
}
|
||||
}
|
||||
@at-root #{&}__main {
|
||||
font-weight: 300;
|
||||
font-size: 0.9rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
@at-root #{&}__footer {
|
||||
align-items: center;
|
||||
background-color: $gray-1;
|
||||
border-radius: 0 0 0.25rem 0.25rem;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 0.5rem 1rem;
|
||||
width: 100%;
|
||||
|
||||
button {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,32 @@
|
|||
<template>
|
||||
<c-modal :value='value' @input='e => { $emit("input", e) }'>
|
||||
<div slot='main'>
|
||||
<slot></slot>
|
||||
</div>
|
||||
<div slot='footer'>
|
||||
<button class='button' :class='buttonClass' @click='$emit("input", false); $emit("confirm")'>
|
||||
{{buttonText}}
|
||||
</button>
|
||||
<button class='button' @click='$emit("input", false)'>Cancel</button>
|
||||
</div>
|
||||
</c-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import CModal from './c-modal';
|
||||
|
||||
export default {
|
||||
name: 'c-prompt-modal',
|
||||
props: ['value', 'button-text', 'color'],
|
||||
components: { CModal },
|
||||
computed: {
|
||||
buttonClass () {
|
||||
if(this.color) {
|
||||
return 'button--' + this.color;
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
|
@ -0,0 +1,30 @@
|
|||
<template>
|
||||
<div class='scroll_load' @scroll='handler'>
|
||||
<c-loading-icon v-if='position === "top" && loading'></c-loading-icon>
|
||||
<slot></slot>
|
||||
<c-loading-icon v-if='position === "bottom" && loading'></c-loading-icon>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import CLoadingIcon from '../components/c-loading-icon';
|
||||
|
||||
export default {
|
||||
name: 'c-scroll-load',
|
||||
props: ['position', 'loading'],
|
||||
components: { CLoadingIcon },
|
||||
methods: {
|
||||
handler (e) {
|
||||
let $el = e.target;
|
||||
let height = $el.scrollHeight - $el.scrollTop - 16 <= $el.clientHeight;
|
||||
|
||||
if(
|
||||
(this.position === 'top' && $el.scrollTop < 16) ||
|
||||
(this.position === 'bottom' && height)
|
||||
) {
|
||||
this.$emit('load');
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
|
@ -0,0 +1,113 @@
|
|||
<template>
|
||||
<div
|
||||
class='conversation_message'
|
||||
:class='{
|
||||
"conversation_message--self": self,
|
||||
"conversation_message--margin": useMargin
|
||||
}'
|
||||
>
|
||||
<conversation-time-break :message='message' :previous='adjacentMessages.previous'></conversation-time-break>
|
||||
<div
|
||||
class='conversation_message__username'
|
||||
v-if='showUsername'
|
||||
>
|
||||
{{message.User.username}}
|
||||
</div>
|
||||
<pre
|
||||
class='conversation_message__message'
|
||||
:class='{
|
||||
"conversation_message__message--self": self
|
||||
}'
|
||||
>{{message.content}}</pre>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ConversationTimeBreak from './conversation-time-break';
|
||||
|
||||
export default {
|
||||
name: 'conversation-message',
|
||||
props: ['message', 'users', 'context'],
|
||||
components: {
|
||||
ConversationTimeBreak
|
||||
},
|
||||
computed: {
|
||||
self () {
|
||||
return this.message.User.username === this.$store.state.username;
|
||||
},
|
||||
showUsername () {
|
||||
let prev = this.adjacentMessages.previous;
|
||||
let selfUsername = this.message.User.username;
|
||||
|
||||
//If first message or not from the same user
|
||||
//and there are more than two users in the conversation
|
||||
return (
|
||||
(!prev || prev.User.username !== selfUsername) &&
|
||||
(this.users.length > 2 && !this.self)
|
||||
);
|
||||
},
|
||||
useMargin () {
|
||||
let next = this.context[1];
|
||||
|
||||
//If next message exists and is not from the same user
|
||||
return (
|
||||
next &&
|
||||
next.User.username !== this.message.User.username
|
||||
);
|
||||
},
|
||||
adjacentMessages () {
|
||||
let currentIndex = this.context.findIndex(m => m.id === this.message.id);
|
||||
|
||||
return {
|
||||
previous: this.context[currentIndex-1],
|
||||
next: this.context[currentIndex+1]
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
@import '../assets/scss/variables.scss';
|
||||
|
||||
.conversation_message {
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 0.125rem;
|
||||
|
||||
@at-root #{&}--self {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
@at-root #{&}--margin {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
@at-root #{&}__username {
|
||||
color: $text-secondary;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 300;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
@at-root #{&}__message {
|
||||
background-color: $gray-0;
|
||||
border-radius: 1rem;
|
||||
font-family: $font-family;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 300;
|
||||
margin: 0;
|
||||
max-width: 75%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
white-space: pre-line;
|
||||
word-break: break-all;
|
||||
|
||||
@at-root #{&}--self {
|
||||
background-color: $blue-2;
|
||||
border-radius: 1rem;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,53 @@
|
|||
<template>
|
||||
<div class='conversation_time_break' v-if='showDate'>
|
||||
{{formattedDate}}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'conversation-time-break',
|
||||
props: ['message', 'previous'],
|
||||
computed: {
|
||||
formattedDate () {
|
||||
let date = new Date(this.message.createdAt);
|
||||
|
||||
let beginningOfToday = new Date();beginningOfToday.setMilliseconds(0);beginningOfToday.setSeconds(0);beginningOfToday.setMinutes(0);beginningOfToday.setHours(0);
|
||||
let beginningOfYesterday = new Date(beginningOfToday - 24*60*60*1000);
|
||||
let timeString = date.toTimeString().slice(0, 5);
|
||||
|
||||
if(date - beginningOfToday >= 0) {
|
||||
return timeString;
|
||||
} else if(date - beginningOfYesterday >= 0) {
|
||||
return 'Yesterday at ' + timeString;
|
||||
} else {
|
||||
return date.toLocaleDateString() + ' at ' + timeString;
|
||||
}
|
||||
},
|
||||
showDate () {
|
||||
if(!this.previous) {
|
||||
return true;
|
||||
} else {
|
||||
let date = new Date(this.message.createdAt);
|
||||
let prev = new Date(this.previous.createdAt);
|
||||
|
||||
//Greater than 30 minutes difference
|
||||
return !prev || (date - prev) > 1000*60*30;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
@import '../assets/scss/variables.scss';
|
||||
|
||||
.conversation_time_break {
|
||||
color: $text-secondary;
|
||||
cursor: default;
|
||||
font-size: 0.85rem;
|
||||
text-align: center;
|
||||
user-select: none;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,281 @@
|
|||
<template>
|
||||
<div class='new_conversation_input'>
|
||||
<div
|
||||
class='new_conversation_input__input_bar'
|
||||
:class='{ "new_conversation_input__input_bar--focus": inputFocused }'
|
||||
>
|
||||
<div class='new_conversation_input__selected_users' v-if="selected.user">
|
||||
<div class='new_conversation_input__selected_user' :v-for='user in selected'>
|
||||
{{user.username}}
|
||||
</div>
|
||||
</div>
|
||||
<div class='new_conversation_input__placeholders_input'>
|
||||
<input
|
||||
ref='input'
|
||||
v-model='input'
|
||||
placeholder="Enter a username"
|
||||
class='new_conversation_input__input'
|
||||
@keydown='inputHandler'
|
||||
@focus='inputFocused = true;'
|
||||
@blur='inputFocused = false;'
|
||||
/>
|
||||
<div class='new_conversation_input__placeholders'>
|
||||
<span
|
||||
class='new_conversation_input__placeholder--hidden'
|
||||
>
|
||||
{{placeholders[0]}}
|
||||
</span>
|
||||
<span
|
||||
class='new_conversation_input__placeholder'
|
||||
v-if='placeholders[1]'
|
||||
>
|
||||
{{placeholders[1]}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<transition name='transition-slide-fade'>
|
||||
<div class='new_conversation_input__suggestions' v-if='suggestions.length'>
|
||||
<div
|
||||
class='new_conversation_input__suggestion_item'
|
||||
:class='{ "new_conversation_input__suggestion_item--focus": focusedSuggestionIndex === $index }'
|
||||
tabindex='0'
|
||||
:v-for='(suggestion, $index) in suggestions'
|
||||
@click='addUser(suggestion)'
|
||||
>
|
||||
<div>
|
||||
<div class='new_conversation_input__profile_picture'></div>
|
||||
</div>
|
||||
<div class='new_conversation_input__name'>{{suggestion.username}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'new-conversation-input',
|
||||
props: ['value'],
|
||||
data () {
|
||||
return {
|
||||
input: '',
|
||||
inputFocused: false,
|
||||
suggestionsData: [],
|
||||
selected: [],
|
||||
focusedSuggestionIndex: null
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
suggestions () {
|
||||
return this.suggestionsData.filter(user => {
|
||||
return (
|
||||
!this.selected.filter(u => u.id === user.id).length &&
|
||||
user.username !== this.$store.state.username
|
||||
);
|
||||
});
|
||||
},
|
||||
selectedSuggestionIndex () {
|
||||
if(this.focusedSuggestionIndex !== null) {
|
||||
return this.focusedSuggestionIndex;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
},
|
||||
placeholders () {
|
||||
if(!this.suggestions.length) {
|
||||
return ['', ''];
|
||||
}
|
||||
let firstSuggestion = this.suggestions[this.selectedSuggestionIndex].username;
|
||||
|
||||
return [
|
||||
firstSuggestion.slice(0, this.input.length),
|
||||
firstSuggestion.slice(this.input.length, firstSuggestion.length)
|
||||
];
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
inputHandler (e) {
|
||||
//tab, space or enter
|
||||
if([9, 32, 13].indexOf(e.keyCode) > -1) {
|
||||
e.preventDefault();
|
||||
this.addUser();
|
||||
//backspace
|
||||
} else if (
|
||||
e.keyCode === 8 &&
|
||||
!this.input.length &&
|
||||
this.selected.length
|
||||
) {
|
||||
this.input = this.selected.pop().username;
|
||||
//Down
|
||||
} else if(e.keyCode === 40) {
|
||||
this.setSelectionFocus(1);
|
||||
//Up
|
||||
} else if(e.keyCode === 38) {
|
||||
this.setSelectionFocus(-1);
|
||||
}
|
||||
},
|
||||
setSelectionFocus (direction) {
|
||||
let index = this.focusedSuggestionIndex;
|
||||
let length = this.suggestions.length;
|
||||
|
||||
if(!length) return;
|
||||
|
||||
if(index !== null) {
|
||||
let newIndex = (index + 1*direction) % length;
|
||||
this.focusedSuggestionIndex = (newIndex > 0) ? newIndex : null;
|
||||
} else if (direction === 1 && index === null) {
|
||||
this.focusedSuggestionIndex = 0;
|
||||
}
|
||||
},
|
||||
addUser (user) {
|
||||
if(user) {
|
||||
this.selected.push(user);
|
||||
} else if(this.suggestions.length) {
|
||||
this.selected.push(
|
||||
this.suggestions[this.selectedSuggestionIndex]
|
||||
);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
this.input = '';
|
||||
this.focusedSuggestionIndex = null;
|
||||
this.$refs.input.focus();
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
input () {
|
||||
let input = this.input.trim();
|
||||
|
||||
if(input) {
|
||||
this.axios
|
||||
.get('/api/v1/kaverti/search/user/?q=' + input)
|
||||
.then(res => {
|
||||
this.suggestionsData = res.data.users;
|
||||
})
|
||||
.catch(e => {
|
||||
this.$store.commit('setErrors', e.response.data.errors);
|
||||
})
|
||||
} else {
|
||||
this.suggestionsData = [];
|
||||
}
|
||||
},
|
||||
selected () {
|
||||
this.$emit('input', this.selected);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
@import '../assets/scss/variables.scss';
|
||||
@import '../assets/scss/elements.scss';
|
||||
|
||||
.new_conversation_input {
|
||||
position: relative;
|
||||
|
||||
@at-root #{&}__input_bar {
|
||||
@extend .input;
|
||||
|
||||
display: flex;
|
||||
height: 2rem;
|
||||
padding: 0 0.25rem;
|
||||
padding-left: 0.125rem;
|
||||
width: 30rem;
|
||||
overflow-x: hidden;
|
||||
|
||||
@at-root #{&}--focus {
|
||||
border-color: $gray-2;
|
||||
box-shadow: 0 0 0 2px $gray-2;
|
||||
}
|
||||
}
|
||||
@at-root #{&}__selected_users {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
font-weight: 300;
|
||||
font-size: 0.85rem;
|
||||
margin-right: 0.125rem;
|
||||
}
|
||||
@at-root #{&}__selected_user {
|
||||
background-color: $gray-1;
|
||||
border-radius: 0.25rem;
|
||||
cursor: default;
|
||||
font-size: 0.8rem;
|
||||
height: 1.5rem;
|
||||
line-height: 1.5rem;
|
||||
margin: 0.125rem;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
@at-root #{&}__placeholders_input {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
@at-root #{&}__input {
|
||||
border: 0;
|
||||
font-family: $font-family;
|
||||
font-weight: 300;
|
||||
font-size: 0.85rem;
|
||||
height: 100%;
|
||||
outline: none;
|
||||
width: 100%;
|
||||
}
|
||||
@at-root #{&}__placeholders {
|
||||
align-items: center;
|
||||
color: $gray-2;
|
||||
display: flex;
|
||||
font-size: 0.85rem;
|
||||
height: 100%;
|
||||
left: 1px;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
}
|
||||
@at-root #{&}__placeholder {
|
||||
margin-left: -0.5px;
|
||||
|
||||
@at-root #{&}--hidden {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
@at-root #{&}__suggestions {
|
||||
background-color: #fff;
|
||||
border: thin solid $gray-2;
|
||||
border-radius: 0.25rem;
|
||||
box-shadow: 0 0.25rem 0.25rem rgba(0, 0, 0, 0.2);
|
||||
left: calc(50% - 15rem);
|
||||
max-height: 25rem;
|
||||
overflow-y: auto;
|
||||
padding: 0.25rem 0;
|
||||
position: absolute;
|
||||
top: calc(100% + 0.25rem);
|
||||
width: 30rem;
|
||||
}
|
||||
@at-root #{&}__suggestion_item {
|
||||
align-items: center;
|
||||
cursor: default;
|
||||
display: flex;
|
||||
font-weight: 300;
|
||||
padding: 0.5rem 1rem;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: $gray-hover;
|
||||
}
|
||||
@at-root #{&}--focus {
|
||||
background-color: $gray-0;
|
||||
}
|
||||
&:active, &:focus {
|
||||
background-color: $gray-0;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
@at-root #{&}__profile_picture {
|
||||
border-radius: 100%;
|
||||
background-color: $gray-1;
|
||||
height: 1.5rem;
|
||||
margin-right: 0.5rem;
|
||||
width: 1.5em;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -1,78 +1,29 @@
|
|||
<template>
|
||||
<div class="container">
|
||||
<div class="columns">
|
||||
<div class="column" cols="6">
|
||||
<b-text-field
|
||||
b-model="textInput"
|
||||
@keypress.enter="sendMessage()"
|
||||
></b-text-field>
|
||||
<b-button @click="sendMessage()">
|
||||
Send
|
||||
</b-button>
|
||||
</div>
|
||||
<div class="column" cols="6">
|
||||
<div class="card">
|
||||
<div class="table">
|
||||
<template b-slot:default>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Message</th>
|
||||
<th>Sent</th>
|
||||
<th>Processed</th>
|
||||
<th>Received</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
b-for="output in serverOutput"
|
||||
:key="output.text"
|
||||
>
|
||||
<td>{{ output.message }}</td>
|
||||
<td>{{ formatTime(output.sent) }}</td>
|
||||
<td>{{ formatTime(output.processed) }}</td>
|
||||
<td>{{ formatTime(output.received) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class='index'>
|
||||
<side-panel></side-panel>
|
||||
<router-view class='router_view'></router-view>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import * as socketio from 'socket.io-client';
|
||||
import SidePanel from '../side-panel';
|
||||
|
||||
export default {
|
||||
name: 'RealTimeDemo',
|
||||
data: () => ({
|
||||
textInput: '',
|
||||
serverOutput: []
|
||||
}),
|
||||
mounted() {
|
||||
socketio.addEventListener({
|
||||
type: 'message',
|
||||
callback: (message) => {
|
||||
message.received = Date.now();
|
||||
this.serverOutput.push(message);
|
||||
}
|
||||
});
|
||||
},
|
||||
methods: {
|
||||
sendMessage() {
|
||||
socketio.sendEvent({
|
||||
type: 'message',
|
||||
data: {
|
||||
message: this.textInput,
|
||||
sent: Date.now()
|
||||
}
|
||||
});
|
||||
|
||||
this.textInput = '';
|
||||
},
|
||||
formatTime(timestamp) {
|
||||
return this.moment(timestamp).format('h:mm:ss.SSS');
|
||||
}
|
||||
}
|
||||
}
|
||||
name: 'app-route',
|
||||
components: { SidePanel }
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
.index {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.router_view {
|
||||
width: calc(100% - 17rem);
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -0,0 +1,436 @@
|
|||
<template>
|
||||
<div class='conversation'>
|
||||
<c-prompt-modal
|
||||
v-model='showDeleteModal'
|
||||
button-text='OK, delete'
|
||||
color='red'
|
||||
@confirm='alert'
|
||||
>
|
||||
Are you sure you want to delete this conversation?
|
||||
</c-prompt-modal>
|
||||
|
||||
<c-prompt-modal
|
||||
v-model='showEditModal'
|
||||
button-text='Change name'
|
||||
color='blue'
|
||||
@confirm='saveEditModalModelName'
|
||||
>
|
||||
Enter the new name for your chat below
|
||||
<input
|
||||
type='text'
|
||||
placeholder='Chat name'
|
||||
class='input'
|
||||
style='margin-top: 0.5rem;'
|
||||
v-model='editModalModel'
|
||||
/>
|
||||
</c-prompt-modal>
|
||||
|
||||
<transition name='transition-fade' mode='out-in'>
|
||||
<div
|
||||
class='conversation__header'
|
||||
key='new-conversation'
|
||||
v-if='showNewConversationBar'
|
||||
>
|
||||
<new-conversation-input
|
||||
class='conversation__new_conversation_input'
|
||||
@input='selected => { newConversationUsers = selected }'
|
||||
></new-conversation-input>
|
||||
</div>
|
||||
<div class='conversation__header' key='header' v-else>
|
||||
<div class='conversation__title'>{{name}}</div>
|
||||
<div class='conversation__actions'>
|
||||
<c-menu
|
||||
:items='settingsItems'
|
||||
@delete='showDeleteModal = true'
|
||||
@edit='showEditModal = true'
|
||||
>Settings</c-menu>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<c-scroll-load
|
||||
class='conversation__main'
|
||||
position='top'
|
||||
:loading='loading'
|
||||
@load='getConversation'
|
||||
|
||||
ref='conversation'
|
||||
>
|
||||
<div class='conversation__main__conversations'>
|
||||
<conversation-message
|
||||
:v-for='message in messages'
|
||||
:context='messages'
|
||||
:message='message'
|
||||
:users='users'
|
||||
></conversation-message>
|
||||
</div>
|
||||
|
||||
<user-typing :users='users' :typing-users='typingUsers'></user-typing>
|
||||
|
||||
<div style='padding: 2.5rem'></div>
|
||||
</c-scroll-load>
|
||||
|
||||
|
||||
<div class='conversation__input_bar input'>
|
||||
<textarea
|
||||
class='input--textarea conversation__input'
|
||||
placeholder='Type your message here'
|
||||
@keydown.enter.prevent='() => $route.params.id ? sendMessage() : createConversation()'
|
||||
@keydown='sendTyping'
|
||||
v-model='input'
|
||||
></textarea>
|
||||
<button
|
||||
class='conversation__submit button button--blue'
|
||||
@click='() => $route.params.id ? sendMessage() : createConversation()'
|
||||
>
|
||||
<font-awesome-icon icon='paper-plane'></font-awesome-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import CMenu from '../c-menu';
|
||||
import ConversationMessage from '../conversation-message';
|
||||
import ConversationTimeBreak from '../conversation-time-break';
|
||||
import CPromptModal from '../c-prompt-modal';
|
||||
import CScrollLoad from '../c-scroll-load';
|
||||
import NewConversationInput from '../new-conversation-input';
|
||||
import UserTyping from '../user-typing';
|
||||
|
||||
export default {
|
||||
name: 'conversation',
|
||||
components: {
|
||||
CMenu,
|
||||
ConversationMessage,
|
||||
// eslint-disable-next-line vue/no-unused-components
|
||||
ConversationTimeBreak,
|
||||
CPromptModal,
|
||||
CScrollLoad,
|
||||
NewConversationInput,
|
||||
UserTyping
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
name: '',
|
||||
messages: [],
|
||||
users: [],
|
||||
input: '',
|
||||
page: 1,
|
||||
|
||||
typingUsers: [],
|
||||
typingInterval: null,
|
||||
typingTimer: null,
|
||||
|
||||
newConversationUsers: [],
|
||||
|
||||
settingsItems: [
|
||||
{ text: 'Delete', event: 'delete' },
|
||||
{ text: 'Edit chat name', event: 'edit' }
|
||||
],
|
||||
|
||||
showDeleteModal: false,
|
||||
|
||||
showEditModal: false,
|
||||
editModalModel: '',
|
||||
|
||||
showNewConversationBar: !this.$route.params.id,
|
||||
|
||||
loading: false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
alert () {
|
||||
console.log('Confirm')
|
||||
},
|
||||
saveEditModalModelName () {
|
||||
let name = this.editModalModel.trim();
|
||||
|
||||
if(name.length) {
|
||||
this.axios
|
||||
.put(`/api/v1/chat/conversation/${this.$route.params.id}/name`, { name })
|
||||
.then(res => {
|
||||
this.name = name;
|
||||
res
|
||||
this.$store.commit(
|
||||
'updateConversationName',
|
||||
{ id: +this.$route.params.id, name }
|
||||
);
|
||||
})
|
||||
.catch(e => {
|
||||
this.$store.commit('setErrors', e.response.data.errors);
|
||||
});
|
||||
}
|
||||
|
||||
this.editModalModel = '';
|
||||
},
|
||||
clearData () {
|
||||
this.name = '';
|
||||
this.messages = [];
|
||||
this.page = 1;
|
||||
this.newConversationUsers = [];
|
||||
|
||||
this.$io.emit('leaveConversation', {
|
||||
conversationId: +this.$route.params.id || 0
|
||||
});
|
||||
},
|
||||
hasConversationGotScrollbar () {
|
||||
let $el = this.$refs.conversation.$el;
|
||||
|
||||
return $el.scrollHeight > $el.clientHeight;
|
||||
},
|
||||
getConversation () {
|
||||
//If there all pages have been loaded or
|
||||
//a new page is currently loading
|
||||
//then do not send off another request
|
||||
if(!this.$route.params.id || this.page === null || this.loading) return;
|
||||
|
||||
|
||||
this.showNewConversationBar = false;
|
||||
this.loading = true;
|
||||
|
||||
this.axios
|
||||
.get(`/api/v1/chat/conversation/${this.$route.params.id}?page=${this.page}`)
|
||||
.then(res => {
|
||||
this.loading = false;
|
||||
this.showModal = false;
|
||||
|
||||
this.users = res.data.Users;
|
||||
this.name = res.data.name;
|
||||
this.page= res.data.continuePagination ? this.page + 1 : null;
|
||||
|
||||
let $conversation = this.$refs.conversation.$el;
|
||||
let scrollBottom = $conversation.scrollHeight - $conversation.scrollTop;
|
||||
|
||||
let ids = this.messages.map(m => m.id);
|
||||
let uniqueMessages = res.data.Messages.filter(message => {
|
||||
return !ids.includes(message.id);
|
||||
});
|
||||
this.messages.unshift(...uniqueMessages);
|
||||
|
||||
//Scroll back to original position before new messages were added
|
||||
this.$nextTick(() => {
|
||||
$conversation.scrollTop = $conversation.scrollHeight - scrollBottom;
|
||||
});
|
||||
|
||||
//Keep loading conversations until there is a scroll bar
|
||||
//To enable the scroll load mechanism
|
||||
if(!this.hasConversationGotScrollbar()) {
|
||||
this.getConversation();
|
||||
}
|
||||
})
|
||||
.catch(e => {
|
||||
this.loading = false;
|
||||
this.$store.commit('setErrors', e.response.data.errors);
|
||||
});
|
||||
},
|
||||
createConversation () {
|
||||
let userIds = this.newConversationUsers.map(user => user.id);
|
||||
userIds.push(+this.$store.state.userId);
|
||||
|
||||
//If there is no message or only one user (themselves)
|
||||
//then do not create new conversation
|
||||
if(!this.input.trim().length || userIds.length < 2) return;
|
||||
|
||||
this.axios
|
||||
.post('/api/v1/chat/conversation', { userIds })
|
||||
.then(res => {
|
||||
this.name = res.data.name;
|
||||
this.$router.push({
|
||||
name: 'conversation',
|
||||
params: { id: res.data.id }
|
||||
});
|
||||
this.sendMessage();
|
||||
})
|
||||
.catch(e => {
|
||||
this.$store.commit('setErrors', e.response.data.errors);
|
||||
});
|
||||
},
|
||||
updateLastRead () {
|
||||
this.axios.put('/api/v1/chat/conversation/' + this.$route.params.id);
|
||||
|
||||
//Conversation panel might not have loaded request, so try again in 200 msec
|
||||
if(!this.$store.state.conversations.length) {
|
||||
setTimeout(this.updateLastRead, 200);
|
||||
} else {
|
||||
this.$store.commit('updateConversationLastRead', +this.$route.params.id);
|
||||
}
|
||||
},
|
||||
sendMessage () {
|
||||
if(!this.input.trim().length) return;
|
||||
|
||||
this.$io.emit('stopTyping', {
|
||||
conversationId: +this.$route.params.id
|
||||
});
|
||||
|
||||
this.axios
|
||||
.post('/api/v1/chat/message', {
|
||||
content: this.input.trim(),
|
||||
conversationId: +this.$route.params.id
|
||||
})
|
||||
.then(res => {
|
||||
res
|
||||
this.input = '';
|
||||
})
|
||||
.catch(e => {
|
||||
this.$store.commit('setErrors', e.response.data.errors);
|
||||
});
|
||||
},
|
||||
setTypingTimer () {
|
||||
this.typingTimer = setTimeout(() => {
|
||||
this.typingInterval = null;
|
||||
|
||||
this.$io.emit('stopTyping', {
|
||||
conversationId: +this.$route.params.id
|
||||
});
|
||||
}, 2000);
|
||||
},
|
||||
sendTyping (e) {
|
||||
//Ignore enter keypress or if no conversation created yet
|
||||
if(e.keyCode === 13 || !this.$route.params.id) return;
|
||||
|
||||
//if interval does not exist --> send startTyping, start timer
|
||||
if(this.typingInterval === null) {
|
||||
this.$io.emit('startTyping', {
|
||||
conversationId: +this.$route.params.id
|
||||
});
|
||||
|
||||
this.typingInterval = new Date();
|
||||
this.setTypingTimer();
|
||||
//if interval is less than 2 seconds --> clear timer
|
||||
} else if (new Date() - this.typingInterval < 2000) {
|
||||
clearTimeout(this.typingTimer);
|
||||
this.setTypingTimer();
|
||||
}
|
||||
},
|
||||
scrollToBottom (scrollIfNotAtBottom) {
|
||||
let $conversation = this.$refs.conversation.$el;
|
||||
|
||||
//If currently scorlled to bottom or parameter set to true
|
||||
if(
|
||||
scrollIfNotAtBottom ||
|
||||
$conversation.scrollHeight - $conversation.scrollTop === $conversation.clientHeight
|
||||
) {
|
||||
this.$nextTick(() => {
|
||||
$conversation.scrollTop = $conversation.scrollHeight;
|
||||
});
|
||||
}
|
||||
},
|
||||
pageLoad () {
|
||||
this.clearData();
|
||||
|
||||
if(this.$route.params.id) {
|
||||
this.$io.emit('joinConversation', {
|
||||
conversationId: +this.$route.params.id
|
||||
});
|
||||
this.getConversation();
|
||||
this.updateLastRead();
|
||||
} else {
|
||||
this.showNewConversationBar = true;
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
'$route.params': 'pageLoad'
|
||||
},
|
||||
mounted () {
|
||||
this.pageLoad();
|
||||
|
||||
this.$io.on('message', message => {
|
||||
if(message.ConversationId !== +this.$route.params.id) return;
|
||||
|
||||
this.messages.push(message);
|
||||
this.scrollToBottom();
|
||||
this.updateLastRead();
|
||||
});
|
||||
this.$io.on('startTyping', ({ userId }) => {
|
||||
let user = this.users.find(u => u.id === userId);
|
||||
this.typingUsers.push(user);
|
||||
this.scrollToBottom();
|
||||
});
|
||||
this.$io.on('stopTyping', ({ userId }) => {
|
||||
let index = this.typingUsers.findIndex(u => u.id === userId);
|
||||
this.typingUsers.splice(index, 1);
|
||||
});
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
@import '../../assets/scss/variables';
|
||||
|
||||
.conversation {
|
||||
display: grid;
|
||||
grid-template-rows: 3rem auto;
|
||||
position: relative;
|
||||
|
||||
@at-root #{&}__header {
|
||||
border-bottom: thin solid $gray-1;
|
||||
display: grid;
|
||||
grid-column-gap: 0.5rem;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
padding: 0rem 2rem;
|
||||
}
|
||||
@at-root #{&}__title, #{&}__new_conversation_input {
|
||||
align-self: center;
|
||||
font-weight: bold;
|
||||
grid-column: 2;
|
||||
justify-self: center;
|
||||
}
|
||||
@at-root #{&}__actions {
|
||||
align-self: center;
|
||||
font-weight: 300;
|
||||
grid-column: 3;
|
||||
justify-self: end;
|
||||
}
|
||||
@at-root #{&}__main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
justify-content: space-between;
|
||||
overflow-y: auto;
|
||||
padding: 0 1rem;
|
||||
padding-top: 1rem;
|
||||
|
||||
@at-root #{&}__conversations {
|
||||
align-content: flex-end;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
@at-root #{&}__input_bar {
|
||||
align-items: center;
|
||||
background-color: #fff;
|
||||
box-shadow: 0 5px 6px 0px $gray-1;
|
||||
bottom: 0.5rem;
|
||||
display: flex;
|
||||
height: unset;
|
||||
margin: 0 2rem;
|
||||
padding: 0rem;
|
||||
position: absolute;
|
||||
width: calc(100% - 4rem);
|
||||
}
|
||||
@at-root #{&}__input {
|
||||
border: 0;
|
||||
font-family: $font-family;
|
||||
height: 3.25rem;
|
||||
padding: 0.75rem;
|
||||
width: 100%;
|
||||
}
|
||||
@at-root #{&}__submit {
|
||||
border-radius: 100%;
|
||||
box-shadow: 0 4px 6px rgba($blue-5, 0.25);
|
||||
font-size: 1rem;
|
||||
height: 2rem;
|
||||
margin: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
width: 2rem;
|
||||
|
||||
svg {
|
||||
left: -0.1rem;
|
||||
position: relative;
|
||||
top: -0.15rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,212 @@
|
|||
<template>
|
||||
<div
|
||||
class='side_panel_conversation'
|
||||
:class='{
|
||||
"side_panel_conversation--selected": selected,
|
||||
"side_panel_conversation--unread": unread
|
||||
}'
|
||||
@click='goToConversation'
|
||||
@keydown.enter='goToConversation'
|
||||
>
|
||||
<div>
|
||||
<div class='side_panel_conversation__profile_picture'>
|
||||
<div
|
||||
class='side_panel_conversation__profile_picture__letter'
|
||||
:v-for='userLetter in userLetters'
|
||||
:class='[
|
||||
"side_panel_conversation__profile_picture__letter--" + userLetters.length
|
||||
]'
|
||||
:style='{
|
||||
"background-color": userLetter.color
|
||||
}'
|
||||
>
|
||||
{{userLetter.letter}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class='side_panel_conversation__conversation_content'>
|
||||
<div class='side_panel_conversation__name'>{{conversation.name}}</div>
|
||||
<div class='side_panel_conversation__snippet'>
|
||||
{{username}}:
|
||||
{{conversation.Messages[0].content}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'side-panel-conversation',
|
||||
props: ['conversation'],
|
||||
computed: {
|
||||
selected () {
|
||||
return (
|
||||
this.$route.name === 'conversation' &&
|
||||
+this.$route.params.id === this.conversation.id
|
||||
);
|
||||
},
|
||||
unread () {
|
||||
return (
|
||||
new Date(this.conversation.lastRead) - new Date(this.conversation.Messages[0].createdAt) < 0
|
||||
);
|
||||
},
|
||||
username () {
|
||||
let username = this.conversation.Messages[0].User.username;
|
||||
|
||||
if(username === this.$store.state.username) {
|
||||
return 'You';
|
||||
} else {
|
||||
return username;
|
||||
}
|
||||
},
|
||||
userLetters () {
|
||||
return this.conversation.Users
|
||||
.filter(u => u.username !== this.$store.state.username)
|
||||
.map(u => {
|
||||
return {
|
||||
letter: u.username[0].toUpperCase(),
|
||||
color: u.color
|
||||
};
|
||||
})
|
||||
.slice(0, 4);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
goToConversation () {
|
||||
this.$router.push("/chat/" + this.conversation.id);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
@import '../assets/scss/variables.scss';
|
||||
|
||||
.side_panel_conversation {
|
||||
background-color: #fff;
|
||||
//border-bottom: thin solid $gray-1;
|
||||
border-radius: 0.25rem;
|
||||
cursor: default;
|
||||
display: flex;
|
||||
height: 5rem;
|
||||
margin: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
position: relative;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover, &:focus {
|
||||
background-color: $gray-hover;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&::after {
|
||||
background-color: $gray-3;
|
||||
border-radius: 0.25rem 0 0 0.25rem;
|
||||
content: '';
|
||||
height: 100%;
|
||||
left: 0;
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
transition: opacity 0.2s;
|
||||
width: 0.25rem;
|
||||
}
|
||||
|
||||
@at-root #{&}--selected {
|
||||
background-color: $gray-0;
|
||||
|
||||
&::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:hover, &:focus {
|
||||
background-color: $gray-0;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
@at-root #{&}--unread {
|
||||
background-color: transparentize($blue-1, 0.7);
|
||||
font-weight: bold;
|
||||
|
||||
&:hover { background-color: transparentize($blue-1, 0.7); }
|
||||
}
|
||||
|
||||
@at-root #{&}__profile_picture {
|
||||
background-color: $gray-1;
|
||||
border-radius: 100%;
|
||||
height: 3rem;
|
||||
overflow: hidden;
|
||||
width: 3rem;
|
||||
|
||||
@at-root #{&}__letter {
|
||||
font-weight: 300;
|
||||
padding: 0.25rem;
|
||||
text-align: center;
|
||||
|
||||
@at-root #{&}--1, #{&}--2 {
|
||||
font-size: 1.5rem;
|
||||
height: 3rem;
|
||||
line-height: 2.5rem;
|
||||
width: 3rem;
|
||||
}
|
||||
@at-root #{&}--2 {
|
||||
display: inline-block;
|
||||
font-size: 1rem;
|
||||
width: 1.5rem;
|
||||
}
|
||||
@at-root #{&}--3 {
|
||||
height: 1.5rem;
|
||||
|
||||
&:nth-child(1), &:nth-child(2) {
|
||||
display: inline-block;
|
||||
padding-bottom: 0;
|
||||
width: 1.5rem;
|
||||
}
|
||||
&:nth-child(3) {
|
||||
padding-bottom: 0.5rem;
|
||||
padding-top: 0;
|
||||
width: 3rem;
|
||||
}
|
||||
}
|
||||
@at-root #{&}--4 {
|
||||
display: inline-block;
|
||||
height: 1.5rem;
|
||||
width: 1.5rem;
|
||||
|
||||
&:nth-child(1), &:nth-child(2) {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
&:nth-child(3), &:nth-child(4) {
|
||||
padding-top: 0;
|
||||
}
|
||||
&:nth-child(1), &:nth-child(3) {
|
||||
padding-right: 0;
|
||||
}
|
||||
&:nth-child(2), &:nth-child(4) {
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@at-root #{&}__name {
|
||||
display: block;
|
||||
max-height: 1.25rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
width: 10.25rem;
|
||||
}
|
||||
@at-root #{&}__conversation_content {
|
||||
font-size: 0.9rem;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
@at-root #{&}__snippet {
|
||||
color: $text-secondary;
|
||||
font-weight: 300;
|
||||
height: 2.5rem;
|
||||
overflow-y: hidden;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,235 @@
|
|||
<template>
|
||||
<div class='side_panel'>
|
||||
<div class='side_panel__header'>
|
||||
<c-menu
|
||||
class='side_panel__username'
|
||||
:items='userMenu'
|
||||
@logout='logout'
|
||||
>
|
||||
{{$store.state.username}} <font-awesome-icon icon='angle-down'></font-awesome-icon>
|
||||
</c-menu>
|
||||
<button
|
||||
class='side_panel__add button button--blue_border'
|
||||
@click='$router.push("/chat/conversation")'
|
||||
>
|
||||
New conversation
|
||||
</button>
|
||||
</div>
|
||||
<div class='side_panel__search'>
|
||||
<input
|
||||
type='text'
|
||||
placeholder='Search conversations and people'
|
||||
class='input side_panel__search__input'
|
||||
:class='{
|
||||
"side_panel__search__input--small": showCloseButton
|
||||
}'
|
||||
@focus='setShowCloseButton(true)'
|
||||
@keyup='getConversations'
|
||||
v-model='searchQuery'
|
||||
>
|
||||
<transition name='transition-grow'>
|
||||
<button
|
||||
class='button side_panel__search__close'
|
||||
v-if='showCloseButton'
|
||||
@click='setShowCloseButton(false)'
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</transition>
|
||||
</div>
|
||||
<c-scroll-load
|
||||
class='side_panel__conversations'
|
||||
:class='{ "side_panel__conversations--empty": !$store.state.conversations.length }'
|
||||
|
||||
:loading='loading'
|
||||
position='bottom'
|
||||
@load='getConversations'
|
||||
>
|
||||
<side-panel-conversation
|
||||
:v-for='conversation in $store.state.conversations'
|
||||
:conversation='conversation'
|
||||
tabindex='0'
|
||||
></side-panel-conversation>
|
||||
|
||||
<div v-if='!$store.state.conversations.length && !loading'>
|
||||
No conversations
|
||||
</div>
|
||||
</c-scroll-load>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import CMenu from './c-menu';
|
||||
import CScrollLoad from './c-scroll-load';
|
||||
import SidePanelConversation from './side-panel-conversation';
|
||||
|
||||
export default {
|
||||
name: 'side-panel',
|
||||
components: {
|
||||
CMenu,
|
||||
CScrollLoad,
|
||||
SidePanelConversation
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
page: 0,
|
||||
|
||||
loading: false,
|
||||
userMenu: [
|
||||
{ text: 'Settings', event: 'settings' },
|
||||
{ text: 'Log out', event: 'logout' }
|
||||
],
|
||||
|
||||
showCloseButton: false,
|
||||
searchQuery: ''
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
//When typing for each letter clear the data
|
||||
searchQuery () {
|
||||
this.page = 0;
|
||||
this.$store.commit('clearConversations');
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
logout () {
|
||||
this.axios
|
||||
.post('/api/user/logout')
|
||||
.then(() => {
|
||||
this.$store.commit('setUser', { id: null, username: null });
|
||||
this.$router.push('/');
|
||||
})
|
||||
.catch(e => {
|
||||
this.$store.commit('setErrors', e.response.data.errors);
|
||||
});
|
||||
},
|
||||
setShowCloseButton (val) {
|
||||
if(this.showCloseButton !== val) {
|
||||
this.showCloseButton = val;
|
||||
this.$store.commit('clearConversations');
|
||||
this.page = 0;
|
||||
|
||||
if(!val) {
|
||||
this.searchQuery = '';
|
||||
this.getConversations();
|
||||
}
|
||||
}
|
||||
},
|
||||
getConversations () {
|
||||
if(!this.loading && this.page !== null) {
|
||||
let params = { page: this.page };
|
||||
let searchQuery = this.searchQuery.trim();
|
||||
if(searchQuery) params.search = searchQuery;
|
||||
|
||||
this.loading = true;
|
||||
|
||||
this.axios
|
||||
.get(`/api/v1/users/conversations`, { params })
|
||||
.then(res => {
|
||||
this.loading = false;
|
||||
this.$store.commit('addConversations', res.data.Conversations);
|
||||
this.page = res.data.continuePagination ? this.page+1 : null;
|
||||
})
|
||||
.catch(e => {
|
||||
this.loading = false;
|
||||
this.$store.commit('setErrors', e.response.data.errors);
|
||||
});
|
||||
}
|
||||
},
|
||||
updateConversations (updatedConversation) {
|
||||
//If conversation is already open, assume message has been read
|
||||
if(
|
||||
this.$route.name === 'conversation' &&
|
||||
this.$route.params.id &&
|
||||
+this.$route.params.id === updatedConversation.id
|
||||
) {
|
||||
updatedConversation.lastRead = new Date() + '';
|
||||
}
|
||||
this.$store.commit('updateUnshiftConversation', updatedConversation);
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
this.getConversations();
|
||||
this.$io.on('conversation', this.updateConversations);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
@import '../assets/scss/variables.scss';
|
||||
|
||||
.side_panel {
|
||||
border-right: thin solid $gray-1;
|
||||
display: grid;
|
||||
grid-template-rows: 4rem 2.75rem auto;
|
||||
height: 100%;
|
||||
width: 17rem;
|
||||
|
||||
@at-root #{&}__username {
|
||||
svg {
|
||||
color: $gray-5;
|
||||
position: relative;
|
||||
top: 0.1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@at-root #{&}__header {
|
||||
align-items: center;
|
||||
align-self: center;
|
||||
display: flex;
|
||||
font-weight: 300;
|
||||
justify-content: space-between;
|
||||
padding: 0 0.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
@at-root #{&}__username {
|
||||
cursor: pointer;
|
||||
|
||||
span {
|
||||
color: $gray-4;
|
||||
font-size: 0.85rem;
|
||||
margin-left: -0.25rem;
|
||||
}
|
||||
}
|
||||
@at-root #{&}__add {
|
||||
cursor: pointer;
|
||||
height: 2rem;
|
||||
}
|
||||
@at-root #{&}__search {
|
||||
align-self: start;
|
||||
display: grid;
|
||||
grid-template-columns: auto min-content;
|
||||
padding: 0 0.5rem;
|
||||
|
||||
@at-root #{&}__input {
|
||||
padding: 1rem 0.75rem;
|
||||
transition: padding 0.2s;
|
||||
|
||||
@at-root #{&}--small {
|
||||
padding: 1rem 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
@at-root #{&}__close {
|
||||
margin-left: 0.25rem;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
}
|
||||
@at-root #{&}__conversations {
|
||||
align-self: start;
|
||||
//border-top: thin solid $gray-1;
|
||||
max-height: 100%;
|
||||
padding: 0.25rem;
|
||||
overflow-y: auto;
|
||||
|
||||
@at-root #{&}--empty {
|
||||
align-items: center;
|
||||
cursor: default;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,43 @@
|
|||
<template>
|
||||
<transition name='transition-fade'>
|
||||
<div class='user_typing' v-if='typingUsers.length'>
|
||||
<div class='user_typing__users' v-if='users.length > 2'>{{userList}} {{typingUsers.length > 2 ? "are" : "is"}} typing</div>
|
||||
<c-loading-dots></c-loading-dots>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import CLoadingDots from './c-loading-dots';
|
||||
|
||||
export default {
|
||||
name: 'user-typing',
|
||||
props: ['users', 'typing-users'],
|
||||
components: { CLoadingDots },
|
||||
computed: {
|
||||
userList () {
|
||||
this.typingUsers
|
||||
return this.typingUsers
|
||||
.map(u => u.username)
|
||||
.join(', ')
|
||||
.replace(/, ([^,]*)$/, ' and $1');
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
@import '../assets/scss/variables.scss';
|
||||
|
||||
.user_typing {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0.5rem 0;
|
||||
|
||||
@at-root #{&}__users {
|
||||
color: $text-secondary;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 300;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,11 @@
|
|||
export default function getCookies () {
|
||||
let pairs = document.cookie.split(';').map(pair => pair.trim());
|
||||
let obj = {};
|
||||
|
||||
pairs.forEach(pair => {
|
||||
let [key, value] = pair.split('=');
|
||||
obj[key] = value;
|
||||
});
|
||||
|
||||
return obj;
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
import io from 'socket.io-client';
|
||||
|
||||
export default {
|
||||
install (Vue) {
|
||||
Vue.prototype.$io = io('http://localhost:23981');
|
||||
}
|
||||
};
|
|
@ -89,7 +89,9 @@ const DeleteVerify = () => import('./components/routes/DeleteVerify')
|
|||
const DeleteError = () => import('./components/routes/DeleteError')
|
||||
|
||||
const Recovery = () => import('./components/routes/Recovery')
|
||||
|
||||
const Chat = () => import('./components/routes/Chat')
|
||||
const Conversation = () => import('./components/routes/Conversation')
|
||||
|
||||
const DeveloperPortal = () => import('./components/routes/DeveloperPortal')
|
||||
const DeveloperDocs = () => import('./components/routes/DeveloperDocs')
|
||||
|
@ -147,7 +149,7 @@ Sentry.init({
|
|||
});
|
||||
Vue.use({
|
||||
install (Vue) {
|
||||
Vue.prototype.$socket = io("https://gateway.kaverti.com")
|
||||
Vue.prototype.$socket = io(process.env.VUE_APP_GATEWAYENDPOINT)
|
||||
}
|
||||
})
|
||||
|
||||
|
@ -212,7 +214,15 @@ const router = new VueRouter({
|
|||
{ path: '/user/:username/items', component: User, redirect: '/u/:username/items' },
|
||||
{ path: '/user/:username/wall', component: User, redirect: '/u/:username/wall' },
|
||||
{ path: '/notifications', component: Notifications },
|
||||
{ path: '/chat', component: Chat, },
|
||||
{
|
||||
path: '/chat',
|
||||
component: Chat,
|
||||
children: [
|
||||
{ path: '/', component: Chat },
|
||||
{ path: 'conversation', component: Conversation },
|
||||
{ path: 'conversation/:id', component: Conversation, name: 'conversation' }
|
||||
]
|
||||
},
|
||||
{ path: '/u/:username', redirect: '/u/:username/posts', component: User, children: [
|
||||
{ path: 'posts', component: UserPosts },
|
||||
{ path: 'threads', component: UserThreads },
|
||||
|
|
|
@ -33,6 +33,7 @@ export default new Vuex.Store({
|
|||
|
||||
token: null,
|
||||
passkey: "register",
|
||||
id: '',
|
||||
|
||||
show404Page: false,
|
||||
showConnModal: false,
|
||||
|
@ -40,7 +41,9 @@ export default new Vuex.Store({
|
|||
ajaxErrors: [],
|
||||
ajaxErrorsModal: false,
|
||||
|
||||
MinQueryLength: 2
|
||||
conversations: [],
|
||||
|
||||
MinQueryLength: 2
|
||||
},
|
||||
getters: {
|
||||
categoriesWithoutAll(state) {
|
||||
|
@ -149,6 +152,9 @@ export default new Vuex.Store({
|
|||
},
|
||||
setEmailVerified(state, value) {
|
||||
state.emailVerified = value
|
||||
},
|
||||
setUserId(state, value) {
|
||||
state.UserId = value
|
||||
},
|
||||
setSettings(state, value) {
|
||||
state.meta.name = value.siteName
|
||||
|
@ -178,7 +184,45 @@ export default new Vuex.Store({
|
|||
let index = state.meta.categories.indexOf(category)
|
||||
|
||||
state.meta.categories.splice(index, 1, updated)
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
clearConversations (state, conversations) {
|
||||
state.conversations = [];
|
||||
},
|
||||
addConversations (state, conversations) {
|
||||
state.conversations.push(...conversations);
|
||||
},
|
||||
updateConversationLastRead (state, id) {
|
||||
let index = state.conversations.findIndex(conversation => {
|
||||
return conversation.id === id;
|
||||
});
|
||||
|
||||
let conversation = state.conversations[index];
|
||||
conversation.lastRead = new Date() + '';
|
||||
|
||||
state.conversations.splice(index, 1, conversation);
|
||||
},
|
||||
updateUnshiftConversation (state, updatedConversation) {
|
||||
let index = state.conversations.findIndex(conversation => {
|
||||
return conversation.id === updatedConversation.id;
|
||||
});
|
||||
|
||||
if(index > -1) {
|
||||
state.conversations.splice(index, 1);
|
||||
}
|
||||
|
||||
state.conversations.unshift(updatedConversation);
|
||||
},
|
||||
updateConversationName (state, { id, name }) {
|
||||
let index = state.conversations.findIndex(conversation => {
|
||||
return conversation.id === id;
|
||||
});
|
||||
|
||||
let conversation = state.conversations[index];
|
||||
conversation.name = name;
|
||||
|
||||
state.conversations.splice(index, 1, conversation);
|
||||
}
|
||||
},
|
||||
modules: { thread, category, moderation }
|
||||
})
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
module.exports = class TypeValidationError extends Error {
|
||||
constructor (errors) {
|
||||
super('The request failed type validations');
|
||||
|
||||
this.name = 'TypeValidationError';
|
||||
this.errors = errors;
|
||||
|
||||
}
|
||||
};
|
|
@ -0,0 +1,12 @@
|
|||
let Sequelize = require('sequelize');
|
||||
|
||||
module.exports = function (error) {
|
||||
return new Sequelize.ValidationError(error.message, [
|
||||
new Sequelize.ValidationErrorItem(
|
||||
error.message,
|
||||
'Validation error',
|
||||
error.path,
|
||||
error.value
|
||||
)
|
||||
]);
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
const TypeValidationError = require('./errors/typeValidationError');
|
||||
const { sequelize } = require('../models');
|
||||
|
||||
module.exports = function (err, socket) {
|
||||
if(err instanceof sequelize.ValidationError || err instanceof TypeValidationError) {
|
||||
socket.emit('errors', {
|
||||
status: 400,
|
||||
...err
|
||||
});
|
||||
} else if (err.message === 'unauthorized') {
|
||||
socket.emit('errors', {
|
||||
status: 401,
|
||||
errors: [{ message: 'Request not authorized' }]
|
||||
});
|
||||
} else {
|
||||
console.log(err);
|
||||
socket.emit('errors', {
|
||||
status: 500,
|
||||
errors: [{ message: 'There was an unknown error on our side - please try again later' }]
|
||||
});
|
||||
}
|
||||
};
|
|
@ -0,0 +1,24 @@
|
|||
let { Sequelize } = require('../../models');
|
||||
let validationError = require('../errors/validationError');
|
||||
|
||||
module.exports = {
|
||||
type (type, val) {
|
||||
if(typeof val !== type) {
|
||||
throw validationError(Sequelize, {
|
||||
message: 'Parameter must be of type ' + type,
|
||||
value: val
|
||||
});
|
||||
} else {
|
||||
return val;
|
||||
}
|
||||
},
|
||||
number (val) {
|
||||
return this.type('number', val);
|
||||
},
|
||||
string (val) {
|
||||
return this.type('string', val);
|
||||
},
|
||||
boolean (val) {
|
||||
return this.type('boolean', val);
|
||||
},
|
||||
};
|
|
@ -0,0 +1,21 @@
|
|||
let TypeValidationError = require('../errors/typeValidationError');
|
||||
let validator = require('./validator');
|
||||
|
||||
module.exports = function (schema) {
|
||||
return function (req, res, next) {
|
||||
let errors = [];
|
||||
|
||||
for(let key in schema) {
|
||||
errors.push(
|
||||
...validator(req[key], schema[key])
|
||||
);
|
||||
}
|
||||
|
||||
if(errors.length) {
|
||||
res.status(400);
|
||||
res.json(new TypeValidationError(errors));
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
};
|
||||
};
|
|
@ -0,0 +1,8 @@
|
|||
const TypeValidationError = require('../errors/typeValidationError');
|
||||
const validator = require('./validator');
|
||||
|
||||
module.exports = function (schema, obj) {
|
||||
let errors = validator(obj, schema)
|
||||
|
||||
if(errors.length) throw new TypeValidationError(errors);
|
||||
};
|
|
@ -0,0 +1,53 @@
|
|||
function typeChecker (val, type) {
|
||||
let types = type.split('.');
|
||||
|
||||
if(type === 'integer') {
|
||||
return Number.isSafeInteger(val);
|
||||
} else if(types.length === 2 && types[0] === 'array') {
|
||||
return (
|
||||
Array.isArray(val) &&
|
||||
val.filter(v => typeChecker(v, types[1])).length === val.length
|
||||
);
|
||||
} else if (type === 'string(integer)') {
|
||||
return (
|
||||
typeof val === 'string' &&
|
||||
/^\d+$/.test(val)
|
||||
);
|
||||
} else {
|
||||
return typeof val === type;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = function (obj, props) {
|
||||
let errors = [];
|
||||
|
||||
for(let prop in props) {
|
||||
let objVal = obj[prop];
|
||||
let propVals = props[prop];
|
||||
|
||||
//If it is undefined but required
|
||||
if (objVal === undefined && !propVals.required) {
|
||||
continue;
|
||||
//If there is a function to test it
|
||||
} else if (
|
||||
typeof propVals.type === 'function' &&
|
||||
!propVals.type(objVal)
|
||||
) {
|
||||
errors.push({
|
||||
path: prop,
|
||||
message: prop + ' must be of type ' + propVals.typeName
|
||||
});
|
||||
//If there the type name is a string
|
||||
} else if (
|
||||
typeof propVals.type === 'string' &&
|
||||
!typeChecker(objVal, propVals.type)
|
||||
) {
|
||||
errors.push({
|
||||
path: prop,
|
||||
message: prop + ' must be of type ' + propVals.type
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
};
|
|
@ -0,0 +1,30 @@
|
|||
'use strict';
|
||||
module.exports = {
|
||||
up: (queryInterface, Sequelize) => {
|
||||
return queryInterface.createTable('conversations', {
|
||||
id: {
|
||||
allowNull: false,
|
||||
autoIncrement: true,
|
||||
primaryKey: true,
|
||||
type: Sequelize.INTEGER
|
||||
},
|
||||
name: {
|
||||
type: Sequelize.STRING
|
||||
},
|
||||
groupUsers: {
|
||||
type: Sequelize.STRING
|
||||
},
|
||||
createdAt: {
|
||||
allowNull: false,
|
||||
type: Sequelize.DATE
|
||||
},
|
||||
updatedAt: {
|
||||
allowNull: false,
|
||||
type: Sequelize.DATE
|
||||
}
|
||||
});
|
||||
},
|
||||
down: (queryInterface, Sequelize) => {
|
||||
return queryInterface.dropTable('conversations');
|
||||
}
|
||||
};
|
|
@ -0,0 +1,29 @@
|
|||
'use strict';
|
||||
module.exports = {
|
||||
up: (queryInterface, Sequelize) => {
|
||||
return queryInterface.createTable('userconversations', {
|
||||
UserId: {
|
||||
type: Sequelize.INTEGER
|
||||
},
|
||||
ConversationId: {
|
||||
type: Sequelize.INTEGER
|
||||
},
|
||||
createdAt: {
|
||||
allowNull: false,
|
||||
type: Sequelize.DATE
|
||||
},
|
||||
updatedAt: {
|
||||
allowNull: false,
|
||||
type: Sequelize.DATE
|
||||
},
|
||||
lastRead: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: new Date(0)
|
||||
}
|
||||
});
|
||||
},
|
||||
down: (queryInterface, Sequelize) => {
|
||||
return queryInterface.dropTable('user_conversation');
|
||||
}
|
||||
};
|
|
@ -0,0 +1,37 @@
|
|||
'use strict';
|
||||
module.exports = {
|
||||
up: (queryInterface, Sequelize) => {
|
||||
return queryInterface.createTable('messages', {
|
||||
id: {
|
||||
allowNull: false,
|
||||
autoIncrement: true,
|
||||
primaryKey: true,
|
||||
type: Sequelize.INTEGER
|
||||
},
|
||||
content: {
|
||||
type: Sequelize.TEXT('long'),
|
||||
allowNull: false
|
||||
},
|
||||
read: {
|
||||
type: Sequelize.BOOLEAN
|
||||
},
|
||||
UserId: {
|
||||
type: Sequelize.INTEGER
|
||||
},
|
||||
ConversationId: {
|
||||
type: Sequelize.INTEGER
|
||||
},
|
||||
createdAt: {
|
||||
allowNull: false,
|
||||
type: Sequelize.DATE
|
||||
},
|
||||
updatedAt: {
|
||||
allowNull: false,
|
||||
type: Sequelize.DATE
|
||||
}
|
||||
});
|
||||
},
|
||||
down: (queryInterface, Sequelize) => {
|
||||
return queryInterface.dropTable('messages');
|
||||
}
|
||||
};
|
|
@ -0,0 +1,19 @@
|
|||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
up: (queryInterface, Sequelize) => {
|
||||
return queryInterface.addColumn(
|
||||
'UserConversations',
|
||||
'lastRead',
|
||||
{
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: new Date(0)
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
down: (queryInterface, Sequelize) => {
|
||||
queryInterface.removeColumn('UserConversations', 'lastRead');
|
||||
}
|
||||
};
|
|
@ -0,0 +1,56 @@
|
|||
module.exports = (sequelize, DataTypes) => {
|
||||
let Conversation = sequelize.define('Conversation', {
|
||||
name: {
|
||||
type: DataTypes.STRING,
|
||||
validate: {
|
||||
isString (val) {
|
||||
if(typeof val !== 'string') {
|
||||
throw new Error('Name must be of type string');
|
||||
}
|
||||
},
|
||||
maxLength (val) {
|
||||
if(val.toString().trim().length > 1000) {
|
||||
throw new Error('Name must be 1000 characters or less')
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
groupUsers: DataTypes.STRING
|
||||
}, {
|
||||
classMethods: {
|
||||
associate (models) {
|
||||
Conversation.belongsToMany(models.User, {
|
||||
through: models.UserConversation
|
||||
});
|
||||
Conversation.hasMany(models.Message);
|
||||
},
|
||||
async setName (userId) {
|
||||
let json = this.toJSON();
|
||||
|
||||
if (!this.Users || this.Users.length < 2) {
|
||||
let users = await sequelize.models.User.findAll({
|
||||
attributes: {exclude: ['hash']},
|
||||
include: [{
|
||||
model: sequelize.models.Conversation,
|
||||
where: {id: this.id}
|
||||
}]
|
||||
});
|
||||
|
||||
json.Users = users.map(u => u.toJSON());
|
||||
}
|
||||
|
||||
if (this.name) {
|
||||
return json;
|
||||
} else if (json.Users.length === 2) {
|
||||
json.name = json.Users.find(u => u.id !== userId).username;
|
||||
} else {
|
||||
json.name = json.Users.map(u => u.username).join(', ');
|
||||
}
|
||||
|
||||
return json;
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return Conversation;
|
||||
};
|
|
@ -0,0 +1,31 @@
|
|||
module.exports = (sequelize, DataTypes) => {
|
||||
let Message = sequelize.define('Message', {
|
||||
content: {
|
||||
type: DataTypes.TEXT('long'),
|
||||
validate: {
|
||||
isString (val) {
|
||||
if(typeof val !== 'string') {
|
||||
throw new Error('Message must be a string');
|
||||
}
|
||||
},
|
||||
minLength (val) {
|
||||
if(!String(val).trim().length) {
|
||||
throw new Error('Message cannot be blank');
|
||||
}
|
||||
}
|
||||
},
|
||||
allowNull: false
|
||||
},
|
||||
read: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: false
|
||||
}
|
||||
}, {});
|
||||
|
||||
Message.associate = function(models) {
|
||||
Message.belongsTo(models.User);
|
||||
Message.belongsTo(models.Conversation);
|
||||
};
|
||||
|
||||
return Message;
|
||||
};
|
|
@ -0,0 +1,15 @@
|
|||
module.exports = (sequelize, DataTypes) => {
|
||||
let User_Conversation = sequelize.define('UserConversation', {
|
||||
lastRead: {
|
||||
type: DataTypes.DATE,
|
||||
defaultValue: new Date(0)
|
||||
}
|
||||
}, {});
|
||||
|
||||
User_Conversation.associate = function(models) {
|
||||
User_Conversation.belongsTo(models.User);
|
||||
User_Conversation.belongsTo(models.Conversation);
|
||||
};
|
||||
|
||||
return User_Conversation;
|
||||
};
|
|
@ -13,7 +13,7 @@ const Errors = require('../lib/errors')
|
|||
var randomString = (Math.random().toString(36).substring(2))
|
||||
router.use(limiter);
|
||||
router.post("/refresh", limiter, async (req, res, next) => {
|
||||
User.findByPk(req.session.UserId).then(function(selected){
|
||||
User.findById(req.session.UserId).then(function(selected){
|
||||
var blendFilePath = "../rendering/avatar.blend";
|
||||
var imageSavePath = "/usr/share/nginx/html/cdn/user/avatars/full/"+req.session.UserID+"-"+randomString+".png";
|
||||
var pythonFilePath = "../rendering/usercontent/"+req.session.UserID+".py";
|
||||
|
|
|
@ -0,0 +1,103 @@
|
|||
let validation = require('../lib/validation/validateMiddleware');
|
||||
let conversationController = require('../controllers/conversation');
|
||||
let router = require('express').Router();
|
||||
const Errors = require('../lib/errors')
|
||||
|
||||
router.all('*', (req, res, next) => {
|
||||
if(!req.session.loggedIn) {
|
||||
throw Errors.requestNotAuthorized
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
});
|
||||
|
||||
let createPostSchema = {
|
||||
body: {
|
||||
userIds: {
|
||||
required: true,
|
||||
type: 'array.integer'
|
||||
},
|
||||
name: {
|
||||
required: false,
|
||||
type: 'string'
|
||||
}
|
||||
}
|
||||
};
|
||||
router.post('/', validation(createPostSchema), async (req, res, next) => {
|
||||
try {
|
||||
let conversation = await conversationController.create(
|
||||
req.body.userIds,
|
||||
req.body.name
|
||||
);
|
||||
|
||||
res.json(conversation);
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
let getPostSchema = {
|
||||
params: {
|
||||
conversationId: {
|
||||
required: true,
|
||||
type: 'string(integer)'
|
||||
}
|
||||
},
|
||||
query: {
|
||||
page: {
|
||||
required: false,
|
||||
type: 'string(integer)'
|
||||
}
|
||||
}
|
||||
};
|
||||
router.get('/:conversationId', validation(getPostSchema), async (req, res, next) => {
|
||||
try {
|
||||
let conversation = await conversationController.get(
|
||||
req.session.UserId, +req.params.conversationId, +req.query.page
|
||||
);
|
||||
|
||||
res.json(conversation);
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
let putPostSchema = {
|
||||
params: {
|
||||
conversationId: {
|
||||
required: true,
|
||||
type: 'string(integer)'
|
||||
}
|
||||
}
|
||||
};
|
||||
router.put('/:conversationId', validation(putPostSchema), async (req, res, next) => {
|
||||
try {
|
||||
let conversationId = +req.params.conversationId;
|
||||
res.json(
|
||||
await conversationController.updateLastRead(conversationId, req.session.UserId)
|
||||
);
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
let putNameSchema = {
|
||||
params: {
|
||||
conversationId: {
|
||||
required: true,
|
||||
type: 'string(integer)'
|
||||
}
|
||||
},
|
||||
body: {
|
||||
name: {
|
||||
required: true,
|
||||
type: 'string'
|
||||
}
|
||||
}
|
||||
};
|
||||
router.put('/:conversationId/name', validation(putNameSchema), async (req, res, next) => {
|
||||
try {
|
||||
let conversationId = +req.params.conversationId;
|
||||
let name = req.body.name;
|
||||
|
||||
res.json(
|
||||
await conversationController.updateName(conversationId, req.session.UserId, name)
|
||||
);
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
module.exports = router;
|
|
@ -0,0 +1,61 @@
|
|||
let validation = require('../lib/validation/validateMiddleware');
|
||||
let router = require('express').Router();
|
||||
|
||||
let conversationController = require('../controllers/conversation');
|
||||
let messageController = require('../controllers/message');
|
||||
let userController = require('../controllers/user');
|
||||
|
||||
router.all('*', (req, res, next) => {
|
||||
if(!req.session.authenticated) {
|
||||
res.status(401);
|
||||
res.json({
|
||||
errors: [ { message: 'Request not authorized' } ]
|
||||
})
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
});
|
||||
|
||||
let messageValidationSchema = {
|
||||
body: {
|
||||
content: {
|
||||
type: 'string',
|
||||
required: true
|
||||
},
|
||||
conversationId: {
|
||||
type: 'integer',
|
||||
required: true
|
||||
}
|
||||
}
|
||||
};
|
||||
router.post('/', validation(messageValidationSchema), async (req, res, next) => {
|
||||
try {
|
||||
let message = await messageController.create({
|
||||
content: req.body.content,
|
||||
userId: req.session.UserId,
|
||||
conversationId: req.body.conversationId
|
||||
});
|
||||
let user = await userController.get(req.session.UserId);
|
||||
let retMessage = Object.assign(message.toJSON(), {
|
||||
User: user
|
||||
});
|
||||
|
||||
let conversations = await conversationController.getFromUser(req.session.UserId);
|
||||
let retConversation = conversations.Conversations[0];
|
||||
|
||||
//Get the users in the conversation and the id for the socket
|
||||
//(if it exists) and emit the message to them
|
||||
let userIds = await conversationController.getUserIds(req.body.conversationId);
|
||||
userIds
|
||||
.map(id => req.app.get('io-users')[id])
|
||||
.filter(id => id !== undefined)
|
||||
.forEach(id => {
|
||||
req.app.get('io').to(id).emit('message', retMessage);
|
||||
req.app.get('io').to(id).emit('conversation', retConversation);
|
||||
});
|
||||
|
||||
res.json(retMessage);
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
module.exports = router;
|
|
@ -26,6 +26,8 @@ const emailLimiter = rateLimit({
|
|||
max: 1, // limit each IP to 100 requests per windowMs
|
||||
message: "{\"errors\":[{\"name\":\"rateLimit\",\"message\":\"You may only make 1 request to this endpoint per minute.\",\"status\":429}]}"
|
||||
});
|
||||
let conversationController = require('../controllers/conversation');
|
||||
|
||||
function setUserSession(req, res, username, UserId, admin) {
|
||||
req.session.loggedIn = true
|
||||
req.session.username = username
|
||||
|
@ -349,6 +351,18 @@ router.all('*', (req, res, next) => {
|
|||
})
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/conversations', async (req, res, next) => {
|
||||
try {
|
||||
let user = await User.findOne({ where: {
|
||||
username: req.session.username
|
||||
}})
|
||||
|
||||
let conversations = await conversationController.getFromUser(user.id, +req.query.page, req.query.search);
|
||||
res.json(conversations);
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
router.put('/delete', emailLimiter, async (req, res, next) => {
|
||||
const mailGenerator = new MailGen({
|
||||
theme: 'salted',
|
||||
|
|
|
@ -118,6 +118,8 @@ app.use('/api/v1/users/render', require('./routes/avatar'))
|
|||
app.use('/api/v1/users/render/', require('./routes/avatar'))
|
||||
app.use('/api/v1/userinfo', require('./routes/userinfo'))
|
||||
app.use('/api/v1/wall', require('./routes/user_wall'))
|
||||
app.use('/api/v1/chat/conversation', require('./routes/conversation'));
|
||||
app.use('/api/v1/chat/message', require('./routes/message'));
|
||||
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(specs, { explorer: true }));
|
||||
app.use(require('./lib/errorHandler'))
|
||||
app.use(profanity.init);
|
||||
|
|
Loading…
Reference in New Issue