BlueSky & more on desktop lazurite.stormlightlabs.org/
tauri rust typescript bluesky appview atproto solid
2
fork

Configure Feed

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

feat: add dms and follow/unfollow feature in profile panel

+1951 -23
+56
src-tauri/src/commands/mod.rs
··· 6 6 pub mod settings; 7 7 8 8 use super::auth::{self, LoginSuggestion}; 9 + use super::conversations; 9 10 use super::error::AppError; 10 11 use super::feed::{self, CreateRecordResult, EmbedInput, FeedViewPrefItem, ReplyRefInput, UserPreferences}; 11 12 use super::notifications; ··· 129 130 } 130 131 131 132 #[tauri::command] 133 + pub async fn follow_actor(did: String, state: State<'_, AppState>) -> Result<CreateRecordResult, AppError> { 134 + feed::follow_actor(did, &state).await 135 + } 136 + 137 + #[tauri::command] 138 + pub async fn unfollow_actor(follow_uri: String, state: State<'_, AppState>) -> Result<(), AppError> { 139 + feed::unfollow_actor(follow_uri, &state).await 140 + } 141 + 142 + #[tauri::command] 143 + pub async fn get_followers( 144 + actor: String, cursor: Option<String>, limit: Option<u32>, state: State<'_, AppState>, 145 + ) -> Result<Value, AppError> { 146 + feed::get_followers(actor, cursor, limit, &state).await 147 + } 148 + 149 + #[tauri::command] 150 + pub async fn get_follows( 151 + actor: String, cursor: Option<String>, limit: Option<u32>, state: State<'_, AppState>, 152 + ) -> Result<Value, AppError> { 153 + feed::get_follows(actor, cursor, limit, &state).await 154 + } 155 + 156 + #[tauri::command] 132 157 pub async fn update_saved_feeds(feeds: Vec<feed::SavedFeedItem>, state: State<'_, AppState>) -> Result<(), AppError> { 133 158 feed::update_saved_feeds(feed::UpdateSavedFeedsInput { feeds }, &state).await 134 159 } ··· 152 177 pub async fn get_unread_count(state: State<'_, AppState>) -> Result<i64, AppError> { 153 178 notifications::get_unread_count(&state).await 154 179 } 180 + 181 + #[tauri::command] 182 + pub async fn list_convos( 183 + cursor: Option<String>, limit: Option<u32>, state: State<'_, AppState>, 184 + ) -> Result<Value, AppError> { 185 + conversations::list_convos(cursor, limit, &state).await 186 + } 187 + 188 + #[tauri::command] 189 + pub async fn get_convo_for_members(members: Vec<String>, state: State<'_, AppState>) -> Result<Value, AppError> { 190 + conversations::get_convo_for_members(members, &state).await 191 + } 192 + 193 + #[tauri::command] 194 + pub async fn get_messages( 195 + convo_id: String, cursor: Option<String>, limit: Option<u32>, state: State<'_, AppState>, 196 + ) -> Result<Value, AppError> { 197 + conversations::get_messages(convo_id, cursor, limit, &state).await 198 + } 199 + 200 + #[tauri::command] 201 + pub async fn send_message(convo_id: String, text: String, state: State<'_, AppState>) -> Result<Value, AppError> { 202 + conversations::send_message(convo_id, text, &state).await 203 + } 204 + 205 + #[tauri::command] 206 + pub async fn update_read( 207 + convo_id: String, message_id: Option<String>, state: State<'_, AppState>, 208 + ) -> Result<(), AppError> { 209 + conversations::update_read(convo_id, message_id, &state).await 210 + }
+194
src-tauri/src/conversations.rs
··· 1 + use super::auth::LazuriteOAuthSession; 2 + use super::error::{AppError, Result}; 3 + use super::state::AppState; 4 + use jacquard::api::chat_bsky::convo::get_convo_for_members::GetConvoForMembers; 5 + use jacquard::api::chat_bsky::convo::get_messages::GetMessages; 6 + use jacquard::api::chat_bsky::convo::list_convos::ListConvos; 7 + use jacquard::api::chat_bsky::convo::send_message::SendMessage; 8 + use jacquard::api::chat_bsky::convo::update_read::UpdateRead; 9 + use jacquard::api::chat_bsky::convo::MessageInput; 10 + use jacquard::types::did::Did; 11 + use jacquard::xrpc::{CallOptions, XrpcClient}; 12 + use jacquard::IntoStatic; 13 + use serde_json::Value; 14 + use std::sync::Arc; 15 + use tauri_plugin_log::log; 16 + 17 + const CHAT_PROXY: &str = "did:web:api.bsky.chat#bsky_chat"; 18 + 19 + async fn get_session(state: &AppState) -> Result<Arc<LazuriteOAuthSession>> { 20 + let did = state 21 + .active_session 22 + .read() 23 + .map_err(|error| { 24 + log::error!("active_session poisoned: {error}"); 25 + AppError::StatePoisoned("active_session") 26 + })? 27 + .as_ref() 28 + .ok_or_else(|| { 29 + log::error!("no active account"); 30 + AppError::Validation("no active account".into()) 31 + })? 32 + .did 33 + .clone(); 34 + 35 + state 36 + .sessions 37 + .read() 38 + .map_err(|error| { 39 + log::error!("sessions poisoned: {error}"); 40 + AppError::StatePoisoned("sessions") 41 + })? 42 + .get(&did) 43 + .cloned() 44 + .ok_or_else(|| { 45 + log::error!("session not found for active account"); 46 + AppError::Validation("session not found for active account".into()) 47 + }) 48 + } 49 + 50 + fn chat_opts() -> CallOptions<'static> { 51 + CallOptions { atproto_proxy: Some(CHAT_PROXY.into()), ..Default::default() } 52 + } 53 + 54 + pub async fn list_convos(cursor: Option<String>, limit: Option<u32>, state: &AppState) -> Result<Value> { 55 + let session = get_session(state).await?; 56 + let mut req = ListConvos::new().limit(limit.map(|n| n as i64)); 57 + if let Some(c) = &cursor { 58 + req = req.cursor(Some(c.as_str().into())); 59 + } 60 + 61 + let output = session 62 + .send_with_opts(req.build(), chat_opts()) 63 + .await 64 + .map_err(|error| { 65 + log::error!("listConvos error: {error}"); 66 + AppError::validation("Could not load conversations.") 67 + })? 68 + .into_output() 69 + .map_err(|error| { 70 + log::error!("listConvos output error: {error}"); 71 + AppError::validation("Could not load conversations.") 72 + })?; 73 + 74 + serde_json::to_value(&output).map_err(AppError::from) 75 + } 76 + 77 + pub async fn get_convo_for_members(members: Vec<String>, state: &AppState) -> Result<Value> { 78 + if members.is_empty() { 79 + return Err(AppError::validation("members must not be empty")); 80 + } 81 + 82 + let session = get_session(state).await?; 83 + let dids: Result<Vec<Did<'static>>> = members 84 + .iter() 85 + .map(|m| { 86 + Did::new(m.trim()) 87 + .map(|d| d.into_static()) 88 + .map_err(|_| AppError::validation("invalid DID in members list")) 89 + }) 90 + .collect(); 91 + let req = GetConvoForMembers::new().members(dids?).build(); 92 + 93 + let output = session 94 + .send_with_opts(req, chat_opts()) 95 + .await 96 + .map_err(|error| { 97 + log::error!("getConvoForMembers error: {error}"); 98 + AppError::validation("Could not open this conversation.") 99 + })? 100 + .into_output() 101 + .map_err(|error| { 102 + log::error!("getConvoForMembers output error: {error}"); 103 + AppError::validation("Could not open this conversation.") 104 + })?; 105 + 106 + serde_json::to_value(&output).map_err(AppError::from) 107 + } 108 + 109 + pub async fn get_messages( 110 + convo_id: String, cursor: Option<String>, limit: Option<u32>, state: &AppState, 111 + ) -> Result<Value> { 112 + if convo_id.is_empty() { 113 + return Err(AppError::validation("convo_id must not be empty")); 114 + } 115 + 116 + let session = get_session(state).await?; 117 + let mut req = GetMessages::new() 118 + .convo_id(convo_id.as_str()) 119 + .limit(limit.map(|n| n as i64)); 120 + if let Some(c) = &cursor { 121 + req = req.cursor(Some(c.as_str().into())); 122 + } 123 + 124 + let output = session 125 + .send_with_opts(req.build(), chat_opts()) 126 + .await 127 + .map_err(|error| { 128 + log::error!("getMessages error: {error}"); 129 + AppError::validation("Could not load messages.") 130 + })? 131 + .into_output() 132 + .map_err(|error| { 133 + log::error!("getMessages output error: {error}"); 134 + AppError::validation("Could not load messages.") 135 + })?; 136 + 137 + serde_json::to_value(&output).map_err(AppError::from) 138 + } 139 + 140 + pub async fn send_message(convo_id: String, text: String, state: &AppState) -> Result<Value> { 141 + if convo_id.is_empty() { 142 + return Err(AppError::validation("convo_id must not be empty")); 143 + } 144 + if text.trim().is_empty() { 145 + return Err(AppError::validation("message text must not be empty")); 146 + } 147 + 148 + let session = get_session(state).await?; 149 + let msg = MessageInput { text: text.into(), facets: None, embed: None, ..Default::default() }; 150 + let req = SendMessage::new().convo_id(convo_id.as_str()).message(msg).build(); 151 + 152 + let output = session 153 + .send_with_opts(req, chat_opts()) 154 + .await 155 + .map_err(|error| { 156 + log::error!("sendMessage error: {error}"); 157 + AppError::validation("Could not send this message.") 158 + })? 159 + .into_output() 160 + .map_err(|error| { 161 + log::error!("sendMessage output error: {error}"); 162 + AppError::validation("Could not send this message.") 163 + })?; 164 + 165 + serde_json::to_value(&output).map_err(AppError::from) 166 + } 167 + 168 + pub async fn update_read(convo_id: String, message_id: Option<String>, state: &AppState) -> Result<()> { 169 + if convo_id.is_empty() { 170 + return Err(AppError::validation("convo_id must not be empty")); 171 + } 172 + 173 + let session = get_session(state).await?; 174 + let req = UpdateRead { 175 + convo_id: convo_id.as_str().into(), 176 + message_id: message_id.as_deref().map(|s| s.into()), 177 + ..Default::default() 178 + }; 179 + 180 + session 181 + .send_with_opts(req, chat_opts()) 182 + .await 183 + .map_err(|error| { 184 + log::error!("updateRead error: {error}"); 185 + AppError::validation("Could not update the read status for this conversation.") 186 + })? 187 + .into_output() 188 + .map_err(|error| { 189 + log::error!("updateRead output error: {error}"); 190 + AppError::validation("Could not update the read status for this conversation.") 191 + })?; 192 + 193 + Ok(()) 194 + }
+115
src-tauri/src/feed.rs
··· 18 18 use jacquard::api::app_bsky::feed::like::Like; 19 19 use jacquard::api::app_bsky::feed::post::{Post, PostEmbed, ReplyRef}; 20 20 use jacquard::api::app_bsky::feed::repost::Repost; 21 + use jacquard::api::app_bsky::graph::follow::Follow; 22 + use jacquard::api::app_bsky::graph::get_followers::GetFollowers; 23 + use jacquard::api::app_bsky::graph::get_follows::GetFollows; 21 24 use jacquard::api::com_atproto::repo::create_record::CreateRecord; 22 25 use jacquard::api::com_atproto::repo::delete_record::DeleteRecord; 23 26 use jacquard::api::com_atproto::repo::strong_ref::StrongRef; ··· 708 711 })?; 709 712 710 713 Ok(()) 714 + } 715 + 716 + pub async fn follow_actor(did: String, state: &AppState) -> Result<CreateRecordResult> { 717 + let session = get_session(state).await?; 718 + let active_did = active_did(state)?; 719 + 720 + let follow = Follow::new().created_at(Datetime::now()).subject(Did::new(&did)?).build(); 721 + 722 + let record_json = serde_json::to_value(&follow)?; 723 + let record_data = 724 + Data::from_json_owned(record_json).map_err(|_| AppError::validation("serialize follow"))?; 725 + 726 + let repo = AtIdentifier::Did(Did::new(&active_did)?); 727 + let collection = Nsid::new("app.bsky.graph.follow").map_err(|_| AppError::validation("nsid"))?; 728 + 729 + let output = session 730 + .send(CreateRecord::new().repo(repo).collection(collection).record(record_data).build()) 731 + .await 732 + .map_err(|error| { 733 + log::error!("createRecord (follow) error: {error}"); 734 + AppError::validation("Could not follow this account.") 735 + })? 736 + .into_output() 737 + .map_err(|error| { 738 + log::error!("createRecord (follow) output error: {error}"); 739 + AppError::validation("Could not follow this account.") 740 + })?; 741 + 742 + Ok(CreateRecordResult { uri: output.uri.to_string(), cid: output.cid.to_string() }) 743 + } 744 + 745 + pub async fn unfollow_actor(follow_uri: String, state: &AppState) -> Result<()> { 746 + let session = get_session(state).await?; 747 + let did = active_did(state)?; 748 + 749 + let at_uri = 750 + AtUri::new(&follow_uri).map_err(|_| AppError::validation("invalid follow URI"))?; 751 + let RecordKey(rkey) = 752 + at_uri.rkey().ok_or_else(|| AppError::Validation("follow URI has no rkey".into()))?; 753 + let rkey_str = rkey.to_string(); 754 + 755 + let repo = AtIdentifier::Did(Did::new(&did)?); 756 + let collection = 757 + Nsid::new("app.bsky.graph.follow").map_err(|_| AppError::validation("nsid"))?; 758 + let rkey = RecordKey::any(&rkey_str).map_err(|_| AppError::validation("invalid rkey"))?; 759 + 760 + session 761 + .send(DeleteRecord::new().repo(repo).collection(collection).rkey(rkey).build()) 762 + .await 763 + .map_err(|error| { 764 + log::error!("deleteRecord (unfollow) error: {error}"); 765 + AppError::validation("Could not unfollow this account.") 766 + })? 767 + .into_output() 768 + .map_err(|error| { 769 + log::error!("deleteRecord (unfollow) output error: {error}"); 770 + AppError::validation("Could not unfollow this account.") 771 + })?; 772 + 773 + Ok(()) 774 + } 775 + 776 + pub async fn get_followers( 777 + actor: String, cursor: Option<String>, limit: Option<u32>, state: &AppState, 778 + ) -> Result<serde_json::Value> { 779 + let session = get_session(state).await?; 780 + let actor = parse_actor_identifier(&actor)?; 781 + let mut req = GetFollowers::new().actor(actor).limit(limit.map(|value| value as i64)); 782 + if let Some(c) = &cursor { 783 + req = req.cursor(Some(c.as_str().into())); 784 + } 785 + 786 + let output = session 787 + .send(req.build()) 788 + .await 789 + .map_err(|error| { 790 + log::error!("getFollowers error: {error}"); 791 + AppError::validation("Could not load followers.") 792 + })? 793 + .into_output() 794 + .map_err(|error| { 795 + log::error!("getFollowers output error: {error}"); 796 + AppError::validation("Could not load followers.") 797 + })?; 798 + 799 + serde_json::to_value(&output).map_err(AppError::from) 800 + } 801 + 802 + pub async fn get_follows( 803 + actor: String, cursor: Option<String>, limit: Option<u32>, state: &AppState, 804 + ) -> Result<serde_json::Value> { 805 + let session = get_session(state).await?; 806 + let actor = parse_actor_identifier(&actor)?; 807 + let mut req = GetFollows::new().actor(actor).limit(limit.map(|value| value as i64)); 808 + if let Some(c) = &cursor { 809 + req = req.cursor(Some(c.as_str().into())); 810 + } 811 + 812 + let output = session 813 + .send(req.build()) 814 + .await 815 + .map_err(|error| { 816 + log::error!("getFollows error: {error}"); 817 + AppError::validation("Could not load following.") 818 + })? 819 + .into_output() 820 + .map_err(|error| { 821 + log::error!("getFollows output error: {error}"); 822 + AppError::validation("Could not load following.") 823 + })?; 824 + 825 + serde_json::to_value(&output).map_err(AppError::from) 711 826 } 712 827 713 828 fn strong_ref_from_input(input: &StrongRefInput) -> Result<StrongRef<'static>> {
+11 -1
src-tauri/src/lib.rs
··· 1 1 mod auth; 2 2 mod columns; 3 3 mod commands; 4 + mod conversations; 4 5 mod db; 5 6 mod error; 6 7 mod explorer; ··· 92 93 cmd::unlike_post, 93 94 cmd::repost, 94 95 cmd::unrepost, 96 + cmd::follow_actor, 97 + cmd::unfollow_actor, 98 + cmd::get_followers, 99 + cmd::get_follows, 95 100 cmd::update_saved_feeds, 96 101 cmd::update_feed_view_pref, 97 102 cmd::list_notifications, ··· 127 132 cmd::columns::add_column, 128 133 cmd::columns::remove_column, 129 134 cmd::columns::reorder_columns, 130 - cmd::columns::update_column 135 + cmd::columns::update_column, 136 + cmd::list_convos, 137 + cmd::get_convo_for_members, 138 + cmd::get_messages, 139 + cmd::send_message, 140 + cmd::update_read 131 141 ]) 132 142 .run(tauri::generate_context!()) 133 143 .expect("error while running tauri application");
+2
src/App.tsx
··· 8 8 import { ComposerWindow } from "./components/feeds/ComposerWindow"; 9 9 import { FeedWorkspace } from "./components/feeds/FeedWorkspace"; 10 10 import { LoginPanel } from "./components/LoginPanel"; 11 + import { MessagesPanel } from "./components/messages/MessagesPanel"; 11 12 import { NotificationsPanel } from "./components/notifications/NotificationsPanel"; 12 13 import { HeaderPanel } from "./components/panels/Header"; 13 14 import { ProfilePanel } from "./components/profile/ProfilePanel"; ··· 81 82 <AppRouter 82 83 renderAuth={() => <AuthWorkspace />} 83 84 renderComposer={() => <ComposerWindow />} 85 + renderMessages={(props) => <MessagesPanel memberDid={props.memberDid} />} 84 86 renderNotifications={() => <NotificationsPanel />} 85 87 renderProfile={(props) => <ProfilePanel actor={props.actor} />} 86 88 renderShell={AppShell}
+2 -4
src/components/deck/DeckWorkspace.tsx
··· 6 6 import { createEffect, For, onCleanup, onMount, Show } from "solid-js"; 7 7 import { createStore, produce } from "solid-js/store"; 8 8 import { Motion } from "solid-motionone"; 9 - import { Icon } from "../shared/Icon"; 9 + import { ActionIcon, Icon } from "../shared/Icon"; 10 10 import { AddColumnPanel } from "./AddColumnPanel"; 11 11 import { DeckColumn } from "./DeckColumn"; 12 12 ··· 52 52 type="button" 53 53 class="inline-flex h-9 items-center gap-2 rounded-full border-0 bg-primary/15 px-4 text-sm font-medium text-primary transition duration-150 hover:-translate-y-px hover:bg-primary/25" 54 54 onClick={() => props.onAdd()}> 55 - <span class="flex items-center"> 56 - <i class="i-ri-add-line" /> 57 - </span> 55 + <ActionIcon kind="add" /> 58 56 Add first column 59 57 </button> 60 58 </div>
+684
src/components/messages/MessagesPanel.tsx
··· 1 + import { useAppSession } from "$/contexts/app-session"; 2 + import { getConvoForMembers, getMessages, listConvos, sendMessage, updateRead } from "$/lib/api/conversations"; 3 + import { formatRelativeTime, getDisplayName } from "$/lib/feeds"; 4 + import type { ConvoView, DeletedMessageView, MessageView } from "$/lib/types"; 5 + import { normalizeError } from "$/lib/utils/text"; 6 + import * as logger from "@tauri-apps/plugin-log"; 7 + import { createEffect, createMemo, createSignal, For, Show } from "solid-js"; 8 + import { AvatarBadge } from "../AvatarBadge"; 9 + import { Icon } from "../shared/Icon"; 10 + 11 + type MessagesPanelProps = { memberDid?: string | null }; 12 + 13 + type MessagesState = { 14 + convos: ConvoView[]; 15 + convoCursor: string | null; 16 + loadingConvos: boolean; 17 + convoError: string | null; 18 + }; 19 + 20 + type ConvoContentState = { 21 + messages: Array<MessageView | DeletedMessageView>; 22 + messageCursor: string | null; 23 + loadingMessages: boolean; 24 + sending: boolean; 25 + messageError: string | null; 26 + }; 27 + 28 + function createMessagesState(loadingConvos = true): MessagesState { 29 + return { convos: [], convoCursor: null, loadingConvos, convoError: null }; 30 + } 31 + 32 + function createConvoContentState(loadingMessages = false): ConvoContentState { 33 + return { messages: [], messageCursor: null, loadingMessages, sending: false, messageError: null }; 34 + } 35 + 36 + function isMessageView(item: MessageView | DeletedMessageView): item is MessageView { 37 + return "text" in item; 38 + } 39 + 40 + function getConvoOtherMember(convo: ConvoView, selfDid: string | null) { 41 + return convo.members.find((member) => member.did !== selfDid) ?? convo.members[0]; 42 + } 43 + 44 + function getConvoDisplayName(convo: ConvoView, selfDid: string | null): string { 45 + const other = getConvoOtherMember(convo, selfDid); 46 + return other ? getDisplayName(other) : "Unknown account"; 47 + } 48 + 49 + function getLastMessageText(convo: ConvoView): string { 50 + const message = convo.lastMessage; 51 + if (!message) { 52 + return "No messages yet"; 53 + } 54 + 55 + return isMessageView(message) ? message.text : "Message deleted"; 56 + } 57 + 58 + function getLastMessageTime(convo: ConvoView): string { 59 + const message = convo.lastMessage; 60 + return message ? formatRelativeTime(message.sentAt) : ""; 61 + } 62 + 63 + function mergeConvos(current: ConvoView[], incoming: ConvoView[]) { 64 + const byId = new Map(current.map((convo) => [convo.id, convo])); 65 + for (const convo of incoming) { 66 + byId.set(convo.id, convo); 67 + } 68 + 69 + return [...byId.values()]; 70 + } 71 + 72 + function upsertConvo(current: ConvoView[], convo: ConvoView) { 73 + return [convo, ...current.filter((item) => item.id !== convo.id)]; 74 + } 75 + 76 + function updateConvo(current: ConvoView[], convoId: string, updater: (convo: ConvoView) => ConvoView) { 77 + return current.map((convo) => convo.id === convoId ? updater(convo) : convo); 78 + } 79 + 80 + function UnreadCount(props: { count: number }) { 81 + return ( 82 + <Show when={props.count > 0}> 83 + <span class="inline-flex h-5 min-w-5 shrink-0 items-center justify-center rounded-full bg-primary px-1 text-xs font-semibold text-on-primary-fixed"> 84 + {props.count} 85 + </span> 86 + </Show> 87 + ); 88 + } 89 + 90 + function Retry(props: { error: string; onRetry: () => void }) { 91 + return ( 92 + <div class="flex flex-1 flex-col items-center justify-center gap-3 px-6 py-12 text-center"> 93 + <Icon kind="danger" /> 94 + <p class="m-0 text-sm text-on-surface-variant">{props.error}</p> 95 + <button 96 + type="button" 97 + class="rounded-full border-0 bg-primary/15 px-4 py-2 text-xs font-medium text-primary transition hover:bg-primary/25" 98 + onClick={() => props.onRetry()}> 99 + Retry 100 + </button> 101 + </div> 102 + ); 103 + } 104 + 105 + function MessageMeta(props: { name: string; time: string; unread: number; text: string }) { 106 + return ( 107 + <div class="min-w-0 flex-1"> 108 + <div class="mb-0.5 flex items-center justify-between gap-2"> 109 + <span class="truncate text-sm font-medium text-on-surface">{props.name}</span> 110 + <Show when={props.time}> 111 + <span 112 + class="shrink-0 text-xs" 113 + classList={{ "text-primary": props.unread > 0, "text-on-surface-variant": props.unread === 0 }}> 114 + {props.time} 115 + </span> 116 + </Show> 117 + </div> 118 + <div class="flex items-center justify-between gap-2"> 119 + <p 120 + class="truncate text-xs" 121 + classList={{ "text-on-surface": props.unread > 0, "text-on-surface-variant": props.unread === 0 }}> 122 + {props.text} 123 + </p> 124 + <UnreadCount count={props.unread} /> 125 + </div> 126 + </div> 127 + ); 128 + } 129 + 130 + function ConvoItem(props: { active: boolean; convo: ConvoView; onClick: () => void; selfDid: string | null }) { 131 + const other = createMemo(() => getConvoOtherMember(props.convo, props.selfDid)); 132 + const displayName = createMemo(() => getConvoDisplayName(props.convo, props.selfDid)); 133 + const lastText = createMemo(() => getLastMessageText(props.convo)); 134 + const lastTime = createMemo(() => getLastMessageTime(props.convo)); 135 + 136 + return ( 137 + <button 138 + type="button" 139 + class="w-full cursor-pointer border-0 border-b border-white/5 bg-transparent px-4 py-3.5 text-left transition duration-150 ease-out hover:bg-white/3" 140 + classList={{ "bg-primary/10 hover:bg-primary/12": props.active }} 141 + onClick={() => props.onClick()}> 142 + <div class="flex items-start gap-3"> 143 + <AvatarBadge label={other()?.handle ?? "?"} src={other()?.avatar} tone="primary" /> 144 + <MessageMeta name={displayName()} text={lastText()} time={lastTime()} unread={props.convo.unreadCount ?? 0} /> 145 + </div> 146 + </button> 147 + ); 148 + } 149 + 150 + function MessageBubble(props: { isSelf: boolean; message: MessageView; senderAvatar?: string | null }) { 151 + const timeLabel = createMemo(() => { 152 + const parsed = new Date(props.message.sentAt); 153 + return parsed.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); 154 + }); 155 + 156 + return ( 157 + <Show 158 + when={props.isSelf} 159 + fallback={ 160 + <div class="flex items-end gap-2"> 161 + <AvatarBadge label={props.message.sender.did} src={props.senderAvatar} /> 162 + <div class="max-w-[70%] rounded-2xl rounded-bl-md bg-surface-container-high px-4 py-2.5 text-sm text-on-surface"> 163 + <p class="m-0">{props.message.text}</p> 164 + </div> 165 + <span class="shrink-0 text-xs text-on-surface-variant">{timeLabel()}</span> 166 + </div> 167 + }> 168 + <div class="flex items-end justify-end gap-2"> 169 + <span class="shrink-0 text-xs text-on-surface-variant">{timeLabel()}</span> 170 + <div class="max-w-[70%] rounded-2xl rounded-br-md bg-primary px-4 py-2.5 text-sm font-medium text-on-primary-fixed"> 171 + <p class="m-0">{props.message.text}</p> 172 + </div> 173 + </div> 174 + </Show> 175 + ); 176 + } 177 + 178 + function DeletedBubble(props: { isSelf: boolean }) { 179 + return ( 180 + <div class="flex" classList={{ "justify-end": props.isSelf }}> 181 + <p class="rounded-xl border border-white/10 px-3 py-2 text-xs italic text-on-surface-variant">Message deleted</p> 182 + </div> 183 + ); 184 + } 185 + 186 + function ComposeBar(props: { disabled: boolean; onSend: (text: string) => Promise<boolean> }) { 187 + const [text, setText] = createSignal(""); 188 + 189 + async function submit() { 190 + const trimmed = text().trim(); 191 + if (!trimmed || props.disabled) { 192 + return; 193 + } 194 + 195 + const sent = await props.onSend(trimmed); 196 + if (sent) { 197 + setText(""); 198 + } 199 + } 200 + 201 + function handleKeyDown(event: KeyboardEvent) { 202 + if (event.key === "Enter" && !event.shiftKey) { 203 + event.preventDefault(); 204 + void submit(); 205 + } 206 + } 207 + 208 + return ( 209 + <div class="flex items-end gap-2 border-t border-white/5 p-4 bg-surface-container/80"> 210 + <div class="relative flex-1"> 211 + <textarea 212 + value={text()} 213 + onInput={(event) => { 214 + const element = event.currentTarget; 215 + element.style.height = ""; 216 + element.style.height = `${Math.min(element.scrollHeight, 120)}px`; 217 + setText(element.value); 218 + }} 219 + onKeyDown={handleKeyDown} 220 + placeholder="Type a message…" 221 + rows={1} 222 + class="w-full resize-none rounded-2xl border border-white/8 bg-black/40 px-4 py-3 text-sm text-on-surface outline-none transition focus:border-primary/50 focus:ring-1 focus:ring-primary/30" 223 + style={{ "min-height": "48px", "max-height": "120px" }} 224 + disabled={props.disabled} /> 225 + </div> 226 + 227 + <button 228 + type="button" 229 + class="flex h-12 w-12 shrink-0 items-center justify-center rounded-full border-0 bg-primary text-on-primary-fixed transition duration-150 ease-out hover:-translate-y-px hover:bg-primary/90 disabled:translate-y-0 disabled:opacity-40" 230 + disabled={props.disabled || !text().trim()} 231 + aria-label="Send message" 232 + onClick={() => void submit()}> 233 + <Icon iconClass="i-ri-send-plane-fill" /> 234 + </button> 235 + </div> 236 + ); 237 + } 238 + 239 + function EmptyChatPane() { 240 + return ( 241 + <div class="flex flex-1 flex-col items-center justify-center gap-4 text-center"> 242 + <Icon kind="messages" class="text-5xl text-on-surface-variant opacity-20" /> 243 + <div> 244 + <p class="m-0 text-sm font-medium text-on-surface">No conversation selected</p> 245 + <p class="m-0 mt-1 text-xs text-on-surface-variant">Choose a conversation from the list to start messaging.</p> 246 + </div> 247 + </div> 248 + ); 249 + } 250 + 251 + function EmptyConvoList() { 252 + return ( 253 + <div class="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center"> 254 + <Icon iconClass="i-ri-chat-smile-3-line" class="text-4xl text-on-surface-variant opacity-20" /> 255 + <div> 256 + <p class="m-0 text-sm font-medium text-on-surface">No conversations yet</p> 257 + <p class="m-0 mt-1 text-xs text-on-surface-variant">Open a profile and start a chat to see it here.</p> 258 + </div> 259 + </div> 260 + ); 261 + } 262 + 263 + function ReloadMessagesButton(props: { isLoading: boolean; onLoadMore: () => void }) { 264 + return ( 265 + <div class="flex justify-center"> 266 + <button 267 + type="button" 268 + class="rounded-full border-0 bg-white/5 px-4 py-1.5 text-xs text-on-surface-variant transition hover:bg-white/8 disabled:opacity-40" 269 + disabled={props.isLoading} 270 + onClick={() => props.onLoadMore()}> 271 + <Show when={props.isLoading} fallback="Load earlier messages"> 272 + <Icon kind="loader" class="animate-spin text-xs" name="Loading" /> 273 + </Show> 274 + </button> 275 + </div> 276 + ); 277 + } 278 + 279 + function MessageError(props: { error: string | null }) { 280 + return ( 281 + <Show when={props.error}> 282 + {(error) => ( 283 + <div class="flex justify-center"> 284 + <p class="m-0 text-sm text-on-surface-variant">{error()}</p> 285 + </div> 286 + )} 287 + </Show> 288 + ); 289 + } 290 + 291 + function ChatPane( 292 + props: { 293 + chatState: ConvoContentState; 294 + convo: ConvoView; 295 + onLoadMore: () => void; 296 + onSend: (text: string) => Promise<boolean>; 297 + selfDid: string | null; 298 + }, 299 + ) { 300 + const otherMember = createMemo(() => getConvoOtherMember(props.convo, props.selfDid)); 301 + const displayName = createMemo(() => getConvoDisplayName(props.convo, props.selfDid)); 302 + 303 + function getMemberAvatar(did: string) { 304 + return props.convo.members.find((member) => member.did === did)?.avatar; 305 + } 306 + 307 + return ( 308 + <> 309 + <header class="flex shrink-0 items-center gap-3 border-b border-white/5 bg-surface-container/80 px-5 py-3.5 backdrop-blur-[12px]"> 310 + <AvatarBadge label={otherMember()?.handle ?? "?"} src={otherMember()?.avatar} tone="primary" /> 311 + <div class="min-w-0 flex-1"> 312 + <p class="m-0 truncate text-sm font-semibold text-on-surface">{displayName()}</p> 313 + <p class="m-0 truncate text-xs text-on-surface-variant">@{otherMember()?.handle ?? ""}</p> 314 + </div> 315 + </header> 316 + 317 + <div class="flex flex-1 flex-col gap-3 overflow-y-auto px-5 py-4 [scrollbar-width:thin]"> 318 + <Show when={props.chatState.messageCursor}> 319 + <ReloadMessagesButton isLoading={props.chatState.loadingMessages} onLoadMore={props.onLoadMore} /> 320 + </Show> 321 + 322 + <Show 323 + when={!props.chatState.loadingMessages || props.chatState.messages.length > 0} 324 + fallback={ 325 + <div class="flex flex-1 items-center justify-center"> 326 + <Icon kind="loader" class="animate-spin text-xl text-on-surface-variant" name="Loading" /> 327 + </div> 328 + }> 329 + <MessageError error={props.chatState.messageError} /> 330 + <For each={props.chatState.messages}> 331 + {(message) => ( 332 + <Show 333 + when={isMessageView(message) ? message : null} 334 + keyed 335 + fallback={<DeletedBubble isSelf={message.sender.did === props.selfDid} />}> 336 + {(item) => ( 337 + <MessageBubble 338 + isSelf={item.sender.did === props.selfDid} 339 + message={item} 340 + senderAvatar={getMemberAvatar(item.sender.did)} /> 341 + )} 342 + </Show> 343 + )} 344 + </For> 345 + </Show> 346 + </div> 347 + 348 + <ComposeBar disabled={props.chatState.sending} onSend={props.onSend} /> 349 + </> 350 + ); 351 + } 352 + 353 + export function MessagesPanel(props: MessagesPanelProps) { 354 + const session = useAppSession(); 355 + 356 + const [listState, setListState] = createSignal<MessagesState>(createMessagesState(false)); 357 + const [activeConvoId, setActiveConvoId] = createSignal<string | null>(null); 358 + const [chatState, setChatState] = createSignal<ConvoContentState>(createConvoContentState()); 359 + 360 + const requestedMemberDid = createMemo(() => { 361 + const trimmed = props.memberDid?.trim(); 362 + return trimmed && trimmed !== session.activeDid ? trimmed : null; 363 + }); 364 + const activeConvo = createMemo(() => listState().convos.find((convo) => convo.id === activeConvoId()) ?? null); 365 + 366 + let convoListRequest = 0; 367 + let openMemberRequest = 0; 368 + let messageRequest = 0; 369 + 370 + createEffect(() => { 371 + const activeDid = session.activeDid; 372 + const memberDid = requestedMemberDid(); 373 + 374 + convoListRequest += 1; 375 + openMemberRequest += 1; 376 + messageRequest += 1; 377 + 378 + setListState(createMessagesState(!!activeDid)); 379 + setActiveConvoId(null); 380 + setChatState(createConvoContentState()); 381 + 382 + if (!activeDid) { 383 + setListState(createMessagesState(false)); 384 + return; 385 + } 386 + 387 + void loadConvos({ preserveActive: false, targetMemberDid: memberDid }); 388 + }); 389 + 390 + async function loadConvos(options: { preserveActive: boolean; targetMemberDid: string | null }) { 391 + const currentRequest = ++convoListRequest; 392 + 393 + setListState((prev) => ({ ...prev, loadingConvos: true, convoError: null })); 394 + 395 + try { 396 + const response = await listConvos(); 397 + if (currentRequest !== convoListRequest) { 398 + return; 399 + } 400 + 401 + setListState({ 402 + convos: response.convos, 403 + convoCursor: response.cursor ?? null, 404 + convoError: null, 405 + loadingConvos: false, 406 + }); 407 + 408 + const preservedConvo = options.preserveActive 409 + ? response.convos.find((convo) => convo.id === activeConvoId()) ?? null 410 + : null; 411 + const targetedConvo = options.targetMemberDid 412 + ? response.convos.find((convo) => convo.members.some((member) => member.did === options.targetMemberDid)) 413 + ?? null 414 + : null; 415 + const nextConvo = preservedConvo ?? targetedConvo 416 + ?? (!options.preserveActive ? response.convos[0] ?? null : null); 417 + 418 + if (nextConvo && nextConvo.id !== activeConvoId()) { 419 + void openConvo(nextConvo); 420 + return; 421 + } 422 + 423 + if (!nextConvo && options.targetMemberDid) { 424 + await ensureConvoForMember(options.targetMemberDid); 425 + } 426 + } catch (error) { 427 + const message = normalizeError(error); 428 + logger.warn("failed to list conversations", { keyValues: { error: message } }); 429 + if (currentRequest !== convoListRequest) { 430 + return; 431 + } 432 + 433 + setListState((prev) => ({ 434 + ...prev, 435 + convoError: "Could not load conversations. Please try again.", 436 + loadingConvos: false, 437 + })); 438 + } 439 + } 440 + 441 + async function loadMoreConvos() { 442 + const cursor = listState().convoCursor; 443 + if (!cursor) { 444 + return; 445 + } 446 + 447 + const currentRequest = ++convoListRequest; 448 + setListState((prev) => ({ ...prev, loadingConvos: true })); 449 + 450 + try { 451 + const response = await listConvos(cursor); 452 + if (currentRequest !== convoListRequest) { 453 + return; 454 + } 455 + 456 + setListState((prev) => ({ 457 + convos: mergeConvos(prev.convos, response.convos), 458 + convoCursor: response.cursor ?? null, 459 + convoError: prev.convoError, 460 + loadingConvos: false, 461 + })); 462 + } catch (error) { 463 + logger.error("listConvos (load more) failed", { keyValues: { error: normalizeError(error) } }); 464 + if (currentRequest !== convoListRequest) { 465 + return; 466 + } 467 + 468 + setListState((prev) => ({ ...prev, loadingConvos: false })); 469 + } 470 + } 471 + 472 + async function ensureConvoForMember(memberDid: string) { 473 + const currentRequest = ++openMemberRequest; 474 + 475 + try { 476 + const response = await getConvoForMembers([memberDid]); 477 + if (currentRequest !== openMemberRequest) { 478 + return; 479 + } 480 + 481 + setListState((prev) => ({ ...prev, convos: upsertConvo(prev.convos, response.convo) })); 482 + 483 + if (response.convo.id !== activeConvoId()) { 484 + await openConvo(response.convo); 485 + } 486 + } catch (error) { 487 + logger.error("getConvoForMembers failed", { keyValues: { error: normalizeError(error), memberDid } }); 488 + session.reportError("Could not open a conversation with this account."); 489 + } 490 + } 491 + 492 + async function openConvo(convo: ConvoView) { 493 + const currentRequest = ++messageRequest; 494 + 495 + setActiveConvoId(convo.id); 496 + setChatState(createConvoContentState(true)); 497 + 498 + try { 499 + const response = await getMessages(convo.id); 500 + if (currentRequest !== messageRequest || activeConvoId() !== convo.id) { 501 + return; 502 + } 503 + 504 + const ordered = [...response.messages].toReversed(); 505 + setChatState({ 506 + loadingMessages: false, 507 + messageCursor: response.cursor ?? null, 508 + messageError: null, 509 + messages: ordered, 510 + sending: false, 511 + }); 512 + 513 + if ((convo.unreadCount ?? 0) > 0) { 514 + const newestMessage = ordered.at(-1); 515 + void updateRead(convo.id, newestMessage?.id ?? null).catch(() => { 516 + logger.error("updateRead failed", { keyValues: { convoId: convo.id } }); 517 + }); 518 + 519 + setListState((prev) => ({ 520 + ...prev, 521 + convos: updateConvo(prev.convos, convo.id, (item) => ({ ...item, unreadCount: 0 })), 522 + })); 523 + } 524 + } catch (error) { 525 + logger.error("getMessages failed", { keyValues: { error: normalizeError(error), convoId: convo.id } }); 526 + if (currentRequest !== messageRequest || activeConvoId() !== convo.id) { 527 + return; 528 + } 529 + 530 + setChatState((prev) => ({ 531 + ...prev, 532 + loadingMessages: false, 533 + messageError: "Could not load messages. Please try again.", 534 + })); 535 + } 536 + } 537 + 538 + async function loadMoreMessages() { 539 + const convoId = activeConvoId(); 540 + const cursor = chatState().messageCursor; 541 + if (!convoId || !cursor || chatState().loadingMessages) { 542 + return; 543 + } 544 + 545 + setChatState((prev) => ({ ...prev, loadingMessages: true })); 546 + 547 + try { 548 + const response = await getMessages(convoId, cursor); 549 + if (activeConvoId() !== convoId) { 550 + return; 551 + } 552 + 553 + setChatState((prev) => ({ 554 + ...prev, 555 + loadingMessages: false, 556 + messageCursor: response.cursor ?? null, 557 + messages: [...response.messages.toReversed(), ...prev.messages], 558 + })); 559 + } catch (error) { 560 + logger.error("getMessages (load more) failed", { keyValues: { error: normalizeError(error), convoId } }); 561 + if (activeConvoId() !== convoId) { 562 + return; 563 + } 564 + 565 + setChatState((prev) => ({ 566 + ...prev, 567 + loadingMessages: false, 568 + messageError: "Could not load more messages. Please try again.", 569 + })); 570 + } 571 + } 572 + 573 + async function handleSend(text: string) { 574 + const convoId = activeConvoId(); 575 + const convo = activeConvo(); 576 + if (!convoId || !convo) { 577 + return false; 578 + } 579 + 580 + setChatState((prev) => ({ ...prev, messageError: null, sending: true })); 581 + 582 + try { 583 + const message = await sendMessage(convoId, text); 584 + if (activeConvoId() !== convoId) { 585 + return false; 586 + } 587 + 588 + setChatState((prev) => ({ ...prev, messages: [...prev.messages, message], sending: false })); 589 + 590 + setListState((prev) => ({ 591 + ...prev, 592 + convos: upsertConvo(prev.convos, { ...convo, lastMessage: message, unreadCount: 0 }), 593 + })); 594 + 595 + return true; 596 + } catch (error) { 597 + logger.error("sendMessage failed", { keyValues: { error: normalizeError(error), convoId } }); 598 + if (activeConvoId() !== convoId) { 599 + return false; 600 + } 601 + 602 + setChatState((prev) => ({ ...prev, messageError: "Failed to send message. Please try again.", sending: false })); 603 + 604 + return false; 605 + } 606 + } 607 + 608 + function handleRefresh() { 609 + void loadConvos({ preserveActive: true, targetMemberDid: requestedMemberDid() }); 610 + } 611 + 612 + return ( 613 + <div class="flex h-full min-h-0 gap-0"> 614 + <aside class="flex w-80 shrink-0 flex-col overflow-hidden rounded-2xl border-r border-white/5 bg-surface-container/40"> 615 + <header class="flex shrink-0 items-center justify-between border-b border-white/5 bg-surface-container/80 px-5 py-4 backdrop-blur-[12px]"> 616 + <div> 617 + <h1 class="m-0 text-lg font-semibold tracking-tight text-on-surface">Messages</h1> 618 + </div> 619 + <button 620 + type="button" 621 + class="inline-flex h-9 w-9 items-center justify-center rounded-full border-0 bg-white/5 text-on-surface-variant transition duration-150 ease-out hover:-translate-y-px hover:bg-white/8 hover:text-on-surface" 622 + aria-label="Refresh conversations" 623 + title="Refresh conversations" 624 + onClick={handleRefresh}> 625 + <Icon kind="refresh" /> 626 + </button> 627 + </header> 628 + 629 + <div class="flex flex-1 flex-col overflow-y-auto"> 630 + <Show 631 + when={!listState().loadingConvos || listState().convos.length > 0} 632 + fallback={ 633 + <div class="flex flex-1 items-center justify-center py-12"> 634 + <Icon kind="loader" class="animate-spin text-xl text-on-surface-variant" name="Loading" /> 635 + </div> 636 + }> 637 + <Show 638 + when={listState().convoError} 639 + fallback={ 640 + <Show when={listState().convos.length > 0} fallback={<EmptyConvoList />}> 641 + <div class="flex flex-1 flex-col"> 642 + <For each={listState().convos}> 643 + {(convo) => ( 644 + <ConvoItem 645 + active={activeConvoId() === convo.id} 646 + convo={convo} 647 + selfDid={session.activeDid} 648 + onClick={() => void openConvo(convo)} /> 649 + )} 650 + </For> 651 + 652 + <Show when={listState().convoCursor}> 653 + <button 654 + type="button" 655 + class="my-3 self-center rounded-full border-0 bg-white/5 px-4 py-2 text-xs text-on-surface-variant transition hover:bg-white/8 disabled:opacity-40" 656 + disabled={listState().loadingConvos} 657 + onClick={() => void loadMoreConvos()}> 658 + Load more 659 + </button> 660 + </Show> 661 + </div> 662 + </Show> 663 + }> 664 + {(error) => <Retry error={error()} onRetry={handleRefresh} />} 665 + </Show> 666 + </Show> 667 + </div> 668 + </aside> 669 + 670 + <div class="flex min-w-0 flex-1 flex-col overflow-hidden"> 671 + <Show when={activeConvo()} keyed fallback={<EmptyChatPane />}> 672 + {(convo) => ( 673 + <ChatPane 674 + chatState={chatState()} 675 + convo={convo} 676 + selfDid={session.activeDid} 677 + onLoadMore={() => void loadMoreMessages()} 678 + onSend={handleSend} /> 679 + )} 680 + </Show> 681 + </div> 682 + </div> 683 + ); 684 + }
+392 -13
src/components/profile/ProfilePanel.tsx
··· 2 2 import { ProfileSkeleton } from "$/components/ProfileSkeleton"; 3 3 import { Icon } from "$/components/shared/Icon"; 4 4 import { useAppSession } from "$/contexts/app-session"; 5 - import { getActorLikes, getAuthorFeed, getProfile } from "$/lib/api/profile"; 5 + import { 6 + followActor, 7 + getActorLikes, 8 + getAuthorFeed, 9 + getFollowers, 10 + getFollows, 11 + getProfile, 12 + unfollowActor, 13 + } from "$/lib/api/profile"; 14 + import { buildMessagesRoute } from "$/lib/conversations"; 6 15 import { buildThreadRoute, getAvatarLabel, getDisplayName } from "$/lib/feeds"; 7 - import { filterProfileFeed, type ProfileTab } from "$/lib/profile"; 8 - import type { FeedResponse, FeedViewPost, ProfileViewDetailed } from "$/lib/types"; 16 + import { buildProfileRoute, filterProfileFeed, getProfileRouteActor, type ProfileTab } from "$/lib/profile"; 17 + import type { ActorListResponse, FeedResponse, FeedViewPost, ProfileViewBasic, ProfileViewDetailed } from "$/lib/types"; 9 18 import { formatCount, normalizeError } from "$/lib/utils/text"; 10 19 import { useNavigate } from "@solidjs/router"; 11 20 import { createEffect, createMemo, For, Match, Show, Switch } from "solid-js"; ··· 23 32 loadingMore: boolean; 24 33 }; 25 34 35 + type ActorListState = { 36 + actors: ProfileViewBasic[]; 37 + cursor: string | null; 38 + error: string | null; 39 + kind: "followers" | "follows" | null; 40 + loading: boolean; 41 + loadingMore: boolean; 42 + }; 43 + 26 44 type ProfilePanelState = { 27 45 activeTab: ProfileTab; 46 + actorList: ActorListState; 28 47 authorFeed: FeedState; 48 + followLoading: boolean; 29 49 likesFeed: FeedState; 30 50 profile: ProfileViewDetailed | null; 31 51 profileError: string | null; ··· 37 57 return { cursor: null, error: null, items: [], loaded: false, loading: false, loadingMore: false }; 38 58 } 39 59 60 + function createActorListState(): ActorListState { 61 + return { actors: [], cursor: null, error: null, kind: null, loading: false, loadingMore: false }; 62 + } 63 + 40 64 function createProfilePanelState(): ProfilePanelState { 41 65 return { 42 66 activeTab: "posts", 67 + actorList: createActorListState(), 43 68 authorFeed: createFeedState(), 69 + followLoading: false, 44 70 likesFeed: createFeedState(), 45 71 profile: null, 46 72 profileError: null, ··· 240 266 navigate(buildThreadRoute(uri)); 241 267 } 242 268 269 + async function handleFollow() { 270 + const profile = state.profile; 271 + if (!profile || state.followLoading) { 272 + return; 273 + } 274 + 275 + const prevViewer = profile.viewer; 276 + const prevFollowersCount = profile.followersCount ?? 0; 277 + setState("followLoading", true); 278 + setState("profile", "viewer", { ...prevViewer, following: "optimistic" }); 279 + setState("profile", "followersCount", prevFollowersCount + 1); 280 + 281 + try { 282 + const result = await followActor(profile.did); 283 + setState("profile", "viewer", { ...state.profile?.viewer, following: result.uri }); 284 + } catch { 285 + setState("profile", "viewer", prevViewer ?? null); 286 + setState("profile", "followersCount", prevFollowersCount); 287 + } finally { 288 + setState("followLoading", false); 289 + } 290 + } 291 + 292 + async function handleUnfollow() { 293 + const profile = state.profile; 294 + const followUri = profile?.viewer?.following; 295 + if (!profile || !followUri || state.followLoading || followUri === "optimistic") { 296 + return; 297 + } 298 + 299 + const prevViewer = profile.viewer; 300 + const prevFollowersCount = profile.followersCount ?? 0; 301 + setState("followLoading", true); 302 + setState("profile", "viewer", { ...prevViewer, following: null }); 303 + setState("profile", "followersCount", Math.max(0, prevFollowersCount - 1)); 304 + 305 + try { 306 + await unfollowActor(followUri); 307 + } catch { 308 + setState("profile", "viewer", prevViewer ?? null); 309 + setState("profile", "followersCount", prevFollowersCount); 310 + } finally { 311 + setState("followLoading", false); 312 + } 313 + } 314 + 315 + function handleMessage() { 316 + const profile = state.profile; 317 + if (!profile || profile.did === session.activeDid) { 318 + return; 319 + } 320 + 321 + navigate(buildMessagesRoute(profile.did)); 322 + } 323 + 324 + function openActorList(kind: "followers" | "follows") { 325 + const actor = activeActor(); 326 + if (!actor) { 327 + return; 328 + } 329 + 330 + setState("actorList", { actors: [], cursor: null, error: null, kind, loading: true, loadingMore: false }); 331 + void loadActorListPage(actor, kind, false); 332 + } 333 + 334 + function closeActorList() { 335 + setState("actorList", createActorListState()); 336 + } 337 + 338 + async function loadActorListPage(actor: string, kind: "followers" | "follows", loadMore: boolean) { 339 + const current = state.actorList; 340 + const cursor = loadMore ? current.cursor : null; 341 + 342 + setState("actorList", loadMore ? "loadingMore" : "loading", true); 343 + 344 + try { 345 + const response: ActorListResponse = kind === "followers" 346 + ? await getFollowers(actor, cursor) 347 + : await getFollows(actor, cursor); 348 + 349 + const nextActors = loadMore ? [...current.actors, ...response.actors] : response.actors; 350 + setState("actorList", { 351 + actors: nextActors, 352 + cursor: response.cursor ?? null, 353 + error: null, 354 + kind, 355 + loading: false, 356 + loadingMore: false, 357 + }); 358 + } catch (error) { 359 + setState("actorList", "error", normalizeError(error)); 360 + setState("actorList", "loading", false); 361 + setState("actorList", "loadingMore", false); 362 + } 363 + } 364 + 365 + function handleActorListLoadMore() { 366 + const { kind } = state.actorList; 367 + const actor = activeActor(); 368 + if (!actor || !kind) { 369 + return; 370 + } 371 + 372 + void loadActorListPage(actor, kind, true); 373 + } 374 + 243 375 return ( 244 - <section class="grid min-h-0 overflow-hidden rounded-4xl bg-[rgba(8,8,8,0.32)] shadow-[inset_0_0_0_1px_rgba(255,255,255,0.03)]"> 376 + <section class="relative grid min-h-0 overflow-hidden rounded-4xl bg-[rgba(8,8,8,0.32)] shadow-[inset_0_0_0_1px_rgba(255,255,255,0.03)]"> 245 377 <div 246 378 class="min-h-0 overflow-y-auto overscroll-contain" 247 379 onScroll={(event) => setState("scrollTop", event.currentTarget.scrollTop)}> ··· 256 388 avatarScale={avatarScale()} 257 389 coverOffset={coverOffset()} 258 390 coverScale={coverScale()} 391 + followLoading={state.followLoading} 259 392 isSelf={isSelf()} 260 393 joinedLabel={joinedLabel()} 394 + onFollow={handleFollow} 395 + onMessage={handleMessage} 396 + onOpenFollowers={() => openActorList("followers")} 397 + onOpenFollows={() => openActorList("follows")} 398 + onUnfollow={handleUnfollow} 261 399 pinnedPostHref={pinnedPostHref()} 262 400 profile={profile()} 263 401 profileBadges={profileBadges()} ··· 279 417 </Show> 280 418 </Show> 281 419 </div> 420 + 421 + <Show when={state.actorList.kind}> 422 + <ActorListOverlay 423 + actorList={state.actorList} 424 + onClose={closeActorList} 425 + onLoadMore={handleActorListLoadMore} 426 + onSelectActor={(actor) => { 427 + closeActorList(); 428 + navigate(buildProfileRoute(getProfileRouteActor(actor))); 429 + }} /> 430 + </Show> 282 431 </section> 283 432 ); 284 433 } ··· 289 438 avatarScale: number; 290 439 coverOffset: number; 291 440 coverScale: number; 441 + followLoading: boolean; 292 442 isSelf: boolean; 293 443 joinedLabel: string | null; 444 + onFollow: () => void; 445 + onMessage: () => void; 446 + onOpenFollowers: () => void; 447 + onOpenFollows: () => void; 448 + onUnfollow: () => void; 294 449 pinnedPostHref: string | null; 295 450 profile: ProfileViewDetailed; 296 451 profileBadges: string[]; ··· 299 454 ) { 300 455 const avatarLabel = createMemo(() => getAvatarLabel(props.profile)); 301 456 const displayName = createMemo(() => getDisplayName(props.profile)); 457 + const isFollowing = createMemo(() => !!props.profile.viewer?.following); 302 458 const bannerStyle = createMemo(() => ({ 303 459 transform: `translate3d(0, ${props.coverOffset}px, 0) scale(${props.coverScale})`, 304 460 })); 305 461 const avatarStyle = createMemo(() => ({ 306 - transform: `translate3d(0, ${-10 * props.avatarProgress}px, 0) scale(${props.avatarScale})`, 462 + transform: `scale(${props.avatarScale})`, 463 + "transform-origin": "bottom left", 307 464 })); 308 465 309 466 return ( ··· 323 480 </div> 324 481 325 482 <div class="relative z-10 -mt-16 px-6 pb-6 max-[760px]:px-4 max-[520px]:px-3"> 326 - <div class="sticky top-4 z-20 mb-4 flex items-end gap-4"> 483 + <div class="sticky top-4 z-20 mb-4 flex items-center gap-3"> 327 484 <div 328 485 class="relative h-32 w-32 shrink-0 overflow-hidden rounded-full bg-black/60 shadow-[0_0_0_4px_rgba(8,8,8,0.96),0_0_0_6px_rgba(125,175,255,0.22),0_24px_40px_rgba(0,0,0,0.36)] backdrop-blur-sm transition-transform duration-100 ease-out" 329 486 style={avatarStyle()}> ··· 350 507 displayName={displayName()} 351 508 handle={props.profile.handle} 352 509 viewLabel={props.viewLabel} /> 353 - <ProfileBadgeRow badges={props.profileBadges} isSelf={props.isSelf} /> 510 + <ProfileHeroActions 511 + badges={props.profileBadges} 512 + followLoading={props.followLoading} 513 + isFollowing={isFollowing()} 514 + isSelf={props.isSelf} 515 + onFollow={props.onFollow} 516 + onMessage={props.onMessage} 517 + onUnfollow={props.onUnfollow} /> 354 518 </div> 355 519 356 520 <ProfileMetaRow ··· 360 524 website={props.profile.website ?? null} /> 361 525 362 526 <div class="flex flex-wrap gap-6"> 363 - <ProfileStat label="Following" value={props.profile.followsCount} /> 364 - <ProfileStat label="Followers" value={props.profile.followersCount} /> 527 + <ProfileStat label="Following" value={props.profile.followsCount} onClick={props.onOpenFollows} /> 528 + <ProfileStat label="Followers" value={props.profile.followersCount} onClick={props.onOpenFollowers} /> 365 529 <ProfileStat label="Posts" value={props.profile.postsCount} /> 366 530 </div> 367 531 </div> ··· 370 534 ); 371 535 } 372 536 373 - function ProfileStat(props: { label: string; value?: number | null }) { 537 + function FollowButton(props: { isFollowing: boolean; loading: boolean; onFollow: () => void; onUnfollow: () => void }) { 538 + return ( 539 + <Show 540 + when={props.isFollowing} 541 + fallback={ 542 + <button 543 + class="inline-flex min-h-9 items-center gap-2 rounded-full border border-white/20 bg-transparent px-5 text-sm font-medium text-on-surface transition duration-150 ease-out hover:bg-white/5 disabled:opacity-50" 544 + disabled={props.loading} 545 + type="button" 546 + onClick={props.onFollow}> 547 + <Show when={props.loading}> 548 + <Icon iconClass="i-ri-loader-4-line animate-spin" class="text-base" /> 549 + </Show> 550 + Follow 551 + </button> 552 + }> 553 + <button 554 + class="group inline-flex min-h-9 items-center gap-2 rounded-full bg-primary/15 px-5 text-sm font-medium text-primary shadow-[inset_0_0_0_1px_rgba(125,175,255,0.25)] transition duration-150 ease-out hover:bg-red-500/15 hover:text-red-400 hover:shadow-[inset_0_0_0_1px_rgba(239,68,68,0.25)] disabled:opacity-50" 555 + disabled={props.loading} 556 + type="button" 557 + onClick={() => props.onUnfollow()}> 558 + <Show when={props.loading}> 559 + <Icon iconClass="i-ri-loader-4-line animate-spin" class="text-base" /> 560 + </Show> 561 + <span class="group-hover:hidden">Following</span> 562 + <span class="hidden group-hover:inline">Unfollow</span> 563 + </button> 564 + </Show> 565 + ); 566 + } 567 + 568 + function MessageButton(props: { onClick: () => void }) { 374 569 return ( 375 - <div class="grid gap-1"> 376 - <span class="text-lg font-semibold tracking-[-0.02em] text-on-surface">{formatCount(props.value ?? 0)}</span> 377 - <span class="text-xs uppercase tracking-[0.12em] text-on-surface-variant">{props.label}</span> 570 + <button 571 + class="inline-flex min-h-9 items-center gap-2 rounded-full border border-white/12 bg-white/6 px-4 text-sm font-medium text-on-surface transition duration-150 ease-out hover:bg-white/10" 572 + type="button" 573 + onClick={() => props.onClick()}> 574 + <Icon kind="messages" class="text-base" /> 575 + Message 576 + </button> 577 + ); 578 + } 579 + 580 + function ProfileHeroActions( 581 + props: { 582 + badges: string[]; 583 + followLoading: boolean; 584 + isFollowing: boolean; 585 + isSelf: boolean; 586 + onFollow: () => void; 587 + onMessage: () => void; 588 + onUnfollow: () => void; 589 + }, 590 + ) { 591 + return ( 592 + <div class="flex flex-col items-end gap-2"> 593 + <Show when={!props.isSelf}> 594 + <div class="flex flex-wrap justify-end gap-2"> 595 + <MessageButton onClick={props.onMessage} /> 596 + <FollowButton 597 + isFollowing={props.isFollowing} 598 + loading={props.followLoading} 599 + onFollow={props.onFollow} 600 + onUnfollow={props.onUnfollow} /> 601 + </div> 602 + </Show> 603 + <ProfileBadgeRow badges={props.badges} isSelf={props.isSelf} /> 378 604 </div> 605 + ); 606 + } 607 + 608 + function ProfileStat(props: { label: string; onClick?: () => void; value?: number | null }) { 609 + return ( 610 + <Show 611 + when={props.onClick} 612 + fallback={ 613 + <div class="grid gap-1"> 614 + <span class="text-lg font-semibold tracking-[-0.02em] text-on-surface">{formatCount(props.value ?? 0)}</span> 615 + <span class="text-xs uppercase tracking-[0.12em] text-on-surface-variant">{props.label}</span> 616 + </div> 617 + }> 618 + {(onClick) => ( 619 + <button 620 + class="grid gap-1 text-left transition duration-150 ease-out hover:opacity-80" 621 + type="button" 622 + onClick={onClick()}> 623 + <span class="text-lg font-semibold tracking-[-0.02em] text-on-surface">{formatCount(props.value ?? 0)}</span> 624 + <span class="text-xs uppercase tracking-[0.12em] text-on-surface-variant">{props.label}</span> 625 + </button> 626 + )} 627 + </Show> 379 628 ); 380 629 } 381 630 ··· 640 889 <p class="m-0 text-lg font-semibold tracking-[-0.02em] text-on-surface">{props.title}</p> 641 890 <p class="m-0 text-sm leading-relaxed text-on-surface-variant">{props.body}</p> 642 891 </div> 892 + </div> 893 + ); 894 + } 895 + 896 + function ActorListOverlay( 897 + props: { 898 + actorList: ActorListState; 899 + onClose: () => void; 900 + onLoadMore: () => void; 901 + onSelectActor: (actor: ProfileViewBasic) => void; 902 + }, 903 + ) { 904 + const title = createMemo(() => props.actorList.kind === "followers" ? "Followers" : "Following"); 905 + 906 + return ( 907 + <div class="absolute inset-0 z-50 flex flex-col overflow-hidden rounded-4xl bg-[rgba(8,8,8,0.88)] backdrop-blur-xl"> 908 + <ActorListHeader onClose={props.onClose} title={title()} /> 909 + 910 + <div class="min-h-0 flex-1 overflow-y-auto overscroll-contain"> 911 + <ActorListContent actorList={props.actorList} onSelectActor={props.onSelectActor} title={title()} /> 912 + 913 + <Show when={props.actorList.cursor}> 914 + <ActorListLoadMoreButton loadingMore={props.actorList.loadingMore} onLoadMore={props.onLoadMore} /> 915 + </Show> 916 + </div> 917 + </div> 918 + ); 919 + } 920 + 921 + function ActorListHeader(props: { onClose: () => void; title: string }) { 922 + return ( 923 + <div class="flex shrink-0 items-center justify-between border-b border-white/5 px-5 py-4"> 924 + <p class="m-0 text-base font-semibold text-on-surface">{props.title}</p> 925 + <button 926 + class="flex h-8 w-8 items-center justify-center rounded-full border-0 bg-white/6 text-on-surface-variant transition hover:bg-white/10 hover:text-on-surface" 927 + type="button" 928 + onClick={() => props.onClose()}> 929 + <Icon iconClass="i-ri-close-line" class="text-base" /> 930 + </button> 931 + </div> 932 + ); 933 + } 934 + 935 + function ActorListLoadMoreButton(props: { loadingMore: boolean; onLoadMore: () => void }) { 936 + return ( 937 + <div class="flex justify-center py-4"> 938 + <button 939 + class="inline-flex min-h-10 items-center gap-2 rounded-full border-0 bg-white/6 px-5 text-sm font-medium text-on-surface transition hover:-translate-y-px hover:bg-white/10 disabled:translate-y-0 disabled:opacity-70" 940 + disabled={props.loadingMore} 941 + type="button" 942 + onClick={() => props.onLoadMore()}> 943 + <Show when={props.loadingMore}> 944 + <Icon iconClass="i-ri-loader-4-line animate-spin" class="text-base" /> 945 + </Show> 946 + {props.loadingMore ? "Loading..." : "Load more"} 947 + </button> 948 + </div> 949 + ); 950 + } 951 + 952 + function ActorListContent( 953 + props: { actorList: ActorListState; onSelectActor: (actor: ProfileViewBasic) => void; title: string }, 954 + ) { 955 + return ( 956 + <Show when={!props.actorList.loading} fallback={<ActorListSkeleton />}> 957 + <Show 958 + when={!props.actorList.error} 959 + fallback={ 960 + <div class="grid place-items-center py-12 text-sm text-on-surface-variant">{props.actorList.error}</div> 961 + }> 962 + <Show 963 + when={props.actorList.actors.length > 0} 964 + fallback={ 965 + <div class="grid place-items-center py-12 text-sm text-on-surface-variant"> 966 + No {props.title.toLowerCase()} yet. 967 + </div> 968 + }> 969 + <div class="divide-y divide-white/5"> 970 + <For each={props.actorList.actors}> 971 + {(actor) => <ActorCard actor={actor} onSelect={() => props.onSelectActor(actor)} />} 972 + </For> 973 + </div> 974 + </Show> 975 + </Show> 976 + </Show> 977 + ); 978 + } 979 + 980 + function ActorCard(props: { actor: ProfileViewBasic; onSelect: () => void }) { 981 + const label = createMemo(() => getAvatarLabel(props.actor)); 982 + const name = createMemo(() => getDisplayName(props.actor)); 983 + 984 + return ( 985 + <button 986 + class="flex w-full items-center gap-3 border-0 bg-transparent px-5 py-3 text-left transition hover:bg-white/4" 987 + type="button" 988 + onClick={() => props.onSelect()}> 989 + <div class="h-10 w-10 shrink-0 overflow-hidden rounded-full bg-surface-container-high"> 990 + <Show 991 + when={props.actor.avatar} 992 + fallback={ 993 + <div class="flex h-full w-full items-center justify-center text-sm font-semibold text-on-surface"> 994 + {label()} 995 + </div> 996 + }> 997 + {(avatar) => <img alt="" class="h-full w-full object-cover" src={avatar()} />} 998 + </Show> 999 + </div> 1000 + <div class="min-w-0 flex-1"> 1001 + <p class="m-0 truncate text-sm font-medium text-on-surface">{name()}</p> 1002 + <p class="m-0 truncate text-xs text-on-surface-variant">@{props.actor.handle}</p> 1003 + </div> 1004 + </button> 1005 + ); 1006 + } 1007 + 1008 + function ActorListSkeleton() { 1009 + return ( 1010 + <div class="divide-y divide-white/5"> 1011 + <For each={Array.from({ length: 6 })}> 1012 + {() => ( 1013 + <div class="flex items-center gap-3 px-5 py-3"> 1014 + <span class="skeleton-block h-10 w-10 shrink-0 rounded-full" /> 1015 + <div class="grid flex-1 gap-1.5"> 1016 + <span class="skeleton-block h-3.5 w-32 rounded-full" /> 1017 + <span class="skeleton-block h-3 w-24 rounded-full" /> 1018 + </div> 1019 + </div> 1020 + )} 1021 + </For> 643 1022 </div> 644 1023 ); 645 1024 }
+1
src/components/rail/AppRail.tsx
··· 42 42 href="/notifications" 43 43 label="Notifications" 44 44 icon="notifications" /> 45 + <RailButton end compact={props.collapsed} href="/messages" label="Messages" icon="messages" /> 45 46 <RailButton end compact={props.collapsed} href="/deck" label="Deck" icon="deck" /> 46 47 <RailButton end compact={props.collapsed} href="/explorer" label="AT Explorer" icon="explorer" /> 47 48 <RailButton end compact={props.collapsed} href="/settings" label="Settings" icon="settings" />
+32 -1
src/components/shared/Icon.tsx
··· 2 2 import type { ExplorerTargetKind } from "$/lib/api/types/explorer"; 3 3 import { type JSX, Match, splitProps, Switch } from "solid-js"; 4 4 5 + export type ActionIconKind = "add" | "edit" | "delete" | "save" | "cancel"; 6 + 5 7 export type SettingsIconKind = 6 8 | "computer" 7 9 | "info" ··· 53 55 | "theme" 54 56 | "deck" 55 57 | "list" 56 - | "rss"; 58 + | "rss" 59 + | "messages"; 57 60 58 61 type IconProps = JSX.HTMLAttributes<HTMLSpanElement> & { 59 62 class?: string; ··· 167 170 <Match when={local.kind === "rss"}> 168 171 <i class="i-ri-rss-line" /> 169 172 </Match> 173 + <Match when={local.kind === "messages"}> 174 + <i class="i-ri-message-3-line" /> 175 + </Match> 170 176 </Switch> 171 177 </span> 172 178 ); ··· 268 274 </span> 269 275 ); 270 276 } 277 + 278 + export function ActionIcon(props: Omit<IconProps, "kind"> & { kind: ActionIconKind }) { 279 + const [local, rest] = splitProps(props, ["class", "iconClass", "kind", "name"]); 280 + return ( 281 + <span class="flex items-center justify-center" classList={{ [local.class ?? ""]: !!local.class }} {...rest}> 282 + <Switch> 283 + <Match when={local.kind === "add"}> 284 + <i class="i-ri-add-line" /> 285 + </Match> 286 + <Match when={local.kind === "edit"}> 287 + <i class="i-ri-edit-line" /> 288 + </Match> 289 + <Match when={local.kind === "delete"}> 290 + <i class="i-ri-delete-line" /> 291 + </Match> 292 + <Match when={local.kind === "save"}> 293 + <i class="i-ri-save-line" /> 294 + </Match> 295 + <Match when={local.kind === "cancel"}> 296 + <i class="i-ri-close-line" /> 297 + </Match> 298 + </Switch> 299 + </span> 300 + ); 301 + }
+88
src/lib/api/conversations.test.ts
··· 1 + import { describe, expect, it } from "vitest"; 2 + import { 3 + parseGetConvoForMembersResponse, 4 + parseGetMessagesResponse, 5 + parseListConvosResponse, 6 + parseSendMessageResponse, 7 + } from "./conversations"; 8 + 9 + function createMember(overrides: Record<string, unknown> = {}) { 10 + return { did: "did:plc:bob", displayName: "Bob", handle: "bob.test", ...overrides }; 11 + } 12 + 13 + function createMessage(overrides: Record<string, unknown> = {}) { 14 + return { 15 + $type: "chat.bsky.convo.defs#messageView", 16 + id: "msg-1", 17 + rev: "1", 18 + sender: { did: "did:plc:bob" }, 19 + sentAt: "2026-03-29T12:00:00.000Z", 20 + text: "hello", 21 + ...overrides, 22 + }; 23 + } 24 + 25 + function createConvo(overrides: Record<string, unknown> = {}) { 26 + return { 27 + id: "convo-1", 28 + lastMessage: createMessage(), 29 + members: [createMember()], 30 + muted: false, 31 + rev: "1", 32 + status: "active", 33 + unreadCount: 2, 34 + ...overrides, 35 + }; 36 + } 37 + 38 + describe("conversation payload parsers", () => { 39 + it("parses the conversation list response", () => { 40 + const response = parseListConvosResponse({ convos: [createConvo()], cursor: "cursor-1" }); 41 + 42 + expect(response.cursor).toBe("cursor-1"); 43 + expect(response.convos).toHaveLength(1); 44 + expect(response.convos[0]?.members[0]?.handle).toBe("bob.test"); 45 + }); 46 + 47 + it("parses a conversation lookup response", () => { 48 + const response = parseGetConvoForMembersResponse({ convo: createConvo() }); 49 + 50 + expect(response.convo.id).toBe("convo-1"); 51 + }); 52 + 53 + it("parses mixed message payloads", () => { 54 + const response = parseGetMessagesResponse({ 55 + cursor: "cursor-2", 56 + messages: [createMessage(), { 57 + $type: "chat.bsky.convo.defs#deletedMessageView", 58 + id: "msg-2", 59 + rev: "2", 60 + sender: { did: "did:plc:alice" }, 61 + sentAt: "2026-03-29T12:01:00.000Z", 62 + }], 63 + }); 64 + 65 + expect(response.cursor).toBe("cursor-2"); 66 + expect(response.messages).toHaveLength(2); 67 + expect(response.messages[1]?.$type).toBe("chat.bsky.convo.defs#deletedMessageView"); 68 + }); 69 + 70 + it("parses a sent message payload", () => { 71 + const response = parseSendMessageResponse(createMessage({ id: "msg-3" })); 72 + 73 + expect(response.id).toBe("msg-3"); 74 + expect(response.text).toBe("hello"); 75 + }); 76 + 77 + it("rejects invalid conversations", () => { 78 + expect(() => parseListConvosResponse({ convos: [{ nope: true }] })).toThrow( 79 + "conversations response contains an invalid conversation", 80 + ); 81 + }); 82 + 83 + it("rejects invalid messages", () => { 84 + expect(() => parseGetMessagesResponse({ messages: [{ nope: true }] })).toThrow( 85 + "messages response contains an invalid message", 86 + ); 87 + }); 88 + });
+220
src/lib/api/conversations.ts
··· 1 + import type { 2 + ConvoView, 3 + DeletedMessageView, 4 + GetConvoForMembersResponse, 5 + GetMessagesResponse, 6 + ListConvosResponse, 7 + MessageView, 8 + ProfileViewBasic, 9 + } from "$/lib/types"; 10 + import { invoke } from "@tauri-apps/api/core"; 11 + import { asArray, asRecord } from "../type-guards"; 12 + 13 + function optionalNumber(value: unknown) { 14 + return typeof value === "number" ? value : null; 15 + } 16 + 17 + function optionalString(value: unknown) { 18 + return typeof value === "string" ? value : null; 19 + } 20 + 21 + function parseProfileBasic(value: unknown): ProfileViewBasic | null { 22 + const record = asRecord(value); 23 + if (!record || typeof record.did !== "string" || typeof record.handle !== "string") { 24 + return null; 25 + } 26 + 27 + const viewer = asRecord(record.viewer); 28 + 29 + return { 30 + avatar: optionalString(record.avatar), 31 + did: record.did, 32 + displayName: optionalString(record.displayName), 33 + handle: record.handle, 34 + viewer: viewer ? { following: optionalString(viewer.following) } : null, 35 + }; 36 + } 37 + 38 + function parseMessageSender(value: unknown) { 39 + const record = asRecord(value); 40 + if (!record || typeof record.did !== "string") { 41 + return null; 42 + } 43 + 44 + return { did: record.did }; 45 + } 46 + 47 + function parseDeletedMessageView(value: unknown): DeletedMessageView | null { 48 + const record = asRecord(value); 49 + const sender = parseMessageSender(record?.sender); 50 + if ( 51 + !record || !sender || typeof record.id !== "string" || typeof record.rev !== "string" 52 + || typeof record.sentAt !== "string" 53 + ) { 54 + return null; 55 + } 56 + 57 + return { 58 + $type: optionalString(record.$type) as DeletedMessageView["$type"], 59 + id: record.id, 60 + rev: record.rev, 61 + sender, 62 + sentAt: record.sentAt, 63 + }; 64 + } 65 + 66 + function parseMessageView(value: unknown): MessageView | null { 67 + const record = asRecord(value); 68 + const sender = parseMessageSender(record?.sender); 69 + if ( 70 + !record 71 + || !sender 72 + || typeof record.id !== "string" 73 + || typeof record.rev !== "string" 74 + || typeof record.sentAt !== "string" 75 + || typeof record.text !== "string" 76 + ) { 77 + return null; 78 + } 79 + 80 + return { 81 + $type: optionalString(record.$type) as MessageView["$type"], 82 + id: record.id, 83 + rev: record.rev, 84 + sender, 85 + sentAt: record.sentAt, 86 + text: record.text, 87 + }; 88 + } 89 + 90 + function parseConvoMessage(value: unknown) { 91 + const record = asRecord(value); 92 + if (!record) { 93 + return null; 94 + } 95 + 96 + if (record.$type === "chat.bsky.convo.defs#deletedMessageView") { 97 + return parseDeletedMessageView(record); 98 + } 99 + 100 + return parseMessageView(record); 101 + } 102 + 103 + function parseConvoView(value: unknown): ConvoView | null { 104 + const record = asRecord(value); 105 + const rawMembers = asArray(record?.members); 106 + if ( 107 + !record 108 + || !rawMembers 109 + || typeof record.id !== "string" 110 + || typeof record.rev !== "string" 111 + || typeof record.muted !== "boolean" 112 + ) { 113 + return null; 114 + } 115 + 116 + const members = rawMembers.map((member) => parseProfileBasic(member)); 117 + if (members.some((member) => !member)) { 118 + return null; 119 + } 120 + 121 + const lastMessage = record.lastMessage === null || record.lastMessage === undefined 122 + ? null 123 + : parseConvoMessage(record.lastMessage); 124 + if (record.lastMessage !== null && record.lastMessage !== undefined && !lastMessage) { 125 + return null; 126 + } 127 + 128 + return { 129 + id: record.id, 130 + lastMessage, 131 + members: members as ProfileViewBasic[], 132 + muted: record.muted, 133 + rev: record.rev, 134 + status: optionalString(record.status), 135 + unreadCount: optionalNumber(record.unreadCount) ?? 0, 136 + }; 137 + } 138 + 139 + export function parseListConvosResponse(value: unknown): ListConvosResponse { 140 + const record = asRecord(value); 141 + const rawConvos = asArray(record?.convos); 142 + if (!record || !rawConvos) { 143 + throw new Error("conversations response payload is invalid"); 144 + } 145 + 146 + const convos = rawConvos.map((convo) => parseConvoView(convo)); 147 + if (convos.some((convo) => !convo)) { 148 + throw new Error("conversations response contains an invalid conversation"); 149 + } 150 + 151 + if (record.cursor !== undefined && record.cursor !== null && typeof record.cursor !== "string") { 152 + throw new Error("conversations response cursor is invalid"); 153 + } 154 + 155 + return { convos: convos as ConvoView[], cursor: optionalString(record.cursor) }; 156 + } 157 + 158 + export function parseGetConvoForMembersResponse(value: unknown): GetConvoForMembersResponse { 159 + const record = asRecord(value); 160 + const convo = parseConvoView(record?.convo); 161 + if (!record || !convo) { 162 + throw new Error("conversation payload is invalid"); 163 + } 164 + 165 + return { convo }; 166 + } 167 + 168 + export function parseGetMessagesResponse(value: unknown): GetMessagesResponse { 169 + const record = asRecord(value); 170 + const rawMessages = asArray(record?.messages); 171 + if (!record || !rawMessages) { 172 + throw new Error("messages response payload is invalid"); 173 + } 174 + 175 + const messages = rawMessages.map((message) => parseConvoMessage(message)); 176 + if (messages.some((message) => !message)) { 177 + throw new Error("messages response contains an invalid message"); 178 + } 179 + 180 + if (record.cursor !== undefined && record.cursor !== null && typeof record.cursor !== "string") { 181 + throw new Error("messages response cursor is invalid"); 182 + } 183 + 184 + return { cursor: optionalString(record.cursor), messages: messages as Array<MessageView | DeletedMessageView> }; 185 + } 186 + 187 + export function parseSendMessageResponse(value: unknown): MessageView { 188 + const message = parseMessageView(value); 189 + if (!message) { 190 + throw new Error("sent message payload is invalid"); 191 + } 192 + 193 + return message; 194 + } 195 + 196 + export async function listConvos(cursor?: string | null, limit?: number): Promise<ListConvosResponse> { 197 + return invoke("list_convos", { cursor: cursor ?? null, limit: limit ?? null }).then(parseListConvosResponse); 198 + } 199 + 200 + export async function getConvoForMembers(members: string[]): Promise<GetConvoForMembersResponse> { 201 + return invoke("get_convo_for_members", { members }).then(parseGetConvoForMembersResponse); 202 + } 203 + 204 + export async function getMessages( 205 + convoId: string, 206 + cursor?: string | null, 207 + limit?: number, 208 + ): Promise<GetMessagesResponse> { 209 + return invoke("get_messages", { convoId, cursor: cursor ?? null, limit: limit ?? null }).then( 210 + parseGetMessagesResponse, 211 + ); 212 + } 213 + 214 + export async function sendMessage(convoId: string, text: string): Promise<MessageView> { 215 + return invoke("send_message", { convoId, text }).then(parseSendMessageResponse); 216 + } 217 + 218 + export async function updateRead(convoId: string, messageId?: string | null): Promise<void> { 219 + return invoke("update_read", { convoId, messageId: messageId ?? null }); 220 + }
+24 -1
src/lib/api/profile.ts
··· 1 - import { parseProfile, parseProfileFeed } from "$/lib/profile"; 1 + import { parseActorList, parseProfile, parseProfileFeed } from "$/lib/profile"; 2 + import type { CreateRecordResult } from "$/lib/types"; 2 3 import { invoke } from "@tauri-apps/api/core"; 3 4 4 5 export async function getProfile(actor: string) { ··· 12 13 export async function getActorLikes(actor: string, cursor?: string | null, limit?: number) { 13 14 return parseProfileFeed(await invoke("get_actor_likes", { actor, cursor: cursor ?? null, limit: limit ?? null })); 14 15 } 16 + 17 + export async function followActor(did: string): Promise<CreateRecordResult> { 18 + return invoke("follow_actor", { did }); 19 + } 20 + 21 + export async function unfollowActor(followUri: string): Promise<void> { 22 + return invoke("unfollow_actor", { followUri }); 23 + } 24 + 25 + export async function getFollowers(actor: string, cursor?: string | null, limit?: number) { 26 + return parseActorList( 27 + await invoke("get_followers", { actor, cursor: cursor ?? null, limit: limit ?? null }), 28 + "followers", 29 + ); 30 + } 31 + 32 + export async function getFollows(actor: string, cursor?: string | null, limit?: number) { 33 + return parseActorList( 34 + await invoke("get_follows", { actor, cursor: cursor ?? null, limit: limit ?? null }), 35 + "follows", 36 + ); 37 + }
+22
src/lib/conversations.ts
··· 1 + export const MESSAGES_ROUTE = "/messages"; 2 + 3 + export function buildMessagesRoute(memberDid?: string | null) { 4 + const trimmed = memberDid?.trim(); 5 + if (!trimmed) { 6 + return MESSAGES_ROUTE; 7 + } 8 + 9 + return `${MESSAGES_ROUTE}/${encodeURIComponent(trimmed)}`; 10 + } 11 + 12 + export function decodeMessagesRouteMemberDid(value?: string | null) { 13 + if (!value) { 14 + return null; 15 + } 16 + 17 + try { 18 + return decodeURIComponent(value); 19 + } catch { 20 + return value; 21 + } 22 + }
+29 -2
src/lib/profile.ts
··· 1 1 import { isReplyItem, parseFeedResponse } from "$/lib/feeds"; 2 - import type { FeedResponse, FeedViewPost, ProfileViewDetailed } from "$/lib/types"; 3 - import { asRecord } from "./type-guards"; 2 + import type { ActorListResponse, FeedResponse, FeedViewPost, ProfileViewBasic, ProfileViewDetailed } from "$/lib/types"; 3 + import { asArray, asRecord } from "./type-guards"; 4 4 5 5 export type ProfileTab = "posts" | "replies" | "media" | "likes"; 6 6 ··· 60 60 61 61 export function parseProfileFeed(value: unknown): FeedResponse { 62 62 return parseFeedResponse(value); 63 + } 64 + 65 + export function parseActorList(value: unknown, listKey: "followers" | "follows"): ActorListResponse { 66 + const record = asRecord(value); 67 + if (!record) { 68 + throw new Error("actor list payload is invalid"); 69 + } 70 + 71 + const rawActors = asArray(record[listKey]) ?? []; 72 + const actors = rawActors.map((item) => parseProfileBasic(item)).filter(Boolean) as ProfileViewBasic[]; 73 + 74 + return { cursor: optionalString(record.cursor), actors }; 75 + } 76 + 77 + function parseProfileBasic(value: unknown): ProfileViewBasic | null { 78 + const record = asRecord(value); 79 + if (!record || typeof record.did !== "string" || typeof record.handle !== "string") { 80 + return null; 81 + } 82 + 83 + return { 84 + did: record.did, 85 + handle: record.handle, 86 + displayName: optionalString(record.displayName), 87 + avatar: optionalString(record.avatar), 88 + viewer: asRecord(record.viewer) ? { following: optionalString(asRecord(record.viewer)?.following) } : null, 89 + }; 63 90 } 64 91 65 92 export function filterProfileFeed(items: FeedViewPost[], tab: ProfileTab) {
+41
src/lib/types.ts
··· 56 56 website?: string | null; 57 57 }; 58 58 59 + export type ActorListResponse = { cursor?: string | null; actors: ProfileViewBasic[] }; 60 + 59 61 export type FeedGeneratorView = { 60 62 uri: string; 61 63 did: string; ··· 249 251 export type RefreshInterval = 30 | 60 | 120 | 300 | 0; 250 252 251 253 export type Theme = "light" | "dark" | "auto"; 254 + 255 + // ── DMs / Conversations ────────────────────────────────────────────────────── 256 + 257 + export type MessageViewSender = { did: string }; 258 + 259 + export type MessageView = { 260 + $type?: "chat.bsky.convo.defs#messageView"; 261 + id: string; 262 + text: string; 263 + sender: MessageViewSender; 264 + sentAt: string; 265 + rev: string; 266 + }; 267 + 268 + export type DeletedMessageView = { 269 + $type?: "chat.bsky.convo.defs#deletedMessageView"; 270 + id: string; 271 + rev: string; 272 + sender: MessageViewSender; 273 + sentAt: string; 274 + }; 275 + 276 + export type ConvoLastMessage = MessageView | DeletedMessageView; 277 + 278 + export type ConvoView = { 279 + id: string; 280 + members: ProfileViewBasic[]; 281 + lastMessage?: ConvoLastMessage | null; 282 + unreadCount: number; 283 + muted: boolean; 284 + rev: string; 285 + status?: string | null; 286 + }; 287 + 288 + export type ListConvosResponse = { convos: ConvoView[]; cursor?: string | null }; 289 + 290 + export type GetConvoForMembersResponse = { convo: ConvoView }; 291 + 292 + export type GetMessagesResponse = { messages: Array<MessageView | DeletedMessageView>; cursor?: string | null };
+17 -1
src/router.test.tsx
··· 2 2 import { render, screen } from "@solidjs/testing-library"; 3 3 import type { Component, ParentProps } from "solid-js"; 4 4 import { beforeEach, describe, expect, it, vi } from "vitest"; 5 + import { buildMessagesRoute } from "./lib/conversations"; 5 6 import { buildThreadRoute } from "./lib/feeds"; 6 7 import { buildProfileRoute } from "./lib/profile"; 7 8 import { AppRouter } from "./router"; ··· 29 30 <div data-testid="timeline-view"> 30 31 <span>{props.context.threadUri ?? "no-thread"}</span> 31 32 </div> 33 + )); 34 + 35 + const renderMessages = vi.fn((props: { memberDid: string | null }) => ( 36 + <div data-testid="messages-view">{props.memberDid ?? "messages"}</div> 32 37 )); 33 38 34 39 render(() => ( ··· 41 46 <AppRouter 42 47 renderAuth={() => <div>Auth</div>} 43 48 renderComposer={renderComposer} 49 + renderMessages={renderMessages} 44 50 renderNotifications={renderNotifications} 45 51 renderProfile={renderProfile} 46 52 renderShell={Shell} ··· 48 54 </AppTestProviders> 49 55 )); 50 56 51 - return { renderComposer, renderNotifications, renderProfile, renderTimeline }; 57 + return { renderComposer, renderMessages, renderNotifications, renderProfile, renderTimeline }; 52 58 } 53 59 54 60 describe("AppRouter", () => { ··· 94 100 expect(renderNotifications).toHaveBeenCalledOnce(); 95 101 expect(screen.getByText("notifications")).toBeInTheDocument(); 96 102 expect(screen.getByTestId("shell")).toHaveAttribute("data-full-width", "false"); 103 + }); 104 + 105 + it("passes the decoded member did on targeted message routes", async () => { 106 + const memberDid = "did:plc:bob"; 107 + const { renderMessages } = renderRouter(`#${buildMessagesRoute(memberDid)}`); 108 + 109 + await screen.findByTestId("messages-view"); 110 + 111 + expect(renderMessages.mock.lastCall?.[0].memberDid).toBe(memberDid); 112 + expect(screen.getByText(memberDid)).toBeInTheDocument(); 97 113 }); 98 114 99 115 it("renders the explorer route inside the full-width shell", async () => {
+21
src/router.tsx
··· 8 8 import { ExplorerPanel } from "./components/explorer/ExplorerPanel"; 9 9 import { SearchPanel } from "./components/search/SearchPanel"; 10 10 import { SettingsPanel } from "./components/settings/SettingsPanel"; 11 + import { decodeMessagesRouteMemberDid } from "./lib/conversations"; 11 12 import { buildThreadRoute, decodeThreadRouteUri, TIMELINE_ROUTE } from "./lib/feeds"; 12 13 import { decodeProfileRouteActor } from "./lib/profile"; 13 14 14 15 type TTimelineRouteProps = { context: { onThreadRouteChange: (uri: string | null) => void; threadUri: string | null } }; 16 + type TMessagesRouteProps = { memberDid: string | null }; 15 17 type TProfileRouteProps = { actor: string | null }; 16 18 17 19 type AppShellProps = ParentProps<{ fullWidth?: boolean }>; ··· 19 21 type AppRouterProps = { 20 22 renderAuth: () => JSX.Element; 21 23 renderComposer: () => JSX.Element; 24 + renderMessages: Component<TMessagesRouteProps>; 22 25 renderNotifications: () => JSX.Element; 23 26 renderProfile: Component<TProfileRouteProps>; 24 27 renderShell: Component<AppShellProps>; ··· 98 101 99 102 const NotificationsRoute = () => <ProtectedRouteView>{props.renderNotifications()}</ProtectedRouteView>; 100 103 104 + const MessagesRoute = () => ( 105 + <ProtectedRouteView> 106 + <Dynamic component={props.renderMessages} memberDid={null} /> 107 + </ProtectedRouteView> 108 + ); 109 + 110 + const MemberMessagesRoute = () => { 111 + const params = useParams<{ memberDid: string }>(); 112 + 113 + return ( 114 + <ProtectedRouteView> 115 + <Dynamic component={props.renderMessages} memberDid={decodeMessagesRouteMemberDid(params.memberDid)} /> 116 + </ProtectedRouteView> 117 + ); 118 + }; 119 + 101 120 const ComposerRoute = () => <ProtectedRouteView>{props.renderComposer()}</ProtectedRouteView>; 102 121 103 122 const DeckRoute = () => ( ··· 135 154 <Route path="/composer" component={ComposerRoute} /> 136 155 <Route path="/search" component={SearchRoute} /> 137 156 <Route path="/notifications" component={NotificationsRoute} /> 157 + <Route path="/messages" component={MessagesRoute} /> 158 + <Route path="/messages/:memberDid" component={MemberMessagesRoute} /> 138 159 <Route path="/deck" component={DeckRoute} /> 139 160 <Route path="/explorer" component={ExplorerRoute} /> 140 161 <Route path="/settings" component={SettingsRoute} />