From 328ea97d82798a015f8431d6790bbaa6b36537dc Mon Sep 17 00:00:00 2001 From: Bretton Date: Thu, 19 Feb 2026 12:52:59 -0800 Subject: [PATCH] feat(migration): migrate components from Lemmy to Coves data model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrate post, community, user, and feed components to use Coves ATProto types (PostView, CommunityView, AuthorView) instead of legacy Lemmy types. This completes the data model migration for the core UI layer. Changes: - API: add getPost() method, strengthen param types (string → DID/AtUri/Handle), narrow $type literals, make ImageEmbed.images a non-empty tuple - Utilities: add communityLink() and userLink() supporting both Coves and legacy types, add isImage/isVideo explicit return types, make fullCommunityName resilient to invalid URLs - Post components: migrate Post, PostItem, PostLink, PostMeta, PostVote, PostActions, PostActionsMenu, PostMedia, PostImage, PostMediaCompact to use Coves PostView and embed types - Post helpers: add embed extraction functions (extractEmbedUrl, extractEmbedThumbnail, extractEmbedTitle, extractEmbedAlt), computeVoteState(), mediaType() for Coves embeds - Community components: migrate CommunityCard, CommunityHeader, CommunityItem, CommunityItemBig, CommunityLink to CommunityView/ CommunityViewDetailed; add communityIdentifier/communityDisplayName helpers - User components: migrate UserItem, UserLink to AuthorView; remove legacy addSubscription - Feed types: update FeedTypes to Coves response shapes; add Feed.error state - Remove Lemmy-specific features: flair, NSFW badges, instance blocking, banned_from_community, resolveObject - Tests: add util.svelte.test.ts, helpers.test.ts; update client.test.ts with branded types Co-Authored-By: Claude Opus 4.6 --- src/lib/api/coves/client.test.ts | 42 +- src/lib/api/coves/client.ts | 7 + src/lib/api/coves/types.ts | 41 +- src/lib/app/util.svelte.test.ts | 168 +++++++ src/lib/app/util.svelte.ts | 80 ++- .../feature/community/CommunityCard.svelte | 238 +++------ .../feature/community/CommunityHeader.svelte | 231 +++------ .../feature/community/CommunityItem.svelte | 181 +++---- .../feature/community/CommunityItemBig.svelte | 183 +++---- .../feature/community/CommunityLink.svelte | 38 +- src/lib/feature/community/helpers.ts | 24 + src/lib/feature/feeds/feed.svelte.ts | 98 ++-- src/lib/feature/post/Post.svelte | 99 ++-- src/lib/feature/post/PostItem.svelte | 2 +- src/lib/feature/post/PostLink.svelte | 10 +- src/lib/feature/post/PostMeta.svelte | 151 ++---- src/lib/feature/post/PostVote.svelte | 141 +++--- .../feature/post/actions/PostActions.svelte | 164 ++----- .../post/actions/PostActionsMenu.svelte | 111 ++--- src/lib/feature/post/feed/PostFeed.svelte | 14 +- src/lib/feature/post/feed/VirtualFeed.svelte | 66 ++- src/lib/feature/post/helpers.test.ts | 461 ++++++++++++++++++ src/lib/feature/post/helpers.ts | 234 +++++++-- src/lib/feature/post/index.ts | 3 +- src/lib/feature/post/media/PostImage.svelte | 34 +- src/lib/feature/post/media/PostMedia.svelte | 55 ++- .../post/media/PostMediaCompact.svelte | 72 +-- src/lib/feature/user/UserItem.svelte | 23 +- src/lib/feature/user/UserLink.svelte | 50 +- src/lib/feature/user/index.ts | 37 +- 30 files changed, 1700 insertions(+), 1358 deletions(-) create mode 100644 src/lib/app/util.svelte.test.ts create mode 100644 src/lib/feature/community/helpers.ts create mode 100644 src/lib/feature/post/helpers.test.ts diff --git a/src/lib/api/coves/client.test.ts b/src/lib/api/coves/client.test.ts index 3c8395af..8bb74cd0 100644 --- a/src/lib/api/coves/client.test.ts +++ b/src/lib/api/coves/client.test.ts @@ -49,10 +49,10 @@ describe('Feed methods', () => { }) it('getCommunityFeed() calls query with correct NSID', async () => { - await client.getCommunityFeed({ community: 'tech' }) + await client.getCommunityFeed({ community: 'did:plc:tech' as DID }) expect(querySpy).toHaveBeenCalledWith(NSID.getCommunityFeed, { - community: 'tech', + community: 'did:plc:tech', }) }) }) @@ -63,7 +63,7 @@ describe('Feed methods', () => { describe('Comment methods', () => { it('getComments() calls query with correct NSID', async () => { - await client.getComments({ post: 'at://did:plc:abc/post/1' }) + await client.getComments({ post: 'at://did:plc:abc/post/1' as AtUri }) expect(querySpy).toHaveBeenCalledWith(NSID.getComments, { post: 'at://did:plc:abc/post/1', @@ -137,7 +137,7 @@ describe('Vote methods', () => { describe('Actor methods', () => { it('getProfile() calls query with correct NSID', async () => { - await client.getProfile({ actor: 'did:plc:abc123' }) + await client.getProfile({ actor: 'did:plc:abc123' as DID }) expect(querySpy).toHaveBeenCalledWith(NSID.getProfile, { actor: 'did:plc:abc123', @@ -145,7 +145,7 @@ describe('Actor methods', () => { }) it('getActorPosts() calls query with correct NSID', async () => { - await client.getActorPosts({ actor: 'did:plc:abc123', limit: 10 }) + await client.getActorPosts({ actor: 'did:plc:abc123' as DID, limit: 10 }) expect(querySpy).toHaveBeenCalledWith(NSID.getActorPosts, { actor: 'did:plc:abc123', @@ -154,7 +154,7 @@ describe('Actor methods', () => { }) it('getActorComments() calls query with correct NSID', async () => { - await client.getActorComments({ actor: 'did:plc:abc123' }) + await client.getActorComments({ actor: 'did:plc:abc123' as DID }) expect(querySpy).toHaveBeenCalledWith(NSID.getActorComments, { actor: 'did:plc:abc123', @@ -184,10 +184,10 @@ describe('Actor methods', () => { describe('Community methods', () => { it('getCommunity() calls query with correct NSID', async () => { - await client.getCommunity({ community: 'tech' }) + await client.getCommunity({ community: 'did:plc:tech' as DID }) expect(querySpy).toHaveBeenCalledWith(NSID.getCommunity, { - community: 'tech', + community: 'did:plc:tech', }) }) @@ -219,34 +219,34 @@ describe('Community methods', () => { }) it('subscribe() calls procedure with correct NSID', async () => { - await client.subscribe({ community: 'tech' }) + await client.subscribe({ community: 'did:plc:tech' as DID }) expect(procedureSpy).toHaveBeenCalledWith(NSID.subscribe, { - community: 'tech', + community: 'did:plc:tech', }) }) it('unsubscribe() calls procedure with correct NSID', async () => { - await client.unsubscribe({ community: 'tech' }) + await client.unsubscribe({ community: 'did:plc:tech' as DID }) expect(procedureSpy).toHaveBeenCalledWith(NSID.unsubscribe, { - community: 'tech', + community: 'did:plc:tech', }) }) it('blockCommunity() calls procedure with correct NSID', async () => { - await client.blockCommunity({ community: 'spam' }) + await client.blockCommunity({ community: 'did:plc:spam' as DID }) expect(procedureSpy).toHaveBeenCalledWith(NSID.blockCommunity, { - community: 'spam', + community: 'did:plc:spam', }) }) it('unblockCommunity() calls procedure with correct NSID', async () => { - await client.unblockCommunity({ community: 'spam' }) + await client.unblockCommunity({ community: 'did:plc:spam' as DID }) expect(procedureSpy).toHaveBeenCalledWith(NSID.unblockCommunity, { - community: 'spam', + community: 'did:plc:spam', }) }) }) @@ -258,7 +258,7 @@ describe('Community methods', () => { describe('Post methods', () => { it('createPost() calls procedure with correct NSID', async () => { const input = { - community: 'tech', + community: 'did:plc:tech' as DID, title: 'Hello World', content: 'My first post', } @@ -274,4 +274,12 @@ describe('Post methods', () => { uri: 'at://did:plc:abc/post/1', }) }) + + it('getPost() calls query with correct NSID', async () => { + await client.getPost({ uri: 'at://did:plc:abc/post/1' as AtUri }) + + expect(querySpy).toHaveBeenCalledWith(NSID.getPost, { + uri: 'at://did:plc:abc/post/1', + }) + }) }) diff --git a/src/lib/api/coves/client.ts b/src/lib/api/coves/client.ts index f519aef8..6ea52c77 100644 --- a/src/lib/api/coves/client.ts +++ b/src/lib/api/coves/client.ts @@ -21,11 +21,13 @@ import type { GetCommentsResponse, GetCommunityFeedParams, GetCommunityParams, + GetPostParams, GetDiscoverParams, GetProfileParams, GetTimelineParams, ListCommunitiesParams, ListCommunitiesResponse, + PostView, ProfileViewDetailed, SearchCommunitiesParams, SubscribeCommunityInput, @@ -55,6 +57,7 @@ export const NSID = { unblockCommunity: 'social.coves.community.unblockCommunity', createPost: 'social.coves.community.post.create', deletePost: 'social.coves.community.post.delete', + getPost: 'social.coves.community.post.get', } as const export class CovesClient { @@ -167,4 +170,8 @@ export class CovesClient { deletePost(input: { uri: AtUri }): Promise { return this.xrpc.procedure(NSID.deletePost, input) } + + getPost(params: GetPostParams): Promise { + return this.xrpc.query(NSID.getPost, params) + } } diff --git a/src/lib/api/coves/types.ts b/src/lib/api/coves/types.ts index 6df8674b..ecb64f14 100644 --- a/src/lib/api/coves/types.ts +++ b/src/lib/api/coves/types.ts @@ -28,7 +28,8 @@ export function tryAsAtUri(value: string): AtUri | null { export function isValidCID(value: string): value is CID { // Permissive check: CIDs can be multibase-encoded (base32, base58btc, base64, etc.) // Full validation would require decoding; this just rejects obviously invalid strings. - return value.length > 0 && /^[a-zA-Z0-9+/=]+$/.test(value) + // Real CIDs are at least 8 characters (multicodec prefix + hash digest). + return value.length >= 8 && /^[a-zA-Z0-9+/=]+$/.test(value) } export function asCID(value: string): CID { @@ -45,7 +46,7 @@ export function tryAsCID(value: string): CID | null { // --------------------------------------------------------------------------- export interface PostRecord { - $type: string + $type: 'social.coves.community.post' community: string author: string createdAt: string @@ -98,6 +99,10 @@ export interface PostStats { tagCounts?: Record } +// TODO: Refactor to a discriminated union to enforce vote/voteUri correlation: +// { vote: 'up' | 'down'; voteUri: AtUri } | { vote?: undefined; voteUri?: undefined } +// Blocked by PostVote.svelte castVote() which independently mutates vote and voteUri +// on a spread copy, which is incompatible with discriminated union assignment rules. export interface PostViewerState { saved: boolean vote?: 'up' | 'down' @@ -289,7 +294,7 @@ export interface EmbedImage { export interface ImageEmbed { $type: 'social.coves.embed.images#view' - images: EmbedImage[] + images: [EmbedImage, ...EmbedImage[]] } export interface ExternalEmbedSource { @@ -349,7 +354,7 @@ export type GetDiscoverParams = FeedPaginationParams export type GetTimelineParams = FeedPaginationParams export interface GetCommunityFeedParams extends FeedPaginationParams { - community: string + community: DID } export interface FeedResponse { @@ -362,7 +367,7 @@ export interface FeedResponse { // --------------------------------------------------------------------------- export interface GetCommentsParams { - post: string + post: AtUri sort?: string depth?: number limit?: number @@ -380,13 +385,13 @@ export interface GetCommentsResponse { // --------------------------------------------------------------------------- export interface GetProfileParams { - actor: string + actor: DID | Handle } export interface GetActorPostsParams { - actor: string + actor: DID | Handle filter?: string - community?: string + community?: DID limit?: number cursor?: string } @@ -397,8 +402,8 @@ export interface GetActorPostsResponse { } export interface GetActorCommentsParams { - actor: string - community?: string + actor: DID | Handle + community?: DID limit?: number cursor?: string } @@ -413,7 +418,7 @@ export interface GetActorCommentsResponse { // --------------------------------------------------------------------------- export interface GetCommunityParams { - community: string + community: DID } export interface ListCommunitiesParams { @@ -452,12 +457,20 @@ export interface DeleteVoteInput { subject: StrongRef } +// --------------------------------------------------------------------------- +// Request / response types — post retrieval +// --------------------------------------------------------------------------- + +export interface GetPostParams { + uri: AtUri +} + // --------------------------------------------------------------------------- // Request / response types — post creation // --------------------------------------------------------------------------- export interface CreatePostInput { - community: string + community: DID title?: string content?: string embed?: unknown @@ -505,11 +518,11 @@ export interface CreateCommunityInput { } export interface SubscribeCommunityInput { - community: string + community: DID } export interface BlockCommunityInput { - community: string + community: DID } // --------------------------------------------------------------------------- diff --git a/src/lib/app/util.svelte.test.ts b/src/lib/app/util.svelte.test.ts new file mode 100644 index 00000000..46cb5b6e --- /dev/null +++ b/src/lib/app/util.svelte.test.ts @@ -0,0 +1,168 @@ +import { describe, it, expect } from 'vitest' +import type { AuthorView, CommunityRef } from '$lib/api/coves/types' +import type { DID, Handle } from '$lib/types/atproto' +import { + canParseUrl, + communityLink, + escapeHtml, + findClosestNumber, + isImage, + isVideo, + userLink, +} from './util.svelte' + +// --------------------------------------------------------------------------- +// communityLink() +// --------------------------------------------------------------------------- + +describe('communityLink', () => { + const community: CommunityRef = { + did: 'did:plc:abc123' as DID, + handle: 'tech.coves.social' as Handle, + name: 'tech', + } + + it('returns /c/{handle} for CommunityRef with handle', () => { + expect(communityLink(community)).toBe('/c/tech.coves.social') + }) + + it('returns /c/{name} for CommunityRef without handle', () => { + const noHandle: CommunityRef = { + did: 'did:plc:abc123' as DID, + handle: '' as Handle, + name: 'tech', + } + expect(communityLink(noHandle)).toBe('/c/tech') + }) + + it('prepends prefix when provided', () => { + expect(communityLink(community, '/prefix')).toBe( + '/prefix/c/tech.coves.social', + ) + }) +}) + +// --------------------------------------------------------------------------- +// userLink() +// --------------------------------------------------------------------------- + +describe('userLink', () => { + const author: AuthorView = { + did: 'did:plc:user1' as DID, + handle: 'alice.coves.social' as Handle, + } + + it('returns /u/{handle} for AuthorView with handle', () => { + expect(userLink(author)).toBe('/u/alice.coves.social') + }) + + it('returns /u/{did} for AuthorView without handle', () => { + const noHandle: AuthorView = { + did: 'did:plc:user1' as DID, + handle: '' as Handle, + } + expect(userLink(noHandle)).toBe('/u/did:plc:user1') + }) + + it('prepends prefix when provided', () => { + expect(userLink(author, '/app')).toBe('/app/u/alice.coves.social') + }) +}) + +// --------------------------------------------------------------------------- +// isImage() +// --------------------------------------------------------------------------- + +describe('isImage', () => { + it('returns false for undefined', () => { + expect(isImage(undefined)).toBe(false) + }) + + it('returns true for .jpg URL', () => { + expect(isImage('https://example.com/photo.jpg')).toBe(true) + }) + + it('returns true for .png URL (case-insensitive)', () => { + expect(isImage('https://example.com/PHOTO.PNG')).toBe(true) + }) + + it('returns false for .mp4 URL', () => { + expect(isImage('https://example.com/video.mp4')).toBe(false) + }) + + it('returns false for URL without image extension', () => { + expect(isImage('https://example.com/page')).toBe(false) + }) +}) + +// --------------------------------------------------------------------------- +// isVideo() +// --------------------------------------------------------------------------- + +describe('isVideo', () => { + it('returns false for undefined', () => { + expect(isVideo(undefined)).toBe(false) + }) + + it('returns true for .mp4 URL', () => { + expect(isVideo('https://example.com/clip.mp4')).toBe(true) + }) + + it('returns true for .webm URL', () => { + expect(isVideo('https://example.com/clip.webm')).toBe(true) + }) + + it('returns false for .jpg URL', () => { + expect(isVideo('https://example.com/photo.jpg')).toBe(false) + }) +}) + +// --------------------------------------------------------------------------- +// findClosestNumber() +// --------------------------------------------------------------------------- + +describe('findClosestNumber', () => { + it('rounds up to nearest value at or above target', () => { + expect(findClosestNumber([8, 16, 32, 64, 128], 76)).toBe(128) + }) + + it('returns exact match when present', () => { + expect(findClosestNumber([8, 16, 32, 64, 128], 64)).toBe(64) + }) + + it('returns smallest value above target', () => { + expect(findClosestNumber([128, 256, 512, 1024], 200)).toBe(256) + }) +}) + +// --------------------------------------------------------------------------- +// canParseUrl() +// --------------------------------------------------------------------------- + +describe('canParseUrl', () => { + it('returns true for valid URL', () => { + expect(canParseUrl('https://example.com')).toBe(true) + }) + + it('returns false for invalid URL', () => { + expect(canParseUrl('not a url')).toBe(false) + }) +}) + +// --------------------------------------------------------------------------- +// escapeHtml() +// --------------------------------------------------------------------------- + +describe('escapeHtml', () => { + it('escapes angle brackets', () => { + expect(escapeHtml('')).toBe( + '<script>alert("xss")</script>', + ) + }) + + it('escapes ampersands and quotes', () => { + expect(escapeHtml('rock & roll\'s "best"')).toBe( + 'rock & roll's "best"', + ) + }) +}) diff --git a/src/lib/app/util.svelte.ts b/src/lib/app/util.svelte.ts index d0b2c90b..c1f722a9 100644 --- a/src/lib/app/util.svelte.ts +++ b/src/lib/app/util.svelte.ts @@ -1,6 +1,7 @@ import { browser } from '$app/environment' import { goto } from '$app/navigation' import { client } from '$lib/api/client.svelte' +import type { AuthorView, CommunityRef } from '$lib/api/coves/types' import type { Community, Person } from '$lib/api/types' import { SvelteURL } from 'svelte/reactivity' import { t } from './i18n' @@ -26,8 +27,13 @@ export const searchParam = ( }) } -export const fullCommunityName = (name: string, actorId: string) => - `${name}@${new SvelteURL(actorId).hostname}` +export const fullCommunityName = (name: string, actorId: string): string => { + try { + return `${name}@${new SvelteURL(actorId).hostname}` + } catch { + return name + } +} export const placeholders = { get: (type: 'url' | 'post' | 'body' | 'comment') => { @@ -167,32 +173,70 @@ export function snapshot(item: T) { return $state.snapshot(item) } -export const isImage = (url: string | undefined) => { - try { - if (!url) return false +export const isImage = (url: string | undefined): boolean => { + if (!url) return false + return /\.(jpeg|jpg|gif|png|svg|bmp|webp|avif)/i.test(url) +} - return /\.(jpeg|jpg|gif|png|svg|bmp|webp|avif)/i.test(url) +export const isVideo = (url: string | undefined): boolean => { + if (!url) return false + return /\.(mp4|mov|webm|mkv|avi)/i.test(url) +} + +/** + * Generate a link path for a community. + * Accepts either a Coves CommunityRef or a legacy Lemmy Community. + */ +export function communityLink(community: CommunityRef, prefix?: string): string +/** @deprecated Use CommunityRef overload instead */ +export function communityLink(community: Community, prefix?: string): string +export function communityLink( + community: CommunityRef | Community, + prefix: string = '', +): string { + // Coves CommunityRef: has `handle` field, no `actor_id` + if ('handle' in community && community.handle) { + return `${prefix}/c/${community.handle}` + } + // Coves CommunityRef without handle: use name + if ('did' in community && !('actor_id' in community)) { + return `${prefix}/c/${community.name}` + } + // Legacy Lemmy Community: has `actor_id` + try { + return `${prefix}/c/${fullCommunityName((community as Community).name, (community as Community).actor_id)}` } catch { - return false + return `${prefix}/c/${(community as Community).name}` } } -export const isVideo = (url: string | undefined) => { +/** + * Generate a link path for a user profile. + * Accepts either a Coves AuthorView or a legacy Lemmy Person. + */ +export function userLink(author: AuthorView, prefix?: string): string +/** @deprecated Use AuthorView overload instead */ +export function userLink(person: Person, prefix?: string): string +export function userLink( + user: AuthorView | Person, + prefix: string = '', +): string { + // Coves AuthorView: has `handle` field + if ('handle' in user && user.handle) { + return `${prefix}/u/${user.handle}` + } + // Coves AuthorView without handle: use DID as fallback + if ('did' in user && !('actor_id' in user)) { + return `${prefix}/u/${user.did}` + } + // Legacy Lemmy Person: has `actor_id` and `name` try { - if (!url) return false - - return /\.(mp4|mov|webm|mkv|avi)/i.test(url) + return `${prefix}/u/${(user as Person).name}@${new SvelteURL((user as Person).actor_id).hostname}` } catch { - return false + return `${prefix}/u/${(user as Person).name}` } } -export const communityLink = (community: Community, prefix: string = '') => - `${prefix}/c/${fullCommunityName(community.name, community.actor_id)}` - -export const userLink = (person: Person, prefix: string = '') => - `${prefix}/u/${person.name}@${new SvelteURL(person.actor_id).hostname}` - /** * Basic types only, don't use for anything more than basic equality */ diff --git a/src/lib/feature/community/CommunityCard.svelte b/src/lib/feature/community/CommunityCard.svelte index 601f059c..b1f0c57d 100644 --- a/src/lib/feature/community/CommunityCard.svelte +++ b/src/lib/feature/community/CommunityCard.svelte @@ -1,13 +1,11 @@ -{#await community_view} +{#await community}
-{:then community_view} - - (setFlair = !setFlair)} - /> - +{:then community}