@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,87 @@
|
|
|
1
|
+
@font-face { font-family: "Fira Sans"; font-style: normal; font-weight: 400; font-display: swap; src: url("./assets/fonts/fira-sans-latin-400-normal.woff2") format("woff2"); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; }
|
|
2
|
+
@font-face { font-family: "Fira Sans"; font-style: normal; font-weight: 600; font-display: swap; src: url("./assets/fonts/fira-sans-latin-600-normal.woff2") format("woff2"); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; }
|
|
3
|
+
@font-face { font-family: "Fira Sans"; font-style: normal; font-weight: 700; font-display: swap; src: url("./assets/fonts/fira-sans-latin-700-normal.woff2") format("woff2"); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; }
|
|
4
|
+
@font-face { font-family: "Fira Sans"; font-style: normal; font-weight: 400; font-display: swap; src: url("./assets/fonts/fira-sans-latin-ext-400-normal.woff2") format("woff2"); unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; }
|
|
5
|
+
@font-face { font-family: "Fira Sans"; font-style: normal; font-weight: 600; font-display: swap; src: url("./assets/fonts/fira-sans-latin-ext-600-normal.woff2") format("woff2"); unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; }
|
|
6
|
+
@font-face { font-family: "Fira Sans"; font-style: normal; font-weight: 700; font-display: swap; src: url("./assets/fonts/fira-sans-latin-ext-700-normal.woff2") format("woff2"); unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; }
|
|
7
|
+
|
|
8
|
+
:root {
|
|
9
|
+
--ground: #f4f2ee;
|
|
10
|
+
--card: #ffffff;
|
|
11
|
+
--blue: #0a66c2;
|
|
12
|
+
--blue-hover: #004182;
|
|
13
|
+
--blue-tint: rgba(112, 181, 249, 0.2);
|
|
14
|
+
--text: rgba(0, 0, 0, 0.9);
|
|
15
|
+
--text-2: rgba(0, 0, 0, 0.6);
|
|
16
|
+
--text-3: rgba(0, 0, 0, 0.45);
|
|
17
|
+
--hairline: rgba(140, 140, 140, 0.2);
|
|
18
|
+
--divider: #e8e8e8;
|
|
19
|
+
--hover: rgba(0, 0, 0, 0.08);
|
|
20
|
+
--press: rgba(0, 0, 0, 0.12);
|
|
21
|
+
--green: #01754f;
|
|
22
|
+
--danger: #cc1016;
|
|
23
|
+
--search: #edf3f8;
|
|
24
|
+
--radius: 8px;
|
|
25
|
+
--shadow: 0 0 0 1px var(--hairline);
|
|
26
|
+
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Fira Sans", Ubuntu, Oxygen, "Oxygen Sans", Cantarell, "Droid Sans", "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Lato Extended", Lato, sans-serif;
|
|
27
|
+
}
|
|
28
|
+
* { box-sizing: border-box; }
|
|
29
|
+
html, body { margin: 0; }
|
|
30
|
+
body { font-family: var(--font); font-size: 14px; line-height: 1.42857; color: var(--text); background: var(--ground); -webkit-font-smoothing: antialiased; }
|
|
31
|
+
button, input, textarea, select { font: inherit; color: inherit; }
|
|
32
|
+
a { color: var(--blue); text-decoration: none; }
|
|
33
|
+
a:hover { text-decoration: underline; }
|
|
34
|
+
h1, h2, h3, p { margin: 0; }
|
|
35
|
+
ul { margin: 0; padding: 0; list-style: none; }
|
|
36
|
+
:focus-visible { outline: 2px solid var(--blue); outline-offset: 1px; }
|
|
37
|
+
[hidden] { display: none !important; }
|
|
38
|
+
.visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
|
|
39
|
+
.icon { display: block; flex: none; }
|
|
40
|
+
|
|
41
|
+
.card { background: var(--card); border-radius: var(--radius); box-shadow: var(--shadow); }
|
|
42
|
+
.card + .card { margin-top: 8px; }
|
|
43
|
+
|
|
44
|
+
/* Pill buttons */
|
|
45
|
+
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 4px; min-height: 32px; padding: 6px 16px; border-radius: 24px; border: 0; font-weight: 600; font-size: 16px; line-height: 20px; cursor: pointer; background: transparent; transition: background-color .167s, box-shadow .167s, color .167s; white-space: nowrap; }
|
|
46
|
+
.btn--sm { min-height: 32px; padding: 6px 12px; font-size: 14px; }
|
|
47
|
+
.btn--primary { background: var(--blue); color: #fff; }
|
|
48
|
+
.btn--primary:hover { background: var(--blue-hover); }
|
|
49
|
+
.btn--primary:disabled { background: rgba(0, 0, 0, 0.08); color: rgba(0, 0, 0, 0.3); cursor: not-allowed; }
|
|
50
|
+
.btn--secondary { box-shadow: inset 0 0 0 1px var(--blue); color: var(--blue); }
|
|
51
|
+
.btn--secondary:hover { box-shadow: inset 0 0 0 2px var(--blue-hover); background: var(--blue-tint); color: var(--blue-hover); }
|
|
52
|
+
.btn--muted { box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.6); color: var(--text-2); }
|
|
53
|
+
.btn--muted:hover { box-shadow: inset 0 0 0 2px rgba(0, 0, 0, 0.75); background: var(--hover); color: var(--text); }
|
|
54
|
+
.btn--tertiary { color: var(--text-2); border-radius: 4px; padding: 6px 8px; }
|
|
55
|
+
.btn--tertiary:hover { background: var(--hover); color: var(--text); }
|
|
56
|
+
.btn--danger { background: var(--danger); color: #fff; }
|
|
57
|
+
.btn--danger:hover { background: #8a0005; }
|
|
58
|
+
.btn[data-busy="true"] { position: relative; color: transparent !important; }
|
|
59
|
+
.btn[data-busy="true"]::after { content: ""; position: absolute; width: 16px; height: 16px; border-radius: 50%; border: 2px solid rgba(0, 0, 0, .25); border-top-color: var(--blue); animation: spin .8s linear infinite; }
|
|
60
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
61
|
+
|
|
62
|
+
.icon-btn { display: inline-flex; align-items: center; justify-content: center; width: 40px; height: 40px; border: 0; border-radius: 50%; background: transparent; color: var(--text-2); cursor: pointer; }
|
|
63
|
+
.icon-btn:hover { background: var(--hover); color: var(--text); }
|
|
64
|
+
.icon-btn--sm { width: 32px; height: 32px; }
|
|
65
|
+
|
|
66
|
+
.avatar { display: inline-flex; align-items: center; justify-content: center; flex: none; border-radius: 50%; background: var(--avatar-bg, #56687a); color: #fff; font-weight: 600; overflow: hidden; user-select: none; }
|
|
67
|
+
.avatar--org { border-radius: 4px; }
|
|
68
|
+
.avatar--24 { width: 24px; height: 24px; font-size: 10px; }
|
|
69
|
+
.avatar--32 { width: 32px; height: 32px; font-size: 13px; }
|
|
70
|
+
.avatar--40 { width: 40px; height: 40px; font-size: 15px; }
|
|
71
|
+
.avatar--48 { width: 48px; height: 48px; font-size: 18px; }
|
|
72
|
+
.avatar--56 { width: 56px; height: 56px; font-size: 20px; }
|
|
73
|
+
.avatar--72 { width: 72px; height: 72px; font-size: 26px; border: 2px solid #fff; }
|
|
74
|
+
.avatar--152 { width: 152px; height: 152px; font-size: 56px; border: 4px solid #fff; }
|
|
75
|
+
.avatar--org.avatar--72, .avatar--org.avatar--152 { border-radius: 4px; }
|
|
76
|
+
|
|
77
|
+
.link-quiet { color: inherit; }
|
|
78
|
+
.link-quiet:hover { color: var(--blue); text-decoration: underline; }
|
|
79
|
+
.muted { color: var(--text-2); }
|
|
80
|
+
.t-12 { font-size: 12px; line-height: 16px; }
|
|
81
|
+
.t-bold { font-weight: 600; }
|
|
82
|
+
|
|
83
|
+
.boot { position: fixed; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 24px; background: #fff; z-index: 50; }
|
|
84
|
+
.boot-bar { width: 132px; height: 2px; background: #e9e5df; overflow: hidden; border-radius: 2px; }
|
|
85
|
+
.boot-bar span { display: block; width: 40%; height: 100%; background: var(--blue); animation: boot 1.2s ease-in-out infinite; }
|
|
86
|
+
@keyframes boot { from { transform: translateX(-100%); } to { transform: translateX(260%); } }
|
|
87
|
+
.boot__message { max-width: 420px; text-align: center; color: var(--text-2); font-size: 16px; }
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Comment/reply input with "comment as" avatar selector and an @mention picker over the viewer's connections.
|
|
2
|
+
import { el, call, describe, avatar, busy } from "./ui.js";
|
|
3
|
+
import { icon } from "./icons.js";
|
|
4
|
+
import { session, actingPages } from "./store.js";
|
|
5
|
+
import { showMenu, notSimulated } from "./overlay.js";
|
|
6
|
+
|
|
7
|
+
export function commentBox({ placeholder = "Add a comment…", submitLabel = "Comment", asPages = false, autofocus = false, compact = false, initialText = "", initialAttributes = [], onSubmit, onCancel }) {
|
|
8
|
+
const viewer = session.viewer;
|
|
9
|
+
let actor = viewer.personUrn;
|
|
10
|
+
const mentions = []; // { name, urn }
|
|
11
|
+
for (const attribute of initialAttributes ?? []) {
|
|
12
|
+
const urn = attribute.value?.person?.person ?? attribute.value?.organization?.organization;
|
|
13
|
+
if (urn) mentions.push({ name: initialText.slice(attribute.start, attribute.start + attribute.length), urn });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const form = el("form", { class: `comment-box${compact ? " comment-box--compact" : ""}` });
|
|
17
|
+
const who = el("button", { class: "comment-box__as", attrs: { type: "button", "aria-haspopup": "menu", "aria-expanded": "false" } });
|
|
18
|
+
const paintWho = () => {
|
|
19
|
+
const page = actingPages().find((p) => p.organizationUrn === actor);
|
|
20
|
+
const name = page ? page.name : viewer.name;
|
|
21
|
+
who.replaceChildren(avatar(name, actor, compact ? 32 : 40, Boolean(page)));
|
|
22
|
+
who.setAttribute("aria-label", `Commenting as ${name}. Change`);
|
|
23
|
+
who.title = `Commenting as ${name}`;
|
|
24
|
+
};
|
|
25
|
+
paintWho();
|
|
26
|
+
const pages = asPages ? actingPages() : [];
|
|
27
|
+
if (pages.length > 0) {
|
|
28
|
+
who.addEventListener("click", () => showMenu(who, [{ urn: viewer.personUrn, name: viewer.name }, ...pages.map((p) => ({ urn: p.organizationUrn, name: p.name }))].map((choice) => ({
|
|
29
|
+
label: `${choice.urn === actor ? "✓ " : ""}${choice.name}`, sub: choice.urn === viewer.personUrn ? "Your profile" : "Page you manage", onSelect: () => { actor = choice.urn; paintWho(); },
|
|
30
|
+
})), { align: "left" }));
|
|
31
|
+
} else {
|
|
32
|
+
who.disabled = true;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const input = el("textarea", { class: "comment-box__input", attrs: { rows: "1", placeholder, "aria-label": placeholder.replace("…", "") } });
|
|
36
|
+
input.value = initialText;
|
|
37
|
+
const picker = el("div", { class: "mention-picker", attrs: { role: "listbox", "aria-label": "Mention suggestions" } });
|
|
38
|
+
picker.hidden = true;
|
|
39
|
+
const error = el("p", { class: "comment-box__error", attrs: { role: "alert" } });
|
|
40
|
+
error.hidden = true;
|
|
41
|
+
const submit = el("button", { class: "btn btn--primary btn--sm comment-box__submit", text: submitLabel, attrs: { type: "submit" } });
|
|
42
|
+
const tools = el("span", { class: "comment-box__tools" }, [
|
|
43
|
+
el("button", { class: "icon-btn icon-btn--sm", title: "Open Emoji Keyboard", attrs: { type: "button", "aria-label": "Open Emoji Keyboard" }, on: { click: () => notSimulated("Emoji keyboard") } }, icon("emoji")),
|
|
44
|
+
el("button", { class: "icon-btn icon-btn--sm", title: "Add a photo", attrs: { type: "button", "aria-label": "Add a photo" }, on: { click: () => notSimulated("Images in comments") } }, icon("photo")),
|
|
45
|
+
]);
|
|
46
|
+
const field = el("div", { class: "comment-box__field" }, [input, tools]);
|
|
47
|
+
const row = el("div", { class: "comment-box__row" }, [who, el("div", { class: "comment-box__main" }, [field, picker])]);
|
|
48
|
+
const footer = el("div", { class: "comment-box__footer" }, [
|
|
49
|
+
onCancel ? el("button", { class: "btn btn--tertiary btn--sm", text: "Cancel", attrs: { type: "button" }, on: { click: onCancel } }) : null,
|
|
50
|
+
submit,
|
|
51
|
+
]);
|
|
52
|
+
form.append(row, error, footer);
|
|
53
|
+
|
|
54
|
+
const sync = () => {
|
|
55
|
+
input.style.height = "auto";
|
|
56
|
+
input.style.height = `${Math.min(input.scrollHeight, 240)}px`;
|
|
57
|
+
const has = input.value.trim().length > 0;
|
|
58
|
+
footer.hidden = !has && !onCancel;
|
|
59
|
+
submit.disabled = !has || [...input.value].length > 1250;
|
|
60
|
+
};
|
|
61
|
+
sync();
|
|
62
|
+
|
|
63
|
+
let searchToken = 0;
|
|
64
|
+
const mentionQuery = () => {
|
|
65
|
+
const before = input.value.slice(0, input.selectionStart ?? input.value.length);
|
|
66
|
+
const match = /(?:^|\s)@([\p{L}\p{N} .'-]{0,40})$/u.exec(before);
|
|
67
|
+
return match ? { query: match[1], at: before.length - match[1].length - 1 } : null;
|
|
68
|
+
};
|
|
69
|
+
const refreshPicker = async () => {
|
|
70
|
+
const found = mentionQuery();
|
|
71
|
+
if (!found) { picker.hidden = true; return; }
|
|
72
|
+
const token = ++searchToken;
|
|
73
|
+
try {
|
|
74
|
+
const page = await call("connections.list", { query: found.query.trim(), start: 0, count: 5 });
|
|
75
|
+
if (token !== searchToken) return;
|
|
76
|
+
picker.replaceChildren();
|
|
77
|
+
if (page.elements.length === 0) { picker.append(el("p", { class: "mention-picker__empty", text: "No matching connections" })); }
|
|
78
|
+
for (const { person } of page.elements) {
|
|
79
|
+
const name = `${person.localizedFirstName} ${person.localizedLastName}`.trim();
|
|
80
|
+
picker.append(el("button", { class: "mention-picker__item", attrs: { type: "button", role: "option" }, on: { mousedown: (event) => event.preventDefault(), click: () => {
|
|
81
|
+
input.value = `${input.value.slice(0, found.at)}${name} ${input.value.slice(input.selectionStart ?? input.value.length)}`;
|
|
82
|
+
mentions.push({ name, urn: `urn:li:person:${person.id}` });
|
|
83
|
+
picker.hidden = true;
|
|
84
|
+
input.focus();
|
|
85
|
+
sync();
|
|
86
|
+
} } }, [avatar(name, `urn:li:person:${person.id}`, 32), el("span", {}, [el("span", { class: "t-bold", text: name }), el("span", { class: "mention-picker__sub", text: person.localizedHeadline ?? "" })])]));
|
|
87
|
+
}
|
|
88
|
+
picker.hidden = false;
|
|
89
|
+
} catch { picker.hidden = true; }
|
|
90
|
+
};
|
|
91
|
+
input.addEventListener("input", () => { error.hidden = true; sync(); void refreshPicker(); });
|
|
92
|
+
input.addEventListener("keydown", (event) => { if (event.key === "Escape" && !picker.hidden) { event.stopPropagation(); picker.hidden = true; } });
|
|
93
|
+
input.addEventListener("blur", () => setTimeout(() => { picker.hidden = true; }, 150));
|
|
94
|
+
|
|
95
|
+
form.addEventListener("submit", (event) => {
|
|
96
|
+
event.preventDefault();
|
|
97
|
+
void busy(submit, async () => {
|
|
98
|
+
const text = input.value.trim();
|
|
99
|
+
const attributes = [];
|
|
100
|
+
let from = 0;
|
|
101
|
+
for (const mention of mentions) {
|
|
102
|
+
const start = text.indexOf(mention.name, from);
|
|
103
|
+
if (start < 0) continue;
|
|
104
|
+
const kind = mention.urn.startsWith("urn:li:organization:") ? "organization" : "person";
|
|
105
|
+
attributes.push({ start, length: mention.name.length, value: kind === "person" ? { person: { person: mention.urn } } : { organization: { organization: mention.urn } } });
|
|
106
|
+
from = start + mention.name.length;
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
await onSubmit({ text, attributes, actor });
|
|
110
|
+
if (form.isConnected) { input.value = ""; mentions.length = 0; sync(); }
|
|
111
|
+
} catch (failure) {
|
|
112
|
+
error.textContent = describe(failure);
|
|
113
|
+
error.hidden = false;
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
if (autofocus) queueMicrotask(() => { input.focus(); input.setSelectionRange(input.value.length, input.value.length); });
|
|
118
|
+
return form;
|
|
119
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
.comments { padding: 4px 16px 12px; }
|
|
2
|
+
.comments__closed { display: flex; align-items: center; gap: 8px; padding: 12px 0 4px; color: var(--text-2); }
|
|
3
|
+
.comments__sort { display: inline-flex; align-items: center; gap: 2px; margin: 8px 0 4px; font-size: 14px; font-weight: 600; color: var(--text-2); }
|
|
4
|
+
.comments__sort .icon { width: 16px; height: 16px; }
|
|
5
|
+
.comments__empty, .comments__error { padding: 8px 0; color: var(--text-2); }
|
|
6
|
+
.comments__error { display: flex; align-items: center; gap: 8px; color: var(--danger); }
|
|
7
|
+
.comments__loading { display: flex; justify-content: center; padding: 12px; }
|
|
8
|
+
.comments__more { display: inline-flex; margin: 4px 0 0 48px; padding: 4px 8px; border: 0; border-radius: 16px; background: none; font-size: 14px; font-weight: 600; color: var(--text-2); cursor: pointer; }
|
|
9
|
+
.comments__more:hover { background: var(--hover); color: var(--text); }
|
|
10
|
+
.spinner { display: inline-block; width: 20px; height: 20px; border-radius: 50%; border: 2px solid rgba(0, 0, 0, .15); border-top-color: var(--blue); animation: spin .8s linear infinite; }
|
|
11
|
+
|
|
12
|
+
.comment { display: flex; gap: 4px; margin-top: 12px; }
|
|
13
|
+
.comment__avatar { flex: none; margin-right: 4px; }
|
|
14
|
+
.comment__body { flex: 1; min-width: 0; }
|
|
15
|
+
.comment__bubble { padding: 8px 12px 12px; border-radius: 0 8px 8px 8px; background: #f2f2f2; }
|
|
16
|
+
.comment__head { display: flex; justify-content: space-between; gap: 8px; }
|
|
17
|
+
.comment__who { display: flex; flex-direction: column; min-width: 0; }
|
|
18
|
+
.comment__name { font-size: 14px; font-weight: 600; color: var(--text); }
|
|
19
|
+
.comment__name:hover { color: var(--blue); }
|
|
20
|
+
.comment__name-line { display: flex; align-items: center; gap: 6px; }
|
|
21
|
+
.comment__degree { font-size: 12px; color: var(--text-2); }
|
|
22
|
+
.comment__author-badge { padding: 0 4px; border-radius: 2px; background: #56687a; color: #fff; font-size: 12px; line-height: 16px; }
|
|
23
|
+
.comment__headline { font-size: 12px; line-height: 16px; color: var(--text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
24
|
+
.comment__aside { display: flex; align-items: flex-start; gap: 2px; flex: none; }
|
|
25
|
+
.comment__time { font-size: 12px; color: var(--text-2); line-height: 32px; }
|
|
26
|
+
.comment__menu { margin-top: 0; }
|
|
27
|
+
.comment__menu .icon { width: 20px; height: 20px; }
|
|
28
|
+
.comment__text { margin-top: 8px; font-size: 14px; line-height: 20px; white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
29
|
+
.comment__actions { display: flex; align-items: center; gap: 6px; padding: 2px 0 0 4px; font-size: 12px; color: var(--text-2); }
|
|
30
|
+
.comment__action { padding: 4px; border: 0; border-radius: 4px; background: none; font-size: 12px; font-weight: 600; color: var(--text-2); cursor: pointer; }
|
|
31
|
+
.comment__action:hover { background: var(--hover); color: var(--text); }
|
|
32
|
+
.comment__action--active { color: var(--blue); }
|
|
33
|
+
.comment__sep { color: var(--text-3); }
|
|
34
|
+
.comment__count-badge { display: inline-flex; align-items: center; gap: 3px; }
|
|
35
|
+
.comment__dot { color: var(--text-3); }
|
|
36
|
+
.comment__count-badge .icon { width: 14px; height: 14px; color: #378fe9; }
|
|
37
|
+
.comment__replies { margin-top: 4px; }
|
|
38
|
+
.comment--reply { margin-top: 8px; }
|
|
39
|
+
|
|
40
|
+
.comment-box { margin-top: 8px; }
|
|
41
|
+
.comment-box__row { display: flex; gap: 8px; align-items: flex-start; }
|
|
42
|
+
.comment-box__as { flex: none; padding: 0; border: 0; border-radius: 50%; background: none; cursor: pointer; }
|
|
43
|
+
.comment-box__as:disabled { cursor: default; }
|
|
44
|
+
.comment-box__main { position: relative; flex: 1; min-width: 0; }
|
|
45
|
+
.comment-box__field { display: flex; align-items: flex-end; min-height: 40px; padding: 2px 4px 2px 16px; border-radius: 24px; box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.6); background: #fff; }
|
|
46
|
+
.comment-box__field:focus-within { box-shadow: inset 0 0 0 2px rgba(0, 0, 0, 0.9); }
|
|
47
|
+
.comment-box--compact .comment-box__field { min-height: 32px; border-radius: 16px; }
|
|
48
|
+
.comment-box__input { flex: 1; min-height: 36px; max-height: 240px; padding: 8px 0; border: 0; resize: none; overflow-y: auto; background: transparent; font-size: 14px; line-height: 20px; }
|
|
49
|
+
.comment-box--compact .comment-box__input { min-height: 28px; padding: 4px 0; }
|
|
50
|
+
.comment-box__input:focus { outline: none; }
|
|
51
|
+
.comment-box__tools { display: flex; color: var(--text-2); }
|
|
52
|
+
.comment-box__footer { display: flex; justify-content: flex-start; gap: 8px; margin: 8px 0 0 48px; }
|
|
53
|
+
.comment-box__error { margin: 6px 0 0 48px; font-size: 12px; color: var(--danger); }
|
|
54
|
+
.mention-picker { position: absolute; left: 0; top: calc(100% + 4px); width: 320px; padding: 4px 0; background: #fff; border-radius: 8px; box-shadow: 0 0 0 1px var(--hairline), 0 4px 12px rgba(0, 0, 0, .15); z-index: 12; }
|
|
55
|
+
.mention-picker__item { display: flex; align-items: center; gap: 8px; width: 100%; padding: 6px 12px; border: 0; background: none; text-align: left; cursor: pointer; }
|
|
56
|
+
.mention-picker__item > span { display: flex; flex-direction: column; min-width: 0; }
|
|
57
|
+
.mention-picker__item:hover { background: var(--hover); }
|
|
58
|
+
.mention-picker__sub { font-size: 12px; color: var(--text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
59
|
+
.mention-picker__empty { padding: 8px 12px; color: var(--text-2); }
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// Comments section under a post: comment box with @mentions and "comment as", paged list, replies, likes, edit and delete.
|
|
2
|
+
import { el, call, newKey, describe, toast, avatar, relativeTime, busy } from "./ui.js";
|
|
3
|
+
import { icon } from "./icons.js";
|
|
4
|
+
import { session, actingPages, resolveEntity, hrefForUrn, isViewer } from "./store.js";
|
|
5
|
+
import { renderAttributedText } from "./text.js";
|
|
6
|
+
import { confirmDialog, showMenu, notSimulated } from "./overlay.js";
|
|
7
|
+
import { commentBox } from "./comment-box.js";
|
|
8
|
+
import { canManage } from "./post.js";
|
|
9
|
+
|
|
10
|
+
const PAGE = 10;
|
|
11
|
+
|
|
12
|
+
/** Expand (or collapse) the comments section of a post card. */
|
|
13
|
+
export function toggleComments(host, entry, { focus = false, onChanged } = {}) {
|
|
14
|
+
if (host.dataset.open === "true") {
|
|
15
|
+
if (focus) host.querySelector(".comment-box__input")?.focus();
|
|
16
|
+
else { host.dataset.open = "false"; host.replaceChildren(); }
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
host.dataset.open = "true";
|
|
20
|
+
const ctx = { entry, onChanged: async () => { await onChanged?.(); } };
|
|
21
|
+
const section = el("section", { class: "comments", attrs: { "aria-label": "Comments" } });
|
|
22
|
+
host.replaceChildren(section);
|
|
23
|
+
if (entry.social.commentsState === "CLOSED") {
|
|
24
|
+
section.append(el("p", { class: "comments__closed" }, [icon("comment-off"), el("span", { text: "Comments are turned off for this post." })]));
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const list = el("div", { class: "comments__list" });
|
|
28
|
+
const box = commentBox({
|
|
29
|
+
placeholder: "Add a comment…",
|
|
30
|
+
submitLabel: "Comment",
|
|
31
|
+
asPages: true,
|
|
32
|
+
autofocus: focus,
|
|
33
|
+
onSubmit: async ({ text, attributes, actor }) => {
|
|
34
|
+
await call("comments.create", { target: entry.post.id, actor, message: { text, attributes } }, newKey());
|
|
35
|
+
await loadInto(list, ctx, { reset: true });
|
|
36
|
+
await ctx.onChanged();
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
section.append(box, el("div", { class: "comments__sort" }, [el("span", { text: "Oldest" }), icon("caret-down")]), list);
|
|
40
|
+
void loadInto(list, ctx, { reset: true });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function loadInto(list, ctx, { reset = false, parent = null } = {}) {
|
|
44
|
+
const target = parent ? parent.commentUrn : ctx.entry.post.id;
|
|
45
|
+
const start = reset ? 0 : Number(list.dataset.loaded ?? 0);
|
|
46
|
+
if (reset) { list.replaceChildren(el("div", { class: "comments__loading", attrs: { role: "status" } }, el("span", { class: "spinner", attrs: { "aria-label": "Loading comments" } }))); }
|
|
47
|
+
list.querySelector(".comments__more")?.remove();
|
|
48
|
+
try {
|
|
49
|
+
const page = await call("comments.list", { target, start, count: PAGE });
|
|
50
|
+
if (reset) list.replaceChildren();
|
|
51
|
+
for (const comment of page.elements) list.append(await renderComment(comment, ctx, parent));
|
|
52
|
+
const loaded = start + page.elements.length;
|
|
53
|
+
list.dataset.loaded = String(loaded);
|
|
54
|
+
if (reset && page.elements.length === 0 && !parent) list.append(el("p", { class: "comments__empty", text: "No comments yet. Be the first to comment." }));
|
|
55
|
+
const total = page.paging.total ?? loaded;
|
|
56
|
+
if (loaded < total) {
|
|
57
|
+
list.append(el("button", { class: "comments__more", text: parent ? `Load more replies` : "Load more comments", attrs: { type: "button" }, on: { click: (event) => busy(event.currentTarget, () => loadInto(list, ctx, { parent })) } }));
|
|
58
|
+
}
|
|
59
|
+
} catch (error) {
|
|
60
|
+
if (reset) list.replaceChildren();
|
|
61
|
+
list.append(el("p", { class: "comments__error", attrs: { role: "alert" } }, [el("span", { text: describe(error) }), el("button", { class: "btn btn--tertiary btn--sm", text: "Retry", attrs: { type: "button" }, on: { click: () => loadInto(list, ctx, { reset: true, parent }) } })]));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function renderComment(comment, ctx, parent) {
|
|
66
|
+
const who = await resolveEntity(comment.actor);
|
|
67
|
+
const isOrg = who.kind === "organization";
|
|
68
|
+
const row = el("article", { class: `comment${parent ? " comment--reply" : ""}`, attrs: { "data-comment": comment.id } });
|
|
69
|
+
const href = hrefForUrn(comment.actor);
|
|
70
|
+
const edited = comment.lastModified.time > comment.created.time;
|
|
71
|
+
const mine = isViewer(comment.actor) || actingPages().some((p) => p.organizationUrn === comment.actor);
|
|
72
|
+
const canDelete = mine || canManage(ctx.entry.post.author);
|
|
73
|
+
|
|
74
|
+
const bubble = el("div", { class: "comment__bubble" });
|
|
75
|
+
const menuButton = el("button", { class: "icon-btn icon-btn--sm comment__menu", title: "Open options", attrs: { type: "button", "aria-label": `Open options for ${who.name}'s comment`, "aria-haspopup": "menu" } }, icon("ellipsis"));
|
|
76
|
+
menuButton.addEventListener("click", () => showMenu(menuButton, [
|
|
77
|
+
...(isViewer(comment.actor) ? [{ icon: "pencil", label: "Edit", onSelect: () => startEdit(bubble, textNode, comment, ctx) }] : []),
|
|
78
|
+
...(canDelete ? [{ icon: "trash", label: "Delete", onSelect: () => removeComment(comment, ctx, row) }] : []),
|
|
79
|
+
{ icon: "alert", label: "Report", onSelect: () => notSimulated("Reporting comments") },
|
|
80
|
+
]));
|
|
81
|
+
const textNode = renderAttributedText(el("div", { class: "comment__text", attrs: { dir: "ltr" } }), comment.message.text, comment.message.attributes);
|
|
82
|
+
bubble.append(
|
|
83
|
+
el("div", { class: "comment__head" }, [
|
|
84
|
+
el("div", { class: "comment__who" }, [
|
|
85
|
+
el("span", { class: "comment__name-line" }, [
|
|
86
|
+
el("a", { class: "comment__name", text: who.name, attrs: { href } }),
|
|
87
|
+
comment.actor === ctx.entry.post.author ? el("span", { class: "comment__author-badge", text: "Author" }) : isViewer(comment.actor) ? el("span", { class: "comment__degree", text: "• You" }) : null,
|
|
88
|
+
]),
|
|
89
|
+
who.headline ? el("span", { class: "comment__headline", text: who.headline }) : null,
|
|
90
|
+
]),
|
|
91
|
+
el("div", { class: "comment__aside" }, [el("span", { class: "comment__time", text: `${relativeTime(comment.created.time)}${edited ? " (edited)" : ""}` }), menuButton]),
|
|
92
|
+
]),
|
|
93
|
+
textNode,
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const likes = comment.likesSummary ?? { totalLikes: 0, likedByCurrentUser: false };
|
|
97
|
+
const likeState = { liked: Boolean(likes.likedByCurrentUser), count: likes.totalLikes ?? 0 };
|
|
98
|
+
const likeButton = el("button", { class: "comment__action", attrs: { type: "button" } });
|
|
99
|
+
const likeCount = el("span", { class: "comment__count" });
|
|
100
|
+
const paintLike = () => {
|
|
101
|
+
likeButton.textContent = "Like";
|
|
102
|
+
likeButton.setAttribute("aria-pressed", String(likeState.liked));
|
|
103
|
+
likeButton.setAttribute("aria-label", likeState.liked ? `Unlike ${who.name}'s comment` : `Like ${who.name}'s comment`);
|
|
104
|
+
likeButton.classList.toggle("comment__action--active", likeState.liked);
|
|
105
|
+
likeCount.replaceChildren();
|
|
106
|
+
if (likeState.count > 0) likeCount.append(el("span", { class: "comment__dot", text: "•" }), el("span", { class: "comment__count-badge" }, [icon("like"), el("span", { text: String(likeState.count) })]));
|
|
107
|
+
};
|
|
108
|
+
paintLike();
|
|
109
|
+
likeButton.addEventListener("click", () => busy(likeButton, async () => {
|
|
110
|
+
const actor = session.viewer.personUrn;
|
|
111
|
+
try {
|
|
112
|
+
if (likeState.liked) await call("reactions.delete", { actor, entity: comment.commentUrn }, newKey());
|
|
113
|
+
else await call("reactions.create", { actor, root: comment.commentUrn, reactionType: "LIKE" }, newKey());
|
|
114
|
+
likeState.count += likeState.liked ? -1 : 1;
|
|
115
|
+
likeState.liked = !likeState.liked;
|
|
116
|
+
paintLike();
|
|
117
|
+
} catch (error) { toast(describe(error), { error: true }); }
|
|
118
|
+
}));
|
|
119
|
+
|
|
120
|
+
const actions = el("div", { class: "comment__actions" }, [likeButton, likeCount]);
|
|
121
|
+
const body = el("div", { class: "comment__body" }, [bubble, actions]);
|
|
122
|
+
row.append(el("a", { class: "comment__avatar", attrs: { href, tabindex: "-1", "aria-hidden": "true" } }, avatar(who.name, comment.actor, parent ? 32 : 40, isOrg)), body);
|
|
123
|
+
|
|
124
|
+
if (!parent) {
|
|
125
|
+
const replyCount = comment.commentsSummary?.aggregatedTotalComments ?? 0;
|
|
126
|
+
const replies = el("div", { class: "comment__replies" });
|
|
127
|
+
const replyButton = el("button", { class: "comment__action", text: "Reply", attrs: { type: "button", "aria-label": `Reply to ${who.name}'s comment` } });
|
|
128
|
+
actions.append(el("span", { class: "comment__sep", text: "|", attrs: { "aria-hidden": "true" } }), replyButton);
|
|
129
|
+
if (replyCount > 0) actions.append(el("span", { class: "comment__dot", text: "•" }), el("span", { class: "comment__count", text: `${replyCount} ${replyCount === 1 ? "reply" : "replies"}` }));
|
|
130
|
+
body.append(replies);
|
|
131
|
+
const openReplies = async (withBox) => {
|
|
132
|
+
if (replies.dataset.open !== "true") {
|
|
133
|
+
replies.dataset.open = "true";
|
|
134
|
+
const list = el("div", { class: "comments__list" });
|
|
135
|
+
replies.append(list);
|
|
136
|
+
await loadInto(list, ctx, { reset: true, parent: comment });
|
|
137
|
+
}
|
|
138
|
+
if (withBox && !replies.querySelector(".comment-box")) {
|
|
139
|
+
const box = commentBox({
|
|
140
|
+
placeholder: "Add a reply…", submitLabel: "Reply", asPages: true, autofocus: true, compact: true,
|
|
141
|
+
onSubmit: async ({ text, attributes, actor }) => {
|
|
142
|
+
await call("comments.create", { target: comment.commentUrn, actor, parentComment: comment.commentUrn, message: { text, attributes } }, newKey());
|
|
143
|
+
replies.replaceChildren();
|
|
144
|
+
replies.dataset.open = "false";
|
|
145
|
+
await openReplies(false);
|
|
146
|
+
await ctx.onChanged();
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
replies.prepend(box);
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
replyButton.addEventListener("click", () => openReplies(true));
|
|
153
|
+
if (replyCount > 0) void openReplies(false);
|
|
154
|
+
}
|
|
155
|
+
return row;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function startEdit(bubble, textNode, comment, ctx) {
|
|
159
|
+
if (bubble.querySelector(".comment-box")) return;
|
|
160
|
+
textNode.hidden = true;
|
|
161
|
+
const box = commentBox({
|
|
162
|
+
initialText: comment.message.text, initialAttributes: comment.message.attributes, submitLabel: "Save changes", autofocus: true, compact: true,
|
|
163
|
+
onCancel: () => { box.remove(); textNode.hidden = false; },
|
|
164
|
+
onSubmit: async ({ text, attributes }) => {
|
|
165
|
+
await call("comments.update", { target: ctx.entry.post.id, commentId: comment.id, message: { text, attributes } }, newKey());
|
|
166
|
+
comment.message = { text, attributes };
|
|
167
|
+
textNode.replaceChildren();
|
|
168
|
+
renderAttributedText(textNode, text, attributes);
|
|
169
|
+
box.remove();
|
|
170
|
+
textNode.hidden = false;
|
|
171
|
+
toast("Comment edited.");
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
textNode.after(box);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function removeComment(comment, ctx, row) {
|
|
178
|
+
const ok = await confirmDialog({ title: "Delete comment?", message: "Are you sure you want to delete this comment? Replies to it will be deleted too.", confirmLabel: "Delete", danger: true });
|
|
179
|
+
if (!ok) return;
|
|
180
|
+
try {
|
|
181
|
+
await call("comments.delete", { target: ctx.entry.post.id, commentId: comment.id }, newKey());
|
|
182
|
+
row.remove();
|
|
183
|
+
toast("Comment deleted.");
|
|
184
|
+
await ctx.onChanged();
|
|
185
|
+
} catch (error) {
|
|
186
|
+
toast(describe(error), { error: true });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Company page (/company/{vanity}): header, admin view (About, Admins) when the viewer administers it, and page posts.
|
|
2
|
+
import { el, call, describe, avatar, relativeTime, busy } from "./ui.js";
|
|
3
|
+
import { icon } from "./icons.js";
|
|
4
|
+
import { session, rememberOrganization, resolveEntity, hrefForUrn } from "./store.js";
|
|
5
|
+
import { littleTextToPlain } from "./text.js";
|
|
6
|
+
import { openComposer } from "./composer.js";
|
|
7
|
+
import { footer } from "./feed.js";
|
|
8
|
+
|
|
9
|
+
const SIZES = { SIZE_1: "1 employee", SIZE_2_TO_10: "2-10 employees", SIZE_11_TO_50: "11-50 employees", SIZE_51_TO_200: "51-200 employees", SIZE_201_TO_500: "201-500 employees", SIZE_501_TO_1000: "501-1,000 employees", SIZE_1001_TO_5000: "1,001-5,000 employees", SIZE_5001_TO_10000: "5,001-10,000 employees", SIZE_10001_OR_MORE: "10,001+ employees" };
|
|
10
|
+
const fmt = (n) => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
|
11
|
+
|
|
12
|
+
export function renderCompany(main, vanity) {
|
|
13
|
+
const host = el("div", { class: "company-main" }, el("section", { class: "card state-card" }, el("span", { class: "spinner", attrs: { "aria-label": "Loading page" } })));
|
|
14
|
+
main.replaceChildren(el("div", { class: "profile-layout" }, [host, el("aside", { class: "profile-aside" }, footer())]));
|
|
15
|
+
|
|
16
|
+
const load = async () => {
|
|
17
|
+
try {
|
|
18
|
+
const found = await call("organizations.find_by_vanity_name", { vanityName: vanity });
|
|
19
|
+
const org = found.elements[0];
|
|
20
|
+
if (!org) throw Object.assign(new Error("This page doesn't exist"), { code: "NOT_FOUND" });
|
|
21
|
+
rememberOrganization(org);
|
|
22
|
+
const urn = `urn:li:organization:${org.id}`;
|
|
23
|
+
const role = session.viewer.pages.find((p) => p.organizationUrn === urn);
|
|
24
|
+
let admin = null;
|
|
25
|
+
if (role?.roles.includes("ADMINISTRATOR")) admin = await call("organizations.get", { organizationId: String(org.id) }).catch(() => null);
|
|
26
|
+
const followers = await call("network_sizes.get", { entity: urn, edgeType: "COMPANY_FOLLOWED_BY_MEMBER" }).then((r) => r.firstDegreeSize).catch(() => null);
|
|
27
|
+
paint(host, { org, admin, urn, followers, role });
|
|
28
|
+
} catch (error) {
|
|
29
|
+
host.replaceChildren(el("section", { class: "card state-card", attrs: { role: "alert" } }, [
|
|
30
|
+
el("h1", { class: "state-card__title", text: error.code === "NOT_FOUND" ? "This page doesn't exist" : "Couldn't load this page" }),
|
|
31
|
+
el("p", { class: "state-card__text", text: error.code === "NOT_FOUND" ? "Please check your URL or return to your feed." : describe(error) }),
|
|
32
|
+
el("a", { class: "btn btn--secondary", text: "Go to your feed", attrs: { href: "#/feed" } }),
|
|
33
|
+
]));
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
void load();
|
|
37
|
+
return { refresh: load };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function paint(host, { org, admin, urn, followers, role }) {
|
|
41
|
+
const location = org.locations?.[0]?.address;
|
|
42
|
+
const where = location ? [location.city, location.geographicArea].filter(Boolean).join(", ") : "";
|
|
43
|
+
const facts = [where, followers === null ? null : `${fmt(followers)} followers`, admin?.staffCountRange ? SIZES[admin.staffCountRange] : null].filter(Boolean).join(" · ");
|
|
44
|
+
const canPost = role?.roles.some((r) => ["ADMINISTRATOR", "CONTENT_ADMINISTRATOR", "DIRECT_SPONSORED_CONTENT_POSTER"].includes(r));
|
|
45
|
+
const posts = el("div", { class: "activity-list" });
|
|
46
|
+
const more = el("div", { class: "activity-more" });
|
|
47
|
+
const top = el("section", { class: "card profile-top" }, [
|
|
48
|
+
el("div", { class: "profile-top__cover profile-top__cover--company", attrs: { "aria-hidden": "true" } }),
|
|
49
|
+
el("div", { class: "profile-top__body" }, [
|
|
50
|
+
el("div", { class: "profile-top__avatar" }, avatar(org.localizedName, urn, 152, true)),
|
|
51
|
+
el("h1", { class: "profile-top__name", text: org.localizedName }),
|
|
52
|
+
admin?.localizedDescription ? el("p", { class: "profile-top__headline", text: admin.localizedDescription }) : null,
|
|
53
|
+
el("p", { class: "profile-top__meta muted", text: facts }),
|
|
54
|
+
el("div", { class: "profile-top__actions" }, admin
|
|
55
|
+
? [el("button", { class: "btn btn--primary btn--sm", attrs: { type: "button" }, on: { click: () => openComposer({ onDone: () => loadPosts(true) }) } }, [icon("pencil"), el("span", { text: "Create" })]), el("button", { class: "btn btn--secondary btn--sm", text: "Edit page", attrs: { type: "button", "data-not-simulated": "Editing page details" } })]
|
|
56
|
+
: [el("button", { class: "btn btn--primary btn--sm", attrs: { type: "button", "data-not-simulated": "Following pages" } }, [icon("plus"), el("span", { text: "Follow" })]), el("button", { class: "btn btn--secondary btn--sm", text: "Message", attrs: { type: "button", "data-not-simulated": "Messaging pages" } })]),
|
|
57
|
+
]),
|
|
58
|
+
role && !admin ? el("p", { class: "company-note", text: "You don't have access to this page's admin view." }) : null,
|
|
59
|
+
]);
|
|
60
|
+
const sections = [top];
|
|
61
|
+
if (admin) {
|
|
62
|
+
sections.push(el("section", { class: "card company-about" }, [
|
|
63
|
+
el("h2", { class: "section-head__title", text: "About" }),
|
|
64
|
+
admin.localizedDescription ? el("p", { class: "company-about__text", text: admin.localizedDescription }) : null,
|
|
65
|
+
el("dl", { class: "company-about__facts" }, [
|
|
66
|
+
["Website", admin.localizedWebsite], ["Company size", SIZES[admin.staffCountRange]], ["Headquarters", where], ["Founded", admin.foundedOn?.year ? String(admin.foundedOn.year) : null],
|
|
67
|
+
].filter(([, value]) => value).flatMap(([label, value]) => [el("dt", { text: label }), el("dd", { text: value })])),
|
|
68
|
+
]));
|
|
69
|
+
const admins = el("ul", { class: "company-admins" });
|
|
70
|
+
sections.push(el("section", { class: "card company-about" }, [el("h2", { class: "section-head__title", text: "Page admins" }), admins]));
|
|
71
|
+
void loadAdmins(admins, urn);
|
|
72
|
+
}
|
|
73
|
+
sections.push(el("section", { class: "card profile-activity" }, [
|
|
74
|
+
el("div", { class: "section-head" }, [el("h2", { class: "section-head__title", text: "Page posts" }), canPost ? el("button", { class: "btn btn--secondary btn--sm", text: "Start a post", attrs: { type: "button" }, on: { click: () => openComposer({ onDone: () => loadPosts(true) }) } }) : null]),
|
|
75
|
+
posts, more,
|
|
76
|
+
]));
|
|
77
|
+
host.replaceChildren(...sections);
|
|
78
|
+
|
|
79
|
+
let start = 0;
|
|
80
|
+
const loadPosts = async (reset) => {
|
|
81
|
+
if (reset) { start = 0; posts.replaceChildren(); }
|
|
82
|
+
more.replaceChildren();
|
|
83
|
+
try {
|
|
84
|
+
const page = await call("posts.list_by_author", { author: urn, sortBy: "CREATED", start, count: 5, ...(admin ? { viewContext: "AUTHOR" } : {}) });
|
|
85
|
+
for (const post of page.elements) posts.append(el("article", { class: "activity-row" }, [
|
|
86
|
+
el("p", { class: "activity-row__meta" }, [el("span", { text: `${org.localizedName} • ${relativeTime(post.publishedAt ?? post.createdAt)}` }), post.lifecycleState === "DRAFT" ? el("span", { class: "badge badge--draft", text: "Draft" }) : null]),
|
|
87
|
+
el("p", { class: "activity-row__text", text: littleTextToPlain(post.commentary) || (post.content?.article?.title ?? "") }),
|
|
88
|
+
]));
|
|
89
|
+
start += page.elements.length;
|
|
90
|
+
if (start === 0) posts.append(el("p", { class: "profile-activity__empty", text: "This page hasn't posted yet." }));
|
|
91
|
+
if (page.paging.links.some((l) => l.rel === "next")) more.append(el("button", { class: "btn btn--tertiary", text: "Show more posts", attrs: { type: "button" }, on: { click: (e) => busy(e.currentTarget, () => loadPosts(false)) } }));
|
|
92
|
+
} catch (error) {
|
|
93
|
+
posts.append(el("p", { class: "comments__error", attrs: { role: "alert" }, text: describe(error) }));
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
void loadPosts(true);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function loadAdmins(list, urn) {
|
|
100
|
+
let start = 0;
|
|
101
|
+
try {
|
|
102
|
+
for (;;) {
|
|
103
|
+
const page = await call("organization_acls.list", { q: "organization", organization: urn, state: "APPROVED", start, count: 20 });
|
|
104
|
+
for (const acl of page.elements) {
|
|
105
|
+
const who = await resolveEntity(acl.roleAssignee);
|
|
106
|
+
list.append(el("li", { class: "company-admins__row" }, [avatar(who.name, acl.roleAssignee, 40), el("div", {}, [el("a", { class: "t-bold link-quiet", text: who.name, attrs: { href: hrefForUrn(acl.roleAssignee) } }), el("div", { class: "muted t-12", text: acl.role.charAt(0) + acl.role.slice(1).toLowerCase().replaceAll("_", " ") })])]));
|
|
107
|
+
}
|
|
108
|
+
start += page.elements.length;
|
|
109
|
+
if (page.elements.length === 0 || !page.paging.links.some((l) => l.rel === "next")) break;
|
|
110
|
+
}
|
|
111
|
+
if (start === 0) list.append(el("li", { class: "muted", text: "No approved admins." }));
|
|
112
|
+
} catch (error) {
|
|
113
|
+
list.append(el("li", { class: "comments__error", attrs: { role: "alert" }, text: describe(error) }));
|
|
114
|
+
}
|
|
115
|
+
}
|