From ab67365a565dc9859c6bff8e48edd6827a584584 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Fri, 3 Jul 2026 12:15:07 +1000 Subject: [PATCH 1/2] make an add as friend web component --- .../ButtonAddFriend.stories.ts | 76 ++++++++ .../ButtonAddFriend.styles.css | 19 ++ .../button-add-friend/ButtonAddFriend.ts | 182 ++++++++++++++++++ src/components/button-add-friend/helpers.ts | 48 +++++ src/components/button-add-friend/index.ts | 4 + 5 files changed, 329 insertions(+) create mode 100644 src/components/button-add-friend/ButtonAddFriend.stories.ts create mode 100644 src/components/button-add-friend/ButtonAddFriend.styles.css create mode 100644 src/components/button-add-friend/ButtonAddFriend.ts create mode 100644 src/components/button-add-friend/helpers.ts create mode 100644 src/components/button-add-friend/index.ts diff --git a/src/components/button-add-friend/ButtonAddFriend.stories.ts b/src/components/button-add-friend/ButtonAddFriend.stories.ts new file mode 100644 index 000000000..612a6a9e9 --- /dev/null +++ b/src/components/button-add-friend/ButtonAddFriend.stories.ts @@ -0,0 +1,76 @@ +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(({ subjectUri, friendExists }) => { + const context = createMockContext(friendExists) + const subject = sym(subjectUri) + + return html` +
+ +
+ ` +}) + +export default meta + +export const Guest = { + render, +} + +export const LoggedIn = { + render, + args: { + user: 'Alice', + }, +} + +export const FriendExists = { + render, + args: { + user: 'Alice', + friendExists: true, + }, +} diff --git a/src/components/button-add-friend/ButtonAddFriend.styles.css b/src/components/button-add-friend/ButtonAddFriend.styles.css new file mode 100644 index 000000000..096d38273 --- /dev/null +++ b/src/components/button-add-friend/ButtonAddFriend.styles.css @@ -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; +} diff --git a/src/components/button-add-friend/ButtonAddFriend.ts b/src/components/button-add-friend/ButtonAddFriend.ts new file mode 100644 index 000000000..01c6fd1e2 --- /dev/null +++ b/src/components/button-add-friend/ButtonAddFriend.ts @@ -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 + } + + protected firstUpdated () { + void this.refreshButton() + } + + protected updated (changedProperties: Map) { + 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 { + 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 { + await store.fetcher.load(me) + return store.whether(me, predicate, subject, me.doc()) !== 0 + } + + render () { + return html` + + + ${this.buttonLabel} + + ${this.statusMessage + ? html` +
+ ${this.statusMessage} + + Close + + +
+ ` + : 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 = '' + } +} diff --git a/src/components/button-add-friend/helpers.ts b/src/components/button-add-friend/helpers.ts new file mode 100644 index 000000000..4b0e491b7 --- /dev/null +++ b/src/components/button-add-friend/helpers.ts @@ -0,0 +1,48 @@ +import { LiveStore } from 'rdflib' + +type PrefixCapable = { + setPrefixForURI?: (prefix: string, uri: string) => void + namespaces?: Record + store?: PrefixCapable +} + +const STANDARD_MUTATION_PREFIXES: Record = { + 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 = [store as PrefixCapable, updater, nestedStore] + + Object.entries(STANDARD_MUTATION_PREFIXES).forEach(([prefix, uri]) => { + targets.forEach((target) => { + registerStorePrefix(target, prefix, uri) + }) + }) +} diff --git a/src/components/button-add-friend/index.ts b/src/components/button-add-friend/index.ts new file mode 100644 index 000000000..0da7ad476 --- /dev/null +++ b/src/components/button-add-friend/index.ts @@ -0,0 +1,4 @@ +import ButtonAddFriend from './ButtonAddFriend' + +export { ButtonAddFriend } +export default ButtonAddFriend From a1ba01770934df3f74bda50137ce0eecae5758b8 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Fri, 3 Jul 2026 14:08:11 +1000 Subject: [PATCH 2/2] Add stories Prompt: Create stories for ButtonAddFriend for guest, loggedin and FriendExists follow the stories in the Component directory Co-authored-by: GPT-5.4 Mini --- src/components/button-add-friend/ButtonAddFriend.stories.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/components/button-add-friend/ButtonAddFriend.stories.ts b/src/components/button-add-friend/ButtonAddFriend.stories.ts index 612a6a9e9..33ebd302a 100644 --- a/src/components/button-add-friend/ButtonAddFriend.stories.ts +++ b/src/components/button-add-friend/ButtonAddFriend.stories.ts @@ -48,9 +48,7 @@ const render = defineAuthStoryRender(({ subjectUri, friendExists }) = const subject = sym(subjectUri) return html` -
- -
+ ` })