0.176-prerelease2

This commit is contained in:
Troplo 2020-10-15 22:29:30 +11:00
parent 22a9a437cc
commit 4d810b0eb9
45 changed files with 2921 additions and 77 deletions

242
controllers/conversation.js Normal file
View File

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

40
controllers/message.js Normal file
View File

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

76
controllers/user.js Normal file
View File

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

View File

@ -1,2 +1,3 @@
VUE_APP_APIENDPOINT="/api/"
VUE_APP_APIVERSION="v1"
VUE_APP_GATEWAYENDPOINT="http://localhost:23981"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,7 @@
import io from 'socket.io-client';
export default {
install (Vue) {
Vue.prototype.$io = io('http://localhost:23981');
}
};

View File

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

View File

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

View File

@ -0,0 +1,9 @@
module.exports = class TypeValidationError extends Error {
constructor (errors) {
super('The request failed type validations');
this.name = 'TypeValidationError';
this.errors = errors;
}
};

View File

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

22
lib/socketErrorHandler.js Normal file
View File

@ -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' }]
});
}
};

24
lib/validation/type.js Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

56
models/conversation.js Normal file
View File

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

31
models/message.js Normal file
View File

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

View File

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

View File

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

103
routes/conversation.js Normal file
View File

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

61
routes/message.js Normal file
View File

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

View File

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

View File

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