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}