use fido_types::{Post, User, UserProfile};
use ratatui::widgets::ListState;
use std::time::Instant;
use tui_textarea::TextArea;
use uuid::Uuid;
use crate::api::Backend;
#[cfg(target_os = "macos")]
pub fn get_modifier_key_name() -> &'static str {
"Cmd"
}
#[cfg(not(target_os = "macos"))]
pub fn get_modifier_key_name() -> &'static str {
"Ctrl"
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum InputMode {
Navigation, Typing, }
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum SettingsField {
ColorScheme,
SortOrder,
MaxPosts,
}
#[derive(Debug, Clone)]
pub enum ComposerMode {
NewPost,
Reply {
parent_post_id: Uuid,
parent_author: String,
parent_content: String,
},
EditBio,
}
pub struct ComposerState {
pub mode: Option<ComposerMode>,
pub textarea: TextArea<'static>,
pub max_chars: usize,
}
impl ComposerState {
pub fn new() -> Self {
let mut textarea = TextArea::default();
textarea.set_hard_tab_indent(true);
Self {
mode: None,
textarea,
max_chars: 280,
}
}
pub fn is_open(&self) -> bool {
self.mode.is_some()
}
pub fn get_content(&self) -> String {
self.textarea.lines().join("\n")
}
pub fn char_count(&self) -> usize {
crate::emoji::count_characters(&self.get_content())
}
}
pub struct FriendsState {
pub show_friends_modal: bool,
pub selected_tab: SocialTab,
pub following: Vec<UserInfo>,
pub followers: Vec<UserInfo>,
pub mutual_friends: Vec<UserInfo>,
pub selected_index: usize,
pub search_query: String,
pub search_mode: bool,
pub error: Option<String>,
pub loading: bool,
pub return_to_modal_after_profile: bool, }
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum SocialTab {
Following,
Followers,
MutualFriends,
}
#[derive(Debug, Clone)]
pub struct UserInfo {
pub username: String,
pub follower_count: usize,
pub following_count: usize,
}
pub struct HashtagsState {
pub hashtags: Vec<String>,
pub show_hashtags_modal: bool,
pub show_add_hashtag_input: bool,
pub add_hashtag_name: String,
pub selected_hashtag: usize,
pub error: Option<String>,
pub loading: bool,
pub show_unfollow_confirmation: bool,
pub hashtag_to_unfollow: Option<String>,
}
pub struct UserSearchState {
pub show_modal: bool,
pub search_query: String,
pub search_results: Vec<UserSearchResult>,
pub selected_index: usize,
pub loading: bool,
pub error: Option<String>,
}
#[derive(Debug, Clone)]
pub struct UserSearchResult {
pub username: String,
}
pub struct App {
pub running: bool,
pub current_screen: Screen,
pub api_client: Backend,
pub auth_state: AuthState,
pub current_tab: Tab,
pub posts_state: PostsState,
pub profile_state: ProfileState,
pub dms_state: DMsState,
pub settings_state: SettingsState,
pub post_detail_state: Option<PostDetailState>,
pub viewing_post_detail: bool,
pub config_manager: crate::config::ConfigManager,
pub instance_id: String,
pub show_help: bool,
pub input_mode: InputMode,
pub composer_state: ComposerState,
pub friends_state: FriendsState,
pub hashtags_state: HashtagsState,
pub user_search_state: UserSearchState,
pub user_profile_view: Option<UserProfileViewState>,
pub log_config: crate::logging::LogConfig,
pub is_demo_mode: bool,
pub pending_vote_tasks:
Vec<tokio::task::JoinHandle<(uuid::Uuid, crate::api::ApiResult<serde_json::Value>)>>,
pub update_available: Option<String>,
}
pub struct SettingsState {
pub config: Option<fido_types::UserConfig>,
pub original_config: Option<fido_types::UserConfig>,
pub original_max_posts_input: String,
pub loading: bool,
pub error: Option<String>,
pub selected_field: SettingsField,
pub max_posts_input: String,
pub has_unsaved_changes: bool,
pub show_save_confirmation: bool,
pub pending_tab: Option<Tab>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum DMSelection {
NewConversation,
PendingDraft,
Conversation(usize),
}
impl DMSelection {
pub fn is_new_conversation(&self) -> bool {
matches!(self, DMSelection::NewConversation)
}
pub fn is_pending_draft(&self) -> bool {
matches!(self, DMSelection::PendingDraft)
}
pub fn conversation_index(&self) -> Option<usize> {
match self {
DMSelection::Conversation(idx) => Some(*idx),
_ => None,
}
}
}
pub struct DMsState {
pub conversations: Vec<Conversation>,
pub conversations_loaded: bool, pub selection: DMSelection, pub messages: Vec<fido_types::DirectMessage>,
pub loading: bool,
pub error: Option<String>,
pub message_textarea: TextArea<'static>, pub show_new_conversation_modal: bool,
pub new_conversation_username: String,
pub pending_conversation_username: Option<String>, pub unread_counts: std::collections::HashMap<uuid::Uuid, usize>, pub current_conversation_user: Option<uuid::Uuid>, pub needs_message_load: bool, pub show_dm_error_modal: bool,
pub dm_error_message: String,
pub failed_username: Option<String>,
pub available_mutual_friends: Vec<UserInfo>,
pub new_conversation_selected_index: usize,
pub new_conversation_search_mode: bool,
pub new_conversation_search_query: String,
}
impl DMsState {
pub fn navigate_down(&mut self) {
match &self.selection {
DMSelection::NewConversation => {
if self.pending_conversation_username.is_some() {
self.selection = DMSelection::PendingDraft;
} else if !self.conversations.is_empty() {
self.selection = DMSelection::Conversation(0);
}
}
DMSelection::PendingDraft => {
if !self.conversations.is_empty() {
self.selection = DMSelection::Conversation(0);
}
}
DMSelection::Conversation(idx) => {
if *idx < self.conversations.len().saturating_sub(1) {
self.selection = DMSelection::Conversation(idx + 1);
}
}
}
}
pub fn navigate_up(&mut self) {
match &self.selection {
DMSelection::NewConversation => {
}
DMSelection::PendingDraft => {
self.selection = DMSelection::NewConversation;
}
DMSelection::Conversation(idx) => {
if *idx == 0 {
if self.pending_conversation_username.is_some() {
self.selection = DMSelection::PendingDraft;
} else {
self.selection = DMSelection::NewConversation;
}
} else {
self.selection = DMSelection::Conversation(idx - 1);
}
}
}
}
}
#[derive(Debug, Clone)]
pub struct Conversation {
pub other_user_id: uuid::Uuid,
pub other_username: String,
pub last_message: String,
pub unread_count: i32,
}
pub struct ProfileState {
pub profile: Option<UserProfile>,
pub user_posts: Vec<Post>,
pub list_state: ListState,
pub loading: bool,
pub error: Option<String>,
pub show_edit_bio_modal: bool,
pub edit_bio_content: String,
pub edit_bio_cursor_position: usize,
}
pub struct UserProfileViewState {
pub username: String,
pub bio: Option<String>,
pub follower_count: usize,
pub following_count: usize,
pub post_count: usize,
}
#[derive(Debug, Clone, PartialEq)]
pub enum PostFilter {
All,
Hashtag(String),
User(String),
Multi {
hashtags: Vec<String>,
users: Vec<String>,
},
}
impl PostFilter {
pub fn to_preferences(&self) -> crate::config::UserPreferences {
match self {
PostFilter::All => crate::config::UserPreferences {
filter_type: "all".to_string(),
filter_hashtag: None,
filter_user: None,
filter_hashtags: Vec::new(),
filter_users: Vec::new(),
},
PostFilter::Hashtag(tag) => crate::config::UserPreferences {
filter_type: "hashtag".to_string(),
filter_hashtag: Some(tag.clone()),
filter_user: None,
filter_hashtags: Vec::new(),
filter_users: Vec::new(),
},
PostFilter::User(user) => crate::config::UserPreferences {
filter_type: "user".to_string(),
filter_hashtag: None,
filter_user: Some(user.clone()),
filter_hashtags: Vec::new(),
filter_users: Vec::new(),
},
PostFilter::Multi { hashtags, users } => crate::config::UserPreferences {
filter_type: "multi".to_string(),
filter_hashtag: None,
filter_user: None,
filter_hashtags: hashtags.clone(),
filter_users: users.clone(),
},
}
}
pub fn from_preferences(prefs: &crate::config::UserPreferences) -> Self {
match prefs.filter_type.as_str() {
"hashtag" => {
if let Some(tag) = &prefs.filter_hashtag {
PostFilter::Hashtag(tag.clone())
} else {
PostFilter::All
}
}
"user" => {
if let Some(user) = &prefs.filter_user {
PostFilter::User(user.clone())
} else {
PostFilter::All
}
}
"multi" => PostFilter::Multi {
hashtags: prefs.filter_hashtags.clone(),
users: prefs.filter_users.clone(),
},
_ => PostFilter::All,
}
}
}
pub struct PostsState {
pub posts: Vec<Post>,
pub list_state: ListState,
pub loading: bool,
pub error: Option<String>,
pub message: Option<(String, Instant)>, pub show_new_post_modal: bool,
pub new_post_content: String,
pub pending_load: bool,
pub current_filter: PostFilter,
pub show_filter_modal: bool,
pub filter_modal_state: FilterModalState,
pub at_end_of_feed: bool,
}
impl PostsState {
pub fn items_before_posts(&self) -> usize {
let mut count = 0;
if self.loading && !self.posts.is_empty() {
count += 1;
}
count
}
pub fn post_index_to_list_index(&self, post_index: usize) -> usize {
post_index + self.items_before_posts()
}
pub fn list_index_to_post_index(&self, list_index: usize) -> Option<usize> {
let offset = self.items_before_posts();
if list_index >= offset {
Some(list_index - offset)
} else {
None
}
}
}
pub struct FilterModalState {
pub selected_tab: FilterTab,
pub hashtag_list: Vec<String>,
pub user_list: Vec<String>,
pub selected_index: usize,
pub search_input: String,
pub search_mode: bool,
pub search_results: Vec<String>,
pub checked_hashtags: Vec<String>,
pub checked_users: Vec<String>,
pub show_add_hashtag_input: bool,
pub add_hashtag_input: String,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum FilterTab {
All,
Hashtags,
Users,
}
pub struct PostDetailState {
pub post: Option<Post>,
pub replies: Vec<Post>,
pub reply_list_state: ListState,
pub loading: bool,
pub error: Option<String>,
pub message: Option<(String, Instant)>, pub show_reply_composer: bool,
pub reply_content: String,
pub show_delete_confirmation: bool,
pub previous_feed_position: Option<usize>,
pub show_full_post_modal: bool,
pub full_post_modal_id: Option<Uuid>,
pub modal_list_state: ListState,
pub modal_expanded_posts: std::collections::HashMap<Uuid, bool>,
}
impl PostDetailState {
pub fn get_direct_replies(&self) -> Vec<&Post> {
use std::collections::HashSet;
let reply_ids: HashSet<Uuid> = self.replies.iter().map(|r| r.id).collect();
self.replies
.iter()
.filter(|reply| {
reply
.parent_post_id
.map(|parent_id| !reply_ids.contains(&parent_id))
.unwrap_or(false)
})
.collect()
}
pub fn get_deletable_post(&self) -> Option<&Post> {
if self.show_full_post_modal {
if let Some(selected_idx) = self.modal_list_state.selected() {
if selected_idx == 0 {
return self.full_post_modal_id.and_then(|id| {
if self.post.as_ref().map(|p| p.id) == Some(id) {
self.post.as_ref()
} else {
self.replies.iter().find(|r| r.id == id)
}
});
} else {
if let Some(root_id) = self.full_post_modal_id {
let mut flattened_posts = Vec::new();
self.collect_visible_posts_for_modal(root_id, &mut flattened_posts);
if selected_idx > 0 && selected_idx <= flattened_posts.len() {
let post_id = flattened_posts[selected_idx - 1];
return self.replies.iter().find(|r| r.id == post_id);
}
}
}
}
return None;
}
if self.replies.is_empty() {
return self.post.as_ref();
}
if let Some(selected_idx) = self.reply_list_state.selected() {
let direct_replies = self.get_direct_replies();
if let Some(reply) = direct_replies.get(selected_idx) {
return Some(reply);
}
}
self.post.as_ref()
}
fn collect_visible_posts_for_modal(&self, root_id: Uuid, result: &mut Vec<Uuid>) {
use std::collections::HashMap;
let mut children_map: HashMap<Uuid, Vec<Uuid>> = HashMap::new();
for reply in &self.replies {
if let Some(parent_id) = reply.parent_post_id {
children_map.entry(parent_id).or_default().push(reply.id);
}
}
fn collect(
post_id: &Uuid,
children_map: &HashMap<Uuid, Vec<Uuid>>,
expanded: &std::collections::HashMap<Uuid, bool>,
result: &mut Vec<Uuid>,
) {
if let Some(children) = children_map.get(post_id) {
for child_id in children {
result.push(*child_id);
if expanded.get(child_id).copied().unwrap_or(false) {
collect(child_id, children_map, expanded, result);
}
}
}
}
collect(&root_id, &children_map, &self.modal_expanded_posts, result);
}
}
pub struct AuthState {
pub test_users: Vec<User>,
pub selected_index: usize,
pub loading: bool,
pub error: Option<String>,
pub current_user: Option<User>,
pub show_github_option: bool,
pub github_auth_in_progress: bool,
pub github_device_code: Option<String>,
pub github_user_code: Option<String>,
pub github_verification_uri: Option<String>,
pub github_poll_interval: Option<i64>,
pub github_auth_start_time: Option<std::time::Instant>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum Screen {
Auth,
Main,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Tab {
Posts,
DMs,
Profile,
Settings,
}
impl Tab {
pub fn next(&self) -> Self {
match self {
Tab::Posts => Tab::DMs,
Tab::DMs => Tab::Profile,
Tab::Profile => Tab::Settings,
Tab::Settings => Tab::Posts,
}
}
pub fn previous(&self) -> Self {
match self {
Tab::Posts => Tab::Settings,
Tab::DMs => Tab::Posts,
Tab::Profile => Tab::DMs,
Tab::Settings => Tab::Profile,
}
}
}