atproto blogging
1use crate::Route;
2use crate::auth::AuthState;
3use crate::components::record_editor::EditableRecordContent;
4use crate::components::record_view::{
5 CodeView, PrettyRecordView, RecordViewLayout, SchemaView, ViewMode,
6};
7use crate::fetch::Fetcher;
8use dioxus::prelude::*;
9use jacquard::common::to_data;
10use jacquard::smol_str::ToSmolStr;
11use jacquard::{
12 client::AgentSessionExt,
13 common::IntoStatic,
14 identity::lexicon_resolver::LexiconSchemaResolver,
15 types::{aturi::AtUri, ident::AtIdentifier, string::Nsid},
16};
17use jacquard_lexicon::lexicon::LexiconDoc;
18
19#[component]
20pub fn RecordIndex() -> Element {
21 let navigator = use_navigator();
22 let mut uri_input = use_signal(|| String::new());
23 let handle_uri_submit = move || {
24 let input_uri = uri_input.read().clone();
25 if !input_uri.is_empty() {
26 if let Ok(parsed) = AtUri::new(&input_uri) {
27 let link = format!("{}/record/{}", crate::env::WEAVER_APP_HOST, parsed);
28 navigator.push(link);
29 }
30 }
31 };
32 rsx! {
33 document::Stylesheet { href: asset!("/assets/styling/record-view.css") }
34 div {
35 class: "record-view-container",
36 div { class: "record-header",
37 h1 { "Record View" }
38 div { class: "uri-input-section",
39 input {
40 r#type: "text",
41 class: "uri-input",
42 placeholder: "at://did:plc:.../collection/rkey",
43 value: "{uri_input}",
44 oninput: move |evt| uri_input.set(evt.value()),
45 onkeydown: move |evt| {
46 if evt.key() == Key::Enter {
47 handle_uri_submit();
48 }
49 },
50 }
51 }
52 }
53
54 Outlet::<Route> {}
55 }
56 }
57}
58
59#[component]
60pub fn RecordPage(uri: ReadSignal<Vec<String>>) -> Element {
61 rsx! {
62 {std::iter::once(rsx! {RecordView {uri}})}
63 }
64}
65
66#[component]
67pub fn RecordView(uri: ReadSignal<Vec<String>>) -> Element {
68 let fetcher = use_context::<Fetcher>();
69 info!("Uri:{:?}", uri().join("/"));
70 let at_uri = AtUri::new_owned(&*uri.read().join("/"));
71 if at_uri.is_err() {
72 return rsx! {};
73 }
74 let uri = use_signal(move || AtUri::new_owned(&*uri.read().join("/")).unwrap());
75 let mut view_mode = use_signal(|| ViewMode::Pretty);
76 let mut edit_mode = use_signal(|| false);
77
78 let client = fetcher.get_client();
79 let record_resource = use_resource(move || {
80 let client = client.clone();
81 async move { client.fetch_record_slingshot(&*uri.read()).await }
82 });
83
84 // Fetch schema for the record
85 let schema_resource = use_resource(move || {
86 let fetcher = fetcher.clone();
87 async move {
88 let record_read = record_resource.read();
89 let record = record_read.as_ref()?.as_ref().ok()?;
90
91 let validator = jacquard_lexicon::validation::SchemaValidator::global();
92 let main_type = record.value.type_discriminator();
93 let mut main_schema = None;
94 let mut resolved = std::collections::HashSet::new();
95
96 // Helper to recursively resolve a schema and its refs
97 fn resolve_schema_with_refs<'a>(
98 fetcher: &'a Fetcher,
99 type_str: &'a str,
100 validator: &'a jacquard_lexicon::validation::SchemaValidator,
101 resolved: &'a mut std::collections::HashSet<String>,
102 ) -> std::pin::Pin<
103 Box<dyn std::future::Future<Output = Option<LexiconDoc<'static>>> + 'a>,
104 > {
105 Box::pin(async move {
106 if resolved.contains(type_str) {
107 return None;
108 }
109 resolved.insert(type_str.to_string());
110
111 let mut split = type_str.split('#');
112 let nsid_str = split.next().unwrap_or_default();
113 let nsid = Nsid::new(nsid_str).ok()?;
114
115 let schema = fetcher.resolve_lexicon_schema(&nsid).await.ok()?;
116
117 // Register by base NSID only (validator handles fragment lookup)
118 validator
119 .registry()
120 .insert(nsid_str.to_smolstr(), schema.doc.clone());
121
122 // Find refs in the schema and resolve them
123 if let Ok(schema_data) = to_data(&schema.doc) {
124 for ref_val in schema_data.query("...ref").values() {
125 if let Some(ref_str) = ref_val.as_str() {
126 if ref_str.contains('.') {
127 resolve_schema_with_refs(fetcher, ref_str, validator, resolved)
128 .await;
129 }
130 }
131 }
132 for ref_val in schema_data.query("...refs").values() {
133 if let Some(ref_str) = ref_val.as_str() {
134 if ref_str.contains('.') {
135 resolve_schema_with_refs(fetcher, ref_str, validator, resolved)
136 .await;
137 }
138 }
139 }
140 }
141
142 Some(schema.doc)
143 })
144 }
145
146 // Find and resolve all schemas (including main and nested)
147 for type_val in record.value.query("...$type").values() {
148 if let Some(type_str) = type_val.as_str() {
149 // Skip non-NSID types (like "blob")
150 if !type_str.contains('.') {
151 continue;
152 }
153
154 if let Some(schema) =
155 resolve_schema_with_refs(&fetcher, type_str, &validator, &mut resolved)
156 .await
157 {
158 // Keep the main record schema
159 if Some(type_str) == main_type {
160 main_schema = Some(schema);
161 }
162 }
163 }
164 }
165
166 main_schema
167 }
168 });
169
170 let schema_signal = use_memo(move || schema_resource.read().clone().flatten());
171
172 // Check ownership for edit access
173 let auth_state = use_context::<Signal<AuthState>>();
174 let is_owner = use_memo(move || {
175 let auth = auth_state();
176 if !auth.is_authenticated() {
177 return false;
178 }
179
180 // authority() returns &AtIdentifier which can be Did or Handle
181 match &*uri.read().authority() {
182 AtIdentifier::Did(record_did) => auth.did.as_ref() == Some(record_did),
183 AtIdentifier::Handle(_) => {
184 // Can't easily check ownership for handles without async resolution
185 false
186 }
187 }
188 });
189 if let Some(Ok(record)) = &*record_resource.read() {
190 let record_value = record.value.clone().into_static();
191 let record = record.clone();
192
193 rsx! {
194 Fragment { key: "{uri()}",
195 RecordViewLayout {
196 uri: uri().clone(),
197 cid: record.cid.clone(),
198 schema: schema_signal,
199 record_value: record_value.clone(),
200 if edit_mode() {
201
202 EditableRecordContent {
203 record_value: record_value,
204 uri: uri,
205 view_mode: view_mode,
206 edit_mode: edit_mode,
207 record_resource: record_resource,
208 schema: schema_signal,
209 }
210 } else {
211 div {
212 class: "tab-bar",
213 button {
214 class: if view_mode() == ViewMode::Pretty { "tab-button active" } else { "tab-button" },
215 onclick: move |_| view_mode.set(ViewMode::Pretty),
216 "View"
217 }
218 button {
219 class: if view_mode() == ViewMode::Json { "tab-button active" } else { "tab-button" },
220 onclick: move |_| view_mode.set(ViewMode::Json),
221 "JSON"
222 }
223 button {
224 class: if view_mode() == ViewMode::Schema { "tab-button active" } else { "tab-button" },
225 onclick: move |_| view_mode.set(ViewMode::Schema),
226 "Schema"
227 }
228 if is_owner() {
229 button {
230 class: "tab-button edit-button",
231 onclick: move |_| edit_mode.set(true),
232 "Edit"
233 }
234 }
235 }
236 div {
237 class: "tab-content",
238 match view_mode() {
239 ViewMode::Pretty => rsx! {
240 PrettyRecordView { record: record_value, uri: uri().clone(), schema: schema_signal }
241 },
242 ViewMode::Json => {
243 let json = use_memo(use_reactive!(|record| serde_json::to_string_pretty(
244 &record.value
245 )
246 .unwrap_or_default()));
247 rsx! {
248 CodeView {
249 code: json,
250 lang: Some("json".to_string()),
251 }
252 }
253 },
254 ViewMode::Schema => rsx! {
255 SchemaView { schema: schema_signal }
256 },
257 }
258 }
259 }
260 }
261 }
262 }
263 } else {
264 rsx! {}
265 }
266}