@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.
Files changed (111) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +189 -0
  3. package/firedrill/agent.target.json +16 -0
  4. package/firedrill/baseline.scenario.json +1834 -0
  5. package/firedrill/comment-throttled.scenario.json +11 -0
  6. package/firedrill/conformance.suite.json +27 -0
  7. package/firedrill/linkedin-comment-throttled.drill.json +51 -0
  8. package/firedrill/linkedin-comments-connection.drill.json +43 -0
  9. package/firedrill/linkedin-comments.drill.json +196 -0
  10. package/firedrill/linkedin-denied.drill.json +383 -0
  11. package/firedrill/linkedin-fresh-install.drill.json +43 -0
  12. package/firedrill/linkedin-identity.drill.json +73 -0
  13. package/firedrill/linkedin-invalid-token.drill.json +393 -0
  14. package/firedrill/linkedin-limited-scopes.drill.json +88 -0
  15. package/firedrill/linkedin-openid-only.drill.json +383 -0
  16. package/firedrill/linkedin-orgs-connection.drill.json +28 -0
  17. package/firedrill/linkedin-orgs-network.drill.json +193 -0
  18. package/firedrill/linkedin-post-lost-response.drill.json +61 -0
  19. package/firedrill/linkedin-posts-lifecycle.drill.json +228 -0
  20. package/firedrill/linkedin-reactions-metadata.drill.json +196 -0
  21. package/firedrill/linkedin-reactions-outage.drill.json +66 -0
  22. package/firedrill/linkedin-tight-limits.drill.json +228 -0
  23. package/firedrill/linkedin-ugc-legacy.drill.json +43 -0
  24. package/firedrill/linkedin-visibility-connection.drill.json +73 -0
  25. package/firedrill/linkedin-visibility-stranger.drill.json +88 -0
  26. package/firedrill/linkedin-wire-errors.drill.json +353 -0
  27. package/firedrill/post-lost-response.scenario.json +11 -0
  28. package/firedrill/reactions-outage.scenario.json +11 -0
  29. package/firedrill/tight-limits.scenario.json +16 -0
  30. package/firedrill/tools/linkedin/app/assets/ATTRIBUTION.md +28 -0
  31. package/firedrill/tools/linkedin/app/assets/fonts/OFL.txt +93 -0
  32. package/firedrill/tools/linkedin/app/assets/linkedin-in.svg +1 -0
  33. package/firedrill/tools/linkedin/app/assets/linkedin-wordmark.svg +1 -0
  34. package/firedrill/tools/linkedin/app/assets/linkedin.svg +1 -0
  35. package/firedrill/tools/linkedin/app/site/app.js +80 -0
  36. package/firedrill/tools/linkedin/app/site/assets/fonts/fira-sans-latin-400-normal.woff2 +0 -0
  37. package/firedrill/tools/linkedin/app/site/assets/fonts/fira-sans-latin-600-normal.woff2 +0 -0
  38. package/firedrill/tools/linkedin/app/site/assets/fonts/fira-sans-latin-700-normal.woff2 +0 -0
  39. package/firedrill/tools/linkedin/app/site/assets/fonts/fira-sans-latin-ext-400-normal.woff2 +0 -0
  40. package/firedrill/tools/linkedin/app/site/assets/fonts/fira-sans-latin-ext-600-normal.woff2 +0 -0
  41. package/firedrill/tools/linkedin/app/site/assets/fonts/fira-sans-latin-ext-700-normal.woff2 +0 -0
  42. package/firedrill/tools/linkedin/app/site/assets/linkedin-wordmark.svg +1 -0
  43. package/firedrill/tools/linkedin/app/site/assets/linkedin.svg +1 -0
  44. package/firedrill/tools/linkedin/app/site/base.css +87 -0
  45. package/firedrill/tools/linkedin/app/site/comment-box.js +119 -0
  46. package/firedrill/tools/linkedin/app/site/comments.css +59 -0
  47. package/firedrill/tools/linkedin/app/site/comments.js +188 -0
  48. package/firedrill/tools/linkedin/app/site/company.js +115 -0
  49. package/firedrill/tools/linkedin/app/site/composer.js +112 -0
  50. package/firedrill/tools/linkedin/app/site/feed.css +105 -0
  51. package/firedrill/tools/linkedin/app/site/feed.js +134 -0
  52. package/firedrill/tools/linkedin/app/site/icons.js +70 -0
  53. package/firedrill/tools/linkedin/app/site/index.html +58 -0
  54. package/firedrill/tools/linkedin/app/site/nav.css +51 -0
  55. package/firedrill/tools/linkedin/app/site/network.js +77 -0
  56. package/firedrill/tools/linkedin/app/site/overlay.css +62 -0
  57. package/firedrill/tools/linkedin/app/site/overlay.js +108 -0
  58. package/firedrill/tools/linkedin/app/site/pages.css +78 -0
  59. package/firedrill/tools/linkedin/app/site/post-actions.js +83 -0
  60. package/firedrill/tools/linkedin/app/site/post-menu.js +72 -0
  61. package/firedrill/tools/linkedin/app/site/post.css +82 -0
  62. package/firedrill/tools/linkedin/app/site/post.js +131 -0
  63. package/firedrill/tools/linkedin/app/site/profile.js +139 -0
  64. package/firedrill/tools/linkedin/app/site/reactions.js +46 -0
  65. package/firedrill/tools/linkedin/app/site/reactors.js +71 -0
  66. package/firedrill/tools/linkedin/app/site/shell.js +107 -0
  67. package/firedrill/tools/linkedin/app/site/store.js +62 -0
  68. package/firedrill/tools/linkedin/app/site/text.js +80 -0
  69. package/firedrill/tools/linkedin/app/site/ui.js +142 -0
  70. package/firedrill/tools/linkedin/behavior.mjs +66 -0
  71. package/firedrill/tools/linkedin/lib/access.mjs +69 -0
  72. package/firedrill/tools/linkedin/lib/auth.mjs +115 -0
  73. package/firedrill/tools/linkedin/lib/cascade.mjs +28 -0
  74. package/firedrill/tools/linkedin/lib/errors.mjs +50 -0
  75. package/firedrill/tools/linkedin/lib/message.mjs +43 -0
  76. package/firedrill/tools/linkedin/lib/paging.mjs +54 -0
  77. package/firedrill/tools/linkedin/lib/postinput.mjs +47 -0
  78. package/firedrill/tools/linkedin/lib/render.mjs +95 -0
  79. package/firedrill/tools/linkedin/lib/routes.mjs +219 -0
  80. package/firedrill/tools/linkedin/lib/social.mjs +25 -0
  81. package/firedrill/tools/linkedin/lib/store.mjs +54 -0
  82. package/firedrill/tools/linkedin/lib/text.mjs +55 -0
  83. package/firedrill/tools/linkedin/lib/urn.mjs +93 -0
  84. package/firedrill/tools/linkedin/lib/wire.mjs +125 -0
  85. package/firedrill/tools/linkedin/linkedin.tool.json +8935 -0
  86. package/firedrill/tools/linkedin/ops/comments-read.mjs +57 -0
  87. package/firedrill/tools/linkedin/ops/comments-write.mjs +104 -0
  88. package/firedrill/tools/linkedin/ops/feed.mjs +74 -0
  89. package/firedrill/tools/linkedin/ops/network.mjs +46 -0
  90. package/firedrill/tools/linkedin/ops/orgs.mjs +93 -0
  91. package/firedrill/tools/linkedin/ops/posts-edit.mjs +71 -0
  92. package/firedrill/tools/linkedin/ops/posts-read.mjs +54 -0
  93. package/firedrill/tools/linkedin/ops/posts-write.mjs +74 -0
  94. package/firedrill/tools/linkedin/ops/profile.mjs +38 -0
  95. package/firedrill/tools/linkedin/ops/reactions.mjs +78 -0
  96. package/firedrill/tools/linkedin/ops/social.mjs +34 -0
  97. package/firedrill/world.json +2585 -0
  98. package/firedrill.json +5 -0
  99. package/package.json +52 -0
  100. package/starter.json +1833 -0
  101. package/test/client.mjs +71 -0
  102. package/test/conformance.mjs +20 -0
  103. package/test/fixtures.mjs +182 -0
  104. package/test/flows/comments.mjs +116 -0
  105. package/test/flows/every.mjs +38 -0
  106. package/test/flows/faults.mjs +56 -0
  107. package/test/flows/identity.mjs +70 -0
  108. package/test/flows/orgs.mjs +107 -0
  109. package/test/flows/posts.mjs +196 -0
  110. package/test/flows/reactions.mjs +101 -0
  111. 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
+ }