[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.

Rename and fix tests

Grace Kind (May 27, 2026, 7:22 PM -0500) f042a12e 17f9b59b

+1709 -1697
+1 -1
package.json
··· 1 1 { 2 2 "name": "impro", 3 - "version": "0.14.107", 3 + "version": "0.14.108", 4 4 "type": "module", 5 5 "scripts": { 6 6 "start": "rm -rf build && NODE_ENV=development eleventy --serve",
+3 -2
src/index.html
··· 100 100 import { ReportService } from "/js/reportService.js"; 101 101 import { InteractionHandlers } from "/js/interactionHandlers.js"; 102 102 import { hapticsImpactLight } from "/js/haptics.js"; 103 - import { effect, isNative, wait } from "/js/utils.js"; 103 + import { isNative, wait } from "/js/utils.js"; 104 + import { effect } from "/js/signals.js"; 104 105 import { 105 106 enableNativeRefresh, 106 107 disableNativeRefresh, ··· 203 204 !window.location.pathname.includes("notifications") 204 205 ) { 205 206 const loadedNotifications = 206 - dataLayer.signals.$notifications.get(); 207 + dataLayer.derived.$notifications.get(); 207 208 const { loading: notificationsLoading } = 208 209 dataLayer.requests.statusStore.$statuses 209 210 .get("loadNotifications")
+2 -1
src/js/chatNotificationService.js
··· 1 - import { wait, Signal } from "/js/utils.js"; 1 + import { wait } from "/js/utils.js"; 2 + import { Signal } from "/js/signals.js"; 2 3 3 4 const POLLING_INTERVAL_SECONDS = 10; 4 5
+6 -6
src/js/identityPrecaching.js
··· 1 1 import { getQuotedPost } from "/js/dataHelpers.js"; 2 - import { effect, untrack } from "/js/utils.js"; 2 + import { effect, untrack } from "/js/signals.js"; 3 3 4 4 export function setUpIdentityPrecaching(dataLayer, identityResolver) { 5 5 const setDid = (entity) => { ··· 37 37 console.error(error); 38 38 } 39 39 } 40 - }, "identityPrecaching:posts"); 40 + }); 41 41 42 42 const seenFeedGeneratorUris = new Set(); 43 43 effect(() => { ··· 59 59 console.error(error); 60 60 } 61 61 } 62 - }, "identityPrecaching:feedGenerators"); 62 + }); 63 63 64 64 effect(() => { 65 65 const profileSearchResults = ··· 68 68 for (const searchResult of profileSearchResults.actors) { 69 69 setDid(searchResult); 70 70 } 71 - }, "identityPrecaching:profileSearch"); 71 + }); 72 72 73 73 effect(() => { 74 74 const preferences = dataLayer.preferencesProvider.$preferences.get(); ··· 81 81 console.error(error); 82 82 } 83 83 } 84 - }, "identityPrecaching:preferences"); 84 + }); 85 85 86 86 effect(() => { 87 87 const notifications = dataLayer.dataStore.$notifications.get(); ··· 94 94 console.error(error); 95 95 } 96 96 } 97 - }, "identityPrecaching:notifications"); 97 + }); 98 98 }
+2 -1
src/js/notificationService.js
··· 1 - import { wait, Signal } from "/js/utils.js"; 1 + import { wait } from "/js/utils.js"; 2 + import { Signal } from "/js/signals.js"; 2 3 3 4 const POLLING_INTERVAL_SECONDS = 10; 4 5
+1 -1
src/js/postInteractionHandler.js
··· 182 182 } 183 183 184 184 async handleQuotePost(post) { 185 - const currentUser = this.dataLayer.signals.$currentUser.get(); 185 + const currentUser = this.dataLayer.derived.$currentUser.get(); 186 186 if (!currentUser) { 187 187 console.warn("No current user"); 188 188 return;
+1 -1
src/js/reportService.js
··· 14 14 } 15 15 return new Promise((resolve, reject) => { 16 16 this.currentReportDialog = document.createElement("report-dialog"); 17 - const preferences = this.dataLayer.signals.$preferences.get(); 17 + const preferences = this.dataLayer.derived.$preferences.get(); 18 18 this.currentReportDialog.labelerDefs = preferences.labelerDefs; 19 19 this.currentReportDialog.subjectType = subjectType; 20 20 this.currentReportDialog.addEventListener("submit-report", async (e) => {
+1 -1
src/js/router.js
··· 1 1 import { EventEmitter } from "/js/eventEmitter.js"; 2 - import { effect } from "/js/utils.js"; 2 + import { effect } from "/js/signals.js"; 3 3 4 4 const MAX_PAGES = 5; 5 5
+346
src/js/signals.js
··· 1 + import { EventEmitter } from "/js/eventEmitter.js"; 2 + 3 + let trackedReads = null; 4 + 5 + function track(signal) { 6 + if (trackedReads) trackedReads.add(signal); 7 + } 8 + 9 + function withTracking(set, fn) { 10 + const previous = trackedReads; 11 + trackedReads = set; 12 + try { 13 + return fn(); 14 + } finally { 15 + trackedReads = previous; 16 + } 17 + } 18 + 19 + export function untrack(fn) { 20 + return withTracking(null, fn); 21 + } 22 + 23 + let globalTick = 0; 24 + 25 + class State { 26 + __debugName = "<State>"; 27 + __lastChangedTick = 0; 28 + #value; 29 + #watchers = new Set(); 30 + #dependents = new Set(); 31 + #equals; 32 + 33 + constructor(initialValue, { equals = Object.is } = {}) { 34 + this.#value = initialValue; 35 + this.#equals = equals; 36 + } 37 + 38 + get() { 39 + track(this); 40 + return this.#value; 41 + } 42 + 43 + set(newValue) { 44 + if (this.#equals(newValue, this.#value)) return; 45 + this.#value = newValue; 46 + this.__lastChangedTick = ++globalTick; 47 + 48 + for (const dependent of [...this.#dependents]) dependent._markDirty(this); 49 + for (const watcher of [...this.#watchers]) watcher._notify(this); 50 + } 51 + 52 + _addDependent(computed) { 53 + this.#dependents.add(computed); 54 + } 55 + _removeDependent(computed) { 56 + this.#dependents.delete(computed); 57 + } 58 + _addWatcher(watcher) { 59 + this.#watchers.add(watcher); 60 + } 61 + _removeWatcher(watcher) { 62 + this.#watchers.delete(watcher); 63 + } 64 + } 65 + 66 + class Computed { 67 + __debugName = "<Computed>"; 68 + __quiet = false; 69 + #cb; 70 + #value; 71 + #dirty = true; 72 + #deps = new Set(); 73 + #dependents = new Set(); 74 + #watchers = new Set(); 75 + #dirtyFrom = new Set(); 76 + 77 + constructor(cb) { 78 + this.#cb = cb; 79 + } 80 + 81 + get() { 82 + track(this); 83 + if (this.#dirty) this.#recompute(); 84 + return this.#value; 85 + } 86 + 87 + #recompute() { 88 + for (const dep of this.#deps) dep._removeDependent(this); 89 + const tracked = new Set(); 90 + this.#value = withTracking(tracked, () => this.#cb.call(this)); 91 + this.#deps = tracked; 92 + for (const dep of this.#deps) dep._addDependent(this); 93 + this.#dirty = false; 94 + this.#dirtyFrom = new Set(); 95 + } 96 + 97 + _markDirty(marker) { 98 + this.#dirtyFrom.add(marker); 99 + const wasDirty = this.#dirty; 100 + this.#dirty = true; 101 + if (wasDirty) return; 102 + for (const dependent of [...this.#dependents]) dependent._markDirty(this); 103 + for (const watcher of [...this.#watchers]) watcher._notify(this); 104 + } 105 + 106 + _getDirtyFrom() { 107 + return this.#dirtyFrom; 108 + } 109 + 110 + dispose() { 111 + for (const dep of this.#deps) dep._removeDependent(this); 112 + this.#deps = new Set(); 113 + this.#dirty = true; 114 + } 115 + 116 + _addDependent(computed) { 117 + this.#dependents.add(computed); 118 + } 119 + _removeDependent(computed) { 120 + this.#dependents.delete(computed); 121 + } 122 + _addWatcher(watcher) { 123 + this.#watchers.add(watcher); 124 + } 125 + _removeWatcher(watcher) { 126 + this.#watchers.delete(watcher); 127 + } 128 + } 129 + 130 + class Watcher { 131 + #notify; 132 + #watched = new Set(); 133 + #pending = new Set(); 134 + #notified = false; 135 + 136 + constructor(notify) { 137 + this.#notify = notify; 138 + } 139 + 140 + watch(...signals) { 141 + this.#notified = false; 142 + for (const sig of signals) { 143 + this.#watched.add(sig); 144 + sig._addWatcher(this); 145 + } 146 + } 147 + 148 + unwatch(...signals) { 149 + for (const sig of signals) { 150 + this.#watched.delete(sig); 151 + this.#pending.delete(sig); 152 + sig._removeWatcher(this); 153 + } 154 + } 155 + 156 + getPending() { 157 + const result = [...this.#pending]; 158 + this.#pending.clear(); 159 + return result; 160 + } 161 + 162 + _notify(signal) { 163 + this.#pending.add(signal); 164 + if (this.#notified) return; 165 + this.#notified = true; 166 + this.#notify(); 167 + } 168 + } 169 + 170 + export const Signal = { State, Computed, subtle: { Watcher } }; 171 + 172 + function logEffectTrigger(effectComputed, debugName, lastRunTick) { 173 + const lines = [`[T${globalTick}] effect(${debugName}) firing, caused by:`]; 174 + const seen = new Set(); 175 + // ancestorBars: for each ancestor level, true if that level still has siblings below 176 + const walk = (node, ancestorBars, isLast) => { 177 + const prefix = 178 + ancestorBars.map((bar) => (bar ? "│ " : " ")).join("") + 179 + (isLast ? "└─ " : "├─ "); 180 + if (seen.has(node)) { 181 + lines.push(`${prefix}↺ ${node.__debugName ?? "?"}`); 182 + return; 183 + } 184 + seen.add(node); 185 + const isState = node instanceof State; 186 + const quiet = node.__quiet; 187 + if (!quiet) { 188 + const kind = isState ? "state" : "computed"; 189 + const tickInfo = 190 + isState && node.__lastChangedTick > lastRunTick 191 + ? ` @T${node.__lastChangedTick}` 192 + : ""; 193 + lines.push(`${prefix}${kind} ${node.__debugName ?? "?"}${tickInfo}`); 194 + } 195 + if (!isState) { 196 + const children = [...node._getDirtyFrom()]; 197 + const childBars = quiet ? ancestorBars : [...ancestorBars, !isLast]; 198 + children.forEach((child, i) => { 199 + walk(child, childBars, i === children.length - 1); 200 + }); 201 + } 202 + }; 203 + const rootMarkers = [...effectComputed._getDirtyFrom()]; 204 + rootMarkers.forEach((marker, i) => { 205 + walk(marker, [], i === rootMarkers.length - 1); 206 + }); 207 + if (lines.length > 1) console.log(lines.join("\n")); 208 + } 209 + 210 + export const effect = (cb, debugName) => { 211 + let cleanup; 212 + let lastRunTick = 0; 213 + let hasRun = false; 214 + const computed = new Computed(() => { 215 + if (typeof cleanup === "function") cleanup(); 216 + cleanup = cb(); 217 + }); 218 + computed.__debugName = "effect(" + (debugName ?? "unknown") + ")"; 219 + 220 + let pendingFlush = false; 221 + const run = () => { 222 + if (hasRun && debugName) { 223 + logEffectTrigger(computed, debugName, lastRunTick); 224 + } 225 + lastRunTick = globalTick; 226 + hasRun = true; 227 + computed.get(); 228 + watcher.watch(); // re-arm 229 + }; 230 + 231 + const watcher = new Watcher(() => { 232 + if (pendingFlush) return; 233 + pendingFlush = true; 234 + requestAnimationFrame(() => { 235 + pendingFlush = false; 236 + run(); 237 + }); 238 + }); 239 + watcher.watch(computed); 240 + run(); 241 + 242 + return () => { 243 + if (typeof cleanup === "function") cleanup(); 244 + watcher.unwatch(computed); 245 + computed.dispose(); 246 + }; 247 + }; 248 + 249 + export class SignalMap { 250 + __debugName = "<SignalMap>"; 251 + 252 + constructor() { 253 + this.map = new Map(); 254 + // Tracks keys that have been explicitly written via set(). Reading $keys 255 + // takes a dependency on collection membership so consumers re-run when 256 + // new entries are added. 257 + this._setKeys = new Set(); 258 + this.$keys = new Signal.State([]); 259 + setTimeout(() => { 260 + this.$keys.__debugName = `${this.__debugName}.$keys`; 261 + }, 0); 262 + } 263 + 264 + get(key) { 265 + let signal = this.map.get(key); 266 + if (!signal) { 267 + signal = new Signal.State(null); 268 + signal.__debugName = `${this.__debugName}[${String(key)}]`; 269 + this.map.set(key, signal); 270 + } 271 + return signal; 272 + } 273 + 274 + set(key, value) { 275 + this.get(key).set(value); 276 + if (!this._setKeys.has(key)) { 277 + this._setKeys.add(key); 278 + this.$keys.set([...this._setKeys]); 279 + } 280 + } 281 + 282 + delete(key) { 283 + const signal = this.map.get(key); 284 + if (signal) signal.set(null); 285 + this.map.delete(key); 286 + if (this._setKeys.delete(key)) { 287 + this.$keys.set([...this._setKeys]); 288 + } 289 + } 290 + 291 + clear() { 292 + for (const signal of this.map.values()) { 293 + signal.set(null); 294 + } 295 + } 296 + 297 + keys() { 298 + return this.map.keys(); 299 + } 300 + 301 + *values() { 302 + for (const signal of this.map.values()) { 303 + yield signal.get(); 304 + } 305 + } 306 + 307 + *entries() { 308 + for (const [key, signal] of this.map.entries()) { 309 + yield [key, signal.get()]; 310 + } 311 + } 312 + } 313 + 314 + export class ComputedMap { 315 + __debugName = "<ComputedMap>"; 316 + 317 + constructor(computeFn) { 318 + this.map = new Map(); 319 + this.computeFn = computeFn; 320 + } 321 + 322 + get(key) { 323 + let signal = this.map.get(key); 324 + if (!signal) { 325 + signal = new Signal.Computed(() => this.computeFn(key)); 326 + signal.__debugName = `${this.__debugName}[${String(key)}]`; 327 + this.map.set(key, signal); 328 + } 329 + return signal; 330 + } 331 + } 332 + 333 + export class ReactiveStore extends EventEmitter { 334 + constructor(id) { 335 + super(); 336 + return new Proxy(this, { 337 + set(target, prop, value) { 338 + if (prop.startsWith("$")) { 339 + value.__debugName = `${id}.${prop}`; 340 + } 341 + target[prop] = value; 342 + return true; 343 + }, 344 + }); 345 + } 346 + }
-346
src/js/utils.js
··· 1 1 import { Capacitor } from "/js/lib/capacitor.js"; 2 - import { EventEmitter } from "/js/eventEmitter.js"; 3 2 4 3 export function noop() {} 5 4 ··· 547 546 return await Promise.race([fn(controller.signal), timeoutPromise]); 548 547 } finally { 549 548 clearTimeout(timeoutId); 550 - } 551 - } 552 - 553 - let trackedReads = null; 554 - 555 - function track(signal) { 556 - if (trackedReads) trackedReads.add(signal); 557 - } 558 - 559 - function withTracking(set, fn) { 560 - const previous = trackedReads; 561 - trackedReads = set; 562 - try { 563 - return fn(); 564 - } finally { 565 - trackedReads = previous; 566 - } 567 - } 568 - 569 - export function untrack(fn) { 570 - return withTracking(null, fn); 571 - } 572 - 573 - let globalTick = 0; 574 - 575 - class State { 576 - __debugName = "<State>"; 577 - __lastChangedTick = 0; 578 - #value; 579 - #watchers = new Set(); 580 - #dependents = new Set(); 581 - #equals; 582 - 583 - constructor(initialValue, { equals = Object.is } = {}) { 584 - this.#value = initialValue; 585 - this.#equals = equals; 586 - } 587 - 588 - get() { 589 - track(this); 590 - return this.#value; 591 - } 592 - 593 - set(newValue) { 594 - if (this.#equals(newValue, this.#value)) return; 595 - this.#value = newValue; 596 - this.__lastChangedTick = ++globalTick; 597 - 598 - for (const dependent of [...this.#dependents]) dependent._markDirty(this); 599 - for (const watcher of [...this.#watchers]) watcher._notify(this); 600 - } 601 - 602 - _addDependent(computed) { 603 - this.#dependents.add(computed); 604 - } 605 - _removeDependent(computed) { 606 - this.#dependents.delete(computed); 607 - } 608 - _addWatcher(watcher) { 609 - this.#watchers.add(watcher); 610 - } 611 - _removeWatcher(watcher) { 612 - this.#watchers.delete(watcher); 613 - } 614 - } 615 - 616 - class Computed { 617 - __debugName = "<Computed>"; 618 - __quiet = false; 619 - #cb; 620 - #value; 621 - #dirty = true; 622 - #deps = new Set(); 623 - #dependents = new Set(); 624 - #watchers = new Set(); 625 - #dirtyFrom = new Set(); 626 - 627 - constructor(cb) { 628 - this.#cb = cb; 629 - } 630 - 631 - get() { 632 - track(this); 633 - if (this.#dirty) this.#recompute(); 634 - return this.#value; 635 - } 636 - 637 - #recompute() { 638 - for (const dep of this.#deps) dep._removeDependent(this); 639 - const tracked = new Set(); 640 - this.#value = withTracking(tracked, () => this.#cb.call(this)); 641 - this.#deps = tracked; 642 - for (const dep of this.#deps) dep._addDependent(this); 643 - this.#dirty = false; 644 - this.#dirtyFrom = new Set(); 645 - } 646 - 647 - _markDirty(marker) { 648 - this.#dirtyFrom.add(marker); 649 - const wasDirty = this.#dirty; 650 - this.#dirty = true; 651 - if (wasDirty) return; 652 - for (const dependent of [...this.#dependents]) dependent._markDirty(this); 653 - for (const watcher of [...this.#watchers]) watcher._notify(this); 654 - } 655 - 656 - _getDirtyFrom() { 657 - return this.#dirtyFrom; 658 - } 659 - 660 - dispose() { 661 - for (const dep of this.#deps) dep._removeDependent(this); 662 - this.#deps = new Set(); 663 - this.#dirty = true; 664 - } 665 - 666 - _addDependent(computed) { 667 - this.#dependents.add(computed); 668 - } 669 - _removeDependent(computed) { 670 - this.#dependents.delete(computed); 671 - } 672 - _addWatcher(watcher) { 673 - this.#watchers.add(watcher); 674 - } 675 - _removeWatcher(watcher) { 676 - this.#watchers.delete(watcher); 677 - } 678 - } 679 - 680 - class Watcher { 681 - #notify; 682 - #watched = new Set(); 683 - #pending = new Set(); 684 - #notified = false; 685 - 686 - constructor(notify) { 687 - this.#notify = notify; 688 - } 689 - 690 - watch(...signals) { 691 - this.#notified = false; 692 - for (const sig of signals) { 693 - this.#watched.add(sig); 694 - sig._addWatcher(this); 695 - } 696 - } 697 - 698 - unwatch(...signals) { 699 - for (const sig of signals) { 700 - this.#watched.delete(sig); 701 - this.#pending.delete(sig); 702 - sig._removeWatcher(this); 703 - } 704 - } 705 - 706 - getPending() { 707 - const result = [...this.#pending]; 708 - this.#pending.clear(); 709 - return result; 710 - } 711 - 712 - _notify(signal) { 713 - this.#pending.add(signal); 714 - if (this.#notified) return; 715 - this.#notified = true; 716 - this.#notify(); 717 - } 718 - } 719 - 720 - export const Signal = { State, Computed, subtle: { Watcher } }; 721 - 722 - function logEffectTrigger(effectComputed, debugName, lastRunTick) { 723 - const lines = [`[T${globalTick}] effect(${debugName}) firing, caused by:`]; 724 - const seen = new Set(); 725 - // ancestorBars: for each ancestor level, true if that level still has siblings below 726 - const walk = (node, ancestorBars, isLast) => { 727 - const prefix = 728 - ancestorBars.map((bar) => (bar ? "│ " : " ")).join("") + 729 - (isLast ? "└─ " : "├─ "); 730 - if (seen.has(node)) { 731 - lines.push(`${prefix}↺ ${node.__debugName ?? "?"}`); 732 - return; 733 - } 734 - seen.add(node); 735 - const isState = node instanceof State; 736 - const quiet = node.__quiet; 737 - if (!quiet) { 738 - const kind = isState ? "state" : "computed"; 739 - const tickInfo = 740 - isState && node.__lastChangedTick > lastRunTick 741 - ? ` @T${node.__lastChangedTick}` 742 - : ""; 743 - lines.push(`${prefix}${kind} ${node.__debugName ?? "?"}${tickInfo}`); 744 - } 745 - if (!isState) { 746 - const children = [...node._getDirtyFrom()]; 747 - const childBars = quiet ? ancestorBars : [...ancestorBars, !isLast]; 748 - children.forEach((child, i) => { 749 - walk(child, childBars, i === children.length - 1); 750 - }); 751 - } 752 - }; 753 - const rootMarkers = [...effectComputed._getDirtyFrom()]; 754 - rootMarkers.forEach((marker, i) => { 755 - walk(marker, [], i === rootMarkers.length - 1); 756 - }); 757 - if (lines.length > 1) console.log(lines.join("\n")); 758 - } 759 - 760 - export const effect = (cb, debugName) => { 761 - let cleanup; 762 - let lastRunTick = 0; 763 - let hasRun = false; 764 - const computed = new Computed(() => { 765 - if (typeof cleanup === "function") cleanup(); 766 - cleanup = cb(); 767 - }); 768 - computed.__debugName = "effect(" + (debugName ?? "unknown") + ")"; 769 - 770 - let pendingFlush = false; 771 - const run = () => { 772 - if (hasRun && debugName) { 773 - logEffectTrigger(computed, debugName, lastRunTick); 774 - } 775 - lastRunTick = globalTick; 776 - hasRun = true; 777 - computed.get(); 778 - watcher.watch(); // re-arm 779 - }; 780 - 781 - const watcher = new Watcher(() => { 782 - if (pendingFlush) return; 783 - pendingFlush = true; 784 - requestAnimationFrame(() => { 785 - pendingFlush = false; 786 - run(); 787 - }); 788 - }); 789 - watcher.watch(computed); 790 - run(); 791 - 792 - return () => { 793 - if (typeof cleanup === "function") cleanup(); 794 - watcher.unwatch(computed); 795 - computed.dispose(); 796 - }; 797 - }; 798 - 799 - export class SignalMap { 800 - __debugName = "<SignalMap>"; 801 - 802 - constructor() { 803 - this.map = new Map(); 804 - // Tracks keys that have been explicitly written via set(). Reading $keys 805 - // takes a dependency on collection membership so consumers re-run when 806 - // new entries are added. 807 - this._setKeys = new Set(); 808 - this.$keys = new Signal.State([]); 809 - setTimeout(() => { 810 - this.$keys.__debugName = `${this.__debugName}.$keys`; 811 - }, 0); 812 - } 813 - 814 - get(key) { 815 - let signal = this.map.get(key); 816 - if (!signal) { 817 - signal = new Signal.State(null); 818 - signal.__debugName = `${this.__debugName}[${String(key)}]`; 819 - this.map.set(key, signal); 820 - } 821 - return signal; 822 - } 823 - 824 - set(key, value) { 825 - this.get(key).set(value); 826 - if (!this._setKeys.has(key)) { 827 - this._setKeys.add(key); 828 - this.$keys.set([...this._setKeys]); 829 - } 830 - } 831 - 832 - delete(key) { 833 - const signal = this.map.get(key); 834 - if (signal) signal.set(null); 835 - this.map.delete(key); 836 - if (this._setKeys.delete(key)) { 837 - this.$keys.set([...this._setKeys]); 838 - } 839 - } 840 - 841 - clear() { 842 - for (const signal of this.map.values()) { 843 - signal.set(null); 844 - } 845 - } 846 - 847 - keys() { 848 - return this.map.keys(); 849 - } 850 - 851 - *values() { 852 - for (const signal of this.map.values()) { 853 - yield signal.get(); 854 - } 855 - } 856 - 857 - *entries() { 858 - for (const [key, signal] of this.map.entries()) { 859 - yield [key, signal.get()]; 860 - } 861 - } 862 - } 863 - 864 - export class ComputedMap { 865 - __debugName = "<ComputedMap>"; 866 - 867 - constructor(computeFn) { 868 - this.map = new Map(); 869 - this.computeFn = computeFn; 870 - } 871 - 872 - get(key) { 873 - let signal = this.map.get(key); 874 - if (!signal) { 875 - signal = new Signal.Computed(() => this.computeFn(key)); 876 - signal.__debugName = `${this.__debugName}[${String(key)}]`; 877 - this.map.set(key, signal); 878 - } 879 - return signal; 880 - } 881 - } 882 - 883 - export class ReactiveStore extends EventEmitter { 884 - constructor(id) { 885 - super(); 886 - return new Proxy(this, { 887 - set(target, prop, value) { 888 - if (prop.startsWith("$")) { 889 - value.__debugName = `${id}.${prop}`; 890 - } 891 - target[prop] = value; 892 - return true; 893 - }, 894 - }); 895 549 } 896 550 }
+3 -3
src/js/components/plugin-posts-feed.js
··· 1 1 import { html, render } from "/js/lib/lit-html.js"; 2 2 import { Component } from "/js/components/component.js"; 3 3 import { postFeedTemplate } from "/js/templates/postFeed.template.js"; 4 - import { Signal, effect } from "/js/utils.js"; 4 + import { Signal, effect } from "/js/signals.js"; 5 5 6 6 class PluginPostsFeed extends Component { 7 7 static get observedAttributes() { ··· 19 19 emptyMessage: new Signal.State(this.getAttribute("empty-message")), 20 20 }; 21 21 this.state = { 22 - currentUser: this.dataLayer.signals.$currentUser, 22 + currentUser: this.dataLayer.derived.$currentUser, 23 23 loaded: new Signal.State(false), 24 24 posts: new Signal.Computed(() => { 25 25 if (!this.state.loaded.get()) return null; 26 26 const uris = this.attribs.uris.get(); 27 27 if (!uris) return null; 28 28 return uris 29 - .map((uri) => this.dataLayer.signals.$hydratedPosts.get(uri).get()) 29 + .map((uri) => this.dataLayer.derived.$hydratedPosts.get(uri).get()) 30 30 .filter(Boolean); 31 31 }), 32 32 error: new Signal.State(null),
+2 -2
src/js/components/plugin-profiles-list.js
··· 1 1 import { html, render } from "/js/lib/lit-html.js"; 2 2 import { Component } from "/js/components/component.js"; 3 3 import { profileFeedTemplate } from "/js/templates/profileFeed.template.js"; 4 - import { Signal, effect } from "/js/utils.js"; 4 + import { Signal, effect } from "/js/signals.js"; 5 5 6 6 class PluginProfilesList extends Component { 7 7 static get observedAttributes() { ··· 21 21 if (!this.state.loaded.get()) return null; 22 22 const dids = this.attribs.dids.get(); 23 23 return dids 24 - .map((did) => this.dataLayer.signals.$hydratedProfiles.get(did).get()) 24 + .map((did) => this.dataLayer.derived.$hydratedProfiles.get(did).get()) 25 25 .filter(Boolean); 26 26 }), 27 27 error: new Signal.State(null),
+1 -1
src/js/components/plugin-slot.js
··· 1 1 import { Component } from "/js/components/component.js"; 2 - import { effect } from "/js/utils.js"; 2 + import { effect } from "/js/signals.js"; 3 3 4 4 const CONTEXT_PREFIX = "context-"; 5 5
+3 -3
src/js/dataLayer/dataLayer.js
··· 3 3 import { Mutations } from "/js/dataLayer/mutations.js"; 4 4 import { Requests } from "/js/dataLayer/requests.js"; 5 5 import { Declarative } from "/js/dataLayer/declarative.js"; 6 - import { Signals } from "/js/dataLayer/signals.js"; 6 + import { Derived } from "/js/dataLayer/derived.js"; 7 7 8 8 export class DataLayer { 9 9 constructor(api, pluginService, preferencesProvider) { ··· 39 39 this.patchStore, 40 40 this.preferencesProvider, 41 41 ); 42 - this.signals = new Signals( 42 + this.derived = new Derived( 43 43 this.dataStore, 44 44 this.patchStore, 45 45 this.preferencesProvider, 46 46 this.pluginService, 47 47 this.isAuthenticated, 48 48 ); 49 - this.declarative = new Declarative(this.signals, this.requests); 49 + this.declarative = new Declarative(this.derived, this.requests); 50 50 this.subscribers = []; 51 51 } 52 52
+1 -1
src/js/dataLayer/dataStore.js
··· 1 - import { Signal, SignalMap, ReactiveStore } from "/js/utils.js"; 1 + import { Signal, SignalMap, ReactiveStore } from "/js/signals.js"; 2 2 import { getQuotedPost, embedViewRecordToPostView } from "/js/dataHelpers.js"; 3 3 4 4 // The store saves canonical data from the server. Patches are layered on top of this.
+21 -21
src/js/dataLayer/declarative.js
··· 1 1 export class Declarative { 2 - constructor(signals, requests) { 3 - this.signals = signals; 2 + constructor(derived, requests) { 3 + this.derived = derived; 4 4 this.requests = requests; 5 5 } 6 6 async ensureCurrentUser() { 7 - let currentUser = this.signals.$currentUser.get(); 7 + let currentUser = this.derived.$currentUser.get(); 8 8 if (!currentUser) { 9 9 await this.requests.loadCurrentUser(); 10 - currentUser = this.signals.$currentUser.get(); 10 + currentUser = this.derived.$currentUser.get(); 11 11 } 12 12 if (!currentUser) { 13 13 throw new Error("Current user not found"); ··· 16 16 } 17 17 18 18 async ensureProfile(profileDid) { 19 - const getProfile = (did) => this.signals.$hydratedProfiles.get(did).get(); 19 + const getProfile = (did) => this.derived.$hydratedProfiles.get(did).get(); 20 20 let profile = getProfile(profileDid); 21 21 if (!profile) { 22 22 await this.requests.loadProfile(profileDid); ··· 29 29 } 30 30 31 31 async ensureProfiles(profileDids) { 32 - const getProfile = (did) => this.signals.$hydratedProfiles.get(did).get(); 32 + const getProfile = (did) => this.derived.$hydratedProfiles.get(did).get(); 33 33 const missing = profileDids.filter((did) => !getProfile(did)); 34 34 if (missing.length > 0) { 35 35 await this.requests.loadProfiles(missing); ··· 38 38 } 39 39 40 40 async ensurePostThread(postURI, { labelers = [] } = {}) { 41 - let postThread = this.signals.$hydratedPostThreads.get(postURI).get(); 41 + let postThread = this.derived.$hydratedPostThreads.get(postURI).get(); 42 42 if (!postThread) { 43 43 await this.requests.loadPostThread(postURI, { labelers }); 44 - postThread = this.signals.$hydratedPostThreads.get(postURI).get(); 44 + postThread = this.derived.$hydratedPostThreads.get(postURI).get(); 45 45 } 46 46 if (!postThread) { 47 47 throw new Error("Post thread not found"); ··· 50 50 } 51 51 52 52 async ensurePost(postURI) { 53 - let post = this.signals.$hydratedPosts.get(postURI).get(); 53 + let post = this.derived.$hydratedPosts.get(postURI).get(); 54 54 if (!post) { 55 55 await this.requests.loadPost(postURI); 56 - post = this.signals.$hydratedPosts.get(postURI).get(); 56 + post = this.derived.$hydratedPosts.get(postURI).get(); 57 57 } 58 58 if (!post) { 59 59 throw new Error("Post not found"); ··· 62 62 } 63 63 64 64 async ensurePosts(postURIs) { 65 - const getPost = (uri) => this.signals.$hydratedPosts.get(uri).get(); 65 + const getPost = (uri) => this.derived.$hydratedPosts.get(uri).get(); 66 66 const missing = postURIs.filter((uri) => !getPost(uri)); 67 67 if (missing.length > 0) { 68 68 await this.requests.loadPosts(missing); ··· 71 71 } 72 72 73 73 async ensureFeedGenerator(feedUri) { 74 - let feedGenerator = this.signals.$feedGenerators.get(feedUri).get(); 74 + let feedGenerator = this.derived.$feedGenerators.get(feedUri).get(); 75 75 if (!feedGenerator) { 76 76 await this.requests.loadFeedGenerator(feedUri); 77 - feedGenerator = this.signals.$feedGenerators.get(feedUri).get(); 77 + feedGenerator = this.derived.$feedGenerators.get(feedUri).get(); 78 78 } 79 79 if (!feedGenerator) { 80 80 throw new Error("Feed generator not found"); ··· 83 83 } 84 84 85 85 async ensurePinnedFeedGenerators() { 86 - let pinnedFeedGenerators = this.signals.$hydratedPinnedFeedGenerators.get(); 86 + let pinnedFeedGenerators = this.derived.$hydratedPinnedFeedGenerators.get(); 87 87 if (!pinnedFeedGenerators) { 88 88 await this.requests.loadPinnedFeedGenerators(); 89 - pinnedFeedGenerators = this.signals.$hydratedPinnedFeedGenerators.get(); 89 + pinnedFeedGenerators = this.derived.$hydratedPinnedFeedGenerators.get(); 90 90 } 91 91 if (!pinnedFeedGenerators) { 92 92 throw new Error("Pinned feed generators not found"); ··· 95 95 } 96 96 97 97 async ensureConvoList() { 98 - let convoList = this.signals.$convoList.get(); 98 + let convoList = this.derived.$convoList.get(); 99 99 if (!convoList) { 100 100 await this.requests.loadConvoList(); 101 - convoList = this.signals.$convoList.get(); 101 + convoList = this.derived.$convoList.get(); 102 102 } 103 103 if (!convoList) { 104 104 throw new Error("Conversation list not found"); ··· 107 107 } 108 108 109 109 async ensureConvo(convoId) { 110 - let convo = this.signals.$convos.get(convoId).get(); 110 + let convo = this.derived.$convos.get(convoId).get(); 111 111 if (!convo) { 112 112 await this.requests.loadConvo(convoId); 113 - convo = this.signals.$convos.get(convoId).get(); 113 + convo = this.derived.$convos.get(convoId).get(); 114 114 } 115 115 if (!convo) { 116 116 throw new Error("Conversation not found"); ··· 119 119 } 120 120 121 121 async ensureConvoForProfile(profileDid) { 122 - let convo = this.signals.$convoForProfile.get(profileDid).get(); 122 + let convo = this.derived.$convoForProfile.get(profileDid).get(); 123 123 if (!convo) { 124 124 await this.requests.loadConvoForProfile(profileDid); 125 - convo = this.signals.$convoForProfile.get(profileDid).get(); 125 + convo = this.derived.$convoForProfile.get(profileDid).get(); 126 126 } 127 127 if (!convo) { 128 128 throw new Error("Conversation not found");
+565
src/js/dataLayer/derived.js
··· 1 + import { 2 + filterFollowingFeed, 3 + filterAlgorithmicFeed, 4 + filterAuthorFeed, 5 + filterBookmarksFeed, 6 + } from "/js/feedFilters.js"; 7 + import { 8 + createUnavailablePost, 9 + createEmbedFromPost, 10 + getBlockedQuote, 11 + getPostUriFromRepost, 12 + getQuotedPost, 13 + isBlockedPost, 14 + isBlockingUser, 15 + isEmptyPost, 16 + isPostView, 17 + getLastInteractionTimestamp, 18 + markBlockedQuoteNotFound, 19 + replaceBlockedQuote, 20 + } from "/js/dataHelpers.js"; 21 + import { sortBy, deepClone } from "/js/utils.js"; 22 + import { 23 + effect, 24 + Signal, 25 + SignalMap, 26 + ComputedMap, 27 + ReactiveStore, 28 + } from "/js/signals.js"; 29 + 30 + function resolveBlockedQuote(post, { getPost }) { 31 + const blockedQuote = getBlockedQuote(post); 32 + if (!blockedQuote || isBlockingUser(blockedQuote)) return post; 33 + const fullBlockedPost = getPost(blockedQuote.uri); 34 + if (fullBlockedPost) { 35 + const blockedQuoteEmbed = createEmbedFromPost(fullBlockedPost); 36 + return replaceBlockedQuote(post, blockedQuoteEmbed); 37 + } 38 + return markBlockedQuoteNotFound(post, blockedQuote.uri); 39 + } 40 + 41 + function applyMutedWordsInPlace(post, preferences) { 42 + if (preferences.postHasMutedWord(post)) { 43 + if (!post.viewer) post.viewer = {}; 44 + post.viewer.hasMutedWord = true; 45 + } 46 + const quotedPost = getQuotedPost(post); 47 + if (quotedPost) { 48 + if (preferences.quotedPostHasMutedWord(quotedPost)) { 49 + quotedPost.hasMutedWord = true; 50 + } 51 + const nestedQuotedPost = getQuotedPost(quotedPost); 52 + if ( 53 + nestedQuotedPost && 54 + preferences.quotedPostHasMutedWord(nestedQuotedPost) 55 + ) { 56 + nestedQuotedPost.hasMutedWord = true; 57 + } 58 + } 59 + } 60 + 61 + function applyIsHiddenInPlace(post, preferences) { 62 + if (preferences.isPostHidden(post.uri)) { 63 + if (!post.viewer) post.viewer = {}; 64 + post.viewer.isHidden = true; 65 + } 66 + const quotedPost = getQuotedPost(post); 67 + if (quotedPost) { 68 + if (preferences.isPostHidden(quotedPost.uri)) { 69 + quotedPost.isHidden = true; 70 + } 71 + const nestedQuotedPost = getQuotedPost(quotedPost); 72 + if (nestedQuotedPost && preferences.isPostHidden(nestedQuotedPost.uri)) { 73 + nestedQuotedPost.isHidden = true; 74 + } 75 + } 76 + } 77 + 78 + function applyLabelsInPlace(post, preferences) { 79 + const badgeLabels = preferences.getBadgeLabels(post); 80 + if (badgeLabels.length > 0) { 81 + post.badgeLabels = badgeLabels; 82 + } 83 + const contentLabel = preferences.getContentLabel(post); 84 + if (contentLabel) { 85 + post.contentLabel = contentLabel; 86 + } 87 + const mediaLabel = preferences.getMediaLabel(post); 88 + if (mediaLabel) { 89 + post.mediaLabel = mediaLabel; 90 + } 91 + const quotedPost = getQuotedPost(post); 92 + if (quotedPost) { 93 + const quotedBadgeLabels = preferences.getBadgeLabels(quotedPost); 94 + if (quotedBadgeLabels.length > 0) { 95 + quotedPost.badgeLabels = quotedBadgeLabels; 96 + } 97 + const quotedContentLabel = preferences.getContentLabel(quotedPost); 98 + if (quotedContentLabel) { 99 + quotedPost.contentLabel = quotedContentLabel; 100 + } 101 + const quotedMediaLabel = preferences.getMediaLabel(quotedPost); 102 + if (quotedMediaLabel) { 103 + quotedPost.mediaLabel = quotedMediaLabel; 104 + } 105 + const nestedQuotedPost = getQuotedPost(quotedPost); 106 + if (nestedQuotedPost) { 107 + const nestedBadgeLabels = preferences.getBadgeLabels(nestedQuotedPost); 108 + if (nestedBadgeLabels.length > 0) { 109 + nestedQuotedPost.badgeLabels = nestedBadgeLabels; 110 + } 111 + const nestedContentLabel = preferences.getContentLabel(nestedQuotedPost); 112 + if (nestedContentLabel) { 113 + nestedQuotedPost.contentLabel = nestedContentLabel; 114 + } 115 + const nestedMediaLabel = preferences.getMediaLabel(nestedQuotedPost); 116 + if (nestedMediaLabel) { 117 + nestedQuotedPost.mediaLabel = nestedMediaLabel; 118 + } 119 + } 120 + } 121 + } 122 + 123 + // Composes the per-post hydrations a view typically wants. 124 + // Returns a new post object; never mutates the input. 125 + function hydratePostForView(post, { preferences, getPost }) { 126 + if (!post) return null; 127 + const resolved = resolveBlockedQuote(post, { getPost }); 128 + const result = resolved === post ? deepClone(post) : deepClone(resolved); 129 + applyMutedWordsInPlace(result, preferences); 130 + applyIsHiddenInPlace(result, preferences); 131 + applyLabelsInPlace(result, preferences); 132 + return result; 133 + } 134 + 135 + // Attach parentAuthor to a post's reply record when its parent is loaded. 136 + // Returns the input unchanged if there's no reply or the parent isn't loaded. 137 + function attachParentAuthor(post, getPost) { 138 + const parentUri = post?.record?.reply?.parent?.uri; 139 + if (!parentUri) return post; 140 + const parentPost = getPost(parentUri); 141 + if (!parentPost) return post; 142 + return { 143 + ...post, 144 + record: { 145 + ...post.record, 146 + reply: { 147 + // NOTE: LEXICON DEVIATION 148 + ...post.record.reply, 149 + parentAuthor: parentPost.author, 150 + }, 151 + }, 152 + }; 153 + } 154 + 155 + function hydrateNotifications(notifications, { getPost }) { 156 + if (!notifications) return null; 157 + return notifications.map((notification) => { 158 + if (notification.reason === "like" || notification.reason === "repost") { 159 + const subject = 160 + getPost(notification.reasonSubject) ?? 161 + createUnavailablePost(notification.reasonSubject); 162 + return { ...notification, subject }; 163 + } 164 + if ( 165 + notification.reason === "like-via-repost" || 166 + notification.reason === "repost-via-repost" 167 + ) { 168 + const postUri = notification.record.subject.uri; 169 + const subject = getPost(postUri) ?? createUnavailablePost(postUri); 170 + return { ...notification, subject }; 171 + } 172 + if ( 173 + notification.reason === "reply" || 174 + notification.reason === "mention" || 175 + notification.reason === "quote" 176 + ) { 177 + const replyPost = getPost(notification.uri); 178 + const parentPostUri = notification.record?.reply?.parent?.uri; 179 + const parentPost = parentPostUri ? getPost(parentPostUri) : null; 180 + return { ...notification, post: replyPost, parentPost }; 181 + } 182 + if (notification.reason === "subscribed-post") { 183 + const post = getPost(notification.uri); 184 + // NOTE: LEXICON DEVIATION 185 + return { ...notification, reasonSubject: post }; 186 + } 187 + return notification; 188 + }); 189 + } 190 + 191 + function hydratePostThreadNode(node, { getPost, hiddenReplyUris }) { 192 + if (!node || isEmptyPost(node)) return node; 193 + const post = getPost(node.post.uri); 194 + if (!post) return null; 195 + const hydrated = { post }; 196 + if (hiddenReplyUris.includes(node.post.uri)) { 197 + // NOTE: LEXICON DEVIATION 198 + hydrated.post = { ...post, isHidden: true }; 199 + } 200 + if (node.replies) { 201 + hydrated.replies = node.replies.map((reply) => { 202 + if (reply.$type === "app.bsky.feed.defs#threadViewPost") { 203 + return hydratePostThreadNode(reply, { getPost, hiddenReplyUris }); 204 + } 205 + return reply; 206 + }); 207 + } 208 + return hydrated; 209 + } 210 + 211 + function hydratePostThreadParent(parent, { getPost, isUnavailable }) { 212 + if (isUnavailable(parent.uri)) { 213 + return createUnavailablePost(parent.uri); 214 + } 215 + if (isBlockedPost(parent) && isBlockingUser(parent)) { 216 + return parent; 217 + } 218 + if (parent.$type !== "app.bsky.feed.defs#threadViewPost") { 219 + return parent; 220 + } 221 + const hydratedParent = { 222 + $type: "app.bsky.feed.defs#threadViewPost", 223 + post: getPost(parent.post.uri), 224 + }; 225 + if (parent.parent) { 226 + hydratedParent.parent = hydratePostThreadParent(parent.parent, { 227 + getPost, 228 + isUnavailable, 229 + }); 230 + } 231 + return hydratedParent; 232 + } 233 + 234 + export class Derived extends ReactiveStore { 235 + constructor( 236 + dataStore, 237 + patchStore, 238 + preferencesProvider, 239 + pluginService, 240 + isAuthenticated, 241 + ) { 242 + super("derived"); 243 + this.dataStore = dataStore; 244 + this.patchStore = patchStore; 245 + this.preferencesProvider = preferencesProvider; 246 + this.pluginService = pluginService; 247 + this.isAuthenticated = isAuthenticated; 248 + this.$showLessInteractions = new Signal.Computed(() => 249 + this.dataStore.$showLessInteractions.get(), 250 + ); 251 + this.$hydratedPosts = new ComputedMap((uri) => { 252 + const post = this.patchStore.$patchedPosts.get(uri).get(); 253 + const preferences = this.$preferences.get(); 254 + if (!post || !preferences) { 255 + return null; 256 + } 257 + return hydratePostForView(post, { 258 + preferences, 259 + getPost: (uri) => this.$hydratedPosts.get(uri).get(), 260 + }); 261 + }); 262 + this.$hydratedFeeds = new ComputedMap((feedURI) => { 263 + const feed = this.dataStore.$feeds.get(feedURI).get(); 264 + if (!feed) { 265 + return null; 266 + } 267 + const hydratedFeedItems = []; 268 + for (const feedItem of feed.feed) { 269 + const hydratedFeedItem = { 270 + feedContext: feedItem.feedContext, 271 + post: this.$hydratedPosts.get(feedItem.post.uri).get(), 272 + }; 273 + if (feedItem.reason) { 274 + hydratedFeedItem.reason = feedItem.reason; 275 + } 276 + const reply = feedItem.reply; 277 + if (reply) { 278 + let root = reply.root; 279 + if (isPostView(root)) { 280 + root = this.$hydratedPosts.get(root.uri).get(); 281 + } 282 + let parent = reply.parent; 283 + if (isPostView(parent)) { 284 + parent = this.$hydratedPosts.get(parent.uri).get(); 285 + } 286 + hydratedFeedItem.reply = { ...reply, root, parent }; 287 + } 288 + hydratedFeedItems.push(hydratedFeedItem); 289 + } 290 + const hydratedFeed = { 291 + feed: hydratedFeedItems, 292 + cursor: feed.cursor, 293 + }; 294 + const pluginFilteredFeedItems = 295 + this.pluginService.$pluginFilteredFeedItems.get(feedURI).get() ?? {}; 296 + if (feedURI === "following") { 297 + const currentUser = this.$currentUser.get(); 298 + const preferences = this.$preferences.get(); 299 + return filterFollowingFeed( 300 + hydratedFeed, 301 + currentUser, 302 + preferences, 303 + pluginFilteredFeedItems, 304 + ); 305 + } else { 306 + return filterAlgorithmicFeed( 307 + hydratedFeed, 308 + this.isAuthenticated, 309 + pluginFilteredFeedItems, 310 + ); 311 + } 312 + }); 313 + this.$currentUser = new Signal.Computed(() => { 314 + const user = this.dataStore.$currentUser.get(); 315 + const patches = this.patchStore.$currentUserPatches.get(); 316 + return this.patchStore.applyCurrentUserPatches(user, patches); 317 + }); 318 + this.$preferences = new Signal.Computed(() => { 319 + const preferences = this.preferencesProvider.$preferences.get(); 320 + if (!preferences) return null; 321 + const patches = this.patchStore.$preferencePatches.get(); 322 + return this.patchStore.applyPreferencePatches(preferences, patches); 323 + }); 324 + const getHydratedPost = (uri) => this.$hydratedPosts.get(uri).get(); 325 + this.$notifications = new Signal.Computed(() => 326 + hydrateNotifications(this.dataStore.$notifications.get(), { 327 + getPost: getHydratedPost, 328 + }), 329 + ); 330 + this.$mentionNotifications = new Signal.Computed(() => 331 + hydrateNotifications(this.dataStore.$mentionNotifications.get(), { 332 + getPost: getHydratedPost, 333 + }), 334 + ); 335 + this.$hydratedPostThreads = new ComputedMap((postURI) => { 336 + const postThread = this.dataStore.$postThreads.get(postURI).get(); 337 + const postThreadOther = this.dataStore.$postThreadOthers 338 + .get(postURI) 339 + .get(); 340 + if (!postThread || !postThreadOther) { 341 + return null; 342 + } 343 + if (isEmptyPost(postThread)) { 344 + return postThread; 345 + } 346 + const hiddenReplyUris = postThreadOther.map((item) => item.uri); 347 + const hydrated = hydratePostThreadNode(postThread, { 348 + getPost: getHydratedPost, 349 + hiddenReplyUris, 350 + }); 351 + if (!hydrated) { 352 + return null; 353 + } 354 + if (postThread.parent) { 355 + hydrated.parent = hydratePostThreadParent(postThread.parent, { 356 + getPost: getHydratedPost, 357 + isUnavailable: (uri) => 358 + this.dataStore.$unavailablePosts.get(uri).get() !== null, 359 + }); 360 + } 361 + return hydrated; 362 + }); 363 + this.$hydratedHashtagFeeds = new ComputedMap((hashtagKey) => { 364 + const feed = this.dataStore.$hashtagFeeds.get(hashtagKey).get(); 365 + if (!feed) { 366 + return null; 367 + } 368 + const hydratedFeedItems = []; 369 + for (const feedItem of feed.feed) { 370 + const post = this.$hydratedPosts.get(feedItem.post.uri).get(); 371 + if (!post) continue; 372 + hydratedFeedItems.push({ 373 + post: attachParentAuthor(post, getHydratedPost), 374 + }); 375 + } 376 + return { 377 + feed: hydratedFeedItems, 378 + cursor: feed.cursor, 379 + }; 380 + }); 381 + this.$feedGenerators = new ComputedMap((feedUri) => 382 + this.dataStore.$feedGenerators.get(feedUri).get(), 383 + ); 384 + this.$profileSearchResults = new Signal.Computed(() => { 385 + const data = this.dataStore.$profileSearchResults.get(); 386 + if (!data) return null; 387 + return data.actors; 388 + }); 389 + this.$profileSearchCursor = new Signal.Computed( 390 + () => this.dataStore.$profileSearchResults.get()?.cursor ?? null, 391 + ); 392 + this.$feedSearchResults = new Signal.Computed(() => { 393 + const data = this.dataStore.$feedSearchResults.get(); 394 + if (!data) return null; 395 + return data.feeds; 396 + }); 397 + this.$feedSearchCursor = new Signal.Computed( 398 + () => this.dataStore.$feedSearchResults.get()?.cursor ?? null, 399 + ); 400 + this.$postSearchResults = new Signal.Computed(() => { 401 + const data = this.dataStore.$postSearchResults.get(); 402 + if (!data) return null; 403 + const hydratedSearchResults = []; 404 + for (const result of data.posts) { 405 + const post = this.$hydratedPosts.get(result.uri).get(); 406 + if (!post) continue; 407 + hydratedSearchResults.push(attachParentAuthor(post, getHydratedPost)); 408 + } 409 + return hydratedSearchResults; 410 + }); 411 + this.$postSearchCursor = new Signal.Computed( 412 + () => this.dataStore.$postSearchResults.get()?.cursor ?? null, 413 + ); 414 + this.$hydratedPostQuotes = new ComputedMap((postUri) => { 415 + const quotes = this.dataStore.$postQuotes.get(postUri).get(); 416 + if (!quotes) { 417 + return null; 418 + } 419 + const hydratedPosts = []; 420 + for (const quote of quotes.posts) { 421 + const post = this.$hydratedPosts.get(quote.uri).get(); 422 + if (!post) continue; 423 + hydratedPosts.push(attachParentAuthor(post, getHydratedPost)); 424 + } 425 + return { 426 + posts: hydratedPosts, 427 + cursor: quotes.cursor, 428 + }; 429 + }); 430 + this.$hydratedPinnedFeedGenerators = new Signal.Computed(() => { 431 + const pinned = this.dataStore.$pinnedFeedGenerators.get(); 432 + if (!pinned) return null; 433 + const hydrated = []; 434 + if (this.isAuthenticated) { 435 + hydrated.push({ uri: "following", displayName: "Following" }); 436 + } 437 + for (const pin of pinned) { 438 + hydrated.push(this.$feedGenerators.get(pin.uri).get()); 439 + } 440 + return hydrated; 441 + }); 442 + this.$hydratedProfiles = new ComputedMap((did) => 443 + this.patchStore.$patchedProfiles.get(did).get(), 444 + ); 445 + this.$hydratedAuthorFeeds = new ComputedMap((feedURI) => { 446 + const rawFeed = this.dataStore.$authorFeeds.get(feedURI).get(); 447 + if (!rawFeed) { 448 + return null; 449 + } 450 + const patches = 451 + this.patchStore.$authorFeedPatches.get(feedURI).get() || []; 452 + let feed = { feed: [...rawFeed.feed], cursor: rawFeed.cursor }; 453 + for (const patch of patches) { 454 + feed = this.patchStore.applyAuthorFeedPatch(feed, patch.body); 455 + } 456 + const hydratedFeedItems = []; 457 + for (const feedItem of feed.feed) { 458 + const hydratedFeedItem = { 459 + post: this.$hydratedPosts.get(feedItem.post.uri).get(), 460 + }; 461 + if (feedItem.reason) { 462 + hydratedFeedItem.reason = feedItem.reason; 463 + } 464 + if (feedItem.reply) { 465 + hydratedFeedItem.reply = { 466 + ...feedItem.reply, 467 + root: this.$hydratedPosts.get(feedItem.reply.root.uri).get(), 468 + parent: this.$hydratedPosts.get(feedItem.reply.parent.uri).get(), 469 + }; 470 + } 471 + hydratedFeedItems.push(hydratedFeedItem); 472 + } 473 + let hydratedFeed = { 474 + feed: hydratedFeedItems, 475 + cursor: feed.cursor, 476 + }; 477 + const dashIndex = feedURI.lastIndexOf("-"); 478 + const feedType = dashIndex >= 0 ? feedURI.slice(dashIndex + 1) : ""; 479 + if (feedType === "replies") { 480 + const filteredFeedItems = []; 481 + for (const feedItem of hydratedFeed.feed) { 482 + if (feedItem.reply && !feedItem.reason) { 483 + filteredFeedItems.push(feedItem); 484 + } 485 + } 486 + hydratedFeed = { 487 + feed: filteredFeedItems, 488 + cursor: hydratedFeed.cursor, 489 + }; 490 + } 491 + return filterAuthorFeed(hydratedFeed, this.isAuthenticated); 492 + }); 493 + this.$actorFeeds = new ComputedMap((did) => 494 + this.dataStore.$actorFeeds.get(did).get(), 495 + ); 496 + this.$profileChatStatus = new ComputedMap((did) => 497 + this.dataStore.$profileChatStatus.get(did).get(), 498 + ); 499 + this.$labelerInfo = new ComputedMap((did) => 500 + this.dataStore.$labelerInfo.get(did).get(), 501 + ); 502 + this.$hydratedBookmarks = new Signal.Computed(() => { 503 + const bookmarks = this.dataStore.$bookmarks.get(); 504 + if (!bookmarks) { 505 + return null; 506 + } 507 + const hydratedFeed = []; 508 + for (const bookmark of bookmarks.feed) { 509 + const post = this.$hydratedPosts.get(bookmark.post.uri).get(); 510 + hydratedFeed.push({ post: attachParentAuthor(post, getHydratedPost) }); 511 + } 512 + return filterBookmarksFeed({ 513 + feed: hydratedFeed, 514 + cursor: bookmarks.cursor, 515 + }); 516 + }); 517 + this.$labelerSettings = new ComputedMap((labelerDid) => { 518 + const preferences = this.$preferences.get(); 519 + if (!preferences) return null; 520 + return preferences.getLabelerSettings(labelerDid); 521 + }); 522 + this.$convos = new ComputedMap((convoId) => 523 + this.dataStore.$convos.get(convoId).get(), 524 + ); 525 + this.$convoList = new Signal.Computed(() => { 526 + const convoList = this.dataStore.$convoList.get(); 527 + if (!convoList) return null; 528 + const hydrated = convoList.map((convo) => 529 + this.$convos.get(convo.id).get(), 530 + ); 531 + return sortBy( 532 + hydrated, 533 + (convo) => new Date(getLastInteractionTimestamp(convo)), 534 + { direction: "desc" }, 535 + ); 536 + }); 537 + this.$convoListCursor = new Signal.Computed(() => 538 + this.dataStore.$convoListCursor.get(), 539 + ); 540 + this.$convoForProfile = new ComputedMap((profileDid) => { 541 + const convoIds = this.dataStore.$convos.$keys.get(); 542 + for (const convoId of convoIds) { 543 + const convo = this.dataStore.$convos.get(convoId).get(); 544 + if (!convo) continue; 545 + if ( 546 + convo.members.length === 2 && 547 + convo.members.some((member) => member.did === profileDid) 548 + ) { 549 + return this.$convos.get(convo.id).get(); 550 + } 551 + } 552 + return null; 553 + }); 554 + this.$convoMessages = new ComputedMap((convoId) => { 555 + const messages = this.dataStore.$convoMessages.get(convoId).get(); 556 + if (!messages) return null; 557 + return { 558 + messages: messages.messages.map((message) => 559 + this.patchStore.$patchedMessages.get(message.id).get(), 560 + ), 561 + cursor: messages.cursor, 562 + }; 563 + }); 564 + } 565 + }
+1 -1
src/js/dataLayer/patchStore.js
··· 1 1 import { deepClone, SimpleUUID } from "/js/utils.js"; 2 2 import { pinPostInFeed, unpinPostInFeed } from "/js/dataHelpers.js"; 3 - import { Signal, SignalMap, ComputedMap, ReactiveStore } from "/js/utils.js"; 3 + import { Signal, SignalMap, ComputedMap, ReactiveStore } from "/js/signals.js"; 4 4 5 5 // The store saves patch data for optimistic updates. 6 6 export class PatchStore extends ReactiveStore {
+1 -1
src/js/dataLayer/preferencesProvider.js
··· 1 1 import { Preferences } from "/js/preferences.js"; 2 - import { Signal } from "/js/utils.js"; 2 + import { Signal } from "/js/signals.js"; 3 3 4 4 export class PreferencesProvider { 5 5 constructor(api) {
+2 -1
src/js/dataLayer/requests.js
··· 11 11 parseUri, 12 12 } from "/js/dataHelpers.js"; 13 13 import { Constellation } from "/js/constellation.js"; 14 - import { unique, SignalMap, ComputedMap, ReactiveStore } from "/js/utils.js"; 14 + import { unique } from "/js/utils.js"; 15 + import { SignalMap, ComputedMap, ReactiveStore } from "/js/signals.js"; 15 16 import { ApiError } from "/js/api.js"; 16 17 17 18 // Get URIs of blocked quotes from posts where the author has not blocked the viewer
-566
src/js/dataLayer/signals.js
··· 1 - import { 2 - filterFollowingFeed, 3 - filterAlgorithmicFeed, 4 - filterAuthorFeed, 5 - filterBookmarksFeed, 6 - } from "/js/feedFilters.js"; 7 - import { 8 - createUnavailablePost, 9 - createEmbedFromPost, 10 - getBlockedQuote, 11 - getPostUriFromRepost, 12 - getQuotedPost, 13 - isBlockedPost, 14 - isBlockingUser, 15 - isEmptyPost, 16 - isPostView, 17 - getLastInteractionTimestamp, 18 - markBlockedQuoteNotFound, 19 - replaceBlockedQuote, 20 - } from "/js/dataHelpers.js"; 21 - import { 22 - sortBy, 23 - effect, 24 - deepClone, 25 - Signal, 26 - SignalMap, 27 - ComputedMap, 28 - ReactiveStore, 29 - } from "/js/utils.js"; 30 - 31 - function resolveBlockedQuote(post, { getPost }) { 32 - const blockedQuote = getBlockedQuote(post); 33 - if (!blockedQuote || isBlockingUser(blockedQuote)) return post; 34 - const fullBlockedPost = getPost(blockedQuote.uri); 35 - if (fullBlockedPost) { 36 - const blockedQuoteEmbed = createEmbedFromPost(fullBlockedPost); 37 - return replaceBlockedQuote(post, blockedQuoteEmbed); 38 - } 39 - return markBlockedQuoteNotFound(post, blockedQuote.uri); 40 - } 41 - 42 - function applyMutedWordsInPlace(post, preferences) { 43 - if (preferences.postHasMutedWord(post)) { 44 - if (!post.viewer) post.viewer = {}; 45 - post.viewer.hasMutedWord = true; 46 - } 47 - const quotedPost = getQuotedPost(post); 48 - if (quotedPost) { 49 - if (preferences.quotedPostHasMutedWord(quotedPost)) { 50 - quotedPost.hasMutedWord = true; 51 - } 52 - const nestedQuotedPost = getQuotedPost(quotedPost); 53 - if ( 54 - nestedQuotedPost && 55 - preferences.quotedPostHasMutedWord(nestedQuotedPost) 56 - ) { 57 - nestedQuotedPost.hasMutedWord = true; 58 - } 59 - } 60 - } 61 - 62 - function applyIsHiddenInPlace(post, preferences) { 63 - if (preferences.isPostHidden(post.uri)) { 64 - if (!post.viewer) post.viewer = {}; 65 - post.viewer.isHidden = true; 66 - } 67 - const quotedPost = getQuotedPost(post); 68 - if (quotedPost) { 69 - if (preferences.isPostHidden(quotedPost.uri)) { 70 - quotedPost.isHidden = true; 71 - } 72 - const nestedQuotedPost = getQuotedPost(quotedPost); 73 - if (nestedQuotedPost && preferences.isPostHidden(nestedQuotedPost.uri)) { 74 - nestedQuotedPost.isHidden = true; 75 - } 76 - } 77 - } 78 - 79 - function applyLabelsInPlace(post, preferences) { 80 - const badgeLabels = preferences.getBadgeLabels(post); 81 - if (badgeLabels.length > 0) { 82 - post.badgeLabels = badgeLabels; 83 - } 84 - const contentLabel = preferences.getContentLabel(post); 85 - if (contentLabel) { 86 - post.contentLabel = contentLabel; 87 - } 88 - const mediaLabel = preferences.getMediaLabel(post); 89 - if (mediaLabel) { 90 - post.mediaLabel = mediaLabel; 91 - } 92 - const quotedPost = getQuotedPost(post); 93 - if (quotedPost) { 94 - const quotedBadgeLabels = preferences.getBadgeLabels(quotedPost); 95 - if (quotedBadgeLabels.length > 0) { 96 - quotedPost.badgeLabels = quotedBadgeLabels; 97 - } 98 - const quotedContentLabel = preferences.getContentLabel(quotedPost); 99 - if (quotedContentLabel) { 100 - quotedPost.contentLabel = quotedContentLabel; 101 - } 102 - const quotedMediaLabel = preferences.getMediaLabel(quotedPost); 103 - if (quotedMediaLabel) { 104 - quotedPost.mediaLabel = quotedMediaLabel; 105 - } 106 - const nestedQuotedPost = getQuotedPost(quotedPost); 107 - if (nestedQuotedPost) { 108 - const nestedBadgeLabels = preferences.getBadgeLabels(nestedQuotedPost); 109 - if (nestedBadgeLabels.length > 0) { 110 - nestedQuotedPost.badgeLabels = nestedBadgeLabels; 111 - } 112 - const nestedContentLabel = preferences.getContentLabel(nestedQuotedPost); 113 - if (nestedContentLabel) { 114 - nestedQuotedPost.contentLabel = nestedContentLabel; 115 - } 116 - const nestedMediaLabel = preferences.getMediaLabel(nestedQuotedPost); 117 - if (nestedMediaLabel) { 118 - nestedQuotedPost.mediaLabel = nestedMediaLabel; 119 - } 120 - } 121 - } 122 - } 123 - 124 - // Composes the per-post hydrations a view typically wants. 125 - // Returns a new post object; never mutates the input. 126 - function hydratePostForView(post, { preferences, getPost }) { 127 - if (!post) return null; 128 - const resolved = resolveBlockedQuote(post, { getPost }); 129 - const result = resolved === post ? deepClone(post) : deepClone(resolved); 130 - applyMutedWordsInPlace(result, preferences); 131 - applyIsHiddenInPlace(result, preferences); 132 - applyLabelsInPlace(result, preferences); 133 - return result; 134 - } 135 - 136 - // Attach parentAuthor to a post's reply record when its parent is loaded. 137 - // Returns the input unchanged if there's no reply or the parent isn't loaded. 138 - function attachParentAuthor(post, getPost) { 139 - const parentUri = post?.record?.reply?.parent?.uri; 140 - if (!parentUri) return post; 141 - const parentPost = getPost(parentUri); 142 - if (!parentPost) return post; 143 - return { 144 - ...post, 145 - record: { 146 - ...post.record, 147 - reply: { 148 - // NOTE: LEXICON DEVIATION 149 - ...post.record.reply, 150 - parentAuthor: parentPost.author, 151 - }, 152 - }, 153 - }; 154 - } 155 - 156 - function hydrateNotifications(notifications, { getPost }) { 157 - if (!notifications) return null; 158 - return notifications.map((notification) => { 159 - if (notification.reason === "like" || notification.reason === "repost") { 160 - const subject = 161 - getPost(notification.reasonSubject) ?? 162 - createUnavailablePost(notification.reasonSubject); 163 - return { ...notification, subject }; 164 - } 165 - if ( 166 - notification.reason === "like-via-repost" || 167 - notification.reason === "repost-via-repost" 168 - ) { 169 - const postUri = notification.record.subject.uri; 170 - const subject = getPost(postUri) ?? createUnavailablePost(postUri); 171 - return { ...notification, subject }; 172 - } 173 - if ( 174 - notification.reason === "reply" || 175 - notification.reason === "mention" || 176 - notification.reason === "quote" 177 - ) { 178 - const replyPost = getPost(notification.uri); 179 - const parentPostUri = notification.record?.reply?.parent?.uri; 180 - const parentPost = parentPostUri ? getPost(parentPostUri) : null; 181 - return { ...notification, post: replyPost, parentPost }; 182 - } 183 - if (notification.reason === "subscribed-post") { 184 - const post = getPost(notification.uri); 185 - // NOTE: LEXICON DEVIATION 186 - return { ...notification, reasonSubject: post }; 187 - } 188 - return notification; 189 - }); 190 - } 191 - 192 - function hydratePostThreadNode(node, { getPost, hiddenReplyUris }) { 193 - if (!node || isEmptyPost(node)) return node; 194 - const post = getPost(node.post.uri); 195 - if (!post) return null; 196 - const hydrated = { post }; 197 - if (hiddenReplyUris.includes(node.post.uri)) { 198 - // NOTE: LEXICON DEVIATION 199 - hydrated.post = { ...post, isHidden: true }; 200 - } 201 - if (node.replies) { 202 - hydrated.replies = node.replies.map((reply) => { 203 - if (reply.$type === "app.bsky.feed.defs#threadViewPost") { 204 - return hydratePostThreadNode(reply, { getPost, hiddenReplyUris }); 205 - } 206 - return reply; 207 - }); 208 - } 209 - return hydrated; 210 - } 211 - 212 - function hydratePostThreadParent(parent, { getPost, isUnavailable }) { 213 - if (isUnavailable(parent.uri)) { 214 - return createUnavailablePost(parent.uri); 215 - } 216 - if (isBlockedPost(parent) && isBlockingUser(parent)) { 217 - return parent; 218 - } 219 - if (parent.$type !== "app.bsky.feed.defs#threadViewPost") { 220 - return parent; 221 - } 222 - const hydratedParent = { 223 - $type: "app.bsky.feed.defs#threadViewPost", 224 - post: getPost(parent.post.uri), 225 - }; 226 - if (parent.parent) { 227 - hydratedParent.parent = hydratePostThreadParent(parent.parent, { 228 - getPost, 229 - isUnavailable, 230 - }); 231 - } 232 - return hydratedParent; 233 - } 234 - 235 - export class Signals extends ReactiveStore { 236 - constructor( 237 - dataStore, 238 - patchStore, 239 - preferencesProvider, 240 - pluginService, 241 - isAuthenticated, 242 - ) { 243 - super("signals"); 244 - this.dataStore = dataStore; 245 - this.patchStore = patchStore; 246 - this.preferencesProvider = preferencesProvider; 247 - this.pluginService = pluginService; 248 - this.isAuthenticated = isAuthenticated; 249 - this.$showLessInteractions = new Signal.Computed(() => 250 - this.dataStore.$showLessInteractions.get(), 251 - ); 252 - this.$hydratedPosts = new ComputedMap((uri) => { 253 - const post = this.patchStore.$patchedPosts.get(uri).get(); 254 - const preferences = this.$preferences.get(); 255 - if (!post || !preferences) { 256 - return null; 257 - } 258 - return hydratePostForView(post, { 259 - preferences, 260 - getPost: (uri) => this.$hydratedPosts.get(uri).get(), 261 - }); 262 - }); 263 - this.$hydratedFeeds = new ComputedMap((feedURI) => { 264 - const feed = this.dataStore.$feeds.get(feedURI).get(); 265 - if (!feed) { 266 - return null; 267 - } 268 - const hydratedFeedItems = []; 269 - for (const feedItem of feed.feed) { 270 - const hydratedFeedItem = { 271 - feedContext: feedItem.feedContext, 272 - post: this.$hydratedPosts.get(feedItem.post.uri).get(), 273 - }; 274 - if (feedItem.reason) { 275 - hydratedFeedItem.reason = feedItem.reason; 276 - } 277 - const reply = feedItem.reply; 278 - if (reply) { 279 - let root = reply.root; 280 - if (isPostView(root)) { 281 - root = this.$hydratedPosts.get(root.uri).get(); 282 - } 283 - let parent = reply.parent; 284 - if (isPostView(parent)) { 285 - parent = this.$hydratedPosts.get(parent.uri).get(); 286 - } 287 - hydratedFeedItem.reply = { ...reply, root, parent }; 288 - } 289 - hydratedFeedItems.push(hydratedFeedItem); 290 - } 291 - const hydratedFeed = { 292 - feed: hydratedFeedItems, 293 - cursor: feed.cursor, 294 - }; 295 - const pluginFilteredFeedItems = 296 - this.pluginService.$pluginFilteredFeedItems.get(feedURI).get() ?? {}; 297 - if (feedURI === "following") { 298 - const currentUser = this.$currentUser.get(); 299 - const preferences = this.$preferences.get(); 300 - return filterFollowingFeed( 301 - hydratedFeed, 302 - currentUser, 303 - preferences, 304 - pluginFilteredFeedItems, 305 - ); 306 - } else { 307 - return filterAlgorithmicFeed( 308 - hydratedFeed, 309 - this.isAuthenticated, 310 - pluginFilteredFeedItems, 311 - ); 312 - } 313 - }); 314 - this.$currentUser = new Signal.Computed(() => { 315 - const user = this.dataStore.$currentUser.get(); 316 - const patches = this.patchStore.$currentUserPatches.get(); 317 - return this.patchStore.applyCurrentUserPatches(user, patches); 318 - }); 319 - this.$preferences = new Signal.Computed(() => { 320 - const preferences = this.preferencesProvider.$preferences.get(); 321 - if (!preferences) return null; 322 - const patches = this.patchStore.$preferencePatches.get(); 323 - return this.patchStore.applyPreferencePatches(preferences, patches); 324 - }); 325 - const getHydratedPost = (uri) => this.$hydratedPosts.get(uri).get(); 326 - this.$notifications = new Signal.Computed(() => 327 - hydrateNotifications(this.dataStore.$notifications.get(), { 328 - getPost: getHydratedPost, 329 - }), 330 - ); 331 - this.$mentionNotifications = new Signal.Computed(() => 332 - hydrateNotifications(this.dataStore.$mentionNotifications.get(), { 333 - getPost: getHydratedPost, 334 - }), 335 - ); 336 - this.$hydratedPostThreads = new ComputedMap((postURI) => { 337 - const postThread = this.dataStore.$postThreads.get(postURI).get(); 338 - const postThreadOther = this.dataStore.$postThreadOthers 339 - .get(postURI) 340 - .get(); 341 - if (!postThread || !postThreadOther) { 342 - return null; 343 - } 344 - if (isEmptyPost(postThread)) { 345 - return postThread; 346 - } 347 - const hiddenReplyUris = postThreadOther.map((item) => item.uri); 348 - const hydrated = hydratePostThreadNode(postThread, { 349 - getPost: getHydratedPost, 350 - hiddenReplyUris, 351 - }); 352 - if (!hydrated) { 353 - return null; 354 - } 355 - if (postThread.parent) { 356 - hydrated.parent = hydratePostThreadParent(postThread.parent, { 357 - getPost: getHydratedPost, 358 - isUnavailable: (uri) => 359 - this.dataStore.$unavailablePosts.get(uri).get() !== null, 360 - }); 361 - } 362 - return hydrated; 363 - }); 364 - this.$hydratedHashtagFeeds = new ComputedMap((hashtagKey) => { 365 - const feed = this.dataStore.$hashtagFeeds.get(hashtagKey).get(); 366 - if (!feed) { 367 - return null; 368 - } 369 - const hydratedFeedItems = []; 370 - for (const feedItem of feed.feed) { 371 - const post = this.$hydratedPosts.get(feedItem.post.uri).get(); 372 - if (!post) continue; 373 - hydratedFeedItems.push({ 374 - post: attachParentAuthor(post, getHydratedPost), 375 - }); 376 - } 377 - return { 378 - feed: hydratedFeedItems, 379 - cursor: feed.cursor, 380 - }; 381 - }); 382 - this.$feedGenerators = new ComputedMap((feedUri) => 383 - this.dataStore.$feedGenerators.get(feedUri).get(), 384 - ); 385 - this.$profileSearchResults = new Signal.Computed(() => { 386 - const data = this.dataStore.$profileSearchResults.get(); 387 - if (!data) return null; 388 - return data.actors; 389 - }); 390 - this.$profileSearchCursor = new Signal.Computed( 391 - () => this.dataStore.$profileSearchResults.get()?.cursor ?? null, 392 - ); 393 - this.$feedSearchResults = new Signal.Computed(() => { 394 - const data = this.dataStore.$feedSearchResults.get(); 395 - if (!data) return null; 396 - return data.feeds; 397 - }); 398 - this.$feedSearchCursor = new Signal.Computed( 399 - () => this.dataStore.$feedSearchResults.get()?.cursor ?? null, 400 - ); 401 - this.$postSearchResults = new Signal.Computed(() => { 402 - const data = this.dataStore.$postSearchResults.get(); 403 - if (!data) return null; 404 - const hydratedSearchResults = []; 405 - for (const result of data.posts) { 406 - const post = this.$hydratedPosts.get(result.uri).get(); 407 - if (!post) continue; 408 - hydratedSearchResults.push(attachParentAuthor(post, getHydratedPost)); 409 - } 410 - return hydratedSearchResults; 411 - }); 412 - this.$postSearchCursor = new Signal.Computed( 413 - () => this.dataStore.$postSearchResults.get()?.cursor ?? null, 414 - ); 415 - this.$hydratedPostQuotes = new ComputedMap((postUri) => { 416 - const quotes = this.dataStore.$postQuotes.get(postUri).get(); 417 - if (!quotes) { 418 - return null; 419 - } 420 - const hydratedPosts = []; 421 - for (const quote of quotes.posts) { 422 - const post = this.$hydratedPosts.get(quote.uri).get(); 423 - if (!post) continue; 424 - hydratedPosts.push(attachParentAuthor(post, getHydratedPost)); 425 - } 426 - return { 427 - posts: hydratedPosts, 428 - cursor: quotes.cursor, 429 - }; 430 - }); 431 - this.$hydratedPinnedFeedGenerators = new Signal.Computed(() => { 432 - const pinned = this.dataStore.$pinnedFeedGenerators.get(); 433 - if (!pinned) return null; 434 - const hydrated = []; 435 - if (this.isAuthenticated) { 436 - hydrated.push({ uri: "following", displayName: "Following" }); 437 - } 438 - for (const pin of pinned) { 439 - hydrated.push(this.$feedGenerators.get(pin.uri).get()); 440 - } 441 - return hydrated; 442 - }); 443 - this.$hydratedProfiles = new ComputedMap((did) => 444 - this.patchStore.$patchedProfiles.get(did).get(), 445 - ); 446 - this.$hydratedAuthorFeeds = new ComputedMap((feedURI) => { 447 - const rawFeed = this.dataStore.$authorFeeds.get(feedURI).get(); 448 - if (!rawFeed) { 449 - return null; 450 - } 451 - const patches = 452 - this.patchStore.$authorFeedPatches.get(feedURI).get() || []; 453 - let feed = { feed: [...rawFeed.feed], cursor: rawFeed.cursor }; 454 - for (const patch of patches) { 455 - feed = this.patchStore.applyAuthorFeedPatch(feed, patch.body); 456 - } 457 - const hydratedFeedItems = []; 458 - for (const feedItem of feed.feed) { 459 - const hydratedFeedItem = { 460 - post: this.$hydratedPosts.get(feedItem.post.uri).get(), 461 - }; 462 - if (feedItem.reason) { 463 - hydratedFeedItem.reason = feedItem.reason; 464 - } 465 - if (feedItem.reply) { 466 - hydratedFeedItem.reply = { 467 - ...feedItem.reply, 468 - root: this.$hydratedPosts.get(feedItem.reply.root.uri).get(), 469 - parent: this.$hydratedPosts.get(feedItem.reply.parent.uri).get(), 470 - }; 471 - } 472 - hydratedFeedItems.push(hydratedFeedItem); 473 - } 474 - let hydratedFeed = { 475 - feed: hydratedFeedItems, 476 - cursor: feed.cursor, 477 - }; 478 - const dashIndex = feedURI.lastIndexOf("-"); 479 - const feedType = dashIndex >= 0 ? feedURI.slice(dashIndex + 1) : ""; 480 - if (feedType === "replies") { 481 - const filteredFeedItems = []; 482 - for (const feedItem of hydratedFeed.feed) { 483 - if (feedItem.reply && !feedItem.reason) { 484 - filteredFeedItems.push(feedItem); 485 - } 486 - } 487 - hydratedFeed = { 488 - feed: filteredFeedItems, 489 - cursor: hydratedFeed.cursor, 490 - }; 491 - } 492 - return filterAuthorFeed(hydratedFeed, this.isAuthenticated); 493 - }); 494 - this.$actorFeeds = new ComputedMap((did) => 495 - this.dataStore.$actorFeeds.get(did).get(), 496 - ); 497 - this.$profileChatStatus = new ComputedMap((did) => 498 - this.dataStore.$profileChatStatus.get(did).get(), 499 - ); 500 - this.$labelerInfo = new ComputedMap((did) => 501 - this.dataStore.$labelerInfo.get(did).get(), 502 - ); 503 - this.$hydratedBookmarks = new Signal.Computed(() => { 504 - const bookmarks = this.dataStore.$bookmarks.get(); 505 - if (!bookmarks) { 506 - return null; 507 - } 508 - const hydratedFeed = []; 509 - for (const bookmark of bookmarks.feed) { 510 - const post = this.$hydratedPosts.get(bookmark.post.uri).get(); 511 - hydratedFeed.push({ post: attachParentAuthor(post, getHydratedPost) }); 512 - } 513 - return filterBookmarksFeed({ 514 - feed: hydratedFeed, 515 - cursor: bookmarks.cursor, 516 - }); 517 - }); 518 - this.$labelerSettings = new ComputedMap((labelerDid) => { 519 - const preferences = this.$preferences.get(); 520 - if (!preferences) return null; 521 - return preferences.getLabelerSettings(labelerDid); 522 - }); 523 - this.$convos = new ComputedMap((convoId) => 524 - this.dataStore.$convos.get(convoId).get(), 525 - ); 526 - this.$convoList = new Signal.Computed(() => { 527 - const convoList = this.dataStore.$convoList.get(); 528 - if (!convoList) return null; 529 - const hydrated = convoList.map((convo) => 530 - this.$convos.get(convo.id).get(), 531 - ); 532 - return sortBy( 533 - hydrated, 534 - (convo) => new Date(getLastInteractionTimestamp(convo)), 535 - { direction: "desc" }, 536 - ); 537 - }); 538 - this.$convoListCursor = new Signal.Computed(() => 539 - this.dataStore.$convoListCursor.get(), 540 - ); 541 - this.$convoForProfile = new ComputedMap((profileDid) => { 542 - const convoIds = this.dataStore.$convos.$keys.get(); 543 - for (const convoId of convoIds) { 544 - const convo = this.dataStore.$convos.get(convoId).get(); 545 - if (!convo) continue; 546 - if ( 547 - convo.members.length === 2 && 548 - convo.members.some((member) => member.did === profileDid) 549 - ) { 550 - return this.$convos.get(convo.id).get(); 551 - } 552 - } 553 - return null; 554 - }); 555 - this.$convoMessages = new ComputedMap((convoId) => { 556 - const messages = this.dataStore.$convoMessages.get(convoId).get(); 557 - if (!messages) return null; 558 - return { 559 - messages: messages.messages.map((message) => 560 - this.patchStore.$patchedMessages.get(message.id).get(), 561 - ), 562 - cursor: messages.cursor, 563 - }; 564 - }); 565 - } 566 - }
+1 -1
src/js/plugins/pluginPreferencesManager.js
··· 1 - import { Signal } from "/js/utils.js"; 1 + import { Signal } from "/js/signals.js"; 2 2 3 3 // Handles persisting plugin settings in user preferences 4 4 export class PluginPreferencesManager {
+4 -3
src/js/plugins/pluginService.js
··· 20 20 showPluginInstallPermissionsModal, 21 21 showPluginUpdatePermissionsModal, 22 22 } from "/js/modals.js"; 23 - import { compareVersions, isDev, sortBy, SignalMap } from "/js/utils.js"; 23 + import { compareVersions, isDev, sortBy } from "/js/utils.js"; 24 + import { SignalMap } from "/js/signals.js"; 24 25 import { EventEmitter } from "/js/eventEmitter.js"; 25 26 import { PLUGIN_REGISTRY_URL } from "/js/config.js"; 26 27 ··· 265 266 }); 266 267 267 268 this.pluginBridge.addHostMethod("getPost", (plugin, { uri }) => { 268 - return this._dataLayer?.signals.$hydratedPosts.get(uri).get() ?? null; 269 + return this._dataLayer?.derived.$hydratedPosts.get(uri).get() ?? null; 269 270 }); 270 271 271 272 this.pluginBridge.addHostMethod("getProfile", (plugin, { did }) => { 272 - return this._dataLayer?.signals.$hydratedProfiles.get(did).get() ?? null; 273 + return this._dataLayer?.derived.$hydratedProfiles.get(did).get() ?? null; 273 274 }); 274 275 275 276 this.pluginBridge.addHostMethod("getCurrentUser", () => {
+2 -2
src/js/views/bookmarks.view.js
··· 37 37 notificationService?.$numNotifications.get() ?? null; 38 38 const numChatNotifications = 39 39 chatNotificationService?.$numNotifications.get() ?? null; 40 - const currentUser = dataLayer.signals.$currentUser.get(); 41 - const bookmarks = dataLayer.signals.$hydratedBookmarks.get(); 40 + const currentUser = dataLayer.derived.$currentUser.get(); 41 + const bookmarks = dataLayer.derived.$hydratedBookmarks.get(); 42 42 43 43 render( 44 44 html`<div id="bookmarks-view">
+3 -3
src/js/views/chat.view.js
··· 165 165 } 166 166 167 167 function renderPage() { 168 - const currentUser = dataLayer.signals.$currentUser.get(); 168 + const currentUser = dataLayer.derived.$currentUser.get(); 169 169 const numNotifications = 170 170 notificationService?.$numNotifications.get() ?? null; 171 171 const numChatNotifications = 172 172 chatNotificationService?.$numNotifications.get() ?? null; 173 - const convos = dataLayer.signals.$convoList.get(); 173 + const convos = dataLayer.derived.$convoList.get(); 174 174 const convosRequestStatus = dataLayer.requests.statusStore.$statuses 175 175 .get("loadConvoList") 176 176 .get(); 177 - const cursor = dataLayer.signals.$convoListCursor.get(); 177 + const cursor = dataLayer.derived.$convoListCursor.get(); 178 178 const hasMore = !!cursor; 179 179 180 180 render(
+6 -5
src/js/views/chatDetail.view.js
··· 11 11 import { postEmbedTemplate } from "/js/templates/postEmbed.template.js"; 12 12 import { CHAT_MESSAGES_PAGE_SIZE } from "/js/config.js"; 13 13 import { showToast } from "/js/toasts.js"; 14 - import { wait, raf, differenceInMinutes, Signal } from "/js/utils.js"; 14 + import { wait, raf, differenceInMinutes } from "/js/utils.js"; 15 + import { Signal } from "/js/signals.js"; 15 16 import { hapticsImpactMedium } from "/js/haptics.js"; 16 17 import "/js/components/infinite-scroll-container.js"; 17 18 import "/js/components/chat-input.js"; ··· 282 283 return ""; 283 284 } 284 285 285 - const currentUser = dataLayer.signals.$currentUser.get(); 286 + const currentUser = dataLayer.derived.$currentUser.get(); 286 287 287 288 // Group reactions by emoji 288 289 const reactionGroups = reactions.reduce((acc, reaction) => { ··· 562 563 } 563 564 564 565 function renderPage() { 565 - const currentUser = dataLayer.signals.$currentUser.get(); 566 + const currentUser = dataLayer.derived.$currentUser.get(); 566 567 const numNotifications = 567 568 notificationService?.$numNotifications.get() ?? null; 568 569 const numChatNotifications = 569 570 chatNotificationService?.$numNotifications.get() ?? 0; 570 - const messagesData = dataLayer.signals.$convoMessages.get(convoId).get(); 571 + const messagesData = dataLayer.derived.$convoMessages.get(convoId).get(); 571 572 const messages = messagesData?.messages ?? null; 572 573 const messagesRequestStatus = dataLayer.requests.statusStore.$statuses 573 574 .get("loadConvoMessages-" + convoId) ··· 576 577 const isSendingMessage = $isSendingMessage.get(); 577 578 578 579 // Get convo details to show other member info 579 - const convo = dataLayer.signals.$convos.get(convoId).get(); 580 + const convo = dataLayer.derived.$convos.get(convoId).get(); 580 581 const otherMember = getOtherMember(currentUser, convo); 581 582 const title = otherMember ? getDisplayName(otherMember) : ""; 582 583
+4 -4
src/js/views/chatRequests.view.js
··· 46 46 function requestItemTemplate({ convo }) { 47 47 const lastMessage = convo.lastMessage; 48 48 const members = convo.members.filter( 49 - (member) => member.did !== dataLayer.signals.$currentUser.get()?.did, 49 + (member) => member.did !== dataLayer.derived.$currentUser.get()?.did, 50 50 ); 51 51 const otherMember = members[0]; 52 52 const timeAgo = lastMessage ··· 160 160 } 161 161 162 162 function renderPage() { 163 - const currentUser = dataLayer.signals.$currentUser.get(); 163 + const currentUser = dataLayer.derived.$currentUser.get(); 164 164 const numNotifications = 165 165 notificationService?.$numNotifications.get() ?? null; 166 166 const numChatNotifications = 167 167 chatNotificationService?.$numNotifications.get() ?? null; 168 - const convos = dataLayer.signals.$convoList.get(); 168 + const convos = dataLayer.derived.$convoList.get(); 169 169 const convosRequestStatus = dataLayer.requests.statusStore.$statuses 170 170 .get("loadConvoList") 171 171 .get(); 172 - const cursor = dataLayer.signals.$convoListCursor.get(); 172 + const cursor = dataLayer.derived.$convoListCursor.get(); 173 173 const hasMore = !!cursor; 174 174 175 175 // Filter to only show chat requests
+5 -5
src/js/views/feedDetail.view.js
··· 47 47 48 48 pageEffect(root, () => { 49 49 const showLessInteractions = 50 - dataLayer.signals.$showLessInteractions.get() ?? []; 50 + dataLayer.derived.$showLessInteractions.get() ?? []; 51 51 const hiddenPostUris = showLessInteractions.map( 52 52 (interaction) => interaction.item, 53 53 ); ··· 55 55 notificationService?.$numNotifications.get() ?? null; 56 56 const numChatNotifications = 57 57 chatNotificationService?.$numNotifications.get() ?? null; 58 - const currentUser = dataLayer.signals.$currentUser.get(); 59 - const feedGenerator = dataLayer.signals.$feedGenerators 58 + const currentUser = dataLayer.derived.$currentUser.get(); 59 + const feedGenerator = dataLayer.derived.$feedGenerators 60 60 .get(feedUri) 61 61 .get(); 62 62 const feedName = feedGenerator?.displayName || ""; 63 63 const feedAuthor = feedGenerator?.creator; 64 64 const feedAuthorHandle = feedAuthor?.handle; 65 - const preferences = dataLayer.signals.$preferences.get(); 65 + const preferences = dataLayer.derived.$preferences.get(); 66 66 const isPinned = preferences?.isFeedPinned(feedUri) ?? false; 67 - const feed = dataLayer.signals.$hydratedFeeds.get(feedUri).get(); 67 + const feed = dataLayer.derived.$hydratedFeeds.get(feedUri).get(); 68 68 render( 69 69 html`<div id="feed-detail-view"> 70 70 ${mainLayoutTemplate({
+2 -2
src/js/views/feeds.view.js
··· 20 20 await auth.requireAuth(); 21 21 22 22 pageEffect(root, () => { 23 - const currentUser = dataLayer.signals.$currentUser.get(); 23 + const currentUser = dataLayer.derived.$currentUser.get(); 24 24 const numNotifications = 25 25 notificationService?.$numNotifications.get() ?? null; 26 26 const numChatNotifications = 27 27 chatNotificationService?.$numNotifications.get() ?? null; 28 28 const pinnedFeedGenerators = 29 - dataLayer.signals.$hydratedPinnedFeedGenerators.get(); 29 + dataLayer.derived.$hydratedPinnedFeedGenerators.get(); 30 30 31 31 render( 32 32 html`<div id="feeds-view">
+4 -4
src/js/views/hashtag.view.js
··· 7 7 import { tabBarTemplate } from "/js/templates/tabBar.template.js"; 8 8 import { HASHTAG_FEED_PAGE_SIZE } from "/js/config.js"; 9 9 import { pageEffect } from "/js/router.js"; 10 - import { Signal } from "/js/utils.js"; 10 + import { Signal } from "/js/signals.js"; 11 11 12 12 class HashtagView extends View { 13 13 async render({ ··· 64 64 } 65 65 // Load feed if not cached 66 66 const hashtagKey = `${hashtag}-${sortValue}`; 67 - const feed = dataLayer.signals.$hydratedHashtagFeeds 67 + const feed = dataLayer.derived.$hydratedHashtagFeeds 68 68 .get(hashtagKey) 69 69 .get(); 70 70 if (!feed) { ··· 77 77 notificationService?.$numNotifications.get() ?? null; 78 78 const numChatNotifications = 79 79 chatNotificationService?.$numNotifications.get() ?? null; 80 - const currentUser = dataLayer.signals.$currentUser.get(); 80 + const currentUser = dataLayer.derived.$currentUser.get(); 81 81 const currentSort = $currentSort.get(); 82 82 render( 83 83 html`<div id="hashtag-view"> ··· 103 103 }), 104 104 })} 105 105 ${sortOptions.map((sort) => { 106 - const feed = dataLayer.signals.$hydratedHashtagFeeds 106 + const feed = dataLayer.derived.$hydratedHashtagFeeds 107 107 .get(`${hashtag}-${sort.value}`) 108 108 .get(); 109 109 return html`<div
+5 -5
src/js/views/home.view.js
··· 9 9 import { FEED_PAGE_SIZE, DISCOVER_FEED_URI } from "/js/config.js"; 10 10 import { bindToPage, pageEffect } from "/js/router.js"; 11 11 import { showToast } from "/js/toasts.js"; 12 - import { Signal } from "/js/utils.js"; 12 + import { Signal } from "/js/signals.js"; 13 13 14 14 class HomeView extends View { 15 15 async render({ ··· 188 188 189 189 pageEffect(root, () => { 190 190 const showLessInteractions = 191 - dataLayer.signals.$showLessInteractions.get() ?? []; 191 + dataLayer.derived.$showLessInteractions.get() ?? []; 192 192 const hiddenPostUris = showLessInteractions.map( 193 193 (interaction) => interaction.item, 194 194 ); ··· 196 196 notificationService?.$numNotifications.get() ?? null; 197 197 const numChatNotifications = 198 198 chatNotificationService?.$numNotifications.get() ?? null; 199 - const currentUser = dataLayer.signals.$currentUser.get(); 199 + const currentUser = dataLayer.derived.$currentUser.get(); 200 200 const feedGenerators = 201 - dataLayer.signals.$hydratedPinnedFeedGenerators.get() ?? []; 201 + dataLayer.derived.$hydratedPinnedFeedGenerators.get() ?? []; 202 202 const currentFeedUri = state.$currentFeedUri.get(); 203 203 const feedGenerator = 204 204 feedGenerators.find((fg) => fg.uri === currentFeedUri) ?? null; ··· 241 241 const acceptsInteractions = 242 242 feedGenerator.acceptsInteractions || 243 243 feedGenerator.uri === DISCOVER_FEED_URI; 244 - const feed = dataLayer.signals.$hydratedFeeds 244 + const feed = dataLayer.derived.$hydratedFeeds 245 245 .get(feedGenerator.uri) 246 246 .get(); 247 247 const feedRequestStatus =
+6 -5
src/js/views/notifications.view.js
··· 6 6 import { mainLayoutTemplate } from "/js/templates/mainLayout.template.js"; 7 7 import { smallPostTemplate } from "/js/templates/smallPost.template.js"; 8 8 import { postSkeletonTemplate } from "/js/templates/postSkeleton.template.js"; 9 - import { displayRelativeTime, batch, Signal } from "/js/utils.js"; 9 + import { displayRelativeTime, batch } from "/js/utils.js"; 10 + import { Signal } from "/js/signals.js"; 10 11 import { pageEffect } from "/js/router.js"; 11 12 import { userIconTemplate } from "/js/templates/icons/userIcon.template.js"; 12 13 import { repostIconTemplate } from "/js/templates/icons/repostIcon.template.js"; ··· 675 676 window.scrollTo(0, 0); 676 677 if ( 677 678 tab === "mentions" && 678 - !dataLayer.signals.$mentionNotifications.get() 679 + !dataLayer.derived.$mentionNotifications.get() 679 680 ) { 680 681 await loadMentionNotifications({ reload: true }); 681 682 } ··· 683 684 684 685 pageEffect(root, () => { 685 686 const activeTab = $activeTab.get(); 686 - const currentUser = dataLayer.signals.$currentUser.get(); 687 + const currentUser = dataLayer.derived.$currentUser.get(); 687 688 const numNotifications = 688 689 notificationService?.$numNotifications.get() ?? null; 689 690 const numChatNotifications = 690 691 chatNotificationService?.$numNotifications.get() ?? null; 691 - const notifications = dataLayer.signals.$notifications.get(); 692 + const notifications = dataLayer.derived.$notifications.get(); 692 693 const notificationsRequestStatus = 693 694 dataLayer.requests.statusStore.$statuses.get("loadNotifications").get(); 694 695 const groupedNotifications = groupNotificationsByType(notifications); ··· 696 697 const hasMore = !!cursor; 697 698 698 699 const mentionNotifications = 699 - dataLayer.signals.$mentionNotifications.get(); 700 + dataLayer.derived.$mentionNotifications.get(); 700 701 const mentionNotificationsRequestStatus = 701 702 dataLayer.requests.statusStore.$statuses 702 703 .get("loadMentionNotifications")
+2 -2
src/js/views/postLikes.view.js
··· 40 40 } 41 41 42 42 pageEffect(root, () => { 43 - const currentUser = dataLayer.signals.$currentUser.get(); 43 + const currentUser = dataLayer.derived.$currentUser.get(); 44 44 const numNotifications = 45 45 notificationService?.$numNotifications.get() ?? null; 46 46 const numChatNotifications = 47 47 chatNotificationService?.$numNotifications.get() ?? null; 48 48 const postLikes = dataLayer.dataStore.$postLikes.get(postUri).get(); 49 - const post = dataLayer.signals.$hydratedPosts.get(postUri).get(); 49 + const post = dataLayer.derived.$hydratedPosts.get(postUri).get(); 50 50 const postLikesRequestStatus = dataLayer.requests.statusStore.$statuses 51 51 .get("loadPostLikes-" + postUri) 52 52 .get();
+3 -3
src/js/views/postQuotes.view.js
··· 43 43 } 44 44 45 45 pageEffect(root, () => { 46 - const currentUser = dataLayer.signals.$currentUser.get(); 46 + const currentUser = dataLayer.derived.$currentUser.get(); 47 47 const numNotifications = 48 48 notificationService?.$numNotifications.get() ?? null; 49 49 const numChatNotifications = 50 50 chatNotificationService?.$numNotifications.get() ?? null; 51 - const postQuotes = dataLayer.signals.$hydratedPostQuotes 51 + const postQuotes = dataLayer.derived.$hydratedPostQuotes 52 52 .get(postUri) 53 53 .get(); 54 - const post = dataLayer.signals.$hydratedPosts.get(postUri).get(); 54 + const post = dataLayer.derived.$hydratedPosts.get(postUri).get(); 55 55 const postQuotesRequestStatus = dataLayer.requests.statusStore.$statuses 56 56 .get("loadPostQuotes-" + postUri) 57 57 .get();
+2 -2
src/js/views/postReposts.view.js
··· 40 40 } 41 41 42 42 pageEffect(root, () => { 43 - const currentUser = dataLayer.signals.$currentUser.get(); 43 + const currentUser = dataLayer.derived.$currentUser.get(); 44 44 const numNotifications = 45 45 notificationService?.$numNotifications.get() ?? null; 46 46 const numChatNotifications = 47 47 chatNotificationService?.$numNotifications.get() ?? null; 48 48 const postReposts = dataLayer.dataStore.$postReposts.get(postUri).get(); 49 - const post = dataLayer.signals.$hydratedPosts.get(postUri).get(); 49 + const post = dataLayer.derived.$hydratedPosts.get(postUri).get(); 50 50 const postRepostsRequestStatus = dataLayer.requests.statusStore.$statuses 51 51 .get("loadPostReposts-" + postUri) 52 52 .get();
+4 -4
src/js/views/postThread.view.js
··· 190 190 const numReplies = replyChain.length; 191 191 return html`<div class="post-thread-reply-chain"> 192 192 ${replyChain.map((reply, i) => { 193 - const post = dataLayer.signals.$hydratedPosts 193 + const post = dataLayer.derived.$hydratedPosts 194 194 .get(reply.post.uri) 195 195 .get(); 196 196 if (!post) return ""; ··· 498 498 } 499 499 500 500 pageEffect(root, () => { 501 - let postThread = dataLayer.signals.$hydratedPostThreads 501 + let postThread = dataLayer.derived.$hydratedPostThreads 502 502 .get(postUri) 503 503 .get(); 504 504 if (!postThread) { 505 505 // Prefill with saved post if available 506 - const post = dataLayer.signals.$hydratedPosts.get(postUri).get(); 506 + const post = dataLayer.derived.$hydratedPosts.get(postUri).get(); 507 507 if (post) { 508 508 postThread = { 509 509 __isPrefill: true, ··· 513 513 }; 514 514 } 515 515 } 516 - const currentUser = dataLayer.signals.$currentUser.get(); 516 + const currentUser = dataLayer.derived.$currentUser.get(); 517 517 const numNotifications = 518 518 notificationService?.$numNotifications.get() ?? null; 519 519 const numChatNotifications =
+13 -12
src/js/views/profile.view.js
··· 1 1 import { html, render } from "/js/lib/lit-html.js"; 2 - import { wait, Signal } from "/js/utils.js"; 2 + import { wait } from "/js/utils.js"; 3 + import { Signal } from "/js/signals.js"; 3 4 import { 4 5 doHideAuthorOnUnauthenticated, 5 6 isLabelerProfile, ··· 143 144 } 144 145 // Load feed if needed 145 146 if (tab === "feeds") { 146 - if (!dataLayer.signals.$actorFeeds.get(profileDid).get()) { 147 + if (!dataLayer.derived.$actorFeeds.get(profileDid).get()) { 147 148 await loadActorFeeds(); 148 149 } 149 150 } else { ··· 258 259 } 259 260 const isBlocking = !!profile.viewer?.blocking; 260 261 const isBlockedBy = !!profile.viewer?.blockedBy; 261 - const profileChatStatus = dataLayer.signals.$profileChatStatus 262 + const profileChatStatus = dataLayer.derived.$profileChatStatus 262 263 .get(profile.did) 263 264 .get(); 264 265 const isCurrentUser = currentUser?.did === profile.did; ··· 282 283 let isSubscribed = false; 283 284 let labelerSettings = null; 284 285 if (isLabeler) { 285 - const preferences = dataLayer.signals.$preferences.get(); 286 + const preferences = dataLayer.derived.$preferences.get(); 286 287 isSubscribed = isDefaultLabeler 287 288 ? true 288 289 : preferences?.isSubscribedToLabeler(profile.did); 289 - labelerSettings = dataLayer.signals.$labelerSettings 290 + labelerSettings = dataLayer.derived.$labelerSettings 290 291 .get(profile.did) 291 292 .get(); 292 293 } ··· 392 393 : null} 393 394 ${authorFeedsToShow.map((feedInfo) => { 394 395 if (feedInfo.feedType === "feeds") { 395 - const actorFeeds = dataLayer.signals.$actorFeeds 396 + const actorFeeds = dataLayer.derived.$actorFeeds 396 397 .get(profileDid) 397 398 .get(); 398 399 return html`<div ··· 406 407 </div>`; 407 408 } 408 409 const feedURI = `${profileDid}-${feedInfo.feedType}`; 409 - const authorFeed = dataLayer.signals.$hydratedAuthorFeeds 410 + const authorFeed = dataLayer.derived.$hydratedAuthorFeeds 410 411 .get(feedURI) 411 412 .get(); 412 413 return html`<div ··· 438 439 } 439 440 440 441 pageEffect(root, () => { 441 - const profile = dataLayer.signals.$hydratedProfiles.get(profileDid).get(); 442 - const currentUser = dataLayer.signals.$currentUser.get(); 442 + const profile = dataLayer.derived.$hydratedProfiles.get(profileDid).get(); 443 + const currentUser = dataLayer.derived.$currentUser.get(); 443 444 const numNotifications = 444 445 notificationService?.$numNotifications.get() ?? null; 445 446 const numChatNotifications = ··· 449 450 .get(); 450 451 const isLabeler = profile && isLabelerProfile(profile); 451 452 const labelerInfo = isLabeler 452 - ? dataLayer.signals.$labelerInfo.get(profile.did).get() 453 + ? dataLayer.derived.$labelerInfo.get(profile.did).get() 453 454 : null; 454 455 // If labeler, require labeler info to be loaded 455 456 const isLoaded = profile && (isLabeler ? !!labelerInfo : true); ··· 546 547 547 548 // This is async because it needs to resolve mentions 548 549 async function loadProfileDescription() { 549 - const profile = dataLayer.signals.$hydratedProfiles.get(profileDid).get(); 550 + const profile = dataLayer.derived.$hydratedProfiles.get(profileDid).get(); 550 551 if (!profile?.description) { 551 552 return; 552 553 } ··· 587 588 // Load chat status 588 589 if ( 589 590 isAuthenticated && 590 - profile.did !== dataLayer.signals.$currentUser.get()?.did 591 + profile.did !== dataLayer.derived.$currentUser.get()?.did 591 592 ) { 592 593 dataLayer.requests.loadProfileChatStatus(profile.did); 593 594 }
+2 -2
src/js/views/profileFollowers.view.js
··· 41 41 } 42 42 43 43 pageEffect(root, () => { 44 - const currentUser = dataLayer.signals.$currentUser.get(); 44 + const currentUser = dataLayer.derived.$currentUser.get(); 45 45 const numNotifications = 46 46 notificationService?.$numNotifications.get() ?? null; 47 47 const numChatNotifications = ··· 49 49 const profileFollowers = dataLayer.dataStore.$profileFollowers 50 50 .get(profileDid) 51 51 .get(); 52 - const profile = dataLayer.signals.$hydratedProfiles.get(profileDid).get(); 52 + const profile = dataLayer.derived.$hydratedProfiles.get(profileDid).get(); 53 53 const profileFollowersRequestStatus = 54 54 dataLayer.requests.statusStore.$statuses 55 55 .get("loadProfileFollowers-" + profileDid)
+2 -2
src/js/views/profileFollowing.view.js
··· 41 41 } 42 42 43 43 pageEffect(root, () => { 44 - const currentUser = dataLayer.signals.$currentUser.get(); 44 + const currentUser = dataLayer.derived.$currentUser.get(); 45 45 const numNotifications = 46 46 notificationService?.$numNotifications.get() ?? null; 47 47 const numChatNotifications = ··· 49 49 const profileFollowing = dataLayer.dataStore.$profileFollows 50 50 .get(profileDid) 51 51 .get(); 52 - const profile = dataLayer.signals.$hydratedProfiles.get(profileDid).get(); 52 + const profile = dataLayer.derived.$hydratedProfiles.get(profileDid).get(); 53 53 const profileFollowingRequestStatus = 54 54 dataLayer.requests.statusStore.$statuses 55 55 .get("loadProfileFollows-" + profileDid)
+13 -12
src/js/views/search.view.js
··· 3 3 import { mainLayoutTemplate } from "/js/templates/mainLayout.template.js"; 4 4 import { searchIconTemplate } from "/js/templates/icons/searchIcon.template.js"; 5 5 import { headerTemplate } from "/js/templates/header.template.js"; 6 - import { classnames, debounce, Signal } from "/js/utils.js"; 6 + import { classnames, debounce } from "/js/utils.js"; 7 + import { Signal } from "/js/signals.js"; 7 8 import { linkToFeed } from "/js/navigation.js"; 8 9 import { smallPostTemplate } from "/js/templates/smallPost.template.js"; 9 10 import { pageEffect } from "/js/router.js"; ··· 72 73 } 73 74 74 75 async function loadMoreProfiles() { 75 - const cursor = dataLayer.signals.$profileSearchCursor.get(); 76 + const cursor = dataLayer.derived.$profileSearchCursor.get(); 76 77 if (!cursor) return; 77 78 await dataLayer.requests.loadProfileSearch($searchQuery.get().trim(), { 78 79 limit: 25, ··· 81 82 } 82 83 83 84 async function loadMorePosts() { 84 - const cursor = dataLayer.signals.$postSearchCursor.get(); 85 + const cursor = dataLayer.derived.$postSearchCursor.get(); 85 86 if (!cursor) return; 86 87 await dataLayer.requests.loadPostSearch($searchQuery.get().trim(), { 87 88 limit: 25, ··· 90 91 } 91 92 92 93 async function loadMoreFeeds() { 93 - const cursor = dataLayer.signals.$feedSearchCursor.get(); 94 + const cursor = dataLayer.derived.$feedSearchCursor.get(); 94 95 if (!cursor) return; 95 96 await dataLayer.requests.loadFeedSearch($searchQuery.get().trim(), { 96 97 limit: 15, ··· 309 310 } 310 311 311 312 pageEffect(root, () => { 312 - const currentUser = dataLayer.signals.$currentUser.get(); 313 + const currentUser = dataLayer.derived.$currentUser.get(); 313 314 const numNotifications = 314 315 notificationService?.$numNotifications.get() ?? null; 315 316 const numChatNotifications = ··· 327 328 const feedStatus = dataLayer.requests.statusStore.$statuses 328 329 .get("loadFeedSearch-" + normalizedQuery) 329 330 .get(); 330 - const postSearchResults = dataLayer.signals.$postSearchResults.get(); 331 + const postSearchResults = dataLayer.derived.$postSearchResults.get(); 331 332 const profileSearchResults = 332 - dataLayer.signals.$profileSearchResults.get(); 333 - const feedSearchResults = dataLayer.signals.$feedSearchResults.get(); 334 - const postSearchHasMore = !!dataLayer.signals.$postSearchCursor.get(); 333 + dataLayer.derived.$profileSearchResults.get(); 334 + const feedSearchResults = dataLayer.derived.$feedSearchResults.get(); 335 + const postSearchHasMore = !!dataLayer.derived.$postSearchCursor.get(); 335 336 const profileSearchHasMore = 336 - !!dataLayer.signals.$profileSearchCursor.get(); 337 - const feedSearchHasMore = !!dataLayer.signals.$feedSearchCursor.get(); 338 - const preferences = dataLayer.signals.$preferences.get(); 337 + !!dataLayer.derived.$profileSearchCursor.get(); 338 + const feedSearchHasMore = !!dataLayer.derived.$feedSearchCursor.get(); 339 + const preferences = dataLayer.derived.$preferences.get(); 339 340 340 341 render( 341 342 html`<div id="search-view">
+1 -1
src/js/views/settings.view.js
··· 73 73 ]; 74 74 75 75 pageEffect(root, () => { 76 - const currentUser = dataLayer.signals.$currentUser.get(); 76 + const currentUser = dataLayer.derived.$currentUser.get(); 77 77 const numNotifications = 78 78 notificationService?.$numNotifications.get() ?? null; 79 79 const numChatNotifications =
+2 -2
src/js/views/settings/advanced.view.js
··· 13 13 } from "/js/appViewConfig.js"; 14 14 import { alertIconTemplate } from "/js/templates/icons/alertIcon.template.js"; 15 15 import { showToast } from "/js/toasts.js"; 16 - import { Signal } from "/js/utils.js"; 16 + import { Signal } from "/js/signals.js"; 17 17 import { PermissionsDeclinedError } from "/js/plugins/pluginService.js"; 18 18 19 19 class SettingsAdvancedView extends View { ··· 130 130 131 131 pageEffect(root, () => { 132 132 $stateTick.get(); 133 - const currentUser = dataLayer.signals.$currentUser.get(); 133 + const currentUser = dataLayer.derived.$currentUser.get(); 134 134 const numNotifications = 135 135 notificationService?.$numNotifications.get() ?? null; 136 136 const numChatNotifications =
+2 -2
src/js/views/settings/appearance.view.js
··· 4 4 import { headerTemplate } from "/js/templates/header.template.js"; 5 5 import { auth } from "/js/auth.js"; 6 6 import { mainLayoutTemplate } from "/js/templates/mainLayout.template.js"; 7 - import { Signal } from "/js/utils.js"; 7 + import { Signal } from "/js/signals.js"; 8 8 import { 9 9 theme, 10 10 getDefaultHighlightColor, ··· 47 47 48 48 pageEffect(root, () => { 49 49 $themeTick.get(); 50 - const currentUser = dataLayer.signals.$currentUser.get(); 50 + const currentUser = dataLayer.derived.$currentUser.get(); 51 51 const numNotifications = 52 52 notificationService?.$numNotifications.get() ?? null; 53 53 const numChatNotifications =
+1 -1
src/js/views/settings/blockedAccounts.view.js
··· 35 35 } 36 36 37 37 pageEffect(root, () => { 38 - const currentUser = dataLayer.signals.$currentUser.get(); 38 + const currentUser = dataLayer.derived.$currentUser.get(); 39 39 const numNotifications = 40 40 notificationService?.$numNotifications.get() ?? null; 41 41 const numChatNotifications =
+2 -2
src/js/views/settings/communityPlugins.view.js
··· 6 6 import { auth } from "/js/auth.js"; 7 7 import { showToast } from "/js/toasts.js"; 8 8 import { confirm } from "/js/modals.js"; 9 - import { Signal } from "/js/utils.js"; 9 + import { Signal } from "/js/signals.js"; 10 10 import { PermissionsDeclinedError } from "/js/plugins/pluginService.js"; 11 11 12 12 class SettingsCommunityPluginsView extends View { ··· 89 89 90 90 pageEffect(root, () => { 91 91 $stateTick.get(); 92 - const currentUser = dataLayer.signals.$currentUser.get(); 92 + const currentUser = dataLayer.derived.$currentUser.get(); 93 93 const listings = pluginService.getRegistryListings(); 94 94 const numNotifications = 95 95 notificationService?.$numNotifications.get() ?? null;
+1 -1
src/js/views/settings/mutedAccounts.view.js
··· 35 35 } 36 36 37 37 pageEffect(root, () => { 38 - const currentUser = dataLayer.signals.$currentUser.get(); 38 + const currentUser = dataLayer.derived.$currentUser.get(); 39 39 const numNotifications = 40 40 notificationService?.$numNotifications.get() ?? null; 41 41 const numChatNotifications =
+4 -3
src/js/views/settings/mutedWords.view.js
··· 5 5 import { auth } from "/js/auth.js"; 6 6 import { mainLayoutTemplate } from "/js/templates/mainLayout.template.js"; 7 7 import { confirm } from "/js/modals.js"; 8 - import { differenceInHours, differenceInDays, Signal } from "/js/utils.js"; 8 + import { differenceInHours, differenceInDays } from "/js/utils.js"; 9 + import { Signal } from "/js/signals.js"; 9 10 import "/js/components/context-menu.js"; 10 11 import "/js/components/context-menu-item.js"; 11 12 import "/js/components/context-menu-label.js"; ··· 245 246 246 247 pageEffect(root, () => { 247 248 $stateTick.get(); 248 - const currentUser = dataLayer.signals.$currentUser.get(); 249 + const currentUser = dataLayer.derived.$currentUser.get(); 249 250 const numNotifications = 250 251 notificationService?.$numNotifications.get() ?? null; 251 252 const numChatNotifications = 252 253 chatNotificationService?.$numNotifications.get() ?? null; 253 - const preferences = dataLayer.signals.$preferences.get(); 254 + const preferences = dataLayer.derived.$preferences.get(); 254 255 const mutedWords = preferences 255 256 ? [...preferences.getMutedWords()].reverse() 256 257 : [];
+2 -2
src/js/views/settings/pluginDetail.view.js
··· 4 4 import { headerTemplate } from "/js/templates/header.template.js"; 5 5 import { mainLayoutTemplate } from "/js/templates/mainLayout.template.js"; 6 6 import { auth } from "/js/auth.js"; 7 - import { Signal } from "/js/utils.js"; 7 + import { Signal } from "/js/signals.js"; 8 8 9 9 class SettingsPluginDetailView extends View { 10 10 async render({ ··· 74 74 75 75 pageEffect(root, () => { 76 76 $stateTick.get(); 77 - const currentUser = dataLayer.signals.$currentUser.get(); 77 + const currentUser = dataLayer.derived.$currentUser.get(); 78 78 const numNotifications = 79 79 notificationService?.$numNotifications.get() ?? null; 80 80 const numChatNotifications =
+2 -2
src/js/views/settings/plugins.view.js
··· 11 11 import { reloadIconTemplate } from "/js/templates/icons/reloadIcon.template.js"; 12 12 import { confirm } from "/js/modals.js"; 13 13 import { showToast } from "/js/toasts.js"; 14 - import { Signal } from "/js/utils.js"; 14 + import { Signal } from "/js/signals.js"; 15 15 import { PermissionsDeclinedError } from "/js/plugins/pluginService.js"; 16 16 import "/js/components/toggle-switch.js"; 17 17 ··· 175 175 176 176 pageEffect(root, () => { 177 177 $stateTick.get(); 178 - const currentUser = dataLayer.signals.$currentUser.get(); 178 + const currentUser = dataLayer.derived.$currentUser.get(); 179 179 const numNotifications = 180 180 notificationService?.$numNotifications.get() ?? null; 181 181 const numChatNotifications =
+10 -7
tests/e2e/specs/concerns/notificationBadges.test.js
··· 111 111 await expect(page.locator("#home-view")).toBeVisible({ timeout: 10000 }); 112 112 113 113 // Verify badge shows before visiting notifications 114 - const badge = page.locator( 115 - '[data-testid="sidebar-nav-notifications"] [data-testid="status-badge"]', 116 - ); 117 - await expect(badge).toBeVisible({ timeout: 10000 }); 118 - await expect(badge).toContainText("2"); 114 + const visibleBadge = () => 115 + page.locator( 116 + '.page-visible [data-testid="sidebar-nav-notifications"] [data-testid="status-badge"]', 117 + ); 118 + await expect(visibleBadge()).toBeVisible({ timeout: 10000 }); 119 + await expect(visibleBadge()).toContainText("2"); 119 120 120 121 // Navigate to notifications — this calls updateSeen 121 122 const updateSeenPromise = page.waitForRequest((req) => 122 123 req.url().includes("app.bsky.notification.updateSeen"), 123 124 ); 124 - await page.locator('[data-testid="sidebar-nav-notifications"]').click(); 125 + await page 126 + .locator('.page-visible [data-testid="sidebar-nav-notifications"]') 127 + .click(); 125 128 126 129 await expect(page.locator("#notifications-view")).toBeVisible({ 127 130 timeout: 10000, ··· 129 132 await updateSeenPromise; 130 133 131 134 // Badge should disappear after notifications are marked as seen 132 - await expect(badge).toBeHidden({ timeout: 10000 }); 135 + await expect(visibleBadge()).toBeHidden({ timeout: 10000 }); 133 136 }); 134 137 135 138 test("should show chat unread badge and clear after reading messages", async ({
+2 -2
tests/unit/specs/components/plugin-posts-feed.test.js
··· 1 1 import { TestSuite } from "../../testSuite.js"; 2 2 import { assert, assertEquals } from "../../testHelpers.js"; 3 - import { Signal, SignalMap } from "/js/utils.js"; 3 + import { Signal, SignalMap } from "/js/signals.js"; 4 4 import "/js/components/plugin-posts-feed.js"; 5 5 6 6 const t = new TestSuite("PluginPostsFeed"); ··· 11 11 declarative: { 12 12 ensurePosts: ensurePosts ?? (() => new Promise(() => {})), 13 13 }, 14 - signals: { 14 + derived: { 15 15 $currentUser: new Signal.State(currentUser ?? null), 16 16 $hydratedPosts: postSignals, 17 17 },
+4 -4
tests/unit/specs/components/plugin-profiles-list.test.js
··· 1 1 import { TestSuite } from "../../testSuite.js"; 2 2 import { assert, assertEquals } from "../../testHelpers.js"; 3 - import { SignalMap } from "/js/utils.js"; 3 + import { SignalMap } from "/js/signals.js"; 4 4 import "/js/components/plugin-profiles-list.js"; 5 5 6 6 const t = new TestSuite("PluginProfilesList"); ··· 15 15 }; 16 16 return { 17 17 declarative, 18 - signals: { 18 + derived: { 19 19 $hydratedProfiles: profileSignals, 20 20 }, 21 21 __setProfile(did, profile) { ··· 154 154 dataLayer.__setProfile(did, makeProfile(did, did)), 155 155 ); 156 156 return dids.map((did) => 157 - dataLayer.signals.$hydratedProfiles.get(did).get(), 157 + dataLayer.derived.$hydratedProfiles.get(did).get(), 158 158 ); 159 159 }, 160 160 }); ··· 187 187 if (callIndex === 1) return firstPromise; 188 188 dids.forEach((did) => dataLayer.__setProfile(did, makeProfile(did, did))); 189 189 return Promise.resolve( 190 - dids.map((did) => dataLayer.signals.$hydratedProfiles.get(did).get()), 190 + dids.map((did) => dataLayer.derived.$hydratedProfiles.get(did).get()), 191 191 ); 192 192 }; 193 193 const element = document.createElement("plugin-profiles-list");
+1 -1
tests/unit/specs/components/plugin-slot.test.js
··· 1 1 import { TestSuite } from "../../testSuite.js"; 2 2 import { assert, assertEquals } from "../../testHelpers.js"; 3 - import { SignalMap } from "/js/utils.js"; 3 + import { SignalMap } from "/js/signals.js"; 4 4 import "/js/components/plugin-slot.js"; 5 5 6 6 const t = new TestSuite("PluginSlot");
+11 -11
tests/unit/specs/dataLayer/dataLayer.test.js
··· 30 30 assert(dataLayer.preferencesProvider !== undefined); 31 31 assert(dataLayer.requests !== undefined); 32 32 assert(dataLayer.mutations !== undefined); 33 - assert(dataLayer.signals !== undefined); 33 + assert(dataLayer.derived !== undefined); 34 34 assert(dataLayer.declarative !== undefined); 35 35 }); 36 36 ··· 146 146 }); 147 147 148 148 t.describe("component integration", (it) => { 149 - it("should pass dataStore to signals", async () => { 149 + it("should pass dataStore to derived", async () => { 150 150 const mockApi = createMockApi({ isAuthenticated: false }); 151 151 const dataLayer = createDataLayer(mockApi); 152 152 const postURI = "at://post/uri"; 153 153 const post = { uri: postURI, text: "test", likeCount: 5 }; 154 154 155 - // Initialize preferences first (required by signals) 155 + // Initialize preferences first (required by derived) 156 156 await dataLayer.initializePreferences(); 157 157 158 158 // Set data through dataStore 159 159 dataLayer.dataStore.$posts.set(postURI, post); 160 160 161 - // Verify signals can access it 162 - const result = dataLayer.signals.$hydratedPosts.get(postURI).get(); 161 + // Verify derived can access it 162 + const result = dataLayer.derived.$hydratedPosts.get(postURI).get(); 163 163 assertEquals(result.uri, postURI); 164 164 }); 165 165 166 - it("should pass patchStore to signals", async () => { 166 + it("should pass patchStore to derived", async () => { 167 167 const mockApi = createMockApi({ isAuthenticated: false }); 168 168 const dataLayer = createDataLayer(mockApi); 169 169 const postURI = "at://post/uri"; 170 170 const post = { uri: postURI, likeCount: 5, viewer: { like: null } }; 171 171 172 - // Initialize preferences first (required by signals) 172 + // Initialize preferences first (required by derived) 173 173 await dataLayer.initializePreferences(); 174 174 175 175 dataLayer.dataStore.$posts.set(postURI, post); 176 176 dataLayer.patchStore.addPostPatch(postURI, { type: "addLike" }); 177 177 178 - // Verify signals apply patches 179 - const result = dataLayer.signals.$hydratedPosts.get(postURI).get(); 178 + // Verify derived apply patches 179 + const result = dataLayer.derived.$hydratedPosts.get(postURI).get(); 180 180 assertEquals(result.likeCount, 6); 181 181 }); 182 182 183 - it("should pass signals and requests to declarative", async () => { 183 + it("should pass derived and requests to declarative", async () => { 184 184 const mockApi = createMockApi({ 185 185 isAuthenticated: false, 186 186 profiles: { ··· 192 192 // Initialize preferences first 193 193 await dataLayer.initializePreferences(); 194 194 195 - // Verify declarative can access signals 195 + // Verify declarative can access derived 196 196 const profile = await dataLayer.declarative.ensureProfile("did:test:user"); 197 197 assert(profile !== null); 198 198 });
+69 -69
tests/unit/specs/dataLayer/declarative.test.js
··· 7 7 const sig = (getter) => ({ get: getter }); 8 8 const mapSig = (getter) => ({ get: (key) => ({ get: () => getter(key) }) }); 9 9 10 - function createMockSignals(data = {}) { 10 + function createMockDerived(data = {}) { 11 11 return { 12 12 $currentUser: sig(() => data.currentUser ?? null), 13 13 $hydratedProfiles: mapSig((did) => data.profiles?.[did] ?? null), ··· 42 42 const currentUser = { did: "did:test:user", handle: "test.user" }; 43 43 let loadCalled = false; 44 44 45 - const signals = createMockSignals({ currentUser }); 45 + const derived = createMockDerived({ currentUser }); 46 46 const requests = { 47 47 loadCurrentUser: async () => { 48 48 loadCalled = true; 49 49 }, 50 50 }; 51 51 52 - const declarative = new Declarative(signals, requests); 52 + const declarative = new Declarative(derived, requests); 53 53 const result = await declarative.ensureCurrentUser(); 54 54 55 55 assertEquals(result, currentUser); ··· 60 60 const currentUser = { did: "did:test:user", handle: "test.user" }; 61 61 let callCount = 0; 62 62 63 - const signals = { 63 + const derived = { 64 64 $currentUser: sig(() => { 65 65 callCount++; 66 66 return callCount > 1 ? currentUser : null; ··· 70 70 loadCurrentUser: async () => {}, 71 71 }; 72 72 73 - const declarative = new Declarative(signals, requests); 73 + const declarative = new Declarative(derived, requests); 74 74 const result = await declarative.ensureCurrentUser(); 75 75 76 76 assertEquals(result, currentUser); ··· 78 78 }); 79 79 80 80 it("should throw when user not found after loading", async () => { 81 - const signals = createMockSignals({}); 81 + const derived = createMockDerived({}); 82 82 const requests = createMockRequests({}); 83 83 84 - const declarative = new Declarative(signals, requests); 84 + const declarative = new Declarative(derived, requests); 85 85 86 86 let error = null; 87 87 try { ··· 101 101 const profile = { did: profileDid, handle: "test.profile" }; 102 102 let loadCalled = false; 103 103 104 - const signals = createMockSignals({ profiles: { [profileDid]: profile } }); 104 + const derived = createMockDerived({ profiles: { [profileDid]: profile } }); 105 105 const requests = { 106 106 loadProfile: async () => { 107 107 loadCalled = true; 108 108 }, 109 109 }; 110 110 111 - const declarative = new Declarative(signals, requests); 111 + const declarative = new Declarative(derived, requests); 112 112 const result = await declarative.ensureProfile(profileDid); 113 113 114 114 assertEquals(result, profile); ··· 120 120 const profile = { did: profileDid, handle: "test.profile" }; 121 121 let callCount = 0; 122 122 123 - const signals = { 123 + const derived = { 124 124 $hydratedProfiles: mapSig(() => { 125 125 callCount++; 126 126 return callCount > 1 ? profile : null; ··· 130 130 loadProfile: async () => {}, 131 131 }; 132 132 133 - const declarative = new Declarative(signals, requests); 133 + const declarative = new Declarative(derived, requests); 134 134 const result = await declarative.ensureProfile(profileDid); 135 135 136 136 assertEquals(result, profile); 137 137 }); 138 138 139 139 it("should throw when profile not found after loading", async () => { 140 - const signals = createMockSignals({}); 140 + const derived = createMockDerived({}); 141 141 const requests = createMockRequests({}); 142 142 143 - const declarative = new Declarative(signals, requests); 143 + const declarative = new Declarative(derived, requests); 144 144 145 145 let error = null; 146 146 try { ··· 160 160 const profileB = { did: "did:test:b", handle: "b.test" }; 161 161 let loadCalled = false; 162 162 163 - const signals = createMockSignals({ 163 + const derived = createMockDerived({ 164 164 profiles: { [profileA.did]: profileA, [profileB.did]: profileB }, 165 165 }); 166 166 const requests = { ··· 169 169 }, 170 170 }; 171 171 172 - const declarative = new Declarative(signals, requests); 172 + const declarative = new Declarative(derived, requests); 173 173 const result = await declarative.ensureProfiles([ 174 174 profileB.did, 175 175 profileA.did, ··· 185 185 const store = { [profileA.did]: profileA }; 186 186 let loadedWith = null; 187 187 188 - const signals = { 188 + const derived = { 189 189 $hydratedProfiles: mapSig((did) => store[did] ?? null), 190 190 }; 191 191 const requests = { ··· 195 195 }, 196 196 }; 197 197 198 - const declarative = new Declarative(signals, requests); 198 + const declarative = new Declarative(derived, requests); 199 199 const result = await declarative.ensureProfiles([ 200 200 profileA.did, 201 201 profileB.did, ··· 206 206 }); 207 207 208 208 it("returns null entries for profiles still missing after load", async () => { 209 - const signals = { $hydratedProfiles: mapSig(() => null) }; 209 + const derived = { $hydratedProfiles: mapSig(() => null) }; 210 210 const requests = { loadProfiles: async () => {} }; 211 211 212 - const declarative = new Declarative(signals, requests); 212 + const declarative = new Declarative(derived, requests); 213 213 const result = await declarative.ensureProfiles(["did:test:missing"]); 214 214 215 215 assertEquals(result, [null]); ··· 222 222 const postThread = { post: { uri: postURI }, replies: [] }; 223 223 let loadCalled = false; 224 224 225 - const signals = createMockSignals({ 225 + const derived = createMockDerived({ 226 226 postThreads: { [postURI]: postThread }, 227 227 }); 228 228 const requests = { ··· 231 231 }, 232 232 }; 233 233 234 - const declarative = new Declarative(signals, requests); 234 + const declarative = new Declarative(derived, requests); 235 235 const result = await declarative.ensurePostThread(postURI); 236 236 237 237 assertEquals(result, postThread); ··· 243 243 const postThread = { post: { uri: postURI }, replies: [] }; 244 244 let callCount = 0; 245 245 246 - const signals = { 246 + const derived = { 247 247 $hydratedPostThreads: mapSig(() => { 248 248 callCount++; 249 249 return callCount > 1 ? postThread : null; ··· 253 253 loadPostThread: async () => {}, 254 254 }; 255 255 256 - const declarative = new Declarative(signals, requests); 256 + const declarative = new Declarative(derived, requests); 257 257 const result = await declarative.ensurePostThread(postURI); 258 258 259 259 assertEquals(result, postThread); ··· 265 265 let passedLabelers = null; 266 266 let callCount = 0; 267 267 268 - const signals = { 268 + const derived = { 269 269 $hydratedPostThreads: mapSig(() => { 270 270 callCount++; 271 271 return callCount > 1 ? postThread : null; ··· 277 277 }, 278 278 }; 279 279 280 - const declarative = new Declarative(signals, requests); 280 + const declarative = new Declarative(derived, requests); 281 281 await declarative.ensurePostThread(postURI, { labelers: ["labeler1"] }); 282 282 283 283 assertEquals(passedLabelers, ["labeler1"]); 284 284 }); 285 285 286 286 it("should throw when post thread not found after loading", async () => { 287 - const signals = createMockSignals({}); 287 + const derived = createMockDerived({}); 288 288 const requests = createMockRequests({}); 289 289 290 - const declarative = new Declarative(signals, requests); 290 + const declarative = new Declarative(derived, requests); 291 291 292 292 let error = null; 293 293 try { ··· 307 307 const post = { uri: postURI, text: "Hello" }; 308 308 let loadCalled = false; 309 309 310 - const signals = createMockSignals({ posts: { [postURI]: post } }); 310 + const derived = createMockDerived({ posts: { [postURI]: post } }); 311 311 const requests = { 312 312 loadPost: async () => { 313 313 loadCalled = true; 314 314 }, 315 315 }; 316 316 317 - const declarative = new Declarative(signals, requests); 317 + const declarative = new Declarative(derived, requests); 318 318 const result = await declarative.ensurePost(postURI); 319 319 320 320 assertEquals(result, post); ··· 326 326 const post = { uri: postURI, text: "Hello" }; 327 327 let callCount = 0; 328 328 329 - const signals = { 329 + const derived = { 330 330 $hydratedPosts: mapSig(() => { 331 331 callCount++; 332 332 return callCount > 1 ? post : null; ··· 336 336 loadPost: async () => {}, 337 337 }; 338 338 339 - const declarative = new Declarative(signals, requests); 339 + const declarative = new Declarative(derived, requests); 340 340 const result = await declarative.ensurePost(postURI); 341 341 342 342 assertEquals(result, post); 343 343 }); 344 344 345 345 it("should throw when post not found after loading", async () => { 346 - const signals = createMockSignals({}); 346 + const derived = createMockDerived({}); 347 347 const requests = createMockRequests({}); 348 348 349 - const declarative = new Declarative(signals, requests); 349 + const declarative = new Declarative(derived, requests); 350 350 351 351 let error = null; 352 352 try { ··· 366 366 const postB = { uri: "at://b", text: "B" }; 367 367 let loadCalled = false; 368 368 369 - const signals = createMockSignals({ 369 + const derived = createMockDerived({ 370 370 posts: { [postA.uri]: postA, [postB.uri]: postB }, 371 371 }); 372 372 const requests = { ··· 375 375 }, 376 376 }; 377 377 378 - const declarative = new Declarative(signals, requests); 378 + const declarative = new Declarative(derived, requests); 379 379 const result = await declarative.ensurePosts([postB.uri, postA.uri]); 380 380 381 381 assertEquals(result, [postB, postA]); ··· 388 388 const store = { [postA.uri]: postA }; 389 389 let loadedWith = null; 390 390 391 - const signals = { 391 + const derived = { 392 392 $hydratedPosts: mapSig((uri) => store[uri] ?? null), 393 393 }; 394 394 const requests = { ··· 398 398 }, 399 399 }; 400 400 401 - const declarative = new Declarative(signals, requests); 401 + const declarative = new Declarative(derived, requests); 402 402 const result = await declarative.ensurePosts([postA.uri, postB.uri]); 403 403 404 404 assertEquals(loadedWith, [postB.uri]); ··· 406 406 }); 407 407 408 408 it("returns null entries for posts still missing after load", async () => { 409 - const signals = { 409 + const derived = { 410 410 $hydratedPosts: mapSig(() => null), 411 411 }; 412 412 const requests = { loadPosts: async () => {} }; 413 413 414 - const declarative = new Declarative(signals, requests); 414 + const declarative = new Declarative(derived, requests); 415 415 const result = await declarative.ensurePosts(["at://missing"]); 416 416 417 417 assertEquals(result, [null]); ··· 424 424 const feedGenerator = { uri: feedUri, displayName: "Test Feed" }; 425 425 let loadCalled = false; 426 426 427 - const signals = createMockSignals({ 427 + const derived = createMockDerived({ 428 428 feedGenerators: { [feedUri]: feedGenerator }, 429 429 }); 430 430 const requests = { ··· 433 433 }, 434 434 }; 435 435 436 - const declarative = new Declarative(signals, requests); 436 + const declarative = new Declarative(derived, requests); 437 437 const result = await declarative.ensureFeedGenerator(feedUri); 438 438 439 439 assertEquals(result, feedGenerator); ··· 445 445 const feedGenerator = { uri: feedUri, displayName: "Test Feed" }; 446 446 let callCount = 0; 447 447 448 - const signals = { 448 + const derived = { 449 449 $feedGenerators: mapSig(() => { 450 450 callCount++; 451 451 return callCount > 1 ? feedGenerator : null; ··· 455 455 loadFeedGenerator: async () => {}, 456 456 }; 457 457 458 - const declarative = new Declarative(signals, requests); 458 + const declarative = new Declarative(derived, requests); 459 459 const result = await declarative.ensureFeedGenerator(feedUri); 460 460 461 461 assertEquals(result, feedGenerator); 462 462 }); 463 463 464 464 it("should throw when feed generator not found after loading", async () => { 465 - const signals = createMockSignals({}); 465 + const derived = createMockDerived({}); 466 466 const requests = createMockRequests({}); 467 467 468 - const declarative = new Declarative(signals, requests); 468 + const declarative = new Declarative(derived, requests); 469 469 470 470 let error = null; 471 471 try { ··· 484 484 const pinnedFeedGenerators = [{ uri: "feed1" }, { uri: "feed2" }]; 485 485 let loadCalled = false; 486 486 487 - const signals = createMockSignals({ pinnedFeedGenerators }); 487 + const derived = createMockDerived({ pinnedFeedGenerators }); 488 488 const requests = { 489 489 loadPinnedFeedGenerators: async () => { 490 490 loadCalled = true; 491 491 }, 492 492 }; 493 493 494 - const declarative = new Declarative(signals, requests); 494 + const declarative = new Declarative(derived, requests); 495 495 const result = await declarative.ensurePinnedFeedGenerators(); 496 496 497 497 assertEquals(result, pinnedFeedGenerators); ··· 502 502 const pinnedFeedGenerators = [{ uri: "feed1" }]; 503 503 let callCount = 0; 504 504 505 - const signals = { 505 + const derived = { 506 506 $hydratedPinnedFeedGenerators: sig(() => { 507 507 callCount++; 508 508 return callCount > 1 ? pinnedFeedGenerators : null; ··· 512 512 loadPinnedFeedGenerators: async () => {}, 513 513 }; 514 514 515 - const declarative = new Declarative(signals, requests); 515 + const declarative = new Declarative(derived, requests); 516 516 const result = await declarative.ensurePinnedFeedGenerators(); 517 517 518 518 assertEquals(result, pinnedFeedGenerators); 519 519 }); 520 520 521 521 it("should throw when pinned feed generators not found after loading", async () => { 522 - const signals = createMockSignals({}); 522 + const derived = createMockDerived({}); 523 523 const requests = createMockRequests({}); 524 524 525 - const declarative = new Declarative(signals, requests); 525 + const declarative = new Declarative(derived, requests); 526 526 527 527 let error = null; 528 528 try { ··· 541 541 const convoList = [{ id: "convo1" }, { id: "convo2" }]; 542 542 let loadCalled = false; 543 543 544 - const signals = createMockSignals({ convoList }); 544 + const derived = createMockDerived({ convoList }); 545 545 const requests = { 546 546 loadConvoList: async () => { 547 547 loadCalled = true; 548 548 }, 549 549 }; 550 550 551 - const declarative = new Declarative(signals, requests); 551 + const declarative = new Declarative(derived, requests); 552 552 const result = await declarative.ensureConvoList(); 553 553 554 554 assertEquals(result, convoList); ··· 559 559 const convoList = [{ id: "convo1" }]; 560 560 let callCount = 0; 561 561 562 - const signals = { 562 + const derived = { 563 563 $convoList: sig(() => { 564 564 callCount++; 565 565 return callCount > 1 ? convoList : null; ··· 569 569 loadConvoList: async () => {}, 570 570 }; 571 571 572 - const declarative = new Declarative(signals, requests); 572 + const declarative = new Declarative(derived, requests); 573 573 const result = await declarative.ensureConvoList(); 574 574 575 575 assertEquals(result, convoList); 576 576 }); 577 577 578 578 it("should throw when convo list not found after loading", async () => { 579 - const signals = createMockSignals({}); 579 + const derived = createMockDerived({}); 580 580 const requests = createMockRequests({}); 581 581 582 - const declarative = new Declarative(signals, requests); 582 + const declarative = new Declarative(derived, requests); 583 583 584 584 let error = null; 585 585 try { ··· 599 599 const convo = { id: convoId, messages: [] }; 600 600 let loadCalled = false; 601 601 602 - const signals = createMockSignals({ convos: { [convoId]: convo } }); 602 + const derived = createMockDerived({ convos: { [convoId]: convo } }); 603 603 const requests = { 604 604 loadConvo: async () => { 605 605 loadCalled = true; 606 606 }, 607 607 }; 608 608 609 - const declarative = new Declarative(signals, requests); 609 + const declarative = new Declarative(derived, requests); 610 610 const result = await declarative.ensureConvo(convoId); 611 611 612 612 assertEquals(result, convo); ··· 618 618 const convo = { id: convoId, messages: [] }; 619 619 let callCount = 0; 620 620 621 - const signals = { 621 + const derived = { 622 622 $convos: mapSig(() => { 623 623 callCount++; 624 624 return callCount > 1 ? convo : null; ··· 628 628 loadConvo: async () => {}, 629 629 }; 630 630 631 - const declarative = new Declarative(signals, requests); 631 + const declarative = new Declarative(derived, requests); 632 632 const result = await declarative.ensureConvo(convoId); 633 633 634 634 assertEquals(result, convo); 635 635 }); 636 636 637 637 it("should throw when convo not found after loading", async () => { 638 - const signals = createMockSignals({}); 638 + const derived = createMockDerived({}); 639 639 const requests = createMockRequests({}); 640 640 641 - const declarative = new Declarative(signals, requests); 641 + const declarative = new Declarative(derived, requests); 642 642 643 643 let error = null; 644 644 try { ··· 658 658 const convo = { id: "convo123", members: [profileDid] }; 659 659 let loadCalled = false; 660 660 661 - const signals = createMockSignals({ 661 + const derived = createMockDerived({ 662 662 convoForProfile: { [profileDid]: convo }, 663 663 }); 664 664 const requests = { ··· 667 667 }, 668 668 }; 669 669 670 - const declarative = new Declarative(signals, requests); 670 + const declarative = new Declarative(derived, requests); 671 671 const result = await declarative.ensureConvoForProfile(profileDid); 672 672 673 673 assertEquals(result, convo); ··· 679 679 const convo = { id: "convo123", members: [profileDid] }; 680 680 let callCount = 0; 681 681 682 - const signals = { 682 + const derived = { 683 683 $convoForProfile: mapSig(() => { 684 684 callCount++; 685 685 return callCount > 1 ? convo : null; ··· 689 689 loadConvoForProfile: async () => {}, 690 690 }; 691 691 692 - const declarative = new Declarative(signals, requests); 692 + const declarative = new Declarative(derived, requests); 693 693 const result = await declarative.ensureConvoForProfile(profileDid); 694 694 695 695 assertEquals(result, convo); 696 696 }); 697 697 698 698 it("should throw when convo for profile not found after loading", async () => { 699 - const signals = createMockSignals({}); 699 + const derived = createMockDerived({}); 700 700 const requests = createMockRequests({}); 701 701 702 - const declarative = new Declarative(signals, requests); 702 + const declarative = new Declarative(derived, requests); 703 703 704 704 let error = null; 705 705 try {
+527
tests/unit/specs/dataLayer/derived.test.js
··· 1 + import { TestSuite } from "../../testSuite.js"; 2 + import { assertEquals } from "../../testHelpers.js"; 3 + import { Derived } from "/js/dataLayer/derived.js"; 4 + import { DataStore } from "/js/dataLayer/dataStore.js"; 5 + import { PatchStore } from "/js/dataLayer/patchStore.js"; 6 + import { Preferences } from "/js/preferences.js"; 7 + import { Signal, SignalMap } from "/js/signals.js"; 8 + 9 + function makeDerived(dataStore, { preferences } = {}) { 10 + const patchStore = new PatchStore(dataStore); 11 + const prefs = preferences ?? Preferences.createLoggedOutPreferences(); 12 + const preferencesProvider = { 13 + requirePreferences: () => prefs, 14 + $preferences: new Signal.State(prefs), 15 + }; 16 + const pluginService = { 17 + $pluginFilteredFeedItems: new SignalMap(), 18 + }; 19 + const derived = new Derived( 20 + dataStore, 21 + patchStore, 22 + preferencesProvider, 23 + pluginService, 24 + false, 25 + ); 26 + return { derived, patchStore }; 27 + } 28 + 29 + function fakePreferences(overrides = {}) { 30 + return { 31 + postHasMutedWord: () => false, 32 + quotedPostHasMutedWord: () => false, 33 + isPostHidden: () => false, 34 + getBadgeLabels: () => [], 35 + getContentLabel: () => null, 36 + getMediaLabel: () => null, 37 + clone() { 38 + return this; 39 + }, 40 + ...overrides, 41 + }; 42 + } 43 + 44 + const t = new TestSuite("Derived"); 45 + 46 + t.describe("$hydratedFeeds", (it) => { 47 + const feedURI = "at://did:test/app.bsky.feed.generator/test"; 48 + 49 + it("should return null when feed does not exist", () => { 50 + const dataStore = new DataStore(); 51 + const { derived } = makeDerived(dataStore); 52 + assertEquals(derived.$hydratedFeeds.get(feedURI).get(), null); 53 + }); 54 + 55 + it("should hydrate and return a feed with posts", () => { 56 + const dataStore = new DataStore(); 57 + const { derived } = makeDerived(dataStore); 58 + 59 + const rawFeed = { 60 + feed: [{ post: { uri: "post1" } }, { post: { uri: "post2" } }], 61 + cursor: "cursor123", 62 + }; 63 + const post1 = { uri: "post1", content: "First post", likeCount: 5 }; 64 + const post2 = { uri: "post2", content: "Second post", likeCount: 10 }; 65 + 66 + dataStore.$posts.set("post1", post1); 67 + dataStore.$posts.set("post2", post2); 68 + dataStore.$feeds.set(feedURI, rawFeed); 69 + 70 + const result = derived.$hydratedFeeds.get(feedURI).get(); 71 + assertEquals(result, { 72 + feed: [{ post: post1 }, { post: post2 }], 73 + cursor: "cursor123", 74 + }); 75 + }); 76 + 77 + it("should apply patches to posts in feed", () => { 78 + const dataStore = new DataStore(); 79 + const { derived, patchStore } = makeDerived(dataStore); 80 + 81 + const rawFeed = { 82 + feed: [{ post: { uri: "post1" } }], 83 + cursor: "cursor123", 84 + }; 85 + const post1 = { 86 + uri: "post1", 87 + content: "Test post", 88 + likeCount: 5, 89 + viewer: { like: null }, 90 + }; 91 + 92 + dataStore.$posts.set("post1", post1); 93 + dataStore.$feeds.set(feedURI, rawFeed); 94 + patchStore.addPostPatch("post1", { type: "addLike" }); 95 + 96 + const result = derived.$hydratedFeeds.get(feedURI).get(); 97 + assertEquals(result.feed[0].post.likeCount, 6); 98 + assertEquals(result.feed[0].post.viewer.like, "fake like"); 99 + }); 100 + }); 101 + 102 + t.describe("$hydratedHashtagFeeds", (it) => { 103 + const hashtagKey = "javascript-top"; 104 + 105 + it("should return null when feed does not exist", () => { 106 + const dataStore = new DataStore(); 107 + const { derived } = makeDerived(dataStore); 108 + assertEquals(derived.$hydratedHashtagFeeds.get(hashtagKey).get(), null); 109 + }); 110 + 111 + it("should hydrate and return a feed with posts", () => { 112 + const dataStore = new DataStore(); 113 + const { derived } = makeDerived(dataStore); 114 + 115 + const rawFeed = { 116 + feed: [{ post: { uri: "post1" } }, { post: { uri: "post2" } }], 117 + cursor: "cursor123", 118 + }; 119 + const post1 = { uri: "post1", content: "First post", likeCount: 5 }; 120 + const post2 = { uri: "post2", content: "Second post", likeCount: 10 }; 121 + 122 + dataStore.$posts.set("post1", post1); 123 + dataStore.$posts.set("post2", post2); 124 + dataStore.$hashtagFeeds.set(hashtagKey, rawFeed); 125 + 126 + const result = derived.$hydratedHashtagFeeds.get(hashtagKey).get(); 127 + assertEquals(result, { 128 + feed: [{ post: post1 }, { post: post2 }], 129 + cursor: "cursor123", 130 + }); 131 + }); 132 + 133 + it("should attach parentAuthor when post is a reply and parent is loaded", () => { 134 + const dataStore = new DataStore(); 135 + const { derived } = makeDerived(dataStore); 136 + 137 + const parentAuthor = { did: "did:parent", handle: "parent.test" }; 138 + const parent = { 139 + uri: "post-parent", 140 + author: parentAuthor, 141 + }; 142 + const reply = { 143 + uri: "post-reply", 144 + record: { 145 + text: "reply text", 146 + reply: { parent: { uri: "post-parent" }, root: { uri: "post-parent" } }, 147 + }, 148 + }; 149 + const rawFeed = { 150 + feed: [{ post: { uri: "post-reply" } }], 151 + cursor: "c", 152 + }; 153 + 154 + dataStore.$posts.set("post-parent", parent); 155 + dataStore.$posts.set("post-reply", reply); 156 + dataStore.$hashtagFeeds.set(hashtagKey, rawFeed); 157 + 158 + const result = derived.$hydratedHashtagFeeds.get(hashtagKey).get(); 159 + assertEquals(result.feed[0].post.record.reply.parentAuthor, parentAuthor); 160 + }); 161 + 162 + it("should apply patches to posts in feed", () => { 163 + const dataStore = new DataStore(); 164 + const { derived, patchStore } = makeDerived(dataStore); 165 + 166 + const rawFeed = { 167 + feed: [{ post: { uri: "post1" } }], 168 + cursor: "c", 169 + }; 170 + const post1 = { 171 + uri: "post1", 172 + likeCount: 5, 173 + viewer: { like: null }, 174 + }; 175 + 176 + dataStore.$posts.set("post1", post1); 177 + dataStore.$hashtagFeeds.set(hashtagKey, rawFeed); 178 + patchStore.addPostPatch("post1", { type: "addLike" }); 179 + 180 + const result = derived.$hydratedHashtagFeeds.get(hashtagKey).get(); 181 + assertEquals(result.feed[0].post.likeCount, 6); 182 + assertEquals(result.feed[0].post.viewer.like, "fake like"); 183 + }); 184 + }); 185 + 186 + t.describe("$hydratedProfiles", (it) => { 187 + const did = "did:plc:user"; 188 + 189 + it("should return null when profile does not exist", () => { 190 + const dataStore = new DataStore(); 191 + const { derived } = makeDerived(dataStore); 192 + assertEquals(derived.$hydratedProfiles.get(did).get(), null); 193 + }); 194 + 195 + it("should return the profile when it exists", () => { 196 + const dataStore = new DataStore(); 197 + const { derived } = makeDerived(dataStore); 198 + const profile = { did, handle: "user.test", followersCount: 10 }; 199 + dataStore.$profiles.set(did, profile); 200 + const result = derived.$hydratedProfiles.get(did).get(); 201 + assertEquals(result.did, did); 202 + assertEquals(result.handle, "user.test"); 203 + assertEquals(result.followersCount, 10); 204 + }); 205 + 206 + it("should apply profile patches", () => { 207 + const dataStore = new DataStore(); 208 + const { derived, patchStore } = makeDerived(dataStore); 209 + const profile = { 210 + did, 211 + handle: "user.test", 212 + followersCount: 10, 213 + viewer: { following: null }, 214 + }; 215 + dataStore.$profiles.set(did, profile); 216 + patchStore.addProfilePatch(did, { type: "followProfile" }); 217 + const result = derived.$hydratedProfiles.get(did).get(); 218 + assertEquals(result.followersCount, 11); 219 + assertEquals(result.viewer.following, "fake following"); 220 + }); 221 + }); 222 + 223 + t.describe("$hydratedAuthorFeeds", (it) => { 224 + const did = "did:plc:author"; 225 + const feedURI = `${did}-posts`; 226 + 227 + it("should return null when author feed does not exist", () => { 228 + const dataStore = new DataStore(); 229 + const { derived } = makeDerived(dataStore); 230 + assertEquals(derived.$hydratedAuthorFeeds.get(feedURI).get(), null); 231 + }); 232 + 233 + it("should hydrate and return an author feed", () => { 234 + const dataStore = new DataStore(); 235 + const { derived } = makeDerived(dataStore); 236 + const post1 = { uri: "post1", likeCount: 1 }; 237 + const post2 = { uri: "post2", likeCount: 2 }; 238 + dataStore.$posts.set("post1", post1); 239 + dataStore.$posts.set("post2", post2); 240 + dataStore.$authorFeeds.set(feedURI, { 241 + feed: [{ post: { uri: "post1" } }, { post: { uri: "post2" } }], 242 + cursor: "c", 243 + }); 244 + const result = derived.$hydratedAuthorFeeds.get(feedURI).get(); 245 + assertEquals(result.feed.length, 2); 246 + assertEquals(result.feed[0].post.uri, "post1"); 247 + assertEquals(result.feed[1].post.uri, "post2"); 248 + assertEquals(result.cursor, "c"); 249 + }); 250 + 251 + it("should filter to replies-only for replies feed type", () => { 252 + const dataStore = new DataStore(); 253 + const { derived } = makeDerived(dataStore); 254 + const repliesFeedURI = `${did}-replies`; 255 + const post1 = { uri: "post1" }; 256 + const post2 = { uri: "post2" }; 257 + dataStore.$posts.set("post1", post1); 258 + dataStore.$posts.set("post2", post2); 259 + dataStore.$posts.set("parent", { uri: "parent" }); 260 + dataStore.$authorFeeds.set(repliesFeedURI, { 261 + feed: [ 262 + { post: { uri: "post1" } }, // top-level, should be filtered out 263 + { 264 + post: { uri: "post2" }, 265 + reply: { root: { uri: "parent" }, parent: { uri: "parent" } }, 266 + }, 267 + ], 268 + cursor: "c", 269 + }); 270 + const result = derived.$hydratedAuthorFeeds.get(repliesFeedURI).get(); 271 + assertEquals(result.feed.length, 1); 272 + assertEquals(result.feed[0].post.uri, "post2"); 273 + }); 274 + 275 + it("should apply author feed patches", () => { 276 + const dataStore = new DataStore(); 277 + const { derived, patchStore } = makeDerived(dataStore); 278 + const pinnedPost = { uri: "pinned", likeCount: 0 }; 279 + const otherPost = { uri: "other", likeCount: 0 }; 280 + dataStore.$posts.set("pinned", pinnedPost); 281 + dataStore.$posts.set("other", otherPost); 282 + dataStore.$authorFeeds.set(feedURI, { 283 + feed: [{ post: { uri: "other" } }, { post: { uri: "pinned" } }], 284 + cursor: null, 285 + }); 286 + patchStore.addAuthorFeedPatch(feedURI, { 287 + type: "pinPost", 288 + post: { uri: "pinned" }, 289 + }); 290 + const result = derived.$hydratedAuthorFeeds.get(feedURI).get(); 291 + assertEquals(result.feed[0].post.uri, "pinned"); 292 + }); 293 + }); 294 + 295 + t.describe("$actorFeeds", (it) => { 296 + const did = "did:plc:author"; 297 + 298 + it("should return null when actor feeds do not exist", () => { 299 + const dataStore = new DataStore(); 300 + const { derived } = makeDerived(dataStore); 301 + assertEquals(derived.$actorFeeds.get(did).get(), null); 302 + }); 303 + 304 + it("should return the stored actor feeds", () => { 305 + const dataStore = new DataStore(); 306 + const { derived } = makeDerived(dataStore); 307 + const actorFeeds = { feeds: [{ uri: "feed-1" }], cursor: "c" }; 308 + dataStore.$actorFeeds.set(did, actorFeeds); 309 + assertEquals(derived.$actorFeeds.get(did).get(), actorFeeds); 310 + }); 311 + }); 312 + 313 + t.describe("$profileChatStatus", (it) => { 314 + const did = "did:plc:user"; 315 + 316 + it("should return null when chat status does not exist", () => { 317 + const dataStore = new DataStore(); 318 + const { derived } = makeDerived(dataStore); 319 + assertEquals(derived.$profileChatStatus.get(did).get(), null); 320 + }); 321 + 322 + it("should return the stored chat status", () => { 323 + const dataStore = new DataStore(); 324 + const { derived } = makeDerived(dataStore); 325 + const status = { canChat: true, convo: null }; 326 + dataStore.$profileChatStatus.set(did, status); 327 + assertEquals(derived.$profileChatStatus.get(did).get(), status); 328 + }); 329 + }); 330 + 331 + t.describe("$labelerInfo", (it) => { 332 + const did = "did:plc:labeler"; 333 + 334 + it("should return null when labeler info does not exist", () => { 335 + const dataStore = new DataStore(); 336 + const { derived } = makeDerived(dataStore); 337 + assertEquals(derived.$labelerInfo.get(did).get(), null); 338 + }); 339 + 340 + it("should return the stored labeler info", () => { 341 + const dataStore = new DataStore(); 342 + const { derived } = makeDerived(dataStore); 343 + const info = { policies: { labelValues: ["spam"] } }; 344 + dataStore.$labelerInfo.set(did, info); 345 + assertEquals(derived.$labelerInfo.get(did).get(), info); 346 + }); 347 + }); 348 + 349 + t.describe("$labelerSettings", (it) => { 350 + it("should return labeler settings from preferences", () => { 351 + const dataStore = new DataStore(); 352 + const { derived } = makeDerived(dataStore); 353 + const labelerDid = "did:plc:labeler"; 354 + const result = derived.$labelerSettings.get(labelerDid).get(); 355 + // Logged-out preferences should still return a settings object 356 + assertEquals(typeof result, "object"); 357 + }); 358 + }); 359 + 360 + t.describe("$hydratedBookmarks", (it) => { 361 + it("should return null when bookmarks do not exist", () => { 362 + const dataStore = new DataStore(); 363 + const { derived } = makeDerived(dataStore); 364 + assertEquals(derived.$hydratedBookmarks.get(), null); 365 + }); 366 + 367 + it("should hydrate and return bookmarks", () => { 368 + const dataStore = new DataStore(); 369 + const { derived } = makeDerived(dataStore); 370 + const post1 = { uri: "post1", likeCount: 5 }; 371 + const post2 = { uri: "post2", likeCount: 10 }; 372 + dataStore.$posts.set("post1", post1); 373 + dataStore.$posts.set("post2", post2); 374 + dataStore.$bookmarks.set({ 375 + feed: [{ post: { uri: "post1" } }, { post: { uri: "post2" } }], 376 + cursor: "c", 377 + }); 378 + const result = derived.$hydratedBookmarks.get(); 379 + assertEquals(result.feed.length, 2); 380 + assertEquals(result.feed[0].post.uri, "post1"); 381 + assertEquals(result.feed[1].post.uri, "post2"); 382 + assertEquals(result.cursor, "c"); 383 + }); 384 + 385 + it("should attach parentAuthor when bookmarked post is a reply", () => { 386 + const dataStore = new DataStore(); 387 + const { derived } = makeDerived(dataStore); 388 + const parentAuthor = { did: "did:parent", handle: "parent.test" }; 389 + dataStore.$posts.set("post-parent", { 390 + uri: "post-parent", 391 + author: parentAuthor, 392 + }); 393 + dataStore.$posts.set("post-reply", { 394 + uri: "post-reply", 395 + record: { 396 + text: "reply", 397 + reply: { parent: { uri: "post-parent" }, root: { uri: "post-parent" } }, 398 + }, 399 + }); 400 + dataStore.$bookmarks.set({ 401 + feed: [{ post: { uri: "post-reply" } }], 402 + cursor: null, 403 + }); 404 + const result = derived.$hydratedBookmarks.get(); 405 + assertEquals(result.feed[0].post.record.reply.parentAuthor, parentAuthor); 406 + }); 407 + }); 408 + 409 + t.describe("$hydratedPinnedFeedGenerators", (it) => { 410 + it("should return null when pinned feed generators are not set", () => { 411 + const dataStore = new DataStore(); 412 + const { derived } = makeDerived(dataStore); 413 + assertEquals(derived.$hydratedPinnedFeedGenerators.get(), null); 414 + }); 415 + 416 + it("should hydrate pinned feed generators from the store", () => { 417 + const dataStore = new DataStore(); 418 + const { derived } = makeDerived(dataStore); 419 + const fg1 = { uri: "feed-1", displayName: "Feed One" }; 420 + const fg2 = { uri: "feed-2", displayName: "Feed Two" }; 421 + dataStore.$feedGenerators.set("feed-1", fg1); 422 + dataStore.$feedGenerators.set("feed-2", fg2); 423 + dataStore.$pinnedFeedGenerators.set([{ uri: "feed-1" }, { uri: "feed-2" }]); 424 + const result = derived.$hydratedPinnedFeedGenerators.get(); 425 + // isAuthenticated is false in test setup, so no "Following" entry 426 + assertEquals(result, [fg1, fg2]); 427 + }); 428 + }); 429 + 430 + t.describe("$hydratedPosts (post hydration)", (it) => { 431 + const postURI = "at://did:test/app.bsky.feed.post/x"; 432 + 433 + it("should return null when the post does not exist", () => { 434 + const dataStore = new DataStore(); 435 + const { derived } = makeDerived(dataStore); 436 + assertEquals(derived.$hydratedPosts.get(postURI).get(), null); 437 + }); 438 + 439 + it("should mark the post when it contains a muted word", () => { 440 + const dataStore = new DataStore(); 441 + const { derived } = makeDerived(dataStore, { 442 + preferences: fakePreferences({ postHasMutedWord: () => true }), 443 + }); 444 + dataStore.$posts.set(postURI, { uri: postURI, record: { text: "hello" } }); 445 + const result = derived.$hydratedPosts.get(postURI).get(); 446 + assertEquals(result.viewer.hasMutedWord, true); 447 + }); 448 + 449 + it("should not mark the post when there is no muted word match", () => { 450 + const dataStore = new DataStore(); 451 + const { derived } = makeDerived(dataStore, { 452 + preferences: fakePreferences(), 453 + }); 454 + dataStore.$posts.set(postURI, { uri: postURI, record: { text: "hello" } }); 455 + const result = derived.$hydratedPosts.get(postURI).get(); 456 + assertEquals(result.viewer, undefined); 457 + }); 458 + 459 + it("should mark the post hidden when preferences say so", () => { 460 + const dataStore = new DataStore(); 461 + const { derived } = makeDerived(dataStore, { 462 + preferences: fakePreferences({ isPostHidden: () => true }), 463 + }); 464 + dataStore.$posts.set(postURI, { uri: postURI, record: { text: "hello" } }); 465 + const result = derived.$hydratedPosts.get(postURI).get(); 466 + assertEquals(result.viewer.isHidden, true); 467 + }); 468 + 469 + it("should attach badge, content, and media labels from preferences", () => { 470 + const dataStore = new DataStore(); 471 + const { derived } = makeDerived(dataStore, { 472 + preferences: fakePreferences({ 473 + getBadgeLabels: () => ["badge"], 474 + getContentLabel: () => "warn", 475 + getMediaLabel: () => "blur", 476 + }), 477 + }); 478 + dataStore.$posts.set(postURI, { uri: postURI, record: { text: "hello" } }); 479 + const result = derived.$hydratedPosts.get(postURI).get(); 480 + assertEquals(result.badgeLabels, ["badge"]); 481 + assertEquals(result.contentLabel, "warn"); 482 + assertEquals(result.mediaLabel, "blur"); 483 + }); 484 + 485 + it("should leave the post untouched when no labels apply", () => { 486 + const dataStore = new DataStore(); 487 + const { derived } = makeDerived(dataStore, { 488 + preferences: fakePreferences(), 489 + }); 490 + dataStore.$posts.set(postURI, { uri: postURI, record: { text: "hello" } }); 491 + const result = derived.$hydratedPosts.get(postURI).get(); 492 + assertEquals(result.badgeLabels, undefined); 493 + assertEquals(result.contentLabel, undefined); 494 + assertEquals(result.mediaLabel, undefined); 495 + }); 496 + 497 + it("should compose muted/hidden/label marks on a single post", () => { 498 + const dataStore = new DataStore(); 499 + const { derived } = makeDerived(dataStore, { 500 + preferences: fakePreferences({ 501 + postHasMutedWord: () => true, 502 + isPostHidden: () => true, 503 + getBadgeLabels: () => ["b"], 504 + }), 505 + }); 506 + dataStore.$posts.set(postURI, { uri: postURI, record: { text: "hello" } }); 507 + const result = derived.$hydratedPosts.get(postURI).get(); 508 + assertEquals(result.viewer.hasMutedWord, true); 509 + assertEquals(result.viewer.isHidden, true); 510 + assertEquals(result.badgeLabels, ["b"]); 511 + }); 512 + 513 + it("should return the post unchanged when there is no blocked quote to resolve", () => { 514 + const dataStore = new DataStore(); 515 + const { derived } = makeDerived(dataStore, { 516 + preferences: fakePreferences(), 517 + }); 518 + const post = { uri: postURI, record: { text: "hello" } }; 519 + dataStore.$posts.set(postURI, post); 520 + const result = derived.$hydratedPosts.get(postURI).get(); 521 + // hydratePostForView always returns a fresh clone 522 + assertEquals(result.uri, post.uri); 523 + assertEquals(result.record.text, "hello"); 524 + }); 525 + }); 526 + 527 + await t.run();
+16 -16
tests/unit/specs/dataLayer/mutations.test.js
··· 3 3 import { Mutations } from "/js/dataLayer/mutations.js"; 4 4 import { DataStore } from "/js/dataLayer/dataStore.js"; 5 5 import { PatchStore } from "/js/dataLayer/patchStore.js"; 6 - import { Signals } from "/js/dataLayer/signals.js"; 6 + import { Derived } from "/js/dataLayer/derived.js"; 7 7 import { Preferences } from "/js/preferences.js"; 8 - import { Signal, SignalMap } from "/js/utils.js"; 8 + import { Signal, SignalMap } from "/js/signals.js"; 9 9 10 - // Minimal pluginService stub for Signals constructor. 10 + // Minimal pluginService stub for Derived constructor. 11 11 function makePluginService() { 12 12 return { 13 13 $pluginFilteredFeedItems: new SignalMap(), ··· 21 21 return patchStore.applyPostPatches(post, patches); 22 22 } 23 23 24 - function makeSignals( 24 + function makeDerived( 25 25 dataStore, 26 26 patchStore, 27 27 preferencesProvider, 28 28 isAuthenticated = true, 29 29 ) { 30 - // Signals' $preferences computed reads `preferencesProvider.$preferences.get()`. 30 + // Derived' $preferences computed reads `preferencesProvider.$preferences.get()`. 31 31 // If the provider doesn't supply that signal, give it a passthrough. 32 32 const provider = preferencesProvider.$preferences 33 33 ? preferencesProvider ··· 39 39 : null, 40 40 ), 41 41 }; 42 - return new Signals( 42 + return new Derived( 43 43 dataStore, 44 44 patchStore, 45 45 provider, ··· 1025 1025 if (authorFeed) { 1026 1026 dataStore.$authorFeeds.set(`${testUser.did}-posts`, authorFeed); 1027 1027 } 1028 - const signals = makeSignals(dataStore, patchStore, mockPreferencesProvider); 1028 + const derived = makeDerived(dataStore, patchStore, mockPreferencesProvider); 1029 1029 return { 1030 1030 mutations: new Mutations( 1031 1031 mockApi, ··· 1035 1035 ), 1036 1036 dataStore, 1037 1037 patchStore, 1038 - signals, 1038 + derived, 1039 1039 }; 1040 1040 } 1041 1041 ··· 1101 1101 getProfileRecord: async () => ({ value: {}, cid: "cid-profile" }), 1102 1102 putProfileRecord: () => putPromise, 1103 1103 }; 1104 - const { mutations, signals, dataStore } = setup(mockApi, { 1104 + const { mutations, derived, dataStore } = setup(mockApi, { 1105 1105 authorFeed: { feed: [otherItem, targetItem], cursor: "" }, 1106 1106 }); 1107 1107 dataStore.$posts.set(otherPost.uri, otherPost); ··· 1111 1111 // Yield so the patches apply before we inspect them. 1112 1112 await new Promise((resolve) => setTimeout(resolve, 0)); 1113 1113 1114 - assertEquals(signals.$currentUser.get().pinnedPost.uri, testPost.uri); 1115 - const inFlightFeed = signals.$hydratedAuthorFeeds 1114 + assertEquals(derived.$currentUser.get().pinnedPost.uri, testPost.uri); 1115 + const inFlightFeed = derived.$hydratedAuthorFeeds 1116 1116 .get(`${testUser.did}-posts`) 1117 1117 .get().feed; 1118 1118 assertEquals(inFlightFeed[0].post.uri, testPost.uri); ··· 1122 1122 await promise; 1123 1123 1124 1124 // After success, dataStore matches the previously-patched view. 1125 - assertEquals(signals.$currentUser.get().pinnedPost.uri, testPost.uri); 1125 + assertEquals(derived.$currentUser.get().pinnedPost.uri, testPost.uri); 1126 1126 }); 1127 1127 1128 1128 it("should revert to original state on failure", async () => { ··· 1144 1144 uri: "at://did:plc:user/app.bsky.feed.post/old", 1145 1145 cid: "cid-old", 1146 1146 }; 1147 - const { mutations, dataStore, signals } = setup(mockApi, { 1147 + const { mutations, dataStore, derived } = setup(mockApi, { 1148 1148 pinnedPost: previousPinned, 1149 1149 authorFeed: { feed: [otherItem, targetItem], cursor: "" }, 1150 1150 }); ··· 1158 1158 threw = true; 1159 1159 } 1160 1160 assertEquals(threw, true); 1161 - // Patches removed; signals reflect original dataStore. 1162 - assertEquals(signals.$currentUser.get().pinnedPost.uri, previousPinned.uri); 1163 - const feed = signals.$hydratedAuthorFeeds 1161 + // Patches removed; derived reflect original dataStore. 1162 + assertEquals(derived.$currentUser.get().pinnedPost.uri, previousPinned.uri); 1163 + const feed = derived.$hydratedAuthorFeeds 1164 1164 .get(`${testUser.did}-posts`) 1165 1165 .get().feed; 1166 1166 assertEquals(feed[0].post.uri, otherItem.post.uri);
+1 -1
tests/unit/specs/dataLayer/requests.test.js
··· 4 4 import { DataStore } from "/js/dataLayer/dataStore.js"; 5 5 import { Preferences } from "/js/preferences.js"; 6 6 import { ApiError } from "/js/api.js"; 7 - import { SignalMap } from "/js/utils.js"; 7 + import { SignalMap } from "/js/signals.js"; 8 8 9 9 const t = new TestSuite("Requests"); 10 10
-527
tests/unit/specs/dataLayer/signals.test.js
··· 1 - import { TestSuite } from "../../testSuite.js"; 2 - import { assertEquals } from "../../testHelpers.js"; 3 - import { Signals } from "/js/dataLayer/signals.js"; 4 - import { DataStore } from "/js/dataLayer/dataStore.js"; 5 - import { PatchStore } from "/js/dataLayer/patchStore.js"; 6 - import { Preferences } from "/js/preferences.js"; 7 - import { Signal, SignalMap } from "/js/utils.js"; 8 - 9 - function makeSignals(dataStore, { preferences } = {}) { 10 - const patchStore = new PatchStore(dataStore); 11 - const prefs = preferences ?? Preferences.createLoggedOutPreferences(); 12 - const preferencesProvider = { 13 - requirePreferences: () => prefs, 14 - $preferences: new Signal.State(prefs), 15 - }; 16 - const pluginService = { 17 - $pluginFilteredFeedItems: new SignalMap(), 18 - }; 19 - const signals = new Signals( 20 - dataStore, 21 - patchStore, 22 - preferencesProvider, 23 - pluginService, 24 - false, 25 - ); 26 - return { signals, patchStore }; 27 - } 28 - 29 - function fakePreferences(overrides = {}) { 30 - return { 31 - postHasMutedWord: () => false, 32 - quotedPostHasMutedWord: () => false, 33 - isPostHidden: () => false, 34 - getBadgeLabels: () => [], 35 - getContentLabel: () => null, 36 - getMediaLabel: () => null, 37 - clone() { 38 - return this; 39 - }, 40 - ...overrides, 41 - }; 42 - } 43 - 44 - const t = new TestSuite("Signals"); 45 - 46 - t.describe("$hydratedFeeds", (it) => { 47 - const feedURI = "at://did:test/app.bsky.feed.generator/test"; 48 - 49 - it("should return null when feed does not exist", () => { 50 - const dataStore = new DataStore(); 51 - const { signals } = makeSignals(dataStore); 52 - assertEquals(signals.$hydratedFeeds.get(feedURI).get(), null); 53 - }); 54 - 55 - it("should hydrate and return a feed with posts", () => { 56 - const dataStore = new DataStore(); 57 - const { signals } = makeSignals(dataStore); 58 - 59 - const rawFeed = { 60 - feed: [{ post: { uri: "post1" } }, { post: { uri: "post2" } }], 61 - cursor: "cursor123", 62 - }; 63 - const post1 = { uri: "post1", content: "First post", likeCount: 5 }; 64 - const post2 = { uri: "post2", content: "Second post", likeCount: 10 }; 65 - 66 - dataStore.$posts.set("post1", post1); 67 - dataStore.$posts.set("post2", post2); 68 - dataStore.$feeds.set(feedURI, rawFeed); 69 - 70 - const result = signals.$hydratedFeeds.get(feedURI).get(); 71 - assertEquals(result, { 72 - feed: [{ post: post1 }, { post: post2 }], 73 - cursor: "cursor123", 74 - }); 75 - }); 76 - 77 - it("should apply patches to posts in feed", () => { 78 - const dataStore = new DataStore(); 79 - const { signals, patchStore } = makeSignals(dataStore); 80 - 81 - const rawFeed = { 82 - feed: [{ post: { uri: "post1" } }], 83 - cursor: "cursor123", 84 - }; 85 - const post1 = { 86 - uri: "post1", 87 - content: "Test post", 88 - likeCount: 5, 89 - viewer: { like: null }, 90 - }; 91 - 92 - dataStore.$posts.set("post1", post1); 93 - dataStore.$feeds.set(feedURI, rawFeed); 94 - patchStore.addPostPatch("post1", { type: "addLike" }); 95 - 96 - const result = signals.$hydratedFeeds.get(feedURI).get(); 97 - assertEquals(result.feed[0].post.likeCount, 6); 98 - assertEquals(result.feed[0].post.viewer.like, "fake like"); 99 - }); 100 - }); 101 - 102 - t.describe("$hydratedHashtagFeeds", (it) => { 103 - const hashtagKey = "javascript-top"; 104 - 105 - it("should return null when feed does not exist", () => { 106 - const dataStore = new DataStore(); 107 - const { signals } = makeSignals(dataStore); 108 - assertEquals(signals.$hydratedHashtagFeeds.get(hashtagKey).get(), null); 109 - }); 110 - 111 - it("should hydrate and return a feed with posts", () => { 112 - const dataStore = new DataStore(); 113 - const { signals } = makeSignals(dataStore); 114 - 115 - const rawFeed = { 116 - feed: [{ post: { uri: "post1" } }, { post: { uri: "post2" } }], 117 - cursor: "cursor123", 118 - }; 119 - const post1 = { uri: "post1", content: "First post", likeCount: 5 }; 120 - const post2 = { uri: "post2", content: "Second post", likeCount: 10 }; 121 - 122 - dataStore.$posts.set("post1", post1); 123 - dataStore.$posts.set("post2", post2); 124 - dataStore.$hashtagFeeds.set(hashtagKey, rawFeed); 125 - 126 - const result = signals.$hydratedHashtagFeeds.get(hashtagKey).get(); 127 - assertEquals(result, { 128 - feed: [{ post: post1 }, { post: post2 }], 129 - cursor: "cursor123", 130 - }); 131 - }); 132 - 133 - it("should attach parentAuthor when post is a reply and parent is loaded", () => { 134 - const dataStore = new DataStore(); 135 - const { signals } = makeSignals(dataStore); 136 - 137 - const parentAuthor = { did: "did:parent", handle: "parent.test" }; 138 - const parent = { 139 - uri: "post-parent", 140 - author: parentAuthor, 141 - }; 142 - const reply = { 143 - uri: "post-reply", 144 - record: { 145 - text: "reply text", 146 - reply: { parent: { uri: "post-parent" }, root: { uri: "post-parent" } }, 147 - }, 148 - }; 149 - const rawFeed = { 150 - feed: [{ post: { uri: "post-reply" } }], 151 - cursor: "c", 152 - }; 153 - 154 - dataStore.$posts.set("post-parent", parent); 155 - dataStore.$posts.set("post-reply", reply); 156 - dataStore.$hashtagFeeds.set(hashtagKey, rawFeed); 157 - 158 - const result = signals.$hydratedHashtagFeeds.get(hashtagKey).get(); 159 - assertEquals(result.feed[0].post.record.reply.parentAuthor, parentAuthor); 160 - }); 161 - 162 - it("should apply patches to posts in feed", () => { 163 - const dataStore = new DataStore(); 164 - const { signals, patchStore } = makeSignals(dataStore); 165 - 166 - const rawFeed = { 167 - feed: [{ post: { uri: "post1" } }], 168 - cursor: "c", 169 - }; 170 - const post1 = { 171 - uri: "post1", 172 - likeCount: 5, 173 - viewer: { like: null }, 174 - }; 175 - 176 - dataStore.$posts.set("post1", post1); 177 - dataStore.$hashtagFeeds.set(hashtagKey, rawFeed); 178 - patchStore.addPostPatch("post1", { type: "addLike" }); 179 - 180 - const result = signals.$hydratedHashtagFeeds.get(hashtagKey).get(); 181 - assertEquals(result.feed[0].post.likeCount, 6); 182 - assertEquals(result.feed[0].post.viewer.like, "fake like"); 183 - }); 184 - }); 185 - 186 - t.describe("$hydratedProfiles", (it) => { 187 - const did = "did:plc:user"; 188 - 189 - it("should return null when profile does not exist", () => { 190 - const dataStore = new DataStore(); 191 - const { signals } = makeSignals(dataStore); 192 - assertEquals(signals.$hydratedProfiles.get(did).get(), null); 193 - }); 194 - 195 - it("should return the profile when it exists", () => { 196 - const dataStore = new DataStore(); 197 - const { signals } = makeSignals(dataStore); 198 - const profile = { did, handle: "user.test", followersCount: 10 }; 199 - dataStore.$profiles.set(did, profile); 200 - const result = signals.$hydratedProfiles.get(did).get(); 201 - assertEquals(result.did, did); 202 - assertEquals(result.handle, "user.test"); 203 - assertEquals(result.followersCount, 10); 204 - }); 205 - 206 - it("should apply profile patches", () => { 207 - const dataStore = new DataStore(); 208 - const { signals, patchStore } = makeSignals(dataStore); 209 - const profile = { 210 - did, 211 - handle: "user.test", 212 - followersCount: 10, 213 - viewer: { following: null }, 214 - }; 215 - dataStore.$profiles.set(did, profile); 216 - patchStore.addProfilePatch(did, { type: "followProfile" }); 217 - const result = signals.$hydratedProfiles.get(did).get(); 218 - assertEquals(result.followersCount, 11); 219 - assertEquals(result.viewer.following, "fake following"); 220 - }); 221 - }); 222 - 223 - t.describe("$hydratedAuthorFeeds", (it) => { 224 - const did = "did:plc:author"; 225 - const feedURI = `${did}-posts`; 226 - 227 - it("should return null when author feed does not exist", () => { 228 - const dataStore = new DataStore(); 229 - const { signals } = makeSignals(dataStore); 230 - assertEquals(signals.$hydratedAuthorFeeds.get(feedURI).get(), null); 231 - }); 232 - 233 - it("should hydrate and return an author feed", () => { 234 - const dataStore = new DataStore(); 235 - const { signals } = makeSignals(dataStore); 236 - const post1 = { uri: "post1", likeCount: 1 }; 237 - const post2 = { uri: "post2", likeCount: 2 }; 238 - dataStore.$posts.set("post1", post1); 239 - dataStore.$posts.set("post2", post2); 240 - dataStore.$authorFeeds.set(feedURI, { 241 - feed: [{ post: { uri: "post1" } }, { post: { uri: "post2" } }], 242 - cursor: "c", 243 - }); 244 - const result = signals.$hydratedAuthorFeeds.get(feedURI).get(); 245 - assertEquals(result.feed.length, 2); 246 - assertEquals(result.feed[0].post.uri, "post1"); 247 - assertEquals(result.feed[1].post.uri, "post2"); 248 - assertEquals(result.cursor, "c"); 249 - }); 250 - 251 - it("should filter to replies-only for replies feed type", () => { 252 - const dataStore = new DataStore(); 253 - const { signals } = makeSignals(dataStore); 254 - const repliesFeedURI = `${did}-replies`; 255 - const post1 = { uri: "post1" }; 256 - const post2 = { uri: "post2" }; 257 - dataStore.$posts.set("post1", post1); 258 - dataStore.$posts.set("post2", post2); 259 - dataStore.$posts.set("parent", { uri: "parent" }); 260 - dataStore.$authorFeeds.set(repliesFeedURI, { 261 - feed: [ 262 - { post: { uri: "post1" } }, // top-level, should be filtered out 263 - { 264 - post: { uri: "post2" }, 265 - reply: { root: { uri: "parent" }, parent: { uri: "parent" } }, 266 - }, 267 - ], 268 - cursor: "c", 269 - }); 270 - const result = signals.$hydratedAuthorFeeds.get(repliesFeedURI).get(); 271 - assertEquals(result.feed.length, 1); 272 - assertEquals(result.feed[0].post.uri, "post2"); 273 - }); 274 - 275 - it("should apply author feed patches", () => { 276 - const dataStore = new DataStore(); 277 - const { signals, patchStore } = makeSignals(dataStore); 278 - const pinnedPost = { uri: "pinned", likeCount: 0 }; 279 - const otherPost = { uri: "other", likeCount: 0 }; 280 - dataStore.$posts.set("pinned", pinnedPost); 281 - dataStore.$posts.set("other", otherPost); 282 - dataStore.$authorFeeds.set(feedURI, { 283 - feed: [{ post: { uri: "other" } }, { post: { uri: "pinned" } }], 284 - cursor: null, 285 - }); 286 - patchStore.addAuthorFeedPatch(feedURI, { 287 - type: "pinPost", 288 - post: { uri: "pinned" }, 289 - }); 290 - const result = signals.$hydratedAuthorFeeds.get(feedURI).get(); 291 - assertEquals(result.feed[0].post.uri, "pinned"); 292 - }); 293 - }); 294 - 295 - t.describe("$actorFeeds", (it) => { 296 - const did = "did:plc:author"; 297 - 298 - it("should return null when actor feeds do not exist", () => { 299 - const dataStore = new DataStore(); 300 - const { signals } = makeSignals(dataStore); 301 - assertEquals(signals.$actorFeeds.get(did).get(), null); 302 - }); 303 - 304 - it("should return the stored actor feeds", () => { 305 - const dataStore = new DataStore(); 306 - const { signals } = makeSignals(dataStore); 307 - const actorFeeds = { feeds: [{ uri: "feed-1" }], cursor: "c" }; 308 - dataStore.$actorFeeds.set(did, actorFeeds); 309 - assertEquals(signals.$actorFeeds.get(did).get(), actorFeeds); 310 - }); 311 - }); 312 - 313 - t.describe("$profileChatStatus", (it) => { 314 - const did = "did:plc:user"; 315 - 316 - it("should return null when chat status does not exist", () => { 317 - const dataStore = new DataStore(); 318 - const { signals } = makeSignals(dataStore); 319 - assertEquals(signals.$profileChatStatus.get(did).get(), null); 320 - }); 321 - 322 - it("should return the stored chat status", () => { 323 - const dataStore = new DataStore(); 324 - const { signals } = makeSignals(dataStore); 325 - const status = { canChat: true, convo: null }; 326 - dataStore.$profileChatStatus.set(did, status); 327 - assertEquals(signals.$profileChatStatus.get(did).get(), status); 328 - }); 329 - }); 330 - 331 - t.describe("$labelerInfo", (it) => { 332 - const did = "did:plc:labeler"; 333 - 334 - it("should return null when labeler info does not exist", () => { 335 - const dataStore = new DataStore(); 336 - const { signals } = makeSignals(dataStore); 337 - assertEquals(signals.$labelerInfo.get(did).get(), null); 338 - }); 339 - 340 - it("should return the stored labeler info", () => { 341 - const dataStore = new DataStore(); 342 - const { signals } = makeSignals(dataStore); 343 - const info = { policies: { labelValues: ["spam"] } }; 344 - dataStore.$labelerInfo.set(did, info); 345 - assertEquals(signals.$labelerInfo.get(did).get(), info); 346 - }); 347 - }); 348 - 349 - t.describe("$labelerSettings", (it) => { 350 - it("should return labeler settings from preferences", () => { 351 - const dataStore = new DataStore(); 352 - const { signals } = makeSignals(dataStore); 353 - const labelerDid = "did:plc:labeler"; 354 - const result = signals.$labelerSettings.get(labelerDid).get(); 355 - // Logged-out preferences should still return a settings object 356 - assertEquals(typeof result, "object"); 357 - }); 358 - }); 359 - 360 - t.describe("$hydratedBookmarks", (it) => { 361 - it("should return null when bookmarks do not exist", () => { 362 - const dataStore = new DataStore(); 363 - const { signals } = makeSignals(dataStore); 364 - assertEquals(signals.$hydratedBookmarks.get(), null); 365 - }); 366 - 367 - it("should hydrate and return bookmarks", () => { 368 - const dataStore = new DataStore(); 369 - const { signals } = makeSignals(dataStore); 370 - const post1 = { uri: "post1", likeCount: 5 }; 371 - const post2 = { uri: "post2", likeCount: 10 }; 372 - dataStore.$posts.set("post1", post1); 373 - dataStore.$posts.set("post2", post2); 374 - dataStore.$bookmarks.set({ 375 - feed: [{ post: { uri: "post1" } }, { post: { uri: "post2" } }], 376 - cursor: "c", 377 - }); 378 - const result = signals.$hydratedBookmarks.get(); 379 - assertEquals(result.feed.length, 2); 380 - assertEquals(result.feed[0].post.uri, "post1"); 381 - assertEquals(result.feed[1].post.uri, "post2"); 382 - assertEquals(result.cursor, "c"); 383 - }); 384 - 385 - it("should attach parentAuthor when bookmarked post is a reply", () => { 386 - const dataStore = new DataStore(); 387 - const { signals } = makeSignals(dataStore); 388 - const parentAuthor = { did: "did:parent", handle: "parent.test" }; 389 - dataStore.$posts.set("post-parent", { 390 - uri: "post-parent", 391 - author: parentAuthor, 392 - }); 393 - dataStore.$posts.set("post-reply", { 394 - uri: "post-reply", 395 - record: { 396 - text: "reply", 397 - reply: { parent: { uri: "post-parent" }, root: { uri: "post-parent" } }, 398 - }, 399 - }); 400 - dataStore.$bookmarks.set({ 401 - feed: [{ post: { uri: "post-reply" } }], 402 - cursor: null, 403 - }); 404 - const result = signals.$hydratedBookmarks.get(); 405 - assertEquals(result.feed[0].post.record.reply.parentAuthor, parentAuthor); 406 - }); 407 - }); 408 - 409 - t.describe("$hydratedPinnedFeedGenerators", (it) => { 410 - it("should return null when pinned feed generators are not set", () => { 411 - const dataStore = new DataStore(); 412 - const { signals } = makeSignals(dataStore); 413 - assertEquals(signals.$hydratedPinnedFeedGenerators.get(), null); 414 - }); 415 - 416 - it("should hydrate pinned feed generators from the store", () => { 417 - const dataStore = new DataStore(); 418 - const { signals } = makeSignals(dataStore); 419 - const fg1 = { uri: "feed-1", displayName: "Feed One" }; 420 - const fg2 = { uri: "feed-2", displayName: "Feed Two" }; 421 - dataStore.$feedGenerators.set("feed-1", fg1); 422 - dataStore.$feedGenerators.set("feed-2", fg2); 423 - dataStore.$pinnedFeedGenerators.set([{ uri: "feed-1" }, { uri: "feed-2" }]); 424 - const result = signals.$hydratedPinnedFeedGenerators.get(); 425 - // isAuthenticated is false in test setup, so no "Following" entry 426 - assertEquals(result, [fg1, fg2]); 427 - }); 428 - }); 429 - 430 - t.describe("$hydratedPosts (post hydration)", (it) => { 431 - const postURI = "at://did:test/app.bsky.feed.post/x"; 432 - 433 - it("should return null when the post does not exist", () => { 434 - const dataStore = new DataStore(); 435 - const { signals } = makeSignals(dataStore); 436 - assertEquals(signals.$hydratedPosts.get(postURI).get(), null); 437 - }); 438 - 439 - it("should mark the post when it contains a muted word", () => { 440 - const dataStore = new DataStore(); 441 - const { signals } = makeSignals(dataStore, { 442 - preferences: fakePreferences({ postHasMutedWord: () => true }), 443 - }); 444 - dataStore.$posts.set(postURI, { uri: postURI, record: { text: "hello" } }); 445 - const result = signals.$hydratedPosts.get(postURI).get(); 446 - assertEquals(result.viewer.hasMutedWord, true); 447 - }); 448 - 449 - it("should not mark the post when there is no muted word match", () => { 450 - const dataStore = new DataStore(); 451 - const { signals } = makeSignals(dataStore, { 452 - preferences: fakePreferences(), 453 - }); 454 - dataStore.$posts.set(postURI, { uri: postURI, record: { text: "hello" } }); 455 - const result = signals.$hydratedPosts.get(postURI).get(); 456 - assertEquals(result.viewer, undefined); 457 - }); 458 - 459 - it("should mark the post hidden when preferences say so", () => { 460 - const dataStore = new DataStore(); 461 - const { signals } = makeSignals(dataStore, { 462 - preferences: fakePreferences({ isPostHidden: () => true }), 463 - }); 464 - dataStore.$posts.set(postURI, { uri: postURI, record: { text: "hello" } }); 465 - const result = signals.$hydratedPosts.get(postURI).get(); 466 - assertEquals(result.viewer.isHidden, true); 467 - }); 468 - 469 - it("should attach badge, content, and media labels from preferences", () => { 470 - const dataStore = new DataStore(); 471 - const { signals } = makeSignals(dataStore, { 472 - preferences: fakePreferences({ 473 - getBadgeLabels: () => ["badge"], 474 - getContentLabel: () => "warn", 475 - getMediaLabel: () => "blur", 476 - }), 477 - }); 478 - dataStore.$posts.set(postURI, { uri: postURI, record: { text: "hello" } }); 479 - const result = signals.$hydratedPosts.get(postURI).get(); 480 - assertEquals(result.badgeLabels, ["badge"]); 481 - assertEquals(result.contentLabel, "warn"); 482 - assertEquals(result.mediaLabel, "blur"); 483 - }); 484 - 485 - it("should leave the post untouched when no labels apply", () => { 486 - const dataStore = new DataStore(); 487 - const { signals } = makeSignals(dataStore, { 488 - preferences: fakePreferences(), 489 - }); 490 - dataStore.$posts.set(postURI, { uri: postURI, record: { text: "hello" } }); 491 - const result = signals.$hydratedPosts.get(postURI).get(); 492 - assertEquals(result.badgeLabels, undefined); 493 - assertEquals(result.contentLabel, undefined); 494 - assertEquals(result.mediaLabel, undefined); 495 - }); 496 - 497 - it("should compose muted/hidden/label marks on a single post", () => { 498 - const dataStore = new DataStore(); 499 - const { signals } = makeSignals(dataStore, { 500 - preferences: fakePreferences({ 501 - postHasMutedWord: () => true, 502 - isPostHidden: () => true, 503 - getBadgeLabels: () => ["b"], 504 - }), 505 - }); 506 - dataStore.$posts.set(postURI, { uri: postURI, record: { text: "hello" } }); 507 - const result = signals.$hydratedPosts.get(postURI).get(); 508 - assertEquals(result.viewer.hasMutedWord, true); 509 - assertEquals(result.viewer.isHidden, true); 510 - assertEquals(result.badgeLabels, ["b"]); 511 - }); 512 - 513 - it("should return the post unchanged when there is no blocked quote to resolve", () => { 514 - const dataStore = new DataStore(); 515 - const { signals } = makeSignals(dataStore, { 516 - preferences: fakePreferences(), 517 - }); 518 - const post = { uri: postURI, record: { text: "hello" } }; 519 - dataStore.$posts.set(postURI, post); 520 - const result = signals.$hydratedPosts.get(postURI).get(); 521 - // hydratePostForView always returns a fresh clone 522 - assertEquals(result.uri, post.uri); 523 - assertEquals(result.record.text, "hello"); 524 - }); 525 - }); 526 - 527 - await t.run();
+5 -5
tests/unit/specs/plugins/pluginService.test.js
··· 1150 1150 return { map, calls }; 1151 1151 } 1152 1152 1153 - it("getPost host method returns the hydrated post from signals", async () => { 1153 + it("getPost host method returns the hydrated post from derived", async () => { 1154 1154 const service = makeServiceWithRealBridge(); 1155 1155 const posts = makeStubSignalMap((uri) => ({ 1156 1156 uri, 1157 1157 record: { text: "cached" }, 1158 1158 })); 1159 1159 service.setDataLayer({ 1160 - signals: { 1160 + derived: { 1161 1161 $hydratedPosts: posts.map, 1162 1162 $hydratedProfiles: makeStubSignalMap(() => null).map, 1163 1163 }, ··· 1171 1171 }); 1172 1172 }); 1173 1173 1174 - it("getProfile host method returns the hydrated profile from signals", async () => { 1174 + it("getProfile host method returns the hydrated profile from derived", async () => { 1175 1175 const service = makeServiceWithRealBridge(); 1176 1176 const profiles = makeStubSignalMap((did) => ({ 1177 1177 did, 1178 1178 handle: "alice.test", 1179 1179 })); 1180 1180 service.setDataLayer({ 1181 - signals: { 1181 + derived: { 1182 1182 $hydratedPosts: makeStubSignalMap(() => null).map, 1183 1183 $hydratedProfiles: profiles.map, 1184 1184 }, ··· 1199 1199 it("getProfile returns null when the hydrated profile signal is empty", async () => { 1200 1200 const service = makeServiceWithRealBridge(); 1201 1201 service.setDataLayer({ 1202 - signals: { 1202 + derived: { 1203 1203 $hydratedPosts: makeStubSignalMap(() => null).map, 1204 1204 $hydratedProfiles: makeStubSignalMap(() => null).map, 1205 1205 },