Mirror of https://github.com/improsocial/impro An extensible Bluesky client for web impro.social
5

Configure Feed

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

Enable feed reordering

Grace Kind (Jul 16, 2026, 4:53 PM -0500) 6964d365 84d101ca

+1073 -72
+1 -1
package.json
··· 1 1 { 2 2 "name": "impro", 3 - "version": "0.17.187", 3 + "version": "0.17.188", 4 4 "type": "module", 5 5 "scripts": { 6 6 "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve",
+113
src/css/style.css
··· 537 537 height: 30px; 538 538 width: 30px; 539 539 border-radius: var(--icon-button-border-radius); 540 + transition: background-color 0.2s; 541 + } 542 + 543 + @media (hover: hover) { 544 + .menu-button:hover { 545 + background-color: var(--back-button-hover-color); 546 + } 540 547 } 541 548 542 549 @media (min-width: 800px) { ··· 565 572 } 566 573 567 574 .inbox-button .icon { 575 + width: 24px; 576 + height: 24px; 577 + } 578 + 579 + .feeds-button { 580 + display: flex; 581 + align-items: center; 582 + justify-content: center; 583 + cursor: pointer; 584 + color: var(--text-color-muted); 585 + height: 30px; 586 + width: 30px; 587 + border-radius: var(--icon-button-border-radius); 588 + transition: background-color 0.2s; 589 + } 590 + 591 + @media (hover: hover) { 592 + .feeds-button:hover { 593 + background-color: var(--back-button-hover-color); 594 + } 595 + } 596 + 597 + .feeds-button .icon { 568 598 width: 24px; 569 599 height: 24px; 570 600 } ··· 5640 5670 font-size: 14px; 5641 5671 color: var(--text-color-secondary); 5642 5672 white-space: pre-wrap; 5673 + } 5674 + 5675 + .feeds-list-item-drag-handle { 5676 + flex-shrink: 0; 5677 + width: 40px; 5678 + height: 40px; 5679 + display: flex; 5680 + align-items: center; 5681 + justify-content: center; 5682 + background: transparent; 5683 + border: none; 5684 + padding: 0; 5685 + color: var(--text-color-muted); 5686 + cursor: grab; 5687 + touch-action: none; 5688 + -webkit-user-select: none; 5689 + user-select: none; 5690 + } 5691 + 5692 + .feeds-list-item-drag-handle:active { 5693 + cursor: grabbing; 5694 + } 5695 + 5696 + .feeds-list-item-drag-handle svg { 5697 + width: 20px; 5698 + height: 20px; 5699 + } 5700 + 5701 + .feeds-list-item.is-dragging { 5702 + opacity: 0.85; 5703 + z-index: 1; 5704 + will-change: transform; 5705 + background-color: var(--background-color); 5706 + box-shadow: 0 4px 12px color-mix(in srgb, var(--text-color) 15%, transparent); 5707 + user-select: none; 5708 + } 5709 + 5710 + .feeds-list-item.is-shifting { 5711 + transition: transform 150ms ease; 5712 + } 5713 + 5714 + .following-avatar { 5715 + width: 40px; 5716 + height: 40px; 5717 + border-radius: var(--list-avatar-border-radius); 5718 + display: flex; 5719 + align-items: center; 5720 + justify-content: center; 5721 + background-color: var(--highlight-color); 5722 + color: var(--background-color); 5723 + } 5724 + 5725 + .following-avatar .icon svg { 5726 + width: 24px; 5727 + height: 24px; 5728 + } 5729 + 5730 + .header-edit-button { 5731 + background: transparent; 5732 + border: none; 5733 + padding: 8px; 5734 + display: flex; 5735 + align-items: center; 5736 + justify-content: center; 5737 + cursor: pointer; 5738 + color: var(--text-color-muted); 5739 + } 5740 + 5741 + .header-edit-button svg { 5742 + width: 24px; 5743 + height: 24px; 5744 + } 5745 + 5746 + .header svg { 5747 + display: block; 5748 + } 5749 + 5750 + .header-actions { 5751 + display: flex; 5752 + align-items: center; 5753 + gap: 8px; 5754 + margin-right: 8px; 5755 + min-height: 40px; 5643 5756 } 5644 5757 5645 5758 .feeds-list-item-actions {
+1
src/img/icons/custom/hashtag-line.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8h16M4 16h16M9 4 7 20M17 4l-2 16"/></svg>
+5
src/js/dataHelpers.js
··· 46 46 return feedURI === FOLLOWING_FEED_URI; 47 47 } 48 48 49 + // The `value` used in savedFeedsPrefV2 for a hydrated pinned item 50 + export function valueForPinnedItem(item) { 51 + return item.type === "timeline" ? FOLLOWING_FEED_URI : item.data.uri; 52 + } 53 + 49 54 export function getRKey(record) { 50 55 return record.uri.split("/").pop(); 51 56 }
+25
src/js/dataLayer/mutations.js
··· 8 8 import { getCurrentTimestamp } from "/js/utils.js"; 9 9 import { PostCreator } from "/js/postCreator.js"; 10 10 import { untrack } from "/js/signals.js"; 11 + import { valueForPinnedItem } from "/js/dataHelpers.js"; 11 12 12 13 // Handles mutations to the data, making optimistic updates if needed. 13 14 export class Mutations { ··· 431 432 throw error; 432 433 } finally { 433 434 this.patchStore.removePreferencePatch(patchId); 435 + } 436 + } 437 + 438 + async reorderPinnedItems(orderedValues) { 439 + const preferences = this.preferencesProvider.requirePreferences(); 440 + const newPreferences = preferences.reorderPinnedItems(orderedValues); 441 + try { 442 + await this.preferencesProvider.updatePreferences(newPreferences); 443 + } catch (error) { 444 + console.error(error); 445 + throw error; 446 + } 447 + 448 + // Update pinned items in memory 449 + const pinnedItems = untrack(() => this.dataStore.$pinnedItems.get()); 450 + if (pinnedItems) { 451 + const indexOf = (item) => { 452 + const idx = orderedValues.indexOf(valueForPinnedItem(item)); 453 + return idx === -1 ? orderedValues.length : idx; 454 + }; 455 + const reordered = [...pinnedItems].sort( 456 + (a, b) => indexOf(a) - indexOf(b), 457 + ); 458 + this.dataStore.$pinnedItems.set(reordered); 434 459 } 435 460 } 436 461
+17
src/js/preferences.js
··· 135 135 return clone; 136 136 } 137 137 138 + reorderPinnedItems(orderedValues) { 139 + const clone = this.clone(); 140 + const savedFeedsPreference = Preferences.getSavedFeedsPreference(clone.obj); 141 + if (!savedFeedsPreference) { 142 + throw new Error("Saved feeds preference not found"); 143 + } 144 + const byValue = new Map( 145 + savedFeedsPreference.items.map((item) => [item.value, item]), 146 + ); 147 + const reordered = orderedValues 148 + .map((value) => byValue.get(value)) 149 + .filter((item) => item && item.pinned); 150 + const unpinned = savedFeedsPreference.items.filter((item) => !item.pinned); 151 + savedFeedsPreference.items = [...reordered, ...unpinned]; 152 + return clone; 153 + } 154 + 138 155 hidePost(postUri) { 139 156 const clone = this.clone(); 140 157 let hiddenPostsPreference = Preferences.getHiddenPostsPreference(clone.obj);
+4 -2
src/js/templates/header.template.js
··· 61 61 <div class="loading-spinner" data-testid="loading-spinner"></div>` 62 62 : ""} 63 63 ${rightItemTemplate 64 - ? html`<div class="header-spacer"></div> 65 - ${rightItemTemplate()}` 64 + ? html`${showLoadingSpinner 65 + ? "" 66 + : html`<div class="header-spacer"></div>`} 67 + ${rightItemTemplate()}` 66 68 : ""} 67 69 </div> 68 70 ${bottomItemTemplate
+16
src/js/templates/icons/hashtagIcon.template.js
··· 1 + import { html } from "/js/lib/lit-html.js"; 2 + 3 + // Source: src/img/icons/custom/hashtag-line.svg 4 + export function hashtagIconTemplate() { 5 + return html`<div class="icon hashtag-icon"> 6 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"> 7 + <path 8 + stroke="currentColor" 9 + stroke-linecap="round" 10 + stroke-linejoin="round" 11 + stroke-width="2" 12 + d="M4 8h16M4 16h16M9 4 7 20M17 4l-2 16" 13 + /> 14 + </svg> 15 + </div>`; 16 + }
+145
src/js/utils.js
··· 756 756 el.addEventListener("contextmenu", suppressContextMenu); 757 757 el.__longPressEnabled = true; 758 758 } 759 + 760 + // Enables drag-to-reorder inside `container`. On pointerdown on any element 761 + // matching `handleSelector`, the containing `itemSelector` element becomes 762 + // draggable; siblings slide out of the way as the pointer crosses their 763 + // midpoints. On pointerup, `onReorder(orderedItemElements)` fires with the new 764 + // element order. 765 + export function enableReorder( 766 + container, 767 + { itemSelector, handleSelector, onReorder }, 768 + ) { 769 + if (container.__reorderEnabled) { 770 + container.__reorderEnabled.cleanup(); 771 + } 772 + 773 + let draggedItem = null; 774 + let pointerId = null; 775 + let startPointerY = 0; 776 + let currentDeltaY = 0; 777 + let currentTargetIndex = 0; 778 + let itemRects = []; 779 + let items = []; 780 + let removeClickSuppressor = null; 781 + 782 + const suppressNextClick = () => { 783 + removeClickSuppressor?.(); 784 + const onClick = (event) => { 785 + event.preventDefault(); 786 + event.stopImmediatePropagation(); 787 + removeSuppressor(); 788 + }; 789 + const removeSuppressor = () => { 790 + document.removeEventListener("click", onClick, true); 791 + removeClickSuppressor = null; 792 + }; 793 + document.addEventListener("click", onClick, true); 794 + removeClickSuppressor = removeSuppressor; 795 + }; 796 + 797 + const onPointerDown = (event) => { 798 + if (event.button > 0) return; 799 + const handle = event.target.closest(handleSelector); 800 + if (!handle || !container.contains(handle)) return; 801 + const item = handle.closest(itemSelector); 802 + if (!item || item.parentElement !== container) return; 803 + 804 + draggedItem = item; 805 + pointerId = event.pointerId; 806 + startPointerY = event.clientY; 807 + currentDeltaY = 0; 808 + items = Array.from(container.querySelectorAll(`:scope > ${itemSelector}`)); 809 + itemRects = items.map((el) => el.getBoundingClientRect()); 810 + currentTargetIndex = items.indexOf(draggedItem); 811 + 812 + handle.setPointerCapture(event.pointerId); 813 + draggedItem.classList.add("is-dragging"); 814 + for (const el of items) { 815 + if (el !== draggedItem) el.classList.add("is-shifting"); 816 + } 817 + event.preventDefault(); 818 + }; 819 + 820 + const onPointerMove = (event) => { 821 + if (!draggedItem || event.pointerId !== pointerId) return; 822 + currentDeltaY = event.clientY - startPointerY; 823 + 824 + const draggedIndex = items.indexOf(draggedItem); 825 + const draggedRect = itemRects[draggedIndex]; 826 + const pointerY = draggedRect.top + draggedRect.height / 2 + currentDeltaY; 827 + 828 + let newIndex = draggedIndex; 829 + for (let i = 0; i < itemRects.length; i++) { 830 + if (i === draggedIndex) continue; 831 + const rect = itemRects[i]; 832 + const mid = rect.top + rect.height / 2; 833 + if (i < draggedIndex && pointerY < mid) { 834 + newIndex = Math.min(newIndex, i); 835 + } else if (i > draggedIndex && pointerY > mid) { 836 + newIndex = Math.max(newIndex, i); 837 + } 838 + } 839 + currentTargetIndex = newIndex; 840 + 841 + draggedItem.style.transform = `translateY(${currentDeltaY}px)`; 842 + 843 + for (let i = 0; i < items.length; i++) { 844 + if (i === draggedIndex) continue; 845 + const el = items[i]; 846 + const height = draggedRect.height; 847 + if (i > draggedIndex && i <= currentTargetIndex) { 848 + el.style.transform = `translateY(${-height}px)`; 849 + } else if (i < draggedIndex && i >= currentTargetIndex) { 850 + el.style.transform = `translateY(${height}px)`; 851 + } else { 852 + el.style.transform = ""; 853 + } 854 + } 855 + }; 856 + 857 + const finish = (event, { commit }) => { 858 + if (!draggedItem || event.pointerId !== pointerId) return; 859 + const handle = event.target.closest(handleSelector); 860 + handle?.releasePointerCapture?.(pointerId); 861 + 862 + const draggedIndex = items.indexOf(draggedItem); 863 + const shouldReorder = commit && currentTargetIndex !== draggedIndex; 864 + 865 + for (const el of items) { 866 + el.style.transform = ""; 867 + el.classList.remove("is-shifting"); 868 + } 869 + draggedItem.classList.remove("is-dragging"); 870 + 871 + if (shouldReorder) { 872 + const reordered = [...items]; 873 + const [moved] = reordered.splice(draggedIndex, 1); 874 + reordered.splice(currentTargetIndex, 0, moved); 875 + suppressNextClick(); 876 + onReorder(reordered); 877 + } 878 + 879 + draggedItem = null; 880 + pointerId = null; 881 + items = []; 882 + itemRects = []; 883 + }; 884 + 885 + const onPointerUp = (event) => finish(event, { commit: true }); 886 + const onPointerCancel = (event) => finish(event, { commit: false }); 887 + 888 + container.addEventListener("pointerdown", onPointerDown); 889 + container.addEventListener("pointermove", onPointerMove); 890 + container.addEventListener("pointerup", onPointerUp); 891 + container.addEventListener("pointercancel", onPointerCancel); 892 + 893 + const cleanup = () => { 894 + container.removeEventListener("pointerdown", onPointerDown); 895 + container.removeEventListener("pointermove", onPointerMove); 896 + container.removeEventListener("pointerup", onPointerUp); 897 + container.removeEventListener("pointercancel", onPointerCancel); 898 + removeClickSuppressor?.(); 899 + delete container.__reorderEnabled; 900 + }; 901 + container.__reorderEnabled = { cleanup }; 902 + return { cleanup }; 903 + }
+2 -2
src/js/views/chat.view.js
··· 135 135 136 136 function inboxButtonTemplate({ hasUnreadRequests }) { 137 137 return html` 138 - <container-link 138 + <a 139 139 class="inbox-button" 140 140 href="/messages/inbox" 141 141 aria-label=${hasUnreadRequests ? "Requests (unread)" : "Requests"} ··· 146 146 ${hasUnreadRequests 147 147 ? html`<div class="unread-dot" data-testid="unread-dot"></div>` 148 148 : ""} 149 - </container-link> 149 + </a> 150 150 `; 151 151 } 152 152
+225 -67
src/js/views/feeds.view.js
··· 3 3 import { html, render } from "/js/lib/lit-html.js"; 4 4 import { auth } from "/js/auth.js"; 5 5 import { headerTemplate } from "/js/templates/header.template.js"; 6 - import { feedGeneratorListItemTemplate } from "/js/templates/feedGeneratorListItem.template.js"; 7 6 import { feedGeneratorListItemSkeletonTemplate } from "/js/templates/feedGeneratorListItemSkeleton.template.js"; 8 - import { linkToList } from "/js/navigation.js"; 7 + import { menuIconTemplate } from "/js/templates/icons/menuIcon.template.js"; 8 + import { settingsIconTemplate } from "/js/templates/icons/settingsIcon.template.js"; 9 + import { homeIconTemplate } from "/js/templates/icons/homeIcon.template.js"; 10 + import { linkToList, linkToFeed } from "/js/navigation.js"; 11 + import { Signal, ReactiveStore } from "/js/signals.js"; 12 + import { enableReorder } from "/js/utils.js"; 13 + import { showToast } from "/js/toasts.js"; 14 + import { valueForPinnedItem } from "/js/dataHelpers.js"; 9 15 import "/js/components/container-link.js"; 10 16 11 17 class FeedsView extends View { 12 18 async render({ root, context: { dataLayer } }) { 13 19 await auth.requireAuth(); 14 20 21 + const state = new ReactiveStore("feedsView"); 22 + state.$draftOrder = new Signal.State(null); 23 + state.$isEditing = new Signal.State(false); 24 + state.$isSaving = new Signal.State(false); 25 + 26 + function orderMatches(orderA, orderB) { 27 + if (orderA.length !== orderB.length) return false; 28 + for (let i = 0; i < orderA.length; i++) { 29 + if (orderA[i] !== orderB[i]) return false; 30 + } 31 + return true; 32 + } 33 + 34 + async function handleSave() { 35 + if (state.$isSaving.get()) return; 36 + const draft = state.$draftOrder.get(); 37 + if (draft) { 38 + state.$isSaving.set(true); 39 + try { 40 + await dataLayer.mutations.reorderPinnedItems(draft); 41 + } catch { 42 + showToast("Couldn't save feed order"); 43 + return; 44 + } finally { 45 + state.$isSaving.set(false); 46 + } 47 + } 48 + state.$draftOrder.set(null); 49 + state.$isEditing.set(false); 50 + } 51 + 52 + function cancelEditing() { 53 + if (state.$isSaving.get()) return; 54 + state.$draftOrder.set(null); 55 + state.$isEditing.set(false); 56 + } 57 + 58 + function rowTemplate({ item, currentUser, isEditing, isSaving }) { 59 + const dragHandle = isEditing 60 + ? html`<button 61 + class="feeds-list-item-drag-handle" 62 + data-testid="feeds-list-item-drag-handle" 63 + aria-label="Drag to reorder" 64 + ?disabled=${isSaving} 65 + > 66 + ${menuIconTemplate()} 67 + </button>` 68 + : ""; 69 + const value = valueForPinnedItem(item); 70 + if (item.type === "timeline") { 71 + return html`<div 72 + class="feeds-list-item" 73 + data-pinned-value=${value} 74 + data-testid="feeds-list-item-following" 75 + > 76 + <div class="feeds-list-item-avatar following-avatar"> 77 + ${homeIconTemplate({ filled: true })} 78 + </div> 79 + <div class="feeds-list-item-content"> 80 + <div class="feeds-list-item-title">Following</div> 81 + <div class="feeds-list-item-creator">Feed by @bsky.app</div> 82 + </div> 83 + ${dragHandle} 84 + </div>`; 85 + } 86 + if (item.type === "list") { 87 + return html`<container-link 88 + class="feeds-list-item clickable" 89 + data-testid="feeds-list-item-list" 90 + data-pinned-value=${value} 91 + href=${linkToList(item.data)} 92 + > 93 + <div class="feeds-list-item-avatar"> 94 + <img 95 + src=${item.data.avatar ?? "/img/list-avatar-fallback.svg"} 96 + alt=${item.data.name} 97 + class="feed-avatar" 98 + /> 99 + </div> 100 + <div class="feeds-list-item-content"> 101 + <div class="feeds-list-item-title">${item.data.name}</div> 102 + ${item.data.creator 103 + ? html`<div class="feeds-list-item-creator"> 104 + List by 105 + ${item.data.creator.did === currentUser?.did 106 + ? "you" 107 + : `@${item.data.creator.handle}`} 108 + </div>` 109 + : ""} 110 + </div> 111 + ${dragHandle} 112 + </container-link>`; 113 + } 114 + const feedGenerator = item.data; 115 + return html`<container-link 116 + class="feeds-list-item clickable" 117 + data-testid="feeds-list-item-feed" 118 + data-pinned-value=${value} 119 + href=${linkToFeed(feedGenerator)} 120 + > 121 + <div class="feeds-list-item-avatar"> 122 + <img 123 + src=${feedGenerator.avatar ?? "/img/list-avatar-fallback.svg"} 124 + alt=${feedGenerator.displayName} 125 + class="feed-avatar" 126 + /> 127 + </div> 128 + <div class="feeds-list-item-content"> 129 + <div class="feeds-list-item-title">${feedGenerator.displayName}</div> 130 + ${feedGenerator.creator 131 + ? html`<div class="feeds-list-item-creator"> 132 + Feed by 133 + ${feedGenerator.creator.did === currentUser?.did 134 + ? "you" 135 + : `@${feedGenerator.creator.handle}`} 136 + </div>` 137 + : ""} 138 + </div> 139 + ${dragHandle} 140 + </container-link>`; 141 + } 142 + 15 143 pageEffect(root, () => { 16 144 const currentUser = dataLayer.derived.$currentUser.get(); 17 145 const pinnedItems = dataLayer.derived.$hydratedPinnedItems.get(); 146 + const draftOrder = state.$draftOrder.get(); 147 + const isEditing = state.$isEditing.get(); 148 + const isSaving = state.$isSaving.get(); 149 + 150 + let orderedItems = pinnedItems; 151 + let persistedOrder = null; 152 + if (pinnedItems) { 153 + persistedOrder = pinnedItems.map(valueForPinnedItem); 154 + if (draftOrder) { 155 + const byValue = new Map( 156 + pinnedItems.map((it) => [valueForPinnedItem(it), it]), 157 + ); 158 + orderedItems = draftOrder 159 + .map((value) => byValue.get(value)) 160 + .filter(Boolean); 161 + } 162 + } 163 + 164 + const canEdit = (pinnedItems?.length ?? 0) >= 2; 18 165 19 166 render( 20 167 html`<div id="feeds-view"> 21 168 ${headerTemplate({ 22 169 title: "Feeds", 23 170 subtitle: "", 171 + rightItemTemplate: () => { 172 + if (!canEdit) return null; 173 + if (isEditing) { 174 + return html`<div class="header-actions"> 175 + <button 176 + class="rounded-button feeds-cancel-button" 177 + data-testid="feeds-cancel-button" 178 + ?disabled=${isSaving} 179 + @click=${cancelEditing} 180 + > 181 + Cancel 182 + </button> 183 + <button 184 + class="rounded-button rounded-button-primary feeds-save-button" 185 + data-testid="feeds-save-button" 186 + ?disabled=${isSaving} 187 + @click=${handleSave} 188 + > 189 + ${isSaving 190 + ? html`Saving<span class="loading-spinner"></span>` 191 + : "Save"} 192 + </button> 193 + </div>`; 194 + } 195 + return html`<button 196 + class="header-edit-button" 197 + data-testid="feeds-edit-button" 198 + aria-label="Reorder feeds" 199 + @click=${() => state.$isEditing.set(true)} 200 + > 201 + ${settingsIconTemplate()} 202 + </button>`; 203 + }, 24 204 })} 25 205 <main> 26 206 <div class="feeds-list-header">Pinned Feeds</div> 27 - <div class="feeds-list"> 28 - ${pinnedItems 29 - ? pinnedItems.map((item) => { 30 - if (item.type === "timeline") { 31 - return html` 32 - <div class="feeds-list-item"> 33 - <div class="feeds-list-item-avatar"> 34 - <img 35 - src="/img/list-avatar-fallback.svg" 36 - alt=${item.data.displayName} 37 - class="feed-avatar" 38 - /> 39 - </div> 40 - <div class="feeds-list-item-content"> 41 - <div class="feeds-list-item-title"> 42 - ${item.data.displayName} 43 - </div> 44 - <div class="feeds-list-item-creator"> 45 - Feed by @bsky.app 46 - </div> 47 - </div> 48 - </div> 49 - `; 50 - } 51 - if (item.type === "list") { 52 - return html` 53 - <container-link 54 - class="feeds-list-item clickable" 55 - data-testid="feeds-list-item-list" 56 - href=${linkToList(item.data)} 57 - > 58 - <div class="feeds-list-item-avatar"> 59 - ${item.data.avatar 60 - ? html`<img 61 - src=${item.data.avatar} 62 - alt=${item.data.name} 63 - class="feed-avatar" 64 - />` 65 - : html`<img 66 - src="/img/list-avatar-fallback.svg" 67 - alt=${item.data.name} 68 - class="feed-avatar" 69 - />`} 70 - </div> 71 - <div class="feeds-list-item-content"> 72 - <div class="feeds-list-item-title"> 73 - ${item.data.name} 74 - </div> 75 - ${item.data.creator 76 - ? html`<div class="feeds-list-item-creator"> 77 - List by 78 - ${item.data.creator.did === currentUser?.did 79 - ? "you" 80 - : `@${item.data.creator.handle}`} 81 - </div>` 82 - : ""} 83 - </div> 84 - </container-link> 85 - `; 86 - } 87 - return feedGeneratorListItemTemplate({ 88 - feedGenerator: item.data, 89 - currentUserDid: currentUser?.did, 90 - }); 91 - }) 207 + <div 208 + class="feeds-list" 209 + data-testid="feeds-list" 210 + ?data-editing=${isEditing} 211 + > 212 + ${orderedItems 213 + ? orderedItems.map((item) => 214 + rowTemplate({ 215 + item, 216 + currentUser, 217 + isEditing, 218 + isSaving, 219 + }), 220 + ) 92 221 : Array.from({ length: 5 }).map(() => 93 222 feedGeneratorListItemSkeletonTemplate(), 94 223 )} ··· 97 226 </div>`, 98 227 root, 99 228 ); 229 + 230 + const listEl = root.querySelector(".feeds-list"); 231 + if (listEl && orderedItems && isEditing && !isSaving) { 232 + enableReorder(listEl, { 233 + itemSelector: ".feeds-list-item", 234 + handleSelector: ".feeds-list-item-drag-handle", 235 + onReorder: (elements) => { 236 + const nextOrder = elements.map((el) => el.dataset.pinnedValue); 237 + const baseline = persistedOrder ?? []; 238 + if (orderMatches(nextOrder, baseline)) { 239 + state.$draftOrder.set(null); 240 + } else { 241 + state.$draftOrder.set(nextOrder); 242 + } 243 + }, 244 + }); 245 + } else if (listEl?.__reorderEnabled) { 246 + listEl.__reorderEnabled.cleanup(); 247 + } 100 248 }); 101 249 250 + function resetEditingState() { 251 + state.$draftOrder.set(null); 252 + state.$isEditing.set(false); 253 + } 254 + 102 255 root.addEventListener("page-enter", async () => { 256 + resetEditingState(); 103 257 await dataLayer.declarative.ensurePinnedItems(); 258 + }); 259 + 260 + root.addEventListener("page-exit", () => { 261 + resetEditingState(); 104 262 }); 105 263 106 264 root.addEventListener("page-restore", (e) => {
+11
src/js/views/home.view.js
··· 3 3 import { linkToProfile } from "/js/navigation.js"; 4 4 import { postFeedTemplate } from "/js/templates/postFeed.template.js"; 5 5 import { headerTemplate } from "/js/templates/header.template.js"; 6 + import { hashtagIconTemplate } from "/js/templates/icons/hashtagIcon.template.js"; 6 7 import { floatingComposeButtonTemplate } from "/js/templates/floatingComposeButton.template.js"; 7 8 import "/js/components/tab-bar.js"; 8 9 import { PostSeenObserver } from "/js/postSeenObserver.js"; ··· 225 226 showLoadingSpinner: isLoading, 226 227 leftButton: "menu", 227 228 onClickMenuButton: () => handleMenuClick(), 229 + rightItemTemplate: () => html` 230 + <a 231 + class="feeds-button" 232 + href="/feeds" 233 + aria-label="Feeds" 234 + data-testid="feeds-button" 235 + > 236 + ${hashtagIconTemplate()} 237 + </a> 238 + `, 228 239 bottomItemTemplate: () => html` 229 240 <tab-bar 230 241 .tabs=${pinnedItems.map((item) => ({
+291
tests/e2e/specs/views/feeds.view.test.js
··· 129 129 await expect(feedsView).toContainText("by @creator2.bsky.social"); 130 130 }); 131 131 132 + test.describe("Reorder pinned items", () => { 133 + async function dragRow(page, fromRow, toRow, { above = false } = {}) { 134 + const handle = fromRow.locator( 135 + '[data-testid="feeds-list-item-drag-handle"]', 136 + ); 137 + const handleBox = await handle.boundingBox(); 138 + const targetBox = await toRow.boundingBox(); 139 + const startX = handleBox.x + handleBox.width / 2; 140 + const startY = handleBox.y + handleBox.height / 2; 141 + const endX = startX; 142 + const endY = above 143 + ? targetBox.y + targetBox.height * 0.25 144 + : targetBox.y + targetBox.height * 0.75; 145 + 146 + await page.mouse.move(startX, startY); 147 + await page.mouse.down(); 148 + // Multi-step move so pointermove fires and siblings shift. 149 + const steps = 10; 150 + for (let i = 1; i <= steps; i++) { 151 + await page.mouse.move( 152 + startX + ((endX - startX) * i) / steps, 153 + startY + ((endY - startY) * i) / steps, 154 + ); 155 + } 156 + await page.mouse.up(); 157 + } 158 + 159 + test("gear button is hidden when there is only one pinned item", async ({ 160 + page, 161 + }) => { 162 + const mockServer = new MockServer(); 163 + await mockServer.setup(page); 164 + await login(page); 165 + await page.goto("/feeds"); 166 + 167 + const feedsView = page.locator("#feeds-view"); 168 + await expect(feedsView.locator(".feeds-list-item")).toHaveCount(1, { 169 + timeout: 10000, 170 + }); 171 + await expect( 172 + feedsView.locator('[data-testid="feeds-edit-button"]'), 173 + ).toHaveCount(0); 174 + }); 175 + 176 + test("drag handles are hidden until entering edit mode via the gear", async ({ 177 + page, 178 + }) => { 179 + const mockServer = new MockServer(); 180 + const feed = createFeedGenerator({ 181 + uri: "at://did:plc:a/app.bsky.feed.generator/a", 182 + displayName: "Feed A", 183 + creatorHandle: "creator-a.bsky.social", 184 + }); 185 + mockServer.addFeedGenerators([feed]); 186 + mockServer.setPinnedFeeds([feed.uri]); 187 + await mockServer.setup(page); 188 + await login(page); 189 + await page.goto("/feeds"); 190 + 191 + const feedsView = page.locator("#feeds-view"); 192 + await expect(feedsView.locator(".feeds-list-item")).toHaveCount(2, { 193 + timeout: 10000, 194 + }); 195 + await expect( 196 + feedsView.locator('[data-testid="feeds-list-item-drag-handle"]'), 197 + ).toHaveCount(0); 198 + await expect( 199 + feedsView.locator('[data-testid="feeds-edit-button"]'), 200 + ).toBeVisible(); 201 + await expect( 202 + feedsView.locator('[data-testid="feeds-save-button"]'), 203 + ).toHaveCount(0); 204 + 205 + await feedsView.locator('[data-testid="feeds-edit-button"]').click(); 206 + await expect( 207 + feedsView.locator('[data-testid="feeds-save-button"]'), 208 + ).toBeVisible(); 209 + await expect( 210 + feedsView.locator('[data-testid="feeds-list-item-drag-handle"]'), 211 + ).toHaveCount(2); 212 + }); 213 + 214 + test("dragging in edit mode + Save persists and reverts to gear", async ({ 215 + page, 216 + }) => { 217 + const mockServer = new MockServer(); 218 + const feedA = createFeedGenerator({ 219 + uri: "at://did:plc:a/app.bsky.feed.generator/a", 220 + displayName: "Feed A", 221 + creatorHandle: "creator-a.bsky.social", 222 + }); 223 + const feedB = createFeedGenerator({ 224 + uri: "at://did:plc:b/app.bsky.feed.generator/b", 225 + displayName: "Feed B", 226 + creatorHandle: "creator-b.bsky.social", 227 + }); 228 + mockServer.addFeedGenerators([feedA, feedB]); 229 + mockServer.setPinnedFeeds([feedA.uri, feedB.uri]); 230 + await mockServer.setup(page); 231 + await login(page); 232 + await page.goto("/feeds"); 233 + 234 + const feedsView = page.locator("#feeds-view"); 235 + await expect(feedsView.locator(".feeds-list-item")).toHaveCount(3, { 236 + timeout: 10000, 237 + }); 238 + 239 + await feedsView.locator('[data-testid="feeds-edit-button"]').click(); 240 + 241 + const followingRow = feedsView.locator( 242 + '[data-testid="feeds-list-item-following"]', 243 + ); 244 + const feedBRow = feedsView.locator(`[data-pinned-value="${feedB.uri}"]`); 245 + await dragRow(page, followingRow, feedBRow); 246 + 247 + const titlesAfterDrag = await feedsView 248 + .locator(".feeds-list-item-title") 249 + .allTextContents(); 250 + expect(titlesAfterDrag).toEqual(["Feed A", "Feed B", "Following"]); 251 + 252 + const putRequest = page.waitForRequest( 253 + (req) => 254 + req.url().includes("/xrpc/app.bsky.actor.putPreferences") && 255 + req.method() === "POST", 256 + ); 257 + await feedsView.locator('[data-testid="feeds-save-button"]').click(); 258 + const request = await putRequest; 259 + const body = request.postDataJSON(); 260 + const savedFeedsPref = body.preferences.find( 261 + (p) => p.$type === "app.bsky.actor.defs#savedFeedsPrefV2", 262 + ); 263 + const pinnedValues = savedFeedsPref.items 264 + .filter((it) => it.pinned) 265 + .map((it) => it.value); 266 + expect(pinnedValues).toEqual([feedA.uri, feedB.uri, "following"]); 267 + 268 + // After saving, the header reverts to the gear button. 269 + await expect( 270 + feedsView.locator('[data-testid="feeds-edit-button"]'), 271 + ).toBeVisible(); 272 + await expect( 273 + feedsView.locator('[data-testid="feeds-save-button"]'), 274 + ).toHaveCount(0); 275 + await expect( 276 + feedsView.locator('[data-testid="feeds-list-item-drag-handle"]'), 277 + ).toHaveCount(0); 278 + }); 279 + 280 + test("Save with no changes exits edit mode without a request", async ({ 281 + page, 282 + }) => { 283 + const mockServer = new MockServer(); 284 + const feedA = createFeedGenerator({ 285 + uri: "at://did:plc:a/app.bsky.feed.generator/a", 286 + displayName: "Feed A", 287 + creatorHandle: "creator-a.bsky.social", 288 + }); 289 + mockServer.addFeedGenerators([feedA]); 290 + mockServer.setPinnedFeeds([feedA.uri]); 291 + await mockServer.setup(page); 292 + await login(page); 293 + await page.goto("/feeds"); 294 + 295 + const feedsView = page.locator("#feeds-view"); 296 + await expect(feedsView.locator(".feeds-list-item")).toHaveCount(2, { 297 + timeout: 10000, 298 + }); 299 + 300 + let sawPut = false; 301 + page.on("request", (req) => { 302 + if (req.url().includes("/xrpc/app.bsky.actor.putPreferences")) { 303 + sawPut = true; 304 + } 305 + }); 306 + 307 + await feedsView.locator('[data-testid="feeds-edit-button"]').click(); 308 + await feedsView.locator('[data-testid="feeds-save-button"]').click(); 309 + 310 + await expect( 311 + feedsView.locator('[data-testid="feeds-edit-button"]'), 312 + ).toBeVisible(); 313 + expect(sawPut).toBe(false); 314 + }); 315 + 316 + test("Cancel discards the unsaved draft order and exits edit mode", async ({ 317 + page, 318 + }) => { 319 + const mockServer = new MockServer(); 320 + const feedA = createFeedGenerator({ 321 + uri: "at://did:plc:a/app.bsky.feed.generator/a", 322 + displayName: "Feed A", 323 + creatorHandle: "creator-a.bsky.social", 324 + }); 325 + const feedB = createFeedGenerator({ 326 + uri: "at://did:plc:b/app.bsky.feed.generator/b", 327 + displayName: "Feed B", 328 + creatorHandle: "creator-b.bsky.social", 329 + }); 330 + mockServer.addFeedGenerators([feedA, feedB]); 331 + mockServer.setPinnedFeeds([feedA.uri, feedB.uri]); 332 + await mockServer.setup(page); 333 + await login(page); 334 + await page.goto("/feeds"); 335 + 336 + const feedsView = page.locator("#feeds-view"); 337 + await expect(feedsView.locator(".feeds-list-item")).toHaveCount(3, { 338 + timeout: 10000, 339 + }); 340 + 341 + let sawPut = false; 342 + page.on("request", (req) => { 343 + if (req.url().includes("/xrpc/app.bsky.actor.putPreferences")) { 344 + sawPut = true; 345 + } 346 + }); 347 + 348 + await feedsView.locator('[data-testid="feeds-edit-button"]').click(); 349 + 350 + const followingRow = feedsView.locator( 351 + '[data-testid="feeds-list-item-following"]', 352 + ); 353 + const feedBRow = feedsView.locator(`[data-pinned-value="${feedB.uri}"]`); 354 + await dragRow(page, followingRow, feedBRow); 355 + 356 + await feedsView.locator('[data-testid="feeds-cancel-button"]').click(); 357 + 358 + await expect( 359 + feedsView.locator('[data-testid="feeds-edit-button"]'), 360 + ).toBeVisible(); 361 + await expect( 362 + feedsView.locator('[data-testid="feeds-save-button"]'), 363 + ).toHaveCount(0); 364 + await expect( 365 + feedsView.locator('[data-testid="feeds-cancel-button"]'), 366 + ).toHaveCount(0); 367 + 368 + const titlesAfterCancel = await feedsView 369 + .locator(".feeds-list-item-title") 370 + .allTextContents(); 371 + expect(titlesAfterCancel).toEqual(["Following", "Feed A", "Feed B"]); 372 + expect(sawPut).toBe(false); 373 + }); 374 + 375 + test("navigating away discards the unsaved draft order", async ({ 376 + page, 377 + }) => { 378 + const mockServer = new MockServer(); 379 + const feedA = createFeedGenerator({ 380 + uri: "at://did:plc:a/app.bsky.feed.generator/a", 381 + displayName: "Feed A", 382 + creatorHandle: "creator-a.bsky.social", 383 + }); 384 + const feedB = createFeedGenerator({ 385 + uri: "at://did:plc:b/app.bsky.feed.generator/b", 386 + displayName: "Feed B", 387 + creatorHandle: "creator-b.bsky.social", 388 + }); 389 + mockServer.addFeedGenerators([feedA, feedB]); 390 + mockServer.setPinnedFeeds([feedA.uri, feedB.uri]); 391 + await mockServer.setup(page); 392 + await login(page); 393 + await page.goto("/feeds"); 394 + 395 + const feedsView = page.locator("#feeds-view"); 396 + await expect(feedsView.locator(".feeds-list-item")).toHaveCount(3, { 397 + timeout: 10000, 398 + }); 399 + 400 + await feedsView.locator('[data-testid="feeds-edit-button"]').click(); 401 + 402 + const followingRow = feedsView.locator( 403 + '[data-testid="feeds-list-item-following"]', 404 + ); 405 + const feedBRow = feedsView.locator(`[data-pinned-value="${feedB.uri}"]`); 406 + await dragRow(page, followingRow, feedBRow); 407 + 408 + await page.goto("/settings"); 409 + await page.goto("/feeds"); 410 + await expect(feedsView.locator(".feeds-list-item")).toHaveCount(3, { 411 + timeout: 10000, 412 + }); 413 + const titlesAfterReturn = await feedsView 414 + .locator(".feeds-list-item-title") 415 + .allTextContents(); 416 + expect(titlesAfterReturn).toEqual(["Following", "Feed A", "Feed B"]); 417 + await expect( 418 + feedsView.locator('[data-testid="feeds-edit-button"]'), 419 + ).toBeVisible(); 420 + }); 421 + }); 422 + 132 423 test.describe("Logged-out behavior", () => { 133 424 test("should redirect to /login when not authenticated", async ({ 134 425 page,
+25
tests/e2e/specs/views/home.view.test.js
··· 81 81 }); 82 82 }); 83 83 84 + // The home header row is only visible on mobile widths; on desktop only the 85 + // tab bar row renders. 86 + test.describe("Feeds button (mobile viewport)", () => { 87 + test.use({ viewport: { width: 375, height: 667 } }); 88 + 89 + test("should navigate to the feeds view when clicking the feeds button", async ({ 90 + page, 91 + }) => { 92 + const mockServer = new MockServer(); 93 + await mockServer.setup(page); 94 + 95 + await login(page); 96 + await page.goto("/"); 97 + 98 + const view = page.locator("#home-view"); 99 + const feedsButton = view.locator('[data-testid="feeds-button"]'); 100 + await expect(feedsButton).toBeVisible(); 101 + 102 + await feedsButton.click(); 103 + 104 + await expect(page.locator("#feeds-view")).toBeVisible(); 105 + await expect(page).toHaveURL("/feeds"); 106 + }); 107 + }); 108 + 84 109 test("should display pinned feed tabs alongside Following", async ({ 85 110 page, 86 111 }) => {
+138
tests/unit/specs/dataLayer/mutations.test.js
··· 2036 2036 }); 2037 2037 }); 2038 2038 2039 + describe("reorderPinnedItems", () => { 2040 + const feedA = "at://did:plc:x/app.bsky.feed.generator/a"; 2041 + const feedB = "at://did:plc:x/app.bsky.feed.generator/b"; 2042 + const listA = "at://did:plc:x/app.bsky.graph.list/a"; 2043 + 2044 + function setup({ preloadPinnedItems = true } = {}) { 2045 + let updatedPreferences = null; 2046 + let resolveUpdate; 2047 + const updatePromise = new Promise((resolve) => { 2048 + resolveUpdate = resolve; 2049 + }); 2050 + const preferences = new Preferences( 2051 + [ 2052 + { 2053 + $type: "app.bsky.actor.defs#savedFeedsPrefV2", 2054 + items: [ 2055 + { 2056 + id: "1", 2057 + value: "following", 2058 + type: "timeline", 2059 + pinned: true, 2060 + }, 2061 + { id: "2", value: feedA, type: "feed", pinned: true }, 2062 + { id: "3", value: listA, type: "list", pinned: true }, 2063 + { id: "4", value: feedB, type: "feed", pinned: false }, 2064 + ], 2065 + }, 2066 + ], 2067 + [], 2068 + ); 2069 + const mockPreferencesProvider = { 2070 + requirePreferences: () => preferences, 2071 + updatePreferences: async (prefs) => { 2072 + updatedPreferences = prefs; 2073 + await updatePromise; 2074 + }, 2075 + }; 2076 + const dataStore = new DataStore(); 2077 + if (preloadPinnedItems) { 2078 + dataStore.$pinnedItems.set([ 2079 + { type: "timeline", data: { uri: "following" } }, 2080 + { type: "feed", data: { uri: feedA } }, 2081 + { type: "list", data: { uri: listA } }, 2082 + ]); 2083 + } 2084 + const patchStore = new PatchStore(dataStore); 2085 + const mutations = makeMutations( 2086 + {}, 2087 + dataStore, 2088 + patchStore, 2089 + mockPreferencesProvider, 2090 + ); 2091 + return { 2092 + mutations, 2093 + patchStore, 2094 + dataStore, 2095 + resolveUpdate, 2096 + getUpdatedPreferences: () => updatedPreferences, 2097 + }; 2098 + } 2099 + 2100 + it("does not touch $pinnedItems until the network round-trip resolves", async () => { 2101 + const { mutations, dataStore, resolveUpdate } = setup(); 2102 + const before = dataStore.$pinnedItems 2103 + .get() 2104 + .map((it) => (it.type === "timeline" ? "following" : it.data.uri)); 2105 + const promise = mutations.reorderPinnedItems([listA, "following", feedA]); 2106 + const during = dataStore.$pinnedItems 2107 + .get() 2108 + .map((it) => (it.type === "timeline" ? "following" : it.data.uri)); 2109 + assert.deepEqual(during, before); 2110 + resolveUpdate(); 2111 + await promise; 2112 + const after = dataStore.$pinnedItems 2113 + .get() 2114 + .map((it) => (it.type === "timeline" ? "following" : it.data.uri)); 2115 + assert.deepEqual(after, [listA, "following", feedA]); 2116 + }); 2117 + 2118 + it("sends the reordered preferences to updatePreferences", async () => { 2119 + const { mutations, resolveUpdate, getUpdatedPreferences } = setup(); 2120 + const promise = mutations.reorderPinnedItems([feedA, listA, "following"]); 2121 + resolveUpdate(); 2122 + await promise; 2123 + const items = getUpdatedPreferences().obj[0].items; 2124 + assert.deepEqual( 2125 + items.map((it) => it.value), 2126 + [feedA, listA, "following", feedB], 2127 + ); 2128 + }); 2129 + 2130 + it("leaves $pinnedItems untouched when updatePreferences rejects", async () => { 2131 + const preferences = new Preferences( 2132 + [ 2133 + { 2134 + $type: "app.bsky.actor.defs#savedFeedsPrefV2", 2135 + items: [ 2136 + { id: "1", value: feedA, type: "feed", pinned: true }, 2137 + { id: "2", value: listA, type: "list", pinned: true }, 2138 + ], 2139 + }, 2140 + ], 2141 + [], 2142 + ); 2143 + const error = new Error("network"); 2144 + const mockPreferencesProvider = { 2145 + requirePreferences: () => preferences, 2146 + updatePreferences: async () => { 2147 + throw error; 2148 + }, 2149 + }; 2150 + const dataStore = new DataStore(); 2151 + dataStore.$pinnedItems.set([ 2152 + { type: "feed", data: { uri: feedA } }, 2153 + { type: "list", data: { uri: listA } }, 2154 + ]); 2155 + const patchStore = new PatchStore(dataStore); 2156 + const mutations = makeMutations( 2157 + {}, 2158 + dataStore, 2159 + patchStore, 2160 + mockPreferencesProvider, 2161 + ); 2162 + const originalConsoleError = console.error; 2163 + console.error = () => {}; 2164 + try { 2165 + await assert.rejects( 2166 + () => mutations.reorderPinnedItems([listA, feedA]), 2167 + (err) => err === error, 2168 + ); 2169 + } finally { 2170 + console.error = originalConsoleError; 2171 + } 2172 + const after = dataStore.$pinnedItems.get().map((it) => it.data.uri); 2173 + assert.deepEqual(after, [feedA, listA]); 2174 + }); 2175 + }); 2176 + 2039 2177 describe("hidePost", () => { 2040 2178 const testPost = { uri: "at://did:plc:author/app.bsky.feed.post/1" }; 2041 2179
+54
tests/unit/specs/preferences.test.js
··· 255 255 }); 256 256 }); 257 257 258 + describe("Preferences.reorderPinnedItems", () => { 259 + const buildObj = () => [ 260 + { 261 + $type: "app.bsky.actor.defs#savedFeedsPrefV2", 262 + items: [ 263 + { id: "1", value: "following", type: "timeline", pinned: true }, 264 + { id: "2", value: "feed-a", type: "feed", pinned: true }, 265 + { id: "3", value: "list-a", type: "list", pinned: true }, 266 + { id: "4", value: "feed-unpinned", type: "feed", pinned: false }, 267 + ], 268 + }, 269 + ]; 270 + 271 + it("reorders the pinned slice and preserves unpinned entries at the end", () => { 272 + const preferences = new Preferences(buildObj(), []); 273 + const newPreferences = preferences.reorderPinnedItems([ 274 + "list-a", 275 + "following", 276 + "feed-a", 277 + ]); 278 + const items = Preferences.getSavedFeedsPreference(newPreferences.obj).items; 279 + assert.deepEqual( 280 + items.map((it) => it.value), 281 + ["list-a", "following", "feed-a", "feed-unpinned"], 282 + ); 283 + }); 284 + 285 + it("does not mutate the original preferences", () => { 286 + const preferences = new Preferences(buildObj(), []); 287 + preferences.reorderPinnedItems(["feed-a", "following", "list-a"]); 288 + const items = Preferences.getSavedFeedsPreference(preferences.obj).items; 289 + assert.deepEqual( 290 + items.map((it) => it.value), 291 + ["following", "feed-a", "list-a", "feed-unpinned"], 292 + ); 293 + }); 294 + 295 + it("drops unknown values and unpinned values silently", () => { 296 + const preferences = new Preferences(buildObj(), []); 297 + const newPreferences = preferences.reorderPinnedItems([ 298 + "feed-a", 299 + "unknown-value", 300 + "feed-unpinned", 301 + "following", 302 + "list-a", 303 + ]); 304 + const items = Preferences.getSavedFeedsPreference(newPreferences.obj).items; 305 + assert.deepEqual( 306 + items.map((it) => it.value), 307 + ["feed-a", "following", "list-a", "feed-unpinned"], 308 + ); 309 + }); 310 + }); 311 + 258 312 describe("Preferences.getLabelerDids", () => { 259 313 it("should return labeler DIDs from preferences", () => { 260 314 const obj = [