solid-panes
Version:
Solid-compatible Panes: applets and views for the mashlib and databrowser
558 lines (543 loc) • 21.8 kB
JavaScript
"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;
}