[READ-ONLY] Mirror of https://github.com/improsocial/impro An extensible Bluesky client for web impro.social
6

Configure Feed

Select the types of activity you want to include in your feed.

Render plugin icons

Grace Kind (May 22, 2026, 5:21 PM -0500) 58c81f20 006617a7

+1103 -321
+1 -1
package.json
··· 1 1 { 2 2 "name": "impro", 3 - "version": "0.14.87", 3 + "version": "0.14.88", 4 4 "type": "module", 5 5 "scripts": { 6 6 "start": "rm -rf build && NODE_ENV=development eleventy --serve",
+18
src/css/style.css
··· 1169 1169 stroke: var(--text-color); 1170 1170 } 1171 1171 1172 + plugin-icon { 1173 + display: inline-flex; 1174 + width: 1em; 1175 + height: 1em; 1176 + color: inherit; 1177 + } 1178 + 1179 + plugin-icon svg { 1180 + width: 100%; 1181 + height: 100%; 1182 + } 1183 + 1184 + .sidebar-nav-icon plugin-icon { 1185 + width: 25px; 1186 + height: 25px; 1187 + color: var(--text-color); 1188 + } 1189 + 1172 1190 .sidebar-nav-icon .status-badge { 1173 1191 top: 0; 1174 1192 left: 12px;
src/img/icons/custom/chat-line.svg src/img/icons/custom/chat-dots-line.svg
src/img/icons/custom/chat.svg src/img/icons/custom/chat-dots.svg
src/img/icons/custom/menu.svg src/img/icons/custom/hamburger-menu.svg
-65
src/js/components/impro-icon.js
··· 1 - import { Component } from "/js/components/component.js"; 2 - 3 - const DEFAULT_SET = "majesticons"; 4 - 5 - const cache = new Map(); 6 - 7 - function fetchIcon(set, name) { 8 - const key = `${set}/${name}`; 9 - if (cache.has(key)) { 10 - return cache.get(key); 11 - } 12 - const url = `/img/icons/${set}/${name}.svg`; 13 - const promise = 14 - typeof globalThis.fetch === "function" 15 - ? globalThis.fetch(url).then((response) => { 16 - if (!response.ok) { 17 - throw new Error(`Failed to load icon "${key}": ${response.status}`); 18 - } 19 - return response.text(); 20 - }) 21 - : Promise.reject(new Error("fetch-unavailable")); 22 - promise.catch(() => {}); 23 - cache.set(key, promise); 24 - return promise; 25 - } 26 - 27 - class ImproIcon extends Component { 28 - static observedAttributes = ["name", "set"]; 29 - 30 - attributeChangedCallback() { 31 - this.render(); 32 - } 33 - 34 - async render() { 35 - const name = this.getAttribute("name"); 36 - const set = this.getAttribute("set") || DEFAULT_SET; 37 - if (!name) { 38 - this.innerHTML = ""; 39 - return; 40 - } 41 - try { 42 - const svg = await fetchIcon(set, name); 43 - if ( 44 - this.getAttribute("name") !== name || 45 - (this.getAttribute("set") || DEFAULT_SET) !== set 46 - ) { 47 - return; 48 - } 49 - this.innerHTML = svg; 50 - } catch (error) { 51 - if ( 52 - this.getAttribute("name") !== name || 53 - (this.getAttribute("set") || DEFAULT_SET) !== set 54 - ) { 55 - return; 56 - } 57 - if (error.message !== "fetch-unavailable") { 58 - console.warn(error.message); 59 - } 60 - this.innerHTML = ""; 61 - } 62 - } 63 - } 64 - 65 - ImproIcon.register();
+844
src/js/components/plugin-icon.js
··· 1 + import { Component } from "/js/components/component.js"; 2 + 3 + const ICONS = { 4 + custom: new Set([ 5 + "chat-dots", 6 + "chat-dots-line", 7 + "check", 8 + "circle-check", 9 + "hamburger-menu", 10 + "reply", 11 + "repost", 12 + "verified-check", 13 + "verifier-check", 14 + ]), 15 + majesticons: new Set([ 16 + "add-column", 17 + "add-column-line", 18 + "add-row", 19 + "add-row-line", 20 + "airplane", 21 + "airplane-flight-2", 22 + "airplane-flight-2-line", 23 + "airplane-line", 24 + "alert-circle", 25 + "alert-circle-line", 26 + "align-bottom", 27 + "align-bottom-line", 28 + "align-horizontal-center", 29 + "align-horizontal-center-line", 30 + "align-left", 31 + "align-left-line", 32 + "align-right", 33 + "align-right-line", 34 + "align-top", 35 + "align-top-line", 36 + "align-vertical-center", 37 + "align-vertical-center-line", 38 + "analytics", 39 + "analytics-delete", 40 + "analytics-delete-line", 41 + "analytics-line", 42 + "analytics-plus", 43 + "analytics-plus-line", 44 + "analytics-restricted", 45 + "analytics-restricted-line", 46 + "arrow-down", 47 + "arrow-down-circle", 48 + "arrow-down-circle-line", 49 + "arrow-down-line", 50 + "arrow-left", 51 + "arrow-left-circle", 52 + "arrow-left-circle-line", 53 + "arrow-left-line", 54 + "arrow-right", 55 + "arrow-right-circle", 56 + "arrow-right-circle-line", 57 + "arrow-right-line", 58 + "arrow-up", 59 + "arrow-up-circle", 60 + "arrow-up-circle-line", 61 + "arrow-up-line", 62 + "arrows-collapse-full", 63 + "arrows-collapse-full-line", 64 + "arrows-expand-full", 65 + "arrows-expand-full-line", 66 + "article", 67 + "article-line", 68 + "article-search", 69 + "article-search-line", 70 + "atom-2", 71 + "atom-2-line", 72 + "attachment", 73 + "attachment-line", 74 + "award", 75 + "award-line", 76 + "back-circle", 77 + "back-circle-line", 78 + "backward-circle", 79 + "backward-circle-line", 80 + "backward-start-circle", 81 + "backward-start-circle-line", 82 + "band-aids", 83 + "band-aids-line", 84 + "barcode-2", 85 + "barcode-2-line", 86 + "basket-2", 87 + "basket-2-line", 88 + "bath-shower", 89 + "bath-shower-line", 90 + "battery", 91 + "battery-full", 92 + "battery-full-line", 93 + "battery-half", 94 + "battery-half-line", 95 + "battery-line", 96 + "battery-low", 97 + "battery-low-line", 98 + "beach", 99 + "beach-line", 100 + "bell", 101 + "bell-line", 102 + "bitcoin-circle", 103 + "bitcoin-circle-line", 104 + "bluetooth", 105 + "bluetooth-line", 106 + "bold", 107 + "bold-line", 108 + "book", 109 + "book-line", 110 + "book-minus", 111 + "book-minus-line", 112 + "book-open", 113 + "book-open-line", 114 + "book-plus", 115 + "book-plus-line", 116 + "bookmark", 117 + "bookmark-line", 118 + "bookmark-minus", 119 + "bookmark-minus-line", 120 + "bookmark-plus", 121 + "bookmark-plus-line", 122 + "box", 123 + "box-line", 124 + "briefcase", 125 + "briefcase-line", 126 + "browser", 127 + "browser-cookie", 128 + "browser-cookie-line", 129 + "browser-line", 130 + "bug-2", 131 + "bug-2-line", 132 + "burger", 133 + "burger-line", 134 + "bus", 135 + "bus-line", 136 + "cake", 137 + "cake-line", 138 + "calculator", 139 + "calculator-line", 140 + "calendar", 141 + "calendar-line", 142 + "calendar-plus", 143 + "calendar-plus-line", 144 + "camera", 145 + "camera-line", 146 + "camera-off", 147 + "camera-off-line", 148 + "car", 149 + "car-line", 150 + "cent-circle", 151 + "cent-circle-line", 152 + "chat", 153 + "chat-2", 154 + "chat-2-line", 155 + "chat-2-text", 156 + "chat-2-text-line", 157 + "chat-line", 158 + "chat-signal", 159 + "chat-signal-line", 160 + "chat-status", 161 + "chat-status-line", 162 + "chat-text", 163 + "chat-text-line", 164 + "chats", 165 + "chats-2", 166 + "chats-2-line", 167 + "chats-line", 168 + "checkbox-list", 169 + "checkbox-list-detail", 170 + "checkbox-list-detail-line", 171 + "checkbox-list-line", 172 + "cheese", 173 + "cheese-line", 174 + "chevron-down", 175 + "chevron-down-circle", 176 + "chevron-down-circle-line", 177 + "chevron-down-line", 178 + "chevron-left", 179 + "chevron-left-circle", 180 + "chevron-left-circle-line", 181 + "chevron-left-line", 182 + "chevron-right", 183 + "chevron-right-circle", 184 + "chevron-right-circle-line", 185 + "chevron-right-line", 186 + "chevron-up", 187 + "chevron-up-circle", 188 + "chevron-up-circle-line", 189 + "chevron-up-line", 190 + "chromecast", 191 + "chromecast-line", 192 + "church", 193 + "church-line", 194 + "clipboard", 195 + "clipboard-check", 196 + "clipboard-check-line", 197 + "clipboard-line", 198 + "clipboard-minus", 199 + "clipboard-minus-line", 200 + "clipboard-plus", 201 + "clipboard-plus-line", 202 + "clock", 203 + "clock-line", 204 + "clock-plus", 205 + "clock-plus-line", 206 + "close", 207 + "close-line", 208 + "cloud", 209 + "cloud-line", 210 + "code", 211 + "code-block", 212 + "code-block-line", 213 + "code-line", 214 + "coins", 215 + "coins-line", 216 + "comet", 217 + "comet-line", 218 + "comment", 219 + "comment-2", 220 + "comment-2-line", 221 + "comment-2-text", 222 + "comment-2-text-line", 223 + "comment-line", 224 + "comment-text", 225 + "comment-text-line", 226 + "comments", 227 + "comments-2", 228 + "comments-2-line", 229 + "comments-line", 230 + "community", 231 + "community-line", 232 + "compass-2", 233 + "compass-2-line", 234 + "cookie", 235 + "cookie-line", 236 + "covid", 237 + "covid-exclamation", 238 + "covid-exclamation-line", 239 + "covid-line", 240 + "covid-off", 241 + "covid-off-line", 242 + "cpu", 243 + "cpu-line", 244 + "creditcard", 245 + "creditcard-hand", 246 + "creditcard-hand-line", 247 + "creditcard-line", 248 + "creditcard-plus", 249 + "creditcard-plus-line", 250 + "crown", 251 + "crown-line", 252 + "cup", 253 + "cup-line", 254 + "curly-braces", 255 + "curly-braces-line", 256 + "data", 257 + "data-line", 258 + "data-minus", 259 + "data-minus-line", 260 + "data-plus", 261 + "data-plus-line", 262 + "delete-bin", 263 + "delete-bin-line", 264 + "distribute-horizontal", 265 + "distribute-horizontal-line", 266 + "distribute-vertical", 267 + "distribute-vertical-line", 268 + "divide", 269 + "divide-line", 270 + "document", 271 + "document-award", 272 + "document-award-line", 273 + "document-line", 274 + "dollar-circle", 275 + "dollar-circle-line", 276 + "door-enter", 277 + "door-enter-line", 278 + "door-exit", 279 + "door-exit-line", 280 + "earth-sphere", 281 + "earth-sphere-line", 282 + "edit-pen-2", 283 + "edit-pen-2-line", 284 + "edit-pen-4", 285 + "edit-pen-4-line", 286 + "eject", 287 + "eject-line", 288 + "eraser", 289 + "eraser-line", 290 + "etherium-circle", 291 + "etherium-circle-line", 292 + "euro-circle", 293 + "euro-circle-line", 294 + "eye", 295 + "eye-line", 296 + "eye-off", 297 + "eye-off-line", 298 + "ferris-wheel", 299 + "ferris-wheel-line", 300 + "file", 301 + "file-line", 302 + "file-minus", 303 + "file-minus-line", 304 + "file-plus", 305 + "file-plus-line", 306 + "filter", 307 + "filter-line", 308 + "fish", 309 + "fish-line", 310 + "flag", 311 + "flag-line", 312 + "flask", 313 + "flask-line", 314 + "flower-2", 315 + "flower-2-line", 316 + "folder", 317 + "folder-check", 318 + "folder-check-line", 319 + "folder-line", 320 + "folder-minus", 321 + "folder-minus-line", 322 + "folder-plus", 323 + "folder-plus-line", 324 + "font-size", 325 + "font-size-line", 326 + "forward-circle", 327 + "forward-circle-line", 328 + "forward-end-circle", 329 + "forward-end-circle-line", 330 + "git-branch", 331 + "git-branch-line", 332 + "git-commit", 333 + "git-commit-line", 334 + "git-compare", 335 + "git-compare-line", 336 + "git-fork", 337 + "git-fork-line", 338 + "git-merge", 339 + "git-merge-line", 340 + "git-pull", 341 + "git-pull-line", 342 + "glas-water", 343 + "glas-water-line", 344 + "globe-earth", 345 + "globe-earth-2", 346 + "globe-earth-2-line", 347 + "globe-earth-line", 348 + "globe-grid", 349 + "globe-grid-line", 350 + "hand", 351 + "hand-line", 352 + "hand-pointer", 353 + "hand-pointer-2", 354 + "hand-pointer-2-line", 355 + "hand-pointer-event", 356 + "hand-pointer-event-line", 357 + "hand-pointer-line", 358 + "hard-drive", 359 + "hard-drive-line", 360 + "headset", 361 + "headset-line", 362 + "heart", 363 + "heart-line", 364 + "home", 365 + "home-analytics", 366 + "home-analytics-line", 367 + "home-line", 368 + "home-simple", 369 + "home-simple-line", 370 + "image", 371 + "image-circle", 372 + "image-circle-line", 373 + "image-circle-off", 374 + "image-circle-off-line", 375 + "image-circle-plus", 376 + "image-circle-plus-line", 377 + "image-circle-story", 378 + "image-circle-story-line", 379 + "image-frame", 380 + "image-frame-line", 381 + "image-in-picture", 382 + "image-in-picture-line", 383 + "image-line", 384 + "image-multiple", 385 + "image-multiple-line", 386 + "image-off", 387 + "image-off-line", 388 + "image-photography", 389 + "image-photography-line", 390 + "image-plus", 391 + "image-plus-line", 392 + "incognito", 393 + "incognito-line", 394 + "info-circle", 395 + "info-circle-line", 396 + "iphone-old-apps", 397 + "iphone-old-apps-line", 398 + "iphone-x-apps", 399 + "iphone-x-apps-line", 400 + "italic", 401 + "italic-line", 402 + "key", 403 + "key-line", 404 + "keyboard", 405 + "keyboard-line", 406 + "laptop", 407 + "laptop-line", 408 + "leaf-3-angled", 409 + "leaf-3-angled-line", 410 + "library", 411 + "library-line", 412 + "lidquid-drop-waves-2", 413 + "lidquid-drop-waves-2-line", 414 + "lifebuoy", 415 + "lifebuoy-line", 416 + "lightbulb-shine", 417 + "lightbulb-shine-line", 418 + "lightning-bolt", 419 + "lightning-bolt-line", 420 + "line-height", 421 + "line-height-line", 422 + "link", 423 + "link-circle", 424 + "link-circle-line", 425 + "link-line", 426 + "lira-circle", 427 + "lira-circle-line", 428 + "list-box", 429 + "list-box-line", 430 + "lock", 431 + "lock-line", 432 + "lock-off", 433 + "lock-off-line", 434 + "login", 435 + "login-half-circle", 436 + "login-half-circle-line", 437 + "login-line", 438 + "logout", 439 + "logout-half-circle", 440 + "logout-half-circle-line", 441 + "logout-line", 442 + "mail", 443 + "mail-line", 444 + "map-marker", 445 + "map-marker-area", 446 + "map-marker-area-line", 447 + "map-marker-line", 448 + "map-marker-path", 449 + "map-marker-path-line", 450 + "map-marker-plus", 451 + "map-marker-plus-line", 452 + "map-simple", 453 + "map-simple-destination", 454 + "map-simple-destination-line", 455 + "map-simple-line", 456 + "map-simple-marker", 457 + "map-simple-marker-line", 458 + "map-simple-off", 459 + "map-simple-off-line", 460 + "maximize", 461 + "maximize-line", 462 + "megaphone", 463 + "megaphone-line", 464 + "menu", 465 + "menu-expand-left", 466 + "menu-expand-left-line", 467 + "menu-expand-right", 468 + "menu-expand-right-line", 469 + "menu-line", 470 + "microphone", 471 + "microphone-line", 472 + "minimize", 473 + "minimize-line", 474 + "minus", 475 + "minus-five-circle", 476 + "minus-five-circle-line", 477 + "minus-line", 478 + "minus-ten-circle", 479 + "minus-ten-circle-line", 480 + "money", 481 + "money-hand", 482 + "money-hand-line", 483 + "money-line", 484 + "money-minus", 485 + "money-minus-line", 486 + "money-plus", 487 + "money-plus-line", 488 + "monitor", 489 + "monitor-line", 490 + "moon", 491 + "moon-line", 492 + "more-menu", 493 + "more-menu-line", 494 + "more-menu-vertical", 495 + "more-menu-vertical-line", 496 + "mouse", 497 + "mouse-line", 498 + "multiply", 499 + "multiply-line", 500 + "music", 501 + "music-line", 502 + "music-note", 503 + "music-note-line", 504 + "next-circle", 505 + "next-circle-line", 506 + "note-text", 507 + "note-text-line", 508 + "note-text-minus", 509 + "note-text-minus-line", 510 + "note-text-plus", 511 + "note-text-plus-line", 512 + "noteblock", 513 + "noteblock-line", 514 + "noteblock-text", 515 + "noteblock-text-line", 516 + "open", 517 + "open-line", 518 + "paper-fold", 519 + "paper-fold-line", 520 + "paper-fold-text", 521 + "paper-fold-text-line", 522 + "paper-roll-2", 523 + "paper-roll-2-line", 524 + "paragraph", 525 + "paragraph-line", 526 + "pause-circle", 527 + "pause-circle-line", 528 + "percent", 529 + "percent-line", 530 + "phone", 531 + "phone-dial", 532 + "phone-dial-line", 533 + "phone-hangup", 534 + "phone-hangup-line", 535 + "phone-incoming", 536 + "phone-incoming-line", 537 + "phone-line", 538 + "phone-outgoing", 539 + "phone-outgoing-line", 540 + "phone-retro", 541 + "phone-retro-line", 542 + "phone-ring", 543 + "phone-ring-line", 544 + "pill", 545 + "pill-line", 546 + "pin", 547 + "pin-line", 548 + "pinwheel", 549 + "pinwheel-line", 550 + "planet", 551 + "planet-line", 552 + "planet-ring-2", 553 + "planet-ring-2-line", 554 + "planet-rocket", 555 + "planet-rocket-line", 556 + "play-circle", 557 + "play-circle-line", 558 + "playlist", 559 + "playlist-line", 560 + "plus", 561 + "plus-five-circle", 562 + "plus-five-circle-line", 563 + "plus-line", 564 + "plus-minus", 565 + "plus-minus-2", 566 + "plus-minus-2-line", 567 + "plus-minus-line", 568 + "plus-ten-circle", 569 + "plus-ten-circle-line", 570 + "pound-circle", 571 + "pound-circle-line", 572 + "presentation", 573 + "presentation-chart", 574 + "presentation-chart-line", 575 + "presentation-line", 576 + "presentation-play", 577 + "presentation-play-line", 578 + "printer", 579 + "printer-line", 580 + "pulse", 581 + "pulse-line", 582 + "puzzle", 583 + "puzzle-line", 584 + "qr-code", 585 + "qr-code-line", 586 + "question-circle", 587 + "question-circle-line", 588 + "radio-list", 589 + "radio-list-line", 590 + "receipt-text", 591 + "receipt-text-line", 592 + "redo", 593 + "redo-line", 594 + "reload", 595 + "reload-circle", 596 + "reload-circle-line", 597 + "reload-line", 598 + "remove-column", 599 + "remove-column-line", 600 + "remove-format", 601 + "remove-format-line", 602 + "remove-row", 603 + "remove-row-line", 604 + "repeat-circle", 605 + "repeat-circle-line", 606 + "restricted", 607 + "restricted-line", 608 + "robot", 609 + "robot-line", 610 + "rocket-3-start", 611 + "rocket-3-start-line", 612 + "rubel-circle", 613 + "rubel-circle-line", 614 + "ruler-2", 615 + "ruler-2-line", 616 + "rupee-circle", 617 + "rupee-circle-line", 618 + "save", 619 + "save-line", 620 + "scale-light", 621 + "scale-light-line", 622 + "scan-fingerprint", 623 + "scan-fingerprint-line", 624 + "scan-user", 625 + "scan-user-line", 626 + "scanner", 627 + "scanner-line", 628 + "scooter", 629 + "scooter-line", 630 + "script-prescription", 631 + "script-prescription-line", 632 + "scroll", 633 + "scroll-line", 634 + "scroll-text", 635 + "scroll-text-line", 636 + "search", 637 + "search-line", 638 + "search-minus", 639 + "search-minus-line", 640 + "search-plus", 641 + "search-plus-line", 642 + "send", 643 + "send-line", 644 + "server", 645 + "server-line", 646 + "settings-cog", 647 + "settings-cog-check", 648 + "settings-cog-check-line", 649 + "settings-cog-line", 650 + "settings-cog-plus", 651 + "settings-cog-plus-line", 652 + "share", 653 + "share-circle", 654 + "share-circle-line", 655 + "share-line", 656 + "shield", 657 + "shield-line", 658 + "shield-off", 659 + "shield-off-line", 660 + "shield-plus", 661 + "shield-plus-line", 662 + "ship", 663 + "ship-line", 664 + "shooting-star", 665 + "shooting-star-line", 666 + "shopping-cart", 667 + "shopping-cart-line", 668 + "sim-card", 669 + "sim-card-line", 670 + "sitemap", 671 + "sitemap-line", 672 + "skull", 673 + "skull-line", 674 + "smartphone-apps", 675 + "smartphone-apps-line", 676 + "speaker", 677 + "speaker-line", 678 + "stop-circle", 679 + "stop-circle-line", 680 + "strike-through", 681 + "strike-through-line", 682 + "suitcase", 683 + "suitcase-2", 684 + "suitcase-2-line", 685 + "suitcase-3", 686 + "suitcase-3-line", 687 + "suitcase-line", 688 + "t-shirt", 689 + "t-shirt-line", 690 + "table", 691 + "table-heart", 692 + "table-heart-line", 693 + "table-line", 694 + "table-plus", 695 + "table-plus-line", 696 + "tag", 697 + "tag-line", 698 + "tag-off", 699 + "tag-off-line", 700 + "telescope", 701 + "telescope-line", 702 + "test-tube-filled", 703 + "test-tube-filled-line", 704 + "text", 705 + "text-align-center", 706 + "text-align-center-line", 707 + "text-align-justify", 708 + "text-align-justify-line", 709 + "text-align-left", 710 + "text-align-left-line", 711 + "text-align-right", 712 + "text-align-right-line", 713 + "text-line", 714 + "text-wrap", 715 + "text-wrap-line", 716 + "textbox", 717 + "textbox-line", 718 + "textbox-minus", 719 + "textbox-minus-line", 720 + "textbox-plus", 721 + "textbox-plus-line", 722 + "ticket", 723 + "ticket-check", 724 + "ticket-check-line", 725 + "ticket-line", 726 + "ticket-text", 727 + "ticket-text-line", 728 + "tickets", 729 + "tickets-line", 730 + "timer", 731 + "timer-line", 732 + "tooltip", 733 + "tooltip-line", 734 + "tooltip-text", 735 + "tooltip-text-line", 736 + "tooltips", 737 + "tooltips-2", 738 + "tooltips-2-line", 739 + "tooltips-line", 740 + "tv-old", 741 + "tv-old-line", 742 + "umbrella", 743 + "umbrella-line", 744 + "underline", 745 + "underline-2", 746 + "underline-2-line", 747 + "underline-line", 748 + "undo", 749 + "undo-line", 750 + "unlock-open", 751 + "unlock-open-line", 752 + "usb", 753 + "usb-line", 754 + "user", 755 + "user-box", 756 + "user-box-line", 757 + "user-line", 758 + "users", 759 + "users-line", 760 + "ux-circle", 761 + "ux-circle-line", 762 + "video", 763 + "video-line", 764 + "video-minus", 765 + "video-minus-line", 766 + "video-plus", 767 + "video-plus-line", 768 + "view-columns", 769 + "view-columns-line", 770 + "view-rows", 771 + "view-rows-line", 772 + "watch", 773 + "watch-line", 774 + "yen-circle", 775 + "yen-circle-line", 776 + ]), 777 + }; 778 + 779 + const ICON_TO_SET = new Map(); 780 + for (const [iconset, icons] of Object.entries(ICONS)) { 781 + for (const icon of icons) { 782 + ICON_TO_SET.set(icon, iconset); 783 + } 784 + } 785 + 786 + class PluginIcon extends Component { 787 + static observedAttributes = ["icon"]; 788 + static cache = new Map(); 789 + 790 + cache = PluginIcon.cache; 791 + 792 + attributeChangedCallback() { 793 + this.render(); 794 + } 795 + 796 + fetchIcon(iconset, icon) { 797 + const key = `${iconset}/${icon}`; 798 + if (this.cache.has(key)) { 799 + return this.cache.get(key); 800 + } 801 + const url = `/img/icons/${iconset}/${icon}.svg`; 802 + const promise = 803 + typeof globalThis.fetch === "function" 804 + ? globalThis.fetch(url).then((response) => { 805 + if (!response.ok) { 806 + throw new Error( 807 + `Failed to load icon "${key}": ${response.status}`, 808 + ); 809 + } 810 + return response.text(); 811 + }) 812 + : Promise.reject(new Error("fetch-unavailable")); 813 + promise.catch(() => {}); 814 + this.cache.set(key, promise); 815 + return promise; 816 + } 817 + 818 + async render() { 819 + const icon = this.getAttribute("icon"); 820 + if (!icon) { 821 + this.innerHTML = ""; 822 + return; 823 + } 824 + const iconset = ICON_TO_SET.get(icon); 825 + if (!iconset) { 826 + console.warn(`Unknown icon "${icon}"`); 827 + this.innerHTML = ""; 828 + return; 829 + } 830 + try { 831 + const svg = await this.fetchIcon(iconset, icon); 832 + if (this.getAttribute("icon") !== icon) return; 833 + this.innerHTML = svg; 834 + } catch (error) { 835 + if (this.getAttribute("icon") !== icon) return; 836 + if (error.message !== "fetch-unavailable") { 837 + console.warn(error.message); 838 + } 839 + this.innerHTML = ""; 840 + } 841 + } 842 + } 843 + 844 + PluginIcon.register();
+3 -14
src/js/plugins/pluginRendering.js
··· 1 - import { lightningBoltIconTemplate } from "/js/templates/icons/lightningBoltIcon.template.js"; 2 1 import { showExternalLinkWarningModal } from "/js/modals.js"; 3 2 import "/js/components/toggle-switch.js"; 4 3 import "/js/components/plugin-profiles-list.js"; 4 + import "/js/components/plugin-icon.js"; 5 5 6 6 function isExternalHref(href) { 7 7 try { ··· 38 38 "textarea", 39 39 "a", 40 40 "profiles-list", 41 + "plugin-icon", 41 42 ]; 42 43 43 44 const ALLOWED_EVENTS = ["click", "change", "input"]; ··· 63 64 "id", 64 65 "href", 65 66 "dids", 67 + "icon", 66 68 ]; 67 69 68 70 function isSafeHref(value) { ··· 82 84 name.startsWith("aria-") 83 85 ); 84 86 } 85 - 86 - const PLUGIN_ICON_TEMPLATES = { 87 - "lightning-bolt": lightningBoltIconTemplate, 88 - }; 89 87 90 88 function createVirtualEvent(e) { 91 89 const target = e.target ?? {}; ··· 344 342 return true; 345 343 } 346 344 } 347 - 348 - export function getPluginIconTemplate(icon) { 349 - const template = PLUGIN_ICON_TEMPLATES[icon]; 350 - if (!template) { 351 - console.warn(`[plugins] requested unknown icon "${icon}"`); 352 - return null; 353 - } 354 - return template; 355 - }
-40
src/js/templates/icons/lightningBoltIcon.template.js
··· 1 - import { html } from "/js/lib/lit-html.js"; 2 - import { classnames } from "/js/utils.js"; 3 - 4 - // Source: https://github.com/halfmage/majesticons/blob/main/line/lightning-bolt-line.svg 5 - export function lightningBoltIconTemplate({ filled = false } = {}) { 6 - return html`<div 7 - class=${classnames("icon lightning-bolt-icon", { 8 - filled, 9 - })} 10 - > 11 - ${filled 12 - ? html`<svg 13 - xmlns="http://www.w3.org/2000/svg" 14 - viewBox="0 0 24 24" 15 - fill="none" 16 - > 17 - <path 18 - fill="currentColor" 19 - stroke="currentColor" 20 - stroke-linecap="round" 21 - stroke-linejoin="round" 22 - stroke-width="2" 23 - d="M4 14 14 3v7h6L10 21v-7H4z" 24 - /> 25 - </svg>` 26 - : html`<svg 27 - xmlns="http://www.w3.org/2000/svg" 28 - viewBox="0 0 24 24" 29 - fill="none" 30 - > 31 - <path 32 - stroke="currentColor" 33 - stroke-linecap="round" 34 - stroke-linejoin="round" 35 - stroke-width="2" 36 - d="M4 14 14 3v7h6L10 21v-7H4z" 37 - /> 38 - </svg>`} 39 - </div>`; 40 - }
+4 -5
src/js/templates/sidebar.template.js
··· 21 21 linkToLogin, 22 22 } from "/js/navigation.js"; 23 23 import "/js/components/animated-sidebar.js"; 24 + import "/js/components/plugin-icon.js"; 24 25 import { showInfoModal } from "/js/modals.js"; 25 - import { getPluginIconTemplate } from "/js/plugins/pluginRendering.js"; 26 26 27 27 function pluginSidebarItemTemplate({ entry }) { 28 - const iconTemplate = getPluginIconTemplate(entry.icon); 29 28 return html` 30 29 <button 31 30 class="sidebar-nav-item sidebar-plugin-nav-item" ··· 36 35 entry.invoke(); 37 36 }} 38 37 > 39 - <span class="sidebar-nav-icon" 40 - >${iconTemplate ? iconTemplate() : ""}</span 41 - > 38 + <span class="sidebar-nav-icon"> 39 + <plugin-icon icon=${entry.icon}></plugin-icon> 40 + </span> 42 41 <span class="sidebar-nav-label">${entry.title}</span> 43 42 </button> 44 43 `;
-196
tests/unit/specs/components/impro-icon.test.js
··· 1 - import { TestSuite } from "../../testSuite.js"; 2 - import { assert, assertEquals, mock, MockFetch } from "../../testHelpers.js"; 3 - import "/js/components/impro-icon.js"; 4 - 5 - const t = new TestSuite("ImproIcon"); 6 - 7 - const SAMPLE_SVG = 8 - '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/></svg>'; 9 - 10 - function flush() { 11 - return new Promise((resolve) => setTimeout(resolve, 0)); 12 - } 13 - 14 - function okResponse(body) { 15 - return { 16 - ok: true, 17 - status: 200, 18 - statusText: "OK", 19 - text: async () => body, 20 - }; 21 - } 22 - 23 - function notFoundResponse() { 24 - return { 25 - ok: false, 26 - status: 404, 27 - statusText: "Not Found", 28 - text: async () => "", 29 - }; 30 - } 31 - 32 - t.beforeEach(() => { 33 - document.body.innerHTML = ""; 34 - }); 35 - 36 - t.describe("ImproIcon - set defaulting", (it) => { 37 - it("defaults set to majesticons when not specified", async () => { 38 - const fetch = new MockFetch(); 39 - fetch.__intercept("/img/icons/", async () => okResponse(SAMPLE_SVG)); 40 - globalThis.fetch = fetch; 41 - 42 - const element = document.createElement("impro-icon"); 43 - element.setAttribute("name", "default-set-icon"); 44 - document.body.appendChild(element); 45 - await flush(); 46 - 47 - assertEquals( 48 - fetch.calls[0].url, 49 - "/img/icons/majesticons/default-set-icon.svg", 50 - ); 51 - }); 52 - 53 - it("uses the provided set", async () => { 54 - const fetch = new MockFetch(); 55 - fetch.__intercept("/img/icons/", async () => okResponse(SAMPLE_SVG)); 56 - globalThis.fetch = fetch; 57 - 58 - const element = document.createElement("impro-icon"); 59 - element.setAttribute("set", "custom-set"); 60 - element.setAttribute("name", "explicit-set-icon"); 61 - document.body.appendChild(element); 62 - await flush(); 63 - 64 - assertEquals( 65 - fetch.calls[0].url, 66 - "/img/icons/custom-set/explicit-set-icon.svg", 67 - ); 68 - }); 69 - }); 70 - 71 - t.describe("ImproIcon - rendering", (it) => { 72 - it("injects the fetched SVG markup", async () => { 73 - const fetch = new MockFetch(); 74 - fetch.__intercept("/img/icons/", async () => okResponse(SAMPLE_SVG)); 75 - globalThis.fetch = fetch; 76 - 77 - const element = document.createElement("impro-icon"); 78 - element.setAttribute("name", "render-test"); 79 - document.body.appendChild(element); 80 - await flush(); 81 - 82 - const svg = element.querySelector("svg"); 83 - assert(svg !== null); 84 - assertEquals(svg.getAttribute("viewBox"), "0 0 24 24"); 85 - }); 86 - 87 - it("renders nothing when name is empty", async () => { 88 - const fetch = new MockFetch(); 89 - globalThis.fetch = fetch; 90 - 91 - const element = document.createElement("impro-icon"); 92 - document.body.appendChild(element); 93 - await flush(); 94 - 95 - assertEquals(fetch.calls.length, 0); 96 - assertEquals(element.innerHTML, ""); 97 - }); 98 - 99 - it("swaps the icon when name changes", async () => { 100 - const fetch = new MockFetch(); 101 - fetch.__intercept("/img/icons/majesticons/swap-first.svg", async () => 102 - okResponse('<svg id="first"></svg>'), 103 - ); 104 - fetch.__intercept("/img/icons/majesticons/swap-second.svg", async () => 105 - okResponse('<svg id="second"></svg>'), 106 - ); 107 - globalThis.fetch = fetch; 108 - 109 - const element = document.createElement("impro-icon"); 110 - element.setAttribute("name", "swap-first"); 111 - document.body.appendChild(element); 112 - await flush(); 113 - assertEquals(element.querySelector("svg").id, "first"); 114 - 115 - element.setAttribute("name", "swap-second"); 116 - await flush(); 117 - assertEquals(element.querySelector("svg").id, "second"); 118 - }); 119 - 120 - it("refetches when set changes", async () => { 121 - const fetch = new MockFetch(); 122 - fetch.__intercept("/img/icons/set-a/set-swap.svg", async () => 123 - okResponse('<svg id="a"></svg>'), 124 - ); 125 - fetch.__intercept("/img/icons/set-b/set-swap.svg", async () => 126 - okResponse('<svg id="b"></svg>'), 127 - ); 128 - globalThis.fetch = fetch; 129 - 130 - const element = document.createElement("impro-icon"); 131 - element.setAttribute("set", "set-a"); 132 - element.setAttribute("name", "set-swap"); 133 - document.body.appendChild(element); 134 - await flush(); 135 - assertEquals(element.querySelector("svg").id, "a"); 136 - 137 - element.setAttribute("set", "set-b"); 138 - await flush(); 139 - assertEquals(element.querySelector("svg").id, "b"); 140 - }); 141 - }); 142 - 143 - t.describe("ImproIcon - caching", (it) => { 144 - it("only fetches once when the same icon is rendered twice", async () => { 145 - const fetch = new MockFetch(); 146 - fetch.__intercept("/img/icons/", async () => okResponse(SAMPLE_SVG)); 147 - globalThis.fetch = fetch; 148 - 149 - const first = document.createElement("impro-icon"); 150 - first.setAttribute("name", "cache-shared"); 151 - document.body.appendChild(first); 152 - 153 - const second = document.createElement("impro-icon"); 154 - second.setAttribute("name", "cache-shared"); 155 - document.body.appendChild(second); 156 - 157 - await flush(); 158 - 159 - assertEquals(fetch.calls.length, 1); 160 - assert(first.querySelector("svg") !== null); 161 - assert(second.querySelector("svg") !== null); 162 - }); 163 - }); 164 - 165 - t.describe("ImproIcon - error handling", (it) => { 166 - it("warns and renders nothing when the fetch 404s; does not retry", async () => { 167 - const fetch = new MockFetch(); 168 - fetch.__intercept("/img/icons/", async () => notFoundResponse()); 169 - globalThis.fetch = fetch; 170 - 171 - const originalWarn = console.warn; 172 - const warnMock = mock(); 173 - console.warn = warnMock; 174 - 175 - try { 176 - const element = document.createElement("impro-icon"); 177 - element.setAttribute("name", "missing-icon"); 178 - document.body.appendChild(element); 179 - await flush(); 180 - 181 - assertEquals(element.innerHTML, ""); 182 - assertEquals(warnMock.calls.length, 1); 183 - 184 - const retry = document.createElement("impro-icon"); 185 - retry.setAttribute("name", "missing-icon"); 186 - document.body.appendChild(retry); 187 - await flush(); 188 - 189 - assertEquals(fetch.calls.length, 1); 190 - } finally { 191 - console.warn = originalWarn; 192 - } 193 - }); 194 - }); 195 - 196 - await t.run();
+190
tests/unit/specs/components/plugin-icon.test.js
··· 1 + import { TestSuite } from "../../testSuite.js"; 2 + import { assert, assertEquals, mock, MockFetch } from "../../testHelpers.js"; 3 + import "/js/components/plugin-icon.js"; 4 + 5 + const t = new TestSuite("PluginIcon"); 6 + 7 + const SAMPLE_SVG = 8 + '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/></svg>'; 9 + 10 + function flush() { 11 + return new Promise((resolve) => setTimeout(resolve, 0)); 12 + } 13 + 14 + function okResponse(body) { 15 + return { 16 + ok: true, 17 + status: 200, 18 + statusText: "OK", 19 + text: async () => body, 20 + }; 21 + } 22 + 23 + function notFoundResponse() { 24 + return { 25 + ok: false, 26 + status: 404, 27 + statusText: "Not Found", 28 + text: async () => "", 29 + }; 30 + } 31 + 32 + t.beforeEach(() => { 33 + document.body.innerHTML = ""; 34 + customElements.get("plugin-icon").cache = new Map(); 35 + }); 36 + 37 + t.describe("PluginIcon - iconset resolution", (it) => { 38 + it("resolves an icon from the majesticons set", async () => { 39 + const fetch = new MockFetch(); 40 + fetch.__intercept("/img/icons/", async () => okResponse(SAMPLE_SVG)); 41 + globalThis.fetch = fetch; 42 + 43 + const element = document.createElement("plugin-icon"); 44 + element.setAttribute("icon", "bell"); 45 + document.body.appendChild(element); 46 + await flush(); 47 + 48 + assertEquals(fetch.calls[0].url, "/img/icons/majesticons/bell.svg"); 49 + }); 50 + 51 + it("resolves an icon from the custom set", async () => { 52 + const fetch = new MockFetch(); 53 + fetch.__intercept("/img/icons/", async () => okResponse(SAMPLE_SVG)); 54 + globalThis.fetch = fetch; 55 + 56 + const element = document.createElement("plugin-icon"); 57 + element.setAttribute("icon", "verified-check"); 58 + document.body.appendChild(element); 59 + await flush(); 60 + 61 + assertEquals(fetch.calls[0].url, "/img/icons/custom/verified-check.svg"); 62 + }); 63 + 64 + it("warns and renders nothing for an unknown icon", async () => { 65 + const fetch = new MockFetch(); 66 + globalThis.fetch = fetch; 67 + 68 + const originalWarn = console.warn; 69 + const warnMock = mock(); 70 + console.warn = warnMock; 71 + 72 + try { 73 + const element = document.createElement("plugin-icon"); 74 + element.setAttribute("icon", "not-a-real-icon"); 75 + document.body.appendChild(element); 76 + await flush(); 77 + 78 + assertEquals(fetch.calls.length, 0); 79 + assertEquals(element.innerHTML, ""); 80 + assertEquals(warnMock.calls.length, 1); 81 + } finally { 82 + console.warn = originalWarn; 83 + } 84 + }); 85 + }); 86 + 87 + t.describe("PluginIcon - rendering", (it) => { 88 + it("injects the fetched SVG markup", async () => { 89 + const fetch = new MockFetch(); 90 + fetch.__intercept("/img/icons/", async () => okResponse(SAMPLE_SVG)); 91 + globalThis.fetch = fetch; 92 + 93 + const element = document.createElement("plugin-icon"); 94 + element.setAttribute("icon", "cake"); 95 + document.body.appendChild(element); 96 + await flush(); 97 + 98 + const svg = element.querySelector("svg"); 99 + assert(svg !== null); 100 + assertEquals(svg.getAttribute("viewBox"), "0 0 24 24"); 101 + }); 102 + 103 + it("renders nothing when icon is empty", async () => { 104 + const fetch = new MockFetch(); 105 + globalThis.fetch = fetch; 106 + 107 + const element = document.createElement("plugin-icon"); 108 + document.body.appendChild(element); 109 + await flush(); 110 + 111 + assertEquals(fetch.calls.length, 0); 112 + assertEquals(element.innerHTML, ""); 113 + }); 114 + 115 + it("swaps the icon when icon changes", async () => { 116 + const fetch = new MockFetch(); 117 + fetch.__intercept("/img/icons/majesticons/bus.svg", async () => 118 + okResponse('<svg id="first"></svg>'), 119 + ); 120 + fetch.__intercept("/img/icons/majesticons/car.svg", async () => 121 + okResponse('<svg id="second"></svg>'), 122 + ); 123 + globalThis.fetch = fetch; 124 + 125 + const element = document.createElement("plugin-icon"); 126 + element.setAttribute("icon", "bus"); 127 + document.body.appendChild(element); 128 + await flush(); 129 + assertEquals(element.querySelector("svg").id, "first"); 130 + 131 + element.setAttribute("icon", "car"); 132 + await flush(); 133 + assertEquals(element.querySelector("svg").id, "second"); 134 + }); 135 + }); 136 + 137 + t.describe("PluginIcon - caching", (it) => { 138 + it("only fetches once when the same icon is rendered twice", async () => { 139 + const fetch = new MockFetch(); 140 + fetch.__intercept("/img/icons/", async () => okResponse(SAMPLE_SVG)); 141 + globalThis.fetch = fetch; 142 + 143 + const first = document.createElement("plugin-icon"); 144 + first.setAttribute("icon", "chat"); 145 + document.body.appendChild(first); 146 + 147 + const second = document.createElement("plugin-icon"); 148 + second.setAttribute("icon", "chat"); 149 + document.body.appendChild(second); 150 + 151 + await flush(); 152 + 153 + assertEquals(fetch.calls.length, 1); 154 + assert(first.querySelector("svg") !== null); 155 + assert(second.querySelector("svg") !== null); 156 + }); 157 + }); 158 + 159 + t.describe("PluginIcon - error handling", (it) => { 160 + it("warns and renders nothing when the fetch 404s; does not retry", async () => { 161 + const fetch = new MockFetch(); 162 + fetch.__intercept("/img/icons/", async () => notFoundResponse()); 163 + globalThis.fetch = fetch; 164 + 165 + const originalWarn = console.warn; 166 + const warnMock = mock(); 167 + console.warn = warnMock; 168 + 169 + try { 170 + const element = document.createElement("plugin-icon"); 171 + element.setAttribute("icon", "moon"); 172 + document.body.appendChild(element); 173 + await flush(); 174 + 175 + assertEquals(element.innerHTML, ""); 176 + assertEquals(warnMock.calls.length, 1); 177 + 178 + const retry = document.createElement("plugin-icon"); 179 + retry.setAttribute("icon", "moon"); 180 + document.body.appendChild(retry); 181 + await flush(); 182 + 183 + assertEquals(fetch.calls.length, 1); 184 + } finally { 185 + console.warn = originalWarn; 186 + } 187 + }); 188 + }); 189 + 190 + await t.run();
+24
tests/unit/specs/plugins/pluginRendering.test.js
··· 317 317 }); 318 318 }); 319 319 320 + t.describe("PluginRenderer:plugin-icon", (it) => { 321 + it("renders <plugin-icon> with the icon attribute passed through", () => { 322 + const { bridge } = makeBridge(); 323 + const renderer = new PluginRenderer(bridge, "demo"); 324 + const element = renderer.createRoot().render({ 325 + tag: "plugin-icon", 326 + attrs: { icon: "bell" }, 327 + }); 328 + assertEquals(element.tagName.toLowerCase(), "plugin-icon"); 329 + assertEquals(element.getAttribute("icon"), "bell"); 330 + }); 331 + 332 + it("drops disallowed attributes from <plugin-icon>", () => { 333 + const { bridge } = makeBridge(); 334 + const renderer = new PluginRenderer(bridge, "demo"); 335 + const element = renderer.createRoot().render({ 336 + tag: "plugin-icon", 337 + attrs: { icon: "bell", onclick: "alert(1)" }, 338 + }); 339 + assert(!element.hasAttribute("onclick")); 340 + assertEquals(element.getAttribute("icon"), "bell"); 341 + }); 342 + }); 343 + 320 344 t.describe("PluginRenderer:anchor tags", (it) => { 321 345 it("renders <a> with safe https href and forces target/rel", () => { 322 346 const { bridge } = makeBridge();
+19
tests/unit/specs/templates/sidebar.template.test.js
··· 421 421 assert(invoked); 422 422 }); 423 423 424 + it("should render a <plugin-icon> with the entry's icon for each plugin item", () => { 425 + const result = sidebarTemplate({ 426 + isAuthenticated: true, 427 + currentUser: mockUser, 428 + pluginSidebarItems: [ 429 + { title: "Plugin One", icon: "lightning-bolt", invoke: () => {} }, 430 + { title: "Plugin Two", icon: "bell", invoke: () => {} }, 431 + ], 432 + }); 433 + const container = document.createElement("div"); 434 + render(result, container); 435 + const icons = container.querySelectorAll( 436 + ".sidebar-plugin-nav-item plugin-icon", 437 + ); 438 + assertEquals(icons.length, 2); 439 + assertEquals(icons[0].getAttribute("icon"), "lightning-bolt"); 440 + assertEquals(icons[1].getAttribute("icon"), "bell"); 441 + }); 442 + 424 443 it("should not render plugin sidebar items in logged out sidebar", () => { 425 444 const result = sidebarTemplate({ 426 445 isAuthenticated: false,