Compare commits

..

6 commits

Author SHA1 Message Date
hippoz
b100f13640
show detail for rpc bad request 2023-10-04 21:32:46 +03:00
hippoz
1bd0351f1b
support audio attachments 2023-10-01 19:21:05 +03:00
hippoz
dc3191b187
janky fix to ensure consistent sizing of videos 2023-10-01 19:19:03 +03:00
hippoz
08f82f5845
remove deprecated hints in presence payload 2023-10-01 18:40:39 +03:00
hippoz
3740d764a7
clean up modals and remove deprecated UserInfo overlay 2023-10-01 18:37:29 +03:00
hippoz
745e10b6bb
turn most modals into inline modals 2023-10-01 18:22:30 +03:00
29 changed files with 389 additions and 213 deletions

View file

@ -11,11 +11,7 @@ class Bridge {
intents: 0 | (1 << 0) | (1 << 9) | (1 << 15), // GUILDS | GUILD_MESSAGES | MESSAGE_CONTENT intents: 0 | (1 << 0) | (1 << 9) | (1 << 15), // GUILDS | GUILD_MESSAGES | MESSAGE_CONTENT
} }
); );
this.waffleClient = new WaffleClient(WAFFLE_TOKEN, { this.waffleClient = new WaffleClient(WAFFLE_TOKEN, {});
bridgesTo: "Discord Inc. (not affiliated)",
privacy: "https://discord.com/privacy",
terms: "https://discord.com/terms"
});
this.waffleChannelIdToDiscordChannelIdMap = new Map(); this.waffleChannelIdToDiscordChannelIdMap = new Map();
this.discordChannelIdToWaffleChannelIdMap = new Map(); this.discordChannelIdToWaffleChannelIdMap = new Map();

View file

@ -30,7 +30,7 @@
<span class="unread-indicator">{$totalUnreadsStore}</span> <span class="unread-indicator">{$totalUnreadsStore}</span>
{/if} {/if}
<span class="material-icons-outlined" class:tag-icon-has-sidebar-button="{ !$showSidebar }" class:has-unreads="{ $totalUnreadsStore > 0 }">tag</span> <span class="material-icons-outlined" class:tag-icon-has-sidebar-button="{ !$showSidebar }" class:has-unreads="{ $totalUnreadsStore > 0 }">tag</span>
<span class="text-small top-bar-heading accent" on:click="{ () => overlayStore.push(OverlayType.EditChannel, {channel}) }">{ channel.name }</span> <span class="text-small top-bar-heading accent" on:click|stopPropagation="{ ({ pageX, pageY }) => overlayStore.pushAbsolute(OverlayType.EditChannel, pageX, pageY, {channel}) }">{ channel.name }</span>
<div class="right-buttons"> <div class="right-buttons">
<button class="icon-button" on:click="{ () => showPresenceSidebar.set(!showPresenceSidebar.value) }" aria-label="Toggle user list"> <button class="icon-button" on:click="{ () => showPresenceSidebar.set(!showPresenceSidebar.value) }" aria-label="Toggle user list">
<span class="material-icons-outlined">people</span> <span class="material-icons-outlined">people</span>

View file

@ -9,14 +9,14 @@
onSelect(selectedOptionId); onSelect(selectedOptionId);
} }
const optionClick = (option) => { const optionClick = (option, event) => {
if (option) { if (option) {
if (doHighlight) { if (doHighlight) {
selectedOptionId = option.id; selectedOptionId = option.id;
} }
onSelect(selectedOptionId); onSelect(selectedOptionId);
if (option.handle) { if (option.handle) {
option.handle(); option.handle(event);
} }
} }
}; };
@ -45,6 +45,12 @@
font-weight: 500; font-weight: 500;
} }
.smaller button {
font-size: 0.85em;
background-color: transparent;
border: 1px solid var(--background-color-3);
}
button:hover { button:hover {
background-color: var(--background-color-3); background-color: var(--background-color-3);
} }
@ -65,17 +71,13 @@
.has-text .material-icons-outlined { .has-text .material-icons-outlined {
margin-right: var(--space-xxs); margin-right: var(--space-xxs);
} }
.smaller button {
font-size: 0.85em;
}
</style> </style>
<div class:smaller={smaller}> <div class:smaller={smaller}>
{#each options as option (option.id)} {#each options as option (option.id)}
{#if !option.hidden} {#if !option.hidden}
<button class="button" class:selected={ selectedOptionId === option.id } class:has-text={!!option.text} on:click={ optionClick(option) }> <button class="button" class:selected={ selectedOptionId === option.id } class:has-text={!!option.text} on:click|stopPropagation="{ e => optionClick(option, e) }">
{#if option.icon} {#if option.icon}
<span class="material-icons-outlined">{ option.icon }</span> <span class="material-icons-outlined">{ option.icon }</span>
{/if} {/if}

View file

@ -0,0 +1,54 @@
<script>
export let value = "";
export let fieldName = "Text";
export let onSave = (_) => {};
export let disabled = false;
let showEditButton = false;
let currentValue = value;
$: showEditButton = (value !== currentValue);
</script>
<style>
input {
display: block;
font-size: var(--h4);
line-height: inherit;
background-color: transparent;
border-radius: var(--radius-xs);
color: currentColor;
padding: var(--space-xxs);
outline: none;
border: none;
flex: 1;
min-width: 20ch;
}
input:focus-visible {
outline: 1px solid var(--background-color-3);
}
div {
display: flex;
align-items: center;
width: 100%;
}
.icon-button {
margin-left: var(--space-xxs);
}
</style>
<div>
<input bind:value={ currentValue } type="text" name="{ fieldName }" disabled={ disabled }>
{#if !disabled && showEditButton}
<button class="icon-button material-icons-outlined" on:click="{ () => onSave(currentValue) }" aria-label="Save">
edit
</button>
<button class="icon-button material-icons-outlined" on:click="{ () => currentValue = value }" aria-label="Reset">
refresh
</button>
{/if}
</div>

View file

@ -0,0 +1,29 @@
<script>
import { getGrantFor } from "../permissions";
import { methods, remoteCall, responseOk } from "../request";
import UserView from "./UserView.svelte";
export let userId;
export let capabilityType = 0;
export let capabilityResource = null;
export let showBadges = true;
let grant = 0;
let userInfoPromise = remoteCall(methods.getUser, userId).then(response => {
if (!responseOk(response)) {
throw new Error("Failed to get user info");
}
grant = capabilityType && capabilityResource ? getGrantFor(response.data, capabilityType, capabilityResource) : 0;
return response.data;
});
</script>
{#await userInfoPromise}
<span class="text-fg-3 text-bold text-small">Loading...</span>
{:then user}
<UserView size="28" {user} {grant} {showBadges} />
{:catch}
<span class="text-fg-3 text-bold text-small">Failed to load user info</span>
{/await}

View file

@ -92,12 +92,14 @@
<img loading="lazy" decoding="async" width="{ attachment.width }" height="{ attachment.height }" class="attachment media" alt="Attachment" src="{ attachmentUrl(attachment.file) }"> <img loading="lazy" decoding="async" width="{ attachment.width }" height="{ attachment.height }" class="attachment media" alt="Attachment" src="{ attachmentUrl(attachment.file) }">
{:else if renderAs === AttachmentRenderAs.Video} {:else if renderAs === AttachmentRenderAs.Video}
<!-- svelte-ignore a11y-media-has-caption --> <!-- svelte-ignore a11y-media-has-caption -->
<video controls="controls" class="attachment media" src="{ attachmentUrl(attachment.file) }"></video> <video controls="controls" class="attachment media" src="{ attachmentUrl(attachment.file) }" width=400 height=400></video>
{:else if renderAs === AttachmentRenderAs.DownloadableFile} {:else if renderAs === AttachmentRenderAs.DownloadableFile}
<div class="attachment attachment-card"> <div class="attachment attachment-card">
<div class="attachment-filename">{ attachment.file_name }</div> <div class="attachment-filename">{ attachment.file_name }</div>
<a class="icon-button material-icons-outlined small download" href="{ attachmentUrl(attachment.file) }" target="_blank">download</a> <a class="icon-button material-icons-outlined small download" href="{ attachmentUrl(attachment.file) }" target="_blank">download</a>
</div> </div>
{:else if renderAs === AttachmentRenderAs.Audio}
<audio controls="controls" class="attachment media" src="{ attachmentUrl(attachment.file) }"></audio>
{:else} {:else}
<div class="attachment attachment-card">Couldn't render attachment</div> <div class="attachment attachment-card">Couldn't render attachment</div>
{/if} {/if}

View file

@ -23,6 +23,10 @@
min-width: 248px; min-width: 248px;
max-width: 248px; max-width: 248px;
} }
.sidebar-button {
color: var(--foreground-color-2);
}
</style> </style>
<div class="sidebar-container" class:presence-sidebar-limited="{ !$smallViewport }" in:maybeFly="{{ duration: 175, easing: quadInOut, x: 10 }}" out:maybeFlyIf="{{ _condition: !smallViewport.value, duration: 175, easing: quadInOut, x: -10 }}"> <div class="sidebar-container" class:presence-sidebar-limited="{ !$smallViewport }" in:maybeFly="{{ duration: 175, easing: quadInOut, x: 10 }}" out:maybeFlyIf="{{ _condition: !smallViewport.value, duration: 175, easing: quadInOut, x: -10 }}">
@ -33,9 +37,6 @@
{#each $presenceStore as entry (entry.user.id)} {#each $presenceStore as entry (entry.user.id)}
<button class="sidebar-button"> <button class="sidebar-button">
<UserView size={28} user={entry.user}></UserView> <UserView size={28} user={entry.user}></UserView>
{#if entry.bridgesTo || entry.privacy || entry.terms}
<button class="user-badge" on:click={ () => overlayStore.push(OverlayType.UserInfo, { presenceEntry: entry }) }>SERVICE</button>
{/if}
</button> </button>
{/each} {/each}
{#if $smallViewport} {#if $smallViewport}

View file

@ -91,7 +91,7 @@
{/if} {/if}
</button> </button>
{/each} {/each}
<button class="button" on:click={() => overlayStore.push(OverlayType.AddCommunity)}> <button class="button" on:click|stopPropagation={({ pageX, pageY }) => overlayStore.pushAbsolute(OverlayType.AddCommunity, pageX, pageY)}>
<span class="material-icons-outlined">add</span> <span class="material-icons-outlined">add</span>
</button> </button>
<button class="button" on:click={() => overlayStore.push(OverlayType.Settings)}> <button class="button" on:click={() => overlayStore.push(OverlayType.Settings)}>
@ -109,11 +109,9 @@
{#if $unreadStore.get(channel.id)} {#if $unreadStore.get(channel.id)}
<div class="unread-indicator">{ $unreadStore.get(channel.id) }</div> <div class="unread-indicator">{ $unreadStore.get(channel.id) }</div>
{/if} {/if}
{#if $userInfoStore && (channel.owner_id === $userInfoStore.id || $userInfoStore.is_superuser)} <button class="icon-button" on:click|stopPropagation="{ ({ pageX, pageY }) => overlayStore.pushAbsolute(OverlayType.EditChannel, pageX, pageY, { channel }) }" aria-label="Edit Channel">
<button class="icon-button" on:click|stopPropagation="{ () => overlayStore.push(OverlayType.EditChannel, { channel }) }" aria-label="Edit Channel">
<span class="material-icons-outlined">more_vert</span> <span class="material-icons-outlined">more_vert</span>
</button> </button>
{/if}
</div> </div>
</button> </button>
{/each} {/each}

View file

@ -1,4 +1,5 @@
<script> <script>
import { CapabilityType, getGrantFor } from "../permissions";
import { avatarUrl } from "../storage"; import { avatarUrl } from "../storage";
import { OverlayType, overlayStore, userInfoStore } from "../stores"; import { OverlayType, overlayStore, userInfoStore } from "../stores";
import ChipBar from "./ChipBar.svelte"; import ChipBar from "./ChipBar.svelte";
@ -17,18 +18,18 @@
}, },
{ {
id: "EDIT_COMMUNITY", id: "EDIT_COMMUNITY",
icon: "edit", icon: "more_vert",
hidden: !(!isUser && communityLike && $userInfoStore && communityLike.owner_id === $userInfoStore.id), hidden: isUser || !communityLike,
handle() { handle({ pageX, pageY }) {
overlayStore.push(OverlayType.EditCommunity, { community: communityLike }); overlayStore.pushAbsolute(OverlayType.EditCommunity, pageX, pageY, { community: communityLike });
} }
}, },
{ {
id: "NEW_CHANNEL", id: "NEW_CHANNEL",
icon: "add", icon: "add",
hidden: (!$userInfoStore || !$userInfoStore.permissions.create_channel), hidden: (!$userInfoStore || !$userInfoStore.permissions.create_channel),
handle() { handle({ pageX, pageY }) {
overlayStore.push(OverlayType.CreateChannel, { community: isUser ? { id: -1 } : communityLike }); overlayStore.pushAbsolute(OverlayType.CreateChannel, pageX, pageY, { community: isUser ? { id: -1 } : communityLike });
} }
} }
]; ];

View file

@ -1,16 +1,20 @@
<script> <script>
import { CapabilityGrant } from "../permissions";
import { avatarUrl } from "../storage"; import { avatarUrl } from "../storage";
export let user = null; export let user = null;
export let size = 32; export let size = 32;
export let showBadges = true;
export let grant = CapabilityGrant.None;
</script> </script>
<style> <style>
div { div {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; font-size: var(--h6);
font-weight: 600;
} }
img { img {
@ -30,9 +34,19 @@
<div> <div>
{#if user && user.avatar} {#if user && user.avatar}
<img src={avatarUrl(user.avatar, size)} alt=" "> <img src={avatarUrl(user.avatar, size)} width={size} height={size} alt=" ">
{:else} {:else}
<span class="material-icons-outlined circled-icon">alternate_email</span> <span class="material-icons-outlined circled-icon" style="width: {size}px; height: {size}px;">alternate_email</span>
{/if} {/if}
<span class="username">{ user ? user.username : "" }</span> <span class="username">{ user ? user.username : "" }</span>
{#if showBadges}
{#if user.is_superuser}
<span class="user-badge secondary">Superuser</span>
{/if}
{#if grant === CapabilityGrant.ResourceOwner}
<span class="user-badge secondary">Owner</span>
{:else if grant === CapabilityGrant.ResourceManager}
<span class="user-badge secondary">Manager</span>
{/if}
{/if}
</div> </div>

View file

@ -7,6 +7,7 @@
let createButtonEnabled = true; let createButtonEnabled = true;
let response; let response;
export let close = () => {}; export let close = () => {};
export let place = null;
const create = async () => { const create = async () => {
createButtonEnabled = false; createButtonEnabled = false;
@ -17,7 +18,7 @@
}; };
</script> </script>
<Modal {close} enter={create}> <Modal {place} {close} enter={create} showCloseButton={false}>
<span class="h4" slot="header">Create Community</span> <span class="h4" slot="header">Create Community</span>
<svelte:fragment slot="content"> <svelte:fragment slot="content">
@ -30,7 +31,6 @@
</svelte:fragment> </svelte:fragment>
<svelte:fragment slot="footer"> <svelte:fragment slot="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> <button class="button button-accent modal-primary-action" on:click="{ create }" disabled="{ !createButtonEnabled }">Create</button>
</svelte:fragment> </svelte:fragment>
</Modal> </Modal>

View file

@ -38,7 +38,7 @@
} }
</style> </style>
<Modal opaque close={loginInstead} {outroEnd} enter={create}> <Modal opaque {outroEnd} enter={create} showCloseButton={false}>
<span class="h4" slot="header">Create an Account</span> <span class="h4" slot="header">Create an Account</span>
<svelte:fragment slot="content"> <svelte:fragment slot="content">
@ -60,7 +60,7 @@
</svelte:fragment> </svelte:fragment>
<svelte:fragment slot="footer"> <svelte:fragment slot="footer">
<button class="button modal-secondary-action" on:click="{ loginInstead }" disabled="{ !buttonsEnabled }">Log in instead</button> <button class="hyperlink-button padded" on:click="{ loginInstead }" disabled="{ !buttonsEnabled }">Log in instead</button>
<button class="button button-accent modal-primary-action" on:click="{ create }" disabled="{ !buttonsEnabled }">Create</button> <button class="button button-accent modal-primary-action" on:click="{ create }" disabled="{ !buttonsEnabled }">Create</button>
</svelte:fragment> </svelte:fragment>
</Modal> </Modal>

View file

@ -8,6 +8,7 @@
let response; let response;
export let close = () => {}; export let close = () => {};
export let community = null; export let community = null;
export let place = null;
const create = async () => { const create = async () => {
createButtonEnabled = false; createButtonEnabled = false;
@ -18,12 +19,14 @@
}; };
</script> </script>
<Modal {close} enter={create}> <Modal {place} {close} enter={create} showCloseButton={false}>
<svelte:fragment slot="header"> <svelte:fragment slot="header">
<div>
<span class="h4">Create Channel</span> <span class="h4">Create Channel</span>
{#if community.id !== -1} {#if community.id !== -1}
<span class="text-fg-3 text-small">in <span class="text-fg-2">{ community.name }</span></span> <span class="text-fg-3 text-small">in <span class="text-fg-2">{ community.name }</span></span>
{/if} {/if}
</div>
</svelte:fragment> </svelte:fragment>
<svelte:fragment slot="content"> <svelte:fragment slot="content">
@ -36,7 +39,6 @@
</svelte:fragment> </svelte:fragment>
<svelte:fragment slot="footer"> <svelte:fragment slot="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> <button class="button button-accent modal-primary-action" on:click="{ create }" disabled="{ !createButtonEnabled }">Create</button>
</svelte:fragment> </svelte:fragment>
</Modal> </Modal>

View file

@ -1,22 +1,26 @@
<script> <script>
import { overlayStore } from "../../stores"; import { overlayStore, userInfoStore } from "../../stores";
import { getMessageFromResponse, methods, remoteSignal, responseOk } from "../../request"; import { getGrantFor, CapabilityType } from "../../permissions";
import { getMessageFromResponse, methods, remoteCall, remoteSignal, responseOk } from "../../request";
import Modal from "./Modal.svelte"; import Modal from "./Modal.svelte";
import RpcErrorDisplay from "../rpc/RpcErrorDisplay.svelte"; import RpcErrorDisplay from "../rpc/RpcErrorDisplay.svelte";
import EditableText from "../EditableText.svelte";
import FetchedUserView from "../FetchedUserView.svelte";
export let channel; export let channel;
export let place = null;
let channelName = channel.name;
let buttonsEnabled = true; let buttonsEnabled = true;
let response; let response;
$: grant = getGrantFor($userInfoStore, CapabilityType.ManageChannel, channel);
export let close = () => {}; export let close = () => {};
const save = async () => { const save = async (newName) => {
buttonsEnabled = false; buttonsEnabled = false;
response = await remoteSignal(methods.updateChannelName, channel.id, channelName); response = await remoteSignal(methods.updateChannelName, channel.id, newName);
buttonsEnabled = true; buttonsEnabled = true;
if (responseOk(response)) { if (responseOk(response)) {
close(); channel.name = newName;
} }
}; };
const deleteChannel = async () => { const deleteChannel = async () => {
@ -30,26 +34,26 @@
</script> </script>
<style> <style>
.delete-button { .created-by {
color: var(--red-2); margin-bottom: var(--space-md);
} }
</style> </style>
<Modal {close} enter={save}> <Modal {close} {place} enter={save} showCloseButton={false}>
<span class="h4" slot="header">Edit Channel</span> <svelte:fragment slot="header">
<EditableText slot="header" value="{ channel.name }" disabled={ !buttonsEnabled || !grant } onSave={ save }></EditableText>
</svelte:fragment>
<svelte:fragment slot="content"> <svelte:fragment slot="content">
<RpcErrorDisplay response={response} /> <RpcErrorDisplay response={response} />
<RpcErrorDisplay validationIndex={1} response={response} /> <RpcErrorDisplay validationIndex={1} response={response} />
<label class="input-label">
<span>Channel Name</span>
<input class="input full-width" minlength="1" maxlength="32" bind:value={ channelName } />
</label>
</svelte:fragment>
<svelte:fragment slot="footer"> <div class="created-by">
<button class="button modal-secondary-action" on:click="{ close }">Cancel</button> <FetchedUserView userId={ channel.owner_id } capabilityType={ CapabilityType.ManageChannel } capabilityResource={ channel } />
<button class="button modal-secondary-action delete-button" on:click="{ deleteChannel }" disabled="{ !buttonsEnabled }">Delete</button> </div>
<button class="button button-accent modal-primary-action" on:click="{ save }" disabled="{ !buttonsEnabled }">Save</button>
{#if grant}
<button class="hyperlink-button button-danger" on:click="{ deleteChannel }" disabled="{ !buttonsEnabled }">Delete</button>
{/if}
</svelte:fragment> </svelte:fragment>
</Modal> </Modal>

View file

@ -1,22 +1,26 @@
<script> <script>
import { overlayStore } from "../../stores"; import { overlayStore, userInfoStore } from "../../stores";
import { getMessageFromResponse, methods, remoteSignal, responseOk } from "../../request"; import { getGrantFor, CapabilityType } from "../../permissions";
import { getMessageFromResponse, methods, remoteCall, remoteSignal, responseOk } from "../../request";
import Modal from "./Modal.svelte"; import Modal from "./Modal.svelte";
import RpcErrorDisplay from "../rpc/RpcErrorDisplay.svelte"; import RpcErrorDisplay from "../rpc/RpcErrorDisplay.svelte";
import EditableText from "../EditableText.svelte";
import FetchedUserView from "../FetchedUserView.svelte";
export let community; export let community;
export let place = null;
let communityName = community.name;
let buttonsEnabled = true; let buttonsEnabled = true;
let response; let response;
$: grant = getGrantFor($userInfoStore, CapabilityType.ManageCommunity, community);
export let close = () => {}; export let close = () => {};
const save = async () => { const save = async (newName) => {
buttonsEnabled = false; buttonsEnabled = false;
response = await remoteSignal(methods.updateCommunityName, community.id, communityName); response = await remoteSignal(methods.updateCommunityName, community.id, newName);
buttonsEnabled = true; buttonsEnabled = true;
if (responseOk(response)) { if (responseOk(response)) {
close(); community.name = newName;
} }
}; };
const deleteCommunity = async () => { const deleteCommunity = async () => {
@ -30,26 +34,26 @@
</script> </script>
<style> <style>
.delete-button { .created-by {
color: var(--red-2); margin-bottom: var(--space-md);
} }
</style> </style>
<Modal {close} enter={save}> <Modal {close} {place} enter={save} showCloseButton={false}>
<span class="h4" slot="header">Edit Community</span> <svelte:fragment slot="header">
<EditableText slot="header" value="{ community.name }" disabled={ !buttonsEnabled || !grant } onSave={ save }></EditableText>
</svelte:fragment>
<svelte:fragment slot="content"> <svelte:fragment slot="content">
<RpcErrorDisplay response={response} /> <RpcErrorDisplay response={response} />
<RpcErrorDisplay validationIndex={1} response={response} /> <RpcErrorDisplay validationIndex={1} response={response} />
<label class="input-label">
<span>Community Name</span>
<input class="input full-width" minlength="1" maxlength="32" bind:value={ communityName } />
</label>
</svelte:fragment>
<svelte:fragment slot="footer"> <div class="created-by">
<button class="button modal-secondary-action" on:click="{ close }">Cancel</button> <FetchedUserView userId={ community.owner_id } capabilityType={ CapabilityType.ManageCommunity } capabilityResource={ community } />
<button class="button modal-secondary-action delete-button" on:click="{ deleteCommunity }" disabled="{ !buttonsEnabled }">Delete</button> </div>
<button class="button button-accent modal-primary-action" on:click="{ save }" disabled="{ !buttonsEnabled }">Save</button>
{#if grant}
<button class="hyperlink-button button-danger" on:click="{ deleteCommunity }" disabled="{ !buttonsEnabled }">Delete</button>
{/if}
</svelte:fragment> </svelte:fragment>
</Modal> </Modal>

View file

@ -28,12 +28,6 @@
}; };
</script> </script>
<style>
.delete-button {
color: var(--red-2);
}
</style>
<Modal {close} enter={save}> <Modal {close} enter={save}>
<span class="h4" slot="header">Edit Message</span> <span class="h4" slot="header">Edit Message</span>
@ -47,8 +41,7 @@
</svelte:fragment> </svelte:fragment>
<svelte:fragment slot="footer"> <svelte:fragment slot="footer">
<button class="button modal-secondary-action" on:click="{ close }">Cancel</button> <button class="hyperlink-button padded button-danger" on:click="{ deleteMessage }" disabled="{ !buttonsEnabled }">Delete</button>
<button class="button modal-secondary-action delete-button" on:click="{ deleteMessage }" disabled="{ !buttonsEnabled }">Delete</button>
<button class="button button-accent modal-primary-action" on:click="{ save }" disabled="{ !buttonsEnabled }">Save</button> <button class="button button-accent modal-primary-action" on:click="{ save }" disabled="{ !buttonsEnabled }">Save</button>
</svelte:fragment> </svelte:fragment>
</Modal> </Modal>

View file

@ -39,7 +39,7 @@
} }
</style> </style>
<Modal opaque close={createAccountInstead} {outroEnd} enter={login}> <Modal opaque {outroEnd} enter={login} showCloseButton={false}>
<span class="h4" slot="header">Welcome back!</span> <span class="h4" slot="header">Welcome back!</span>
<svelte:fragment slot="content"> <svelte:fragment slot="content">
@ -61,7 +61,7 @@
</svelte:fragment> </svelte:fragment>
<svelte:fragment slot="footer"> <svelte:fragment slot="footer">
<button class="button modal-secondary-action" on:click="{ createAccountInstead }" disabled="{ !buttonsEnabled }">Create an account instead</button> <button class="hyperlink-button padded" on:click="{ createAccountInstead }" disabled="{ !buttonsEnabled }">Create an account instead</button>
<button class="button button-accent modal-primary-action" on:click="{ login }" disabled="{ !buttonsEnabled }">Log In</button> <button class="button button-accent modal-primary-action" on:click="{ login }" disabled="{ !buttonsEnabled }">Log In</button>
</svelte:fragment> </svelte:fragment>
</Modal> </Modal>

View file

@ -1,15 +1,20 @@
<script> <script>
import { onMount } from "svelte"; import { onMount } from "svelte";
import { maybeModalFade, maybeModalFadeIf, maybeModalScale } from "../../animations"; import { maybeModalFade, maybeModalFadeIf, maybeModalScale } from "../../animations";
import { OverlayType, overlayStore } from "../../stores";
export let close = () => {}; export let close = () => {};
export let enter = () => {}; export let enter = () => {};
export let outroEnd = () => {}; export let outroEnd = () => {};
export let className = ""; export let className = "";
export let opaque = false; export let opaque = false;
export let place = null;
export let showCloseButton = true;
let modal; let modal;
let blur = false; let blur = false;
$: backdropStyle = !!place ? `top: ${place.y || 0}px; left: ${place.x || 0}px;` : "";
const onKeydown = ({ code }) => { const onKeydown = ({ code }) => {
if (code === "Enter") { if (code === "Enter") {
enter(); enter();
@ -31,14 +36,25 @@
}); });
</script> </script>
<style>
.close-modal {
margin-left: auto;
}
</style>
<!-- svelte-ignore a11y-click-events-have-key-events --> <!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions --> <!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-no-noninteractive-element-interactions --> <!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<div class="modal-backdrop" class:modal-backdrop-opaque={opaque} class:blur={blur} transition:maybeModalFadeIf="{{ _condition: !opaque }}" on:click="{ close }" on:keydown="{ onKeydown }" on:introend={backdropIntroEnd} on:outrostart={backdropOutroStart}> <div class="modal-backdrop" style="{backdropStyle}" class:positioned={!!place} class:modal-backdrop-opaque={opaque} class:blur={blur} transition:maybeModalFadeIf="{{ _condition: !opaque }}" on:click="{ close }" on:keydown="{ onKeydown }" on:introend={backdropIntroEnd} on:outrostart={backdropOutroStart}>
<div bind:this={modal} role="alertdialog" tabindex="-1" aria-modal="true" class={className + " modal"} transition:maybeModalScale on:click|stopPropagation on:outroend={outroEnd}> <div bind:this={modal} class:positioned={!!place} role="alertdialog" tabindex="-1" aria-modal="true" class={className + " modal"} transition:maybeModalScale on:click|stopPropagation on:outroend={outroEnd}>
{#if $$slots.header} {#if $$slots.header}
<div class="modal-header"> <div class="modal-header">
<slot name="header" /> <slot name="header" />
{#if showCloseButton}
<button class="icon-button material-icons-outlined close-modal" on:click="{ close }" aria-label="Close">
close
</button>
{/if}
</div> </div>
{/if} {/if}

View file

@ -1,5 +1,5 @@
<script> <script>
import { overlayStore } from "../../stores"; import { overlayStore, smallViewport } from "../../stores";
import EditChannel from "./EditChannel.svelte"; import EditChannel from "./EditChannel.svelte";
import CreateChannel from "./CreateChannel.svelte"; import CreateChannel from "./CreateChannel.svelte";
@ -9,7 +9,6 @@
import EditMessage from "./EditMessage.svelte"; import EditMessage from "./EditMessage.svelte";
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 AddCommunity from "./AddCommunity.svelte"; import AddCommunity from "./AddCommunity.svelte";
import EditCommunity from "./EditCommunity.svelte"; import EditCommunity from "./EditCommunity.svelte";
@ -22,12 +21,11 @@
5: EditMessage, 5: EditMessage,
6: Settings, 6: Settings,
7: Prompt, 7: Prompt,
8: UserInfo, 8: AddCommunity,
9: AddCommunity, 9: EditCommunity,
10: EditCommunity,
}; };
</script> </script>
{#each $overlayStore as overlay (overlay.id)} {#each $overlayStore as overlay (overlay.id)}
<svelte:component this={ OverlayComponent[overlay.type] } {...overlay.props} /> <svelte:component this={ OverlayComponent[overlay.type] } {...overlay.props} place={ $smallViewport ? undefined : overlay.props.place } />
{/each} {/each}

View file

@ -30,7 +30,6 @@
</label> </label>
<svelte:fragment slot="footer"> <svelte:fragment slot="footer">
<button class="button modal-secondary-action" on:click="{ closePrompt }">Cancel</button>
<button class="button button-accent modal-primary-action" on:click="{ save }" disabled="{ !buttonsEnabled }">Submit</button> <button class="button button-accent modal-primary-action" on:click="{ save }" disabled="{ !buttonsEnabled }">Submit</button>
</svelte:fragment> </svelte:fragment>
</Modal> </Modal>

View file

@ -1,61 +0,0 @@
<script>
import { maybeModalFade, maybeModalScale } from "../../animations";
export let presenceEntry;
export let close = () => {};
</script>
<style>
.user-info-modal {
max-width: 560px;
}
.user-info-row {
display: flex;
align-items: flex-start;
margin-bottom: var(--space-sm);
}
.user-info-row .material-icons-outlined {
margin-right: var(--space-xs);
}
</style>
<div class="modal-backdrop" transition:maybeModalFade on:click="{ close }">
<div class="modal user-info-modal" transition:maybeModalScale on:click|stopPropagation>
<div class="modal-header">
<span class="h4">{ presenceEntry.user.username }</span>
</div>
<div class="modal-content">
{#if presenceEntry.bridgesTo}
<div class="user-info-row">
<span class="material-icons-outlined">cloud_sync</span>
<span>This application may send messages and metadata to <b>{presenceEntry.bridgesTo}</b></span>
</div>
{/if}
{#if presenceEntry.privacy}
<div class="user-info-row">
<span class="material-icons-outlined">policy</span>
<span>Data accessible by this application is processed in accordance with their Privacy Policy: <b>{ presenceEntry.privacy }</b></span>
</div>
{/if}
{#if presenceEntry.terms}
<div class="user-info-row">
<span class="material-icons-outlined">gavel</span>
<span>The Terms of Service of this application can be found at: <b>{ presenceEntry.terms }</b></span>
</div>
{/if}
{#if presenceEntry.bridgesTo || presenceEntry.privacy || presenceEntry.terms}
<div class="user-info-row">
<span class="material-icons-outlined">shield</span>
<span>You may be able to opt out of the above</span>
</div>
{/if}
</div>
<div class="modal-footer">
<button class="button modal-secondary-action" on:click="{ close }">Cancel</button>
</div>
</div>
</div>

View file

@ -0,0 +1,49 @@
export const CapabilityGrant = {
None: 0,
ResourceOwner: 1,
ResourceManager: 2,
GlobalSuperuser: 3
};
export const CapabilityType = {
None: 0,
ManageChannel: 1,
ManageCommunity: 2,
ManageMessage: 3
};
export function getGrantFor(user, capability, resource) {
let resourceOwnerId = -1;
switch (capability) {
case CapabilityType.ManageChannel: {
resourceOwnerId = resource.owner_id;
break;
}
case CapabilityType.ManageCommunity: {
resourceOwnerId = resource.owner_id;
break;
}
case CapabilityType.ManageMessage: {
resourceOwnerId = resource.author_id;
break;
}
default: {
return CapabilityGrant.None;
}
}
if (user && user.id === resourceOwnerId) {
return CapabilityGrant.ResourceOwner;
}
if (user && user.is_superuser) {
return CapabilityGrant.GlobalSuperuser;
}
return CapabilityGrant.None;
}

View file

@ -10,6 +10,7 @@ export const methods = {
getUserSelf: withCacheable(method(102, true)), getUserSelf: withCacheable(method(102, true)),
promoteUserSelf: method(103, true), promoteUserSelf: method(103, true),
putUserAvatar: method(104, true), putUserAvatar: method(104, true),
getUser: withCacheable(method(105, true)),
createChannel: method(200, true), createChannel: method(200, true),
updateChannelName: method(201, true), updateChannelName: method(201, true),
deleteChannel: method(202, true), deleteChannel: method(202, true),
@ -73,6 +74,10 @@ export function getErrorFromResponse(response) {
return { message: rpcErrorMessage, validationErrors: response.data.errors }; return { message: rpcErrorMessage, validationErrors: response.data.errors };
} }
if (response.data.code === RPCError.BAD_REQUEST.code) {
return { message: rpcErrorMessage + (response.data.detail ? `: ${response.data.detail}` : "") };
}
return { message: rpcErrorMessage }; return { message: rpcErrorMessage };
} }

View file

@ -1,5 +1,5 @@
import { getItem } from "./storage"; import { getItem } from "./storage";
import { showSidebar, smallViewport, theme, usesKeyboardNavigation } from "./stores"; import { showSidebar, smallViewport, theme, usesKeyboardNavigation, overlayStore } from "./stores";
function initViewportSizeHandler() { function initViewportSizeHandler() {
const root = document.querySelector(':root'); const root = document.querySelector(':root');
@ -33,22 +33,30 @@ function updateTheme(themeName) {
classes.add(`theme--${themeName}`); classes.add(`theme--${themeName}`);
} }
function initKeyboardNavigationDetection() { export function initResponsiveHandlers() {
// Keyboard navigation detection
document.addEventListener("keydown", ({ key }) => { document.addEventListener("keydown", ({ key }) => {
if (key === "Tab") { if (key === "Tab") {
usesKeyboardNavigation.set(true); usesKeyboardNavigation.set(true);
} }
}); });
document.addEventListener("click", e => { const keyboardClickHandler = (e) => {
// screenX and screenY are 0 when a user presses enter for navigation // screenX and screenY are 0 when a user presses enter for navigation
usesKeyboardNavigation.set(!e.screenX && !e.screenY); usesKeyboardNavigation.set(!e.screenX && !e.screenY);
}); };
}
const overlayClickHandler = () => {
overlayStore.closeAllAbsolute();
};
document.addEventListener("click", e => {
keyboardClickHandler(e);
overlayClickHandler();
});
export function initResponsiveHandlers() {
initViewportSizeHandler(); initViewportSizeHandler();
initKeyboardNavigationDetection();
const mediaQuery = window.matchMedia('(min-width: 768px)'); const mediaQuery = window.matchMedia('(min-width: 768px)');

View file

@ -1,5 +1,6 @@
import gateway, { GatewayEventType, GatewayPayloadType, GatewayPresenceStatus } from "./gateway"; import gateway, { GatewayEventType, GatewayPayloadType, GatewayPresenceStatus } from "./gateway";
import logger from "./logging"; import logger from "./logging";
import { CapabilityType, getGrantFor } from "./permissions";
import { getMessageFromResponse, methods, remoteCall, remoteSignal, responseOk } from "./request"; import { getMessageFromResponse, methods, remoteCall, remoteSignal, responseOk } from "./request";
import { getItem, setItem } from "./storage"; import { getItem, setItem } from "./storage";
@ -236,7 +237,7 @@ class MessageStore extends Store {
if (userInfoStore.value && message.content.includes("@" + userInfoStore.value.username)) { if (userInfoStore.value && message.content.includes("@" + userInfoStore.value.username)) {
message._mentions = true; message._mentions = true;
} }
if (userInfoStore.value && (message.author_id === userInfoStore.value.id || userInfoStore.value.is_superuser)) { if (getGrantFor(userInfoStore.value, CapabilityType.ManageMessage, message)) {
message._editable = true; message._editable = true;
} }
if (previous && (message._createdAtDate.getTime() - previous._createdAtDate.getTime()) <= 100 * 1000 && message.author_id === previous.author_id && message._effectiveAuthor === previous._effectiveAuthor && message._viaBadge === previous._viaBadge) { if (previous && (message._createdAtDate.getTime() - previous._createdAtDate.getTime()) <= 100 * 1000 && message.author_id === previous.author_id && message._effectiveAuthor === previous._effectiveAuthor && message._viaBadge === previous._viaBadge) {
@ -400,19 +401,39 @@ export const OverlayType = {
EditMessage: 5, EditMessage: 5,
Settings: 6, Settings: 6,
Prompt: 7, Prompt: 7,
UserInfo: 8, AddCommunity: 8,
AddCommunity: 9, EditCommunity: 9
EditCommunity: 10
}; };
class OverlayStore extends Store { class OverlayStore extends Store {
constructor() { constructor() {
super([], "OverlayStore"); super([], "OverlayStore");
} }
closeAllAbsolute() {
const toRemove = [];
for (let i = 0; i < this.value.length; i++) {
const e = this.value[i];
if (e.props && e.props.place) {
toRemove.push(e.id);
}
}
toRemove.forEach(id => this.popId(id));
}
isOverlayPresent() { isOverlayPresent() {
return !!this.value.length; return !!this.value.length;
} }
pushAbsolute(type, x=0, y=0, props={}) {
this.closeAllAbsolute();
return this.push(type, {
...props,
place: {
x, y
}
});
}
push(type, props={}) { push(type, props={}) {
const id = Math.floor(Math.random() * 9999999); const id = Math.floor(Math.random() * 9999999);
@ -581,9 +602,6 @@ class PresenceStore extends Store {
// don't need to push the status, since we remove offline members from the presence list // don't need to push the status, since we remove offline members from the presence list
this.value.push({ this.value.push({
user: entry.user, user: entry.user,
bridgesTo: entry.bridgesTo,
privacy: entry.privacy,
terms: entry.terms
}); });
} }
}); });

View file

@ -178,6 +178,15 @@ body {
backdrop-filter: blur(1.5px); backdrop-filter: blur(1.5px);
} }
.modal-backdrop.positioned {
display: block;
width: fit-content;
height: fit-content;
background-color: transparent;
backdrop-filter: unset;
contain: content;
}
.modal-backdrop-opaque { .modal-backdrop-opaque {
background-color: var(--background-color-1); background-color: var(--background-color-1);
backdrop-filter: unset; backdrop-filter: unset;
@ -193,6 +202,8 @@ body {
} }
.modal-header { .modal-header {
display: flex;
align-items: center;
font-weight: 650; font-weight: 650;
padding: var(--space-md); padding: var(--space-md);
} }
@ -203,25 +214,38 @@ body {
} }
.modal-footer { .modal-footer {
display: flex;
align-items: center;
margin-top: auto; margin-top: auto;
padding: var(--space-norm); padding: var(--space-norm);
background-color: var(--background-color-1); padding-top: var(--space-xs);
background-color: transparent;
border-bottom-right-radius: var(--radius-lg); border-bottom-right-radius: var(--radius-lg);
border-bottom-left-radius: var(--radius-lg); border-bottom-left-radius: var(--radius-lg);
} }
.modal.positioned {
background-color: hsla(0, 0%, 8%, 85%);
backdrop-filter: blur(1.5px);
width: 325px;
}
.theme--light .modal.positioned {
background-color: var(--background-color-0);
}
.modal.positioned .modal-footer {
padding-top: var(--space-xxs);
}
.modal-primary-action { .modal-primary-action {
float: right; margin-left: auto;
} }
.modal-secondary-action { .modal-secondary-action {
float: left; float: left;
} }
.modal-backdrop-opaque .modal .modal-footer {
background-color: var(--background-color-3);
}
@media screen and (max-width: 768px) { @media screen and (max-width: 768px) {
.modal { .modal {
width: 100%; width: 100%;
@ -272,6 +296,27 @@ body {
/* button */ /* button */
.hyperlink-button {
color: var(--foreground-color-1);
background: none;
border: none;
font: inherit;
user-select: none;
font-weight: 650;
cursor: pointer;
font-size: var(--h6);
}
.hyperlink-button.padded {
padding: 0.85em;
padding-top: 0.65em;
padding-bottom: 0.65em;
}
.hyperlink-button:hover {
color: var(--foreground-color-0);
}
.button { .button {
color: var(--foreground-color-1); color: var(--foreground-color-1);
background: none; background: none;
@ -283,7 +328,7 @@ body {
border-radius: 9999px; border-radius: 9999px;
font: inherit; font: inherit;
user-select: none; user-select: none;
font-weight: 550; font-weight: 600;
} }
.button:hover { .button:hover {
@ -292,34 +337,25 @@ body {
.button-accent { .button-accent {
color: var(--colored-element-text-color); color: var(--colored-element-text-color);
background-color: var(--purple-2);
}
.button-accent:hover {
background-color: var(--purple-1); background-color: var(--purple-1);
} }
.button-accent:disabled { .button-accent:hover {
background-color: var(--purple-2); background-color: var(--purple-2);
} }
.button-red { .button-accent:disabled {
color: var(--colored-element-text-color); background-color: var(--purple-1);
background-color: var(--red-2);
}
.button-red:hover {
background-color: var(--red-1);
}
.button-red:disabled {
background-color: var(--red-2);
} }
.button-danger { .button-danger {
color: var(--red-2); color: var(--red-2);
} }
.button-danger:hover {
color: var(--red-1);
}
/* icon buttons */ /* icon buttons */
.icon-button { .icon-button {
@ -531,7 +567,11 @@ body {
border-radius: 9999px; border-radius: 9999px;
font-size: x-small; font-size: x-small;
margin-left: var(--space-sm); margin-left: var(--space-sm);
cursor: pointer; }
.user-badge.secondary {
background-color: var(--background-color-1);
border: 1px solid var(--background-color-2);
} }
/* util */ /* util */

View file

@ -167,9 +167,6 @@ export class GatewayClient {
lastAliveCheck: number; lastAliveCheck: number;
clientDispatchChannels: Set<string>; clientDispatchChannels: Set<string>;
messagesSinceLastCheck: number; messagesSinceLastCheck: number;
bridgesTo?: string;
privacy?: string;
terms?: string;
constructor(ws: WebSocket) { constructor(ws: WebSocket) {
this.ws = ws; this.ws = ws;
@ -179,9 +176,6 @@ export class GatewayClient {
this.lastAliveCheck = performance.now(); this.lastAliveCheck = performance.now();
this.clientDispatchChannels = new Set(); this.clientDispatchChannels = new Set();
this.messagesSinceLastCheck = 0; this.messagesSinceLastCheck = 0;
this.bridgesTo = undefined;
this.privacy = undefined;
this.terms = undefined;
gatewayClients.add(this); gatewayClients.add(this);
this.ws.on("close", this.handleClose.bind(this)); this.ws.on("close", this.handleClose.bind(this));
@ -243,9 +237,6 @@ export class GatewayClient {
avatar: this.user.avatar avatar: this.user.avatar
}, },
status, status,
bridgesTo: this.bridgesTo,
privacy: this.privacy,
terms: this.terms,
}; };
} }

View file

@ -2,7 +2,7 @@ import { errors } from "../../errors";
import { query } from "../../database"; import { query } from "../../database";
import { compare, hash } from "bcrypt"; import { compare, hash } from "bcrypt";
import { getPublicUserObject, loginAttempt } from "../../auth"; import { getPublicUserObject, loginAttempt } from "../../auth";
import { RPCContext, bufferSlice, method, methodButWarningDoesNotAuthenticate, string, usernameRegex, withRegexp } from "./../rpc"; import { RPCContext, bufferSlice, method, methodButWarningDoesNotAuthenticate, string, uint, usernameRegex, withRegexp } from "./../rpc";
import sharp from "sharp"; import sharp from "sharp";
import { randomBytes } from "crypto"; import { randomBytes } from "crypto";
import { unlink } from "fs/promises"; import { unlink } from "fs/promises";
@ -165,3 +165,19 @@ method(
return filenames; return filenames;
} }
); );
method(
"getUser",
[uint("id", "ID of the user to get")],
async (_: User, id: number) => {
const getUserResult = await query("SELECT * FROM users WHERE id = $1", [id]);
if (!getUserResult) {
return errors.GOT_NO_DATABASE_DATA;
}
if (getUserResult.rowCount < 1) {
return errors.NOT_FOUND;
}
return getPublicUserObject(getUserResult.rows[0]);
}
);

View file

@ -6,8 +6,5 @@ export interface GatewayPresenceEntry {
id: number, id: number,
avatar: string | null avatar: string | null
}, },
bridgesTo?: string,
privacy?: string,
terms?: string,
status: GatewayPresenceStatus status: GatewayPresenceStatus
} }