Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions src/components/button-add-friend/ButtonAddFriend.stories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { html } from 'lit'
import { sym } from 'rdflib'
import { DataBrowserContext } from 'pane-registry'
import { defineAuthStoryRender, USER_OPTIONS } from '@/storybook'

import './ButtonAddFriend'

type StoryArgs = {
user: typeof USER_OPTIONS.control
subjectUri: string
friendExists: boolean
}

const meta = {
title: 'ButtonAddFriend',
args: {
user: 'Guest',
subjectUri: 'https://example.com/profile/card#me',
friendExists: false,
},
argTypes: {
user: USER_OPTIONS.control,
subjectUri: { control: 'text' },
friendExists: { control: 'boolean' },
},
} as const

function createMockContext(friendExists: boolean): DataBrowserContext {
const store = {
fetcher: {
load: async () => undefined,
},
updater: {
update: async () => undefined,
},
whether: () => (friendExists ? 1 : 0),
}

return {
dom: document,
environment: { layout: 'desktop' },
session: { store },
} as unknown as DataBrowserContext
}

const render = defineAuthStoryRender<StoryArgs>(({ subjectUri, friendExists }) => {
const context = createMockContext(friendExists)
const subject = sym(subjectUri)

return html`
<solid-ui-button-add-friend .context=${context} .subject=${subject}></solid-ui-button-add-friend>
`
})

export default meta

export const Guest = {
render,
}

export const LoggedIn = {
render,
args: {
user: 'Alice',
},
}

export const FriendExists = {
render,
args: {
user: 'Alice',
friendExists: true,
},
}
19 changes: 19 additions & 0 deletions src/components/button-add-friend/ButtonAddFriend.styles.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
.button-add-friend__status {
display: inline-flex;
align-items: center;
gap: 8px;
margin-top: 12px;
padding: 8px 12px;
border: 1px solid var(--red-300, #f5c2c7);
border-radius: 8px;
background: #fee;
color: var(--red-700, #b42318);
}

.button-add-friend__status span {
line-height: 1.2;
}

.button-add-friend__status solid-ui-button {
flex: 0 0 auto;
}
Comment thread
Copilot marked this conversation as resolved.
182 changes: 182 additions & 0 deletions src/components/button-add-friend/ButtonAddFriend.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import { customElement, WebComponent } from '@/lib/components'
import { consume } from '@lit/context'
import { html, nothing } from 'lit'
import { property } from 'lit/decorators.js'
import '@/components/button'
import '~icons/lucide/x'
import '~icons/lucide/user-round-plus'
import styles from './ButtonAddFriend.styles.css'
import * as debug from '../../lib/debug'
import { authContext, AuthContext, DEFAULT_AUTH_CONTEXT } from '@/lib/auth'
import { DataBrowserContext } from 'pane-registry'
import { LiveStore, NamedNode, st, sym } from 'rdflib'
import ns from '../../lib/ns'
import { ensureStandardMutationPrefixes } from './helpers'

const addMeToYourFriendsButtonText = 'Add as Friend'
const logInAddMeToYourFriendsButtonText = 'Log in to add as friend'
const friendExistsMessage = 'This friend is already in your list'
const friendNotAddedMessage = 'Error adding friend, friend not added'
const userNotLoggedInErrorMessage = 'Please log in first'

@customElement('solid-ui-button-add-friend')
export default class ButtonAddFriend extends WebComponent {
static styles = styles

@consume({ context: authContext, subscribe: true })
private accessor auth: AuthContext = DEFAULT_AUTH_CONTEXT

@property({ type: Boolean })
accessor disabled: boolean | undefined = undefined

@property({ attribute: false })
accessor subject: NamedNode | undefined = undefined

@property({ attribute: false })
accessor context: DataBrowserContext | undefined = undefined

@property({ attribute: false })
accessor buttonLabel = addMeToYourFriendsButtonText

@property({ attribute: false })
accessor statusMessage = ''

private checkIfAnyUserLoggedIn(me: NamedNode | null): me is NamedNode {
return Boolean(me)
}

private currentUser (): NamedNode | null {
return this.auth.account ? sym(this.auth.account.webId) : null
}
Comment thread
Copilot marked this conversation as resolved.

protected firstUpdated () {
void this.refreshButton()
}

protected updated (changedProperties: Map<PropertyKey, unknown>) {
super.updated(changedProperties)

if (changedProperties.has('subject') || changedProperties.has('context') || changedProperties.has('auth')) {
void this.refreshButton()
}
}

private async refreshButton() {
if (!this.subject || !this.context) {
this.buttonLabel = logInAddMeToYourFriendsButtonText
this.disabled = true
this.statusMessage = ''
return
}

const me = this.currentUser()
const store = this.context.session.store as unknown as LiveStore

if (!this.checkIfAnyUserLoggedIn(me)) {
this.buttonLabel = logInAddMeToYourFriendsButtonText
this.disabled = true
this.statusMessage = ''
return
}

const friendExists = await this.checkIfThingExists(store, me, this.subject, ns.foaf('knows'))
if (friendExists) {
this.buttonLabel = friendExistsMessage
this.disabled = true
this.statusMessage = ''
return
}

this.buttonLabel = addMeToYourFriendsButtonText
this.disabled = false
this.statusMessage = ''
}

private async saveNewThing(
subject: NamedNode,
context: DataBrowserContext,
predicate: NamedNode
): Promise<void> {
const me = this.currentUser()
const store = context.session.store as unknown as LiveStore

if (this.checkIfAnyUserLoggedIn(me)) {
if (!(await this.checkIfThingExists(store, me, subject, predicate))) {
await store.fetcher.load(me)
const updater = store.updater
if (!updater) {
throw new Error('Store updater is unavailable')
}
const toBeInserted = [st(me, predicate, subject, me.doc())]
try {
ensureStandardMutationPrefixes(store)
await updater.update([], toBeInserted)
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
const message = errorMessage.includes('Unauthenticated') ? userNotLoggedInErrorMessage : errorMessage
throw new Error(message)
}
} else {
throw new Error(friendExistsMessage)
}
} else {
throw new Error(userNotLoggedInErrorMessage)
}
}

private async checkIfThingExists(
store: LiveStore,
me: NamedNode,
subject: NamedNode,
predicate: NamedNode
): Promise<boolean> {
await store.fetcher.load(me)
return store.whether(me, predicate, subject, me.doc()) !== 0
}

render () {
return html`
<solid-ui-button
variant="secondary"
?disabled=${this.disabled}
@click=${this.onClick}
>
<icon-lucide-user-round-plus slot="left-icon"></icon-lucide-user-round-plus>
${this.buttonLabel}
</solid-ui-button>
${this.statusMessage
? html`
<div role="status" aria-live="polite" class="button-add-friend__status">
<span>${this.statusMessage}</span>
<solid-ui-button variant="ghost" @click=${this.clearStatusMessage}>
<span class="sr-only">Close</span>
<icon-lucide-x slot="icon"></icon-lucide-x>
</solid-ui-button>
</div>
`
: nothing}
`
}

private async onClick (event: Event) {
event.preventDefault()

if (!this.subject || !this.context) {
this.disabled = true
return
}

try {
await this.saveNewThing(this.subject, this.context, ns.foaf('knows'))
await this.refreshButton()
} catch (error) {
this.disabled = true
this.statusMessage = error instanceof Error ? error.message : friendNotAddedMessage
debug.error(error)
}
}

private clearStatusMessage () {
this.statusMessage = ''
}
}
48 changes: 48 additions & 0 deletions src/components/button-add-friend/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { LiveStore } from 'rdflib'

type PrefixCapable = {
setPrefixForURI?: (prefix: string, uri: string) => void
namespaces?: Record<string, string>
store?: PrefixCapable
}

const STANDARD_MUTATION_PREFIXES: Record<string, string> = {
rdf: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#',
vcard: 'http://www.w3.org/2006/vcard/ns#',
foaf: 'http://xmlns.com/foaf/0.1/',
solid: 'http://www.w3.org/ns/solid/terms#',
schema: 'http://schema.org/',
org: 'http://www.w3.org/ns/org#',
owl: 'http://www.w3.org/2002/07/owl#',
dc: 'http://purl.org/dc/elements/1.1/'
}

function registerStorePrefix(target: PrefixCapable | undefined, prefix: string, uri: string): void {
if (!target) return
if (typeof target.setPrefixForURI === 'function') {
target.setPrefixForURI(prefix, uri)
return
}
if (!target.namespaces) {
target.namespaces = {}
}
target.namespaces[prefix] = uri
}

function getStoreUpdater(store: LiveStore): PrefixCapable | undefined {
return store.updater as PrefixCapable | undefined
}

export function ensureStandardMutationPrefixes(store: LiveStore | undefined): void {
if (!store) return

const updater = getStoreUpdater(store)
const nestedStore = (updater as { store?: PrefixCapable } | undefined)?.store
const targets: Array<PrefixCapable | undefined> = [store as PrefixCapable, updater, nestedStore]

Object.entries(STANDARD_MUTATION_PREFIXES).forEach(([prefix, uri]) => {
targets.forEach((target) => {
registerStorePrefix(target, prefix, uri)
})
})
}
4 changes: 4 additions & 0 deletions src/components/button-add-friend/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import ButtonAddFriend from './ButtonAddFriend'

export { ButtonAddFriend }
export default ButtonAddFriend
Loading