Standardise usage of user avatars and colours across the entire platform

This commit is contained in:
Andrew Kingston 2023-05-26 09:24:53 +01:00
parent f246a982db
commit cc7df474c9
22 changed files with 254 additions and 296 deletions

View File

@ -13,10 +13,12 @@
export let url = "" export let url = ""
export let disabled = false export let disabled = false
export let initials = "JD" export let initials = "JD"
export let color = null
const DefaultColor = "#3aab87" const DefaultColor = "#3aab87"
$: color = getColor(initials) $: avatarColor = color || getColor(initials)
$: style = getStyle(size, avatarColor)
const getColor = initials => { const getColor = initials => {
if (!initials?.length) { if (!initials?.length) {
@ -26,6 +28,12 @@
const hue = ((code % 26) / 26) * 360 const hue = ((code % 26) / 26) * 360
return `hsl(${hue}, 50%, 50%)` return `hsl(${hue}, 50%, 50%)`
} }
const getStyle = (sizeKey, color) => {
const size = `var(${sizes.get(sizeKey)})`
const fontSize = `calc(${size} / 2)`
return `width:${size}; height:${size}; font-size:${fontSize}; background:${color};`
}
</script> </script>
{#if url} {#if url}
@ -37,13 +45,7 @@
style="width: var({sizes.get(size)}); height: var({sizes.get(size)});" style="width: var({sizes.get(size)}); height: var({sizes.get(size)});"
/> />
{:else} {:else}
<div <div class="spectrum-Avatar" class:is-disabled={disabled} {style}>
class="spectrum-Avatar"
class:is-disabled={disabled}
style="width: var({sizes.get(size)}); height: var({sizes.get(
size
)}); font-size: calc(var({sizes.get(size)}) / 2); background: {color};"
>
{initials || ""} {initials || ""}
</div> </div>
{/if} {/if}

View File

@ -9,11 +9,11 @@ export const getUserStore = () => {
const updateUser = user => { const updateUser = user => {
const $users = get(store) const $users = get(store)
if (!$users.some(x => x.id === user.id)) { if (!$users.some(x => x.sessionId === user.sessionId)) {
store.set([...$users, user]) store.set([...$users, user])
} else { } else {
store.update(state => { store.update(state => {
const index = state.findIndex(x => x.id === user.id) const index = state.findIndex(x => x.sessionId === user.sessionId)
state[index] = user state[index] = user
return state.slice() return state.slice()
}) })
@ -22,7 +22,7 @@ export const getUserStore = () => {
const removeUser = user => { const removeUser = user => {
store.update(state => { store.update(state => {
return state.filter(x => x.id !== user.id) return state.filter(x => x.sessionId !== user.sessionId)
}) })
} }

View File

@ -1,143 +0,0 @@
<script>
import {
Button,
ButtonGroup,
ModalContent,
Modal,
notifications,
ProgressCircle,
Layout,
Body,
Icon,
} from "@budibase/bbui"
import { auth, apps } from "stores/portal"
import { processStringSync } from "@budibase/string-templates"
import { API } from "api"
export let app
export let buttonSize = "M"
let APP_DEV_LOCK_SECONDS = 600 //common area for this?
let appLockModal
let processing = false
$: lockedBy = app?.lockedBy
$: lockedByYou = $auth.user.email === lockedBy?.email
$: lockIdentifer = `${
lockedBy && lockedBy.firstName ? lockedBy?.firstName : lockedBy?.email
}`
$: lockedByHeading =
lockedBy && lockedByYou ? "Locked by you" : `Locked by ${lockIdentifer}`
const getExpiryDuration = app => {
if (!app?.lockedBy?.lockedAt) {
return -1
}
let expiry =
new Date(app.lockedBy.lockedAt).getTime() + APP_DEV_LOCK_SECONDS * 1000
return expiry - new Date().getTime()
}
const releaseLock = async () => {
processing = true
if (app) {
try {
await API.releaseAppLock(app.devId)
await apps.load()
notifications.success("Lock released successfully")
} catch (err) {
notifications.error("Error releasing lock")
}
} else {
notifications.error("No application is selected")
}
processing = false
}
</script>
{#if lockedBy}
<div class="lock-status">
<Icon
name="User"
hoverable
size={buttonSize}
on:click={e => {
e.stopPropagation()
appLockModal.show()
}}
/>
</div>
{/if}
<Modal bind:this={appLockModal}>
<ModalContent
title={lockedByHeading}
showConfirmButton={false}
showCancelButton={false}
>
<Layout noPadding>
<Body size="S">
Apps are locked to prevent work being lost from overlapping changes
between your team.
</Body>
{#if lockedByYou && getExpiryDuration(app) > 0}
<span class="lock-expiry-body">
{processStringSync(
"This lock will expire in {{ duration time 'millisecond' }} from now.",
{
time: getExpiryDuration(app),
}
)}
</span>
{/if}
<div class="lock-modal-actions">
<ButtonGroup>
<Button
secondary
quiet={lockedBy && lockedByYou}
disabled={processing}
on:click={() => {
appLockModal.hide()
}}
>
<span class="cancel"
>{lockedBy && !lockedByYou ? "Done" : "Cancel"}</span
>
</Button>
{#if lockedByYou}
<Button
cta
disabled={processing}
on:click={() => {
releaseLock()
appLockModal.hide()
}}
>
{#if processing}
<ProgressCircle overBackground={true} size="S" />
{:else}
<span class="unlock">Release Lock</span>
{/if}
</Button>
{/if}
</ButtonGroup>
</div>
</Layout>
</ModalContent>
</Modal>
<style>
.lock-modal-actions {
display: flex;
justify-content: flex-end;
margin-top: var(--spacing-l);
gap: var(--spacing-xl);
}
.lock-status {
display: flex;
gap: var(--spacing-s);
max-width: 175px;
}
</style>

View File

@ -1,13 +1,16 @@
<script> <script>
import { Heading, Body, Button, Icon, notifications } from "@budibase/bbui" import { Heading, Body, Button, Icon, Tooltip } from "@budibase/bbui"
import AppLockModal from "../common/AppLockModal.svelte"
import { processStringSync } from "@budibase/string-templates" import { processStringSync } from "@budibase/string-templates"
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import { helpers } from "@budibase/shared-core"
import { UserAvatar } from "@budibase/frontend-core"
export let app export let app
export let lockedAction export let lockedAction
$: editing = app?.lockedBy != null
$: initials = helpers.getUserInitials(app?.lockedBy)
const handleDefaultClick = () => { const handleDefaultClick = () => {
if (window.innerWidth < 640) { if (window.innerWidth < 640) {
goToOverview() goToOverview()
@ -38,7 +41,10 @@
</div> </div>
<div class="updated"> <div class="updated">
{#if app.updatedAt} {#if editing}
Currently editing
<UserAvatar user={app.lockedBy} />
{:else if app.updatedAt}
{processStringSync("Updated {{ duration time 'millisecond' }} ago", { {processStringSync("Updated {{ duration time 'millisecond' }} ago", {
time: new Date().getTime() - new Date(app.updatedAt).getTime(), time: new Date().getTime() - new Date(app.updatedAt).getTime(),
})} })}
@ -53,12 +59,12 @@
</div> </div>
<div class="app-row-actions"> <div class="app-row-actions">
<AppLockModal {app} buttonSize="M" /> <Button size="S" secondary on:click={lockedAction || goToOverview}>
<Button size="S" secondary on:click={lockedAction || goToOverview} Manage
>Manage</Button </Button>
> <Button size="S" primary on:click={lockedAction || goToBuilder}>
<Button size="S" primary on:click={lockedAction || goToBuilder}>Edit</Button Edit
> </Button>
</div> </div>
</div> </div>
@ -81,6 +87,9 @@
.updated { .updated {
color: var(--spectrum-global-color-gray-700); color: var(--spectrum-global-color-gray-700);
display: flex;
align-items: center;
gap: 8px;
} }
.title, .title,

View File

@ -1,12 +1,22 @@
<script> <script>
import { UserAvatar } from "@budibase/frontend-core"
export let users = [] export let users = []
$: uniqueUsers = unique(users)
const unique = users => {
let uniqueUsers = {}
users?.forEach(user => {
uniqueUsers[user.email] = user
})
return Object.values(uniqueUsers)
}
</script> </script>
<div class="avatars"> <div class="avatars">
{#each users as user} {#each uniqueUsers as user}
<div class="avatar" style="background:{user.color};" title={user.email}> <UserAvatar {user} tooltipDirection="bottom" />
{user.email[0]}
</div>
{/each} {/each}
</div> </div>
@ -15,19 +25,4 @@
display: flex; display: flex;
gap: 4px; gap: 4px;
} }
.avatar {
width: 24px;
height: 24px;
display: grid;
place-items: center;
color: white;
border-radius: 50%;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
line-height: 0;
}
.avatar:hover {
cursor: pointer;
}
</style> </style>

View File

@ -218,7 +218,9 @@
<!-- This should probably be some kind of loading state? --> <!-- This should probably be some kind of loading state? -->
<div class="loading" /> <div class="loading" />
{:then _} {:then _}
<slot /> <div class="body">
<slot />
</div>
{:catch error} {:catch error}
<p>Something went wrong: {error.message}</p> <p>Something went wrong: {error.message}</p>
{/await} {/await}
@ -254,6 +256,7 @@
box-sizing: border-box; box-sizing: border-box;
align-items: stretch; align-items: stretch;
border-bottom: var(--border-light); border-bottom: var(--border-light);
z-index: 2;
} }
.topleftnav { .topleftnav {
@ -294,4 +297,11 @@
flex-direction: row; flex-direction: row;
gap: 8px; gap: 8px;
} }
.body {
flex: 1 1 auto;
z-index: 1;
display: flex;
flex-direction: column;
}
</style> </style>

View File

@ -5,7 +5,6 @@
Divider, Divider,
ActionMenu, ActionMenu,
MenuItem, MenuItem,
Avatar,
Page, Page,
Icon, Icon,
Body, Body,
@ -22,6 +21,8 @@
import { processStringSync } from "@budibase/string-templates" import { processStringSync } from "@budibase/string-templates"
import Spaceman from "assets/bb-space-man.svg" import Spaceman from "assets/bb-space-man.svg"
import Logo from "assets/bb-emblem.svg" import Logo from "assets/bb-emblem.svg"
import { UserAvatar } from "@budibase/frontend-core"
import { helpers } from "@budibase/shared-core"
let loaded = false let loaded = false
let userInfoModal let userInfoModal
@ -96,11 +97,7 @@
<img class="logo" alt="logo" src={$organisation.logoUrl || Logo} /> <img class="logo" alt="logo" src={$organisation.logoUrl || Logo} />
<ActionMenu align="right"> <ActionMenu align="right">
<div slot="control" class="avatar"> <div slot="control" class="avatar">
<Avatar <UserAvatar user={$auth.user} showTooltip={false} />
size="M"
initials={$auth.initials}
url={$auth.user.pictureUrl}
/>
<Icon size="XL" name="ChevronDown" /> <Icon size="XL" name="ChevronDown" />
</div> </div>
<MenuItem icon="UserEdit" on:click={() => userInfoModal.show()}> <MenuItem icon="UserEdit" on:click={() => userInfoModal.show()}>
@ -125,7 +122,7 @@
</div> </div>
<Layout noPadding gap="XS"> <Layout noPadding gap="XS">
<Heading size="M"> <Heading size="M">
Hey {$auth.user.firstName || $auth.user.email} Hey {helpers.getUserLabel($auth.user)}
</Heading> </Heading>
<Body> <Body>
Welcome to the {$organisation.company} portal. Below you'll find the Welcome to the {$organisation.company} portal. Below you'll find the

View File

@ -1,11 +1,12 @@
<script> <script>
import { auth } from "stores/portal" import { auth } from "stores/portal"
import { ActionMenu, Avatar, MenuItem, Icon, Modal } from "@budibase/bbui" import { ActionMenu, MenuItem, Icon, Modal } from "@budibase/bbui"
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import ProfileModal from "components/settings/ProfileModal.svelte" import ProfileModal from "components/settings/ProfileModal.svelte"
import ChangePasswordModal from "components/settings/ChangePasswordModal.svelte" import ChangePasswordModal from "components/settings/ChangePasswordModal.svelte"
import ThemeModal from "components/settings/ThemeModal.svelte" import ThemeModal from "components/settings/ThemeModal.svelte"
import APIKeyModal from "components/settings/APIKeyModal.svelte" import APIKeyModal from "components/settings/APIKeyModal.svelte"
import { UserAvatar } from "@budibase/frontend-core"
let themeModal let themeModal
let profileModal let profileModal
@ -23,7 +24,7 @@
<ActionMenu align="right"> <ActionMenu align="right">
<div slot="control" class="user-dropdown"> <div slot="control" class="user-dropdown">
<Avatar size="M" initials={$auth.initials} url={$auth.user.pictureUrl} /> <UserAvatar user={$auth.user} showTooltip={false} />
<Icon size="XL" name="ChevronDown" /> <Icon size="XL" name="ChevronDown" />
</div> </div>
<MenuItem icon="UserEdit" on:click={() => profileModal.show()}> <MenuItem icon="UserEdit" on:click={() => profileModal.show()}>

View File

@ -1,15 +1,10 @@
<script> <script>
import { Avatar, Tooltip } from "@budibase/bbui" import { Tooltip } from "@budibase/bbui"
import { UserAvatar } from "@budibase/frontend-core"
export let row export let row
let showTooltip let showTooltip
const getInitials = user => {
let initials = ""
initials += user.firstName ? user.firstName[0] : ""
initials += user.lastName ? user.lastName[0] : ""
return initials === "" ? user.email[0] : initials
}
</script> </script>
{#if row?.user?.email} {#if row?.user?.email}
@ -19,7 +14,7 @@
on:focus={() => (showTooltip = true)} on:focus={() => (showTooltip = true)}
on:mouseleave={() => (showTooltip = false)} on:mouseleave={() => (showTooltip = false)}
> >
<Avatar size="M" initials={getInitials(row.user)} /> <UserAvatar user={row.user} />
</div> </div>
{#if showTooltip} {#if showTooltip}
<div class="tooltip"> <div class="tooltip">

View File

@ -24,7 +24,6 @@
import { AppStatus } from "constants" import { AppStatus } from "constants"
import analytics, { Events, EventSource } from "analytics" import analytics, { Events, EventSource } from "analytics"
import { store } from "builderStore" import { store } from "builderStore"
import AppLockModal from "components/common/AppLockModal.svelte"
import EditableIcon from "components/common/EditableIcon.svelte" import EditableIcon from "components/common/EditableIcon.svelte"
import { API } from "api" import { API } from "api"
import ConfirmDialog from "components/common/ConfirmDialog.svelte" import ConfirmDialog from "components/common/ConfirmDialog.svelte"
@ -128,7 +127,6 @@
/> />
</div> </div>
<div slot="buttons"> <div slot="buttons">
<AppLockModal {app} />
<span class="desktop"> <span class="desktop">
<Button <Button
size="M" size="M"

View File

@ -1,14 +1,11 @@
<script> <script>
import getUserInitials from "helpers/userInitials.js" import { UserAvatar } from "@budibase/frontend-core"
import { Avatar } from "@budibase/bbui"
export let value export let value
$: initials = getUserInitials(value)
</script> </script>
<div title={value.email} class="cell"> <div class="cell">
<Avatar size="M" {initials} /> <UserAvatar user={value} />
</div> </div>
<style> <style>

View File

@ -15,11 +15,12 @@
import { store } from "builderStore" import { store } from "builderStore"
import { processStringSync } from "@budibase/string-templates" import { processStringSync } from "@budibase/string-templates"
import { users, auth, apps, groups, overview } from "stores/portal" import { users, auth, apps, groups, overview } from "stores/portal"
import { fetchData } from "@budibase/frontend-core" import { fetchData, UserAvatar } from "@budibase/frontend-core"
import { API } from "api" import { API } from "api"
import GroupIcon from "../../users/groups/_components/GroupIcon.svelte" import GroupIcon from "../../users/groups/_components/GroupIcon.svelte"
import ConfirmDialog from "components/common/ConfirmDialog.svelte" import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import { checkIncomingDeploymentStatus } from "components/deploy/utils" import { checkIncomingDeploymentStatus } from "components/deploy/utils"
import { helpers } from "@budibase/shared-core"
let appEditor let appEditor
let unpublishModal let unpublishModal
@ -56,14 +57,6 @@
appEditor = await users.get(editorId) appEditor = await users.get(editorId)
} }
const getInitials = user => {
let initials = ""
initials += user.firstName ? user.firstName[0] : ""
initials += user.lastName ? user.lastName[0] : ""
return initials === "" ? user.email[0] : initials
}
const confirmUnpublishApp = async () => { const confirmUnpublishApp = async () => {
try { try {
await API.unpublishApp(app.prodId) await API.unpublishApp(app.prodId)
@ -140,7 +133,7 @@
<div class="last-edited-content"> <div class="last-edited-content">
<div class="updated-by"> <div class="updated-by">
{#if appEditor} {#if appEditor}
<Avatar size="M" initials={getInitials(appEditor)} /> <UserAvatar user={appEditor} showTooltip={false} />
<div class="editor-name"> <div class="editor-name">
{appEditor._id === $auth.user._id ? "You" : appEditorText} {appEditor._id === $auth.user._id ? "You" : appEditorText}
</div> </div>
@ -201,7 +194,7 @@
<div class="users"> <div class="users">
<div class="list"> <div class="list">
{#each appUsers.slice(0, 4) as user} {#each appUsers.slice(0, 4) as user}
<Avatar size="M" initials={getInitials(user)} /> <UserAvatar {user} />
{/each} {/each}
</div> </div>
<div class="text"> <div class="text">

View File

@ -2,7 +2,6 @@
import { goto, url } from "@roxi/routify" import { goto, url } from "@roxi/routify"
import { import {
ActionMenu, ActionMenu,
Avatar,
Button, Button,
Layout, Layout,
Heading, Heading,
@ -25,13 +24,14 @@
import UserGroupPicker from "components/settings/UserGroupPicker.svelte" import UserGroupPicker from "components/settings/UserGroupPicker.svelte"
import DeleteUserModal from "./_components/DeleteUserModal.svelte" import DeleteUserModal from "./_components/DeleteUserModal.svelte"
import GroupIcon from "../groups/_components/GroupIcon.svelte" import GroupIcon from "../groups/_components/GroupIcon.svelte"
import { Constants } from "@budibase/frontend-core" import { Constants, UserAvatar } from "@budibase/frontend-core"
import { Breadcrumbs, Breadcrumb } from "components/portal/page" import { Breadcrumbs, Breadcrumb } from "components/portal/page"
import RemoveGroupTableRenderer from "./_components/RemoveGroupTableRenderer.svelte" import RemoveGroupTableRenderer from "./_components/RemoveGroupTableRenderer.svelte"
import GroupNameTableRenderer from "../groups/_components/GroupNameTableRenderer.svelte" import GroupNameTableRenderer from "../groups/_components/GroupNameTableRenderer.svelte"
import AppNameTableRenderer from "./_components/AppNameTableRenderer.svelte" import AppNameTableRenderer from "./_components/AppNameTableRenderer.svelte"
import AppRoleTableRenderer from "./_components/AppRoleTableRenderer.svelte" import AppRoleTableRenderer from "./_components/AppRoleTableRenderer.svelte"
import ScimBanner from "../_components/SCIMBanner.svelte" import ScimBanner from "../_components/SCIMBanner.svelte"
import { helpers } from "@budibase/shared-core"
export let userId export let userId
@ -91,7 +91,7 @@
$: readonly = !$auth.isAdmin || scimEnabled $: readonly = !$auth.isAdmin || scimEnabled
$: privileged = user?.admin?.global || user?.builder?.global $: privileged = user?.admin?.global || user?.builder?.global
$: nameLabel = getNameLabel(user) $: nameLabel = getNameLabel(user)
$: initials = getInitials(nameLabel) $: initials = helpers.getUserInitials(user)
$: filteredGroups = getFilteredGroups($groups, searchTerm) $: filteredGroups = getFilteredGroups($groups, searchTerm)
$: availableApps = getAvailableApps($apps, privileged, user?.roles) $: availableApps = getAvailableApps($apps, privileged, user?.roles)
$: userGroups = $groups.filter(x => { $: userGroups = $groups.filter(x => {
@ -150,17 +150,6 @@
return label return label
} }
const getInitials = nameLabel => {
if (!nameLabel) {
return "?"
}
return nameLabel
.split(" ")
.slice(0, 2)
.map(x => x[0])
.join("")
}
async function updateUserFirstName(evt) { async function updateUserFirstName(evt) {
try { try {
await users.save({ ...user, firstName: evt.target.value }) await users.save({ ...user, firstName: evt.target.value })
@ -238,7 +227,7 @@
<div class="title"> <div class="title">
<div class="user-info"> <div class="user-info">
<Avatar size="XXL" {initials} /> <UserAvatar size="XXL" {user} showTooltip={false} />
<div class="subtitle"> <div class="subtitle">
<Heading size="M">{nameLabel}</Heading> <Heading size="M">{nameLabel}</Heading>
{#if nameLabel !== user?.email} {#if nameLabel !== user?.email}

View File

@ -0,0 +1,58 @@
<script>
import { Avatar, Tooltip } from "@budibase/bbui"
import { helpers } from "@budibase/shared-core"
export let user
export let size
export let tooltipDirection = "top"
export let showTooltip = true
$: tooltipStyle = getTooltipStyle(tooltipDirection)
const getTooltipStyle = direction => {
if (!direction) {
return ""
}
if (direction === "top") {
return "transform: translateX(-50%) translateY(-100%);"
} else if (direction === "bottom") {
return "transform: translateX(-50%) translateY(100%);"
}
}
</script>
{#if user}
<div class="user-avatar">
<Avatar
{size}
initials={helpers.getUserInitials(user)}
color={helpers.getUserColor(user)}
/>
{#if showTooltip}
<div class="tooltip" style={tooltipStyle}>
<Tooltip
direction={tooltipDirection}
textWrapping
text={user.email}
size="S"
/>
</div>
{/if}
</div>
{/if}
<style>
.user-avatar {
position: relative;
}
.tooltip {
display: none;
position: absolute;
top: 0;
left: 50%;
white-space: nowrap;
}
.user-avatar:hover .tooltip {
display: block;
}
</style>

View File

@ -1,24 +0,0 @@
<script>
export let user
</script>
<div class="user" style="background:{user.color};" title={user.email}>
{user.email[0]}
</div>
<style>
div {
width: 24px;
height: 24px;
display: grid;
place-items: center;
color: white;
border-radius: 50%;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
}
div:hover {
cursor: pointer;
}
</style>

View File

@ -1,13 +1,23 @@
<script> <script>
import { getContext } from "svelte" import { getContext } from "svelte"
import Avatar from "./Avatar.svelte" import UserAvatar from "../../UserAvatar.svelte"
const { users } = getContext("grid") const { users } = getContext("grid")
$: uniqueUsers = unique($users)
const unique = users => {
let uniqueUsers = {}
users?.forEach(user => {
uniqueUsers[user.email] = user
})
return Object.values(uniqueUsers)
}
</script> </script>
<div class="users"> <div class="users">
{#each $users as user} {#each uniqueUsers as user}
<Avatar {user} /> <UserAvatar {user} />
{/each} {/each}
</div> </div>

View File

@ -1,10 +1,22 @@
import { writable, get, derived } from "svelte/store" import { writable, get, derived } from "svelte/store"
import { helpers } from "@budibase/shared-core"
export const createStores = () => { export const createStores = () => {
const users = writable([]) const users = writable([])
const enrichedUsers = derived(users, $users => {
return $users.map(user => ({
...user,
color: helpers.getUserColor(user),
label: helpers.getUserLabel(user),
}))
})
return { return {
users, users: {
...users,
subscribe: enrichedUsers.subscribe,
},
} }
} }
@ -28,11 +40,11 @@ export const deriveStores = context => {
const updateUser = user => { const updateUser = user => {
const $users = get(users) const $users = get(users)
if (!$users.some(x => x.id === user.id)) { if (!$users.some(x => x.sessionId === user.sessionId)) {
users.set([...$users, user]) users.set([...$users, user])
} else { } else {
users.update(state => { users.update(state => {
const index = state.findIndex(x => x.id === user.id) const index = state.findIndex(x => x.sessionId === user.sessionId)
state[index] = user state[index] = user
return state.slice() return state.slice()
}) })
@ -41,7 +53,7 @@ export const deriveStores = context => {
const removeUser = user => { const removeUser = user => {
users.update(state => { users.update(state => {
return state.filter(x => x.id !== user.id) return state.filter(x => x.sessionId !== user.sessionId)
}) })
} }

View File

@ -1,4 +1,5 @@
export { default as SplitPage } from "./SplitPage.svelte" export { default as SplitPage } from "./SplitPage.svelte"
export { default as TestimonialPage } from "./TestimonialPage.svelte" export { default as TestimonialPage } from "./TestimonialPage.svelte"
export { default as Testimonial } from "./Testimonial.svelte" export { default as Testimonial } from "./Testimonial.svelte"
export { default as UserAvatar } from "./UserAvatar.svelte"
export { Grid } from "./grid" export { Grid } from "./grid"

View File

@ -5,6 +5,7 @@ import http from "http"
import Koa from "koa" import Koa from "koa"
import { Datasource, Table } from "@budibase/types" import { Datasource, Table } from "@budibase/types"
import { gridSocket } from "./index" import { gridSocket } from "./index"
import { clearLock } from "../utilities/redis"
export default class BuilderSocket extends Socket { export default class BuilderSocket extends Socket {
constructor(app: Koa, server: http.Server) { constructor(app: Koa, server: http.Server) {
@ -15,8 +16,7 @@ export default class BuilderSocket extends Socket {
const user = socket.data.user const user = socket.data.user
const appId = socket.data.appId const appId = socket.data.appId
socket.join(appId) socket.join(appId)
socket.to(appId).emit("user-update", socket.data.user) socket.to(appId).emit("user-update", user)
console.log(`Builder user connected: ${user?.id}`)
// Initial identification of connected spreadsheet // Initial identification of connected spreadsheet
socket.on("get-users", async (payload, callback) => { socket.on("get-users", async (payload, callback) => {
@ -27,8 +27,22 @@ export default class BuilderSocket extends Socket {
}) })
// Disconnection cleanup // Disconnection cleanup
socket.on("disconnect", () => { socket.on("disconnect", async () => {
socket.to(appId).emit("user-disconnect", socket.data.user) socket.to(appId).emit("user-disconnect", user)
// Remove app lock from this user if they have no other connections
try {
const sockets = await this.io.in(appId).fetchSockets()
const hasOtherConnection = sockets.some(socket => {
const { _id, sessionId } = socket.data.user
return _id === user._id && sessionId !== user.sessionId
})
if (!hasOtherConnection) {
await clearLock(appId, user)
}
} catch (e) {
// This is fine, just means this user didn't hold the lock
}
}) })
}) })
} }

View File

@ -12,7 +12,6 @@ export default class GridSocket extends Socket {
this.io.on("connection", socket => { this.io.on("connection", socket => {
const user = socket.data.user const user = socket.data.user
console.log(`Spreadsheet user connected: ${user?.id}`)
// Socket state // Socket state
let currentRoom: string let currentRoom: string
@ -21,14 +20,14 @@ export default class GridSocket extends Socket {
socket.on("select-table", async (tableId, callback) => { socket.on("select-table", async (tableId, callback) => {
// Leave current room // Leave current room
if (currentRoom) { if (currentRoom) {
socket.to(currentRoom).emit("user-disconnect", socket.data.user) socket.to(currentRoom).emit("user-disconnect", user)
socket.leave(currentRoom) socket.leave(currentRoom)
} }
// Join new room // Join new room
currentRoom = tableId currentRoom = tableId
socket.join(currentRoom) socket.join(currentRoom)
socket.to(currentRoom).emit("user-update", socket.data.user) socket.to(currentRoom).emit("user-update", user)
// Reply with all users in current room // Reply with all users in current room
const sockets = await this.io.in(currentRoom).fetchSockets() const sockets = await this.io.in(currentRoom).fetchSockets()
@ -41,14 +40,14 @@ export default class GridSocket extends Socket {
socket.on("select-cell", cellId => { socket.on("select-cell", cellId => {
socket.data.user.focusedCellId = cellId socket.data.user.focusedCellId = cellId
if (currentRoom) { if (currentRoom) {
socket.to(currentRoom).emit("user-update", socket.data.user) socket.to(currentRoom).emit("user-update", user)
} }
}) })
// Disconnection cleanup // Disconnection cleanup
socket.on("disconnect", () => { socket.on("disconnect", () => {
if (currentRoom) { if (currentRoom) {
socket.to(currentRoom).emit("user-disconnect", socket.data.user) socket.to(currentRoom).emit("user-disconnect", user)
} }
}) })
}) })

View File

@ -7,6 +7,7 @@ import { auth } from "@budibase/backend-core"
import currentApp from "../middleware/currentapp" import currentApp from "../middleware/currentapp"
import { createAdapter } from "@socket.io/redis-adapter" import { createAdapter } from "@socket.io/redis-adapter"
import { getSocketPubSubClients } from "../utilities/redis" import { getSocketPubSubClients } from "../utilities/redis"
import uuid from "uuid"
export default class Socket { export default class Socket {
io: Server io: Server
@ -64,29 +65,14 @@ export default class Socket {
// Middlewares are finished // Middlewares are finished
// Extract some data from our enriched koa context to persist // Extract some data from our enriched koa context to persist
// as metadata for the socket // as metadata for the socket
// Add user info, including a deterministic color and label
// Add user info, including a deterministic color and friendly
// label
const { _id, email, firstName, lastName } = ctx.user const { _id, email, firstName, lastName } = ctx.user
let hue = 1
for (let i = 0; i < email.length && i < 5; i++) {
hue *= email.charCodeAt(i + 1)
hue /= 17
}
hue = hue % 360
const color = `hsl(${hue}, 50%, 40%)`
let label = email
if (firstName) {
label = firstName
if (lastName) {
label += ` ${lastName}`
}
}
socket.data.user = { socket.data.user = {
id: _id, _id,
email, email,
color, firstName,
label, lastName,
sessionId: uuid.v4(),
} }
// Add app ID to help split sockets into rooms // Add app ID to help split sockets into rooms

View File

@ -1,3 +1,5 @@
import { User } from "@budibase/types"
/** /**
* Gets a key within an object. The key supports dot syntax for retrieving deep * Gets a key within an object. The key supports dot syntax for retrieving deep
* fields - e.g. "a.b.c". * fields - e.g. "a.b.c".
@ -21,3 +23,60 @@ export const deepGet = (obj: { [x: string]: any }, key: string) => {
} }
return obj return obj
} }
/**
* Gets the initials to show in a user avatar.
* @param user the user
*/
export const getUserInitials = (user: User) => {
if (!user) {
return "?"
}
let initials = ""
initials += user.firstName ? user.firstName[0] : ""
initials += user.lastName ? user.lastName[0] : ""
return initials === "" ? user.email[0] : initials
}
/**
* Gets a deterministic colour for a particular user
* @param user the user
*/
export const getUserColor = (user: User) => {
let id = user?._id
if (!id) {
return "var(--spectrum-global-color-blue-400)"
}
// In order to generate the same color for global users as app users, we need
// to remove the app-specific table prefix
id = id.replace("ro_ta_users_", "")
// Generate a hue based on the ID
let hue = 1
for (let i = 0; i < id.length; i++) {
hue += id.charCodeAt(i)
hue = hue % 36
}
return `hsl(${hue * 10}, 50%, 40%)`
}
/**
* Gets a friendly label to describe who a user is.
* @param user the user
*/
export const getUserLabel = (user: User) => {
if (!user) {
return ""
}
const { firstName, lastName, email } = user
if (firstName && lastName) {
return `${firstName} ${lastName}`
} else if (firstName) {
return firstName
} else if (lastName) {
return lastName
} else {
return email
}
}