Image sharing backed by ATProto
atproto images gleam

in-app viewing

Signed-off-by: Naomi Roberts <mia@naomieow.xyz>

lesbian.skin 041a18e7 47331808

verified
+371 -37
+5 -3
README.md
··· 10 10 servers!). 11 11 12 12 ## Roadmap 13 - - [ ] In-app viewing 14 - - [ ] Image titles/descriptions 15 - - [x] Formal Lexicon definition(s) 13 + - [x] MVP: In-app viewing 14 + - [ ] MVP: Image titles/descriptions 15 + - [x] MVP: Formal Lexicon definition(s) 16 + - [ ] MVP: Embeds 17 + - [ ] MVP: Styling 16 18 - [ ] Pre-upload previews 17 19 - [ ] Image collections 18 20 - [ ] Bulk uploading
+15 -1
bun.lock
··· 5 5 "": { 6 6 "dependencies": { 7 7 "@atproto/api": "^0.18.4", 8 + "@atproto/identity": "^0.4.10", 9 + "@atproto/lex-data": "^0.0.4", 8 10 "@atproto/oauth-client-browser": "^0.3.36", 9 11 }, 10 12 }, ··· 28 30 29 31 "@atproto/common-web": ["@atproto/common-web@0.4.6", "", { "dependencies": { "@atproto/lex-data": "0.0.2", "@atproto/lex-json": "0.0.2", "zod": "^3.23.8" } }, "sha512-+2mG/1oBcB/ZmYIU1ltrFMIiuy9aByKAkb2Fos/0eTdczcLBaH17k0KoxMGvhfsujN2r62XlanOAMzysa7lv1g=="], 30 32 33 + "@atproto/crypto": ["@atproto/crypto@0.4.5", "", { "dependencies": { "@noble/curves": "^1.7.0", "@noble/hashes": "^1.6.1", "uint8arrays": "3.0.0" } }, "sha512-n40aKkMoCatP0u9Yvhrdk6fXyOHFDDbkdm4h4HCyWW+KlKl8iXfD5iV+ECq+w5BM+QH25aIpt3/j6EUNerhLxw=="], 34 + 31 35 "@atproto/did": ["@atproto/did@0.2.3", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-VI8JJkSizvM2cHYJa37WlbzeCm5tWpojyc1/Zy8q8OOjyoy6X4S4BEfoP941oJcpxpMTObamibQIXQDo7tnIjg=="], 36 + 37 + "@atproto/identity": ["@atproto/identity@0.4.10", "", { "dependencies": { "@atproto/common-web": "^0.4.4", "@atproto/crypto": "^0.4.4" } }, "sha512-nQbzDLXOhM8p/wo0cTh5DfMSOSHzj6jizpodX37LJ4S1TZzumSxAjHEZa5Rev3JaoD5uSWMVE0MmKEGWkPPvfQ=="], 32 38 33 39 "@atproto/jwk": ["@atproto/jwk@0.6.0", "", { "dependencies": { "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-bDoJPvt7TrQVi/rBfBrSSpGykhtIriKxeYCYQTiPRKFfyRhbgpElF0wPXADjIswnbzZdOwbY63az4E/CFVT3Tw=="], 34 40 ··· 36 42 37 43 "@atproto/jwk-webcrypto": ["@atproto/jwk-webcrypto@0.2.0", "", { "dependencies": { "@atproto/jwk": "0.6.0", "@atproto/jwk-jose": "0.1.11", "zod": "^3.23.8" } }, "sha512-UmgRrrEAkWvxwhlwe30UmDOdTEFidlIzBC7C3cCbeJMcBN1x8B3KH+crXrsTqfWQBG58mXgt8wgSK3Kxs2LhFg=="], 38 44 39 - "@atproto/lex-data": ["@atproto/lex-data@0.0.2", "", { "dependencies": { "@atproto/syntax": "0.4.2", "multiformats": "^9.9.0", "tslib": "^2.8.1", "uint8arrays": "3.0.0", "unicode-segmenter": "^0.14.0" } }, "sha512-euV2rDGi+coH8qvZOU+ieUOEbwPwff9ca6IiXIqjZJ76AvlIpj7vtAyIRCxHUW2BoU6h9yqyJgn9MKD2a7oIwg=="], 45 + "@atproto/lex-data": ["@atproto/lex-data@0.0.4", "", { "dependencies": { "@atproto/syntax": "0.4.2", "multiformats": "^9.9.0", "tslib": "^2.8.1", "uint8arrays": "3.0.0", "unicode-segmenter": "^0.14.0" } }, "sha512-ziWY8R4wJ0NGDSlt+gzPxMsIh1DXFeLt+lsBoVc6wPaJamCxngwWAxONuQ3p9oRE6zR/gXsCOdtZAH5yjWW5ag=="], 40 46 41 47 "@atproto/lex-json": ["@atproto/lex-json@0.0.2", "", { "dependencies": { "@atproto/lex-data": "0.0.2", "tslib": "^2.8.1" } }, "sha512-Pd72lO+l2rhOTutnf11omh9ZkoB/elbzE3HSmn2wuZlyH1mRhTYvoH8BOGokWQwbZkCE8LL3nOqMT3gHCD2l7g=="], 42 48 ··· 52 58 53 59 "@atproto/xrpc": ["@atproto/xrpc@0.7.6", "", { "dependencies": { "@atproto/lexicon": "^0.5.2", "zod": "^3.23.8" } }, "sha512-RvCf4j0JnKYWuz3QzsYCntJi3VuiAAybQsMIUw2wLWcHhchO9F7UaBZINLL2z0qc/cYWPv5NSwcVydMseoCZLA=="], 54 60 61 + "@noble/curves": ["@noble/curves@1.9.7", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw=="], 62 + 63 + "@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], 64 + 55 65 "await-lock": ["await-lock@2.2.2", "", {}, "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw=="], 56 66 57 67 "core-js": ["core-js@3.47.0", "", {}, "sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg=="], ··· 73 83 "unicode-segmenter": ["unicode-segmenter@0.14.1", "", {}, "sha512-yHedxlEpUyD+u1UE8qAuCMXVdMLn7yUdlmd8WN7FGmO1ICnpE7LJfnmuXBB+T0zkie3qHsy8fSucqceI/MylOg=="], 74 84 75 85 "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], 86 + 87 + "@atproto/common-web/@atproto/lex-data": ["@atproto/lex-data@0.0.2", "", { "dependencies": { "@atproto/syntax": "0.4.2", "multiformats": "^9.9.0", "tslib": "^2.8.1", "uint8arrays": "3.0.0", "unicode-segmenter": "^0.14.0" } }, "sha512-euV2rDGi+coH8qvZOU+ieUOEbwPwff9ca6IiXIqjZJ76AvlIpj7vtAyIRCxHUW2BoU6h9yqyJgn9MKD2a7oIwg=="], 88 + 89 + "@atproto/lex-json/@atproto/lex-data": ["@atproto/lex-data@0.0.2", "", { "dependencies": { "@atproto/syntax": "0.4.2", "multiformats": "^9.9.0", "tslib": "^2.8.1", "uint8arrays": "3.0.0", "unicode-segmenter": "^0.14.0" } }, "sha512-euV2rDGi+coH8qvZOU+ieUOEbwPwff9ca6IiXIqjZJ76AvlIpj7vtAyIRCxHUW2BoU6h9yqyJgn9MKD2a7oIwg=="], 76 90 } 77 91 }
+9 -1
gleam.toml
··· 21 21 gleam_javascript = ">= 1.0.0 and < 2.0.0" 22 22 gleam_http = ">= 4.3.0 and < 5.0.0" 23 23 modem = ">= 2.1.2 and < 3.0.0" 24 + rsvp = ">= 1.1.3 and < 2.0.0" 24 25 25 26 [dev-dependencies] 26 27 gleeunit = ">= 1.0.0 and < 2.0.0" 27 28 28 29 [tools.lustre.html] 29 30 scripts = [ 30 - { type = "importmap", content = "{ \"imports\": { \"@atproto/oauth-client-browser\": \"https://esm.sh/@atproto/oauth-client-browser@0.3.36\", \"@atproto/api\": \"https://esm.sh/@atproto/api@0.18.4\" } }" } 31 + { type = "importmap", content = """{ 32 + \"imports\": { 33 + \"@atproto/oauth-client-browser\": \"https://esm.sh/@atproto/oauth-client-browser@0.3.36\", 34 + \"@atproto/api\": \"https://esm.sh/@atproto/api@0.18.4\", 35 + \"@atproto/identity\": \"https://esm.sh/@atproto/identity@0.4.10\", 36 + \"@atproto/lex-data\": \"https://esm.sh/@atproto/lex-data@0.0.4\" 37 + } 38 + }""" } 31 39 ]
+1
lexicons/image.json
··· 4 4 "defs": { 5 5 "main": { 6 6 "type": "record", 7 + "key": "tid", 7 8 "description": "A shared image", 8 9 "record": { 9 10 "type": "object",
+3
manifest.toml
··· 13 13 { name = "gleam_community_colour", version = "2.0.2", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "E34DD2C896AC3792151EDA939DA435FF3B69922F33415ED3C4406C932FBE9634" }, 14 14 { name = "gleam_crypto", version = "1.5.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "50774BAFFF1144E7872814C566C5D653D83A3EBF23ACC3156B757A1B6819086E" }, 15 15 { name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" }, 16 + { name = "gleam_fetch", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_http", "gleam_javascript", "gleam_stdlib"], otp_app = "gleam_fetch", source = "hex", outer_checksum = "2CBF9F2E1C71AEBBFB13A9D5720CD8DB4263EB02FE60C5A7A1C6E17B0151C20C" }, 16 17 { name = "gleam_http", version = "4.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "82EA6A717C842456188C190AFB372665EA56CE13D8559BF3B1DD9E40F619EE0C" }, 17 18 { name = "gleam_httpc", version = "5.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gleam_httpc", source = "hex", outer_checksum = "C545172618D07811494E97AAA4A0FB34DA6F6D0061FDC8041C2F8E3BE2B2E48F" }, 18 19 { name = "gleam_javascript", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_javascript", source = "hex", outer_checksum = "EF6C77A506F026C6FB37941889477CD5E4234FCD4337FF0E9384E297CB8F97EB" }, ··· 38 39 { name = "modem", version = "2.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib", "lustre"], otp_app = "modem", source = "hex", outer_checksum = "3F9682EBCBF4D26045F1038A7507E8C7967E49D43F9CA6BA68EF0C971B195A7F" }, 39 40 { name = "platform", version = "1.0.0", build_tools = ["gleam"], requirements = [], otp_app = "platform", source = "hex", outer_checksum = "8339420A95AD89AAC0F82F4C3DB8DD401041742D6C3F46132A8739F6AEB75391" }, 40 41 { name = "polly", version = "2.1.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib", "simplifile"], otp_app = "polly", source = "hex", outer_checksum = "1BA4D0ACE9BCF52AEA6AD9DE020FD8220CCA399A379E50A1775FC5C1204FCF56" }, 42 + { name = "rsvp", version = "1.1.3", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_fetch", "gleam_http", "gleam_httpc", "gleam_javascript", "gleam_json", "gleam_stdlib", "lustre"], otp_app = "rsvp", source = "hex", outer_checksum = "4A582C9C49B4EC3197631E78FDB4D0A8703F14043EC12EAAC608E7B9347C2211" }, 41 43 { name = "simplifile", version = "2.3.1", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "957E0E5B75927659F1D2A1B7B75D7B9BA96FAA8D0C53EA71C4AD9CD0C6B848F6" }, 42 44 { name = "snag", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "snag", source = "hex", outer_checksum = "274F41D6C3ECF99F7686FDCE54183333E41D2C1CA5A3A673F9A8B2C7A4401077" }, 43 45 { name = "telemetry", version = "1.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "7015FC8919DBE63764F4B4B87A95B7C0996BD539E0D499BE6EC9D7F3875B79E6" }, ··· 54 56 lustre = { version = ">= 5.4.0 and < 6.0.0" } 55 57 lustre_dev_tools = { version = ">= 2.3.1 and < 3.0.0" } 56 58 modem = { version = ">= 2.1.2 and < 3.0.0" } 59 + rsvp = { version = ">= 1.1.3 and < 2.0.0" }
+2
package.json
··· 1 1 { 2 2 "dependencies": { 3 3 "@atproto/api": "^0.18.4", 4 + "@atproto/identity": "^0.4.10", 5 + "@atproto/lex-data": "^0.0.4", 4 6 "@atproto/oauth-client-browser": "^0.3.36" 5 7 } 6 8 }
+5 -5
src/plonk.gleam
··· 67 67 let #(model, effect) = upload.init(Nil) 68 68 #(UploadPage(model), effect.map(effect, UploadPageMsg)) 69 69 } 70 - Ok([handle, cid]) -> { 71 - let #(model, effect) = image_view.init(handle:, cid:) 70 + Ok([handle, id]) -> { 71 + let #(model, effect) = image_view.init(handle:, id:) 72 72 #(ImageViewPage(model), effect.map(effect, ImageViewPageMsg)) 73 73 } 74 74 _ -> { ··· 109 109 let #(model, effect) = upload.init(Nil) 110 110 OnRouteChange(UploadPage(model), effect.map(effect, UploadPageMsg)) 111 111 } 112 - [handle, cid] -> { 113 - let #(model, effect) = image_view.init(handle:, cid:) 112 + [handle, id] -> { 113 + let #(model, effect) = image_view.init(handle:, id:) 114 114 OnRouteChange(ImageViewPage(model), effect.map(effect, ImageViewPageMsg)) 115 115 } 116 116 _ -> { ··· 160 160 case model.oac { 161 161 Some(oac) -> 162 162 case 163 - echo atp.do_login(oac, case login.username { 163 + atp.do_login(oac, case login.username { 164 164 "@" <> rest -> rest 165 165 _ -> login.username 166 166 })
+25 -5
src/plonk/atp.ffi.mjs
··· 1 1 import { BrowserOAuthClient } from "@atproto/oauth-client-browser"; 2 - import { Agent, BlobRef, ComAtprotoRepoUploadBlob, RichText } from "@atproto/api"; 2 + import { Agent, BlobRef, ComAtprotoRepoUploadBlob } from "@atproto/api"; 3 3 import { Result, Result$Ok, Result$Error } from "../gleam.mjs"; 4 4 5 - export function inspect(a) { 6 - console.log(a); 7 - return a; 5 + export function unauthenticatedAgent(uri) { 6 + return new Agent(uri); 7 + } 8 + 9 + /** 10 + * @param {Agent} agent 11 + * @param {String} did 12 + * @param {String} cid 13 + * @returns {Promise<Result<[Uint8Array, String], String>>} 14 + */ 15 + export async function getBlob(agent, did, cid) { 16 + let res; 17 + try { 18 + res = await agent.com.atproto.sync.getBlob({ 19 + did: did, 20 + cid: cid, 21 + }); 22 + if (!res.success) { 23 + return Result$Error(JSON.stringify(res)); 24 + } 25 + return Result$Ok([res.data, res.headers["content-type"]]); 26 + } catch (err) { 27 + return Result$Error(`${err}`); 28 + } 8 29 } 9 30 10 31 export function buildClientID() { ··· 83 104 * @returns {Result<Promise<OAuthSession>, String>} res 84 105 */ 85 106 export async function doLogin(oac, identifier) { 86 - console.log(oac); 87 107 try { 88 108 return Result$Ok( 89 109 await oac.signIn(identifier, {
+12
src/plonk/atp.gleam
··· 73 73 74 74 @external(javascript, "./atp.ffi.mjs", "blobRef") 75 75 pub fn get_blob_ref(output_schema _: BlobOutputSchema) -> BlobRef 76 + 77 + @external(javascript, "./atp.ffi.mjs", "unauthenticatedAgent") 78 + pub fn unauthenticated_agent(uri: String) -> Agent 79 + 80 + pub type UInt8Array 81 + 82 + @external(javascript, "./atp.ffi.mjs", "getBlob") 83 + pub fn get_blob( 84 + agent: Agent, 85 + did: String, 86 + cid: String, 87 + ) -> promise.Promise(Result(#(UInt8Array, String), String))
+27
src/plonk/atp/identity.ffi.mjs
··· 1 + import { DidResolver, HandleResolver } from "@atproto/identity"; 2 + import { Result, Result$Ok, Result$Error } from "../../gleam.mjs"; 3 + 4 + export function newDidResolver() { 5 + return new DidResolver({}); 6 + } 7 + 8 + /** 9 + * @param {DidResolver} resolver 10 + * @param {String} did 11 + * @returns {Promise<Result<DidDocument, String>>} 12 + */ 13 + export async function resolveDid(resolver, did) { 14 + const doc = await resolver.resolve(did); 15 + if (doc == null) { 16 + return Result$Error(`DID ${did} failed to resolve`); 17 + } 18 + return Result$Ok(doc); 19 + } 20 + 21 + /** 22 + * @param {DidDocument} doc 23 + * @returns {String} endpoint 24 + */ 25 + export function getServiceEndpoint(doc) { 26 + return doc.service.serviceEndpoint; 27 + }
+44
src/plonk/atp/identity.gleam
··· 1 + import gleam/dynamic/decode 2 + import gleam/javascript/promise 3 + import lustre/effect 4 + import rsvp 5 + 6 + pub type DidResolver 7 + 8 + pub type DidDocument 9 + 10 + pub type MiniDoc { 11 + MiniDoc(did: String, handle: String, pds: String, signing_key: String) 12 + } 13 + 14 + fn mini_doc_decoder() -> decode.Decoder(MiniDoc) { 15 + use did <- decode.field("did", decode.string) 16 + use handle <- decode.field("handle", decode.string) 17 + use pds <- decode.field("pds", decode.string) 18 + use signing_key <- decode.field("signing_key", decode.string) 19 + decode.success(MiniDoc(did:, handle:, pds:, signing_key:)) 20 + } 21 + 22 + @external(javascript, "./identity.ffi.mjs", "newDidResolver") 23 + pub fn new_did_resolver() -> DidResolver 24 + 25 + pub fn resolve_handle( 26 + handle: String, 27 + msg: fn(Result(MiniDoc, rsvp.Error)) -> msg, 28 + ) -> effect.Effect(msg) { 29 + let url = 30 + "https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=" 31 + <> handle 32 + let handler = rsvp.expect_json(mini_doc_decoder(), msg) 33 + 34 + rsvp.get(url, handler) 35 + } 36 + 37 + @external(javascript, "./identity.ffi.mjs", "resolveDid") 38 + pub fn resolve_did( 39 + resolver: DidResolver, 40 + did: String, 41 + ) -> promise.Promise(Result(DidDocument, String)) 42 + 43 + @external(javascript, "./identity.ffi.mjs", "getServiceEndpoint") 44 + pub fn get_service_endpoint(doc: DidDocument) -> String
+109 -4
src/plonk/pages/image_view.gleam
··· 1 + import gleam/javascript/promise 2 + import gleam/option 3 + import gleam/string 4 + import lustre/attribute 1 5 import lustre/effect 2 6 import lustre/element 3 7 import lustre/element/html 8 + import plonk/atp 9 + import plonk/atp/identity 10 + import plonk/record/image 11 + import rsvp 4 12 5 13 pub type Msg { 6 14 UserClickedDelete 7 15 UserClickedEdit 16 + ClientRequestedBlob( 17 + agent: atp.Agent, 18 + result: Result(#(atp.UInt8Array, String), String), 19 + ) 20 + ClientRequestedRecord( 21 + agent: atp.Agent, 22 + did: String, 23 + result: Result(image.Record, String), 24 + ) 25 + ClientResolvedHandle(result: Result(identity.MiniDoc, rsvp.Error)) 8 26 } 9 27 10 28 pub type Model { 11 - Model(editing: Bool, owner: Bool, handle: String, cid: String) 29 + Model( 30 + editing: Bool, 31 + busy: Bool, 32 + owner: Bool, 33 + handle: String, 34 + id: String, 35 + image: option.Option(image.Record), 36 + pds: option.Option(String), 37 + error: option.Option(String), 38 + file: option.Option(String), 39 + ) 12 40 } 13 41 14 42 pub fn init( 15 43 handle handle: String, 16 - cid cid: String, 44 + id id: String, 17 45 ) -> #(Model, effect.Effect(Msg)) { 18 46 // TODO: fire effect to check if owner 19 - #(Model(editing: False, owner: False, handle:, cid:), effect.none()) 47 + #( 48 + Model( 49 + editing: False, 50 + busy: True, 51 + owner: False, 52 + handle:, 53 + id:, 54 + image: option.None, 55 + pds: option.None, 56 + error: option.None, 57 + file: option.None, 58 + ), 59 + identity.resolve_handle(handle, ClientResolvedHandle), 60 + ) 20 61 } 21 62 22 63 pub fn update(model: Model, msg: Msg) -> #(Model, effect.Effect(Msg)) { 23 64 case msg { 24 65 UserClickedDelete -> #(model, effect.none()) 25 66 UserClickedEdit -> #(model, effect.none()) 67 + ClientRequestedRecord(agent:, did:, result:) -> 68 + case result { 69 + Ok(record) -> #( 70 + Model(..model, busy: True, image: option.Some(record)), 71 + effect.from(fn(dispatch) { 72 + atp.get_blob(agent, did, record.blob_cid) 73 + |> promise.map(ClientRequestedBlob(agent, _)) 74 + |> promise.tap(dispatch) 75 + Nil 76 + }), 77 + ) 78 + Error(_) -> #( 79 + Model(..model, busy: False, image: option.None), 80 + effect.none(), 81 + ) 82 + } 83 + ClientResolvedHandle(result:) -> 84 + case result { 85 + Error(error) -> #( 86 + Model(..model, busy: False, error: option.Some(string.inspect(error))), 87 + effect.none(), 88 + ) 89 + Ok(doc) -> { 90 + let agent = atp.unauthenticated_agent(doc.pds) 91 + #( 92 + Model(..model, pds: option.Some(doc.pds)), 93 + effect.from(fn(dispatch) { 94 + image.get_record(agent, model.handle, model.id) 95 + |> promise.map(ClientRequestedRecord(agent, doc.did, _)) 96 + |> promise.tap(dispatch) 97 + Nil 98 + }), 99 + ) 100 + } 101 + } 102 + ClientRequestedBlob(agent:, result:) -> { 103 + case result { 104 + Error(err) -> #( 105 + Model(..model, busy: False, error: option.Some(err)), 106 + effect.none(), 107 + ) 108 + Ok(#(file, file_type)) -> { 109 + #( 110 + Model( 111 + ..model, 112 + busy: False, 113 + file: option.Some(image.image_url(file, file_type)), 114 + ), 115 + effect.none(), 116 + ) 117 + } 118 + } 119 + } 26 120 } 27 121 } 28 122 29 123 pub fn view(model: Model) -> element.Element(Msg) { 30 - html.article([], [html.h2([], [html.text(model.handle <> "/" <> model.cid)])]) 124 + html.article([], [ 125 + case model.busy { 126 + False -> 127 + case model.file { 128 + option.Some(image) -> { 129 + html.img([attribute.src(image)]) 130 + } 131 + option.None -> html.h2([], [html.text("404 Not Found")]) 132 + } 133 + True -> html.h2([], [html.text("Busy")]) 134 + }, 135 + ]) 31 136 }
+1 -1
src/plonk/pages/upload.gleam
··· 17 17 pub type Msg { 18 18 UserSubmittedUploadForm(formdata: List(#(String, forms.FormDataType))) 19 19 ClientUploadedBlob(result: Result(atp.BlobOutputSchema, Nil)) 20 - ClientCreatedRecord(result: Result(image.OutputSchema, String)) 20 + ClientCreatedRecord(result: Result(image.Record, String)) 21 21 } 22 22 23 23 pub fn init(_flags: Nil) -> #(Model, effect.Effect(Msg)) {
+62 -3
src/plonk/record/image.ffi.mjs
··· 1 - import { ComAtprotoRepoCreateRecord } from "@atproto/api"; 1 + import { Agent, BlobRef, ComAtprotoRepoCreateRecord, ComAtprotoRepoGetRecord } from "@atproto/api"; 2 + import { Result, Result$Ok, Result$Error } from "../../gleam.mjs"; 3 + import { asCid } from "@atproto/lex-data"; 4 + 5 + /** 6 + * @param {Uint8Array} content 7 + * @param {String} fileType 8 + * @returns {String} blobUrl 9 + */ 10 + export function imgToURL(content, fileType) { 11 + return URL.createObjectURL(new Blob([content.buffer], { type: fileType })).toString(); 12 + } 13 + 14 + /** 15 + * @param {Agent} agent 16 + * @param {String} repo 17 + * @param {String} rkey 18 + * @returns {Promise<Result<ComAtprotoRepoGetRecord.OutputSchema, String>>} 19 + */ 20 + export async function getRecord(agent, repo, rkey) { 21 + let res; 22 + try { 23 + agent.com.atproto.repo.listRecords; 24 + res = await agent.com.atproto.repo.getRecord({ 25 + repo: repo, 26 + collection: "top.plonk.image", 27 + rkey: rkey, 28 + }); 29 + if (!res.success) { 30 + return Result$Error(JSON.stringify(res)); 31 + } 32 + return Result$Ok(res.data); 33 + } catch (e) { 34 + return Result$Error(`${e}`); 35 + } 36 + } 37 + 38 + /** 39 + * 40 + * @param {ComAtprotoRepoGetRecord.OutputSchema} os 41 + */ 42 + export function getBlobCid(os) { 43 + return asCid(os.value.content.original.ref).toString(); 44 + } 2 45 3 46 /** 4 47 * ··· 35 78 * @param {ComAtprotoRepoCreateRecord.OutputSchema} output 36 79 * @returns {String} uri 37 80 */ 38 - export function getUri(output) { 81 + export function getCrUri(output) { 39 82 return output.uri; 40 83 } 41 84 ··· 43 86 * @param {ComAtprotoRepoCreateRecord.OutputSchema} output 44 87 * @returns {String} cid 45 88 */ 46 - export function getCid(output) { 89 + export function getCrCid(output) { 90 + return output.cid; 91 + } 92 + 93 + /** 94 + * @param {ComAtprotoRepoGetRecord.OutputSchema} output 95 + * @returns {String} uri 96 + */ 97 + export function getGrUri(output) { 98 + return output.uri; 99 + } 100 + 101 + /** 102 + * @param {ComAtprotoRepoGetRecord.OutputSchema} output 103 + * @returns {String} cid 104 + */ 105 + export function getGrCid(output) { 47 106 return output.cid; 48 107 }
+51 -14
src/plonk/record/image.gleam
··· 2 2 import gleam/result 3 3 import plonk/atp 4 4 5 - pub type OutputSchema { 6 - OutputSchema(uri: String, cid: String) 5 + pub type Record { 6 + Record(uri: String, cid: String, blob_cid: String) 7 7 } 8 8 9 + type GetRecordOutputSchema 10 + 9 11 type CreateRecordOutputSchema 10 12 13 + pub fn get_record( 14 + agent: atp.Agent, 15 + repo: String, 16 + rkey: String, 17 + ) -> promise.Promise(Result(Record, String)) { 18 + do_get_record(agent, repo, rkey) 19 + |> promise.map(fn(r) { 20 + result.map(r, fn(output_schema) { 21 + let uri = get_gr_uri(output_schema) 22 + let cid = get_gr_cid(output_schema) 23 + let blob_cid = get_blob_cid(output_schema) 24 + Record(uri:, cid:, blob_cid:) 25 + }) 26 + }) 27 + } 28 + 29 + @external(javascript, "./image.ffi.mjs", "getRecord") 30 + fn do_get_record( 31 + agent: atp.Agent, 32 + repo: String, 33 + rkey: String, 34 + ) -> promise.Promise(Result(GetRecordOutputSchema, String)) 35 + 36 + @external(javascript, "./image.ffi.mjs", "getGrUri") 37 + fn get_gr_uri(output: GetRecordOutputSchema) -> String 38 + 39 + @external(javascript, "./image.ffi.mjs", "getGrCid") 40 + fn get_gr_cid(output: GetRecordOutputSchema) -> String 41 + 11 42 pub fn create_record( 12 43 agent: atp.Agent, 13 44 title: String, 14 45 description: String, 15 46 content: atp.BlobRef, 16 - ) -> promise.Promise(Result(OutputSchema, String)) { 47 + ) -> promise.Promise(Result(Record, String)) { 17 48 do_create_record(agent, title, description, content) 18 49 |> promise.map(fn(r) { 19 50 result.map(r, fn(output_schema) { 20 - let uri = get_uri(output_schema) 21 - let cid = get_cid(output_schema) 22 - OutputSchema(uri:, cid:) 51 + let uri = get_cr_uri(output_schema) 52 + let cid = get_cr_cid(output_schema) 53 + Record(uri:, cid:, blob_cid: "") 23 54 }) 24 55 }) 25 56 } 26 57 27 58 @external(javascript, "./image.ffi.mjs", "createRecord") 28 59 fn do_create_record( 29 - agent _: atp.Agent, 30 - title _: String, 31 - description _: String, 32 - content _: atp.BlobRef, 60 + agent: atp.Agent, 61 + title: String, 62 + description: String, 63 + content: atp.BlobRef, 33 64 ) -> promise.Promise(Result(CreateRecordOutputSchema, String)) 34 65 35 - @external(javascript, "./image.ffi.mjs", "getUri") 36 - fn get_uri(output: CreateRecordOutputSchema) -> String 66 + @external(javascript, "./image.ffi.mjs", "getCrUri") 67 + fn get_cr_uri(output: CreateRecordOutputSchema) -> String 37 68 38 - @external(javascript, "./image.ffi.mjs", "getCid") 39 - fn get_cid(output: CreateRecordOutputSchema) -> String 69 + @external(javascript, "./image.ffi.mjs", "getCrCid") 70 + fn get_cr_cid(output: CreateRecordOutputSchema) -> String 71 + 72 + @external(javascript, "./image.ffi.mjs", "getBlobCid") 73 + fn get_blob_cid(output: GetRecordOutputSchema) -> String 74 + 75 + @external(javascript, "./image.ffi.mjs", "imgToURL") 76 + pub fn image_url(content: atp.UInt8Array, file_type: String) -> String