@firedrill-tools/linkedin 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +189 -0
- package/firedrill/agent.target.json +16 -0
- package/firedrill/baseline.scenario.json +1834 -0
- package/firedrill/comment-throttled.scenario.json +11 -0
- package/firedrill/conformance.suite.json +27 -0
- package/firedrill/linkedin-comment-throttled.drill.json +51 -0
- package/firedrill/linkedin-comments-connection.drill.json +43 -0
- package/firedrill/linkedin-comments.drill.json +196 -0
- package/firedrill/linkedin-denied.drill.json +383 -0
- package/firedrill/linkedin-fresh-install.drill.json +43 -0
- package/firedrill/linkedin-identity.drill.json +73 -0
- package/firedrill/linkedin-invalid-token.drill.json +393 -0
- package/firedrill/linkedin-limited-scopes.drill.json +88 -0
- package/firedrill/linkedin-openid-only.drill.json +383 -0
- package/firedrill/linkedin-orgs-connection.drill.json +28 -0
- package/firedrill/linkedin-orgs-network.drill.json +193 -0
- package/firedrill/linkedin-post-lost-response.drill.json +61 -0
- package/firedrill/linkedin-posts-lifecycle.drill.json +228 -0
- package/firedrill/linkedin-reactions-metadata.drill.json +196 -0
- package/firedrill/linkedin-reactions-outage.drill.json +66 -0
- package/firedrill/linkedin-tight-limits.drill.json +228 -0
- package/firedrill/linkedin-ugc-legacy.drill.json +43 -0
- package/firedrill/linkedin-visibility-connection.drill.json +73 -0
- package/firedrill/linkedin-visibility-stranger.drill.json +88 -0
- package/firedrill/linkedin-wire-errors.drill.json +353 -0
- package/firedrill/post-lost-response.scenario.json +11 -0
- package/firedrill/reactions-outage.scenario.json +11 -0
- package/firedrill/tight-limits.scenario.json +16 -0
- package/firedrill/tools/linkedin/app/assets/ATTRIBUTION.md +28 -0
- package/firedrill/tools/linkedin/app/assets/fonts/OFL.txt +93 -0
- package/firedrill/tools/linkedin/app/assets/linkedin-in.svg +1 -0
- package/firedrill/tools/linkedin/app/assets/linkedin-wordmark.svg +1 -0
- package/firedrill/tools/linkedin/app/assets/linkedin.svg +1 -0
- package/firedrill/tools/linkedin/app/site/app.js +80 -0
- package/firedrill/tools/linkedin/app/site/assets/fonts/fira-sans-latin-400-normal.woff2 +0 -0
- package/firedrill/tools/linkedin/app/site/assets/fonts/fira-sans-latin-600-normal.woff2 +0 -0
- package/firedrill/tools/linkedin/app/site/assets/fonts/fira-sans-latin-700-normal.woff2 +0 -0
- package/firedrill/tools/linkedin/app/site/assets/fonts/fira-sans-latin-ext-400-normal.woff2 +0 -0
- package/firedrill/tools/linkedin/app/site/assets/fonts/fira-sans-latin-ext-600-normal.woff2 +0 -0
- package/firedrill/tools/linkedin/app/site/assets/fonts/fira-sans-latin-ext-700-normal.woff2 +0 -0
- package/firedrill/tools/linkedin/app/site/assets/linkedin-wordmark.svg +1 -0
- package/firedrill/tools/linkedin/app/site/assets/linkedin.svg +1 -0
- package/firedrill/tools/linkedin/app/site/base.css +87 -0
- package/firedrill/tools/linkedin/app/site/comment-box.js +119 -0
- package/firedrill/tools/linkedin/app/site/comments.css +59 -0
- package/firedrill/tools/linkedin/app/site/comments.js +188 -0
- package/firedrill/tools/linkedin/app/site/company.js +115 -0
- package/firedrill/tools/linkedin/app/site/composer.js +112 -0
- package/firedrill/tools/linkedin/app/site/feed.css +105 -0
- package/firedrill/tools/linkedin/app/site/feed.js +134 -0
- package/firedrill/tools/linkedin/app/site/icons.js +70 -0
- package/firedrill/tools/linkedin/app/site/index.html +58 -0
- package/firedrill/tools/linkedin/app/site/nav.css +51 -0
- package/firedrill/tools/linkedin/app/site/network.js +77 -0
- package/firedrill/tools/linkedin/app/site/overlay.css +62 -0
- package/firedrill/tools/linkedin/app/site/overlay.js +108 -0
- package/firedrill/tools/linkedin/app/site/pages.css +78 -0
- package/firedrill/tools/linkedin/app/site/post-actions.js +83 -0
- package/firedrill/tools/linkedin/app/site/post-menu.js +72 -0
- package/firedrill/tools/linkedin/app/site/post.css +82 -0
- package/firedrill/tools/linkedin/app/site/post.js +131 -0
- package/firedrill/tools/linkedin/app/site/profile.js +139 -0
- package/firedrill/tools/linkedin/app/site/reactions.js +46 -0
- package/firedrill/tools/linkedin/app/site/reactors.js +71 -0
- package/firedrill/tools/linkedin/app/site/shell.js +107 -0
- package/firedrill/tools/linkedin/app/site/store.js +62 -0
- package/firedrill/tools/linkedin/app/site/text.js +80 -0
- package/firedrill/tools/linkedin/app/site/ui.js +142 -0
- package/firedrill/tools/linkedin/behavior.mjs +66 -0
- package/firedrill/tools/linkedin/lib/access.mjs +69 -0
- package/firedrill/tools/linkedin/lib/auth.mjs +115 -0
- package/firedrill/tools/linkedin/lib/cascade.mjs +28 -0
- package/firedrill/tools/linkedin/lib/errors.mjs +50 -0
- package/firedrill/tools/linkedin/lib/message.mjs +43 -0
- package/firedrill/tools/linkedin/lib/paging.mjs +54 -0
- package/firedrill/tools/linkedin/lib/postinput.mjs +47 -0
- package/firedrill/tools/linkedin/lib/render.mjs +95 -0
- package/firedrill/tools/linkedin/lib/routes.mjs +219 -0
- package/firedrill/tools/linkedin/lib/social.mjs +25 -0
- package/firedrill/tools/linkedin/lib/store.mjs +54 -0
- package/firedrill/tools/linkedin/lib/text.mjs +55 -0
- package/firedrill/tools/linkedin/lib/urn.mjs +93 -0
- package/firedrill/tools/linkedin/lib/wire.mjs +125 -0
- package/firedrill/tools/linkedin/linkedin.tool.json +8935 -0
- package/firedrill/tools/linkedin/ops/comments-read.mjs +57 -0
- package/firedrill/tools/linkedin/ops/comments-write.mjs +104 -0
- package/firedrill/tools/linkedin/ops/feed.mjs +74 -0
- package/firedrill/tools/linkedin/ops/network.mjs +46 -0
- package/firedrill/tools/linkedin/ops/orgs.mjs +93 -0
- package/firedrill/tools/linkedin/ops/posts-edit.mjs +71 -0
- package/firedrill/tools/linkedin/ops/posts-read.mjs +54 -0
- package/firedrill/tools/linkedin/ops/posts-write.mjs +74 -0
- package/firedrill/tools/linkedin/ops/profile.mjs +38 -0
- package/firedrill/tools/linkedin/ops/reactions.mjs +78 -0
- package/firedrill/tools/linkedin/ops/social.mjs +34 -0
- package/firedrill/world.json +2585 -0
- package/firedrill.json +5 -0
- package/package.json +52 -0
- package/starter.json +1833 -0
- package/test/client.mjs +71 -0
- package/test/conformance.mjs +20 -0
- package/test/fixtures.mjs +182 -0
- package/test/flows/comments.mjs +116 -0
- package/test/flows/every.mjs +38 -0
- package/test/flows/faults.mjs +56 -0
- package/test/flows/identity.mjs +70 -0
- package/test/flows/orgs.mjs +107 -0
- package/test/flows/posts.mjs +196 -0
- package/test/flows/reactions.mjs +101 -0
- package/test/flows/wire.mjs +62 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Feed post card: actor line, commentary, article/reshare frames, social counts and the action bar.
|
|
2
|
+
import { el, call, relativeTime, avatar, iconButton } from "./ui.js";
|
|
3
|
+
import { icon } from "./icons.js";
|
|
4
|
+
import { reactionBadge } from "./reactions.js";
|
|
5
|
+
import { renderLittleText } from "./text.js";
|
|
6
|
+
import { session, isViewer, hrefForUrn, parseEntityUrn, rememberActor } from "./store.js";
|
|
7
|
+
import { showMenu, notSimulated } from "./overlay.js";
|
|
8
|
+
import { toggleComments } from "./comments.js";
|
|
9
|
+
import { openReactionsModal } from "./reactors.js";
|
|
10
|
+
|
|
11
|
+
const VISIBILITY = { PUBLIC: ["globe", "Anyone on or off LinkedIn"], CONNECTIONS: ["people", "Connections only"], LOGGED_IN: ["lock", "Members only"] };
|
|
12
|
+
|
|
13
|
+
function hostname(source) {
|
|
14
|
+
const match = /^[a-z]+:\/\/([^/?#:]+)/i.exec(String(source ?? ""));
|
|
15
|
+
return match ? match[1] : String(source ?? "");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function actorLine(actor, post, { compact = false } = {}) {
|
|
19
|
+
const isOrg = actor.kind === "organization";
|
|
20
|
+
const href = hrefForUrn(actor.urn);
|
|
21
|
+
const nameLine = el("span", { class: "post-actor__name-line" }, [
|
|
22
|
+
el("a", { class: "post-actor__name", text: actor.name, attrs: { href } }),
|
|
23
|
+
isViewer(actor.urn) ? el("span", { class: "post-actor__degree", text: " • You" }) : null,
|
|
24
|
+
]);
|
|
25
|
+
const [visIcon, visLabel] = VISIBILITY[post.visibility] ?? VISIBILITY.PUBLIC;
|
|
26
|
+
const time = el("span", { class: "post-actor__time" }, [
|
|
27
|
+
el("span", { text: relativeTime(post.publishedAt ?? post.createdAt) }),
|
|
28
|
+
post.lifecycleStateInfo?.isEditedByAuthor ? el("span", { text: " • Edited" }) : null,
|
|
29
|
+
el("span", { text: " • " }),
|
|
30
|
+
el("span", { class: "post-actor__vis", title: visLabel, attrs: { role: "img", "aria-label": visLabel } }, icon(visIcon)),
|
|
31
|
+
]);
|
|
32
|
+
return el("div", { class: `post-actor${compact ? " post-actor--compact" : ""}` }, [
|
|
33
|
+
el("a", { class: "post-actor__avatar", attrs: { href, "aria-label": actor.name, tabindex: "-1" } }, avatar(actor.name, actor.urn, compact ? 32 : 48, isOrg)),
|
|
34
|
+
el("div", { class: "post-actor__meta" }, [
|
|
35
|
+
nameLine,
|
|
36
|
+
actor.headline ? el("span", { class: "post-actor__headline", text: isOrg ? actor.headline : actor.headline }) : isOrg ? el("span", { class: "post-actor__headline", text: "Company page" }) : null,
|
|
37
|
+
time,
|
|
38
|
+
]),
|
|
39
|
+
]);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function commentary(text) {
|
|
43
|
+
const box = el("div", { class: "post-text" });
|
|
44
|
+
const inner = renderLittleText(el("span", { class: "post-text__inner", attrs: { dir: "ltr" } }), text);
|
|
45
|
+
box.append(inner);
|
|
46
|
+
const more = el("button", { class: "post-text__more", text: "…more", attrs: { type: "button" }, on: { click: () => { box.classList.add("post-text--expanded"); more.remove(); } } });
|
|
47
|
+
box.append(more);
|
|
48
|
+
requestAnimationFrame(() => { if (inner.scrollHeight <= inner.clientHeight + 2) more.remove(); });
|
|
49
|
+
return box;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function articleCard(article) {
|
|
53
|
+
return el("a", { class: "post-article", attrs: { href: "#/feed", "data-not-simulated": "Opening external links" } }, [
|
|
54
|
+
el("div", { class: "post-article__art", attrs: { "aria-hidden": "true" } }, icon("article")),
|
|
55
|
+
el("div", { class: "post-article__meta" }, [
|
|
56
|
+
el("span", { class: "post-article__title", text: article.title }),
|
|
57
|
+
el("span", { class: "post-article__source", text: hostname(article.source) }),
|
|
58
|
+
]),
|
|
59
|
+
]);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function canManage(authorUrn) {
|
|
63
|
+
if (isViewer(authorUrn)) return true;
|
|
64
|
+
const parsed = parseEntityUrn(authorUrn);
|
|
65
|
+
return parsed?.kind === "organization" && (session.viewer?.pages ?? []).some((p) => p.organizationUrn === authorUrn && p.roles.some((r) => r === "ADMINISTRATOR" || r === "CONTENT_ADMINISTRATOR"));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Build a post card. ctx: { onReload(): Promise } */
|
|
69
|
+
export function renderPostCard(entry, ctx) {
|
|
70
|
+
const { post, actor } = entry;
|
|
71
|
+
rememberActor(actor);
|
|
72
|
+
if (entry.reshareOf) rememberActor(entry.reshareOf.actor);
|
|
73
|
+
const state = { social: entry.social, viewerReaction: entry.viewerReaction };
|
|
74
|
+
const card = el("article", { class: "card feed-post", attrs: { "data-urn": post.id, "aria-label": `Post by ${actor.name}` } });
|
|
75
|
+
|
|
76
|
+
const menuButton = iconButton("ellipsis", `Open control menu for post by ${actor.name}`, () => showMenu(menuButton, postMenu(entry, ctx)), "feed-post__menu");
|
|
77
|
+
menuButton.setAttribute("aria-haspopup", "menu");
|
|
78
|
+
const hide = iconButton("close", `Hide post by ${actor.name}`, () => notSimulated("Hiding posts from your feed"), "feed-post__hide");
|
|
79
|
+
card.append(el("div", { class: "feed-post__controls" }, [menuButton, hide]));
|
|
80
|
+
card.append(actorLine(actor, post));
|
|
81
|
+
if (post.commentary) card.append(commentary(post.commentary));
|
|
82
|
+
if (post.content?.article) card.append(articleCard(post.content.article));
|
|
83
|
+
if (entry.reshareOf) {
|
|
84
|
+
const inner = el("div", { class: "post-reshare" }, [actorLine(entry.reshareOf.actor, entry.reshareOf.post, { compact: true })]);
|
|
85
|
+
if (entry.reshareOf.post.commentary) inner.append(commentary(entry.reshareOf.post.commentary));
|
|
86
|
+
if (entry.reshareOf.post.content?.article) inner.append(articleCard(entry.reshareOf.post.content.article));
|
|
87
|
+
card.append(inner);
|
|
88
|
+
} else if (post.reshareContext) {
|
|
89
|
+
card.append(el("div", { class: "post-reshare post-reshare--gone", text: "This content isn't available" }));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const counts = el("div", { class: "social-counts" });
|
|
93
|
+
const actions = el("div", { class: "social-actions" });
|
|
94
|
+
card.append(counts, actions);
|
|
95
|
+
const commentsHost = el("div", { class: "comments-host" });
|
|
96
|
+
card.append(commentsHost);
|
|
97
|
+
|
|
98
|
+
const paint = () => renderCounts(counts, entry, state, commentsHost);
|
|
99
|
+
paint();
|
|
100
|
+
renderActions(actions, entry, state, { paint, commentsHost, ctx });
|
|
101
|
+
card.refreshSocial = async () => {
|
|
102
|
+
try {
|
|
103
|
+
const meta = await call("social_metadata.get", { entity: post.id });
|
|
104
|
+
state.social = { ...state.social, reactionSummaries: meta.reactionSummaries, commentSummary: meta.commentSummary, commentsState: meta.commentsState };
|
|
105
|
+
paint();
|
|
106
|
+
} catch { /* counts stay as last read */ }
|
|
107
|
+
};
|
|
108
|
+
return card;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function renderCounts(host, entry, state, commentsHost) {
|
|
112
|
+
host.replaceChildren();
|
|
113
|
+
const summaries = Object.values(state.social.reactionSummaries ?? {}).sort((a, b) => b.count - a.count);
|
|
114
|
+
const total = summaries.reduce((sum, s) => sum + s.count, 0);
|
|
115
|
+
const comments = state.social.commentSummary?.count ?? 0;
|
|
116
|
+
const reposts = state.social.repostCount ?? 0;
|
|
117
|
+
if (total === 0 && comments === 0 && reposts === 0) { host.hidden = true; return; }
|
|
118
|
+
host.hidden = false;
|
|
119
|
+
if (total > 0) {
|
|
120
|
+
const badges = el("span", { class: "social-counts__badges" }, summaries.slice(0, 3).map((s) => reactionBadge(s.reactionType, 16)));
|
|
121
|
+
host.append(el("button", { class: "social-counts__reactions", attrs: { type: "button", "aria-label": `${total} reactions` }, on: { click: () => openReactionsModal(entry.post, state.social.reactionSummaries) } }, [badges, el("span", { text: String(total) })]));
|
|
122
|
+
}
|
|
123
|
+
const right = el("span", { class: "social-counts__right" });
|
|
124
|
+
if (comments > 0) right.append(el("button", { class: "social-counts__link", text: `${comments} comment${comments === 1 ? "" : "s"}`, attrs: { type: "button" }, on: { click: () => toggleComments(commentsHost, entry, { onChanged: () => commentsHost.closest(".feed-post")?.refreshSocial?.() }) } }));
|
|
125
|
+
if (comments > 0 && reposts > 0) right.append(el("span", { class: "social-counts__dot", text: "•", attrs: { "aria-hidden": "true" } }));
|
|
126
|
+
if (reposts > 0) right.append(el("span", { class: "social-counts__link social-counts__link--static", text: `${reposts} repost${reposts === 1 ? "" : "s"}` }));
|
|
127
|
+
host.append(right);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
import { renderActions } from "./post-actions.js";
|
|
131
|
+
import { postMenu } from "./post-menu.js";
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// Profile (/in/{id}): top card, contact info, and the Activity card with posts and drafts (own profile).
|
|
2
|
+
import { el, call, describe, avatar, newKey, toast, busy, relativeTime } from "./ui.js";
|
|
3
|
+
import { icon } from "./icons.js";
|
|
4
|
+
import { session, rememberPerson } from "./store.js";
|
|
5
|
+
import { littleTextToPlain } from "./text.js";
|
|
6
|
+
import { openModal, confirmDialog } from "./overlay.js";
|
|
7
|
+
import { openComposer } from "./composer.js";
|
|
8
|
+
import { footer } from "./feed.js";
|
|
9
|
+
|
|
10
|
+
const PAGE = 5;
|
|
11
|
+
|
|
12
|
+
export function renderProfile(main, personId) {
|
|
13
|
+
const self = session.viewer.personUrn === `urn:li:person:${personId}`;
|
|
14
|
+
const top = el("section", { class: "card profile-top", attrs: { "aria-busy": "true" } }, el("div", { class: "profile-top__loading" }, el("span", { class: "spinner", attrs: { "aria-label": "Loading profile" } })));
|
|
15
|
+
const activity = el("section", { class: "card profile-activity" });
|
|
16
|
+
main.replaceChildren(el("div", { class: "profile-layout" }, [el("div", { class: "profile-main" }, [top, activity]), el("aside", { class: "profile-aside" }, [
|
|
17
|
+
el("div", { class: "card aside-card" }, [el("h2", { class: "aside-card__title", text: "Profile language" }), el("p", { class: "muted", text: "English" })]),
|
|
18
|
+
el("div", { class: "card aside-card" }, [el("h2", { class: "aside-card__title", text: "Public profile & URL" }), el("p", { class: "muted", text: "Custom profile URLs aren't simulated." })]),
|
|
19
|
+
footer(),
|
|
20
|
+
])]));
|
|
21
|
+
|
|
22
|
+
const load = async () => {
|
|
23
|
+
try {
|
|
24
|
+
const person = self ? await call("profile.me") : await call("people.get", { personId });
|
|
25
|
+
rememberPerson(person);
|
|
26
|
+
paintTop(top, person, self);
|
|
27
|
+
if (self) paintActivity(activity, person, load); else paintOther(activity, person);
|
|
28
|
+
} catch (error) {
|
|
29
|
+
top.removeAttribute("aria-busy");
|
|
30
|
+
top.replaceChildren(el("div", { class: "state-card", attrs: { role: "alert" } }, [
|
|
31
|
+
el("h1", { class: "state-card__title", text: error.code === "NOT_FOUND" ? "This profile is not available" : "Couldn't load this profile" }),
|
|
32
|
+
el("p", { class: "state-card__text", text: describe(error) }),
|
|
33
|
+
el("a", { class: "btn btn--secondary", text: "Go to your feed", attrs: { href: "#/feed" } }),
|
|
34
|
+
]));
|
|
35
|
+
activity.hidden = true;
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
void load();
|
|
39
|
+
return { refresh: load };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function paintTop(top, person, self) {
|
|
43
|
+
const name = `${person.localizedFirstName} ${person.localizedLastName}`.trim();
|
|
44
|
+
top.removeAttribute("aria-busy");
|
|
45
|
+
const actions = self
|
|
46
|
+
? [el("button", { class: "btn btn--primary btn--sm", text: "Open to", attrs: { type: "button", "data-not-simulated": "Open to work" } }), el("button", { class: "btn btn--secondary btn--sm", text: "Add profile section", attrs: { type: "button", "data-not-simulated": "Profile sections" } }), el("button", { class: "btn btn--muted btn--sm", text: "Resources", attrs: { type: "button", "data-not-simulated": "Profile resources" } })]
|
|
47
|
+
: [el("button", { class: "btn btn--primary btn--sm", attrs: { type: "button", "data-not-simulated": "Messaging" } }, [icon("send"), el("span", { text: "Message" })]), el("button", { class: "btn btn--muted btn--sm", text: "More", attrs: { type: "button", "data-not-simulated": "Profile actions" } })];
|
|
48
|
+
const contact = el("button", { class: "profile-top__contact", text: "Contact info", attrs: { type: "button" }, on: { click: () => showContact(name, person, self) } });
|
|
49
|
+
top.replaceChildren(
|
|
50
|
+
el("div", { class: "profile-top__cover", attrs: { "aria-hidden": "true" } }),
|
|
51
|
+
el("div", { class: "profile-top__body" }, [
|
|
52
|
+
el("div", { class: "profile-top__avatar" }, avatar(name, `urn:li:person:${person.id}`, 152)),
|
|
53
|
+
self ? el("button", { class: "icon-btn profile-top__edit", title: "Edit intro", attrs: { type: "button", "aria-label": "Edit intro", "data-not-simulated": "Editing your profile intro" } }, icon("pencil")) : null,
|
|
54
|
+
el("h1", { class: "profile-top__name", text: name }),
|
|
55
|
+
person.localizedHeadline ? el("p", { class: "profile-top__headline", text: person.localizedHeadline }) : null,
|
|
56
|
+
el("p", { class: "profile-top__meta" }, [contact]),
|
|
57
|
+
el("p", { class: "profile-top__connections" }, self
|
|
58
|
+
? el("a", { text: `${session.viewer.connectionCount} connection${session.viewer.connectionCount === 1 ? "" : "s"}`, attrs: { href: "#/mynetwork" } })
|
|
59
|
+
: el("span", { class: "muted", text: "1st degree connection" })),
|
|
60
|
+
el("div", { class: "profile-top__actions" }, actions),
|
|
61
|
+
]),
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function showContact(name, person, self) {
|
|
66
|
+
const modal = openModal({ title: name });
|
|
67
|
+
modal.body.append(el("h3", { class: "contact__title", text: "Contact Info" }), el("div", { class: "contact__row" }, [icon("nav-home"), el("div", {}, [el("div", { class: "t-bold", text: "Your Profile" }), el("span", { class: "muted", text: `linkedin.com/in/${person.vanityName}` })])]));
|
|
68
|
+
if (!self) return;
|
|
69
|
+
try {
|
|
70
|
+
const info = await call("profile.userinfo");
|
|
71
|
+
if (info.email) modal.body.append(el("div", { class: "contact__row" }, [icon("nav-messaging"), el("div", {}, [el("div", { class: "t-bold", text: "Email" }), el("span", { class: "muted", text: info.email })])]));
|
|
72
|
+
else modal.body.append(el("p", { class: "muted", text: "No e-mail address is shared with this app." }));
|
|
73
|
+
} catch (error) {
|
|
74
|
+
modal.body.append(el("p", { class: "comments__error", attrs: { role: "alert" }, text: describe(error) }));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function paintOther(activity, person) {
|
|
79
|
+
activity.hidden = false;
|
|
80
|
+
activity.replaceChildren(el("div", { class: "section-head" }, el("h2", { class: "section-head__title", text: "Activity" })), el("p", { class: "profile-activity__empty", text: `${person.localizedFirstName}'s posts appear in your feed when they share them with you.` }));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function paintActivity(activity, person, reload) {
|
|
84
|
+
const personUrn = `urn:li:person:${person.id}`;
|
|
85
|
+
let tab = "PUBLISHED";
|
|
86
|
+
const list = el("div", { class: "activity-list" });
|
|
87
|
+
const more = el("div", { class: "activity-more" });
|
|
88
|
+
const tabButtons = [["PUBLISHED", "Posts"], ["DRAFT", "Drafts"]].map(([key, label]) => el("button", { class: "chip", text: label, attrs: { type: "button", "aria-pressed": String(key === tab) }, on: { click: () => { tab = key; tabButtons.forEach((b) => b.setAttribute("aria-pressed", String(b.textContent === label))); void load(true); } } }));
|
|
89
|
+
let start = 0;
|
|
90
|
+
let collected = 0;
|
|
91
|
+
|
|
92
|
+
const load = async (reset) => {
|
|
93
|
+
if (reset) { start = 0; collected = 0; list.replaceChildren(el("span", { class: "spinner", attrs: { "aria-label": "Loading activity" } })); }
|
|
94
|
+
more.replaceChildren();
|
|
95
|
+
try {
|
|
96
|
+
// Drafts and posts share one author listing; page until a screenful of the chosen state or the end.
|
|
97
|
+
let shown = 0;
|
|
98
|
+
let done = false;
|
|
99
|
+
if (reset) list.replaceChildren();
|
|
100
|
+
while (shown < PAGE && !done) {
|
|
101
|
+
const page = await call("posts.list_by_author", { author: personUrn, viewContext: "AUTHOR", sortBy: "LAST_MODIFIED", start, count: 10 });
|
|
102
|
+
start += page.elements.length;
|
|
103
|
+
done = page.elements.length === 0 || !page.paging.links.some((l) => l.rel === "next");
|
|
104
|
+
for (const post of page.elements.filter((p) => p.lifecycleState === tab)) { list.append(activityRow(post, reload)); shown += 1; collected += 1; }
|
|
105
|
+
}
|
|
106
|
+
if (collected === 0) list.append(el("p", { class: "profile-activity__empty", text: tab === "DRAFT" ? "You have no drafts." : "You haven't posted yet. Posts you share will be displayed here." }));
|
|
107
|
+
if (!done) more.append(el("button", { class: "btn btn--tertiary", text: tab === "DRAFT" ? "Show more drafts" : "Show more posts", attrs: { type: "button" }, on: { click: (e) => busy(e.currentTarget, () => load(false)) } }));
|
|
108
|
+
} catch (error) {
|
|
109
|
+
if (reset) list.replaceChildren();
|
|
110
|
+
list.append(el("p", { class: "comments__error", attrs: { role: "alert" }, text: describe(error) }));
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
activity.hidden = false;
|
|
115
|
+
activity.replaceChildren(
|
|
116
|
+
el("div", { class: "section-head" }, [el("div", {}, [el("h2", { class: "section-head__title", text: "Activity" }), el("a", { class: "section-head__sub", text: `${session.viewer.connectionCount} connections`, attrs: { href: "#/mynetwork" } })]), el("button", { class: "btn btn--secondary btn--sm", text: "Create a post", attrs: { type: "button" }, on: { click: () => openComposer({ onDone: () => load(true) }) } })]),
|
|
117
|
+
el("div", { class: "chips", attrs: { role: "group", "aria-label": "Activity type" } }, tabButtons),
|
|
118
|
+
list, more,
|
|
119
|
+
);
|
|
120
|
+
void load(true);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function activityRow(post, reload) {
|
|
124
|
+
const draft = post.lifecycleState === "DRAFT";
|
|
125
|
+
const row = el("article", { class: "activity-row" }, [
|
|
126
|
+
el("p", { class: "activity-row__meta" }, [el("span", { text: `${session.viewer.name} ${draft ? "saved a draft" : "posted this"} • ${relativeTime(post.lastModifiedAt)}` }), draft ? el("span", { class: "badge badge--draft", text: "Draft" }) : null]),
|
|
127
|
+
el("p", { class: "activity-row__text", text: littleTextToPlain(post.commentary) || (post.content?.article?.title ?? "") }),
|
|
128
|
+
]);
|
|
129
|
+
if (draft) {
|
|
130
|
+
const publish = el("button", { class: "btn btn--primary btn--sm", text: "Publish", attrs: { type: "button" } });
|
|
131
|
+
publish.addEventListener("click", () => busy(publish, async () => {
|
|
132
|
+
if (!(await confirmDialog({ title: "Publish draft?", message: "This draft will be posted and shown to your network.", confirmLabel: "Publish" }))) return;
|
|
133
|
+
try { await call("posts.update", { postUrn: post.id, lifecycleState: "PUBLISHED" }, newKey()); toast("Post successful."); await reload(); }
|
|
134
|
+
catch (error) { toast(describe(error), { error: true }); }
|
|
135
|
+
}));
|
|
136
|
+
row.append(el("div", { class: "activity-row__actions" }, publish));
|
|
137
|
+
}
|
|
138
|
+
return row;
|
|
139
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// The six reaction types with their product labels, colours and badge glyphs (original simple paths).
|
|
2
|
+
const NS = "http://www.w3.org/2000/svg";
|
|
3
|
+
|
|
4
|
+
export const REACTIONS = [
|
|
5
|
+
{ type: "LIKE", label: "Like", colour: "#378fe9", verb: "Like",
|
|
6
|
+
d: "M11.6 7.1H9.3l.4-2.3c.1-.7-.3-1.4-1-1.5h-.3L6 6.6V12h4.6c.5 0 .9-.3 1-.8l.7-3c.1-.6-.3-1.1-.7-1.1zM4 6.6H3.3v5.4H5V6.6z" },
|
|
7
|
+
{ type: "PRAISE", label: "Celebrate", colour: "#6dae4f", verb: "Celebrate",
|
|
8
|
+
d: "M6.3 3.5l.6 1.8M9.7 3.5l-.6 1.8M8 2.8v1.9M4.6 7.4l2.8-1.6 1.1 1.2 1.9-.8 1.8 2.8-1.2 3.3H7.2L4.3 9.4c-.4-.4-.3-1.1.3-2z" },
|
|
9
|
+
{ type: "APPRECIATION", label: "Support", colour: "#a872e8", verb: "Support",
|
|
10
|
+
d: "M8 4.9c.6-1.1 2.6-1.2 2.9.3.2 1-.8 2-2.9 3.3-2.1-1.3-3.1-2.3-2.9-3.3.3-1.5 2.3-1.4 2.9-.3zM3.2 9.2l1.8-.5 2.4 1h2.2c.5 0 .7.6.3.9l-.6.2h-2l3.4.1 1.8-1.2c.4-.2.8.3.5.6l-2.4 2.2H6.2L3.2 12z" },
|
|
11
|
+
{ type: "EMPATHY", label: "Love", colour: "#df704d", verb: "Love",
|
|
12
|
+
d: "M8 12.5S3 9.6 3 6.3C3 4.9 4.1 3.8 5.4 3.8c1.1 0 2 .6 2.6 1.5.6-.9 1.5-1.5 2.6-1.5 1.3 0 2.4 1.1 2.4 2.5 0 3.3-5 6.2-5 6.2z" },
|
|
13
|
+
{ type: "INTEREST", label: "Insightful", colour: "#f5bb5c", verb: "Insightful",
|
|
14
|
+
d: "M8 2.8a3.6 3.6 0 00-2.2 6.4c.4.3.6.8.6 1.3v.4h3.2v-.4c0-.5.2-1 .6-1.3A3.6 3.6 0 008 2.8zM6.4 11.7h3.2v.8c0 .4-.4.8-.8.8H7.2c-.4 0-.8-.4-.8-.8z" },
|
|
15
|
+
{ type: "ENTERTAINMENT", label: "Funny", colour: "#44bfd3", verb: "Funny",
|
|
16
|
+
d: "M8 2.9a5.1 5.1 0 100 10.2A5.1 5.1 0 008 2.9zM6.1 5.6a.8.8 0 110 1.6.8.8 0 010-1.6zm3.8 0a.8.8 0 110 1.6.8.8 0 010-1.6zM4.9 8.6h6.2A3.2 3.2 0 018 11.4a3.2 3.2 0 01-3.1-2.8z" },
|
|
17
|
+
];
|
|
18
|
+
export const REACTION_BY_TYPE = new Map(REACTIONS.map((r) => [r.type, r]));
|
|
19
|
+
|
|
20
|
+
/** A coloured circular badge (size px) for a reaction type. */
|
|
21
|
+
export function reactionBadge(type, size = 16) {
|
|
22
|
+
const reaction = REACTION_BY_TYPE.get(type) ?? REACTIONS[0];
|
|
23
|
+
const svg = document.createElementNS(NS, "svg");
|
|
24
|
+
svg.setAttribute("viewBox", "0 0 16 16");
|
|
25
|
+
svg.setAttribute("width", String(size));
|
|
26
|
+
svg.setAttribute("height", String(size));
|
|
27
|
+
svg.setAttribute("class", `reaction-badge reaction-badge--${reaction.type.toLowerCase()}`);
|
|
28
|
+
svg.setAttribute("role", "img");
|
|
29
|
+
svg.setAttribute("aria-label", reaction.label);
|
|
30
|
+
const circle = document.createElementNS(NS, "circle");
|
|
31
|
+
circle.setAttribute("cx", "8");
|
|
32
|
+
circle.setAttribute("cy", "8");
|
|
33
|
+
circle.setAttribute("r", "8");
|
|
34
|
+
circle.setAttribute("fill", reaction.colour);
|
|
35
|
+
const path = document.createElementNS(NS, "path");
|
|
36
|
+
path.setAttribute("d", reaction.d);
|
|
37
|
+
if (reaction.type === "PRAISE") {
|
|
38
|
+
path.setAttribute("fill", "#fff");
|
|
39
|
+
path.setAttribute("stroke", "#fff");
|
|
40
|
+
path.setAttribute("stroke-width", "0.9");
|
|
41
|
+
path.setAttribute("stroke-linecap", "round");
|
|
42
|
+
path.setAttribute("stroke-linejoin", "round");
|
|
43
|
+
} else path.setAttribute("fill", reaction.type === "INTEREST" ? "#6b3e00" : "#fff");
|
|
44
|
+
svg.append(circle, path);
|
|
45
|
+
return svg;
|
|
46
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Reactions modal: All / per-type tabs with counts; rows page through reactions.list (newest first).
|
|
2
|
+
import { el, call, describe, avatar } from "./ui.js";
|
|
3
|
+
import { REACTIONS, reactionBadge } from "./reactions.js";
|
|
4
|
+
import { openModal } from "./overlay.js";
|
|
5
|
+
import { resolveEntity, hrefForUrn } from "./store.js";
|
|
6
|
+
|
|
7
|
+
const PAGE = 20;
|
|
8
|
+
|
|
9
|
+
export function openReactionsModal(post, summaries = {}) {
|
|
10
|
+
const modal = openModal({ title: "Reactions", className: "modal--reactors" });
|
|
11
|
+
const total = Object.values(summaries).reduce((sum, s) => sum + s.count, 0);
|
|
12
|
+
const tabs = el("div", { class: "reactors__tabs", attrs: { role: "tablist" } });
|
|
13
|
+
const list = el("ul", { class: "reactors__list", attrs: { role: "tabpanel" } });
|
|
14
|
+
const status = el("div", { class: "reactors__status", attrs: { role: "status" } });
|
|
15
|
+
modal.body.append(tabs, list, status);
|
|
16
|
+
|
|
17
|
+
const rows = []; // every loaded reaction, newest first
|
|
18
|
+
let nextStart = 0;
|
|
19
|
+
let done = false;
|
|
20
|
+
let filter = null;
|
|
21
|
+
|
|
22
|
+
const addTab = (type, label, count) => {
|
|
23
|
+
const tab = el("button", { class: "reactors__tab", attrs: { type: "button", role: "tab", "aria-selected": String(type === filter), "aria-label": `${label} ${count}` } }, [
|
|
24
|
+
type ? reactionBadge(type, 16) : null, el("span", { text: type ? String(count) : `All ${count}` }),
|
|
25
|
+
]);
|
|
26
|
+
tab.addEventListener("click", () => { filter = type; for (const t of tabs.children) t.setAttribute("aria-selected", String(t === tab)); void paint(); });
|
|
27
|
+
tabs.append(tab);
|
|
28
|
+
};
|
|
29
|
+
addTab(null, "All", total);
|
|
30
|
+
for (const reaction of REACTIONS) if (summaries[reaction.type]) addTab(reaction.type, reaction.label, summaries[reaction.type].count);
|
|
31
|
+
|
|
32
|
+
const loadPage = async () => {
|
|
33
|
+
const page = await call("reactions.list", { entity: post.id, sort: "REVERSE_CHRONOLOGICAL", start: nextStart, count: PAGE });
|
|
34
|
+
rows.push(...page.elements);
|
|
35
|
+
nextStart += page.elements.length;
|
|
36
|
+
done = page.elements.length === 0 || !page.paging.links.some((link) => link.rel === "next");
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
let painting = 0;
|
|
40
|
+
const paint = async () => {
|
|
41
|
+
const token = ++painting;
|
|
42
|
+
status.replaceChildren(el("span", { class: "spinner", attrs: { "aria-label": "Loading reactions" } }));
|
|
43
|
+
try {
|
|
44
|
+
// A per-type tab keeps paging until it has a screenful of matches or the list ends, so nothing is hidden.
|
|
45
|
+
const visible = () => rows.filter((r) => !filter || r.reactionType === filter);
|
|
46
|
+
if (rows.length === 0 && !done) await loadPage();
|
|
47
|
+
while (filter && !done && visible().length < PAGE) await loadPage();
|
|
48
|
+
if (token !== painting) return;
|
|
49
|
+
list.replaceChildren();
|
|
50
|
+
for (const reaction of visible()) list.append(await renderRow(reaction));
|
|
51
|
+
if (token !== painting) return;
|
|
52
|
+
status.replaceChildren();
|
|
53
|
+
if (visible().length === 0) status.append(el("p", { class: "muted", text: "No reactions yet." }));
|
|
54
|
+
if (!done) status.append(el("button", { class: "btn btn--tertiary btn--sm", text: "Show more results", attrs: { type: "button" }, on: { click: async () => { await loadPage(); await paint(); } } }));
|
|
55
|
+
} catch (error) {
|
|
56
|
+
status.replaceChildren(el("p", { class: "comments__error", attrs: { role: "alert" }, text: describe(error) }));
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
void paint();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function renderRow(reaction) {
|
|
63
|
+
const who = await resolveEntity(reaction.created.actor);
|
|
64
|
+
return el("li", { class: "reactors__row" }, [
|
|
65
|
+
el("a", { class: "reactors__avatar", attrs: { href: hrefForUrn(reaction.created.actor), "aria-label": who.name } }, [avatar(who.name, reaction.created.actor, 48, who.kind === "organization"), reactionBadge(reaction.reactionType, 16)]),
|
|
66
|
+
el("div", { class: "reactors__meta" }, [
|
|
67
|
+
el("a", { class: "reactors__name", text: who.name, attrs: { href: hrefForUrn(reaction.created.actor) } }),
|
|
68
|
+
el("div", { class: "reactors__sub", text: who.headline ?? "" }),
|
|
69
|
+
]),
|
|
70
|
+
]);
|
|
71
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Global navigation: active item, the Me menu and the search typeahead (connections + exact company page).
|
|
2
|
+
import { $, $$, el, call, avatar } from "./ui.js";
|
|
3
|
+
import { session, rememberOrganization } from "./store.js";
|
|
4
|
+
import { notSimulated } from "./overlay.js";
|
|
5
|
+
|
|
6
|
+
export function setActiveNav(key) {
|
|
7
|
+
for (const item of $$("[data-nav]")) {
|
|
8
|
+
if (item.dataset.nav === key) item.setAttribute("aria-current", "page");
|
|
9
|
+
else item.removeAttribute("aria-current");
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function paintMe() {
|
|
14
|
+
const v = session.viewer;
|
|
15
|
+
const holder = $("#me-avatar");
|
|
16
|
+
const fresh = avatar(v.name, v.personUrn, 24);
|
|
17
|
+
fresh.id = "me-avatar";
|
|
18
|
+
holder.replaceWith(fresh);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function closeMe() {
|
|
22
|
+
$("#me-menu").hidden = true;
|
|
23
|
+
$("#me-button").setAttribute("aria-expanded", "false");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function openMe() {
|
|
27
|
+
const v = session.viewer;
|
|
28
|
+
const menu = $("#me-menu");
|
|
29
|
+
const personId = v.personUrn.split(":").pop();
|
|
30
|
+
menu.replaceChildren(
|
|
31
|
+
el("div", { class: "me-menu__head" }, [avatar(v.name, v.personUrn, 56), el("div", {}, [el("div", { class: "me-menu__name", text: v.name }), v.headline ? el("div", { class: "me-menu__headline", text: v.headline }) : null])]),
|
|
32
|
+
el("div", { class: "me-menu__view" }, el("a", { class: "btn btn--secondary btn--sm", text: "View Profile", attrs: { href: `#/in/${encodeURIComponent(personId)}`, role: "menuitem" }, on: { click: closeMe } })),
|
|
33
|
+
el("div", { class: "me-menu__section" }, [
|
|
34
|
+
el("div", { class: "me-menu__title", text: "Account" }),
|
|
35
|
+
...["Try Premium for $0", "Settings & Privacy", "Help", "Language"].map((label) => el("button", { class: "me-menu__link", text: label, attrs: { type: "button", role: "menuitem" }, on: { click: () => { closeMe(); notSimulated(label); } } })),
|
|
36
|
+
]),
|
|
37
|
+
el("div", { class: "me-menu__section" }, [
|
|
38
|
+
el("div", { class: "me-menu__title", text: "Manage" }),
|
|
39
|
+
el("button", { class: "me-menu__link", text: "Posts & Activity", attrs: { type: "button", role: "menuitem" }, on: { click: () => { closeMe(); location.hash = `#/in/${encodeURIComponent(personId)}`; } } }),
|
|
40
|
+
...v.pages.map((p) => el("a", { class: "me-menu__link", text: `Company: ${p.name}`, attrs: { href: `#/company/${encodeURIComponent(p.vanityName)}`, role: "menuitem" }, on: { click: closeMe } })),
|
|
41
|
+
el("button", { class: "me-menu__link", text: "Job Posting Account", attrs: { type: "button", role: "menuitem" }, on: { click: () => { closeMe(); notSimulated("Job posting"); } } }),
|
|
42
|
+
]),
|
|
43
|
+
el("div", { class: "me-menu__section" }, el("button", { class: "me-menu__link", text: "Sign Out", title: "Signing out is not simulated", attrs: { type: "button", role: "menuitem", disabled: true } })),
|
|
44
|
+
);
|
|
45
|
+
menu.hidden = false;
|
|
46
|
+
$("#me-button").setAttribute("aria-expanded", "true");
|
|
47
|
+
menu.querySelector("a, button:not([disabled])")?.focus();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function initShell() {
|
|
51
|
+
$("#me-button").addEventListener("click", () => ($("#me-menu").hidden ? openMe() : closeMe()));
|
|
52
|
+
document.addEventListener("mousedown", (event) => {
|
|
53
|
+
if (!$("#me-menu").hidden && !event.target.closest(".nav-me")) closeMe();
|
|
54
|
+
if (!event.target.closest(".search")) hideTypeahead();
|
|
55
|
+
});
|
|
56
|
+
document.addEventListener("keydown", (event) => { if (event.key === "Escape") { closeMe(); hideTypeahead(); } });
|
|
57
|
+
const input = $("#search-input");
|
|
58
|
+
let timer;
|
|
59
|
+
input.addEventListener("input", () => { clearTimeout(timer); timer = setTimeout(() => void search(input.value), 180); });
|
|
60
|
+
input.addEventListener("focus", () => { if (input.value.trim()) void search(input.value); });
|
|
61
|
+
input.addEventListener("keydown", (event) => {
|
|
62
|
+
const items = $$(".typeahead__item", $("#search-results"));
|
|
63
|
+
if (!items.length || !["ArrowDown", "ArrowUp", "Enter"].includes(event.key)) return;
|
|
64
|
+
event.preventDefault();
|
|
65
|
+
let index = items.findIndex((i) => i.getAttribute("aria-selected") === "true");
|
|
66
|
+
if (event.key === "Enter") { (items[index] ?? items[0]).click(); return; }
|
|
67
|
+
index = event.key === "ArrowDown" ? (index + 1) % items.length : (index - 1 + items.length) % items.length;
|
|
68
|
+
items.forEach((item, i) => item.setAttribute("aria-selected", String(i === index)));
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function hideTypeahead() {
|
|
73
|
+
$("#search-results").hidden = true;
|
|
74
|
+
$("#search-input").setAttribute("aria-expanded", "false");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let searchToken = 0;
|
|
78
|
+
async function search(raw) {
|
|
79
|
+
const query = raw.trim();
|
|
80
|
+
const box = $("#search-results");
|
|
81
|
+
if (!query) { hideTypeahead(); return; }
|
|
82
|
+
const token = ++searchToken;
|
|
83
|
+
const vanity = query.toLowerCase().replace(/\s+/g, "-");
|
|
84
|
+
const [people, companies] = await Promise.all([
|
|
85
|
+
call("connections.list", { query, start: 0, count: 6 }).catch(() => null),
|
|
86
|
+
/^[a-z0-9-]{1,100}$/.test(vanity) ? call("organizations.find_by_vanity_name", { vanityName: vanity }).catch(() => null) : Promise.resolve(null),
|
|
87
|
+
]);
|
|
88
|
+
if (token !== searchToken) return;
|
|
89
|
+
const go = (href) => () => { hideTypeahead(); $("#search-input").value = ""; location.hash = href; };
|
|
90
|
+
box.replaceChildren();
|
|
91
|
+
const orgs = companies?.elements ?? [];
|
|
92
|
+
const members = people?.elements ?? [];
|
|
93
|
+
if (members.length) box.append(el("div", { class: "typeahead__heading", text: "Your connections" }));
|
|
94
|
+
for (const { person } of members) {
|
|
95
|
+
const name = `${person.localizedFirstName} ${person.localizedLastName}`;
|
|
96
|
+
box.append(el("button", { class: "typeahead__item", attrs: { type: "button", role: "option", "aria-selected": "false" }, on: { click: go(`#/in/${encodeURIComponent(person.id)}`) } }, [avatar(name, `urn:li:person:${person.id}`, 32), el("span", { class: "typeahead__text" }, [el("span", { class: "typeahead__name", text: name }), el("span", { class: "typeahead__sub", text: ` • 1st${person.localizedHeadline ? ` • ${person.localizedHeadline}` : ""}` })])]));
|
|
97
|
+
}
|
|
98
|
+
if (orgs.length) box.append(el("div", { class: "typeahead__heading", text: "Companies" }));
|
|
99
|
+
for (const org of orgs) {
|
|
100
|
+
rememberOrganization(org);
|
|
101
|
+
box.append(el("button", { class: "typeahead__item", attrs: { type: "button", role: "option", "aria-selected": "false" }, on: { click: go(`#/company/${encodeURIComponent(org.vanityName)}`) } }, [avatar(org.localizedName, `urn:li:organization:${org.id}`, 32, true), el("span", { class: "typeahead__text" }, [el("span", { class: "typeahead__name", text: org.localizedName }), el("span", { class: "typeahead__sub", text: " • Company" })])]));
|
|
102
|
+
}
|
|
103
|
+
if (!members.length && !orgs.length) box.append(el("p", { class: "typeahead__empty", text: people === null ? "Search is unavailable right now." : `No results for "${query.slice(0, 80)}"` }));
|
|
104
|
+
box.append(el("button", { class: "typeahead__footer", text: "See all results", attrs: { type: "button", "data-not-simulated": "Full search results" } }));
|
|
105
|
+
box.hidden = false;
|
|
106
|
+
$("#search-input").setAttribute("aria-expanded", "true");
|
|
107
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Session facts (viewer from feed.list) and name lookups for person/organization URNs, cached per revision.
|
|
2
|
+
import { call } from "./ui.js";
|
|
3
|
+
|
|
4
|
+
export const session = { viewer: null, revision: null, feedPaging: null };
|
|
5
|
+
const people = new Map(); // personId -> {name, headline, vanityName}
|
|
6
|
+
const orgs = new Map(); // orgId -> {name, vanityName}
|
|
7
|
+
const pending = new Map();
|
|
8
|
+
|
|
9
|
+
export function resetCaches() { people.clear(); orgs.clear(); pending.clear(); }
|
|
10
|
+
|
|
11
|
+
export function parseEntityUrn(urn) {
|
|
12
|
+
const match = /^urn:li:(person|organization):([A-Za-z0-9_-]{1,64})$/.exec(String(urn ?? ""));
|
|
13
|
+
return match ? { kind: match[1], id: match[2] } : null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function rememberActor(actor) {
|
|
17
|
+
const parsed = parseEntityUrn(actor?.urn);
|
|
18
|
+
if (!parsed) return;
|
|
19
|
+
const record = { name: actor.name, headline: actor.headline ?? null, vanityName: actor.vanityName };
|
|
20
|
+
(parsed.kind === "person" ? people : orgs).set(parsed.id, record);
|
|
21
|
+
}
|
|
22
|
+
export function rememberOrganization(org) {
|
|
23
|
+
if (org && org.id !== undefined) orgs.set(String(org.id), { name: org.localizedName ?? "", vanityName: org.vanityName ?? "" });
|
|
24
|
+
}
|
|
25
|
+
export function rememberPerson(person) {
|
|
26
|
+
if (person?.id) people.set(person.id, { name: `${person.localizedFirstName} ${person.localizedLastName}`.trim(), headline: person.localizedHeadline ?? null, vanityName: person.vanityName });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function isViewer(urn) { return Boolean(session.viewer) && urn === session.viewer.personUrn; }
|
|
30
|
+
/** Pages the viewer can act as (post/comment/react for). */
|
|
31
|
+
export function actingPages() {
|
|
32
|
+
const allowed = new Set(["ADMINISTRATOR", "CONTENT_ADMINISTRATOR", "DIRECT_SPONSORED_CONTENT_POSTER"]);
|
|
33
|
+
return (session.viewer?.pages ?? []).filter((p) => p.roles.some((r) => allowed.has(r)));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function hrefForUrn(urn) {
|
|
37
|
+
const parsed = parseEntityUrn(urn);
|
|
38
|
+
if (!parsed) return "#/feed";
|
|
39
|
+
if (parsed.kind === "person") return `#/in/${encodeURIComponent(parsed.id)}`;
|
|
40
|
+
const org = orgs.get(parsed.id);
|
|
41
|
+
return org?.vanityName ? `#/company/${encodeURIComponent(org.vanityName)}` : "#/feed";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Resolve display facts for a URN; unknown members use people.get, unknown organizations fall back to a label. */
|
|
45
|
+
export async function resolveEntity(urn) {
|
|
46
|
+
const parsed = parseEntityUrn(urn);
|
|
47
|
+
if (!parsed) return { name: "LinkedIn Member", headline: null, kind: "person", id: "" };
|
|
48
|
+
const cache = parsed.kind === "person" ? people : orgs;
|
|
49
|
+
if (cache.has(parsed.id)) return { ...cache.get(parsed.id), kind: parsed.kind, id: parsed.id };
|
|
50
|
+
const pendingKey = urn;
|
|
51
|
+
if (!pending.has(pendingKey)) {
|
|
52
|
+
const lookup = parsed.kind === "person"
|
|
53
|
+
? call("people.get", { personId: parsed.id }).then(rememberPerson)
|
|
54
|
+
: call("organizations.get", { organizationId: parsed.id }).then(rememberOrganization);
|
|
55
|
+
pending.set(pendingKey, lookup.catch(() => undefined));
|
|
56
|
+
}
|
|
57
|
+
await pending.get(pendingKey);
|
|
58
|
+
const found = cache.get(parsed.id);
|
|
59
|
+
return found
|
|
60
|
+
? { ...found, kind: parsed.kind, id: parsed.id }
|
|
61
|
+
: { name: parsed.kind === "person" ? "LinkedIn Member" : "Organization", headline: null, kind: parsed.kind, id: parsed.id };
|
|
62
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Little-text rendering (hashtag templates, @[Name](urn) mentions, backslash escapes) into safe DOM nodes.
|
|
2
|
+
import { el } from "./ui.js";
|
|
3
|
+
import { hrefForUrn } from "./store.js";
|
|
4
|
+
|
|
5
|
+
const RESERVED = new Set(["\\", "|", "{", "}", "@", "[", "]", "(", ")", "<", ">", "#", "*", "_", "~"]);
|
|
6
|
+
|
|
7
|
+
/** Escape plain user text for the posts commentary field. `#word` stays unescaped so the service templates hashtags. */
|
|
8
|
+
export function escapeLittleText(text) {
|
|
9
|
+
let out = "";
|
|
10
|
+
const chars = [...String(text)];
|
|
11
|
+
for (let i = 0; i < chars.length; i += 1) {
|
|
12
|
+
const char = chars[i];
|
|
13
|
+
const next = chars[i + 1] ?? "";
|
|
14
|
+
const hashtag = char === "#" && /[\p{L}\p{N}_]/u.test(next) && !/[\p{L}\p{N}_]/u.test(chars[i - 1] ?? "");
|
|
15
|
+
out += RESERVED.has(char) && !hashtag ? `\\${char}` : char;
|
|
16
|
+
}
|
|
17
|
+
return out;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Parse little text into segments: {kind:"text"|"hashtag"|"mention", text, urn?}. Linear single pass. */
|
|
21
|
+
export function parseLittleText(source) {
|
|
22
|
+
const chars = [...String(source ?? "")];
|
|
23
|
+
const segments = [];
|
|
24
|
+
let buffer = "";
|
|
25
|
+
const flush = () => { if (buffer) segments.push({ kind: "text", text: buffer }); buffer = ""; };
|
|
26
|
+
for (let i = 0; i < chars.length; i += 1) {
|
|
27
|
+
const char = chars[i];
|
|
28
|
+
if (char === "\\" && i + 1 < chars.length) { buffer += chars[i + 1]; i += 1; continue; }
|
|
29
|
+
if (char === "{" && chars.slice(i + 1, i + 12).join("") === "hashtag|\\#|") {
|
|
30
|
+
const end = chars.indexOf("}", i + 12);
|
|
31
|
+
if (end > i + 12) { flush(); segments.push({ kind: "hashtag", text: chars.slice(i + 12, end).join("") }); i = end; continue; }
|
|
32
|
+
}
|
|
33
|
+
if (char === "@" && chars[i + 1] === "[") {
|
|
34
|
+
const close = chars.indexOf("]", i + 2);
|
|
35
|
+
if (close > i + 1 && chars[close + 1] === "(") {
|
|
36
|
+
const paren = chars.indexOf(")", close + 2);
|
|
37
|
+
const urn = paren > close ? chars.slice(close + 2, paren).join("") : "";
|
|
38
|
+
if (urn.startsWith("urn:li:")) {
|
|
39
|
+
flush();
|
|
40
|
+
segments.push({ kind: "mention", text: chars.slice(i + 2, close).join("").replace(/\\(.)/gu, "$1"), urn });
|
|
41
|
+
i = paren;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
buffer += char;
|
|
47
|
+
}
|
|
48
|
+
flush();
|
|
49
|
+
return segments;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Plain text form (for counting and edit boxes). */
|
|
53
|
+
export function littleTextToPlain(source) {
|
|
54
|
+
return parseLittleText(source).map((s) => (s.kind === "hashtag" ? `#${s.text}` : s.text)).join("");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Render commentary into a container. */
|
|
58
|
+
export function renderLittleText(container, source) {
|
|
59
|
+
for (const segment of parseLittleText(source)) {
|
|
60
|
+
if (segment.kind === "text") container.append(document.createTextNode(segment.text));
|
|
61
|
+
else if (segment.kind === "hashtag") container.append(el("a", { class: "hashtag", text: `#${segment.text}`, attrs: { href: "#/feed", "data-not-simulated": "Hashtag feeds" } }));
|
|
62
|
+
else container.append(el("a", { class: "mention", text: segment.text, attrs: { href: hrefForUrn(segment.urn) } }));
|
|
63
|
+
}
|
|
64
|
+
return container;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Render comment text with {start,length,value} mention attributes (offsets in UTF-16 units). */
|
|
68
|
+
export function renderAttributedText(container, text, attributes = []) {
|
|
69
|
+
const ranges = [...attributes].filter((a) => a && Number.isInteger(a.start) && Number.isInteger(a.length)).sort((a, b) => a.start - b.start);
|
|
70
|
+
let cursor = 0;
|
|
71
|
+
for (const range of ranges) {
|
|
72
|
+
if (range.start < cursor || range.start + range.length > text.length) continue;
|
|
73
|
+
container.append(document.createTextNode(text.slice(cursor, range.start)));
|
|
74
|
+
const urn = range.value?.person?.person ?? range.value?.organization?.organization;
|
|
75
|
+
container.append(el("a", { class: "mention", text: text.slice(range.start, range.start + range.length), attrs: { href: urn ? hrefForUrn(urn) : "#/feed" } }));
|
|
76
|
+
cursor = range.start + range.length;
|
|
77
|
+
}
|
|
78
|
+
container.append(document.createTextNode(text.slice(cursor)));
|
|
79
|
+
return container;
|
|
80
|
+
}
|