diff --git a/src/lib/components/AddDropdownMenu.svelte b/src/lib/components/AddDropdownMenu.svelte index 05c5def..afafdff 100644 --- a/src/lib/components/AddDropdownMenu.svelte +++ b/src/lib/components/AddDropdownMenu.svelte @@ -125,15 +125,7 @@ role="menuitem" > - Add RSS Feed - - + {/each} + + {/if} +{/snippet} + + {#if isAtLimit}

You've reached the maximum of {subscriptionsStore.maxSubscriptions} feeds. Remove some feeds to add new ones.

- {:else} - - {#if error} -

{error}

- {/if} + {:else if step === 'input'} + + {:else if step === 'select-feeds'} + + {:else if step === 'select-content' && selectedAccount} + + {/if} + + {#if error} +

{error}

{/if}
diff --git a/src/lib/components/EditFeedModal.svelte b/src/lib/components/EditFeedModal.svelte index e37477c..51d6746 100644 --- a/src/lib/components/EditFeedModal.svelte +++ b/src/lib/components/EditFeedModal.svelte @@ -129,6 +129,24 @@

Leave empty to use the auto-detected favicon

+ {#if subscription.feedUrl} +
+ Feed URL + {subscription.feedUrl} +
+ {/if} + +
+
+ Source + {subscription.source ?? 'manual'} +
+
+ Type + {subscription.sourceType ?? 'rss'} +
+
+
Preview: {#if previewUrl} @@ -241,6 +259,37 @@ margin: 0; } + .info-row { + display: flex; + gap: 1.5rem; + padding: 0.75rem; + background: var(--color-bg-secondary); + border-radius: 6px; + } + + .info-item { + display: flex; + flex-direction: column; + gap: 0.25rem; + } + + .info-label { + font-size: 0.75rem; + color: var(--color-text-secondary); + font-weight: 500; + } + + .info-value { + font-size: 0.875rem; + color: var(--color-text); + } + + .info-value.url { + word-break: break-all; + font-size: 0.8rem; + color: var(--color-text-secondary); + } + .icon-preview { display: flex; align-items: center; diff --git a/src/lib/components/FilteredViewModal.svelte b/src/lib/components/FilteredViewModal.svelte index 7a2417b..6244182 100644 --- a/src/lib/components/FilteredViewModal.svelte +++ b/src/lib/components/FilteredViewModal.svelte @@ -1,7 +1,6 @@ - - -
-

- You can follow up to {socialStore.followLimit} accounts. -

-

- You're currently following {socialStore.inAppFollowCount} of {socialStore.followLimit} accounts. -

- {#if auth.user?.tier !== 'supporter'} - - {/if} -
- {#snippet footer()} - - {/snippet} -
- - diff --git a/src/lib/components/FollowUserModal.svelte b/src/lib/components/FollowUserModal.svelte deleted file mode 100644 index 6f13621..0000000 --- a/src/lib/components/FollowUserModal.svelte +++ /dev/null @@ -1,337 +0,0 @@ - - - - - - - diff --git a/src/lib/components/ImportOPMLModal.svelte b/src/lib/components/ImportOPMLModal.svelte index 5796458..5ce0e09 100644 --- a/src/lib/components/ImportOPMLModal.svelte +++ b/src/lib/components/ImportOPMLModal.svelte @@ -62,7 +62,9 @@ } // Check which feeds already exist - const existing = new Set(subscriptionsStore.subscriptions.map((s) => s.feedUrl.toLowerCase())); + const existing = new Set( + subscriptionsStore.subscriptions.filter((s) => s.feedUrl).map((s) => s.feedUrl!.toLowerCase()) + ); existingUrls = existing; // Pre-select only non-duplicate feeds diff --git a/src/lib/components/NavigationDropdown.svelte b/src/lib/components/NavigationDropdown.svelte index 4ba081a..fc315b8 100644 --- a/src/lib/components/NavigationDropdown.svelte +++ b/src/lib/components/NavigationDropdown.svelte @@ -2,11 +2,9 @@ import { goto } from '$app/navigation'; import { page } from '$app/stores'; import { onMount, onDestroy } from 'svelte'; - import { profileService } from '$lib/services/profiles'; import { getFaviconUrl } from '$lib/utils/favicon'; import { sidebarStore } from '$lib/stores/sidebar.svelte'; import { subscriptionsStore } from '$lib/stores/subscriptions.svelte'; - import { socialStore } from '$lib/stores/social.svelte'; import { itemLabelsStore } from '$lib/stores/itemLabels.svelte'; import { sharesStore } from '$lib/stores/shares.svelte'; import { activityStore } from '$lib/stores/activity.svelte'; @@ -15,7 +13,6 @@ import { feedViewStore } from '$lib/stores/feedView.svelte'; import Icon from './Icon.svelte'; import AddDropdownMenu from './AddDropdownMenu.svelte'; - import type { BlueskyProfile } from '$lib/types'; interface Props { currentTitle: string; @@ -25,13 +22,9 @@ // Derive data from stores let subscriptions = $derived(subscriptionsStore.subscriptions); - // Use inAppFollows instead of followedUsers to show ALL followed accounts, - // not just those with shares/content - let followedUsers = $derived(socialStore.inAppFollows); let feedUnreadCounts = $derived(unreadCounts.feedCounts); let totalUnread = $derived(unreadCounts.totalArticles); - let sharerCounts = $derived(unreadCounts.sharerShareCounts); let savedCount = $derived(itemLabelsStore.savedCount); let sharedCount = $derived(sharesStore.userShares.size); @@ -47,9 +40,6 @@ // Use store for open state so it can be controlled externally (keyboard shortcut) let isOpen = $derived(sidebarStore.navigationDropdownOpen); - // Profiles cache for followed users - let userProfiles = $state>(new Map()); - // Check if we're on mobile function checkMobile() { isMobile = window.matchMedia('(max-width: 1000px)').matches; @@ -64,21 +54,6 @@ window.removeEventListener('resize', checkMobile); }); - // Load profiles for followed users when they change - $effect(() => { - const dids = followedUsers.map((u) => u.did); - for (const did of dids) { - if (!userProfiles.has(did)) { - profileService.getProfile(did).then((profile) => { - if (profile) { - userProfiles.set(did, profile); - userProfiles = new Map(userProfiles); - } - }); - } - } - }); - // Icon names type (matches Icon.svelte) type IconName = | 'inbox' @@ -87,19 +62,16 @@ | 'search' | 'bell' | 'settings' - | 'users' | 'rss' | 'newspaper' | 'plus' | 'filter' - | 'layers' - | 'share-2'; + | 'layers'; // Navigation item type type NavItem = | { type: 'view'; id: string; label: string; count?: number; icon: IconName } | { type: 'feed'; id: number; label: string; count: number; iconUrl: string | null } - | { type: 'user'; did: string; label: string; count: number; avatarUrl: string | null } | { type: 'utility'; id: string; label: string; count?: number; icon: IconName } | { type: 'action'; id: string; label: string; icon: IconName } | { type: 'filteredView'; id: number; label: string; icon: IconName }; @@ -120,7 +92,6 @@ { type: 'view', id: 'all', label: 'All', count: totalUnread, icon: 'inbox' }, { type: 'view', id: 'saved', label: 'Saved', count: savedCount, icon: 'bookmark' }, { type: 'view', id: 'shared', label: 'Shared', count: sharedCount, icon: 'share' }, - { type: 'utility', id: 'discover', label: 'Discover', icon: 'share-2' }, { type: 'utility', id: 'activity', label: 'Activity', count: activityCount, icon: 'bell' }, { type: 'utility', id: 'settings', label: 'Settings', icon: 'settings' }, ]; @@ -139,28 +110,18 @@ icon: 'plus', }; - const userItems: NavItem[] = followedUsers.map((u) => { - const profile = userProfiles.get(u.did); - return { - type: 'user' as const, - did: u.did, - label: - profile?.displayName || - u.displayName || - profile?.handle || - u.handle || - u.did.slice(0, 20) + '...', - count: sharerCounts.get(u.did) || 0, - avatarUrl: profile?.avatar || u.avatarUrl || null, - }; - }); - const feedItems: NavItem[] = subscriptions.map((s) => ({ type: 'feed' as const, id: s.id!, label: s.customTitle || s.title, count: feedUnreadCounts.get(s.id!) || 0, - iconUrl: s.customIconUrl || getFaviconUrl(s.siteUrl || s.feedUrl), + iconUrl: + s.customIconUrl || + (s.sourceType?.startsWith('atproto.') + ? s.siteUrl + ? getFaviconUrl(s.siteUrl) + : '/icons/icon-192.svg' + : getFaviconUrl(s.siteUrl || s.feedUrl || '')), })); // Filter by search query @@ -182,19 +143,6 @@ sections.push({ section: '', items: allViews }); } - const filteredUsers = userItems.filter(filterItem); - if (filteredUsers.length > 0 || filterSection('Following')) { - sections.push({ - section: 'Following', - icon: 'users', - onSectionClick: () => { - goto('/following'); - close(); - }, - items: filteredUsers, - }); - } - const filteredFeeds = feedItems.filter(filterItem); if (filteredFeeds.length > 0 || filterSection('Feeds')) { sections.push({ @@ -215,53 +163,43 @@ let flatItems = $derived(filteredItems.flatMap((s) => s.items)); // Derive the current view's icon for the trigger button - type TriggerIcon = - | { type: 'icon'; name: IconName } - | { type: 'favicon'; url: string } - | { type: 'avatar'; url: string }; + type TriggerIcon = { type: 'icon'; name: IconName } | { type: 'favicon'; url: string }; let currentIcon = $derived.by((): TriggerIcon => { const pathname = $page.url.pathname; // Utility pages (separate routes) - if (pathname === '/discover') return { type: 'icon', name: 'share-2' }; if (pathname === '/activity') return { type: 'icon', name: 'bell' }; if (pathname === '/settings') return { type: 'icon', name: 'settings' }; - if (pathname === '/following') return { type: 'icon', name: 'users' }; // Feed page filters (query params on /) const url = $page.url; const feed = url.searchParams.get('feed'); const saved = url.searchParams.get('saved'); const shared = url.searchParams.get('shared'); - const sharer = url.searchParams.get('sharer'); - const following = url.searchParams.get('following'); const feeds = url.searchParams.get('feeds'); const view = url.searchParams.get('view'); if (view) return { type: 'icon', name: 'filter' }; if (saved) return { type: 'icon', name: 'bookmark' }; if (shared) return { type: 'icon', name: 'share' }; - if (following) return { type: 'icon', name: 'users' }; if (feeds) return { type: 'icon', name: 'rss' }; if (feed) { const sub = subscriptions.find((s) => s.id === parseInt(feed)); if (sub) { - const iconUrl = sub.customIconUrl || getFaviconUrl(sub.siteUrl || sub.feedUrl); + const iconUrl = + sub.customIconUrl || + (sub.sourceType?.startsWith('atproto.') + ? sub.siteUrl + ? getFaviconUrl(sub.siteUrl) + : '/icons/icon-192.svg' + : getFaviconUrl(sub.siteUrl || sub.feedUrl || '')); return { type: 'favicon', url: iconUrl }; } return { type: 'icon', name: 'rss' }; } - if (sharer) { - const profile = userProfiles.get(sharer); - const user = followedUsers.find((u) => u.did === sharer); - const avatarUrl = profile?.avatar || user?.avatarUrl; - if (avatarUrl) return { type: 'avatar', url: avatarUrl }; - return { type: 'icon', name: 'users' }; - } - return { type: 'icon', name: 'inbox' }; }); @@ -271,17 +209,12 @@ const feed = url.searchParams.get('feed'); const saved = url.searchParams.get('saved'); const shared = url.searchParams.get('shared'); - const sharer = url.searchParams.get('sharer'); - const following = url.searchParams.get('following'); const feeds = url.searchParams.get('feeds'); const view = url.searchParams.get('view'); - const type = url.searchParams.get('type') as 'shares' | 'documents' | null; if (view) return { type: 'filteredView', id: parseInt(view) }; if (feed) return { type: 'feed', id: parseInt(feed) }; if (saved) return { type: 'saved' }; if (shared) return { type: 'shared' }; - if (sharer) return { type: 'sharer', id: sharer }; - if (following) return { type: 'following', contentType: type }; if (feeds) return { type: 'feeds' }; return { type: 'all' }; }); @@ -294,10 +227,7 @@ if (item.id === 'shared' && filter.type === 'shared') return true; if (item.id === 'feeds' && filter.type === 'feeds') return true; } - if (item.type === 'utility' && item.id === 'following' && $page.url.pathname === '/following') - return true; if (item.type === 'feed' && filter.type === 'feed' && filter.id === item.id) return true; - if (item.type === 'user' && filter.type === 'sharer' && filter.id === item.did) return true; if (item.type === 'filteredView' && filter.type === 'filteredView' && filter.id === item.id) return true; return false; @@ -367,8 +297,6 @@ url = `/?feed=${item.id}`; } else if (item.type === 'filteredView') { url = `/?view=${item.id}`; - } else if (item.type === 'user') { - url = `/?sharer=${item.did}`; } else if (item.type === 'utility') { url = `/${item.id}`; } @@ -491,8 +419,6 @@ {:else if currentIcon.type === 'favicon'} - {:else if currentIcon.type === 'avatar'} - {/if} {currentTitle} {/if} - {:else if item.type === 'user'} - {#if item.avatarUrl} - - {:else} - - {/if} {/if} {item.label} {#if item.type !== 'action' && item.type !== 'filteredView' && item.count && item.count > 0} @@ -653,12 +573,6 @@ {:else} {/if} - {:else if item.type === 'user'} - {#if item.avatarUrl} - - {:else} - - {/if} {/if} {item.label} {#if item.type !== 'action' && item.type !== 'filteredView' && item.count && item.count > 0} @@ -717,15 +631,6 @@ display: block; } - .trigger-avatar { - width: 18px; - height: 18px; - flex-shrink: 0; - border-radius: 50%; - object-fit: cover; - display: block; - } - .trigger-title { font-size: 1rem; font-weight: 600; @@ -1002,24 +907,6 @@ display: block; } - .user-avatar { - width: 18px; - height: 18px; - flex-shrink: 0; - border-radius: 50%; - object-fit: cover; - display: block; - } - - .user-avatar-placeholder { - width: 18px; - height: 18px; - flex-shrink: 0; - background: var(--color-border); - border-radius: 50%; - display: block; - } - .item-label { flex: 1; overflow: hidden; @@ -1209,22 +1096,6 @@ border-radius: 2px; } - :global(.mobile-portal .user-avatar) { - width: 18px; - height: 18px; - flex-shrink: 0; - border-radius: 50%; - object-fit: cover; - } - - :global(.mobile-portal .user-avatar-placeholder) { - width: 18px; - height: 18px; - flex-shrink: 0; - background: var(--color-border); - border-radius: 50%; - } - :global(.mobile-portal .item-label) { flex: 1; overflow: hidden; diff --git a/src/lib/components/Sidebar.svelte b/src/lib/components/Sidebar.svelte index 28fa76b..0f78691 100644 --- a/src/lib/components/Sidebar.svelte +++ b/src/lib/components/Sidebar.svelte @@ -4,7 +4,6 @@ import { auth } from '$lib/stores/auth.svelte'; import { sidebarStore } from '$lib/stores/sidebar.svelte'; import { subscriptionsStore } from '$lib/stores/subscriptions.svelte'; - import { socialStore } from '$lib/stores/social.svelte'; import { sharesStore } from '$lib/stores/shares.svelte'; import { feedStatusStore } from '$lib/stores/feedStatus.svelte'; import { articlesStore } from '$lib/stores/articles.svelte'; @@ -20,11 +19,10 @@ import AddDropdownMenu from './AddDropdownMenu.svelte'; import EditFeedModal from './EditFeedModal.svelte'; import ContextMenu from './sidebar/ContextMenu.svelte'; - import UserContextMenu from './sidebar/UserContextMenu.svelte'; import NavSection from './sidebar/NavSection.svelte'; import FeedItem from './sidebar/FeedItem.svelte'; - import UserItem from './sidebar/UserItem.svelte'; import ViewItem from './sidebar/ViewItem.svelte'; + import SidebarAddFeed from './sidebar/SidebarAddFeed.svelte'; import Icon from './Icon.svelte'; import type { Subscription } from '$lib/types'; @@ -39,11 +37,6 @@ let longPressTimer: ReturnType | null = null; let longPressTriggered = $state(false); - // User context menu state - let userContextMenu = $state<{ x: number; y: number; userDid: string } | null>(null); - let userLongPressTimer: ReturnType | null = null; - let userLongPressTriggered = $state(false); - // Edit modal state let editingSubscription = $state(null); let editModalOpen = $state(false); @@ -96,55 +89,10 @@ contextMenu = null; } - // User context menu handlers - function handleUserContextMenu(e: MouseEvent, userDid: string) { - e.preventDefault(); - userContextMenu = { x: e.clientX, y: e.clientY, userDid }; - } - - function handleUserTouchStart(e: TouchEvent, userDid: string) { - userLongPressTriggered = false; - const touch = e.touches[0]; - userLongPressTimer = setTimeout(() => { - userLongPressTriggered = true; - userContextMenu = { x: touch.clientX, y: touch.clientY, userDid }; - }, 500); - } - - function handleUserTouchEnd(e: TouchEvent) { - if (userLongPressTimer) { - clearTimeout(userLongPressTimer); - userLongPressTimer = null; - } - if (userLongPressTriggered) { - e.preventDefault(); - } - } - - function handleUserTouchMove() { - if (userLongPressTimer) { - clearTimeout(userLongPressTimer); - userLongPressTimer = null; - } - } - - function closeUserContextMenu() { - userContextMenu = null; - } - - async function handleUnfollowUser(userDid: string) { - if (confirm('Are you sure you want to unfollow this user?')) { - await socialStore.unfollowInApp(userDid); - } - } - function handleClickOutside(e: MouseEvent) { if (contextMenu) { closeContextMenu(); } - if (userContextMenu) { - closeUserContextMenu(); - } if (viewContextMenu) { closeViewContextMenu(); } @@ -154,9 +102,6 @@ if (contextMenu && e.key === 'Escape') { closeContextMenu(); } - if (userContextMenu && e.key === 'Escape') { - closeUserContextMenu(); - } if (viewContextMenu && e.key === 'Escape') { closeViewContextMenu(); } @@ -176,7 +121,6 @@ document.removeEventListener('click', handleClickOutside); document.removeEventListener('keydown', handleKeydown); if (longPressTimer) clearTimeout(longPressTimer); - if (userLongPressTimer) clearTimeout(userLongPressTimer); if (viewLongPressTimer) clearTimeout(viewLongPressTimer); document.body.classList.remove('sidebar-open-mobile'); }); @@ -193,44 +137,24 @@ let feedUnreadCounts = $derived(unreadCounts.feedCounts); let totalUnread = $derived(unreadCounts.totalArticles); - let sharerCounts = $derived(() => unreadCounts.sharerShareCounts); - let sharerDocCounts = $derived(() => unreadCounts.sharerDocCounts); // Current filter from URL let currentFilter = $derived(() => { // Only show feed filters as active on the home page if ($page.url.pathname !== '/') { - return { type: 'none' as const, contentType: null as 'shares' | 'documents' | null }; + return { type: 'none' as const }; } const view = $page.url.searchParams.get('view'); const feed = $page.url.searchParams.get('feed'); const starred = $page.url.searchParams.get('saved'); const shared = $page.url.searchParams.get('shared'); - const sharer = $page.url.searchParams.get('sharer'); - const following = $page.url.searchParams.get('following'); const feeds = $page.url.searchParams.get('feeds'); - const type = $page.url.searchParams.get('type') as 'shares' | 'documents' | null; - if (view) - return { - type: 'view' as const, - id: parseInt(view), - contentType: null as 'shares' | 'documents' | null, - }; - if (feed) - return { - type: 'feed' as const, - id: parseInt(feed), - contentType: null as 'shares' | 'documents' | null, - }; - if (starred) - return { type: 'saved' as const, contentType: null as 'shares' | 'documents' | null }; - if (shared) - return { type: 'shared' as const, contentType: null as 'shares' | 'documents' | null }; - if (following) return { type: 'following' as const, contentType: type }; - if (sharer) return { type: 'sharer' as const, id: sharer, contentType: type }; - if (feeds) - return { type: 'feeds' as const, contentType: null as 'shares' | 'documents' | null }; - return { type: 'all' as const, contentType: null as 'shares' | 'documents' | null }; + if (view) return { type: 'view' as const, id: parseInt(view) }; + if (feed) return { type: 'feed' as const, id: parseInt(feed) }; + if (starred) return { type: 'saved' as const }; + if (shared) return { type: 'shared' as const }; + if (feeds) return { type: 'feeds' as const }; + return { type: 'all' as const }; }); // Sort and optionally filter subscriptions by unread count (descending) @@ -246,38 +170,6 @@ return subs; }); - // Sort followed users: all in-app follows, sorted by unread items (shares + documents) then by DID - let sortedFollowedUsers = $derived(() => { - const shareCounts = sharerCounts(); - const docCounts = sharerDocCounts(); - // Use inAppFollows which includes ALL users we follow on Skyreader (not just those with shares) - let users = socialStore.inAppFollows - .map((f) => ({ did: f.did, source: 'inapp' as const })) - .sort((a, b) => { - const countA = (shareCounts.get(a.did) || 0) + (docCounts.get(a.did) || 0); - const countB = (shareCounts.get(b.did) || 0) + (docCounts.get(b.did) || 0); - const hasUnreadA = countA > 0; - const hasUnreadB = countB > 0; - - // Tier 1: accounts with unread items (shares or documents) - if (hasUnreadA && !hasUnreadB) return -1; - if (!hasUnreadA && hasUnreadB) return 1; - - // Within unread tier, sort by count descending - if (hasUnreadA && hasUnreadB) { - return countB - countA; - } - - // Tier 2: by DID (stable sort - profiles are fetched async in UserItem) - return a.did.localeCompare(b.did); - }); - if (sidebarStore.showOnlyUnread.shared) { - const totalCount = (did: string) => (shareCounts.get(did) || 0) + (docCounts.get(did) || 0); - users = users.filter((u) => totalCount(u.did) > 0); - } - return users; - }); - // Update sorted IDs in store for keyboard navigation $effect(() => { const sorted = sortedSubscriptions(); @@ -285,12 +177,6 @@ sidebarStore.setSortedFeedIds(ids); }); - $effect(() => { - const sorted = sortedFollowedUsers(); - const dids = sorted.map((u) => u.did); - sidebarStore.setSortedUserDids(dids); - }); - // View context menu state let viewContextMenu = $state<{ x: number; y: number; viewId: number } | null>(null); let viewLongPressTimer: ReturnType | null = null; @@ -342,19 +228,13 @@ } } - function selectFilter(type: string, id?: string | number, contentType?: 'shares' | 'documents') { + function selectFilter(type: string, id?: string | number) { const params = new URLSearchParams(); if (type === 'view' && id) params.set('view', String(id)); else if (type === 'feed' && id) params.set('feed', String(id)); else if (type === 'saved') params.set('saved', 'true'); else if (type === 'shared') params.set('shared', 'true'); - else if (type === 'following') { - params.set('following', 'true'); - if (contentType) params.set('type', contentType); - } else if (type === 'sharer' && id) { - params.set('sharer', String(id)); - if (contentType) params.set('type', contentType); - } else if (type === 'feeds') params.set('feeds', 'true'); + else if (type === 'feeds') params.set('feeds', 'true'); const query = params.toString(); goto(query ? `/?${query}` : '/'); @@ -429,16 +309,6 @@ {/if} - sidebarStore.closeMobile()} - > - - Discover - -
- - sidebarStore.toggleSection('shared')} - onLabelClick={() => { - goto('/following'); - sidebarStore.closeMobile(); - }} - onUnreadToggle={() => sidebarStore.toggleShowOnlyUnread('shared')} - onAdd={() => sidebarStore.openFollowUserModal()} - > - {@const totalShareCount = Array.from(sharerCounts().values()).reduce((a, b) => a + b, 0)} - {@const totalDocCount = Array.from(sharerDocCounts().values()).reduce((a, b) => a + b, 0)} - {@const filter = currentFilter()} - {@const allUsers = sortedFollowedUsers()} - {@const displayedUsers = allUsers.slice(0, 10)} - {#each displayedUsers as user (user.did)} - {@const shareCount = sharerCounts().get(user.did) || 0} - {@const docCount = sharerDocCounts().get(user.did) || 0} - {@const filter = currentFilter()} - selectFilter('sharer', user.did)} - onSelectShares={() => selectFilter('sharer', user.did, 'shares')} - onSelectDocuments={() => selectFilter('sharer', user.did, 'documents')} - onContextMenu={(e) => handleUserContextMenu(e, user.did)} - onTouchStart={(e) => handleUserTouchStart(e, user.did)} - onTouchEnd={handleUserTouchEnd} - onTouchMove={handleUserTouchMove} - onMoreClick={(e) => handleUserContextMenu(e, user.did)} - /> - {:else} -
No followed users
- {/each} - {#if allUsers.length > 10} -
...
- {/if} -
- sidebarStore.toggleShowOnlyUnread('feeds')} onAdd={() => sidebarStore.openAddFeedModal()} > + {#each sortedSubscriptions() as sub (sub.id)} {@const count = feedUnreadCounts.get(sub.id!) || 0} - {@const status = feedStatusStore.getStatus(sub.feedUrl)} + {@const status = sub.feedUrl ? feedStatusStore.getStatus(sub.feedUrl) : undefined} {@const loadingState = status?.status === 'pending' ? 'loading' : status?.status === 'error' || status?.status === 'circuit-open' ? 'error' : undefined} - {@const feedError = feedStatusStore.getStatusMessage(sub.feedUrl)} - {@const errorDetails = feedStatusStore.getErrorDetails(sub.feedUrl)} + {@const feedError = sub.feedUrl ? feedStatusStore.getStatusMessage(sub.feedUrl) : undefined} + {@const errorDetails = sub.feedUrl + ? feedStatusStore.getErrorDetails(sub.feedUrl) + : undefined} {/if} -{#if userContextMenu} - {@const userDid = userContextMenu.userDid} - handleUnfollowUser(userDid)} - onClose={closeUserContextMenu} - /> -{/if} - {#if viewContextMenu} @@ -824,12 +638,6 @@ height: 0.5rem; } - .more-indicator { - padding: 0.25rem 0.75rem; - font-size: 0.8125rem; - color: var(--color-text-secondary); - } - /* Mobile styles */ @media (max-width: 1000px) { .sidebar-backdrop { diff --git a/src/lib/components/feed/FilterToolbar.svelte b/src/lib/components/feed/FilterToolbar.svelte index 49da46a..9976ea3 100644 --- a/src/lib/components/feed/FilterToolbar.svelte +++ b/src/lib/components/feed/FilterToolbar.svelte @@ -4,7 +4,6 @@ import { feedViewStore } from '$lib/stores/feedView.svelte'; import { filteredViewsStore } from '$lib/stores/filteredViews.svelte'; import { subscriptionsStore } from '$lib/stores/subscriptions.svelte'; - import { socialStore } from '$lib/stores/social.svelte'; import { profileService } from '$lib/services/profiles'; import { getFaviconUrl } from '$lib/utils/favicon'; import { itemLabelsStore } from '$lib/stores/itemLabels.svelte'; @@ -22,38 +21,39 @@ } from '$lib/utils/sourceKeys'; import type { BlueskyProfile } from '$lib/types'; + // Derive unique account DIDs from atproto subscriptions + let accountDids = $derived(() => { + const dids = new Set(); + for (const sub of subscriptionsStore.subscriptions) { + if (sub.sourceType?.startsWith('atproto.') && sub.subjectDid) { + dids.add(sub.subjectDid); + } + } + return [...dids]; + }); + // Resolved profiles for accounts that only have DIDs let resolvedProfiles = $state>(new Map()); - // Resolve profiles when inAppFollows changes + // Resolve profiles when account DIDs change $effect(() => { - const follows = socialStore.inAppFollows; - const needsResolving = follows.filter( - (f) => !f.displayName && (!f.handle || f.handle === f.did || f.handle.startsWith('did:')) - ); + const dids = accountDids(); + const needsResolving = dids.filter((did) => !resolvedProfiles.has(did)); if (needsResolving.length > 0) { - profileService.getProfiles(needsResolving.map((f) => f.did)).then((profiles) => { + profileService.getProfiles(needsResolving).then((profiles) => { resolvedProfiles = profiles; }); } }); - function getAccountDisplayName(follow: { - did: string; - handle?: string; - displayName?: string; - }): string { - if (follow.displayName) return follow.displayName; - if (follow.handle && follow.handle !== follow.did && !follow.handle.startsWith('did:')) - return follow.handle; - const resolved = resolvedProfiles.get(follow.did); + function getAccountDisplayName(did: string): string { + const resolved = resolvedProfiles.get(did); if (resolved) return resolved.displayName || resolved.handle; - return follow.did; + return did; } - function getAccountAvatarUrl(follow: { did: string; avatarUrl?: string }): string | undefined { - if (follow.avatarUrl) return follow.avatarUrl; - const resolved = resolvedProfiles.get(follow.did); + function getAccountAvatarUrl(did: string): string | undefined { + const resolved = resolvedProfiles.get(did); return resolved?.avatar; } @@ -91,21 +91,21 @@ const term = feedSearch.toLowerCase(); return ( (sub.customTitle || sub.title).toLowerCase().includes(term) || - sub.feedUrl.toLowerCase().includes(term) + (sub.feedUrl?.toLowerCase().includes(term) ?? false) ); }) : subscriptionsStore.subscriptions ); - // Filtered follows based on search - let filteredFollows = $derived( + // Filtered accounts based on search + let filteredAccounts = $derived( accountSearch - ? socialStore.inAppFollows.filter((follow) => { + ? accountDids().filter((did) => { const term = accountSearch.toLowerCase(); - const displayName = getAccountDisplayName(follow).toLowerCase(); - return displayName.includes(term) || follow.did.toLowerCase().includes(term); + const displayName = getAccountDisplayName(did).toLowerCase(); + return displayName.includes(term) || did.toLowerCase().includes(term); }) - : socialStore.inAppFollows + : accountDids() ); // Tag filter state @@ -155,7 +155,7 @@ // All possible account keys let allAccountKeys = $derived( - socialStore.inAppFollows.flatMap((f) => ACCOUNT_SOURCE_KINDS.map(({ keyFn }) => keyFn(f.did))) + accountDids().flatMap((did) => ACCOUNT_SOURCE_KINDS.map(({ keyFn }) => keyFn(did))) ); let allFeedsSelected = $derived( @@ -450,7 +450,7 @@ {#if sub.id != null} {@const key = rssSourceKey(sub.id)} {@const iconUrl = - sub.customIconUrl || getFaviconUrl(sub.siteUrl || sub.feedUrl)} + sub.customIconUrl || getFaviconUrl(sub.siteUrl || sub.feedUrl || '')}
{#each ACCOUNT_SOURCE_KINDS as { kind, label, keyFn }} - {@const key = keyFn(follow.did)} + {@const key = keyFn(did)}
- - - {#if hasContent} - - - {#if isExpanded} - {#if hasShares} -
-

- - Shares -

- -
- {/if} - {#if hasDocuments} -
-

- - Articles -

- -
- {/if} - {/if} - {/if} -
- - diff --git a/src/lib/components/following/StandardSubscriptionsTab.svelte b/src/lib/components/following/StandardSubscriptionsTab.svelte deleted file mode 100644 index 273c42f..0000000 --- a/src/lib/components/following/StandardSubscriptionsTab.svelte +++ /dev/null @@ -1,481 +0,0 @@ - - -{#if standardError} -

{standardError}

-{/if} - - - {#if unaddedStandardSubs.length > 0} - - {/if} - -
- {#each standardSubscriptions as sub (sub.uri)} - {@const alreadyAdded = isAlreadySubscribed(sub.publication.url)} -
-
-

{sub.publication.name}

- - {sub.publication.url} - - {#if sub.publication.description} -

{sub.publication.description}

- {/if} -
-
- {#if alreadyAdded} - - - Added - - {:else} - - {/if} -
-
- {/each} -
-
- - { - feedPickerSub = null; - discoveredFeeds = []; - }} - title="Choose a Feed" -> -
-

Multiple RSS feeds were found for {feedPickerSub?.publication.name}:

-
- {#each discoveredFeeds as feed} - - {/each} -
-
-
- - diff --git a/src/lib/components/sidebar/FeedItem.svelte b/src/lib/components/sidebar/FeedItem.svelte index 0f83500..fec4705 100644 --- a/src/lib/components/sidebar/FeedItem.svelte +++ b/src/lib/components/sidebar/FeedItem.svelte @@ -41,8 +41,15 @@ onMoreClick, }: Props = $props(); + let isAtProto = $derived(subscription.sourceType?.startsWith('atproto.') ?? false); + let faviconUrl = $derived( - subscription.customIconUrl || getFaviconUrl(subscription.siteUrl || subscription.feedUrl) + subscription.customIconUrl || + (isAtProto + ? subscription.siteUrl + ? getFaviconUrl(subscription.siteUrl) + : '/icons/icon-192.svg' + : getFaviconUrl(subscription.siteUrl || subscription.feedUrl || '')) ); let faviconLoaded = $state(false); diff --git a/src/lib/components/sidebar/SidebarAddFeed.svelte b/src/lib/components/sidebar/SidebarAddFeed.svelte new file mode 100644 index 0000000..7d01078 --- /dev/null +++ b/src/lib/components/sidebar/SidebarAddFeed.svelte @@ -0,0 +1,886 @@ + + + + + diff --git a/src/lib/components/sidebar/UserContextMenu.svelte b/src/lib/components/sidebar/UserContextMenu.svelte deleted file mode 100644 index 44b42f1..0000000 --- a/src/lib/components/sidebar/UserContextMenu.svelte +++ /dev/null @@ -1,115 +0,0 @@ - - - - - diff --git a/src/lib/components/sidebar/UserItem.svelte b/src/lib/components/sidebar/UserItem.svelte deleted file mode 100644 index 9e60ed7..0000000 --- a/src/lib/components/sidebar/UserItem.svelte +++ /dev/null @@ -1,261 +0,0 @@ - - -
- - - - -
- - diff --git a/src/lib/components/sidebar/UserSearchCompact.svelte b/src/lib/components/sidebar/UserSearchCompact.svelte deleted file mode 100644 index d845de8..0000000 --- a/src/lib/components/sidebar/UserSearchCompact.svelte +++ /dev/null @@ -1,377 +0,0 @@ - - -
-
- @ - - {#if isSearching} -
- {/if} -
- - {#if showLimitWarning} -
- Follow limit reached ({socialStore.followLimit} max). - {#if auth.user?.tier !== 'supporter'} - to get raised limits. - {/if} -
- {/if} - - {#if isOpen && results.length > 0} - - {/if} -
- - diff --git a/src/lib/hooks/useFeedKeyboardShortcuts.svelte.ts b/src/lib/hooks/useFeedKeyboardShortcuts.svelte.ts index 2865f36..5f8ff7c 100644 --- a/src/lib/hooks/useFeedKeyboardShortcuts.svelte.ts +++ b/src/lib/hooks/useFeedKeyboardShortcuts.svelte.ts @@ -184,7 +184,7 @@ export function useFeedKeyboardShortcuts(params: KeyboardShortcutsParams) { } else { sharesStore.share( sub.rkey, - sub.feedUrl, + sub.feedUrl || '', article.guid, article.url, article.title, diff --git a/src/lib/services/api.ts b/src/lib/services/api.ts index b9ff564..a79abac 100644 --- a/src/lib/services/api.ts +++ b/src/lib/services/api.ts @@ -1,7 +1,5 @@ import type { - DiscoverUser, FeedItem, - FollowedUserDetailed, GroupedShare, ItemLabel, ItemLabelType, @@ -198,43 +196,6 @@ class ApiClient { return this.fetch(`/api/activity/reshares?${params}`); } - async getFollowedUsers( - cursor?: string, - limit?: number - ): Promise<{ - users: Array<{ - did: string; - source: 'bluesky' | 'inapp' | 'both'; - }>; - cursor: string | null; - }> { - const params = new URLSearchParams(); - if (cursor) params.set('cursor', cursor); - if (limit) params.set('limit', limit.toString()); - const query = params.toString(); - return this.fetch(`/api/social/following${query ? `?${query}` : ''}`); - } - - async getFollowingDetailed( - limit = 50, - offset = 0 - ): Promise<{ - users: FollowedUserDetailed[]; - nextOffset: number | null; - }> { - const params = new URLSearchParams({ - source: 'skyreader', - limit: limit.toString(), - offset: offset.toString(), - }); - return this.fetch(`/api/social/following-detailed?${params}`); - } - - async getDiscoverUsers(limit = 20): Promise<{ users: DiscoverUser[] }> { - const params = new URLSearchParams({ limit: limit.toString() }); - return this.fetch(`/api/discover?${params}`); - } - async getPopularShares( period: 'day' | 'week' | 'month' = 'week', cursor?: string, @@ -274,15 +235,34 @@ class ApiClient { return this.fetch('/api/shares/my'); } + // Content detection + async detectContent(did: string): Promise<{ + did: string; + publications: Array<{ + uri: string; + name: string; + url: string; + description?: string; + iconUrl?: string; + }>; + shareCount: number; + freestandingDocumentCount: number; + }> { + return this.fetch(`/api/social/detect-content?did=${encodeURIComponent(did)}`); + } + // Subscriptions async createSubscription(data: { rkey: string; - feedUrl: string; + feedUrl?: string; title?: string; siteUrl?: string; category?: string; tags?: string[]; source?: string; + sourceType?: string; + subjectDid?: string; + collectionNsid?: string; }): Promise<{ rkey: string; uri: string }> { return this.fetch('/api/subscriptions', { method: 'POST', @@ -319,33 +299,6 @@ class ApiClient { }); } - // Follows - async followUser(rkey: string, subject: string): Promise<{ rkey: string; uri: string }> { - return this.fetch('/api/social/follow', { - method: 'POST', - body: JSON.stringify({ rkey, subject }), - }); - } - - async unfollowUser(rkey: string): Promise<{ success: boolean }> { - return this.fetch(`/api/social/follow/${rkey}`, { - method: 'DELETE', - }); - } - - async listInAppFollows(): Promise<{ - follows: Array<{ - rkey: string; - did: string; - handle?: string; - displayName?: string; - avatarUrl?: string; - createdAt: number; - }>; - }> { - return this.fetch('/api/social/follows'); - } - // Shares async createShare(data: { rkey: string; diff --git a/src/lib/services/db.ts b/src/lib/services/db.ts index 8c2dd02..0c7b9c0 100644 --- a/src/lib/services/db.ts +++ b/src/lib/services/db.ts @@ -276,6 +276,12 @@ class SkyreaderDatabase extends Dexie { await tx.table('saved').bulkAdd(oldRows); } }); + + // Add sourceType and subjectDid indexes to subscriptions for AT Proto content streams + this.version(25).stores({ + subscriptions: + '++id, rkey, feedUrl, category, fetchStatus, source, localUpdatedAt, sourceType, subjectDid', + }); } } diff --git a/src/lib/services/feedFetcher.ts b/src/lib/services/feedFetcher.ts index 2b3d384..0ca6663 100644 --- a/src/lib/services/feedFetcher.ts +++ b/src/lib/services/feedFetcher.ts @@ -11,16 +11,20 @@ const GUIDS_PER_FEED = 10; * Returns true when the current title is a fallback (URL, hostname, etc.) * and the feed provides a real title. */ -function shouldUpdateTitle(currentTitle: string, feedUrl: string, fetchedTitle: string): boolean { +function shouldUpdateTitle( + currentTitle: string, + feedUrl: string | undefined, + fetchedTitle: string +): boolean { if (!fetchedTitle || fetchedTitle === 'Untitled Feed') return false; if (currentTitle === fetchedTitle) return false; // Update if current title is the feed URL - if (currentTitle === feedUrl) return true; + if (feedUrl && currentTitle === feedUrl) return true; // Update if current title is just a hostname try { - const hostname = new URL(feedUrl).hostname; + const hostname = feedUrl ? new URL(feedUrl).hostname : ''; if (currentTitle === hostname) return true; } catch { // ignore invalid URL @@ -64,7 +68,10 @@ export async function fetchAllFeeds( const feedRequests: Array<{ url: string; since_guids?: string[]; subscriptionId: number }> = []; for (const sub of subscriptions) { - if (!sub.id) continue; + if (!sub.id || !sub.feedUrl) continue; + + // Skip AT Proto subscriptions (they don't have RSS feeds) + if (sub.sourceType && sub.sourceType.startsWith('atproto.')) continue; // Skip feeds in circuit-breaker cooldown if (!feedStatusStore.canFetch(sub.feedUrl)) { @@ -168,7 +175,7 @@ export async function fetchSingleFeed( force = false, savedGuids: Set = new Set() ): Promise { - if (!subscription.id) { + if (!subscription.id || !subscription.feedUrl) { return { success: false, newArticles: 0 }; } @@ -208,7 +215,7 @@ export async function fetchSingleFeed( }; } catch (e) { const errorMessage = e instanceof Error ? e.message : 'Failed to fetch feed'; - feedStatusStore.markError(subscription.feedUrl, errorMessage); + if (subscription.feedUrl) feedStatusStore.markError(subscription.feedUrl, errorMessage); return { success: false, newArticles: 0 }; } } diff --git a/src/lib/services/liveDb.svelte.ts b/src/lib/services/liveDb.svelte.ts index 5207222..50fe5d8 100644 --- a/src/lib/services/liveDb.svelte.ts +++ b/src/lib/services/liveDb.svelte.ts @@ -264,7 +264,7 @@ class LiveDatabase { * Get a subscription by feed URL */ getSubscriptionByUrl(feedUrl: string): Subscription | undefined { - return this._subscriptions.find((s) => s.feedUrl.toLowerCase() === feedUrl.toLowerCase()); + return this._subscriptions.find((s) => s.feedUrl?.toLowerCase() === feedUrl.toLowerCase()); } /** diff --git a/src/lib/services/sync-queue.ts b/src/lib/services/sync-queue.ts index a5cd04f..a7dfd04 100644 --- a/src/lib/services/sync-queue.ts +++ b/src/lib/services/sync-queue.ts @@ -9,7 +9,6 @@ export type SyncCollection = | 'shares' | 'shareReading' | 'socialReading' - | 'follows' | 'label' | 'saved'; @@ -55,11 +54,6 @@ export interface SocialReadingPayload { itemTitle?: string; } -export interface FollowPayload { - rkey: string; - did: string; -} - export interface LabelPayload { itemKey: string; itemType: string; @@ -86,7 +80,6 @@ type SyncPayload = | SharePayload | ShareReadingPayload | SocialReadingPayload - | FollowPayload | LabelPayload | SavedPayload; @@ -416,9 +409,6 @@ class SyncQueue { case 'socialReading': await this.executeSocialReadingOperation(entry.operation, payload as SocialReadingPayload); break; - case 'follows': - await this.executeFollowOperation(entry.operation, payload as FollowPayload); - break; case 'label': await this.executeLabelOperation(entry.operation, payload as LabelPayload); break; @@ -529,20 +519,6 @@ class SyncQueue { } } - private async executeFollowOperation( - operation: SyncOperation, - payload: FollowPayload - ): Promise { - switch (operation) { - case 'create': - await api.followUser(payload.rkey, payload.did); - break; - case 'delete': - await api.unfollowUser(payload.rkey); - break; - } - } - private async executeLabelOperation( operation: SyncOperation, payload: LabelPayload diff --git a/src/lib/stores/app.svelte.ts b/src/lib/stores/app.svelte.ts index f3d43d9..c4159a0 100644 --- a/src/lib/stores/app.svelte.ts +++ b/src/lib/stores/app.svelte.ts @@ -68,7 +68,10 @@ function createAppManager() { ]); // Initialize feed statuses for existing subscriptions - const feedUrls = liveDb.subscriptions.map((s) => s.feedUrl); + const feedUrls = liveDb.subscriptions + .filter((s) => !s.sourceType?.startsWith('atproto.')) + .map((s) => s.feedUrl) + .filter((u): u is string => !!u); feedStatusStore.initializeFeeds(feedUrls); // Initialize pending count and process queue if online @@ -113,8 +116,6 @@ function createAppManager() { syncSubscriptions(), itemLabelsStore.load(), shareReadingStore.load(), - socialStore.loadFollowedUsers(), - socialStore.loadInAppFollowCount(), socialStore.loadFeed(true), ]); @@ -155,13 +156,16 @@ function createAppManager() { try { const response = await api.listRecords<{ - feedUrl: string; + feedUrl?: string; title?: string; siteUrl?: string; category?: string; tags?: string[]; createdAt: string; updatedAt?: string; + sourceType?: string; + subjectDid?: string; + collectionNsid?: string; }>('app.skyreader.feed.subscription'); // Build maps for comparison @@ -171,23 +175,28 @@ function createAppManager() { // Find added subscriptions (in remote but not local) for (const [rkey, record] of remoteByRkey) { if (!localByRkey.has(rkey)) { + const isAtProto = record.value.sourceType?.startsWith('atproto.'); const subscription: Subscription = { rkey, feedUrl: record.value.feedUrl, - title: record.value.title || record.value.feedUrl, + title: + record.value.title || record.value.feedUrl || record.value.subjectDid || 'Untitled', siteUrl: record.value.siteUrl, category: record.value.category, tags: record.value.tags || [], createdAt: record.value.createdAt, updatedAt: record.value.updatedAt, localUpdatedAt: Date.now(), - fetchStatus: 'pending', + fetchStatus: isAtProto ? 'ready' : 'pending', + sourceType: record.value.sourceType as Subscription['sourceType'], + subjectDid: record.value.subjectDid, + collectionNsid: record.value.collectionNsid, }; const id = await liveDb.addSubscription(subscription); - result.added.push(subscription.feedUrl); + result.added.push(subscription.feedUrl || ''); result.addedSubs.push({ ...subscription, id }); - feedStatusStore.markPending(subscription.feedUrl); + if (subscription.feedUrl) feedStatusStore.markPending(subscription.feedUrl); } } @@ -197,8 +206,8 @@ function createAppManager() { if (sub.id) { await liveDb.deleteSubscription(sub.id); } - result.removed.push(sub.feedUrl); - feedStatusStore.clearStatus(sub.feedUrl); + result.removed.push(sub.feedUrl || ''); + if (sub.feedUrl) feedStatusStore.clearStatus(sub.feedUrl); } } @@ -207,15 +216,17 @@ function createAppManager() { const local = localByRkey.get(rkey); if (local?.id) { // Check if anything changed + // Preserve local siteUrl if PDS record doesn't have it + const resolvedSiteUrl = record.value.siteUrl || local.siteUrl; const hasChanges = local.title !== (record.value.title || record.value.feedUrl) || - local.siteUrl !== record.value.siteUrl || + local.siteUrl !== resolvedSiteUrl || local.category !== record.value.category; if (hasChanges) { await liveDb.updateSubscription(local.id, { title: record.value.title || record.value.feedUrl, - siteUrl: record.value.siteUrl, + siteUrl: resolvedSiteUrl, category: record.value.category, tags: record.value.tags || [], updatedAt: record.value.updatedAt, diff --git a/src/lib/stores/feedView.svelte.ts b/src/lib/stores/feedView.svelte.ts index 5ca063e..02f4fa1 100644 --- a/src/lib/stores/feedView.svelte.ts +++ b/src/lib/stores/feedView.svelte.ts @@ -153,7 +153,13 @@ function createFeedViewStore() { } function getAllFollowDids(): string[] { - return socialStore.inAppFollows.map((f) => f.did); + const dids = new Set(); + for (const sub of subscriptionsStore.subscriptions) { + if (sub.sourceType?.startsWith('atproto.') && sub.subjectDid) { + dids.add(sub.subjectDid); + } + } + return [...dids]; } // Derived: effective filters (always uses toolbar state as the working copy) @@ -191,12 +197,27 @@ function createFeedViewStore() { return false; }); + // Derived: the subscription selected by feedFilter (if any) + let feedFilterSubscription = $derived.by(() => { + if (!feedFilter) return null; + const id = parseInt(feedFilter); + return subscriptionsStore.getById(id) ?? null; + }); + // Derived: view mode let viewMode = $derived.by((): ViewMode => { if (activeFilteredView) return 'combined'; if (sharedFilter) return 'userShares'; if (sharerFilter || followingFilter) return 'shares'; - if (feedFilter || savedFilter || feedsFilter) return 'articles'; + if (feedFilter) { + // AT Proto subscriptions show shares/documents, not articles + const sub = feedFilterSubscription; + if (sub?.sourceType === 'atproto.shares' || sub?.sourceType === 'atproto.documents') { + return 'shares'; + } + return 'articles'; + } + if (savedFilter || feedsFilter) return 'articles'; return 'combined'; }); @@ -305,11 +326,18 @@ function createFeedViewStore() { // Return empty if contentTypeFilter is 'documents' if (contentTypeFilter === 'documents') return []; + // When feedFilter points to an atproto.documents subscription, hide shares + const feedSub = feedFilterSubscription; + if (feedSub?.sourceType === 'atproto.documents') return []; + const shares = socialStore.shares; const sortOrder = fv.sortOrder; let filtered: SocialShare[]; - if (sharerFilter) { + if (feedSub?.sourceType === 'atproto.shares' && feedSub.subjectDid) { + // Filter to shares from this subscription's subject + filtered = shares.filter((s) => s.authorDid === feedSub.subjectDid); + } else if (sharerFilter) { filtered = shares.filter((s) => s.authorDid === sharerFilter); } else { filtered = [...shares]; @@ -364,13 +392,27 @@ function createFeedViewStore() { // Return empty if contentTypeFilter is 'shares' if (contentTypeFilter === 'shares') return []; + // When feedFilter points to an atproto.shares subscription, hide documents + const feedSub = feedFilterSubscription; + if (feedSub?.sourceType === 'atproto.shares') return []; + const docs = socialStore.documents; const sortOrder = fv.sortOrder; let filtered = [...docs]; - // Filter by author if sharerFilter is set - if (sharerFilter) { + if (feedSub?.sourceType === 'atproto.documents' && feedSub.subjectDid) { + // Filter to documents from this subscription's subject + filtered = filtered.filter((d) => d.authorDid === feedSub.subjectDid); + // If freestanding, only show documents not associated with a publication + if (feedSub.feedUrl === '__freestanding__') { + filtered = filtered.filter((d) => !d.siteUri || !d.siteUri.startsWith('at://')); + } else if (feedSub.feedUrl && feedSub.feedUrl.startsWith('at://')) { + // If publication-scoped (feedUrl is a publication AT URI), also filter by siteUri + filtered = filtered.filter((d) => d.siteUri === feedSub.feedUrl); + } + } else if (sharerFilter) { + // Filter by author if sharerFilter is set filtered = filtered.filter((d) => d.authorDid === sharerFilter); } diff --git a/src/lib/stores/sidebar.svelte.ts b/src/lib/stores/sidebar.svelte.ts index 107b1c2..3de44be 100644 --- a/src/lib/stores/sidebar.svelte.ts +++ b/src/lib/stores/sidebar.svelte.ts @@ -3,7 +3,6 @@ import { browser } from '$app/environment'; interface SidebarState { isOpen: boolean; // For mobile overlay addFeedModalOpen: boolean; - followUserModalOpen: boolean; // For follow user modal saveArticleModalOpen: boolean; // For save article by URL modal navigationDropdownOpen: boolean; // For navigation dropdown expandedSections: { @@ -16,16 +15,12 @@ interface SidebarState { }; // Sorted IDs for keyboard navigation (matches visual sidebar order) sortedFeedIds: number[]; - sortedUserDids: string[]; - // Expanded users in Following section - expandedUsers: Set; } function createSidebarStore() { let state = $state({ isOpen: false, addFeedModalOpen: false, - followUserModalOpen: false, saveArticleModalOpen: false, navigationDropdownOpen: false, expandedSections: { @@ -37,8 +32,6 @@ function createSidebarStore() { feeds: false, }, sortedFeedIds: [], - sortedUserDids: [], - expandedUsers: new Set(), }); // Restore from localStorage on init @@ -53,7 +46,6 @@ function createSidebarStore() { ...parsed.expandedSections, }; state.showOnlyUnread = parsed.showOnlyUnread ?? { shared: false, feeds: false }; - state.expandedUsers = new Set(parsed.expandedUsers ?? []); } catch { // Ignore parse errors } @@ -67,7 +59,6 @@ function createSidebarStore() { JSON.stringify({ expandedSections: state.expandedSections, showOnlyUnread: state.showOnlyUnread, - expandedUsers: Array.from(state.expandedUsers), }) ); } @@ -99,14 +90,6 @@ function createSidebarStore() { state.addFeedModalOpen = false; } - function openFollowUserModal() { - state.followUserModalOpen = true; - } - - function closeFollowUserModal() { - state.followUserModalOpen = false; - } - function openSaveArticleModal() { state.saveArticleModalOpen = true; } @@ -127,24 +110,6 @@ function createSidebarStore() { state.sortedFeedIds = ids; } - function setSortedUserDids(dids: string[]) { - state.sortedUserDids = dids; - } - - function toggleUserExpanded(did: string) { - if (state.expandedUsers.has(did)) { - state.expandedUsers.delete(did); - } else { - state.expandedUsers.add(did); - } - state.expandedUsers = new Set(state.expandedUsers); // Trigger reactivity - persist(); - } - - function isUserExpanded(did: string) { - return state.expandedUsers.has(did); - } - return { get isOpen() { return state.isOpen; @@ -152,9 +117,6 @@ function createSidebarStore() { get addFeedModalOpen() { return state.addFeedModalOpen; }, - get followUserModalOpen() { - return state.followUserModalOpen; - }, get saveArticleModalOpen() { return state.saveArticleModalOpen; }, @@ -170,28 +132,17 @@ function createSidebarStore() { get sortedFeedIds() { return state.sortedFeedIds; }, - get sortedUserDids() { - return state.sortedUserDids; - }, - get expandedUsers() { - return state.expandedUsers; - }, toggleMobile, closeMobile, toggleSection, toggleShowOnlyUnread, openAddFeedModal, closeAddFeedModal, - openFollowUserModal, - closeFollowUserModal, openSaveArticleModal, closeSaveArticleModal, toggleNavigationDropdown, closeNavigationDropdown, setSortedFeedIds, - setSortedUserDids, - toggleUserExpanded, - isUserExpanded, }; } diff --git a/src/lib/stores/social.svelte.ts b/src/lib/stores/social.svelte.ts index 5eb7a4f..9cbc08e 100644 --- a/src/lib/stores/social.svelte.ts +++ b/src/lib/stores/social.svelte.ts @@ -1,57 +1,18 @@ import { db } from '$lib/services/db'; -import { safeBulkAdd } from '$lib/services/safeDb.svelte'; +import { safeBulkPut } from '$lib/services/safeDb.svelte'; import { api } from '$lib/services/api'; import { profileService } from '$lib/services/profiles'; -import { syncQueue, type FollowPayload } from '$lib/services/sync-queue'; -import { syncStore } from './sync.svelte'; import { itemLabelsStore } from './itemLabels.svelte'; -import type { DiscoverUser, FollowedUserDetailed, SocialDocument, SocialShare } from '$lib/types'; -import { generateTid } from '$lib/utils/tid'; -import { auth } from './auth.svelte'; - -export interface FollowedUser { - did: string; - source: 'bluesky' | 'inapp' | 'both'; -} +import type { SocialDocument, SocialShare } from '$lib/types'; function createSocialStore() { let shares = $state([]); let documents = $state([]); let popularShares = $state<(SocialShare & { shareCount: number })[]>([]); - let followedUsers = $state([]); - let discoverUsers = $state([]); - let skyreaderFollows = $state([]); - let blueskyFollows = $state([]); - let skyreaderFollowsNextOffset = $state(null); - let blueskyFollowsCursor = $state(null); let isLoadingFeed = $state(false); - let isLoadingUsers = $state(false); - let isLoadingSkyreaderFollows = $state(false); - let isLoadingBlueskyFollows = $state(false); - let isDiscoverLoading = $state(false); let cursor = $state(null); let hasMore = $state(true); let error = $state(null); - let inAppFollowCount = $state(0); - let inAppFollows = $state< - Array<{ - rkey: string; - did: string; - handle?: string; - displayName?: string; - avatarUrl?: string; - createdAt: number; - }> - >([]); - - // Derived: any loading operation in progress - let isLoading = $derived(isLoadingFeed || isLoadingUsers); - - // Derived: follow limit from user tier (fallback to 50 for free) - let followLimit = $derived(auth.user?.limits?.maxFollows ?? 50); - - // Derived: whether we've hit the follow limit - let isAtFollowLimit = $derived(inAppFollowCount >= followLimit); async function loadFeed(reset = false) { if (isLoadingFeed || (!hasMore && !reset)) { @@ -69,17 +30,17 @@ function createSocialStore() { documents = result.documents || []; // Cache in IndexedDB await db.socialShares.clear(); - await safeBulkAdd(db.socialShares, result.shares); + await safeBulkPut(db.socialShares, result.shares); await db.socialDocuments.clear(); if (result.documents && result.documents.length > 0) { - await safeBulkAdd(db.socialDocuments, result.documents); + await safeBulkPut(db.socialDocuments, result.documents); } } else { shares = [...shares, ...result.shares]; documents = [...documents, ...(result.documents || [])]; - await safeBulkAdd(db.socialShares, result.shares); + await safeBulkPut(db.socialShares, result.shares); if (result.documents && result.documents.length > 0) { - await safeBulkAdd(db.socialDocuments, result.documents); + await safeBulkPut(db.socialDocuments, result.documents); } } @@ -121,348 +82,10 @@ function createSocialStore() { } } - async function loadFollowedUsers() { - isLoadingUsers = true; - error = null; - - try { - const result = await api.getFollowedUsers(); - followedUsers = result.users; - // Prefetch profiles from Bluesky (fire and forget) - profileService.prefetch(result.users.map((u) => u.did)); - } catch (e) { - error = e instanceof Error ? e.message : 'Failed to load followed users'; - } finally { - isLoadingUsers = false; - } - } - - async function loadInAppFollowCount() { - try { - const result = await api.listInAppFollows(); - inAppFollowCount = result.follows.length; - inAppFollows = result.follows; - - // Prefetch profiles for follows that only have a DID as their handle - const needsProfile = result.follows - .filter((f) => !f.handle || f.handle === f.did || f.handle.startsWith('did:')) - .map((f) => f.did); - if (needsProfile.length > 0) { - profileService.prefetch(needsProfile); - } - } catch (e) { - // Silently fail - the count will stay at its previous value - console.error('Failed to load in-app follow count:', e); - } - } - - async function loadDiscoverUsers() { - isDiscoverLoading = true; - error = null; - - try { - const result = await api.getDiscoverUsers(); - discoverUsers = result.users; - } catch (e) { - error = e instanceof Error ? e.message : 'Failed to load discover users'; - } finally { - isDiscoverLoading = false; - } - } - - async function loadSkyreaderFollows(reset = true) { - if (isLoadingSkyreaderFollows) return; - if (!reset && skyreaderFollowsNextOffset === null) return; - - isLoadingSkyreaderFollows = true; - error = null; - - try { - const offset = reset ? 0 : (skyreaderFollowsNextOffset ?? 0); - const result = await api.getFollowingDetailed(50, offset); - - if (reset) { - skyreaderFollows = result.users; - } else { - skyreaderFollows = [...skyreaderFollows, ...result.users]; - } - skyreaderFollowsNextOffset = result.nextOffset; - - // Prefetch profiles from Bluesky - profileService.prefetch(result.users.map((u) => u.did)); - } catch (e) { - error = e instanceof Error ? e.message : 'Failed to load Skyreader follows'; - } finally { - isLoadingSkyreaderFollows = false; - } - } - - async function loadBlueskyFollows(reset = true, userDid?: string) { - if (isLoadingBlueskyFollows) return; - if (!reset && blueskyFollowsCursor === null) return; - if (!userDid) return; - - isLoadingBlueskyFollows = true; - error = null; - - try { - const params = new URLSearchParams({ - actor: userDid, - limit: '50', - }); - if (!reset && blueskyFollowsCursor) { - params.set('cursor', blueskyFollowsCursor); - } - - const response = await fetch( - `https://public.api.bsky.app/xrpc/app.bsky.graph.getFollows?${params}` - ); - if (!response.ok) { - throw new Error(`Failed to fetch Bluesky follows: ${response.status}`); - } - - const data = (await response.json()) as { - follows: Array<{ - did: string; - handle: string; - displayName?: string; - avatar?: string; - }>; - cursor?: string; - }; - - // Map Bluesky follows into FollowedUserDetailed shape - // Check which are also followed in-app - const inappDids = new Set(inAppFollows.map((f) => f.did)); - const inappRkeys = new Map(inAppFollows.map((f) => [f.did, f.rkey])); - - const users: FollowedUserDetailed[] = data.follows.map((f) => ({ - did: f.did, - source: inappDids.has(f.did) ? 'both' : 'bluesky', - shareCount: 0, - lastSharedAt: null, - followedAt: 0, - rkey: inappRkeys.get(f.did), - })); - - if (reset) { - blueskyFollows = users; - } else { - blueskyFollows = [...blueskyFollows, ...users]; - } - blueskyFollowsCursor = data.cursor || null; - - // Prefetch profiles from Bluesky - profileService.prefetch(users.map((u) => u.did)); - } catch (e) { - error = e instanceof Error ? e.message : 'Failed to load Bluesky follows'; - } finally { - isLoadingBlueskyFollows = false; - } - } - - async function followUser(did: string): Promise { - const rkey = generateTid(); - - // Optimistic update - remove from discover - discoverUsers = discoverUsers.filter((u) => u.did !== did); - - // Optimistic update - add to followed users for sidebar - const existingUser = followedUsers.find((u) => u.did === did); - if (existingUser) { - // Update source to 'both' if already following on Bluesky - followedUsers = followedUsers.map((u) => - u.did === did ? { ...u, source: 'both' as const } : u - ); - } else { - // Add new follow - followedUsers = [...followedUsers, { did, source: 'inapp' as const }]; - } - - // Optimistic update - update blueskyFollows source to 'both' - const originalBlueskyFollow = blueskyFollows.find((u) => u.did === did); - if (originalBlueskyFollow) { - blueskyFollows = blueskyFollows.map((u) => - u.did === did ? { ...u, source: 'both' as const } : u - ); - } - - // Optimistic update - increment follow count - inAppFollowCount++; - - const payload: FollowPayload = { rkey, did }; - - if (syncStore.isOnline) { - try { - await api.followUser(rkey, did); - // Refresh followed users to get accurate data - await loadFollowedUsers(); - await loadInAppFollowCount(); - // Load social feed and read positions to include the new user's shares/articles - try { - await Promise.all([loadFeed(true), itemLabelsStore.load()]); - } catch (e) { - console.error('Failed to reload social feed after follow:', e); - // Don't propagate - follow succeeded, feed will load on next refresh - } - return true; - } catch (e) { - error = e instanceof Error ? e.message : 'Failed to follow user'; - // Revert optimistic update on error - if (existingUser) { - followedUsers = followedUsers.map((u) => - u.did === did ? { ...u, source: existingUser.source } : u - ); - } else { - followedUsers = followedUsers.filter((u) => u.did !== did); - } - // Revert blueskyFollows - if (originalBlueskyFollow) { - blueskyFollows = blueskyFollows.map((u) => - u.did === did ? { ...u, source: originalBlueskyFollow.source } : u - ); - } - // Revert count - inAppFollowCount--; - // Queue for retry - await syncQueue.enqueue('create', 'follows', did, payload); - return false; - } - } else { - // Offline - queue the operation - await syncQueue.enqueue('create', 'follows', did, payload); - return true; // Optimistically return success - } - } - - async function unfollowInApp(did: string): Promise { - // Need to get the rkey first - this requires being online - if (!syncStore.isOnline) { - error = 'Cannot unfollow while offline'; - return false; - } - - // Store original state for potential rollback - const originalUser = followedUsers.find((u) => u.did === did); - const originalCount = inAppFollowCount; - - // Optimistic update - update source or remove from followed users - if (originalUser?.source === 'both') { - // If following on both, change to bluesky-only - followedUsers = followedUsers.map((u) => - u.did === did ? { ...u, source: 'bluesky' as const } : u - ); - } else { - // If only following in-app, remove entirely - followedUsers = followedUsers.filter((u) => u.did !== did); - } - - // Optimistic update - remove from skyreaderFollows - const originalSkyreaderFollows = skyreaderFollows; - skyreaderFollows = skyreaderFollows.filter((u) => u.did !== did); - - // Optimistic update - update blueskyFollows source - const originalBlueskyFollowForUnfollow = blueskyFollows.find((u) => u.did === did); - if (originalBlueskyFollowForUnfollow) { - blueskyFollows = blueskyFollows.map((u) => - u.did === did ? { ...u, source: 'bluesky' as const } : u - ); - } - - // Optimistic update - decrement follow count - inAppFollowCount = Math.max(0, inAppFollowCount - 1); - - try { - // Get in-app follows with rkeys - const { follows } = await api.listInAppFollows(); - const followRecord = follows.find((f) => f.did === did); - - if (!followRecord) { - // Revert optimistic update - if (originalUser) { - if (originalUser.source === 'both') { - followedUsers = followedUsers.map((u) => - u.did === did ? { ...u, source: 'both' as const } : u - ); - } else { - followedUsers = [...followedUsers, originalUser]; - } - } - skyreaderFollows = originalSkyreaderFollows; - if (originalBlueskyFollowForUnfollow) { - blueskyFollows = blueskyFollows.map((u) => - u.did === did ? { ...u, source: originalBlueskyFollowForUnfollow.source } : u - ); - } - inAppFollowCount = originalCount; - error = 'Follow record not found'; - return false; - } - - const payload: FollowPayload = { rkey: followRecord.rkey, did }; - - try { - await api.unfollowUser(followRecord.rkey); - // Refresh followed users to get accurate data - await loadFollowedUsers(); - await loadInAppFollowCount(); - return true; - } catch (e) { - error = e instanceof Error ? e.message : 'Failed to unfollow user'; - // Revert optimistic update - if (originalUser) { - if (originalUser.source === 'both') { - followedUsers = followedUsers.map((u) => - u.did === did ? { ...u, source: 'both' as const } : u - ); - } else { - followedUsers = [...followedUsers, originalUser]; - } - } - skyreaderFollows = originalSkyreaderFollows; - if (originalBlueskyFollowForUnfollow) { - blueskyFollows = blueskyFollows.map((u) => - u.did === did ? { ...u, source: originalBlueskyFollowForUnfollow.source } : u - ); - } - inAppFollowCount = originalCount; - // Queue for retry - await syncQueue.enqueue('delete', 'follows', did, payload); - return false; - } - } catch (e) { - // Revert optimistic update - if (originalUser) { - if (originalUser.source === 'both') { - followedUsers = followedUsers.map((u) => - u.did === did ? { ...u, source: 'both' as const } : u - ); - } else { - followedUsers = [...followedUsers, originalUser]; - } - } - skyreaderFollows = originalSkyreaderFollows; - if (originalBlueskyFollowForUnfollow) { - blueskyFollows = blueskyFollows.map((u) => - u.did === did ? { ...u, source: originalBlueskyFollowForUnfollow.source } : u - ); - } - inAppFollowCount = originalCount; - error = e instanceof Error ? e.message : 'Failed to unfollow user'; - return false; - } - } - function reset() { shares = []; documents = []; popularShares = []; - followedUsers = []; - discoverUsers = []; - skyreaderFollows = []; - blueskyFollows = []; - skyreaderFollowsNextOffset = null; - blueskyFollowsCursor = null; cursor = null; hasMore = true; error = null; @@ -482,35 +105,8 @@ function createSocialStore() { get popularShares() { return popularShares; }, - get followedUsers() { - return followedUsers; - }, - get discoverUsers() { - return discoverUsers; - }, - get skyreaderFollows() { - return skyreaderFollows; - }, - get blueskyFollows() { - return blueskyFollows; - }, - get hasMoreSkyreaderFollows() { - return skyreaderFollowsNextOffset !== null; - }, - get hasMoreBlueskyFollows() { - return blueskyFollowsCursor !== null; - }, get isLoading() { - return isLoading; - }, - get isLoadingSkyreaderFollows() { - return isLoadingSkyreaderFollows; - }, - get isLoadingBlueskyFollows() { - return isLoadingBlueskyFollows; - }, - get isDiscoverLoading() { - return isDiscoverLoading; + return isLoadingFeed; }, get hasMore() { return hasMore; @@ -518,27 +114,8 @@ function createSocialStore() { get error() { return error; }, - get inAppFollowCount() { - return inAppFollowCount; - }, - get inAppFollows() { - return inAppFollows; - }, - get isAtFollowLimit() { - return isAtFollowLimit; - }, - get followLimit() { - return followLimit; - }, loadFeed, loadPopular, - loadFollowedUsers, - loadInAppFollowCount, - loadDiscoverUsers, - loadSkyreaderFollows, - loadBlueskyFollows, - followUser, - unfollowInApp, reset, getSharesByAuthor, }; diff --git a/src/lib/stores/subscriptions.svelte.ts b/src/lib/stores/subscriptions.svelte.ts index 0ec1186..736cba8 100644 --- a/src/lib/stores/subscriptions.svelte.ts +++ b/src/lib/stores/subscriptions.svelte.ts @@ -2,7 +2,7 @@ import { liveDb } from '$lib/services/liveDb.svelte'; import { feedStatusStore } from './feedStatus.svelte'; import { api } from '$lib/services/api'; import { auth } from './auth.svelte'; -import type { Subscription } from '$lib/types'; +import type { Subscription, SubscriptionSourceType } from '$lib/types'; // Generate a TID (Timestamp Identifier) for AT Protocol records function generateTid(): string { @@ -53,10 +53,10 @@ function createSubscriptionsStore() { } /** - * Add a new subscription + * Add a new subscription (RSS or AT Proto content stream) */ async function add( - feedUrl: string, + feedUrl: string | undefined, title: string, options?: Partial ): Promise { @@ -64,9 +64,24 @@ function createSubscriptionsStore() { throw new Error(`Feed limit reached. You can have up to ${maxSubscriptions} feeds.`); } + const isAtProto = options?.sourceType && options.sourceType.startsWith('atproto.'); + // Check for duplicate - if (liveDb.getSubscriptionByUrl(feedUrl)) { - throw new Error('You are already subscribed to this feed'); + if (isAtProto && options?.subjectDid && options?.sourceType) { + // For AT Proto subs, check by subjectDid + sourceType + feedUrl (publication URI) + const existing = subscriptions.find( + (s) => + s.sourceType === options.sourceType && + s.subjectDid === options.subjectDid && + (s.feedUrl || '') === (options.feedUrl || feedUrl || '') + ); + if (existing) { + throw new Error('You are already subscribed to this content stream'); + } + } else if (feedUrl) { + if (liveDb.getSubscriptionByUrl(feedUrl)) { + throw new Error('You are already subscribed to this feed'); + } } const rkey = generateTid(); @@ -75,11 +90,14 @@ function createSubscriptionsStore() { // Sync to backend first await api.createSubscription({ rkey, - feedUrl, + feedUrl: feedUrl || undefined, title, siteUrl: options?.siteUrl, category: options?.category, tags: options?.tags, + sourceType: options?.sourceType, + subjectDid: options?.subjectDid, + collectionNsid: options?.collectionNsid, }); // Store locally after successful backend sync @@ -92,12 +110,17 @@ function createSubscriptionsStore() { tags: options?.tags || [], createdAt: now, localUpdatedAt: Date.now(), - fetchStatus: 'pending', + fetchStatus: isAtProto ? 'ready' : 'pending', source: options?.source, + sourceType: options?.sourceType, + subjectDid: options?.subjectDid, + collectionNsid: options?.collectionNsid, }; const id = await liveDb.addSubscription(subscription); - feedStatusStore.markPending(feedUrl); + if (!isAtProto && feedUrl) { + feedStatusStore.markPending(feedUrl); + } return id; } @@ -127,7 +150,9 @@ function createSubscriptionsStore() { const source = options?.source || 'manual'; // Get existing feed URLs for duplicate detection - const existingUrls = new Set(subscriptions.map((s) => s.feedUrl.toLowerCase())); + const existingUrls = new Set( + subscriptions.filter((s) => s.feedUrl).map((s) => s.feedUrl!.toLowerCase()) + ); // Filter out duplicates first let feedsToAdd = feeds.filter((feed) => { @@ -270,7 +295,7 @@ function createSubscriptionsStore() { // Delete locally (includes articles) await liveDb.deleteSubscription(id); - feedStatusStore.clearStatus(sub.feedUrl); + if (sub.feedUrl) feedStatusStore.clearStatus(sub.feedUrl); } /** diff --git a/src/lib/types/index.ts b/src/lib/types/index.ts index 5103515..fdc5435 100644 --- a/src/lib/types/index.ts +++ b/src/lib/types/index.ts @@ -7,15 +7,20 @@ export interface User { tier?: string; limits?: { maxSubscriptions: number; - maxFollows: number; maxUrlSavesPerMonth: number; }; } +export type SubscriptionSourceType = + | 'rss' + | 'atproto.shares' + | 'atproto.documents' + | 'atproto.collection'; + export interface Subscription { id?: number; rkey: string; - feedUrl: string; + feedUrl?: string; // Required for RSS, optional for AT Proto subscriptions title: string; siteUrl?: string; category?: string; @@ -29,6 +34,9 @@ export interface Subscription { source?: 'manual' | 'opml'; customTitle?: string; // User-set title override (local only) customIconUrl?: string; // User-set icon override (local only) + sourceType?: SubscriptionSourceType; // Content source type; omitted = RSS + subjectDid?: string; // AT Protocol account DID; required for atproto.* types + collectionNsid?: string; // Collection NSID for atproto.collection (future) } export interface Article { @@ -717,44 +725,3 @@ export interface Highlight { selector: TextQuoteSelector; createdAt: number; // epoch ms } - -export interface DiscoverUser { - did: string; - handle: string; - displayName?: string; - avatarUrl?: string; - shareCount: number; - recentShares?: Array<{ - itemUrl: string; - itemTitle?: string; - createdAt: string; - }>; -} - -export interface InappFollow { - id?: number; - rkey?: string; - subjectDid: string; - createdAt: string; -} - -export interface FollowedUserDetailed { - did: string; - source: 'bluesky' | 'inapp' | 'both'; - shareCount: number; - lastSharedAt: string | null; - followedAt: number; - rkey?: string; - recentShares?: Array<{ - itemUrl: string; - itemTitle?: string; - createdAt: string; - }>; - documentCount?: number; - lastPublishedAt?: string | null; - recentDocuments?: Array<{ - url: string; - title: string; - publishedAt: string; - }>; -} diff --git a/src/lib/utils/opml-exporter.ts b/src/lib/utils/opml-exporter.ts index 410852f..13333f4 100644 --- a/src/lib/utils/opml-exporter.ts +++ b/src/lib/utils/opml-exporter.ts @@ -51,8 +51,8 @@ export function generateOPML(subscriptions: Subscription[]): string { } function buildOutline(sub: Subscription): string { - const title = sub.customTitle || sub.title || sub.feedUrl; - let outline = ` 0 ? `(${count}) ${suffix}` : suffix; }); - // Helper functions for feed/user cycling - function getCurrentFeedId(): number | null { - const feedParam = $page.url.searchParams.get('feed'); - return feedParam ? parseInt(feedParam) : null; - } - - function getCurrentSharerId(): string | null { - return $page.url.searchParams.get('sharer'); - } - + // Helper function for feed cycling function cycleFeeds(direction: 1 | -1) { // Use sorted feed IDs from sidebar store (matches visual order) const feedIds = sidebarStore.sortedFeedIds; if (feedIds.length === 0) return; - const currentFeedId = getCurrentFeedId(); + const feedParam = $page.url.searchParams.get('feed'); + const currentFeedId = feedParam ? parseInt(feedParam) : null; if (currentFeedId === null) { // Not on a feed view, go to first/last feed const targetId = direction === 1 ? feedIds[0] : feedIds[feedIds.length - 1]; @@ -58,45 +49,6 @@ goto(`/?feed=${feedIds[newIndex]}`); } - function cycleUsers(direction: 1 | -1) { - // Use sorted user DIDs from sidebar store (matches visual order) - const userDids = sidebarStore.sortedUserDids; - if (userDids.length === 0) return; - - const currentSharerId = getCurrentSharerId(); - if (currentSharerId === null) { - // Not on a sharer view, go to first/last user - const targetDid = direction === 1 ? userDids[0] : userDids[userDids.length - 1]; - goto(`/?sharer=${targetDid}`); - return; - } - - const currentIndex = userDids.indexOf(currentSharerId); - if (currentIndex === -1) { - // Current user not found in sorted list, go to first - goto(`/?sharer=${userDids[0]}`); - return; - } - - const newIndex = (currentIndex + direction + userDids.length) % userDids.length; - goto(`/?sharer=${userDids[newIndex]}`); - } - - // Determine whether to cycle feeds or users based on current view - function cycleSidebar(direction: 1 | -1) { - const feedParam = $page.url.searchParams.get('feed'); - const sharerParam = $page.url.searchParams.get('sharer'); - const followingParam = $page.url.searchParams.get('following'); - - // If on a specific sharer or following view, cycle users - if (sharerParam || followingParam) { - cycleUsers(direction); - } else { - // Otherwise cycle feeds - cycleFeeds(direction); - } - } - // Register global keyboard shortcuts on mount onMount(() => { // View switching shortcuts @@ -132,22 +84,6 @@ condition: () => auth.isAuthenticated, }); - keyboardStore.register({ - key: '5', - description: 'Following', - category: 'Views', - action: () => goto('/?following=true'), - condition: () => auth.isAuthenticated, - }); - - keyboardStore.register({ - key: '6', - description: 'Discover', - category: 'Views', - action: () => goto('/discover'), - condition: () => auth.isAuthenticated, - }); - keyboardStore.register({ key: '0', description: 'Settings', @@ -159,17 +95,17 @@ // Feed/user cycling shortcuts keyboardStore.register({ key: '[', - description: 'Previous feed/user', - category: 'Feed/User', - action: () => cycleSidebar(-1), + description: 'Previous feed', + category: 'Feed', + action: () => cycleFeeds(-1), condition: () => auth.isAuthenticated, }); keyboardStore.register({ key: ']', - description: 'Next feed/user', - category: 'Feed/User', - action: () => cycleSidebar(1), + description: 'Next feed', + category: 'Feed', + action: () => cycleFeeds(1), condition: () => auth.isAuthenticated, }); @@ -246,10 +182,6 @@ - sidebarStore.closeFollowUserModal()} -/>
{#if !auth.isLoading} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index a1fe83f..e477244 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -506,7 +506,7 @@ onShare={(article, sub) => sharesStore.share( sub.rkey, - sub.feedUrl, + sub.feedUrl || '', article.guid, article.url, article.title, diff --git a/src/routes/discover/+page.svelte b/src/routes/discover/+page.svelte deleted file mode 100644 index d7bfbab..0000000 --- a/src/routes/discover/+page.svelte +++ /dev/null @@ -1,305 +0,0 @@ - - -
- - - - - {#if socialStore.error} -

{socialStore.error}

- {/if} - - -
- {#each socialStore.discoverUsers as user (user.did)} - {@const isExpanded = expandedUsers.has(user.did)} - {@const hasShares = user.recentShares && user.recentShares.length > 0} -
-
- - - -
- - {#if hasShares} - - - {#if isExpanded} - - {/if} - {/if} -
- {/each} -
-
-
- - (showLimitModal = false)} /> - - diff --git a/src/routes/following/+page.svelte b/src/routes/following/+page.svelte deleted file mode 100644 index 8274d1d..0000000 --- a/src/routes/following/+page.svelte +++ /dev/null @@ -1,260 +0,0 @@ - - -
- - -
- -
- - {#if socialStore.error} -

{socialStore.error}

- {/if} - -
- - - -
- - {#if activeTab === 'standard'} - - {:else} - -
- {#each currentUsers as user (user.did)} - toggleExpanded(user.did)} - onFollow={handleFollow} - onUnfollow={handleUnfollow} - /> - {/each} -
- - -
- {/if} -
- - (showLimitModal = false)} /> - - diff --git a/src/routes/settings/+page.svelte b/src/routes/settings/+page.svelte index 671869e..2d68bcd 100644 --- a/src/routes/settings/+page.svelte +++ b/src/routes/settings/+page.svelte @@ -3,7 +3,6 @@ import { goto } from '$app/navigation'; import { auth } from '$lib/stores/auth.svelte'; import { subscriptionsStore } from '$lib/stores/subscriptions.svelte'; - import { socialStore } from '$lib/stores/social.svelte'; import { savesStore } from '$lib/stores/saves.svelte'; import { preferences, @@ -55,9 +54,6 @@ await subscriptionsStore.load(); } - // Load follow count for plan usage - socialStore.loadInAppFollowCount(); - // Load PDS sync settings await loadSyncSettings(); }); @@ -245,8 +241,6 @@ {#if auth.user.limits} {@const subCount = subscriptionsStore.subscriptions.length} {@const subLimit = auth.user.limits.maxSubscriptions} - {@const followCount = socialStore.inAppFollowCount} - {@const followLimit = auth.user.limits.maxFollows} {@const urlSaveLimit = auth.user.limits.maxUrlSavesPerMonth} {@const monthStart = new Date( Date.UTC(new Date().getUTCFullYear(), new Date().getUTCMonth(), 1) @@ -270,21 +264,6 @@
-
-
- Follows - {followCount} / {followLimit} -
-
-
0.8} - class:limit-bar-full={followCount >= followLimit} - style:width="{Math.min((followCount / followLimit) * 100, 100)}%" - >
-
-
-
URL saves this month