Skip to content

Commit ab67365

Browse files
committed
make an add as friend web component
1 parent acdc053 commit ab67365

5 files changed

Lines changed: 329 additions & 0 deletions

File tree

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { html } from 'lit'
2+
import { sym } from 'rdflib'
3+
import { DataBrowserContext } from 'pane-registry'
4+
import { defineAuthStoryRender, USER_OPTIONS } from '@/storybook'
5+
6+
import './ButtonAddFriend'
7+
8+
type StoryArgs = {
9+
user: typeof USER_OPTIONS.control
10+
subjectUri: string
11+
friendExists: boolean
12+
}
13+
14+
const meta = {
15+
title: 'ButtonAddFriend',
16+
args: {
17+
user: 'Guest',
18+
subjectUri: 'https://example.com/profile/card#me',
19+
friendExists: false,
20+
},
21+
argTypes: {
22+
user: USER_OPTIONS.control,
23+
subjectUri: { control: 'text' },
24+
friendExists: { control: 'boolean' },
25+
},
26+
} as const
27+
28+
function createMockContext(friendExists: boolean): DataBrowserContext {
29+
const store = {
30+
fetcher: {
31+
load: async () => undefined,
32+
},
33+
updater: {
34+
update: async () => undefined,
35+
},
36+
whether: () => (friendExists ? 1 : 0),
37+
}
38+
39+
return {
40+
dom: document,
41+
environment: { layout: 'desktop' },
42+
session: { store },
43+
} as unknown as DataBrowserContext
44+
}
45+
46+
const render = defineAuthStoryRender<StoryArgs>(({ subjectUri, friendExists }) => {
47+
const context = createMockContext(friendExists)
48+
const subject = sym(subjectUri)
49+
50+
return html`
51+
<div style="padding: 1rem; display: inline-flex; flex-direction: column; align-items: flex-start; gap: 0.75rem;">
52+
<solid-ui-button-add-friend .context=${context} .subject=${subject}></solid-ui-button-add-friend>
53+
</div>
54+
`
55+
})
56+
57+
export default meta
58+
59+
export const Guest = {
60+
render,
61+
}
62+
63+
export const LoggedIn = {
64+
render,
65+
args: {
66+
user: 'Alice',
67+
},
68+
}
69+
70+
export const FriendExists = {
71+
render,
72+
args: {
73+
user: 'Alice',
74+
friendExists: true,
75+
},
76+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
.button-add-friend__status {
2+
display: inline-flex;
3+
align-items: center;
4+
gap: 8px;
5+
margin-top: 12px;
6+
padding: 8px 12px;
7+
border: 1px solid var(--red-300, #f5c2c7);
8+
border-radius: 8px;
9+
background: #fee;
10+
color: var(--red-700, #b42318);
11+
}
12+
13+
.button-add-friend__status span {
14+
line-height: 1.2;
15+
}
16+
17+
.button-add-friend__status solid-ui-button {
18+
flex: 0 0 auto;
19+
}
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
import { customElement, WebComponent } from '@/lib/components'
2+
import { consume } from '@lit/context'
3+
import { html, nothing } from 'lit'
4+
import { property } from 'lit/decorators.js'
5+
import '@/components/button'
6+
import '~icons/lucide/x'
7+
import '~icons/lucide/user-round-plus'
8+
import styles from './ButtonAddFriend.styles.css'
9+
import * as debug from '../../lib/debug'
10+
import { authContext, AuthContext, DEFAULT_AUTH_CONTEXT } from '@/lib/auth'
11+
import { DataBrowserContext } from 'pane-registry'
12+
import { LiveStore, NamedNode, st, sym } from 'rdflib'
13+
import ns from '../../lib/ns'
14+
import { ensureStandardMutationPrefixes } from './helpers'
15+
16+
const addMeToYourFriendsButtonText = 'Add as Friend'
17+
const logInAddMeToYourFriendsButtonText = 'Log in to add as friend'
18+
const friendExistsMessage = 'This friend is already in your list'
19+
const friendNotAddedMessage = 'Error adding friend, friend not added'
20+
const userNotLoggedInErrorMessage = 'Please log in first'
21+
22+
@customElement('solid-ui-button-add-friend')
23+
export default class ButtonAddFriend extends WebComponent {
24+
static styles = styles
25+
26+
@consume({ context: authContext, subscribe: true })
27+
private accessor auth: AuthContext = DEFAULT_AUTH_CONTEXT
28+
29+
@property({ type: Boolean })
30+
accessor disabled: boolean | undefined = undefined
31+
32+
@property({ attribute: false })
33+
accessor subject: NamedNode | undefined = undefined
34+
35+
@property({ attribute: false })
36+
accessor context: DataBrowserContext | undefined = undefined
37+
38+
@property({ attribute: false })
39+
accessor buttonLabel = addMeToYourFriendsButtonText
40+
41+
@property({ attribute: false })
42+
accessor statusMessage = ''
43+
44+
private checkIfAnyUserLoggedIn(me: NamedNode | null): me is NamedNode {
45+
return Boolean(me)
46+
}
47+
48+
private currentUser (): NamedNode | null {
49+
return this.auth.account ? sym(this.auth.account.webId) : null
50+
}
51+
52+
protected firstUpdated () {
53+
void this.refreshButton()
54+
}
55+
56+
protected updated (changedProperties: Map<PropertyKey, unknown>) {
57+
super.updated(changedProperties)
58+
59+
if (changedProperties.has('subject') || changedProperties.has('context') || changedProperties.has('auth')) {
60+
void this.refreshButton()
61+
}
62+
}
63+
64+
private async refreshButton() {
65+
if (!this.subject || !this.context) {
66+
this.buttonLabel = logInAddMeToYourFriendsButtonText
67+
this.disabled = true
68+
this.statusMessage = ''
69+
return
70+
}
71+
72+
const me = this.currentUser()
73+
const store = this.context.session.store as unknown as LiveStore
74+
75+
if (!this.checkIfAnyUserLoggedIn(me)) {
76+
this.buttonLabel = logInAddMeToYourFriendsButtonText
77+
this.disabled = true
78+
this.statusMessage = ''
79+
return
80+
}
81+
82+
const friendExists = await this.checkIfThingExists(store, me, this.subject, ns.foaf('knows'))
83+
if (friendExists) {
84+
this.buttonLabel = friendExistsMessage
85+
this.disabled = true
86+
this.statusMessage = ''
87+
return
88+
}
89+
90+
this.buttonLabel = addMeToYourFriendsButtonText
91+
this.disabled = false
92+
this.statusMessage = ''
93+
}
94+
95+
private async saveNewThing(
96+
subject: NamedNode,
97+
context: DataBrowserContext,
98+
predicate: NamedNode
99+
): Promise<void> {
100+
const me = this.currentUser()
101+
const store = context.session.store as unknown as LiveStore
102+
103+
if (this.checkIfAnyUserLoggedIn(me)) {
104+
if (!(await this.checkIfThingExists(store, me, subject, predicate))) {
105+
await store.fetcher.load(me)
106+
const updater = store.updater
107+
if (!updater) {
108+
throw new Error('Store updater is unavailable')
109+
}
110+
const toBeInserted = [st(me, predicate, subject, me.doc())]
111+
try {
112+
ensureStandardMutationPrefixes(store)
113+
await updater.update([], toBeInserted)
114+
} catch (error) {
115+
const errorMessage = error instanceof Error ? error.message : String(error)
116+
const message = errorMessage.includes('Unauthenticated') ? userNotLoggedInErrorMessage : errorMessage
117+
throw new Error(message)
118+
}
119+
} else {
120+
throw new Error(friendExistsMessage)
121+
}
122+
} else {
123+
throw new Error(userNotLoggedInErrorMessage)
124+
}
125+
}
126+
127+
private async checkIfThingExists(
128+
store: LiveStore,
129+
me: NamedNode,
130+
subject: NamedNode,
131+
predicate: NamedNode
132+
): Promise<boolean> {
133+
await store.fetcher.load(me)
134+
return store.whether(me, predicate, subject, me.doc()) !== 0
135+
}
136+
137+
render () {
138+
return html`
139+
<solid-ui-button
140+
variant="secondary"
141+
?disabled=${this.disabled}
142+
@click=${this.onClick}
143+
>
144+
<icon-lucide-user-round-plus slot="left-icon"></icon-lucide-user-round-plus>
145+
${this.buttonLabel}
146+
</solid-ui-button>
147+
${this.statusMessage
148+
? html`
149+
<div role="status" aria-live="polite" class="button-add-friend__status">
150+
<span>${this.statusMessage}</span>
151+
<solid-ui-button variant="ghost" @click=${this.clearStatusMessage}>
152+
<span class="sr-only">Close</span>
153+
<icon-lucide-x slot="icon"></icon-lucide-x>
154+
</solid-ui-button>
155+
</div>
156+
`
157+
: nothing}
158+
`
159+
}
160+
161+
private async onClick (event: Event) {
162+
event.preventDefault()
163+
164+
if (!this.subject || !this.context) {
165+
this.disabled = true
166+
return
167+
}
168+
169+
try {
170+
await this.saveNewThing(this.subject, this.context, ns.foaf('knows'))
171+
await this.refreshButton()
172+
} catch (error) {
173+
this.disabled = true
174+
this.statusMessage = error instanceof Error ? error.message : friendNotAddedMessage
175+
debug.error(error)
176+
}
177+
}
178+
179+
private clearStatusMessage () {
180+
this.statusMessage = ''
181+
}
182+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { LiveStore } from 'rdflib'
2+
3+
type PrefixCapable = {
4+
setPrefixForURI?: (prefix: string, uri: string) => void
5+
namespaces?: Record<string, string>
6+
store?: PrefixCapable
7+
}
8+
9+
const STANDARD_MUTATION_PREFIXES: Record<string, string> = {
10+
rdf: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#',
11+
vcard: 'http://www.w3.org/2006/vcard/ns#',
12+
foaf: 'http://xmlns.com/foaf/0.1/',
13+
solid: 'http://www.w3.org/ns/solid/terms#',
14+
schema: 'http://schema.org/',
15+
org: 'http://www.w3.org/ns/org#',
16+
owl: 'http://www.w3.org/2002/07/owl#',
17+
dc: 'http://purl.org/dc/elements/1.1/'
18+
}
19+
20+
function registerStorePrefix(target: PrefixCapable | undefined, prefix: string, uri: string): void {
21+
if (!target) return
22+
if (typeof target.setPrefixForURI === 'function') {
23+
target.setPrefixForURI(prefix, uri)
24+
return
25+
}
26+
if (!target.namespaces) {
27+
target.namespaces = {}
28+
}
29+
target.namespaces[prefix] = uri
30+
}
31+
32+
function getStoreUpdater(store: LiveStore): PrefixCapable | undefined {
33+
return store.updater as PrefixCapable | undefined
34+
}
35+
36+
export function ensureStandardMutationPrefixes(store: LiveStore | undefined): void {
37+
if (!store) return
38+
39+
const updater = getStoreUpdater(store)
40+
const nestedStore = (updater as { store?: PrefixCapable } | undefined)?.store
41+
const targets: Array<PrefixCapable | undefined> = [store as PrefixCapable, updater, nestedStore]
42+
43+
Object.entries(STANDARD_MUTATION_PREFIXES).forEach(([prefix, uri]) => {
44+
targets.forEach((target) => {
45+
registerStorePrefix(target, prefix, uri)
46+
})
47+
})
48+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import ButtonAddFriend from './ButtonAddFriend'
2+
3+
export { ButtonAddFriend }
4+
export default ButtonAddFriend

0 commit comments

Comments
 (0)