diff --git a/src/data/repositories/PostRepository.ts b/src/data/repositories/PostRepository.ts index 0abeded..95a0f75 100644 --- a/src/data/repositories/PostRepository.ts +++ b/src/data/repositories/PostRepository.ts @@ -1,6 +1,6 @@ import { IPostRepository } from '../../domain/post/repositories/IPostRepository'; import { PostRequestBody, PostResponseBody } from '../../domain/post/entities/Post'; -import { postAuth, getAuth } from '../../core/http'; +import { postAuth, getAuth, deleteAuth } from '../../core/http'; export class PostRepository implements IPostRepository { async getAllPosts(): Promise { @@ -13,4 +13,10 @@ export class PostRepository implements IPostRepository { async create(postBody: PostRequestBody): Promise { return await postAuth('posts/', postBody); } + async likePost(postId: string): Promise { + return await postAuth(`posts/${postId}/likes/`, {}); + } + async unlikePost(postId: string): Promise { + return await deleteAuth(`posts/${postId}/likes/`); + } } diff --git a/src/domain/notification/entities/Activity.ts b/src/domain/notification/entities/Activity.ts index 92245ce..3882952 100644 --- a/src/domain/notification/entities/Activity.ts +++ b/src/domain/notification/entities/Activity.ts @@ -6,6 +6,7 @@ export interface Activity { id: number; actor_username: string; + actor_avatar_url?: string; verb: string; is_read: boolean; created_at: string; diff --git a/src/domain/post/repositories/IPostRepository.ts b/src/domain/post/repositories/IPostRepository.ts index f6ba9c1..13fe208 100644 --- a/src/domain/post/repositories/IPostRepository.ts +++ b/src/domain/post/repositories/IPostRepository.ts @@ -4,4 +4,6 @@ export interface IPostRepository { getAllPosts(): Promise; getPostById(id: string): Promise; create(postBody: PostRequestBody): Promise; + likePost(postId: string): Promise; + unlikePost(postId: string): Promise; } diff --git a/src/domain/post/usecases/commands/ToggleLikePost.ts b/src/domain/post/usecases/commands/ToggleLikePost.ts new file mode 100644 index 0000000..266b57d --- /dev/null +++ b/src/domain/post/usecases/commands/ToggleLikePost.ts @@ -0,0 +1,17 @@ +import { IPostRepository } from '../../repositories/IPostRepository'; + +/** + * ToggleLikePost Use Case + * SOLID: Single Responsibility - Handle like/unlike logic + */ +export class ToggleLikePost { + constructor(private readonly postRepository: IPostRepository) {} + + async execute(postId: string, isCurrentlyLiked: boolean): Promise { + if (isCurrentlyLiked) { + await this.postRepository.unlikePost(postId); + } else { + await this.postRepository.likePost(postId); + } + } +} diff --git a/src/domain/user/entities/Connection.ts b/src/domain/user/entities/Connection.ts index dfaefe8..8f5343f 100644 --- a/src/domain/user/entities/Connection.ts +++ b/src/domain/user/entities/Connection.ts @@ -1,13 +1,15 @@ +export interface CurrentWork { + job_title: string; + company_name: string; +} + export interface Connection { id: number; display_name: string; username: string; avatar: string; headline: string; - current_position: string; - company: string; - mutual_connections_count: string; - is_connected: string; + current_work: CurrentWork; is_verified: boolean; followers_count: string; } \ No newline at end of file diff --git a/src/presentation/components/atoms/NotificationIcon.tsx b/src/presentation/components/atoms/NotificationIcon.tsx index 62e645d..6aae329 100644 --- a/src/presentation/components/atoms/NotificationIcon.tsx +++ b/src/presentation/components/atoms/NotificationIcon.tsx @@ -10,6 +10,7 @@ interface NotificationIconProps { icon: IconDefinition; color?: string; size?: number; + containerSize?: number; } /** @@ -22,17 +23,20 @@ export const NotificationIcon: React.FC = ({ icon, color = theme.colors.primary, size = notificationIcon.defaultSize, + containerSize: customContainerSize, }) => { + const actualContainerSize = customContainerSize ?? notificationIcon.containerSize; + const containerStyle = useMemo( () => ({ - width: notificationIcon.containerSize, - height: notificationIcon.containerSize, - borderRadius: notificationIcon.containerSize / 2, + width: actualContainerSize, + height: actualContainerSize, + borderRadius: actualContainerSize / 2, backgroundColor: `${color}${notificationIcon.backgroundOpacity}`, alignItems: 'center', justifyContent: 'center', }), - [color], + [color, actualContainerSize], ); return ( diff --git a/src/presentation/components/molecules/NetworkListHeader.tsx b/src/presentation/components/molecules/NetworkListHeader.tsx index 6cf113e..ffb0a49 100644 --- a/src/presentation/components/molecules/NetworkListHeader.tsx +++ b/src/presentation/components/molecules/NetworkListHeader.tsx @@ -1,13 +1,12 @@ -import React, { useMemo } from 'react'; -import { ViewStyle } from 'react-native'; -import { ScreenHeader } from './ScreenHeader'; +import React, { useMemo, useState } from 'react'; +import { View, ViewStyle, TouchableOpacity, Text, TextStyle } from 'react-native'; +import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome'; +import { faSearch, faTimes } from '@fortawesome/free-solid-svg-icons'; +import { PageHeader } from './PageHeader'; import { SearchInput } from './SearchInput'; -import { TabBar } from './TabBar'; -import { faUsers, faUserPlus, faUserCheck } from '@fortawesome/free-solid-svg-icons'; import { theme } from '../../theme'; -import { Badge } from '../atoms/Badge'; -const { networkListHeader } = theme.components; +const { notificationTabs } = theme.components; interface NetworkListHeaderProps { connectedCount: number; @@ -17,14 +16,14 @@ interface NetworkListHeaderProps { onTabChange: (tab: 'suggestions' | 'connections') => void; } -const TABS = [ - { key: 'suggestions', label: 'Suggestions', icon: faUserPlus }, - { key: 'connections', label: 'My Network', icon: faUserCheck }, +const TABS: { key: 'suggestions' | 'connections'; label: string }[] = [ + { key: 'suggestions', label: 'Suggestions' }, + { key: 'connections', label: 'My Network' }, ]; /** * NetworkListHeader Molecule - * Atomic Design: Molecule - Composed of ScreenHeader + Badge + SearchInput + TabBar + * Atomic Design: Molecule - Composed of PageHeader + Tabs + Badge + SearchInput * SOLID: Single Responsibility - Network list header UI only * SOLID: Open/Closed - Styles from theme */ @@ -35,40 +34,129 @@ export const NetworkListHeader: React.FC = ({ activeTab, onTabChange, }) => { - const badgeStyle = useMemo( - () => ({ marginBottom: networkListHeader.badgeMarginBottom }), + const [isSearchVisible, setIsSearchVisible] = useState(false); + + const containerStyle = useMemo( + () => ({ + backgroundColor: theme.colors.white, + }), [], ); - const searchStyle = useMemo( - () => ({ marginBottom: networkListHeader.searchMarginBottom }), + const tabsContainerStyle = useMemo( + () => ({ + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: notificationTabs.paddingHorizontal, + paddingVertical: notificationTabs.paddingVertical, + backgroundColor: theme.colors.white, + borderBottomWidth: 1, + borderBottomColor: theme.colors.border, + }), [], ); - const tabsStyle = useMemo( - () => ({ marginTop: networkListHeader.tabsMarginTop }), + const tabsRowStyle = useMemo( + () => ({ + flexDirection: 'row', + alignItems: 'center', + }), [], ); + const searchIconStyle = useMemo( + () => ({ + padding: 8, + }), + [], + ); + + const searchContainerStyle = useMemo( + () => ({ + paddingHorizontal: 16, + paddingVertical: 12, + backgroundColor: theme.colors.white, + borderBottomWidth: 1, + borderBottomColor: theme.colors.border, + }), + [], + ); + + const handleSearchToggle = () => { + if (isSearchVisible) { + onSearchChange(''); + } + setIsSearchVisible(!isSearchVisible); + }; + + return ( + + + + + {TABS.map((tab) => ( + onTabChange(tab.key)} + /> + ))} + + + + + + {isSearchVisible && ( + + + + )} + + ); +}; + +interface NetworkTabButtonProps { + label: string; + isActive: boolean; + onPress: () => void; +} + +const NetworkTabButton: React.FC = ({ label, isActive, onPress }) => { + const buttonStyle = useMemo( + () => ({ + paddingVertical: notificationTabs.tabPaddingVertical, + paddingHorizontal: notificationTabs.tabPaddingHorizontal, + marginRight: notificationTabs.tabMarginRight, + borderRadius: notificationTabs.tabBorderRadius, + backgroundColor: isActive + ? notificationTabs.activeBackgroundColor + : 'transparent', + }), + [isActive], + ); + + const textStyle = useMemo( + () => ({ + fontSize: notificationTabs.fontSize, + fontWeight: isActive ? notificationTabs.activeFontWeight : 'normal', + color: isActive ? theme.colors.primary : notificationTabs.inactiveColor, + }), + [isActive], + ); + return ( - - - - onTabChange(key as 'suggestions' | 'connections')} - style={tabsStyle} - /> - + + {label} + ); }; diff --git a/src/presentation/components/molecules/NotificationItem.tsx b/src/presentation/components/molecules/NotificationItem.tsx index ab0c8c7..2c81b3c 100644 --- a/src/presentation/components/molecules/NotificationItem.tsx +++ b/src/presentation/components/molecules/NotificationItem.tsx @@ -4,6 +4,7 @@ import { NotificationIcon } from '../atoms/NotificationIcon'; import { NotificationMessage } from '../atoms/NotificationMessage'; import { ActivityTimestamp } from '../atoms/ActivityTimestamp'; import { UnreadDot } from '../atoms/UnreadDot'; +import { AvatarImage } from '../atoms/AvatarImage'; import { Activity } from '../../../domain/notification/entities/Activity'; import { NotificationFormatter } from '../../../data/services/NotificationFormatter'; import { theme } from '../../theme'; @@ -66,9 +67,35 @@ export const NotificationItem: React.FC = ({ [], ); + const avatarContainerStyle = useMemo( + () => ({ + position: 'relative', + }), + [], + ); + + const iconOverlayStyle = useMemo( + () => ({ + position: 'absolute', + bottom: -2, + right: -2, + backgroundColor: theme.colors.white, + borderRadius: 10, + padding: 2, + }), + [], + ); + + const actorInitial = notification.actor_username?.[0]?.toUpperCase() ?? '?'; + return ( - + + + + + + diff --git a/src/presentation/components/molecules/PageHeader.tsx b/src/presentation/components/molecules/PageHeader.tsx new file mode 100644 index 0000000..b796273 --- /dev/null +++ b/src/presentation/components/molecules/PageHeader.tsx @@ -0,0 +1,170 @@ +import React, { useMemo } from 'react'; +import { View, Text, TouchableOpacity, ViewStyle, TextStyle, ActivityIndicator } from 'react-native'; +import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome'; +import { faArrowLeft, faTimes } from '@fortawesome/free-solid-svg-icons'; +import { theme } from '../../theme'; + +const { pageHeader } = theme.components; + +type LeftAction = + | { type: 'back'; onPress: () => void } + | { type: 'close'; onPress: () => void } + | { type: 'custom'; render: () => React.ReactNode }; + +type RightAction = + | { type: 'text'; label: string; onPress: () => void; disabled?: boolean; loading?: boolean } + | { type: 'icon'; icon: any; onPress: () => void; color?: string } + | { type: 'custom'; render: () => React.ReactNode }; + +interface PageHeaderProps { + title: string; + leftAction?: LeftAction; + rightAction?: RightAction; +} + +/** + * PageHeader Molecule + * Atomic Design: Molecule - Unified header for all screens + * Single Responsibility: Display consistent page header with optional actions + * SOLID: Open/Closed - Extensible via left/right action props, styles from theme + */ +export const PageHeader: React.FC = ({ + title, + leftAction, + rightAction, +}) => { + const containerStyle = useMemo( + () => ({ + height: pageHeader.height, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: pageHeader.paddingHorizontal, + backgroundColor: theme.colors.white, + borderBottomWidth: pageHeader.borderBottomWidth, + borderBottomColor: theme.colors.border, + }), + [], + ); + + const titleStyle = useMemo( + () => ({ + fontSize: pageHeader.titleFontSize, + fontWeight: pageHeader.titleFontWeight, + color: theme.colors.gray900, + }), + [], + ); + + const actionContainerStyle = useMemo( + () => ({ + minWidth: pageHeader.actionMinWidth, + alignItems: 'flex-start', + }), + [], + ); + + const rightActionContainerStyle = useMemo( + () => ({ + minWidth: pageHeader.actionMinWidth, + alignItems: 'flex-end', + }), + [], + ); + + const backButtonStyle = useMemo( + () => ({ + padding: pageHeader.actionPadding, + }), + [], + ); + + const textButtonStyle = useMemo( + () => ({ + fontSize: pageHeader.actionFontSize, + fontWeight: pageHeader.actionFontWeight, + color: theme.colors.primary, + }), + [], + ); + + const disabledTextStyle = useMemo( + () => ({ + color: theme.colors.gray400, + }), + [], + ); + + const renderLeftAction = () => { + if (!leftAction) return ; + + if (leftAction.type === 'custom') { + return {leftAction.render()}; + } + + const icon = leftAction.type === 'back' ? faArrowLeft : faTimes; + + return ( + + + + + + ); + }; + + const renderRightAction = () => { + if (!rightAction) return ; + + if (rightAction.type === 'custom') { + return {rightAction.render()}; + } + + if (rightAction.type === 'icon') { + return ( + + + + + + ); + } + + // Text button + const isDisabled = rightAction.disabled || rightAction.loading; + + return ( + + + {rightAction.loading ? ( + + ) : ( + + {rightAction.label} + + )} + + + ); + }; + + return ( + + {renderLeftAction()} + {title} + {renderRightAction()} + + ); +}; diff --git a/src/presentation/components/organisms/ConnectionCard.tsx b/src/presentation/components/organisms/ConnectionCard.tsx index 5254c4a..552e375 100644 --- a/src/presentation/components/organisms/ConnectionCard.tsx +++ b/src/presentation/components/organisms/ConnectionCard.tsx @@ -50,9 +50,6 @@ export const ConnectionCard: React.FC = ({ ); const subtitle = ConnectionSubtitleService.getSubtitle(item); - const mutualCount = item.mutual_connections_count - ? Number(item.mutual_connections_count) - : 0; return ( @@ -60,7 +57,6 @@ export const ConnectionCard: React.FC = ({ avatarUri={item.avatar} displayName={item.display_name} subtitle={subtitle} - mutualConnectionsCount={mutualCount} onPress={() => onClickViewProfile(item)} /> diff --git a/src/presentation/components/organisms/ConnectionList.tsx b/src/presentation/components/organisms/ConnectionList.tsx index 47a5440..ae485a5 100644 --- a/src/presentation/components/organisms/ConnectionList.tsx +++ b/src/presentation/components/organisms/ConnectionList.tsx @@ -78,5 +78,5 @@ export const ConnectionList: React.FC = ({ }; const styles = StyleSheet.create({ - list: { padding: 16 }, + list: { paddingHorizontal: 16, paddingBottom: 16 }, }); diff --git a/src/presentation/components/organisms/PostCard.tsx b/src/presentation/components/organisms/PostCard.tsx index cc3ee3c..ec99304 100644 --- a/src/presentation/components/organisms/PostCard.tsx +++ b/src/presentation/components/organisms/PostCard.tsx @@ -13,7 +13,7 @@ const { postCard } = theme.components; interface PostCardProps { post: PostResponseBody; onPostOpen?: () => void; - onLike?: () => void; + onLike?: (isLiked: boolean) => void; onComment?: () => void; onShare?: () => void; onSend?: () => void; @@ -44,12 +44,13 @@ export const PostCard: React.FC = ({ const [likeCount, setLikeCount] = useState(post.likes || 0); const handleLike = useCallback(() => { + const currentLikedState = isLiked; setIsLiked(prev => { setLikeCount((count: number) => prev ? count - 1 : count + 1); return !prev; }); - onLike?.(); - }, [onLike]); + onLike?.(currentLikedState); + }, [isLiked, onLike]); const handleComment = useCallback(() => { onComment?.(); diff --git a/src/presentation/components/organisms/PostList.tsx b/src/presentation/components/organisms/PostList.tsx index 947b32c..c935827 100644 --- a/src/presentation/components/organisms/PostList.tsx +++ b/src/presentation/components/organisms/PostList.tsx @@ -3,6 +3,7 @@ import { FlatList, StyleSheet } from 'react-native'; import { PostResponseBody } from '../../../domain/post/entities/Post'; import { theme } from '../../theme'; import { PostCard } from './PostCard'; +import { useToggleLike } from '../../../ui/hooks/useToggleLike'; interface PostListProps { posts: PostResponseBody[]; @@ -27,12 +28,14 @@ export const PostList: React.FC = ({ onCommentPress, ListHeaderComponent, }) => { + const { toggleLike } = useToggleLike(); const keyExtractor = (item: PostResponseBody) => item.id; const renderItem = ({ item }: { item: PostResponseBody }) => ( onCommentPress(item.id)} + onLike={(isLiked: boolean) => toggleLike(item.id, isLiked)} /> ); diff --git a/src/presentation/components/templates/ActivityLogScreenTemplate.tsx b/src/presentation/components/templates/ActivityLogScreenTemplate.tsx index 5a5e2b4..429d51e 100644 --- a/src/presentation/components/templates/ActivityLogScreenTemplate.tsx +++ b/src/presentation/components/templates/ActivityLogScreenTemplate.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { StyleSheet } from 'react-native'; -import { ActivityLogHeader } from '../molecules/ActivityLogHeader'; +import { PageHeader } from '../molecules/PageHeader'; import { ActivityList } from '../organisms/ActivityList'; import { CenteredLoader } from '../molecules/CenteredLoader'; import { CenteredError } from '../molecules/CenteredError'; @@ -37,7 +37,7 @@ export const ActivityLogScreenTemplate: React.FC if (isLoading) { return ( - + ); @@ -47,7 +47,7 @@ export const ActivityLogScreenTemplate: React.FC if (isError) { return ( - + ); @@ -56,7 +56,7 @@ export const ActivityLogScreenTemplate: React.FC // Success state return ( - + {/* Header */} - - - - - Advanced Settings - - + {/* Server Configuration Section */} @@ -207,27 +197,6 @@ const styles = StyleSheet.create({ alignItems: 'center', backgroundColor: theme.colors.background, }, - header: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingHorizontal: 16, - paddingVertical: 12, - borderBottomWidth: 1, - borderBottomColor: theme.colors.border, - backgroundColor: '#fff', - }, - backButton: { - padding: 8, - }, - headerTitle: { - fontSize: 18, - fontWeight: '600', - color: theme.colors.gray900, - }, - headerRight: { - width: 36, - }, content: { flex: 1, padding: 16, diff --git a/src/presentation/components/templates/CreatePostScreenTemplate.tsx b/src/presentation/components/templates/CreatePostScreenTemplate.tsx index 57c532e..293a8f6 100644 --- a/src/presentation/components/templates/CreatePostScreenTemplate.tsx +++ b/src/presentation/components/templates/CreatePostScreenTemplate.tsx @@ -8,7 +8,7 @@ import { theme } from '../../theme'; // Molecules import { AddPostOptions } from '../molecules/AddPostOptions'; -import { CreatePostHeader } from '../molecules/CreatePostHeader'; +import { PageHeader } from '../molecules/PageHeader'; import { PostInput } from '../molecules/PostInput'; import { SelectedImagePreview } from '../molecules/SelectedImagePreview'; import { UserSection } from '../molecules/UserSection'; @@ -108,10 +108,9 @@ export const CreatePostScreenTemplate: React.FC = return ( - diff --git a/src/presentation/components/templates/EditProfileScreenTemplate.tsx b/src/presentation/components/templates/EditProfileScreenTemplate.tsx index 6bbf22c..f18fef2 100644 --- a/src/presentation/components/templates/EditProfileScreenTemplate.tsx +++ b/src/presentation/components/templates/EditProfileScreenTemplate.tsx @@ -11,7 +11,7 @@ import { FormInput } from '../atoms/FormInput'; // Molecules import { CenteredLoader } from '../molecules/CenteredLoader'; import { CenteredError } from '../molecules/CenteredError'; -import { EditProfileHeader } from '../molecules/EditProfileHeader'; +import { PageHeader } from '../molecules/PageHeader'; import { ProfileImageSection } from '../molecules/ProfileImageSection'; import { FormSection } from '../molecules/FormSection'; @@ -114,11 +114,10 @@ export const EditProfileScreenTemplate: React.FC return ( - diff --git a/src/presentation/components/templates/NetworkScreenTemplate.tsx b/src/presentation/components/templates/NetworkScreenTemplate.tsx index 8cc11b2..6e9a3fa 100644 --- a/src/presentation/components/templates/NetworkScreenTemplate.tsx +++ b/src/presentation/components/templates/NetworkScreenTemplate.tsx @@ -5,6 +5,7 @@ import { Connection } from '../../../domain/user/entities/Connection'; import { CenteredLoader } from '../molecules/CenteredLoader'; import { CenteredError } from '../molecules/CenteredError'; import { ConnectionList } from '../organisms/ConnectionList'; +import { theme } from '../../theme'; interface NetworkScreenTemplateProps { // Loading states @@ -95,6 +96,6 @@ export const NetworkScreenTemplate: React.FC = ({ const styles = StyleSheet.create({ container: { flex: 1, - backgroundColor: '#f9f9f9', + backgroundColor: theme.colors.background, }, }); diff --git a/src/presentation/components/templates/NotificationsScreenTemplate.tsx b/src/presentation/components/templates/NotificationsScreenTemplate.tsx index 707f412..78fa13e 100644 --- a/src/presentation/components/templates/NotificationsScreenTemplate.tsx +++ b/src/presentation/components/templates/NotificationsScreenTemplate.tsx @@ -1,6 +1,8 @@ import React from 'react'; -import { StyleSheet } from 'react-native'; -import { NotificationHeader } from '../molecules/NotificationHeader'; +import { StyleSheet, TouchableOpacity } from 'react-native'; +import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome'; +import { faCheckDouble } from '@fortawesome/free-solid-svg-icons'; +import { PageHeader } from '../molecules/PageHeader'; import { NotificationTabs } from '../molecules/NotificationTabs'; import { NotificationList } from '../organisms/NotificationList'; import { CenteredLoader } from '../molecules/CenteredLoader'; @@ -44,11 +46,24 @@ export const NotificationsScreenTemplate: React.FC { + const renderMarkAllReadAction = () => ( + + + + ); + // Loading state if (isLoading) { return ( - + - + - {/* Header */} - + {/* Cover Image */} diff --git a/src/presentation/screens/MessagesScreen.tsx b/src/presentation/screens/MessagesScreen.tsx index aaa2afe..8cad301 100644 --- a/src/presentation/screens/MessagesScreen.tsx +++ b/src/presentation/screens/MessagesScreen.tsx @@ -1,15 +1,16 @@ // presentation/screens/MessagesScreen.tsx import React, { useState, useMemo } from "react"; -import { View, Text, FlatList, StyleSheet, RefreshControl } from "react-native"; +import { FlatList, StyleSheet, RefreshControl } from "react-native"; import { useNavigation } from "@react-navigation/native"; import { SafeAreaView } from "react-native-safe-area-context"; import { Conversation } from "../../domain/chat/entities/Conversation"; import { useConversations } from "../../ui/hooks/useChat"; import { ConversationCard } from "../components/organisms/ConversationCard"; import { EmptyState } from "../components/molecules/EmptyState"; -import { ScreenHeader } from '../components/molecules/ScreenHeader'; +import { PageHeader } from '../components/molecules/PageHeader'; import { SearchInput } from '../components/molecules/SearchInput'; import { MessagesNavigationProp } from "../navigation/messages-screen-navigation/MessageScreenStackParamList"; +import { theme } from "../theme"; const MessagesScreen: React.FC = () => { const [search, setSearch] = useState(""); @@ -32,40 +33,43 @@ const MessagesScreen: React.FC = () => { }; return ( - - + + - + - ( - handleConversationPress(item)} - /> - )} - keyExtractor={(item) => item.id.toString()} - refreshControl={ - - } - ListEmptyComponent={ - - } - /> - + ( + handleConversationPress(item)} + /> + )} + keyExtractor={(item) => item.id.toString()} + refreshControl={ + + } + ListEmptyComponent={ + + } + /> + ); }; export default MessagesScreen; const styles = StyleSheet.create({ - container: { flex: 1, backgroundColor: '#fff', padding: 16 }, + container: { + flex: 1, + backgroundColor: theme.colors.background, + }, }); \ No newline at end of file diff --git a/src/presentation/theme.ts b/src/presentation/theme.ts index acf6c42..e775b0f 100644 --- a/src/presentation/theme.ts +++ b/src/presentation/theme.ts @@ -649,6 +649,19 @@ export const theme = { paddingVertical: 8, textAlignVertical: 'top' as const, }, + // PageHeader molecule styles (unified header for all screens) + pageHeader: { + height: 56, + paddingHorizontal: 16, + borderBottomWidth: 1, + titleFontSize: 18, + titleFontWeight: '600' as const, + iconSize: 20, + actionMinWidth: 60, + actionPadding: 8, + actionFontSize: 16, + actionFontWeight: '600' as const, + }, // ScreenHeader molecule styles screenHeader: { padding: 16, diff --git a/src/ui/form-hooks/useAdvancedSettingsForm.ts b/src/ui/form-hooks/useAdvancedSettingsForm.ts index 347eb8f..749486f 100644 --- a/src/ui/form-hooks/useAdvancedSettingsForm.ts +++ b/src/ui/form-hooks/useAdvancedSettingsForm.ts @@ -8,18 +8,31 @@ import { AdvancedSettingsFormValues } from '../form-types/AdvancedSettingsForm.t */ export const advancedSettingsValidationRules = { ip: { - required: 'IP address is required', - pattern: { - value: /^(\d{1,3}\.){3}\d{1,3}$/, - message: 'Please enter a valid IP address (e.g., 192.168.0.5)', - }, + required: 'IP address or URL is required', validate: (value: string) => { - const parts = value.split('.'); - const isValid = parts.every(part => { - const num = parseInt(part, 10); - return num >= 0 && num <= 255; - }); - return isValid || 'Each IP segment must be between 0 and 255'; + // Check if it's a valid IP address + const ipPattern = /^(\d{1,3}\.){3}\d{1,3}$/; + if (ipPattern.test(value)) { + const parts = value.split('.'); + const isValidIp = parts.every(part => { + const num = parseInt(part, 10); + return num >= 0 && num <= 255; + }); + if (!isValidIp) { + return 'Each IP segment must be between 0 and 255'; + } + return true; + } + + // Check if it's a valid URL/hostname + const urlPattern = /^(https?:\/\/)?([a-zA-Z0-9-]+\.)*[a-zA-Z0-9-]+(\.[a-zA-Z]{2,})?(:\d+)?(\/.*)?$/; + const localhostPattern = /^(https?:\/\/)?localhost(:\d+)?(\/.*)?$/; + + if (urlPattern.test(value) || localhostPattern.test(value)) { + return true; + } + + return 'Please enter a valid IP address (e.g., 192.168.0.5) or URL (e.g., example.com)'; }, }, port: { diff --git a/src/ui/hooks/useToggleLike.ts b/src/ui/hooks/useToggleLike.ts new file mode 100644 index 0000000..4fca375 --- /dev/null +++ b/src/ui/hooks/useToggleLike.ts @@ -0,0 +1,31 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { PostRepository } from '../../data/repositories/PostRepository'; +import { ToggleLikePost } from '../../domain/post/usecases/commands/ToggleLikePost'; + +const postRepository = new PostRepository(); +const toggleLikeUseCase = new ToggleLikePost(postRepository); + +/** + * useToggleLike Hook + * SOLID: Single Responsibility - Handle like/unlike API calls with optimistic updates + */ +export const useToggleLike = () => { + const queryClient = useQueryClient(); + + const mutation = useMutation({ + mutationFn: ({ postId, isLiked }: { postId: string; isLiked: boolean }) => + toggleLikeUseCase.execute(postId, isLiked), + onError: (error) => { + console.error('Error toggling like:', error); + }, + }); + + const toggleLike = (postId: string, isLiked: boolean) => { + mutation.mutate({ postId, isLiked }); + }; + + return { + toggleLike, + isLoading: mutation.isPending, + }; +}; diff --git a/src/ui/hooks/useUserAvatar.ts b/src/ui/hooks/useUserAvatar.ts index bc42a7b..2bb127d 100644 --- a/src/ui/hooks/useUserAvatar.ts +++ b/src/ui/hooks/useUserAvatar.ts @@ -3,6 +3,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import ReactNativeBlobUtil from 'react-native-blob-util'; const AVATAR_FILENAME = 'user_avatar.jpg'; +const AVATAR_DOWNLOADED_URL_KEY = 'userAvatarDownloadedUrl'; /** * useUserAvatar Hook @@ -22,16 +23,24 @@ export const useUserAvatar = () => { try { const localPath = getLocalAvatarPath(); const exists = await ReactNativeBlobUtil.fs.exists(localPath); + const currentProfileUrl = await AsyncStorage.getItem('userProfilePhoto'); + const downloadedUrl = await AsyncStorage.getItem(AVATAR_DOWNLOADED_URL_KEY); - if (exists) { - // Use locally stored avatar + // Check if cached file exists and was downloaded from the current URL + if (exists && currentProfileUrl && currentProfileUrl === downloadedUrl) { + // Use locally stored avatar (URL hasn't changed) setAvatarUri(`file://${localPath}`); - } else { - // Check if we have a URL to download from - const cachedUrl = await AsyncStorage.getItem('userProfilePhoto'); - if (cachedUrl) { - await downloadAndCacheAvatar(cachedUrl); + } else if (currentProfileUrl) { + // URL changed or no cached file - delete old and download new + if (exists) { + await ReactNativeBlobUtil.fs.unlink(localPath); } + await downloadAndCacheAvatar(currentProfileUrl); + } else if (exists) { + // No current URL but file exists - clear stale cache + await ReactNativeBlobUtil.fs.unlink(localPath); + await AsyncStorage.removeItem(AVATAR_DOWNLOADED_URL_KEY); + setAvatarUri(''); } } catch (error) { console.error('Error loading user avatar:', error); @@ -59,6 +68,8 @@ export const useUserAvatar = () => { if (res.info().status === 200) { // Save the original URL for reference await AsyncStorage.setItem('userProfilePhoto', url); + // Track which URL was actually downloaded to the local file + await AsyncStorage.setItem(AVATAR_DOWNLOADED_URL_KEY, url); setAvatarUri(`file://${localPath}`); return true; } @@ -71,9 +82,11 @@ export const useUserAvatar = () => { /** * Updates avatar with a new URL - downloads and caches locally + * Use this when user uploads a new profile photo */ const updateAvatar = async (newUrl: string) => { setIsLoading(true); + setAvatarUri(''); // Clear current avatar to trigger UI refresh try { // Delete old cached image const localPath = getLocalAvatarPath(); @@ -81,6 +94,8 @@ export const useUserAvatar = () => { if (exists) { await ReactNativeBlobUtil.fs.unlink(localPath); } + // Clear old downloaded URL tracking + await AsyncStorage.removeItem(AVATAR_DOWNLOADED_URL_KEY); // Download and cache new avatar await downloadAndCacheAvatar(newUrl); @@ -116,6 +131,7 @@ export const useUserAvatar = () => { await ReactNativeBlobUtil.fs.unlink(localPath); } await AsyncStorage.removeItem('userProfilePhoto'); + await AsyncStorage.removeItem(AVATAR_DOWNLOADED_URL_KEY); setAvatarUri(''); } catch (error) { console.error('Error clearing avatar:', error); diff --git a/src/ui/services/ConnectionSubtitleService.ts b/src/ui/services/ConnectionSubtitleService.ts index 748815c..050ee87 100644 --- a/src/ui/services/ConnectionSubtitleService.ts +++ b/src/ui/services/ConnectionSubtitleService.ts @@ -11,16 +11,17 @@ export class ConnectionSubtitleService { return connection.headline; } - if (connection.current_position && connection.company) { - return `${connection.current_position} at ${connection.company}`; + const { current_work } = connection; + if (current_work?.job_title && current_work?.company_name) { + return `${current_work.job_title} at ${current_work.company_name}`; } - if (connection.current_position) { - return connection.current_position; + if (current_work?.job_title) { + return current_work.job_title; } - if (connection.company) { - return connection.company; + if (current_work?.company_name) { + return current_work.company_name; } return '';