personal web client for Bluesky
typescript solidjs bluesky atcute

feat: render bluemoji emotes in post

mary.my.id fbc05a48 c34033ca

verified
Changed files
+69 -49
src
api
richtext
components
lib
bluemoji
+6 -6
src/api/richtext/segment.ts
··· 1 - import type { AppBskyRichtextFacet } from '@atcute/client/lexicons'; 1 + import type { AppBskyRichtextFacet, BlueMojiRichtextFacet, Brand } from '@atcute/client/lexicons'; 2 2 3 3 import type { UnwrapArray } from '../utils/types'; 4 4 import { textDecoder, textEncoder } from './intl'; ··· 21 21 }; 22 22 23 23 type Facet = AppBskyRichtextFacet.Main; 24 - type FacetFeature = UnwrapArray<Facet['features']>; 24 + type FacetFeature = UnwrapArray<Facet['features']> | Brand.Union<BlueMojiRichtextFacet.Main>; 25 25 26 26 export interface RichtextSegment { 27 27 text: string; 28 - feature: FacetFeature | undefined; 28 + features: FacetFeature[] | undefined; 29 29 } 30 30 31 - const createRichtextSegment = (text: string, feature: FacetFeature | undefined): RichtextSegment => { 32 - return { text: text, feature: feature }; 31 + const createRichtextSegment = (text: string, features: FacetFeature[] | undefined): RichtextSegment => { 32 + return { text: text, features: features }; 33 33 }; 34 34 35 35 export const segmentRichText = (rtText: string, facets: Facet[] | undefined): RichtextSegment[] => { ··· 65 65 if (features.length === 0 || subtext.trim().length === 0) { 66 66 segments.push(createRichtextSegment(subtext, undefined)); 67 67 } else { 68 - segments.push(createRichtextSegment(subtext, features[0])); 68 + segments.push(createRichtextSegment(subtext, features)); 69 69 } 70 70 } 71 71
+62 -40
src/components/rich-text.tsx
··· 5 5 import { segmentRichText } from '~/api/richtext/segment'; 6 6 import { isLinkValid, safeUrlParse } from '~/api/utils/strings'; 7 7 8 + import { getCdnUrl } from '~/lib/bluemoji/render'; 8 9 import { 9 10 BSKY_FEED_LINK_RE, 10 11 BSKY_LIST_LINK_RE, ··· 38 39 for (let idx = 0, len = segments.length; idx < len; idx++) { 39 40 const segment = segments[idx]; 40 41 const subtext = segment.text; 41 - const feature = segment.feature; 42 + const features = segment.features; 43 + 44 + let node: JSX.Element = subtext; 45 + 46 + if (features) { 47 + for (let j = 0, jlen = features.length; j < jlen; j++) { 48 + const feature = features[j]; 49 + const type = feature.$type; 50 + 51 + if (type === 'app.bsky.richtext.facet#link') { 52 + const uri = feature.uri; 53 + const redirect = findLinkRedirect(uri); 54 + 55 + if (redirect === null) { 56 + node = renderExternalLink(uri, subtext); 57 + } else { 58 + node = renderInternalLink(redirect, subtext); 59 + } 42 60 43 - let to: string | undefined; 44 - let external = false; 61 + break; 62 + } else if (type === 'app.bsky.richtext.facet#mention') { 63 + node = renderInternalLink(`/${feature.did}`, subtext); 45 64 46 - if (feature) { 47 - const type = feature.$type; 65 + break; 66 + } else if (type === 'app.bsky.richtext.facet#tag') { 67 + node = renderInternalLink(`/topics/${feature.tag}`, subtext); 48 68 49 - if (type === 'app.bsky.richtext.facet#link') { 50 - const uri = feature.uri; 51 - const redirect = findLinkRedirect(uri); 69 + break; 70 + } else if (type === 'blue.moji.richtext.facet') { 71 + const formats = feature.formats; 72 + if (formats.$type !== 'blue.moji.richtext.facet#formats_v0' || !formats.png_128) { 73 + continue; 74 + } 52 75 53 - if (redirect === null) { 54 - to = uri; 55 - external = true; 56 - } else { 57 - to = redirect; 76 + node = ( 77 + <img 78 + src={/* @once */ getCdnUrl(feature.did, formats.png_128)} 79 + title={/* @once */ feature.name} 80 + class={`mx-px inline-block align-top text-[0]` + (!large ? ` h-5 w-5` : ` h-6 w-6`)} 81 + /> 82 + ); 83 + break; 58 84 } 59 - } else if (type === 'app.bsky.richtext.facet#mention') { 60 - to = `/${feature.did}`; 61 - } else if (type === 'app.bsky.richtext.facet#tag') { 62 - to = `/topics/${feature.tag}`; 63 85 } 64 86 } 65 87 66 - if (to !== undefined) { 67 - if (!external) { 68 - nodes.push( 69 - <a href={to} class="text-accent hover:underline"> 70 - {subtext} 71 - </a>, 72 - ); 73 - } else { 74 - nodes.push( 75 - <a 76 - target="_blank" 77 - href={to} 78 - onClick={handleUnsafeLinkNavigation} 79 - onAuxClick={handleUnsafeLinkNavigation} 80 - class="text-accent hover:underline" 81 - > 82 - {subtext} 83 - </a>, 84 - ); 85 - } 86 - } else { 87 - nodes.push(subtext); 88 - } 88 + nodes.push(node); 89 89 } 90 90 } else { 91 91 nodes = text; ··· 108 108 }; 109 109 110 110 export default RichText; 111 + 112 + const renderInternalLink = (to: string, subtext: string) => { 113 + return ( 114 + <a href={to} class="text-accent hover:underline"> 115 + {subtext} 116 + </a> 117 + ); 118 + }; 119 + 120 + const renderExternalLink = (to: string, subtext: string) => { 121 + return ( 122 + <a 123 + target="_blank" 124 + href={to} 125 + onClick={handleUnsafeLinkNavigation} 126 + onAuxClick={handleUnsafeLinkNavigation} 127 + class="text-accent hover:underline" 128 + > 129 + {subtext} 130 + </a> 131 + ); 132 + }; 111 133 112 134 const handleUnsafeLinkNavigation = (ev: MouseEvent) => { 113 135 if (ev.defaultPrevented || (ev.type === 'auxclick' && (ev as MouseEvent).button !== 1)) {
+1 -3
src/lib/bluemoji/render.ts
··· 1 - import type { At } from '@atcute/client/lexicons'; 2 - 3 - export const getCdnUrl = (did: At.DID, cid: string, format: 'png' | 'jpeg' | 'webp' = 'webp') => { 1 + export const getCdnUrl = (did: string, cid: string, format: 'png' | 'jpeg' | 'webp' = 'webp') => { 4 2 return `https://cdn.bsky.app/img/avatar_thumbnail/plain/${did}/${cid}@${format}`; 5 3 };