social bookmarking for atproto

[frontend] Read profile from Appview & edit it from the frontend (not clean)

hexmani.ac 8492f766 f7aace82

verified
+253 -25
+2
frontend/package.json
··· 20 20 "vite-plugin-solid": "^2.11.8" 21 21 }, 22 22 "dependencies": { 23 + "@atcute/client": "^4.0.3", 23 24 "@atcute/lexicons": "^1.1.0", 24 25 "@atcute/oauth-browser-client": "^1.0.26", 26 + "@mary/exif-rm": "jsr:^0.2.2", 25 27 "@solidjs/router": "^0.15.3", 26 28 "solid-js": "^1.9.9" 27 29 }
+15 -4
frontend/pnpm-lock.yaml
··· 8 8 9 9 .: 10 10 dependencies: 11 + '@atcute/client': 12 + specifier: ^4.0.3 13 + version: 4.0.3 11 14 '@atcute/lexicons': 12 15 specifier: ^1.1.0 13 16 version: 1.1.0 14 17 '@atcute/oauth-browser-client': 15 18 specifier: ^1.0.26 16 19 version: 1.0.26 20 + '@mary/exif-rm': 21 + specifier: jsr:^0.2.2 22 + version: '@jsr/mary__exif-rm@0.2.2' 17 23 '@solidjs/router': 18 24 specifier: ^0.15.3 19 25 version: 0.15.3(solid-js@1.9.9) ··· 324 330 '@jridgewell/trace-mapping@0.3.30': 325 331 resolution: {integrity: sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==} 326 332 333 + '@jsr/mary__exif-rm@0.2.2': 334 + resolution: {integrity: sha512-+ZpLaC+1CyqWhH608Sqd6/yTG0pOlokn2tCXha7s1SMQ+GLKo4Nn/PskTeeP9Pt+6gNYSu6ednoSlRvXb2ZGxg==, tarball: https://npm.jsr.io/~/11/@jsr/mary__exif-rm/0.2.2.tgz} 335 + 327 336 '@nothing-but/utils@0.17.0': 328 337 resolution: {integrity: sha512-TuCHcHLOqDL0SnaAxACfuRHBNRgNJcNn9X0GiH5H3YSDBVquCr3qEIG3FOQAuMyZCbu9w8nk2CHhOsn7IvhIwQ==} 329 338 ··· 552 561 supports-color: 553 562 optional: true 554 563 555 - electron-to-chromium@1.5.206: 556 - resolution: {integrity: sha512-/eucXSTaI8L78l42xPurxdBzPTjAkMVCQO7unZCWk9LnZiwKcSvQUhF4c99NWQLwMQXxjlfoQy0+8m9U2yEDQQ==} 564 + electron-to-chromium@1.5.207: 565 + resolution: {integrity: sha512-mryFrrL/GXDTmAtIVMVf+eIXM09BBPlO5IQ7lUyKmK8d+A4VpRGG+M3ofoVef6qyF8s60rJei8ymlJxjUA8Faw==} 557 566 558 567 entities@6.0.1: 559 568 resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} ··· 1019 1028 '@jridgewell/resolve-uri': 3.1.2 1020 1029 '@jridgewell/sourcemap-codec': 1.5.5 1021 1030 1031 + '@jsr/mary__exif-rm@0.2.2': {} 1032 + 1022 1033 '@nothing-but/utils@0.17.0': {} 1023 1034 1024 1035 '@rollup/rollup-android-arm-eabi@4.46.3': ··· 1223 1234 browserslist@4.25.3: 1224 1235 dependencies: 1225 1236 caniuse-lite: 1.0.30001735 1226 - electron-to-chromium: 1.5.206 1237 + electron-to-chromium: 1.5.207 1227 1238 node-releases: 2.0.19 1228 1239 update-browserslist-db: 1.1.3(browserslist@4.25.3) 1229 1240 ··· 1237 1248 dependencies: 1238 1249 ms: 2.1.3 1239 1250 1240 - electron-to-chromium@1.5.206: {} 1251 + electron-to-chromium@1.5.207: {} 1241 1252 1242 1253 entities@6.0.1: {} 1243 1254
+10 -17
frontend/public/oauth/client-metadata.json
··· 1 1 { 2 - "client_id": "https://clippr.social/oauth/client-metadata.json", 3 - "client_name": "Clippr", 4 - "client_uri": "https://clippr.social", 5 - "redirect_uris": [ 6 - "https://clippr.social/" 7 - ], 8 - "scope": "atproto blob?accept=image/* repo:social.clippr.actor.profile repo:social.clippr.feed.clip repo:social.clippr.feed.tag", 9 - "grant_types": [ 10 - "authorization_code", 11 - "refresh_token" 12 - ], 13 - "response_types": [ 14 - "code" 15 - ], 16 - "token_endpoint_auth_method": "none", 17 - "application_type": "web", 18 - "dpop_bound_access_tokens": true 2 + "client_id": "https://clippr.social/oauth/client-metadata.json", 3 + "client_name": "Clippr", 4 + "client_uri": "https://clippr.social", 5 + "redirect_uris": ["https://clippr.social/"], 6 + "scope": "atproto blob?accept=image/* repo:social.clippr.actor.profile repo:social.clippr.feed.clip repo:social.clippr.feed.tag", 7 + "grant_types": ["authorization_code", "refresh_token"], 8 + "response_types": ["code"], 9 + "token_endpoint_auth_method": "none", 10 + "application_type": "web", 11 + "dpop_bound_access_tokens": true 19 12 }
+3 -4
frontend/src/components/loginForm.tsx
··· 94 94 const retrieveSession = async (): Promise<void> => { 95 95 const init = async (): Promise<Session | undefined> => { 96 96 const params = new URLSearchParams(location.hash.slice(1)); 97 - console.log("Params", params); 98 97 99 98 if (params.has("state") && (params.has("code") || params.has("error"))) { 100 99 history.replaceState(null, "", location.pathname + location.search); 101 100 102 101 const session = await finalizeAuthorization(params); 103 - console.log("Authorization finalization", session); 102 + console.log("Finalizing authorization...", session); 104 103 const agent = new OAuthUserAgent(session); 105 104 console.log(await agent.getSession()); 106 105 const did = session.info.sub; ··· 112 111 113 112 if (currentUser) { 114 113 try { 115 - console.log("Retrieving session"); 114 + console.log("Retrieving session..."); 116 115 return await getSession(currentUser as Did); 117 116 } catch (err) { 118 117 deleteStoredSession(currentUser as Did); ··· 126 125 const session = await init().catch(() => {}); 127 126 128 127 if (session) { 129 - console.log("Retrieved session", session); 128 + console.log("Retrieved session!", session); 130 129 agent = new OAuthUserAgent(session); 131 130 setLoginState(true); 132 131 }
+53
frontend/src/components/profile.tsx
··· 1 + /* 2 + * clippr: a social bookmarking service for the AT Protocol 3 + * Copyright (c) 2025 clippr contributors. 4 + * SPDX-License-Identifier: AGPL-3.0-only 5 + */ 6 + 7 + import { createResource, createSignal, Match, Show, Switch } from "solid-js"; 8 + import { agent } from "./loginForm.tsx"; 9 + import { ErrorResponse } from "../types.ts"; 10 + 11 + const Profile = () => { 12 + const fetchProfile = async (actor: any) => { 13 + const response: Response = await fetch( 14 + `${import.meta.env.VITE_CLIPPR_APPVIEW}/xrpc/social.clippr.actor.getProfile?actor=${actor}`, 15 + ); 16 + 17 + if (response.status !== 200) { 18 + if (response.status === 400) { 19 + const json: ErrorResponse = await response.json(); 20 + console.log(json); 21 + throw new Error(json.message); 22 + } else { 23 + throw new Error(response.statusText); 24 + } 25 + } 26 + 27 + return response.json(); 28 + }; 29 + 30 + const [actor, setActor] = createSignal(); 31 + setActor(agent.session.info.sub); 32 + 33 + const [profile] = createResource(actor, fetchProfile); 34 + 35 + return ( 36 + <div> 37 + <h2>profile</h2> 38 + <Show when={profile.loading}> 39 + <p>loading...</p> 40 + </Show> 41 + <Switch> 42 + <Match when={profile.error}> 43 + <p>error: {profile.error.message}</p> 44 + </Match> 45 + <Match when={profile()}> 46 + <p>profile: {JSON.stringify(profile())}</p> 47 + </Match> 48 + </Switch> 49 + </div> 50 + ); 51 + }; 52 + 53 + export { Profile };
+147
frontend/src/components/profileEditor.tsx
··· 1 + /* 2 + * clippr: a social bookmarking service for the AT Protocol 3 + * Copyright (c) 2025 clippr contributors. 4 + * SPDX-License-Identifier: AGPL-3.0-only 5 + */ 6 + 7 + import { createSignal } from "solid-js"; 8 + import { Client, ClientResponse } from "@atcute/client"; 9 + import { remove } from "@mary/exif-rm"; 10 + import { agent } from "./loginForm.tsx"; 11 + 12 + const ProfileEditor = () => { 13 + const [notice, setNotice] = createSignal(""); 14 + let formRef: HTMLFormElement = document.createElement("form"); 15 + 16 + const uploadBlob = async () => { 17 + setNotice(""); 18 + console.log("starting upload..."); 19 + let blob: Blob; 20 + 21 + const file = (document.getElementById("avatar") as HTMLInputElement) 22 + ?.files?.[0]; 23 + if (!file) return; 24 + console.log(file); 25 + 26 + if (!file.type.startsWith("image/")) { 27 + setNotice("error: avatar must be an image"); 28 + console.log("error: avatar must be an image"); 29 + return; 30 + } 31 + 32 + if (file.size > 1000000) { 33 + setNotice("error: avatar must be less than 1MB"); 34 + console.log("error: avatar must be less than 1MB"); 35 + return; 36 + } 37 + 38 + blob = file; 39 + console.log("removing exif data..."); 40 + const exifRemoved = remove(new Uint8Array(await blob.arrayBuffer())); 41 + if (exifRemoved !== null) { 42 + // @ts-ignore 43 + blob = new Blob([exifRemoved], { type: blob.type }); 44 + } 45 + 46 + console.log(blob); 47 + 48 + const rpc = new Client({ handler: agent! }); 49 + setNotice("uploading avatar..."); 50 + // @ts-ignore 51 + const uploadRes: ClientResponse<any, any> = await rpc.post( "com.atproto.repo.uploadBlob", 52 + { 53 + input: blob, 54 + }, 55 + ); 56 + (document.getElementById("avatar") as HTMLInputElement).value = ""; 57 + if (!uploadRes.ok) { 58 + setNotice(uploadRes.data.error); 59 + return; 60 + } 61 + setNotice("avatar has been uploaded!"); 62 + 63 + console.log(uploadRes.data); 64 + localStorage.setItem("avatar", JSON.stringify(uploadRes.data.blob)); 65 + }; 66 + 67 + const applyProfileChanges = async (formData: FormData) => { 68 + const rpc = new Client({ handler: agent! }); 69 + 70 + const avatar = localStorage.getItem("avatar"); 71 + 72 + if (avatar === null) { 73 + setNotice("error: avatar is missing"); 74 + return; 75 + } 76 + 77 + if (formData.get("displayName") === null) { 78 + setNotice("error: display name is missing"); 79 + } 80 + 81 + try { 82 + // @ts-ignore 83 + const res = await rpc.post("com.atproto.repo.putRecord", { 84 + input: { 85 + repo: agent!.sub, 86 + collection: "social.clippr.actor.profile", 87 + rkey: "self", 88 + record: { 89 + $type: "social.clippr.actor.profile", 90 + avatar: JSON.parse(avatar), 91 + displayName: formData.get("displayName"), 92 + description: formData.get("description") || "", 93 + createdAt: new Date().toISOString(), 94 + }, 95 + }, 96 + }); 97 + 98 + if (!res.ok) { 99 + setNotice(res.data.error); 100 + return; 101 + } 102 + } catch (e: any) { 103 + setNotice(e.message); 104 + return; 105 + } 106 + 107 + setNotice("profile changed!"); 108 + setTimeout(() => { 109 + window.location.reload(); 110 + }, 1000); 111 + }; 112 + 113 + return ( 114 + <div> 115 + <h2>profile editor</h2> 116 + <form ref={formRef}> 117 + <label for="avatar">avatar</label> 118 + <input 119 + type="file" 120 + name="avatar" 121 + id="avatar" 122 + accept=".jpg,.jpeg,.png,image/jpeg,image/png" 123 + onChange={() => uploadBlob()} 124 + /> 125 + <label for="displayName">display name</label> 126 + <input type="text" name="displayName" id="displayName" /> 127 + <label for="description">bio</label> 128 + <textarea name="description" id="description"></textarea> 129 + <button 130 + type="submit" 131 + onClick={(e) => { 132 + e.preventDefault(); 133 + // @ts-ignore 134 + applyProfileChanges(new FormData(formRef)).then(); 135 + }} 136 + > 137 + apply changes 138 + </button> 139 + </form> 140 + <p id="submitDetails" hidden={notice() === null}> 141 + {notice()} 142 + </p> 143 + </div> 144 + ); 145 + }; 146 + 147 + export { ProfileEditor };
+10
frontend/src/types.ts
··· 1 + /* 2 + * clippr: a social bookmarking service for the AT Protocol 3 + * Copyright (c) 2025 clippr contributors. 4 + * SPDX-License-Identifier: AGPL-3.0-only 5 + */ 6 + 7 + export type ErrorResponse = { 8 + error: string; 9 + message: string; 10 + };
+4
frontend/src/views/home.tsx
··· 5 5 */ 6 6 7 7 import { killSession, loginState } from "../components/loginForm.tsx"; 8 + import { ProfileEditor } from "../components/profileEditor.tsx"; 9 + import { Profile } from "../components/profile.tsx"; 8 10 9 11 const Home = () => { 10 12 if (!loginState()) { ··· 17 19 <div id="main-content" class="centered"> 18 20 <h2>home</h2> 19 21 <p>OAuth!</p> 22 + <Profile /> 23 + <ProfileEditor /> 20 24 <button type="button" onClick={killSession}> 21 25 Log out 22 26 </button>
+7
frontend/src/vite-env.d.ts
··· 6 6 7 7 interface ImportMetaEnv { 8 8 readonly VITE_DEV_SERVER_PORT?: string; 9 + 10 + /// OAuth environment variables 9 11 readonly VITE_CLIENT_URI: string; 10 12 readonly VITE_OAUTH_CLIENT_ID: string; 11 13 readonly VITE_OAUTH_REDIRECT_URI: string; 12 14 readonly VITE_OAUTH_SCOPE: string; 15 + 16 + /// Clippr-related environment variables 17 + /// 18 + /// A URL for where the clippr appview is hosted 19 + readonly VITE_CLIPPR_APPVIEW: string; 13 20 } 14 21 15 22 interface ImportMeta {
+2
frontend/vite.config.ts
··· 25 25 if (command === "build") { 26 26 process.env.VITE_OAUTH_CLIENT_ID = metadata.client_id; 27 27 process.env.VITE_OAUTH_REDIRECT_URI = metadata.redirect_uris[0]; 28 + process.env.VITE_CLIPPR_APPVIEW = "https://api.clippr.social"; 28 29 } else { 29 30 const redirectUri = ((): string => { 30 31 const url = new URL(metadata.redirect_uris[0]); ··· 39 40 process.env.VITE_DEV_SERVER_PORT = "" + SERVER_PORT; 40 41 process.env.VITE_OAUTH_CLIENT_ID = clientId; 41 42 process.env.VITE_OAUTH_REDIRECT_URI = redirectUri; 43 + process.env.VITE_CLIPPR_APPVIEW = `http://localhost:9090`; 42 44 } 43 45 44 46 process.env.VITE_CLIENT_URI = metadata.client_uri;