first implementation of "communities" + visual improvements

This commit is contained in:
hippoz 2023-05-31 22:09:36 +03:00
parent 5a5588f1e3
commit 045e34cbd5
Signed by: hippoz
GPG key ID: 56C4E02A85F2FBED
23 changed files with 576 additions and 144 deletions

View file

@ -11,7 +11,7 @@
<style> <style>
.main-container { .main-container {
background-color: var(--background-color-1); background-color: var(--background-color-2);
overflow: hidden; overflow: hidden;
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View file

@ -43,7 +43,7 @@ import { overlayStore, OverlayType, setMessageInputEvent } from "../stores";
} }
.message:hover, .message.pinged { .message:hover, .message.pinged {
background-color: var(--background-color-2); background-color: var(--background-color-3);
} }
.message-content { .message-content {

View file

@ -111,7 +111,7 @@
.message-input { .message-input {
width: 100%; width: 100%;
background-color : var(--background-color-2); background-color : var(--background-color-3);
border: none; border: none;
color: currentColor; color: currentColor;
border-radius: var(--radius-md); border-radius: var(--radius-md);

View file

@ -81,7 +81,6 @@
flex-grow: 0; flex-grow: 0;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
background-color: var(--background-color-1);
padding-top: var(--space-sm); padding-top: var(--space-sm);
} }
@ -106,7 +105,7 @@
padding: 0; padding: 0;
padding-left: var(--space-xs); padding-left: var(--space-xs);
padding-right: var(--space-xs); padding-right: var(--space-xs);
background-color: var(--background-color-1); background-color: var(--background-color-2);
color: var(--foreground-color-2); color: var(--foreground-color-2);
font-size: 0.75em; font-size: 0.75em;
} }

View file

@ -18,24 +18,32 @@
font-weight: 700; font-weight: 700;
font-size: var(--h6); font-size: var(--h6);
} }
.presence-sidebar {
min-width: 248px;
max-width: 248px;
}
</style> </style>
<div class="sidebar-container" in:maybeFly="{{ duration: 175, easing: quadInOut, x: 10 }}" out:maybeFlyIf="{{ _condition: !smallViewport.value, duration: 175, easing: quadInOut, x: -10 }}"> <div class="sidebar-container presence-sidebar" in:maybeFly="{{ duration: 175, easing: quadInOut, x: 10 }}" out:maybeFlyIf="{{ _condition: !smallViewport.value, duration: 175, easing: quadInOut, x: -10 }}">
<span class="online-label">ONLINE</span>
<div class="sidebar"> <div class="sidebar">
{#each $presenceStore as entry (entry.user.id)} <span class="online-label">ONLINE</span>
<button class="sidebar-button">
<UserView size={28} user={entry.user}></UserView> <div class="sidebar-buttons">
{#if entry.bridgesTo || entry.privacy || entry.terms} {#each $presenceStore as entry (entry.user.id)}
<span class="user-badge" on:click={ () => overlayStore.push(OverlayType.UserInfo, { presenceEntry: entry }) }>SERVICE</span> <button class="sidebar-button">
{/if} <UserView size={28} user={entry.user}></UserView>
</button> {#if entry.bridgesTo || entry.privacy || entry.terms}
{/each} <span class="user-badge" on:click={ () => overlayStore.push(OverlayType.UserInfo, { presenceEntry: entry }) }>SERVICE</span>
{#if $smallViewport} {/if}
<button on:click={ close } class="sidebar-button"> </button>
<div class="material-icons-outlined">arrow_back</div> {/each}
<span class="sidebar-button-text">Back</span> {#if $smallViewport}
</button> <button on:click={ close } class="sidebar-button">
{/if} <div class="material-icons-outlined">arrow_back</div>
<span class="sidebar-button-text">Back</span>
</button>
{/if}
</div>
</div> </div>
</div> </div>

View file

@ -1,7 +1,9 @@
<script> <script>
import { quadInOut } from "svelte/easing"; import { quadInOut } from "svelte/easing";
import { maybeFly, maybeFlyIf } from "../animations"; import { maybeFly, maybeFlyIf } from "../animations";
import { channels, gatewayStatus, overlayStore, selectedChannel, showSidebar, smallViewport, userInfoStore, unreadStore, OverlayType } from "../stores"; import { avatarUrl } from "../storage";
import { channels, gatewayStatus, overlayStore, selectedChannel, showSidebar, smallViewport, userInfoStore, unreadStore, OverlayType, communities, selectedCommunity } from "../stores";
import AddCommunity from "./overlays/AddCommunity.svelte";
import UserView from "./UserView.svelte"; import UserView from "./UserView.svelte";
const selectChannel = (channel) => { const selectChannel = (channel) => {
@ -13,46 +15,6 @@
} }
</script> </script>
<div class="sidebar-container" in:maybeFly="{{ duration: 175, easing: quadInOut, x: -10 }}" out:maybeFlyIf="{{ _condition: !smallViewport.value, duration: 175, easing: quadInOut, x: -10 }}">
<div class="top-bar text-small text-bold">
<UserView size={28} user={$userInfoStore}></UserView>
</div>
<div class="sidebar">
{#each $channels as channel (channel.id)}
<button on:click="{ selectChannel(channel) }" class="sidebar-button" class:selected={ channel.id === $selectedChannel.id }>
<span class="material-icons-outlined">tag</span>
<span class="sidebar-button-text">{ channel.name }</span>
<div class="sidebar-channel-buttons">
{#if $unreadStore.get(channel.id)}
<div class="unread-indicator">{ $unreadStore.get(channel.id) }</div>
{/if}
{#if $userInfoStore && (channel.owner_id === $userInfoStore.id || $userInfoStore.is_superuser)}
<button class="icon-button" on:click|stopPropagation="{ () => overlayStore.push(OverlayType.EditChannel, { channel }) }" aria-label="Edit Channel">
<span class="material-icons-outlined">more_vert</span>
</button>
{/if}
</div>
</button>
{/each}
{#if $userInfoStore && $userInfoStore.permissions.create_channel}
<button on:click="{ () => overlayStore.push(OverlayType.CreateChannel) }" class="sidebar-button">
<span class="material-icons-outlined">add</span>
<span class="sidebar-button-text">Create Channel</span>
</button>
{/if}
<button on:click="{ () => overlayStore.push(OverlayType.Settings) }" class="sidebar-button">
<span class="material-icons-outlined">settings</span>
<span class="sidebar-button-text">Settings</span>
</button>
</div>
{#if !$gatewayStatus.ready}
<div class="top-bar darker">
<span class="material-icons-outlined">cloud</span>
<span class="h5 top-bar-heading">connecting...</span>
</div>
{/if}
</div>
<style> <style>
.sidebar-channel-buttons { .sidebar-channel-buttons {
display: flex; display: flex;
@ -62,4 +24,108 @@
flex-shrink: 0; flex-shrink: 0;
margin-left: auto; margin-left: auto;
} }
.communities {
display: flex;
flex-direction: column;
background-color: var(--background-color-0);
}
.communities .button {
flex-shrink: 0;
flex-grow: 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--background-color-1);
padding: 0;
font-size: inherit;
border-radius: 50%;
contain: content;
width: 48px;
height: 48px;
margin: var(--space-sm);
margin-bottom: 0;
transition-duration: 200ms;
transition-property: border-radius;
}
.communities .button:hover, .communities .button.selected {
border-radius: 16px;
}
.communities .button span {
margin: 0.45em;
}
.communities .button img {
width: 100%;
height: 100%;
}
</style> </style>
<div class="sidebar-container" in:maybeFly="{{ duration: 175, easing: quadInOut, x: -10 }}" out:maybeFlyIf="{{ _condition: !smallViewport.value, duration: 175, easing: quadInOut, x: -10 }}">
<div class="communities">
<button class="button" on:click={() => selectedCommunity.clear()} class:selected={ $selectedCommunity.id === -1 }>
{#if $userInfoStore}
<img src={avatarUrl($userInfoStore.avatar, 64)} alt=" ">
{/if}
</button>
{#each $communities as community (community.id)}
<button class="button" on:click={() => $selectedCommunity = community} class:selected={ $selectedCommunity.id === community.id }>
{#if community && community.avatar}
<img src={avatarUrl(community.avatar, 64)} alt=" ">
{:else}
<span>{ community.name[0] || "" }</span>
{/if}
</button>
{/each}
<button class="button" on:click={() => overlayStore.push(OverlayType.AddCommunity)}>
<span class="material-icons-outlined">add</span>
</button>
<button class="button" on:click={() => overlayStore.push(OverlayType.Settings)}>
<span class="material-icons-outlined">settings</span>
</button>
</div>
<div class="sidebar">
<div class="top-bar text-small text-bold">
{#if $selectedCommunity.id !== -1}
{$selectedCommunity.name || ""}
{:else}
{$userInfoStore ? $userInfoStore.username || "" : ""}
{/if}
</div>
<div class="sidebar-buttons">
{#each $channels as channel (channel.id)}
{#if ($selectedCommunity.id === -1 && channel.community_id === null) || ($selectedCommunity.id !== -1 && channel.community_id === $selectedCommunity.id)}
<button on:click="{ selectChannel(channel) }" class="sidebar-button" class:selected={ channel.id === $selectedChannel.id }>
<span class="material-icons-outlined">tag</span>
<span class="sidebar-button-text">{ channel.name }</span>
<div class="sidebar-channel-buttons">
{#if $unreadStore.get(channel.id)}
<div class="unread-indicator">{ $unreadStore.get(channel.id) }</div>
{/if}
{#if $userInfoStore && (channel.owner_id === $userInfoStore.id || $userInfoStore.is_superuser)}
<button class="icon-button" on:click|stopPropagation="{ () => overlayStore.push(OverlayType.EditChannel, { channel }) }" aria-label="Edit Channel">
<span class="material-icons-outlined">more_vert</span>
</button>
{/if}
</div>
</button>
{/if}
{/each}
{#if $userInfoStore && $userInfoStore.permissions.create_channel}
<button on:click="{ () => overlayStore.push(OverlayType.CreateChannel, { community: selectedCommunity.value }) }" class="sidebar-button">
<span class="material-icons-outlined">add</span>
<span class="sidebar-button-text">Create Channel</span>
</button>
{/if}
</div>
{#if !$gatewayStatus.ready}
<div class="top-bar darker">
<span class="material-icons-outlined">cloud</span>
<span class="h5 top-bar-heading">connecting...</span>
</div>
{/if}
</div>
</div>

View file

@ -0,0 +1,50 @@
<script>
import { overlayStore } from "../../stores";
import { methods, remoteSignal } from "../../request";
import { maybeModalFade, maybeModalScale } from "../../animations";
let communityName = "";
let createButtonEnabled = true;
export let close = () => {};
const create = async () => {
createButtonEnabled = false;
const { ok } = await remoteSignal(methods.createCommunity, communityName);
if (!ok) {
overlayStore.toast("Couldn't create community");
}
close();
};
const onKeydown = async (e) => {
if (e.code !== "Enter")
return;
await create();
};
</script>
<style>
.full-width {
width: 100%;
}
</style>
<div class="modal-backdrop" transition:maybeModalFade on:click="{ close }" on:keydown="{ onKeydown }">
<div class="modal" transition:maybeModalScale on:click|stopPropagation>
<div class="modal-header">
<span class="h4">Create Community</span>
</div>
<div class="modal-content">
<label class="input-label">
Community Name
<input class="input full-width" minlength="1" maxlength="32" bind:value={ communityName } />
</label>
</div>
<div class="modal-footer">
<button class="button modal-secondary-action" on:click="{ close }">Cancel</button>
<button class="button button-accent modal-primary-action" on:click="{ create }" disabled="{ !createButtonEnabled }">Create</button>
</div>
</div>
</div>

View file

@ -6,10 +6,11 @@
let channelName = ""; let channelName = "";
let createButtonEnabled = true; let createButtonEnabled = true;
export let close = () => {}; export let close = () => {};
export let community = null;
const create = async () => { const create = async () => {
createButtonEnabled = false; createButtonEnabled = false;
const { ok } = await remoteSignal(methods.createChannel, channelName); const { ok } = await remoteSignal(methods.createChannel, channelName, community.id !== -1 ? community.id : null);
if (!ok) { if (!ok) {
overlayStore.toast("Couldn't create channel"); overlayStore.toast("Couldn't create channel");
} }
@ -33,6 +34,9 @@
<div class="modal" transition:maybeModalScale on:click|stopPropagation> <div class="modal" transition:maybeModalScale on:click|stopPropagation>
<div class="modal-header"> <div class="modal-header">
<span class="h4">Create Channel</span> <span class="h4">Create Channel</span>
{#if community.id !== -1}
<span class="text-fg-3 text-small">in <span class="text-fg-2">{ community.name }</span></span>
{/if}
</div> </div>
<div class="modal-content"> <div class="modal-content">

View file

@ -10,6 +10,7 @@
import Settings from "./Settings.svelte"; import Settings from "./Settings.svelte";
import Prompt from "./Prompt.svelte"; import Prompt from "./Prompt.svelte";
import UserInfo from "./UserInfo.svelte"; import UserInfo from "./UserInfo.svelte";
import AddCommunity from "./AddCommunity.svelte";
const OverlayComponent = { const OverlayComponent = {
0: CreateChannel, 0: CreateChannel,
@ -21,6 +22,7 @@
6: Settings, 6: Settings,
7: Prompt, 7: Prompt,
8: UserInfo, 8: UserInfo,
9: AddCommunity,
}; };
</script> </script>

View file

@ -35,6 +35,10 @@ export const GatewayPayloadType = {
PresenceUpdate: 140, PresenceUpdate: 140,
UserUpdate: 150, UserUpdate: 150,
CommunityCreate: 160,
CommunityUpdate: 161,
CommunityDelete: 162,
} }
export const GatewayEventType = { export const GatewayEventType = {
@ -59,6 +63,7 @@ export default {
heartbeatInterval: null, heartbeatInterval: null,
user: null, user: null,
channels: null, channels: null,
communities: null,
reconnectDelay: 400, reconnectDelay: 400,
reconnectTimeout: null, reconnectTimeout: null,
handlers: new Map(), handlers: new Map(),
@ -113,6 +118,7 @@ export default {
this.user = payload.d.user; this.user = payload.d.user;
this.channels = payload.d.channels; this.channels = payload.d.channels;
this.communities = payload.d.communities;
this.authenticated = true; this.authenticated = true;
this.reconnectDelay = 400; this.reconnectDelay = 400;

View file

@ -5,23 +5,28 @@ const method = (methodId, requiresAuthentication) => ({methodId, requiresAuthent
const withCacheable = (method) => ({ ...method, cacheable: true }) const withCacheable = (method) => ({ ...method, cacheable: true })
export const methods = { export const methods = {
// methodName: [ methodId, requiresAuthentication ] createUser: method(100, false),
createUser: method(0, false), loginUser: method(101, false),
loginUser: method(1, false), getUserSelf: withCacheable(method(102, true)),
getUserSelf: withCacheable(method(2, true)), promoteUserSelf: method(103, true),
promoteUserSelf: method(3, true), putUserAvatar: method(104, true),
putUserAvatar: method(4, true), createChannel: method(200, true),
createChannel: method(5, true), updateChannelName: method(201, true),
updateChannelName: method(6, true), deleteChannel: method(202, true),
deleteChannel: method(7, true), getChannel: withCacheable(method(203, true)),
getChannel: withCacheable(method(8, true)), getChannels: withCacheable(method(204, true)),
getChannels: withCacheable(method(9, true)), createChannelMessage: method(205, true),
createChannelMessage: method(10, true), getChannelMessages: withCacheable(method(206, true)),
getChannelMessages: withCacheable(method(11, true)), putChannelTyping: method(207, true),
putChannelTyping: method(12, true), deleteMessage: method(300, true),
deleteMessage: method(13, true), updateMessageContent: method(301, true),
updateMessageContent: method(14, true), getMessage: withCacheable(method(302, true)),
getMessage: withCacheable(method(15, true)) createCommunity: method(400, true),
updateCommunityName: method(401, true),
deleteCommunity: method(402, true),
getCommunity: withCacheable(method(403, true)),
getCommunities: withCacheable(method(404, true)),
getCommunityChannels: withCacheable(method(405, true)),
}; };
export function compatibleFetch(endpoint, options) { export function compatibleFetch(endpoint, options) {

View file

@ -5,12 +5,13 @@ const defaults = {
"auth:token": "", "auth:token": "",
"ui:doAnimations": true, "ui:doAnimations": true,
"ui:theme": "dark", "ui:theme": "dark",
"state:openChannelId": -1, "state:openCommunityId": -1,
"state:selectedChannels": {},
"log:Gateway": false, "log:Gateway": false,
"log:Store": false, "log:Store": false,
"log:Messages": false, "log:Messages": false,
"log:Timeline": false, "log:Timeline": false,
"ui:stateful:presistSelectedChannel": true, "ui:stateful:presistSelection": true,
"ui:showSidebarToggle": false, "ui:showSidebarToggle": false,
"ui:alwaysUseMobileChatBar": false, "ui:alwaysUseMobileChatBar": false,
"ui:online:processRemoteTypingEvents": true, "ui:online:processRemoteTypingEvents": true,

View file

@ -139,21 +139,6 @@ class ChannelsStore extends Store {
gateway.subscribe(GatewayEventType.Ready, ({ channels }) => { gateway.subscribe(GatewayEventType.Ready, ({ channels }) => {
this.value = channels; this.value = channels;
if (getItem("ui:stateful:presistSelectedChannel")) {
selectedChannel.value.id = getItem("state:openChannelId");
}
if (channels.length >= 1) {
if (!selectedChannel.value || selectedChannel.value.id === -1) {
selectedChannel.set(channels[0]);
} else {
// if a channel id is already selected, we'll populate it with the data we just got from the gateway
const index = this.value.findIndex(e => e.id === selectedChannel.value.id);
if (index !== -1)
selectedChannel.set(this.value[index]);
else // if the channel doesn't exist, just select the first one
selectedChannel.set(channels[0]);
}
}
this.updated(); this.updated();
}); });
gateway.subscribe(GatewayEventType.ChannelCreate, (channel) => { gateway.subscribe(GatewayEventType.ChannelCreate, (channel) => {
@ -414,7 +399,8 @@ export const OverlayType = {
EditMessage: 5, EditMessage: 5,
Settings: 6, Settings: 6,
Prompt: 7, Prompt: 7,
UserInfo: 8 UserInfo: 8,
AddCommunity: 9
}; };
class OverlayStore extends Store { class OverlayStore extends Store {
constructor() { constructor() {
@ -672,7 +658,161 @@ class PluginStore extends Store {
} }
} }
export const selectedChannel = new Store({ id: -1, name: "none", creator_id: -1 }, "selectedChannel"); class CommunitiesStore extends Store {
constructor() {
super(gateway.communities || [], "CommunitiesStore");
gateway.subscribe(GatewayEventType.Ready, ({ communities }) => {
this.value = communities;
this.updated();
});
gateway.subscribe(GatewayEventType.CommunityCreate, (community) => {
this.value.push(community);
this.updated();
});
gateway.subscribe(GatewayEventType.CommunityDelete, ({ id }) => {
const index = this.value.findIndex(e => e.id === id);
if (index === -1)
return;
this.value.splice(index, 1);
this.updated();
});
gateway.subscribe(GatewayEventType.CommunityUpdate, (data) => {
const index = this.value.findIndex(e => e.id === data.id);
if (index === -1)
return;
if (!this.value[index])
return;
this.value[index] = data;
this.updated();
});
}
}
const noneChannel = {id: -1, name: "none", creator_id: -1}
class SelectedChannelStore extends Store {
constructor() {
super(noneChannel, "SelectedChannelStore");
this.communityIdToSelectedChannel = new Map();
selectedCommunity.subscribe((community) => {
this.value = this.communityIdToSelectedChannel.get(community.id) || noneChannel;
this.updated();
});
gateway.subscribe(GatewayEventType.Ready, ({ channels }) => {
this.communityIdToSelectedChannel.clear();
const savedMap = getItem("state:selectedChannels");
for (const [communityId, channelId] of Object.entries(savedMap)) {
const channel = channels.find(c => c.id === channelId);
if (channel) {
this.communityIdToSelectedChannel.set(parseInt(communityId), channel);
}
}
console.log(this.communityIdToSelectedChannel);
this.value = this.communityIdToSelectedChannel.get(selectedCommunity.value.id) || noneChannel;
this.updateSavedMap();
this.updated();
});
gateway.subscribe(GatewayEventType.CommunityDelete, ({ id }) => {
if (this.communityIdToSelectedChannel.delete(id) && this.value && this.value.id === id) {
this.value = noneChannel;
this.updateSavedMap();
this.updated();
}
});
gateway.subscribe(GatewayEventType.ChannelDelete, ({ id }) => {
this.communityIdToSelectedChannel.forEach((channel, communityId) => {
if (channel.id === id) {
if (this.communityIdToSelectedChannel.delete(communityId) && this.value && this.value.id === id) {
this.value = noneChannel;
this.updateSavedMap();
this.updated();
return;
}
}
});
});
gateway.subscribe(GatewayEventType.ChannelUpdate, (data) => {
if (this.value && this.value.id === data.id) {
this.value = data;
this.communityIdToSelectedChannel.set(selectedCommunity.value.id, data);
this.updateSavedMap();
this.updated();
}
});
}
updateSavedMap() {
if (getItem("ui:stateful:presistSelection")) {
const value = {};
this.communityIdToSelectedChannel.forEach((channel, communityId) => {
if (!channel || channel.id === -1) return;
value[communityId] = channel.id;
});
setItem("state:selectedChannels", value);
}
}
set(value) {
if (value === this.value)
return;
this.value = value;
this.communityIdToSelectedChannel.set(selectedCommunity.value.id, value);
this.updateSavedMap();
this.updated();
}
}
class SelectedCommunityStore extends Store {
constructor() {
super({ id: -1, name: "none", creator_id: -1, created_at: 0, avatar: null }, "SelectedCommunityStore");
gateway.subscribe(GatewayEventType.Ready, ({ communities }) => {
if (getItem("ui:stateful:presistSelection")) {
this.value.id = getItem("state:openCommunityId");
}
if (communities.length > 0 && this.value.id !== -1) {
const index = communities.findIndex(e => e.id === this.value.id);
if (index !== -1) {
this.set(communities[index]);
}
}
});
gateway.subscribe(GatewayEventType.CommunityDelete, ({ id }) => {
if (this.value.id === id) {
this.clear();
}
});
gateway.subscribe(GatewayEventType.CommunityUpdate, (data) => {
if (data.id === this.value.id) {
this.value = data;
this.updated();
}
});
this.on(value => {
if (getItem("ui:stateful:presistSelection")) {
setItem("state:openCommunityId", value.id);
}
});
}
clear() {
this.value = { id: -1, name: "none", creator_id: -1, created_at: 0, avatar: null };
this.updated();
}
}
export const selectedCommunity = new SelectedCommunityStore();
export const selectedChannel = new SelectedChannelStore();
export const showSidebar = new Store(true, "showSidebar"); export const showSidebar = new Store(true, "showSidebar");
export const showPresenceSidebar = new Store(false, "showPresenceSidebar"); export const showPresenceSidebar = new Store(false, "showPresenceSidebar");
export const smallViewport = new Store(false, "smallViewport"); export const smallViewport = new Store(false, "smallViewport");
@ -680,6 +820,7 @@ export const showChannelView = new Store(true, "showChannelView");
export const theme = new StorageItemStore("ui:theme"); export const theme = new StorageItemStore("ui:theme");
export const doAnimations = new StorageItemStore("ui:doAnimations"); export const doAnimations = new StorageItemStore("ui:doAnimations");
export const channels = new ChannelsStore(); export const channels = new ChannelsStore();
export const communities = new CommunitiesStore();
export const gatewayStatus = new GatewayStatusStore(); export const gatewayStatus = new GatewayStatusStore();
export const messagesStoreProvider = new MessagesStoreProvider(); export const messagesStoreProvider = new MessagesStoreProvider();
export const userInfoStore = new UserInfoStore(); export const userInfoStore = new UserInfoStore();
@ -741,12 +882,6 @@ export const allStores = {
sendMessageAction, sendMessageAction,
}; };
selectedChannel.on((newSelectedChannel) => {
if (getItem("ui:stateful:presistSelectedChannel")) {
setItem("state:openChannelId", newSelectedChannel.id);
}
});
unreadStore.subscribe(() => { unreadStore.subscribe(() => {
let totalUnreads = 0; let totalUnreads = 0;
unreadStore.value.forEach(count => totalUnreads += count); unreadStore.value.forEach(count => totalUnreads += count);

View file

@ -34,11 +34,10 @@
/* top-level */ /* top-level */
:root { :root {
--background-color-0: hsl(180, 11%, 4%); --background-color-0: #151515;
--background-color-1: hsl(180, 11%, 5%); --background-color-1: #1a1a1a;
--background-color-2: hsl(180, 11%, 8%); --background-color-2: #202020;
--background-color-3: hsl(180, 11%, 11%); --background-color-3: #262626;
--background-color-4: hsl(180, 11%, 15%);
--foreground-color-1: hsl(210, 100%, 100%); --foreground-color-1: hsl(210, 100%, 100%);
--foreground-color-2: hsl(63, 10%, 82%); --foreground-color-2: hsl(63, 10%, 82%);
--foreground-color-3: hsl(63, 2%, 60%); --foreground-color-3: hsl(63, 2%, 60%);
@ -144,6 +143,7 @@ body {
width: 100%; width: 100%;
padding: var(--space-sm); padding: var(--space-sm);
height: 56px; height: 56px;
flex-shrink: 0;
} }
.top-bar-heading { .top-bar-heading {
@ -413,16 +413,22 @@ body {
.h5 {font-size: var(--h5)} .h5 {font-size: var(--h5)}
.text-small {font-size: var(--h6)} .text-small {font-size: var(--h6)}
.text-bold {font-weight: 700} .text-bold {font-weight: 700}
.text-fg-2 {
color: var(--foreground-color-2);
}
.text-fg-3 {
color: var(--foreground-color-3);
}
/* sidebar */ /* sidebar */
.sidebar-container { .sidebar-container {
display: flex; display: flex;
flex-direction: column; flex-direction: row;
background-color: var(--background-color-0); background-color: var(--background-color-0);
height: 100%; height: 100%;
min-width: 255px; min-width: 320px;
max-width: 255px; max-width: 320px;
contain: content; contain: content;
} }
@ -435,6 +441,13 @@ body {
} }
.sidebar { .sidebar {
display: flex;
flex-direction: column;
flex-grow: 1;
background-color: var(--background-color-1);
}
.sidebar-buttons {
width: 100%; width: 100%;
height: 100%; height: 100%;
padding: var(--space-xs); padding: var(--space-xs);
@ -450,7 +463,7 @@ body {
align-items: center; align-items: center;
justify-content: left; justify-content: left;
border: none; border: none;
background-color: var(--background-color-0); background-color: var(--background-color-1);
padding: var(--space-xs); padding: var(--space-xs);
margin-bottom: var(--space-xxs); margin-bottom: var(--space-xxs);
color: var(--foreground-special-color-1); color: var(--foreground-special-color-1);

View file

@ -18,6 +18,7 @@ export const query = function(text: string, params: any[] = [], rejectOnError =
if (rejectOnError) { if (rejectOnError) {
reject(error); reject(error);
} else { } else {
console.error(error);
resolve(null); resolve(null);
} }
}); });

View file

@ -27,10 +27,22 @@ export default async function databaseInit() {
); );
`, `,
` `
ALTER TABLE messages ADD COLUMN nick_username VARCHAR(64) DEFAULT NULL; ALTER TABLE messages ADD COLUMN IF NOT EXISTS nick_username VARCHAR(64) DEFAULT NULL;
`, `,
` `
ALTER TABLE users ADD COLUMN avatar VARCHAR(48) DEFAULT NULL; ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar VARCHAR(48) DEFAULT NULL;
`,
`
CREATE TABLE IF NOT EXISTS communities(
id SERIAL PRIMARY KEY,
name VARCHAR(64) NOT NULL,
owner_id SERIAL REFERENCES users ON DELETE CASCADE,
avatar VARCHAR(48) DEFAULT NULL,
created_at BIGINT
);
`,
`
ALTER TABLE channels ADD COLUMN IF NOT EXISTS community_id INT NULL REFERENCES communities(id) ON DELETE CASCADE;
` `
]; ];

View file

@ -20,6 +20,10 @@ export enum GatewayPayloadType {
PresenceUpdate = 140, PresenceUpdate = 140,
UserUpdate = 150, UserUpdate = 150,
CommunityCreate = 160,
CommunityUpdate,
CommunityDelete,
} }
export enum GatewayPresenceStatus { export enum GatewayPresenceStatus {

View file

@ -381,23 +381,30 @@ export default function(server: Server) {
if ((sessions.length + 1) > MAX_GATEWAY_SESSIONS_PER_USER) { if ((sessions.length + 1) > MAX_GATEWAY_SESSIONS_PER_USER) {
return closeWithError(ws, gatewayErrors.TOO_MANY_SESSIONS); return closeWithError(ws, gatewayErrors.TOO_MANY_SESSIONS);
} }
} else { }
// TODO: each user should have their own list of channels that they join
const [channels, communities] = await Promise.all([
query("SELECT id, name, owner_id, community_id FROM channels ORDER BY id ASC"),
query("SELECT id, name, owner_id, avatar, created_at FROM communities ORDER BY id ASC"),
]);
if (!channels || !communities) {
return closeWithError(ws, gatewayErrors.GOT_NO_DATABASE_DATA);
}
if (!sessions) {
sessions = []; sessions = [];
sessionsByUserId.set(user.id, sessions); sessionsByUserId.set(user.id, sessions);
} }
sessions.push(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");
if (!channels) {
return closeWithError(ws, gatewayErrors.GOT_NO_DATABASE_DATA);
}
clientSubscribe(ws, "*"); clientSubscribe(ws, "*");
channels.rows.forEach(c => { for (let i = 0; i < channels.rows.length; i++) {
clientSubscribe(ws, `channel:${c.id}`); clientSubscribe(ws, `channel:${channels.rows[i].id}`);
}); }
for (let i = 0; i < communities.rows.length; i++) {
clientSubscribe(ws, `community:${communities.rows[i].id}`);
}
ws.state.user = user; ws.state.user = user;
ws.state.bridgesTo = authData.bridgesTo; ws.state.bridgesTo = authData.bridgesTo;
@ -419,6 +426,7 @@ export default function(server: Server) {
d: { d: {
user: getPublicUserObject(ws.state.user), user: getPublicUserObject(ws.state.user),
channels: channels.rows, channels: channels.rows,
communities: communities.rows,
presence: getInitialPresenceEntries() presence: getInitialPresenceEntries()
} }
}); });

View file

@ -10,13 +10,13 @@ import serverConfig from "../../serverconfig";
method( method(
"createChannel", "createChannel",
[withRegexp(channelNameRegex, string(1, 32))], [withRegexp(channelNameRegex, string(1, 32)), withOptional(uint())],
async (user: User, name: string) => { async (user: User, name: string, communityId: number | null) => {
if (serverConfig.superuserRequirement.createChannel && !user.is_superuser) { if (serverConfig.superuserRequirement.createChannel && !user.is_superuser) {
return errors.FORBIDDEN_DUE_TO_MISSING_PERMISSIONS; return errors.FORBIDDEN_DUE_TO_MISSING_PERMISSIONS;
} }
const result = await query("INSERT INTO channels(name, owner_id) VALUES ($1, $2) RETURNING id, name, owner_id", [name, user.id]); const result = await query("INSERT INTO channels(name, owner_id, community_id) VALUES ($1, $2, $3) RETURNING id, name, owner_id, community_id", [name, user.id, communityId]);
if (!result || result.rowCount < 1) { if (!result || result.rowCount < 1) {
return errors.GOT_NO_DATABASE_DATA; return errors.GOT_NO_DATABASE_DATA;
} }
@ -46,23 +46,17 @@ method(
return errors.FORBIDDEN_DUE_TO_MISSING_PERMISSIONS; return errors.FORBIDDEN_DUE_TO_MISSING_PERMISSIONS;
} }
const result = await query("UPDATE channels SET name = $1 WHERE id = $2", [name, id]); const result = await query("UPDATE channels SET name = $1 WHERE id = $2 RETURNING id, name, owner_id, community_id", [name, id]);
if (!result || result.rowCount < 1) { if (!result || result.rowCount < 1) {
return errors.GOT_NO_DATABASE_DATA; return errors.GOT_NO_DATABASE_DATA;
} }
const updatePayload = {
id,
name,
owner_id: permissionCheckResult.rows[0].owner_id
};
dispatch(`channel:${id}`, { dispatch(`channel:${id}`, {
t: GatewayPayloadType.ChannelUpdate, t: GatewayPayloadType.ChannelUpdate,
d: updatePayload d: result.rows[0]
}); });
return updatePayload; return result.rows[0];
} }
); );
@ -96,7 +90,7 @@ method(
"getChannel", "getChannel",
[uint()], [uint()],
async (_user: User, id: number) => { async (_user: User, id: number) => {
const result = await query("SELECT id, name, owner_id FROM channels WHERE id = $1", [id]); const result = await query("SELECT id, name, owner_id, community_id FROM channels WHERE id = $1", [id]);
if (!result || result.rowCount < 1) { if (!result || result.rowCount < 1) {
return errors.NOT_FOUND; return errors.NOT_FOUND;
} }
@ -109,7 +103,7 @@ method(
"getChannels", "getChannels",
[], [],
async (_user: User) => { async (_user: User) => {
const result = await query("SELECT id, name, owner_id FROM channels"); const result = await query("SELECT id, name, owner_id, community_id FROM channels");
return (result && result.rows) ? result.rows : []; return (result && result.rows) ? result.rows : [];
} }

114
src/rpc/apis/communities.ts Normal file
View file

@ -0,0 +1,114 @@
import { channelNameRegex, method, int, string, uint, withRegexp } from "../rpc";
import { query } from "../../database";
import { errors } from "../../errors";
import { dispatch, dispatchChannelSubscribe } from "../../gateway";
import { GatewayPayloadType } from "../../gateway/gatewaypayloadtype";
import serverConfig from "../../serverconfig";
method(
"createCommunity",
[withRegexp(channelNameRegex, string(1, 64))],
async (user: User, name: string) => {
if (serverConfig.superuserRequirement.createChannel && !user.is_superuser) {
return errors.FORBIDDEN_DUE_TO_MISSING_PERMISSIONS;
}
const result = await query("INSERT INTO communities(name, owner_id, created_at) VALUES ($1, $2, $3) RETURNING id, name, owner_id, avatar, created_at", [name, user.id, Date.now().toString()]);
if (!result || result.rowCount < 1) {
return errors.GOT_NO_DATABASE_DATA;
}
dispatch("*", {
t: GatewayPayloadType.CommunityCreate,
d: result.rows[0]
});
dispatchChannelSubscribe("*", `community:${result.rows[0].id}`);
return result.rows[0];
}
);
method(
"updateCommunityName",
[uint(), withRegexp(channelNameRegex, string(1, 32))],
async (user: User, id: number, name: string) => {
const permissionCheckResult = await query("SELECT owner_id FROM communities WHERE id = $1", [id]);
if (!permissionCheckResult || permissionCheckResult.rowCount < 1) {
return errors.NOT_FOUND;
}
if (permissionCheckResult.rows[0].owner_id !== user.id && !user.is_superuser) {
return errors.FORBIDDEN_DUE_TO_MISSING_PERMISSIONS;
}
const result = await query("UPDATE communities SET name = $1 WHERE id = $2 RETURNING id, name, owner_id, avatar, created_at", [name, id]);
if (!result || result.rowCount < 1) {
return errors.GOT_NO_DATABASE_DATA;
}
dispatch(`community:${id}`, {
t: GatewayPayloadType.CommunityUpdate,
d: result.rows[0]
});
return result.rows[0];
}
);
method(
"deleteCommunity",
[uint()],
async (user: User, id: number) => {
const permissionCheckResult = await query("SELECT owner_id FROM communities WHERE id = $1", [id]);
if (!permissionCheckResult || permissionCheckResult.rowCount < 1) {
return errors.NOT_FOUND;
}
if (permissionCheckResult.rows[0].owner_id !== user.id && !user.is_superuser) {
return errors.FORBIDDEN_DUE_TO_MISSING_PERMISSIONS;
}
const result = await query("DELETE FROM communities WHERE id = $1", [id]);
if (!result || result.rowCount < 1) {
return errors.GOT_NO_DATABASE_DATA;
}
dispatch(`community:${id}`, {
t: GatewayPayloadType.CommunityDelete,
d: {id}
});
return {id};
}
);
method(
"getCommunity",
[uint()],
async (_user: User, id: number) => {
const result = await query("SELECT id, name, owner_id, avatar, created_at FROM communities WHERE id = $1", [id]);
if (!result || result.rowCount < 1) {
return errors.NOT_FOUND;
}
return result.rows[0];
}
);
method(
"getCommunities",
[],
async (_user: User) => {
const result = await query("SELECT id, name, owner_id, avatar, created_at FROM communities");
return (result && result.rows) ? result.rows : [];
}
);
method(
"getCommunityChannels",
[uint()],
async (_user: User, id: number) => {
const result = await query("SELECT id, name, owner_id, community_id FROM channels WHERE community_id = $1", [id]);
return (result && result.rows) ? result.rows : [];
}
);

View file

@ -1,8 +1,14 @@
import "./rpc"; import { methodGroup, methodNameToId, methods } from "./rpc";
methodGroup(100);
import "./apis/users"; import "./apis/users";
methodGroup(200);
import "./apis/channels"; import "./apis/channels";
methodGroup(300);
import "./apis/messages"; import "./apis/messages";
import { methodNameToId, methods } from "./rpc"; methodGroup(400);
import "./apis/communities";
console.log("--- begin rpc method map ---") console.log("--- begin rpc method map ---")
const methodMap: any = Object.fromEntries(methodNameToId); const methodMap: any = Object.fromEntries(methodNameToId);

View file

@ -49,6 +49,10 @@ export const methods: Map<number, RPCMethod> = new Map();
export const methodNameToId: Map<string, number> = new Map(); export const methodNameToId: Map<string, number> = new Map();
let lastMethodId = 0; let lastMethodId = 0;
export const methodGroup = (origin: number) => {
lastMethodId = origin;
}
export const method = (name: string, args: RPCArgument[], func: ((...args: any[]) => any), requiresAuthentication: boolean = true) => { export const method = (name: string, args: RPCArgument[], func: ((...args: any[]) => any), requiresAuthentication: boolean = true) => {
let id = lastMethodId++; let id = lastMethodId++;
methodNameToId.set(name, id); methodNameToId.set(name, id);

View file

@ -4,7 +4,7 @@ import rpcRouter from "./routes/api/v1/rpc";
import matrixRouter from "./routes/matrix"; import matrixRouter from "./routes/matrix";
import { errors } from "./errors"; import { errors } from "./errors";
const ENABLE_MATRIX_LAYER = false; const ENABLE_MATRIX_LAYER = true;
export default function(app: Application) { export default function(app: Application) {
app.use(json()); app.use(json());