From b70e5b2f387e8de6dac5d388aee1ccbf5b217adc Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Tue, 26 Aug 2025 18:24:46 +0300 Subject: Add verification checkmarks to `embed.bsky.app` (#8644) * update vite+typescript * update atproto api to latest, split out utils * add checkmark to post * add checkie to embed * revert change to example post * fix ext link color --- bskyembed/src/components/container.tsx | 2 +- bskyembed/src/components/embed.tsx | 40 +++++-- bskyembed/src/components/post.tsx | 50 +++++--- bskyembed/src/components/verification-check.tsx | 56 +++++++++ bskyembed/src/screens/landing.tsx | 13 +- bskyembed/src/screens/post.tsx | 2 +- bskyembed/src/types/bsky/index.ts | 49 ++++++++ bskyembed/src/types/bsky/profile.ts | 10 ++ bskyembed/src/util/nice-date.ts | 11 ++ bskyembed/src/util/parse-embed.ts | 152 ++++++++++++++++++++++++ bskyembed/src/util/pretty-number.ts | 9 ++ bskyembed/src/util/rkey.ts | 6 + bskyembed/src/util/verification-state.ts | 31 +++++ bskyembed/src/utils.ts | 28 ----- 14 files changed, 403 insertions(+), 56 deletions(-) create mode 100644 bskyembed/src/components/verification-check.tsx create mode 100644 bskyembed/src/types/bsky/index.ts create mode 100644 bskyembed/src/types/bsky/profile.ts create mode 100644 bskyembed/src/util/nice-date.ts create mode 100644 bskyembed/src/util/parse-embed.ts create mode 100644 bskyembed/src/util/pretty-number.ts create mode 100644 bskyembed/src/util/rkey.ts create mode 100644 bskyembed/src/util/verification-state.ts delete mode 100644 bskyembed/src/utils.ts (limited to 'bskyembed/src') diff --git a/bskyembed/src/components/container.tsx b/bskyembed/src/components/container.tsx index 8e142a25b..bafc497ae 100644 --- a/bskyembed/src/components/container.tsx +++ b/bskyembed/src/components/container.tsx @@ -49,7 +49,7 @@ export function Container({ } }}> {href && } -
{children}
+
{children}
) } diff --git a/bskyembed/src/components/embed.tsx b/bskyembed/src/components/embed.tsx index 428782b64..52618a89d 100644 --- a/bskyembed/src/components/embed.tsx +++ b/bskyembed/src/components/embed.tsx @@ -17,8 +17,11 @@ import infoIcon from '../../assets/circleInfo_stroke2_corner0_rounded.svg' import playIcon from '../../assets/play_filled_corner2_rounded.svg' import starterPackIcon from '../../assets/starterPack.svg' import {CONTENT_LABELS, labelsToInfo} from '../labels' -import {getRkey} from '../utils' +import * as bsky from '../types/bsky' +import {getRkey} from '../util/rkey' +import {getVerificationState} from '../util/verification-state' import {Link} from './link' +import {VerificationCheck} from './verification-check' export function Embed({ content, @@ -75,23 +78,35 @@ export function Embed({ CONTENT_LABELS.includes(label.val), ) + const verification = getVerificationState({profile: record.author}) + return (
-
+
-

- {record.author.displayName} - +

+

+ {record.author.displayName?.trim() || record.author.handle} +

+ {verification.isVerified && ( + + )} +

@{record.author.handle} - -

+

+
{text &&

{text}

} {record.embeds?.map(embed => ( @@ -404,7 +419,12 @@ function StarterPackEmbed({ }: { content: AppBskyGraphDefs.StarterPackViewBasic }) { - if (!AppBskyGraphStarterpack.isRecord(content.record)) { + if ( + !bsky.dangerousIsType( + content.record, + AppBskyGraphStarterpack.isRecord, + ) + ) { return null } @@ -443,7 +463,9 @@ function StarterPackEmbed({ } // from #/lib/strings/starter-pack.ts -function getStarterPackImage(starterPack: AppBskyGraphDefs.StarterPackView) { +function getStarterPackImage( + starterPack: AppBskyGraphDefs.StarterPackViewBasic, +) { const rkey = getRkey({uri: starterPack.uri}) return `https://ogcard.cdn.bsky.app/start/${starterPack.creator.did}/${rkey}` } diff --git a/bskyembed/src/components/post.tsx b/bskyembed/src/components/post.tsx index 6ecac5796..d216ce0e5 100644 --- a/bskyembed/src/components/post.tsx +++ b/bskyembed/src/components/post.tsx @@ -11,10 +11,15 @@ import likeIcon from '../../assets/heart2_filled_stroke2_corner0_rounded.svg' import logo from '../../assets/logo.svg' import repostIcon from '../../assets/repost_stroke2_corner2_rounded.svg' import {CONTENT_LABELS} from '../labels' -import {getRkey, niceDate, prettyNumber} from '../utils' +import * as bsky from '../types/bsky' +import {niceDate} from '../util/nice-date' +import {prettyNumber} from '../util/pretty-number' +import {getRkey} from '../util/rkey' +import {getVerificationState} from '../util/verification-state' import {Container} from './container' import {Embed} from './embed' import {Link} from './link' +import {VerificationCheck} from './verification-check' interface Props { thread: AppBskyFeedDefs.ThreadViewPost @@ -28,16 +33,25 @@ export function Post({thread}: Props) { ) let record: AppBskyFeedPost.Record | null = null - if (AppBskyFeedPost.isRecord(post.record)) { + if ( + bsky.dangerousIsType( + post.record, + AppBskyFeedPost.isRecord, + ) + ) { record = post.record } + const verification = getVerificationState({profile: post.author}) + const href = `/profile/${post.author.did}/post/${getRkey(post)}` return (
-
- +
+
-
- -

{post.author.displayName}

- +
+
+ + {post.author.displayName?.trim() || post.author.handle} + + {verification.isVerified && ( + + )} +
-

@{post.author.handle}

+ className="block text-[15px] text-textLight dark:text-textDimmed hover:underline line-clamp-1"> + @{post.author.handle}
-
@@ -133,7 +155,7 @@ function PostContent({record}: {record: AppBskyFeedPost.Record | null}) { : +} + +export function VerifiedCheck({size, className}: IconProps) { + return ( + + + + + ) +} + +export function VerifierCheck({size, className}: IconProps) { + return ( + + + + + ) +} diff --git a/bskyembed/src/screens/landing.tsx b/bskyembed/src/screens/landing.tsx index 880b71337..bb650241f 100644 --- a/bskyembed/src/screens/landing.tsx +++ b/bskyembed/src/screens/landing.tsx @@ -14,9 +14,11 @@ import { import {Container} from '../components/container' import {Link} from '../components/link' import {Post} from '../components/post' -import {niceDate} from '../utils' +import * as bsky from '../types/bsky' +import {niceDate} from '../util/nice-date' -const DEFAULT_POST = 'https://bsky.app/profile/emilyliu.me/post/3jzn6g7ixgq2y' +const DEFAULT_POST = + 'https://bsky.app/profile/did:plc:vjug55kidv6sye7ykr5faxxn/post/3jzn6g7ixgq2y' const DEFAULT_URI = 'at://did:plc:vjug55kidv6sye7ykr5faxxn/app.bsky.feed.post/3jzn6g7ixgq2y' @@ -222,7 +224,12 @@ function Snippet({ const snippet = useMemo(() => { const record = thread.post.record - if (!AppBskyFeedPost.isRecord(record)) { + if ( + !bsky.dangerousIsType( + record, + AppBskyFeedPost.isRecord, + ) + ) { return '' } diff --git a/bskyembed/src/screens/post.tsx b/bskyembed/src/screens/post.tsx index 4cd72b69b..83914f66f 100644 --- a/bskyembed/src/screens/post.tsx +++ b/bskyembed/src/screens/post.tsx @@ -8,7 +8,7 @@ import {applyTheme, initSystemColorMode} from '../color-mode' import {Container} from '../components/container' import {Link} from '../components/link' import {Post} from '../components/post' -import {getRkey} from '../utils' +import {getRkey} from '../util/rkey' const root = document.getElementById('app') if (!root) throw new Error('No root element') diff --git a/bskyembed/src/types/bsky/index.ts b/bskyembed/src/types/bsky/index.ts new file mode 100644 index 000000000..c462cdf65 --- /dev/null +++ b/bskyembed/src/types/bsky/index.ts @@ -0,0 +1,49 @@ +import {ValidationResult} from '@atproto/lexicon' + +export * as profile from './profile' + +/** + * Fast type checking without full schema validation, for use with data we + * trust, or for non-critical path use cases. Why? Our SDK's `is*` identity + * utils do not assert the type of the entire object, only the `$type` string. + * + * For full validation of the object schema, use the `validate` export from + * this file. + * + * Usage: + * ```ts + * import * as bsky from '#/types/bsky' + * + * if (bsky.dangerousIsType(item, AppBskyFeedPost.isRecord)) { + * // `item` has type `$Typed` here + * } + * ``` + */ +export function dangerousIsType( + record: unknown, + identity: (v: V) => v is V & {$type: NonNullable}, +): record is R { + return identity(record) +} + +/** + * Fully validates the object schema, which has a performance cost. + * + * For faster checks with data we trust, like that from our app view, use the + * `dangerousIsType` export from this same file. + * + * Usage: + * ```ts + * import * as bsky from '#/types/bsky' + * + * if (bsky.validate(item, AppBskyFeedPost.validateRecord)) { + * // `item` has type `$Typed` here + * } + * ``` + */ +export function validate( + record: unknown, + validator: (v: unknown) => ValidationResult, +): record is R { + return validator(record).success +} diff --git a/bskyembed/src/types/bsky/profile.ts b/bskyembed/src/types/bsky/profile.ts new file mode 100644 index 000000000..12c8146ae --- /dev/null +++ b/bskyembed/src/types/bsky/profile.ts @@ -0,0 +1,10 @@ +import {type AppBskyActorDefs, type ChatBskyActorDefs} from '@atproto/api' + +/** + * Matches any profile view exported by our SDK + */ +export type AnyProfileView = + | AppBskyActorDefs.ProfileViewBasic + | AppBskyActorDefs.ProfileView + | AppBskyActorDefs.ProfileViewDetailed + | ChatBskyActorDefs.ProfileViewBasic diff --git a/bskyembed/src/util/nice-date.ts b/bskyembed/src/util/nice-date.ts new file mode 100644 index 000000000..016c97a69 --- /dev/null +++ b/bskyembed/src/util/nice-date.ts @@ -0,0 +1,11 @@ +export function niceDate(date: number | string | Date) { + const d = new Date(date) + return `${d.toLocaleDateString('en-us', { + year: 'numeric', + month: 'short', + day: 'numeric', + })} at ${d.toLocaleTimeString(undefined, { + hour: 'numeric', + minute: '2-digit', + })}` +} diff --git a/bskyembed/src/util/parse-embed.ts b/bskyembed/src/util/parse-embed.ts new file mode 100644 index 000000000..97c3dc33e --- /dev/null +++ b/bskyembed/src/util/parse-embed.ts @@ -0,0 +1,152 @@ +/** + * This file is a copy of what exists in the social-app + */ + +import { + AppBskyEmbedExternal, + AppBskyEmbedImages, + AppBskyEmbedRecord, + AppBskyEmbedRecordWithMedia, + AppBskyEmbedVideo, + AppBskyFeedDefs, + AppBskyGraphDefs, + AppBskyLabelerDefs, +} from '@atproto/api' + +export type Embed = + | { + type: 'post' + view: AppBskyEmbedRecord.ViewRecord + } + | { + type: 'post_not_found' + view: AppBskyEmbedRecord.ViewNotFound + } + | { + type: 'post_blocked' + view: AppBskyEmbedRecord.ViewBlocked + } + | { + type: 'post_detached' + view: AppBskyEmbedRecord.ViewDetached + } + | { + type: 'feed' + view: AppBskyFeedDefs.GeneratorView + } + | { + type: 'list' + view: AppBskyGraphDefs.ListView + } + | { + type: 'labeler' + view: AppBskyLabelerDefs.LabelerView + } + | { + type: 'starter_pack' + view: AppBskyGraphDefs.StarterPackViewBasic + } + | { + type: 'images' + view: AppBskyEmbedImages.View + } + | { + type: 'link' + view: AppBskyEmbedExternal.View + } + | { + type: 'video' + view: AppBskyEmbedVideo.View + } + | { + type: 'post_with_media' + view: Embed + media: Embed + } + | { + type: 'unknown' + view: null + } + +export type EmbedType = Extract + +export function parseEmbedRecordView({record}: AppBskyEmbedRecord.View): Embed { + if (AppBskyEmbedRecord.isViewRecord(record)) { + return { + type: 'post', + view: record, + } + } else if (AppBskyEmbedRecord.isViewNotFound(record)) { + return { + type: 'post_not_found', + view: record, + } + } else if (AppBskyEmbedRecord.isViewBlocked(record)) { + return { + type: 'post_blocked', + view: record, + } + } else if (AppBskyEmbedRecord.isViewDetached(record)) { + return { + type: 'post_detached', + view: record, + } + } else if (AppBskyFeedDefs.isGeneratorView(record)) { + return { + type: 'feed', + view: record, + } + } else if (AppBskyGraphDefs.isListView(record)) { + return { + type: 'list', + view: record, + } + } else if (AppBskyLabelerDefs.isLabelerView(record)) { + return { + type: 'labeler', + view: record, + } + } else if (AppBskyGraphDefs.isStarterPackViewBasic(record)) { + return { + type: 'starter_pack', + view: record, + } + } else { + return { + type: 'unknown', + view: null, + } + } +} + +export function parseEmbed(embed: AppBskyFeedDefs.PostView['embed']): Embed { + if (AppBskyEmbedImages.isView(embed)) { + return { + type: 'images', + view: embed, + } + } else if (AppBskyEmbedExternal.isView(embed)) { + return { + type: 'link', + view: embed, + } + } else if (AppBskyEmbedVideo.isView(embed)) { + return { + type: 'video', + view: embed, + } + } else if (AppBskyEmbedRecord.isView(embed)) { + return parseEmbedRecordView(embed) + } else if (AppBskyEmbedRecordWithMedia.isView(embed)) { + return { + type: 'post_with_media', + view: parseEmbedRecordView(embed.record), + media: parseEmbed(embed.media), + } + } else { + return { + type: 'unknown', + view: null, + } + } +} diff --git a/bskyembed/src/util/pretty-number.ts b/bskyembed/src/util/pretty-number.ts new file mode 100644 index 000000000..07f7e9577 --- /dev/null +++ b/bskyembed/src/util/pretty-number.ts @@ -0,0 +1,9 @@ +const formatter = new Intl.NumberFormat('en-US', { + notation: 'compact', + maximumFractionDigits: 1, + roundingMode: 'trunc', +}) + +export function prettyNumber(number: number) { + return formatter.format(number) +} diff --git a/bskyembed/src/util/rkey.ts b/bskyembed/src/util/rkey.ts new file mode 100644 index 000000000..71d57f8c6 --- /dev/null +++ b/bskyembed/src/util/rkey.ts @@ -0,0 +1,6 @@ +import {AtUri} from '@atproto/api' + +export function getRkey({uri}: {uri: string}): string { + const at = new AtUri(uri) + return at.rkey +} diff --git a/bskyembed/src/util/verification-state.ts b/bskyembed/src/util/verification-state.ts new file mode 100644 index 000000000..29355e511 --- /dev/null +++ b/bskyembed/src/util/verification-state.ts @@ -0,0 +1,31 @@ +import * as bsky from '../types/bsky' + +export type VerificationState = { + role: 'default' | 'verifier' + isVerified: boolean +} + +export function getVerificationState({ + profile, +}: { + profile?: bsky.profile.AnyProfileView +}): VerificationState { + if (!profile || !profile.verification) { + return { + role: 'default', + isVerified: false, + } + } + + const {verifiedStatus, trustedVerifierStatus} = profile.verification + const isVerifiedUser = ['valid', 'invalid'].includes(verifiedStatus) + const isVerifierUser = ['valid', 'invalid'].includes(trustedVerifierStatus) + const isVerified = + (isVerifiedUser && verifiedStatus === 'valid') || + (isVerifierUser && trustedVerifierStatus === 'valid') + + return { + role: isVerifierUser ? 'verifier' : 'default', + isVerified, + } +} diff --git a/bskyembed/src/utils.ts b/bskyembed/src/utils.ts deleted file mode 100644 index cfa4a525b..000000000 --- a/bskyembed/src/utils.ts +++ /dev/null @@ -1,28 +0,0 @@ -import {AtUri} from '@atproto/api' - -export function niceDate(date: number | string | Date) { - const d = new Date(date) - return `${d.toLocaleDateString('en-us', { - year: 'numeric', - month: 'short', - day: 'numeric', - })} at ${d.toLocaleTimeString(undefined, { - hour: 'numeric', - minute: '2-digit', - })}` -} - -export function getRkey({uri}: {uri: string}): string { - const at = new AtUri(uri) - return at.rkey -} - -const formatter = new Intl.NumberFormat('en-US', { - notation: 'compact', - maximumFractionDigits: 1, - roundingMode: 'trunc', -}) - -export function prettyNumber(number: number) { - return formatter.format(number) -} -- cgit 1.4.1