Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion frontend/src/lib/services/subscriptionDedup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -373,14 +373,31 @@ describe('findCrossTypeDuplicates', () => {
expect(findCrossTypeDuplicates([rss, std])).toEqual([]);
});

it('skips RSS subs whose feed has not resolved (no siteUrl)', () => {
it('pairs an RSS sub with no resolved siteUrl via its feedUrl host', () => {
// A feed subscribed before siteUrl tracking existed has no siteUrl; its
// feedUrl host is the only bridge, and it's the same host the add screen
// matches on. It must still surface as a cross-type duplicate.
const rss = sub({ id: 1, feedUrl: 'https://blog.example.com/feed.xml', siteUrl: undefined });
const std = sub({
id: 2,
sourceType: 'atproto.documents',
subjectDid: 'd',
siteUrl: 'https://blog.example.com',
});
const pairs = findCrossTypeDuplicates([rss, std]);
expect(pairs).toHaveLength(1);
expect(pairs[0].rss.id).toBe(1);
expect(pairs[0].host).toBe('blog.example.com');
});

it('skips RSS subs with neither a siteUrl nor a usable feedUrl host', () => {
const rss = sub({ id: 1, feedUrl: undefined, siteUrl: undefined });
const std = sub({
id: 2,
sourceType: 'atproto.documents',
subjectDid: 'd',
siteUrl: 'https://blog.example.com',
});
expect(findCrossTypeDuplicates([rss, std])).toEqual([]);
});

Expand Down
20 changes: 13 additions & 7 deletions frontend/src/lib/services/subscriptionDedup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,18 +198,24 @@ export interface CrossTypeDuplicate {
* callers must confirm before unifying.
*
* Each standard.site stream is paired with the oldest matching RSS sub. An RSS
* sub whose feed hasn't resolved yet (no `siteUrl`) can't match and is skipped.
* sub is indexed by every host it exposes — its resolved site (`siteUrl`) and
* its feed URL's host (`feedUrl`). The feedUrl host matters because a sub added
* before siteUrl tracking existed has no `siteUrl`, so its feedUrl is the only
* bridge; it's also the exact host the add screen matches on (it pairs on the
* typed feed URL, not the not-yet-resolved siteUrl), so indexing both keeps the
* /sources notices consistent with what the add flow already warns about.
*/
export function findCrossTypeDuplicates(subs: Subscription[]): CrossTypeDuplicate[] {
// Index RSS subs by normalized host, keeping the oldest per host (the one a
// racing add wouldn't have replaced).
// Index RSS subs by every host they expose, keeping the oldest per host (the
// one a racing add wouldn't have replaced).
const rssByHost = new Map<string, Subscription>();
for (const s of subs) {
if (s.sourceType && s.sourceType !== 'rss') continue;
const host = normalizeSiteHost(s.siteUrl);
if (!host) continue;
const current = rssByHost.get(host);
if (!current || isOlder(s, current)) rssByHost.set(host, s);
for (const host of [normalizeSiteHost(s.siteUrl), normalizeSiteHost(s.feedUrl)]) {
if (!host) continue;
const current = rssByHost.get(host);
if (!current || isOlder(s, current)) rssByHost.set(host, s);
}
}

const pairs: CrossTypeDuplicate[] = [];
Expand Down
Loading