diff --git a/Server/api/servers.js b/Server/api/servers.js index 0e6ef99..fe0aa0d 100644 --- a/Server/api/servers.js +++ b/Server/api/servers.js @@ -3,7 +3,7 @@ module.exports = (R, $) => { try { const { userId } = ctx.session const srv = $.discord.getRelevantServers(userId) - const presentable = await $.P.oldPresentableServers(srv, userId) + const presentable = await $.P.presentableServers(srv, userId) ctx.body = presentable } catch (e) { @@ -23,12 +23,35 @@ module.exports = (R, $) => { return } - const gm = srv.members.get(userId) - const server = $.discord.presentableRoles(id, gm) + const gm = $.discord.gm(id, userId) + const server = await $.P.presentableServer(srv, gm) ctx.body = server }) + R.patch('/api/server/:id', async (ctx) => { + const { userId } = ctx.session + const { id } = ctx.params + let gm = $.discord.gm(id, userId) + + // check perms + if (!$.discord.getPermissions(gm).canManageRoles) { + ctx.status = 403 + ctx.body = { err: 'cannot_manage_roles' } + return + } + + const { message = null, categories = null } = ctx.request.body + + // todo make less nasty + await $.server.update(id, { + ...((message != null) ? { message } : {}), + ...((categories != null) ? { categories } : {}) + }) + + ctx.body = { ok: true } + }) + R.patch('/api/servers/:server/roles', async ctx => { const { userId } = ctx.session const { server } = ctx.params @@ -36,12 +59,16 @@ module.exports = (R, $) => { const { added, removed } = ctx.request.body + const allowedRoles = await $.server.getAllowedRoles(server) + + const pred = r => $.discord.safeRole(server, r) && allowedRoles.indexOf(r) !== -1 + if (added.length > 0) { - gm = await gm.addRoles(added.filter(r => $.discord.safeRole(server, r))) + gm = await gm.addRoles(added.filter(pred)) } if (removed.length > 0) { - gm = await gm.removeRoles(removed.filter(r => $.discord.safeRole(server, r))) + gm = await gm.removeRoles(removed.filter(pred)) } ctx.body = { ok: true } diff --git a/Server/index.js b/Server/index.js index 931b23e..4990020 100644 --- a/Server/index.js +++ b/Server/index.js @@ -8,6 +8,15 @@ const _io = require('socket.io') const router = require('koa-better-router')().loadMethods() const Roleypoly = require('./Roleypoly') +// monkey patch async-reduce because F U T U R E +Array.prototype.areduce = async function (predicate, acc = []) { // eslint-disable-line + for (let i of this) { + acc = await predicate(acc, i) + } + + return acc +} + // Create the server and socket.io server const server = http.createServer(app.callback()) const io = _io(server, { transports: ['websocket'], path: '/api/socket.io', wsEngine: 'uws' }) diff --git a/Server/services/discord.js b/Server/services/discord.js index d039434..74f2dd6 100644 --- a/Server/services/discord.js +++ b/Server/services/discord.js @@ -11,6 +11,7 @@ class DiscordService extends Service { this.clientSecret = process.env.DISCORD_CLIENT_SECRET this.oauthCallback = process.env.OAUTH_AUTH_CALLBACK this.botCallback = `${ctx.config.appUrl}/api/oauth/bot/callback` + this.appUrl = process.env.APP_URL this.client = new discord.Client() @@ -24,6 +25,8 @@ class DiscordService extends Service { async startBot () { await this.client.login(this.botToken) + this.client.on('message', this.handleMessage.bind(this)) + for (let server of this.client.guilds.array()) { await this.ctx.server.ensure(server) } @@ -127,6 +130,20 @@ class DiscordService extends Service { getBotJoinUrl () { return `https://discordapp.com/oauth2/authorize?client_id=${this.clientId}&scope=bot&permissions=268435456&redirect_uri=${this.botCallback}` } + + mentionResponse (message) { + message.channel.send(`🔰 Assign your roles here! <${this.appUrl}/s/${message.guild.id}>`, { disableEveryone: true }) + } + + handleMessage (message) { + if (message.author.bot && message.channel.type !== 'text') { // drop bot messages and dms + return + } + + if (message.mentions.users.has(this.client.user.id)) { + this.mentionResponse(message) + } + } } module.exports = DiscordService diff --git a/Server/services/presentation.js b/Server/services/presentation.js index f1232ec..7d80c96 100644 --- a/Server/services/presentation.js +++ b/Server/services/presentation.js @@ -14,32 +14,44 @@ class PresentationService extends Service { let servers = [] for (let server of collection.array()) { - const sd = await this.ctx.server.get(server.id) - console.log(sd.categories) const gm = server.members.get(userId) - servers.push({ - id: server.id, - gm: { - nickname: gm.nickname, - color: gm.displayHexColor - }, - server: { - id: server.id, - name: server.name, - ownerID: server.ownerID, - icon: server.icon - }, - roles: (await this.rolesByServer(server, sd)).map(r => ({ ...r, selected: gm.roles.has(r.id) })), - message: sd.message, - categories: sd.categories, - perms: this.discord.getPermissions(gm) - }) + servers.push(await this.presentableServer(server, gm)) } return servers } + async presentableServers (collection, userId) { + return collection.array().areduce(async (acc, server) => { + const gm = server.members.get(userId) + acc.push(await this.presentableServer(server, gm)) + return acc + }) + } + + async presentableServer (server, gm) { + const sd = await this.ctx.server.get(server.id) + + return { + id: server.id, + gm: { + nickname: gm.nickname, + color: gm.displayHexColor + }, + server: { + id: server.id, + name: server.name, + ownerID: server.ownerID, + icon: server.icon + }, + roles: (await this.rolesByServer(server, sd)).map(r => ({ ...r, selected: gm.roles.has(r.id) })), + message: sd.message, + categories: sd.categories, + perms: this.discord.getPermissions(gm) + } + } + async rolesByServer (server) { return server.roles .filter(r => r.id !== server.id) // get rid of @everyone diff --git a/Server/services/server.js b/Server/services/server.js index d7b3d6f..fef8284 100644 --- a/Server/services/server.js +++ b/Server/services/server.js @@ -30,18 +30,36 @@ class ServerService extends Service { return srv.save() } - update (id, newData) { - const srv = this.get(id) + async update (id, newData) { + const srv = await this.get(id, false) return srv.update(newData) } - async get (id) { - return (await this.Server.findOne({ + async get (id, plain = true) { + const s = await this.Server.findOne({ where: { id } - })).get({ plain: true }) + }) + + if (!plain) { + return s + } + + return s.get({ plain: true }) + } + + async getAllowedRoles (id) { + const server = await this.get(id) + + return Object.values(server.categories).reduce((acc, c) => { + if (c.hidden !== true) { + return acc.concat(c.roles) + } + + return acc + }, []) } } diff --git a/UI/package.json b/UI/package.json index 80412cc..4038784 100644 --- a/UI/package.json +++ b/UI/package.json @@ -8,6 +8,7 @@ "eslint": "^4.14.0", "history": "^4.7.2", "immutable": "^3.8.2", + "ksuid": "^0.4.0", "prop-types": "^15.6.0", "react": "^16.2.0", "react-custom-scrollbars": "^4.2.1", diff --git a/UI/src/components/role-editor/Category.js b/UI/src/components/role-editor/Category.js index 03cbbe4..9134092 100644 --- a/UI/src/components/role-editor/Category.js +++ b/UI/src/components/role-editor/Category.js @@ -8,8 +8,8 @@ import CategoryEditor from './CategoryEditor' drop (props, monitor, element) { props.onDrop(monitor.getItem()) }, - canDrop (props) { - return props.mode !== Symbol.for('edit') + canDrop (props, monitor) { + return (props.mode !== Symbol.for('edit') && monitor.getItem().category !== props.name) } }, (connect, monitor) => ({ connectDropTarget: connect.dropTarget(), @@ -20,14 +20,17 @@ import CategoryEditor from './CategoryEditor' })) class Category extends Component { render () { - const { category, name, isOver, connectDropTarget, mode, ...rest } = this.props + const { category, name, isOver, canDrop, connectDropTarget, mode, onEditOpen, ...rest } = this.props if (mode === Symbol.for('edit')) { return } - return connectDropTarget(
-

{ name }

+ return connectDropTarget(
+
+

{ category.get('name') }

+
+
{ category.get('roles_map') .sortBy(r => r.get('position')) diff --git a/UI/src/components/role-editor/CategoryEditor.js b/UI/src/components/role-editor/CategoryEditor.js index ba58882..9847b41 100644 --- a/UI/src/components/role-editor/CategoryEditor.js +++ b/UI/src/components/role-editor/CategoryEditor.js @@ -1,18 +1,28 @@ import React, { Component } from 'react' export default class CategoryEditor extends Component { + + onKeyPress = (e) => { + const { onSave } = this.props + + switch (e.key) { + case 'Enter': + case 'Escape': + return onSave() + } + } + render () { const { - name, category } = this.props - return
-
e.preventDefault()} className="uk-form-stacked uk-light"> + return
+
- +
@@ -21,7 +31,7 @@ export default class CategoryEditor extends Component { @@ -29,13 +39,26 @@ export default class CategoryEditor extends Component {
+
+
Type
+
+ +
+
- +
} } diff --git a/UI/src/components/role-editor/RoleEditor.sass b/UI/src/components/role-editor/RoleEditor.sass index 0b577c8..74a5b85 100644 --- a/UI/src/components/role-editor/RoleEditor.sass +++ b/UI/src/components/role-editor/RoleEditor.sass @@ -7,7 +7,7 @@ &__actions display: flex - margin-top: 10px + margin: 10px 0 button padding: 0 @@ -19,12 +19,16 @@ &_save flex: 4 + &__uncat-zone + padding: 10px + .role-editor__category box-sizing: border-box background-color: var(--c-1) padding: 15px margin: 10px min-width: 220px - 30px + position: relative &.add-button height: 100px @@ -43,4 +47,44 @@ transform: scale(1.1) color: var(--c-7) - \ No newline at end of file + &-header + display: flex + align-items: center + justify-content: left + margin-bottom: 10px + + color: var(--c-7) + + svg + transition: transform 0.05s ease-in-out + transform: translateZ(0) + &:hover + transform: translateZ(0) scale(1.1) + color: var(--c-9) + + h4 + margin: 0 + flex: 1 + margin-right: 10px + + .drop-zone + position: relative + &::after + content: "" + background-color: var(--c-7) + box-sizing: border-box + position: absolute + top: 0 + right: 0 + left: 0 + bottom: 0 + border: 5px dashed var(--c-3) + transition: opacity 0.15s ease-in-out + opacity: 0 + pointer-events: none + + &.is-over::after + opacity: 0.5 + + &.can-drop::after + opacity: 0.2 \ No newline at end of file diff --git a/UI/src/components/role-editor/actions.js b/UI/src/components/role-editor/actions.js index 26106fd..3a5deb0 100644 --- a/UI/src/components/role-editor/actions.js +++ b/UI/src/components/role-editor/actions.js @@ -1,6 +1,8 @@ import { Set } from 'immutable' import * as UIActions from '../../actions/ui' import { getViewMap } from '../role-picker/actions' +import ksuid from 'ksuid' +import superagent from 'superagent' export const constructView = id => (dispatch, getState) => { const server = getState().servers.get(id) @@ -11,78 +13,133 @@ export const constructView = id => (dispatch, getState) => { dispatch({ type: Symbol.for('re: setup'), data: { - viewMap: viewMap + viewMap: viewMap, + originalSnapshot: viewMap } }) dispatch(UIActions.fadeIn) } -export const addRoleToCategory = (name, oldName, role, flip = true) => (dispatch) => { +export const addRoleToCategory = (id, oldId, role, flip = true) => (dispatch) => { dispatch({ type: Symbol.for('re: add role to category'), data: { - name, + id, role } }) if (flip) { - dispatch(removeRoleFromCategory(oldName, name, role, false)) + dispatch(removeRoleFromCategory(oldId, id, role, false)) } } -export const removeRoleFromCategory = (name, oldName, role, flip = true) => (dispatch) => { +export const removeRoleFromCategory = (id, oldId, role, flip = true) => (dispatch) => { dispatch({ type: Symbol.for('re: remove role from category'), data: { - name, + id, role } }) - + if (flip) { - dispatch(addRoleToCategory(oldName, name, role, false)) + dispatch(addRoleToCategory(oldId, id, role, false)) } } +export const editCategory = ({ id, key, value }) => dispatch => { + dispatch({ + type: Symbol.for('re: edit category'), + data: { + id, + key, + value + } + }) +} -export const editCategory = (stuff) => dispatch => { +export const saveCategory = (id, category) => (dispatch) => { + if (category.get('name') === '') { + return + } + + dispatch({ + type: Symbol.for('re: switch category mode'), + data: { + id, + mode: Symbol.for('drop') + } + }) } -export const saveCategory = (name) => ({ +export const openEditor = (id) => ({ type: Symbol.for('re: switch category mode'), data: { - name, - mode: Symbol.for('drop') + id, + mode: Symbol.for('edit') } }) -export const deleteCategory = (name) => ({ - type: Symbol.for('re: delete category'), - data: name -}) +export const deleteCategory = (id, category) => (dispatch, getState) => { + const roles = category.get('roles') + const rolesMap = category.get('roles_map') -export const createCategory = (dispatch, getState) => { + let uncategorized = getState().roleEditor.getIn(['viewMap', 'Uncategorized']) + + dispatch({ + type: Symbol.for('re: set category'), + data: { + id: 'Uncategorized', + name: '', + roles: uncategorized.get('roles').union(roles), + roles_map: uncategorized.get('roles_map').union(rolesMap), + hidden: true, + type: 'multi', + mode: null + } + }) + + dispatch({ + type: Symbol.for('re: delete category'), + data: id + }) +} + +export const createCategory = async (dispatch, getState) => { const { roleEditor } = getState() const vm = roleEditor.get('viewMap') let name = 'New Category' let idx = 1 - while (vm.has(name)) { + while (vm.find(c => c.get('name') === name) !== undefined) { idx++ name = `New Category ${idx}` } + const id = (await ksuid.random()).string + dispatch({ type: Symbol.for('re: set category'), data: { + id, name, roles: Set([]), roles_map: Set([]), hidden: true, + type: 'multi', mode: Symbol.for('edit') } }) } + +export const saveServer = id => async (dispatch, getState) => { + const viewMap = getState().roleEditor.get('viewMap') + .filterNot((_, k) => k === 'Uncategorized') + .map(v => v.delete('roles_map').delete('mode').delete('id')) + + await superagent.patch(`/api/server/${id}`).send({ categories: viewMap.toJS() }) + dispatch({ type: Symbol.for('re: swap original state') }) +} diff --git a/UI/src/components/role-editor/index.js b/UI/src/components/role-editor/index.js index 3f98c2f..9ad6133 100644 --- a/UI/src/components/role-editor/index.js +++ b/UI/src/components/role-editor/index.js @@ -22,6 +22,9 @@ const mapState = ({ rolePicker, roleEditor, servers }, ownProps) => ({ @DropTarget(Symbol.for('dnd: role'), { drop (props, monitor, element) { element.dropRole({}, 'Uncategorized')(monitor.getItem()) + }, + canDrop (props, monitor) { + return (monitor.getItem().category !== 'Uncategorized') } }, (connect, monitor) => ({ connectDropTarget: connect.dropTarget(), @@ -56,15 +59,20 @@ class RoleEditor extends Component { saveCategory = (category, name) => () => { const { dispatch } = this.props - dispatch(Actions.saveCategory(name)) + dispatch(Actions.saveCategory(name, category)) } - deleteCategory = (category, name) => () => { + deleteCategory = (category, id) => () => { const { dispatch } = this.props - dispatch(Actions.deleteCategory(name)) + dispatch(Actions.deleteCategory(id, category)) } - editCategory = (category, name) => (key, type) => event => { + openEditor = (category, name) => () => { + const { dispatch } = this.props + dispatch(Actions.openEditor(name)) + } + + editCategory = (category, id) => (key, type) => event => { const { dispatch } = this.props let value @@ -77,16 +85,46 @@ class RoleEditor extends Component { value = event.target.checked break + case Symbol.for('edit: select'): + value = event.target.value + break + default: value = null } - dispatch(Actions.editCategory({ category, name, key, type, value })) + dispatch(Actions.editCategory({ category, id, key, type, value })) + } + + resetServer = () => { + const { dispatch } = this.props + dispatch({ type: Symbol.for('re: reset') }) + } + + saveServer = () => { + const { dispatch, match: { params: { server } } } = this.props + dispatch(Actions.saveServer(server)) + } + + get hasChanged () { + return this.props.editor.get('originalSnapshot').hashCode() !== this.props.editor.get('viewMap').hashCode() } render () { const vm = this.props.editor.get('viewMap') return
+
+

{this.props.server.getIn(['server','name'])}

+
+
+ + +
+
@@ -100,7 +138,7 @@ class RoleEditor extends Component { mode={c.get('mode')} onDrop={this.dropRole(c, name)} onEdit={this.editCategory(c, name)} - // onEditOpen={this.openEditor(c, name)} + onEditOpen={this.openEditor(c, name)} onSave={this.saveCategory(c, name)} onDelete={this.deleteCategory(c, name)} />) @@ -113,14 +151,18 @@ class RoleEditor extends Component {
{ this.props.connectDropTarget( -
- { - (vm.getIn(['Uncategorized', 'roles_map']) || Set()) - .sortBy(r => r.get('position')) - .reverse() - .map((r, k) => ) - .toArray() - } +
+ +
+ { + (vm.getIn(['Uncategorized', 'roles_map']) || Set()) + .sortBy(r => r.get('position')) + .reverse() + .map((r, k) => ) + .toArray() + } +
+
) }
diff --git a/UI/src/components/role-picker/Category.js b/UI/src/components/role-picker/Category.js index 4d04969..0bcf855 100644 --- a/UI/src/components/role-picker/Category.js +++ b/UI/src/components/role-picker/Category.js @@ -17,9 +17,11 @@ class Category extends Component { const type = this.props.category.get('type') switch (type) { - case 'multi': return this.toggleRoleMulti(id, next) case 'single': return this.toggleRoleSingle(id, next) - default: console.warn('NOT SURE') + case 'multi': return this.toggleRoleMulti(id, next) + default: + console.warn('DEFAULTING TO MULTI', id, next, old) + return this.toggleRoleMulti(id, next) } } @@ -34,7 +36,7 @@ class Category extends Component { } return
-

{ name }

+

{ category.get('name') }

{ category.get('roles_map') .sortBy(r => r.get('position')) diff --git a/UI/src/components/role-picker/RolePicker.sass b/UI/src/components/role-picker/RolePicker.sass index 873e93e..7bf7191 100644 --- a/UI/src/components/role-picker/RolePicker.sass +++ b/UI/src/components/role-picker/RolePicker.sass @@ -49,4 +49,15 @@ &.hidden opacity: 0 - // display: none \ No newline at end of file + // display: none + + &__msg-editor + background-color: rgba(0,0,0,0.2) + border-color: rgba(0,0,0,0.1) + color: var(--c-white) + margin: 10px 0 + + &:focus, &:active + color: var(--c-white) + background-color: rgba(0,0,0,0.2) + border-color: var(--c-7) \ No newline at end of file diff --git a/UI/src/components/role-picker/actions.js b/UI/src/components/role-picker/actions.js index 85ea257..9bfd25e 100644 --- a/UI/src/components/role-picker/actions.js +++ b/UI/src/components/role-picker/actions.js @@ -3,16 +3,18 @@ import superagent from 'superagent' import * as UIActions from '../../actions/ui' export const setup = id => async dispatch => { - // const rsp = await superagent.get(`/api/server/${id}`) - // const data = rsp.body + const rsp = await superagent.get(`/api/server/${id}`) + const data = rsp.body - // dispatch({ - // type: Symbol.for('update server roles'), - // data: { - // id, - // roles: data - // } - // }) + console.log(data) + + dispatch({ + type: Symbol.for('server: set'), + data: { + id, + ...data + } + }) dispatch(constructView(id)) } @@ -29,15 +31,19 @@ export const getViewMap = server => { const viewMap = categories.set('Uncategorized', fromJS({ roles: unaccountedRoles, - hidden: false, - type: 'multi' + hidden: true, + type: 'multi', + name: 'Uncategorized' })).map(c => { const roles = c.get('roles') + // fill in roles_map .map(r => server.get('roles').find(sr => sr.get('id') === r) ) + // sort by server position, backwards. .sort((a, b) => a.position > b.position) - return c.set('roles_map', roles) + // force data to sets + return c.set('roles_map', Set(roles)).set('roles', Set(c.get('roles'))) }) const selected = roles.reduce((acc, r) => acc.set(r.get('id'), r.get('selected')), Map()) @@ -54,12 +60,13 @@ export const constructView = id => (dispatch, getState) => { const { viewMap, selected } = getViewMap(server) dispatch({ - type: Symbol.for('setup role picker'), + type: Symbol.for('rp: setup role picker'), data: { viewMap: viewMap, rolesSelected: selected, originalRolesSelected: selected, - hidden: false + hidden: false, + isEditingMessage: false } }) @@ -68,7 +75,7 @@ export const constructView = id => (dispatch, getState) => { export const resetSelected = (dispatch) => { dispatch({ - type: Symbol.for('reset selected') + type: Symbol.for('rp: reset selected') }) } @@ -93,11 +100,35 @@ export const submitSelected = serverId => async (dispatch, getState) => { await superagent.patch(`/api/servers/${serverId}/roles`).send(diff.toJS()) dispatch({ - type: Symbol.for('sync selected roles') + type: Symbol.for('rp: sync selected roles') }) } export const updateRoles = roles => ({ - type: Symbol.for('update selected roles'), + type: Symbol.for('rp: update selected roles'), data: roles }) + +export const openMessageEditor = ({ + type: Symbol.for('rp: set message editor state'), + data: true +}) + +export const saveServerMessage = id => async (dispatch, getState) => { + const message = getState().servers.getIn([id, 'message']) + + await superagent.patch(`/api/server/${id}`).send({ message }) + + dispatch({ + type: Symbol.for('rp: set message editor state'), + data: false + }) +} + +export const editServerMessage = (id, message) => ({ + type: Symbol.for('server: edit message'), + data: { + id, + message + } +}) diff --git a/UI/src/components/role-picker/index.js b/UI/src/components/role-picker/index.js index e2b556d..049d059 100644 --- a/UI/src/components/role-picker/index.js +++ b/UI/src/components/role-picker/index.js @@ -39,9 +39,28 @@ class RolePicker extends Component { return !data.get('rolesSelected').equals(data.get('originalRolesSelected')) } + editServerMessage = (e) => { + const { dispatch } = this.props + dispatch(Actions.editServerMessage(this.props.server.get('id'), e.target.value)) + } + + saveServerMessage = (e) => { + const { dispatch } = this.props + dispatch(Actions.saveServerMessage(this.props.server.get('id'))) + } + + openMessageEditor = () => { + const { dispatch } = this.props + dispatch(Actions.openMessageEditor) + } + renderServerMessage (server) { + const isEditing = this.props.data.get('isEditingMessage') const roleManager = server.getIn(['perms', 'canManageRoles']) const msg = server.get('message') + + console.log(msg, roleManager, isEditing, this.props.data.toJS()) + if (!roleManager && msg !== '') { return

Server Message

@@ -49,16 +68,26 @@ class RolePicker extends Component {
} - if (roleManager) { + if (roleManager && !isEditing) { return

Server Message

- +

{msg || no server message}

} + if (roleManager && isEditing) { + return
+
+

Server Message

+
+
+