add presence and user list
This commit is contained in:
parent
f9a62cec4e
commit
e31d8c5973
10 changed files with 263 additions and 87 deletions
|
@ -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 */
|
||||
|
||||
*,
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<script>
|
||||
import { HashIcon, MenuIcon } from "svelte-feather-icons";
|
||||
import { HashIcon, MenuIcon, UsersIcon } from "svelte-feather-icons";
|
||||
import { getItem } from "../storage";
|
||||
import { overlayStore, showSidebar } from "../stores";
|
||||
import { overlayStore, showPresenceSidebar, showSidebar } from "../stores";
|
||||
|
||||
export let channel;
|
||||
</script>
|
||||
|
@ -10,6 +10,11 @@
|
|||
.menu-button {
|
||||
margin-right: var(--space-md);
|
||||
}
|
||||
|
||||
.right-buttons {
|
||||
margin-left: auto;
|
||||
margin-right: var(--space-xs);
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="top-bar">
|
||||
|
@ -20,4 +25,9 @@
|
|||
{/if}
|
||||
<HashIcon />
|
||||
<span class="h5 top-bar-heading" on:click="{ () => overlayStore.open('editChannel', {channel}) }">{ channel.name }</span>
|
||||
<div class="right-buttons">
|
||||
<button class="icon-button" on:click="{ () => showPresenceSidebar.set(!showPresenceSidebar.value) }" aria-label="Toggle user list">
|
||||
<UsersIcon />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
<script>
|
||||
import { CloudIcon } from "svelte-feather-icons";
|
||||
import { gatewayStatus, showSidebar, selectedChannel, smallViewport, showChannelView, theme } from "../stores";
|
||||
import { gatewayStatus, showSidebar, selectedChannel, smallViewport, showChannelView, theme, showPresenceSidebar } from "../stores";
|
||||
import ChannelView from "./ChannelView.svelte";
|
||||
import OverlayProvider from "./overlays/OverlayProvider.svelte";
|
||||
import PresenceSidebar from "./PresenceSidebar.svelte";
|
||||
import Sidebar from "./Sidebar.svelte";
|
||||
</script>
|
||||
|
||||
|
@ -48,7 +49,10 @@
|
|||
{#if $showSidebar || $selectedChannel.id === -1}
|
||||
<Sidebar />
|
||||
{/if}
|
||||
{#if !($smallViewport && $showSidebar) && $showChannelView && $selectedChannel.id !== -1}
|
||||
{#if !($smallViewport && $showSidebar) && !($smallViewport && $showPresenceSidebar) && $showChannelView && $selectedChannel.id !== -1}
|
||||
<ChannelView channel={$selectedChannel} />
|
||||
{/if}
|
||||
{#if $showPresenceSidebar}
|
||||
<PresenceSidebar />
|
||||
{/if}
|
||||
</div>
|
||||
|
|
47
frontend/src/components/PresenceSidebar.svelte
Normal file
47
frontend/src/components/PresenceSidebar.svelte
Normal file
|
@ -0,0 +1,47 @@
|
|||
<script>
|
||||
import { ArrowLeftIcon, AtSignIcon } from "svelte-feather-icons";
|
||||
import { quadInOut } from "svelte/easing";
|
||||
import { maybeFly } from "../animations";
|
||||
import { presenceStore, showChannelView, showPresenceSidebar, smallViewport } from "../stores";
|
||||
|
||||
let pendingExit = false;
|
||||
|
||||
const scheduleClose = () => {
|
||||
if ($smallViewport) {
|
||||
$showChannelView = false;
|
||||
pendingExit = true;
|
||||
}
|
||||
$showPresenceSidebar = false;
|
||||
};
|
||||
|
||||
const outroEnd = () => {
|
||||
if (pendingExit) {
|
||||
pendingExit = false;
|
||||
$showChannelView = true;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="sidebar-container" transition:maybeFly="{{ duration: 200, easing: quadInOut, x: 10 }}" on:outroend="{ outroEnd }">
|
||||
<div class="top-bar">
|
||||
<span class="input-label">User List</span>
|
||||
</div>
|
||||
<div class="sidebar">
|
||||
{#each $presenceStore as entry}
|
||||
<button class="sidebar-button">
|
||||
<div>
|
||||
<AtSignIcon />
|
||||
</div>
|
||||
<span>{ entry.user.username }</span>
|
||||
</button>
|
||||
{/each}
|
||||
{#if $smallViewport}
|
||||
<button on:click={ scheduleClose } class="sidebar-button">
|
||||
<div>
|
||||
<ArrowLeftIcon />
|
||||
</div>
|
||||
<span>Back</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
|
@ -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);
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
9
src/gateway/gatewaypresence.ts
Normal file
9
src/gateway/gatewaypresence.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
import { GatewayPresenceStatus } from "./gatewaypayloadtype"
|
||||
|
||||
export interface GatewayPresenceEntry {
|
||||
user: {
|
||||
username: string,
|
||||
id: number
|
||||
},
|
||||
status: GatewayPresenceStatus
|
||||
}
|
|
@ -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<string, Set<WebSocket>>();
|
||||
|
||||
// mapping between a user id and the websocket sessions it has
|
||||
const sessionsByUserId = new Map<number, Set<WebSocket>>();
|
||||
const sessionsByUserId = new Map<number, WebSocket[]>();
|
||||
|
||||
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: {
|
||||
|
|
Loading…
Reference in a new issue