Laravel AT Protocol Client (alpha & unstable)
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add RichText support with TextBuilder, FacetDetector, and ByteCounter

+487
src/Client/Requests/Atproto/Identity.php src/Client/Requests/Atproto/IdentityRequestClient.php
src/Client/Requests/Atproto/Repo.php src/Client/Requests/Atproto/RepoRequestClient.php
src/Client/Requests/Atproto/Server.php src/Client/Requests/Atproto/ServerRequestClient.php
src/Client/Requests/Atproto/Sync.php src/Client/Requests/Atproto/SyncRequestClient.php
src/Client/Requests/Bsky/Actor.php src/Client/Requests/Bsky/ActorRequestClient.php
src/Client/Requests/Bsky/Feed.php src/Client/Requests/Bsky/FeedRequestClient.php
src/Client/Requests/Chat/Actor.php src/Client/Requests/Chat/ActorRequestClient.php
src/Client/Requests/Chat/Convo.php src/Client/Requests/Chat/ConvoRequestClient.php
src/Client/Requests/Ozone/Moderation.php src/Client/Requests/Ozone/ModerationRequestClient.php
src/Client/Requests/Ozone/Server.php src/Client/Requests/Ozone/ServerRequestClient.php
src/Client/Requests/Ozone/Team.php src/Client/Requests/Ozone/TeamRequestClient.php
+58
src/RichText/ByteCounter.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\RichText; 4 + 5 + class ByteCounter 6 + { 7 + /** 8 + * Get the byte length of a UTF-8 string 9 + */ 10 + public static function length(string $text): int 11 + { 12 + return strlen($text); 13 + } 14 + 15 + /** 16 + * Get byte position of character at given index 17 + */ 18 + public static function bytePosition(string $text, int $charIndex): int 19 + { 20 + $chars = mb_str_split($text, 1, 'UTF-8'); 21 + $bytePos = 0; 22 + 23 + for ($i = 0; $i < $charIndex && $i < count($chars); $i++) { 24 + $bytePos += strlen($chars[$i]); 25 + } 26 + 27 + return $bytePos; 28 + } 29 + 30 + /** 31 + * Get substring by byte positions 32 + */ 33 + public static function substring(string $text, int $byteStart, int $byteEnd): string 34 + { 35 + return substr($text, $byteStart, $byteEnd - $byteStart); 36 + } 37 + 38 + /** 39 + * Validate byte positions don't split multi-byte characters 40 + */ 41 + public static function validateBytePositions(string $text, int $byteStart, int $byteEnd): bool 42 + { 43 + // Check if positions are within bounds 44 + if ($byteStart < 0 || $byteEnd > strlen($text) || $byteStart > $byteEnd) { 45 + return false; 46 + } 47 + 48 + // Ensure we're not splitting a multi-byte character 49 + $before = substr($text, 0, $byteStart); 50 + $middle = substr($text, $byteStart, $byteEnd - $byteStart); 51 + $after = substr($text, $byteEnd); 52 + 53 + // Check if reconstructed string is valid UTF-8 54 + return mb_check_encoding($before, 'UTF-8') 55 + && mb_check_encoding($middle, 'UTF-8') 56 + && mb_check_encoding($after, 'UTF-8'); 57 + } 58 + }
+168
src/RichText/FacetDetector.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\RichText; 4 + 5 + use SocialDept\AtpResolver\Facades\Resolver; 6 + 7 + class FacetDetector 8 + { 9 + /** 10 + * Auto-detect all facets (mentions, links, tags) from text 11 + */ 12 + public static function detect(string $text): array 13 + { 14 + $facets = []; 15 + 16 + // Detect mentions 17 + $facets = array_merge($facets, static::detectMentions($text)); 18 + 19 + // Detect URLs 20 + $facets = array_merge($facets, static::detectLinks($text)); 21 + 22 + // Detect tags 23 + $facets = array_merge($facets, static::detectTags($text)); 24 + 25 + // Sort facets by byte position 26 + usort($facets, fn ($a, $b) => $a['index']['byteStart'] <=> $b['index']['byteStart']); 27 + 28 + return $facets; 29 + } 30 + 31 + /** 32 + * Detect mentions (@handle.bsky.social) 33 + */ 34 + public static function detectMentions(string $text): array 35 + { 36 + $facets = []; 37 + $pattern = '/@([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?/'; 38 + 39 + preg_match_all($pattern, $text, $matches, PREG_OFFSET_CAPTURE); 40 + 41 + foreach ($matches[0] as $match) { 42 + $handle = ltrim($match[0], '@'); 43 + $byteStart = $match[1]; 44 + $byteEnd = $byteStart + strlen($match[0]); 45 + 46 + try { 47 + $did = Resolver::handleToDid($handle); 48 + 49 + $facets[] = [ 50 + 'index' => [ 51 + 'byteStart' => $byteStart, 52 + 'byteEnd' => $byteEnd, 53 + ], 54 + 'features' => [ 55 + [ 56 + '$type' => 'app.bsky.richtext.facet#mention', 57 + 'did' => $did, 58 + ], 59 + ], 60 + ]; 61 + } catch (\Exception $e) { 62 + // Skip if handle cannot be resolved 63 + continue; 64 + } 65 + } 66 + 67 + return $facets; 68 + } 69 + 70 + /** 71 + * Detect URLs (http:// and https://) 72 + */ 73 + public static function detectLinks(string $text): array 74 + { 75 + $facets = []; 76 + $pattern = '/https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&\/\/=]*)/'; 77 + 78 + preg_match_all($pattern, $text, $matches, PREG_OFFSET_CAPTURE); 79 + 80 + foreach ($matches[0] as $match) { 81 + $url = $match[0]; 82 + $byteStart = $match[1]; 83 + $byteEnd = $byteStart + strlen($url); 84 + 85 + $facets[] = [ 86 + 'index' => [ 87 + 'byteStart' => $byteStart, 88 + 'byteEnd' => $byteEnd, 89 + ], 90 + 'features' => [ 91 + [ 92 + '$type' => 'app.bsky.richtext.facet#link', 93 + 'uri' => $url, 94 + ], 95 + ], 96 + ]; 97 + } 98 + 99 + return $facets; 100 + } 101 + 102 + /** 103 + * Detect hashtags (#tag) 104 + */ 105 + public static function detectTags(string $text): array 106 + { 107 + $facets = []; 108 + $pattern = '/#([a-zA-Z0-9_]+)/u'; 109 + 110 + preg_match_all($pattern, $text, $matches, PREG_OFFSET_CAPTURE); 111 + 112 + foreach ($matches[0] as $i => $match) { 113 + $fullTag = $match[0]; // includes # 114 + $tag = $matches[1][$i][0]; // without # 115 + $byteStart = $match[1]; 116 + $byteEnd = $byteStart + strlen($fullTag); 117 + 118 + $facets[] = [ 119 + 'index' => [ 120 + 'byteStart' => $byteStart, 121 + 'byteEnd' => $byteEnd, 122 + ], 123 + 'features' => [ 124 + [ 125 + '$type' => 'app.bsky.richtext.facet#tag', 126 + 'tag' => $tag, 127 + ], 128 + ], 129 + ]; 130 + } 131 + 132 + return $facets; 133 + } 134 + 135 + /** 136 + * Merge overlapping or adjacent facets 137 + */ 138 + public static function mergeFacets(array $facets): array 139 + { 140 + if (empty($facets)) { 141 + return []; 142 + } 143 + 144 + // Sort by byte position 145 + usort($facets, fn ($a, $b) => $a['index']['byteStart'] <=> $b['index']['byteStart']); 146 + 147 + $merged = []; 148 + $current = $facets[0]; 149 + 150 + for ($i = 1; $i < count($facets); $i++) { 151 + $next = $facets[$i]; 152 + 153 + // Check for overlap 154 + if ($current['index']['byteEnd'] >= $next['index']['byteStart']) { 155 + // Merge features 156 + $current['features'] = array_merge($current['features'], $next['features']); 157 + $current['index']['byteEnd'] = max($current['index']['byteEnd'], $next['index']['byteEnd']); 158 + } else { 159 + $merged[] = $current; 160 + $current = $next; 161 + } 162 + } 163 + 164 + $merged[] = $current; 165 + 166 + return $merged; 167 + } 168 + }
+261
src/RichText/TextBuilder.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\RichText; 4 + 5 + use SocialDept\AtpResolver\Facades\Resolver; 6 + 7 + class TextBuilder 8 + { 9 + protected string $text = ''; 10 + protected array $facets = []; 11 + 12 + /** 13 + * Create a new text builder instance 14 + */ 15 + public static function make(): self 16 + { 17 + return new self(); 18 + } 19 + 20 + /** 21 + * Build text using a callback 22 + */ 23 + public static function build(callable $callback): array 24 + { 25 + $builder = new self(); 26 + $callback($builder); 27 + 28 + return $builder->toArray(); 29 + } 30 + 31 + /** 32 + * Add plain text 33 + */ 34 + public function text(string $text): self 35 + { 36 + $this->text .= $text; 37 + 38 + return $this; 39 + } 40 + 41 + /** 42 + * Add a new line 43 + */ 44 + public function newLine(): self 45 + { 46 + $this->text .= "\n"; 47 + 48 + return $this; 49 + } 50 + 51 + /** 52 + * Add mention (@handle) 53 + */ 54 + public function mention(string $handle, ?string $did = null): self 55 + { 56 + $handle = ltrim($handle, '@'); 57 + $start = $this->getBytePosition(); 58 + $this->text .= '@'.$handle; 59 + $end = $this->getBytePosition(); 60 + 61 + // Resolve DID if not provided 62 + if (! $did) { 63 + try { 64 + $did = Resolver::handleToDid($handle); 65 + } catch (\Exception $e) { 66 + // If resolution fails, still add the text but skip the facet 67 + return $this; 68 + } 69 + } 70 + 71 + $this->facets[] = [ 72 + 'index' => [ 73 + 'byteStart' => $start, 74 + 'byteEnd' => $end, 75 + ], 76 + 'features' => [ 77 + [ 78 + '$type' => 'app.bsky.richtext.facet#mention', 79 + 'did' => $did, 80 + ], 81 + ], 82 + ]; 83 + 84 + return $this; 85 + } 86 + 87 + /** 88 + * Add link with custom display text 89 + */ 90 + public function link(string $text, string $uri): self 91 + { 92 + $start = $this->getBytePosition(); 93 + $this->text .= $text; 94 + $end = $this->getBytePosition(); 95 + 96 + $this->facets[] = [ 97 + 'index' => [ 98 + 'byteStart' => $start, 99 + 'byteEnd' => $end, 100 + ], 101 + 'features' => [ 102 + [ 103 + '$type' => 'app.bsky.richtext.facet#link', 104 + 'uri' => $uri, 105 + ], 106 + ], 107 + ]; 108 + 109 + return $this; 110 + } 111 + 112 + /** 113 + * Add a URL (displayed as-is) 114 + */ 115 + public function url(string $url): self 116 + { 117 + return $this->link($url, $url); 118 + } 119 + 120 + /** 121 + * Add hashtag 122 + */ 123 + public function tag(string $tag): self 124 + { 125 + $tag = ltrim($tag, '#'); 126 + 127 + $start = $this->getBytePosition(); 128 + $this->text .= '#'.$tag; 129 + $end = $this->getBytePosition(); 130 + 131 + $this->facets[] = [ 132 + 'index' => [ 133 + 'byteStart' => $start, 134 + 'byteEnd' => $end, 135 + ], 136 + 'features' => [ 137 + [ 138 + '$type' => 'app.bsky.richtext.facet#tag', 139 + 'tag' => $tag, 140 + ], 141 + ], 142 + ]; 143 + 144 + return $this; 145 + } 146 + 147 + /** 148 + * Auto-detect and add facets from plain text 149 + */ 150 + public function autoDetect(string $text): self 151 + { 152 + $start = $this->getBytePosition(); 153 + $this->text .= $text; 154 + 155 + // Detect facets in the added text 156 + $detected = FacetDetector::detect($text); 157 + 158 + // Adjust byte positions to account for existing text 159 + foreach ($detected as $facet) { 160 + $facet['index']['byteStart'] += $start; 161 + $facet['index']['byteEnd'] += $start; 162 + $this->facets[] = $facet; 163 + } 164 + 165 + return $this; 166 + } 167 + 168 + /** 169 + * Get current byte position 170 + */ 171 + protected function getBytePosition(): int 172 + { 173 + return strlen($this->text); 174 + } 175 + 176 + /** 177 + * Get the text content 178 + */ 179 + public function getText(): string 180 + { 181 + return $this->text; 182 + } 183 + 184 + /** 185 + * Get the facets 186 + */ 187 + public function getFacets(): array 188 + { 189 + return $this->facets; 190 + } 191 + 192 + /** 193 + * Build the final text and facets array 194 + */ 195 + public function toArray(): array 196 + { 197 + return [ 198 + 'text' => $this->text, 199 + 'facets' => $this->facets, 200 + ]; 201 + } 202 + 203 + /** 204 + * Convert to JSON string 205 + */ 206 + public function toJson(int $options = 0): string 207 + { 208 + return json_encode($this->toArray(), $options); 209 + } 210 + 211 + /** 212 + * Create from existing text with auto-detection 213 + */ 214 + public static function parse(string $text): array 215 + { 216 + return [ 217 + 'text' => $text, 218 + 'facets' => FacetDetector::detect($text), 219 + ]; 220 + } 221 + 222 + /** 223 + * Get character count (for post limits) 224 + */ 225 + public function getCharacterCount(): int 226 + { 227 + return mb_strlen($this->text, 'UTF-8'); 228 + } 229 + 230 + /** 231 + * Get byte count 232 + */ 233 + public function getByteCount(): int 234 + { 235 + return strlen($this->text); 236 + } 237 + 238 + /** 239 + * Check if text exceeds AT Protocol post limit (300 graphemes) 240 + */ 241 + public function exceedsLimit(int $limit = 300): bool 242 + { 243 + return $this->getGraphemeCount() > $limit; 244 + } 245 + 246 + /** 247 + * Get grapheme count (closest to what AT Protocol uses) 248 + */ 249 + public function getGraphemeCount(): int 250 + { 251 + return grapheme_strlen($this->text); 252 + } 253 + 254 + /** 255 + * Convert to string (returns text only) 256 + */ 257 + public function __toString(): string 258 + { 259 + return $this->text; 260 + } 261 + }