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: {