add presence and user list

This commit is contained in:
hippoz 2022-08-08 05:12:31 +03:00
parent f9a62cec4e
commit e31d8c5973
Signed by: hippoz
GPG key ID: 7C52899193467641
10 changed files with 263 additions and 87 deletions

View file

@ -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 */
*,

View file

@ -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>

View file

@ -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>

View 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>

View file

@ -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>

View file

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

View file

@ -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,

View file

@ -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,
}

View file

@ -0,0 +1,9 @@
import { GatewayPresenceStatus } from "./gatewaypayloadtype"
export interface GatewayPresenceEntry {
user: {
username: string,
id: number
},
status: GatewayPresenceStatus
}

View file

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