diff --git a/hack/react.bzl b/hack/react.bzl
index 15b482e..2b1c0f3 100644
--- a/hack/react.bzl
+++ b/hack/react.bzl
@@ -11,6 +11,7 @@ def _render_deps(deps = []):
if has_added_grpc_deps == False:
output_deps.extend([
"@npm//google-protobuf",
+ "@npm//@types/google-protobuf",
"@npm//@improbable-eng/grpc-web",
])
has_added_grpc_deps = True
diff --git a/src/common/utils/BUILD.bazel b/src/common/utils/BUILD.bazel
new file mode 100644
index 0000000..3dc189e
--- /dev/null
+++ b/src/common/utils/BUILD.bazel
@@ -0,0 +1,23 @@
+load("//:hack/react.bzl", "react_library")
+
+package(default_visibility = ["//visibility:public"])
+
+react_library(
+ name = "utils",
+ deps = [
+ "chroma-js",
+ "react",
+ "styled-components",
+ "//src/rpc/shared",
+ "@types/chroma-js",
+ "@types/react",
+ "@types/styled-components",
+ ],
+)
+
+# jest_test(
+# src = ":utils",
+# deps = [
+# "//hack/fixtures",
+# ],
+# )
diff --git a/src/common/utils/ReactifyNewlines.spec.tsx b/src/common/utils/ReactifyNewlines.spec.tsx
new file mode 100644
index 0000000..734fab4
--- /dev/null
+++ b/src/common/utils/ReactifyNewlines.spec.tsx
@@ -0,0 +1,9 @@
+import { shallow } from 'enzyme';
+import * as React from 'react';
+import { ReactifyNewlines } from './ReactifyNewlines';
+
+it('renders a correct number of divs per newlines', () => {
+ const view = shallow({`1\n2\n3`});
+
+ expect(view.find('div').length).toBe(3);
+});
diff --git a/src/common/utils/ReactifyNewlines.tsx b/src/common/utils/ReactifyNewlines.tsx
new file mode 100644
index 0000000..ec68c49
--- /dev/null
+++ b/src/common/utils/ReactifyNewlines.tsx
@@ -0,0 +1,12 @@
+import * as React from 'react';
+
+export const ReactifyNewlines = (props: { children: string }) => {
+ const textArray = props.children.split('\n');
+ return (
+ <>
+ {textArray.map((part, idx) => (
+
{part || <> >}
+ ))}
+ >
+ );
+};
diff --git a/src/common/utils/hasPermission.spec.ts b/src/common/utils/hasPermission.spec.ts
new file mode 100644
index 0000000..9c952ed
--- /dev/null
+++ b/src/common/utils/hasPermission.spec.ts
@@ -0,0 +1,37 @@
+import { hasPermission, permissions, hasPermissionOrAdmin } from './hasPermission';
+import { Role } from 'roleypoly/src/rpc/shared';
+import { guildRoles } from 'roleypoly/hack/fixtures/storyData';
+
+const roles: Role.AsObject[] = [
+ {
+ ...guildRoles.rolesList[0],
+ permissions: permissions.ADMINISTRATOR,
+ },
+ {
+ ...guildRoles.rolesList[0],
+ permissions:
+ permissions.SPEAK | permissions.BAN_MEMBERS | permissions.CHANGE_NICKNAME,
+ },
+ {
+ ...guildRoles.rolesList[0],
+ permissions: permissions.BAN_MEMBERS,
+ },
+];
+
+it('finds a permission within a list of roles', () => {
+ const result = hasPermission(roles, permissions.CHANGE_NICKNAME);
+
+ expect(result).toBe(true);
+});
+
+it('finds admin within a list of roles', () => {
+ const result = hasPermissionOrAdmin(roles, permissions.BAN_MEMBERS);
+
+ expect(result).toBe(true);
+});
+
+it('does not find a permission within a list of roles without one', () => {
+ const result = hasPermission(roles, permissions.KICK_MEMBERS);
+
+ expect(result).toBe(false);
+});
diff --git a/src/common/utils/hasPermission.ts b/src/common/utils/hasPermission.ts
new file mode 100644
index 0000000..eed6c15
--- /dev/null
+++ b/src/common/utils/hasPermission.ts
@@ -0,0 +1,45 @@
+import { Role } from 'roleypoly/src/rpc/shared';
+
+export const hasPermission = (roles: Role.AsObject[], permission: number): boolean => {
+ const aggregateRoles = roles.reduce((acc, role) => acc | role.permissions, 0);
+ return (aggregateRoles & permission) === permission;
+};
+
+export const hasPermissionOrAdmin = (
+ roles: Role.AsObject[],
+ permission: number
+): boolean => hasPermission(roles, permission | permissions.ADMINISTRATOR);
+
+export const permissions = {
+ CREATE_INSTANT_INVITE: 0x1,
+ KICK_MEMBERS: 0x2,
+ BAN_MEMBERS: 0x4,
+ ADMINISTRATOR: 0x8,
+ MANAGE_CHANNELS: 0x10,
+ MANAGE_GUILD: 0x20,
+ ADD_REACTIONS: 0x40,
+ VIEW_AUDIT_LOG: 0x80,
+ VIEW_CHANNEL: 0x400,
+ SEND_MESSAGES: 0x800,
+ SEND_TTS_MESSAGES: 0x1000,
+ MANAGE_MESSAGES: 0x2000,
+ EMBED_LINKS: 0x4000,
+ ATTACH_FILES: 0x8000,
+ READ_MESSAGE_HISTORY: 0x10000,
+ MENTION_EVERYONE: 0x20000,
+ USE_EXTERNAL_EMOJIS: 0x40000,
+ VIEW_GUILD_INSIGHTS: 0x80000,
+ CONNECT: 0x100000,
+ SPEAK: 0x200000,
+ MUTE_MEMBERS: 0x400000,
+ DEAFEN_MEMBERS: 0x800000,
+ MOVE_MEMBERS: 0x1000000,
+ USE_VAD: 0x2000000,
+ PRIORITY_SPEAKER: 0x100,
+ STREAM: 0x200,
+ CHANGE_NICKNAME: 0x4000000,
+ MANAGE_NICKNAMES: 0x8000000,
+ MANAGE_ROLES: 0x10000000,
+ MANAGE_WEBHOOKS: 0x20000000,
+ MANAGE_EMOJIS: 0x40000000,
+};
diff --git a/src/common/utils/protoReflection.spec.ts b/src/common/utils/protoReflection.spec.ts
new file mode 100644
index 0000000..9b7050e
--- /dev/null
+++ b/src/common/utils/protoReflection.spec.ts
@@ -0,0 +1,9 @@
+import { DiscordUser } from 'roleypoly/src/rpc/shared';
+import { user } from 'roleypoly/hack/fixtures/storyData';
+import { AsObjectToProto } from './protoReflection';
+
+it('converts a RoleypolyUser.AsObject back to protobuf', () => {
+ const proto = AsObjectToProto(DiscordUser, user);
+
+ expect(proto.toObject()).toMatchObject(user);
+});
diff --git a/src/common/utils/protoReflection.ts b/src/common/utils/protoReflection.ts
new file mode 100644
index 0000000..2c23b21
--- /dev/null
+++ b/src/common/utils/protoReflection.ts
@@ -0,0 +1,30 @@
+import * as pbjs from 'google-protobuf';
+
+type GenericObject = T;
+type ProtoFunction> = (
+ value: U[keyof U]
+) => void;
+
+export const AsObjectToProto = (
+ protoClass: { new (): T },
+ input: ReturnType
+): GenericObject => {
+ const proto = new protoClass();
+ const protoKeys = Object.getOwnPropertyNames((proto as any).__proto__);
+
+ for (let inputKey in input) {
+ const setCallName = protoKeys.find(
+ (key) => `set${inputKey.toLowerCase()}` === key.toLowerCase()
+ ) as keyof typeof proto;
+
+ if (!setCallName) {
+ continue;
+ }
+
+ ((proto[setCallName] as unknown) as ProtoFunction)(
+ input[inputKey]
+ );
+ }
+
+ return proto;
+};
diff --git a/src/common/utils/sortBy.spec.ts b/src/common/utils/sortBy.spec.ts
new file mode 100644
index 0000000..71cc0ef
--- /dev/null
+++ b/src/common/utils/sortBy.spec.ts
@@ -0,0 +1,48 @@
+import { sortBy } from './sortBy';
+
+it('sorts an array of objects by its key', () => {
+ const output = sortBy(
+ [
+ {
+ name: 'bbb',
+ },
+ {
+ name: 'aaa',
+ },
+ {
+ name: 'ddd',
+ },
+ {
+ name: 'ccc',
+ },
+ ],
+ 'name'
+ );
+
+ expect(output.map((v) => v.name)).toEqual(['aaa', 'bbb', 'ccc', 'ddd']);
+});
+
+it('sorts an array of objects by its key with a predicate', () => {
+ const output = sortBy(
+ [
+ {
+ name: 'cc',
+ },
+ {
+ name: 'bbb',
+ },
+ {
+ name: 'aaaa',
+ },
+ {
+ name: 'd',
+ },
+ ],
+ 'name',
+ (a, b) => {
+ return a.length > b.length ? 1 : -1;
+ }
+ );
+
+ expect(output.map((v) => v.name)).toEqual(['d', 'cc', 'bbb', 'aaaa']);
+});
diff --git a/src/common/utils/sortBy.ts b/src/common/utils/sortBy.ts
new file mode 100644
index 0000000..e74ec5b
--- /dev/null
+++ b/src/common/utils/sortBy.ts
@@ -0,0 +1,21 @@
+export const sortBy = (
+ array: T[],
+ key: keyof T,
+ predicate?: (a: T[keyof T], b: T[keyof T]) => number
+) => {
+ return array.sort((a, b) => {
+ if (predicate) {
+ return predicate(a[key], b[key]);
+ }
+
+ if (a[key] === b[key]) {
+ return 0;
+ }
+
+ if (a[key] > b[key]) {
+ return 1;
+ }
+
+ return -1;
+ });
+};