UNPKG

solid-panes

Version:

Solid-compatible Panes: applets and views for the mashlib and databrowser

558 lines (543 loc) • 21.8 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.extractFriends = extractFriends; exports.pronounsAsText = pronounsAsText; exports.selectProfileData = selectProfileData; exports.socialPane = void 0; exports.streamFriends = streamFriends; require("./socialPane.css"); var _solidUi = require("solid-ui"); var _solidLogic = require("solid-logic"); var _icons = require("./icons"); var _socialSections = require("./socialSections"); /* Social Pane ** ** This outline pane provides social network functions ** Using for example the FOAF ontology. ** Goal: A *distributed* version of facebook, advogato, etc etc ** - Similarly easy user interface, but data storage distributed ** - Read and write both user-private (address book) and public data clearly ** -- todo: use common code to get username and load profile and set 'me' */ const socialPane = exports.socialPane = { icon: _solidUi.icons.originalIconBase + 'foaf/foafTiny.gif', name: 'social', label: function (subject, context) { const kb = context.session.store; const types = kb.findTypeURIs(subject); if (types[_solidUi.ns.foaf('Person').uri] || types[_solidUi.ns.vcard('Individual').uri]) { return 'Friends'; } return null; }, global: false, render: function (s, context) { const dom = context.dom; const common = function (x, y) { // Find common members of two lists const both = []; for (let i = 0; i < x.length; i++) { for (let j = 0; j < y.length; j++) { if (y[j].sameTerm(x[i])) { both.push(y[j]); break; } } } return both; }; const uniqueNodes = function (nodes) { const seen = new Set(); const unique = []; for (const node of nodes) { if (!node?.value || seen.has(node.value)) continue; seen.add(node.value); unique.push(node); } return unique; }; const link = function (contents, uri) { if (!uri) return contents; const a = dom.createElement('a'); a.setAttribute('href', uri); a.appendChild(contents); return a; }; const text = function (str) { return dom.createTextNode(str); }; const buildCheckboxForm = function (lab, statement, state, options = {}) { const f = dom.createElement('form'); const label = dom.createElement('label'); const input = dom.createElement('input'); const tx = dom.createElement('span'); tx.className = 'question'; if (typeof lab === 'string') { tx.textContent = lab; } else { tx.appendChild(lab); } input.setAttribute('type', 'checkbox'); if (options.disabled) { input.disabled = true; if (options.disabledTitle) { input.title = options.disabledTitle; input.setAttribute('aria-label', options.disabledTitle); } } label.appendChild(tx); label.appendChild(input); f.appendChild(label); const boxHandler = function (_e) { if (this.checked) { try { outliner.UserInput.sparqler.insert_statement(statement, function (uri, success, errorBody) { tx.className = 'question'; if (!success) { _solidUi.log.alert('Error occurs while inserting ' + statement + '\n\n' + errorBody); input.checked = false; // rollback UI return; } kb.add(statement.subject, statement.predicate, statement.object, statement.why); }); } catch (e) { _solidUi.log.error('Data write fails:' + e); _solidUi.log.alert('Data write fails:' + e); input.checked = false; // rollback UI tx.className = 'question'; } } else { try { outliner.UserInput.sparqler.delete_statement(statement, function (uri, success, errorBody) { tx.className = 'question'; if (!success) { _solidUi.log.alert('Error occurs while deleting ' + statement + '\n\n' + errorBody); input.checked = true; // Rollback UI } else { kb.removeMany(statement.subject, statement.predicate, statement.object, statement.why); } }); } catch (e) { _solidUi.log.alert('Delete fails:' + e); input.checked = true; // Rollback UI // return } } }; input.checked = state; input.addEventListener('click', boxHandler, false); return f; }; // ////////// Body of render(): const outliner = context.getOutliner(dom); const kb = context.session.store; const socialPane = dom.createElement('div'); socialPane.classList.add('social-pane', 'flex-column', 'gap-xxs', 'p-lg'); const foaf = _solidUi.ns.foaf; const vcard = _solidUi.ns.vcard; const me = _solidLogic.authn.currentUser(); const meUri = me ? me.uri : null; const thisIsYou = me && kb.sameThings(me, s); const knows = foaf('knows'); // var givenName = kb.sym('http://www.w3.org/2000/10/swap/pim/contact#givenName') const familiar = kb.anyValue(s, foaf('givenname')) || kb.anyValue(s, foaf('firstName')) || kb.anyValue(s, foaf('nick')) || kb.anyValue(s, foaf('name')) || kb.anyValue(s, vcard('fn')); const friends = kb.each(s, knows); const uniqueFriends = uniqueNodes(friends); const myFriends = me ? uniqueNodes(kb.each(me, foaf('knows'))) : []; const mutualConnections = me && !thisIsYou ? uniqueNodes(common(uniqueFriends, myFriends)).filter(friend => !kb.sameThings(friend, me)) : []; const mutualFriendCount = me && !thisIsYou ? mutualConnections.length : null; const viewerMode = getViewerMode(s, me); // Do I have a public profile document? let profile = null; // This could be SPARQL { ?me foaf:primaryTopic [ a foaf:PersonalProfileDocument ] } let editable = false; let incoming = false; let outgoing = false; const structure = socialPane.appendChild(dom.createElement('div')); structure.className = 'social-layout'; const primary = structure.appendChild(dom.createElement('div')); primary.className = 'social-primary'; const tabs = primary.appendChild(dom.createElement('div')); tabs.classList.add('social-primary__tabs', 'flex-center'); tabs.setAttribute('role', 'tablist'); tabs.setAttribute('aria-label', 'Social sections'); const allFriendsTab = tabs.appendChild(dom.createElement('button')); allFriendsTab.className = 'social-primary__tab'; allFriendsTab.type = 'button'; allFriendsTab.id = 'social-tab-all-friends'; allFriendsTab.textContent = 'All Friends'; allFriendsTab.setAttribute('role', 'tab'); allFriendsTab.setAttribute('aria-controls', 'social-panel-all-friends'); allFriendsTab.setAttribute('aria-selected', 'true'); allFriendsTab.tabIndex = 0; const mutualTab = tabs.appendChild(dom.createElement('button')); mutualTab.className = 'social-primary__tab'; mutualTab.type = 'button'; mutualTab.id = 'social-tab-mutual'; mutualTab.textContent = 'Mutual'; mutualTab.setAttribute('role', 'tab'); mutualTab.setAttribute('aria-controls', 'social-panel-mutual'); mutualTab.setAttribute('aria-selected', 'false'); mutualTab.tabIndex = -1; if (me) { // The definition of FOAF personal profile document is .. const works = kb.each(undefined, foaf('primaryTopic'), me); // having me as primary topic let message = ''; for (let i = 0; i < works.length; i++) { if (kb.whether(works[i], _solidUi.ns.rdf('type'), foaf('PersonalProfileDocument'))) { const doc = works[i]; editable = outliner.UserInput.sparqler.editable(doc.uri, kb); if (!editable) { message += 'Your profile <' + _solidUi.utils.escapeForXML(doc.uri) + '> is not remotely editable.'; } else { profile = doc; break; } } } /* if (!profile) { say( message + '\nI couldn\'t find your editable personal profile document.' ) } else { say('Editing your profile ' + profile + '.') editable = outliner.UserInput.sparqler.editable(profile.uri, kb) } */ if (thisIsYou) { // This is about me // pass... @@ } else { // This is about someone else // My relationship with this person const cme = kb.canon(me); incoming = kb.whether(s, knows, cme); outgoing = false; const outgoingSt = kb.statementsMatching(cme, knows, s); if (outgoingSt.length) { outgoing = true; if (!profile) profile = outgoingSt[0].why; } } // About someone else } // me is defined // End of you and s let headerControls = { canEdit: viewerMode === 'owner', viewerMode }; const header = (0, _socialSections.createHeaderSection)(context, s, headerControls, { friendCount: uniqueFriends.length, mutualFriendCount, onSelectFriends: function () { setActivePanel('all-friends'); }, onSelectMutual: typeof mutualFriendCount === 'number' ? function () { setActivePanel('mutual'); } : undefined }, function () { return selectProfileData(context, s); }); header.classList.add('social-pane__header-section', 'flex-column'); socialPane.prepend(header); // div.appendChild(dom.createTextNode(plural(friends.length, 'acquaintance') +'. ')) // ///////////////////////////////////////////// Main block // // Should: Find the intersection and difference sets const friendDetailsByUri = new Map(); const hydrateFriendDetailsCache = function (friendNodes) { const nextCache = new Map(); friendNodes.forEach(friendNode => { if (!friendNode?.value || friendNode.value === s.value) return; nextCache.set(friendNode.value, toFriendDetails(kb, friendNode)); }); friendDetailsByUri.clear(); nextCache.forEach((value, key) => { friendDetailsByUri.set(key, value); }); }; hydrateFriendDetailsCache(uniqueFriends); const renderSupportingInfo = function (target, renderDom) { const friend = friendDetailsByUri.get(target.value); if (!friend) return null; const container = renderDom.createElement('div'); const jobAndOrganization = [friend.jobTitle, friend.organization].filter(Boolean).join(' | '); if (jobAndOrganization) { const jobLine = container.appendChild(renderDom.createElement('div')); jobLine.className = 'social-friend-job-org'; jobLine.textContent = jobAndOrganization; } if (friend.location) { const locationLine = container.appendChild(renderDom.createElement('div')); locationLine.className = 'social-friend-location'; locationLine.innerHTML = `${_icons.locationIcon} ${friend.location}`; } if (!container.childNodes.length) return null; return container; }; const renderNameSuffix = function (target, renderDom) { const pronouns = friendDetailsByUri.get(target.value)?.pronouns; if (!pronouns) return null; const suffix = renderDom.createElement('span'); suffix.className = 'social-friend-pronouns'; suffix.textContent = `(${pronouns})`; return suffix; }; const sEditable = outliner.UserInput.sparqler.editable(s.uri, kb); const mutualSection = me && !thisIsYou ? (0, _socialSections.createMutualSection)({ dom, subject: s, familiar, me, meUri, incoming, outgoing, editable: !!sEditable, profile, knows, mutualConnections, link, text, buildCheckboxForm, renderSupportingInfo, renderNameSuffix }) : { section: dom.createElement('section'), content: dom.createElement('div'), refreshMutualFriends: function () {} }; const mutualFriends = mutualSection.section; const mutualContent = mutualSection.content; if (me && !thisIsYou) { mutualFriends.setAttribute('style', 'display: none'); } else { mutualFriends.setAttribute('style', 'display: block'); } if (!mutualFriends.className) { mutualFriends.className = 'social-pane__mutual-friends social-primary__panel'; mutualFriends.id = 'social-panel-mutual'; mutualFriends.setAttribute('role', 'tabpanel'); mutualFriends.setAttribute('aria-labelledby', 'social-tab-mutual'); mutualContent.className = 'social-main social-main--mutual'; mutualFriends.appendChild(mutualContent); } primary.appendChild(mutualFriends); const allFriendsSection = (0, _socialSections.createAllFriendsSection)({ dom, subject: s, profile, editable: !!sEditable, renderSupportingInfo, renderNameSuffix }); const allFriends = allFriendsSection.section; const friendsList = allFriendsSection.friendsList; primary.appendChild(allFriends); const setActivePanel = function (panel) { const showMutual = panel === 'mutual'; mutualTab.classList.toggle('social-primary__tab--active', showMutual); mutualTab.setAttribute('aria-selected', String(showMutual)); mutualTab.tabIndex = showMutual ? 0 : -1; allFriendsTab.classList.toggle('social-primary__tab--active', !showMutual); allFriendsTab.setAttribute('aria-selected', String(!showMutual)); allFriendsTab.tabIndex = showMutual ? -1 : 0; mutualFriends.classList.toggle('social-primary__panel--active', showMutual); mutualFriends.setAttribute('aria-hidden', String(!showMutual)); allFriends.classList.toggle('social-primary__panel--active', !showMutual); allFriends.setAttribute('aria-hidden', String(showMutual)); }; setActivePanel('all-friends'); const applyViewerMode = function (mode) { const showMutualTab = mode === 'authenticated'; mutualTab.hidden = !showMutualTab; setActivePanel('all-friends'); }; mutualTab.addEventListener('click', function () { setActivePanel('mutual'); }); allFriendsTab.addEventListener('click', function () { setActivePanel('all-friends'); }); const refreshFriendsList = function () { const refresh = friendsList.refresh; if (typeof refresh !== 'function') return; refresh.call(friendsList); }; const refreshMutualFriends = function () { mutualSection.refreshMutualFriends(); }; (async () => { try { for await (const streamedFriends of streamFriends(context, s)) { friendDetailsByUri.clear(); streamedFriends.forEach(friend => { friendDetailsByUri.set(friend.url, friend); }); refreshFriendsList(); refreshMutualFriends(); } } catch { // Keep the initial snapshot if async friend loading fails. } })(); /* if ($rdf.keepThisCodeForLaterButDisableFerossConstantConditionPolice) { triageFriends(s) } */ // //////////////////////////////////// Basic info on left const preds2 = [_solidUi.ns.foaf('openid'), _solidUi.ns.foaf('nick')]; for (let i2 = 0; i2 < preds2.length; i2++) { const pred = preds2[i2]; const sts2 = kb.statementsMatching(s, pred); if (sts2.length === 0) { // if (editable) say("No home page set. Use the blue + icon at the bottom of the main view to add information.") } else { outliner.appendPropertyTRs(mutualContent, sts2, false, function (_pred) { return true; }); } } applyViewerMode('anonymous'); _solidLogic.authn.checkUser().then(webId => { const confirmedViewerMode = getViewerMode(s, webId); applyViewerMode(confirmedViewerMode); headerControls = { ...headerControls, canEdit: confirmedViewerMode === 'owner', viewerMode: confirmedViewerMode }; header.refreshSocialHeader?.(headerControls); }).catch(() => { applyViewerMode('anonymous'); headerControls = { ...headerControls, canEdit: false, viewerMode: 'anonymous' }; header.refreshSocialHeader?.(headerControls); }); return socialPane; } // render() }; // // ends // ***************** Social Pane Selectors **********/ /* Should move to another file, but will leave for now */ /* Will create a social pane folder or maybe repo later */ const FRIEND_BATCH_SIZE = 3; /* pronounsAsText and formatLocation were copied from HeadingSection selectors */ function pronounsAsText(store, subject) { let pronouns = store.anyJS(subject, _solidUi.ns.solid('preferredSubjectPronoun')) || ''; if (pronouns) { const them = store.anyJS(subject, _solidUi.ns.solid('preferredObjectPronoun')); if (them) { pronouns += '/' + them; } } return pronouns || ''; } function formatLocation(countryName, locality) { return countryName && locality ? `${locality}, ${countryName}` : countryName || locality || null; } function toFriendDetails(store, friendNode) { const name = store.anyValue(friendNode, _solidUi.ns.vcard('fn')) || store.anyValue(friendNode, _solidUi.ns.foaf('name')) || null; const nickname = store.anyValue(friendNode, _solidUi.ns.vcard('nickname')) || store.anyValue(friendNode, _solidUi.ns.foaf('nick')) || null; const dateOfBirth = store.anyValue(friendNode, _solidUi.ns.vcard('bday')) || null; const imageSrc = _solidUi.widgets.findImage(friendNode); const jobTitle = store.anyValue(friendNode, _solidUi.ns.vcard('role')) || null; const orgName = store.anyValue(friendNode, _solidUi.ns.vcard('organization-name')) || null; const primaryAddressEntryNode = store.any(friendNode, _solidUi.ns.vcard('hasAddress')); const address = primaryAddressEntryNode || null; const countryName = address != null ? store.anyValue(address, _solidUi.ns.vcard('country-name')) : null; const locality = address != null ? store.anyValue(address, _solidUi.ns.vcard('locality')) : null; const location = formatLocation(countryName, locality); const pronouns = pronounsAsText(store, friendNode); return { url: friendNode.value, imageUrl: imageSrc, name, nickname, jobTitle, organization: orgName, birthdate: dateOfBirth, location, pronouns, subjectNode: friendNode }; } async function* streamFriends(context, subject, batchSize = FRIEND_BATCH_SIZE) { const store = context.session.store; const fetcher = store?.fetcher; if (fetcher && typeof fetcher.load === 'function') { try { await fetcher.load(subject.doc()); } catch { // Continue with whatever is already in the store. } } const seen = new Set(); const friendNodes = store.each(subject, _solidUi.ns.foaf('knows'), null, subject.doc()); const uniqueFriendNodes = []; for (const friendNode of friendNodes) { const key = friendNode?.value; if (!key || seen.has(key) || subject.value === key) continue; seen.add(key); uniqueFriendNodes.push(friendNode); } const friends = []; for (const friendNode of uniqueFriendNodes) { if (fetcher && typeof fetcher.load === 'function') { try { await fetcher.load(friendNode.doc()); } catch { // Keep partial friend data when one linked document fails to load. } } friends.push(toFriendDetails(store, friendNode)); if (friends.length % batchSize === 0) { yield [...friends]; } } if (friends.length > 0 && friends.length % batchSize !== 0) { yield [...friends]; } } async function extractFriends(context, subject) { let latestFriends = null; for await (const friends of streamFriends(context, subject)) { latestFriends = friends; } return latestFriends; } function selectProfileData(context, subject) { const store = context.session.store; const name = store.anyValue(subject, _solidUi.ns.vcard('fn')) || store.anyValue(subject, _solidUi.ns.foaf('name')) || undefined; const nickname = store.anyValue(subject, _solidUi.ns.vcard('nickname')) || store.anyValue(subject, _solidUi.ns.foaf('nick')) || undefined; const dateOfBirth = store.anyValue(subject, _solidUi.ns.vcard('bday')) || undefined; const imageSrc = _solidUi.widgets.findImage(subject); const jobTitle = store.anyValue(subject, _solidUi.ns.vcard('role')) || undefined; const orgName = store.anyValue(subject, _solidUi.ns.vcard('organization-name')) || undefined; const primaryAddressEntryNode = store.any(subject, _solidUi.ns.vcard('hasAddress')); const address = primaryAddressEntryNode || null; const countryName = address != null ? store.anyValue(address, _solidUi.ns.vcard('country-name')) : undefined; const locality = address != null ? store.anyValue(address, _solidUi.ns.vcard('locality')) : undefined; const location = formatLocation(countryName, locality); const pronouns = pronounsAsText(store, subject); return { url: subject.value, imageUrl: imageSrc, name, nickname, jobTitle, organization: orgName, birthdate: dateOfBirth, location, pronouns }; } function getViewerMode(subject, currentUser = _solidLogic.authn.currentUser()) { const currentUserUri = typeof currentUser === 'string' ? currentUser : typeof currentUser === 'object' && currentUser !== null ? currentUser.value || currentUser.uri || null : null; let mode = 'anonymous'; if (currentUserUri === subject.value) mode = 'owner'; if (currentUserUri && currentUserUri !== subject.value) mode = 'authenticated'; return mode; }