├── .npmignore ├── demo ├── screenshot.png └── style.css ├── .gitignore ├── .vscode ├── extensions.json └── settings.json ├── .editorconfig ├── src ├── index.js ├── likes │ ├── ui.js │ └── index.js ├── autoload.js ├── likers │ ├── ui.js │ └── index.js └── api.js ├── .prettierrc.json ├── LICENSE ├── package.json ├── logo.svg ├── index.html └── README.md /.npmignore: -------------------------------------------------------------------------------- 1 | index.html 2 | demo/ 3 | .github 4 | .vscode 5 | .editorconfig 6 | .prettierrc.json 7 | -------------------------------------------------------------------------------- /demo/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeaVerou/bluesky-likes/HEAD/demo/screenshot.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .DS_Store 3 | Thumbs.db 4 | 5 | # Build artifacts 6 | types/ 7 | custom-elements.json 8 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "EditorConfig.EditorConfig", 4 | "esbenp.prettier-vscode" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | charset = utf-8 6 | trim_trailing_whitespace = true 7 | insert_final_newline = true 8 | end_of_line = lf 9 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export { default as BlueskyLikes } from "./likes/index.js"; 2 | export { default as BlueskyLikers } from "./likers/index.js"; 3 | export * as bsky from "./api.js"; 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.defaultFormatter": "esbenp.prettier-vscode", 4 | "prettier.enable": true, 5 | "debug.enableStatusBarColor": false 6 | } 7 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "prettier-plugin-brace-style", 4 | "prettier-plugin-space-before-function-paren", 5 | "prettier-plugin-merge" 6 | ], 7 | "braceStyle": "stroustrup", 8 | "arrowParens": "avoid", 9 | "bracketSpacing": true, 10 | "endOfLine": "auto", 11 | "semi": true, 12 | "singleQuote": false, 13 | "tabWidth": 4, 14 | "useTabs": true, 15 | "trailingComma": "all", 16 | "printWidth": 100 17 | } 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Lea Verou 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/likes/ui.js: -------------------------------------------------------------------------------- 1 | let url = import.meta.url; 2 | 3 | export const templates = { 4 | root () { 5 | let defaultIcon = url 6 | ? `` 7 | : "🦋"; 8 | return ` 9 | 10 | ${defaultIcon} 11 | 0 12 | 13 | `; 14 | }, 15 | }; 16 | 17 | export const styles = ` 18 | :host { 19 | display: inline-flex; 20 | gap: 0.25em; 21 | align-items: center; 22 | color: #1185fe; 23 | border: max(1px, .07em) solid currentColor; 24 | border-radius: 0.25em; 25 | padding: 0.25em 0.3em; 26 | text-decoration: none; 27 | font-weight: bold; 28 | line-height: 1; 29 | } 30 | 31 | @keyframes loading { 32 | from { opacity: 0.5; } 33 | to { opacity: 0.8; } 34 | } 35 | 36 | :host(:state(loading)) { 37 | animation: loading 1s infinite alternate; 38 | } 39 | 40 | a { 41 | color: inherit; 42 | text-decoration: none; 43 | } 44 | 45 | :host:has(a:hover) { 46 | background: lab(from currentColor l a b / 0.1); 47 | } 48 | 49 | [part=icon] { 50 | block-size: 1em; 51 | vertical-align: -0.16em; 52 | } 53 | `; 54 | -------------------------------------------------------------------------------- /src/autoload.js: -------------------------------------------------------------------------------- 1 | export const components = ["likes", "likers"]; 2 | let observer = null; 3 | 4 | if (globalThis.document) { 5 | discover(document.body); 6 | } 7 | 8 | /** @param {Node} node */ 9 | export function observe (node) { 10 | observer ??= new MutationObserver(mutations => { 11 | for (const { addedNodes } of mutations) { 12 | for (const node of addedNodes) { 13 | if (node.nodeType === Node.ELEMENT_NODE) { 14 | discover(node); 15 | } 16 | } 17 | } 18 | }); 19 | observer.observe(node, { childList: true, subtree: true }); 20 | } 21 | 22 | /** @param {Node} node */ 23 | export function unobserve (node) { 24 | observer.unobserve(node); 25 | } 26 | 27 | /** @param {Element} node */ 28 | export function discover (node) { 29 | for (let i = 0; i < components.length; i++) { 30 | let component = components[i]; 31 | let tag = `bluesky-${component}`; 32 | let isRegistered = Boolean(customElements.get(tag)); 33 | 34 | if (isRegistered) { 35 | // Only check components that are not already defined 36 | components.splice(i, 1); 37 | i--; 38 | continue; 39 | } 40 | 41 | if (node.querySelector(tag) || node.matches?.(tag)) { 42 | import(`./${component}/index.js`); 43 | components.splice(i, 1); 44 | i--; 45 | } 46 | } 47 | 48 | if (components.length === 0 && observer) { 49 | // Nothing left to do here 50 | observer.disconnect(); 51 | observer = null; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /demo/style.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --color-accent: #1185fe; 3 | --color-neutral: oklch(50% 0.01 220); 4 | } 5 | 6 | html { 7 | font: 8 | 1rem/1.5 ui-sans-serif, 9 | system-ui, 10 | sans-serif; 11 | 12 | accent-color: var(--color-accent); 13 | } 14 | 15 | a { 16 | color: var(--color-accent); 17 | 18 | &:not(:hover, :focus) { 19 | text-decoration: none; 20 | } 21 | } 22 | 23 | body { 24 | margin: 0; 25 | padding: 2rem; 26 | } 27 | 28 | header { 29 | display: grid; 30 | grid-template-columns: auto 1fr; 31 | gap: 0.25rem 1rem; 32 | margin-bottom: 2rem; 33 | } 34 | 35 | h1, 36 | h2 { 37 | line-height: 1.15; 38 | text-wrap: balance; 39 | } 40 | 41 | h1, 42 | .logo { 43 | font-size: calc(200% + 1vw); 44 | color: var(--color-accent); 45 | margin: 0; 46 | } 47 | 48 | .logo { 49 | grid-row: 1 / 3; 50 | height: 1.5em; 51 | margin-top: 0.15em; 52 | } 53 | 54 | h2 { 55 | font-size: 200%; 56 | } 57 | 58 | input, 59 | button { 60 | font: inherit; 61 | } 62 | 63 | form { 64 | align-items: end; 65 | } 66 | 67 | form, 68 | label { 69 | display: flex; 70 | gap: 0.5rem; 71 | 72 | label:has(#url), 73 | #url { 74 | flex: 1; 75 | } 76 | 77 | label { 78 | gap: 0; 79 | flex-flow: column; 80 | font-size: 80%; 81 | 82 | input { 83 | font-size: 125%; 84 | } 85 | } 86 | } 87 | 88 | footer { 89 | margin-top: 2rem; 90 | font-size: 70%; 91 | color: var(--color-neutral); 92 | font-weight: 500; 93 | } 94 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bluesky-likes", 3 | "version": "0.0.5", 4 | "description": "Description", 5 | "keywords": [], 6 | "homepage": "https://github.com/leaverou/bluesky-likes/#readme", 7 | "bugs": { 8 | "url": "https://github.com/leaverou/bluesky-likes/issues" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/leaverou/bluesky-likes.git" 13 | }, 14 | "license": "MIT", 15 | "author": "Lea Verou", 16 | "contributors": [ 17 | "Lea Verou", 18 | "Dmitry Sharabin" 19 | ], 20 | "funding": [ 21 | { 22 | "type": "individual", 23 | "url": "https://github.com/sponsors/LeaVerou" 24 | } 25 | ], 26 | "type": "module", 27 | "main": "src/index.js", 28 | "exports": { 29 | ".": { 30 | "import": "./src/index.js", 31 | "types": "./types/index.d.ts" 32 | }, 33 | "./likes": { 34 | "import": "./src/likes/index.js", 35 | "types": "./types/likes/index.d.ts" 36 | }, 37 | "./likers": { 38 | "import": "./src/likers/index.js", 39 | "types": "./types/likers/index.d.ts" 40 | }, 41 | "./api": { 42 | "import": "./src/api.js", 43 | "types": "./types/api.d.ts" 44 | }, 45 | "./autoload": { 46 | "import": "./src/autoload.js", 47 | "types": "./types/autoload.d.ts" 48 | } 49 | }, 50 | "types": "./types/index.d.ts", 51 | "sideEffects": true, 52 | "scripts": { 53 | "build:types": "tsc --allowJs --emitDeclarationOnly --declaration --outDir types src/index.js src/autoload.js", 54 | "build:cem": "cem analyze --litelement", 55 | "build": "npm run build:types && npm run build:cem", 56 | "prepublishOnly": "npm run build", 57 | "release": "release-it", 58 | "test": "echo \"Error: no test specified\" && exit 1" 59 | }, 60 | "devDependencies": { 61 | "@custom-elements-manifest/analyzer": "^0.8.0", 62 | "prettier-plugin-brace-style": "latest", 63 | "prettier-plugin-merge": "latest", 64 | "prettier-plugin-space-before-function-paren": "latest", 65 | "release-it": "latest", 66 | "typescript": "^5.8.3" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Bluesky Likes Web Components 7 | 8 | 9 | 10 | 21 | 22 | 23 |
24 | 25 |

Bluesky Likes Web Components

26 | 34 |
35 | 36 |
37 | 48 | 52 | 53 |
54 |

55 | 59 | likes on Bluesky 60 |

61 | 62 | 66 | 67 |

Other examples

68 | 69 |

70 | Using inside a link suppresses its internal link, allowing you to link to a different 71 | place: 72 | 78 |

79 | 80 | 86 | 87 | 88 | -------------------------------------------------------------------------------- /src/likes/index.js: -------------------------------------------------------------------------------- 1 | import { getPost } from "../api.js"; 2 | import { styles, templates } from "./ui.js"; 3 | 4 | /** 5 | * @customElement bluesky-likes 6 | * @element bluesky-likes 7 | * 8 | * Displays the number of likes on a post and links to the full list. 9 | * 10 | * @attr {string} src - The URL of the post to display likes for. This is the only required attribute and must be a valid Bluesky post URL. 11 | * 12 | * @prop {BlueskyPost | undefined} post - The Bluesky post data containing information about the post, including its author, content, and engagement metrics 13 | * @prop {number} likes - The number of likes on the post 14 | * @prop {string} likersUrl - The URL to view the post's likers 15 | * 16 | * @slot - Content added after the likes count, typically used for additional information or actions 17 | * @slot prefix - Custom icon to replace the default heart icon. The icon should be an inline SVG or image element. 18 | * 19 | * @csspart count - The element that displays the likes count, formatted according to the user's locale 20 | * @csspart link - The link to view the post's likers, automatically removed if the component is placed inside another link 21 | * @csspart icon - The default heart icon which is displayed if nothing is slotted in the prefix slot 22 | * 23 | * @state loading - Indicates that the component is currently loading data from the Bluesky API 24 | */ 25 | export default class BlueskyLikes extends HTMLElement { 26 | static templates = templates; 27 | static styles = styles; 28 | static sheet; 29 | 30 | data = {}; 31 | #dom = {}; 32 | 33 | constructor () { 34 | super(); 35 | this.attachShadow({ mode: "open" }); 36 | this._internals = this.attachInternals?.(); 37 | 38 | let { templates, styles, sheet } = this.constructor; 39 | 40 | if (this.shadowRoot.adoptedStyleSheets) { 41 | if (!sheet) { 42 | sheet = new CSSStyleSheet(); 43 | sheet.replaceSync(styles); 44 | this.constructor.sheet = sheet; 45 | } 46 | 47 | this.shadowRoot.adoptedStyleSheets = [sheet]; 48 | } 49 | 50 | this._setShadowHTML(templates.root()); 51 | 52 | this.init(); 53 | } 54 | 55 | init () { 56 | this.#dom.count = this.shadowRoot.querySelector("[part~=count]"); 57 | this.#dom.link = this.shadowRoot.querySelector("[part~=link]"); 58 | } 59 | 60 | /** 61 | * Set the shadow root’s innerHTML. 62 | * @protected 63 | * @param {string} html 64 | */ 65 | _setShadowHTML (html) { 66 | if (!this.shadowRoot.adoptedStyleSheets && this.constructor.styles) { 67 | html = `\n${html}`; 68 | } 69 | 70 | this.shadowRoot.innerHTML = html; 71 | } 72 | 73 | connectedCallback () { 74 | // Polyfill for https://github.com/whatwg/html/issues/7039 75 | this._currentLang = 76 | this.lang || 77 | this.parentNode.closest("[lang]")?.lang || 78 | this.ownerDocument.documentElement.lang || 79 | "en"; 80 | 81 | // Only include a link iff the element is not within one 82 | this._isInLink = this.closest("a[href]") !== null; 83 | 84 | if (this._isInLink && this.#dom.link?.parentNode) { 85 | // Is inside a link, but we also have a link 86 | this.#dom.link.replaceWith(...this.#dom.link.childNodes); 87 | } 88 | else if (!this._isInLink && !this.#dom.link?.parentNode) { 89 | // Is not inside a link, but we don't have a link, (re-)insert it 90 | this._setShadowHTML(templates.root()); 91 | this.init(); 92 | } 93 | 94 | if (this.src) { 95 | this.render({ useCache: true }); 96 | } 97 | } 98 | 99 | /** 100 | * @returns {import("../api.js").BlueskyPost | undefined} 101 | */ 102 | get post () { 103 | return this.data.post; 104 | } 105 | 106 | /** 107 | * @returns {number} 108 | */ 109 | get likes () { 110 | return this.data.post?.likeCount ?? 0; 111 | } 112 | 113 | get likersUrl () { 114 | return this.src + "/liked-by"; 115 | } 116 | 117 | async fetch ({ force } = {}) { 118 | let postUrl = this.src; 119 | 120 | let post = await getPost(postUrl, { force }); 121 | 122 | if (!post) { 123 | // Lazy loading? 124 | return; 125 | } 126 | 127 | this.data.post = post; 128 | 129 | return this.data; 130 | } 131 | 132 | async render ({ useCache = false } = {}) { 133 | this._internals.states?.add("loading"); 134 | 135 | if (this._isInsideLink) { 136 | // Remove link from the DOM 137 | } 138 | else { 139 | this.#dom.link.href = this.likersUrl; 140 | } 141 | 142 | if (!this.data.post || !useCache) { 143 | await this.fetch({ force: !useCache }); 144 | } 145 | 146 | if (this.data.post && this.#dom.count) { 147 | this.#dom.count.value = this.likes; 148 | this.#dom.count.textContent = this.likes.toLocaleString(this._currentLang, { 149 | notation: "compact", 150 | }); 151 | } 152 | this._internals.states?.delete("loading"); 153 | } 154 | 155 | get src () { 156 | return this.getAttribute("src"); 157 | } 158 | 159 | set src (value) { 160 | this.setAttribute("src", value); 161 | } 162 | 163 | static get observedAttributes () { 164 | return ["src"]; 165 | } 166 | 167 | attributeChangedCallback (name, oldValue, newValue) { 168 | if (name === "src") { 169 | this.render(); 170 | } 171 | } 172 | } 173 | 174 | if (!customElements.get("bluesky-likes")) { 175 | customElements.define("bluesky-likes", BlueskyLikes); 176 | } 177 | -------------------------------------------------------------------------------- /src/likers/ui.js: -------------------------------------------------------------------------------- 1 | export const templates = { 2 | root ({ likers, likes, hiddenCount, url, element } = {}) { 3 | if (likes === 0) { 4 | return this.empty({ url }); 5 | } 6 | 7 | return `${this.description({ likers, likes, hiddenCount })} 8 | ${this.skipLink({ likers, likes, hiddenCount, url, element })} 9 | ${likers?.map(liker => this.user(liker)).join(" ")} 10 | ${hiddenCount > 0 ? this.more({ hiddenCount, url, element }) : ""}`; 11 | }, 12 | skipLink ({ likes }) { 13 | if (likes <= 2) { 14 | return ""; 15 | } 16 | 17 | return ` 18 | 19 | 20 | Skip to end 21 | 22 | `; 23 | }, 24 | description ({ likers, likes, hiddenCount }) { 25 | return `${likes} users liked this post${hiddenCount > 0 ? `, ${likers.length} shown` : ""}.`; 26 | }, 27 | user ({ actor }) { 28 | let title = actor.displayName 29 | ? `${actor.displayName} (@${actor.handle})` 30 | : `@${actor.handle}`; 31 | let avatarSrc = actor.avatar?.replace("avatar", "avatar_thumbnail"); 32 | return ` 33 | 34 | ${avatarSrc ? `` : ""} 35 | `; 36 | }, 37 | more ({ hiddenCount, url, element }) { 38 | let hiddenCountFormatted = hiddenCount.toLocaleString(element._currentLang || "en", { 39 | notation: "compact", 40 | }); 41 | let likedBy = url + "/liked-by"; 42 | return `+${hiddenCountFormatted}`; 43 | }, 44 | empty ({ url }) { 45 | return `No likes yet :( Be the first?`; 46 | }, 47 | }; 48 | 49 | export const styles = ` 50 | :host { 51 | --avatar-size: calc(2em + 1vw); 52 | /* This is registered as a , so we can use smaller font sizes in more while keeping the same avatar size */ 53 | --bluesky-likers-avatar-size: var(--avatar-size); 54 | --avatar-overlap-percentage: 0.3; 55 | --avatar-overlap-percentage-y: 0.2; 56 | --avatar-border: .15em solid canvas; 57 | --avatar-shadow: 0 .1em .4em -.3em rgb(0 0 0 / 0.4); 58 | --avatar-background: ${svg(` 59 | 60 | 61 | `)} center / cover canvas; 62 | --more-background: #1185fe; 63 | --more-color-text: white; 64 | 65 | --avatar-overlap: calc(var(--bluesky-likers-avatar-size) * var(--avatar-overlap-percentage)); 66 | --avatar-overlap-y: calc(var(--bluesky-likers-avatar-size) * var(--avatar-overlap-percentage-y)); 67 | 68 | display: block; 69 | padding-inline-start: var(--avatar-overlap); 70 | padding-block-start: var(--avatar-overlap-y); 71 | /* Avoid more being on a new line by itself */ 72 | text-wrap: pretty; 73 | } 74 | 75 | @keyframes loading { 76 | from { opacity: 0.5; } 77 | to { opacity: 0.8; } 78 | } 79 | 80 | :host(:state(loading)) a { 81 | animation: loading 1s infinite alternate; 82 | } 83 | 84 | a { 85 | text-decoration: none; 86 | color: inherit; 87 | } 88 | 89 | [part~="link"] { 90 | display: inline-flex; 91 | margin-inline-start: calc(-1 * var(--avatar-overlap)); 92 | margin-block-start: calc(-1 * var(--avatar-overlap-y)); 93 | 94 | vertical-align: middle; 95 | position: relative; 96 | 97 | &:hover, 98 | &:focus { 99 | z-index: 1; 100 | } 101 | } 102 | 103 | img { 104 | display: block; 105 | } 106 | 107 | [part~="avatar"] { 108 | block-size: var(--bluesky-likers-avatar-size); 109 | inline-size: var(--bluesky-likers-avatar-size); /* fallback for Safari which doesn't respect aspect-ratio for some reason */ 110 | aspect-ratio: 1; 111 | object-fit: cover; 112 | border-radius: 50%; 113 | box-shadow: var(--avatar-shadow); 114 | border: var(--avatar-border); 115 | background: var(--avatar-background); 116 | transition: scale 0.1s ease-in-out; 117 | 118 | &:hover, 119 | &:focus { 120 | scale: 1.1; 121 | } 122 | } 123 | 124 | [part~="more"] { 125 | display: inline-flex; 126 | align-items: center; 127 | justify-content: center; 128 | background: var(--more-background); 129 | color: var(--more-color-text); 130 | font-weight: 600; 131 | letter-spacing: -.03em; 132 | text-indent: -.2em; /* visual centering to account for + */ 133 | font-size: calc(var(--bluesky-likers-avatar-size) / 3 - clamp(0, var(--content-length) - 3, 10) * .05em); 134 | } 135 | 136 | .visually-hidden, 137 | .visually-hidden-always { 138 | position: absolute; 139 | } 140 | 141 | .visually-hidden:not(:focus-within), 142 | .visually-hidden-always { 143 | display: block; 144 | width: 1px; 145 | height: 1px; 146 | clip: rect(0 0 0 0); 147 | clip-path: inset(50%); 148 | border: none; 149 | overflow: hidden; 150 | white-space: nowrap; 151 | padding: 0; 152 | } 153 | 154 | slot[name="skip-link"]:focus-within { 155 | display: block; 156 | z-index: 1; 157 | } 158 | 159 | slot[name="skip-link"]::slotted(a:focus), 160 | [part~="skip-link"]:focus { 161 | padding: 0.25em .5em; 162 | background: canvas; 163 | color: canvastext; 164 | } 165 | `; 166 | 167 | function svg (markup) { 168 | return `url('data:image/svg+xml,${encodeURIComponent(markup)}')`; 169 | } 170 | -------------------------------------------------------------------------------- /src/likers/index.js: -------------------------------------------------------------------------------- 1 | import BlueskyLikes from "../likes/index.js"; 2 | import { getPostLikes } from "../api.js"; 3 | import { styles, templates } from "./ui.js"; 4 | 5 | /** 6 | * @customElement bluesky-likers 7 | * @element bluesky-likers 8 | * 9 | * Displays the avatars of users who liked a post up to a max limit, and the number of additional users not shown. 10 | * 11 | * @attr {string} src - The URL of the post to display likes for. This is the only required attribute and must be a valid Bluesky post URL. 12 | * @attr {number} max - Maximum number of likers to display (default: 50). If the total number of likers exceeds this value, a "+N" indicator will be shown. 13 | * 14 | * @prop {BlueskyLike[]} likers - Array of users who liked the post, limited by the max attribute. Each user object contains their handle, display name, and avatar URL. 15 | * @prop {number} hiddenCount - The number of additional likers not shown in the list, calculated as the difference between total likes and the number of displayed likers 16 | * 17 | * @slot - Visually hidden content for screen reader users, providing additional context about the likers 18 | * @slot empty - Content displayed when there are no likers, defaults to a message with a link to the post 19 | * @slot skip-link - Skip link for keyboard and screen reader users. See Accessibility Notes. 20 | * @slot skip-text - Content for the skip link, defaults to "Skip to end". Ignored when skip-link has content. 21 | * @slot description - Visually hidden content for screen reader users, defaults to a description of the likers 22 | * 23 | * @csspart avatar - The circular element that displays a user's avatar or the "+N" indicator for additional likers 24 | * @csspart avatar-img - The img element for users with an avatar, with lazy loading enabled 25 | * @csspart link - The a element that wraps each entry, providing hover and focus states 26 | * @csspart profile-link - The a element that links to the user's profile on Bluesky 27 | * @csspart more - The a element that displays the hidden count and links to the full list of likers 28 | * @csspart skip-link - The a element that links to the end of the likers list for keyboard and screen reader users 29 | * 30 | * @cssproperty --avatar-size - The size of each avatar (default: calc(2em + 1vw)) 31 | * @cssproperty --bluesky-likers-avatar-size - The size of the avatar images (inherits from --avatar-size) 32 | * @cssproperty --avatar-overlap-percentage - The percentage of horizontal overlap between avatars (default: 0.3) 33 | * @cssproperty --avatar-overlap-percentage-y - The percentage of vertical overlap between avatars (default: 0.2) 34 | * @cssproperty --avatar-border - The border style for each avatar (default: .15em solid canvas) 35 | * @cssproperty --avatar-shadow - The box-shadow applied to each avatar (default: 0 .1em .4em -.3em rgb(0 0 0 / 0.4)) 36 | * @cssproperty --avatar-background - The background for avatars without a user image (default: SVG data URL with a default avatar) 37 | * @cssproperty --more-background - The background color for the "+N" (more) avatar (default: #1185fe) 38 | * @cssproperty --more-color-text - The text color for the "+N" (more) avatar (default: white) 39 | * @cssproperty --avatar-overlap - The actual horizontal overlap between avatars (calculated from --avatar-size and --avatar-overlap-percentage) 40 | * @cssproperty --avatar-overlap-y - The actual vertical overlap between avatars (calculated from --avatar-size and --avatar-overlap-percentage-y) 41 | * 42 | * @state loading - Indicates that the component is currently loading data from the Bluesky API 43 | * @state empty - Indicates that there are no likers to display, showing the empty slot content 44 | */ 45 | export default class BlueskyLikers extends BlueskyLikes { 46 | static templates = templates; 47 | static styles = styles; 48 | static sheet; 49 | 50 | /** 51 | * Set to true once the first instance of the component is created. 52 | * @type {boolean} 53 | * @private 54 | */ 55 | static _initialized = false; 56 | data = {}; 57 | 58 | constructor () { 59 | super(); 60 | this.constructor.init(); 61 | this._internals.ariaDescribedBy = "description"; 62 | this.addEventListener("keyup", this); 63 | } 64 | 65 | handleEvent (event) { 66 | if (event.type === "keyup") { 67 | if (event.key === "Home" || event.key === "End") { 68 | event.preventDefault?.(); 69 | event.stopPropagation?.(); 70 | let links = Array.from(this.shadowRoot.querySelectorAll("a")); 71 | 72 | if (links.length > 0) { 73 | if (event.key === "Home") { 74 | links.at(0)?.focus(); 75 | } 76 | else { 77 | links.at(-1)?.focus(); 78 | } 79 | } 80 | } 81 | } 82 | } 83 | 84 | static init () { 85 | if (this._initialized) { 86 | return; 87 | } 88 | 89 | this._initialized = true; 90 | 91 | // Register the CSS custom property for the avatar size 92 | // @property in Shadow DOM is not yet supported, we need to register it in the light DOM 93 | globalThis?.CSS?.registerProperty?.({ 94 | name: "--bluesky-likers-avatar-size", 95 | initialValue: "48px", 96 | syntax: "", 97 | inherits: true, 98 | }); 99 | } 100 | 101 | /** 102 | * @returns {import("../api.js").BlueskyLike[]} 103 | */ 104 | get likers () { 105 | return this.data.likers ?? []; 106 | } 107 | 108 | hiddenCount = 0; 109 | 110 | async fetch ({ force } = {}) { 111 | await super.fetch({ force }); 112 | 113 | if (this.data.post) { 114 | this.data.likers = await getPostLikes(this.src, { force, limit: this.max }); 115 | } 116 | 117 | return this.data; 118 | } 119 | 120 | async render ({ useCache = false } = {}) { 121 | this._internals.states?.add("loading"); 122 | if (!this.data.likers || !useCache) { 123 | await this.fetch({ force: !useCache }); 124 | } 125 | 126 | if (!this.data.likers) { 127 | return; 128 | } 129 | 130 | let likes = this.likes; 131 | let hasLikes = likes > 0; 132 | 133 | let { templates } = this.constructor; 134 | 135 | if (hasLikes) { 136 | this._internals.states?.delete("empty"); 137 | 138 | // Render the likers 139 | let likers = this.data.likers ?? []; 140 | let max = this.max; 141 | 142 | if (likers.length > max) { 143 | likers = likers.slice(0, max); 144 | } 145 | 146 | this.hiddenCount = likes - likers.length; 147 | 148 | this._setShadowHTML( 149 | templates.root({ 150 | likers, 151 | likes, 152 | hiddenCount: this.hiddenCount, 153 | post: this.data.post, 154 | url: this.src, 155 | element: this, 156 | }), 157 | ); 158 | } 159 | else { 160 | this._internals.states?.add("empty"); 161 | 162 | this._setShadowHTML(`${templates.empty({ url: this.src })}`); 163 | } 164 | 165 | this._internals.states?.delete("loading"); 166 | } 167 | 168 | get max () { 169 | return Number(this.getAttribute("max") || 50); 170 | } 171 | 172 | set max (value) { 173 | this.setAttribute("max", value); 174 | } 175 | 176 | static get observedAttributes () { 177 | return [...super.observedAttributes, "max"]; 178 | } 179 | 180 | attributeChangedCallback (name, oldValue, newValue) { 181 | super.attributeChangedCallback(name, oldValue, newValue); 182 | 183 | if (name === "max") { 184 | let oldMax = oldValue === null ? 50 : Number(oldValue); 185 | this.render({ useCache: oldMax <= newValue }); 186 | } 187 | } 188 | } 189 | 190 | if (!customElements.get("bluesky-likers")) { 191 | customElements.define("bluesky-likers", BlueskyLikers); 192 | } 193 | -------------------------------------------------------------------------------- /src/api.js: -------------------------------------------------------------------------------- 1 | const BASE_ENDPOINT = "https://public.api.bsky.app/xrpc/app.bsky."; 2 | 3 | let endpoints = { 4 | profile: "actor.getProfile", 5 | posts: "feed.getPosts", 6 | likes: "feed.getLikes", 7 | }; 8 | 9 | /** 10 | * Cached API responses by endpoint 11 | * @type {Record | null | undefined>>} 12 | */ 13 | export const cacheByEndpoint = Object.fromEntries( 14 | Object.values(endpoints).map(endpoint => [endpoint, {}]), 15 | ); 16 | 17 | /** 18 | * Parse a post like "https://bsky.app/profile/lea.verou.me/post/3lhygzakuic2n" 19 | * and return the handle and post ID 20 | * @param {string} url 21 | * @returns {{ handle: string | undefined, postId: string | undefined }} 22 | */ 23 | export function parsePostUrl (url) { 24 | return { 25 | handle: url.match(/\/profile\/([^\/]+)/)?.[1], 26 | postId: url.match(/\/post\/([^\/]+)/)?.[1], 27 | }; 28 | } 29 | 30 | /** 31 | * Get profile details by handle 32 | * @param {string} handle 33 | * @param {Object} [options] 34 | * @param {boolean} [options.force] - Bypass the cache and fetch the data again even if cached. 35 | * @returns {Promise} 36 | */ 37 | export async function getProfile (handle, options = {}) { 38 | let endpoint = endpoints.profile; 39 | let cache = cacheByEndpoint[endpoint]; 40 | 41 | if (cache[handle] && !options.force) { 42 | return cache[handle]; 43 | } 44 | 45 | let profileUrl = `${BASE_ENDPOINT}${endpoint}?actor=${handle}`; 46 | let data = getJSON(profileUrl); 47 | cache[handle] = data; 48 | data = await data; 49 | 50 | return (cache[handle] = data); 51 | } 52 | 53 | /** 54 | * Get the DID of a user by their handle. 55 | * Does not send an API call if the handle is already a DID. 56 | * @param {string} handle 57 | * @returns {Promise} 58 | */ 59 | export async function getDid (handle) { 60 | if (handle.startsWith("did:")) { 61 | return handle; 62 | } 63 | 64 | return (await getProfile(handle))?.did; 65 | } 66 | 67 | /** 68 | * Bluesky "at-uri" of the post 69 | * @param {string} postUrl 70 | * @returns {Promise} 71 | */ 72 | export async function getPostUri (postUrl) { 73 | let post = parsePostUrl(postUrl); 74 | 75 | if (!post.handle || !post.postId) { 76 | return undefined; 77 | } 78 | 79 | let did = await getDid(post.handle); 80 | 81 | if (!did) { 82 | return undefined; 83 | } 84 | 85 | return `at://${did}/app.bsky.feed.post/${post.postId}`; 86 | } 87 | 88 | /** 89 | * Get post details by URL. 90 | * @param {string} postUrl 91 | * @param {Object} [options] 92 | * @param {boolean} [options.force] - Bypass the cache and fetch the data again even if cached. 93 | * @returns {Promise} 94 | */ 95 | export async function getPost (postUrl, options = {}) { 96 | let endpoint = endpoints.posts; 97 | let cache = cacheByEndpoint[endpoint]; 98 | 99 | if (cache[postUrl] && !options.force) { 100 | return cache[postUrl]; 101 | } 102 | 103 | const postUri = await getPostUri(postUrl); 104 | 105 | if (!postUri) { 106 | return undefined; 107 | } 108 | 109 | const apiCall = `${BASE_ENDPOINT}${endpoint}?uris=${postUri}`; 110 | let data = getJSON(apiCall).then(data => data?.posts?.[0]); 111 | cache[postUrl] = data; 112 | data = await data; 113 | 114 | if (!data) { 115 | return null; 116 | } 117 | 118 | return (cache[postUrl] = data); 119 | } 120 | 121 | /** 122 | * Get the likers for a post by its URL. 123 | * @param {string} postUrl 124 | * @param {Object} [options] 125 | * @param {boolean} [options.force] - Bypass the cache and fetch the data again even if cached. 126 | * @param {number} [options.limit] - Limit the number of returned likes 127 | * @returns {Promise} 128 | */ 129 | export async function getPostLikes (postUrl, options = {}) { 130 | let endpoint = endpoints.likes; 131 | let cache = cacheByEndpoint[endpoint]; 132 | 133 | if (cache[postUrl] && !options.force) { 134 | return cache[postUrl]; 135 | } 136 | 137 | const postUri = await getPostUri(postUrl); 138 | 139 | if (!postUri) { 140 | return undefined; 141 | } 142 | 143 | let apiCall = `${BASE_ENDPOINT}${endpoint}?uri=${postUri}`; 144 | 145 | if (options.limit) { 146 | let limit = Math.min(options.limit, 100); 147 | apiCall += `&limit=${limit}`; 148 | } 149 | 150 | let data = getJSON(apiCall).then(data => data?.likes); 151 | cache[postUrl] = data; 152 | 153 | data = await data; 154 | 155 | if (!data) { 156 | return null; 157 | } 158 | 159 | return (cache[postUrl] = data); 160 | } 161 | 162 | /** 163 | * @param {string} url 164 | * @returns {Promise} 165 | */ 166 | function getJSON (url) { 167 | return fetch(url).then(res => res.json()); 168 | } 169 | 170 | // Extracted from the @atproto/api package 171 | 172 | /** 173 | * @typedef {Object} BlueskyActor 174 | * @property {string} did - The DID (Decentralized Identifier) of the actor 175 | * @property {string} handle - The handle (username) of the actor 176 | * @property {string} [displayName] - Optional display name of the actor 177 | * @property {string} [avatar] - Optional URL to the actor's avatar 178 | */ 179 | 180 | /** 181 | * @typedef {Object} BlueskyPostRecord 182 | * @property {string} text - The text content of the post 183 | * @property {string} createdAt - The creation timestamp of the post 184 | * @property {string[]} [langs] - Optional languages used 185 | * @property {any[]} [facets] - Optional facets 186 | * @property {any} [embed] - Optional embed object 187 | * @property {{ root: { cid: string, uri: string }, parent: { cid: string, uri: string } }} [reply] - Optional reply info 188 | * @property {any[]} [labels] - Optional labels 189 | */ 190 | 191 | /** 192 | * @typedef {Object} BlueskyPost 193 | * @property {string} uri - The URI of the post 194 | * @property {string} cid - The CID of the post 195 | * @property {BlueskyActor} author - The author of the post 196 | * @property {BlueskyPostRecord} record - The post record 197 | * @property {number} likeCount - Number of likes 198 | * @property {number} repostCount - Number of reposts 199 | * @property {number} replyCount - Number of replies 200 | * @property {string} indexedAt - Index timestamp 201 | * @property {{ like?: string, repost?: string }} [viewer] - Viewer state 202 | */ 203 | 204 | /** 205 | * @typedef {Object} BlueskyLike 206 | * @property {string} uri - The URI of the like 207 | * @property {string} cid - The CID of the like 208 | * @property {string} createdAt - Like timestamp 209 | * @property {string} indexedAt - Index timestamp 210 | * @property {BlueskyActor} actor - The actor who liked the post 211 | */ 212 | 213 | /** 214 | * @typedef {Object} BlueskyProfile 215 | * @property {string} did - The DID of the profile 216 | * @property {string} handle - The handle of the profile 217 | * @property {string} [displayName] - Optional display name 218 | * @property {string} [description] - Optional profile description 219 | * @property {string} [avatar] - Optional avatar URL 220 | * @property {string} [banner] - Optional banner URL 221 | * @property {number} followersCount - Number of followers 222 | * @property {number} followsCount - Number of accounts followed 223 | * @property {number} postsCount - Number of posts 224 | * @property {string} indexedAt - When the profile was indexed 225 | * @property {{ muted: boolean, blockedBy: boolean }} [viewer] - Viewer state 226 | */ 227 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # BlueSky Likes Components 2 | 3 | Components to display (and encourage) likes on [BlueSky](https://bsky.app) posts. 4 | 5 | - [``](#bluesky-likes): Displays the number of likes on a post. 6 | - [``](#bluesky-likers): Displays avatars of users who liked a post. 7 | 8 | For a demo, check out https://projects.verou.me/bluesky-likes/ 9 | 10 | Can be used separately, or together. 11 | 12 | E.g. for something similar to [Salma Alam-Nayor’s](https://whitep4nth3r.com/blog/show-bluesky-likes-on-blog-posts/): 13 | 14 | ```html 15 |

16 | 17 | likes on Bluesky 18 |

19 | 20 |

21 | Like this post on Bluesky to see your face on this page 22 |

23 | 24 | 25 | ``` 26 | 27 | ![](demo/screenshot.png) 28 | 29 | ## Features 30 | 31 | These components are designed to make common cases easy, and complex cases possible. 32 | 33 | - **Dynamic**: Components respond to changes in the URL of the post — or when it’s lazily set later 34 | - **Aggressive caching**: API calls are cached even across component instances, so you can have multiple components about the same post without making duplicate requests. 35 | - **Ultra-lightweight**: The whole package is [~2 KB minified & gzipped](https://bundlephobia.com/package/bluesky-likes) and dependency-free 36 | - [**Accessible**](#accessibility-notes) & [**i18n** friendly](#i18n-notes) 37 | - **Autoloading** is available to take the hassle out of figuring out when to load the components 38 | - **Highly customizable styling** via regular CSS properties, custom properties, states, and parts (but also beautiful by default so you don’t have to). 39 | - **Customizable content** via slots 40 | - **Hackable**: You can [replace the templates and styles](#replacing-templates-and-styles) of the components with your own, or even subclass them to create new components with different templates and styles 41 | 42 | ## Installation 43 | 44 | The easiest way is to use the [autoloader](#autoloader) and a CDN such as [unpkg](https://unpkg.com/). 45 | All it takes is pasting this into your HTML and you’re ready to use the components: 46 | 47 | ```html 48 | 49 | ``` 50 | 51 | Or, if you know which ones you need, you can import them individually: 52 | 53 | ```html 54 | 55 | 56 | ``` 57 | 58 | You can also install the components via npm and use with your toolchain of choice: 59 | 60 | ```bash 61 | npm install bluesky-likes 62 | ``` 63 | 64 | Then import the components in your JavaScript. 65 | You can import everything: 66 | 67 | ```js 68 | import { BlueskyLikes, BlueskyLikers, bsky } from "bluesky-likes"; 69 | ``` 70 | 71 | Or you can use individual exports like `bluesky-likes/likes`. 72 | 73 | ## `` 74 | 75 | Displays the number of likes on a post and links to the full list. 76 | 77 | ```html 78 | 79 | ``` 80 | 81 | To link to a different URL (e.g. the post itself), simply wrap the component in a link: 82 | 83 | ```html 84 | 85 | 86 | 87 | ``` 88 | 89 | ### Attributes 90 | 91 | | Attribute | Type | Description | 92 | | --------- | -------- | ----------------------------------------- | 93 | | `src` | `string` | The URL of the post to display likes for. | 94 | 95 | ### Slots 96 | 97 | | Name | Description | 98 | | ----------- | ------------------------------ | 99 | | _(Default)_ | Content added after the count. | 100 | | `prefix` | Custom icon | 101 | 102 | ### Styling 103 | 104 | #### Custom properties 105 | 106 | None! 107 | Pretty much all styling is on the host element, so you can just override regular CSS properties such as `border`, `padding` or `color` to restyle the component. 108 | 109 | #### [Parts](https://developer.mozilla.org/en-US/docs/Web/CSS/::part) 110 | 111 | | Name | Description | 112 | | ------- | ------------------------------------------------------------------------------- | 113 | | `link` | The `` element that links to all likes. | 114 | | `count` | The `` that contains the like count. | 115 | | `icon` | The default icon which is displayed if nothing is slotted in the `prefix` slot. | 116 | 117 | #### [States](https://developer.mozilla.org/en-US/docs/Web/CSS/:state) 118 | 119 | | Name | Description | 120 | | --------- | ------------------------------------------------------- | 121 | | `loading` | Indicates that the component is currently loading data. | 122 | 123 | ## `` 124 | 125 | Displays the avatars of users who liked a post up to a max limit, and the number of additional users not shown. 126 | 127 | ```html 128 | 129 | ``` 130 | 131 | ### Attributes 132 | 133 | | Attribute | Type | Description | 134 | | --------- | -------- | --------------------------------------------------------- | 135 | | `src` | `string` | The URL of the post to display likes for. | 136 | | `max` | `number` | The maximum number of avatars to display. Defaults to 50. | 137 | 138 | ### Slots 139 | 140 | | Name | Description | 141 | | ------------- | ------------------------------------------------------------------------------------------------------------------------ | 142 | | _(Default)_ | Fallback content to display before component is registered. | 143 | | `empty` | Content displayed when there are no likers. | 144 | | `skip-link` | Skip link. See [Accessibility Notes](#accessibility-notes). You don't need to implement event handling or hiding for it. | 145 | | `skip-text` | Content for the skip link. See [Accessibility Notes](#accessibility-notes). Ignored when `skip-link` has content. | 146 | | `description` | Visually hidden content for screen reader users. See [Accessibility Notes](#accessibility-notes). | 147 | 148 | ### Styling 149 | 150 | #### Recipes 151 | 152 | - Apply `text-wrap: balance` to the component to equalize the width of the rows. 153 | 154 | #### [States](https://developer.mozilla.org/en-US/docs/Web/CSS/:state) 155 | 156 | | Name | Description | 157 | | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 158 | | `loading` | Indicates that the component is currently loading data. Note that the state will be removed when data loads and the component is updated, not after all avatars load. | 159 | | `empty` | Indicates that there are no likers to display. | 160 | 161 | #### Custom properties 162 | 163 | | Name | Default Value | Description | 164 | | ------------------------------- | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------ | 165 | | `--avatar-size` | `calc(2em + 1vw)` | The size of each avatar. | 166 | | `--avatar-overlap-percentage` | `0.3` | The percentage of horizontal overlap between avatars. | 167 | | `--avatar-overlap-percentage-y` | `0.2` | The percentage of vertical overlap between avatars. | 168 | | `--avatar-border` | `.15em solid canvas` | The border style for each avatar. | 169 | | `--avatar-shadow` | `0 .1em .4em -.3em rgb(0 0 0 / 0.4)` | The box-shadow applied to each avatar. | 170 | | `--avatar-background` | `url('data:image/svg+xml,…') center / cover canvas` | The background for avatars without a user image (default SVG, centered and covered). | 171 | | `--more-background` | `#1185fe` | The background color for the "+N" (more) avatar. | 172 | | `--more-color-text` | `white` | The text color for the "+N" (more) avatar. | 173 | | `--avatar-overlap` | `calc(var(--avatar-size) \* var(--avatar-overlap-percentage))` | The actual horizontal overlap between avatars (as a ``). | 174 | | `--avatar-overlap-y` | `calc(var(--avatar-size) \* var(--avatar-overlap-percentage-y))` | The actual vertical overlap between avatars (as a ``). | 175 | 176 | ### [Parts](https://developer.mozilla.org/en-US/docs/Web/CSS/::part) 177 | 178 | | Name | Description | 179 | | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | 180 | | `avatar` | The circular element that displays a user, or the +N for users not shown. Corresponds to an `` element for users with an avatar, and an `` in other cases. | 181 | | `avatar-img` | The `` element for users with an avatar. | 182 | | `link` | The `` element that wraps each entry (either links to the user's profile, or to all likers) | 183 | | `profile-link` | The `` element that links to the user's profile. | 184 | | `more` | The `` element that displays the hidden count. | 185 | | `skip-link` | The `` element to skip to the end that is visually hidden but available to keyboard users and screen readers. | 186 | 187 | ## Autoloader 188 | 189 | Due to its side effects, the autoloader is a separate export: 190 | 191 | ```js 192 | import "bluesky-likes/autoload"; 193 | ``` 194 | 195 | By default, the autoloader will not observe future changes: if the components are not available when the script runs, they will not be fetched. 196 | It will also not discover components that are in shadow roots of other components. 197 | This is done for performance reasons, since these features are slow and these components are mostly used on blogs and other content-focused websites that don’t need this. 198 | 199 | If, however, you do, you can use the `observe()` and `discover()` methods the autoloader exports: 200 | 201 | - `observe(root)` will observe `root` for changes and load components as they are added. You can use `unobserve()` to stop observing. 202 | - `discover(root)` will discover components in `root` and load them if they are not already loaded. `root` can be any DOM node, including documents and shadow roots. 203 | 204 | ## Replacing templates and styles 205 | 206 | For most common cases, slots should be sufficient for customizing the content of the components and regular CSS to for styling them. 207 | However, for more advanced use cases, you can completely gut them and replace their templates and styles with your own. 208 | 209 | Every component class includes the templates used to render it as a static `templates` property and its CSS styles as a `styles` property. 210 | For example, `BlueskyLikes.templates` is the templates used by the `` component, and `BlueskyLikers.styles` is the styles used by the `` component. 211 | Each template is a function that takes a `data` object and returns a string of HTML, while the styles are a string of CSS. 212 | 213 | You can either tweak the templates directly, or you can create a subclass with different values and register it as a new component. 214 | If you make changes after elements have already been initialized, you should call `element.render({useCache: true})` on these elements. 215 | 216 | ## API wrapper 217 | 218 | Since these components had to interface with the BlueSky API, they also implement a tiny wrapper for the relevant parts of it. 219 | While this library is absolutely not intended as a BlueSky API SDK, if you do need these functions, they are in [`src/api.js`](src/api.js) and have their own export too: `bluesky-likes/api`. 220 | 221 | The following functions are available: 222 | 223 | - `getProfile(handle)`: Fetches a user profile by handle. 224 | - `getPost(url)`: Fetches a post details by URL. 225 | - `getPostLikes(url)`: Fetches the likers for a post by its URL. 226 | 227 | Also these, though you probably won’t need them unless you’re making new API calls not covered by these endpoints: 228 | 229 | - `parsePostUrl(url)`: Parses a BlueSky post URL and returns the post's handle and URI. **Synchronous**. 230 | - `getDid(handle)`: Get the DID of a user by their handle. 231 | - `getPostUri(url)`: Fetches a post AT URI by its URL. 232 | 233 | Unless otherwise mentioned, all functions are async. 234 | 235 | ## Accessibility Notes 236 | 237 | These components are designed with accessibility in mind, 238 | in the sense that they use semantically appropriate HTML elements 239 | and have been tested with screen readers. 240 | 241 | However, the accessibility of the end result also depends on how you use them. 242 | 243 | ### `` 244 | 245 | By default, the icon’s alt text is empty, since it is considered presentational. 246 | To change this, you can slot in your own icon with a different alt text. 247 | 248 | By default, the link’s title is "View all Bluesky likes". 249 | To localize this, you can wrap the element in another link, with your own title. 250 | 251 | ### `` 252 | 253 | By default, the component includes a description for non-sighted users like "271 users liked this post, 50 shown". 254 | You can customize that content by providing your own content in the `description` slot. 255 | 256 | The component includes links to the profiles of the users who liked the post (with `rel="nofollow"`), 257 | and a skip link to skip to the end of the list. 258 | You can customize the content of the link via the `skip` slot and the styling of the link via the `skip-link` part. 259 | 260 | ## i18n Notes 261 | 262 | - Number formatting uses locale-aware formatting (via `Intl.NumberFormat`) using the element’s inherited language. 263 | - CSS uses logical properties where appropriate, so that the components can be used in right-to-left languages without changes 264 | - Any content that may need to be localized has a way to replace it (e.g. via slots) 265 | 266 | ## Credits 267 | 268 | - [Salma Alam-Nayor](https://whitep4nth3r.com/blog/show-bluesky-likes-on-blog-posts/) for the initial idea 269 | - [Dmitry Sharabin](https://d12n.me) for the [CodePen that sparked this](https://codepen.io/dmitrysharabin/pen/Jodbyqm) 270 | 271 | ## License 272 | 273 | These components are [MIT licensed](LICENSE). 274 | However, if you are using them in a way that helps you profit, there is a **social** (not legal) expectation that you [give back by funding their development](https://github.com/sponsors/LeaVerou). 275 | --------------------------------------------------------------------------------