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
This commit is contained in:
Samuel Newman
2025-08-26 18:24:46 +03:00
committed by GitHub
parent 5a074fa37a
commit b70e5b2f38
17 changed files with 1607 additions and 1287 deletions
+10 -9
View File
@@ -6,24 +6,25 @@
"dev": "vite",
"build": "tsc && vite build",
"build-snippet": "tsc --project tsconfig.snippet.json",
"lint": "eslint --cache --ext .js,.jsx,.ts,.tsx src"
"lint": "eslint --cache --ext .js,.jsx,.ts,.tsx src",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@atproto/api": "0.13.6",
"@preact/preset-vite": "^2.8.2",
"@vitejs/plugin-legacy": "^5.3.2",
"preact": "^10.4.8",
"terser": "^5.30.3"
"@atproto/api": "^0.15.25",
"preact": "^10.4.8"
},
"devDependencies": {
"@preact/preset-vite": "^2.10.2",
"@vitejs/plugin-legacy": "^7.0.0",
"autoprefixer": "^10.4.19",
"eslint": "^8.19.0",
"eslint-config-preact": "^1.3.0",
"eslint-plugin-simple-import-sort": "^12.0.0",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.3",
"typescript": "^5.5.4",
"vite": "^5.2.8",
"vite-tsconfig-paths": "^4.3.2"
"terser": "^5.43.1",
"typescript": "^5.8.3",
"vite": "^7.0.4",
"vite-tsconfig-paths": "^5.1.4"
}
}
+1 -1
View File
@@ -49,7 +49,7 @@ export function Container({
}
}}>
{href && <Link href={href} />}
<div className="flex-1 px-4 pt-3 pb-2.5">{children}</div>
<div className="flex-1 px-4 pt-3 pb-2.5 max-w-full">{children}</div>
</div>
)
}
+31 -9
View File
@@ -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 (
<Link
href={`/profile/${record.author.did}/post/${getRkey(record)}`}
className="transition-colors hover:bg-neutral-100 dark:hover:bg-slate-700 border dark:border-slate-600 rounded-xl p-2 gap-1.5 w-full flex flex-col">
<div className="flex gap-1.5 items-center">
<div className="w-4 h-4 overflow-hidden rounded-full bg-neutral-300 dark:bg-slate-700 shrink-0">
<div className="w-4 h-4 rounded-full bg-neutral-300 dark:bg-slate-700 shrink-0">
<img
className="rounded-full"
src={record.author.avatar}
style={isAuthorLabeled ? {filter: 'blur(1.5px)'} : undefined}
/>
</div>
<p className="line-clamp-1 text-sm">
<span className="font-bold">{record.author.displayName}</span>
<span className="text-textLight dark:text-textDimmed ml-1">
<div className="flex flex-1 items-center shrink min-w-0 min-h-0">
<p className="block text-sm shrink-0 font-bold max-w-[70%] line-clamp-1">
{record.author.displayName?.trim() || record.author.handle}
</p>
{verification.isVerified && (
<VerificationCheck
className="ml-[3px] mt-px shrink-0 self-center"
verifier={verification.role === 'verifier'}
size={12}
/>
)}
<p className="block line-clamp-1 text-sm text-textLight dark:text-textDimmed shrink-[10] ml-1">
@{record.author.handle}
</span>
</p>
</p>
</div>
</div>
{text && <p className="text-sm">{text}</p>}
{record.embeds?.map(embed => (
@@ -404,7 +419,12 @@ function StarterPackEmbed({
}: {
content: AppBskyGraphDefs.StarterPackViewBasic
}) {
if (!AppBskyGraphStarterpack.isRecord(content.record)) {
if (
!bsky.dangerousIsType<AppBskyGraphStarterpack.Record>(
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}`
}
+36 -14
View File
@@ -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<AppBskyFeedPost.Record>(
post.record,
AppBskyFeedPost.isRecord,
)
) {
record = post.record
}
const verification = getVerificationState({profile: post.author})
const href = `/profile/${post.author.did}/post/${getRkey(post)}`
return (
<Container href={href}>
<div className="flex-1 flex-col flex gap-2" lang={record?.langs?.[0]}>
<div className="flex gap-2.5 items-center cursor-pointer">
<Link href={`/profile/${post.author.did}`} className="rounded-full">
<div className="flex gap-2.5 items-center cursor-pointer w-full max-w-full">
<Link
href={`/profile/${post.author.did}`}
className="rounded-full shrink-0">
<div className="w-10 h-10 overflow-hidden rounded-full bg-neutral-300 dark:bg-slate-700 shrink-0">
<img
src={post.author.avatar}
@@ -45,19 +59,27 @@ export function Post({thread}: Props) {
/>
</div>
</Link>
<div>
<div className="flex flex-1 flex-col min-w-0">
<div className="flex flex-1 items-center">
<Link
href={`/profile/${post.author.did}`}
className="block font-bold text-[17px] leading-5 line-clamp-1 hover:underline underline-offset-2 text-ellipsis decoration-2">
{post.author.displayName?.trim() || post.author.handle}
</Link>
{verification.isVerified && (
<VerificationCheck
className="pl-[3px] mt-px shrink-0"
verifier={verification.role === 'verifier'}
size={15}
/>
)}
</div>
<Link
href={`/profile/${post.author.did}`}
className="font-bold text-[17px] leading-5 line-clamp-1 hover:underline underline-offset-2 decoration-2">
<p>{post.author.displayName}</p>
</Link>
<Link
href={`/profile/${post.author.did}`}
className="text-[15px] text-textLight dark:text-textDimmed hover:underline line-clamp-1">
<p>@{post.author.handle}</p>
className="block text-[15px] text-textLight dark:text-textDimmed hover:underline line-clamp-1">
@{post.author.handle}
</Link>
</div>
<div className="flex-1" />
<Link
href={href}
className="transition-transform hover:scale-110 shrink-0 self-start">
@@ -133,7 +155,7 @@ function PostContent({record}: {record: AppBskyFeedPost.Record | null}) {
<Link
key={counter}
href={segment.link.uri}
className="text-blue-400 hover:underline"
className="text-blue-500 hover:underline"
disableTracking={
!segment.link.uri.startsWith('https://bsky.app') &&
!segment.link.uri.startsWith('https://go.bsky.app')
@@ -0,0 +1,56 @@
import {h} from 'preact'
type IconProps = {
size: number
className?: string
}
export function VerificationCheck({
verifier,
...rest
}: {verifier: boolean} & IconProps) {
return verifier ? <VerifierCheck {...rest} /> : <VerifiedCheck {...rest} />
}
export function VerifiedCheck({size, className}: IconProps) {
return (
<svg
fill="none"
viewBox="0 0 24 24"
width={size}
height={size}
className={className}>
<circle cx="12" cy="12" r="11.5" fill="hsl(211, 99%, 53%)" />
<path
fill="#fff"
fillRule="evenodd"
clipRule="evenodd"
d="M17.659 8.175a1.361 1.361 0 0 1 0 1.925l-6.224 6.223a1.361 1.361 0 0 1-1.925 0L6.4 13.212a1.361 1.361 0 0 1 1.925-1.925l2.149 2.148 5.26-5.26a1.361 1.361 0 0 1 1.925 0Z"
/>
</svg>
)
}
export function VerifierCheck({size, className}: IconProps) {
return (
<svg
fill="none"
viewBox="0 0 24 24"
width={size}
height={size}
className={className}>
<path
fill="hsl(211, 99%, 53%)"
fillRule="evenodd"
clipRule="evenodd"
d="M8.792 1.615a4.154 4.154 0 0 1 6.416 0 4.154 4.154 0 0 0 3.146 1.515 4.154 4.154 0 0 1 4 5.017 4.154 4.154 0 0 0 .777 3.404 4.154 4.154 0 0 1-1.427 6.255 4.153 4.153 0 0 0-2.177 2.73 4.154 4.154 0 0 1-5.781 2.784 4.154 4.154 0 0 0-3.492 0 4.154 4.154 0 0 1-5.78-2.784 4.154 4.154 0 0 0-2.178-2.73A4.154 4.154 0 0 1 .87 11.551a4.154 4.154 0 0 0 .776-3.404A4.154 4.154 0 0 1 5.646 3.13a4.154 4.154 0 0 0 3.146-1.515Z"
/>
<path
fill="#fff"
fillRule="evenodd"
clipRule="evenodd"
d="M17.861 8.26a1.438 1.438 0 0 1 0 2.033l-6.571 6.571a1.437 1.437 0 0 1-2.033 0L5.97 13.58a1.438 1.438 0 0 1 2.033-2.033l2.27 2.269 5.554-5.555a1.437 1.437 0 0 1 2.033 0Z"
/>
</svg>
)
}
+10 -3
View File
@@ -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<AppBskyFeedPost.Record>(
record,
AppBskyFeedPost.isRecord,
)
) {
return ''
}
+1 -1
View File
@@ -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')
+49
View File
@@ -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<AppBskyFeedPost.Record>(item, AppBskyFeedPost.isRecord)) {
* // `item` has type `$Typed<AppBskyFeedPost.Record>` here
* }
* ```
*/
export function dangerousIsType<R extends {$type?: string}>(
record: unknown,
identity: <V>(v: V) => v is V & {$type: NonNullable<R['$type']>},
): 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<AppBskyFeedPost.Record>` here
* }
* ```
*/
export function validate<R extends {$type?: string}>(
record: unknown,
validator: (v: unknown) => ValidationResult<R>,
): record is R {
return validator(record).success
}
+10
View File
@@ -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
+11
View File
@@ -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',
})}`
}
+152
View File
@@ -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<T extends Embed['type']> = Extract<Embed, {type: T}>
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,
}
}
}
+9
View File
@@ -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)
}
+6
View File
@@ -0,0 +1,6 @@
import {AtUri} from '@atproto/api'
export function getRkey({uri}: {uri: string}): string {
const at = new AtUri(uri)
return at.rkey
}
+31
View File
@@ -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,
}
}
-28
View File
@@ -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)
}
+2 -2
View File
@@ -10,7 +10,7 @@
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
@@ -19,5 +19,5 @@
"jsxFragmentFactory": "Fragment",
"downlevelIteration": true
},
"include": ["src", "snippet", "vite.config.ts"]
"include": ["src", "snippet", "vite.config.ts", "*.config.cjs", ".eslintrc.cjs"]
}
+1192 -1220
View File
File diff suppressed because it is too large Load Diff