Skip to main content

hashiverse_client_wasm/
hashiverse_client_wasm.rs

1use crate::wasm_client_storage::WasmClientStorage;
2use crate::wasm_key_locker::WasmKeyLockerManager;
3use crate::wasm_transport::WasmTransportFactory;
4use crate::wasm_try;
5use hashiverse_lib::client::args::Args;
6use hashiverse_lib::client::hashiverse_client::HashiverseClient;
7use hashiverse_lib::client::key_locker::key_locker::KeyLockerManager;
8use hashiverse_lib::tools::buckets::{BucketLocation, BucketType};
9use hashiverse_lib::tools::time::TimeMillis;
10use hashiverse_lib::tools::time_provider::time_provider::RealTimeProvider;
11use hashiverse_lib::tools::parallel_pow_generator::{ParallelPowGenerator, StubParallelPowGenerator};
12use hashiverse_lib::tools::runtime_services::RuntimeServices;
13use hashiverse_lib::tools::types::Id;
14use log::warn;
15use serde::{Deserialize, Serialize};
16use std::sync::Arc;
17use anyhow::anyhow;
18use tsify::Tsify;
19use wasm_bindgen::prelude::*;
20use wasm_bindgen::JsValue;
21use bytes::Bytes;
22use hashiverse_lib::protocol::posting::encoded_post::EncodedPostV1;
23
24#[wasm_bindgen]
25/// Provides a simplified dispatch interface for [HashiverseClient] to the browser.
26pub struct HashiverseClientWasm {
27    logged_in: bool,
28    hashiverse_client: HashiverseClient,
29}
30
31#[wasm_bindgen]
32impl HashiverseClientWasm {
33    async fn create_from_xxx(logged_in: bool, key_locker: Arc<dyn hashiverse_lib::client::key_locker::key_locker::KeyLocker>) -> anyhow::Result<Self> {
34        let time_provider: Arc<dyn hashiverse_lib::tools::time_provider::time_provider::TimeProvider> = Arc::new(RealTimeProvider::default());
35        let transport_factory: Arc<dyn hashiverse_lib::transport::transport::TransportFactory> = Arc::new(WasmTransportFactory::default());
36        let client_storage = WasmClientStorage::new().await?;
37        let pow_generator: Arc<dyn ParallelPowGenerator> = match crate::get_wasm_parallel_pow_generator() {
38            Some(g) => g as Arc<dyn ParallelPowGenerator>,
39            None => {
40                warn!("No native PoW generator available, falling back to StubParallelPowGenerator");
41                Arc::new(StubParallelPowGenerator::new())
42            }
43        };
44        let runtime_services = Arc::new(RuntimeServices { time_provider, transport_factory, pow_generator });
45        let hashiverse_client = HashiverseClient::new(runtime_services, client_storage, key_locker, Args::new()).await?;
46        Ok(Self { logged_in, hashiverse_client })
47    }
48
49    #[wasm_bindgen]
50    pub async fn create_from_keyphrase(key_phrase: String) -> Result<Self, JsValue> {
51        wasm_try!({
52            let logged_in = !key_phrase.is_empty();
53            let key_locker_manager = WasmKeyLockerManager::new().await?;
54            let key_locker = key_locker_manager.create(key_phrase).await?;
55            Self::create_from_xxx(logged_in, key_locker).await?
56        })
57    }
58
59    #[wasm_bindgen]
60    pub async fn create_from_stored_key(client_id_hex: String) -> Result<Self, JsValue> {
61        wasm_try!({
62            let key_locker_manager = WasmKeyLockerManager::new().await?;
63            let key_locker = key_locker_manager.switch(client_id_hex).await?;
64            Self::create_from_xxx(true, key_locker).await?
65        })
66    }
67
68    #[wasm_bindgen]
69    pub fn logged_in(&self) -> bool {
70        self.logged_in
71    }
72
73    #[wasm_bindgen]
74    pub async fn list_stored_key_ids_v1(&self) -> Result<Vec<String>, JsValue> {
75        wasm_try!({
76            let key_locker_manager = WasmKeyLockerManager::new().await?;
77            key_locker_manager.list().await?
78        })
79    }
80
81    #[wasm_bindgen]
82    pub async fn delete_stored_key_v1(&self, key_public: String) -> Result<(), JsValue> {
83        wasm_try!({
84            let key_locker_manager = WasmKeyLockerManager::new().await?;
85            key_locker_manager.delete(key_public).await?;
86        })
87    }
88
89    #[wasm_bindgen]
90    pub async fn delete_all_stored_keys_v1(&self) -> Result<(), JsValue> {
91        wasm_try!({
92            let key_locker_manager = WasmKeyLockerManager::new().await?;
93            key_locker_manager.reset().await?;
94        })
95    }
96
97    #[wasm_bindgen]
98    pub fn get_client_id(&self) -> String {
99        self.hashiverse_client.client_id().id_hex()
100    }
101
102    #[wasm_bindgen]
103    pub async fn client_storage_reset(&self) -> Result<(), JsValue> {
104        wasm_try!({
105            self.hashiverse_client.client_storage_reset().await?;
106        })
107    }
108
109    #[wasm_bindgen]
110    pub async fn post_v1(&self, post: &str) -> Result<Post, JsValue> {
111        wasm_try!({
112            let (commit_tokens, (encoded_post, raw_bytes)) = self.hashiverse_client.submit_post(post).await?;
113            let bucket_location = &commit_tokens[0].bucket_location;
114            let client_id = encoded_post.header.client_id()?;
115            let encoded_post_header_hex = hex::encode(EncodedPostV1::bytes_without_body(raw_bytes)?);
116            Post {
117                post_id: encoded_post.post_id.to_hex_str(),
118                time_millis: encoded_post.header.time_millis.0,
119                client_id: client_id.id_hex(),
120                bucket_location: bucket_location.to_html_attr(),
121                post: encoded_post.post,
122                encoded_post_header_hex,
123                healed: false,
124            }
125        })
126    }
127
128    fn meta_post_manager(&self) -> &hashiverse_lib::client::meta_post::meta_post_manager::MetaPostManager {
129        self.hashiverse_client.meta_post_manager()
130    }
131
132    pub async fn set_bio(&self, nickname: String, status: String, selfie: String, avatar: String) -> Result<(), JsValue> {
133        wasm_try!({
134            self.meta_post_manager().set_bio(nickname, status, selfie, avatar).await?;
135        })
136    }
137
138    #[wasm_bindgen]
139    pub async fn submit_feedback_v1(&self, bucket_location: String, post_id: String, feedback_type: u8) -> Result<(), JsValue> {
140        wasm_try!({
141            let bucket_location = BucketLocation::from_html_attr(&bucket_location)?;
142            let post_id = Id::from_hex_str(&post_id)?;
143            self.hashiverse_client.submit_feedback(bucket_location, post_id, feedback_type).await?;
144        })
145    }
146
147    #[wasm_bindgen]
148    /// Gets a specific post
149    pub async fn get_post_v1(&self, bucket_location: String, post_id: String) -> Result<Post, JsValue> {
150        wasm_try!({
151            let bucket_location = BucketLocation::from_html_attr(&bucket_location)?;
152            let post_id = Id::from_hex_str(&post_id)?;
153            let (bucket_location, post, raw_bytes, healed) = self.hashiverse_client.get_post(bucket_location, &post_id).await?;
154            let client_id = post.header.client_id()?;
155            let encoded_post_header_hex = hex::encode(EncodedPostV1::bytes_without_body(raw_bytes)?);
156            Post {
157                post_id: post.post_id.to_hex_str(),
158                time_millis: post.header.time_millis.0,
159                client_id: client_id.id_hex(),
160                bucket_location: bucket_location.to_html_attr(),
161                post: post.post,
162                encoded_post_header_hex,
163                healed,
164            }
165        })
166    }
167
168    #[wasm_bindgen]
169    /// Gets all the feedbacks for a specific post
170    ///
171    /// The resulting vector has 256 entries - one per feedback_type that have been mapped to the statistical number of clicks a feedback button has received.
172    pub async fn get_post_feedbacks_v1(&self, bucket_location: String, post_id: String) -> Result<Vec<u32>, JsValue> {
173        wasm_try!({
174            let bucket_location = BucketLocation::from_html_attr(&bucket_location)?;
175            let post_id = Id::from_hex_str(&post_id)?;
176            let post_feedbacks = self.hashiverse_client.get_post_feedbacks(bucket_location, post_id).await?;
177            post_feedbacks.iter().map(|&feedback| feedback.min(u32::MAX as u64) as u32).collect()
178        })
179    }
180
181    #[wasm_bindgen]
182    pub async fn get_bio(&self, id: String) -> Result<Bio, JsValue> {
183        wasm_try!({
184            let meta_post_public = self.meta_post_manager().get_meta_post_public(Id::from_hex_str(&id)?).await?;
185            match meta_post_public {
186                Some(meta_post_public) => Bio {
187                    client_id: id,
188                    nickname: meta_post_public.nickname.value.unwrap_or_default(),
189                    status: meta_post_public.status.value.unwrap_or_default(),
190                    selfie: meta_post_public.selfie.value.unwrap_or_default(),
191                    avatar: meta_post_public.avatar.value.unwrap_or_default(),
192                },
193                None => Bio {
194                    client_id: id,
195                    nickname: "".to_string(),
196                    status: "".to_string(),
197                    selfie: "".to_string(),
198                    avatar: "".to_string(),
199                },
200            }
201        })
202    }
203
204    #[wasm_bindgen]
205    pub async fn get_all_bios(&self) -> Result<Vec<Bio>, JsValue> {
206        wasm_try!({
207            let meta_post_publics = self.meta_post_manager().get_all_meta_post_publics().await?;
208            meta_post_publics.into_iter()
209                .map(|(client_id, meta_post_public)| Bio {
210                    client_id,
211                    nickname: meta_post_public.nickname.value.unwrap_or_default(),
212                    status: meta_post_public.status.value.unwrap_or_default(),
213                    selfie: meta_post_public.selfie.value.unwrap_or_default(),
214                    avatar: meta_post_public.avatar.value.unwrap_or_default(),
215                })
216                .collect()
217        })
218    }
219
220    #[wasm_bindgen]
221    pub async fn get_all_known_peers_v1(&self) -> Result<Vec<PeerInfoV1>, JsValue> {
222        wasm_try!({
223            self.hashiverse_client.get_all_known_peers().await
224                .into_iter()
225                .map(|peer| PeerInfoV1 {
226                    peer_id_hex: peer.id.to_hex_str(),
227                    address: peer.address,
228                    version: peer.version,
229                    timestamp_millis: peer.timestamp.0,
230                    pow_initial: peer.pow_initial.pow.0,
231                    pow_current_day: peer.pow_current_day.pow.0,
232                    pow_current_month: peer.pow_current_month.pow.0,
233                })
234                .collect::<Vec<_>>()
235        })
236    }
237
238    #[wasm_bindgen]
239    pub async fn get_peer_stats_v1(&self, peer_id_hex: String) -> Result<JsValue, JsValue> {
240        wasm_try!({
241            let peer_id = Id::from_hex_str(&peer_id_hex)?;
242            let doc = self.hashiverse_client.fetch_peer_stats(&peer_id).await?;
243            let serializer = serde_wasm_bindgen::Serializer::new().serialize_maps_as_objects(true);
244            doc.serialize(&serializer).map_err(|e| anyhow!("serde_wasm_bindgen error: {}", e))?
245        })
246    }
247
248    #[wasm_bindgen]
249    pub async fn get_active_pow_jobs_v1(&self) -> Result<Vec<PowJobStatusV1>, JsValue> {
250        wasm_try!({
251            self.hashiverse_client.active_pow_jobs()
252                .into_iter()
253                .map(|job| PowJobStatusV1 {
254                    label: job.label,
255                    pow_min: job.pow_min.0,
256                    best_pow_so_far: job.best_pow_so_far.0,
257                })
258                .collect::<Vec<_>>()
259        })
260    }
261
262    fn post_process_timeline_posts(&self, encoded_posts: Vec<(BucketLocation, EncodedPostV1, Bytes, bool)>, oldest_processed_time_millis: TimeMillis) -> anyhow::Result<SingleTimelineGetMoreV1Response> {
263        let response = SingleTimelineGetMoreV1Response {
264            oldest_processed_time_millis: if oldest_processed_time_millis == TimeMillis::MAX { None } else { Some(oldest_processed_time_millis.0) },
265            posts: encoded_posts
266                .into_iter()
267                .filter_map(|(bucket_location, post, raw_bytes, healed)| {
268                    let client_id = match post.header.client_id() {
269                        Ok(client_id) => client_id,
270                        Err(e) => {
271                            warn!("Skipping post with bad client_id in header: {}", e);
272                            return None;
273                        }
274                    };
275                    let encoded_post_header_hex = match EncodedPostV1::bytes_without_body(raw_bytes) {
276                        Ok(header_bytes) => hex::encode(header_bytes),
277                        Err(e) => {
278                            warn!("Skipping post with bad header bytes: {}", e);
279                            return None;
280                        }
281                    };
282                    Some(Post {
283                        post_id: post.post_id.to_hex_str(),
284                        time_millis: post.header.time_millis.0,
285                        client_id: client_id.id_hex(),
286                        bucket_location: bucket_location.to_html_attr(),
287                        post: post.post,
288                        encoded_post_header_hex,
289                        healed,
290                    })
291                })
292                .collect(),
293        };
294
295        Ok(response)
296    }
297
298
299    #[wasm_bindgen]
300    pub async fn single_timeline_reset(&self) -> Result<(), JsValue> {
301        wasm_try!({
302            self.hashiverse_client.single_timeline_reset().await?;
303        })
304    }
305
306    async fn single_timeline_get_more(&self, bucket_type: BucketType, base_id: &Id) -> anyhow::Result<SingleTimelineGetMoreV1Response> {
307        let (encoded_posts, oldest_processed_time_millis) = self.hashiverse_client.single_timeline_get_more(bucket_type, base_id).await?;
308        self.post_process_timeline_posts(encoded_posts, oldest_processed_time_millis)
309    }
310
311    #[wasm_bindgen]
312    pub async fn single_timeline_get_more_me_v1(&self) -> Result<SingleTimelineGetMoreV1Response, JsValue> {
313        wasm_try!({
314            let id = self.hashiverse_client.client_id().id;
315            self.single_timeline_get_more(BucketType::User, &id).await?
316        })
317    }
318
319    #[wasm_bindgen]
320    pub async fn single_timeline_get_more_me_mentioned_v1(&self) -> Result<SingleTimelineGetMoreV1Response, JsValue> {
321        wasm_try!({
322            let id = self.hashiverse_client.client_id().id;
323            self.single_timeline_get_more(BucketType::Mention, &id).await?
324        })
325    }
326
327    #[wasm_bindgen]
328    pub async fn single_timeline_get_more_hashtag_v1(&self, hashtag: String) -> Result<SingleTimelineGetMoreV1Response, JsValue> {
329        wasm_try!({
330            let id = Id::from_hashtag_str(&hashtag)?;
331            self.single_timeline_get_more(BucketType::Hashtag, &id).await?
332        })
333    }
334
335    #[wasm_bindgen]
336    pub async fn single_timeline_get_more_user_v1(&self, client_id_hex: String) -> Result<SingleTimelineGetMoreV1Response, JsValue> {
337        wasm_try!({
338            let id = Id::from_hex_str(&client_id_hex)?;
339            self.single_timeline_get_more(BucketType::User, &id).await?
340        })
341    }
342
343    #[wasm_bindgen]
344    pub async fn single_timeline_get_more_user_mentioned_v1(&self, client_id_hex: String) -> Result<SingleTimelineGetMoreV1Response, JsValue> {
345        wasm_try!({
346            let id = Id::from_hex_str(&client_id_hex)?;
347            self.single_timeline_get_more(BucketType::Mention, &id).await?
348        })
349    }
350
351    #[wasm_bindgen]
352    pub async fn single_timeline_get_more_reply_to_post_v1(&self, post_id: String) -> Result<SingleTimelineGetMoreV1Response, JsValue> {
353        wasm_try!({
354            let id = Id::from_hex_str(&post_id)?;
355            self.single_timeline_get_more(BucketType::ReplyToPost, &id).await?
356        })
357    }
358
359    #[wasm_bindgen]
360    pub async fn single_timeline_get_more_sequel_v1(&self, post_id: String) -> Result<SingleTimelineGetMoreV1Response, JsValue> {
361        wasm_try!({
362            let id = Id::from_hex_str(&post_id)?;
363            self.single_timeline_get_more(BucketType::Sequel, &id).await?
364        })
365    }
366
367    #[wasm_bindgen]
368    pub async fn multiple_timeline_reset(&self) -> Result<(), JsValue> {
369        wasm_try!({
370            self.hashiverse_client.multiple_timeline_reset().await?;
371        })
372    }
373
374    async fn multiple_timeline_get_more(&self, bucket_type: BucketType, base_ids: &Vec<Id>) -> anyhow::Result<SingleTimelineGetMoreV1Response> {
375        let (encoded_posts, oldest_processed_time_millis) = self.hashiverse_client.multiple_timeline_get_more(bucket_type, base_ids).await?;
376        self.post_process_timeline_posts(encoded_posts, oldest_processed_time_millis)
377    }
378
379    #[wasm_bindgen]
380    pub async fn multiple_timeline_get_more_followed_users(&self) -> Result<SingleTimelineGetMoreV1Response, JsValue> {
381        wasm_try!({
382            let ids = self.meta_post_manager().get_followed_client_ids().await?;
383            self.multiple_timeline_get_more(BucketType::User, &ids).await?
384        })
385    }
386
387    #[wasm_bindgen]
388    pub async fn get_followed_client_ids_v1(&self) -> Result<Vec<String>, JsValue> {
389        wasm_try!({
390            let ids = self.meta_post_manager().get_followed_client_ids().await?;
391            ids.into_iter().map(|id| id.to_hex_str()).collect()
392        })
393    }
394
395    #[wasm_bindgen]
396    pub async fn set_followed_client_ids_v1(&self, client_ids: JsValue) -> Result<(), JsValue> {
397        wasm_try!({
398            let client_id_strs: Vec<String> = serde_wasm_bindgen::from_value(client_ids).map_err(|e| anyhow!("serde_wasm_bindgen::from_value error: {}", e))?;
399            let ids = client_id_strs.iter().map(|s| Id::from_hex_str(s)).collect::<anyhow::Result<Vec<_>>>()?;
400            self.meta_post_manager().set_followed_client_ids(ids).await?;
401        })
402    }
403
404    #[wasm_bindgen]
405    pub async fn set_followed_client_id_v1(&self, client_id: String, is_followed: bool) -> Result<(), JsValue> {
406        wasm_try!({
407            let id = Id::from_hex_str(&client_id)?;
408            self.meta_post_manager().set_followed_client_id(id, is_followed).await?;
409        })
410    }
411
412    #[wasm_bindgen]
413    pub async fn multiple_timeline_get_more_followed_hashtags(&self) -> Result<SingleTimelineGetMoreV1Response, JsValue> {
414        wasm_try!({
415            let hashtags = self.meta_post_manager().get_followed_hashtags().await?;
416            let ids = hashtags.iter().map(|h| Id::from_hashtag_str(h)).collect::<anyhow::Result<Vec<_>>>()?;
417            self.multiple_timeline_get_more(BucketType::Hashtag, &ids).await?
418        })
419    }
420
421    #[wasm_bindgen]
422    pub async fn get_followed_hashtags_v1(&self) -> Result<Vec<String>, JsValue> {
423        wasm_try!({
424            self.meta_post_manager().get_followed_hashtags().await?
425        })
426    }
427
428    #[wasm_bindgen]
429    pub async fn set_followed_hashtags_v1(&self, hashtags: JsValue) -> Result<(), JsValue> {
430        wasm_try!({
431            let hashtags: Vec<String> = serde_wasm_bindgen::from_value(hashtags).map_err(|e| anyhow!("serde_wasm_bindgen::from_value error: {}", e))?;
432            self.meta_post_manager().set_followed_hashtags(hashtags).await?;
433        })
434    }
435
436    #[wasm_bindgen]
437    pub async fn set_followed_hashtag_v1(&self, hashtag: String, is_followed: bool) -> Result<(), JsValue> {
438        wasm_try!({
439            self.meta_post_manager().set_followed_hashtag(hashtag, is_followed).await?;
440        })
441    }
442
443    // ------------------------------------------------------------------
444    // MetaPostV1 — unified config publish
445    // ------------------------------------------------------------------
446
447    #[wasm_bindgen]
448    pub async fn submit_meta_post_v1(&self) -> Result<(), JsValue> {
449        wasm_try!({
450            self.hashiverse_client.submit_meta_post().await?;
451        })
452    }
453
454    #[wasm_bindgen]
455    pub async fn ensure_meta_post_in_current_bucket_v1(&self) -> Result<(), JsValue> {
456        wasm_try!({
457            self.hashiverse_client.ensure_meta_post_in_current_bucket().await?;
458        })
459    }
460
461    // ------------------------------------------------------------------
462    // Content thresholds
463    // ------------------------------------------------------------------
464
465    #[wasm_bindgen]
466    pub async fn get_content_thresholds_v1(&self) -> Result<JsValue, JsValue> {
467        wasm_try!({
468            let thresholds = self.meta_post_manager().get_content_thresholds().await?;
469            // serde_wasm_bindgen serializes HashMaps as JS Map (not plain object) and rejects
470            // non-string keys with serialize_maps_as_objects.  Convert to String keys so the
471            // result is a plain JS object matching TS Record<number, number>.
472            let thresholds_js: std::collections::HashMap<String, u32> = thresholds.into_iter().map(|(k, v)| (k.to_string(), v)).collect();
473            let serializer = serde_wasm_bindgen::Serializer::new().serialize_maps_as_objects(true);
474            thresholds_js.serialize(&serializer).map_err(|e| anyhow!("serde_wasm_bindgen error: {}", e))?
475        })
476    }
477
478    #[wasm_bindgen]
479    pub async fn set_content_thresholds_v1(&self, thresholds: JsValue) -> Result<(), JsValue> {
480        wasm_try!({
481            let thresholds_str: std::collections::HashMap<String, u32> = serde_wasm_bindgen::from_value(thresholds).map_err(|e| anyhow!("serde_wasm_bindgen error: {}", e))?;
482            let thresholds: std::collections::HashMap<u8, u32> = thresholds_str.into_iter()
483                .map(|(k, v)| Ok((k.parse::<u8>().map_err(|e| anyhow!("invalid feedback_type key: {}", e))?, v)))
484                .collect::<anyhow::Result<_>>()?;
485            self.meta_post_manager().set_content_thresholds(thresholds).await?;
486        })
487    }
488
489    // ------------------------------------------------------------------
490    // Skip warnings for followed
491    // ------------------------------------------------------------------
492
493    #[wasm_bindgen]
494    pub async fn get_skip_warnings_for_followed_v1(&self) -> Result<bool, JsValue> {
495        wasm_try!({
496            self.meta_post_manager().get_skip_warnings_for_followed().await?
497        })
498    }
499
500    #[wasm_bindgen]
501    pub async fn set_skip_warnings_for_followed_v1(&self, value: bool) -> Result<(), JsValue> {
502        wasm_try!({
503            self.meta_post_manager().set_skip_warnings_for_followed(value).await?;
504        })
505    }
506
507    #[wasm_bindgen]
508    pub async fn fetch_url_preview_v1(&self, url: String) -> Result<UrlPreview, JsValue> {
509        wasm_try!({
510            let preview = self.hashiverse_client.fetch_url_preview(&url).await?;
511            UrlPreview {
512                url: preview.url,
513                title: preview.title,
514                description: preview.description,
515                image_url: preview.image_url,
516            }
517        })
518    }
519
520    #[wasm_bindgen]
521    pub async fn fetch_trending_hashtags_v1(&self, limit: u16) -> Result<TrendingHashtagsFetchResponse, JsValue> {
522        wasm_try!({
523            let response = self.hashiverse_client.fetch_trending_hashtags(limit).await?;
524            TrendingHashtagsFetchResponse {
525                trending_hashtags: response.trending_hashtags.into_iter().map(|entry| TrendingHashtag {
526                    hashtag: entry.hashtag,
527                    count: entry.count,
528                }).collect(),
529            }
530        })
531    }
532}
533
534#[derive(Tsify, Serialize, Deserialize)]
535#[tsify(into_wasm_abi)]
536pub struct SingleTimelineGetMoreV1Response {
537    pub posts: Vec<Post>,
538    pub oldest_processed_time_millis: Option<i64>,
539}
540
541#[derive(Tsify, Serialize, Deserialize)]
542#[tsify(into_wasm_abi)]
543pub struct Post {
544    pub post_id: String,
545    pub time_millis: i64,
546    pub client_id: String,
547    pub bucket_location: String,
548    pub post: String,
549    pub encoded_post_header_hex: String, // contains the hex-encoded EncodedPost without the post body
550    pub healed: bool, // true if the bundle header marks this post as healed (re-uploaded after loss); the displayed time may be inaccurate
551}
552
553#[derive(Tsify, Serialize, Deserialize)]
554#[tsify(into_wasm_abi)]
555pub struct Bio {
556    pub client_id: String,
557    pub nickname: String,
558    pub status: String,
559    pub selfie: String,
560    pub avatar: String,
561}
562
563#[derive(Tsify, Serialize, Deserialize)]
564#[tsify(into_wasm_abi)]
565pub struct UrlPreview {
566    pub url: String,
567    pub title: String,
568    pub description: String,
569    pub image_url: String,
570}
571
572#[derive(Tsify, Serialize, Deserialize)]
573#[tsify(into_wasm_abi)]
574pub struct TrendingHashtag {
575    pub hashtag: String,
576    pub count: u64,
577}
578
579#[derive(Tsify, Serialize, Deserialize)]
580#[tsify(into_wasm_abi)]
581pub struct TrendingHashtagsFetchResponse {
582    pub trending_hashtags: Vec<TrendingHashtag>,
583}
584
585#[derive(Tsify, Serialize, Deserialize)]
586#[tsify(into_wasm_abi)]
587pub struct PeerInfoV1 {
588    pub peer_id_hex: String,
589    pub address: String,
590    pub version: String,
591    pub timestamp_millis: i64,
592    pub pow_initial: u8,
593    pub pow_current_day: u8,
594    pub pow_current_month: u8,
595}
596
597#[derive(Tsify, Serialize, Deserialize)]
598#[tsify(into_wasm_abi)]
599pub struct PowJobStatusV1 {
600    pub label: String,
601    pub pow_min: u8,
602    pub best_pow_so_far: u8,
603}