A decentralized music tracking and discovery platform built on AT Protocol 🎵 rocksky.app
spotify atproto lastfm musicbrainz scrobbling listenbrainz
0

Configure Feed

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

feat(shoutbox): add GIF/sticker/clip media + @mention & link support

Lexicon (app.rocksky.shout, Pkl-first):
- add a `gif` embed def (url/previewUrl/alt/width/height) and `facets`
(mention: did/byteStart/byteEnd); make `message` optional so a shout can
carry only a gif. Regenerated JSON + in-app types/registry.

API:
- store gif as flat columns and facets as jsonb on `shouts`; persist both into
the PDS record and DB in createShout/replyShout; return them from every shout
GET; zod schema + migrations 0016/0017.

web + web-mobile:
- KLIPY-powered MediaPicker (GIFs/stickers/clips) wired into every composer,
with a selected-media preview and gif rendering in shout rows.
- @mention typeahead (MentionTextarea, Bluesky AppView actor search, caret-
anchored popup) + facet resolution on submit.
- RichText rendering: @mentions link to profiles (by DID when faceted) and
http links become clickable, in shout rows and reply previews.

+3252 -85
+1
.tangled/workflows/deploy-web.yml
··· 25 25 echo 'VITE_PUBLIC_URL=https://rocksky.app' >> .env.prod 26 26 echo 'VITE_ROCKBOX_URL=https://rockbox.rocksky.app' >> .env.prod 27 27 echo 'VITE_PUBLIC_POSTHOG_KEY=phc_erMjlNEGNOSKX6dXBZne7k4PuUWqM4Bw4EuQxdcaSd5' >> .env.prod 28 + echo "VITE_KLIPY_API_KEY=$KLIPY_API_KEY" >> .env.prod 28 29 bun run build:prod 29 30 30 31 - name: deploy to cloudflare
+5
apps/api/drizzle/0016_shouts_gif.sql
··· 1 + ALTER TABLE "shouts" ADD COLUMN IF NOT EXISTS "gif_url" text;--> statement-breakpoint 2 + ALTER TABLE "shouts" ADD COLUMN IF NOT EXISTS "gif_preview_url" text;--> statement-breakpoint 3 + ALTER TABLE "shouts" ADD COLUMN IF NOT EXISTS "gif_alt" text;--> statement-breakpoint 4 + ALTER TABLE "shouts" ADD COLUMN IF NOT EXISTS "gif_width" integer;--> statement-breakpoint 5 + ALTER TABLE "shouts" ADD COLUMN IF NOT EXISTS "gif_height" integer;
+1
apps/api/drizzle/0017_shouts_facets.sql
··· 1 + ALTER TABLE "shouts" ADD COLUMN IF NOT EXISTS "facets" jsonb;
+14
apps/api/drizzle/meta/_journal.json
··· 99 99 "when": 1780700000000, 100 100 "tag": "0015_navidrome_playlists", 101 101 "breakpoints": true 102 + }, 103 + { 104 + "idx": 16, 105 + "version": "7", 106 + "when": 1780800000000, 107 + "tag": "0016_shouts_gif", 108 + "breakpoints": true 109 + }, 110 + { 111 + "idx": 17, 112 + "version": "7", 113 + "when": 1780800100000, 114 + "tag": "0017_shouts_facets", 115 + "breakpoints": true 102 116 } 103 117 ] 104 118 }
+73
apps/api/lexicons/shout/defs.json
··· 54 54 "type": "ref", 55 55 "description": "The author of the shout.", 56 56 "ref": "app.rocksky.shout.defs#author" 57 + }, 58 + "gif": { 59 + "type": "ref", 60 + "description": "An attached GIF, sticker, or clip.", 61 + "ref": "app.rocksky.shout.defs#gif" 62 + }, 63 + "facets": { 64 + "type": "array", 65 + "description": "Mentions of other actors within the message, anchored to UTF-8 byte ranges.", 66 + "items": { 67 + "type": "ref", 68 + "ref": "app.rocksky.shout.defs#mention" 69 + } 70 + } 71 + } 72 + }, 73 + "mention": { 74 + "type": "object", 75 + "description": "A mention of another actor within the shout message, anchored to a UTF-8 byte range in the message.", 76 + "required": [ 77 + "did", 78 + "byteStart", 79 + "byteEnd" 80 + ], 81 + "properties": { 82 + "did": { 83 + "type": "string", 84 + "description": "The DID of the mentioned actor.", 85 + "format": "did" 86 + }, 87 + "byteStart": { 88 + "type": "integer", 89 + "description": "Inclusive UTF-8 byte offset of the mention start.", 90 + "minimum": 0 91 + }, 92 + "byteEnd": { 93 + "type": "integer", 94 + "description": "Exclusive UTF-8 byte offset of the mention end.", 95 + "minimum": 0 96 + } 97 + } 98 + }, 99 + "gif": { 100 + "type": "object", 101 + "description": "A GIF, sticker, or clip embedded in a shout. `url` may point at an image (GIF/WebP) or a video (MP4); the client decides how to render it from the file extension.", 102 + "required": [ 103 + "url" 104 + ], 105 + "properties": { 106 + "url": { 107 + "type": "string", 108 + "description": "Direct URL of the animated GIF/MP4.", 109 + "format": "uri" 110 + }, 111 + "previewUrl": { 112 + "type": "string", 113 + "description": "Smaller still/preview image URL.", 114 + "format": "uri" 115 + }, 116 + "alt": { 117 + "type": "string", 118 + "description": "Alternative text describing the media.", 119 + "maxLength": 512 120 + }, 121 + "width": { 122 + "type": "integer", 123 + "description": "The intrinsic width of the media in pixels.", 124 + "minimum": 0 125 + }, 126 + "height": { 127 + "type": "integer", 128 + "description": "The intrinsic height of the media in pixels.", 129 + "minimum": 0 57 130 } 58 131 } 59 132 }
+14 -3
apps/api/lexicons/shout/shout.json
··· 9 9 "record": { 10 10 "type": "object", 11 11 "required": [ 12 - "message", 13 12 "createdAt", 14 13 "subject" 15 14 ], 16 15 "properties": { 17 16 "message": { 18 17 "type": "string", 19 - "description": "The message of the shout.", 20 - "minLength": 1, 18 + "description": "The message of the shout. Optional when a gif/sticker/clip is attached.", 21 19 "maxLength": 1000 22 20 }, 23 21 "createdAt": { ··· 32 30 "subject": { 33 31 "type": "ref", 34 32 "ref": "com.atproto.repo.strongRef" 33 + }, 34 + "gif": { 35 + "type": "ref", 36 + "description": "An attached GIF, sticker, or clip (e.g. from KLIPY).", 37 + "ref": "app.rocksky.shout.defs#gif" 38 + }, 39 + "facets": { 40 + "type": "array", 41 + "description": "Mentions of other actors within the message, anchored to UTF-8 byte ranges.", 42 + "items": { 43 + "type": "ref", 44 + "ref": "app.rocksky.shout.defs#mention" 45 + } 35 46 } 36 47 } 37 48 }
+75
apps/api/pkl/defs/shout/defs.pkl
··· 64 64 description = "The author of the shout." 65 65 ref = "app.rocksky.shout.defs#author" 66 66 } 67 + 68 + ["gif"] = new Ref { 69 + type = "ref" 70 + description = "An attached GIF, sticker, or clip." 71 + ref = "app.rocksky.shout.defs#gif" 72 + } 73 + 74 + ["facets"] = new Array { 75 + type = "array" 76 + description = "Mentions of other actors within the message, anchored to UTF-8 byte ranges." 77 + items = new Ref { 78 + type = "ref" 79 + ref = "app.rocksky.shout.defs#mention" 80 + } 81 + } 82 + } 83 + } 84 + ["mention"] { 85 + type = "object" 86 + description = "A mention of another actor within the shout message, anchored to a UTF-8 byte range in the message." 87 + required = List("did", "byteStart", "byteEnd") 88 + properties { 89 + ["did"] = new StringType { 90 + type = "string" 91 + format = "did" 92 + description = "The DID of the mentioned actor." 93 + } 94 + 95 + ["byteStart"] = new IntegerType { 96 + type = "integer" 97 + minimum = 0 98 + description = "Inclusive UTF-8 byte offset of the mention start." 99 + } 100 + 101 + ["byteEnd"] = new IntegerType { 102 + type = "integer" 103 + minimum = 0 104 + description = "Exclusive UTF-8 byte offset of the mention end." 105 + } 106 + } 107 + } 108 + ["gif"] { 109 + type = "object" 110 + description = "A GIF, sticker, or clip embedded in a shout. `url` may point at an image (GIF/WebP) or a video (MP4); the client decides how to render it from the file extension." 111 + required = List("url") 112 + properties { 113 + ["url"] = new StringType { 114 + type = "string" 115 + format = "uri" 116 + description = "Direct URL of the animated GIF/MP4." 117 + } 118 + 119 + ["previewUrl"] = new StringType { 120 + type = "string" 121 + format = "uri" 122 + description = "Smaller still/preview image URL." 123 + } 124 + 125 + ["alt"] = new StringType { 126 + type = "string" 127 + maxLength = 512 128 + description = "Alternative text describing the media." 129 + } 130 + 131 + ["width"] = new IntegerType { 132 + type = "integer" 133 + minimum = 0 134 + description = "The intrinsic width of the media in pixels." 135 + } 136 + 137 + ["height"] = new IntegerType { 138 + type = "integer" 139 + minimum = 0 140 + description = "The intrinsic height of the media in pixels." 141 + } 67 142 } 68 143 } 69 144 }
+17 -3
apps/api/pkl/defs/shout/shout.pkl
··· 9 9 description = "A declaration of a shout." 10 10 `record` { 11 11 type = "object" 12 - required = List("message", "createdAt", "subject") 12 + required = List("createdAt", "subject") 13 13 properties { 14 14 ["message"] = new StringType { 15 15 type = "string" 16 - description = "The message of the shout." 17 - minLength = 1 16 + description = "The message of the shout. Optional when a gif/sticker/clip is attached." 18 17 maxLength = 1000 19 18 } 20 19 ··· 32 31 ["subject"] = new Ref { 33 32 type = "ref" 34 33 ref = "com.atproto.repo.strongRef" 34 + } 35 + 36 + ["gif"] = new Ref { 37 + type = "ref" 38 + description = "An attached GIF, sticker, or clip (e.g. from KLIPY)." 39 + ref = "app.rocksky.shout.defs#gif" 40 + } 41 + 42 + ["facets"] = new Array { 43 + type = "array" 44 + description = "Mentions of other actors within the message, anchored to UTF-8 byte ranges." 45 + items = new Ref { 46 + type = "ref" 47 + ref = "app.rocksky.shout.defs#mention" 48 + } 35 49 } 36 50 } 37 51 }
+88 -3
apps/api/src/lexicon/lexicons.ts
··· 6008 6008 description: "The author of the shout.", 6009 6009 ref: "lex:app.rocksky.shout.defs#author", 6010 6010 }, 6011 + gif: { 6012 + type: "ref", 6013 + description: "An attached GIF, sticker, or clip.", 6014 + ref: "lex:app.rocksky.shout.defs#gif", 6015 + }, 6016 + facets: { 6017 + type: "array", 6018 + description: 6019 + "Mentions of other actors within the message, anchored to UTF-8 byte ranges.", 6020 + items: { 6021 + type: "ref", 6022 + ref: "lex:app.rocksky.shout.defs#mention", 6023 + }, 6024 + }, 6025 + }, 6026 + }, 6027 + mention: { 6028 + type: "object", 6029 + description: 6030 + "A mention of another actor within the shout message, anchored to a UTF-8 byte range in the message.", 6031 + required: ["did", "byteStart", "byteEnd"], 6032 + properties: { 6033 + did: { 6034 + type: "string", 6035 + description: "The DID of the mentioned actor.", 6036 + format: "did", 6037 + }, 6038 + byteStart: { 6039 + type: "integer", 6040 + description: "Inclusive UTF-8 byte offset of the mention start.", 6041 + minimum: 0, 6042 + }, 6043 + byteEnd: { 6044 + type: "integer", 6045 + description: "Exclusive UTF-8 byte offset of the mention end.", 6046 + minimum: 0, 6047 + }, 6048 + }, 6049 + }, 6050 + gif: { 6051 + type: "object", 6052 + description: 6053 + "A GIF, sticker, or clip embedded in a shout. `url` may point at an image (GIF/WebP) or a video (MP4); the client decides how to render it from the file extension.", 6054 + required: ["url"], 6055 + properties: { 6056 + url: { 6057 + type: "string", 6058 + description: "Direct URL of the animated GIF/MP4.", 6059 + format: "uri", 6060 + }, 6061 + previewUrl: { 6062 + type: "string", 6063 + description: "Smaller still/preview image URL.", 6064 + format: "uri", 6065 + }, 6066 + alt: { 6067 + type: "string", 6068 + description: "Alternative text describing the media.", 6069 + maxLength: 512, 6070 + }, 6071 + width: { 6072 + type: "integer", 6073 + description: "The intrinsic width of the media in pixels.", 6074 + minimum: 0, 6075 + }, 6076 + height: { 6077 + type: "integer", 6078 + description: "The intrinsic height of the media in pixels.", 6079 + minimum: 0, 6080 + }, 6011 6081 }, 6012 6082 }, 6013 6083 }, ··· 6343 6413 key: "tid", 6344 6414 record: { 6345 6415 type: "object", 6346 - required: ["message", "createdAt", "subject"], 6416 + required: ["createdAt", "subject"], 6347 6417 properties: { 6348 6418 message: { 6349 6419 type: "string", 6350 - description: "The message of the shout.", 6351 - minLength: 1, 6420 + description: 6421 + "The message of the shout. Optional when a gif/sticker/clip is attached.", 6352 6422 maxLength: 1000, 6353 6423 }, 6354 6424 createdAt: { ··· 6363 6433 subject: { 6364 6434 type: "ref", 6365 6435 ref: "lex:com.atproto.repo.strongRef", 6436 + }, 6437 + gif: { 6438 + type: "ref", 6439 + description: 6440 + "An attached GIF, sticker, or clip (e.g. from KLIPY).", 6441 + ref: "lex:app.rocksky.shout.defs#gif", 6442 + }, 6443 + facets: { 6444 + type: "array", 6445 + description: 6446 + "Mentions of other actors within the message, anchored to UTF-8 byte ranges.", 6447 + items: { 6448 + type: "ref", 6449 + ref: "lex:app.rocksky.shout.defs#mention", 6450 + }, 6366 6451 }, 6367 6452 }, 6368 6453 },
+7 -2
apps/api/src/lexicon/types/app/rocksky/shout.ts
··· 6 6 import { isObj, hasProp } from "../../../util"; 7 7 import { CID } from "multiformats/cid"; 8 8 import type * as ComAtprotoRepoStrongRef from "../../com/atproto/repo/strongRef"; 9 + import type * as AppRockskyShoutDefs from "./shout/defs"; 9 10 10 11 export interface Record { 11 - /** The message of the shout. */ 12 - message: string; 12 + /** The message of the shout. Optional when a gif/sticker/clip is attached. */ 13 + message?: string; 13 14 /** The date when the shout was created. */ 14 15 createdAt: string; 15 16 parent?: ComAtprotoRepoStrongRef.Main; 16 17 subject: ComAtprotoRepoStrongRef.Main; 18 + /** An attached GIF, sticker, or clip (e.g. from KLIPY). */ 19 + gif?: AppRockskyShoutDefs.Gif; 20 + /** Mentions of other actors within the message, anchored to UTF-8 byte ranges. */ 21 + facets?: AppRockskyShoutDefs.Mention[]; 17 22 [k: string]: unknown; 18 23 } 19 24
+50
apps/api/src/lexicon/types/app/rocksky/shout/defs.ts
··· 42 42 /** The date and time when the shout was created. */ 43 43 createdAt?: string; 44 44 author?: Author; 45 + /** An attached GIF, sticker, or clip. */ 46 + gif?: Gif; 47 + /** Mentions of other actors within the message, anchored to UTF-8 byte ranges. */ 48 + facets?: Mention[]; 45 49 [k: string]: unknown; 46 50 } 47 51 ··· 56 60 export function validateShoutView(v: unknown): ValidationResult { 57 61 return lexicons.validate("app.rocksky.shout.defs#shoutView", v); 58 62 } 63 + 64 + export interface Gif { 65 + /** Direct URL of the animated GIF/MP4. */ 66 + url: string; 67 + /** Smaller still/preview image URL. */ 68 + previewUrl?: string; 69 + /** Alternative text describing the media. */ 70 + alt?: string; 71 + /** The intrinsic width of the media in pixels. */ 72 + width?: number; 73 + /** The intrinsic height of the media in pixels. */ 74 + height?: number; 75 + [k: string]: unknown; 76 + } 77 + 78 + export function isGif(v: unknown): v is Gif { 79 + return ( 80 + isObj(v) && hasProp(v, "$type") && v.$type === "app.rocksky.shout.defs#gif" 81 + ); 82 + } 83 + 84 + export function validateGif(v: unknown): ValidationResult { 85 + return lexicons.validate("app.rocksky.shout.defs#gif", v); 86 + } 87 + 88 + export interface Mention { 89 + /** The DID of the mentioned actor. */ 90 + did: string; 91 + /** Inclusive UTF-8 byte offset of the mention start. */ 92 + byteStart: number; 93 + /** Exclusive UTF-8 byte offset of the mention end. */ 94 + byteEnd: number; 95 + [k: string]: unknown; 96 + } 97 + 98 + export function isMention(v: unknown): v is Mention { 99 + return ( 100 + isObj(v) && 101 + hasProp(v, "$type") && 102 + v.$type === "app.rocksky.shout.defs#mention" 103 + ); 104 + } 105 + 106 + export function validateMention(v: unknown): ValidationResult { 107 + return lexicons.validate("app.rocksky.shout.defs#mention", v); 108 + }
+8 -1
apps/api/src/schema/shouts.ts
··· 1 1 import { type InferInsertModel, type InferSelectModel, sql } from "drizzle-orm"; 2 - import { pgTable, text, timestamp } from "drizzle-orm/pg-core"; 2 + import { integer, jsonb, pgTable, text, timestamp } from "drizzle-orm/pg-core"; 3 + import type { Mention } from "../types/shout"; 3 4 import albums from "./albums"; 4 5 import scrobbles from "./scrobbles"; 5 6 import tracks from "./tracks"; ··· 17 18 .references(() => users.id) 18 19 .notNull(), 19 20 parentId: text("parent_id").references(() => shouts.id), 21 + gifUrl: text("gif_url"), 22 + gifPreviewUrl: text("gif_preview_url"), 23 + gifAlt: text("gif_alt"), 24 + gifWidth: integer("gif_width"), 25 + gifHeight: integer("gif_height"), 26 + facets: jsonb("facets").$type<Mention[]>(), 20 27 createdAt: timestamp("xata_createdat").defaultNow().notNull(), 21 28 updatedAt: timestamp("xata_updatedat").defaultNow().notNull(), 22 29 });
+20 -4
apps/api/src/shouts/shouts.service.ts
··· 107 107 const record = { 108 108 $type: "app.rocksky.shout", 109 109 subject: subjectRef.value, 110 - message: shout.message, 110 + ...(shout.message ? { message: shout.message } : {}), 111 + ...(shout.gif ? { gif: shout.gif } : {}), 112 + ...(shout.facets?.length ? { facets: shout.facets } : {}), 111 113 createdAt: new Date().toISOString(), 112 114 }; 113 115 ··· 131 133 const createdShout = await ctx.db 132 134 .insert(shouts) 133 135 .values({ 134 - content: shout.message, 136 + content: shout.message ?? "", 135 137 uri, 136 138 authorId: user.id, 137 139 albumId: album?.id, 138 140 artistId: artist?.id, 139 141 trackId: track?.id, 140 142 scrobbleId: scrobble?.scrobble.id, 143 + gifUrl: shout.gif?.url, 144 + gifPreviewUrl: shout.gif?.previewUrl, 145 + gifAlt: shout.gif?.alt, 146 + gifWidth: shout.gif?.width, 147 + gifHeight: shout.gif?.height, 148 + facets: shout.facets?.length ? shout.facets : null, 141 149 }) 142 150 .returning() 143 151 .then((rows) => rows[0]); ··· 265 273 $type: "app.rocksky.shout", 266 274 subject: subjectRef.value, 267 275 parent: parentRef.value, 268 - message: reply.message, 276 + ...(reply.message ? { message: reply.message } : {}), 277 + ...(reply.gif ? { gif: reply.gif } : {}), 278 + ...(reply.facets?.length ? { facets: reply.facets } : {}), 269 279 createdAt: new Date().toISOString(), 270 280 }; 271 281 ··· 289 299 const createdShout = await ctx.db 290 300 .insert(shouts) 291 301 .values({ 292 - content: reply.message, 302 + content: reply.message ?? "", 293 303 uri, 294 304 parentId: shout.shout.id, 295 305 authorId: user.id, ··· 297 307 albumId: shout.album?.id, 298 308 artistId: shout.artist?.id, 299 309 scrobbleId: shout.scrobble?.id, 310 + gifUrl: reply.gif?.url, 311 + gifPreviewUrl: reply.gif?.previewUrl, 312 + gifAlt: reply.gif?.alt, 313 + gifWidth: reply.gif?.width, 314 + gifHeight: reply.gif?.height, 315 + facets: reply.facets?.length ? reply.facets : null, 300 316 }) 301 317 .returning() 302 318 .then((rows) => rows[0]);
+26 -2
apps/api/src/types/shout.ts
··· 1 1 import z from "zod"; 2 2 3 - export const shoutSchema = z.object({ 4 - message: z.string().nonempty(), 3 + export const gifSchema = z.object({ 4 + url: z.string().url(), 5 + previewUrl: z.string().url().optional(), 6 + alt: z.string().max(512).optional(), 7 + width: z.number().int().min(0).optional(), 8 + height: z.number().int().min(0).optional(), 5 9 }); 10 + 11 + export type Gif = z.infer<typeof gifSchema>; 12 + 13 + export const mentionSchema = z.object({ 14 + did: z.string(), 15 + byteStart: z.number().int().min(0), 16 + byteEnd: z.number().int().min(0), 17 + }); 18 + 19 + export type Mention = z.infer<typeof mentionSchema>; 20 + 21 + export const shoutSchema = z 22 + .object({ 23 + message: z.string().max(1000).optional(), 24 + gif: gifSchema.optional(), 25 + facets: z.array(mentionSchema).optional(), 26 + }) 27 + .refine((v) => (v.message?.trim().length ?? 0) > 0 || v.gif != null, { 28 + message: "A shout must have a message or a gif.", 29 + }); 6 30 7 31 export type Shout = z.infer<typeof shoutSchema>;
+21
apps/api/src/users/app.ts
··· 32 32 33 33 const app = new Hono(); 34 34 35 + /** Shared media (gif/sticker/clip) + mention-facet columns selected alongside 36 + * every shout row. */ 37 + const shoutGifColumns = { 38 + gifUrl: tables.shouts.gifUrl, 39 + gifPreviewUrl: tables.shouts.gifPreviewUrl, 40 + gifAlt: tables.shouts.gifAlt, 41 + gifWidth: tables.shouts.gifWidth, 42 + gifHeight: tables.shouts.gifHeight, 43 + facets: tables.shouts.facets, 44 + }; 45 + 35 46 app.get("/:did/likes", async (c) => { 36 47 requestCounter.add(1, { method: "GET", route: "/users/:did/likes" }); 37 48 const did = c.req.param("did"); ··· 1074 1085 ? { 1075 1086 id: tables.shouts.id, 1076 1087 content: tables.shouts.content, 1088 + ...shoutGifColumns, 1077 1089 createdAt: tables.shouts.createdAt, 1078 1090 uri: tables.shouts.uri, 1079 1091 parent: tables.shouts.parentId, ··· 1089 1101 : { 1090 1102 id: tables.shouts.id, 1091 1103 content: tables.shouts.content, 1104 + ...shoutGifColumns, 1092 1105 createdAt: tables.shouts.createdAt, 1093 1106 parent: tables.shouts.parentId, 1094 1107 uri: tables.shouts.uri, ··· 1157 1170 ? { 1158 1171 id: tables.shouts.id, 1159 1172 content: tables.shouts.content, 1173 + ...shoutGifColumns, 1160 1174 createdAt: tables.shouts.createdAt, 1161 1175 parent: tables.shouts.parentId, 1162 1176 uri: tables.shouts.uri, ··· 1172 1186 : { 1173 1187 id: tables.shouts.id, 1174 1188 content: tables.shouts.content, 1189 + ...shoutGifColumns, 1175 1190 createdAt: tables.shouts.createdAt, 1176 1191 parent: tables.shouts.parentId, 1177 1192 uri: tables.shouts.uri, ··· 1240 1255 ? { 1241 1256 id: tables.shouts.id, 1242 1257 content: tables.shouts.content, 1258 + ...shoutGifColumns, 1243 1259 createdAt: tables.shouts.createdAt, 1244 1260 uri: tables.shouts.uri, 1245 1261 parent: tables.shouts.parentId, ··· 1255 1271 : { 1256 1272 id: tables.shouts.id, 1257 1273 content: tables.shouts.content, 1274 + ...shoutGifColumns, 1258 1275 createdAt: tables.shouts.createdAt, 1259 1276 uri: tables.shouts.uri, 1260 1277 parent: tables.shouts.parentId, ··· 1323 1340 ? { 1324 1341 id: tables.shouts.id, 1325 1342 content: tables.shouts.content, 1343 + ...shoutGifColumns, 1326 1344 createdAt: tables.shouts.createdAt, 1327 1345 uri: tables.shouts.uri, 1328 1346 parent: tables.shouts.parentId, ··· 1338 1356 : { 1339 1357 id: tables.shouts.id, 1340 1358 content: tables.shouts.content, 1359 + ...shoutGifColumns, 1341 1360 createdAt: tables.shouts.createdAt, 1342 1361 uri: tables.shouts.uri, 1343 1362 parent: tables.shouts.parentId, ··· 1409 1428 ? { 1410 1429 id: tables.shouts.id, 1411 1430 content: tables.shouts.content, 1431 + ...shoutGifColumns, 1412 1432 createdAt: tables.shouts.createdAt, 1413 1433 uri: tables.shouts.uri, 1414 1434 parent: tables.shouts.parentId, ··· 1431 1451 : { 1432 1452 id: tables.shouts.id, 1433 1453 content: tables.shouts.content, 1454 + ...shoutGifColumns, 1434 1455 createdAt: tables.shouts.createdAt, 1435 1456 uri: tables.shouts.uri, 1436 1457 parent: tables.shouts.parentId,
+3 -1
apps/web-mobile/.env.example
··· 1 - VITE_API_URL=http://localhost:8000 1 + VITE_API_URL=http://localhost:8000 2 + VITE_KLIPY_API_KEY= 3 +
+219
apps/web-mobile/src/api/klipy.ts
··· 1 + /** 2 + * KLIPY media API (GIFs, stickers, clips). Set `VITE_KLIPY_API_KEY` to enable 3 + * the picker. https://klipy.com/developers — endpoints follow 4 + * `api.klipy.com/api/v1/{key}/{type}/{search|trending}`. 5 + * 6 + * Note: this API/plan exposes `gifs`, `stickers`, and `clips`. There is no 7 + * `memes` route (it 404s "Route not found"), so we don't offer that tab. 8 + */ 9 + const KLIPY_KEY = import.meta.env.VITE_KLIPY_API_KEY ?? ""; 10 + const KLIPY_BASE = "https://api.klipy.com/api/v1"; 11 + 12 + /** True when a key is configured; the picker degrades to a hint otherwise. */ 13 + export const KLIPY_ENABLED = Boolean(KLIPY_KEY); 14 + 15 + export type KlipyMediaType = "gifs" | "stickers" | "clips"; 16 + 17 + export const KLIPY_TABS: { type: KlipyMediaType; label: string }[] = [ 18 + { type: "gifs", label: "GIFs" }, 19 + { type: "stickers", label: "Stickers" }, 20 + { type: "clips", label: "Clips" }, 21 + ]; 22 + 23 + /** The media embed persisted on a shout record (mirrors app.rocksky.shout#gif). */ 24 + export interface GifEmbed { 25 + url: string; 26 + previewUrl?: string; 27 + alt?: string; 28 + width?: number; 29 + height?: number; 30 + } 31 + 32 + /** A pickable media item: renders in the grid, embeds on select. */ 33 + export interface MediaResult extends GifEmbed { 34 + id: string; 35 + /** True when `url` is a video (mp4) rather than an image. */ 36 + isVideo: boolean; 37 + } 38 + 39 + /** True when a URL points at a video the UI should render with <video>. */ 40 + export function isVideoUrl(url: string): boolean { 41 + return /\.mp4($|\?)/i.test(url); 42 + } 43 + 44 + interface Rendition { 45 + url: string; 46 + width?: number; 47 + height?: number; 48 + } 49 + 50 + // KLIPY returns two different `file` shapes: 51 + // • gifs / stickers — nested by size then format: 52 + // file: { md: { gif: {url,width,height}, webp: {...}, mp4: {...} }, ... } 53 + // • clips — flat format → url string, with dimensions in a sibling `file_meta`: 54 + // file: { mp4: "url", gif: "url", webp: "url" } 55 + // file_meta: { mp4: {width,height}, gif: {...}, webp: {...} } 56 + type NestedRendition = { 57 + url?: string; 58 + width?: number | string; 59 + height?: number | string; 60 + }; 61 + type NestedFormatMap = Record<string, NestedRendition | undefined>; 62 + type NestedFile = Record<string, NestedFormatMap | undefined>; 63 + type FlatFile = Record<string, string | undefined>; 64 + type MetaMap = Record< 65 + string, 66 + { width?: number | string; height?: number | string } | undefined 67 + >; 68 + 69 + interface KlipyItem { 70 + id?: number | string; 71 + slug?: string; 72 + title?: string; 73 + file?: NestedFile | FlatFile; 74 + files?: NestedFile | FlatFile; 75 + file_meta?: MetaMap; 76 + } 77 + 78 + const num = (v: number | string | undefined): number | undefined => { 79 + const n = typeof v === "string" ? Number(v) : v; 80 + return Number.isFinite(n) ? (n as number) : undefined; 81 + }; 82 + 83 + /** A `file` whose top-level values are strings is the flat (clips) shape. */ 84 + function isFlatFile(file: NestedFile | FlatFile): file is FlatFile { 85 + return Object.values(file).some((v) => typeof v === "string"); 86 + } 87 + 88 + /** Flat (clips) shape → renditions from format→url strings + `file_meta` dims. */ 89 + function pickFlat( 90 + file: FlatFile, 91 + meta: MetaMap | undefined, 92 + wantVideo: boolean, 93 + ): { full?: Rendition; preview?: Rendition } { 94 + const at = (fmt: string): Rendition | undefined => { 95 + const url = file[fmt]; 96 + if (typeof url !== "string" || !url) return undefined; 97 + const m = meta?.[fmt]; 98 + return { url, width: num(m?.width), height: num(m?.height) }; 99 + }; 100 + const fullFmts = wantVideo ? ["mp4", "webp", "gif"] : ["gif", "webp", "mp4"]; 101 + let full: Rendition | undefined; 102 + for (const f of fullFmts) if (!full) full = at(f); 103 + let preview: Rendition | undefined; 104 + for (const f of ["gif", "webp", "jpg", "png"]) if (!preview) preview = at(f); 105 + return { full, preview }; 106 + } 107 + 108 + /** Nested (gifs/stickers) shape → walk size then format. */ 109 + function pickNested( 110 + file: NestedFile, 111 + wantVideo: boolean, 112 + ): { full?: Rendition; preview?: Rendition } { 113 + const sizes = ["md", "sm", "hd", "lg", "xs"]; 114 + const fullFmts = wantVideo ? ["mp4", "webp", "gif"] : ["gif", "webp", "mp4"]; 115 + const previewFmts = ["webp", "gif", "png", "jpg"]; 116 + const norm = (r: NestedRendition | undefined): Rendition | undefined => 117 + r?.url ? { url: r.url, width: num(r.width), height: num(r.height) } : undefined; 118 + 119 + let full: Rendition | undefined; 120 + for (const s of sizes) { 121 + const fm = file[s]; 122 + if (!fm) continue; 123 + for (const f of fullFmts) if (!full) full = norm(fm[f]); 124 + if (full) break; 125 + } 126 + let preview: Rendition | undefined; 127 + for (const s of ["sm", "md", "hd", "xs"]) { 128 + const fm = file[s]; 129 + if (!fm) continue; 130 + for (const f of previewFmts) if (!preview) preview = norm(fm[f]); 131 + if (preview) break; 132 + } 133 + return { full, preview }; 134 + } 135 + 136 + function toResult(item: KlipyItem, type: KlipyMediaType): MediaResult | null { 137 + const wantVideo = type === "clips"; 138 + const file = item.file ?? item.files; 139 + if (!file) return null; 140 + const { full, preview } = isFlatFile(file) 141 + ? pickFlat(file, item.file_meta, wantVideo) 142 + : pickNested(file, wantVideo); 143 + if (!full?.url) return null; 144 + return { 145 + id: String(item.id ?? item.slug ?? full.url), 146 + url: full.url, 147 + previewUrl: preview?.url ?? full.url, 148 + alt: item.title?.trim() || item.slug || undefined, 149 + width: full.width, 150 + height: full.height, 151 + isVideo: isVideoUrl(full.url), 152 + }; 153 + } 154 + 155 + /** A stable per-browser id KLIPY uses to personalize trending/recents. */ 156 + function customerId(): string { 157 + try { 158 + const key = "rocksky:klipy-cid"; 159 + let v = localStorage.getItem(key); 160 + if (!v) { 161 + v = Math.random().toString(36).slice(2) + Date.now().toString(36); 162 + localStorage.setItem(key, v); 163 + } 164 + return v; 165 + } catch { 166 + return "anon"; 167 + } 168 + } 169 + 170 + async function request( 171 + type: KlipyMediaType, 172 + action: "search" | "trending", 173 + params: URLSearchParams, 174 + ): Promise<MediaResult[]> { 175 + if (!KLIPY_KEY) return []; 176 + params.set("customer_id", customerId()); 177 + const url = `${KLIPY_BASE}/${encodeURIComponent(KLIPY_KEY)}/${type}/${action}?${params}`; 178 + try { 179 + const res = await fetch(url, { headers: { Accept: "application/json" } }); 180 + if (!res.ok) return []; 181 + const json = (await res.json()) as { 182 + data?: { data?: KlipyItem[] } | KlipyItem[]; 183 + }; 184 + const list = Array.isArray(json.data) 185 + ? json.data 186 + : (json.data?.data ?? []); 187 + return list 188 + .map((it) => toResult(it, type)) 189 + .filter((r): r is MediaResult => r !== null); 190 + } catch { 191 + return []; 192 + } 193 + } 194 + 195 + /** Search KLIPY media of a given type; empty query → trending. */ 196 + export function searchMedia( 197 + type: KlipyMediaType, 198 + q: string, 199 + perPage = 24, 200 + ): Promise<MediaResult[]> { 201 + const query = q.trim(); 202 + if (!query) { 203 + return request( 204 + type, 205 + "trending", 206 + new URLSearchParams({ page: "1", per_page: String(perPage) }), 207 + ); 208 + } 209 + return request( 210 + type, 211 + "search", 212 + new URLSearchParams({ q: query, page: "1", per_page: String(perPage) }), 213 + ); 214 + } 215 + 216 + /** Adapt a stored gif embed back into the picker's MediaResult shape. */ 217 + export function gifEmbedToMedia(g: GifEmbed): MediaResult { 218 + return { ...g, id: g.url, isVideo: isVideoUrl(g.url) }; 219 + }
+247
apps/web-mobile/src/components/Shout/MediaPicker/MediaPicker.tsx
··· 1 + import styled from "@emotion/styled"; 2 + import { IconSearch, IconX } from "@tabler/icons-react"; 3 + import { useEffect, useRef, useState } from "react"; 4 + import { useQuery } from "@tanstack/react-query"; 5 + import { 6 + KLIPY_ENABLED, 7 + KLIPY_TABS, 8 + searchMedia, 9 + type KlipyMediaType, 10 + type MediaResult, 11 + } from "../../../api/klipy"; 12 + 13 + const Panel = styled.div` 14 + display: flex; 15 + flex-direction: column; 16 + gap: 8px; 17 + width: 340px; 18 + max-width: calc(100vw - 32px); 19 + padding: 12px; 20 + border-radius: 12px; 21 + background-color: var(--color-background); 22 + border: 1px solid var(--color-input-background); 23 + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.35); 24 + `; 25 + 26 + const TopBar = styled.div` 27 + display: flex; 28 + align-items: center; 29 + justify-content: space-between; 30 + `; 31 + 32 + const Tabs = styled.div` 33 + display: flex; 34 + gap: 4px; 35 + `; 36 + 37 + const Tab = styled.button<{ active: boolean }>` 38 + border: none; 39 + cursor: pointer; 40 + border-radius: 999px; 41 + padding: 4px 10px; 42 + font-size: 12px; 43 + font-weight: 600; 44 + transition: all 0.15s ease; 45 + background-color: ${({ active }) => 46 + active ? "var(--color-purple)" : "transparent"}; 47 + color: ${({ active }) => 48 + active ? "var(--color-button-text)" : "var(--color-text-muted)"}; 49 + 50 + &:hover { 51 + color: var(--color-text); 52 + } 53 + `; 54 + 55 + const CloseButton = styled.button` 56 + display: flex; 57 + align-items: center; 58 + justify-content: center; 59 + height: 28px; 60 + width: 28px; 61 + border: none; 62 + border-radius: 999px; 63 + cursor: pointer; 64 + background-color: transparent; 65 + color: var(--color-text-muted); 66 + 67 + &:hover { 68 + background-color: var(--color-input-background); 69 + color: var(--color-text); 70 + } 71 + `; 72 + 73 + const SearchBox = styled.div` 74 + display: flex; 75 + align-items: center; 76 + gap: 8px; 77 + padding: 0 10px; 78 + border-radius: 8px; 79 + background-color: var(--color-input-background); 80 + `; 81 + 82 + const SearchInput = styled.input` 83 + height: 34px; 84 + width: 100%; 85 + border: none; 86 + outline: none; 87 + background-color: transparent; 88 + font-size: 14px; 89 + color: var(--color-text); 90 + 91 + &::placeholder { 92 + color: var(--color-text-muted); 93 + } 94 + `; 95 + 96 + const Grid = styled.div` 97 + display: grid; 98 + grid-template-columns: repeat(3, 1fr); 99 + gap: 6px; 100 + height: 300px; 101 + align-content: start; 102 + overflow-y: auto; 103 + `; 104 + 105 + const Tile = styled.button` 106 + aspect-ratio: 1; 107 + overflow: hidden; 108 + border-radius: 8px; 109 + border: 1px solid var(--color-input-background); 110 + cursor: pointer; 111 + padding: 0; 112 + background-color: var(--color-input-background); 113 + transition: transform 0.12s ease; 114 + 115 + &:hover { 116 + transform: scale(1.03); 117 + border-color: var(--color-purple); 118 + } 119 + 120 + & > img, 121 + & > video { 122 + height: 100%; 123 + width: 100%; 124 + object-fit: cover; 125 + display: block; 126 + } 127 + `; 128 + 129 + const Empty = styled.div` 130 + grid-column: span 3; 131 + padding: 32px 0; 132 + text-align: center; 133 + font-size: 12px; 134 + color: var(--color-text-muted); 135 + `; 136 + 137 + const Credit = styled.p` 138 + margin: 0; 139 + text-align: center; 140 + font-size: 10px; 141 + color: var(--color-text-muted); 142 + `; 143 + 144 + /** Debounce a fast-changing value. */ 145 + function useDebounced<T>(value: T, delay: number): T { 146 + const [debounced, setDebounced] = useState(value); 147 + useEffect(() => { 148 + const t = setTimeout(() => setDebounced(value), delay); 149 + return () => clearTimeout(t); 150 + }, [value, delay]); 151 + return debounced; 152 + } 153 + 154 + interface MediaPickerProps { 155 + onSelect: (media: MediaResult) => void; 156 + onClose: () => void; 157 + } 158 + 159 + /** KLIPY-powered GIF / sticker / clip picker. */ 160 + function MediaPicker({ onSelect, onClose }: MediaPickerProps) { 161 + const [type, setType] = useState<KlipyMediaType>("gifs"); 162 + const [query, setQuery] = useState(""); 163 + const debounced = useDebounced(query, 350); 164 + const inputRef = useRef<HTMLInputElement>(null); 165 + 166 + useEffect(() => { 167 + requestAnimationFrame(() => inputRef.current?.focus()); 168 + }, []); 169 + 170 + const { data, isFetching } = useQuery({ 171 + queryKey: ["klipy", type, debounced], 172 + queryFn: () => searchMedia(type, debounced), 173 + enabled: KLIPY_ENABLED, 174 + staleTime: 60_000, 175 + }); 176 + 177 + const results = data ?? []; 178 + 179 + return ( 180 + <Panel onClick={(e) => e.stopPropagation()}> 181 + <TopBar> 182 + <Tabs> 183 + {KLIPY_TABS.map((tab) => ( 184 + <Tab 185 + key={tab.type} 186 + type="button" 187 + active={type === tab.type} 188 + onClick={() => setType(tab.type)} 189 + > 190 + {tab.label} 191 + </Tab> 192 + ))} 193 + </Tabs> 194 + <CloseButton type="button" aria-label="Close" onClick={onClose}> 195 + <IconX size={16} /> 196 + </CloseButton> 197 + </TopBar> 198 + 199 + <SearchBox> 200 + <IconSearch size={14} color="var(--color-text-muted)" /> 201 + <SearchInput 202 + ref={inputRef} 203 + value={query} 204 + onChange={(e) => setQuery(e.target.value)} 205 + placeholder={`Search ${type}`} 206 + /> 207 + </SearchBox> 208 + 209 + <Grid> 210 + {!KLIPY_ENABLED ? ( 211 + <Empty> 212 + Set <code>VITE_KLIPY_API_KEY</code> to enable GIFs, stickers &amp; 213 + clips. 214 + </Empty> 215 + ) : results.length === 0 ? ( 216 + <Empty>{isFetching ? "Loading…" : "No results"}</Empty> 217 + ) : ( 218 + results.map((m) => ( 219 + <Tile 220 + key={m.id} 221 + type="button" 222 + title={m.alt} 223 + onClick={() => onSelect(m)} 224 + > 225 + {m.isVideo ? ( 226 + <video 227 + src={m.url} 228 + poster={m.previewUrl} 229 + autoPlay 230 + loop 231 + muted 232 + playsInline 233 + /> 234 + ) : ( 235 + <img src={m.previewUrl ?? m.url} alt={m.alt ?? ""} loading="lazy" /> 236 + )} 237 + </Tile> 238 + )) 239 + )} 240 + </Grid> 241 + 242 + <Credit>Powered by KLIPY</Credit> 243 + </Panel> 244 + ); 245 + } 246 + 247 + export default MediaPicker;
+1
apps/web-mobile/src/components/Shout/MediaPicker/index.tsx
··· 1 + export { default } from "./MediaPicker";
+228
apps/web-mobile/src/components/Shout/MentionTextarea/MentionTextarea.tsx
··· 1 + import styled from "@emotion/styled"; 2 + import { useQuery } from "@tanstack/react-query"; 3 + import { Textarea, type TextareaProps } from "baseui/textarea"; 4 + import { useEffect, useRef, useState } from "react"; 5 + import { getCaretCoordinates } from "../../../lib/caret"; 6 + import { 7 + activeMentionToken, 8 + searchActorsTypeahead, 9 + } from "../../../lib/richtext"; 10 + 11 + /** 12 + * A baseui Textarea with an `@mention` typeahead popup. Fully controlled via 13 + * `value`/`onChange`; the popup queries the Bluesky AppView and inserts the 14 + * chosen handle. Purely a UI affordance — facets are resolved separately on 15 + * submit via `resolveMentionFacets`. 16 + */ 17 + 18 + const Wrapper = styled.div` 19 + position: relative; 20 + `; 21 + 22 + const Popup = styled.ul` 23 + position: absolute; 24 + z-index: 30; 25 + margin: 0; 26 + padding: 4px; 27 + list-style: none; 28 + max-height: 224px; 29 + width: 256px; 30 + max-width: calc(100% - 8px); 31 + overflow-y: auto; 32 + border-radius: 12px; 33 + border: 1px solid var(--color-input-background); 34 + background-color: var(--color-background); 35 + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.35); 36 + `; 37 + 38 + const Item = styled.button<{ active: boolean }>` 39 + display: flex; 40 + align-items: center; 41 + gap: 8px; 42 + width: 100%; 43 + padding: 8px; 44 + border: none; 45 + border-radius: 8px; 46 + cursor: pointer; 47 + text-align: left; 48 + background-color: ${({ active }) => 49 + active ? "var(--color-input-background)" : "transparent"}; 50 + 51 + &:hover { 52 + background-color: var(--color-input-background); 53 + } 54 + `; 55 + 56 + const Avatar = styled.span` 57 + display: flex; 58 + height: 28px; 59 + width: 28px; 60 + flex-shrink: 0; 61 + overflow: hidden; 62 + border-radius: 999px; 63 + background-color: var(--color-input-background); 64 + 65 + & > img { 66 + height: 100%; 67 + width: 100%; 68 + object-fit: cover; 69 + } 70 + `; 71 + 72 + const Names = styled.span` 73 + min-width: 0; 74 + `; 75 + 76 + const DisplayName = styled.span` 77 + display: block; 78 + overflow: hidden; 79 + text-overflow: ellipsis; 80 + white-space: nowrap; 81 + color: var(--color-text); 82 + font-size: 14px; 83 + `; 84 + 85 + const Handle = styled.span` 86 + display: block; 87 + overflow: hidden; 88 + text-overflow: ellipsis; 89 + white-space: nowrap; 90 + color: var(--color-text-muted); 91 + font-size: 12px; 92 + `; 93 + 94 + /** Debounce a fast-changing value. */ 95 + function useDebounced<T>(value: T, delay: number): T { 96 + const [debounced, setDebounced] = useState(value); 97 + useEffect(() => { 98 + const t = setTimeout(() => setDebounced(value), delay); 99 + return () => clearTimeout(t); 100 + }, [value, delay]); 101 + return debounced; 102 + } 103 + 104 + interface MentionTextareaProps extends Omit<TextareaProps, "onChange"> { 105 + value: string; 106 + onChange: (value: string) => void; 107 + } 108 + 109 + function MentionTextarea({ value, onChange, ...rest }: MentionTextareaProps) { 110 + const textareaRef = useRef<HTMLTextAreaElement>(null); 111 + const [mention, setMention] = useState<{ start: number; query: string } | null>( 112 + null, 113 + ); 114 + const [caret, setCaret] = useState<{ top: number; left: number } | null>(null); 115 + const [activeIdx, setActiveIdx] = useState(0); 116 + const debouncedQuery = useDebounced(mention?.query ?? "", 200); 117 + 118 + const { data: suggestions = [] } = useQuery({ 119 + queryKey: ["mention-typeahead", debouncedQuery], 120 + queryFn: () => searchActorsTypeahead(debouncedQuery), 121 + enabled: mention !== null && debouncedQuery.length >= 1, 122 + staleTime: 30_000, 123 + }); 124 + const popupOpen = mention !== null && suggestions.length > 0; 125 + 126 + const syncMention = (text: string, caretPos: number) => { 127 + const token = activeMentionToken(text, caretPos); 128 + setMention(token); 129 + setActiveIdx(0); 130 + const el = textareaRef.current; 131 + if (token && el) { 132 + const c = getCaretCoordinates(el, token.start); 133 + const maxLeft = Math.max(0, el.clientWidth - 256); 134 + setCaret({ 135 + top: el.offsetTop + c.top + c.height, 136 + left: el.offsetLeft + Math.min(c.left, maxLeft), 137 + }); 138 + } else { 139 + setCaret(null); 140 + } 141 + }; 142 + 143 + const insertMention = (handle: string) => { 144 + if (!mention) return; 145 + const el = textareaRef.current; 146 + const caretPos = el?.selectionStart ?? value.length; 147 + const next = 148 + value.slice(0, mention.start) + `@${handle} ` + value.slice(caretPos); 149 + onChange(next); 150 + setMention(null); 151 + setCaret(null); 152 + requestAnimationFrame(() => { 153 + const pos = mention.start + handle.length + 2; 154 + el?.focus(); 155 + el?.setSelectionRange(pos, pos); 156 + }); 157 + }; 158 + 159 + const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => { 160 + if (!popupOpen) return; 161 + if (e.key === "ArrowDown") { 162 + e.preventDefault(); 163 + setActiveIdx((i) => (i + 1) % suggestions.length); 164 + } else if (e.key === "ArrowUp") { 165 + e.preventDefault(); 166 + setActiveIdx((i) => (i - 1 + suggestions.length) % suggestions.length); 167 + } else if (e.key === "Enter" || e.key === "Tab") { 168 + e.preventDefault(); 169 + insertMention(suggestions[activeIdx]!.handle); 170 + } else if (e.key === "Escape") { 171 + e.preventDefault(); 172 + setMention(null); 173 + setCaret(null); 174 + } 175 + }; 176 + 177 + return ( 178 + <Wrapper> 179 + <Textarea 180 + inputRef={textareaRef} 181 + value={value} 182 + onChange={(e) => { 183 + onChange(e.currentTarget.value); 184 + syncMention(e.currentTarget.value, e.currentTarget.selectionStart ?? 0); 185 + }} 186 + onClick={(e) => 187 + syncMention( 188 + e.currentTarget.value, 189 + e.currentTarget.selectionStart ?? 0, 190 + ) 191 + } 192 + onKeyUp={(e) => 193 + syncMention( 194 + e.currentTarget.value, 195 + e.currentTarget.selectionStart ?? 0, 196 + ) 197 + } 198 + onKeyDown={onKeyDown} 199 + {...rest} 200 + /> 201 + 202 + {popupOpen && caret && ( 203 + <Popup style={{ top: caret.top, left: caret.left }}> 204 + {suggestions.map((s, i) => ( 205 + <li key={s.did}> 206 + <Item 207 + type="button" 208 + active={i === activeIdx} 209 + onMouseDown={(e) => { 210 + e.preventDefault(); 211 + insertMention(s.handle); 212 + }} 213 + > 214 + <Avatar>{s.avatar && <img src={s.avatar} alt="" />}</Avatar> 215 + <Names> 216 + <DisplayName>{s.displayName || s.handle}</DisplayName> 217 + <Handle>@{s.handle}</Handle> 218 + </Names> 219 + </Item> 220 + </li> 221 + ))} 222 + </Popup> 223 + )} 224 + </Wrapper> 225 + ); 226 + } 227 + 228 + export default MentionTextarea;
+1
apps/web-mobile/src/components/Shout/MentionTextarea/index.tsx
··· 1 + export { default } from "./MentionTextarea";
+64
apps/web-mobile/src/components/Shout/RichText.tsx
··· 1 + import styled from "@emotion/styled"; 2 + import { Link } from "react-router"; 3 + import { Fragment } from "react"; 4 + import { type Mention, segmentText } from "../../lib/richtext"; 5 + 6 + /** 7 + * Renders a shout message as rich text: bare URLs become external links and 8 + * `@mentions` become links to the mentioned user's profile. When the shout 9 + * carries mention facets the links resolve to the exact DID; otherwise 10 + * `@handles` are detected heuristically. Messages are still stored as plain 11 + * strings + facets — this is purely presentational. 12 + */ 13 + 14 + const Anchor = styled.a` 15 + color: var(--color-primary); 16 + text-decoration: none; 17 + word-break: break-word; 18 + 19 + &:hover { 20 + text-decoration: underline; 21 + } 22 + `; 23 + 24 + const MentionLink = styled(Link)` 25 + color: var(--color-primary); 26 + text-decoration: none; 27 + 28 + &:hover { 29 + text-decoration: underline; 30 + } 31 + `; 32 + 33 + interface RichTextProps { 34 + children: string; 35 + facets?: Mention[]; 36 + } 37 + 38 + function RichText({ children, facets }: RichTextProps) { 39 + const segments = segmentText(children, facets); 40 + return ( 41 + <> 42 + {segments.map((seg, i) => 43 + seg.type === "mention" ? ( 44 + <MentionLink key={i} to={`/profile/${seg.target}`}> 45 + {seg.value} 46 + </MentionLink> 47 + ) : seg.type === "link" ? ( 48 + <Anchor 49 + key={i} 50 + href={seg.href} 51 + target="_blank" 52 + rel="noreferrer noopener" 53 + > 54 + {seg.value} 55 + </Anchor> 56 + ) : ( 57 + <Fragment key={i}>{seg.value}</Fragment> 58 + ), 59 + )} 60 + </> 61 + ); 62 + } 63 + 64 + export default RichText;
+93 -8
apps/web-mobile/src/components/Shout/Shout.tsx
··· 1 1 /* eslint-disable @typescript-eslint/no-explicit-any */ 2 2 import { zodResolver } from "@hookform/resolvers/zod"; 3 3 import { Button } from "baseui/button"; 4 + import { PLACEMENT, StatefulPopover } from "baseui/popover"; 4 5 import { Spinner } from "baseui/spinner"; 5 - import { Textarea } from "baseui/textarea"; 6 6 import { LabelLarge, LabelMedium } from "baseui/typography"; 7 + import { IconGif, IconX } from "@tabler/icons-react"; 7 8 import { useAtomValue, useSetAtom } from "jotai"; 8 9 import { useState } from "react"; 9 10 import { Controller, useForm } from "react-hook-form"; 10 11 import { useLocation, useParams } from "react-router"; 11 12 import z from "zod"; 13 + import { isVideoUrl, type MediaResult } from "../../api/klipy"; 14 + import { resolveMentionFacets } from "../../lib/richtext"; 12 15 import { profileAtom } from "../../atoms/profile"; 13 16 import { shoutsAtom } from "../../atoms/shouts"; 14 17 import { userAtom } from "../../atoms/user"; 15 18 import useShout from "../../hooks/useShout"; 19 + import MediaPicker from "./MediaPicker"; 20 + import MentionTextarea from "./MentionTextarea"; 16 21 import SignInModal from "../SignInModal"; 17 22 import ShoutList from "./ShoutList"; 18 23 19 24 const ShoutSchema = z.object({ 20 - message: z.string().min(1).max(1000), 25 + message: z.string().max(1000), 21 26 }); 22 27 23 28 interface ShoutProps { ··· 47 52 const { did, rkey } = useParams<{ did: string; rkey: string }>(); 48 53 const location = useLocation(); 49 54 const [loading, setLoading] = useState(false); 55 + const [gif, setGif] = useState<MediaResult | null>(null); 50 56 51 57 const onShout = async ({ message }: z.infer<typeof ShoutSchema>) => { 58 + if (message.trim().length === 0 && !gif) { 59 + return; 60 + } 52 61 setLoading(true); 53 62 let uri = ""; 54 63 ··· 72 81 uri = `at://${did}/app.rocksky.scrobble/${rkey}`; 73 82 } 74 83 75 - await shout(uri, message); 84 + const gifEmbed = gif 85 + ? { 86 + url: gif.url, 87 + previewUrl: gif.previewUrl, 88 + alt: gif.alt, 89 + width: gif.width, 90 + height: gif.height, 91 + } 92 + : undefined; 93 + 94 + const facets = message.trim() ? await resolveMentionFacets(message) : []; 95 + 96 + await shout(uri, message, gifEmbed, facets.length ? facets : undefined); 76 97 77 98 const data = await getShouts(uri); 78 99 setShouts({ ··· 81 102 }); 82 103 83 104 setLoading(false); 84 - 105 + setGif(null); 85 106 reset(); 86 107 }; 87 108 ··· 97 118 liked: x.shouts.liked, 98 119 reported: x.shouts.reported, 99 120 likes: x.shouts.likes, 121 + gif: x.shouts.gifUrl 122 + ? { 123 + url: x.shouts.gifUrl, 124 + previewUrl: x.shouts.gifPreviewUrl, 125 + alt: x.shouts.gifAlt, 126 + width: x.shouts.gifWidth, 127 + height: x.shouts.gifHeight, 128 + } 129 + : undefined, 130 + facets: x.shouts.facets ?? undefined, 100 131 user: { 101 132 did: x.users.did, 102 133 avatar: x.users.avatar, ··· 119 150 name="message" 120 151 control={control} 121 152 render={({ field }) => ( 122 - <Textarea 123 - {...field} 153 + <MentionTextarea 154 + value={field.value} 155 + onChange={field.onChange} 124 156 placeholder={ 125 157 props.type === "profile" 126 158 ? `@${profile?.handle}, leave a shout for @${user?.handle} ...` ··· 139 171 )} 140 172 /> 141 173 174 + {gif && ( 175 + <div className="mt-[10px] relative w-fit max-w-[220px] overflow-hidden rounded-[12px] border border-[var(--color-input-background)]"> 176 + {isVideoUrl(gif.url) ? ( 177 + <video 178 + src={gif.url} 179 + className="block w-full h-auto" 180 + autoPlay 181 + loop 182 + muted 183 + playsInline 184 + /> 185 + ) : ( 186 + <img 187 + src={gif.previewUrl ?? gif.url} 188 + alt={gif.alt ?? ""} 189 + className="block w-full h-auto" 190 + /> 191 + )} 192 + <button 193 + type="button" 194 + aria-label="Remove media" 195 + onClick={() => setGif(null)} 196 + className="absolute right-[6px] top-[6px] flex h-[24px] w-[24px] items-center justify-center rounded-full bg-black/60 text-white cursor-pointer border-none hover:bg-black/80" 197 + > 198 + <IconX size={14} /> 199 + </button> 200 + </div> 201 + )} 202 + 142 203 <div 143 204 style={{ 144 205 marginTop: 15, 145 206 display: "flex", 146 - justifyContent: "flex-end", 207 + justifyContent: "space-between", 208 + alignItems: "center", 147 209 }} 148 210 > 211 + <StatefulPopover 212 + placement={PLACEMENT.bottomLeft} 213 + overrides={{ Body: { style: { zIndex: 3 } } }} 214 + content={({ close }) => ( 215 + <MediaPicker 216 + onSelect={(m) => { 217 + setGif(m); 218 + close(); 219 + }} 220 + onClose={close} 221 + /> 222 + )} 223 + > 224 + <button 225 + type="button" 226 + aria-label="Add a GIF, sticker or clip" 227 + className="flex items-center gap-[4px] rounded-full px-[10px] py-[5px] text-[13px] cursor-pointer border-none bg-transparent text-[var(--color-text-muted)] hover:bg-[var(--color-input-background)] hover:text-[var(--color-text)]" 228 + > 229 + <IconGif size={20} /> 230 + GIF 231 + </button> 232 + </StatefulPopover> 233 + 149 234 {!loading && ( 150 235 <Button 151 236 disabled={ 152 - watch("message").length === 0 || 237 + (watch("message").length === 0 && !gif) || 153 238 watch("message").length > 1000 154 239 } 155 240 onClick={handleSubmit(onShout)}
+123 -17
apps/web-mobile/src/components/Shout/ShoutList/Shout/ReplyModal/ReplyModal.tsx
··· 1 1 /* eslint-disable @typescript-eslint/no-explicit-any */ 2 + import { PLACEMENT, StatefulPopover } from "baseui/popover"; 3 + import { IconGif, IconX } from "@tabler/icons-react"; 2 4 import { useAtomValue, useSetAtom } from "jotai"; 3 - import { useRef, useState } from "react"; 5 + import { useState } from "react"; 4 6 import { Link, useParams } from "react-router"; 7 + import { isVideoUrl, type MediaResult } from "../../../../../api/klipy"; 8 + import { type Mention, resolveMentionFacets } from "../../../../../lib/richtext"; 5 9 import { profileAtom } from "../../../../../atoms/profile"; 6 10 import { shoutsAtom } from "../../../../../atoms/shouts"; 7 11 import useShout from "../../../../../hooks/useShout"; 12 + import MediaPicker from "../../../MediaPicker"; 13 + import MentionTextarea from "../../../MentionTextarea"; 14 + import RichText from "../../../RichText"; 8 15 9 16 interface ReplyModalProps { 10 17 isOpen: boolean; ··· 12 19 shout: { 13 20 uri: string; 14 21 message: string; 22 + facets?: Mention[]; 15 23 user: { 16 24 avatar: string; 17 25 displayName: string; ··· 28 36 const { did, rkey } = useParams<{ did: string; rkey: string }>(); 29 37 const [loading, setLoading] = useState(false); 30 38 const [message, setMessage] = useState(""); 31 - const textareaRef = useRef<HTMLTextAreaElement>(null); 39 + const [gif, setGif] = useState<MediaResult | null>(null); 32 40 33 41 if (!isOpen) return null; 34 42 ··· 44 52 liked: x.shouts.liked, 45 53 reported: x.shouts.reported, 46 54 likes: x.shouts.likes, 55 + gif: x.shouts.gifUrl 56 + ? { 57 + url: x.shouts.gifUrl, 58 + previewUrl: x.shouts.gifPreviewUrl, 59 + alt: x.shouts.gifAlt, 60 + width: x.shouts.gifWidth, 61 + height: x.shouts.gifHeight, 62 + } 63 + : undefined, 64 + facets: x.shouts.facets ?? undefined, 47 65 user: { 48 66 did: x.users.did, 49 67 avatar: x.users.avatar, ··· 56 74 }; 57 75 58 76 const onReply = async () => { 59 - if (!message.trim() || loading) return; 77 + if ((!message.trim() && !gif) || loading) return; 60 78 setLoading(true); 61 - await reply(shout.uri, message); 79 + const gifEmbed = gif 80 + ? { 81 + url: gif.url, 82 + previewUrl: gif.previewUrl, 83 + alt: gif.alt, 84 + width: gif.width, 85 + height: gif.height, 86 + } 87 + : undefined; 88 + const facets = message.trim() ? await resolveMentionFacets(message) : []; 89 + await reply(shout.uri, message, gifEmbed, facets.length ? facets : undefined); 62 90 63 91 let uri = ""; 64 92 if (location.pathname.startsWith("/profile")) uri = `at://${did}`; ··· 74 102 75 103 setLoading(false); 76 104 setMessage(""); 105 + setGif(null); 77 106 close(); 78 107 }; 79 108 80 - const canReply = message.trim() && !loading; 109 + const canReply = (message.trim() || gif) && !loading; 81 110 82 111 return ( 83 112 <div className="fixed inset-0 z-50 flex items-end" onClick={close}> ··· 115 144 {shout.user.displayName} 116 145 </p> 117 146 <p className="m-0 text-[13px] leading-snug text-[var(--color-text-muted)]"> 118 - {shout.message} 147 + <RichText facets={shout.facets}>{shout.message}</RichText> 119 148 </p> 120 149 </div> 121 150 </div> ··· 125 154 {profile?.avatar && ( 126 155 <img src={profile.avatar} className="h-9 w-9 shrink-0 rounded-full" /> 127 156 )} 128 - <textarea 129 - ref={textareaRef} 130 - autoFocus 131 - value={message} 132 - onChange={(e) => setMessage(e.target.value)} 133 - placeholder="Write your reply..." 134 - maxLength={1000} 135 - className="min-h-[80px] flex-1 resize-none border-none bg-transparent font-[inherit] text-sm leading-relaxed outline-none text-[var(--color-text)]" 136 - /> 157 + <div className="flex-1"> 158 + <MentionTextarea 159 + value={message} 160 + onChange={setMessage} 161 + autoFocus 162 + placeholder="Write your reply..." 163 + maxLength={1000} 164 + resize="vertical" 165 + overrides={{ 166 + Root: { 167 + style: { 168 + border: "none", 169 + width: "100%", 170 + }, 171 + }, 172 + InputContainer: { 173 + style: { 174 + border: "none", 175 + backgroundColor: "transparent", 176 + }, 177 + }, 178 + Input: { 179 + style: { 180 + border: "none", 181 + backgroundColor: "transparent", 182 + color: "var(--color-text)", 183 + caretColor: "var(--color-primary)", 184 + fontFamily: "inherit", 185 + }, 186 + }, 187 + }} 188 + /> 189 + </div> 137 190 </div> 138 - <div className="mt-1 text-right text-[11px] text-[var(--color-text-muted)]"> 139 - {message.length}/1000 191 + {gif && ( 192 + <div className="ml-[46px] mt-[10px] relative w-fit max-w-[220px] overflow-hidden rounded-[12px] border border-[var(--color-input-background)]"> 193 + {isVideoUrl(gif.url) ? ( 194 + <video 195 + src={gif.url} 196 + className="block h-auto w-full" 197 + autoPlay 198 + loop 199 + muted 200 + playsInline 201 + /> 202 + ) : ( 203 + <img 204 + src={gif.previewUrl ?? gif.url} 205 + alt={gif.alt ?? ""} 206 + className="block h-auto w-full" 207 + /> 208 + )} 209 + <button 210 + type="button" 211 + aria-label="Remove media" 212 + onClick={() => setGif(null)} 213 + className="absolute right-[6px] top-[6px] flex h-[24px] w-[24px] items-center justify-center rounded-full border-none bg-black/60 text-white cursor-pointer hover:bg-black/80" 214 + > 215 + <IconX size={14} /> 216 + </button> 217 + </div> 218 + )} 219 + 220 + <div className="mt-2 flex items-center justify-between"> 221 + <StatefulPopover 222 + placement={PLACEMENT.topLeft} 223 + overrides={{ Body: { style: { zIndex: 60 } } }} 224 + content={({ close: closePopover }) => ( 225 + <MediaPicker 226 + onSelect={(m) => { 227 + setGif(m); 228 + closePopover(); 229 + }} 230 + onClose={closePopover} 231 + /> 232 + )} 233 + > 234 + <button 235 + type="button" 236 + aria-label="Add a GIF, sticker or clip" 237 + className="flex items-center gap-[4px] rounded-full border-none bg-transparent px-[10px] py-[5px] text-[13px] text-[var(--color-text-muted)] cursor-pointer hover:bg-[var(--color-input-background)] hover:text-[var(--color-text)]" 238 + > 239 + <IconGif size={20} /> 240 + GIF 241 + </button> 242 + </StatefulPopover> 243 + <span className="text-[11px] text-[var(--color-text-muted)]"> 244 + {message.length}/1000 245 + </span> 140 246 </div> 141 247 </div> 142 248 </div>
+33 -3
apps/web-mobile/src/components/Shout/ShoutList/Shout/Shout.tsx
··· 3 3 import { useAtomValue } from "jotai"; 4 4 import { useState } from "react"; 5 5 import { Link } from "react-router"; 6 + import { type GifEmbed, isVideoUrl } from "../../../../api/klipy"; 6 7 import { profileAtom } from "../../../../atoms/profile"; 7 8 import Heart from "../../../Icons/Heart"; 8 9 import HeartOutline from "../../../Icons/HeartOutline"; 9 10 import SignInModal from "../../../SignInModal"; 10 11 import useLike from "../../../../hooks/useLike"; 11 12 import useShout from "../../../../hooks/useShout"; 13 + import { type Mention } from "../../../../lib/richtext"; 14 + import RichText from "../../RichText"; 12 15 import DeleteShoutModal from "./DeleteShoutModal"; 13 16 import ReplyModal from "./ReplyModal"; 14 17 ··· 20 23 liked: boolean; 21 24 reported: boolean; 22 25 likes: number; 26 + gif?: GifEmbed; 27 + facets?: Mention[]; 23 28 user: { 24 29 did: string; 25 30 avatar: string; ··· 121 126 </div> 122 127 123 128 {/* Message */} 124 - <p className="mb-2.5 mt-0 text-sm leading-[1.55] text-[var(--color-text)]"> 125 - {shout.message} 126 - </p> 129 + {shout.message && ( 130 + <p className="mb-2.5 mt-0 text-sm leading-[1.55] text-[var(--color-text)]"> 131 + <RichText facets={shout.facets}>{shout.message}</RichText> 132 + </p> 133 + )} 134 + 135 + {/* Media */} 136 + {shout.gif && ( 137 + <div className="mb-2.5 w-fit max-w-[260px] overflow-hidden rounded-[12px] border border-[var(--color-input-background)]"> 138 + {isVideoUrl(shout.gif.url) ? ( 139 + <video 140 + src={shout.gif.url} 141 + poster={shout.gif.previewUrl} 142 + className="block h-auto w-full" 143 + autoPlay 144 + loop 145 + muted 146 + playsInline 147 + /> 148 + ) : ( 149 + <img 150 + src={shout.gif.previewUrl ?? shout.gif.url} 151 + alt={shout.gif.alt ?? ""} 152 + className="block h-auto w-full" 153 + /> 154 + )} 155 + </div> 156 + )} 127 157 128 158 {/* Actions */} 129 159 <div className="flex items-center gap-4">
+10
apps/web-mobile/src/components/Shout/ShoutList/ShoutList.tsx
··· 78 78 liked: x.shouts.liked, 79 79 reported: x.shouts.reported, 80 80 likes: x.shouts.likes, 81 + gif: x.shouts.gifUrl 82 + ? { 83 + url: x.shouts.gifUrl, 84 + previewUrl: x.shouts.gifPreviewUrl, 85 + alt: x.shouts.gifAlt, 86 + width: x.shouts.gifWidth, 87 + height: x.shouts.gifHeight, 88 + } 89 + : undefined, 90 + facets: x.shouts.facets ?? undefined, 81 91 user: { 82 92 did: x.users.did, 83 93 avatar: x.users.avatar,
+16 -4
apps/web-mobile/src/hooks/useShout.tsx
··· 1 1 import axios from "axios"; 2 2 import { useCallback } from "react"; 3 + import type { GifEmbed } from "../api/klipy"; 4 + import type { Mention } from "../lib/richtext"; 3 5 import { API_URL } from "../consts"; 4 6 5 7 function useShout() { 6 - const shout = async (uri: string, message: string) => { 8 + const shout = async ( 9 + uri: string, 10 + message: string, 11 + gif?: GifEmbed, 12 + facets?: Mention[] 13 + ) => { 7 14 const response = await axios.post( 8 15 `${API_URL}/users/${uri.replace("at://", "")}/shouts`, 9 - { message }, 16 + { message, gif, facets }, 10 17 { 11 18 headers: { 12 19 "Content-Type": "application/json", ··· 29 36 return response.data; 30 37 }, []); 31 38 32 - const reply = async (uri: string, message: string) => { 39 + const reply = async ( 40 + uri: string, 41 + message: string, 42 + gif?: GifEmbed, 43 + facets?: Mention[] 44 + ) => { 33 45 const response = await axios.post( 34 46 `${API_URL}/users/${uri.replace("at://", "")}/replies`, 35 - { message }, 47 + { message, gif, facets }, 36 48 { 37 49 headers: { 38 50 "Content-Type": "application/json",
+75
apps/web-mobile/src/lib/caret.ts
··· 1 + /** 2 + * Pixel coordinates of the caret within a textarea, relative to the element's 3 + * own top-left (padding box). Used to anchor the @-mention popup right under the 4 + * text being typed instead of at the bottom of the whole field. 5 + * 6 + * Works by rendering an invisible "mirror" div that copies the textarea's text 7 + * and relevant styles, then measuring a marker placed at the caret offset. 8 + */ 9 + const MIRRORED_PROPS = [ 10 + "boxSizing", 11 + "width", 12 + "fontFamily", 13 + "fontSize", 14 + "fontWeight", 15 + "fontStyle", 16 + "letterSpacing", 17 + "textTransform", 18 + "wordSpacing", 19 + "lineHeight", 20 + "paddingTop", 21 + "paddingRight", 22 + "paddingBottom", 23 + "paddingLeft", 24 + "borderTopWidth", 25 + "borderRightWidth", 26 + "borderBottomWidth", 27 + "borderLeftWidth", 28 + "whiteSpace", 29 + "wordWrap", 30 + "tabSize", 31 + ] as const; 32 + 33 + export interface CaretCoords { 34 + /** Offset from the textarea's top edge to the top of the caret line. */ 35 + top: number; 36 + /** Offset from the textarea's left edge to the caret. */ 37 + left: number; 38 + /** Line height at the caret. */ 39 + height: number; 40 + } 41 + 42 + export function getCaretCoordinates( 43 + el: HTMLTextAreaElement, 44 + position: number, 45 + ): CaretCoords { 46 + const style = window.getComputedStyle(el); 47 + const mirror = document.createElement("div"); 48 + 49 + mirror.style.position = "absolute"; 50 + mirror.style.visibility = "hidden"; 51 + mirror.style.whiteSpace = "pre-wrap"; 52 + mirror.style.wordWrap = "break-word"; 53 + mirror.style.top = "0"; 54 + mirror.style.left = "-9999px"; 55 + for (const prop of MIRRORED_PROPS) { 56 + mirror.style[prop as never] = style[prop as never]; 57 + } 58 + // Overflow must not add scrollbars that shift metrics. 59 + mirror.style.overflow = "hidden"; 60 + 61 + mirror.textContent = el.value.slice(0, position); 62 + // A marker span at the caret; its offset is the caret position. 63 + const marker = document.createElement("span"); 64 + marker.textContent = el.value.slice(position) || "."; 65 + mirror.appendChild(marker); 66 + 67 + document.body.appendChild(mirror); 68 + const top = marker.offsetTop - el.scrollTop; 69 + const left = marker.offsetLeft - el.scrollLeft; 70 + const height = 71 + parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.2; 72 + document.body.removeChild(mirror); 73 + 74 + return { top, left, height }; 75 + }
+246
apps/web-mobile/src/lib/richtext.ts
··· 1 + /** 2 + * Rich text helpers for shouts: detecting `@mentions`, resolving them to 3 + * ATProto mention facets (UTF-8 byte ranges → DID), splitting a message into 4 + * renderable segments, and typeahead actor search for the composer. 5 + * 6 + * Rocksky handles are ATProto/Bluesky handles, so resolution and typeahead go 7 + * through the public Bluesky AppView (same as atradio.fm). 8 + */ 9 + 10 + const BSKY_APPVIEW = "https://public.api.bsky.app"; 11 + 12 + const encoder = new TextEncoder(); 13 + const decoder = new TextDecoder(); 14 + 15 + /** UTF-8 byte length of a string (facets index by bytes, not code units). */ 16 + function byteLength(s: string): number { 17 + return encoder.encode(s).length; 18 + } 19 + 20 + /** A mention facet stored on a shout record. */ 21 + export interface Mention { 22 + did: string; 23 + byteStart: number; 24 + byteEnd: number; 25 + } 26 + 27 + /** 28 + * Handle mentions like `@alice.bsky.social`. Matches the leading `@` plus a 29 + * dotted handle; the capture excludes any trailing punctuation. 30 + */ 31 + const MENTION_RE = 32 + /(^|[\s(])@([a-zA-Z0-9][a-zA-Z0-9.-]*[a-zA-Z0-9]|[a-zA-Z0-9])/g; 33 + 34 + export interface MentionSpan { 35 + /** String (code-unit) index of the `@`. */ 36 + start: number; 37 + /** String index just past the handle. */ 38 + end: number; 39 + /** Handle without the leading `@`. */ 40 + handle: string; 41 + } 42 + 43 + /** Find `@handle` spans in text (string indices). */ 44 + export function detectMentionSpans(text: string): MentionSpan[] { 45 + const spans: MentionSpan[] = []; 46 + for (const m of text.matchAll(MENTION_RE)) { 47 + const lead = m[1] ?? ""; 48 + const handle = m[2]; 49 + if (!handle) continue; 50 + const at = (m.index ?? 0) + lead.length; 51 + spans.push({ start: at, end: at + 1 + handle.length, handle }); 52 + } 53 + return spans; 54 + } 55 + 56 + /** Resolve a single handle to a DID via the public AppView (null on failure). */ 57 + async function resolveHandle(handle: string): Promise<string | null> { 58 + try { 59 + const res = await fetch( 60 + `${BSKY_APPVIEW}/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent( 61 + handle, 62 + )}`, 63 + { headers: { Accept: "application/json" } }, 64 + ); 65 + if (!res.ok) return null; 66 + const d = (await res.json()) as { did?: string }; 67 + return d.did ?? null; 68 + } catch { 69 + return null; 70 + } 71 + } 72 + 73 + /** 74 + * Resolve every `@handle` in `text` to a mention facet (UTF-8 byte range → DID). 75 + * Handles that don't resolve are silently dropped. Resolution is cached per call 76 + * so a handle mentioned twice is only fetched once. 77 + */ 78 + export async function resolveMentionFacets(text: string): Promise<Mention[]> { 79 + const spans = detectMentionSpans(text); 80 + if (spans.length === 0) return []; 81 + 82 + const cache = new Map<string, string | null>(); 83 + const resolve = async (handle: string): Promise<string | null> => { 84 + const key = handle.toLowerCase(); 85 + if (cache.has(key)) return cache.get(key)!; 86 + const did = await resolveHandle(handle); 87 + cache.set(key, did); 88 + return did; 89 + }; 90 + 91 + const facets: Mention[] = []; 92 + for (const span of spans) { 93 + const did = await resolve(span.handle); 94 + if (!did) continue; 95 + facets.push({ 96 + did, 97 + byteStart: byteLength(text.slice(0, span.start)), 98 + byteEnd: byteLength(text.slice(0, span.end)), 99 + }); 100 + } 101 + return facets; 102 + } 103 + 104 + export type Segment = 105 + | { type: "text"; value: string } 106 + | { type: "mention"; value: string; target: string } 107 + | { type: "link"; value: string; href: string }; 108 + 109 + /** Matches http(s) URLs; the capture excludes common trailing punctuation. */ 110 + const URL_RE = /https?:\/\/[^\s<]+[^\s<.,:;!?)\]}'"]/g; 111 + 112 + /** Split a plain-text run into text + link segments (URLs → clickable). */ 113 + function splitLinks(text: string): Segment[] { 114 + const out: Segment[] = []; 115 + let last = 0; 116 + for (const m of text.matchAll(URL_RE)) { 117 + const start = m.index ?? 0; 118 + const url = m[0]; 119 + if (start > last) out.push({ type: "text", value: text.slice(last, start) }); 120 + out.push({ type: "link", value: url, href: url }); 121 + last = start + url.length; 122 + } 123 + if (last < text.length) out.push({ type: "text", value: text.slice(last) }); 124 + return out; 125 + } 126 + 127 + /** Regex-based mention + link segmentation, used when a shout has no facets. */ 128 + function segmentWithoutFacets(text: string): Segment[] { 129 + const spans = detectMentionSpans(text); 130 + if (spans.length === 0) return splitLinks(text); 131 + const out: Segment[] = []; 132 + let cursor = 0; 133 + for (const span of spans) { 134 + if (span.start > cursor) { 135 + out.push(...splitLinks(text.slice(cursor, span.start))); 136 + } 137 + out.push({ 138 + type: "mention", 139 + value: text.slice(span.start, span.end), 140 + target: span.handle, 141 + }); 142 + cursor = span.end; 143 + } 144 + if (cursor < text.length) out.push(...splitLinks(text.slice(cursor))); 145 + return out; 146 + } 147 + 148 + /** 149 + * Split a shout's message into plain + mention + link segments. When facets are 150 + * present, mentions come from their byte ranges (linking to the DID); otherwise 151 + * `@handles` are detected heuristically. URLs are always linkified. 152 + */ 153 + export function segmentText( 154 + text: string, 155 + facets: Mention[] | undefined, 156 + ): Segment[] { 157 + if (!facets?.length) return segmentWithoutFacets(text); 158 + const bytes = encoder.encode(text); 159 + const sorted = [...facets].sort((a, b) => a.byteStart - b.byteStart); 160 + const segments: Segment[] = []; 161 + let cursor = 0; 162 + for (const f of sorted) { 163 + if ( 164 + f.byteStart < cursor || 165 + f.byteEnd > bytes.length || 166 + f.byteEnd <= f.byteStart 167 + ) 168 + continue; 169 + if (f.byteStart > cursor) { 170 + segments.push( 171 + ...splitLinks(decoder.decode(bytes.slice(cursor, f.byteStart))), 172 + ); 173 + } 174 + segments.push({ 175 + type: "mention", 176 + value: decoder.decode(bytes.slice(f.byteStart, f.byteEnd)), 177 + target: f.did, 178 + }); 179 + cursor = f.byteEnd; 180 + } 181 + if (cursor < bytes.length) { 182 + segments.push(...splitLinks(decoder.decode(bytes.slice(cursor)))); 183 + } 184 + return segments; 185 + } 186 + 187 + export interface ActorSuggestion { 188 + did: string; 189 + handle: string; 190 + displayName?: string; 191 + avatar?: string; 192 + } 193 + 194 + /** Typeahead actor search (Bluesky AppView) for the mention autocomplete. */ 195 + export async function searchActorsTypeahead( 196 + q: string, 197 + limit = 6, 198 + ): Promise<ActorSuggestion[]> { 199 + const query = q.trim(); 200 + if (!query) return []; 201 + const url = 202 + `${BSKY_APPVIEW}/xrpc/app.bsky.actor.searchActorsTypeahead?q=` + 203 + encodeURIComponent(query) + 204 + `&limit=${limit}`; 205 + try { 206 + const res = await fetch(url, { headers: { Accept: "application/json" } }); 207 + if (!res.ok) return []; 208 + const d = (await res.json()) as { 209 + actors?: { 210 + did: string; 211 + handle: string; 212 + displayName?: string; 213 + avatar?: string; 214 + }[]; 215 + }; 216 + return (d.actors ?? []).map((a) => ({ 217 + did: a.did, 218 + handle: a.handle, 219 + displayName: a.displayName, 220 + avatar: a.avatar, 221 + })); 222 + } catch { 223 + return []; 224 + } 225 + } 226 + 227 + /** The `@token` the caret currently sits in, for the mention popup. */ 228 + export function activeMentionToken( 229 + text: string, 230 + caret: number, 231 + ): { start: number; query: string } | null { 232 + let i = caret - 1; 233 + while (i >= 0) { 234 + const ch = text[i]; 235 + if (ch === "@") { 236 + const before = i === 0 ? " " : text[i - 1]; 237 + if (i === 0 || /[\s(]/.test(before)) { 238 + return { start: i, query: text.slice(i + 1, caret) }; 239 + } 240 + return null; 241 + } 242 + if (!/[a-zA-Z0-9.\-]/.test(ch)) return null; 243 + i--; 244 + } 245 + return null; 246 + }
+116 -9
apps/web-mobile/src/pages/shout-editor/ShoutEditor.tsx
··· 1 1 /* eslint-disable @typescript-eslint/no-explicit-any */ 2 + import { PLACEMENT, StatefulPopover } from "baseui/popover"; 3 + import { IconGif, IconX } from "@tabler/icons-react"; 2 4 import { useAtomValue, useSetAtom } from "jotai"; 3 5 import { useState } from "react"; 4 6 import { useLocation, useNavigate } from "react-router-dom"; 7 + import { isVideoUrl, type MediaResult } from "../../api/klipy"; 8 + import { resolveMentionFacets } from "../../lib/richtext"; 5 9 import { profileAtom } from "../../atoms/profile"; 6 10 import { shoutsAtom } from "../../atoms/shouts"; 11 + import MediaPicker from "../../components/Shout/MediaPicker"; 12 + import MentionTextarea from "../../components/Shout/MentionTextarea"; 7 13 import ShoutList from "../../components/Shout/ShoutList/ShoutList"; 8 14 import SignInModal from "../../components/SignInModal"; 9 15 import useShout from "../../hooks/useShout"; ··· 22 28 const setShouts = useSetAtom(shoutsAtom); 23 29 const { shout: postShout, getShouts } = useShout(); 24 30 const [message, setMessage] = useState(""); 31 + const [gif, setGif] = useState<MediaResult | null>(null); 25 32 const [loading, setLoading] = useState(false); 26 33 const [isSignInOpen, setIsSignInOpen] = useState(false); 27 34 ··· 37 44 liked: x.shouts.liked, 38 45 reported: x.shouts.reported, 39 46 likes: x.shouts.likes, 47 + gif: x.shouts.gifUrl 48 + ? { 49 + url: x.shouts.gifUrl, 50 + previewUrl: x.shouts.gifPreviewUrl, 51 + alt: x.shouts.gifAlt, 52 + width: x.shouts.gifWidth, 53 + height: x.shouts.gifHeight, 54 + } 55 + : undefined, 56 + facets: x.shouts.facets ?? undefined, 40 57 user: { 41 58 did: x.users.did, 42 59 avatar: x.users.avatar, ··· 49 66 }; 50 67 51 68 const handleSubmit = async () => { 52 - if (!message.trim() || !uri || loading) return; 69 + if ((!message.trim() && !gif) || !uri || loading) return; 53 70 setLoading(true); 71 + const gifEmbed = gif 72 + ? { 73 + url: gif.url, 74 + previewUrl: gif.previewUrl, 75 + alt: gif.alt, 76 + width: gif.width, 77 + height: gif.height, 78 + } 79 + : undefined; 54 80 try { 55 - await postShout(uri, message); 81 + const facets = message.trim() ? await resolveMentionFacets(message) : []; 82 + await postShout(uri, message, gifEmbed, facets.length ? facets : undefined); 56 83 const data = await getShouts(uri); 57 84 setShouts({ ...shouts, [uri]: processShouts(data) }); 58 85 setMessage(""); 86 + setGif(null); 59 87 } finally { 60 88 setLoading(false); 61 89 } 62 90 }; 63 91 64 - const canPost = message.trim() && !loading; 92 + const canPost = (message.trim() || gif) && !loading; 65 93 66 94 return ( 67 95 <div className="min-h-screen bg-[var(--color-background)] pb-[calc(16px+env(safe-area-inset-bottom))]"> ··· 102 130 )} 103 131 {profile && ( 104 132 <> 105 - <textarea 133 + <MentionTextarea 106 134 value={message} 107 - onChange={(e) => setMessage(e.target.value)} 135 + onChange={setMessage} 108 136 placeholder={`@${profile.handle}, share your thoughts about this ${type}...`} 109 137 maxLength={1000} 110 - className="w-full min-h-[80px] resize-y rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-2)] p-3 text-sm font-[inherit] text-[var(--color-text)] outline-none" 138 + resize="vertical" 139 + overrides={{ 140 + Root: { 141 + style: { 142 + width: "100%", 143 + borderRadius: "12px", 144 + borderColor: "var(--color-border)", 145 + backgroundColor: "var(--color-surface-2)", 146 + }, 147 + }, 148 + InputContainer: { 149 + style: { 150 + backgroundColor: "var(--color-surface-2)", 151 + }, 152 + }, 153 + Input: { 154 + style: { 155 + minHeight: "80px", 156 + backgroundColor: "var(--color-surface-2)", 157 + color: "var(--color-text)", 158 + caretColor: "var(--color-text)", 159 + fontFamily: "inherit", 160 + fontSize: "14px", 161 + }, 162 + }, 163 + }} 111 164 /> 165 + 166 + {gif && ( 167 + <div className="mt-[10px] relative w-fit max-w-[220px] overflow-hidden rounded-[12px] border border-[var(--color-input-background)]"> 168 + {isVideoUrl(gif.url) ? ( 169 + <video 170 + src={gif.url} 171 + className="block h-auto w-full" 172 + autoPlay 173 + loop 174 + muted 175 + playsInline 176 + /> 177 + ) : ( 178 + <img 179 + src={gif.previewUrl ?? gif.url} 180 + alt={gif.alt ?? ""} 181 + className="block h-auto w-full" 182 + /> 183 + )} 184 + <button 185 + type="button" 186 + aria-label="Remove media" 187 + onClick={() => setGif(null)} 188 + className="absolute right-[6px] top-[6px] flex h-[24px] w-[24px] items-center justify-center rounded-full border-none bg-black/60 text-white cursor-pointer hover:bg-black/80" 189 + > 190 + <IconX size={14} /> 191 + </button> 192 + </div> 193 + )} 194 + 112 195 <div className="mt-2 flex items-center justify-between"> 113 - <span className="text-xs text-[var(--color-text-muted)]"> 114 - {message.length}/1000 115 - </span> 196 + <div className="flex items-center gap-3"> 197 + <StatefulPopover 198 + placement={PLACEMENT.topLeft} 199 + overrides={{ Body: { style: { zIndex: 60 } } }} 200 + content={({ close }) => ( 201 + <MediaPicker 202 + onSelect={(m) => { 203 + setGif(m); 204 + close(); 205 + }} 206 + onClose={close} 207 + /> 208 + )} 209 + > 210 + <button 211 + type="button" 212 + aria-label="Add a GIF, sticker or clip" 213 + className="flex items-center gap-[4px] rounded-full border-none bg-transparent px-[10px] py-[5px] text-[13px] text-[var(--color-text-muted)] cursor-pointer hover:bg-[var(--color-input-background)] hover:text-[var(--color-text)]" 214 + > 215 + <IconGif size={20} /> 216 + GIF 217 + </button> 218 + </StatefulPopover> 219 + <span className="text-xs text-[var(--color-text-muted)]"> 220 + {message.length}/1000 221 + </span> 222 + </div> 116 223 <button 117 224 onClick={handleSubmit} 118 225 disabled={!canPost}
+1
apps/web/.env.example
··· 1 1 VITE_API_URL=http://localhost:3004 2 2 VITE_WS_URL=ws://localhost:8002 3 + VITE_KLIPY_API_KEY=
+219
apps/web/src/api/klipy.ts
··· 1 + /** 2 + * KLIPY media API (GIFs, stickers, clips). Set `VITE_KLIPY_API_KEY` to enable 3 + * the picker. https://klipy.com/developers — endpoints follow 4 + * `api.klipy.com/api/v1/{key}/{type}/{search|trending}`. 5 + * 6 + * Note: this API/plan exposes `gifs`, `stickers`, and `clips`. There is no 7 + * `memes` route (it 404s "Route not found"), so we don't offer that tab. 8 + */ 9 + const KLIPY_KEY = import.meta.env.VITE_KLIPY_API_KEY ?? ""; 10 + const KLIPY_BASE = "https://api.klipy.com/api/v1"; 11 + 12 + /** True when a key is configured; the picker degrades to a hint otherwise. */ 13 + export const KLIPY_ENABLED = Boolean(KLIPY_KEY); 14 + 15 + export type KlipyMediaType = "gifs" | "stickers" | "clips"; 16 + 17 + export const KLIPY_TABS: { type: KlipyMediaType; label: string }[] = [ 18 + { type: "gifs", label: "GIFs" }, 19 + { type: "stickers", label: "Stickers" }, 20 + { type: "clips", label: "Clips" }, 21 + ]; 22 + 23 + /** The media embed persisted on a shout record (mirrors app.rocksky.shout#gif). */ 24 + export interface GifEmbed { 25 + url: string; 26 + previewUrl?: string; 27 + alt?: string; 28 + width?: number; 29 + height?: number; 30 + } 31 + 32 + /** A pickable media item: renders in the grid, embeds on select. */ 33 + export interface MediaResult extends GifEmbed { 34 + id: string; 35 + /** True when `url` is a video (mp4) rather than an image. */ 36 + isVideo: boolean; 37 + } 38 + 39 + /** True when a URL points at a video the UI should render with <video>. */ 40 + export function isVideoUrl(url: string): boolean { 41 + return /\.mp4($|\?)/i.test(url); 42 + } 43 + 44 + interface Rendition { 45 + url: string; 46 + width?: number; 47 + height?: number; 48 + } 49 + 50 + // KLIPY returns two different `file` shapes: 51 + // • gifs / stickers — nested by size then format: 52 + // file: { md: { gif: {url,width,height}, webp: {...}, mp4: {...} }, ... } 53 + // • clips — flat format → url string, with dimensions in a sibling `file_meta`: 54 + // file: { mp4: "url", gif: "url", webp: "url" } 55 + // file_meta: { mp4: {width,height}, gif: {...}, webp: {...} } 56 + type NestedRendition = { 57 + url?: string; 58 + width?: number | string; 59 + height?: number | string; 60 + }; 61 + type NestedFormatMap = Record<string, NestedRendition | undefined>; 62 + type NestedFile = Record<string, NestedFormatMap | undefined>; 63 + type FlatFile = Record<string, string | undefined>; 64 + type MetaMap = Record< 65 + string, 66 + { width?: number | string; height?: number | string } | undefined 67 + >; 68 + 69 + interface KlipyItem { 70 + id?: number | string; 71 + slug?: string; 72 + title?: string; 73 + file?: NestedFile | FlatFile; 74 + files?: NestedFile | FlatFile; 75 + file_meta?: MetaMap; 76 + } 77 + 78 + const num = (v: number | string | undefined): number | undefined => { 79 + const n = typeof v === "string" ? Number(v) : v; 80 + return Number.isFinite(n) ? (n as number) : undefined; 81 + }; 82 + 83 + /** A `file` whose top-level values are strings is the flat (clips) shape. */ 84 + function isFlatFile(file: NestedFile | FlatFile): file is FlatFile { 85 + return Object.values(file).some((v) => typeof v === "string"); 86 + } 87 + 88 + /** Flat (clips) shape → renditions from format→url strings + `file_meta` dims. */ 89 + function pickFlat( 90 + file: FlatFile, 91 + meta: MetaMap | undefined, 92 + wantVideo: boolean, 93 + ): { full?: Rendition; preview?: Rendition } { 94 + const at = (fmt: string): Rendition | undefined => { 95 + const url = file[fmt]; 96 + if (typeof url !== "string" || !url) return undefined; 97 + const m = meta?.[fmt]; 98 + return { url, width: num(m?.width), height: num(m?.height) }; 99 + }; 100 + const fullFmts = wantVideo ? ["mp4", "webp", "gif"] : ["gif", "webp", "mp4"]; 101 + let full: Rendition | undefined; 102 + for (const f of fullFmts) if (!full) full = at(f); 103 + let preview: Rendition | undefined; 104 + for (const f of ["gif", "webp", "jpg", "png"]) if (!preview) preview = at(f); 105 + return { full, preview }; 106 + } 107 + 108 + /** Nested (gifs/stickers) shape → walk size then format. */ 109 + function pickNested( 110 + file: NestedFile, 111 + wantVideo: boolean, 112 + ): { full?: Rendition; preview?: Rendition } { 113 + const sizes = ["md", "sm", "hd", "lg", "xs"]; 114 + const fullFmts = wantVideo ? ["mp4", "webp", "gif"] : ["gif", "webp", "mp4"]; 115 + const previewFmts = ["webp", "gif", "png", "jpg"]; 116 + const norm = (r: NestedRendition | undefined): Rendition | undefined => 117 + r?.url ? { url: r.url, width: num(r.width), height: num(r.height) } : undefined; 118 + 119 + let full: Rendition | undefined; 120 + for (const s of sizes) { 121 + const fm = file[s]; 122 + if (!fm) continue; 123 + for (const f of fullFmts) if (!full) full = norm(fm[f]); 124 + if (full) break; 125 + } 126 + let preview: Rendition | undefined; 127 + for (const s of ["sm", "md", "hd", "xs"]) { 128 + const fm = file[s]; 129 + if (!fm) continue; 130 + for (const f of previewFmts) if (!preview) preview = norm(fm[f]); 131 + if (preview) break; 132 + } 133 + return { full, preview }; 134 + } 135 + 136 + function toResult(item: KlipyItem, type: KlipyMediaType): MediaResult | null { 137 + const wantVideo = type === "clips"; 138 + const file = item.file ?? item.files; 139 + if (!file) return null; 140 + const { full, preview } = isFlatFile(file) 141 + ? pickFlat(file, item.file_meta, wantVideo) 142 + : pickNested(file, wantVideo); 143 + if (!full?.url) return null; 144 + return { 145 + id: String(item.id ?? item.slug ?? full.url), 146 + url: full.url, 147 + previewUrl: preview?.url ?? full.url, 148 + alt: item.title?.trim() || item.slug || undefined, 149 + width: full.width, 150 + height: full.height, 151 + isVideo: isVideoUrl(full.url), 152 + }; 153 + } 154 + 155 + /** A stable per-browser id KLIPY uses to personalize trending/recents. */ 156 + function customerId(): string { 157 + try { 158 + const key = "rocksky:klipy-cid"; 159 + let v = localStorage.getItem(key); 160 + if (!v) { 161 + v = Math.random().toString(36).slice(2) + Date.now().toString(36); 162 + localStorage.setItem(key, v); 163 + } 164 + return v; 165 + } catch { 166 + return "anon"; 167 + } 168 + } 169 + 170 + async function request( 171 + type: KlipyMediaType, 172 + action: "search" | "trending", 173 + params: URLSearchParams, 174 + ): Promise<MediaResult[]> { 175 + if (!KLIPY_KEY) return []; 176 + params.set("customer_id", customerId()); 177 + const url = `${KLIPY_BASE}/${encodeURIComponent(KLIPY_KEY)}/${type}/${action}?${params}`; 178 + try { 179 + const res = await fetch(url, { headers: { Accept: "application/json" } }); 180 + if (!res.ok) return []; 181 + const json = (await res.json()) as { 182 + data?: { data?: KlipyItem[] } | KlipyItem[]; 183 + }; 184 + const list = Array.isArray(json.data) 185 + ? json.data 186 + : (json.data?.data ?? []); 187 + return list 188 + .map((it) => toResult(it, type)) 189 + .filter((r): r is MediaResult => r !== null); 190 + } catch { 191 + return []; 192 + } 193 + } 194 + 195 + /** Search KLIPY media of a given type; empty query → trending. */ 196 + export function searchMedia( 197 + type: KlipyMediaType, 198 + q: string, 199 + perPage = 24, 200 + ): Promise<MediaResult[]> { 201 + const query = q.trim(); 202 + if (!query) { 203 + return request( 204 + type, 205 + "trending", 206 + new URLSearchParams({ page: "1", per_page: String(perPage) }), 207 + ); 208 + } 209 + return request( 210 + type, 211 + "search", 212 + new URLSearchParams({ q: query, page: "1", per_page: String(perPage) }), 213 + ); 214 + } 215 + 216 + /** Adapt a stored gif embed back into the picker's MediaResult shape. */ 217 + export function gifEmbedToMedia(g: GifEmbed): MediaResult { 218 + return { ...g, id: g.url, isVideo: isVideoUrl(g.url) }; 219 + }
+16 -4
apps/web/src/api/shouts.ts
··· 1 1 import axios from "axios"; 2 + import type { GifEmbed } from "./klipy"; 3 + import type { Mention } from "../lib/richtext"; 2 4 import { API_URL } from "../consts"; 3 5 4 - export const shout = async (uri: string, message: string) => { 6 + export const shout = async ( 7 + uri: string, 8 + message: string, 9 + gif?: GifEmbed, 10 + facets?: Mention[], 11 + ) => { 5 12 const response = await axios.post( 6 13 `${API_URL}/users/${uri.replace("at://", "")}/shouts`, 7 - { message }, 14 + { message, gif, facets }, 8 15 { 9 16 headers: { 10 17 "Content-Type": "application/json", ··· 27 34 return response.data; 28 35 }; 29 36 30 - export const reply = async (uri: string, message: string) => { 37 + export const reply = async ( 38 + uri: string, 39 + message: string, 40 + gif?: GifEmbed, 41 + facets?: Mention[], 42 + ) => { 31 43 const response = await axios.post( 32 44 `${API_URL}/users/${uri.replace("at://", "")}/replies`, 33 - { message }, 45 + { message, gif, facets }, 34 46 { 35 47 headers: { 36 48 "Content-Type": "application/json",
+247
apps/web/src/components/Shout/MediaPicker/MediaPicker.tsx
··· 1 + import styled from "@emotion/styled"; 2 + import { IconSearch, IconX } from "@tabler/icons-react"; 3 + import { useEffect, useRef, useState } from "react"; 4 + import { useQuery } from "@tanstack/react-query"; 5 + import { 6 + KLIPY_ENABLED, 7 + KLIPY_TABS, 8 + searchMedia, 9 + type KlipyMediaType, 10 + type MediaResult, 11 + } from "../../../api/klipy"; 12 + 13 + const Panel = styled.div` 14 + display: flex; 15 + flex-direction: column; 16 + gap: 8px; 17 + width: 340px; 18 + max-width: calc(100vw - 32px); 19 + padding: 12px; 20 + border-radius: 12px; 21 + background-color: var(--color-background); 22 + border: 1px solid var(--color-input-background); 23 + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.35); 24 + `; 25 + 26 + const TopBar = styled.div` 27 + display: flex; 28 + align-items: center; 29 + justify-content: space-between; 30 + `; 31 + 32 + const Tabs = styled.div` 33 + display: flex; 34 + gap: 4px; 35 + `; 36 + 37 + const Tab = styled.button<{ active: boolean }>` 38 + border: none; 39 + cursor: pointer; 40 + border-radius: 999px; 41 + padding: 4px 10px; 42 + font-size: 12px; 43 + font-weight: 600; 44 + transition: all 0.15s ease; 45 + background-color: ${({ active }) => 46 + active ? "var(--color-purple)" : "transparent"}; 47 + color: ${({ active }) => 48 + active ? "var(--color-button-text)" : "var(--color-text-muted)"}; 49 + 50 + &:hover { 51 + color: var(--color-text); 52 + } 53 + `; 54 + 55 + const CloseButton = styled.button` 56 + display: flex; 57 + align-items: center; 58 + justify-content: center; 59 + height: 28px; 60 + width: 28px; 61 + border: none; 62 + border-radius: 999px; 63 + cursor: pointer; 64 + background-color: transparent; 65 + color: var(--color-text-muted); 66 + 67 + &:hover { 68 + background-color: var(--color-input-background); 69 + color: var(--color-text); 70 + } 71 + `; 72 + 73 + const SearchBox = styled.div` 74 + display: flex; 75 + align-items: center; 76 + gap: 8px; 77 + padding: 0 10px; 78 + border-radius: 8px; 79 + background-color: var(--color-input-background); 80 + `; 81 + 82 + const SearchInput = styled.input` 83 + height: 34px; 84 + width: 100%; 85 + border: none; 86 + outline: none; 87 + background-color: transparent; 88 + font-size: 14px; 89 + color: var(--color-text); 90 + 91 + &::placeholder { 92 + color: var(--color-text-muted); 93 + } 94 + `; 95 + 96 + const Grid = styled.div` 97 + display: grid; 98 + grid-template-columns: repeat(3, 1fr); 99 + gap: 6px; 100 + height: 300px; 101 + align-content: start; 102 + overflow-y: auto; 103 + `; 104 + 105 + const Tile = styled.button` 106 + aspect-ratio: 1; 107 + overflow: hidden; 108 + border-radius: 8px; 109 + border: 1px solid var(--color-input-background); 110 + cursor: pointer; 111 + padding: 0; 112 + background-color: var(--color-input-background); 113 + transition: transform 0.12s ease; 114 + 115 + &:hover { 116 + transform: scale(1.03); 117 + border-color: var(--color-purple); 118 + } 119 + 120 + & > img, 121 + & > video { 122 + height: 100%; 123 + width: 100%; 124 + object-fit: cover; 125 + display: block; 126 + } 127 + `; 128 + 129 + const Empty = styled.div` 130 + grid-column: span 3; 131 + padding: 32px 0; 132 + text-align: center; 133 + font-size: 12px; 134 + color: var(--color-text-muted); 135 + `; 136 + 137 + const Credit = styled.p` 138 + margin: 0; 139 + text-align: center; 140 + font-size: 10px; 141 + color: var(--color-text-muted); 142 + `; 143 + 144 + /** Debounce a fast-changing value. */ 145 + function useDebounced<T>(value: T, delay: number): T { 146 + const [debounced, setDebounced] = useState(value); 147 + useEffect(() => { 148 + const t = setTimeout(() => setDebounced(value), delay); 149 + return () => clearTimeout(t); 150 + }, [value, delay]); 151 + return debounced; 152 + } 153 + 154 + interface MediaPickerProps { 155 + onSelect: (media: MediaResult) => void; 156 + onClose: () => void; 157 + } 158 + 159 + /** KLIPY-powered GIF / sticker / clip picker. */ 160 + function MediaPicker({ onSelect, onClose }: MediaPickerProps) { 161 + const [type, setType] = useState<KlipyMediaType>("gifs"); 162 + const [query, setQuery] = useState(""); 163 + const debounced = useDebounced(query, 350); 164 + const inputRef = useRef<HTMLInputElement>(null); 165 + 166 + useEffect(() => { 167 + requestAnimationFrame(() => inputRef.current?.focus()); 168 + }, []); 169 + 170 + const { data, isFetching } = useQuery({ 171 + queryKey: ["klipy", type, debounced], 172 + queryFn: () => searchMedia(type, debounced), 173 + enabled: KLIPY_ENABLED, 174 + staleTime: 60_000, 175 + }); 176 + 177 + const results = data ?? []; 178 + 179 + return ( 180 + <Panel onClick={(e) => e.stopPropagation()}> 181 + <TopBar> 182 + <Tabs> 183 + {KLIPY_TABS.map((tab) => ( 184 + <Tab 185 + key={tab.type} 186 + type="button" 187 + active={type === tab.type} 188 + onClick={() => setType(tab.type)} 189 + > 190 + {tab.label} 191 + </Tab> 192 + ))} 193 + </Tabs> 194 + <CloseButton type="button" aria-label="Close" onClick={onClose}> 195 + <IconX size={16} /> 196 + </CloseButton> 197 + </TopBar> 198 + 199 + <SearchBox> 200 + <IconSearch size={14} color="var(--color-text-muted)" /> 201 + <SearchInput 202 + ref={inputRef} 203 + value={query} 204 + onChange={(e) => setQuery(e.target.value)} 205 + placeholder={`Search ${type}`} 206 + /> 207 + </SearchBox> 208 + 209 + <Grid> 210 + {!KLIPY_ENABLED ? ( 211 + <Empty> 212 + Set <code>VITE_KLIPY_API_KEY</code> to enable GIFs, stickers &amp; 213 + clips. 214 + </Empty> 215 + ) : results.length === 0 ? ( 216 + <Empty>{isFetching ? "Loading…" : "No results"}</Empty> 217 + ) : ( 218 + results.map((m) => ( 219 + <Tile 220 + key={m.id} 221 + type="button" 222 + title={m.alt} 223 + onClick={() => onSelect(m)} 224 + > 225 + {m.isVideo ? ( 226 + <video 227 + src={m.url} 228 + poster={m.previewUrl} 229 + autoPlay 230 + loop 231 + muted 232 + playsInline 233 + /> 234 + ) : ( 235 + <img src={m.previewUrl ?? m.url} alt={m.alt ?? ""} loading="lazy" /> 236 + )} 237 + </Tile> 238 + )) 239 + )} 240 + </Grid> 241 + 242 + <Credit>Powered by KLIPY</Credit> 243 + </Panel> 244 + ); 245 + } 246 + 247 + export default MediaPicker;
+1
apps/web/src/components/Shout/MediaPicker/index.tsx
··· 1 + export { default } from "./MediaPicker";
+228
apps/web/src/components/Shout/MentionTextarea/MentionTextarea.tsx
··· 1 + import styled from "@emotion/styled"; 2 + import { useQuery } from "@tanstack/react-query"; 3 + import { Textarea, type TextareaProps } from "baseui/textarea"; 4 + import { useEffect, useRef, useState } from "react"; 5 + import { getCaretCoordinates } from "../../../lib/caret"; 6 + import { 7 + activeMentionToken, 8 + searchActorsTypeahead, 9 + } from "../../../lib/richtext"; 10 + 11 + /** 12 + * A baseui Textarea with an `@mention` typeahead popup. Fully controlled via 13 + * `value`/`onChange`; the popup queries the Bluesky AppView and inserts the 14 + * chosen handle. Purely a UI affordance — facets are resolved separately on 15 + * submit via `resolveMentionFacets`. 16 + */ 17 + 18 + const Wrapper = styled.div` 19 + position: relative; 20 + `; 21 + 22 + const Popup = styled.ul` 23 + position: absolute; 24 + z-index: 30; 25 + margin: 0; 26 + padding: 4px; 27 + list-style: none; 28 + max-height: 224px; 29 + width: 256px; 30 + max-width: calc(100% - 8px); 31 + overflow-y: auto; 32 + border-radius: 12px; 33 + border: 1px solid var(--color-input-background); 34 + background-color: var(--color-background); 35 + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.35); 36 + `; 37 + 38 + const Item = styled.button<{ active: boolean }>` 39 + display: flex; 40 + align-items: center; 41 + gap: 8px; 42 + width: 100%; 43 + padding: 8px; 44 + border: none; 45 + border-radius: 8px; 46 + cursor: pointer; 47 + text-align: left; 48 + background-color: ${({ active }) => 49 + active ? "var(--color-input-background)" : "transparent"}; 50 + 51 + &:hover { 52 + background-color: var(--color-input-background); 53 + } 54 + `; 55 + 56 + const Avatar = styled.span` 57 + display: flex; 58 + height: 28px; 59 + width: 28px; 60 + flex-shrink: 0; 61 + overflow: hidden; 62 + border-radius: 999px; 63 + background-color: var(--color-input-background); 64 + 65 + & > img { 66 + height: 100%; 67 + width: 100%; 68 + object-fit: cover; 69 + } 70 + `; 71 + 72 + const Names = styled.span` 73 + min-width: 0; 74 + `; 75 + 76 + const DisplayName = styled.span` 77 + display: block; 78 + overflow: hidden; 79 + text-overflow: ellipsis; 80 + white-space: nowrap; 81 + color: var(--color-text); 82 + font-size: 14px; 83 + `; 84 + 85 + const Handle = styled.span` 86 + display: block; 87 + overflow: hidden; 88 + text-overflow: ellipsis; 89 + white-space: nowrap; 90 + color: var(--color-text-muted); 91 + font-size: 12px; 92 + `; 93 + 94 + /** Debounce a fast-changing value. */ 95 + function useDebounced<T>(value: T, delay: number): T { 96 + const [debounced, setDebounced] = useState(value); 97 + useEffect(() => { 98 + const t = setTimeout(() => setDebounced(value), delay); 99 + return () => clearTimeout(t); 100 + }, [value, delay]); 101 + return debounced; 102 + } 103 + 104 + interface MentionTextareaProps extends Omit<TextareaProps, "onChange"> { 105 + value: string; 106 + onChange: (value: string) => void; 107 + } 108 + 109 + function MentionTextarea({ value, onChange, ...rest }: MentionTextareaProps) { 110 + const textareaRef = useRef<HTMLTextAreaElement>(null); 111 + const [mention, setMention] = useState<{ start: number; query: string } | null>( 112 + null, 113 + ); 114 + const [caret, setCaret] = useState<{ top: number; left: number } | null>(null); 115 + const [activeIdx, setActiveIdx] = useState(0); 116 + const debouncedQuery = useDebounced(mention?.query ?? "", 200); 117 + 118 + const { data: suggestions = [] } = useQuery({ 119 + queryKey: ["mention-typeahead", debouncedQuery], 120 + queryFn: () => searchActorsTypeahead(debouncedQuery), 121 + enabled: mention !== null && debouncedQuery.length >= 1, 122 + staleTime: 30_000, 123 + }); 124 + const popupOpen = mention !== null && suggestions.length > 0; 125 + 126 + const syncMention = (text: string, caretPos: number) => { 127 + const token = activeMentionToken(text, caretPos); 128 + setMention(token); 129 + setActiveIdx(0); 130 + const el = textareaRef.current; 131 + if (token && el) { 132 + const c = getCaretCoordinates(el, token.start); 133 + const maxLeft = Math.max(0, el.clientWidth - 256); 134 + setCaret({ 135 + top: el.offsetTop + c.top + c.height, 136 + left: el.offsetLeft + Math.min(c.left, maxLeft), 137 + }); 138 + } else { 139 + setCaret(null); 140 + } 141 + }; 142 + 143 + const insertMention = (handle: string) => { 144 + if (!mention) return; 145 + const el = textareaRef.current; 146 + const caretPos = el?.selectionStart ?? value.length; 147 + const next = 148 + value.slice(0, mention.start) + `@${handle} ` + value.slice(caretPos); 149 + onChange(next); 150 + setMention(null); 151 + setCaret(null); 152 + requestAnimationFrame(() => { 153 + const pos = mention.start + handle.length + 2; 154 + el?.focus(); 155 + el?.setSelectionRange(pos, pos); 156 + }); 157 + }; 158 + 159 + const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => { 160 + if (!popupOpen) return; 161 + if (e.key === "ArrowDown") { 162 + e.preventDefault(); 163 + setActiveIdx((i) => (i + 1) % suggestions.length); 164 + } else if (e.key === "ArrowUp") { 165 + e.preventDefault(); 166 + setActiveIdx((i) => (i - 1 + suggestions.length) % suggestions.length); 167 + } else if (e.key === "Enter" || e.key === "Tab") { 168 + e.preventDefault(); 169 + insertMention(suggestions[activeIdx]!.handle); 170 + } else if (e.key === "Escape") { 171 + e.preventDefault(); 172 + setMention(null); 173 + setCaret(null); 174 + } 175 + }; 176 + 177 + return ( 178 + <Wrapper> 179 + <Textarea 180 + inputRef={textareaRef} 181 + value={value} 182 + onChange={(e) => { 183 + onChange(e.currentTarget.value); 184 + syncMention(e.currentTarget.value, e.currentTarget.selectionStart ?? 0); 185 + }} 186 + onClick={(e) => 187 + syncMention( 188 + e.currentTarget.value, 189 + e.currentTarget.selectionStart ?? 0, 190 + ) 191 + } 192 + onKeyUp={(e) => 193 + syncMention( 194 + e.currentTarget.value, 195 + e.currentTarget.selectionStart ?? 0, 196 + ) 197 + } 198 + onKeyDown={onKeyDown} 199 + {...rest} 200 + /> 201 + 202 + {popupOpen && caret && ( 203 + <Popup style={{ top: caret.top, left: caret.left }}> 204 + {suggestions.map((s, i) => ( 205 + <li key={s.did}> 206 + <Item 207 + type="button" 208 + active={i === activeIdx} 209 + onMouseDown={(e) => { 210 + e.preventDefault(); 211 + insertMention(s.handle); 212 + }} 213 + > 214 + <Avatar>{s.avatar && <img src={s.avatar} alt="" />}</Avatar> 215 + <Names> 216 + <DisplayName>{s.displayName || s.handle}</DisplayName> 217 + <Handle>@{s.handle}</Handle> 218 + </Names> 219 + </Item> 220 + </li> 221 + ))} 222 + </Popup> 223 + )} 224 + </Wrapper> 225 + ); 226 + } 227 + 228 + export default MentionTextarea;
+1
apps/web/src/components/Shout/MentionTextarea/index.tsx
··· 1 + export { default } from "./MentionTextarea";
+64
apps/web/src/components/Shout/RichText.tsx
··· 1 + import styled from "@emotion/styled"; 2 + import { Link } from "@tanstack/react-router"; 3 + import { Fragment } from "react"; 4 + import { type Mention, segmentText } from "../../lib/richtext"; 5 + 6 + /** 7 + * Renders a shout message as rich text: bare URLs become external links and 8 + * `@mentions` become links to the mentioned user's profile. When the shout 9 + * carries mention facets the links resolve to the exact DID; otherwise 10 + * `@handles` are detected heuristically. Messages are still stored as plain 11 + * strings + facets — this is purely presentational. 12 + */ 13 + 14 + const Anchor = styled.a` 15 + color: var(--color-primary); 16 + text-decoration: none; 17 + word-break: break-word; 18 + 19 + &:hover { 20 + text-decoration: underline; 21 + } 22 + `; 23 + 24 + const MentionLink = styled(Link)` 25 + color: var(--color-primary); 26 + text-decoration: none; 27 + 28 + &:hover { 29 + text-decoration: underline; 30 + } 31 + `; 32 + 33 + interface RichTextProps { 34 + children: string; 35 + facets?: Mention[]; 36 + } 37 + 38 + function RichText({ children, facets }: RichTextProps) { 39 + const segments = segmentText(children, facets); 40 + return ( 41 + <> 42 + {segments.map((seg, i) => 43 + seg.type === "mention" ? ( 44 + <MentionLink key={i} to={`/profile/${seg.target}`}> 45 + {seg.value} 46 + </MentionLink> 47 + ) : seg.type === "link" ? ( 48 + <Anchor 49 + key={i} 50 + href={seg.href} 51 + target="_blank" 52 + rel="noreferrer noopener" 53 + > 54 + {seg.value} 55 + </Anchor> 56 + ) : ( 57 + <Fragment key={i}>{seg.value}</Fragment> 58 + ), 59 + )} 60 + </> 61 + ); 62 + } 63 + 64 + export default RichText;
+92 -8
apps/web/src/components/Shout/Shout.tsx
··· 2 2 import { zodResolver } from "@hookform/resolvers/zod"; 3 3 import { useParams } from "@tanstack/react-router"; 4 4 import { Button } from "baseui/button"; 5 + import { StatefulPopover, PLACEMENT } from "baseui/popover"; 5 6 import { Spinner } from "baseui/spinner"; 6 - import { Textarea } from "baseui/textarea"; 7 7 import { LabelLarge, LabelMedium } from "baseui/typography"; 8 + import { IconGif, IconX } from "@tabler/icons-react"; 8 9 import { useAtomValue, useSetAtom } from "jotai"; 9 10 import { useState } from "react"; 10 11 import { Controller, useForm } from "react-hook-form"; 11 12 import z from "zod"; 13 + import { isVideoUrl, type MediaResult } from "../../api/klipy"; 14 + import { resolveMentionFacets } from "../../lib/richtext"; 12 15 import { profileAtom } from "../../atoms/profile"; 13 16 import { shoutsAtom } from "../../atoms/shouts"; 14 17 import { userAtom } from "../../atoms/user"; 15 18 import useShout from "../../hooks/useShout"; 19 + import MediaPicker from "./MediaPicker"; 20 + import MentionTextarea from "./MentionTextarea"; 16 21 import SignInModal from "../SignInModal"; 17 22 import ShoutList from "./ShoutList"; 18 23 19 24 const ShoutSchema = z.object({ 20 - message: z.string().min(1).max(1000), 25 + message: z.string().max(1000), 21 26 }); 22 27 23 28 interface ShoutProps { ··· 47 52 const { did, rkey } = useParams({ strict: false }); 48 53 const location = window.location; 49 54 const [loading, setLoading] = useState(false); 55 + const [gif, setGif] = useState<MediaResult | null>(null); 50 56 51 57 const onShout = async ({ message }: z.infer<typeof ShoutSchema>) => { 58 + if (message.trim().length === 0 && !gif) { 59 + return; 60 + } 52 61 setLoading(true); 53 62 let uri = ""; 54 63 ··· 72 81 uri = `at://${did}/app.rocksky.scrobble/${rkey}`; 73 82 } 74 83 75 - await shout(uri, message); 84 + const gifEmbed = gif 85 + ? { 86 + url: gif.url, 87 + previewUrl: gif.previewUrl, 88 + alt: gif.alt, 89 + width: gif.width, 90 + height: gif.height, 91 + } 92 + : undefined; 93 + 94 + const facets = message.trim() ? await resolveMentionFacets(message) : []; 95 + 96 + await shout(uri, message, gifEmbed, facets.length ? facets : undefined); 76 97 77 98 const data = await getShouts(uri); 78 99 setShouts({ ··· 81 102 }); 82 103 83 104 setLoading(false); 84 - 105 + setGif(null); 85 106 reset(); 86 107 }; 87 108 ··· 97 118 liked: x.shouts.liked, 98 119 reported: x.shouts.reported, 99 120 likes: x.shouts.likes, 121 + gif: x.shouts.gifUrl 122 + ? { 123 + url: x.shouts.gifUrl, 124 + previewUrl: x.shouts.gifPreviewUrl, 125 + alt: x.shouts.gifAlt, 126 + width: x.shouts.gifWidth, 127 + height: x.shouts.gifHeight, 128 + } 129 + : undefined, 130 + facets: x.shouts.facets ?? undefined, 100 131 user: { 101 132 did: x.users.did, 102 133 avatar: x.users.avatar, ··· 121 152 name="message" 122 153 control={control} 123 154 render={({ field }) => ( 124 - <Textarea 125 - {...field} 155 + <MentionTextarea 156 + value={field.value} 157 + onChange={field.onChange} 126 158 placeholder={ 127 159 props.type === "profile" 128 160 ? `@${profile?.handle}, leave a shout for @${user?.handle} ...` ··· 156 188 )} 157 189 /> 158 190 159 - <div className="mt-[15px] flex justify-end"> 191 + {gif && ( 192 + <div className="mt-[10px] relative w-fit max-w-[220px] overflow-hidden rounded-[12px] border border-[var(--color-input-background)]"> 193 + {isVideoUrl(gif.url) ? ( 194 + <video 195 + src={gif.url} 196 + className="block w-full h-auto" 197 + autoPlay 198 + loop 199 + muted 200 + playsInline 201 + /> 202 + ) : ( 203 + <img 204 + src={gif.previewUrl ?? gif.url} 205 + alt={gif.alt ?? ""} 206 + className="block w-full h-auto" 207 + /> 208 + )} 209 + <button 210 + type="button" 211 + aria-label="Remove media" 212 + onClick={() => setGif(null)} 213 + className="absolute right-[6px] top-[6px] flex h-[24px] w-[24px] items-center justify-center rounded-full bg-black/60 text-white cursor-pointer border-none hover:bg-black/80" 214 + > 215 + <IconX size={14} /> 216 + </button> 217 + </div> 218 + )} 219 + 220 + <div className="mt-[15px] flex justify-between items-center"> 221 + <StatefulPopover 222 + placement={PLACEMENT.bottomLeft} 223 + overrides={{ Body: { style: { zIndex: 3 } } }} 224 + content={({ close }) => ( 225 + <MediaPicker 226 + onSelect={(m) => { 227 + setGif(m); 228 + close(); 229 + }} 230 + onClose={close} 231 + /> 232 + )} 233 + > 234 + <button 235 + type="button" 236 + aria-label="Add a GIF, sticker or clip" 237 + className="flex items-center gap-[4px] rounded-full px-[10px] py-[5px] text-[13px] cursor-pointer border-none bg-transparent text-[var(--color-text-muted)] hover:bg-[var(--color-input-background)] hover:text-[var(--color-text)]" 238 + > 239 + <IconGif size={20} /> 240 + GIF 241 + </button> 242 + </StatefulPopover> 243 + 160 244 {!loading && ( 161 245 <Button 162 246 disabled={ 163 - watch("message").length === 0 || 247 + (watch("message").length === 0 && !gif) || 164 248 watch("message").length > 1000 165 249 } 166 250 onClick={handleSubmit(onShout)}
+95 -8
apps/web/src/components/Shout/ShoutList/Shout/ReplyModal/ReplyModal.tsx
··· 9 9 ModalFooter, 10 10 ModalHeader, 11 11 } from "baseui/modal"; 12 + import { StatefulPopover, PLACEMENT } from "baseui/popover"; 12 13 import { Spinner } from "baseui/spinner"; 13 - import { Textarea } from "baseui/textarea"; 14 14 import { LabelMedium, LabelSmall } from "baseui/typography"; 15 + import { IconGif, IconX } from "@tabler/icons-react"; 15 16 import { useAtomValue, useSetAtom } from "jotai"; 16 17 import { useState } from "react"; 17 18 import { Controller, useForm } from "react-hook-form"; 18 19 import z from "zod"; 20 + import { isVideoUrl, type MediaResult } from "../../../../../api/klipy"; 21 + import { type Mention, resolveMentionFacets } from "../../../../../lib/richtext"; 19 22 import { profileAtom } from "../../../../../atoms/profile"; 20 23 import { shoutsAtom } from "../../../../../atoms/shouts"; 21 24 import useShout from "../../../../../hooks/useShout"; 22 25 import scrollToTop from "../../../../../lib/scrollToTop"; 26 + import MediaPicker from "../../../MediaPicker"; 27 + import MentionTextarea from "../../../MentionTextarea"; 28 + import RichText from "../../../RichText"; 23 29 24 30 const ShoutSchema = z.object({ 25 - message: z.string().min(1).max(1000), 31 + message: z.string().max(1000), 26 32 }); 27 33 28 34 const Link = styled(DefaultLink)` ··· 54 60 shout: { 55 61 uri: string; 56 62 message: string; 63 + facets?: Mention[]; 57 64 user: { 58 65 avatar: string; 59 66 displayName: string; ··· 70 77 const setShouts = useSetAtom(shoutsAtom); 71 78 const { did, rkey } = useParams({ strict: false }); 72 79 const [loading, setLoading] = useState(false); 80 + const [gif, setGif] = useState<MediaResult | null>(null); 73 81 74 82 const { control, handleSubmit, reset, watch } = useForm< 75 83 z.infer<typeof ShoutSchema> ··· 83 91 84 92 const onClose = () => { 85 93 reset(); 94 + setGif(null); 86 95 close(); 87 96 }; 88 97 89 98 const onReply = async ({ message }: z.infer<typeof ShoutSchema>) => { 99 + if (message.trim().length === 0 && !gif) { 100 + return; 101 + } 90 102 setLoading(true); 91 - await reply(shout.uri, message); 103 + const gifEmbed = gif 104 + ? { 105 + url: gif.url, 106 + previewUrl: gif.previewUrl, 107 + alt: gif.alt, 108 + width: gif.width, 109 + height: gif.height, 110 + } 111 + : undefined; 112 + const facets = message.trim() ? await resolveMentionFacets(message) : []; 113 + await reply(shout.uri, message, gifEmbed, facets.length ? facets : undefined); 92 114 setLoading(false); 93 115 reset(); 116 + setGif(null); 94 117 close(); 95 118 96 119 let uri = ""; ··· 134 157 liked: x.shouts.liked, 135 158 reported: x.shouts.reported, 136 159 likes: x.shouts.likes, 160 + gif: x.shouts.gifUrl 161 + ? { 162 + url: x.shouts.gifUrl, 163 + previewUrl: x.shouts.gifPreviewUrl, 164 + alt: x.shouts.gifAlt, 165 + width: x.shouts.gifWidth, 166 + height: x.shouts.gifHeight, 167 + } 168 + : undefined, 169 + facets: x.shouts.facets ?? undefined, 137 170 user: { 138 171 did: x.users.did, 139 172 avatar: x.users.avatar, ··· 194 227 onClick={handleSubmit(onReply)} 195 228 shape={"pill"} 196 229 disabled={ 197 - watch("message").length === 0 || watch("message").length > 1000 230 + (watch("message").length === 0 && !gif) || 231 + watch("message").length > 1000 198 232 } 199 233 overrides={{ 200 234 BaseButton: { ··· 255 289 </div> 256 290 </Header> 257 291 <Message className="!text-[var(--color-text)]"> 258 - {shout.message} 292 + <RichText facets={shout.facets}>{shout.message}</RichText> 259 293 </Message> 260 294 </div> 261 295 </div> ··· 269 303 name="message" 270 304 control={control} 271 305 render={({ field }) => ( 272 - <Textarea 306 + <MentionTextarea 307 + value={field.value} 308 + onChange={field.onChange} 273 309 resize="vertical" 274 310 overrides={{ 275 311 Root: { ··· 296 332 autoFocus 297 333 maxLength={1000} 298 334 placeholder="Write your reply" 299 - {...field} 300 335 /> 301 336 )} 302 337 /> 303 338 </div> 339 + 340 + {gif && ( 341 + <div className="ml-[70px] mt-[10px] relative w-fit max-w-[220px] overflow-hidden rounded-[12px] border border-[var(--color-input-background)]"> 342 + {isVideoUrl(gif.url) ? ( 343 + <video 344 + src={gif.url} 345 + className="block w-full h-auto" 346 + autoPlay 347 + loop 348 + muted 349 + playsInline 350 + /> 351 + ) : ( 352 + <img 353 + src={gif.previewUrl ?? gif.url} 354 + alt={gif.alt ?? ""} 355 + className="block w-full h-auto" 356 + /> 357 + )} 358 + <button 359 + type="button" 360 + aria-label="Remove media" 361 + onClick={() => setGif(null)} 362 + className="absolute right-[6px] top-[6px] flex h-[24px] w-[24px] items-center justify-center rounded-full bg-black/60 text-white cursor-pointer border-none hover:bg-black/80" 363 + > 364 + <IconX size={14} /> 365 + </button> 366 + </div> 367 + )} 304 368 </ModalBody> 305 - <ModalFooter></ModalFooter> 369 + <ModalFooter className="flex justify-start !mx-[16px]"> 370 + <StatefulPopover 371 + placement={PLACEMENT.topLeft} 372 + overrides={{ Body: { style: { zIndex: 3 } } }} 373 + content={({ close: closePopover }) => ( 374 + <MediaPicker 375 + onSelect={(m) => { 376 + setGif(m); 377 + closePopover(); 378 + }} 379 + onClose={closePopover} 380 + /> 381 + )} 382 + > 383 + <button 384 + type="button" 385 + aria-label="Add a GIF, sticker or clip" 386 + className="flex items-center gap-[4px] rounded-full px-[10px] py-[5px] text-[13px] cursor-pointer border-none bg-transparent text-[var(--color-text-muted)] hover:bg-[var(--color-input-background)] hover:text-[var(--color-text)]" 387 + > 388 + <IconGif size={20} /> 389 + GIF 390 + </button> 391 + </StatefulPopover> 392 + </ModalFooter> 306 393 </Modal> 307 394 ); 308 395 }
+46 -1
apps/web/src/components/Shout/ShoutList/Shout/Shout.tsx
··· 11 11 import { useAtomValue } from "jotai"; 12 12 import { useState } from "react"; 13 13 import { profileAtom } from "../../../../atoms/profile"; 14 + import { type GifEmbed, isVideoUrl } from "../../../../api/klipy"; 15 + import type { Mention } from "../../../../lib/richtext"; 14 16 import useLike from "../../../../hooks/useLike"; 15 17 import useShout from "../../../../hooks/useShout"; 18 + import RichText from "../../RichText"; 16 19 import HeartOutline from "../../../Icons/HeartOutline"; 17 20 import SignInModal from "../../../SignInModal"; 18 21 import DeleteShoutModal from "./DeleteShoutModal"; ··· 71 74 } 72 75 `; 73 76 77 + const MediaEmbed = styled.div` 78 + margin-top: 8px; 79 + width: fit-content; 80 + max-width: 260px; 81 + overflow: hidden; 82 + border-radius: 12px; 83 + border: 1px solid var(--color-input-background); 84 + 85 + & > img, 86 + & > video { 87 + display: block; 88 + width: 100%; 89 + height: auto; 90 + } 91 + `; 92 + 74 93 interface ShoutProps { 75 94 shout: { 76 95 uri: string; ··· 79 98 liked: boolean; 80 99 reported: boolean; 81 100 likes: number; 101 + gif?: GifEmbed; 102 + facets?: Mention[]; 82 103 user: { 83 104 did: string; 84 105 avatar: string; ··· 212 233 </StatefulTooltip> 213 234 </div> 214 235 </Header> 215 - <Message>{shout.message}</Message> 236 + {shout.message && ( 237 + <Message> 238 + <RichText facets={shout.facets}>{shout.message}</RichText> 239 + </Message> 240 + )} 241 + 242 + {shout.gif && ( 243 + <MediaEmbed> 244 + {isVideoUrl(shout.gif.url) ? ( 245 + <video 246 + src={shout.gif.url} 247 + poster={shout.gif.previewUrl} 248 + autoPlay 249 + loop 250 + muted 251 + playsInline 252 + /> 253 + ) : ( 254 + <img 255 + src={shout.gif.previewUrl ?? shout.gif.url} 256 + alt={shout.gif.alt ?? ""} 257 + /> 258 + )} 259 + </MediaEmbed> 260 + )} 216 261 217 262 <Actions> 218 263 <ReplyButton onClick={onReply}>
+10
apps/web/src/components/Shout/ShoutList/ShoutList.tsx
··· 73 73 liked: x.shouts.liked, 74 74 reported: x.shouts.reported, 75 75 likes: x.shouts.likes, 76 + gif: x.shouts.gifUrl 77 + ? { 78 + url: x.shouts.gifUrl, 79 + previewUrl: x.shouts.gifPreviewUrl, 80 + alt: x.shouts.gifAlt, 81 + width: x.shouts.gifWidth, 82 + height: x.shouts.gifHeight, 83 + } 84 + : undefined, 85 + facets: x.shouts.facets ?? undefined, 76 86 user: { 77 87 did: x.users.did, 78 88 avatar: x.users.avatar,
+16 -4
apps/web/src/hooks/useShout.tsx
··· 8 8 reportShout, 9 9 shout, 10 10 } from "../api/shouts"; 11 + import type { GifEmbed } from "../api/klipy"; 12 + import type { Mention } from "../lib/richtext"; 11 13 import { API_URL } from "../consts"; 12 14 13 15 export const useShoutMutation = () => ··· 46 48 }); 47 49 48 50 function useShout() { 49 - const shout = async (uri: string, message: string) => { 51 + const shout = async ( 52 + uri: string, 53 + message: string, 54 + gif?: GifEmbed, 55 + facets?: Mention[], 56 + ) => { 50 57 const response = await axios.post( 51 58 `${API_URL}/users/${uri.replace("at://", "")}/shouts`, 52 - { message }, 59 + { message, gif, facets }, 53 60 { 54 61 headers: { 55 62 "Content-Type": "application/json", ··· 72 79 return response.data; 73 80 }, []); 74 81 75 - const reply = async (uri: string, message: string) => { 82 + const reply = async ( 83 + uri: string, 84 + message: string, 85 + gif?: GifEmbed, 86 + facets?: Mention[], 87 + ) => { 76 88 const response = await axios.post( 77 89 `${API_URL}/users/${uri.replace("at://", "")}/replies`, 78 - { message }, 90 + { message, gif, facets }, 79 91 { 80 92 headers: { 81 93 "Content-Type": "application/json",
+75
apps/web/src/lib/caret.ts
··· 1 + /** 2 + * Pixel coordinates of the caret within a textarea, relative to the element's 3 + * own top-left (padding box). Used to anchor the @-mention popup right under the 4 + * text being typed instead of at the bottom of the whole field. 5 + * 6 + * Works by rendering an invisible "mirror" div that copies the textarea's text 7 + * and relevant styles, then measuring a marker placed at the caret offset. 8 + */ 9 + const MIRRORED_PROPS = [ 10 + "boxSizing", 11 + "width", 12 + "fontFamily", 13 + "fontSize", 14 + "fontWeight", 15 + "fontStyle", 16 + "letterSpacing", 17 + "textTransform", 18 + "wordSpacing", 19 + "lineHeight", 20 + "paddingTop", 21 + "paddingRight", 22 + "paddingBottom", 23 + "paddingLeft", 24 + "borderTopWidth", 25 + "borderRightWidth", 26 + "borderBottomWidth", 27 + "borderLeftWidth", 28 + "whiteSpace", 29 + "wordWrap", 30 + "tabSize", 31 + ] as const; 32 + 33 + export interface CaretCoords { 34 + /** Offset from the textarea's top edge to the top of the caret line. */ 35 + top: number; 36 + /** Offset from the textarea's left edge to the caret. */ 37 + left: number; 38 + /** Line height at the caret. */ 39 + height: number; 40 + } 41 + 42 + export function getCaretCoordinates( 43 + el: HTMLTextAreaElement, 44 + position: number, 45 + ): CaretCoords { 46 + const style = window.getComputedStyle(el); 47 + const mirror = document.createElement("div"); 48 + 49 + mirror.style.position = "absolute"; 50 + mirror.style.visibility = "hidden"; 51 + mirror.style.whiteSpace = "pre-wrap"; 52 + mirror.style.wordWrap = "break-word"; 53 + mirror.style.top = "0"; 54 + mirror.style.left = "-9999px"; 55 + for (const prop of MIRRORED_PROPS) { 56 + mirror.style[prop as never] = style[prop as never]; 57 + } 58 + // Overflow must not add scrollbars that shift metrics. 59 + mirror.style.overflow = "hidden"; 60 + 61 + mirror.textContent = el.value.slice(0, position); 62 + // A marker span at the caret; its offset is the caret position. 63 + const marker = document.createElement("span"); 64 + marker.textContent = el.value.slice(position) || "."; 65 + mirror.appendChild(marker); 66 + 67 + document.body.appendChild(mirror); 68 + const top = marker.offsetTop - el.scrollTop; 69 + const left = marker.offsetLeft - el.scrollLeft; 70 + const height = 71 + parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.2; 72 + document.body.removeChild(mirror); 73 + 74 + return { top, left, height }; 75 + }
+246
apps/web/src/lib/richtext.ts
··· 1 + /** 2 + * Rich text helpers for shouts: detecting `@mentions`, resolving them to 3 + * ATProto mention facets (UTF-8 byte ranges → DID), splitting a message into 4 + * renderable segments, and typeahead actor search for the composer. 5 + * 6 + * Rocksky handles are ATProto/Bluesky handles, so resolution and typeahead go 7 + * through the public Bluesky AppView (same as atradio.fm). 8 + */ 9 + 10 + const BSKY_APPVIEW = "https://public.api.bsky.app"; 11 + 12 + const encoder = new TextEncoder(); 13 + const decoder = new TextDecoder(); 14 + 15 + /** UTF-8 byte length of a string (facets index by bytes, not code units). */ 16 + function byteLength(s: string): number { 17 + return encoder.encode(s).length; 18 + } 19 + 20 + /** A mention facet stored on a shout record. */ 21 + export interface Mention { 22 + did: string; 23 + byteStart: number; 24 + byteEnd: number; 25 + } 26 + 27 + /** 28 + * Handle mentions like `@alice.bsky.social`. Matches the leading `@` plus a 29 + * dotted handle; the capture excludes any trailing punctuation. 30 + */ 31 + const MENTION_RE = 32 + /(^|[\s(])@([a-zA-Z0-9][a-zA-Z0-9.-]*[a-zA-Z0-9]|[a-zA-Z0-9])/g; 33 + 34 + export interface MentionSpan { 35 + /** String (code-unit) index of the `@`. */ 36 + start: number; 37 + /** String index just past the handle. */ 38 + end: number; 39 + /** Handle without the leading `@`. */ 40 + handle: string; 41 + } 42 + 43 + /** Find `@handle` spans in text (string indices). */ 44 + export function detectMentionSpans(text: string): MentionSpan[] { 45 + const spans: MentionSpan[] = []; 46 + for (const m of text.matchAll(MENTION_RE)) { 47 + const lead = m[1] ?? ""; 48 + const handle = m[2]; 49 + if (!handle) continue; 50 + const at = (m.index ?? 0) + lead.length; 51 + spans.push({ start: at, end: at + 1 + handle.length, handle }); 52 + } 53 + return spans; 54 + } 55 + 56 + /** Resolve a single handle to a DID via the public AppView (null on failure). */ 57 + async function resolveHandle(handle: string): Promise<string | null> { 58 + try { 59 + const res = await fetch( 60 + `${BSKY_APPVIEW}/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent( 61 + handle, 62 + )}`, 63 + { headers: { Accept: "application/json" } }, 64 + ); 65 + if (!res.ok) return null; 66 + const d = (await res.json()) as { did?: string }; 67 + return d.did ?? null; 68 + } catch { 69 + return null; 70 + } 71 + } 72 + 73 + /** 74 + * Resolve every `@handle` in `text` to a mention facet (UTF-8 byte range → DID). 75 + * Handles that don't resolve are silently dropped. Resolution is cached per call 76 + * so a handle mentioned twice is only fetched once. 77 + */ 78 + export async function resolveMentionFacets(text: string): Promise<Mention[]> { 79 + const spans = detectMentionSpans(text); 80 + if (spans.length === 0) return []; 81 + 82 + const cache = new Map<string, string | null>(); 83 + const resolve = async (handle: string): Promise<string | null> => { 84 + const key = handle.toLowerCase(); 85 + if (cache.has(key)) return cache.get(key)!; 86 + const did = await resolveHandle(handle); 87 + cache.set(key, did); 88 + return did; 89 + }; 90 + 91 + const facets: Mention[] = []; 92 + for (const span of spans) { 93 + const did = await resolve(span.handle); 94 + if (!did) continue; 95 + facets.push({ 96 + did, 97 + byteStart: byteLength(text.slice(0, span.start)), 98 + byteEnd: byteLength(text.slice(0, span.end)), 99 + }); 100 + } 101 + return facets; 102 + } 103 + 104 + export type Segment = 105 + | { type: "text"; value: string } 106 + | { type: "mention"; value: string; target: string } 107 + | { type: "link"; value: string; href: string }; 108 + 109 + /** Matches http(s) URLs; the capture excludes common trailing punctuation. */ 110 + const URL_RE = /https?:\/\/[^\s<]+[^\s<.,:;!?)\]}'"]/g; 111 + 112 + /** Split a plain-text run into text + link segments (URLs → clickable). */ 113 + function splitLinks(text: string): Segment[] { 114 + const out: Segment[] = []; 115 + let last = 0; 116 + for (const m of text.matchAll(URL_RE)) { 117 + const start = m.index ?? 0; 118 + const url = m[0]; 119 + if (start > last) out.push({ type: "text", value: text.slice(last, start) }); 120 + out.push({ type: "link", value: url, href: url }); 121 + last = start + url.length; 122 + } 123 + if (last < text.length) out.push({ type: "text", value: text.slice(last) }); 124 + return out; 125 + } 126 + 127 + /** Regex-based mention + link segmentation, used when a shout has no facets. */ 128 + function segmentWithoutFacets(text: string): Segment[] { 129 + const spans = detectMentionSpans(text); 130 + if (spans.length === 0) return splitLinks(text); 131 + const out: Segment[] = []; 132 + let cursor = 0; 133 + for (const span of spans) { 134 + if (span.start > cursor) { 135 + out.push(...splitLinks(text.slice(cursor, span.start))); 136 + } 137 + out.push({ 138 + type: "mention", 139 + value: text.slice(span.start, span.end), 140 + target: span.handle, 141 + }); 142 + cursor = span.end; 143 + } 144 + if (cursor < text.length) out.push(...splitLinks(text.slice(cursor))); 145 + return out; 146 + } 147 + 148 + /** 149 + * Split a shout's message into plain + mention + link segments. When facets are 150 + * present, mentions come from their byte ranges (linking to the DID); otherwise 151 + * `@handles` are detected heuristically. URLs are always linkified. 152 + */ 153 + export function segmentText( 154 + text: string, 155 + facets: Mention[] | undefined, 156 + ): Segment[] { 157 + if (!facets?.length) return segmentWithoutFacets(text); 158 + const bytes = encoder.encode(text); 159 + const sorted = [...facets].sort((a, b) => a.byteStart - b.byteStart); 160 + const segments: Segment[] = []; 161 + let cursor = 0; 162 + for (const f of sorted) { 163 + if ( 164 + f.byteStart < cursor || 165 + f.byteEnd > bytes.length || 166 + f.byteEnd <= f.byteStart 167 + ) 168 + continue; 169 + if (f.byteStart > cursor) { 170 + segments.push( 171 + ...splitLinks(decoder.decode(bytes.slice(cursor, f.byteStart))), 172 + ); 173 + } 174 + segments.push({ 175 + type: "mention", 176 + value: decoder.decode(bytes.slice(f.byteStart, f.byteEnd)), 177 + target: f.did, 178 + }); 179 + cursor = f.byteEnd; 180 + } 181 + if (cursor < bytes.length) { 182 + segments.push(...splitLinks(decoder.decode(bytes.slice(cursor)))); 183 + } 184 + return segments; 185 + } 186 + 187 + export interface ActorSuggestion { 188 + did: string; 189 + handle: string; 190 + displayName?: string; 191 + avatar?: string; 192 + } 193 + 194 + /** Typeahead actor search (Bluesky AppView) for the mention autocomplete. */ 195 + export async function searchActorsTypeahead( 196 + q: string, 197 + limit = 6, 198 + ): Promise<ActorSuggestion[]> { 199 + const query = q.trim(); 200 + if (!query) return []; 201 + const url = 202 + `${BSKY_APPVIEW}/xrpc/app.bsky.actor.searchActorsTypeahead?q=` + 203 + encodeURIComponent(query) + 204 + `&limit=${limit}`; 205 + try { 206 + const res = await fetch(url, { headers: { Accept: "application/json" } }); 207 + if (!res.ok) return []; 208 + const d = (await res.json()) as { 209 + actors?: { 210 + did: string; 211 + handle: string; 212 + displayName?: string; 213 + avatar?: string; 214 + }[]; 215 + }; 216 + return (d.actors ?? []).map((a) => ({ 217 + did: a.did, 218 + handle: a.handle, 219 + displayName: a.displayName, 220 + avatar: a.avatar, 221 + })); 222 + } catch { 223 + return []; 224 + } 225 + } 226 + 227 + /** The `@token` the caret currently sits in, for the mention popup. */ 228 + export function activeMentionToken( 229 + text: string, 230 + caret: number, 231 + ): { start: number; query: string } | null { 232 + let i = caret - 1; 233 + while (i >= 0) { 234 + const ch = text[i]; 235 + if (ch === "@") { 236 + const before = i === 0 ? " " : text[i - 1]; 237 + if (i === 0 || /[\s(]/.test(before)) { 238 + return { start: i, query: text.slice(i + 1, caret) }; 239 + } 240 + return null; 241 + } 242 + if (!/[a-zA-Z0-9.\-]/.test(ch)) return null; 243 + i--; 244 + } 245 + return null; 246 + }