diff --git a/frontend/public/global.css b/frontend/public/global.css
index 1131c0e..13f72a7 100644
--- a/frontend/public/global.css
+++ b/frontend/public/global.css
@@ -278,6 +278,79 @@ body {
font-size: 0.833rem;
}
+/* sidebar */
+
+.sidebar-container {
+ display: flex;
+ flex-direction: column;
+ background-color: var(--background-color-0);
+ height: 100%;
+ min-width: 255px;
+ max-width: 255px;
+}
+
+@media screen and (max-width: 768px) {
+ .sidebar-container {
+ flex-basis: 100%;
+ min-width: unset;
+ max-width: unset;
+ }
+}
+
+.sidebar {
+ width: 100%;
+ height: 100%;
+ padding: var(--space-xs);
+ overflow-x: hidden;
+ overflow-y: auto;
+}
+
+.sidebar-button {
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ justify-content: left;
+ border: none;
+ background-color: var(--background-color-0);
+ padding: var(--space-xs);
+ margin-bottom: var(--space-xxs);
+ color: currentColor;
+ font: inherit;
+ border-radius: var(--radius-md);
+ width: 100%;
+ max-height: 3.4em;
+}
+
+.sidebar-button span {
+ margin-left: var(--space-xxs);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.sidebar-button div {
+ display: inline;
+ flex-shrink: 0;
+
+ /* TODO: HACK! */
+ width: 24px;
+ height: 24px;
+}
+
+.sidebar-button .icon-button {
+ visibility: hidden;
+}
+
+.sidebar-button.selected .icon-button,
+.sidebar-button:hover .icon-button {
+ visibility: visible;
+}
+
+.sidebar-button.selected,
+.sidebar-button:hover {
+ background-color: var(--background-color-2);
+}
+
/*! the tweaks below are heavily based on modern-normalize v1.1.0 | MIT License | https://github.com/sindresorhus/modern-normalize */
*,
diff --git a/frontend/src/components/ChannelTopBar.svelte b/frontend/src/components/ChannelTopBar.svelte
index bd71aa2..e395730 100644
--- a/frontend/src/components/ChannelTopBar.svelte
+++ b/frontend/src/components/ChannelTopBar.svelte
@@ -1,7 +1,7 @@
@@ -10,6 +10,11 @@
.menu-button {
margin-right: var(--space-md);
}
+
+ .right-buttons {
+ margin-left: auto;
+ margin-right: var(--space-xs);
+ }
@@ -20,4 +25,9 @@
{/if}
{ channel.name }
+
+
+
diff --git a/frontend/src/components/Main.svelte b/frontend/src/components/Main.svelte
index 16b1f1e..41a1c5d 100644
--- a/frontend/src/components/Main.svelte
+++ b/frontend/src/components/Main.svelte
@@ -1,8 +1,9 @@
@@ -48,7 +49,10 @@
{#if $showSidebar || $selectedChannel.id === -1}
{/if}
- {#if !($smallViewport && $showSidebar) && $showChannelView && $selectedChannel.id !== -1}
+ {#if !($smallViewport && $showSidebar) && !($smallViewport && $showPresenceSidebar) && $showChannelView && $selectedChannel.id !== -1}
{/if}
+ {#if $showPresenceSidebar}
+
+ {/if}
diff --git a/frontend/src/components/PresenceSidebar.svelte b/frontend/src/components/PresenceSidebar.svelte
new file mode 100644
index 0000000..36b990d
--- /dev/null
+++ b/frontend/src/components/PresenceSidebar.svelte
@@ -0,0 +1,47 @@
+
+
+
diff --git a/frontend/src/components/Sidebar.svelte b/frontend/src/components/Sidebar.svelte
index d2d9548..16ba48f 100644
--- a/frontend/src/components/Sidebar.svelte
+++ b/frontend/src/components/Sidebar.svelte
@@ -78,74 +78,4 @@
margin-left: auto;
}
- .sidebar-container {
- display: flex;
- flex-direction: column;
- background-color: var(--background-color-0);
- height: 100%;
- min-width: 255px;
- max-width: 255px;
- }
-
- @media screen and (max-width: 768px) {
- .sidebar-container {
- flex-basis: 100%;
- min-width: unset;
- max-width: unset;
- }
- }
-
- .sidebar {
- width: 100%;
- height: 100%;
- padding: var(--space-xs);
- overflow-x: hidden;
- overflow-y: auto;
- }
-
- .sidebar-button {
- flex-shrink: 0;
- display: flex;
- align-items: center;
- justify-content: left;
- border: none;
- background-color: var(--background-color-0);
- padding: var(--space-xs);
- margin-bottom: var(--space-xxs);
- color: currentColor;
- font: inherit;
- border-radius: var(--radius-md);
- width: 100%;
- max-height: 3.4em;
- }
-
- .sidebar-button span {
- margin-left: var(--space-xxs);
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- }
-
- .sidebar-button div {
- display: inline;
- flex-shrink: 0;
-
- /* TODO: HACK! */
- width: 24px;
- height: 24px;
- }
-
- .sidebar-button .icon-button {
- visibility: hidden;
- }
-
- .sidebar-button.selected .icon-button,
- .sidebar-button:hover .icon-button {
- visibility: visible;
- }
-
- .sidebar-button.selected,
- .sidebar-button:hover {
- background-color: var(--background-color-2);
- }
diff --git a/frontend/src/gateway.js b/frontend/src/gateway.js
index e190e83..e7a6d61 100644
--- a/frontend/src/gateway.js
+++ b/frontend/src/gateway.js
@@ -27,6 +27,8 @@ export const GatewayPayloadType = {
MessageDelete: 122,
TypingStart: 130,
+
+ PresenceUpdate: 140
}
export const GatewayEventType = {
@@ -37,6 +39,11 @@ export const GatewayEventType = {
BadAuth: -3,
}
+export const GatewayPresenceStatus = {
+ Offline: 0,
+ Online: 1
+}
+
const log = logger("Gateway");
export default {
diff --git a/frontend/src/stores.js b/frontend/src/stores.js
index 48d41af..e494729 100644
--- a/frontend/src/stores.js
+++ b/frontend/src/stores.js
@@ -1,4 +1,4 @@
-import gateway, { GatewayEventType, GatewayPayloadType } from "./gateway";
+import gateway, { GatewayEventType, GatewayPayloadType, GatewayPresenceStatus } from "./gateway";
import logger from "./logging";
import request from "./request";
import { apiRoute, getItem, setItem } from "./storage";
@@ -398,8 +398,45 @@ class TypingStore extends Store {
}
}
+class PresenceStore extends Store {
+ constructor() {
+ super([], "PresenceStore");
+
+ gateway.subscribe(GatewayEventType.Ready, ({ presence }) => {
+ this.ingestPresenceUpdate(presence);
+ });
+
+ gateway.subscribe(GatewayEventType.PresenceUpdate, (data) => {
+ this.ingestPresenceUpdate(data);
+ });
+ }
+
+ entryIndexByUserId(userId) {
+ return this.value.findIndex(a => a.user.id === userId);
+ }
+
+ ingestPresenceUpdate(payload) {
+ payload.forEach((entry) => {
+ const existingEntry = this.entryIndexByUserId(entry.user.id);
+ if (existingEntry !== -1 && entry.status === GatewayPresenceStatus.Offline) {
+ this.value.splice(existingEntry, 1);
+ } else if (existingEntry !== -1 && entry.status !== GatewayPresenceStatus.Offline) {
+ this.value[existingEntry] = entry;
+ } else {
+ // don't need to push the status, since we remove offline members from the presence list
+ this.value.push({
+ user: entry.user
+ });
+ }
+ });
+ console.log(this.value);
+ this.updated();
+ }
+}
+
export const selectedChannel = new Store({ id: -1, name: "none", creator_id: -1 }, "selectedChannel");
export const showSidebar = new Store(true, "showSidebar");
+export const showPresenceSidebar = new Store(false, "showPresenceSidebar");
export const smallViewport = new Store(false, "smallViewport");
export const showChannelView = new Store(true, "showChannelView");
export const theme = new StorageItemStore("ui:theme");
@@ -410,10 +447,12 @@ export const messagesStoreProvider = new MessagesStoreProvider();
export const userInfoStore = new UserInfoStore();
export const overlayStore = new OverlayStore();
export const typingStore = new TypingStore();
+export const presenceStore = new PresenceStore();
export const allStores = {
selectedChannel,
showSidebar,
+ showPresenceSidebar,
showChannelView,
smallViewport,
theme,
diff --git a/src/gateway/gatewaypayloadtype.ts b/src/gateway/gatewaypayloadtype.ts
index b8bdc16..9320c2c 100644
--- a/src/gateway/gatewaypayloadtype.ts
+++ b/src/gateway/gatewaypayloadtype.ts
@@ -1,8 +1,8 @@
export enum GatewayPayloadType {
Hello = 0,
- Authenticate,
+ Authenticate, // client
Ready,
- Ping,
+ Ping, // client
ChannelCreate = 110,
ChannelUpdate,
@@ -13,4 +13,11 @@ export enum GatewayPayloadType {
MessageDelete,
TypingStart = 130,
+
+ PresenceUpdate = 140,
+}
+
+export enum GatewayPresenceStatus {
+ Offline = 0,
+ Online,
}
diff --git a/src/gateway/gatewaypresence.ts b/src/gateway/gatewaypresence.ts
new file mode 100644
index 0000000..286af4c
--- /dev/null
+++ b/src/gateway/gatewaypresence.ts
@@ -0,0 +1,9 @@
+import { GatewayPresenceStatus } from "./gatewaypayloadtype"
+
+export interface GatewayPresenceEntry {
+ user: {
+ username: string,
+ id: number
+ },
+ status: GatewayPresenceStatus
+}
diff --git a/src/gateway/index.ts b/src/gateway/index.ts
index 8f039cb..a54d602 100644
--- a/src/gateway/index.ts
+++ b/src/gateway/index.ts
@@ -5,7 +5,8 @@ import { decodeTokenOrNull, getPublicUserObject } from "../auth";
import { query } from "../database";
import { gatewayErrors } from "../errors";
import { GatewayPayload } from "../types/gatewaypayload";
-import { GatewayPayloadType } from "./gatewaypayloadtype";
+import { GatewayPayloadType, GatewayPresenceStatus } from "./gatewaypayloadtype";
+import { GatewayPresenceEntry } from "./gatewaypresence";
const GATEWAY_BATCH_INTERVAL = 50000;
const GATEWAY_PING_INTERVAL = 40000;
@@ -16,7 +17,7 @@ const MAX_GATEWAY_SESSIONS_PER_USER = 5;
const dispatchChannels = new Map>();
// mapping between a user id and the websocket sessions it has
-const sessionsByUserId = new Map>();
+const sessionsByUserId = new Map();
function clientSubscribe(ws: WebSocket, dispatchChannel: string) {
ws.state.dispatchChannels.add(dispatchChannel);
@@ -71,7 +72,9 @@ export function dispatch(channel: string, message: GatewayPayload) {
if (!members) return;
members.forEach(e => {
- e.send(JSON.stringify(message));
+ if (e.state.ready) {
+ e.send(JSON.stringify(message));
+ }
});
}
@@ -120,6 +123,35 @@ function sendPayload(ws: WebSocket, payload: GatewayPayload) {
ws.send(JSON.stringify(payload));
}
+function getPresenceEntryForUser(user: User, status: GatewayPresenceStatus): GatewayPresenceEntry {
+ return {
+ user: {
+ id: user.id,
+ username: user.username
+ },
+ status
+ }
+}
+
+// The initial presence entries are sent right when the user connects.
+// In the future, each user will have their own list of channels that they can join and leave.
+// In that case, we will send the presence entries to a certain user only for the channels they're in.
+function getInitialPresenceEntries(): GatewayPresenceEntry[] {
+ const entries: GatewayPresenceEntry[] = [];
+
+ sessionsByUserId.forEach((wsList: WebSocket[], userId: number) => {
+ if (wsList.length < 1)
+ return;
+
+ const firstWs = wsList[0];
+ if (firstWs.state.ready && firstWs.state.user) {
+ entries.push(getPresenceEntryForUser(firstWs.state.user, GatewayPresenceStatus.Online));
+ }
+ });
+
+ return entries;
+}
+
export default function(server: Server) {
const wss = new WebSocketServer({ server });
@@ -163,12 +195,20 @@ export default function(server: Server) {
ws.on("close", () => {
clientUnsubscribeAll(ws);
+ ws.state.ready = false;
if (ws.state.user && ws.state.user.id) {
const sessions = sessionsByUserId.get(ws.state.user.id);
if (sessions) {
- sessions.delete(ws);
- if (sessions.size < 1) {
+ const index = sessions.indexOf(ws);
+ sessions.splice(index, 1);
+ if (sessions.length < 1) {
sessionsByUserId.delete(ws.state.user.id);
+
+ // user no longer has any sessions, update presence
+ dispatch("*", {
+ t: GatewayPayloadType.PresenceUpdate,
+ d: [getPresenceEntryForUser(ws.state.user, GatewayPresenceStatus.Offline)]
+ });
}
}
}
@@ -211,14 +251,14 @@ export default function(server: Server) {
let sessions = sessionsByUserId.get(user.id);
if (sessions) {
- if ((sessions.size + 1) > MAX_GATEWAY_SESSIONS_PER_USER) {
+ if ((sessions.length + 1) > MAX_GATEWAY_SESSIONS_PER_USER) {
return closeWithError(ws, gatewayErrors.TOO_MANY_SESSIONS);
}
} else {
- sessions = new Set();
+ sessions = [];
sessionsByUserId.set(user.id, sessions);
}
- sessions.add(ws);
+ sessions.push(ws);
// TODO: each user should have their own list of channels that they join
const channels = await query("SELECT id, name, owner_id FROM channels ORDER BY id ASC");
@@ -233,15 +273,25 @@ export default function(server: Server) {
});
ws.state.user = user;
+
+ // first session, notify others that we are online
+ if (sessions.length === 1) {
+ dispatch("*", {
+ t: GatewayPayloadType.PresenceUpdate,
+ d: [getPresenceEntryForUser(ws.state.user, GatewayPresenceStatus.Online)]
+ });
+ }
+
ws.state.ready = true;
sendPayload(ws, {
t: GatewayPayloadType.Ready,
d: {
user: getPublicUserObject(ws.state.user),
- channels: channels.rows
+ channels: channels.rows,
+ presence: getInitialPresenceEntries()
}
- })
+ });
break;
}
case GatewayPayloadType.Ping: {