an independent Bluesky client using Constellation, PDS Queries, and other services
reddwarf.app
frontend
spa
bluesky
reddwarf
microcosm
client
app
1import type { $Typed,Facet } from "@atproto/api";
2import * as React from "react";
3
4export const CACHE_TIMEOUT = 5 * 60 * 1000;
5const HANDLE_DID_CACHE_TIMEOUT = 60 * 60 * 1000; // 1 hour
6
7export function asTyped<T extends { $type: string }>(obj: T): $Typed<T> {
8 return obj as $Typed<T>;
9}
10
11export const fullDateTimeFormat = (iso: string) => {
12 const date = new Date(iso);
13 return date.toLocaleString("en-US", {
14 month: "long",
15 day: "numeric",
16 year: "numeric",
17 hour: "numeric",
18 minute: "2-digit",
19 hour12: true,
20 });
21};
22
23export const shortTimeAgo = (iso: string) => {
24 const diff = Date.now() - new Date(iso).getTime();
25 const mins = Math.floor(diff / 60000);
26 if (mins < 1) return "now";
27 if (mins < 60) return `${mins}m`;
28 const hrs = Math.floor(mins / 60);
29 if (hrs < 24) return `${hrs}h`;
30 const days = Math.floor(hrs / 24);
31 return `${days}d`;
32};
33
34export function getByteToCharMap(text: string): number[] {
35 const encoder = new TextEncoder();
36
37 const map: number[] = [];
38 let byteIndex = 0;
39 let charIndex = 0;
40
41 for (const char of text) {
42 const bytes = encoder.encode(char);
43 for (let i = 0; i < bytes.length; i++) {
44 map[byteIndex++] = charIndex;
45 }
46 charIndex += char.length;
47 }
48
49 return map;
50}
51
52export function facetByteRangeToCharRange(
53 byteStart: number,
54 byteEnd: number,
55 byteToCharMap: number[],
56): [number, number] {
57 return [
58 byteToCharMap[byteStart] ?? 0,
59 byteToCharMap[byteEnd - 1]! + 1, // inclusive end -> exclusive char end
60 ];
61}
62
63interface FacetRange {
64 start: number;
65 end: number;
66 feature: Facet["features"][number];
67}
68
69export function extractFacetRanges(
70 text: string,
71 facets: Facet[],
72): FacetRange[] {
73 const map = getByteToCharMap(text);
74 return facets.map((f) => {
75 const [start, end] = facetByteRangeToCharRange(
76 f.index.byteStart,
77 f.index.byteEnd,
78 map,
79 );
80 return { start, end, feature: f.features[0] };
81 });
82}
83
84export function renderTextWithFacets({
85 text,
86 facets,
87 navigate,
88}: {
89 text: string;
90 facets: Facet[];
91 navigate: (_: any) => void;
92}) {
93 const ranges = extractFacetRanges(text, facets).sort(
94 (a: any, b: any) => a.start - b.start,
95 );
96
97 const result: React.ReactNode[] = [];
98 let current = 0;
99
100 for (const { start, end, feature } of ranges) {
101 if (current < start) {
102 result.push(<span key={current}>{text.slice(current, start)}</span>);
103 }
104
105 const fragment = text.slice(start, end);
106 // @ts-expect-error i didnt bother with the correct types here sorry. bsky api types are cursed
107 if (feature.$type === "app.bsky.richtext.facet#link" && feature.uri) {
108 result.push(
109 <a
110 // @ts-expect-error i didnt bother with the correct types here sorry. bsky api types are cursed
111 href={feature.uri}
112 key={start}
113 className="link"
114 style={{
115 textDecoration: "none",
116 color: "var(--link-text-color)",
117 wordBreak: "break-all",
118 }}
119 target="_blank"
120 rel="noreferrer"
121 onClick={(e) => {
122 e.stopPropagation();
123 }}
124 >
125 {fragment}
126 </a>,
127 );
128 } else if (
129 feature.$type === "app.bsky.richtext.facet#mention" &&
130 // @ts-expect-error i didnt bother with the correct types here sorry. bsky api types are cursed
131 feature.did
132 ) {
133 result.push(
134 <span
135 key={start}
136 style={{ color: "var(--link-text-color)" }}
137 className=" cursor-pointer"
138 onClick={(e) => {
139 e.stopPropagation();
140 navigate({
141 to: "/profile/$did",
142 // @ts-expect-error i didnt bother with the correct types here sorry. bsky api types are cursed
143 params: { did: feature.did },
144 });
145 }}
146 >
147 {fragment}
148 </span>,
149 );
150 } else if (feature.$type === "app.bsky.richtext.facet#tag") {
151 result.push(
152 <span
153 key={start}
154 style={{ color: "var(--link-text-color)" }}
155 onClick={(e) => {
156 e.stopPropagation();
157 }}
158 >
159 {fragment}
160 </span>,
161 );
162 } else {
163 result.push(<span key={start}>{fragment}</span>);
164 }
165
166 current = end;
167 }
168
169 if (current < text.length) {
170 result.push(<span key={current}>{text.slice(current)}</span>);
171 }
172
173 return result;
174}
175
176export function getDomain(url: string) {
177 try {
178 const { hostname } = new URL(url);
179 return hostname;
180 } catch (e) {
181 if (!url.startsWith("http")) {
182 try {
183 const { hostname } = new URL("http://" + url);
184 return hostname;
185 } catch {
186 return null;
187 }
188 }
189 return null;
190 }
191}
192
193export function randomString(length = 8) {
194 const chars =
195 "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
196 return Array.from(
197 { length },
198 () => chars[Math.floor(Math.random() * chars.length)],
199 ).join("");
200}
201
202export function HitSlopButton({
203 onClick,
204 children,
205 style = {},
206 ...rest
207}: React.HTMLAttributes<HTMLSpanElement> & {
208 onClick?: (e: React.MouseEvent) => void;
209 children: React.ReactNode;
210 style?: React.CSSProperties;
211}) {
212 return (
213 <span
214 style={{
215 position: "relative",
216 display: "inline-block",
217 cursor: "pointer",
218 }}
219 >
220 <span
221 style={{
222 position: "absolute",
223 top: -8,
224 left: -8,
225 right: -8,
226 bottom: -8,
227 zIndex: 0,
228 }}
229 onClick={(e) => {
230 e.stopPropagation();
231 onClick?.(e);
232 }}
233 />
234 <span
235 style={{
236 ...style,
237 position: "relative",
238 zIndex: 1,
239 pointerEvents: "none",
240 }}
241 {...rest}
242 >
243 {children}
244 </span>
245 </span>
246 );
247}
248
249export const btnstyle = {
250 display: "flex",
251 gap: 4,
252 cursor: "pointer",
253 alignItems: "center",
254 fontSize: 14,
255};