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
328 changes: 166 additions & 162 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@
"escape-html": "^1.0.3",
"lit": "^3.3.3",
"mime-types": "^3.0.2",
"pane-registry": "3.1.2-1",
"pane-registry": "3.1.2-2",
"rdflib": "2.4.0",
"solid-logic": "4.0.8-1",
"solid-namespace": "^0.5.4",
Expand Down
98 changes: 98 additions & 0 deletions src/components/button-add-friend/ButtonAddFriend.stories.ts
Comment thread
SharonStrats marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { html } from 'lit'
import { DataBrowserContext } from 'pane-registry'
import { defineAuthStoryRender, USER_OPTIONS } from '@/storybook'

import './ButtonAddFriend'

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

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

function createMockContext (friendExists: boolean, simulateError: boolean): DataBrowserContext {
const store = {
fetcher: {
load: async () => undefined,
},
updater: {
update: async () => {
if (simulateError) {
throw new Error('Error adding friend, friend not added')
}
return undefined
},
},
whether: () => (friendExists ? 1 : 0),
}

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

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

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

export const ErrorStatus = {
render,
args: {
user: 'Alice',
simulateError: true,
},
play: async ({ canvasElement }) => {
await Promise.resolve()

const button = canvasElement.querySelector('solid-ui-button-add-friend') as HTMLElement & { shadowRoot?: ShadowRoot | null } | null
const innerButton = button?.shadowRoot?.querySelector('solid-ui-button') as HTMLElement & { shadowRoot?: ShadowRoot | null } | null
const nativeButton = innerButton?.shadowRoot?.querySelector('button') as HTMLButtonElement | null

nativeButton?.click()
await Promise.resolve()
}
}
15 changes: 15 additions & 0 deletions src/components/button-add-friend/ButtonAddFriend.styles.css
Comment thread
SharonStrats marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
.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;
}
190 changes: 190 additions & 0 deletions src/components/button-add-friend/ButtonAddFriend.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import { customElement, WebComponent } from '@/lib/components'
import { consume } from '@lit/context'
import { html, nothing } from 'lit'
import { property, state } 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({ type: String, reflect: true })
accessor subject = ''

@property({ attribute: false })
accessor context: DataBrowserContext | undefined = undefined
Comment on lines +35 to +36

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We shouldn't be passing the context as an attribute, it should be a context provided by a parent component. We already do this with the auth context, so we could reuse that one or create a dedicated one for the store.

Timea has been working on this, and she's leaning more towards having a separate "StoreContext". Check it out in her PR, as they should both follow the same approach: #798

@SharonStrats SharonStrats Jul 7, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I should probably wait until Timea merges that PR then so i can use the store she added.


@state()
private accessor buttonLabel = addMeToYourFriendsButtonText

@state()
private 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() {
const subject = this.getSubjectNode()

if (!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, 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()

const subject = this.getSubjectNode()

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

try {
await this.saveNewThing(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 = ''
}

private getSubjectNode (): NamedNode | undefined {
return this.subject ? sym(this.subject) : undefined
}
}
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